feat: AcroForm-Fill via Claude CLI, Multi-Source, Kanagawa, Docker-Deploy
Komplettes Rework der AI-Studio-Vorlage zu einem produktiven Werkzeug fuer
deutsche AcroForm-Formulare (Reha-Antraege, Arzt-Befundberichte):
- Backend: Express spawnt headless Claude CLI ('claude -p --output-format json'
via stdin-Pipe). Prompt enthaelt die Feldnamen als Ziel-Schema plus die
Arbeitsregeln (Stichwortstil, feste Zeichen-Kaestchen ohne Leerzeichen,
Vordrucke respektieren, keine geratenen Werte, nur medizinisch).
- PDF-Handling: pdfjs-dist statt pdf-lib — pdf-lib scheitert an verschluesselten
Object-Streams in DRV-Formularen. annotationStorage + saveDocument, kein
Flatten. Worker-Patch zur Laufzeit forciert Auto-Size und schwarze Schrift.
- Multi-Source-Upload: beliebig viele PDFs/Bilder + optional Freitext.
- Design: Kanagawa Design System (Preset aus ../kanagawa-design-system),
Tailwind lokal gebaut statt CDN, Dark/Light-Toggle, Progress-Indicator.
- Deployment: Multi-Stage-Dockerfile, docker-compose in matrix_default-Netz,
Claude-Credentials vom Host per Volume. PLAN.md + AGENTS.md (Alex-Schema).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d6cab4aeb5
commit
3c669fb003
28 changed files with 6756 additions and 934 deletions
9
.dockerignore
Normal file
9
.dockerignore
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
node_modules
|
||||
dist
|
||||
.git
|
||||
.vscode
|
||||
test-fixtures
|
||||
*.log
|
||||
.env*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -85,4 +85,11 @@ Thumbs.db
|
|||
|
||||
# Dist folder
|
||||
dist/
|
||||
dist-ssr/
|
||||
dist-ssr/
|
||||
|
||||
# Server temp uploads (falls lokal ausgelagert)
|
||||
/tmp/
|
||||
/uploads/
|
||||
|
||||
# Test-Fixtures (nicht committen — enthalten ggf. echte Formulare)
|
||||
/test-fixtures/
|
||||
|
|
|
|||
200
AGENTS.md
Normal file
200
AGENTS.md
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# AGENTS.md - Rentenversicherer Repository Guidelines
|
||||
|
||||
## Overview
|
||||
|
||||
Rentenversicherer ist ein lokales Werkzeug zum halb-automatischen
|
||||
Ausfüllen von AcroForm-PDFs. Browser-UI + lokaler Node-Server. Der
|
||||
Server ruft die Claude Code CLI (`claude -p`) als Subprozess auf, um
|
||||
Daten aus einem Quelldokument in die AcroForm-Feldnamen eines Ziel-PDFs
|
||||
zu mappen. Das ausgefüllte PDF bleibt editierbar (kein Flatten).
|
||||
|
||||
## Run / Dev Commands
|
||||
|
||||
Requires: Node.js 20+, Claude Code CLI im PATH, gültiges Claude-Code-Login.
|
||||
|
||||
```bash
|
||||
# Install
|
||||
npm install
|
||||
|
||||
# Development — Vite (5173) + Server (3001) parallel
|
||||
npm run dev
|
||||
|
||||
# Production build und starten
|
||||
npm run build
|
||||
npm start
|
||||
|
||||
# Typecheck
|
||||
npx tsc --noEmit
|
||||
```
|
||||
|
||||
## Lint / Test / Validation
|
||||
|
||||
Kein dediziertes Test-Framework. Validation:
|
||||
|
||||
```bash
|
||||
# Typecheck (Client + Server, shared tsconfig)
|
||||
npx tsc --noEmit
|
||||
|
||||
# Claude CLI verfügbar?
|
||||
claude --version
|
||||
|
||||
# Smoke-Test (manuell):
|
||||
# 1. npm run dev
|
||||
# 2. http://localhost:5173
|
||||
# 3. Beispiel-PDF hochladen (G2210-11_Aerztlicher_Befundbericht_Anforderung_WAG.pdf)
|
||||
# 4. Quelldokument hochladen
|
||||
# 5. Download
|
||||
# 6. PDF in Acrobat öffnen → Feld klicken → editieren möglich?
|
||||
```
|
||||
|
||||
## Repo Layout
|
||||
|
||||
```
|
||||
Rentenversicherer/
|
||||
├── App.tsx # React-Einstieg, State-Machine (IDLE/PROCESSING/REVIEW)
|
||||
├── components/
|
||||
│ ├── FileUpload.tsx # Drag & Drop + base64-Encoding
|
||||
│ └── ReviewPanel.tsx # List-View für extrahierte Felder + PDF-Preview
|
||||
├── services/
|
||||
│ ├── api.ts # fetch('/api/process'), multipart/form-data
|
||||
│ └── pdfService.ts # pdfjs-dist: Widgets lesen + saveDocument
|
||||
├── server/
|
||||
│ ├── index.ts # Express + multer, POST /api/process
|
||||
│ └── claudeRunner.ts # spawn('claude', ['-p', ...]), JSON-Parsing
|
||||
├── types.ts # ExtractedField, FormResponse, AppStatus, FileData
|
||||
├── vite.config.ts
|
||||
├── tsconfig.json
|
||||
├── package.json
|
||||
└── PLAN.md
|
||||
```
|
||||
|
||||
## Code Style - TypeScript / React
|
||||
|
||||
**Komponenten:**
|
||||
|
||||
- Functional Components mit Hooks; keine Class-Components.
|
||||
- Props-Interfaces am Anfang der Datei, Name endet auf `Props`.
|
||||
- Dateinamen: `PascalCase.tsx` für Komponenten, `camelCase.ts` für Services.
|
||||
|
||||
**Imports:**
|
||||
|
||||
- React-Imports zuerst, dann 3rd-Party, dann relativ.
|
||||
- Keine `*.tsx`-/`*.ts`-Extensions in Imports.
|
||||
|
||||
**Styling:**
|
||||
|
||||
- Tailwind ausschließlich. Keine CSS-Module, kein inline-style außer für
|
||||
dynamisch berechnete Werte.
|
||||
- Lucide-Icons (`lucide-react`) für alle Icons.
|
||||
|
||||
**Types:**
|
||||
|
||||
- Shared Types in `types.ts`. Keine Duplikate in Komponenten.
|
||||
- `any` vermeiden. `unknown` + Type-Guards bevorzugen.
|
||||
|
||||
## Code Style - Server (Node/TypeScript)
|
||||
|
||||
- Module: ESM (`"type": "module"` in package.json).
|
||||
- `tsx watch` für dev; kein ts-node, kein nodemon.
|
||||
- Async/await überall, kein callback-style.
|
||||
- Fehler aus dem Claude-Subprozess als 502 ans Frontend weiterreichen,
|
||||
mit `message` im JSON-Body — niemals stdout/stderr roh leaken.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
- Komponenten: `PascalCase` (`ReviewPanel`, `FileUpload`).
|
||||
- Services: `camelCase` (`pdfService`, `claudeRunner`, `api`).
|
||||
- Types/Interfaces: `PascalCase` (`ExtractedField`, `FormResponse`).
|
||||
- Enums: `PascalCase` Key, `UPPER_CASE` Values (`AppStatus.IDLE`).
|
||||
- Routes: `kebab-case` (`/api/process`).
|
||||
|
||||
## Security Rules
|
||||
|
||||
1. Kein API-Key im Code und nicht in `.env.local`. Auth läuft über das
|
||||
bestehende Claude-Code-Login.
|
||||
2. Uploads nur in `os.tmpdir()`, pro Request eigenes Subverzeichnis,
|
||||
Cleanup im `finally`-Block.
|
||||
3. `multer` mit `limits.fileSize` (z.B. 20 MB) — keine unbegrenzten
|
||||
Uploads.
|
||||
4. `multer` mit Allowlist für MIME-Types (PDF, PNG, JPEG, WEBP).
|
||||
5. Server lauscht nur auf `127.0.0.1`, nicht auf `0.0.0.0`.
|
||||
6. Dateinamen aus dem Upload niemals als Pfad verwenden — immer
|
||||
UUID/Counter.
|
||||
|
||||
## Architecture Rules (non-negotiable)
|
||||
|
||||
1. **Nie** flatten — bricht das Kernversprechen, dass das PDF
|
||||
im Reader editierbar bleibt. pdfjs' `saveDocument()` flacht nicht,
|
||||
das ist genau der richtige Modus.
|
||||
2. Werte **immer** über `annotationStorage.setValue(widgetId, { value })`
|
||||
setzen, nicht versuchen einzelne Annotation-Objekte zu mutieren.
|
||||
3. Original-PDF-Bytes bleiben strukturell unverändert. Nur Feldwerte
|
||||
werden gesetzt. Keine Seiten-Mutationen, keine neuen Objekte, keine
|
||||
Annotations.
|
||||
4. Wenn Ziel-PDF keine AcroForm-Felder hat: Hard-Fail mit klarer
|
||||
User-Meldung. **Kein** Fallback auf Overlay/Koordinaten-Modus.
|
||||
5. `claude`-Subprozess **immer** mit `--permission-mode bypassPermissions`,
|
||||
sonst blockiert er auf Tool-Prompts.
|
||||
6. Der Server gibt **nur** strukturiertes JSON an das Frontend weiter —
|
||||
Claude-Stdout nie ungeparst durchreichen.
|
||||
7. Temp-Verzeichnisse werden nach jedem Request gelöscht, auch im
|
||||
Fehlerfall.
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Client zeigt alle Fehler im roten Banner auf der Upload-Seite.
|
||||
- Server-Errors: JSON `{ error: string, details?: string }`, HTTP-Status
|
||||
4xx/5xx passend.
|
||||
- Claude-CLI-Exitcode ≠ 0 oder Timeout → 502 + `message: "Claude CLI
|
||||
failed"` + Exit-Code.
|
||||
- Kein Retry auf Server-Seite. Bei Bedarf triggert der User den Request
|
||||
erneut.
|
||||
- Unhandled rejections im Server: `process.on('unhandledRejection', ...)`
|
||||
loggen, nicht crashen.
|
||||
|
||||
## Claude CLI Interface
|
||||
|
||||
Der Aufruf aus `server/claudeRunner.ts`:
|
||||
|
||||
```bash
|
||||
claude \
|
||||
-p "<prompt>" \
|
||||
--output-format json \
|
||||
--permission-mode bypassPermissions
|
||||
```
|
||||
|
||||
Prompt-Grobskizze (Deutsch, weil Formulare deutsch sind):
|
||||
|
||||
```
|
||||
Du füllst ein deutsches Behördenformular aus.
|
||||
|
||||
TARGET-FORM: <tempdir>/form.pdf
|
||||
SOURCE: <tempdir>/source.pdf
|
||||
|
||||
Die Feldnamen im TARGET-Formular sind:
|
||||
- feld1 (PDFTextField)
|
||||
- feld2 (PDFCheckBox)
|
||||
...
|
||||
|
||||
Lies beide Dateien mit dem Read-Tool, extrahiere die Werte aus dem
|
||||
SOURCE und gib NUR ein JSON-Objekt zurück im Format:
|
||||
|
||||
{
|
||||
"summary": "...",
|
||||
"fields": [
|
||||
{ "key": "feldname", "label": "...", "value": "...",
|
||||
"sourceContext": "...",
|
||||
"validation": { "status": "VALID"|"WARNING"|"INVALID",
|
||||
"message": "...", "suggestion": "..." } }
|
||||
]
|
||||
}
|
||||
|
||||
Deutsches Format:
|
||||
- Datum: DD.MM.YYYY
|
||||
- Zahlen: Komma als Dezimaltrenner
|
||||
- Checkbox-Wert: "X" wenn angekreuzt, "" sonst
|
||||
```
|
||||
|
||||
Das JSON landet als String im `result`-Feld des CLI-Output-Wrappers
|
||||
(`--output-format json`). `claudeRunner.ts` zieht es raus und
|
||||
`JSON.parse`t es.
|
||||
290
App.tsx
290
App.tsx
|
|
@ -1,234 +1,282 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import { AppStatus, FileData, FormResponse } from './types';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { AppStatus, type FileData, type FormResponse } from './types';
|
||||
import { FileUpload } from './components/FileUpload';
|
||||
import { SourceInput } from './components/SourceInput';
|
||||
import { ReviewPanel } from './components/ReviewPanel';
|
||||
import { processDocuments } from './services/geminiService';
|
||||
import { getPdfFields, PdfFieldInfo } from './services/pdfService';
|
||||
import { Bot, Sparkles, ArrowRight, FileCheck2, ScanText, Loader2, AlertTriangle, FileText, Check } from 'lucide-react';
|
||||
import { ThemeToggle } from './components/ThemeToggle';
|
||||
import { ProcessingIndicator } from './components/ProcessingIndicator';
|
||||
import { processDocuments } from './services/api';
|
||||
import { getPdfFields, type PdfFieldInfo } from './services/pdfService';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Bot,
|
||||
FileCheck2,
|
||||
ScanText,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [status, setStatus] = useState<AppStatus>(AppStatus.IDLE);
|
||||
const [formFile, setFormFile] = useState<FileData | null>(null);
|
||||
const [sourceFile, setSourceFile] = useState<FileData | null>(null);
|
||||
const [sourceFiles, setSourceFiles] = useState<FileData[]>([]);
|
||||
const [sourceText, setSourceText] = useState('');
|
||||
const [pdfFields, setPdfFields] = useState<PdfFieldInfo[]>([]);
|
||||
const [pdfFieldsChecked, setPdfFieldsChecked] = useState(false);
|
||||
const [responseData, setResponseData] = useState<FormResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const analyzePdf = async () => {
|
||||
if (formFile && formFile.type === 'application/pdf') {
|
||||
const fields = await getPdfFields(formFile.base64);
|
||||
setPdfFields(fields);
|
||||
console.log("Detected PDF fields:", fields);
|
||||
} else {
|
||||
const run = async () => {
|
||||
if (!formFile) {
|
||||
setPdfFields([]);
|
||||
setPdfFieldsChecked(false);
|
||||
return;
|
||||
}
|
||||
if (formFile.type !== 'application/pdf') {
|
||||
setPdfFields([]);
|
||||
setPdfFieldsChecked(true);
|
||||
return;
|
||||
}
|
||||
const fields = await getPdfFields(formFile.base64);
|
||||
setPdfFields(fields);
|
||||
setPdfFieldsChecked(true);
|
||||
};
|
||||
analyzePdf();
|
||||
run();
|
||||
}, [formFile]);
|
||||
|
||||
const noAcroForm = !!formFile && pdfFieldsChecked && pdfFields.length === 0;
|
||||
const hasAnySource = sourceFiles.length > 0 || sourceText.trim().length > 0;
|
||||
|
||||
const handleAnalyze = async () => {
|
||||
if (!formFile || !sourceFile) return;
|
||||
|
||||
if (!formFile || !hasAnySource) return;
|
||||
if (pdfFields.length === 0) {
|
||||
setError(
|
||||
'Das Ziel-PDF enthält keine AcroForm-Felder. Bitte ein Formular mit interaktiven Feldern hochladen.'
|
||||
);
|
||||
setStatus(AppStatus.ERROR);
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus(AppStatus.PROCESSING);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
const data = await processDocuments(formFile, sourceFile, pdfFields);
|
||||
const data = await processDocuments(
|
||||
formFile,
|
||||
sourceFiles,
|
||||
sourceText,
|
||||
pdfFields
|
||||
);
|
||||
setResponseData(data);
|
||||
setStatus(AppStatus.REVIEW);
|
||||
} catch (e: any) {
|
||||
setError(e.message || "Something went wrong during analysis.");
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg || 'Während der Analyse ist etwas schiefgelaufen.');
|
||||
setStatus(AppStatus.ERROR);
|
||||
}
|
||||
};
|
||||
|
||||
const reset = () => {
|
||||
// Cleanup blob URLs to prevent memory leaks
|
||||
if (formFile?.previewUrl && formFile.previewUrl.startsWith('blob:')) {
|
||||
if (formFile?.previewUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(formFile.previewUrl);
|
||||
}
|
||||
if (sourceFile?.previewUrl && sourceFile.previewUrl.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(sourceFile.previewUrl);
|
||||
for (const f of sourceFiles) {
|
||||
if (f.previewUrl?.startsWith('blob:')) URL.revokeObjectURL(f.previewUrl);
|
||||
}
|
||||
|
||||
setStatus(AppStatus.IDLE);
|
||||
setFormFile(null);
|
||||
setSourceFile(null);
|
||||
setSourceFiles([]);
|
||||
setSourceText('');
|
||||
setResponseData(null);
|
||||
setError(null);
|
||||
setPdfFields([]);
|
||||
setPdfFieldsChecked(false);
|
||||
};
|
||||
|
||||
if (status === AppStatus.REVIEW && responseData && formFile && sourceFile) {
|
||||
if (status === AppStatus.REVIEW && responseData && formFile) {
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50">
|
||||
<header className="bg-white border-b border-slate-200 sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center">
|
||||
<div className="min-h-screen bg-kng-bg">
|
||||
<header className="bg-kng-bg-elevated border-b border-kng-border sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 h-16 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="bg-indigo-600 p-1.5 rounded-lg">
|
||||
<Bot className="w-5 h-5 text-white" />
|
||||
<div className="bg-kng-accent p-1.5 rounded-kng-md">
|
||||
<Bot className="w-5 h-5 text-kng-bg" />
|
||||
</div>
|
||||
<span className="font-bold text-lg text-slate-900">AutoForm AI</span>
|
||||
<span className="font-bold text-lg text-kng-text">
|
||||
Rentenversicherer
|
||||
</span>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</header>
|
||||
<ReviewPanel
|
||||
fields={responseData.fields}
|
||||
<ReviewPanel
|
||||
fields={responseData.fields}
|
||||
summary={responseData.summary}
|
||||
formFile={formFile}
|
||||
sourceFile={sourceFile}
|
||||
isFillablePdf={pdfFields.length > 0}
|
||||
onReset={reset}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const analyzeDisabled =
|
||||
!formFile ||
|
||||
!hasAnySource ||
|
||||
!pdfFieldsChecked ||
|
||||
pdfFields.length === 0 ||
|
||||
status === AppStatus.PROCESSING;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-slate-50 flex flex-col">
|
||||
{/* Header */}
|
||||
<header className="bg-white border-b border-slate-200">
|
||||
<div className="min-h-screen bg-kng-bg flex flex-col">
|
||||
<header className="bg-kng-bg-elevated border-b border-kng-border">
|
||||
<div className="max-w-7xl mx-auto px-4 h-20 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="bg-indigo-600 p-2 rounded-xl shadow-lg shadow-indigo-200">
|
||||
<Bot className="w-6 h-6 text-white" />
|
||||
<div className="bg-kng-accent p-2 rounded-kng-lg shadow-kng-md">
|
||||
<Bot className="w-6 h-6 text-kng-bg" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="font-bold text-xl text-slate-900 tracking-tight">AutoForm AI</h1>
|
||||
<p className="text-xs text-slate-500 font-medium">Intelligent Document Processing</p>
|
||||
<h1 className="font-bold text-xl text-kng-text tracking-tight">
|
||||
Rentenversicherer
|
||||
</h1>
|
||||
<p className="text-xs text-kng-text-muted font-medium">
|
||||
AcroForm-PDFs halbautomatisch ausfüllen
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden md:flex items-center space-x-6 text-sm font-medium text-slate-600">
|
||||
<span className="flex items-center"><ScanText className="w-4 h-4 mr-2" />1. Scan</span>
|
||||
<span className="flex items-center"><Sparkles className="w-4 h-4 mr-2" />2. Extract</span>
|
||||
<span className="flex items-center"><FileCheck2 className="w-4 h-4 mr-2" />3. Review</span>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="hidden md:flex items-center space-x-6 text-sm font-medium text-kng-text-secondary">
|
||||
<span className="flex items-center">
|
||||
<ScanText className="w-4 h-4 mr-2" />
|
||||
1. Scan
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Sparkles className="w-4 h-4 mr-2" />
|
||||
2. Extract
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<FileCheck2 className="w-4 h-4 mr-2" />
|
||||
3. Review
|
||||
</span>
|
||||
</div>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 max-w-5xl mx-auto w-full px-4 py-12 flex flex-col justify-center">
|
||||
|
||||
{status === AppStatus.IDLE || status === AppStatus.ERROR ? (
|
||||
<>
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-4xl font-extrabold text-slate-900 mb-4">
|
||||
Fill Forms Automatically with AI
|
||||
<h2 className="text-3xl font-extrabold text-kng-text mb-4">
|
||||
PDF-Formular automatisch ausfüllen
|
||||
</h2>
|
||||
<p className="text-lg text-slate-600 max-w-2xl mx-auto">
|
||||
Upload a blank PDF form and a source document.
|
||||
We'll extract the data and fill the PDF fields for you.
|
||||
<p className="text-lg text-kng-text-secondary max-w-2xl mx-auto">
|
||||
Original-PDF (mit AcroForm-Feldern) und beliebig viele
|
||||
Quelldokumente hochladen. Claude extrahiert die Daten, du
|
||||
prüfst und lädst das ausgefüllte — weiterhin editierbare —
|
||||
PDF runter.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-slate-200 overflow-hidden">
|
||||
{error && (
|
||||
<div className="bg-red-50 border-l-4 border-red-500 p-4 m-4">
|
||||
<div className="bg-kng-bg-elevated rounded-kng-xl shadow-kng-lg border border-kng-border overflow-hidden">
|
||||
{error && (
|
||||
<div className="bg-kng-bg border-l-4 border-kng-error p-4 m-4 rounded-kng-md">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-5 w-5 text-red-400" aria-hidden="true" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm text-red-700">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
<AlertTriangle className="h-5 w-5 text-kng-error flex-shrink-0" />
|
||||
<p className="text-sm text-kng-error ml-3">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{noAcroForm && !error && (
|
||||
<div className="bg-kng-bg border-l-4 border-kng-warning p-4 m-4 rounded-kng-md">
|
||||
<div className="flex">
|
||||
<AlertTriangle className="h-5 w-5 text-kng-warning flex-shrink-0" />
|
||||
<p className="text-sm text-kng-warning ml-3">
|
||||
Das Ziel-PDF enthält keine AcroForm-Felder. Nur Formulare
|
||||
mit interaktiven Feldern werden unterstützt.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid md:grid-cols-2 gap-8 p-8">
|
||||
{/* Step 1: Blank Form */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center font-bold text-slate-600 border border-slate-300">1</div>
|
||||
<div className="w-8 h-8 rounded-kng-full bg-kng-surface flex items-center justify-center font-bold text-kng-text-secondary border border-kng-border">
|
||||
1
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-slate-900 text-lg">Target Form</h3>
|
||||
<h3 className="font-bold text-kng-text text-lg">
|
||||
Ziel-Formular
|
||||
</h3>
|
||||
{pdfFields.length > 0 && (
|
||||
<span className="text-xs text-emerald-600 font-medium bg-emerald-50 px-2 py-0.5 rounded-full">
|
||||
{pdfFields.length} fillable fields detected
|
||||
<span className="text-xs text-kng-success font-medium bg-kng-surface px-2 py-0.5 rounded-kng-full">
|
||||
{pdfFields.length} AcroForm-Felder erkannt
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FileUpload
|
||||
label="Fillable PDF Form"
|
||||
description="The empty PDF you want to fill."
|
||||
accept="application/pdf,image/*"
|
||||
label="Ausfüllbares PDF"
|
||||
description="Original-PDF mit AcroForm-Feldern."
|
||||
accept="application/pdf"
|
||||
onFileSelect={setFormFile}
|
||||
selectedFile={formFile}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2: Source Data */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-full bg-slate-100 flex items-center justify-center font-bold text-slate-600 border border-slate-300">2</div>
|
||||
<h3 className="font-bold text-slate-900 text-lg">Source Document</h3>
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<div className="w-8 h-8 rounded-kng-full bg-kng-surface flex items-center justify-center font-bold text-kng-text-secondary border border-kng-border">
|
||||
2
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-bold text-kng-text text-lg">
|
||||
Quelldokumente
|
||||
</h3>
|
||||
{sourceFiles.length > 0 && (
|
||||
<span className="text-xs text-kng-accent font-medium bg-kng-surface px-2 py-0.5 rounded-kng-full">
|
||||
{sourceFiles.length}{' '}
|
||||
{sourceFiles.length === 1 ? 'Datei' : 'Dateien'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FileUpload
|
||||
label="Source Data"
|
||||
description="Scan, Letter, ID, etc."
|
||||
accept="image/*,application/pdf"
|
||||
onFileSelect={setSourceFile}
|
||||
selectedFile={sourceFile}
|
||||
<SourceInput
|
||||
files={sourceFiles}
|
||||
text={sourceText}
|
||||
onFilesChange={setSourceFiles}
|
||||
onTextChange={setSourceText}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-6 border-t border-slate-100 flex justify-end">
|
||||
<div className="bg-kng-bg p-6 border-t border-kng-border flex justify-end">
|
||||
<button
|
||||
onClick={handleAnalyze}
|
||||
disabled={!formFile || !sourceFile}
|
||||
className={`
|
||||
flex items-center px-6 py-3 rounded-xl font-bold text-white shadow-lg transition-all
|
||||
${(!formFile || !sourceFile)
|
||||
? 'bg-slate-300 cursor-not-allowed shadow-none'
|
||||
: 'bg-indigo-600 hover:bg-indigo-700 hover:shadow-indigo-500/30 transform hover:-translate-y-0.5'
|
||||
}
|
||||
`}
|
||||
disabled={analyzeDisabled}
|
||||
className={`flex items-center px-6 py-3 rounded-kng-lg font-bold shadow-kng-md transition-all ${
|
||||
analyzeDisabled
|
||||
? 'bg-kng-surface text-kng-text-muted cursor-not-allowed shadow-none'
|
||||
: 'bg-kng-accent text-kng-bg hover:brightness-110 transform hover:-translate-y-0.5'
|
||||
}`}
|
||||
>
|
||||
<Sparkles className="w-5 h-5 mr-2" />
|
||||
Analyze & Fill
|
||||
Analysieren & Ausfüllen
|
||||
<ArrowRight className="w-5 h-5 ml-2" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
/* Processing State */
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-indigo-500 blur-xl opacity-20 rounded-full animate-pulse"></div>
|
||||
<div className="relative bg-white p-6 rounded-2xl shadow-xl border border-indigo-100">
|
||||
<Loader2 className="w-12 h-12 text-indigo-600 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mt-8 text-2xl font-bold text-slate-900">Processing Documents...</h3>
|
||||
<p className="mt-2 text-slate-500 max-w-md text-center">
|
||||
AI is reading the source document and mapping data to your PDF form fields.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-4 w-full max-w-2xl">
|
||||
<div className="bg-white p-4 rounded-lg border border-slate-200 shadow-sm flex items-center space-x-3 opacity-50">
|
||||
<ScanText className="w-5 h-5 text-indigo-600" />
|
||||
<span className="text-sm font-medium">Parsing PDF</span>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border border-slate-200 shadow-sm flex items-center space-x-3 opacity-50 animate-pulse delay-75">
|
||||
<FileText className="w-5 h-5 text-indigo-600" />
|
||||
<span className="text-sm font-medium">Extracting Data</span>
|
||||
</div>
|
||||
<div className="bg-white p-4 rounded-lg border border-slate-200 shadow-sm flex items-center space-x-3 opacity-50 animate-pulse delay-150">
|
||||
<Check className="w-5 h-5 text-indigo-600" />
|
||||
<span className="text-sm font-medium">Filling Form</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ProcessingIndicator />
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
|
|
|
|||
42
Dockerfile
Normal file
42
Dockerfile
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
# syntax=docker/dockerfile:1.7
|
||||
# Build-Context: Parent-Verzeichnis (enthält Rentenversicherer/ und
|
||||
# kanagawa-design-system/). Die relativen CSS-Imports bleiben damit gültig.
|
||||
|
||||
# -------- BUILD --------
|
||||
FROM node:20-alpine AS build
|
||||
WORKDIR /workspace
|
||||
COPY kanagawa-design-system ./kanagawa-design-system
|
||||
WORKDIR /workspace/Rentenversicherer
|
||||
COPY Rentenversicherer/package.json Rentenversicherer/package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY Rentenversicherer ./
|
||||
RUN npm run build
|
||||
|
||||
# -------- RUNTIME --------
|
||||
FROM node:20-alpine AS runtime
|
||||
WORKDIR /app
|
||||
|
||||
# node:20-alpine bringt schon den User `node` (UID 1000, GID 1000) mit —
|
||||
# den nehmen wir direkt, damit der Host-Mount /home/openclaw/.claude
|
||||
# (UID 1000) lesbar und beschreibbar ist.
|
||||
RUN apk add --no-cache bash ca-certificates dumb-init \
|
||||
&& npm install -g @anthropic-ai/claude-code@2.1.113 tsx@4.19.0 \
|
||||
&& chown -R node:node /app
|
||||
|
||||
USER node:node
|
||||
|
||||
COPY --chown=node:node Rentenversicherer/package.json Rentenversicherer/package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY --chown=node:node --from=build /workspace/Rentenversicherer/dist ./dist
|
||||
COPY --chown=node:node Rentenversicherer/server ./server
|
||||
COPY --chown=node:node Rentenversicherer/types.ts ./types.ts
|
||||
COPY --chown=node:node Rentenversicherer/tsconfig.json ./tsconfig.json
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3011
|
||||
|
||||
EXPOSE 3011
|
||||
ENTRYPOINT ["dumb-init", "--"]
|
||||
CMD ["tsx", "server/index.ts"]
|
||||
135
PLAN.md
Normal file
135
PLAN.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# Rentenversicherer: AcroForm Auto-Fill
|
||||
|
||||
Purpose
|
||||
|
||||
- Browser-UI nimmt ein Original-PDF (mit AcroForm-Feldern) und ein
|
||||
Quelldokument (Scan, Brief, Ausweis, o.ä.) entgegen.
|
||||
- Ein lokales Node-Backend ruft die Claude Code CLI (`claude -p`) als
|
||||
Subprozess auf, übergibt beide Dateien und die Liste der AcroForm-
|
||||
Feldnamen. Claude liefert strukturiertes JSON mit `{feldname -> wert}`.
|
||||
- User reviewt/korrigiert die Werte im Browser, lädt das ausgefüllte
|
||||
PDF runter.
|
||||
- Das heruntergeladene PDF bleibt AcroForm (keine Flattening-Operation).
|
||||
Im PDF-Reader nachträglich manuell editierbar — als hätte ein Mensch
|
||||
es ausgefüllt.
|
||||
|
||||
## Scope
|
||||
|
||||
- Frontend: React/Vite, Upload + Review-Panel + Live-Preview + Download.
|
||||
- Backend: Minimaler Node-Server, eine Route `POST /api/process`.
|
||||
Spawnt `claude -p --output-format json` mit Temp-Files und einem
|
||||
Prompt, der die Ziel-Feldnamen enthält.
|
||||
- PDF-Handling: `pdfjs-dist` client-side — Widgets sammeln, Werte via
|
||||
`annotationStorage.setValue(id, { value })` setzen, `doc.saveDocument()`
|
||||
schreibt ein PDF ohne Flatten. (pdf-lib wurde getestet, scheitert aber
|
||||
an komprimierten Object-Streams in DRV-Formularen.)
|
||||
- Lokaler Single-User-Betrieb (localhost).
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Gemini, Anthropic SDK, Claude Agent SDK — nur die CLI als Subprozess.
|
||||
- Visual-Overlay-Modus mit Koordinaten für gescannte Flat-PDFs.
|
||||
- Form-Overlay-View (Drag & Drop) im Review-Panel.
|
||||
- Authentifizierung, Multi-User, Persistenz, Datenbank.
|
||||
- PDF-Flattening, Signaturen, Formular-Editor, Seiten-Modifikation.
|
||||
- Cloud-Deployment.
|
||||
|
||||
## Architektur
|
||||
|
||||
```
|
||||
Browser (Vite :5173)
|
||||
│ multipart upload: form.pdf + source.pdf + fieldNames[]
|
||||
▼
|
||||
Node Server (:3001, Express)
|
||||
│ 1. schreibt beide PDFs in Temp-Verzeichnis
|
||||
│ 2. spawnt:
|
||||
│ claude -p --output-format json \
|
||||
│ --permission-mode bypassPermissions \
|
||||
│ "<Prompt mit Feldliste + Pfaden>"
|
||||
│ 3. parst stdout → extrahiert JSON aus result-Feld
|
||||
│ 4. löscht Temp-Verzeichnis
|
||||
▼
|
||||
Browser bekommt { fields: [...], summary: "..." }
|
||||
│ User reviewt/korrigiert
|
||||
│ pdfjs-dist: annotationStorage.setValue + saveDocument
|
||||
│ (kein flatten; AcroForm bleibt erhalten)
|
||||
▼
|
||||
Download
|
||||
```
|
||||
|
||||
## Repo-Layout
|
||||
|
||||
```
|
||||
Rentenversicherer/
|
||||
├── App.tsx # Upload → Process → Review Flow
|
||||
├── components/
|
||||
│ ├── FileUpload.tsx
|
||||
│ └── ReviewPanel.tsx # nur List-View, Overlay-View entfernt
|
||||
├── services/
|
||||
│ ├── api.ts # Frontend-Client für /api/process
|
||||
│ └── pdfService.ts # getPdfFields + createFilledPdf via pdfjs-dist
|
||||
├── server/
|
||||
│ ├── index.ts # Express: static + /api/process
|
||||
│ └── claudeRunner.ts # spawn('claude', ['-p', ...]); JSON-Extraktion
|
||||
├── types.ts
|
||||
├── vite.config.ts # dev-proxy /api → :3001
|
||||
├── package.json # dev-script: vite + server parallel
|
||||
├── tsconfig.json # shared tsconfig für Client + Server
|
||||
├── .gitignore
|
||||
├── PLAN.md
|
||||
├── AGENTS.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
## Workflow (User-Sicht)
|
||||
|
||||
1. Original-PDF hochladen. Client scannt AcroForm-Feldnamen
|
||||
mit `getPdfFields()` und zeigt die Anzahl als Hinweis.
|
||||
2. Quelldokument hochladen (PDF oder Bild).
|
||||
3. "Analyze & Fill" klicken.
|
||||
4. Browser sendet beide Dateien + Feldliste an `POST /api/process`.
|
||||
5. Server schreibt Dateien ins Temp-Verzeichnis, baut Prompt, spawnt
|
||||
Claude CLI, parst JSON, antwortet.
|
||||
6. Review-Panel zeigt `{label, value, validation}`-Paare — User korrigiert,
|
||||
markiert als verified.
|
||||
7. "Download" → `createFilledPdf` setzt die Werte via pdfjs
|
||||
`annotationStorage` und schreibt mit `doc.saveDocument()`.
|
||||
**Kein** Flatten.
|
||||
8. User öffnet das runtergeladene PDF → Felder sind gefüllt und
|
||||
bleiben klick- und editierbar in Acrobat/Reader.
|
||||
|
||||
## Umsetzungsschritte
|
||||
|
||||
1. `package.json`: Server-Deps (`express`, `multer`, `tsx`,
|
||||
`concurrently`, typings) ergänzen, Scripts aktualisieren.
|
||||
2. `server/claudeRunner.ts`: `spawn` mit Windows-kompatiblem Aufruf,
|
||||
stdout buffern, `--output-format json` Result-Wrapper entpacken,
|
||||
inneres JSON parsen.
|
||||
3. `server/index.ts`: Express-App, `multer` für Upload, Temp-Dir pro
|
||||
Request, `finally`-Cleanup.
|
||||
4. `vite.config.ts`: `server.proxy['/api']` nach `http://localhost:3001`.
|
||||
5. `services/api.ts` als Frontend-Client; `services/geminiService.ts`
|
||||
löschen.
|
||||
6. `services/pdfService.ts`: auf `pdfjs-dist` umgestellt.
|
||||
`getPdfFields` iteriert über Pages + Widget-Annotations,
|
||||
`createFilledPdf` nutzt `annotationStorage` + `saveDocument`.
|
||||
7. `components/ReviewPanel.tsx`: Form-Overlay-View entfernen, nur
|
||||
List-View behalten, Koordinaten-Logik raus.
|
||||
8. `App.tsx`: Fehlermeldung, falls Ziel-PDF keine AcroForm-Felder hat
|
||||
(UI blockiert Upload dann).
|
||||
9. Smoke-Test mit
|
||||
`G2210-11_Aerztlicher_Befundbericht_Anforderung_WAG.pdf`:
|
||||
`npm run dev`, ausfüllen, runterladen, in Acrobat öffnen,
|
||||
Feld editieren können.
|
||||
|
||||
## Notes
|
||||
|
||||
- Claude CLI wird über dein bestehendes Login authentifiziert. Kein
|
||||
API-Key im Code oder in Env-Dateien.
|
||||
- `claude -p` braucht `--permission-mode bypassPermissions`, damit das
|
||||
Read-Tool ohne User-Prompt auf die Temp-Files zugreifen darf.
|
||||
- Temp-Verzeichnisse liegen unter `os.tmpdir()` und werden nach jedem
|
||||
Request gelöscht. Kein persistenter Upload-Ordner.
|
||||
- Windows-Pfad-Konvention: Server nutzt `path.join`, arbeitet also
|
||||
plattformneutral. Der Claude-CLI-Aufruf läuft via `shell: true` unter
|
||||
PowerShell (vgl. globale CLAUDE.md).
|
||||
64
README.md
64
README.md
|
|
@ -1,20 +1,58 @@
|
|||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
|
||||
</div>
|
||||
# Rentenversicherer
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
Halbautomatisches Ausfüllen von deutschen AcroForm-PDFs (Reha-Anträge,
|
||||
ärztliche Befundberichte, etc.) mit der Claude Code CLI als Subprozess.
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
- Original-PDF bleibt strukturell unverändert — Felder sind nach dem
|
||||
Ausfüllen im PDF-Reader weiter editierbar (kein Flatten).
|
||||
- Claude zieht die Werte aus beliebig vielen Quelldateien (PDF/Bild) und
|
||||
optionalem freiem Text.
|
||||
- Browser-UI im Kanagawa-Design-Schema, Review-Panel mit Live-Preview.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/drive/1eOJcZ5qjOqVKG1eSXvA6HRcCwqcgcuGO
|
||||
## Voraussetzungen
|
||||
|
||||
## Run Locally
|
||||
- Node.js 20+
|
||||
- Claude Code CLI im `PATH`, gültiges Claude-Login
|
||||
- Windows: Git Bash (`CLAUDE_CODE_GIT_BASH_PATH` wird auf dem
|
||||
Scoop-Standard-Pfad gesetzt — falls du Git anders installiert hast,
|
||||
in der Shell vorher setzen oder `server/claudeRunner.ts:GIT_BASH_FALLBACK`
|
||||
anpassen)
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
## Start
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
- Browser-UI: <http://localhost:5173>
|
||||
- Backend-Health: <http://127.0.0.1:3001/api/health>
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Ziel-Formular** (PDF mit AcroForm-Feldern) in die linke Spalte ziehen.
|
||||
Die App zeigt an, wie viele Felder erkannt wurden.
|
||||
2. **Quelldokumente** rechts hochladen — beliebig viele PDFs/Bilder, plus
|
||||
optional ein Freitext-Feld.
|
||||
3. **„Analysieren & Ausfüllen"** — kann 30–120 Sekunden dauern, je nach
|
||||
Umfang. Claude-CLI läuft headless im Backend.
|
||||
4. **Review-Panel** — Werte prüfen, bei Bedarf korrigieren, mit Haken
|
||||
bestätigen.
|
||||
5. **„PDF runterladen"** — das Original-PDF mit gesetzten Feldern.
|
||||
Im Acrobat/Reader können die Felder weiter bearbeitet werden.
|
||||
|
||||
## Arbeitsregeln für die Verarbeitung
|
||||
|
||||
Im Prompt an Claude fest eingebaut (siehe `server/claudeRunner.ts`):
|
||||
|
||||
- Stichwortstil, kein Gutachten
|
||||
- Feste Zeichen-Kästchen (VSNR, IBAN, BIC, IK) ohne Leerzeichen
|
||||
- Vordrucke respektieren (kein doppeltes "DE", kein "€")
|
||||
- Nur medizinisch; Sozialbereich bleibt leer
|
||||
- Keine geratenen Werte — bei Unsicherheit leer + WARNING
|
||||
- PDF nie flatten
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- [`PLAN.md`](./PLAN.md) — Zweck, Scope, Architektur
|
||||
- [`AGENTS.md`](./AGENTS.md) — Commands, Code-Style, Architektur-Regeln
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import { Upload, FileText, CheckCircle, X, Image as ImageIcon } from 'lucide-react';
|
||||
import { FileData } from '../types';
|
||||
import { Upload, FileText, CheckCircle, X } from 'lucide-react';
|
||||
import type { FileData } from '../types';
|
||||
|
||||
interface FileUploadProps {
|
||||
label: string;
|
||||
|
|
@ -15,26 +15,22 @@ export const FileUpload: React.FC<FileUploadProps> = ({
|
|||
description,
|
||||
accept,
|
||||
onFileSelect,
|
||||
selectedFile
|
||||
selectedFile,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const processFile = (file: File) => {
|
||||
// Create a robust Blob URL for previewing (works better than Base64 for PDFs)
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64String = reader.result as string;
|
||||
// Remove data URL prefix for API usage
|
||||
const base64Content = base64String.split(',')[1];
|
||||
|
||||
onFileSelect({
|
||||
file,
|
||||
previewUrl: objectUrl,
|
||||
base64: base64Content,
|
||||
type: file.type as any
|
||||
type: file.type as FileData['type'],
|
||||
});
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
|
|
@ -62,18 +58,24 @@ export const FileUpload: React.FC<FileUploadProps> = ({
|
|||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<label className="block text-sm font-medium text-slate-700 mb-2">{label}</label>
|
||||
|
||||
<label className="block text-sm font-medium text-kng-text-secondary mb-2">
|
||||
{label}
|
||||
</label>
|
||||
|
||||
{!selectedFile ? (
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => { e.preventDefault(); setIsDragging(true); }}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={handleDrop}
|
||||
className={`
|
||||
relative border-2 border-dashed rounded-xl p-8 text-center cursor-pointer transition-all duration-200
|
||||
${isDragging ? 'border-indigo-500 bg-indigo-50' : 'border-slate-300 hover:border-slate-400 hover:bg-slate-50'}
|
||||
`}
|
||||
className={`relative border-2 border-dashed rounded-kng-lg p-8 text-center cursor-pointer transition-all duration-200 ${
|
||||
isDragging
|
||||
? 'border-kng-accent bg-kng-surface'
|
||||
: 'border-kng-border hover:border-kng-accent hover:bg-kng-surface'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
|
|
@ -83,37 +85,43 @@ export const FileUpload: React.FC<FileUploadProps> = ({
|
|||
onChange={handleChange}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center space-y-3">
|
||||
<div className="p-3 bg-white rounded-full shadow-sm">
|
||||
<Upload className="w-6 h-6 text-indigo-600" />
|
||||
<div className="p-3 bg-kng-bg-elevated rounded-kng-full shadow-kng-sm">
|
||||
<Upload className="w-6 h-6 text-kng-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-900">Click to upload or drag and drop</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{description}</p>
|
||||
<p className="text-sm font-semibold text-kng-text">
|
||||
Klick oder Drag & Drop
|
||||
</p>
|
||||
<p className="text-xs text-kng-text-muted mt-1">{description}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative border border-indigo-100 bg-indigo-50/50 rounded-xl p-4 flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-white rounded-lg shadow-sm flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
<div className="relative border border-kng-border bg-kng-surface rounded-kng-lg p-4 flex items-center space-x-4">
|
||||
<div className="w-12 h-12 bg-kng-bg-elevated rounded-kng-md shadow-kng-sm flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
{selectedFile.type === 'application/pdf' ? (
|
||||
<FileText className="w-6 h-6 text-indigo-600" />
|
||||
<FileText className="w-6 h-6 text-kng-accent" />
|
||||
) : (
|
||||
<img src={selectedFile.previewUrl!} alt="Preview" className="w-full h-full object-cover" />
|
||||
<img
|
||||
src={selectedFile.previewUrl!}
|
||||
alt="Preview"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-900 truncate">
|
||||
<p className="text-sm font-medium text-kng-text truncate">
|
||||
{selectedFile.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
<p className="text-xs text-kng-text-muted">
|
||||
{(selectedFile.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<CheckCircle className="w-5 h-5 text-emerald-500" />
|
||||
<button
|
||||
<CheckCircle className="w-5 h-5 text-kng-success" />
|
||||
<button
|
||||
onClick={clearFile}
|
||||
className="p-1 hover:bg-white rounded-full transition-colors text-slate-400 hover:text-red-500"
|
||||
className="p-1 hover:bg-kng-surface-hover rounded-kng-full transition-colors text-kng-text-muted hover:text-kng-error"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
|
|
|||
80
components/ProcessingIndicator.tsx
Normal file
80
components/ProcessingIndicator.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Check, FileText, Loader2, ScanText, Sparkles } from 'lucide-react';
|
||||
|
||||
const STAGES = [
|
||||
{ icon: ScanText, label: 'PDF parsen' },
|
||||
{ icon: FileText, label: 'Quelldateien lesen' },
|
||||
{ icon: Sparkles, label: 'Daten extrahieren' },
|
||||
{ icon: Check, label: 'Felder mappen' },
|
||||
];
|
||||
|
||||
export const ProcessingIndicator: React.FC = () => {
|
||||
const [elapsed, setElapsed] = useState(0);
|
||||
const [activeStage, setActiveStage] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const started = Date.now();
|
||||
const id = setInterval(() => {
|
||||
const s = Math.floor((Date.now() - started) / 1000);
|
||||
setElapsed(s);
|
||||
// Rotierende Stage alle 6 Sekunden — nur Deko, kein echter Progress.
|
||||
setActiveStage(Math.min(Math.floor(s / 6), STAGES.length - 1));
|
||||
}, 500);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const minutes = Math.floor(elapsed / 60);
|
||||
const seconds = elapsed % 60;
|
||||
const elapsedLabel =
|
||||
minutes > 0
|
||||
? `${minutes}:${seconds.toString().padStart(2, '0')} min`
|
||||
: `${seconds}s`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 bg-kng-accent blur-xl opacity-20 rounded-kng-full animate-pulse" />
|
||||
<div className="relative bg-kng-bg-elevated p-6 rounded-kng-xl shadow-kng-lg border border-kng-border">
|
||||
<Loader2 className="w-12 h-12 text-kng-accent animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mt-8 text-2xl font-bold text-kng-text">
|
||||
Claude verarbeitet die Dokumente …
|
||||
</h3>
|
||||
<p className="mt-2 text-kng-text-secondary max-w-md text-center">
|
||||
Die Claude-CLI liest alle Quellen und mappt die Daten auf die
|
||||
AcroForm-Feldnamen.
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-kng-text-muted font-mono">
|
||||
Läuft seit {elapsedLabel}
|
||||
</p>
|
||||
|
||||
<div className="mt-8 grid grid-cols-1 md:grid-cols-4 gap-3 w-full max-w-3xl">
|
||||
{STAGES.map((stage, idx) => {
|
||||
const Icon = stage.icon;
|
||||
const isActive = idx === activeStage;
|
||||
const isDone = idx < activeStage;
|
||||
return (
|
||||
<div
|
||||
key={stage.label}
|
||||
className={`p-3 rounded-kng-md border flex items-center space-x-2 shadow-kng-sm transition-all duration-300 ${
|
||||
isActive
|
||||
? 'border-kng-accent bg-kng-surface text-kng-text'
|
||||
: isDone
|
||||
? 'border-kng-success/50 bg-kng-bg-elevated text-kng-success'
|
||||
: 'border-kng-border bg-kng-bg-elevated text-kng-text-muted opacity-60'
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`w-4 h-4 flex-shrink-0 ${
|
||||
isActive ? 'text-kng-accent animate-pulse' : ''
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs font-medium truncate">{stage.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,552 +1,354 @@
|
|||
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
||||
import { ExtractedField, FileData } from '../types';
|
||||
import { Check, Edit2, Download, RefreshCw, FileText, AlertTriangle, XCircle, ArrowRight, PenTool, CheckCircle2, Circle, LayoutTemplate, List, Move, ExternalLink } from 'lucide-react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import type { ExtractedField, FileData } from '../types';
|
||||
import {
|
||||
AlertTriangle,
|
||||
ArrowRight,
|
||||
Check,
|
||||
Download,
|
||||
ExternalLink,
|
||||
FileText,
|
||||
RefreshCw,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { createFilledPdf } from '../services/pdfService';
|
||||
import { jsPDF } from "jspdf";
|
||||
|
||||
interface ReviewPanelProps {
|
||||
fields: ExtractedField[];
|
||||
formFile: FileData;
|
||||
sourceFile: FileData;
|
||||
summary: string;
|
||||
isFillablePdf: boolean;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
export const ReviewPanel: React.FC<ReviewPanelProps> = ({
|
||||
fields: initialFields,
|
||||
formFile,
|
||||
sourceFile,
|
||||
type FilterMode = 'ALL' | 'ATTENTION';
|
||||
|
||||
export const ReviewPanel: React.FC<ReviewPanelProps> = ({
|
||||
fields: initialFields,
|
||||
formFile,
|
||||
summary,
|
||||
isFillablePdf,
|
||||
onReset
|
||||
onReset,
|
||||
}) => {
|
||||
const [fields, setFields] = useState(initialFields);
|
||||
const [fields, setFields] = useState<ExtractedField[]>(initialFields);
|
||||
const [activeField, setActiveField] = useState<number | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [filterMode, setFilterMode] = useState<'ALL' | 'ATTENTION'>('ALL');
|
||||
const [viewMode, setViewMode] = useState<'LIST' | 'FORM'>('LIST');
|
||||
const [filterMode, setFilterMode] = useState<FilterMode>('ALL');
|
||||
|
||||
// Dragging state
|
||||
const [draggingField, setDraggingField] = useState<number | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Derived state for progress
|
||||
const verifiedCount = fields.filter(f => f.isVerified).length;
|
||||
const verifiedCount = fields.filter((f) => f.isVerified).length;
|
||||
const totalCount = fields.length;
|
||||
const progressPercent = Math.round((verifiedCount / totalCount) * 100);
|
||||
|
||||
const fieldsRequiresAttention = useMemo(() =>
|
||||
fields.filter(f => f.validation?.status !== 'VALID'),
|
||||
[fields]);
|
||||
const progressPercent =
|
||||
totalCount === 0 ? 0 : Math.round((verifiedCount / totalCount) * 100);
|
||||
|
||||
const fieldsRequiresAttention = useMemo(
|
||||
() => fields.filter((f) => f.validation?.status !== 'VALID'),
|
||||
[fields]
|
||||
);
|
||||
|
||||
// Generate preview for PDF download
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let timeoutId: any;
|
||||
|
||||
const generatePreview = async () => {
|
||||
// If it's a PDF, we try to show the filled version
|
||||
if (formFile.type === 'application/pdf') {
|
||||
try {
|
||||
const filledPdfBytes = await createFilledPdf(formFile.base64, fields, isFillablePdf);
|
||||
if (!active) return;
|
||||
|
||||
const blob = new Blob([filledPdfBytes], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
setPreviewUrl(prev => {
|
||||
if (prev && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return url;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to generate PDF preview", e);
|
||||
// Fallback to original if generation fails
|
||||
setPreviewUrl(formFile.previewUrl);
|
||||
}
|
||||
} else {
|
||||
// For images, just show the original
|
||||
setPreviewUrl(formFile.previewUrl);
|
||||
const handle = setTimeout(async () => {
|
||||
try {
|
||||
const bytes = await createFilledPdf(formFile.base64, fields);
|
||||
if (!active) return;
|
||||
const blob = new Blob([bytes as BlobPart], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return url;
|
||||
});
|
||||
} catch (e) {
|
||||
console.error('[ReviewPanel] preview failed', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Debounce to avoid excessive PDF generation while typing
|
||||
timeoutId = setTimeout(generatePreview, 1000);
|
||||
}, 600);
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
clearTimeout(timeoutId);
|
||||
clearTimeout(handle);
|
||||
};
|
||||
}, [fields, isFillablePdf, formFile]);
|
||||
}, [fields, formFile]);
|
||||
|
||||
// Cleanup blob URL on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
setPreviewUrl(prev => {
|
||||
if (prev && prev.startsWith('blob:') && prev !== formFile.previewUrl) {
|
||||
URL.revokeObjectURL(prev);
|
||||
}
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev && prev.startsWith('blob:')) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleUpdate = (index: number, newValue: string) => {
|
||||
const newFields = [...fields];
|
||||
newFields[index] = {
|
||||
...newFields[index],
|
||||
value: newValue,
|
||||
// Auto-verify when manually edited
|
||||
isVerified: true,
|
||||
validation: { ...newFields[index].validation!, status: 'VALID', message: 'Manually verified' }
|
||||
};
|
||||
setFields(newFields);
|
||||
};
|
||||
|
||||
const handleCoordinateUpdate = (index: number, x: number, y: number) => {
|
||||
const newFields = [...fields];
|
||||
if (newFields[index].coordinates) {
|
||||
newFields[index] = {
|
||||
...newFields[index],
|
||||
coordinates: { ...newFields[index].coordinates!, x, y },
|
||||
isVerified: true // Moving it implies verification
|
||||
setFields((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = {
|
||||
...next[index],
|
||||
value: newValue,
|
||||
isVerified: true,
|
||||
validation: {
|
||||
...(next[index].validation ?? { status: 'VALID' }),
|
||||
status: 'VALID',
|
||||
message: 'Manuell bestätigt',
|
||||
},
|
||||
};
|
||||
setFields(newFields);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const toggleVerify = (index: number) => {
|
||||
const newFields = [...fields];
|
||||
newFields[index] = {
|
||||
...newFields[index],
|
||||
isVerified: !newFields[index].isVerified
|
||||
};
|
||||
setFields(newFields);
|
||||
setFields((prev) => {
|
||||
const next = [...prev];
|
||||
next[index] = { ...next[index], isVerified: !next[index].isVerified };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const applySuggestion = (index: number) => {
|
||||
const field = fields[index];
|
||||
if (field.validation?.suggestion) {
|
||||
handleUpdate(index, field.validation.suggestion);
|
||||
}
|
||||
const suggestion = fields[index].validation?.suggestion;
|
||||
if (suggestion) handleUpdate(index, suggestion);
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (formFile.type === 'application/pdf' && previewUrl) {
|
||||
const a = document.createElement('a');
|
||||
a.href = previewUrl;
|
||||
a.download = `filled_${formFile.file.name}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
} else {
|
||||
const doc = new jsPDF();
|
||||
doc.text("Extracted Data", 20, 20);
|
||||
let y = 40;
|
||||
fields.forEach(f => {
|
||||
doc.text(`${f.label}: ${f.value}`, 20, y);
|
||||
y += 10;
|
||||
});
|
||||
doc.save("data_report.pdf");
|
||||
}
|
||||
const bytes = await createFilledPdf(formFile.base64, fields);
|
||||
const blob = new Blob([bytes as BlobPart], { type: 'application/pdf' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `filled_${formFile.file.name}`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
};
|
||||
|
||||
// Drag handlers
|
||||
const handleDragStart = (e: React.MouseEvent, index: number) => {
|
||||
e.stopPropagation();
|
||||
setDraggingField(index);
|
||||
setActiveField(index);
|
||||
};
|
||||
|
||||
const handleMouseMove = (e: React.MouseEvent) => {
|
||||
if (draggingField !== null && containerRef.current) {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const relativeX = e.clientX - rect.left;
|
||||
const relativeY = e.clientY - rect.top;
|
||||
|
||||
// Convert pixels to 0-1000 scale
|
||||
const scaleX = (relativeX / rect.width) * 1000;
|
||||
const scaleY = (relativeY / rect.height) * 1000;
|
||||
|
||||
// Clamp values
|
||||
const clampedX = Math.max(0, Math.min(1000, scaleX));
|
||||
const clampedY = Math.max(0, Math.min(1000, scaleY));
|
||||
|
||||
handleCoordinateUpdate(draggingField, clampedX, clampedY);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setDraggingField(null);
|
||||
};
|
||||
|
||||
// Sort: Unverified/Issues first, then verified
|
||||
const displayedFields = fields.map((f, i) => ({ ...f, originalIndex: i }))
|
||||
const displayedFields = fields
|
||||
.map((f, i) => ({ ...f, originalIndex: i }))
|
||||
.sort((a, b) => {
|
||||
// Priority 1: Attention needed
|
||||
const aNeedsAttn = a.validation?.status !== 'VALID';
|
||||
const bNeedsAttn = b.validation?.status !== 'VALID';
|
||||
if (aNeedsAttn && !bNeedsAttn) return -1;
|
||||
if (!aNeedsAttn && bNeedsAttn) return 1;
|
||||
|
||||
// Priority 2: Unverified
|
||||
const aAttn = a.validation?.status !== 'VALID';
|
||||
const bAttn = b.validation?.status !== 'VALID';
|
||||
if (aAttn && !bAttn) return -1;
|
||||
if (!aAttn && bAttn) return 1;
|
||||
if (!a.isVerified && b.isVerified) return -1;
|
||||
if (a.isVerified && !b.isVerified) return 1;
|
||||
|
||||
return a.originalIndex - b.originalIndex;
|
||||
})
|
||||
.filter(f => filterMode === 'ALL' || (f.validation?.status !== 'VALID' && !f.isVerified));
|
||||
.filter(
|
||||
(f) =>
|
||||
filterMode === 'ALL' ||
|
||||
(f.validation?.status !== 'VALID' && !f.isVerified)
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="max-w-7xl mx-auto px-4 py-8 h-[calc(100vh-80px)] flex flex-col">
|
||||
{/* Header Section */}
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center mb-6 gap-4 flex-shrink-0">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-slate-900">Review & Verify</h2>
|
||||
<p className="text-slate-500 text-sm mt-1">{summary}</p>
|
||||
<h2 className="text-2xl font-bold text-kng-text">Review & Verify</h2>
|
||||
<p className="text-kng-text-muted text-sm mt-1">{summary}</p>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-4 w-full md:w-auto">
|
||||
{/* View Toggle */}
|
||||
<div className="flex bg-slate-100 p-1 rounded-lg border border-slate-200">
|
||||
<button
|
||||
onClick={() => setViewMode('LIST')}
|
||||
className={`p-1.5 rounded-md flex items-center space-x-2 text-sm font-medium transition-all ${viewMode === 'LIST' ? 'bg-white shadow text-slate-900' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
title="List View"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">List</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('FORM')}
|
||||
className={`p-1.5 rounded-md flex items-center space-x-2 text-sm font-medium transition-all ${viewMode === 'FORM' ? 'bg-white shadow text-slate-900' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
title="Form Overlay View"
|
||||
>
|
||||
<LayoutTemplate className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">Form View</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="h-6 w-px bg-slate-300 mx-2 hidden md:block"></div>
|
||||
|
||||
{/* Verification Progress */}
|
||||
<div className="hidden md:flex flex-col items-end mr-2">
|
||||
<span className="text-xs font-semibold text-slate-600 mb-1">
|
||||
{verifiedCount} / {totalCount} Verified
|
||||
<span className="text-xs font-semibold text-kng-text-secondary mb-1">
|
||||
{verifiedCount} / {totalCount} verifiziert
|
||||
</span>
|
||||
<div className="w-32 h-2 bg-slate-200 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-emerald-500 transition-all duration-500 ease-out"
|
||||
<div className="w-32 h-2 bg-kng-surface rounded-kng-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-kng-success transition-all duration-500 ease-out"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
<button
|
||||
onClick={onReset}
|
||||
className="flex items-center px-4 py-2 text-sm font-medium text-slate-700 bg-white border border-slate-300 rounded-lg hover:bg-slate-50 transition-colors"
|
||||
className="flex items-center px-4 py-2 text-sm font-medium text-kng-text bg-kng-surface border border-kng-border rounded-kng-md hover:bg-kng-surface-hover transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Start Over
|
||||
Neu starten
|
||||
</button>
|
||||
|
||||
<button
|
||||
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className={`flex items-center px-4 py-2 text-sm font-medium text-white rounded-lg transition-colors shadow-sm bg-indigo-600 hover:bg-indigo-700`}
|
||||
className="flex items-center px-4 py-2 text-sm font-bold text-kng-bg rounded-kng-md transition-all shadow-kng-md bg-kng-accent hover:brightness-110"
|
||||
>
|
||||
<Download className="w-4 h-4 mr-2" />
|
||||
Download PDF
|
||||
PDF runterladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{viewMode === 'LIST' ? (
|
||||
/* ================= LIST VIEW ================= */
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
|
||||
{/* Left Column: Preview */}
|
||||
<div className="bg-slate-900 rounded-xl overflow-hidden shadow-lg flex flex-col">
|
||||
<div className="p-3 bg-slate-800 border-b border-slate-700 flex justify-between items-center">
|
||||
<div className="flex items-center space-x-2">
|
||||
<span className="text-xs font-medium text-slate-300 uppercase tracking-wider">PDF Preview (Live)</span>
|
||||
</div>
|
||||
<span className="text-xs text-slate-400">{formFile.file.name}</span>
|
||||
</div>
|
||||
<div className="flex-1 bg-slate-900 relative">
|
||||
{previewUrl ? (
|
||||
formFile.type === 'application/pdf' ? (
|
||||
<>
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
title="Form PDF Preview"
|
||||
className="w-full h-full border-none"
|
||||
/>
|
||||
{/* Fallback open button */}
|
||||
<div className="absolute bottom-4 right-4 opacity-50 hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center bg-black/70 text-white text-xs px-3 py-1.5 rounded-full hover:bg-black"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
Open in new tab
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="w-full h-full overflow-auto flex items-center justify-center p-4">
|
||||
<img
|
||||
src={previewUrl}
|
||||
alt="Form Document"
|
||||
className="max-w-full shadow-lg border border-slate-700"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-slate-500">
|
||||
<FileText className="w-16 h-16 mb-4 opacity-50" />
|
||||
<p>Generating preview...</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
|
||||
{/* Preview */}
|
||||
<div className="bg-kng-bg-elevated rounded-kng-xl overflow-hidden shadow-kng-lg flex flex-col border border-kng-border">
|
||||
<div className="p-3 bg-kng-surface border-b border-kng-border flex justify-between items-center">
|
||||
<span className="text-xs font-medium text-kng-text-secondary uppercase tracking-wider">
|
||||
PDF Preview (live)
|
||||
</span>
|
||||
<span className="text-xs text-kng-text-muted">
|
||||
{formFile.file.name}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Right Column: Verification List */}
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-200 flex flex-col overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-100 bg-slate-50/50">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="font-semibold text-slate-800">Field Verification</h3>
|
||||
<div className="flex space-x-2 text-xs">
|
||||
<button
|
||||
onClick={() => setFilterMode('ALL')}
|
||||
className={`px-3 py-1 rounded-full border transition-colors ${filterMode === 'ALL' ? 'bg-slate-800 text-white border-slate-800' : 'bg-white text-slate-600 border-slate-200 hover:bg-slate-50'}`}
|
||||
<div className="flex-1 bg-kng-bg relative">
|
||||
{previewUrl ? (
|
||||
<>
|
||||
<iframe
|
||||
src={previewUrl}
|
||||
title="Form PDF Preview"
|
||||
className="w-full h-full border-none bg-white"
|
||||
/>
|
||||
<div className="absolute bottom-4 right-4 opacity-60 hover:opacity-100 transition-opacity">
|
||||
<a
|
||||
href={previewUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="flex items-center bg-kng-bg/80 text-kng-text text-xs px-3 py-1.5 rounded-kng-full hover:bg-kng-bg border border-kng-border"
|
||||
>
|
||||
All ({totalCount})
|
||||
</button>
|
||||
{fieldsRequiresAttention.length > 0 && (
|
||||
<button
|
||||
onClick={() => setFilterMode('ATTENTION')}
|
||||
className={`px-3 py-1 rounded-full border transition-colors flex items-center ${filterMode === 'ATTENTION' ? 'bg-amber-100 text-amber-800 border-amber-200' : 'bg-white text-amber-600 border-slate-200 hover:bg-amber-50'}`}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Needs Review ({fieldsRequiresAttention.length})
|
||||
</button>
|
||||
)}
|
||||
<ExternalLink className="w-3 h-3 mr-1" />
|
||||
In neuem Tab öffnen
|
||||
</a>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center h-full text-kng-text-muted">
|
||||
<FileText className="w-16 h-16 mb-4 opacity-50" />
|
||||
<p>Preview wird erstellt …</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Field List */}
|
||||
<div className="bg-kng-bg-elevated rounded-kng-xl shadow-kng-md border border-kng-border flex flex-col overflow-hidden">
|
||||
<div className="p-4 border-b border-kng-border bg-kng-surface">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="font-semibold text-kng-text">Feld-Verifikation</h3>
|
||||
<div className="flex space-x-2 text-xs">
|
||||
<button
|
||||
onClick={() => setFilterMode('ALL')}
|
||||
className={`px-3 py-1 rounded-kng-full border transition-colors ${
|
||||
filterMode === 'ALL'
|
||||
? 'bg-kng-accent text-kng-bg border-kng-accent'
|
||||
: 'bg-kng-bg-elevated text-kng-text-secondary border-kng-border hover:bg-kng-surface-hover'
|
||||
}`}
|
||||
>
|
||||
Alle ({totalCount})
|
||||
</button>
|
||||
{fieldsRequiresAttention.length > 0 && (
|
||||
<button
|
||||
onClick={() => setFilterMode('ATTENTION')}
|
||||
className={`px-3 py-1 rounded-kng-full border transition-colors flex items-center ${
|
||||
filterMode === 'ATTENTION'
|
||||
? 'bg-kng-warning text-kng-bg border-kng-warning'
|
||||
: 'bg-kng-bg-elevated text-kng-warning border-kng-border hover:bg-kng-surface-hover'
|
||||
}`}
|
||||
>
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
Prüfen ({fieldsRequiresAttention.length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-slate-50/30">
|
||||
{displayedFields.map((field) => {
|
||||
const idx = field.originalIndex;
|
||||
const status = field.validation?.status || 'VALID';
|
||||
const isVerified = field.isVerified;
|
||||
|
||||
let statusBorder = isVerified ? "border-emerald-200" : "border-slate-200";
|
||||
let statusBg = isVerified ? "bg-emerald-50/30" : "bg-white";
|
||||
</div>
|
||||
|
||||
if (!isVerified) {
|
||||
if (status === 'INVALID') {
|
||||
statusBorder = "border-red-200";
|
||||
statusBg = "bg-red-50/50";
|
||||
} else if (status === 'WARNING') {
|
||||
statusBorder = "border-amber-200";
|
||||
statusBg = "bg-amber-50/50";
|
||||
}
|
||||
<div className="flex-1 overflow-y-auto p-4 space-y-3 bg-kng-bg">
|
||||
{displayedFields.map((field) => {
|
||||
const idx = field.originalIndex;
|
||||
const status = field.validation?.status ?? 'VALID';
|
||||
const isVerified = !!field.isVerified;
|
||||
|
||||
let statusBorder = isVerified
|
||||
? 'border-kng-success'
|
||||
: 'border-kng-border';
|
||||
let statusBg = 'bg-kng-bg-elevated';
|
||||
|
||||
if (!isVerified) {
|
||||
if (status === 'INVALID') {
|
||||
statusBorder = 'border-kng-error';
|
||||
} else if (status === 'WARNING') {
|
||||
statusBorder = 'border-kng-warning';
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`relative group rounded-lg border transition-all duration-200 p-3 shadow-sm ${activeField === idx ? 'ring-1 ring-indigo-500 border-indigo-500 shadow-md z-10' : statusBorder} ${statusBg}`}
|
||||
onFocus={() => setActiveField(idx)}
|
||||
onBlur={() => setActiveField(null)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
onClick={() => toggleVerify(idx)}
|
||||
className={`mt-1 flex-shrink-0 w-5 h-5 rounded border flex items-center justify-center transition-colors ${isVerified ? 'bg-emerald-500 border-emerald-500 text-white' : 'bg-white border-slate-300 text-transparent hover:border-emerald-400'}`}
|
||||
title={isVerified ? "Mark as unverified" : "Mark as verified"}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" strokeWidth={3} />
|
||||
</button>
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`relative group rounded-kng-md border transition-all duration-200 p-3 shadow-kng-sm ${
|
||||
activeField === idx
|
||||
? 'ring-1 ring-kng-accent border-kng-accent shadow-kng-md z-10'
|
||||
: statusBorder
|
||||
} ${statusBg}`}
|
||||
onFocus={() => setActiveField(idx)}
|
||||
onBlur={() => setActiveField(null)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<button
|
||||
onClick={() => toggleVerify(idx)}
|
||||
className={`mt-1 flex-shrink-0 w-5 h-5 rounded-kng-sm border flex items-center justify-center transition-colors ${
|
||||
isVerified
|
||||
? 'bg-kng-success border-kng-success text-kng-bg'
|
||||
: 'bg-kng-bg border-kng-border text-transparent hover:border-kng-success'
|
||||
}`}
|
||||
title={
|
||||
isVerified
|
||||
? 'Als unbestätigt markieren'
|
||||
: 'Als bestätigt markieren'
|
||||
}
|
||||
>
|
||||
<Check className="w-3.5 h-3.5" strokeWidth={3} />
|
||||
</button>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label className={`text-xs font-semibold uppercase tracking-wider truncate ${isVerified ? 'text-emerald-700' : 'text-slate-600'}`}>
|
||||
{field.label || field.key || "Unknown Field"}
|
||||
</label>
|
||||
{!isVerified && status !== 'VALID' && (
|
||||
<span className={`flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded ${status === 'INVALID' ? 'bg-red-100 text-red-700' : 'bg-amber-100 text-amber-700'}`}>
|
||||
{status === 'INVALID' ? <XCircle className="w-3 h-3 mr-1"/> : <AlertTriangle className="w-3 h-3 mr-1"/>}
|
||||
{status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<label
|
||||
className={`text-xs font-semibold uppercase tracking-wider truncate ${
|
||||
isVerified
|
||||
? 'text-kng-success'
|
||||
: 'text-kng-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{field.label || field.key || 'Unbekanntes Feld'}
|
||||
</label>
|
||||
{!isVerified && status !== 'VALID' && (
|
||||
<span
|
||||
className={`flex items-center text-[10px] font-bold px-1.5 py-0.5 rounded-kng-sm ${
|
||||
status === 'INVALID'
|
||||
? 'bg-kng-error text-kng-bg'
|
||||
: 'bg-kng-warning text-kng-bg'
|
||||
}`}
|
||||
>
|
||||
{status === 'INVALID' ? (
|
||||
<XCircle className="w-3 h-3 mr-1" />
|
||||
) : (
|
||||
<AlertTriangle className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
{status}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={field.value}
|
||||
onChange={(e) => handleUpdate(idx, e.target.value)}
|
||||
className={`block w-full rounded-md px-2.5 py-1.5 text-sm font-medium transition-colors border ${isVerified ? 'border-emerald-200 text-emerald-900 bg-emerald-50/50' : 'border-slate-300 text-slate-900 focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500'}`}
|
||||
placeholder="Empty"
|
||||
/>
|
||||
</div>
|
||||
{(!isVerified && field.validation?.suggestion && status !== 'VALID') && (
|
||||
<button
|
||||
<input
|
||||
type="text"
|
||||
value={field.value}
|
||||
onChange={(e) => handleUpdate(idx, e.target.value)}
|
||||
className="block w-full rounded-kng-sm px-2.5 py-1.5 text-sm font-medium transition-colors border border-kng-border bg-kng-surface text-kng-text placeholder-kng-text-muted focus:border-kng-accent focus:outline-none focus:ring-1 focus:ring-kng-accent"
|
||||
placeholder="leer"
|
||||
/>
|
||||
|
||||
{!isVerified &&
|
||||
field.validation?.suggestion &&
|
||||
status !== 'VALID' && (
|
||||
<button
|
||||
onClick={() => applySuggestion(idx)}
|
||||
className="mt-2 flex items-center text-xs font-bold text-indigo-600 hover:text-indigo-800 bg-indigo-50 hover:bg-indigo-100 px-2 py-1 rounded transition-colors"
|
||||
className="mt-2 flex items-center text-xs font-bold text-kng-accent hover:brightness-110 bg-kng-surface hover:bg-kng-surface-hover px-2 py-1 rounded-kng-sm transition-colors border border-kng-border"
|
||||
>
|
||||
<ArrowRight className="w-3 h-3 mr-1" />
|
||||
Accept: "{field.validation.suggestion}"
|
||||
Übernehmen: "{field.validation.suggestion}"
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ================= FORM VIEW (Overlay with Drag & Drop) ================= */
|
||||
<div className="bg-slate-200 rounded-xl overflow-auto shadow-inner flex-1 border border-slate-300 relative p-8 flex justify-center">
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative inline-block shadow-2xl transition-cursor bg-white"
|
||||
style={{
|
||||
cursor: draggingField !== null ? 'grabbing' : 'default'
|
||||
}}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* Background Image/PDF */}
|
||||
{formFile.type === 'application/pdf' ? (
|
||||
<div className="flex flex-col items-center justify-center p-20 bg-slate-50 border border-slate-200 text-center" style={{ width: '794px', height: '1123px' }}>
|
||||
<FileText className="w-20 h-20 text-slate-300 mb-4" />
|
||||
<h3 className="text-xl font-bold text-slate-800">Visual Editing Unavailable for PDF</h3>
|
||||
<p className="text-slate-500 max-w-sm mt-2">
|
||||
The visual drag-and-drop editor works best with Image uploads.
|
||||
For this PDF, please use the List View to verify data or Download to see the final result.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setViewMode('LIST')}
|
||||
className="mt-6 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Switch to List View
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={formFile.previewUrl!}
|
||||
className="block max-w-full h-auto pointer-events-none opacity-90 select-none"
|
||||
style={{ maxHeight: '1200px' }}
|
||||
alt="Form Background"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Fallback info if not visual mode compatible */}
|
||||
{!fields.some(f => f.coordinates) && formFile.type !== 'application/pdf' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/80 z-20 backdrop-blur-sm">
|
||||
<div className="text-center p-6 max-w-md">
|
||||
<AlertTriangle className="w-12 h-12 text-amber-500 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-bold text-slate-900">Visual Mode Not Available</h3>
|
||||
<p className="text-slate-600 mt-2">
|
||||
Coordinates were not extracted for this document. Please use the List View to edit fields.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setViewMode('LIST')}
|
||||
className="mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
|
||||
>
|
||||
Switch to List View
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Overlay Inputs (Only show if not PDF, or if we force it but it's blank bg) */}
|
||||
{formFile.type !== 'application/pdf' && fields.map((field, idx) => {
|
||||
if (!field.coordinates) return null;
|
||||
|
||||
// Coordinate conversion (0-1000 scale to percentage)
|
||||
const left = (field.coordinates.x / 1000) * 100;
|
||||
const top = (field.coordinates.y / 1000) * 100;
|
||||
|
||||
const status = field.validation?.status || 'VALID';
|
||||
let borderColor = 'border-indigo-400 bg-white/60';
|
||||
if (field.isVerified) borderColor = 'border-emerald-500 bg-emerald-50/70';
|
||||
else if (status === 'INVALID') borderColor = 'border-red-500 bg-red-50/70';
|
||||
else if (status === 'WARNING') borderColor = 'border-amber-500 bg-amber-50/70';
|
||||
|
||||
const isDragging = draggingField === idx;
|
||||
const isCheckbox = field.value === 'X';
|
||||
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className={`absolute group hover:z-50 ${isDragging ? 'z-50 cursor-grabbing' : 'z-10'}`}
|
||||
style={{
|
||||
left: `${left}%`,
|
||||
top: `${top}%`,
|
||||
width: isCheckbox ? '20px' : '200px',
|
||||
transform: 'translateY(-50%)', // Center vertically
|
||||
}}
|
||||
onMouseDown={(e) => handleDragStart(e, idx)}
|
||||
>
|
||||
<div className="relative">
|
||||
{/* Drag Handle (Visible on Hover) */}
|
||||
<div className={`absolute -top-3 -left-3 cursor-grab p-1 bg-slate-800 text-white rounded-full opacity-0 group-hover:opacity-100 transition-opacity ${isDragging ? 'opacity-100' : ''}`}>
|
||||
<Move className="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
{isCheckbox ? (
|
||||
<div className={`w-6 h-6 border-2 flex items-center justify-center font-bold text-black ${borderColor}`}>
|
||||
X
|
||||
</div>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={field.value}
|
||||
onChange={(e) => handleUpdate(idx, e.target.value)}
|
||||
onFocus={() => setActiveField(idx)}
|
||||
className={`
|
||||
w-full px-1 py-0.5 text-xs font-medium border-2 rounded transition-all shadow-sm
|
||||
focus:ring-2 focus:ring-offset-1 focus:z-10 focus:bg-white
|
||||
${borderColor}
|
||||
`}
|
||||
style={{
|
||||
fontFamily: 'Courier, monospace',
|
||||
color: 'black',
|
||||
background: 'rgba(255, 255, 255, 0.7)'
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Validation Icon Overlay */}
|
||||
{!field.isVerified && status !== 'VALID' && (
|
||||
<div className="absolute -top-2 -right-2 bg-white rounded-full shadow-md z-20 pointer-events-none">
|
||||
{status === 'INVALID'
|
||||
? <XCircle className="w-4 h-4 text-red-500" />
|
||||
: <AlertTriangle className="w-4 h-4 text-amber-500" />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tooltip on Hover */}
|
||||
<div className="absolute opacity-0 group-hover:opacity-100 bottom-full left-0 mb-1 bg-slate-800 text-white text-[10px] px-2 py-1 rounded whitespace-nowrap pointer-events-none z-30 transition-opacity">
|
||||
{field.label}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
151
components/SourceInput.tsx
Normal file
151
components/SourceInput.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import React, { useRef, useState } from 'react';
|
||||
import { Upload, FileText, X, Image as ImageIcon } from 'lucide-react';
|
||||
import type { FileData } from '../types';
|
||||
|
||||
interface SourceInputProps {
|
||||
files: FileData[];
|
||||
text: string;
|
||||
onFilesChange: (files: FileData[]) => void;
|
||||
onTextChange: (text: string) => void;
|
||||
}
|
||||
|
||||
const ACCEPT = 'application/pdf,image/png,image/jpeg,image/webp';
|
||||
|
||||
function readFile(file: File): Promise<FileData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const dataUrl = reader.result as string;
|
||||
const base64 = dataUrl.split(',')[1];
|
||||
resolve({
|
||||
file,
|
||||
previewUrl: objectUrl,
|
||||
base64,
|
||||
type: file.type as FileData['type'],
|
||||
});
|
||||
};
|
||||
reader.onerror = () => reject(reader.error);
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export const SourceInput: React.FC<SourceInputProps> = ({
|
||||
files,
|
||||
text,
|
||||
onFilesChange,
|
||||
onTextChange,
|
||||
}) => {
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const handleIncoming = async (fileList: FileList | null) => {
|
||||
if (!fileList || fileList.length === 0) return;
|
||||
const incoming: FileData[] = [];
|
||||
for (const f of Array.from(fileList)) {
|
||||
if (!ACCEPT.split(',').includes(f.type)) continue;
|
||||
incoming.push(await readFile(f));
|
||||
}
|
||||
onFilesChange([...files, ...incoming]);
|
||||
};
|
||||
|
||||
const removeAt = (index: number) => {
|
||||
const removed = files[index];
|
||||
if (removed.previewUrl?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(removed.previewUrl);
|
||||
}
|
||||
onFilesChange(files.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div
|
||||
onClick={() => inputRef.current?.click()}
|
||||
onDragOver={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}}
|
||||
onDragLeave={() => setIsDragging(false)}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
void handleIncoming(e.dataTransfer.files);
|
||||
}}
|
||||
className={`relative border-2 border-dashed rounded-kng-lg p-6 text-center cursor-pointer transition-all duration-200 ${
|
||||
isDragging
|
||||
? 'border-kng-accent bg-kng-surface'
|
||||
: 'border-kng-border hover:border-kng-accent hover:bg-kng-surface'
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
accept={ACCEPT}
|
||||
onChange={(e) => void handleIncoming(e.target.files)}
|
||||
/>
|
||||
<div className="flex flex-col items-center justify-center space-y-2">
|
||||
<div className="p-2 bg-kng-bg-elevated rounded-kng-full shadow-kng-sm">
|
||||
<Upload className="w-5 h-5 text-kng-accent" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-kng-text">
|
||||
Dateien hinzufügen (Klick oder Drop)
|
||||
</p>
|
||||
<p className="text-xs text-kng-text-muted mt-1">
|
||||
Scans, Briefe, Ausweise — PDF oder Bild. Mehrere möglich.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{files.length > 0 && (
|
||||
<ul className="space-y-2">
|
||||
{files.map((f, idx) => (
|
||||
<li
|
||||
key={idx}
|
||||
className="flex items-center gap-3 border border-kng-border bg-kng-surface rounded-kng-md px-3 py-2"
|
||||
>
|
||||
<div className="w-8 h-8 bg-kng-bg-elevated rounded-kng-sm flex items-center justify-center flex-shrink-0 overflow-hidden">
|
||||
{f.type === 'application/pdf' ? (
|
||||
<FileText className="w-4 h-4 text-kng-text-secondary" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 text-kng-text-secondary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-kng-text truncate">
|
||||
{f.file.name}
|
||||
</p>
|
||||
<p className="text-xs text-kng-text-muted">
|
||||
{(f.file.size / 1024 / 1024).toFixed(2)} MB
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeAt(idx)}
|
||||
className="p-1 hover:bg-kng-surface-hover rounded-kng-full transition-colors text-kng-text-muted hover:text-kng-error"
|
||||
aria-label="Entfernen"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-xs font-semibold uppercase tracking-wider text-kng-text-secondary mb-1">
|
||||
Zusätzlicher Text (optional)
|
||||
</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => onTextChange(e.target.value)}
|
||||
rows={4}
|
||||
placeholder="Notizen, E-Mail-Text, Werte die nicht in den Dokumenten stehen …"
|
||||
className="block w-full rounded-kng-md border border-kng-border bg-kng-surface px-3 py-2 text-sm text-kng-text placeholder-kng-text-muted focus:border-kng-accent focus:outline-none focus:ring-1 focus:ring-kng-accent resize-y"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
53
components/ThemeToggle.tsx
Normal file
53
components/ThemeToggle.tsx
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import React, { useEffect, useState } from 'react';
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
|
||||
const STORAGE_KEY = 'kng_theme';
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
function getSystemTheme(): Theme {
|
||||
if (typeof window === 'undefined' || !window.matchMedia) return 'dark';
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches
|
||||
? 'light'
|
||||
: 'dark';
|
||||
}
|
||||
|
||||
function readStored(): Theme | null {
|
||||
try {
|
||||
const v = localStorage.getItem(STORAGE_KEY);
|
||||
return v === 'dark' || v === 'light' ? v : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
}
|
||||
|
||||
export const ThemeToggle: React.FC = () => {
|
||||
const [theme, setTheme] = useState<Theme>(() => readStored() ?? getSystemTheme());
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, theme);
|
||||
} catch {
|
||||
// localStorage nicht verfügbar — egal
|
||||
}
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
className="p-2 rounded-kng-md border border-kng-border bg-kng-surface hover:bg-kng-surface-hover text-kng-text-secondary hover:text-kng-text transition-colors"
|
||||
aria-label={theme === 'dark' ? 'Zu Light-Mode wechseln' : 'Zu Dark-Mode wechseln'}
|
||||
title={theme === 'dark' ? 'Light-Mode' : 'Dark-Mode'}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
<Sun className="w-4 h-4" />
|
||||
) : (
|
||||
<Moon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
29
docker-compose.yml
Normal file
29
docker-compose.yml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
services:
|
||||
rentenv:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: Rentenversicherer/Dockerfile
|
||||
image: rentenv:latest
|
||||
container_name: rentenv
|
||||
restart: unless-stopped
|
||||
user: "1000:1000"
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 3011
|
||||
# Claude-CLI liest HOME um .claude zu finden. node-User hat /home/node.
|
||||
HOME: /home/node
|
||||
volumes:
|
||||
# Claude-Credentials vom Host (User openclaw, UID 1000).
|
||||
# Read-write weil Claude Session-State rein schreibt.
|
||||
- /home/openclaw/.claude:/home/node/.claude
|
||||
- /home/openclaw/.config/claude:/home/node/.config/claude
|
||||
networks:
|
||||
- matrix
|
||||
expose:
|
||||
- "3011"
|
||||
|
||||
networks:
|
||||
matrix:
|
||||
external: true
|
||||
name: matrix_default
|
||||
35
index.html
35
index.html
|
|
@ -1,34 +1,15 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>AutoForm AI</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
background-color: #f8fafc;
|
||||
}
|
||||
</style>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react-dom/": "https://esm.sh/react-dom@^19.2.4/",
|
||||
"react/": "https://esm.sh/react@^19.2.4/",
|
||||
"react": "https://esm.sh/react@^19.2.4",
|
||||
"lucide-react": "https://esm.sh/lucide-react@^0.563.0",
|
||||
"@google/genai": "https://esm.sh/@google/genai@^1.38.0",
|
||||
"jspdf": "https://esm.sh/jspdf@2.5.1",
|
||||
"pdf-lib": "https://esm.sh/pdf-lib@^1.17.1",
|
||||
"vite": "https://esm.sh/vite@^7.3.1",
|
||||
"@vitejs/plugin-react": "https://esm.sh/@vitejs/plugin-react@^5.1.2"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</head>
|
||||
<title>Rentenversicherer</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/index.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
if (!rootElement) {
|
||||
throw new Error("Could not find root element to mount to");
|
||||
throw new Error('Could not find root element to mount to');
|
||||
}
|
||||
|
||||
const root = ReactDOM.createRoot(rootElement);
|
||||
|
|
@ -12,4 +13,4 @@ root.render(
|
|||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
);
|
||||
|
|
|
|||
4858
package-lock.json
generated
Normal file
4858
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
22
package.json
22
package.json
|
|
@ -1,29 +1,35 @@
|
|||
{
|
||||
"name": "autoform-ai",
|
||||
"name": "rentenversicherer",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev": "concurrently -n vite,server -c cyan,magenta \"vite\" \"tsx watch server/index.ts\"",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"start": "tsx server/index.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.38.0",
|
||||
"jspdf": "^2.5.1",
|
||||
"express": "^4.19.2",
|
||||
"lucide-react": "^0.563.0",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"multer": "^2.1.1",
|
||||
"pdfjs-dist": "^5.6.205",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/multer": "^2.1.0",
|
||||
"@types/node": "^20.12.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"autoprefixer": "^10.4.18",
|
||||
"concurrently": "^8.2.2",
|
||||
"postcss": "^8.4.35",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.1.4"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
export default {
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
};
|
||||
269
server/claudeRunner.ts
Normal file
269
server/claudeRunner.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { spawn } from 'node:child_process';
|
||||
import path from 'node:path';
|
||||
|
||||
export interface ClaudeFieldSpec {
|
||||
name: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface ClaudeExtractedField {
|
||||
key: string;
|
||||
label: string;
|
||||
value: string;
|
||||
sourceContext?: string;
|
||||
validation: {
|
||||
status: 'VALID' | 'WARNING' | 'INVALID';
|
||||
message?: string;
|
||||
suggestion?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ClaudeFormResponse {
|
||||
summary: string;
|
||||
fields: ClaudeExtractedField[];
|
||||
}
|
||||
|
||||
interface ClaudeCliEnvelope {
|
||||
type: string;
|
||||
subtype: string;
|
||||
is_error: boolean;
|
||||
result?: string;
|
||||
}
|
||||
|
||||
export interface RunClaudeArgs {
|
||||
formFilename: string;
|
||||
sourceFilenames: string[];
|
||||
hasSourceText: boolean;
|
||||
fields: ClaudeFieldSpec[];
|
||||
}
|
||||
|
||||
const GIT_BASH_FALLBACK =
|
||||
'C:\\Users\\benad\\scoop\\apps\\git\\2.53.0\\usr\\bin\\bash.exe';
|
||||
const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
|
||||
function describeType(t: string): string {
|
||||
switch (t) {
|
||||
case 'Tx':
|
||||
return 'Text';
|
||||
case 'Btn':
|
||||
return 'Checkbox/Button';
|
||||
case 'Ch':
|
||||
return 'Auswahlliste';
|
||||
case 'Sig':
|
||||
return 'Unterschrift';
|
||||
default:
|
||||
return t;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPrompt(tempDir: string, args: RunClaudeArgs): string {
|
||||
const formPath = path.join(tempDir, args.formFilename);
|
||||
const sourcePaths = args.sourceFilenames.map((n) => path.join(tempDir, n));
|
||||
const textPath = args.hasSourceText
|
||||
? path.join(tempDir, 'source_text.txt')
|
||||
: null;
|
||||
|
||||
const fieldList = args.fields
|
||||
.map((f) => ` - "${f.name}" (${describeType(f.type)})`)
|
||||
.join('\n');
|
||||
|
||||
const sourceBlock: string[] = [];
|
||||
if (sourcePaths.length > 0) {
|
||||
sourceBlock.push('SOURCE-DATEIEN (alle mit dem Read-Tool lesen):');
|
||||
for (const p of sourcePaths) sourceBlock.push(` - ${p}`);
|
||||
}
|
||||
if (textPath) {
|
||||
sourceBlock.push(
|
||||
`ZUSÄTZLICHER TEXT: ${textPath} (Notizen/Kontext vom User)`
|
||||
);
|
||||
}
|
||||
|
||||
return [
|
||||
'Du füllst ein deutsches Behörden-/Arztformular aus.',
|
||||
'',
|
||||
`TARGET-FORM: ${formPath}`,
|
||||
'',
|
||||
...sourceBlock,
|
||||
'',
|
||||
'AcroForm-Felder im TARGET:',
|
||||
fieldList,
|
||||
'',
|
||||
'Lies das TARGET-Formular und alle Quelldateien/Text mit dem Read-Tool.',
|
||||
'Extrahiere die Werte aus den Quellen und mappe sie auf die Feldnamen',
|
||||
'des TARGET. Quellen können sich überschneiden — bei Widersprüchen',
|
||||
'nimm den plausibelsten Wert und setze validation.status = "WARNING".',
|
||||
'',
|
||||
'ARBEITSREGELN (non-negotiable):',
|
||||
'',
|
||||
'1. STICHWORTSTIL, KEIN GUTACHTEN — kurze Einträge, keine ausformulierten',
|
||||
' Sätze. "Akute Lumboischialgie" statt "Der Patient leidet seit …".',
|
||||
'',
|
||||
'2. FESTE ZEICHEN-KÄSTCHEN OHNE LEERZEICHEN — bei VSNR, IBAN, BIC,',
|
||||
' Institutionskennzeichen (IK), Postleitzahl etc. werden die Zeichen in',
|
||||
' einzelne Kästchen geschrieben. Zusammenhängend ohne Leerzeichen/Punkte',
|
||||
' /Bindestriche ausgeben, auch wenn die Quelle sie enthält.',
|
||||
' Beispiel: Quelle "12 340567 A 005" → value "12340567A005".',
|
||||
'',
|
||||
'3. VORDRUCKE RESPEKTIEREN — wenn das Formular ein Präfix schon druckt',
|
||||
' (z.B. "DE" vor der IBAN, "€" vor dem Betrag), NICHT nochmal',
|
||||
' mitschreiben. Nur den variablen Teil ins Feld.',
|
||||
'',
|
||||
'4. RICHTIGES FELD — gleiche Labels können mehrfach vorkommen',
|
||||
' (Antragsteller vs. Zahlungsempfänger, erste vs. Folgeseite).',
|
||||
' Feldname und Umfeld analysieren, Wert in den passenden Abschnitt.',
|
||||
'',
|
||||
'5. NUR MEDIZINISCH — Sozialbereich (Familienstand, Einkommen,',
|
||||
' Wohnsituation jenseits der Adresse) bleibt leer, außer explizit',
|
||||
' in den Quellen enthalten.',
|
||||
'',
|
||||
'6. KEINE GERATENEN WERTE — bei Unsicherheit: value="" und',
|
||||
' validation.status="WARNING" mit Begründung. Nicht halluzinieren.',
|
||||
' Lieber leer lassen als falsch ausfüllen.',
|
||||
'',
|
||||
'FORMAT-REGELN:',
|
||||
'- Datum: DD.MM.YYYY',
|
||||
'- Zahlen: Komma als Dezimaltrenner, Punkt als Tausendertrenner',
|
||||
'- Checkbox/Button (Btn): value="X" wenn angekreuzt, value="" sonst',
|
||||
'',
|
||||
'Wenn ein Feld aus den Quellen nicht sicher ableitbar ist:',
|
||||
' value="" und validation.status="WARNING" mit Begründung.',
|
||||
'Wenn ein Feld klar kein Match hat: value="" und status="VALID".',
|
||||
'',
|
||||
'ANTWORTE NUR mit einem JSON-Objekt in diesem Format,',
|
||||
'ohne Markdown-Codefence, ohne Kommentar davor oder danach:',
|
||||
'',
|
||||
'{',
|
||||
' "summary": "kurze Beschreibung was verarbeitet wurde",',
|
||||
' "fields": [',
|
||||
' {',
|
||||
' "key": "<exakter Feldname aus Liste>",',
|
||||
' "label": "<menschenlesbar>",',
|
||||
' "value": "<Wert oder leer>",',
|
||||
' "sourceContext": "<Textsnippet aus der jeweiligen Quelle>",',
|
||||
' "validation": { "status": "VALID|WARNING|INVALID", "message": "...", "suggestion": "..." }',
|
||||
' }',
|
||||
' ]',
|
||||
'}',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function stripCodeFence(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
const fenceMatch = trimmed.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```$/);
|
||||
if (fenceMatch) return fenceMatch[1].trim();
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
export async function runClaude(
|
||||
tempDir: string,
|
||||
args: RunClaudeArgs,
|
||||
timeoutMs: number = DEFAULT_TIMEOUT_MS
|
||||
): Promise<ClaudeFormResponse> {
|
||||
const prompt = buildPrompt(tempDir, args);
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
CLAUDE_CODE_GIT_BASH_PATH:
|
||||
process.env.CLAUDE_CODE_GIT_BASH_PATH ?? GIT_BASH_FALLBACK,
|
||||
};
|
||||
|
||||
const cliArgs = [
|
||||
'-p',
|
||||
'--output-format',
|
||||
'json',
|
||||
'--permission-mode',
|
||||
'bypassPermissions',
|
||||
];
|
||||
|
||||
return new Promise<ClaudeFormResponse>((resolve, reject) => {
|
||||
const child = spawn('claude', cliArgs, {
|
||||
env,
|
||||
cwd: tempDir,
|
||||
shell: true,
|
||||
windowsHide: true,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
|
||||
// Prompt via stdin — umgeht Windows-Shell-Escaping von Newlines/Quotes.
|
||||
child.stdin.write(prompt);
|
||||
child.stdin.end();
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let settled = false;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
child.kill('SIGKILL');
|
||||
reject(new Error(`Claude CLI timeout nach ${timeoutMs}ms`));
|
||||
}, timeoutMs);
|
||||
|
||||
child.stdout.on('data', (chunk: Buffer) => {
|
||||
stdout += chunk.toString('utf8');
|
||||
});
|
||||
child.stderr.on('data', (chunk: Buffer) => {
|
||||
stderr += chunk.toString('utf8');
|
||||
});
|
||||
|
||||
child.on('error', (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
reject(new Error(`Claude CLI spawn-Fehler: ${err.message}`));
|
||||
});
|
||||
|
||||
child.on('close', (code) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(timer);
|
||||
|
||||
if (code !== 0) {
|
||||
reject(
|
||||
new Error(
|
||||
`Claude CLI exit ${code}. stderr: ${stderr.slice(0, 500)}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let envelope: ClaudeCliEnvelope;
|
||||
try {
|
||||
envelope = JSON.parse(stdout);
|
||||
} catch {
|
||||
reject(
|
||||
new Error(`Claude CLI-Output ist kein JSON: ${stdout.slice(0, 300)}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (envelope.is_error || !envelope.result) {
|
||||
reject(
|
||||
new Error(
|
||||
`Claude CLI meldete Fehler: ${JSON.stringify(envelope).slice(0, 500)}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let parsed: ClaudeFormResponse;
|
||||
try {
|
||||
parsed = JSON.parse(stripCodeFence(envelope.result));
|
||||
} catch {
|
||||
reject(
|
||||
new Error(
|
||||
`Inneres Result ist kein JSON: ${envelope.result.slice(0, 300)}`
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsed.fields)) {
|
||||
reject(new Error('Claude-Antwort hat kein fields-Array.'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve(parsed);
|
||||
});
|
||||
});
|
||||
}
|
||||
158
server/index.ts
Normal file
158
server/index.ts
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
import express, { type Request, type Response, type NextFunction } from 'express';
|
||||
import multer from 'multer';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdir, rm, writeFile } from 'node:fs/promises';
|
||||
import { existsSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { runClaude, type ClaudeFieldSpec } from './claudeRunner.ts';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 3001);
|
||||
const HOST = process.env.HOST ?? '127.0.0.1';
|
||||
const IS_PROD = process.env.NODE_ENV === 'production';
|
||||
const DIST_DIR = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'..',
|
||||
'dist'
|
||||
);
|
||||
const MAX_FILE_BYTES = 20 * 1024 * 1024;
|
||||
const MAX_SOURCES = 10;
|
||||
const ALLOWED_MIME = new Set([
|
||||
'application/pdf',
|
||||
'image/png',
|
||||
'image/jpeg',
|
||||
'image/webp',
|
||||
]);
|
||||
|
||||
const upload = multer({
|
||||
storage: multer.memoryStorage(),
|
||||
limits: { fileSize: MAX_FILE_BYTES, files: MAX_SOURCES + 1 },
|
||||
fileFilter: (_req, file, cb) => {
|
||||
if (!ALLOWED_MIME.has(file.mimetype)) {
|
||||
cb(new Error(`Unsupported mime: ${file.mimetype}`));
|
||||
return;
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
const app = express();
|
||||
|
||||
app.post(
|
||||
'/api/process',
|
||||
upload.fields([
|
||||
{ name: 'form', maxCount: 1 },
|
||||
{ name: 'sources', maxCount: MAX_SOURCES },
|
||||
]),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const files = req.files as Record<string, Express.Multer.File[]> | undefined;
|
||||
const formFile = files?.form?.[0];
|
||||
const sourceFiles = files?.sources ?? [];
|
||||
const sourceText =
|
||||
typeof req.body?.sourceText === 'string'
|
||||
? req.body.sourceText.trim()
|
||||
: '';
|
||||
|
||||
if (!formFile) {
|
||||
res.status(400).json({ error: 'form wird benötigt.' });
|
||||
return;
|
||||
}
|
||||
if (sourceFiles.length === 0 && sourceText.length === 0) {
|
||||
res
|
||||
.status(400)
|
||||
.json({ error: 'Mindestens eine Quelldatei oder sourceText nötig.' });
|
||||
return;
|
||||
}
|
||||
|
||||
let fieldSpecs: ClaudeFieldSpec[];
|
||||
try {
|
||||
const raw = req.body?.fields;
|
||||
if (typeof raw !== 'string') throw new Error('fields fehlt');
|
||||
fieldSpecs = JSON.parse(raw);
|
||||
if (!Array.isArray(fieldSpecs) || fieldSpecs.length === 0) {
|
||||
throw new Error('fields muss ein nicht-leeres Array sein');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
res.status(400).json({ error: `fields-Parameter ungültig: ${msg}` });
|
||||
return;
|
||||
}
|
||||
|
||||
const requestId = randomUUID();
|
||||
const tempDir = path.join(os.tmpdir(), `rentenv-${requestId}`);
|
||||
const formName = filenameForMime('form', formFile.mimetype);
|
||||
|
||||
try {
|
||||
await mkdir(tempDir, { recursive: true });
|
||||
await writeFile(path.join(tempDir, formName), formFile.buffer);
|
||||
|
||||
const sourceNames: string[] = [];
|
||||
for (let i = 0; i < sourceFiles.length; i++) {
|
||||
const f = sourceFiles[i];
|
||||
const name = filenameForMime(`source_${i + 1}`, f.mimetype);
|
||||
await writeFile(path.join(tempDir, name), f.buffer);
|
||||
sourceNames.push(name);
|
||||
}
|
||||
|
||||
if (sourceText.length > 0) {
|
||||
await writeFile(path.join(tempDir, 'source_text.txt'), sourceText);
|
||||
}
|
||||
|
||||
const result = await runClaude(tempDir, {
|
||||
formFilename: formName,
|
||||
sourceFilenames: sourceNames,
|
||||
hasSourceText: sourceText.length > 0,
|
||||
fields: fieldSpecs,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (e: unknown) {
|
||||
next(e);
|
||||
} finally {
|
||||
if (existsSync(tempDir)) {
|
||||
rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error('[server error]', msg);
|
||||
res.status(502).json({ error: 'Claude CLI failed', details: msg });
|
||||
});
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// Production: statische Assets + SPA-Fallback auf index.html.
|
||||
if (IS_PROD && existsSync(DIST_DIR)) {
|
||||
app.use(express.static(DIST_DIR));
|
||||
app.get(/^\/(?!api\/).*/, (_req, res) => {
|
||||
res.sendFile(path.join(DIST_DIR, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, HOST, () => {
|
||||
console.log(`[server] listening on http://${HOST}:${PORT}`);
|
||||
});
|
||||
|
||||
function filenameForMime(base: string, mime: string): string {
|
||||
switch (mime) {
|
||||
case 'application/pdf':
|
||||
return `${base}.pdf`;
|
||||
case 'image/png':
|
||||
return `${base}.png`;
|
||||
case 'image/jpeg':
|
||||
return `${base}.jpg`;
|
||||
case 'image/webp':
|
||||
return `${base}.webp`;
|
||||
default:
|
||||
return `${base}.bin`;
|
||||
}
|
||||
}
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
console.error('[unhandledRejection]', reason);
|
||||
});
|
||||
48
services/api.ts
Normal file
48
services/api.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { FileData, FormResponse } from '../types';
|
||||
import type { PdfFieldInfo } from './pdfService';
|
||||
|
||||
export async function processDocuments(
|
||||
formFile: FileData,
|
||||
sourceFiles: FileData[],
|
||||
sourceText: string,
|
||||
pdfFields: PdfFieldInfo[]
|
||||
): Promise<FormResponse> {
|
||||
if (pdfFields.length === 0) {
|
||||
throw new Error(
|
||||
'Das Ziel-PDF enthält keine AcroForm-Felder. ' +
|
||||
'Nur Formulare mit interaktiven Feldern werden unterstützt.'
|
||||
);
|
||||
}
|
||||
if (sourceFiles.length === 0 && sourceText.trim().length === 0) {
|
||||
throw new Error('Mindestens ein Quelldokument oder Text wird benötigt.');
|
||||
}
|
||||
|
||||
const body = new FormData();
|
||||
body.append('form', formFile.file, formFile.file.name);
|
||||
for (const f of sourceFiles) {
|
||||
body.append('sources', f.file, f.file.name);
|
||||
}
|
||||
if (sourceText.trim().length > 0) {
|
||||
body.append('sourceText', sourceText);
|
||||
}
|
||||
body.append('fields', JSON.stringify(pdfFields));
|
||||
|
||||
const res = await fetch('/api/process', {
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let message = `Server antwortete mit ${res.status}`;
|
||||
try {
|
||||
const data = await res.json();
|
||||
if (data?.error) message = data.error;
|
||||
if (data?.details) message += ` — ${data.details}`;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
return (await res.json()) as FormResponse;
|
||||
}
|
||||
|
|
@ -1,155 +0,0 @@
|
|||
import { GoogleGenAI, Type, Schema } from "@google/genai";
|
||||
import { FileData, FormResponse } from "../types";
|
||||
import { PdfFieldInfo } from "./pdfService";
|
||||
|
||||
const ai = new GoogleGenAI({ apiKey: process.env.API_KEY });
|
||||
|
||||
const responseSchema: Schema = {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
summary: {
|
||||
type: Type.STRING,
|
||||
description: "A brief summary of what document was processed."
|
||||
},
|
||||
fields: {
|
||||
type: Type.ARRAY,
|
||||
items: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
key: {
|
||||
type: Type.STRING,
|
||||
description: "The PDF field name (if available)."
|
||||
},
|
||||
label: {
|
||||
type: Type.STRING,
|
||||
description: "A human-readable label for the field."
|
||||
},
|
||||
value: {
|
||||
type: Type.STRING,
|
||||
description: "The value to fill. For checkboxes, use 'X' if true, otherwise leave empty."
|
||||
},
|
||||
sourceContext: {
|
||||
type: Type.STRING,
|
||||
description: "The exact snippet of text from the source document used to derive this value."
|
||||
},
|
||||
coordinates: {
|
||||
type: Type.OBJECT,
|
||||
description: "REQUIRED if no specific PDF field names are provided. Visual location to draw text.",
|
||||
properties: {
|
||||
pageIndex: { type: Type.INTEGER, description: "0-based page index" },
|
||||
x: { type: Type.INTEGER, description: "Horizontal position (0-1000) from Left" },
|
||||
y: { type: Type.INTEGER, description: "Vertical position (0-1000) from Top" }
|
||||
},
|
||||
required: ["pageIndex", "x", "y"]
|
||||
},
|
||||
validation: {
|
||||
type: Type.OBJECT,
|
||||
properties: {
|
||||
status: {
|
||||
type: Type.STRING,
|
||||
description: "VALID, WARNING, or INVALID."
|
||||
},
|
||||
message: {
|
||||
type: Type.STRING,
|
||||
description: "Validation message explaining any issues."
|
||||
},
|
||||
suggestion: {
|
||||
type: Type.STRING,
|
||||
description: "Alternative value suggestion."
|
||||
}
|
||||
},
|
||||
required: ["status"]
|
||||
}
|
||||
},
|
||||
required: ["label", "value", "validation"]
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ["fields", "summary"]
|
||||
};
|
||||
|
||||
export const processDocuments = async (
|
||||
blankForm: FileData,
|
||||
sourceDocument: FileData,
|
||||
pdfFields: PdfFieldInfo[] = []
|
||||
): Promise<FormResponse> => {
|
||||
|
||||
const formPart = {
|
||||
inlineData: {
|
||||
data: blankForm.base64,
|
||||
mimeType: blankForm.type,
|
||||
},
|
||||
};
|
||||
|
||||
const sourcePart = {
|
||||
inlineData: {
|
||||
data: sourceDocument.base64,
|
||||
mimeType: sourceDocument.type,
|
||||
},
|
||||
};
|
||||
|
||||
let systemPrompt = `
|
||||
ROLE: Intelligent Document Processing AI (German Bureaucracy Expert).
|
||||
TASK: Extract data from the SOURCE DOCUMENT and map it to the TARGET FORM visually or logically.
|
||||
|
||||
STRICT FORMATTING RULES (German Context):
|
||||
1. DATES: Must be formatted as 'DD.MM.YYYY' (e.g., 24.01.1982). Do not use ISO or US formats.
|
||||
2. NUMBERS/CURRENCY: Use comma as decimal separator (e.g., 1.425,00). Do NOT write the currency symbol (€) if the form already has it printed.
|
||||
3. CHECKBOXES: If a condition is met (e.g., "Männlich", "Ja"), the 'value' must be "X". If not met, leave empty.
|
||||
|
||||
CRITICAL: Verify every extraction. If ambiguous, set validation.status to 'WARNING'.
|
||||
`;
|
||||
|
||||
if (pdfFields.length > 0) {
|
||||
const fieldList = pdfFields.map(f => `"${f.name}" (${f.type})`).join(", ");
|
||||
systemPrompt += `
|
||||
MODE: FILLABLE PDF (AcroForm).
|
||||
Map extracted data to these exact field IDs: [${fieldList}].
|
||||
`;
|
||||
} else {
|
||||
systemPrompt += `
|
||||
MODE: VISUAL FILLING (Flat Scan/Image).
|
||||
The target form has NO digital fields. You must estimate COORDINATES.
|
||||
|
||||
COORDINATE SYSTEM (0-1000):
|
||||
- x=0, y=0 is Top-Left.
|
||||
- x=1000, y=1000 is Bottom-Right.
|
||||
|
||||
STRATEGY:
|
||||
1. Analyze the blank form image. Identify where user input belongs (lines, boxes).
|
||||
2. For "Reisekosten" (Travel Expenses): Look for columns like "Fahrtkosten", "Übernachtung". accurately place the amounts in the "Betrag" column.
|
||||
3. Place text slightly ABOVE the underline so it looks natural.
|
||||
4. For Checkboxes: Estimate the center of the square box.
|
||||
`;
|
||||
}
|
||||
|
||||
try {
|
||||
const modelId = "gemini-3-flash-preview";
|
||||
|
||||
const response = await ai.models.generateContent({
|
||||
model: modelId,
|
||||
contents: {
|
||||
parts: [
|
||||
formPart,
|
||||
{ text: "TARGET FORM (Blank)" },
|
||||
sourcePart,
|
||||
{ text: "SOURCE DATA (Email/Receipts)" },
|
||||
]
|
||||
},
|
||||
config: {
|
||||
responseMimeType: "application/json",
|
||||
responseSchema: responseSchema,
|
||||
systemInstruction: systemPrompt
|
||||
}
|
||||
});
|
||||
|
||||
const text = response.text;
|
||||
if (!text) throw new Error("No response from Gemini");
|
||||
|
||||
return JSON.parse(text) as FormResponse;
|
||||
|
||||
} catch (error) {
|
||||
console.error("Gemini API Error:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
|
@ -1,105 +1,144 @@
|
|||
import { PDFDocument, PDFTextField, PDFCheckBox, StandardFonts, rgb } from 'pdf-lib';
|
||||
import { ExtractedField } from '../types';
|
||||
import * as pdfjs from 'pdfjs-dist';
|
||||
// @ts-expect-error - ?url-Import ist Vite-spezifisch, wird zur Build-Zeit aufgelöst
|
||||
import workerUrl from 'pdfjs-dist/build/pdf.worker.mjs?url';
|
||||
import type { ExtractedField } from '../types';
|
||||
|
||||
// pdfjs rendert AcroForm-Text mit der im PDF definierten Default-Appearance
|
||||
// (Font-Größe + Farbe). In Behördenformularen ist das oft 10–11pt und blau,
|
||||
// was im ausgefüllten PDF zu groß und farblich falsch aussieht.
|
||||
//
|
||||
// Lösung: Das pdf.worker.mjs-Script wird zur Laufzeit gezogen, zwei Stellen
|
||||
// gepatcht und als Blob-URL als Worker-Src gesetzt:
|
||||
// 1. fontSize → 0 ⇒ pdfjs schaltet auf Auto-Size (passt sich an Feldhöhe an)
|
||||
// 2. fontColor → schwarz
|
||||
// So bleibt der Rest des PDFs unverändert, nur Textwerte rendern kleiner/schwarz.
|
||||
let workerPromise: Promise<void> | null = null;
|
||||
|
||||
function ensureWorker(): Promise<void> {
|
||||
if (workerPromise) return workerPromise;
|
||||
workerPromise = (async () => {
|
||||
try {
|
||||
const res = await fetch(workerUrl as string);
|
||||
if (!res.ok) throw new Error(`worker fetch failed: ${res.status}`);
|
||||
let src = await res.text();
|
||||
|
||||
// 1. Auto-Size erzwingen
|
||||
src = src.replace(
|
||||
/let\s*\{\s*fontSize\s*\}\s*=\s*this\.data\.defaultAppearanceData;/,
|
||||
'let fontSize = 0; void this.data.defaultAppearanceData;'
|
||||
);
|
||||
|
||||
// 2. Font-Farbe schwarz erzwingen (Arbeitsregel: Schrift schwarz)
|
||||
src = src.replace(
|
||||
/const\s*\{\s*fontName,\s*fontColor\s*\}\s*=\s*this\.data\.defaultAppearanceData;/,
|
||||
'const { fontName } = this.data.defaultAppearanceData; const fontColor = new Uint8ClampedArray([0,0,0]);'
|
||||
);
|
||||
|
||||
const blob = new Blob([src], { type: 'text/javascript' });
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
// Fallback: ungepatchter Worker — lieber laufen lassen als App brechen.
|
||||
console.warn('[pdfService] worker patch failed, falling back', e);
|
||||
pdfjs.GlobalWorkerOptions.workerSrc = workerUrl as string;
|
||||
}
|
||||
})();
|
||||
return workerPromise;
|
||||
}
|
||||
|
||||
export interface PdfFieldInfo {
|
||||
name: string;
|
||||
type: string;
|
||||
type: string; // 'Tx' | 'Btn' | 'Ch' | 'Sig'
|
||||
}
|
||||
|
||||
export const getPdfFields = async (base64: string): Promise<PdfFieldInfo[]> => {
|
||||
interface WidgetInfo {
|
||||
id: string;
|
||||
fieldName: string;
|
||||
fieldType: string;
|
||||
}
|
||||
|
||||
function base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binary = atob(base64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function collectWidgets(
|
||||
data: Uint8Array
|
||||
): Promise<{ doc: pdfjs.PDFDocumentProxy; widgets: WidgetInfo[] }> {
|
||||
await ensureWorker();
|
||||
const loadingTask = pdfjs.getDocument({ data, isEvalSupported: false });
|
||||
const doc = await loadingTask.promise;
|
||||
const widgets: WidgetInfo[] = [];
|
||||
|
||||
for (let pageIdx = 1; pageIdx <= doc.numPages; pageIdx++) {
|
||||
const page = await doc.getPage(pageIdx);
|
||||
const anns = await page.getAnnotations();
|
||||
for (const ann of anns) {
|
||||
if (ann.subtype !== 'Widget') continue;
|
||||
if (!ann.fieldName) continue;
|
||||
widgets.push({
|
||||
id: ann.id,
|
||||
fieldName: ann.fieldName,
|
||||
fieldType: ann.fieldType,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return { doc, widgets };
|
||||
}
|
||||
|
||||
export async function getPdfFields(base64: string): Promise<PdfFieldInfo[]> {
|
||||
try {
|
||||
const pdfDoc = await PDFDocument.load(base64);
|
||||
const form = pdfDoc.getForm();
|
||||
const fields = form.getFields();
|
||||
return fields.map(f => ({
|
||||
name: f.getName(),
|
||||
type: f.constructor.name
|
||||
}));
|
||||
} catch (error) {
|
||||
console.warn("Failed to extract PDF fields", error);
|
||||
const data = base64ToUint8Array(base64);
|
||||
const { widgets } = await collectWidgets(data);
|
||||
const seen = new Set<string>();
|
||||
const unique: PdfFieldInfo[] = [];
|
||||
for (const w of widgets) {
|
||||
if (seen.has(w.fieldName)) continue;
|
||||
seen.add(w.fieldName);
|
||||
unique.push({ name: w.fieldName, type: w.fieldType });
|
||||
}
|
||||
return unique;
|
||||
} catch (e) {
|
||||
console.warn('[pdfService] getPdfFields failed:', e);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export const createFilledPdf = async (base64: string, fields: ExtractedField[], isFillable: boolean): Promise<Uint8Array> => {
|
||||
const pdfDoc = await PDFDocument.load(base64);
|
||||
const pages = pdfDoc.getPages();
|
||||
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
|
||||
|
||||
if (isFillable) {
|
||||
try {
|
||||
const form = pdfDoc.getForm();
|
||||
const fieldMap: Record<string, string> = {};
|
||||
fields.forEach(f => {
|
||||
if (f.key) fieldMap[f.key] = f.value;
|
||||
});
|
||||
export async function createFilledPdf(
|
||||
base64: string,
|
||||
fields: ExtractedField[]
|
||||
): Promise<Uint8Array> {
|
||||
const data = base64ToUint8Array(base64);
|
||||
const { doc, widgets } = await collectWidgets(data);
|
||||
|
||||
for (const [key, value] of Object.entries(fieldMap)) {
|
||||
try {
|
||||
const field = form.getField(key);
|
||||
if (!field) continue;
|
||||
|
||||
if (field instanceof PDFTextField) {
|
||||
field.setText(String(value));
|
||||
} else if (field instanceof PDFCheckBox) {
|
||||
const isChecked = String(value).toLowerCase() === 'true' || String(value).toLowerCase() === 'yes' || String(value).toLowerCase() === 'x';
|
||||
if (isChecked) field.check();
|
||||
else field.uncheck();
|
||||
}
|
||||
} catch (e) {
|
||||
// Field might be read-only or tricky
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Error filling form fields", e);
|
||||
}
|
||||
} else {
|
||||
// VISUAL OVERLAY MODE
|
||||
// Iterate through fields and draw them at specific coordinates
|
||||
|
||||
for (const field of fields) {
|
||||
// Skip if no value or no coordinates
|
||||
if (!field.value || !field.coordinates) continue;
|
||||
|
||||
const { pageIndex, x, y } = field.coordinates;
|
||||
|
||||
// Safety check for page index and coordinates
|
||||
if (typeof pageIndex !== 'number' || pageIndex < 0 || pageIndex >= pages.length) continue;
|
||||
if (isNaN(x) || isNaN(y)) continue;
|
||||
|
||||
const page = pages[pageIndex];
|
||||
const { width, height } = page.getSize();
|
||||
|
||||
// Convert 0-1000 coordinates to PDF Point coordinates
|
||||
// PDF (0,0) is bottom-left.
|
||||
// API (0,0) is top-left.
|
||||
// x = (x / 1000) * width
|
||||
// y = height - (y / 1000) * height
|
||||
|
||||
const pdfX = (x / 1000) * width;
|
||||
const pdfY = height - (y / 1000) * height;
|
||||
|
||||
// Adjust slightly for font height (text is drawn from baseline)
|
||||
// A small nudge down (subtract from Y) helps align with lines usually.
|
||||
const adjustedY = pdfY - 4;
|
||||
|
||||
try {
|
||||
page.drawText(field.value, {
|
||||
x: pdfX,
|
||||
y: adjustedY,
|
||||
size: 10,
|
||||
font: font,
|
||||
color: rgb(0, 0, 0),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn(`Failed to draw field ${field.label}`, e);
|
||||
}
|
||||
}
|
||||
const byName = new Map<string, ExtractedField>();
|
||||
for (const f of fields) {
|
||||
if (f.key) byName.set(f.key, f);
|
||||
}
|
||||
|
||||
return await pdfDoc.save();
|
||||
};
|
||||
|
||||
export const fillPdf = async (base64: string, fieldValues: Record<string, string | boolean>): Promise<Uint8Array> => {
|
||||
return new Uint8Array();
|
||||
};
|
||||
const store = doc.annotationStorage;
|
||||
|
||||
for (const w of widgets) {
|
||||
const source = byName.get(w.fieldName);
|
||||
if (!source) continue;
|
||||
|
||||
if (w.fieldType === 'Tx') {
|
||||
store.setValue(w.id, { value: source.value ?? '' });
|
||||
} else if (w.fieldType === 'Btn') {
|
||||
const checked = isTruthyCheckbox(source.value);
|
||||
store.setValue(w.id, { value: checked });
|
||||
} else if (w.fieldType === 'Ch') {
|
||||
store.setValue(w.id, { value: source.value ?? '' });
|
||||
}
|
||||
// Sig (Signature) wird übersprungen.
|
||||
}
|
||||
|
||||
return await doc.saveDocument();
|
||||
}
|
||||
|
||||
function isTruthyCheckbox(value: string): boolean {
|
||||
const v = (value ?? '').trim().toLowerCase();
|
||||
return v === 'x' || v === 'ja' || v === 'yes' || v === 'true' || v === '1';
|
||||
}
|
||||
|
|
|
|||
12
styles.css
Normal file
12
styles.css
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
@import '../kanagawa-design-system/css/kanagawa.css';
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html,
|
||||
body {
|
||||
background-color: var(--kng-bg);
|
||||
color: var(--kng-text);
|
||||
font-family: var(--kng-font-sans);
|
||||
}
|
||||
12
tailwind.config.cjs
Normal file
12
tailwind.config.cjs
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
presets: [require('../kanagawa-design-system/tailwind/kanagawa.preset.js')],
|
||||
content: [
|
||||
'./index.html',
|
||||
'./**/*.{js,ts,jsx,tsx}',
|
||||
'!./node_modules/**',
|
||||
'!./dist/**',
|
||||
'!./test-fixtures/**',
|
||||
],
|
||||
plugins: [],
|
||||
};
|
||||
|
|
@ -1,11 +0,0 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
|
|
@ -14,8 +14,9 @@
|
|||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["**/*.ts", "**/*.tsx"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,15 @@
|
|||
import { defineConfig, loadEnv } from 'vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
const env = loadEnv(mode, '.', '');
|
||||
return {
|
||||
plugins: [react()],
|
||||
define: {
|
||||
// This is necessary to make process.env.API_KEY work in the browser
|
||||
// usually Vite uses import.meta.env
|
||||
'process.env.API_KEY': JSON.stringify(env.API_KEY)
|
||||
}
|
||||
};
|
||||
});
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:3001',
|
||||
changeOrigin: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue