L3 Auftragsimport: Prüfmaske, Upload, API und Lane-Bericht
- /imports: Upload per Drag & Drop/Dateiauswahl mit Fortschritt, Liste mit Status, Neu verarbeiten
- /imports/[id]: Originaldokument + Prüfmaske (Kunde, Objekt, Ansprechpartner, Auftrag, Positionen),
unsichere Felder markiert, Kunden-/Objektentscheidung, Bestätigen/Verwerfen
- Datei-Route für die Vorschau, Server Actions (moduleGuard("imports"))
- API: POST /api/v1/work-orders/import, GET /api/v1/imports/[id], POST /api/v1/imports/[id]/confirm
- Texte messages/{de,en}/imports.json, Bericht docs/craftvia/lanes/import.md
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,106 @@
|
|||||||
|
# Lane L3 – Auftragsimport
|
||||||
|
|
||||||
|
Branch `lane/import` (von `feature/craftvia-mvp` @ `bf44567`). Spec §9 komplett, §7.3, §31, US-002, US-003, ARCHITEKTUR §4.4/§4.5.
|
||||||
|
|
||||||
|
## Umfang / erfüllte Spec-Punkte
|
||||||
|
|
||||||
|
| Spec | Umsetzung |
|
||||||
|
|---|---|
|
||||||
|
| §9.2 Dateien | PDF (Text und Scan: Claude liest PDF nativ als `document`-Block, inkl. Seitenbild → kein separates OCR), JPG, PNG. Allowlist + Magic Bytes + Größenlimit (PDF 25 MB, Bild 15 MB) |
|
||||||
|
| §9.3 1–2 Upload, Typprüfung | `services/imports/upload.ts#createImport` → Dokument (Kategorie `order_confirmation`, Sichtbarkeit `backoffice_only`, SHA-256) → `ImportJob uploaded` → `dispatchJob("import-extraction")` |
|
||||||
|
| §9.3 3–5 Texterkennung, Analyse, Extraktion | `ai/extraction/anthropic.ts` (Anthropic SDK 0.115, Modell `ANTHROPIC_MODEL`, Default `claude-opus-5`): Streaming + `finalMessage()`, adaptive thinking, Structured Output (`output_config.format` JSON-Schema aus `lib/imports/extraction.ts`), Volltext `text` + 22 Felder mit `value/confidence/source`. Prompt: nichts erfinden, unsichere Felder < 0.8, deutsche Datums-/Zahlenformate normalisieren, Kunde ≠ Briefkopf, Objekt ≠ Kundenadresse. Refusal/`max_tokens` → Fehler; bei Opus 5 serverseitiger Fallback (`fallbacks: "default"`, Beta `server-side-fallback-2026-07-01`) |
|
||||||
|
| §9.3 6 Plausibilität | `lib/imports/plausibility.ts`: Datum gültig und plausibel (Auftrags-/Dokumentdatum ≤ +1 Monat, Ausführung −2…+3 Jahre), PLZ 5-stellig (DE), E-Mail-/Telefonformat, Ende ≥ Beginn. Verstoß: Konfidenz ≤ 0.4 + Hinweis |
|
||||||
|
| §9.3 7 / §7.3 / US-003 Dubletten | Kandidaten über Kundennummer, Firmenname (Rechtsform-normalisiert), Personenname, E-Mail, Telefon, Adresse (Straße/Str./Strasse) mit Score + Gründen; Objekt-Kandidaten = Objekte der Kandidaten an gleicher Adresse. Keine automatische Zuordnung/Zusammenführung |
|
||||||
|
| §9.5 Vertrauenswerte | je Feld gespeichert (`ImportJob.extraction.fields`), Prüfmaske markiert < 0.8 mit Warnfarbe + Icon + Text „unsicher · Sicherheit n %“, Quelle als Tooltip und Hinweistext |
|
||||||
|
| §9.6 Manuelle Prüfung | `/imports/[id]`: links Original (PDF-iframe / Bild), rechts Formular Kunde · Objekt · Ansprechpartner · Auftrag · Positionen/Material; Kundenentscheidung (Kandidat mit Score/Gründen · Suche · neu anlegen · Link „Dubletten im Kundenstamm zusammenführen“ → L1), Objekt analog (keins/bestehend/neu), Positionen editierbar mit „als Materialvorgabe übernehmen“. **Kein Auftrag ohne Bestätigung** |
|
||||||
|
| §9.3 10 Bestätigung | `services/imports/confirm.ts#confirmImport`: eine Transaktion – atomarer Statuswechsel `review_required → confirmed` (verhindert Doppelaufträge), Kunde/Kontakt/Objekt anlegen oder zuordnen, `createWorkOrder` (Status-Historie `review_required → planned`, `sourceImportId`, Materialvorgabe), Originaldokument an Auftrag/Kunde/Objekt verknüpft, `corrections` (Diff Extraktion ↔ bestätigt + Entscheidungen). Audit: `import_job` (import), `work_order`/`customer`/`site`/`contact` (create) |
|
||||||
|
| §9.7 Originaldokument | bleibt dauerhaft (auch bei Verwerfen); gespeichert: Importdatum, importierender Nutzer, erkannter Text, Extraktion, Extraktionsversion, Modell, Provider, Korrekturen, `AiGeneration` |
|
||||||
|
| §31 Provider-Abstraktion | `DocumentExtractionProvider` (ARCHITEKTUR §4.5), `getExtractionProvider()` → `null` ohne Key bzw. bei `AI_EXTRACTION_PROVIDER≠anthropic`; `FakeExtractionProvider` für Tests |
|
||||||
|
| Graceful degradation | ohne Provider: `review_required` mit leerer Extraktion + Hinweis „manuell erfassen“ |
|
||||||
|
| Fehler | Provider-/Dateifehler → `failed` + `errorMessage` + Audit + Event `import.failed`; „Neu verarbeiten“ (`failed → uploaded` + Dispatch). Erfolg → Event `import.ready_for_review` |
|
||||||
|
|
||||||
|
## Routen / Screens
|
||||||
|
|
||||||
|
| Route | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `/imports` | Upload (Drag & Drop + Dateiauswahl, Fortschrittsbalken per XHR), Liste mit Status (Pill = Farbe + Icon + Text), Aktionen Prüfen / Neu verarbeiten / Auftrag, Auto-Refresh während Verarbeitung |
|
||||||
|
| `/imports/[id]` | Prüfmaske bzw. Statusansicht (Verarbeitung, fehlgeschlagen + Neu verarbeiten/Verwerfen, bestätigt + Link zum Auftrag, verworfen), erkannter Text aufklappbar |
|
||||||
|
| `/imports/[id]/file` | Original für die Vorschau (inline, nur eigener Origin als Frame; `?download=1`) – Session + `import:write` (DB-autoritativ) + Modul + Dokument-Sichtbarkeit |
|
||||||
|
| `POST /api/v1/work-orders/import` | multipart `file` → `201 { id, status }` |
|
||||||
|
| `GET /api/v1/imports/[id]` | Status, Extraktion inkl. Konfidenzen, Kandidaten |
|
||||||
|
| `POST /api/v1/imports/[id]/confirm` | JSON = Prüfformular (`lib/imports/review.ts#reviewFormSchema`) → `{ workOrderId, workOrderNumber, customerId, siteId, contactId }` |
|
||||||
|
|
||||||
|
Fehler-Mapping API: `not_found` 404, `forbidden` 403, `invalid` 400 (mit Feldpfaden), `conflict` 409, ohne Session 401.
|
||||||
|
|
||||||
|
## Dateien
|
||||||
|
|
||||||
|
- `src/lib/imports/` – `extraction.ts` (Felder, Zod, JSON-Schema, gespeicherte Form), `plausibility.ts`, `review.ts` (Formularschema, Mapping, Feld-Konfidenzen, Korrektur-Diff), `status.ts`
|
||||||
|
- `src/server/ai/extraction/` – `anthropic.ts` (Provider + `getExtractionProvider`), `fake.ts`
|
||||||
|
- `src/server/services/imports/` – `upload.ts`, `process.ts`, `confirm.ts` (inkl. Verwerfen/Neu verarbeiten), `queries.ts`, Stubs `document-store-stub.ts`, `duplicates-stub.ts`, `work-orders-stub.ts`
|
||||||
|
- `src/server/jobs/processors/import-extraction.ts`
|
||||||
|
- `src/server/actions/imports/imports.ts` (confirm, discard, retry, Kundensuche – je `moduleGuard("imports")` + `await guard(...)`)
|
||||||
|
- `src/app/(app)/imports/page.tsx`, `src/app/(app)/imports/[id]/page.tsx`, `src/app/(app)/imports/[id]/file/route.ts`
|
||||||
|
- `src/app/api/v1/imports/_context.ts`, `src/app/api/v1/imports/[id]/route.ts`, `src/app/api/v1/imports/[id]/confirm/route.ts`, `src/app/api/v1/work-orders/import/route.ts`
|
||||||
|
- `src/components/imports/` – `uploader.tsx`, `review-form.tsx`, `status-pill.tsx`, `job-actions.tsx`, `auto-refresh.tsx`
|
||||||
|
- `messages/de/imports.json`, `messages/en/imports.json`
|
||||||
|
- `scripts/make-sample-pdfs.ts` → `docs/craftvia/samples/01-musterbau-auftragsbestaetigung.pdf`, `02-elbblick-wartungsauftrag.pdf`, `03-privatkunde-reparatur.pdf` (fiktiv; Nr. 3 enthält absichtlich PLZ „2148“, E-Mail „(at)“ und Ende vor Beginn für die Plausibilitätshinweise)
|
||||||
|
- Tests: `scripts/test-import-rules.ts`, `scripts/test-import-flow.ts`, `scripts/test-import-live.ts`
|
||||||
|
|
||||||
|
Fremd-Einzeiler: `src/server/jobs/processors/index.ts` (Processor-Registrierung). `src/lib/nav.ts` enthielt `/imports` bereits.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
| Skript | Prüfungen | Inhalt |
|
||||||
|
|---|---|---|
|
||||||
|
| `test-import-rules.ts` | 55 | Plausibilitätsregeln, Parsing der Provider-Ausgabe, JSON-Schema (strict-tauglich), Mapping Extraktion → Formular, Formularvalidierung, Korrektur-Diff, Normalisierung Dubletten, Magic Bytes/Dateiname, PDF-Generator |
|
||||||
|
| `test-import-flow.ts` | 75 | Upload/Validierung, Verarbeitung mit Fake-Provider, Dubletten-/Objektkandidaten, AiGeneration, kein Auftrag ohne Bestätigung, Idempotenz; **Rollen** (Monteur/Teamleiter → `forbidden`); **Mandantentrennung** (B kann A weder lesen, laden, bestätigen, verwerfen, neu verarbeiten noch A-Kunden zuordnen; Kandidaten/Suche nur im Mandanten); Bestätigung bestehender vs. neuer Kunde inkl. Dokumentverknüpfung, Korrekturen, Audit; Doppelbestätigung → conflict; vergebene Kundennummer → conflict ohne Teilanlage; Verwerfen; Provider-Fehler/Datei fehlt/Dispatch-Fehler → failed + Neu verarbeiten; ohne Provider → manuell |
|
||||||
|
| `test-import-live.ts` | 6 | nur mit `ANTHROPIC_API_KEY` (sonst übersprungen): echte Extraktion einer generierten Auftragsbestätigung |
|
||||||
|
|
||||||
|
## Gate
|
||||||
|
|
||||||
|
`npm run gate` grün (Lane-DB `craftvia_import`, `RLS_DATABASE_URL` auf dieselbe DB): prisma generate, `tsc` ohne Fehler, Lint 0 Fehler (2 Warnungen im Fundament-Platzhalter `services/notifications/handle-event.ts`), Build inkl. Modul-Guard-Check (14 Action-Dateien), **25/25 Testskripte grün**. `test-import-live.ts` ohne `ANTHROPIC_API_KEY` übersprungen (Exit 0) – die Live-Extraktion gegen Claude ist damit **nicht** verifiziert.
|
||||||
|
|
||||||
|
## Smoke (Dev-Server :3103, curl mit Seed-Logins)
|
||||||
|
|
||||||
|
| Prüfung | Ergebnis |
|
||||||
|
|---|---|
|
||||||
|
| ohne Session: `/imports`, `/imports/[id]`, `/imports/[id]/file`, `/api/v1/**` | 307 → `/login` |
|
||||||
|
| Backoffice `GET /imports` | 200, Upload-Bereich gerendert |
|
||||||
|
| Backoffice `POST /api/v1/work-orders/import` (Beispiel-PDF 01) | 201, ohne API-Key direkt `review_required` mit Hinweis „manuell erfassen“ |
|
||||||
|
| Upload `package.json` | 400 |
|
||||||
|
| `GET /api/v1/imports/[id]` / unbekannte ID | 200 / 404 |
|
||||||
|
| `GET /imports/[id]` | 200, Prüfmaske + Originaldokument gerendert |
|
||||||
|
| `GET /imports/[id]/file` | 200, `inline`, **aber `X-Frame-Options: DENY`** (siehe Bedarf Punkt 2) |
|
||||||
|
| `POST /api/v1/imports/[id]/confirm` mit `{}` | 400 mit Feldpfaden |
|
||||||
|
| Monteur: API / Datei / Upload | 403 / 404 / 403 |
|
||||||
|
|
||||||
|
Keine visuelle Browser-Prüfung (Login-Maske würde Passworteingabe durch den Agenten erfordern) – Layout 1024/768/375 px noch manuell prüfen. Der Smoke hinterlässt einen Import im Mandanten `demo` der Lane-DB.
|
||||||
|
|
||||||
|
## Stubs / Abhängigkeiten zu anderen Lanes
|
||||||
|
|
||||||
|
| Stub (L3-Pfad) | Vertrag | Ablösen |
|
||||||
|
|---|---|---|
|
||||||
|
| `services/imports/document-store-stub.ts#storeFile` | ARCHITEKTUR §4.3 `services/documents/store.ts#storeFile` (auf dem Basis-Commit nicht vorhanden; keiner Lane zugeordnet) | Import in `upload.ts` umstellen; `readDocumentBytes` durch Service-Funktion ersetzen |
|
||||||
|
| `services/imports/duplicates-stub.ts#findDuplicateCustomers` | L1 `lib/customers/duplicates.ts#findDuplicateCustomers(db, candidate) → { customerId, score, reasons[] }[]` | Import in `process.ts` umstellen (`findSiteCandidates` bleibt L3) |
|
||||||
|
| `services/imports/work-orders-stub.ts#createWorkOrder` + `CreateWorkOrderInput` | L2 `createWorkOrder(ctx, input)`; Stub schreibt Status-Historie direkt statt über `transitionWorkOrder` | Import in `confirm.ts` umstellen; L2-Service muss einen Transaktions-Client in `ctx.db` akzeptieren und `sourceImportId`, Status `planned` (Historie ab `review_required`) und Materialvorgabe unterstützen |
|
||||||
|
| `app/api/v1/imports/_context.ts#importsApiContext` | gemeinsames `requireApiContext` (in `services/context.ts` erwähnt, nicht vorhanden) | durch zentrale Funktion ersetzen |
|
||||||
|
|
||||||
|
Links in andere Lanes: `/work-orders/[id]` (L2), `/customers/[id]` für das Zusammenführen (L1, Merge mit Bestätigung).
|
||||||
|
Hinweis Pfad: `src/app/api/v1/work-orders/import/route.ts` liegt im L2-Ordner `api/v1/work-orders` (vom Auftrag so gefordert) – neue Datei, kein Konflikt mit L2-Dateien zu erwarten.
|
||||||
|
|
||||||
|
## Bedarf an Fundament / Architektur (nicht geändert)
|
||||||
|
|
||||||
|
1. **Upload > 10 MB:** `src/proxy.ts` puffert Request-Bodies standardmäßig nur bis 10 MB (`proxyClientMaxBodySize`, danach wird der Body still abgeschnitten). Für die geforderten 25 MB in `next.config.ts` `experimental.proxyClientMaxBodySize: "26mb"` setzen. Server Actions werden für Uploads bewusst nicht genutzt (1-MB-Limit).
|
||||||
|
2. **PDF-Vorschau im iframe:** globale Header in `next.config.ts` setzen `X-Frame-Options: DENY` und `frame-ancestors 'none'` für alle Pfade. Die Datei-Route setzt `SAMEORIGIN`/`frame-ancestors 'self'`, **im Smoke verifiziert: der globale Header gewinnt (`X-Frame-Options: DENY`) → die iframe-Vorschau bleibt im Browser leer**; „In neuem Tab öffnen“ funktioniert. `object`/`embed` unterliegen derselben Regel, daher kein Umweg in L3. Fix im Fundament: in `next.config.ts` für `/imports/:id/file` `X-Frame-Options: SAMEORIGIN` und `frame-ancestors 'self'` statt `DENY`/`'none'` ausliefern. Außerdem braucht die Vorschau echte Bytes → `S3_*` muss gesetzt sein (ohne S3 speichert der Storage-Stub keine Bytes; Verarbeitung mit Provider endet dann mit `file_unavailable`).
|
||||||
|
3. **RLS_ENFORCED=true:** `dbForTenant` öffnet je Operation eine eigene Transaktion; innerhalb von `ctx.db.$transaction` (Bestätigung) ist das nicht atomar. Betrifft alle Lanes mit Transaktionen – zentral in `db.ts` lösen.
|
||||||
|
4. `documents/store.ts` (§4.3) ist keiner Lane zugeordnet – Zuständigkeit klären.
|
||||||
|
5. **Eigene Lane-Datenbank und RLS-Test:** `scripts/test-rls-enforcement.ts` legt Fixtures über `DATABASE_URL` an, verbindet `craftvia_app` aber ohne `RLS_DATABASE_URL` fest auf die DB `craftvia`. Mit `DATABASE_URL=…/craftvia_import` schlägt der Test deshalb fehl (0 Zeilen, FK-Verletzung) – kein Codefehler. Gate daher mit `RLS_DATABASE_URL=postgresql://craftvia_app:craftvia_app_local@localhost:5432/craftvia_import?schema=public` ausgeführt; der Test ist damit grün. Vorschlag Fundament: Default-URL aus `DATABASE_URL` ableiten.
|
||||||
|
|
||||||
|
## Bekannte Lücken
|
||||||
|
|
||||||
|
- Keine Migration nötig: Hinweise und Objektkandidaten liegen in `ImportJob.extraction` (`{ fields, hints, siteCandidates }`), Dublettenkandidaten in `duplicate_candidates`.
|
||||||
|
- Malware-Scan nur Magic-Byte-/Typprüfung (ClamAV-Hook gehört zum Dokumentservice §4.3).
|
||||||
|
- Kostenlimit/Region/Aufbewahrung je Mandant (§31) nicht umgesetzt; Konfiguration nur per Env.
|
||||||
|
- Kundensuche in der Prüfmaske: Top 10 nach Name/Nummer/Ort/E-Mail; kein Paging.
|
||||||
|
- Keine Teamzuweisung/Auftragsart in der Prüfmaske (erfolgt danach im Auftrag, L2).
|
||||||
|
- Worker: mit `REDIS_URL` läuft die Extraktion in `npm run worker:craftvia`; ohne laufenden Worker bleibt ein Import auf „Hochgeladen“.
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
{
|
||||||
|
"crumb": "Aufträge",
|
||||||
|
"title": "Auftragsimport",
|
||||||
|
"sub": "Auftragsbestätigung hochladen, erkannte Daten prüfen und den Auftrag anlegen. Ohne Bestätigung entsteht kein Auftrag.",
|
||||||
|
"noPermission": "Für den Auftragsimport fehlt die Berechtigung.",
|
||||||
|
"upload": {
|
||||||
|
"title": "Dokument hochladen",
|
||||||
|
"drop": "PDF, JPG oder PNG hierher ziehen",
|
||||||
|
"or": "oder",
|
||||||
|
"choose": "Datei auswählen",
|
||||||
|
"limits": "PDF bis 25 MB, Bilder bis 15 MB. Das Original bleibt am Auftrag gespeichert.",
|
||||||
|
"uploading": "Wird hochgeladen … {percent} %",
|
||||||
|
"done": "Hochgeladen. Daten werden erkannt.",
|
||||||
|
"errors": {
|
||||||
|
"file_type_not_allowed": "Dateityp nicht erlaubt. Erlaubt sind PDF, JPG und PNG.",
|
||||||
|
"file_too_large": "Datei ist zu groß.",
|
||||||
|
"file_type_mismatch": "Dateiinhalt passt nicht zum Dateityp.",
|
||||||
|
"file_empty": "Datei ist leer.",
|
||||||
|
"file_missing": "Keine Datei ausgewählt.",
|
||||||
|
"forbidden": "Keine Berechtigung.",
|
||||||
|
"unauthorized": "Sitzung abgelaufen. Bitte neu anmelden.",
|
||||||
|
"network": "Hochladen fehlgeschlagen. Verbindung prüfen.",
|
||||||
|
"error": "Hochladen fehlgeschlagen."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "Importe",
|
||||||
|
"empty": "Noch keine Importe. Laden Sie eine Auftragsbestätigung hoch.",
|
||||||
|
"file": "Datei",
|
||||||
|
"uploadedAt": "Hochgeladen",
|
||||||
|
"uploadedBy": "Von",
|
||||||
|
"status": "Status",
|
||||||
|
"action": "Aktion",
|
||||||
|
"review": "Prüfen",
|
||||||
|
"open": "Ansehen",
|
||||||
|
"workOrder": "Auftrag {number}"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"uploaded": "Hochgeladen",
|
||||||
|
"processing": "Verarbeitung",
|
||||||
|
"review_required": "Prüfung erforderlich",
|
||||||
|
"confirmed": "Bestätigt",
|
||||||
|
"failed": "Fehlgeschlagen",
|
||||||
|
"discarded": "Verworfen"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"retry": "Neu verarbeiten",
|
||||||
|
"retrying": "Wird gestartet …",
|
||||||
|
"discard": "Verwerfen",
|
||||||
|
"discardConfirm": "Import verwerfen? Es wird kein Auftrag angelegt. Das Originaldokument bleibt gespeichert.",
|
||||||
|
"confirm": "Bestätigen und Auftrag anlegen",
|
||||||
|
"confirming": "Auftrag wird angelegt …"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"back": "Zurück zu den Importen",
|
||||||
|
"meta": "{file} · hochgeladen am {date} von {user}",
|
||||||
|
"model": "Erkannt mit {model}",
|
||||||
|
"preview": "Originaldokument",
|
||||||
|
"previewTitle": "Vorschau des Originaldokuments",
|
||||||
|
"openNewTab": "In neuem Tab öffnen",
|
||||||
|
"download": "Herunterladen",
|
||||||
|
"processing": "Das Dokument wird verarbeitet. Die Seite aktualisiert sich automatisch.",
|
||||||
|
"failed": "Die Verarbeitung ist fehlgeschlagen.",
|
||||||
|
"failedDetail": "Technischer Hinweis: {message}",
|
||||||
|
"confirmed": "Import bestätigt. Auftrag {number} wurde angelegt.",
|
||||||
|
"openWorkOrder": "Auftrag öffnen",
|
||||||
|
"corrections": "{count, plural, =0 {Keine Korrekturen} one {1 Feld korrigiert} other {# Felder korrigiert}}",
|
||||||
|
"discarded": "Import verworfen. Es wurde kein Auftrag angelegt.",
|
||||||
|
"extractedText": "Erkannter Text",
|
||||||
|
"noText": "Kein Text erkannt."
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"title": "Erkannte Daten prüfen",
|
||||||
|
"intro": "Unsichere Felder sind markiert. Bitte prüfen und bei Bedarf korrigieren.",
|
||||||
|
"manualEntry": "Keine automatische Erkennung verfügbar. Bitte Daten manuell erfassen.",
|
||||||
|
"hintsTitle": "Hinweise zur Plausibilität",
|
||||||
|
"uncertain": "unsicher",
|
||||||
|
"confidence": "Sicherheit {percent} %",
|
||||||
|
"source": "Quelle: {text}",
|
||||||
|
"required": "Pflichtfeld",
|
||||||
|
"sections": {
|
||||||
|
"customer": "Kunde",
|
||||||
|
"site": "Objekt",
|
||||||
|
"contact": "Ansprechpartner",
|
||||||
|
"order": "Auftrag",
|
||||||
|
"positions": "Positionen und Material"
|
||||||
|
},
|
||||||
|
"customer": {
|
||||||
|
"existing": "Bestehenden Kunden verwenden",
|
||||||
|
"new": "Neuen Kunden anlegen",
|
||||||
|
"candidates": "Mögliche Treffer im Kundenstamm",
|
||||||
|
"noCandidates": "Keine passenden Kunden gefunden.",
|
||||||
|
"match": "Übereinstimmung {percent} %",
|
||||||
|
"merge": "Dubletten im Kundenstamm zusammenführen",
|
||||||
|
"search": "Kunden suchen",
|
||||||
|
"searchPlaceholder": "Name, Kundennummer oder Ort",
|
||||||
|
"searchButton": "Suchen",
|
||||||
|
"searchEmpty": "Keine Kunden gefunden.",
|
||||||
|
"select": "Auswählen"
|
||||||
|
},
|
||||||
|
"site": {
|
||||||
|
"none": "Kein Objekt zuordnen",
|
||||||
|
"existing": "Bestehendes Objekt verwenden",
|
||||||
|
"new": "Neues Objekt anlegen",
|
||||||
|
"noSites": "Für den gewählten Kunden sind keine Objekte angelegt.",
|
||||||
|
"chooseCustomer": "Zuerst einen bestehenden Kunden auswählen.",
|
||||||
|
"suggested": "Gleiche Adresse"
|
||||||
|
},
|
||||||
|
"reasons": {
|
||||||
|
"customer_number": "Kundennummer",
|
||||||
|
"company_name": "Firmenname",
|
||||||
|
"company_name_similar": "ähnlicher Firmenname",
|
||||||
|
"person_name": "Name",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"phone": "Telefon",
|
||||||
|
"address": "Adresse",
|
||||||
|
"house_number": "Hausnummer",
|
||||||
|
"site_name": "Objektname"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"customerNumber": "Kundennummer",
|
||||||
|
"companyName": "Firmenname",
|
||||||
|
"firstName": "Vorname",
|
||||||
|
"lastName": "Nachname",
|
||||||
|
"street": "Straße",
|
||||||
|
"houseNumber": "Hausnummer",
|
||||||
|
"postalCode": "PLZ",
|
||||||
|
"city": "Ort",
|
||||||
|
"country": "Land",
|
||||||
|
"phone": "Telefon",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"name": "Name",
|
||||||
|
"siteName": "Objektbezeichnung",
|
||||||
|
"title": "Auftragstitel",
|
||||||
|
"externalOrderNumber": "Auftragsnummer (Dokument)",
|
||||||
|
"offerNumber": "Angebotsnummer",
|
||||||
|
"description": "Leistungsbeschreibung",
|
||||||
|
"plannedStart": "Geplanter Beginn",
|
||||||
|
"plannedEnd": "Geplantes Ende",
|
||||||
|
"notes": "Besondere Hinweise"
|
||||||
|
},
|
||||||
|
"extractionFields": {
|
||||||
|
"orderNumber": "Auftragsnummer",
|
||||||
|
"offerNumber": "Angebotsnummer",
|
||||||
|
"customerNumber": "Kundennummer",
|
||||||
|
"companyName": "Firmenname",
|
||||||
|
"customerFirstName": "Vorname",
|
||||||
|
"customerLastName": "Nachname",
|
||||||
|
"customerAddress": "Kundenadresse",
|
||||||
|
"siteName": "Objekt",
|
||||||
|
"siteAddress": "Objektadresse",
|
||||||
|
"contactName": "Ansprechpartner",
|
||||||
|
"phone": "Telefon",
|
||||||
|
"email": "E-Mail",
|
||||||
|
"orderDate": "Auftragsdatum",
|
||||||
|
"documentDate": "Dokumentdatum",
|
||||||
|
"plannedStart": "Geplanter Beginn",
|
||||||
|
"plannedEnd": "Geplantes Ende",
|
||||||
|
"title": "Titel",
|
||||||
|
"description": "Leistungsbeschreibung",
|
||||||
|
"positions": "Positionen",
|
||||||
|
"notes": "Hinweise",
|
||||||
|
"totalAmount": "Gesamtbetrag",
|
||||||
|
"references": "Referenzen"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"date_invalid": "{field}: Datum nicht lesbar.",
|
||||||
|
"date_implausible": "{field}: Datum liegt ungewöhnlich weit in Vergangenheit oder Zukunft.",
|
||||||
|
"postal_code_invalid": "{field}: Postleitzahl ist nicht 5-stellig.",
|
||||||
|
"email_invalid": "{field}: E-Mail-Adresse hat kein gültiges Format.",
|
||||||
|
"phone_invalid": "{field}: Telefonnummer hat kein gültiges Format.",
|
||||||
|
"end_before_start": "{field}: Ende liegt vor dem Beginn.",
|
||||||
|
"manual_entry": "Keine automatische Erkennung verfügbar. Bitte manuell erfassen."
|
||||||
|
},
|
||||||
|
"positions": {
|
||||||
|
"name": "Bezeichnung",
|
||||||
|
"articleNumber": "Artikelnr.",
|
||||||
|
"quantity": "Menge",
|
||||||
|
"unit": "Einheit",
|
||||||
|
"asMaterial": "als Materialvorgabe übernehmen",
|
||||||
|
"remove": "Position entfernen",
|
||||||
|
"add": "Position hinzufügen",
|
||||||
|
"empty": "Keine Positionen erkannt."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"form_invalid": "Bitte die markierten Felder prüfen.",
|
||||||
|
"title_required": "Auftragstitel fehlt.",
|
||||||
|
"customer_required": "Bitte einen Kunden auswählen.",
|
||||||
|
"customer_name_required": "Firmenname oder Nachname angeben.",
|
||||||
|
"site_required": "Bitte ein Objekt auswählen.",
|
||||||
|
"site_name_required": "Objektbezeichnung oder Straße angeben.",
|
||||||
|
"site_requires_existing_customer": "Ein bestehendes Objekt setzt einen bestehenden Kunden voraus.",
|
||||||
|
"email_invalid": "E-Mail-Adresse hat kein gültiges Format.",
|
||||||
|
"postal_code_invalid": "Postleitzahl muss 5-stellig sein.",
|
||||||
|
"end_before_start": "Ende liegt vor dem Beginn.",
|
||||||
|
"date_invalid": "Datum nicht lesbar.",
|
||||||
|
"quantity_invalid": "Menge muss eine Zahl sein.",
|
||||||
|
"customer_number_taken": "Diese Kundennummer ist bereits vergeben. Bestehenden Kunden verwenden oder Nummer leeren.",
|
||||||
|
"import_not_reviewable": "Dieser Import wurde bereits bearbeitet.",
|
||||||
|
"import_not_discardable": "Dieser Import kann nicht mehr verworfen werden.",
|
||||||
|
"import_not_retryable": "Nur fehlgeschlagene Importe können neu verarbeitet werden.",
|
||||||
|
"not_found": "Datensatz nicht gefunden.",
|
||||||
|
"forbidden": "Keine Berechtigung für diese Aktion.",
|
||||||
|
"file_unavailable": "Originaldatei ist nicht verfügbar.",
|
||||||
|
"dispatch_failed": "Verarbeitung konnte nicht gestartet werden.",
|
||||||
|
"error": "Vorgang nicht möglich."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
{
|
||||||
|
"crumb": "Work orders",
|
||||||
|
"title": "Order import",
|
||||||
|
"sub": "Upload an order confirmation, check the recognised data and create the work order. Nothing is created without confirmation.",
|
||||||
|
"noPermission": "You are not allowed to import orders.",
|
||||||
|
"upload": {
|
||||||
|
"title": "Upload document",
|
||||||
|
"drop": "Drop a PDF, JPG or PNG here",
|
||||||
|
"or": "or",
|
||||||
|
"choose": "Choose file",
|
||||||
|
"limits": "PDF up to 25 MB, images up to 15 MB. The original stays attached to the work order.",
|
||||||
|
"uploading": "Uploading … {percent} %",
|
||||||
|
"done": "Uploaded. Recognising data.",
|
||||||
|
"errors": {
|
||||||
|
"file_type_not_allowed": "File type not allowed. Allowed: PDF, JPG and PNG.",
|
||||||
|
"file_too_large": "File is too large.",
|
||||||
|
"file_type_mismatch": "File content does not match its type.",
|
||||||
|
"file_empty": "File is empty.",
|
||||||
|
"file_missing": "No file selected.",
|
||||||
|
"forbidden": "Not allowed.",
|
||||||
|
"unauthorized": "Session expired. Please sign in again.",
|
||||||
|
"network": "Upload failed. Check the connection.",
|
||||||
|
"error": "Upload failed."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"list": {
|
||||||
|
"title": "Imports",
|
||||||
|
"empty": "No imports yet. Upload an order confirmation.",
|
||||||
|
"file": "File",
|
||||||
|
"uploadedAt": "Uploaded",
|
||||||
|
"uploadedBy": "By",
|
||||||
|
"status": "Status",
|
||||||
|
"action": "Action",
|
||||||
|
"review": "Review",
|
||||||
|
"open": "View",
|
||||||
|
"workOrder": "Work order {number}"
|
||||||
|
},
|
||||||
|
"status": {
|
||||||
|
"uploaded": "Uploaded",
|
||||||
|
"processing": "Processing",
|
||||||
|
"review_required": "Review required",
|
||||||
|
"confirmed": "Confirmed",
|
||||||
|
"failed": "Failed",
|
||||||
|
"discarded": "Discarded"
|
||||||
|
},
|
||||||
|
"actions": {
|
||||||
|
"retry": "Process again",
|
||||||
|
"retrying": "Starting …",
|
||||||
|
"discard": "Discard",
|
||||||
|
"discardConfirm": "Discard this import? No work order will be created. The original document is kept.",
|
||||||
|
"confirm": "Confirm and create work order",
|
||||||
|
"confirming": "Creating work order …"
|
||||||
|
},
|
||||||
|
"detail": {
|
||||||
|
"back": "Back to imports",
|
||||||
|
"meta": "{file} · uploaded on {date} by {user}",
|
||||||
|
"model": "Recognised with {model}",
|
||||||
|
"preview": "Original document",
|
||||||
|
"previewTitle": "Preview of the original document",
|
||||||
|
"openNewTab": "Open in new tab",
|
||||||
|
"download": "Download",
|
||||||
|
"processing": "The document is being processed. This page refreshes automatically.",
|
||||||
|
"failed": "Processing failed.",
|
||||||
|
"failedDetail": "Technical detail: {message}",
|
||||||
|
"confirmed": "Import confirmed. Work order {number} was created.",
|
||||||
|
"openWorkOrder": "Open work order",
|
||||||
|
"corrections": "{count, plural, =0 {No corrections} one {1 field corrected} other {# fields corrected}}",
|
||||||
|
"discarded": "Import discarded. No work order was created.",
|
||||||
|
"extractedText": "Recognised text",
|
||||||
|
"noText": "No text recognised."
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"title": "Check recognised data",
|
||||||
|
"intro": "Uncertain fields are marked. Please check and correct them if needed.",
|
||||||
|
"manualEntry": "Automatic recognition is not available. Please enter the data manually.",
|
||||||
|
"hintsTitle": "Plausibility notes",
|
||||||
|
"uncertain": "uncertain",
|
||||||
|
"confidence": "Confidence {percent} %",
|
||||||
|
"source": "Source: {text}",
|
||||||
|
"required": "Required",
|
||||||
|
"sections": {
|
||||||
|
"customer": "Customer",
|
||||||
|
"site": "Site",
|
||||||
|
"contact": "Contact person",
|
||||||
|
"order": "Work order",
|
||||||
|
"positions": "Line items and material"
|
||||||
|
},
|
||||||
|
"customer": {
|
||||||
|
"existing": "Use existing customer",
|
||||||
|
"new": "Create new customer",
|
||||||
|
"candidates": "Possible matches",
|
||||||
|
"noCandidates": "No matching customers found.",
|
||||||
|
"match": "Match {percent} %",
|
||||||
|
"merge": "Merge duplicates in customer records",
|
||||||
|
"search": "Search customers",
|
||||||
|
"searchPlaceholder": "Name, customer number or city",
|
||||||
|
"searchButton": "Search",
|
||||||
|
"searchEmpty": "No customers found.",
|
||||||
|
"select": "Select"
|
||||||
|
},
|
||||||
|
"site": {
|
||||||
|
"none": "No site",
|
||||||
|
"existing": "Use existing site",
|
||||||
|
"new": "Create new site",
|
||||||
|
"noSites": "The selected customer has no sites.",
|
||||||
|
"chooseCustomer": "Select an existing customer first.",
|
||||||
|
"suggested": "Same address"
|
||||||
|
},
|
||||||
|
"reasons": {
|
||||||
|
"customer_number": "customer number",
|
||||||
|
"company_name": "company name",
|
||||||
|
"company_name_similar": "similar company name",
|
||||||
|
"person_name": "name",
|
||||||
|
"email": "e-mail",
|
||||||
|
"phone": "phone",
|
||||||
|
"address": "address",
|
||||||
|
"house_number": "house number",
|
||||||
|
"site_name": "site name"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"customerNumber": "Customer number",
|
||||||
|
"companyName": "Company name",
|
||||||
|
"firstName": "First name",
|
||||||
|
"lastName": "Last name",
|
||||||
|
"street": "Street",
|
||||||
|
"houseNumber": "House number",
|
||||||
|
"postalCode": "Postal code",
|
||||||
|
"city": "City",
|
||||||
|
"country": "Country",
|
||||||
|
"phone": "Phone",
|
||||||
|
"email": "E-mail",
|
||||||
|
"name": "Name",
|
||||||
|
"siteName": "Site name",
|
||||||
|
"title": "Work order title",
|
||||||
|
"externalOrderNumber": "Order number (document)",
|
||||||
|
"offerNumber": "Offer number",
|
||||||
|
"description": "Description of services",
|
||||||
|
"plannedStart": "Planned start",
|
||||||
|
"plannedEnd": "Planned end",
|
||||||
|
"notes": "Special notes"
|
||||||
|
},
|
||||||
|
"extractionFields": {
|
||||||
|
"orderNumber": "Order number",
|
||||||
|
"offerNumber": "Offer number",
|
||||||
|
"customerNumber": "Customer number",
|
||||||
|
"companyName": "Company name",
|
||||||
|
"customerFirstName": "First name",
|
||||||
|
"customerLastName": "Last name",
|
||||||
|
"customerAddress": "Customer address",
|
||||||
|
"siteName": "Site",
|
||||||
|
"siteAddress": "Site address",
|
||||||
|
"contactName": "Contact person",
|
||||||
|
"phone": "Phone",
|
||||||
|
"email": "E-mail",
|
||||||
|
"orderDate": "Order date",
|
||||||
|
"documentDate": "Document date",
|
||||||
|
"plannedStart": "Planned start",
|
||||||
|
"plannedEnd": "Planned end",
|
||||||
|
"title": "Title",
|
||||||
|
"description": "Description",
|
||||||
|
"positions": "Line items",
|
||||||
|
"notes": "Notes",
|
||||||
|
"totalAmount": "Total amount",
|
||||||
|
"references": "References"
|
||||||
|
},
|
||||||
|
"hints": {
|
||||||
|
"date_invalid": "{field}: date not readable.",
|
||||||
|
"date_implausible": "{field}: date is unusually far in the past or future.",
|
||||||
|
"postal_code_invalid": "{field}: postal code does not have 5 digits.",
|
||||||
|
"email_invalid": "{field}: e-mail address format is invalid.",
|
||||||
|
"phone_invalid": "{field}: phone number format is invalid.",
|
||||||
|
"end_before_start": "{field}: end is before start.",
|
||||||
|
"manual_entry": "Automatic recognition is not available. Please enter manually."
|
||||||
|
},
|
||||||
|
"positions": {
|
||||||
|
"name": "Description",
|
||||||
|
"articleNumber": "Article no.",
|
||||||
|
"quantity": "Quantity",
|
||||||
|
"unit": "Unit",
|
||||||
|
"asMaterial": "use as planned material",
|
||||||
|
"remove": "Remove line item",
|
||||||
|
"add": "Add line item",
|
||||||
|
"empty": "No line items recognised."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"form_invalid": "Please check the marked fields.",
|
||||||
|
"title_required": "Work order title is missing.",
|
||||||
|
"customer_required": "Please select a customer.",
|
||||||
|
"customer_name_required": "Enter a company name or last name.",
|
||||||
|
"site_required": "Please select a site.",
|
||||||
|
"site_name_required": "Enter a site name or street.",
|
||||||
|
"site_requires_existing_customer": "An existing site requires an existing customer.",
|
||||||
|
"email_invalid": "E-mail address format is invalid.",
|
||||||
|
"postal_code_invalid": "Postal code must have 5 digits.",
|
||||||
|
"end_before_start": "End is before start.",
|
||||||
|
"date_invalid": "Date not readable.",
|
||||||
|
"quantity_invalid": "Quantity must be a number.",
|
||||||
|
"customer_number_taken": "This customer number is already taken. Use the existing customer or clear the number.",
|
||||||
|
"import_not_reviewable": "This import has already been handled.",
|
||||||
|
"import_not_discardable": "This import can no longer be discarded.",
|
||||||
|
"import_not_retryable": "Only failed imports can be processed again.",
|
||||||
|
"not_found": "Record not found.",
|
||||||
|
"forbidden": "Not allowed.",
|
||||||
|
"file_unavailable": "The original file is not available.",
|
||||||
|
"dispatch_failed": "Processing could not be started.",
|
||||||
|
"error": "Action not possible."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { storage } from "@/server/storage/adapter";
|
||||||
|
import { ctxFromGuard } from "@/server/services/context";
|
||||||
|
import { getImportFile } from "@/server/services/imports/queries";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /imports/[id]/file — original document of an import for the review mask preview.
|
||||||
|
* Authorisation: session + DB-authoritative permission import:write + module + document
|
||||||
|
* visibility (getImportFile). Unknown/foreign ids → 404. `?download=1` forces a download.
|
||||||
|
* Inline delivery only for the allowlisted types stored by the import (PDF/JPEG/PNG) and
|
||||||
|
* only framable by the own origin.
|
||||||
|
*/
|
||||||
|
const INLINE = new Set(["application/pdf", "image/jpeg", "image/png"]);
|
||||||
|
|
||||||
|
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
let file: { storageKey: string; mimeType: string; fileName: string };
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await moduleGuard("imports")("import:write"));
|
||||||
|
const { id } = await params;
|
||||||
|
file = await getImportFile(ctx, id);
|
||||||
|
} catch {
|
||||||
|
return new Response("Nicht gefunden.", { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = await storage.get(file.storageKey);
|
||||||
|
if (!content) return new Response("Datei nicht verfügbar.", { status: 404 });
|
||||||
|
|
||||||
|
const download = new URL(req.url).searchParams.get("download") === "1";
|
||||||
|
const inline = !download && INLINE.has(file.mimeType);
|
||||||
|
const asciiName = file.fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_");
|
||||||
|
const headers = new Headers({
|
||||||
|
"Content-Type": INLINE.has(file.mimeType) ? file.mimeType : "application/octet-stream",
|
||||||
|
"Content-Disposition": `${inline ? "inline" : "attachment"}; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(file.fileName)}`,
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
"Content-Security-Policy": "frame-ancestors 'self'",
|
||||||
|
"X-Frame-Options": "SAMEORIGIN",
|
||||||
|
});
|
||||||
|
if (content.size != null) headers.set("Content-Length", String(content.size));
|
||||||
|
return new Response(content.stream, { headers });
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
import { ArrowLeft, CheckCircle2, Download, ExternalLink, Info, Loader2, XCircle } from "lucide-react";
|
||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { dbForTenant } from "@/server/db";
|
||||||
|
import { hasPermission } from "@/server/rbac";
|
||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { getImportDetail } from "@/server/services/imports/queries";
|
||||||
|
import { confirmImportAction, discardImportAction, retryImportAction, searchCustomersAction } from "@/server/actions/imports/imports";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { ImportStatusPill } from "@/components/imports/status-pill";
|
||||||
|
import { AutoRefresh } from "@/components/imports/auto-refresh";
|
||||||
|
import { DiscardImportButton, RetryImportButton } from "@/components/imports/job-actions";
|
||||||
|
import { ImportReviewForm } from "@/components/imports/review-form";
|
||||||
|
import { extractionToForm, formFieldMeta } from "@/lib/imports/review";
|
||||||
|
import { IMPORT_IN_PROGRESS } from "@/lib/imports/status";
|
||||||
|
|
||||||
|
/** /imports/[id] — review mask: original document left, form right (spec §9.6, US-002/003). */
|
||||||
|
export default async function ImportDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const session = await requireSession();
|
||||||
|
if (!hasPermission(session, "import:write")) redirect("/imports");
|
||||||
|
const { id } = await params;
|
||||||
|
const t = await getTranslations("imports");
|
||||||
|
const locale = await getLocale();
|
||||||
|
const ctx: ServiceCtx = {
|
||||||
|
db: dbForTenant(session.user.tenantId),
|
||||||
|
tenantId: session.user.tenantId,
|
||||||
|
userId: session.user.id,
|
||||||
|
permissions: new Set(session.user.permissions),
|
||||||
|
};
|
||||||
|
|
||||||
|
let detail;
|
||||||
|
try {
|
||||||
|
detail = await getImportDetail(ctx, id);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceError) notFound();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fmt = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
const fileUrl = `/imports/${detail.id}/file`;
|
||||||
|
const isPdf = detail.document?.mimeType === "application/pdf";
|
||||||
|
const fields = detail.extraction.fields;
|
||||||
|
const top = detail.customerCandidates[0];
|
||||||
|
const topSite = top ? detail.siteCandidates.find((s) => s.customerId === top.customerId) : undefined;
|
||||||
|
const inProgress = IMPORT_IN_PROGRESS.includes(detail.status);
|
||||||
|
const errorKey = detail.errorMessage && t.has(`errors.${detail.errorMessage}`) ? `errors.${detail.errorMessage}` : null;
|
||||||
|
const correctionCount =
|
||||||
|
detail.corrections && typeof detail.corrections === "object" && "fields" in detail.corrections
|
||||||
|
? Object.keys((detail.corrections as { fields?: Record<string, unknown> }).fields ?? {}).length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 sm:p-6">
|
||||||
|
<AutoRefresh active={inProgress} />
|
||||||
|
<Link href="/imports" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
|
||||||
|
</Link>
|
||||||
|
<PageHead
|
||||||
|
crumb={t("title")}
|
||||||
|
title={detail.document?.fileName ?? t("title")}
|
||||||
|
sub={t("detail.meta", { file: detail.document?.fileName ?? "—", date: fmt.format(detail.createdAt), user: detail.importedByName ?? "—" })}
|
||||||
|
actions={<ImportStatusPill status={detail.status} label={t(`status.${detail.status}`)} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)]">
|
||||||
|
{/* Original document */}
|
||||||
|
<section aria-labelledby="imp-preview" className="lg:sticky lg:top-20 lg:self-start">
|
||||||
|
<div className="shadow-card rounded-xl border bg-card p-3">
|
||||||
|
<div className="mb-2 flex flex-wrap items-center justify-between gap-2 px-1">
|
||||||
|
<h2 id="imp-preview" className="font-heading text-[15px] font-semibold">{t("detail.preview")}</h2>
|
||||||
|
<div className="flex gap-3 text-[12.5px] font-semibold">
|
||||||
|
<a href={fileUrl} target="_blank" rel="noreferrer" className="inline-flex min-h-11 items-center gap-1 text-[var(--ui-primary)] hover:underline">
|
||||||
|
<ExternalLink className="size-3.5" aria-hidden /> {t("detail.openNewTab")}
|
||||||
|
</a>
|
||||||
|
<a href={`${fileUrl}?download=1`} className="inline-flex min-h-11 items-center gap-1 text-[var(--ui-primary)] hover:underline">
|
||||||
|
<Download className="size-3.5" aria-hidden /> {t("detail.download")}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isPdf ? (
|
||||||
|
<iframe src={fileUrl} title={t("detail.previewTitle")} className="h-[55vh] w-full rounded-lg border bg-muted lg:h-[calc(100vh-11rem)]" />
|
||||||
|
) : (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element -- authenticated same-origin file, no optimisation
|
||||||
|
<img src={fileUrl} alt={t("detail.previewTitle")} className="max-h-[calc(100vh-11rem)] w-full rounded-lg border object-contain" />
|
||||||
|
)}
|
||||||
|
{detail.extractionModel && <p className="mt-2 px-1 text-[12px] text-muted-foreground">{t("detail.model", { model: detail.extractionModel })}</p>}
|
||||||
|
</div>
|
||||||
|
<details className="shadow-card mt-3 rounded-xl border bg-card p-3">
|
||||||
|
<summary className="min-h-11 cursor-pointer content-center px-1 font-heading text-sm font-semibold">{t("detail.extractedText")}</summary>
|
||||||
|
<pre className="mt-2 max-h-80 overflow-auto whitespace-pre-wrap px-1 text-[12px]">{detail.extractedText || t("detail.noText")}</pre>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Status / review mask */}
|
||||||
|
<section aria-labelledby="imp-review">
|
||||||
|
<h2 id="imp-review" className="mb-1 font-heading text-[17px] font-semibold">{t("review.title")}</h2>
|
||||||
|
|
||||||
|
{inProgress && (
|
||||||
|
<div className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5" role="status">
|
||||||
|
<Loader2 className="mt-0.5 size-5 shrink-0 animate-spin text-[var(--info)]" aria-hidden />
|
||||||
|
<p className="text-sm">{t("detail.processing")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.status === "failed" && (
|
||||||
|
<div className="shadow-card space-y-3 rounded-xl border border-[var(--risk)] bg-card p-5" role="alert">
|
||||||
|
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--risk)]">
|
||||||
|
<XCircle className="size-4" aria-hidden /> {t("detail.failed")}
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground">
|
||||||
|
{errorKey ? t(errorKey) : t("detail.failedDetail", { message: detail.errorMessage ?? "—" })}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<RetryImportButton action={retryImportAction.bind(null, detail.id)} />
|
||||||
|
<DiscardImportButton action={discardImportAction.bind(null, detail.id)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.status === "confirmed" && (
|
||||||
|
<div className="shadow-card space-y-2 rounded-xl border border-[var(--ok)] bg-card p-5" role="status">
|
||||||
|
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--ok)]">
|
||||||
|
<CheckCircle2 className="size-4" aria-hidden /> {t("detail.confirmed", { number: detail.createdWorkOrder?.number ?? "—" })}
|
||||||
|
</p>
|
||||||
|
<p className="text-[13px] text-muted-foreground">{t("detail.corrections", { count: correctionCount })}</p>
|
||||||
|
{detail.createdWorkOrder && (
|
||||||
|
<Link href={`/work-orders/${detail.createdWorkOrder.id}`} className="inline-flex min-h-11 items-center rounded-lg bg-[var(--ui-accent)] px-4 font-heading text-[13px] font-semibold text-[var(--ui-accent-foreground)] hover:opacity-90">
|
||||||
|
{t("detail.openWorkOrder")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.status === "discarded" && (
|
||||||
|
<div className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5" role="status">
|
||||||
|
<Info className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<p className="text-sm">{t("detail.discarded")}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detail.status === "review_required" && (
|
||||||
|
<>
|
||||||
|
<p className="mb-3 text-[13px] text-muted-foreground">
|
||||||
|
{detail.extraction.hints.some((h) => h.code === "manual_entry") ? t("review.manualEntry") : t("review.intro")}
|
||||||
|
</p>
|
||||||
|
<ImportReviewForm
|
||||||
|
initial={extractionToForm(fields, { customerId: top && top.score >= 0.6 ? top.customerId : null, siteId: top && top.score >= 0.6 ? topSite?.siteId ?? null : null })}
|
||||||
|
meta={formFieldMeta(fields)}
|
||||||
|
hints={detail.extraction.hints.filter((h) => h.code !== "manual_entry")}
|
||||||
|
customerCandidates={detail.customerCandidates}
|
||||||
|
siteCandidates={detail.siteCandidates}
|
||||||
|
confirmAction={confirmImportAction.bind(null, detail.id)}
|
||||||
|
searchAction={searchCustomersAction}
|
||||||
|
/>
|
||||||
|
<div className="mt-3">
|
||||||
|
<DiscardImportButton action={discardImportAction.bind(null, detail.id)} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,107 @@
|
|||||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
import Link from "next/link";
|
||||||
|
import { getLocale, getTranslations } from "next-intl/server";
|
||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { dbForTenant } from "@/server/db";
|
||||||
|
import { hasPermission } from "@/server/rbac";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { listImports } from "@/server/services/imports/queries";
|
||||||
|
import { retryImportAction } from "@/server/actions/imports/imports";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { ImportUploader } from "@/components/imports/uploader";
|
||||||
|
import { ImportStatusPill } from "@/components/imports/status-pill";
|
||||||
|
import { AutoRefresh } from "@/components/imports/auto-refresh";
|
||||||
|
import { RetryImportButton } from "@/components/imports/job-actions";
|
||||||
|
import { IMPORT_IN_PROGRESS } from "@/lib/imports/status";
|
||||||
|
|
||||||
export default function Page() {
|
/** /imports — upload + list of imports with status (spec §9, US-002). */
|
||||||
return <ModulePlaceholder moduleKey="imports" />;
|
export default async function ImportsPage() {
|
||||||
|
const session = await requireSession();
|
||||||
|
const t = await getTranslations("imports");
|
||||||
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
if (!hasPermission(session, "import:write")) {
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-6">
|
||||||
|
<PageHead crumb={t("crumb")} title={t("title")} />
|
||||||
|
<p className="shadow-card rounded-xl border bg-card p-5 text-sm text-muted-foreground">{t("noPermission")}</p>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx: ServiceCtx = {
|
||||||
|
db: dbForTenant(session.user.tenantId),
|
||||||
|
tenantId: session.user.tenantId,
|
||||||
|
userId: session.user.id,
|
||||||
|
permissions: new Set(session.user.permissions),
|
||||||
|
};
|
||||||
|
const imports = await listImports(ctx);
|
||||||
|
const fmt = new Intl.DateTimeFormat(locale, { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
const inProgress = imports.some((i) => IMPORT_IN_PROGRESS.includes(i.status));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 sm:p-6">
|
||||||
|
<AutoRefresh active={inProgress} />
|
||||||
|
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||||
|
|
||||||
|
<section aria-labelledby="imp-upload" className="max-w-3xl">
|
||||||
|
<h2 id="imp-upload" className="sr-only">{t("upload.title")}</h2>
|
||||||
|
<ImportUploader />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="imp-list" className="mt-6">
|
||||||
|
<h2 id="imp-list" className="mb-3 font-heading text-[15px] font-semibold">{t("list.title")}</h2>
|
||||||
|
{imports.length === 0 ? (
|
||||||
|
<p className="shadow-card rounded-xl border bg-card p-5 text-sm text-muted-foreground">{t("list.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[640px] text-left text-[13px]">
|
||||||
|
<thead className="border-b text-[12px] text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.file")}</th>
|
||||||
|
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.uploadedAt")}</th>
|
||||||
|
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.uploadedBy")}</th>
|
||||||
|
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.status")}</th>
|
||||||
|
<th scope="col" className="px-4 py-2.5 font-semibold">{t("list.action")}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{imports.map((imp) => (
|
||||||
|
<tr key={imp.id} className="border-b last:border-0">
|
||||||
|
<td className="max-w-[280px] truncate px-4 py-2.5 font-semibold">
|
||||||
|
<Link href={`/imports/${imp.id}`} className="hover:underline">
|
||||||
|
{imp.document?.fileName ?? "—"}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 whitespace-nowrap">{fmt.format(imp.createdAt)}</td>
|
||||||
|
<td className="px-4 py-2.5">{imp.importedByName ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<ImportStatusPill status={imp.status} label={t(`status.${imp.status}`)} />
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{imp.status === "review_required" && (
|
||||||
|
<Link href={`/imports/${imp.id}`} className="inline-flex min-h-11 items-center rounded-lg bg-[var(--ui-accent)] px-4 font-heading text-[13px] font-semibold text-[var(--ui-accent-foreground)] hover:opacity-90">
|
||||||
|
{t("list.review")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{imp.status === "failed" && <RetryImportButton action={retryImportAction.bind(null, imp.id)} size="sm" />}
|
||||||
|
{imp.status === "confirmed" && imp.createdWorkOrder && (
|
||||||
|
<Link href={`/work-orders/${imp.createdWorkOrder.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] hover:underline">
|
||||||
|
{t("list.workOrder", { number: imp.createdWorkOrder.number })}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{(imp.status === "uploaded" || imp.status === "processing" || imp.status === "discarded") && (
|
||||||
|
<Link href={`/imports/${imp.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--ui-primary)] hover:underline">
|
||||||
|
{t("list.open")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { confirmImport } from "@/server/services/imports/confirm";
|
||||||
|
import { apiError, importsApiContext } from "../../_context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/imports/[id]/confirm — JSON body = review form (src/lib/imports/review.ts
|
||||||
|
* `reviewFormSchema`). Creates/assigns customer, site, contact and the work order. Lane L3.
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const ctx = await importsApiContext("import:write", "work_order:write");
|
||||||
|
const { id } = await params;
|
||||||
|
let body: unknown;
|
||||||
|
try {
|
||||||
|
body = await req.json();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "invalid", message: "json_required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
return Response.json(await confirmImport(ctx, id, body));
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { getImportDetail } from "@/server/services/imports/queries";
|
||||||
|
import { apiError, importsApiContext } from "../_context";
|
||||||
|
|
||||||
|
/** GET /api/v1/imports/[id] — import status, extraction (with confidences), candidates. Lane L3. */
|
||||||
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const ctx = await importsApiContext("import:write");
|
||||||
|
const { id } = await params;
|
||||||
|
return Response.json(await getImportDetail(ctx, id));
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ForbiddenError, type Permission } from "@/server/rbac";
|
||||||
|
import { ModuleDisabledError } from "@/server/modules";
|
||||||
|
import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lane L3 helper for the /api/v1 import handlers (thin adapters). Uses the same DB-authoritative
|
||||||
|
* guard as the server actions (session → account/identity status → permissions → module).
|
||||||
|
* Not a route: files starting with "_" are ignored by the App Router.
|
||||||
|
* TODO(architecture): replace with a shared `requireApiContext` once it exists.
|
||||||
|
*/
|
||||||
|
const guard = moduleGuard("imports");
|
||||||
|
|
||||||
|
export async function importsApiContext(...permissions: Permission[]): Promise<ServiceCtx> {
|
||||||
|
return ctxFromGuard(await guard(...permissions));
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS: Record<ServiceError["code"], number> = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 409 };
|
||||||
|
|
||||||
|
/** Map service/guard errors to JSON responses without leaking internals. */
|
||||||
|
export function apiError(err: unknown): Response {
|
||||||
|
if (err instanceof ServiceError) {
|
||||||
|
return Response.json({ error: err.code, message: err.message, details: err.code === "invalid" ? err.details : undefined }, { status: STATUS[err.code] });
|
||||||
|
}
|
||||||
|
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) {
|
||||||
|
return Response.json({ error: "forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
if (err instanceof Error && /Nicht angemeldet|nicht mehr gueltig/.test(err.message)) {
|
||||||
|
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (err instanceof Error && /Konto ist nicht aktiv|Passwortwechsel/.test(err.message)) {
|
||||||
|
return Response.json({ error: "forbidden" }, { status: 403 });
|
||||||
|
}
|
||||||
|
console.error("[api/imports]", err);
|
||||||
|
return Response.json({ error: "internal" }, { status: 500 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { createImport } from "@/server/services/imports/upload";
|
||||||
|
import { apiError, importsApiContext } from "../../imports/_context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/work-orders/import — multipart upload of an order document (field `file`).
|
||||||
|
* Lane L3 (import). Response 201 `{ id, status }`; the extraction runs in the background.
|
||||||
|
* Note: bodies > 10 MB need `experimental.proxyClientMaxBodySize` in next.config.ts (see lane report).
|
||||||
|
*/
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const ctx = await importsApiContext("import:write");
|
||||||
|
let form: FormData;
|
||||||
|
try {
|
||||||
|
form = await req.formData();
|
||||||
|
} catch {
|
||||||
|
return Response.json({ error: "invalid", message: "multipart_required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File)) return Response.json({ error: "invalid", message: "file_missing" }, { status: 400 });
|
||||||
|
const job = await createImport(ctx, {
|
||||||
|
bytes: Buffer.from(await file.arrayBuffer()),
|
||||||
|
fileName: file.name,
|
||||||
|
mimeType: file.type,
|
||||||
|
});
|
||||||
|
const current = await ctx.db.importJob.findFirst({ where: { id: job.id }, select: { id: true, status: true } });
|
||||||
|
return Response.json(current ?? { id: job.id, status: job.status }, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
/** Re-renders the server page periodically while an import is being processed. */
|
||||||
|
export function AutoRefresh({ active, intervalMs = 4000 }: { active: boolean; intervalMs?: number }) {
|
||||||
|
const router = useRouter();
|
||||||
|
useEffect(() => {
|
||||||
|
if (!active) return;
|
||||||
|
const id = setInterval(() => router.refresh(), intervalMs);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, [active, intervalMs, router]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { RotateCcw, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { SimpleState } from "@/server/actions/imports/imports";
|
||||||
|
|
||||||
|
type Action = (prev: SimpleState, formData: FormData) => Promise<SimpleState>;
|
||||||
|
|
||||||
|
function ErrorText({ state }: { state: SimpleState }) {
|
||||||
|
const t = useTranslations("imports.errors");
|
||||||
|
if (state.status !== "error") return null;
|
||||||
|
return (
|
||||||
|
<span role="alert" className="text-[12.5px] font-semibold text-[var(--risk)]">
|
||||||
|
{t.has(state.code) ? t(state.code) : t("error")}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetryImportButton({ action, size = "default" }: { action: Action; size?: "default" | "sm" }) {
|
||||||
|
const t = useTranslations("imports.actions");
|
||||||
|
const [state, formAction, pending] = useActionState(action, { status: "idle" } as SimpleState);
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="inline-flex flex-wrap items-center gap-2">
|
||||||
|
<Button type="submit" variant="outline" size={size} disabled={pending} className={size === "sm" ? "h-9" : "h-11 px-4"}>
|
||||||
|
<RotateCcw aria-hidden /> {pending ? t("retrying") : t("retry")}
|
||||||
|
</Button>
|
||||||
|
<ErrorText state={state} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DiscardImportButton({ action }: { action: Action }) {
|
||||||
|
const t = useTranslations("imports.actions");
|
||||||
|
const [state, formAction, pending] = useActionState(action, { status: "idle" } as SimpleState);
|
||||||
|
return (
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
if (!window.confirm(t("discardConfirm"))) e.preventDefault();
|
||||||
|
}}
|
||||||
|
className="inline-flex flex-wrap items-center gap-2"
|
||||||
|
>
|
||||||
|
<Button type="submit" variant="outline" disabled={pending} className="h-11 px-4">
|
||||||
|
<Trash2 aria-hidden /> {t("discard")}
|
||||||
|
</Button>
|
||||||
|
<ErrorText state={state} />
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState, useMemo, useState, useTransition } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { AlertTriangle, Info, Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import type { FieldMeta, FormFieldPath, ReviewFormInput } from "@/lib/imports/review";
|
||||||
|
import type { PlausibilityHint } from "@/lib/imports/extraction";
|
||||||
|
import type { ConfirmState } from "@/server/actions/imports/imports";
|
||||||
|
|
||||||
|
export type SiteOption = { id: string; name: string; street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null };
|
||||||
|
export type CustomerOption = {
|
||||||
|
id: string;
|
||||||
|
customerNumber: string | null;
|
||||||
|
companyName: string | null;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
postalCode: string | null;
|
||||||
|
city: string | null;
|
||||||
|
sites: SiteOption[];
|
||||||
|
};
|
||||||
|
export type CustomerCandidateView = { customerId: string; score: number; reasons: string[]; customer: CustomerOption };
|
||||||
|
export type SiteCandidateView = { siteId: string; customerId: string; score: number; reasons: string[] };
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
initial: ReviewFormInput;
|
||||||
|
meta: Record<FormFieldPath, FieldMeta>;
|
||||||
|
hints: PlausibilityHint[];
|
||||||
|
customerCandidates: CustomerCandidateView[];
|
||||||
|
siteCandidates: SiteCandidateView[];
|
||||||
|
confirmAction: (prev: ConfirmState, formData: FormData) => Promise<ConfirmState>;
|
||||||
|
searchAction: (q: string) => Promise<CustomerOption[]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Section = "customer" | "site" | "contact" | "order";
|
||||||
|
type Position = NonNullable<ReviewFormInput["positions"]>[number];
|
||||||
|
|
||||||
|
const customerLabel = (c: CustomerOption) =>
|
||||||
|
[c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—", c.customerNumber ? `(${c.customerNumber})` : null].filter(Boolean).join(" ");
|
||||||
|
const siteLabel = (s: SiteOption) => [s.name, [s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Review mask (spec §9.6): editable form in sections, uncertain fields (< 0.8) marked with
|
||||||
|
* warning colour + icon + text, source snippet as tooltip; customer/site decisions; editable
|
||||||
|
* line items with "als Materialvorgabe übernehmen". Submits the form as JSON to the confirm action.
|
||||||
|
*/
|
||||||
|
export function ImportReviewForm({ initial, meta, hints, customerCandidates, siteCandidates, confirmAction, searchAction }: Props) {
|
||||||
|
const t = useTranslations("imports.review");
|
||||||
|
const te = useTranslations("imports.errors");
|
||||||
|
const ta = useTranslations("imports.actions");
|
||||||
|
const [form, setForm] = useState<ReviewFormInput>(initial);
|
||||||
|
const [state, formAction, pending] = useActionState(confirmAction, { status: "idle" } as ConfirmState);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [results, setResults] = useState<CustomerOption[] | null>(null);
|
||||||
|
const [searching, startSearch] = useTransition();
|
||||||
|
|
||||||
|
const issues = useMemo(() => {
|
||||||
|
const map = new Map<string, string>();
|
||||||
|
if (state.status === "error") for (const i of state.issues ?? []) if (!map.has(i.path)) map.set(i.path, i.message);
|
||||||
|
return map;
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
const knownCustomers = useMemo(() => {
|
||||||
|
const map = new Map<string, CustomerOption>();
|
||||||
|
for (const c of customerCandidates) map.set(c.customer.id, c.customer);
|
||||||
|
for (const c of results ?? []) map.set(c.id, c);
|
||||||
|
return map;
|
||||||
|
}, [customerCandidates, results]);
|
||||||
|
const selectedCustomer = form.customerMode === "existing" && form.customerId ? knownCustomers.get(form.customerId) ?? null : null;
|
||||||
|
|
||||||
|
const set = <S extends Section>(section: S, key: keyof NonNullable<ReviewFormInput[S]>, value: string) =>
|
||||||
|
setForm((f) => ({ ...f, [section]: { ...(f[section] as object), [key]: value } }));
|
||||||
|
const setPosition = (index: number, patch: Partial<Position>) =>
|
||||||
|
setForm((f) => ({ ...f, positions: (f.positions ?? []).map((p, i) => (i === index ? { ...p, ...patch } : p)) }));
|
||||||
|
|
||||||
|
const errorText = (path: string) => {
|
||||||
|
const code = issues.get(path);
|
||||||
|
return code ? (te.has(code) ? te(code) : te("form_invalid")) : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function field(section: Section, key: string, labelKey: string, opts: { type?: string; multiline?: boolean; required?: boolean; className?: string; inputMode?: "numeric" | "tel" | "email" } = {}) {
|
||||||
|
const path = `${section}.${key}`;
|
||||||
|
const m = (meta as Record<string, FieldMeta | undefined>)[path];
|
||||||
|
const value = String(((form[section] as Record<string, unknown>)[key] as string | undefined) ?? "");
|
||||||
|
const id = `rv-${section}-${key}`;
|
||||||
|
const err = errorText(path);
|
||||||
|
const tip = m?.source ? t("source", { text: m.source }) : undefined;
|
||||||
|
const common = {
|
||||||
|
id,
|
||||||
|
value,
|
||||||
|
title: tip,
|
||||||
|
"aria-invalid": err ? true : undefined,
|
||||||
|
"aria-describedby": m?.uncertain || err ? `${id}-note` : undefined,
|
||||||
|
onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => set(section, key as never, e.target.value),
|
||||||
|
className: cn("mt-1 min-h-11", m?.uncertain && "border-[var(--warn)] bg-[color-mix(in_srgb,var(--warn)_7%,transparent)]"),
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div className={opts.className}>
|
||||||
|
<Label htmlFor={id} className="flex flex-wrap items-center gap-x-2">
|
||||||
|
<span>
|
||||||
|
{t(`fields.${labelKey}`)}
|
||||||
|
{opts.required && <span aria-label={t("required")}> *</span>}
|
||||||
|
</span>
|
||||||
|
{m?.uncertain && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[11.5px] font-bold text-[var(--warn)]" title={tip}>
|
||||||
|
<AlertTriangle className="size-3.5" aria-hidden />
|
||||||
|
{t("uncertain")} · {t("confidence", { percent: Math.round(m.confidence * 100) })}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Label>
|
||||||
|
{opts.multiline ? (
|
||||||
|
<Textarea {...common} rows={4} />
|
||||||
|
) : (
|
||||||
|
<Input {...common} type={opts.type ?? "text"} inputMode={opts.inputMode} required={opts.required} />
|
||||||
|
)}
|
||||||
|
<div id={`${id}-note`}>
|
||||||
|
{m?.uncertain && m.source && <p className="mt-0.5 truncate text-[11.5px] text-muted-foreground">{tip}</p>}
|
||||||
|
{err && (
|
||||||
|
<p role="alert" className="mt-0.5 text-[12px] font-semibold text-[var(--risk)]">
|
||||||
|
{err}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const radio = (name: string, value: string, checked: boolean, onChange: () => void, label: React.ReactNode, extra?: React.ReactNode) => (
|
||||||
|
<label className={cn("flex min-h-11 cursor-pointer items-start gap-2.5 rounded-lg border p-3 text-[13.5px]", checked ? "border-[var(--ui-primary)] bg-[var(--ui-primary-soft)]" : "border-border")}>
|
||||||
|
<input type="radio" name={name} value={value} checked={checked} onChange={onChange} className="mt-1 size-4" />
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="font-semibold">{label}</span>
|
||||||
|
{extra}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
const sectionClass = "shadow-card rounded-xl border bg-card p-4 sm:p-5";
|
||||||
|
const h2 = "mb-3 font-heading text-[15px] font-semibold";
|
||||||
|
const siteOptions = selectedCustomer?.sites ?? [];
|
||||||
|
const siteScore = new Map(siteCandidates.map((s) => [s.siteId, s]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4" noValidate>
|
||||||
|
<input type="hidden" name="payload" value={JSON.stringify(form)} />
|
||||||
|
|
||||||
|
{hints.length > 0 && (
|
||||||
|
<div className="rounded-xl border border-[var(--warn)] bg-[color-mix(in_srgb,var(--warn)_8%,transparent)] p-4" role="status">
|
||||||
|
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--warn)]">
|
||||||
|
<AlertTriangle className="size-4" aria-hidden /> {t("hintsTitle")}
|
||||||
|
</p>
|
||||||
|
<ul className="mt-1.5 list-disc space-y-0.5 pl-6 text-[13px]">
|
||||||
|
{hints.map((h, i) => (
|
||||||
|
<li key={i}>{t(`hints.${h.code}`, { field: h.field ? t(`extractionFields.${h.field}`) : "" })}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Kunde */}
|
||||||
|
<section className={sectionClass} aria-labelledby="rv-h-customer">
|
||||||
|
<h2 id="rv-h-customer" className={h2}>{t("sections.customer")}</h2>
|
||||||
|
<fieldset className="grid gap-2 sm:grid-cols-2">
|
||||||
|
<legend className="sr-only">{t("sections.customer")}</legend>
|
||||||
|
{radio("customerMode", "existing", form.customerMode === "existing", () => setForm((f) => ({ ...f, customerMode: "existing", customerId: f.customerId || customerCandidates[0]?.customerId || "" })), t("customer.existing"))}
|
||||||
|
{radio("customerMode", "new", form.customerMode === "new", () => setForm((f) => ({ ...f, customerMode: "new", siteMode: f.siteMode === "existing" ? "new" : f.siteMode })), t("customer.new"))}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{form.customerMode === "existing" && (
|
||||||
|
<div className="mt-4 space-y-3">
|
||||||
|
<p className="text-[12.5px] font-semibold text-muted-foreground">{t("customer.candidates")}</p>
|
||||||
|
{customerCandidates.length === 0 && <p className="text-[13px] text-muted-foreground">{t("customer.noCandidates")}</p>}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{customerCandidates.map((c) =>
|
||||||
|
radio(
|
||||||
|
"customerId",
|
||||||
|
c.customerId,
|
||||||
|
form.customerId === c.customerId,
|
||||||
|
() => setForm((f) => ({ ...f, customerId: c.customerId, siteId: "" })),
|
||||||
|
customerLabel(c.customer),
|
||||||
|
<span className="mt-0.5 block text-[12.5px] text-muted-foreground">
|
||||||
|
{[c.customer.postalCode, c.customer.city].filter(Boolean).join(" ")} · {t("customer.match", { percent: Math.round(c.score * 100) })} · {c.reasons.map((r) => (t.has(`reasons.${r}`) ? t(`reasons.${r}`) : r)).join(", ")}
|
||||||
|
{" · "}
|
||||||
|
<a href={`/customers/${c.customerId}`} target="_blank" rel="noreferrer" className="font-semibold text-[var(--ui-primary)] underline-offset-2 hover:underline">
|
||||||
|
{t("customer.merge")}
|
||||||
|
</a>
|
||||||
|
</span>,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-3">
|
||||||
|
<Label htmlFor="rv-search">{t("customer.search")}</Label>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="rv-search"
|
||||||
|
value={query}
|
||||||
|
placeholder={t("customer.searchPlaceholder")}
|
||||||
|
className="min-h-11"
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
startSearch(async () => setResults(await searchAction(query)));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" variant="outline" className="h-11 px-4" disabled={searching || query.trim().length < 2} onClick={() => startSearch(async () => setResults(await searchAction(query)))}>
|
||||||
|
{searching ? <Loader2 className="animate-spin" aria-hidden /> : <Search aria-hidden />} {t("customer.searchButton")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{results && results.length === 0 && <p className="mt-2 text-[13px] text-muted-foreground">{t("customer.searchEmpty")}</p>}
|
||||||
|
<div className="mt-2 space-y-2">
|
||||||
|
{(results ?? [])
|
||||||
|
.filter((r) => !customerCandidates.some((c) => c.customerId === r.id))
|
||||||
|
.map((r) =>
|
||||||
|
radio(
|
||||||
|
"customerId",
|
||||||
|
r.id,
|
||||||
|
form.customerId === r.id,
|
||||||
|
() => setForm((f) => ({ ...f, customerId: r.id, siteId: "" })),
|
||||||
|
customerLabel(r),
|
||||||
|
<span className="mt-0.5 block text-[12.5px] text-muted-foreground">{[r.postalCode, r.city].filter(Boolean).join(" ")}</span>,
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{errorText("customerId") && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)]">{errorText("customerId")}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.customerMode === "new" && (
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
|
||||||
|
{field("customer", "companyName", "companyName", { className: "sm:col-span-2 lg:col-span-4" })}
|
||||||
|
{field("customer", "customerNumber", "customerNumber", { className: "lg:col-span-2" })}
|
||||||
|
{field("customer", "firstName", "firstName", { className: "lg:col-span-3" })}
|
||||||
|
{field("customer", "lastName", "lastName", { className: "lg:col-span-3" })}
|
||||||
|
{field("customer", "street", "street", { className: "lg:col-span-4" })}
|
||||||
|
{field("customer", "houseNumber", "houseNumber", { className: "lg:col-span-2" })}
|
||||||
|
{field("customer", "postalCode", "postalCode", { inputMode: "numeric", className: "lg:col-span-2" })}
|
||||||
|
{field("customer", "city", "city", { className: "lg:col-span-3" })}
|
||||||
|
{field("customer", "country", "country", { className: "lg:col-span-1" })}
|
||||||
|
{field("customer", "phone", "phone", { type: "tel", inputMode: "tel", className: "lg:col-span-3" })}
|
||||||
|
{field("customer", "email", "email", { type: "email", inputMode: "email", className: "lg:col-span-3" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Objekt */}
|
||||||
|
<section className={sectionClass} aria-labelledby="rv-h-site">
|
||||||
|
<h2 id="rv-h-site" className={h2}>{t("sections.site")}</h2>
|
||||||
|
<fieldset className="grid gap-2 sm:grid-cols-3">
|
||||||
|
<legend className="sr-only">{t("sections.site")}</legend>
|
||||||
|
{radio("siteMode", "none", form.siteMode === "none", () => setForm((f) => ({ ...f, siteMode: "none" })), t("site.none"))}
|
||||||
|
{radio("siteMode", "existing", form.siteMode === "existing", () => setForm((f) => ({ ...f, siteMode: "existing" })), t("site.existing"))}
|
||||||
|
{radio("siteMode", "new", form.siteMode === "new", () => setForm((f) => ({ ...f, siteMode: "new" })), t("site.new"))}
|
||||||
|
</fieldset>
|
||||||
|
{form.siteMode === "existing" && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{!selectedCustomer && <p className="text-[13px] text-muted-foreground">{t("site.chooseCustomer")}</p>}
|
||||||
|
{selectedCustomer && siteOptions.length === 0 && <p className="text-[13px] text-muted-foreground">{t("site.noSites")}</p>}
|
||||||
|
{siteOptions.map((s) => {
|
||||||
|
const cand = siteScore.get(s.id);
|
||||||
|
return (
|
||||||
|
<div key={s.id}>
|
||||||
|
{radio(
|
||||||
|
"siteId",
|
||||||
|
s.id,
|
||||||
|
form.siteId === s.id,
|
||||||
|
() => setForm((f) => ({ ...f, siteId: s.id })),
|
||||||
|
siteLabel(s),
|
||||||
|
cand ? (
|
||||||
|
<span className="mt-0.5 flex items-center gap-1 text-[12.5px] font-semibold text-[var(--ok)]">
|
||||||
|
<Info className="size-3.5" aria-hidden /> {t("site.suggested")} · {t("customer.match", { percent: Math.round(cand.score * 100) })}
|
||||||
|
</span>
|
||||||
|
) : null,
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{errorText("siteId") && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)]">{errorText("siteId")}</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{form.siteMode === "new" && (
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-6">
|
||||||
|
{field("site", "name", "siteName", { className: "sm:col-span-2 lg:col-span-6" })}
|
||||||
|
{field("site", "street", "street", { className: "lg:col-span-4" })}
|
||||||
|
{field("site", "houseNumber", "houseNumber", { className: "lg:col-span-2" })}
|
||||||
|
{field("site", "postalCode", "postalCode", { inputMode: "numeric", className: "lg:col-span-2" })}
|
||||||
|
{field("site", "city", "city", { className: "lg:col-span-3" })}
|
||||||
|
{field("site", "country", "country", { className: "lg:col-span-1" })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Ansprechpartner */}
|
||||||
|
<section className={sectionClass} aria-labelledby="rv-h-contact">
|
||||||
|
<h2 id="rv-h-contact" className={h2}>{t("sections.contact")}</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
{field("contact", "name", "name")}
|
||||||
|
{field("contact", "phone", "phone", { type: "tel", inputMode: "tel" })}
|
||||||
|
{field("contact", "email", "email", { type: "email", inputMode: "email" })}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Auftrag */}
|
||||||
|
<section className={sectionClass} aria-labelledby="rv-h-order">
|
||||||
|
<h2 id="rv-h-order" className={h2}>{t("sections.order")}</h2>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{field("order", "title", "title", { required: true, className: "sm:col-span-2" })}
|
||||||
|
{field("order", "externalOrderNumber", "externalOrderNumber")}
|
||||||
|
{field("order", "offerNumber", "offerNumber")}
|
||||||
|
{field("order", "plannedStart", "plannedStart", { type: "date" })}
|
||||||
|
{field("order", "plannedEnd", "plannedEnd", { type: "date" })}
|
||||||
|
{field("order", "description", "description", { multiline: true, className: "sm:col-span-2" })}
|
||||||
|
{field("order", "notes", "notes", { multiline: true, className: "sm:col-span-2" })}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Positionen */}
|
||||||
|
<section className={sectionClass} aria-labelledby="rv-h-positions">
|
||||||
|
<h2 id="rv-h-positions" className={h2}>{t("sections.positions")}</h2>
|
||||||
|
{(form.positions ?? []).length === 0 && <p className="text-[13px] text-muted-foreground">{t("positions.empty")}</p>}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(form.positions ?? []).map((p, i) => (
|
||||||
|
<div key={i} className="grid gap-2 rounded-lg border p-3 sm:grid-cols-12">
|
||||||
|
<div className="sm:col-span-5">
|
||||||
|
<Label htmlFor={`rv-pos-${i}-name`}>{t("positions.name")}</Label>
|
||||||
|
<Input id={`rv-pos-${i}-name`} className="mt-1 min-h-11" value={p.name} onChange={(e) => setPosition(i, { name: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-3">
|
||||||
|
<Label htmlFor={`rv-pos-${i}-art`}>{t("positions.articleNumber")}</Label>
|
||||||
|
<Input id={`rv-pos-${i}-art`} className="mt-1 min-h-11" value={p.articleNumber ?? ""} onChange={(e) => setPosition(i, { articleNumber: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label htmlFor={`rv-pos-${i}-qty`}>{t("positions.quantity")}</Label>
|
||||||
|
<Input id={`rv-pos-${i}-qty`} className="mt-1 min-h-11" inputMode="decimal" value={String(p.quantity ?? "")} onChange={(e) => setPosition(i, { quantity: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Label htmlFor={`rv-pos-${i}-unit`}>{t("positions.unit")}</Label>
|
||||||
|
<Input id={`rv-pos-${i}-unit`} className="mt-1 min-h-11" value={p.unit ?? ""} onChange={(e) => setPosition(i, { unit: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2 sm:col-span-12">
|
||||||
|
<label className="flex min-h-11 items-center gap-2 text-[13px]">
|
||||||
|
<input type="checkbox" className="size-4" checked={Boolean(p.asMaterial)} onChange={(e) => setPosition(i, { asMaterial: e.target.checked })} />
|
||||||
|
{t("positions.asMaterial")}
|
||||||
|
</label>
|
||||||
|
<Button type="button" variant="ghost" className="h-11 px-3" aria-label={t("positions.remove")} onClick={() => setForm((f) => ({ ...f, positions: (f.positions ?? []).filter((_, j) => j !== i) }))}>
|
||||||
|
<Trash2 aria-hidden /> <span className="sm:sr-only">{t("positions.remove")}</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{errorText(`positions.${i}.quantity`) && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)] sm:col-span-12">{errorText(`positions.${i}.quantity`)}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="mt-3 h-11 px-4"
|
||||||
|
onClick={() => setForm((f) => ({ ...f, positions: [...(f.positions ?? []), { name: "", articleNumber: "", quantity: "", unit: "", asMaterial: false }] }))}
|
||||||
|
>
|
||||||
|
<Plus aria-hidden /> {t("positions.add")}
|
||||||
|
</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button type="submit" disabled={pending} className="h-11 bg-[var(--ui-accent)] px-5 text-[var(--ui-accent-foreground)]">
|
||||||
|
{pending && <Loader2 className="animate-spin" aria-hidden />}
|
||||||
|
{pending ? ta("confirming") : ta("confirm")}
|
||||||
|
</Button>
|
||||||
|
{state.status === "error" && (
|
||||||
|
<p role="alert" className="text-[13px] font-semibold text-[var(--risk)]">
|
||||||
|
{te.has(state.code) ? te(state.code) : te("error")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { AlertTriangle, Ban, CheckCircle2, Loader2, Upload, XCircle } from "lucide-react";
|
||||||
|
import { Pill } from "@/components/mockup-ui";
|
||||||
|
import { IMPORT_STATUS_TONE, type ImportStatusKey } from "@/lib/imports/status";
|
||||||
|
|
||||||
|
const ICONS: Record<ImportStatusKey, typeof Upload> = {
|
||||||
|
uploaded: Upload,
|
||||||
|
processing: Loader2,
|
||||||
|
review_required: AlertTriangle,
|
||||||
|
confirmed: CheckCircle2,
|
||||||
|
failed: XCircle,
|
||||||
|
discarded: Ban,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Import status as pill: colour + icon + text (never colour alone). */
|
||||||
|
export function ImportStatusPill({ status, label }: { status: ImportStatusKey; label: string }) {
|
||||||
|
const Icon = ICONS[status];
|
||||||
|
return (
|
||||||
|
<Pill tone={IMPORT_STATUS_TONE[status]}>
|
||||||
|
<Icon className={status === "processing" ? "size-3.5 animate-spin" : "size-3.5"} aria-hidden />
|
||||||
|
{label}
|
||||||
|
</Pill>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { CheckCircle2, FileUp, XCircle } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { IMPORT_IMAGE_MAX_BYTES, IMPORT_MAX_BYTES, IMPORT_MIME_TYPES } from "@/lib/imports/status";
|
||||||
|
|
||||||
|
type UploadState =
|
||||||
|
| { kind: "idle" }
|
||||||
|
| { kind: "uploading"; percent: number; name: string }
|
||||||
|
| { kind: "done"; name: string; id: string }
|
||||||
|
| { kind: "error"; code: string };
|
||||||
|
|
||||||
|
const KNOWN_ERRORS = new Set(["file_type_not_allowed", "file_too_large", "file_type_mismatch", "file_empty", "file_missing", "forbidden", "unauthorized", "network"]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drag & drop / file picker upload with progress (XHR upload events). Posts to
|
||||||
|
* POST /api/v1/work-orders/import, then refreshes the server-rendered list.
|
||||||
|
*/
|
||||||
|
export function ImportUploader() {
|
||||||
|
const t = useTranslations("imports.upload");
|
||||||
|
const router = useRouter();
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [state, setState] = useState<UploadState>({ kind: "idle" });
|
||||||
|
|
||||||
|
function upload(file: File) {
|
||||||
|
const type = file.type === "image/jpg" ? "image/jpeg" : file.type;
|
||||||
|
if (type && !(IMPORT_MIME_TYPES as readonly string[]).includes(type)) return setState({ kind: "error", code: "file_type_not_allowed" });
|
||||||
|
const limit = type === "application/pdf" ? IMPORT_MAX_BYTES : IMPORT_IMAGE_MAX_BYTES;
|
||||||
|
if (file.size > limit) return setState({ kind: "error", code: "file_too_large" });
|
||||||
|
if (file.size === 0) return setState({ kind: "error", code: "file_empty" });
|
||||||
|
|
||||||
|
const body = new FormData();
|
||||||
|
body.append("file", file);
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
xhr.open("POST", "/api/v1/work-orders/import");
|
||||||
|
xhr.responseType = "json";
|
||||||
|
xhr.upload.onprogress = (e) => {
|
||||||
|
if (e.lengthComputable) setState({ kind: "uploading", percent: Math.round((e.loaded / e.total) * 100), name: file.name });
|
||||||
|
};
|
||||||
|
xhr.onload = () => {
|
||||||
|
const res = (xhr.response ?? {}) as { id?: string; error?: string; message?: string };
|
||||||
|
if (xhr.status === 201 && res.id) {
|
||||||
|
setState({ kind: "done", name: file.name, id: res.id });
|
||||||
|
router.refresh();
|
||||||
|
} else {
|
||||||
|
const code = res.message && KNOWN_ERRORS.has(res.message) ? res.message : res.error && KNOWN_ERRORS.has(res.error) ? res.error : "error";
|
||||||
|
setState({ kind: "error", code });
|
||||||
|
}
|
||||||
|
if (inputRef.current) inputRef.current.value = "";
|
||||||
|
};
|
||||||
|
xhr.onerror = () => setState({ kind: "error", code: "network" });
|
||||||
|
setState({ kind: "uploading", percent: 0, name: file.name });
|
||||||
|
xhr.send(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const busy = state.kind === "uploading";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!busy) setDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
const file = e.dataTransfer.files?.[0];
|
||||||
|
if (file && !busy) upload(file);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"shadow-card rounded-xl border-2 border-dashed bg-card p-6 text-center transition-colors",
|
||||||
|
dragging ? "border-[var(--ui-accent)] bg-[var(--ui-primary-soft)]" : "border-border",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<FileUp className="mx-auto size-8 text-[var(--ui-accent)]" aria-hidden />
|
||||||
|
<p className="mt-2 font-heading text-sm font-semibold">{t("drop")}</p>
|
||||||
|
<p className="mt-1 text-[13px] text-muted-foreground">{t("or")}</p>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
id="import-file"
|
||||||
|
type="file"
|
||||||
|
accept="application/pdf,image/jpeg,image/png,.pdf,.jpg,.jpeg,.png"
|
||||||
|
className="sr-only"
|
||||||
|
disabled={busy}
|
||||||
|
onChange={(e) => {
|
||||||
|
const file = e.target.files?.[0];
|
||||||
|
if (file) upload(file);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button type="button" className="mt-2 h-11 px-5" disabled={busy} onClick={() => inputRef.current?.click()}>
|
||||||
|
{t("choose")}
|
||||||
|
</Button>
|
||||||
|
<p className="mt-3 text-[12.5px] text-muted-foreground">{t("limits")}</p>
|
||||||
|
|
||||||
|
<div aria-live="polite" className="mt-3 min-h-6">
|
||||||
|
{state.kind === "uploading" && (
|
||||||
|
<div className="mx-auto max-w-md text-left">
|
||||||
|
<div className="flex justify-between text-[12.5px]">
|
||||||
|
<span className="truncate">{state.name}</span>
|
||||||
|
<span>{t("uploading", { percent: state.percent })}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 h-2 overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuenow={state.percent} aria-valuemin={0} aria-valuemax={100}>
|
||||||
|
<div className="h-full bg-[var(--ui-accent)] transition-[width]" style={{ width: `${state.percent}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state.kind === "done" && (
|
||||||
|
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--ok)]">
|
||||||
|
<CheckCircle2 className="size-4" aria-hidden /> {state.name}: {t("done")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state.kind === "error" && (
|
||||||
|
<p role="alert" className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--risk)]">
|
||||||
|
<XCircle className="size-4" aria-hidden /> {t(`errors.${state.code}`)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ForbiddenError } from "@/server/rbac";
|
||||||
|
import { ctxFromGuard, ServiceError } from "@/server/services/context";
|
||||||
|
import { confirmImport, discardImport, retryImport } from "@/server/services/imports/confirm";
|
||||||
|
import { searchCustomers } from "@/server/services/imports/queries";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Server actions of the import module (thin adapters: guard → service → revalidate).
|
||||||
|
* Uploads do NOT go through a server action (1 MB body limit) but through
|
||||||
|
* POST /api/v1/work-orders/import with upload progress.
|
||||||
|
*/
|
||||||
|
const guard = moduleGuard("imports");
|
||||||
|
|
||||||
|
export type ConfirmState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "error"; code: string; issues?: Array<{ path: string; message: string }> };
|
||||||
|
|
||||||
|
export type SimpleState = { status: "idle" } | { status: "error"; code: string };
|
||||||
|
|
||||||
|
function errorCode(err: unknown): string {
|
||||||
|
if (err instanceof ServiceError) return err.code === "invalid" || err.code === "conflict" ? err.message : err.code;
|
||||||
|
if (err instanceof ForbiddenError) return "forbidden";
|
||||||
|
console.error("[actions/imports]", err);
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmImportAction(importId: string, _prev: ConfirmState, formData: FormData): Promise<ConfirmState> {
|
||||||
|
const ctx = ctxFromGuard(await guard("import:write", "work_order:write"));
|
||||||
|
let payload: unknown;
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(String(formData.get("payload") ?? ""));
|
||||||
|
} catch {
|
||||||
|
return { status: "error", code: "form_invalid" };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await confirmImport(ctx, importId, payload);
|
||||||
|
} catch (err) {
|
||||||
|
const issues = err instanceof ServiceError && err.code === "invalid" ? (err.details as Array<{ path: string; message: string }> | undefined) : undefined;
|
||||||
|
return { status: "error", code: errorCode(err), issues };
|
||||||
|
}
|
||||||
|
revalidatePath("/imports");
|
||||||
|
redirect(`/imports/${importId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bound as (prev, formData) action for useActionState; the extra arguments are not needed.
|
||||||
|
export async function discardImportAction(importId: string): Promise<SimpleState> {
|
||||||
|
const ctx = ctxFromGuard(await guard("import:write"));
|
||||||
|
try {
|
||||||
|
await discardImport(ctx, importId);
|
||||||
|
} catch (err) {
|
||||||
|
return { status: "error", code: errorCode(err) };
|
||||||
|
}
|
||||||
|
revalidatePath("/imports");
|
||||||
|
redirect("/imports");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retryImportAction(importId: string): Promise<SimpleState> {
|
||||||
|
const ctx = ctxFromGuard(await guard("import:write"));
|
||||||
|
try {
|
||||||
|
await retryImport(ctx, importId);
|
||||||
|
} catch (err) {
|
||||||
|
return { status: "error", code: errorCode(err) };
|
||||||
|
}
|
||||||
|
revalidatePath("/imports");
|
||||||
|
revalidatePath(`/imports/${importId}`);
|
||||||
|
return { status: "idle" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchCustomersAction(q: string) {
|
||||||
|
const ctx = ctxFromGuard(await guard("import:write"));
|
||||||
|
const rows = await searchCustomers(ctx, String(q ?? "").slice(0, 100));
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user