561 lines
15 KiB
TypeScript
561 lines
15 KiB
TypeScript
import ArrowBackIcon from "@mui/icons-material/ArrowBack";
|
|
import EditIcon from "@mui/icons-material/Edit";
|
|
import {
|
|
Box,
|
|
Button,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogContentText,
|
|
DialogTitle,
|
|
IconButton,
|
|
Link,
|
|
Paper,
|
|
Radio,
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableContainer,
|
|
TableHead,
|
|
TableRow,
|
|
TextField,
|
|
Typography,
|
|
} from "@mui/material";
|
|
import {
|
|
useMutation,
|
|
useQueryClient,
|
|
useSuspenseQuery,
|
|
} from "@tanstack/react-query";
|
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
import { useEffect, useState, type KeyboardEvent } from "react";
|
|
import PDFViewer from "../components/pdfViewer";
|
|
import { fetchPutKPI } from "../util/api";
|
|
import { kpiQueryOptions } from "../util/query";
|
|
import { redirect } from "@tanstack/react-router";
|
|
|
|
export const Route = createFileRoute("/extractedResult_/$pitchBook/$kpi")({
|
|
component: ExtractedResultsPage,
|
|
validateSearch: (search: Record<string, unknown>) => {
|
|
return {
|
|
from: typeof search.from === "string" ? search.from : undefined,
|
|
};
|
|
},
|
|
loader: async ({ context: { queryClient }, params: { pitchBook } }) => {
|
|
try {
|
|
return await queryClient.ensureQueryData(kpiQueryOptions(pitchBook));
|
|
} catch (err) {
|
|
throw redirect({
|
|
to: "/"
|
|
});
|
|
}
|
|
},
|
|
});
|
|
|
|
function ExtractedResultsPage() {
|
|
const params = Route.useParams() as { pitchBook: string; kpi: string };
|
|
const { pitchBook, kpi } = params;
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const { from } = Route.useSearch();
|
|
|
|
const { data: kpiData } = useSuspenseQuery(kpiQueryOptions(pitchBook));
|
|
|
|
const kpiValues = kpiData[kpi.toUpperCase()] || [];
|
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
const [currentPage, setCurrentPage] = useState(kpiValues[0]?.page || 1);
|
|
const [showConfirmDialog, setShowConfirmDialog] = useState(false);
|
|
const [hasChanges, setHasChanges] = useState(false);
|
|
const [customValue, setCustomValue] = useState("");
|
|
const [customPage, setCustomPage] = useState("");
|
|
const [editingCustomPage, setEditingCustomPage] = useState(false);
|
|
const [focusHighlightOverride, setFocusHighlightOverride] = useState<{ page: number; text: string } | null>(null);
|
|
|
|
const originalValue = kpiValues[0]?.entity || "";
|
|
const originalPage = kpiValues[0]?.page || 0;
|
|
|
|
// Funktion, um gleiche Werte zusammenzufassen und die Seiten zu sammeln
|
|
function groupKpiValues(values: Array<{ entity: string; page: number; [key: string]: any }>): Array<{ entity: string; pages: number[]; [key: string]: any }> {
|
|
const map = new Map<string, { entity: string; pages: number[]; [key: string]: any }>();
|
|
values.forEach((item: { entity: string; page: number; [key: string]: any }) => {
|
|
const key = item.entity.toLowerCase();
|
|
if (!map.has(key)) {
|
|
map.set(key, { ...item, pages: [item.page] });
|
|
} else {
|
|
const existingEntry = map.get(key)!;
|
|
if (!existingEntry.pages.includes(item.page)) {
|
|
existingEntry.pages.push(item.page);
|
|
}
|
|
}
|
|
});
|
|
return Array.from(map.values());
|
|
}
|
|
|
|
const groupedKpiValues: Array<{ entity: string; pages: number[]; [key: string]: any }> = groupKpiValues(kpiValues);
|
|
|
|
const selectedValue: string =
|
|
selectedIndex === -1 ? customValue : groupedKpiValues[selectedIndex]?.entity || "";
|
|
|
|
const selectedPage =
|
|
selectedIndex === -1
|
|
? (parseInt(customPage) > 0 ? parseInt(customPage) : 1)
|
|
: groupedKpiValues[selectedIndex]?.pages[0] || 1;
|
|
|
|
// Um zu prüfen, ob der Wert nur aus Leerzeichen besteht
|
|
const isSelectedValueEmpty = selectedIndex === -1 ? customValue.trim() === "" : !selectedValue;
|
|
|
|
const focusHighlight = focusHighlightOverride || {
|
|
page: groupedKpiValues.at(selectedIndex)?.pages[0] || -1,
|
|
text: groupedKpiValues.at(selectedIndex)?.entity || "",
|
|
};
|
|
|
|
useEffect(() => {
|
|
const valueChanged = selectedValue !== originalValue;
|
|
const pageChanged = selectedPage !== originalPage;
|
|
setHasChanges(valueChanged || pageChanged);
|
|
}, [selectedValue, selectedPage, originalValue, originalPage]);
|
|
|
|
const { mutate: updateKPI } = useMutation({
|
|
mutationFn: () => {
|
|
const updatedData = { ...kpiData };
|
|
let baseObject;
|
|
if (selectedIndex >= 0) {
|
|
// Das Originalobjekt mit allen Feldern für diesen Wert suchen
|
|
const original = kpiValues.find(v => v.entity.toLowerCase() === groupedKpiValues[selectedIndex].entity.toLowerCase()) as { status?: string; source?: string } | undefined;
|
|
baseObject = {
|
|
label: kpi.toUpperCase(),
|
|
entity: groupedKpiValues[selectedIndex].entity,
|
|
page: groupedKpiValues[selectedIndex].pages[0],
|
|
status: original?.status || "single-source",
|
|
source: original?.source || "auto",
|
|
};
|
|
} else {
|
|
baseObject = {
|
|
label: kpi.toUpperCase(),
|
|
entity: selectedValue,
|
|
page: selectedPage,
|
|
status: "single-source",
|
|
source: "manual",
|
|
};
|
|
}
|
|
updatedData[kpi.toUpperCase()] = [
|
|
{
|
|
...baseObject,
|
|
entity: selectedValue,
|
|
page: selectedPage,
|
|
},
|
|
];
|
|
return fetchPutKPI(Number(pitchBook), updatedData);
|
|
},
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({
|
|
queryKey: ["pitchBookKPI", pitchBook],
|
|
});
|
|
navigate({
|
|
to: "/extractedResult/$pitchBook",
|
|
params: { pitchBook },
|
|
search: from ? { from } : undefined
|
|
});
|
|
},
|
|
onError: (error) => {
|
|
console.error("Error updating KPI:", error);
|
|
},
|
|
});
|
|
|
|
const handleRadioChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = event.target.value;
|
|
if (value === "custom") {
|
|
setSelectedIndex(-1);
|
|
setFocusHighlightOverride(null);
|
|
} else {
|
|
const index = Number.parseInt(value);
|
|
setSelectedIndex(index);
|
|
setCurrentPage(groupedKpiValues[index].pages[0]);
|
|
setCustomValue("");
|
|
setCustomPage("");
|
|
setFocusHighlightOverride(null);
|
|
}
|
|
};
|
|
|
|
const handleCustomValueChange = (
|
|
event: React.ChangeEvent<HTMLInputElement>,
|
|
) => {
|
|
const value = event.target.value;
|
|
setCustomValue(value);
|
|
setSelectedIndex(-1);
|
|
setFocusHighlightOverride(null);
|
|
}
|
|
|
|
const handleCustomPageChange = (
|
|
event: React.ChangeEvent<HTMLInputElement>,
|
|
) => {
|
|
const value = event.target.value;
|
|
// Allow empty string or positive numbers only (no 0)
|
|
if (value === '' || (/^\d+$/.test(value) && parseInt(value) > 0)) {
|
|
setCustomPage(value);
|
|
}
|
|
};
|
|
|
|
const handleRowClick = (index: number) => {
|
|
setCurrentPage(groupedKpiValues[index].pages[0]);
|
|
setSelectedIndex(index);
|
|
setCustomValue("");
|
|
setCustomPage("");
|
|
setFocusHighlightOverride(null);
|
|
};
|
|
|
|
const handlePageClick = (page: number, entity: string) => {
|
|
setCurrentPage(page);
|
|
setFocusHighlightOverride({
|
|
page: page,
|
|
text: entity,
|
|
});
|
|
};
|
|
|
|
const handleBackClick = () => {
|
|
if (hasChanges) {
|
|
setShowConfirmDialog(true);
|
|
} else {
|
|
navigate({
|
|
to: "/extractedResult/$pitchBook",
|
|
params: { pitchBook },
|
|
search: from ? { from } : undefined
|
|
});
|
|
}
|
|
};
|
|
|
|
const handleConfirmDiscard = () => {
|
|
setShowConfirmDialog(false);
|
|
navigate({
|
|
to: "/extractedResult/$pitchBook",
|
|
params: { pitchBook },
|
|
search: from ? { from } : undefined
|
|
});
|
|
};
|
|
|
|
const handleCancelDiscard = () => {
|
|
setShowConfirmDialog(false);
|
|
};
|
|
|
|
const handleAcceptReview = () => {
|
|
updateKPI();
|
|
};
|
|
|
|
const startCustomPageEditing = () => {
|
|
setEditingCustomPage(true);
|
|
};
|
|
|
|
const handleCustomPageKeyPress = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === "Enter" || e.key === "Escape") {
|
|
setEditingCustomPage(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Box p={4}>
|
|
<Box sx={{ display: "flex", alignItems: "center", mb: 3 }}>
|
|
<IconButton onClick={handleBackClick} sx={{ mr: 2 }}>
|
|
<ArrowBackIcon fontSize="large" sx={{ color: "#383838" }} />
|
|
</IconButton>
|
|
<Typography variant="h5">
|
|
Überprüfung der Kennzahl: <b><u>{kpi}</u></b>
|
|
</Typography>
|
|
</Box>
|
|
|
|
<Box
|
|
display="flex"
|
|
gap={4}
|
|
sx={{
|
|
width: "100vw",
|
|
maxWidth: "100%",
|
|
height: "85vh",
|
|
mt: 4,
|
|
}}
|
|
>
|
|
<Paper
|
|
elevation={2}
|
|
sx={{
|
|
width: "45%",
|
|
maxHeight: "100%",
|
|
height: "fit-content",
|
|
borderRadius: 2,
|
|
backgroundColor: "#eeeeee",
|
|
padding: 2,
|
|
overflow: "auto",
|
|
}}
|
|
>
|
|
<TableContainer component={Paper}>
|
|
<Table>
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell width="85%">
|
|
<strong>Gefundene Werte</strong>
|
|
</TableCell>
|
|
<TableCell align="center" width="15%">
|
|
<strong>Seiten</strong>
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{groupedKpiValues.map((item, index) => (
|
|
<TableRow
|
|
key={`${item.entity}_${item.pages.join('_')}_${index}`}
|
|
sx={{
|
|
"&:hover": { backgroundColor: "#f9f9f9" },
|
|
cursor: "pointer",
|
|
}}
|
|
onClick={() => handleRowClick(index)}
|
|
>
|
|
<TableCell>
|
|
<Box
|
|
sx={{
|
|
borderRadius: 1,
|
|
padding: "4px 8px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
width: "100%",
|
|
cursor: "pointer",
|
|
"&:hover": {
|
|
borderColor: "#ccc",
|
|
},
|
|
}}
|
|
>
|
|
<Radio
|
|
value={index.toString()}
|
|
checked={selectedIndex === index}
|
|
onChange={handleRadioChange}
|
|
sx={{
|
|
color: "#383838",
|
|
"&.Mui-checked": { color: "#383838" },
|
|
padding: "4px",
|
|
marginRight: 1,
|
|
"&:focus": {
|
|
outline: "none",
|
|
},
|
|
}}
|
|
/>
|
|
<span>{item.entity}</span>
|
|
</Box>
|
|
</TableCell>
|
|
<TableCell align="center">
|
|
{item.pages.map((page: number, i: number) => (
|
|
<Link
|
|
key={page}
|
|
component="button"
|
|
onClick={(e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
handlePageClick(page, item.entity);
|
|
}}
|
|
sx={{ cursor: "pointer", ml: i > 0 ? 1 : 0 }}
|
|
>
|
|
{page}
|
|
</Link>
|
|
))}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
|
|
<TableRow>
|
|
<TableCell>
|
|
<Box
|
|
sx={{
|
|
padding: "4px 8px",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
width: "100%",
|
|
cursor: "pointer",
|
|
"&:hover": {
|
|
borderColor: "#ccc",
|
|
},
|
|
}}
|
|
onClick={() => {
|
|
setSelectedIndex(-1);
|
|
setFocusHighlightOverride(null);
|
|
}}
|
|
>
|
|
<Radio
|
|
value="custom"
|
|
checked={selectedIndex === -1 && customValue !== ""}
|
|
onChange={handleRadioChange}
|
|
sx={{
|
|
color: "#383838",
|
|
"&.Mui-checked": { color: "#383838" },
|
|
padding: "4px",
|
|
marginRight: 1,
|
|
"&:focus": {
|
|
outline: "none",
|
|
},
|
|
}}
|
|
/>
|
|
<Box sx={{ width: '100%' }}>
|
|
<TextField
|
|
placeholder="Einen abweichenden Wert eingeben..."
|
|
value={customValue}
|
|
onChange={handleCustomValueChange}
|
|
variant="standard"
|
|
fullWidth
|
|
InputProps={{
|
|
disableUnderline: true,
|
|
}}
|
|
sx={{
|
|
"& .MuiInput-input": {
|
|
padding: 0,
|
|
},
|
|
}}
|
|
onClick={(e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
}}
|
|
error={selectedIndex === -1 && customValue !== "" && customValue.trim() === ""}
|
|
helperText={selectedIndex === -1 && customValue !== "" && customValue.trim() === "" ? "Der Wert, der angegeben wurde, ist leer." : ""}
|
|
/>
|
|
</Box>
|
|
</Box>
|
|
</TableCell>
|
|
<TableCell align="center">
|
|
{editingCustomPage ? (
|
|
<TextField
|
|
value={customPage}
|
|
onChange={handleCustomPageChange}
|
|
onKeyDown={handleCustomPageKeyPress}
|
|
onBlur={() => setEditingCustomPage(false)}
|
|
autoFocus
|
|
size="small"
|
|
variant="standard"
|
|
sx={{
|
|
width: "60px",
|
|
"& .MuiInput-input": {
|
|
textAlign: "center"
|
|
}
|
|
}}
|
|
inputProps={{
|
|
min: 0,
|
|
style: { textAlign: 'center' }
|
|
}}
|
|
/>
|
|
) : (
|
|
<Box
|
|
sx={{
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
gap: 1,
|
|
cursor: "pointer",
|
|
minHeight: "24px",
|
|
minWidth: "100px",
|
|
margin: "0 auto",
|
|
}}
|
|
onClick={(e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
startCustomPageEditing();
|
|
}}
|
|
>
|
|
<span>{customPage || "..."}</span>
|
|
<EditIcon
|
|
fontSize="small"
|
|
sx={{
|
|
color: "black",
|
|
opacity: 0.7,
|
|
transition: "opacity 0.2s ease",
|
|
ml: 1
|
|
}}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
startCustomPageEditing();
|
|
}}
|
|
/>
|
|
</Box>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
</Paper>
|
|
<Box
|
|
display="flex"
|
|
flexDirection="column"
|
|
justifyContent="space-between"
|
|
gap={3}
|
|
sx={{ width: "55%", height: "95%" }}
|
|
>
|
|
<Paper
|
|
elevation={2}
|
|
sx={{
|
|
width: "100%",
|
|
height: "fit-content",
|
|
maxHeight: "100%",
|
|
borderRadius: 2,
|
|
backgroundColor: "#eeeeee",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
overflow: "auto",
|
|
padding: 2,
|
|
}}
|
|
>
|
|
<PDFViewer
|
|
pitchBookId={pitchBook}
|
|
currentPage={currentPage}
|
|
onPageChange={setCurrentPage}
|
|
highlight={groupedKpiValues
|
|
.map((k) => k.pages.map((page: number) => ({ page, text: k.entity })))
|
|
.reduce((acc, val) => acc.concat(val), [])}
|
|
focusHighlight={focusHighlight}
|
|
/>
|
|
</Paper>
|
|
<Box mt={2} display="flex" justifyContent="flex-end" gap={2}>
|
|
<Button
|
|
variant="contained"
|
|
onClick={handleAcceptReview}
|
|
disabled={isSelectedValueEmpty}
|
|
sx={{
|
|
backgroundColor: "#383838",
|
|
"&:hover": { backgroundColor: "#2e2e2e" },
|
|
"&.Mui-disabled": { backgroundColor: "#ccc" },
|
|
}}
|
|
>
|
|
Überprüfung Annehmen
|
|
</Button>
|
|
</Box>
|
|
</Box>
|
|
</Box>
|
|
|
|
<Dialog
|
|
open={showConfirmDialog}
|
|
onClose={handleCancelDiscard}
|
|
maxWidth="sm"
|
|
fullWidth
|
|
>
|
|
<DialogTitle sx={{ fontSize: "1.25rem", fontWeight: "bold" }}>
|
|
Achtung
|
|
</DialogTitle>
|
|
<DialogContent>
|
|
<DialogContentText sx={{ fontSize: "1rem" }}>
|
|
Alle vorgenommenen Änderungen werden verworfen.
|
|
</DialogContentText>
|
|
</DialogContent>
|
|
<DialogActions sx={{ p: 3, gap: 2 }}>
|
|
<Button
|
|
onClick={handleCancelDiscard}
|
|
variant="outlined"
|
|
sx={{
|
|
color: "#666",
|
|
borderColor: "#ddd",
|
|
"&:hover": { backgroundColor: "#f5f5f5" },
|
|
}}
|
|
>
|
|
Abbrechen
|
|
</Button>
|
|
<Button
|
|
onClick={handleConfirmDiscard}
|
|
variant="contained"
|
|
sx={{
|
|
backgroundColor: "#383838",
|
|
"&:hover": { backgroundColor: "#2e2e2e" },
|
|
}}
|
|
>
|
|
Bestätigen
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
</Box>
|
|
);
|
|
} |