Spaces:
Sleeping
Sleeping
import React, { useEffect, useState } from "react"; | |
import { | |
Box, | |
Button, | |
Flex, | |
Heading, | |
Input, | |
Select as ChakraSelect, | |
Spinner, | |
Table, | |
Tbody, | |
Td, | |
Th, | |
Thead, | |
Tr, | |
VStack, | |
HStack, | |
Text, | |
Avatar, | |
} from "@chakra-ui/react"; | |
import type { ProjectExplorerProps, Project, ChatMessage } from "../hooks/types"; | |
interface FilterOptions { | |
statuses: string[]; | |
legalBases: string[]; | |
organizations: string[]; | |
countries: string[]; | |
fundingSchemes: string[]; | |
ids: string[]; | |
topics: string[]; | |
} | |
const MIN_SEARCH_LEN = 3; | |
type SortField = keyof Pick<Project, 'title' | 'status' | 'id' | 'startDate' | 'fundingScheme' | 'ecMaxContribution'>; | |
type SortOrder = 'asc' | 'desc'; | |
const ProjectExplorer: React.FC<ProjectExplorerProps> = ({ | |
projects, | |
search, | |
setSearch, | |
statusFilter, | |
setStatusFilter, | |
legalFilter, | |
setLegalFilter, | |
orgFilter, | |
setOrgFilter, | |
countryFilter, | |
setCountryFilter, | |
fundingSchemeFilter, | |
setFundingSchemeFilter, | |
idFilter, | |
setIdFilter, | |
topicsFilter, | |
setTopicsFilter, | |
setSortField, | |
sortField, | |
setSortOrder, | |
sortOrder, | |
page, | |
setPage, | |
setSelectedProject, | |
question, | |
setQuestion, | |
chatHistory, | |
askChatbot, | |
loading, | |
messagesEndRef, | |
}) => { | |
const [filterOpts, setFilterOpts] = useState<FilterOptions>({ | |
statuses: [], | |
legalBases: [], | |
organizations: [], | |
countries: [], | |
fundingSchemes: [], | |
ids: [], | |
topics: [], | |
}); | |
const [loadingFilters, setLoadingFilters] = useState(false); | |
// Fetch dynamic filter options whenever any filter changes | |
useEffect(() => { | |
setLoadingFilters(true); | |
const params = new URLSearchParams(); | |
if (statusFilter) params.set("status", statusFilter); | |
if (legalFilter) params.set("legalBasis", legalFilter); | |
if (orgFilter) params.set("organization", orgFilter); | |
if (countryFilter) params.set("country", countryFilter); | |
if (search.length >= MIN_SEARCH_LEN) params.set("search", search); | |
if (idFilter.length >= MIN_SEARCH_LEN) params.set("proj_id", idFilter); | |
if (fundingSchemeFilter) params.set("fundingScheme", fundingSchemeFilter); | |
if (topicsFilter) params.set("topics", topicsFilter); | |
params.set("sortField", sortField); | |
params.set("sortOrder", sortOrder); | |
fetch(`/api/filters?${params.toString()}`) | |
.then((res) => res.json()) | |
.then((data: FilterOptions) => setFilterOpts(data)) | |
.catch(console.error) | |
.finally(() => setLoadingFilters(false)); | |
}, [ | |
statusFilter, | |
legalFilter, | |
orgFilter, | |
countryFilter, | |
search, | |
idFilter, | |
fundingSchemeFilter, | |
topicsFilter, | |
sortField, | |
sortOrder, | |
]); | |
const fmtNum = (num: number | null | undefined): string => | |
num != null | |
? num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 }) | |
: '-'; | |
const handleSort = (field: SortField) => { | |
if (sortField === field) { | |
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc'); | |
} else { | |
setSortField(field); | |
setSortOrder('asc'); | |
} | |
setPage(0); | |
}; | |
const handleMinInput = (value: string, setter: (v: string) => void) => { | |
setter(value); | |
setPage(0); | |
}; | |
return ( | |
<Flex direction={{ base: "column", md: "row" }} gap={6}> | |
{/* Left Pane: Projects & Filters */} | |
<Box w={{ base: "100%", md: "70%" }} p={4}> | |
<Flex gap={4} mb={4} flexWrap="wrap"> | |
<Input | |
placeholder="Search by title..." | |
value={search} | |
onChange={(e) => { setSearch(e.target.value); setPage(0); }} | |
width={{ base: "100%", md: "200px" }} | |
/> | |
<Input | |
placeholder={`ID (min ${MIN_SEARCH_LEN})`} | |
value={idFilter} | |
onChange={(e) => handleMinInput(e.target.value, setIdFilter)} | |
w="160px" | |
isDisabled={loadingFilters} | |
/> | |
<ChakraSelect | |
placeholder={loadingFilters ? "Loading..." : "Status"} | |
value={statusFilter} | |
onChange={(e) => { setStatusFilter(e.target.value); setPage(0); }} | |
isDisabled={loadingFilters} | |
width="120px" | |
> | |
{filterOpts.statuses.map((s) => <option key={s} value={s}>{s}</option>)} | |
</ChakraSelect> | |
<ChakraSelect | |
placeholder={loadingFilters ? "Loading..." : "Legal Basis"} | |
value={legalFilter} | |
onChange={(e) => { setLegalFilter(e.target.value); setPage(0); }} | |
isDisabled={loadingFilters} | |
width="150px" | |
> | |
{filterOpts.legalBases.map((lb) => <option key={lb} value={lb}>{lb}</option>)} | |
</ChakraSelect> | |
<ChakraSelect | |
placeholder={loadingFilters ? "Loading..." : "Organization"} | |
value={orgFilter} | |
onChange={(e) => { setOrgFilter(e.target.value); setPage(0); }} | |
isDisabled={loadingFilters} | |
width="150px" | |
> | |
{filterOpts.organizations.map((o) => <option key={o} value={o}>{o}</option>)} | |
</ChakraSelect> | |
<ChakraSelect | |
placeholder={loadingFilters ? "Loading..." : "Country"} | |
value={countryFilter} | |
onChange={(e) => { setCountryFilter(e.target.value); setPage(0); }} | |
isDisabled={loadingFilters} | |
width="120px" | |
> | |
{filterOpts.countries.map((c) => <option key={c} value={c}>{c}</option>)} | |
</ChakraSelect> | |
<ChakraSelect | |
placeholder={loadingFilters ? "Loading..." : "Funding Scheme"} | |
value={fundingSchemeFilter} | |
onChange={(e) => { setFundingSchemeFilter(e.target.value); setPage(0); }} | |
isDisabled={loadingFilters} | |
width="180px" | |
> | |
{filterOpts.fundingSchemes.map((c) => <option key={c} value={c}>{c}</option>)} | |
</ChakraSelect> | |
<ChakraSelect | |
placeholder={loadingFilters ? "Loading..." : "EuroSciVoc"} | |
value={topicsFilter} | |
onChange={(e) => { setTopicsFilter(e.target.value); setPage(0); }} | |
isDisabled={loadingFilters} | |
width="180px" | |
> | |
{filterOpts.topics.map((c) => <option key={c} value={c}>{c}</option>)} | |
</ChakraSelect> | |
</Flex> | |
<Box | |
bg="gray.50" | |
p={4} | |
borderRadius="md" | |
height="500px" | |
overflowY="auto" | |
> | |
{!projects.length ? ( | |
<Flex justify="center" py={10}> | |
<Spinner /> | |
</Flex> | |
) : ( | |
<Table | |
variant="simple" | |
size="sm" | |
width="100%" | |
> | |
<Thead> | |
<Tr> | |
<Th w="50%" whiteSpace="nowrap" onClick={() => handleSort('title')} cursor="pointer"> | |
Title{sortField==='title'? (sortOrder==='asc'?' ↑':' ↓'):''} | |
</Th> | |
<Th w="10%" whiteSpace="nowrap" onClick={() => handleSort('status')} cursor="pointer"> | |
Status{sortField==='status'? (sortOrder==='asc'?' ↑':' ↓'):''} | |
</Th> | |
<Th w="10%" whiteSpace="nowrap" onClick={() => handleSort('id')} cursor="pointer"> | |
ID{sortField==='id'? (sortOrder==='asc'?' ↑':' ↓'):''} | |
</Th> | |
<Th w="10%" whiteSpace="nowrap" onClick={() => handleSort('startDate')} cursor="pointer"> | |
Start Date{sortField==='startDate'? (sortOrder==='asc'?' ↑':' ↓'):''} | |
</Th> | |
<Th w="10%" whiteSpace="nowrap" onClick={() => handleSort('fundingScheme')} cursor="pointer"> | |
Funding Scheme{sortField==='fundingScheme'? (sortOrder==='asc'?' ↑':' ↓'):''} | |
</Th> | |
<Th w="10%" whiteSpace="nowrap" onClick={() => handleSort('ecMaxContribution')} cursor="pointer"> | |
Funding (€){sortField==='ecMaxContribution'? (sortOrder==='asc'?' ↑':' ↓'):''} | |
</Th> | |
</Tr> | |
</Thead> | |
<Tbody> | |
{projects.map((p: Project) => ( | |
<Tr | |
key={p.id} | |
onClick={() => setSelectedProject(p)} | |
cursor="pointer" | |
_hover={{ bg: "gray.100" }} | |
> | |
<Td w="50%" overflow="hidden" textOverflow="ellipsis">{p.title}</Td> | |
<Td w="10%">{p.status}</Td> | |
<Td w="10%">{p.id}</Td> | |
<Td w="10%" whiteSpace="nowrap">{p.startDate}</Td> | |
<Td w="10%">{p.fundingScheme || '-'}</Td> | |
<Td w="10%">€{fmtNum(p.ecMaxContribution)}</Td> | |
</Tr> | |
))} | |
</Tbody> | |
</Table> | |
)} | |
</Box> | |
<Flex mt={4} gap={2} justify="center"> | |
<Button onClick={() => setPage(p => Math.max(p - 1, 0))} isDisabled={page === 0}>Previous</Button> | |
<Button onClick={() => setPage(p => p + 1)}>Next</Button> | |
</Flex> | |
</Box> | |
{/* Right Pane: Assistant */} | |
<Box | |
w={{ base: "100%", md: "30%" }} | |
bg="gray.50" | |
p={4} | |
borderRadius="md" | |
height="500px" | |
display="flex" | |
flexDirection="column" | |
mt={6} // spacing from above | |
> | |
<Heading size="sm" mb={2}>Assistant</Heading> | |
<Text fontSize="xs" color="gray.500" mb={3}> | |
⚠️ The model may occasionally produce incorrect or misleading answers. | |
</Text> | |
<Box flex={1} overflowY="auto" mb={4}> | |
<VStack spacing={3} align="stretch"> | |
{chatHistory.map((msg, i) => ( | |
<HStack | |
key={i} | |
alignSelf={msg.role === "user" ? "flex-end" : "flex-start"} | |
maxW="90%" | |
> | |
{msg.role === "assistant" && <Avatar size="sm" name="Bot" />} | |
<Box> | |
<Text | |
fontSize="sm" | |
bg={msg.role === "user" ? "blue.100" : "gray.200"} | |
px={3} | |
py={2} | |
borderRadius="md" | |
> | |
{msg.content} | |
{msg.content === "Generating answer..." && ( | |
<Spinner size="xs" ml={2} /> | |
)} | |
</Text> | |
</Box> | |
{msg.role === "user" && ( | |
<Avatar size="sm" name="You" bg="blue.300" /> | |
)} | |
</HStack> | |
))} | |
<div ref={messagesEndRef} /> | |
</VStack> | |
</Box> | |
<HStack> | |
<Input | |
placeholder="Ask something…" | |
value={question} | |
onChange={(e) => setQuestion(e.target.value)} | |
onKeyDown={(e) => { | |
if (e.key === "Enter" && !e.shiftKey) { | |
e.preventDefault(); | |
askChatbot(); | |
} | |
}} | |
isDisabled={loading} | |
size="md" // ← make the input a touch taller | |
/> | |
<Button | |
onClick={askChatbot} | |
colorScheme="blue" | |
aria-label="Ask the chatbot" | |
isLoading={loading} | |
loadingText="Waiting…" | |
size="md" | |
px={6} | |
py={3} | |
> | |
Send | |
</Button> | |
</HStack> | |
</Box> | |
</Flex> | |
); | |
}; | |
export default ProjectExplorer; |