diff --git a/project/backend/coordinator/controller/pitch_book_controller.py b/project/backend/coordinator/controller/pitch_book_controller.py index 8dc6776..4a77dbd 100644 --- a/project/backend/coordinator/controller/pitch_book_controller.py +++ b/project/backend/coordinator/controller/pitch_book_controller.py @@ -17,48 +17,6 @@ OCR_SERVICE_URL = os.getenv("OCR_SERVICE_URL", "http://localhost:5051") progress_per_id = {} # {id: {kpi: 0, pdf: 0}} storage_lock = threading.Lock() -def process_pdf_async(app, file_id, file_data, filename): - with app.app_context(): - try: - file_obj = BytesIO(file_data) - file_obj.name = filename - - files = {"file": (filename, file_obj, "application/pdf")} - data = {"id": file_id} - - response = requests.post( - f"{OCR_SERVICE_URL}/ocr", files=files, data=data, timeout=600 - ) - - if response.status_code == 200: - response_data = response.json() - if "ocr_pdf" in response_data: - import base64 - - ocr_pdf_data = base64.b64decode(response_data["ocr_pdf"]) - - file_record = PitchBookModel.query.get(file_id) - if file_record: - file_record.file = ocr_pdf_data - db.session.commit() - - print("[DEBUG] PDF updated in database:") - print("[DEBUG] - Successfully saved to database") - - socketio.emit("progress", {"id": file_id, "progress": 50}) - else: - socketio.emit( - "error", {"id": file_id, "message": "OCR processing failed"} - ) - - except Exception as e: - import traceback - - traceback.print_exc() - socketio.emit( - "error", {"id": file_id, "message": f"Processing failed: {str(e)}"} - ) - @pitch_book_controller.route("/", methods=["POST"]) def upload_file(): @@ -88,6 +46,7 @@ def upload_file(): files = {"file": (uploaded_file.filename, file_data, "application/pdf")} data = {"id": new_file.id} + socketio.emit("progress", {"id": new_file.id, "progress": 5}) response = requests.post( f"{OCR_SERVICE_URL}/ocr", files=files, data=data, timeout=600 ) diff --git a/project/backend/exxetaGPT-service/app.py b/project/backend/exxetaGPT-service/app.py index b40ee91..9b1597b 100644 --- a/project/backend/exxetaGPT-service/app.py +++ b/project/backend/exxetaGPT-service/app.py @@ -15,7 +15,7 @@ def extract_text_from_ocr_json(): pitchbook_id = json_data["id"] pages_data = json_data["extracted_text_per_page"] - entities_json = extract_with_exxeta(pages_data) + entities_json = extract_with_exxeta(pages_data, pitchbook_id) entities = json.loads(entities_json) if isinstance(entities_json, str) else entities_json validate_payload = { @@ -39,4 +39,4 @@ def extract_text_from_ocr_json(): if __name__ == "__main__": - app.run(host="0.0.0.0", port=5053, debug=True) \ No newline at end of file + app.run(host="0.0.0.0", port=5053, debug=True) diff --git a/project/backend/exxetaGPT-service/extractExxeta.py b/project/backend/exxetaGPT-service/extractExxeta.py index 8bd979d..c554f0c 100644 --- a/project/backend/exxetaGPT-service/extractExxeta.py +++ b/project/backend/exxetaGPT-service/extractExxeta.py @@ -9,6 +9,7 @@ MODEL = "gpt-4o-mini" EXXETA_BASE_URL = "https://ai.exxeta.com/api/v2/azure/openai" load_dotenv() EXXETA_API_KEY = os.getenv("API_KEY") +COORDINATOR_URL = os.getenv("COORDINATOR_URL", "http://localhost:5050") MAX_RETRIES = 3 TIMEOUT = 180 @@ -16,14 +17,20 @@ TIMEOUT = 180 logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) -def extract_with_exxeta(pages_json): +def extract_with_exxeta(pages_json, pitchbook_id): results = [] if not EXXETA_API_KEY: logger.warning("EXXETA_API_KEY nicht gesetzt. Rückgabe eines leeren JSON.") return json.dumps(results, indent=2, ensure_ascii=False) + i = 0 for page_data in pages_json: + i += 1 + if i % 8 == 0: + requests.post(COORDINATOR_URL + "/api/progress", json={"id": pitchbook_id, "progress": 35 + 60/len(pages_json)*i}) + + page_num = page_data.get("page") page_data.get("page") text = page_data.get("text", "") @@ -57,7 +64,7 @@ def extract_with_exxeta(pages_json): prompt = ( "Bitte extrahiere relevante Fondskennzahlen aus dem folgenden Pitchbook-Text. " "Analysiere den Text sorgfältig, um **nur exakt benannte und relevante Werte** zu extrahieren.\n\n" - + "ZU EXTRAHIERENDE KENNZAHLEN (immer exakt wie unten angegeben):\n" "- FONDSNAME\n" "- FONDSMANAGER\n" @@ -74,14 +81,14 @@ def extract_with_exxeta(pages_json): "- MANAGEMENTGEBÜHREN (ggf. mit Staffelung und Bezug auf NAV/GAV)\n" "- SEKTORENALLOKATION (z. B. BÜRO, LOGISTIK, WOHNEN... inkl. %-Angaben)\n" "- LÄNDERALLOKATION (z. B. DEUTSCHLAND, FRANKREICH, etc. inkl. %-Angaben)\n\n" - + "WICHTIG:\n" "- Gib **nur eine Entität pro Kennzahl** an - keine Listen oder Interpretationen.\n" "- Wenn mehrere Varianten genannt werden (z. B. \"Core und Core+\"), gib sie im Originalformat als **eine entity** an.\n" "- **Keine Vermutungen oder Ergänzungen**. Wenn keine Information enthalten ist, gib die Kennzahl **nicht aus**.\n" "- Extrahiere **nur wörtlich vorkommende Inhalte** (keine Berechnungen, keine Zusammenfassungen).\n" "- Jeder gefundene Wert muss einem der obigen Label **eindeutig zuordenbar** sein.\n\n" - + "FORMAT:\n" "Antworte als **reines JSON-Array** mit folgendem Format:\n" "[\n" @@ -92,7 +99,7 @@ def extract_with_exxeta(pages_json): " },\n" " ...\n" "]\n\n" - + f"Falls keine Kennzahl enthalten ist, gib ein leeres Array [] zurück.\n\n" f"Nur JSON-Antwort - keine Kommentare, keine Erklärungen, kein Text außerhalb des JSON.\n\n" f"TEXT:\n{text}" @@ -144,4 +151,6 @@ def extract_with_exxeta(pages_json): if attempt == MAX_RETRIES: results.extend([]) - return json.dumps(results, indent=2, ensure_ascii=False) \ No newline at end of file + + requests.post(COORDINATOR_URL + "/api/progress", json={"id": pitchbook_id, "progress": 95}) + return json.dumps(results, indent=2, ensure_ascii=False) diff --git a/project/backend/ocr-service/app.py b/project/backend/ocr-service/app.py index 06a6f14..ba6c0ae 100644 --- a/project/backend/ocr-service/app.py +++ b/project/backend/ocr-service/app.py @@ -41,6 +41,7 @@ def convert_pdf_async(temp_path, pitchbook_id): logger.info("Sending payload to EXXETA and SPACY services") + requests.post(COORDINATOR_URL + "/api/progress", json={"id": pitchbook_id, "progress": 35}) try: exxeta_response = requests.post(EXXETA_URL, json=payload, timeout=600) logger.info(f"EXXETA response: {exxeta_response.status_code}") @@ -59,9 +60,8 @@ def convert_pdf_async(temp_path, pitchbook_id): headers = {} try: - requests.put(f"{COORDINATOR_URL}/api/pitch_book/{pitchbook_id}", files=files, timeout=600, headers=headers) - requests.post(COORDINATOR_URL + "/api/progress", json={"id": pitchbook_id, "progress": 50}, timeout=600) + requests.put(f"{COORDINATOR_URL}/api/pitch_book/{pitchbook_id}", files=files, timeout=600, headers=headers) logger.info("COORDINATOR response: Progress + File updated") except Exception as e: logger.error(f"Error calling COORDINATOR: {e}") diff --git a/project/backend/validate-service/app.py b/project/backend/validate-service/app.py index 2dfb9cb..693c032 100644 --- a/project/backend/validate-service/app.py +++ b/project/backend/validate-service/app.py @@ -10,7 +10,7 @@ import json app = Flask(__name__) load_dotenv() -coordinator_url = os.getenv("COORDINATOR_URL", "http://localhost:5000") +COORDINATOR_URL = os.getenv("COORDINATOR_URL", "http://localhost:5000") # todo add persistence layer data_storage = {} # {id: {spacy_data: [], exxeta_data: []}} @@ -19,7 +19,7 @@ storage_lock = threading.Lock() def send_to_coordinator_service(processed_data, request_id): - if not coordinator_url: + if not COORDINATOR_URL: print("Not processed, missing url", processed_data) return @@ -28,7 +28,7 @@ def send_to_coordinator_service(processed_data, request_id): "kpi": json.dumps(processed_data), } requests.put( - coordinator_url + "/api/pitch_book/" + str(request_id), + COORDINATOR_URL + "/api/pitch_book/" + str(request_id), data=payload, ) print(f"Result PitchBook {request_id} sent to coordinator") @@ -40,6 +40,7 @@ def send_to_coordinator_service(processed_data, request_id): def process_data_async(request_id, spacy_data, exxeta_data): try: + requests.post(COORDINATOR_URL + "/api/progress", json={"id": request_id, "progress": 95}) print(f"Start asynchronous processing for PitchBook: {request_id}") # Perform merge diff --git a/project/docker-compose.yml b/project/docker-compose.yml index 8402897..1c5a848 100644 --- a/project/docker-compose.yml +++ b/project/docker-compose.yml @@ -47,6 +47,7 @@ services: environment: - EXXETA_SERVICE_URL=http://exxeta:5000/extract - SPACY_SERVICE_URL=http://spacy:5052/extract + - COORDINATOR_URL=http://coordinator:5000 ports: - 5051:5000 @@ -68,6 +69,7 @@ services: - .env environment: - VALIDATE_SERVICE_URL=http://validate:5000/validate + - COORDINATOR_URL=http://coordinator:5000 ports: - 5053:5000 diff --git a/project/frontend/src/components/ConfigTable.tsx b/project/frontend/src/components/ConfigTable.tsx index 3b7b6d4..f835021 100644 --- a/project/frontend/src/components/ConfigTable.tsx +++ b/project/frontend/src/components/ConfigTable.tsx @@ -7,7 +7,11 @@ import { getDisplayType } from "../types/kpi"; import { fetchKennzahlen as fetchK } from "../util/api"; import { API_HOST } from "../util/api"; -export function ConfigTable() { +type ConfigTableProps = { + from?: string; +}; + +export function ConfigTable({ from }: ConfigTableProps) { const navigate = useNavigate(); const [kennzahlen, setKennzahlen] = useState([]); const [draggedItem, setDraggedItem] = useState(null); @@ -161,12 +165,10 @@ export function ConfigTable() { return; } - console.log("Navigating to detail page for KPI:", kennzahl); - console.log("KPI ID:", kennzahl.id); - navigate({ to: `/config-detail/$kpiId`, params: { kpiId: kennzahl.id.toString() }, + search: from ? { from } : undefined, }); }; diff --git a/project/frontend/src/components/KennzahlenTable.tsx b/project/frontend/src/components/KennzahlenTable.tsx index db0a891..b6a19bb 100644 --- a/project/frontend/src/components/KennzahlenTable.tsx +++ b/project/frontend/src/components/KennzahlenTable.tsx @@ -34,6 +34,7 @@ interface KennzahlenTableProps { source: string; }[]; }; + from?: string; } export default function KennzahlenTable({ @@ -41,10 +42,11 @@ export default function KennzahlenTable({ data, pdfId, settings, + from }: KennzahlenTableProps) { const [editingIndex, setEditingIndex] = useState(""); const [editValue, setEditValue] = useState(""); - const navigate = useNavigate({ from: "/extractedResult/$pitchBook" }); + const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -120,6 +122,7 @@ export default function KennzahlenTable({ pitchBook: pdfId, kpi: settingName, }, + search: { from: from ?? undefined }, }); }; @@ -297,18 +300,22 @@ export default function KennzahlenTable({ )} - - onPageClick?.( - Number(row.extractedValues.at(0)?.page), - row.extractedValues.at(0)?.entity || "", - ) - } - sx={{ cursor: "pointer" }} - > - {row.extractedValues.at(0)?.page} - + {(row.extractedValues.at(0)?.page ?? 0) > 0 ? ( + { + const extractedValue = row.extractedValues.at(0); + if (extractedValue?.page && extractedValue.page > 0) { + onPageClick?.(Number(extractedValue.page), extractedValue.entity || ""); + } + }} + sx={{ cursor: "pointer" }} + > + {row.extractedValues.at(0)?.page} + + ) : ( + "" + )} ); diff --git a/project/frontend/src/components/PitchBooksTable.tsx b/project/frontend/src/components/PitchBooksTable.tsx new file mode 100644 index 0000000..7a86ada --- /dev/null +++ b/project/frontend/src/components/PitchBooksTable.tsx @@ -0,0 +1,186 @@ +import { Box, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography, CircularProgress, Chip } from "@mui/material"; +import { useSuspenseQuery } from "@tanstack/react-query"; +import { useNavigate } from "@tanstack/react-router"; +import { pitchBooksQueryOptions } from "../util/query"; +import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'; +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import HourglassEmptyIcon from '@mui/icons-material/HourglassEmpty'; + +interface PitchBook { + id: number; + filename: string; + created_at: string; + kpi?: string | { + [key: string]: { + label: string; + entity: string; + page: number; + status: string; + source: string; + }[]; + }; + status?: 'processing' | 'completed'; +} + +export function PitchBooksTable() { + const navigate = useNavigate(); + const { data: pitchBooks, isLoading } = useSuspenseQuery(pitchBooksQueryOptions()); + + const handleRowClick = (pitchBookId: number) => { + navigate({ + to: "/extractedResult/$pitchBook", + params: { pitchBook: pitchBookId.toString() }, + search: { from: "overview" } + }); + }; + + const getKPIValue = (pitchBook: PitchBook, fieldName: string): string => { + if (!pitchBook.kpi || typeof pitchBook.kpi === 'string') { + try { + const parsedKPI = JSON.parse(pitchBook.kpi as string); + // Convert array to object format if needed + const kpiObj = Array.isArray(parsedKPI) ? + parsedKPI.reduce((acc: any, item: any) => { + if (!acc[item.label]) acc[item.label] = []; + acc[item.label].push(item); + return acc; + }, {}) : parsedKPI; + + return kpiObj[fieldName]?.[0]?.entity || 'N/A'; + } catch { + return 'N/A'; + } + } + + return (pitchBook.kpi as any)[fieldName]?.[0]?.entity || 'N/A'; + }; + + const getStatus = (pitchBook: PitchBook) => { + if (pitchBook.kpi && + ((typeof pitchBook.kpi === 'string' && pitchBook.kpi !== '{}') || + (typeof pitchBook.kpi === 'object' && Object.keys(pitchBook.kpi).length > 0))) { + return 'completed'; + } + return 'processing'; + }; + + if (isLoading) { + return ( + + + + ); + } + + return ( + + + + + + Fondsname + Fondsmanager + Dateiname + Status + + + + {pitchBooks.map((pitchBook: PitchBook) => { + const status = getStatus(pitchBook); + const fundName = getKPIValue(pitchBook, 'FONDSNAME') || + getKPIValue(pitchBook, 'FUND_NAME') || + getKPIValue(pitchBook, 'NAME'); + + const manager = getKPIValue(pitchBook, 'FONDSMANAGER') || + getKPIValue(pitchBook, 'MANAGER') || + getKPIValue(pitchBook, 'PORTFOLIO_MANAGER'); + + return ( + handleRowClick(pitchBook.id)} + sx={{ + cursor: "pointer", + "&:hover": { + backgroundColor: "#f9f9f9", + }, + }} + > + + + + + + + + {fundName} + + + {manager} + + + {pitchBook.filename} + + + + {status === 'completed' ? ( + } + label="Abgeschlossen" + size="small" + sx={{ + backgroundColor: "#e8f5e9", + color: "#2e7d32", + "& .MuiChip-icon": { + color: "#2e7d32", + }, + }} + /> + ) : ( + } + label="In Bearbeitung" + size="small" + sx={{ + backgroundColor: "#fff3e0", + color: "#e65100", + "& .MuiChip-icon": { + color: "#e65100", + }, + }} + /> + )} + + + ); + })} + +
+ {pitchBooks.length === 0 && ( + + + Keine Pitch Books vorhanden + + + )} +
+ ); +} \ No newline at end of file diff --git a/project/frontend/src/components/UploadPage.tsx b/project/frontend/src/components/UploadPage.tsx index 20d6f7b..6de8a62 100644 --- a/project/frontend/src/components/UploadPage.tsx +++ b/project/frontend/src/components/UploadPage.tsx @@ -7,8 +7,6 @@ import { socket } from "../socket"; import { CircularProgressWithLabel } from "./CircularProgressWithLabel"; import { API_HOST } from "../util/api"; -const PROGRESS = true; - export default function UploadPage() { const [files, setFiles] = useState([]); const [pageId, setPageId] = useState(null); @@ -28,17 +26,11 @@ export default function UploadPage() { console.log("File uploaded successfully"); const data = await response.json(); setPageId(data.id.toString()); - setLoadingState(0); - - !PROGRESS && - navigate({ - to: "/extractedResult/$pitchBook", - params: { pitchBook: data.id.toString() }, - }); + setLoadingState(5); } else { console.error("Failed to upload file"); } - }, [files, navigate]); + }, [files]); const onConnection = useCallback(() => { console.log("connected"); @@ -80,18 +72,16 @@ export default function UploadPage() { return ( <> - {PROGRESS && ( - ({ color: "#fff", zIndex: theme.zIndex.drawer + 1 })} - open={pageId !== null && loadingState !== null && loadingState < 100} - > - - - )} + ({ color: "#fff", zIndex: theme.zIndex.drawer + 1 })} + open={pageId !== null && loadingState !== null && loadingState < 100} + > + + Kennzahlen extrahieren + ); -} \ No newline at end of file +} diff --git a/project/frontend/src/routeTree.gen.ts b/project/frontend/src/routeTree.gen.ts index 489b1cf..5377387 100644 --- a/project/frontend/src/routeTree.gen.ts +++ b/project/frontend/src/routeTree.gen.ts @@ -11,6 +11,7 @@ // Import Routes import { Route as rootRoute } from './routes/__root' +import { Route as PitchbooksImport } from './routes/pitchbooks' import { Route as ConfigAddImport } from './routes/config-add' import { Route as ConfigImport } from './routes/config' import { Route as IndexImport } from './routes/index' @@ -20,6 +21,12 @@ import { Route as ExtractedResultPitchBookKpiImport } from './routes/extractedRe // Create/Update Routes +const PitchbooksRoute = PitchbooksImport.update({ + id: '/pitchbooks', + path: '/pitchbooks', + getParentRoute: () => rootRoute, +} as any) + const ConfigAddRoute = ConfigAddImport.update({ id: '/config-add', path: '/config-add', @@ -82,6 +89,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ConfigAddImport parentRoute: typeof rootRoute } + '/pitchbooks': { + id: '/pitchbooks' + path: '/pitchbooks' + fullPath: '/pitchbooks' + preLoaderRoute: typeof PitchbooksImport + parentRoute: typeof rootRoute + } '/config-detail/$kpiId': { id: '/config-detail/$kpiId' path: '/config-detail/$kpiId' @@ -112,6 +126,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/config': typeof ConfigRoute '/config-add': typeof ConfigAddRoute + '/pitchbooks': typeof PitchbooksRoute '/config-detail/$kpiId': typeof ConfigDetailKpiIdRoute '/extractedResult/$pitchBook': typeof ExtractedResultPitchBookRoute '/extractedResult/$pitchBook/$kpi': typeof ExtractedResultPitchBookKpiRoute @@ -121,6 +136,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/config': typeof ConfigRoute '/config-add': typeof ConfigAddRoute + '/pitchbooks': typeof PitchbooksRoute '/config-detail/$kpiId': typeof ConfigDetailKpiIdRoute '/extractedResult/$pitchBook': typeof ExtractedResultPitchBookRoute '/extractedResult/$pitchBook/$kpi': typeof ExtractedResultPitchBookKpiRoute @@ -131,6 +147,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/config': typeof ConfigRoute '/config-add': typeof ConfigAddRoute + '/pitchbooks': typeof PitchbooksRoute '/config-detail/$kpiId': typeof ConfigDetailKpiIdRoute '/extractedResult/$pitchBook': typeof ExtractedResultPitchBookRoute '/extractedResult_/$pitchBook/$kpi': typeof ExtractedResultPitchBookKpiRoute @@ -142,6 +159,7 @@ export interface FileRouteTypes { | '/' | '/config' | '/config-add' + | '/pitchbooks' | '/config-detail/$kpiId' | '/extractedResult/$pitchBook' | '/extractedResult/$pitchBook/$kpi' @@ -150,6 +168,7 @@ export interface FileRouteTypes { | '/' | '/config' | '/config-add' + | '/pitchbooks' | '/config-detail/$kpiId' | '/extractedResult/$pitchBook' | '/extractedResult/$pitchBook/$kpi' @@ -158,6 +177,7 @@ export interface FileRouteTypes { | '/' | '/config' | '/config-add' + | '/pitchbooks' | '/config-detail/$kpiId' | '/extractedResult/$pitchBook' | '/extractedResult_/$pitchBook/$kpi' @@ -168,6 +188,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute ConfigRoute: typeof ConfigRoute ConfigAddRoute: typeof ConfigAddRoute + PitchbooksRoute: typeof PitchbooksRoute ConfigDetailKpiIdRoute: typeof ConfigDetailKpiIdRoute ExtractedResultPitchBookRoute: typeof ExtractedResultPitchBookRoute ExtractedResultPitchBookKpiRoute: typeof ExtractedResultPitchBookKpiRoute @@ -177,6 +198,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ConfigRoute: ConfigRoute, ConfigAddRoute: ConfigAddRoute, + PitchbooksRoute: PitchbooksRoute, ConfigDetailKpiIdRoute: ConfigDetailKpiIdRoute, ExtractedResultPitchBookRoute: ExtractedResultPitchBookRoute, ExtractedResultPitchBookKpiRoute: ExtractedResultPitchBookKpiRoute, @@ -195,6 +217,7 @@ export const routeTree = rootRoute "/", "/config", "/config-add", + "/pitchbooks", "/config-detail/$kpiId", "/extractedResult/$pitchBook", "/extractedResult_/$pitchBook/$kpi" @@ -209,6 +232,9 @@ export const routeTree = rootRoute "/config-add": { "filePath": "config-add.tsx" }, + "/pitchbooks": { + "filePath": "pitchbooks.tsx" + }, "/config-detail/$kpiId": { "filePath": "config-detail.$kpiId.tsx" }, diff --git a/project/frontend/src/routes/config-add.tsx b/project/frontend/src/routes/config-add.tsx index 5d536bd..26bc90a 100644 --- a/project/frontend/src/routes/config-add.tsx +++ b/project/frontend/src/routes/config-add.tsx @@ -7,10 +7,23 @@ import { API_HOST } from "../util/api"; export const Route = createFileRoute("/config-add")({ component: ConfigAddPage, + validateSearch: (search: Record): { from?: string } => { + return { + from: search.from as string | undefined, + }; + }, }); function ConfigAddPage() { const navigate = useNavigate(); + const { from } = Route.useSearch(); + + const handleBack = () => { + navigate({ + to: "/config", + search: from ? { from } : undefined, + }); + }; const handleSave = async (formData: Partial) => { try { @@ -69,7 +82,7 @@ function ConfigAddPage() { mb={4} > - navigate({ to: "/config" })}> + diff --git a/project/frontend/src/routes/config-detail.$kpiId.tsx b/project/frontend/src/routes/config-detail.$kpiId.tsx index 92a647f..04ef98c 100644 --- a/project/frontend/src/routes/config-detail.$kpiId.tsx +++ b/project/frontend/src/routes/config-detail.$kpiId.tsx @@ -10,16 +10,29 @@ import { API_HOST } from "../util/api"; export const Route = createFileRoute("/config-detail/$kpiId")({ component: KPIDetailPage, + validateSearch: (search: Record): { from?: string } => { + return { + from: search.from as string | undefined, + }; + }, }); function KPIDetailPage() { const { kpiId } = Route.useParams(); const navigate = useNavigate(); + const { from } = Route.useSearch(); const [kennzahl, setKennzahl] = useState(null); const [isEditing, setIsEditing] = useState(false); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const handleBack = () => { + navigate({ + to: "/config", + search: from ? { from } : undefined + }); + }; + useEffect(() => { const fetchKennzahl = async () => { try { @@ -139,7 +152,7 @@ function KPIDetailPage() { mb={4} > - navigate({ to: "/config" })}> + @@ -250,7 +263,7 @@ function KPIDetailPage() { mb={4} > - navigate({ to: "/config" })}> + diff --git a/project/frontend/src/routes/config.tsx b/project/frontend/src/routes/config.tsx index eddf16e..ac64c3d 100644 --- a/project/frontend/src/routes/config.tsx +++ b/project/frontend/src/routes/config.tsx @@ -6,13 +6,29 @@ import { ConfigTable } from "../components/ConfigTable"; export const Route = createFileRoute("/config")({ component: ConfigPage, + validateSearch: (search: Record): { from?: string } => { + const from = typeof search.from === "string" ? search.from : undefined; + return { from }; + } }); function ConfigPage() { const navigate = useNavigate(); + const { from } = Route.useSearch(); const handleAddNewKPI = () => { - navigate({ to: "/config-add" }); + navigate({ + to: "/config-add", + search: from ? { from } : undefined + }); + }; + + const handleBack = () => { + if (from === "pitchbooks") { + navigate({ to: "/pitchbooks" }); + } else { + navigate({ to: "/" }); + } }; return ( @@ -34,7 +50,7 @@ function ConfigPage() { px={4} > - navigate({ to: "/" })}> + @@ -53,7 +69,7 @@ function ConfigPage() { - + ); diff --git a/project/frontend/src/routes/extractedResult.$pitchBook.tsx b/project/frontend/src/routes/extractedResult.$pitchBook.tsx index bee005d..d579338 100644 --- a/project/frontend/src/routes/extractedResult.$pitchBook.tsx +++ b/project/frontend/src/routes/extractedResult.$pitchBook.tsx @@ -1,26 +1,42 @@ import ContentPasteIcon from "@mui/icons-material/ContentPaste"; -import { Box, Button, Paper, Typography } from "@mui/material"; +import { Box, Button, Paper, Typography, Snackbar, Alert, IconButton } from "@mui/material"; +import ArrowBackIcon from "@mui/icons-material/ArrowBack"; import { useSuspenseQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useCallback, useState } from "react"; +import { useCallback, useState, useMemo } from "react"; import KennzahlenTable from "../components/KennzahlenTable"; import PDFViewer from "../components/pdfViewer"; import { kpiQueryOptions, settingsQueryOptions } from "../util/query"; +import { redirect } from "@tanstack/react-router"; export const Route = createFileRoute("/extractedResult/$pitchBook")({ component: ExtractedResultsPage, - loader: ({ context: { queryClient }, params: { pitchBook } }) => - Promise.allSettled([ + validateSearch: (search: Record): { from?: string } => { + return { + from: search.from as string | undefined, + }; + }, + loader: async ({ context: { queryClient }, params: { pitchBook } }) => { + const results = await Promise.allSettled([ queryClient.ensureQueryData(kpiQueryOptions(pitchBook)), queryClient.ensureQueryData(settingsQueryOptions()), - ]), + ]); + if (results[0].status === "rejected") { + throw redirect({ + to: "/" + }); + } + return results; + } }); function ExtractedResultsPage() { const { pitchBook } = Route.useParams(); const navigate = useNavigate(); - const status: "green" | "yellow" | "red" = "red"; + const { from } = Route.useSearch(); const [currentPage, setCurrentPage] = useState(1); + const [copied, setCopied] = useState(false); + const [snackbarOpen, setSnackbarOpen] = useState(false); const [focusHighlight, setFocusHighlight] = useState({ page: 5, text: "Langjährige", @@ -31,18 +47,106 @@ function ExtractedResultsPage() { setFocusHighlight({ page, text: entity }); }, []); + const { data: kpi } = useSuspenseQuery(kpiQueryOptions(pitchBook)); + const { data: settings } = useSuspenseQuery(settingsQueryOptions()); + + const status = useMemo(() => { + let hasRedBorders = false; + let hasYellowBorders = false; + + settings + .filter((setting) => setting.active) + .forEach((setting) => { + const values = kpi[setting.name.toUpperCase()] || []; + const hasNoValue = setting.mandatory && (values.length === 0 || values[0]?.entity === ""); + const hasMultipleValues = values.length > 1; + + if (hasNoValue) { + hasRedBorders = true; + } else if (hasMultipleValues) { + hasYellowBorders = true; + } + }); + + if (hasRedBorders) return "red"; + if (hasYellowBorders) return "yellow"; + return "green"; + }, [kpi, settings]); + const statusColor = { red: "#f43131", yellow: "#f6ed48", green: "#3fd942", }[status]; - const { data: kpi } = useSuspenseQuery(kpiQueryOptions(pitchBook)); - const { data: settings } = useSuspenseQuery(settingsQueryOptions()); + const prepareClipboardData = () => { + const activeSettings = settings + .filter(setting => setting.active) + .sort((a, b) => a.position - b.position); + + const values = activeSettings.map(setting => { + const settingData = kpi[setting.name.toUpperCase()]; + if (!settingData || settingData.length === 0) { + return ""; + } + let value = settingData[0]?.entity || ""; + value = value + .replace(/[\r\n]/g, ' ') + .replace(/\s+/g, ' ') + .trim(); + value = value.replace(/\t/g, ' '); + return value; + }); + return values.join('\t'); + }; + + const handleCopyToClipboard = async () => { + try { + const textToCopy = prepareClipboardData(); + + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.write([ + new ClipboardItem({ + 'text/plain': new Blob([textToCopy], { type: 'text/plain' }) + }) + ]); + } else { + const textArea = document.createElement("textarea"); + textArea.value = textToCopy; + textArea.style.position = "fixed"; + textArea.style.left = "-999999px"; + textArea.style.top = "-999999px"; + document.body.appendChild(textArea); + textArea.focus(); + textArea.select(); + document.execCommand('copy'); + textArea.remove(); + } + + setCopied(true); + setSnackbarOpen(true); + setTimeout(() => setCopied(false), 2000); + + } catch (err) { + console.error('Fallback to copy failed'); + } + }; + + const handleCloseSnackbar = () => { + setSnackbarOpen(false); + }; return ( + {from === "overview" && ( + navigate({ to: "/pitchbooks" })} + sx={{ ml: -1 }} + > + + + )} -