diff --git a/frontend/bun.lock b/frontend/bun.lock index 08282cb..c500b23 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -13,6 +13,7 @@ "next": "15.5.14", "react": "19.1.0", "react-dom": "19.1.0", + "react-dropzone": "^15.0.0", "sonner": "^2.0.7", }, "devDependencies": { @@ -315,6 +316,8 @@ "async-function": ["async-function@1.0.0", "", {}, "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA=="], + "attr-accept": ["attr-accept@2.2.5", "", {}, "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ=="], + "available-typed-arrays": ["available-typed-arrays@1.0.7", "", { "dependencies": { "possible-typed-array-names": "^1.0.0" } }, "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ=="], "axe-core": ["axe-core@4.11.1", "", {}, "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A=="], @@ -445,6 +448,8 @@ "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + "file-selector": ["file-selector@2.1.2", "", { "dependencies": { "tslib": "^2.7.0" } }, "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig=="], + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], @@ -695,6 +700,8 @@ "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + "react-dropzone": ["react-dropzone@15.0.0", "", { "dependencies": { "attr-accept": "^2.2.4", "file-selector": "^2.1.0", "prop-types": "^15.8.1" }, "peerDependencies": { "react": ">= 16.8 || 18.0.0" } }, "sha512-lGjYV/EoqEjEWPnmiSvH4v5IoIAwQM2W4Z1C0Q/Pw2xD0eVzKPS359BQTUMum+1fa0kH2nrKjuavmTPOGhpLPg=="], + "react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], "reflect.getprototypeof": ["reflect.getprototypeof@1.0.10", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="], diff --git a/frontend/package.json b/frontend/package.json index c0252fe..3db7ec6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "next": "15.5.14", "react": "19.1.0", "react-dom": "19.1.0", + "react-dropzone": "^15.0.0", "sonner": "^2.0.7" }, "devDependencies": { diff --git a/frontend/src/app/(app)/ai/extract/page.tsx b/frontend/src/app/(app)/ai/extract/page.tsx new file mode 100644 index 0000000..932c04c --- /dev/null +++ b/frontend/src/app/(app)/ai/extract/page.tsx @@ -0,0 +1,142 @@ +"use client"; + +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Brain } from "lucide-react"; +import { api } from "@/lib/api"; +import type { + Case, + ExtractedDeadline, + ExtractionResponse, + PaginatedResponse, +} from "@/lib/types"; +import { ExtractionForm } from "@/components/ai/ExtractionForm"; +import { ExtractionResults } from "@/components/ai/ExtractionResults"; + +export default function AIExtractPage() { + const router = useRouter(); + const [selectedCaseId, setSelectedCaseId] = useState(""); + const [isExtracting, setIsExtracting] = useState(false); + const [isAdopting, setIsAdopting] = useState(false); + const [results, setResults] = useState(null); + + const { data: casesData } = useQuery({ + queryKey: ["cases"], + queryFn: () => api.get>("/api/cases"), + }); + + const cases = casesData?.data ?? []; + + async function handleExtract(file: File | null, text: string) { + setIsExtracting(true); + setResults(null); + + try { + let response: ExtractionResponse; + + if (file) { + const formData = new FormData(); + formData.append("file", file); + response = await api.postFormData( + "/api/ai/extract-deadlines", + formData, + ); + } else { + response = await api.post( + "/api/ai/extract-deadlines", + { text }, + ); + } + + setResults(response.deadlines); + + if (response.count === 0) { + toast.info("Keine Fristen im Dokument gefunden."); + } else { + toast.success(`${response.count} Frist(en) erkannt.`); + } + } catch (err: unknown) { + const message = + err && typeof err === "object" && "error" in err + ? (err as { error: string }).error + : "Analyse fehlgeschlagen"; + toast.error(message); + } finally { + setIsExtracting(false); + } + } + + async function handleAdopt(deadlines: ExtractedDeadline[]) { + if (!selectedCaseId) return; + setIsAdopting(true); + + try { + const promises = deadlines.map((d) => + api.post(`/api/cases/${selectedCaseId}/deadlines`, { + title: d.title, + due_date: d.due_date ?? "", + source: "ai_extraction", + notes: [ + d.rule_reference ? `Rechtsgrundlage: ${d.rule_reference}` : "", + d.source_quote ? `Quelle: "${d.source_quote}"` : "", + `Konfidenz: ${Math.round(d.confidence * 100)}%`, + ] + .filter(Boolean) + .join("\n"), + }), + ); + + await Promise.all(promises); + toast.success( + `${deadlines.length} Frist(en) erfolgreich uebernommen.`, + ); + router.push(`/akten/${selectedCaseId}`); + } catch (err: unknown) { + const message = + err && typeof err === "object" && "error" in err + ? (err as { error: string }).error + : "Uebernahme fehlgeschlagen"; + toast.error(message); + } finally { + setIsAdopting(false); + } + } + + return ( +
+
+ +
+

+ AI Fristenanalyse +

+

+ Fristen automatisch aus Dokumenten extrahieren +

+
+
+ +
+ +
+ + {results !== null && ( +
+ +
+ )} +
+ ); +} diff --git a/frontend/src/components/ai/ExtractionForm.tsx b/frontend/src/components/ai/ExtractionForm.tsx new file mode 100644 index 0000000..79bacf3 --- /dev/null +++ b/frontend/src/components/ai/ExtractionForm.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { useDropzone } from "react-dropzone"; +import { Upload, FileText, X, Loader2 } from "lucide-react"; +import type { Case } from "@/lib/types"; + +interface ExtractionFormProps { + cases: Case[]; + selectedCaseId: string; + onCaseChange: (caseId: string) => void; + onExtract: (file: File | null, text: string) => void; + isLoading: boolean; +} + +export function ExtractionForm({ + cases, + selectedCaseId, + onCaseChange, + onExtract, + isLoading, +}: ExtractionFormProps) { + const [file, setFile] = useState(null); + const [text, setText] = useState(""); + + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles.length > 0) { + setFile(acceptedFiles[0]); + setText(""); + } + }, []); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { "application/pdf": [".pdf"] }, + maxFiles: 1, + disabled: isLoading, + }); + + function removeFile() { + setFile(null); + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (!selectedCaseId || (!file && !text.trim())) return; + onExtract(file, text.trim()); + } + + const hasInput = file !== null || text.trim().length > 0; + + return ( +
+ {/* Case selector */} +
+ + +
+ + {/* PDF dropzone */} +
+ + {file ? ( +
+ +
+

+ {file.name} +

+

+ {(file.size / 1024).toFixed(0)} KB +

+
+ +
+ ) : ( +
+ + +

+ PDF hierher ziehen oder{" "} + durchsuchen +

+

Nur PDF-Dateien

+
+ )} +
+ + {/* Divider */} +
+
+ oder +
+
+ + {/* Text input */} +
+ +