Merge pull request 'Data-Fetching Teil 1' (#59) from #53-data-fetching into main

Reviewed-on: #59
pull/61/head
Anastasia Hanna Ougolnikova 2025-06-09 15:25:43 +02:00
commit 211bb9a9d1
8 changed files with 442 additions and 219 deletions

View File

@ -1,6 +1,3 @@
from flask_socketio import SocketIO
socketio = SocketIO(
cors_allowed_origins=["http://localhost:8080", "http://localhost:3000"],
transports=["polling", "websocket"],
)
socketio = SocketIO(cors_allowed_origins="*")

View File

@ -1,203 +1,258 @@
import EditIcon from "@mui/icons-material/Edit";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutline";
import SearchIcon from "@mui/icons-material/Search";
import {
Table, TableBody, TableCell, TableContainer,
TableHead, TableRow, Paper, Box,
TextField, Link
} from '@mui/material';
import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline';
import SearchIcon from '@mui/icons-material/Search';
import EditIcon from '@mui/icons-material/Edit';
import { useState, useEffect } from 'react';
import type { KeyboardEvent } from 'react';
Box,
IconButton,
Link,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TextField,
} from "@mui/material";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import type { KeyboardEvent } from "react";
import { fetchPutKPI } from "../util/api";
const API_BASE_URL = 'http://localhost:5050'; // Korrigierter Port für den Coordinator-Service
const SETTINGS = [
{ name: "Rendite", position: 1, active: true, mandatory: true },
{ name: "Ausschüttungsrendite", position: 2, active: true, mandatory: true },
{ name: "Laufzeit", position: 3, active: true, mandatory: true },
{ name: "Länderallokation", position: 4, active: true, mandatory: true },
{ name: "Managmentgebühren", position: 5, active: true, mandatory: true },
{ name: "Risikoprofil", position: 6, active: false, mandatory: true },
{ name: "Irgendwas", position: 7, active: true, mandatory: true },
];
interface Kennzahl {
pdf_id: string;
label: string;
value: string;
page: number;
status: 'ok' | 'error' | 'warning';
}
interface KennzahlenTableProps {
interface KennzahlenTableProps {
onPageClick?: (page: number) => void;
pdfId?: string; // Neue Prop für die PDF-ID
}
// React-Komponente
export default function KennzahlenTable({ onPageClick, pdfId = 'example' }: KennzahlenTableProps) {
const [rows, setRows] = useState<Kennzahl[]>([]);
const [editingIndex, setEditingIndex] = useState<number | null>(null);
const [editValue, setEditValue] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Initialisiere Beispieldaten
const initializeData = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/kennzahlen/init`, {
method: 'POST'
});
if (!response.ok) {
throw new Error('Fehler beim Initialisieren der Daten');
}
// Lade die Daten nach der Initialisierung
await fetchKennzahlen();
} catch (err) {
setError('Fehler beim Initialisieren der Daten');
console.error('Fehler:', err);
}
pdfId: string; // Neue Prop für die PDF-ID
data: {
[key: string]: {
label: string;
entity: string;
page: number;
status: string;
source: string;
}[];
};
}
// Lade Kennzahlen vom Backend
const fetchKennzahlen = async () => {
try {
const response = await fetch(`${API_BASE_URL}/api/kennzahlen?pdf_id=${pdfId}`);
if (!response.ok) {
throw new Error('Fehler beim Laden der Kennzahlen');
}
const data = await response.json();
if (data.length === 0) {
// Wenn keine Daten vorhanden sind, initialisiere Beispieldaten
await initializeData();
} else {
setRows(data);
setError(null);
}
} catch (err) {
setError('Fehler beim Laden der Daten');
console.error('Fehler:', err);
} finally {
setIsLoading(false);
}
};
// React-Komponente
export default function KennzahlenTable({
onPageClick,
data,
pdfId,
}: KennzahlenTableProps) {
const [editingIndex, setEditingIndex] = useState<string>("");
const [editValue, setEditValue] = useState("");
const navigate = useNavigate({ from: "/extractedResult/$pitchBook" });
// Lade Daten beim ersten Render oder wenn sich die PDF-ID ändert
useEffect(() => {
fetchKennzahlen();
}, [pdfId]);
const queryClient = useQueryClient();
// Funktion zum Senden der PUT-Anfrage
const updateKennzahl = async (label: string, value: string) => {
try {
const response = await fetch(`${API_BASE_URL}/api/kennzahlen/${label}?pdf_id=${pdfId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
const { mutate } = useMutation({
mutationFn: (id: string) => {
const key = id.toUpperCase();
const updatedData = { ...data };
updatedData[key] = data[key]?.map((item) => ({
...item,
entity: editValue,
})) || [{ label: key, entity: editValue }];
return fetchPutKPI(Number(pdfId), updatedData);
},
body: JSON.stringify({ value })
onMutate: async (id: string) => {
await queryClient.cancelQueries({
queryKey: ["pitchBookKPI", pdfId],
});
if (!response.ok) {
throw new Error('Fehler beim Speichern der Kennzahl');
}
const snapshot = queryClient.getQueryData(["pitchBookKPI", pdfId]);
// Lade die Daten neu nach dem Update
await fetchKennzahlen();
} catch (error) {
console.error('Fehler:', error);
setError('Fehler beim Speichern der Änderungen');
}
const key = id.toUpperCase();
queryClient.setQueryData(["pitchBookKPI", pdfId], () => {
const updatedData = { ...data };
updatedData[key] = data[key]?.map((item) => ({
...item,
entity: editValue,
})) || [{ label: key, entity: editValue }];
return updatedData;
});
return () => {
queryClient.setQueryData(["pitchBookKPI", pdfId], snapshot);
};
},
onError: (error, _variables, rollback) => {
console.log("error", error);
rollback?.();
},
onSettled: () => {
return queryClient.invalidateQueries({
queryKey: ["pitchBookKPI", pdfId],
});
},
});
// Bearbeitung starten
const startEditing = (value: string, index: number) => {
const startEditing = (value: string, index: string) => {
setEditingIndex(index);
setEditValue(value);
};
// Bearbeitung beenden und Wert speichern
const handleSave = async (index: number) => {
await updateKennzahl(rows[index].label, editValue);
setEditingIndex(null);
const handleSave = async (index: string) => {
// await updateKennzahl(rows[index].label, editValue);
mutate(index);
setEditingIndex("");
};
// Tastatureingaben verarbeiten
const handleKeyPress = (e: KeyboardEvent<HTMLDivElement>, index: number) => {
if (e.key === 'Enter') {
const handleKeyPress = (e: KeyboardEvent<HTMLDivElement>, index: string) => {
if (e.key === "Enter") {
handleSave(index);
} else if (e.key === 'Escape') {
setEditingIndex(null);
} else if (e.key === "Escape") {
setEditingIndex("null");
}
};
if (isLoading) {
return <div>Lade Daten...</div>;
}
if (error) {
return <Box sx={{ color: 'error.main' }}>{error}</Box>;
}
return (
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell><strong>Kennzahl</strong></TableCell>
<TableCell><strong>Wert</strong></TableCell>
<TableCell><strong>Seite</strong></TableCell>
<TableCell>
<strong>Kennzahl</strong>
</TableCell>
<TableCell>
<strong>Wert</strong>
</TableCell>
<TableCell>
<strong>Seite</strong>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => {
let borderColor = 'transparent';
if (row.status === 'error') borderColor = 'red';
else if (row.status === 'warning') borderColor = '#f6ed48';
{SETTINGS.filter((setting) => setting.active)
.sort((a, b) => a.position - b.position)
.map((setting) => ({
setting: setting,
extractedValues: data[setting.name.toUpperCase()] || [],
}))
.map((row) => {
let borderColor = "transparent";
if (
row.setting.mandatory &&
(row.extractedValues.length === 0 ||
row.extractedValues.at(0)?.entity === "")
)
borderColor = "red";
else if (row.extractedValues.length > 1) borderColor = "#f6ed48";
return (
<TableRow key={index}>
<TableCell>{row.label}</TableCell>
<TableCell onClick={() => startEditing(row.value, index)}>
<TableRow key={row.setting.name}>
<TableCell>{row.setting.name}</TableCell>
<TableCell
onClick={() =>
startEditing(
row.extractedValues.at(0)?.entity || "",
row.setting.name,
)
}
>
<Box
sx={{
border: `2px solid ${borderColor}`,
borderRadius: 1,
padding: '4px 8px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
cursor: 'text',
padding: "4px 8px",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
cursor: "text",
}}
>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, width: '100%' }}>
{row.status === 'error' && <ErrorOutlineIcon fontSize="small" color="error" />}
{row.status === 'warning' && <SearchIcon fontSize="small" sx={{ color: '#f6ed48' }} />}
{editingIndex === index ? (
<Box
sx={{
display: "flex",
alignItems: "center",
gap: 1,
width: "100%",
}}
>
{row.setting.mandatory &&
row.extractedValues.length === 0 && (
<ErrorOutlineIcon fontSize="small" color="error" />
)}
{editingIndex === row.setting.name ? (
<TextField
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={(e) => handleKeyPress(e, index)}
onBlur={() => handleSave(index)}
onKeyDown={(e) =>
handleKeyPress(e, row.setting.name)
}
onBlur={() => handleSave(row.setting.name)}
autoFocus
size="small"
fullWidth
variant="standard"
sx={{ margin: '-8px 0' }}
sx={{ margin: "-8px 0" }}
/>
) : (
<span>{row.value || '—'}</span>
<span>
{row.extractedValues.at(0)?.entity || "—"}
</span>
)}
</Box>
{row.extractedValues.length > 1 && (
<IconButton
aria-label="select"
onClick={() =>
navigate({
to: "/extractedResult/$pitchBook/$kpi",
params: {
pitchBook: pdfId,
kpi: row.setting.name,
},
})
}
>
<SearchIcon
fontSize="small"
sx={{ color: "#f6ed48" }}
/>
</IconButton>
)}
{row.extractedValues.length <= 1 && (
<EditIcon
fontSize="small"
sx={{ color: '#555', cursor: 'pointer' }}
sx={{ color: "#555", cursor: "pointer" }}
onClick={(e) => {
e.stopPropagation();
startEditing(row.value, index);
startEditing(
row.extractedValues.at(0)?.entity || "",
row.setting.name,
);
}}
/>
)}
</Box>
</TableCell>
<TableCell>
<Link
component="button"
onClick={() => onPageClick?.(row.page)}
sx={{ cursor: 'pointer' }}
onClick={() =>
onPageClick?.(Number(row.extractedValues.at(0)?.page))
}
sx={{ cursor: "pointer" }}
>
{row.page}
{row.extractedValues.at(0)?.page}
</Link>
</TableCell>
</TableRow>
@ -207,5 +262,4 @@ import {
</Table>
</TableContainer>
);
}
}

View File

@ -12,7 +12,10 @@ interface PDFViewerProps {
currentPage?: number;
}
export default function PDFViewer({ pitchBookId, currentPage }: PDFViewerProps) {
export default function PDFViewer({
pitchBookId,
currentPage,
}: PDFViewerProps) {
const [numPages, setNumPages] = useState<number | null>(null);
const [pageNumber, setPageNumber] = useState(currentPage || 1);
const [containerWidth, setContainerWidth] = useState<number | null>(null);
@ -39,7 +42,7 @@ export default function PDFViewer({ pitchBookId, currentPage }: PDFViewerProps)
if (currentPage && currentPage !== pageNumber) {
setPageNumber(currentPage);
}
}, [currentPage]);
}, [currentPage, pageNumber]);
useEffect(() => {
const handleProgress = (data: { id: number; progress: number }) => {

View File

@ -16,6 +16,7 @@ import { Route as ConfigImport } from './routes/config'
import { Route as IndexImport } from './routes/index'
import { Route as ExtractedResultPitchBookImport } from './routes/extractedResult.$pitchBook'
import { Route as ConfigDetailKpiIdImport } from './routes/config-detail.$kpiId'
import { Route as ExtractedResultPitchBookKpiImport } from './routes/extractedResult_.$pitchBook.$kpi'
// Create/Update Routes
@ -49,6 +50,13 @@ const ConfigDetailKpiIdRoute = ConfigDetailKpiIdImport.update({
getParentRoute: () => rootRoute,
} as any)
const ExtractedResultPitchBookKpiRoute =
ExtractedResultPitchBookKpiImport.update({
id: '/extractedResult_/$pitchBook/$kpi',
path: '/extractedResult/$pitchBook/$kpi',
getParentRoute: () => rootRoute,
} as any)
// Populate the FileRoutesByPath interface
declare module '@tanstack/react-router' {
@ -88,6 +96,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ExtractedResultPitchBookImport
parentRoute: typeof rootRoute
}
'/extractedResult_/$pitchBook/$kpi': {
id: '/extractedResult_/$pitchBook/$kpi'
path: '/extractedResult/$pitchBook/$kpi'
fullPath: '/extractedResult/$pitchBook/$kpi'
preLoaderRoute: typeof ExtractedResultPitchBookKpiImport
parentRoute: typeof rootRoute
}
}
}
@ -99,6 +114,7 @@ export interface FileRoutesByFullPath {
'/config-add': typeof ConfigAddRoute
'/config-detail/$kpiId': typeof ConfigDetailKpiIdRoute
'/extractedResult/$pitchBook': typeof ExtractedResultPitchBookRoute
'/extractedResult/$pitchBook/$kpi': typeof ExtractedResultPitchBookKpiRoute
}
export interface FileRoutesByTo {
@ -107,6 +123,7 @@ export interface FileRoutesByTo {
'/config-add': typeof ConfigAddRoute
'/config-detail/$kpiId': typeof ConfigDetailKpiIdRoute
'/extractedResult/$pitchBook': typeof ExtractedResultPitchBookRoute
'/extractedResult/$pitchBook/$kpi': typeof ExtractedResultPitchBookKpiRoute
}
export interface FileRoutesById {
@ -116,6 +133,7 @@ export interface FileRoutesById {
'/config-add': typeof ConfigAddRoute
'/config-detail/$kpiId': typeof ConfigDetailKpiIdRoute
'/extractedResult/$pitchBook': typeof ExtractedResultPitchBookRoute
'/extractedResult_/$pitchBook/$kpi': typeof ExtractedResultPitchBookKpiRoute
}
export interface FileRouteTypes {
@ -126,6 +144,7 @@ export interface FileRouteTypes {
| '/config-add'
| '/config-detail/$kpiId'
| '/extractedResult/$pitchBook'
| '/extractedResult/$pitchBook/$kpi'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
@ -133,6 +152,7 @@ export interface FileRouteTypes {
| '/config-add'
| '/config-detail/$kpiId'
| '/extractedResult/$pitchBook'
| '/extractedResult/$pitchBook/$kpi'
id:
| '__root__'
| '/'
@ -140,6 +160,7 @@ export interface FileRouteTypes {
| '/config-add'
| '/config-detail/$kpiId'
| '/extractedResult/$pitchBook'
| '/extractedResult_/$pitchBook/$kpi'
fileRoutesById: FileRoutesById
}
@ -149,6 +170,7 @@ export interface RootRouteChildren {
ConfigAddRoute: typeof ConfigAddRoute
ConfigDetailKpiIdRoute: typeof ConfigDetailKpiIdRoute
ExtractedResultPitchBookRoute: typeof ExtractedResultPitchBookRoute
ExtractedResultPitchBookKpiRoute: typeof ExtractedResultPitchBookKpiRoute
}
const rootRouteChildren: RootRouteChildren = {
@ -157,6 +179,7 @@ const rootRouteChildren: RootRouteChildren = {
ConfigAddRoute: ConfigAddRoute,
ConfigDetailKpiIdRoute: ConfigDetailKpiIdRoute,
ExtractedResultPitchBookRoute: ExtractedResultPitchBookRoute,
ExtractedResultPitchBookKpiRoute: ExtractedResultPitchBookKpiRoute,
}
export const routeTree = rootRoute
@ -173,7 +196,8 @@ export const routeTree = rootRoute
"/config",
"/config-add",
"/config-detail/$kpiId",
"/extractedResult/$pitchBook"
"/extractedResult/$pitchBook",
"/extractedResult_/$pitchBook/$kpi"
]
},
"/": {
@ -190,6 +214,9 @@ export const routeTree = rootRoute
},
"/extractedResult/$pitchBook": {
"filePath": "extractedResult.$pitchBook.tsx"
},
"/extractedResult_/$pitchBook/$kpi": {
"filePath": "extractedResult_.$pitchBook.$kpi.tsx"
}
}
}

View File

@ -1,12 +1,16 @@
import ContentPasteIcon from "@mui/icons-material/ContentPaste";
import { Box, Button, Paper, Typography } from "@mui/material";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { useState } from "react";
import KennzahlenTable from "../components/KennzahlenTable";
import PDFViewer from "../components/pdfViewer";
import { kpiQueryOptions } from "../util/query";
export const Route = createFileRoute("/extractedResult/$pitchBook")({
component: ExtractedResultsPage,
loader: ({ context: { queryClient }, params: { pitchBook } }) =>
queryClient.ensureQueryData(kpiQueryOptions(pitchBook)),
});
function ExtractedResultsPage() {
@ -21,6 +25,8 @@ function ExtractedResultsPage() {
green: "#3fd942",
}[status];
const { data: kpi } = useSuspenseQuery(kpiQueryOptions(pitchBook));
return (
<Box p={4}>
<Box display="flex" alignItems="center" gap={3}>
@ -60,7 +66,11 @@ function ExtractedResultsPage() {
overflow: "auto",
}}
>
<KennzahlenTable onPageClick={setCurrentPage} />
<KennzahlenTable
onPageClick={setCurrentPage}
data={kpi}
pdfId={pitchBook}
/>
</Paper>
<Box
display="flex"

View File

@ -0,0 +1,27 @@
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute } from "@tanstack/react-router";
import { kpiQueryOptions } from "../util/query";
export const Route = createFileRoute("/extractedResult_/$pitchBook/$kpi")({
component: RouteComponent,
loader: ({ context: { queryClient }, params: { pitchBook } }) =>
queryClient.ensureQueryData(kpiQueryOptions(pitchBook)),
});
function RouteComponent() {
const { pitchBook, kpi } = Route.useParams();
const {
data: { [kpi.toUpperCase()]: kpiValues },
} = useSuspenseQuery(kpiQueryOptions(pitchBook));
return (
<div>
{kpiValues.map((e) => (
<div key={`${e.entity}_${e.page}`}>
{e.label}: {e.entity}
</div>
))}
</div>
);
}

View File

@ -0,0 +1,97 @@
export const fetchKPI = async (
pitchBookId: string,
): Promise<{
[key: string]: {
label: string;
entity: string;
page: number;
status: string;
source: string;
}[];
}> => {
const response = await fetch(
`http://localhost:5050/api/pitch_book/${pitchBookId}`,
);
const data = await response.json();
return getKPI(data.kpi);
};
export const fetchPutKPI = async (
pitchBookId: number,
kpi: {
[key: string]: {
label: string;
entity: string;
page: number;
status: string;
source: string;
}[];
},
): Promise<{
[key: string]: {
label: string;
entity: string;
page: number;
status: string;
source: string;
}[];
}> => {
const formData = new FormData();
formData.append("kpi", JSON.stringify(flattenKPIArray(kpi)));
const response = await fetch(
`http://localhost:5050/api/pitch_book/${pitchBookId}`,
{
method: "PUT",
body: formData,
},
);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
return getKPI(data.kpi);
};
const getKPI = (data: string) => {
const kpi = JSON.parse(data) as {
label: string;
entity: string;
page: number;
status: string;
source: string;
}[];
const reducedKpi = kpi.reduce(
(prev, curr) => {
if (!prev[curr.label]) {
prev[curr.label] = [];
}
prev[curr.label].push(curr);
return prev;
},
{} as {
[key: string]: {
label: string;
entity: string;
page: number;
status: string;
source: string;
}[];
},
);
return reducedKpi;
};
export const flattenKPIArray = (kpi: {
[key: string]: {
label: string;
entity: string;
page: number;
status: string;
source: string;
}[];
}) => {
return Object.values(kpi).flat();
};

View File

@ -0,0 +1,8 @@
import { queryOptions } from "@tanstack/react-query";
import { fetchKPI } from "./api";
export const kpiQueryOptions = (pitchBookId: string) =>
queryOptions({
queryKey: ["pitchBookKPI", pitchBookId],
queryFn: () => fetchKPI(pitchBookId),
});