Spaces:
Sleeping
Sleeping
Commit
·
f16cb34
1
Parent(s):
4b2beb8
frontend/src/components/Dashboard.tsx
CHANGED
@@ -1,4 +1,5 @@
|
|
1 |
-
|
|
|
2 |
import {
|
3 |
Box,
|
4 |
Grid,
|
@@ -45,12 +46,14 @@ ChartJS.register(
|
|
45 |
|
46 |
interface ChartData { labels: string[]; values: number[]; }
|
47 |
interface Stats { [key: string]: ChartData; }
|
|
|
48 |
const FILTER_LABELS: Record<keyof FilterState, string> = {
|
49 |
status: "Status",
|
50 |
organization: "Organization",
|
51 |
country: "Country",
|
52 |
legalBasis: "Legal Basis",
|
53 |
};
|
|
|
54 |
interface DashboardProps {
|
55 |
stats: Stats;
|
56 |
filters: FilterState;
|
@@ -70,27 +73,16 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
70 |
const [orgInput, setOrgInput] = useState("");
|
71 |
const [statsData, setStatsData] = useState<Stats>(initialStats);
|
72 |
const [loadingStats, setLoadingStats] = useState(false);
|
73 |
-
|
74 |
-
// ref to hold our debounce timer
|
75 |
const fetchTimer = useRef<number | null>(null);
|
76 |
|
|
|
77 |
useEffect(() => {
|
78 |
-
|
79 |
-
if (fetchTimer.current) {
|
80 |
-
clearTimeout(fetchTimer.current);
|
81 |
-
}
|
82 |
-
|
83 |
-
// schedule new fetch 300ms after last filter change
|
84 |
fetchTimer.current = window.setTimeout(() => {
|
85 |
const qs = new URLSearchParams();
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
if (filters.legalBasis) qs.set("legalBasis", filters.legalBasis);
|
90 |
-
qs.set("minYear", filters.minYear);
|
91 |
-
qs.set("maxYear", filters.maxYear);
|
92 |
-
qs.set("minFunding", filters.minFunding);
|
93 |
-
qs.set("maxFunding", filters.maxFunding);
|
94 |
|
95 |
setLoadingStats(true);
|
96 |
fetch(`/api/stats?${qs.toString()}`)
|
@@ -100,29 +92,19 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
100 |
.finally(() => setLoadingStats(false));
|
101 |
}, 300);
|
102 |
|
103 |
-
// cleanup on unmount or next effect-run
|
104 |
return () => {
|
105 |
-
if (fetchTimer.current)
|
106 |
-
clearTimeout(fetchTimer.current);
|
107 |
-
}
|
108 |
};
|
109 |
-
}, [
|
110 |
-
filters.status,
|
111 |
-
filters.organization,
|
112 |
-
filters.country,
|
113 |
-
filters.legalBasis,
|
114 |
-
filters.minYear,
|
115 |
-
filters.maxYear,
|
116 |
-
filters.minFunding,
|
117 |
-
filters.maxFunding,
|
118 |
-
]);
|
119 |
|
120 |
-
|
121 |
-
|
|
|
|
|
122 |
|
123 |
const updateSlider = (
|
124 |
-
k1:
|
125 |
-
k2:
|
126 |
) => ([min, max]: number[]) =>
|
127 |
setFilters(prev => ({
|
128 |
...prev,
|
@@ -131,13 +113,9 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
131 |
}));
|
132 |
|
133 |
const filterKeys: Array<keyof FilterState> = [
|
134 |
-
|
135 |
-
"organization",
|
136 |
-
"country",
|
137 |
-
"legalBasis",
|
138 |
];
|
139 |
|
140 |
-
// initial blank-state spinner
|
141 |
if (loadingStats && !Object.keys(statsData).length) {
|
142 |
return (
|
143 |
<Flex justify="center" mt={10}>
|
@@ -152,26 +130,21 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
152 |
<Box borderWidth="1px" borderRadius="lg" p={4} mb={6} bg="gray.50">
|
153 |
<Grid
|
154 |
templateColumns={{
|
155 |
-
base:
|
156 |
-
sm:
|
157 |
-
md:
|
158 |
-
lg:
|
159 |
}}
|
160 |
gap={4}
|
161 |
-
columnGap={6}
|
162 |
>
|
163 |
-
{filterKeys.map(
|
164 |
-
const
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
: key === "country"
|
172 |
-
? "countries"
|
173 |
-
: "legalBases"
|
174 |
-
] || [];
|
175 |
|
176 |
return (
|
177 |
<GridItem key={key} colSpan={1}>
|
@@ -180,17 +153,13 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
180 |
</Text>
|
181 |
<Select
|
182 |
options={opts.map(v => ({ label: v, value: v }))}
|
183 |
-
placeholder={
|
184 |
onChange={updateFilter(key)}
|
185 |
isClearable
|
186 |
isSearchable
|
187 |
-
openMenuOnClick
|
188 |
-
openMenuOnFocus
|
189 |
{...(isOrg && {
|
190 |
-
openMenuOnClick: false,
|
191 |
-
openMenuOnFocus: false,
|
192 |
menuIsOpen: orgInput.length > 0,
|
193 |
-
onInputChange:
|
194 |
})}
|
195 |
/>
|
196 |
</GridItem>
|
@@ -198,7 +167,7 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
198 |
})}
|
199 |
|
200 |
{/* Year Range */}
|
201 |
-
<GridItem colSpan={{ base: 1, md:
|
202 |
<Box mb={6}>
|
203 |
<Flex justify="space-between" mb={1}>
|
204 |
<Text fontSize="sm" fontWeight="medium">Year Range</Text>
|
@@ -216,7 +185,7 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
216 |
size="md"
|
217 |
>
|
218 |
<RangeSliderTrack>
|
219 |
-
<RangeSliderFilledTrack
|
220 |
</RangeSliderTrack>
|
221 |
<RangeSliderThumb index={0} boxSize={4}/>
|
222 |
<RangeSliderThumb index={1} boxSize={4}/>
|
@@ -225,7 +194,7 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
225 |
</GridItem>
|
226 |
|
227 |
{/* Funding Range */}
|
228 |
-
<GridItem colSpan={{ base: 1, md:
|
229 |
<Box>
|
230 |
<Flex justify="space-between" mb={1}>
|
231 |
<Text fontSize="sm" fontWeight="medium">Funding (€)</Text>
|
@@ -236,14 +205,14 @@ const Dashboard: React.FC<DashboardProps> = ({
|
|
236 |
<RangeSlider
|
237 |
aria-label={["Min Funding","Max Funding"]}
|
238 |
min={0}
|
239 |
-
max={
|
240 |
-
step={
|
241 |
defaultValue={[+filters.minFunding, +filters.maxFunding]}
|
242 |
onChange={updateSlider("minFunding","maxFunding")}
|
243 |
size="md"
|
244 |
>
|
245 |
<RangeSliderTrack>
|
246 |
-
<RangeSliderFilledTrack
|
247 |
</RangeSliderTrack>
|
248 |
<RangeSliderThumb index={0} boxSize={4}/>
|
249 |
<RangeSliderThumb index={1} boxSize={4}/>
|
|
|
1 |
+
// src/components/Dashboard.tsx
|
2 |
+
import { useState, useEffect, useRef } from "react";
|
3 |
import {
|
4 |
Box,
|
5 |
Grid,
|
|
|
46 |
|
47 |
interface ChartData { labels: string[]; values: number[]; }
|
48 |
interface Stats { [key: string]: ChartData; }
|
49 |
+
|
50 |
const FILTER_LABELS: Record<keyof FilterState, string> = {
|
51 |
status: "Status",
|
52 |
organization: "Organization",
|
53 |
country: "Country",
|
54 |
legalBasis: "Legal Basis",
|
55 |
};
|
56 |
+
|
57 |
interface DashboardProps {
|
58 |
stats: Stats;
|
59 |
filters: FilterState;
|
|
|
73 |
const [orgInput, setOrgInput] = useState("");
|
74 |
const [statsData, setStatsData] = useState<Stats>(initialStats);
|
75 |
const [loadingStats, setLoadingStats] = useState(false);
|
|
|
|
|
76 |
const fetchTimer = useRef<number | null>(null);
|
77 |
|
78 |
+
// Debounced stats fetch
|
79 |
useEffect(() => {
|
80 |
+
if (fetchTimer.current) clearTimeout(fetchTimer.current);
|
|
|
|
|
|
|
|
|
|
|
81 |
fetchTimer.current = window.setTimeout(() => {
|
82 |
const qs = new URLSearchParams();
|
83 |
+
Object.entries(filters).forEach(([key, val]) => {
|
84 |
+
if (val) qs.set(key, val);
|
85 |
+
});
|
|
|
|
|
|
|
|
|
|
|
86 |
|
87 |
setLoadingStats(true);
|
88 |
fetch(`/api/stats?${qs.toString()}`)
|
|
|
92 |
.finally(() => setLoadingStats(false));
|
93 |
}, 300);
|
94 |
|
|
|
95 |
return () => {
|
96 |
+
if (fetchTimer.current) clearTimeout(fetchTimer.current);
|
|
|
|
|
97 |
};
|
98 |
+
}, [filters]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
99 |
|
100 |
+
// Select & slider updaters
|
101 |
+
const updateFilter = (key: keyof FilterState) =>
|
102 |
+
(opt: { value: string } | null) =>
|
103 |
+
setFilters(prev => ({ ...prev, [key]: opt?.value || "" }));
|
104 |
|
105 |
const updateSlider = (
|
106 |
+
k1: 'minYear' | 'minFunding',
|
107 |
+
k2: 'maxYear' | 'maxFunding'
|
108 |
) => ([min, max]: number[]) =>
|
109 |
setFilters(prev => ({
|
110 |
...prev,
|
|
|
113 |
}));
|
114 |
|
115 |
const filterKeys: Array<keyof FilterState> = [
|
116 |
+
'status', 'organization', 'country', 'legalBasis'
|
|
|
|
|
|
|
117 |
];
|
118 |
|
|
|
119 |
if (loadingStats && !Object.keys(statsData).length) {
|
120 |
return (
|
121 |
<Flex justify="center" mt={10}>
|
|
|
130 |
<Box borderWidth="1px" borderRadius="lg" p={4} mb={6} bg="gray.50">
|
131 |
<Grid
|
132 |
templateColumns={{
|
133 |
+
base: '1fr',
|
134 |
+
sm: 'repeat(2,1fr)',
|
135 |
+
md: 'repeat(4,1fr)',
|
136 |
+
lg: 'repeat(6,1fr)',
|
137 |
}}
|
138 |
gap={4}
|
|
|
139 |
>
|
140 |
+
{filterKeys.map(key => {
|
141 |
+
const opts = availableFilters[
|
142 |
+
key === 'status' ? 'statuses'
|
143 |
+
: key === 'organization' ? 'organizations'
|
144 |
+
: key === 'country' ? 'countries'
|
145 |
+
: 'legalBases'
|
146 |
+
] || [];
|
147 |
+
const isOrg = key === 'organization';
|
|
|
|
|
|
|
|
|
148 |
|
149 |
return (
|
150 |
<GridItem key={key} colSpan={1}>
|
|
|
153 |
</Text>
|
154 |
<Select
|
155 |
options={opts.map(v => ({ label: v, value: v }))}
|
156 |
+
placeholder={FILTER_LABELS[key]}
|
157 |
onChange={updateFilter(key)}
|
158 |
isClearable
|
159 |
isSearchable
|
|
|
|
|
160 |
{...(isOrg && {
|
|
|
|
|
161 |
menuIsOpen: orgInput.length > 0,
|
162 |
+
onInputChange: setOrgInput,
|
163 |
})}
|
164 |
/>
|
165 |
</GridItem>
|
|
|
167 |
})}
|
168 |
|
169 |
{/* Year Range */}
|
170 |
+
<GridItem colSpan={{ base: 1, md: 2 }}>
|
171 |
<Box mb={6}>
|
172 |
<Flex justify="space-between" mb={1}>
|
173 |
<Text fontSize="sm" fontWeight="medium">Year Range</Text>
|
|
|
185 |
size="md"
|
186 |
>
|
187 |
<RangeSliderTrack>
|
188 |
+
<RangeSliderFilledTrack />
|
189 |
</RangeSliderTrack>
|
190 |
<RangeSliderThumb index={0} boxSize={4}/>
|
191 |
<RangeSliderThumb index={1} boxSize={4}/>
|
|
|
194 |
</GridItem>
|
195 |
|
196 |
{/* Funding Range */}
|
197 |
+
<GridItem colSpan={{ base: 1, md: 2 }}>
|
198 |
<Box>
|
199 |
<Flex justify="space-between" mb={1}>
|
200 |
<Text fontSize="sm" fontWeight="medium">Funding (€)</Text>
|
|
|
205 |
<RangeSlider
|
206 |
aria-label={["Min Funding","Max Funding"]}
|
207 |
min={0}
|
208 |
+
max={1e7}
|
209 |
+
step={1e5}
|
210 |
defaultValue={[+filters.minFunding, +filters.maxFunding]}
|
211 |
onChange={updateSlider("minFunding","maxFunding")}
|
212 |
size="md"
|
213 |
>
|
214 |
<RangeSliderTrack>
|
215 |
+
<RangeSliderFilledTrack />
|
216 |
</RangeSliderTrack>
|
217 |
<RangeSliderThumb index={0} boxSize={4}/>
|
218 |
<RangeSliderThumb index={1} boxSize={4}/>
|
frontend/src/components/ProjectDetails.tsx
CHANGED
@@ -186,11 +186,11 @@ export default function ProjectDetails({
|
|
186 |
</Box>
|
187 |
|
188 |
{/* Right: Model Explanation */}
|
189 |
-
<Box flex={{ base: '1', md: '0.6' }} bg="
|
190 |
<Heading size="sm" mb={4}>Model Prediction & Explanation</Heading>
|
191 |
{shapData?.length ? (
|
192 |
<>
|
193 |
-
<Text mb={2}><strong>Predicted Label:</strong> {predicted}</Text>
|
194 |
<Text mb={4}><strong>Probability:</strong> {(probability * 100).toFixed(2)}%</Text>
|
195 |
<ResponsiveContainer width="100%" height={300}>
|
196 |
<BarChart data={shapData} margin={{ top: 10, right: 30, left: 0, bottom: 5 }}>
|
@@ -202,7 +202,7 @@ export default function ProjectDetails({
|
|
202 |
{shapData.map((entry, index) => (
|
203 |
<Cell
|
204 |
key={`cell-${index}`}
|
205 |
-
fill={entry.shap >= 0 ? "#
|
206 |
/>
|
207 |
))}
|
208 |
</Bar>
|
|
|
186 |
</Box>
|
187 |
|
188 |
{/* Right: Model Explanation */}
|
189 |
+
<Box flex={{ base: '1', md: '0.6' }} bg="gray.50" p={4} borderRadius="md" boxShadow="sm" height="400px">
|
190 |
<Heading size="sm" mb={4}>Model Prediction & Explanation</Heading>
|
191 |
{shapData?.length ? (
|
192 |
<>
|
193 |
+
<Text mb={2}><strong>Predicted Label:</strong> {predicted === 1 ? 'Terminated' : 'Closed'}</Text>
|
194 |
<Text mb={4}><strong>Probability:</strong> {(probability * 100).toFixed(2)}%</Text>
|
195 |
<ResponsiveContainer width="100%" height={300}>
|
196 |
<BarChart data={shapData} margin={{ top: 10, right: 30, left: 0, bottom: 5 }}>
|
|
|
202 |
{shapData.map((entry, index) => (
|
203 |
<Cell
|
204 |
key={`cell-${index}`}
|
205 |
+
fill={entry.shap >= 0 ? "#003399" : "#FFCC00"}
|
206 |
/>
|
207 |
))}
|
208 |
</Bar>
|
frontend/src/components/ProjectExplorer.tsx
CHANGED
@@ -1,4 +1,4 @@
|
|
1 |
-
import React from "react";
|
2 |
import {
|
3 |
Box,
|
4 |
Button,
|
@@ -18,11 +18,14 @@ import {
|
|
18 |
Text,
|
19 |
Avatar,
|
20 |
} from "@chakra-ui/react";
|
21 |
-
import type {
|
22 |
-
|
23 |
-
|
24 |
-
|
25 |
-
|
|
|
|
|
|
|
26 |
|
27 |
const ProjectExplorer: React.FC<ProjectExplorerProps> = ({
|
28 |
projects,
|
@@ -30,6 +33,12 @@ const ProjectExplorer: React.FC<ProjectExplorerProps> = ({
|
|
30 |
setSearch,
|
31 |
statusFilter,
|
32 |
setStatusFilter,
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
page,
|
34 |
setPage,
|
35 |
setSelectedProject,
|
@@ -38,162 +47,165 @@ const ProjectExplorer: React.FC<ProjectExplorerProps> = ({
|
|
38 |
chatHistory,
|
39 |
askChatbot,
|
40 |
messagesEndRef,
|
41 |
-
}) =>
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
|
62 |
-
|
63 |
-
|
64 |
-
|
65 |
-
|
66 |
-
<option value="Closed">Closed</option>
|
67 |
-
<option value="Terminated">Terminated</option>
|
68 |
-
</ChakraSelect>
|
69 |
-
</Flex>
|
70 |
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
111 |
</Tr>
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
117 |
|
118 |
-
|
119 |
-
|
120 |
-
onClick={() => setPage(
|
121 |
-
|
122 |
-
|
123 |
-
>
|
124 |
-
Previous
|
125 |
-
</Button>
|
126 |
-
<Button onClick={() => setPage((p) => p + 1)} aria-label="Next page">
|
127 |
-
Next
|
128 |
-
</Button>
|
129 |
-
</Flex>
|
130 |
-
</Box>
|
131 |
|
132 |
-
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
<
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
<div ref={messagesEndRef} />
|
173 |
-
</VStack>
|
174 |
</Box>
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
value={question}
|
179 |
-
onChange={(e) => setQuestion(e.target.value)}
|
180 |
-
onKeyDown={(e) => {
|
181 |
-
if (e.key === "Enter" && !e.shiftKey) {
|
182 |
-
e.preventDefault();
|
183 |
-
askChatbot();
|
184 |
-
}
|
185 |
-
}}
|
186 |
-
/>
|
187 |
-
<Button
|
188 |
-
onClick={askChatbot}
|
189 |
-
colorScheme="blue"
|
190 |
-
aria-label="Ask the chatbot"
|
191 |
-
>
|
192 |
-
Send
|
193 |
-
</Button>
|
194 |
-
</HStack>
|
195 |
-
</Box>
|
196 |
-
</Flex>
|
197 |
-
);
|
198 |
|
199 |
-
export default ProjectExplorer;
|
|
|
1 |
+
import React, { useEffect, useState } from "react";
|
2 |
import {
|
3 |
Box,
|
4 |
Button,
|
|
|
18 |
Text,
|
19 |
Avatar,
|
20 |
} from "@chakra-ui/react";
|
21 |
+
import type { ProjectExplorerProps, Project, ChatMessage } from "../hooks/types";
|
22 |
+
|
23 |
+
interface FilterOptions {
|
24 |
+
statuses: string[];
|
25 |
+
legalBases: string[];
|
26 |
+
organizations: string[];
|
27 |
+
countries: string[];
|
28 |
+
}
|
29 |
|
30 |
const ProjectExplorer: React.FC<ProjectExplorerProps> = ({
|
31 |
projects,
|
|
|
33 |
setSearch,
|
34 |
statusFilter,
|
35 |
setStatusFilter,
|
36 |
+
legalFilter,
|
37 |
+
setLegalFilter,
|
38 |
+
orgFilter,
|
39 |
+
setOrgFilter,
|
40 |
+
countryFilter,
|
41 |
+
setCountryFilter,
|
42 |
page,
|
43 |
setPage,
|
44 |
setSelectedProject,
|
|
|
47 |
chatHistory,
|
48 |
askChatbot,
|
49 |
messagesEndRef,
|
50 |
+
}) => {
|
51 |
+
const [filterOpts, setFilterOpts] = useState<FilterOptions>({
|
52 |
+
statuses: [],
|
53 |
+
legalBases: [],
|
54 |
+
organizations: [],
|
55 |
+
countries: []
|
56 |
+
});
|
57 |
+
const [loadingFilters, setLoadingFilters] = useState(false);
|
58 |
+
|
59 |
+
// Fetch dynamic filter options whenever any filter changes
|
60 |
+
useEffect(() => {
|
61 |
+
setLoadingFilters(true);
|
62 |
+
const params = new URLSearchParams();
|
63 |
+
if (statusFilter) params.set("status", statusFilter);
|
64 |
+
if (legalFilter) params.set("legalBasis", legalFilter);
|
65 |
+
if (orgFilter) params.set("organization", orgFilter);
|
66 |
+
if (countryFilter) params.set("country", countryFilter);
|
67 |
+
if (search) params.set("search", search);
|
68 |
+
|
69 |
+
fetch(`/api/filters?${params.toString()}`)
|
70 |
+
.then((res) => res.json())
|
71 |
+
.then((data: FilterOptions) => setFilterOpts(data))
|
72 |
+
.catch(console.error)
|
73 |
+
.finally(() => setLoadingFilters(false));
|
74 |
+
}, [statusFilter, legalFilter, orgFilter, countryFilter, search]);
|
|
|
|
|
|
|
|
|
75 |
|
76 |
+
return (
|
77 |
+
<Flex direction={{ base: "column", md: "row" }} gap={6}>
|
78 |
+
{/* Left Pane: Projects & Filters */}
|
79 |
+
<Box flex={1}>
|
80 |
+
<Heading size="sm" mb={2}>Projects</Heading>
|
81 |
+
<Flex gap={4} mb={4} flexWrap="wrap">
|
82 |
+
<Input
|
83 |
+
placeholder="Search by title..."
|
84 |
+
value={search}
|
85 |
+
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
86 |
+
width={{ base: "100%", md: "200px" }}
|
87 |
+
/>
|
88 |
+
<ChakraSelect
|
89 |
+
placeholder={loadingFilters ? "Loading..." : "Status"}
|
90 |
+
value={statusFilter}
|
91 |
+
onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }}
|
92 |
+
isDisabled={loadingFilters}
|
93 |
+
width="150px"
|
94 |
+
>
|
95 |
+
{filterOpts.statuses.map((s) => <option key={s} value={s}>{s}</option>)}
|
96 |
+
</ChakraSelect>
|
97 |
+
<ChakraSelect
|
98 |
+
placeholder={loadingFilters ? "Loading..." : "Legal Basis"}
|
99 |
+
value={legalFilter}
|
100 |
+
onChange={(e) => { setLegalFilter(e.target.value); setPage(0); }}
|
101 |
+
isDisabled={loadingFilters}
|
102 |
+
width="150px"
|
103 |
+
>
|
104 |
+
{filterOpts.legalBases.map((lb) => <option key={lb} value={lb}>{lb}</option>)}
|
105 |
+
</ChakraSelect>
|
106 |
+
<ChakraSelect
|
107 |
+
placeholder={loadingFilters ? "Loading..." : "Organization"}
|
108 |
+
value={orgFilter}
|
109 |
+
onChange={(e) => { setOrgFilter(e.target.value); setPage(0); }}
|
110 |
+
isDisabled={loadingFilters}
|
111 |
+
width="180px"
|
112 |
+
>
|
113 |
+
{filterOpts.organizations.map((o) => <option key={o} value={o}>{o}</option>)}
|
114 |
+
</ChakraSelect>
|
115 |
+
<ChakraSelect
|
116 |
+
placeholder={loadingFilters ? "Loading..." : "Country"}
|
117 |
+
value={countryFilter}
|
118 |
+
onChange={(e) => { setCountryFilter(e.target.value); setPage(0); }}
|
119 |
+
isDisabled={loadingFilters}
|
120 |
+
width="150px"
|
121 |
+
>
|
122 |
+
{filterOpts.countries.map((c) => <option key={c} value={c}>{c}</option>)}
|
123 |
+
</ChakraSelect>
|
124 |
+
</Flex>
|
125 |
+
|
126 |
+
<Box bg="gray.50" p={4} borderRadius="md" height="500px" overflowY="auto">
|
127 |
+
{!projects.length ? (
|
128 |
+
<Flex justify="center" py={10}><Spinner /></Flex>
|
129 |
+
) : (
|
130 |
+
<Table variant="simple" size="sm" width="100%">
|
131 |
+
<Thead>
|
132 |
+
<Tr>
|
133 |
+
<Th width="60%">Title</Th>
|
134 |
+
<Th>Status</Th>
|
135 |
+
<Th>ID</Th>
|
136 |
+
<Th>Start Date</Th>
|
137 |
+
<Th>Funding €</Th>
|
138 |
</Tr>
|
139 |
+
</Thead>
|
140 |
+
<Tbody>
|
141 |
+
{projects.map((p: Project) => (
|
142 |
+
<Tr
|
143 |
+
key={p.id}
|
144 |
+
onClick={() => setSelectedProject(p)}
|
145 |
+
cursor="pointer"
|
146 |
+
_hover={{ bg: "gray.100" }}
|
147 |
+
>
|
148 |
+
<Td isTruncated>{p.title}</Td>
|
149 |
+
<Td>{p.status}</Td>
|
150 |
+
<Td>{p.id}</Td>
|
151 |
+
<Td whiteSpace="nowrap">{p.startDate}</Td>
|
152 |
+
<Td>{p.ecMaxContribution.toLocaleString()}</Td>
|
153 |
+
</Tr>
|
154 |
+
))}
|
155 |
+
</Tbody>
|
156 |
+
</Table>
|
157 |
+
)}
|
158 |
+
</Box>
|
159 |
|
160 |
+
<Flex mt={4} gap={2} justify="center">
|
161 |
+
<Button onClick={() => setPage(p => Math.max(p - 1, 0))} isDisabled={page === 0}>Previous</Button>
|
162 |
+
<Button onClick={() => setPage(p => p + 1)}>Next</Button>
|
163 |
+
</Flex>
|
164 |
+
</Box>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
165 |
|
166 |
+
{/* Right Pane: Assistant */}
|
167 |
+
<Box
|
168 |
+
flex={{ base: 1, md: 0.4 }}
|
169 |
+
bg="gray.50"
|
170 |
+
p={4}
|
171 |
+
borderRadius="md"
|
172 |
+
height="500px"
|
173 |
+
display="flex"
|
174 |
+
flexDirection="column"
|
175 |
+
>
|
176 |
+
<Heading size="sm" mb={2}>Assistant</Heading>
|
177 |
+
<Box flex={1} overflowY="auto" mb={4}>
|
178 |
+
<VStack spacing={3} align="stretch">
|
179 |
+
{chatHistory.map((msg: ChatMessage, i: number) => (
|
180 |
+
<HStack
|
181 |
+
key={i}
|
182 |
+
alignSelf={msg.role === "user" ? "flex-end" : "flex-start"}
|
183 |
+
maxW="90%"
|
184 |
+
>
|
185 |
+
{msg.role === "assistant" && <Avatar size="sm" name="Bot" />}
|
186 |
+
<Box>
|
187 |
+
<Text fontSize="sm" bg={msg.role === "user" ? "blue.100" : "gray.200"} px={3} py={2} borderRadius="md">
|
188 |
+
{msg.content}
|
189 |
+
</Text>
|
190 |
+
</Box>
|
191 |
+
{msg.role === "user" && <Avatar size="sm" name="You" bg="blue.300" />}
|
192 |
+
</HStack>
|
193 |
+
))}
|
194 |
+
<div ref={messagesEndRef} />
|
195 |
+
</VStack>
|
196 |
+
</Box>
|
197 |
+
<HStack>
|
198 |
+
<Input
|
199 |
+
placeholder="Ask something..."
|
200 |
+
value={question}
|
201 |
+
onChange={(e) => setQuestion(e.target.value)}
|
202 |
+
onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); askChatbot(); } }}
|
203 |
+
/>
|
204 |
+
<Button onClick={askChatbot} colorScheme="blue" aria-label="Ask the chatbot">Send</Button>
|
205 |
+
</HStack>
|
|
|
|
|
206 |
</Box>
|
207 |
+
</Flex>
|
208 |
+
);
|
209 |
+
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
210 |
|
211 |
+
export default ProjectExplorer;
|
frontend/src/hooks/types.ts
CHANGED
@@ -74,6 +74,12 @@ export interface ProjectExplorerProps {
|
|
74 |
setSearch: (value: string) => void;
|
75 |
statusFilter: string;
|
76 |
setStatusFilter: (value: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
77 |
page: number;
|
78 |
setPage: React.Dispatch<React.SetStateAction<number>>;
|
79 |
setSelectedProject: (project: Project) => void;
|
|
|
74 |
setSearch: (value: string) => void;
|
75 |
statusFilter: string;
|
76 |
setStatusFilter: (value: string) => void;
|
77 |
+
legalFilter: string;
|
78 |
+
setLegalFilter: (value: string) => void;
|
79 |
+
orgFilter: string;
|
80 |
+
setOrgFilter: (value: string) => void;
|
81 |
+
countryFilter: string;
|
82 |
+
setCountryFilter: (value: string) => void;
|
83 |
page: number;
|
84 |
setPage: React.Dispatch<React.SetStateAction<number>>;
|
85 |
setSelectedProject: (project: Project) => void;
|