diff --git a/docs/craftvia/lanes/stammdaten.md b/docs/craftvia/lanes/stammdaten.md new file mode 100644 index 0000000..fc4793b --- /dev/null +++ b/docs/craftvia/lanes/stammdaten.md @@ -0,0 +1,98 @@ +# Lane L1 – Stammdaten + +Branch `lane/stammdaten` (Basis `bf44567` auf `feature/craftvia-mvp`). Spec: §7 Kunden, §8 Objekte, +§11 Teams, §24 Dokumente (Objekt-/Kunden-Dokumente), US-003, US-005, US-011. +Keine Schemaänderung, keine neue Migration, keine neuen npm-Abhängigkeiten. + +## 1. Umfang / erfüllte Spec-Punkte + +| Punkt | Umsetzung | +|---|---| +| §7.1 Kundendaten | alle Felder, Status aktiv/inaktiv/vorläufig/zusammengeführt, Anlage/Änderung als Popup, Soft Delete (blockiert bei offenen Aufträgen) | +| Kundennummer | `nextNumber(…, "customer")` (K-00001), manuell überschreibbar, eindeutig je Mandant; der Nummernkreis überspringt manuell vergebene Nummern | +| §7.2 Ansprechpartner | mehrere je Kunde, Funktion, Telefon/Mobil/E-Mail, bevorzugter Kontaktweg, Bemerkungen, Soft Delete | +| §7.3 / US-003 Dubletten | `src/lib/customers/duplicates.ts` (Normalisierung: Kleinschreibung, Umlaute/ß, Rechtsformen, Straße/Str., Telefon nur Ziffern, +49→0; Scoring 0..1 als probabilistisches ODER über Kundennummer 1.0 · E-Mail 0.6 · Name exakt 0.6 / ähnlich 0.45 · Telefon 0.5 · Adresse 0.4/0.25; Schwelle 0.4) + `services/customers/duplicates.ts#findDuplicateCustomers(ctx, candidate, { excludeId?, limit? })` → `{ customerId, score, reasons, customerNumber, displayName, city, status }[]` (Obermenge des Vertrags). Manuelle Anlage zeigt „Mögliche Dublette“ mit Link auf bestehende Kunden und „Trotzdem neu anlegen“ | +| Zusammenführen | `mergeCustomers(ctx, { sourceId, targetId, confirm: true })` nur mit `customer:merge` + Bestätigungs-Checkbox; hängt Kontakte, Objekte, Aufträge (Version +1 für Offline-Konflikte), Dokumente um; Quelle → `merged` + `mergedIntoId`; Audit für Quelle und Ziel; nie automatisch | +| Vorläufige Kunden | `confirmProvisionalCustomer(ctx, id)` (provisional → active), Filter „Vorläufig“, Hinweisbanner; für L8 nutzbar | +| §8.1 Objekte | alle Felder inkl. Zugangs-/Park-/Sicherheits-/technische Hinweise (hervorgehoben, Sicherheit mit Warnkante + Icon), Kontakt des Kunden oder Freitext, Koordinaten optional, Kartenlink OpenStreetMap (Koordinaten oder Adresse, kein Embed) | +| §8.2 / §24 Dokumente | Tab „Dokumente“ an Kunde und Objekt: Upload (Kategorie, Sichtbarkeit, Titel), neue Version, Versionsliste, Metadaten bearbeiten, Soft Delete; `/documents` Backoffice-Übersicht mit Filter Kategorie/Kunde/Objekt/Auftragsnummer, „nur aktuelle Versionen“ | +| §8.3 / US-011 Historie | `getSiteHistory(ctx, siteId, { onlyApproved, page, pageSize })`: neueste zuerst, Datum (Einsatzbeginn/Termin), Auftrag, Auftragsart, Team, Status (Brandbook-Statusgruppe), durchgeführte Arbeiten (ActivityNote work_done), Material aggregiert (ohne not_used), Fotoanzahl, Links auf freigegebene Berichte, Unterschrift ja/nein, offene Folgearbeiten (followUpWork + follow_up-Notizen) mit Warnkante/Icon/Text | +| §11.1 Teams | Liste + Popup: Teamleiter, Mitglieder mit gültig ab/bis, Telefon, Fahrzeug, Einsatzgebiet, interne Hinweise, Status; nur aktive Mandanten-Nutzer; Soft Delete (blockiert bei offenen Aufträgen) | +| §4.3 Dokumenten-Service | `storeFile` (Allowlist PDF/JPEG/PNG/WebP/HEIC/Audio, Magic Bytes vs. deklarierter Typ, Limits Bild 15 MB / PDF 25 MB / Audio 20 MB, Dateiname normalisiert, SHA-256, `storage.put`, Versionierung über `lineageId`), `FileScanner` (Magic Bytes + ClamAV-INSTREAM, wenn `CLAMAV_HOST` gesetzt), `getDownloadUrl` → `/files/` | +| Download-Route | `src/app/(app)/files/[documentId]/route.ts` ersetzt `files/[...key]`: Session + DB-autoritatives `document:read` → Dokument im Mandanten → `allowedDocumentVisibility` → Auftrag in `workOrderScope` bzw. (ohne Auftrag) Objekt in `siteScope` / Kunde in `customerScope`; sonst 404; `attachment` + `nosniff` + `no-store` | +| §29 API | `GET/POST /api/v1/customers`, `GET/PATCH /api/v1/customers/[id]`, `GET/POST /api/v1/sites`, `GET /api/v1/sites/[id]/history`; Paginierung `?page&pageSize` → `{ data, pagination }`; Fehler `{ error: { code, message, details? } }` | + +## 2. Querschnitt, den andere Lanes nutzen + +- `src/server/api/context.ts` + - `requireApiContext(moduleKey | null, ...permissions) → ServiceCtx` – Session-Cookie wie Guard; Mitgliedschaft, Identity-Status, Kill-Switch, Passwortzwang und **effektive Rechte aus der DB**; Fehler als `ApiError` (401/403); `moduleKey = null` für modulübergreifende Endpunkte (Downloads). + - `assertSameOrigin(req)` – CSRF-Schutz (Origin/Sec-Fetch-Site) für schreibende Route Handler. + - `requirePageContext(moduleKey)` – Lesekontext für Seiten (Modul-Gate + JWT-Rechte, wie in AGENTS.md für Lesepfade vorgesehen). +- `src/server/api/respond.ts` – `withApi`, `toErrorResponse` (ServiceError/ZodError/ForbiddenError/ModuleDisabledError/Isolation → HTTP), `json`, `paginated`, `parsePagination`, `readJson`. +- `src/server/api/action-state.ts` – einheitlicher Rückgabetyp für Formular-Actions (Fehlercodes statt Servermeldungen, CWE-209). +- `src/server/services/documents/{store,access,scanner}.ts` – Dokumenten-Service (siehe oben), `listDocuments`, `documentReadWhere`, `authorizeDocumentAccess`, `openDocumentContent`. +- `src/components/sites/site-history.tsx` – Server-Komponente, von L4 read-only einbindbar (`linkOrders={false}`). +- Upload aus dem Browser: `POST /documents/upload` (multipart; 303-Redirect mit `?docOk`/`?docError` oder JSON bei `Accept: application/json`). + +## 3. Dateien + +- Services: `src/server/services/customers/{customers,contacts,duplicates,merge,schemas,format}.ts`, `src/server/services/sites/{sites,history,map-link}.ts`, `src/server/services/teams/teams.ts`, `src/server/services/documents/{store,access,scanner}.ts` +- Lib: `src/lib/customers/duplicates.ts` +- API-Hilfen: `src/server/api/{context,respond,action-state}.ts` +- Actions: `src/server/actions/customers/{customers,contacts}.ts`, `src/server/actions/sites/sites.ts`, `src/server/actions/teams/teams.ts`, `src/server/actions/documents/documents.ts` (alle `moduleGuard` + `await guard(...)`) +- Routen: `src/app/(app)/customers/{page,[id]/page}.tsx`, `src/app/(app)/sites/{page,[id]/page}.tsx`, `src/app/(app)/teams/page.tsx`, `src/app/(app)/documents/{page.tsx,upload/route.ts}`, `src/app/(app)/files/[documentId]/route.ts` (alt `files/[...key]` entfernt), `src/app/api/v1/customers/{route,[id]/route}.ts`, `src/app/api/v1/sites/{route,[id]/history/route}.ts` +- Komponenten: `src/components/customers/{form-ui,action-form,status,customer-form,contact-form,merge-form}.tsx`, `src/components/sites/{site-form,site-history}.tsx`, `src/components/teams/team-form.tsx`, `src/components/documents/{document-panel,document-table,document-upload-form,document-edit-form}.tsx` +- Texte: `messages/{de,en}/{customers,sites,teams,documents}.json` +- Fremd-Einzeiler: `src/components/audit-trail.tsx` (Entity-Label `contact: "Ansprechpartner"`) +- Tests: `scripts/test-stammdaten-{duplicates,customers,sites,teams,documents}.ts`, Fixtures `scripts/lib-stammdaten-fixtures.ts` + +## 4. Tests + +`npm run gate` grün: prisma generate, tsc, lint (0 Fehler; 2 Warnungen im L6-Platzhalter `handle-event.ts`), build inkl. Modul-Guard-Check, **27/27 Testskripte** (davon 5 neu). + +| Skript | Inhalt | +|---|---| +| `test-stammdaten-duplicates` | Normalisierung, Scoring/Schwelle, DB-Suche inkl. formatierter Telefonnummern, `excludeId`, zusammengeführte ausgeschlossen, **Mandant B findet nichts von A**, **Monteur ohne Auftrag findet nichts** | +| `test-stammdaten-customers` | Nummernkreis/manuelle Nummer/Eindeutigkeit je Mandant, Dublettenhinweis + Bestätigung, Validierung, Audit before/after, Ansprechpartner, vorläufig → aktiv, **Mandantentrennung** (lesen/ändern/Kontakt/bestätigen/löschen/zusammenführen → not_found), **Monteur** ohne Zuweisung not_found / mit Teamauftrag sichtbar / keine Schreibrechte, Merge (Rechte, Bestätigung, keine Mandantenkreuzung, Umhängen, Versionserhöhung, Audit, doppelt → conflict), Soft Delete | +| `test-stammdaten-sites` | Objekt-CRUD, Kontakt/Kunde-Validierung, Koordinaten, Kartenlink, Historie (Reihenfolge, Arbeiten, interne Notizen ausgeblendet, Material aggregiert, Fotos, Berichte, Unterschrift, Folgearbeiten, Paginierung), **Monteur sieht nur freigegebene Einsätze**, ohne sichtbaren Auftrag not_found, **Mandantentrennung**, Soft Delete | +| `test-stammdaten-teams` | Anlage, Mitglieder gültig ab/bis, fremder/inaktiver Nutzer abgelehnt, doppelte Mitglieder, Namenskonflikt, Wirkung auf `activeTeamIds`, Monteur liest aber verwaltet nicht, **Mandantentrennung**, Soft Delete | +| `test-stammdaten-documents` | Scanner (Magic Bytes, Mismatch, Allowlist, Alias), Dateinamen, **falscher Magic Byte → abgelehnt**, leer/zu groß/Sichtbarkeit, SHA-256, Versionierung, **backoffice_only für Monteur verweigert**, team_lead nur Teamleiter, Auftrags- und Objekt-Scope, **fremder Mandant verweigert**, Listenfilter, Byte-Roundtrip Garage, Metadaten/Soft Delete | + +**Smoke gegen den Dev-Server** (Port 3101, Seed-Nutzer `backoffice@demo.example`, `monteur@demo.example`, `admin2@demo.example`; Session-Cookie lokal über `finalizeIdentityLogin` + Auth.js-`encode` ausgestellt, ohne Passworteingabe): **alle Prüfungen grün**. +- API: Anlage 201 mit K-Nummer, Dublette 409 `possible_duplicates`, Validierung 422 `invalid`, Cross-Site-Origin 403, Liste/Suche mit `pagination.total`, Detail, PATCH, Objekt anlegen, Historie `meta.onlyApproved`; Monteur ohne Zuweisung 404/403; Mandant demo2 → 404. +- Dateien: Upload → `?docOk=1`, falscher Magic Byte → `?docError=type_mismatch`, externes `returnTo` → kein Open Redirect, JSON-Upload, Download 200 byte-identisch mit `attachment` + `nosniff`, `backoffice_only` als Monteur 404, fremder Mandant 404, ohne Session Redirect. +- Server-Rendering (200 + erwarteter Text): `/customers` (+ `?status=provisional`, `?new=1`), `/customers/[id]` mit allen Tabs sowie `?edit=1`/`?merge=1`, `/sites` (+ `?new=1&customerId=`), `/sites/[id]` (Stammdaten, Kartenlink, Dokumente inkl. Upload-Rückmeldungen, Historie, `?edit=1`), `/teams` (+ `?new=1`, Monteur „Nur Lesezugriff.“), `/documents` (+ Kategoriefilter); Monteur `/customers/[id]` → 404, demo2 `/sites/[id]` → 404. +- Visuell im Browser: Kundenliste, Anlage-Popup, Ansprechpartner über das Client-Formular angelegt (Server Action → Liste aktualisiert); Viewport 800 px und 375 px. + +## 5. Stubs / Abhängigkeiten zu anderen Lanes + +- Keine Stubs nötig. Genutzt werden die Verträge aus dem Architektur-Commit (`visibility.ts`, `numbering.ts`, `status.ts`, `storage/adapter.ts`). +- Links auf `/work-orders/[id]` (L2) und `/reports/[id]` (L5) zeigen bis zum Merge ins Leere (404). +- Vertrag für L3: Architektur nennt `findDuplicateCustomers(db, candidate)`, umgesetzt ist (wie im Lane-Auftrag) `findDuplicateCustomers(ctx, candidate, opts?)`; Rückgabe ist eine Obermenge von `{ customerId, score, reasons }`. +- L4/L5/L8 legen Fotos, Sprachnotizen, Unterschriften, Berichts-PDFs über `storeFile` ab: mit `workOrderId` genügt `field:execute`, `report:write`, `emergency:create` oder `document:write` + Auftrag im Scope; unverknüpfte Originale (Import) brauchen `document:write` oder `import:write`. + +## 6. Bekannte Lücken / offene Punkte + +1. **Fundament – Proxy:** `/api/v1/**` ohne Session-Cookie wird von `src/proxy.ts` auf `/login` umgeleitet (307) statt `401` JSON. Vorschlag: `/api/v1` im Proxy durchlassen (die Handler prüfen selbst über `requireApiContext`). +2. **Fundament – Upload-Größe:** Mit aktivem Proxy puffert Next.js Request-Bodies nur bis `proxyClientMaxBodySize` (Default 10 MB). PDFs bis 25 MB brauchen `experimental.proxyClientMaxBodySize: "26mb"` in `next.config.ts` (nicht in dieser Lane geändert). Größere Uploads werden bis dahin mit `too_large` abgewiesen. +3. **Fundament – DSGVO:** Personenreferenzen der Fachmodelle (u. a. `Customer.createdById`, `Document.uploadedById`, `Team.leaderUserId`, `TeamMember.userId`) sind noch nicht in `src/server/dsgvo/pii-fields.ts` eingetragen (AGENTS.md Regel 6). +4. **Historie für Monteure (Entscheidung zur Prüfung):** Das Objekt muss über einen sichtbaren Auftrag erreichbar sein (`siteScope`). Dann sieht der Monteur **alle freigegebenen** Einsätze am Objekt, auch die anderer Teams (US-005 „freigegebene frühere Berichte“). Nicht freigegebene fremde Einsätze bleiben verborgen. Soll strikt nur `workOrderScope` gelten, ist das eine Zeile in `services/sites/history.ts`. +5. Downloads werden nicht modulgegated (`moduleKey = null`), weil Fotos/Berichte modulübergreifend sind; Rechte, Sichtbarkeit und Scope gelten weiterhin. Downloads werden nicht auditiert. +6. Soft Delete eines Dokuments löscht das Objekt im Speicher nicht (Aufbewahrung). Die Tests hinterlassen kleine Testobjekte im Garage-Bucket (Adapter hat kein `delete`). +7. Objekt-Formular: Die Kontaktauswahl steht nur bei bekanntem Kunden bereit (Bearbeiten bzw. Anlage aus dem Kundendetail). Bei freier Anlage folgt die Auswahl nach dem Speichern. +8. Dublettensuche: Namens-/PLZ-/E-Mail-Vorfilter per SQL (max. 200 Kandidaten), Telefonvergleich über die Telefonspalten im Speicher (max. 10 000 Zeilen) – für MVP-Größen ausreichend; bei großen Mandanten normalisierte Suchspalten ergänzen. +9. OpenAPI-Dokumentation der Endpunkte folgt mit L10. +10. **Fundament – Backoffice-Layout mobil:** Bei 375 px belegt die feste Sidebar (`w-60` in `src/app/(app)/layout.tsx`) den Großteil der Breite. Die L1-Seiten umbrechen Formulare/Filter und scrollen Tabellen horizontal, eine einklappbare Sidebar fehlt aber (Mobile-Zielgruppe nutzt `/m`, L4). + +## 7. Screens / Routen + +| Route | Inhalt | +|---|---| +| `/customers` | Suche, Statusfilter inkl. „Vorläufig“, 25 je Seite, „Neuer Kunde“ (`?new=1`, Dublettenhinweis) | +| `/customers/[id]` | Tabs Stammdaten · Ansprechpartner (`?contact=new|`) · Objekte · Aufträge (Lesesicht → `/work-orders/[id]`) · Dokumente; Aktionen Bearbeiten (`?edit=1`), Kunden bestätigen, Zusammenführen (`?merge=1`), Löschen (`?delete=1`) | +| `/sites` | Suche, Statusfilter, 25 je Seite, „Neues Objekt“ (`?new=1&customerId=`) | +| `/sites/[id]` | Tabs Stammdaten (Hinweise hervorgehoben, Kartenlink) · Dokumente · Historie (Backoffice: alle / nur freigegebene) | +| `/teams` | Liste mit Mitgliedern (aktuell/beendet/ab Datum), Popup Anlage/Bearbeitung/Löschen; Monteur: Nur-Lese-Ansicht | +| `/documents` | Übersicht mit Filtern, Upload, Bearbeiten, neue Version, Entfernen | +| `/files/[documentId]` | autorisierter Download | +| `/documents/upload` | Upload-Endpunkt (POST, multipart) | diff --git a/messages/de/customers.json b/messages/de/customers.json new file mode 100644 index 0000000..b4457a4 --- /dev/null +++ b/messages/de/customers.json @@ -0,0 +1,180 @@ +{ + "title": "Kunden", + "crumb": "Stammdaten", + "sub": "Kunden, Ansprechpartner und Objekte an einem Ort.", + "new": "Neuer Kunde", + "searchLabel": "Suche", + "searchPlaceholder": "Name, Kundennummer, Ort oder E-Mail", + "filter": { + "status": "Status", + "all": "Alle (ohne zusammengeführte)", + "apply": "Filtern", + "reset": "Zurücksetzen" + }, + "status": { + "active": "Aktiv", + "inactive": "Inaktiv", + "provisional": "Vorläufig", + "merged": "Zusammengeführt" + }, + "provisionalHint": "Vorläufig – Prüfung durch Backoffice erforderlich", + "columns": { + "number": "Kundennr.", + "name": "Name", + "city": "Ort", + "contact": "Kontakt", + "sites": "Objekte", + "status": "Status" + }, + "empty": "Keine Kunden gefunden.", + "pagination": { + "prev": "Zurück", + "next": "Weiter", + "summary": "{from}–{to} von {total}" + }, + "fields": { + "customerNumber": "Kundennummer", + "customerNumberHint": "Leer lassen: nächste freie Nummer wird vergeben.", + "companyName": "Firmenname", + "salutation": "Anrede", + "firstName": "Vorname", + "lastName": "Nachname", + "street": "Straße", + "houseNumber": "Hausnr.", + "postalCode": "PLZ", + "city": "Ort", + "country": "Land", + "phone": "Telefon", + "mobile": "Mobil", + "email": "E-Mail", + "notes": "Allgemeine Hinweise", + "billingNotes": "Abrechnungshinweise", + "status": "Status", + "createdAt": "Angelegt", + "updatedAt": "Geändert" + }, + "sections": { + "customer": "Kunde", + "address": "Adresse", + "contact": "Erreichbarkeit", + "notes": "Hinweise" + }, + "form": { + "createTitle": "Kunde anlegen", + "createSub": "Firmen- oder Privatkunde. Firmenname oder Nachname ist Pflicht.", + "editTitle": "Kunde bearbeiten", + "create": "Kunde anlegen", + "save": "Speichern", + "saving": "Speichert…", + "cancel": "Abbrechen", + "close": "Schließen", + "saved": "Gespeichert.", + "required": "Pflichtfeld" + }, + "duplicates": { + "title": "Mögliche Dublette", + "hint": "Diese Kunden sind ähnlich. Bestehenden Kunden verwenden oder trotzdem neu anlegen.", + "open": "Bestehenden Kunden öffnen", + "createAnyway": "Trotzdem neu anlegen", + "score": "Übereinstimmung {percent} %", + "reasons": { + "customer_number": "Kundennummer", + "company_name": "Name", + "address": "Adresse", + "email": "E-Mail", + "phone": "Telefon" + } + }, + "tabs": { + "master": "Stammdaten", + "contacts": "Ansprechpartner", + "sites": "Objekte", + "orders": "Aufträge", + "documents": "Dokumente" + }, + "detail": { + "edit": "Bearbeiten", + "confirm": "Kunden bestätigen", + "confirmHint": "Vorläufigen Kunden nach Prüfung als aktiv übernehmen.", + "merge": "Zusammenführen", + "delete": "Löschen", + "deleteTitle": "Kunden löschen", + "deleteHint": "Der Kunde wird ausgeblendet. Aufträge, Berichte und Nachweise bleiben erhalten. Nur ohne offene Aufträge möglich.", + "deleteConfirm": "Kunden löschen", + "mergedInto": "Dieser Kunde wurde zusammengeführt in:", + "mergedOk": "Kunden zusammengeführt. Kontakte, Objekte, Aufträge und Dokumente liegen jetzt hier.", + "back": "Zur Kundenliste", + "readOnly": "Nur Lesezugriff." + }, + "contacts": { + "new": "Ansprechpartner hinzufügen", + "createTitle": "Ansprechpartner hinzufügen", + "editTitle": "Ansprechpartner bearbeiten", + "empty": "Noch keine Ansprechpartner.", + "delete": "Entfernen", + "deleteConfirm": "Ansprechpartner entfernen?", + "preferred": "bevorzugt", + "fields": { + "name": "Name", + "role": "Funktion", + "phone": "Telefon", + "mobile": "Mobil", + "email": "E-Mail", + "preferredChannel": "Bevorzugter Kontaktweg", + "notes": "Bemerkungen" + }, + "channel": { + "phone": "Telefon", + "mobile": "Mobil", + "email": "E-Mail", + "none": "Keine Angabe" + } + }, + "sites": { + "empty": "Noch keine Objekte.", + "new": "Objekt anlegen" + }, + "orders": { + "empty": "Keine Aufträge sichtbar.", + "hint": "Lesesicht. Aufträge werden im Bereich Aufträge bearbeitet.", + "columns": { + "number": "Auftrag", + "title": "Bezeichnung", + "site": "Objekt", + "team": "Team", + "date": "Termin", + "status": "Status" + } + }, + "merge": { + "title": "Kunden zusammenführen", + "sub": "Kontakte, Objekte, Aufträge und Dokumente werden auf den Zielkunden umgehängt. Dieser Kunde wird als zusammengeführt markiert.", + "source": "Wird aufgelöst", + "target": "Zielkunde", + "candidates": "Mögliche Dubletten", + "noCandidates": "Keine ähnlichen Kunden gefunden. Zielkunde über die Kundennummer wählen.", + "targetNumber": "Oder Kundennummer des Zielkunden", + "confirm": "Datensätze geprüft. Zusammenführen bestätigen – das wird nicht automatisch rückgängig gemacht.", + "submit": "Zusammenführen" + }, + "errors": { + "invalid": "Bitte die markierten Felder prüfen.", + "not_found": "Datensatz nicht gefunden.", + "forbidden": "Dafür fehlt die Berechtigung.", + "conflict": "Das geht so nicht: Der Datensatz wurde geändert oder ist bereits vergeben.", + "blocked": "Aktion aktuell nicht möglich.", + "generic": "Das hat nicht geklappt. Bitte erneut versuchen.", + "required": "Pflichtfeld", + "invalidField": "Eingabe prüfen", + "name_required": "Firmenname oder Nachname angeben.", + "number_taken": "Kundennummer ist bereits vergeben.", + "number_allocation": "Keine freie Kundennummer gefunden.", + "open_work_orders": "Es gibt noch offene Aufträge.", + "not_provisional": "Der Kunde ist nicht vorläufig.", + "already_merged": "Einer der Kunden ist bereits zusammengeführt.", + "same_customer": "Quelle und Ziel sind derselbe Kunde.", + "target_required": "Zielkunden auswählen.", + "target_not_found": "Kundennummer nicht gefunden.", + "confirm_required": "Bitte die Zusammenführung bestätigen." + } +} diff --git a/messages/de/documents.json b/messages/de/documents.json new file mode 100644 index 0000000..e2206b1 --- /dev/null +++ b/messages/de/documents.json @@ -0,0 +1,120 @@ +{ + "title": "Dokumente", + "crumb": "Ablage", + "sub": "Alle Unterlagen zu Kunden, Objekten und Aufträgen – mit Version, Prüfsumme und Sichtbarkeit.", + "empty": "Keine Dokumente gefunden.", + "columns": { + "document": "Dokument", + "category": "Kategorie", + "link": "Zuordnung", + "visibility": "Sichtbarkeit", + "version": "Version", + "size": "Größe", + "uploaded": "Hochgeladen" + }, + "filter": { + "q": "Dateiname oder Titel", + "category": "Kategorie", + "allCategories": "Alle Kategorien", + "customer": "Kunde", + "allCustomers": "Alle Kunden", + "site": "Objekt", + "allSites": "Alle Objekte", + "workOrder": "Auftragsnummer", + "latestOnly": "Nur aktuelle Versionen", + "apply": "Filtern", + "reset": "Zurücksetzen" + }, + "pagination": { + "prev": "Zurück", + "next": "Weiter", + "summary": "{from}–{to} von {total}" + }, + "category": { + "order_confirmation": "Auftragsbestätigung", + "technical_drawing": "Technische Zeichnung", + "floor_plan": "Grundriss", + "wiring_diagram": "Schaltplan", + "assembly_instructions": "Montageanleitung", + "safety_document": "Sicherheitsunterlage", + "product_document": "Produktunterlage", + "customer_note": "Kundenhinweis", + "work_record": "Arbeitsnachweis", + "daily_report": "Tagesbericht", + "completion_report": "Abschlussbericht", + "customer_approval": "Kundenfreigabe", + "photo": "Foto", + "voice_note": "Sprachnotiz", + "signature": "Unterschrift", + "other": "Sonstiges Dokument" + }, + "visibility": { + "backoffice_only": "Nur Backoffice", + "team_lead": "Für Teamleiter", + "team": "Für Montageteam", + "customer_report": "Für Kundenbericht freigegeben" + }, + "link": { + "customer": "Kunde", + "site": "Objekt", + "workOrder": "Auftrag", + "none": "Ohne Zuordnung" + }, + "upload": { + "title": "Dokument hochladen", + "file": "Datei", + "fileHint": "PDF bis 25 MB, Bilder (JPEG, PNG, WebP, HEIC) bis 15 MB, Audio bis 20 MB.", + "titleField": "Titel (optional)", + "category": "Kategorie", + "visibility": "Sichtbarkeit", + "submit": "Hochladen", + "newVersion": "Neue Version", + "newVersionTitle": "Neue Version hochladen", + "newVersionOf": "Neue Version von „{name}“", + "ok": "Dokument gespeichert." + }, + "versions": { + "label": "v{version}", + "older": "{count, plural, one {# ältere Version} other {# ältere Versionen}}", + "latest": "aktuell" + }, + "actions": { + "download": "Herunterladen", + "edit": "Bearbeiten", + "delete": "Entfernen", + "deleteConfirm": "Diese Version entfernen?" + }, + "edit": { + "title": "Dokument bearbeiten", + "titleField": "Titel", + "save": "Speichern", + "saving": "Speichert…", + "cancel": "Abbrechen" + }, + "uploadErrors": { + "empty_file": "Bitte eine Datei auswählen.", + "too_large": "Die Datei ist zu groß.", + "unsupported_type": "Dateityp nicht erlaubt. Erlaubt sind PDF, JPEG, PNG, WebP, HEIC und Audio.", + "type_mismatch": "Dateiinhalt passt nicht zum Dateityp.", + "malware": "Die Datei wurde vom Virenscan abgelehnt.", + "scanner_unavailable": "Virenscan nicht erreichbar. Bitte später erneut versuchen.", + "visibility_not_allowed": "Diese Sichtbarkeit ist nicht erlaubt.", + "lineage_not_found": "Ursprungsdokument nicht gefunden.", + "invalid_category": "Bitte eine Kategorie wählen.", + "site_not_found": "Objekt nicht gefunden.", + "customer_not_found": "Kunde nicht gefunden.", + "forbidden": "Dafür fehlt die Berechtigung.", + "not_found": "Auftrag nicht gefunden.", + "invalid": "Upload ungültig.", + "generic": "Upload fehlgeschlagen. Bitte erneut versuchen." + }, + "errors": { + "invalid": "Bitte die Eingaben prüfen.", + "not_found": "Dokument nicht gefunden.", + "forbidden": "Dafür fehlt die Berechtigung.", + "conflict": "Das geht so nicht.", + "blocked": "Aktion aktuell nicht möglich.", + "generic": "Das hat nicht geklappt. Bitte erneut versuchen.", + "visibility_not_allowed": "Diese Sichtbarkeit ist nicht erlaubt." + } +} diff --git a/messages/de/sites.json b/messages/de/sites.json new file mode 100644 index 0000000..4a25792 --- /dev/null +++ b/messages/de/sites.json @@ -0,0 +1,137 @@ +{ + "title": "Objekte", + "crumb": "Stammdaten", + "sub": "Baustellen, Filialen und Anlagen mit Hinweisen, Dokumenten und Einsatzhistorie.", + "new": "Neues Objekt", + "searchLabel": "Suche", + "searchPlaceholder": "Objekt, Adresse oder Kunde", + "filter": { + "status": "Status", + "all": "Alle", + "apply": "Filtern", + "reset": "Zurücksetzen" + }, + "status": { + "active": "Aktiv", + "inactive": "Inaktiv", + "provisional": "Vorläufig" + }, + "columns": { + "name": "Objekt", + "customer": "Kunde", + "address": "Adresse", + "orders": "Aufträge", + "status": "Status" + }, + "empty": "Keine Objekte gefunden.", + "pagination": { + "prev": "Zurück", + "next": "Weiter", + "summary": "{from}–{to} von {total}" + }, + "fields": { + "customerId": "Kunde", + "selectCustomer": "Kunden auswählen", + "name": "Objektbezeichnung", + "street": "Straße", + "houseNumber": "Hausnr.", + "postalCode": "PLZ", + "city": "Ort", + "country": "Land", + "contactId": "Ansprechpartner vor Ort", + "noContact": "Kein hinterlegter Kontakt", + "onSiteContact": "Ansprechpartner vor Ort (Freitext)", + "phone": "Telefon vor Ort", + "accessNotes": "Zugangshinweise", + "parkingNotes": "Parkhinweise", + "safetyNotes": "Sicherheitsinformationen", + "technicalNotes": "Technische Hinweise", + "status": "Status", + "latitude": "Breitengrad", + "longitude": "Längengrad" + }, + "sections": { + "base": "Objekt", + "address": "Adresse", + "onSite": "Vor Ort", + "notes": "Hinweise für den Einsatz", + "geo": "Koordinaten (optional)" + }, + "form": { + "createTitle": "Objekt anlegen", + "createSub": "Jedes Objekt gehört zu einem Kunden.", + "editTitle": "Objekt bearbeiten", + "create": "Objekt anlegen", + "save": "Speichern", + "saving": "Speichert…", + "cancel": "Abbrechen", + "saved": "Gespeichert.", + "contactHint": "Kontakte des Kunden stehen nach dem Anlegen zur Auswahl." + }, + "tabs": { + "master": "Stammdaten", + "documents": "Dokumente", + "history": "Historie" + }, + "detail": { + "edit": "Bearbeiten", + "delete": "Löschen", + "deleteTitle": "Objekt löschen", + "deleteHint": "Das Objekt wird ausgeblendet. Historie und Nachweise bleiben erhalten. Nur ohne offene Aufträge möglich.", + "deleteConfirm": "Objekt löschen", + "map": "Karte öffnen (OpenStreetMap)", + "noMap": "Keine Adresse für die Karte", + "customer": "Kunde", + "back": "Zur Objektliste", + "noNotes": "Keine Hinweise hinterlegt." + }, + "history": { + "title": "Einsatzhistorie", + "onlyApproved": "Nur freigegebene", + "showAll": "Alle Einsätze", + "approvedOnlyHint": "Angezeigt werden freigegebene Einsätze.", + "empty": "Noch keine Einsätze an diesem Objekt.", + "workDone": "Durchgeführte Arbeiten", + "noWorkDone": "Keine Tätigkeiten erfasst.", + "materials": "Material", + "noMaterials": "Kein Material erfasst.", + "photos": "{count, plural, =0 {Keine Fotos} one {# Foto} other {# Fotos}}", + "reports": "Berichte", + "noReports": "Kein freigegebener Bericht.", + "reportType": { + "daily": "Tagesbericht", + "completion": "Abschlussbericht" + }, + "reportLink": "{type} v{version} vom {date}", + "signed": "Kundenunterschrift liegt vor", + "notSigned": "Keine Kundenunterschrift", + "followUp": "Offene Folgearbeiten", + "emergency": "Notdienst", + "noTeam": "Kein Team", + "openOrder": "Auftrag öffnen" + }, + "statusGroup": { + "new": "Neu", + "planned": "Geplant", + "en_route": "Unterwegs", + "in_progress": "In Arbeit", + "documentation_incomplete": "Dokumentation unvollständig", + "in_review": "Zur Prüfung", + "ready_for_billing": "Bereit zur Abrechnung", + "billed": "Abgerechnet", + "cancelled": "Storniert" + }, + "errors": { + "invalid": "Bitte die markierten Felder prüfen.", + "not_found": "Datensatz nicht gefunden.", + "forbidden": "Dafür fehlt die Berechtigung.", + "conflict": "Das geht so nicht: Der Datensatz wurde geändert.", + "blocked": "Aktion aktuell nicht möglich.", + "generic": "Das hat nicht geklappt. Bitte erneut versuchen.", + "required": "Pflichtfeld", + "invalidField": "Eingabe prüfen", + "customer_not_found": "Kunde nicht gefunden.", + "contact_mismatch": "Der Kontakt gehört nicht zu diesem Kunden.", + "open_work_orders": "Es gibt noch offene Aufträge." + } +} diff --git a/messages/de/teams.json b/messages/de/teams.json new file mode 100644 index 0000000..4f9fdb1 --- /dev/null +++ b/messages/de/teams.json @@ -0,0 +1,75 @@ +{ + "title": "Teams", + "crumb": "Stammdaten", + "sub": "Montageteams mit Teamleitung, Mitgliedern und Einsatzgebiet.", + "new": "Neues Team", + "showInactive": "Inaktive anzeigen", + "hideInactive": "Inaktive ausblenden", + "empty": "Noch keine Teams.", + "columns": { + "name": "Team", + "leader": "Teamleiter", + "members": "Mitglieder", + "phone": "Telefon", + "vehicle": "Fahrzeug", + "area": "Einsatzgebiet", + "status": "Status" + }, + "status": { + "active": "Aktiv", + "inactive": "Inaktiv" + }, + "fields": { + "name": "Teamname", + "leaderUserId": "Teamleiter", + "noLeader": "Kein Teamleiter", + "status": "Status", + "phone": "Telefon", + "vehicle": "Fahrzeug", + "area": "Einsatzgebiet", + "notes": "Interne Hinweise" + }, + "members": { + "title": "Mitglieder", + "add": "Mitglied hinzufügen", + "remove": "Entfernen", + "user": "Person", + "selectUser": "Person auswählen", + "validFrom": "Gültig ab", + "validTo": "Gültig bis", + "validToHint": "Leer = unbefristet", + "empty": "Noch keine Mitglieder.", + "current": "aktuell", + "ended": "beendet", + "upcoming": "ab {date}", + "count": "{count, plural, =0 {Keine Mitglieder} one {# Mitglied} other {# Mitglieder}}" + }, + "form": { + "createTitle": "Team anlegen", + "editTitle": "Team bearbeiten", + "sub": "Mitglieder kommen aus den aktiven Nutzern des Mandanten.", + "save": "Speichern", + "create": "Team anlegen", + "saving": "Speichert…", + "cancel": "Abbrechen", + "delete": "Team löschen", + "deleteHint": "Das Team wird ausgeblendet. Nur ohne offene Aufträge möglich.", + "deleteConfirm": "Team wirklich löschen?" + }, + "readOnly": "Nur Lesezugriff.", + "errors": { + "invalid": "Bitte die markierten Felder prüfen.", + "not_found": "Team nicht gefunden.", + "forbidden": "Dafür fehlt die Berechtigung.", + "conflict": "Das geht so nicht: Der Name ist bereits vergeben.", + "blocked": "Aktion aktuell nicht möglich.", + "generic": "Das hat nicht geklappt. Bitte erneut versuchen.", + "required": "Pflichtfeld", + "invalidField": "Eingabe prüfen", + "duplicate_member": "Eine Person ist mehrfach eingetragen.", + "inactive_user": "Nur aktive Nutzer des Mandanten sind möglich.", + "name_taken": "Teamname ist bereits vergeben.", + "valid_to_before_from": "„Gültig bis“ liegt vor „Gültig ab“.", + "open_work_orders": "Dem Team sind noch offene Aufträge zugewiesen." + } +} diff --git a/messages/en/customers.json b/messages/en/customers.json new file mode 100644 index 0000000..14250eb --- /dev/null +++ b/messages/en/customers.json @@ -0,0 +1,180 @@ +{ + "title": "Customers", + "crumb": "Master data", + "sub": "Customers, contacts and sites in one place.", + "new": "New customer", + "searchLabel": "Search", + "searchPlaceholder": "Name, customer no., city or e-mail", + "filter": { + "status": "Status", + "all": "All (without merged)", + "apply": "Filter", + "reset": "Reset" + }, + "status": { + "active": "Active", + "inactive": "Inactive", + "provisional": "Provisional", + "merged": "Merged" + }, + "provisionalHint": "Provisional – back office review required", + "columns": { + "number": "Customer no.", + "name": "Name", + "city": "City", + "contact": "Contact", + "sites": "Sites", + "status": "Status" + }, + "empty": "No customers found.", + "pagination": { + "prev": "Previous", + "next": "Next", + "summary": "{from}–{to} of {total}" + }, + "fields": { + "customerNumber": "Customer number", + "customerNumberHint": "Leave empty to use the next free number.", + "companyName": "Company name", + "salutation": "Salutation", + "firstName": "First name", + "lastName": "Last name", + "street": "Street", + "houseNumber": "No.", + "postalCode": "Postal code", + "city": "City", + "country": "Country", + "phone": "Phone", + "mobile": "Mobile", + "email": "E-mail", + "notes": "General notes", + "billingNotes": "Billing notes", + "status": "Status", + "createdAt": "Created", + "updatedAt": "Updated" + }, + "sections": { + "customer": "Customer", + "address": "Address", + "contact": "Reachability", + "notes": "Notes" + }, + "form": { + "createTitle": "Create customer", + "createSub": "Business or private customer. Company name or last name is required.", + "editTitle": "Edit customer", + "create": "Create customer", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel", + "close": "Close", + "saved": "Saved.", + "required": "Required" + }, + "duplicates": { + "title": "Possible duplicate", + "hint": "These customers look similar. Use an existing customer or create a new one anyway.", + "open": "Open existing customer", + "createAnyway": "Create anyway", + "score": "Match {percent} %", + "reasons": { + "customer_number": "Customer number", + "company_name": "Name", + "address": "Address", + "email": "E-mail", + "phone": "Phone" + } + }, + "tabs": { + "master": "Master data", + "contacts": "Contacts", + "sites": "Sites", + "orders": "Work orders", + "documents": "Documents" + }, + "detail": { + "edit": "Edit", + "confirm": "Confirm customer", + "confirmHint": "Take over the provisional customer as active after review.", + "merge": "Merge", + "delete": "Delete", + "deleteTitle": "Delete customer", + "deleteHint": "The customer is hidden. Work orders, reports and records are kept. Only possible without open work orders.", + "deleteConfirm": "Delete customer", + "mergedInto": "This customer was merged into:", + "mergedOk": "Customers merged. Contacts, sites, work orders and documents are now here.", + "back": "Back to customers", + "readOnly": "Read-only access." + }, + "contacts": { + "new": "Add contact", + "createTitle": "Add contact", + "editTitle": "Edit contact", + "empty": "No contacts yet.", + "delete": "Remove", + "deleteConfirm": "Remove contact?", + "preferred": "preferred", + "fields": { + "name": "Name", + "role": "Role", + "phone": "Phone", + "mobile": "Mobile", + "email": "E-mail", + "preferredChannel": "Preferred channel", + "notes": "Remarks" + }, + "channel": { + "phone": "Phone", + "mobile": "Mobile", + "email": "E-mail", + "none": "Not specified" + } + }, + "sites": { + "empty": "No sites yet.", + "new": "Create site" + }, + "orders": { + "empty": "No visible work orders.", + "hint": "Read-only. Work orders are edited in the work orders area.", + "columns": { + "number": "Order", + "title": "Title", + "site": "Site", + "team": "Team", + "date": "Scheduled", + "status": "Status" + } + }, + "merge": { + "title": "Merge customers", + "sub": "Contacts, sites, work orders and documents are moved to the target customer. This customer is marked as merged.", + "source": "Will be merged", + "target": "Target customer", + "candidates": "Possible duplicates", + "noCandidates": "No similar customers found. Choose the target by customer number.", + "targetNumber": "Or customer number of the target", + "confirm": "Records checked. Confirm merge – this is not undone automatically.", + "submit": "Merge" + }, + "errors": { + "invalid": "Please check the highlighted fields.", + "not_found": "Record not found.", + "forbidden": "You are not allowed to do this.", + "conflict": "Not possible: the record was changed or is already taken.", + "blocked": "Action currently not possible.", + "generic": "That did not work. Please try again.", + "required": "Required", + "invalidField": "Check input", + "name_required": "Enter a company name or last name.", + "number_taken": "Customer number is already taken.", + "number_allocation": "No free customer number found.", + "open_work_orders": "There are still open work orders.", + "not_provisional": "The customer is not provisional.", + "already_merged": "One of the customers is already merged.", + "same_customer": "Source and target are the same customer.", + "target_required": "Select a target customer.", + "target_not_found": "Customer number not found.", + "confirm_required": "Please confirm the merge." + } +} diff --git a/messages/en/documents.json b/messages/en/documents.json new file mode 100644 index 0000000..3bb2537 --- /dev/null +++ b/messages/en/documents.json @@ -0,0 +1,120 @@ +{ + "title": "Documents", + "crumb": "Storage", + "sub": "All files for customers, sites and work orders – with version, checksum and visibility.", + "empty": "No documents found.", + "columns": { + "document": "Document", + "category": "Category", + "link": "Linked to", + "visibility": "Visibility", + "version": "Version", + "size": "Size", + "uploaded": "Uploaded" + }, + "filter": { + "q": "File name or title", + "category": "Category", + "allCategories": "All categories", + "customer": "Customer", + "allCustomers": "All customers", + "site": "Site", + "allSites": "All sites", + "workOrder": "Work order number", + "latestOnly": "Latest versions only", + "apply": "Filter", + "reset": "Reset" + }, + "pagination": { + "prev": "Previous", + "next": "Next", + "summary": "{from}–{to} of {total}" + }, + "category": { + "order_confirmation": "Order confirmation", + "technical_drawing": "Technical drawing", + "floor_plan": "Floor plan", + "wiring_diagram": "Wiring diagram", + "assembly_instructions": "Assembly instructions", + "safety_document": "Safety document", + "product_document": "Product document", + "customer_note": "Customer note", + "work_record": "Work record", + "daily_report": "Daily report", + "completion_report": "Completion report", + "customer_approval": "Customer approval", + "photo": "Photo", + "voice_note": "Voice note", + "signature": "Signature", + "other": "Other document" + }, + "visibility": { + "backoffice_only": "Back office only", + "team_lead": "Team leads", + "team": "Installation team", + "customer_report": "Released for customer report" + }, + "link": { + "customer": "Customer", + "site": "Site", + "workOrder": "Work order", + "none": "Not linked" + }, + "upload": { + "title": "Upload document", + "file": "File", + "fileHint": "PDF up to 25 MB, images (JPEG, PNG, WebP, HEIC) up to 15 MB, audio up to 20 MB.", + "titleField": "Title (optional)", + "category": "Category", + "visibility": "Visibility", + "submit": "Upload", + "newVersion": "New version", + "newVersionTitle": "Upload new version", + "newVersionOf": "New version of \"{name}\"", + "ok": "Document saved." + }, + "versions": { + "label": "v{version}", + "older": "{count, plural, one {# older version} other {# older versions}}", + "latest": "latest" + }, + "actions": { + "download": "Download", + "edit": "Edit", + "delete": "Remove", + "deleteConfirm": "Remove this version?" + }, + "edit": { + "title": "Edit document", + "titleField": "Title", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel" + }, + "uploadErrors": { + "empty_file": "Please select a file.", + "too_large": "The file is too large.", + "unsupported_type": "File type not allowed. Allowed: PDF, JPEG, PNG, WebP, HEIC and audio.", + "type_mismatch": "File content does not match the file type.", + "malware": "The file was rejected by the virus scan.", + "scanner_unavailable": "Virus scan unavailable. Please try again later.", + "visibility_not_allowed": "This visibility is not allowed.", + "lineage_not_found": "Original document not found.", + "invalid_category": "Please choose a category.", + "site_not_found": "Site not found.", + "customer_not_found": "Customer not found.", + "forbidden": "You are not allowed to do this.", + "not_found": "Work order not found.", + "invalid": "Invalid upload.", + "generic": "Upload failed. Please try again." + }, + "errors": { + "invalid": "Please check your input.", + "not_found": "Document not found.", + "forbidden": "You are not allowed to do this.", + "conflict": "Not possible.", + "blocked": "Action currently not possible.", + "generic": "That did not work. Please try again.", + "visibility_not_allowed": "This visibility is not allowed." + } +} diff --git a/messages/en/sites.json b/messages/en/sites.json new file mode 100644 index 0000000..cf893e4 --- /dev/null +++ b/messages/en/sites.json @@ -0,0 +1,137 @@ +{ + "title": "Sites", + "crumb": "Master data", + "sub": "Construction sites, branches and installations with notes, documents and job history.", + "new": "New site", + "searchLabel": "Search", + "searchPlaceholder": "Site, address or customer", + "filter": { + "status": "Status", + "all": "All", + "apply": "Filter", + "reset": "Reset" + }, + "status": { + "active": "Active", + "inactive": "Inactive", + "provisional": "Provisional" + }, + "columns": { + "name": "Site", + "customer": "Customer", + "address": "Address", + "orders": "Work orders", + "status": "Status" + }, + "empty": "No sites found.", + "pagination": { + "prev": "Previous", + "next": "Next", + "summary": "{from}–{to} of {total}" + }, + "fields": { + "customerId": "Customer", + "selectCustomer": "Select customer", + "name": "Site name", + "street": "Street", + "houseNumber": "No.", + "postalCode": "Postal code", + "city": "City", + "country": "Country", + "contactId": "On-site contact", + "noContact": "No stored contact", + "onSiteContact": "On-site contact (free text)", + "phone": "On-site phone", + "accessNotes": "Access notes", + "parkingNotes": "Parking notes", + "safetyNotes": "Safety information", + "technicalNotes": "Technical notes", + "status": "Status", + "latitude": "Latitude", + "longitude": "Longitude" + }, + "sections": { + "base": "Site", + "address": "Address", + "onSite": "On site", + "notes": "Notes for the job", + "geo": "Coordinates (optional)" + }, + "form": { + "createTitle": "Create site", + "createSub": "Every site belongs to a customer.", + "editTitle": "Edit site", + "create": "Create site", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel", + "saved": "Saved.", + "contactHint": "The customer's contacts can be selected after creating the site." + }, + "tabs": { + "master": "Master data", + "documents": "Documents", + "history": "History" + }, + "detail": { + "edit": "Edit", + "delete": "Delete", + "deleteTitle": "Delete site", + "deleteHint": "The site is hidden. History and records are kept. Only possible without open work orders.", + "deleteConfirm": "Delete site", + "map": "Open map (OpenStreetMap)", + "noMap": "No address for the map", + "customer": "Customer", + "back": "Back to sites", + "noNotes": "No notes stored." + }, + "history": { + "title": "Job history", + "onlyApproved": "Released only", + "showAll": "All jobs", + "approvedOnlyHint": "Showing released jobs.", + "empty": "No jobs at this site yet.", + "workDone": "Work carried out", + "noWorkDone": "No activities recorded.", + "materials": "Material", + "noMaterials": "No material recorded.", + "photos": "{count, plural, =0 {No photos} one {# photo} other {# photos}}", + "reports": "Reports", + "noReports": "No released report.", + "reportType": { + "daily": "Daily report", + "completion": "Completion report" + }, + "reportLink": "{type} v{version} of {date}", + "signed": "Customer signature available", + "notSigned": "No customer signature", + "followUp": "Open follow-up work", + "emergency": "Emergency", + "noTeam": "No team", + "openOrder": "Open work order" + }, + "statusGroup": { + "new": "New", + "planned": "Planned", + "en_route": "On the way", + "in_progress": "In progress", + "documentation_incomplete": "Documentation incomplete", + "in_review": "In review", + "ready_for_billing": "Ready for billing", + "billed": "Billed", + "cancelled": "Cancelled" + }, + "errors": { + "invalid": "Please check the highlighted fields.", + "not_found": "Record not found.", + "forbidden": "You are not allowed to do this.", + "conflict": "Not possible: the record was changed.", + "blocked": "Action currently not possible.", + "generic": "That did not work. Please try again.", + "required": "Required", + "invalidField": "Check input", + "customer_not_found": "Customer not found.", + "contact_mismatch": "The contact does not belong to this customer.", + "open_work_orders": "There are still open work orders." + } +} diff --git a/messages/en/teams.json b/messages/en/teams.json new file mode 100644 index 0000000..41462f9 --- /dev/null +++ b/messages/en/teams.json @@ -0,0 +1,75 @@ +{ + "title": "Teams", + "crumb": "Master data", + "sub": "Installation teams with team lead, members and service area.", + "new": "New team", + "showInactive": "Show inactive", + "hideInactive": "Hide inactive", + "empty": "No teams yet.", + "columns": { + "name": "Team", + "leader": "Team lead", + "members": "Members", + "phone": "Phone", + "vehicle": "Vehicle", + "area": "Service area", + "status": "Status" + }, + "status": { + "active": "Active", + "inactive": "Inactive" + }, + "fields": { + "name": "Team name", + "leaderUserId": "Team lead", + "noLeader": "No team lead", + "status": "Status", + "phone": "Phone", + "vehicle": "Vehicle", + "area": "Service area", + "notes": "Internal notes" + }, + "members": { + "title": "Members", + "add": "Add member", + "remove": "Remove", + "user": "Person", + "selectUser": "Select person", + "validFrom": "Valid from", + "validTo": "Valid until", + "validToHint": "Empty = unlimited", + "empty": "No members yet.", + "current": "current", + "ended": "ended", + "upcoming": "from {date}", + "count": "{count, plural, =0 {No members} one {# member} other {# members}}" + }, + "form": { + "createTitle": "Create team", + "editTitle": "Edit team", + "sub": "Members are active users of the tenant.", + "save": "Save", + "create": "Create team", + "saving": "Saving…", + "cancel": "Cancel", + "delete": "Delete team", + "deleteHint": "The team is hidden. Only possible without open work orders.", + "deleteConfirm": "Really delete the team?" + }, + "readOnly": "Read-only access.", + "errors": { + "invalid": "Please check the highlighted fields.", + "not_found": "Team not found.", + "forbidden": "You are not allowed to do this.", + "conflict": "Not possible: the name is already taken.", + "blocked": "Action currently not possible.", + "generic": "That did not work. Please try again.", + "required": "Required", + "invalidField": "Check input", + "duplicate_member": "A person is listed more than once.", + "inactive_user": "Only active users of the tenant can be added.", + "name_taken": "Team name is already taken.", + "valid_to_before_from": "\"Valid until\" is before \"valid from\".", + "open_work_orders": "Open work orders are still assigned to the team." + } +} diff --git a/scripts/lib-stammdaten-fixtures.ts b/scripts/lib-stammdaten-fixtures.ts new file mode 100644 index 0000000..b0e82f0 --- /dev/null +++ b/scripts/lib-stammdaten-fixtures.ts @@ -0,0 +1,140 @@ +// Shared fixtures for the scripts/test-stammdaten-*.ts tests (lane L1 Stammdaten). +// Creates isolated zz-test tenants with the raw owner client and builds ServiceCtx objects with +// the permission sets of the standard roles (src/server/rbac.ts ROLE_DEFS). Not a test itself +// (the runner only picks up files named test-*.ts). + +import "dotenv/config"; +import { prisma, dbForTenant } from "../src/server/db"; +import { ROLE_DEFS, type RoleKey } from "../src/server/rbac"; +import { ServiceError, type ServiceCtx } from "../src/server/services/context"; + +export function checker(title: string) { + let failures = 0; + const ok = (cond: unknown, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; + }; + /** Expect `fn` to reject with a ServiceError of `code` (and optionally `reason`). */ + const expectServiceError = async (fn: () => Promise, code: ServiceError["code"], msg: string, reason?: string) => { + try { + await fn(); + ok(false, `${msg} — kein Fehler geworfen`); + } catch (err) { + const e = err as ServiceError; + const actualReason = (e.details as { reason?: string } | undefined)?.reason; + const match = e instanceof ServiceError && e.code === code && (!reason || actualReason === reason); + ok(match, `${msg}${match ? "" : ` — erhalten: ${e?.name}/${e?.code ?? ""}/${actualReason ?? ""} ${e?.message ?? ""}`}`); + } + }; + /** Expect `fn` to reject with any error whose name matches (e.g. ZodError). */ + const expectErrorName = async (fn: () => Promise, name: string, msg: string) => { + try { + await fn(); + ok(false, `${msg} — kein Fehler geworfen`); + } catch (err) { + ok((err as Error)?.name === name, `${msg}${(err as Error)?.name === name ? "" : ` — erhalten: ${(err as Error)?.name} ${(err as Error)?.message}`}`); + } + }; + const finish = () => { + console.log(failures === 0 ? `\nOK — ${title}: alle Prüfungen erfüllt.` : `\n${failures} FEHLER in ${title}.`); + return failures; + }; + return { ok, expectServiceError, expectErrorName, finish, get failures() { return failures; } }; +} + +const MODELS_IN_DELETE_ORDER = [ + "signature", + "report", + "photo", + "activityNote", + "voiceNote", + "materialUsage", + "timeEntry", + "workSession", + "materialPlan", + "checklistItem", + "photoRequirement", + "workOrderStatusChange", + "workOrderAssignee", + "document", + "syncOperation", + "notification", + "aiGeneration", + "workOrder", + "importJob", + "site", + "contact", + "customer", + "teamMember", + "team", + "checklistTemplate", + "orderType", + "numberSequence", + "auditLog", +] as const; + +export async function cleanupTenants(slugs: string[], emailDomain: string) { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (ids.length) { + const client = prisma as unknown as Record Promise }>; + for (const model of MODELS_IN_DELETE_ORDER) await client[model].deleteMany({ where: { tenantId: { in: ids } } }); + await prisma.userRole.deleteMany({ where: { user: { tenantId: { in: ids } } } }); + await prisma.user.deleteMany({ where: { tenantId: { in: ids } } }); + await prisma.tenantModule.deleteMany({ where: { tenantId: { in: ids } } }); + await prisma.tenantSettings.deleteMany({ where: { tenantId: { in: ids } } }); + await prisma.role.deleteMany({ where: { tenantId: { in: ids } } }); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + } + await prisma.identity.deleteMany({ where: { email: { endsWith: `@${emailDomain}` }, memberships: { none: {} } } }); +} + +export async function createTenant(slug: string, name: string) { + return prisma.tenant.create({ data: { slug, name } }); +} + +export async function createUser(tenantId: string, email: string, name: string) { + const identity = await prisma.identity.upsert({ where: { email }, update: {}, create: { email, passwordHash: "x" } }); + return prisma.user.create({ data: { tenantId, identityId: identity.id, email, name, status: "ACTIVE" } }); +} + +/** ServiceCtx with the permission set of a standard role (plus/minus adjustments). */ +export function ctxFor(tenantId: string, userId: string, role: RoleKey, adjust: { add?: string[]; remove?: string[] } = {}): ServiceCtx { + const perms = new Set(ROLE_DEFS[role].permissions); + for (const p of adjust.add ?? []) perms.add(p); + for (const p of adjust.remove ?? []) perms.delete(p); + return { db: dbForTenant(tenantId), tenantId, userId, permissions: perms }; +} + +let orderSeq = 0; +export async function createWorkOrder( + tenantId: string, + data: { customerId: string; siteId?: string | null; assignedTeamId?: string | null; status?: string; title?: string; followUpWork?: string | null; plannedStart?: Date }, +) { + orderSeq++; + return prisma.workOrder.create({ + data: { + tenantId, + number: `ZZ-${Date.now().toString(36)}-${orderSeq}`, + customerId: data.customerId, + siteId: data.siteId ?? null, + assignedTeamId: data.assignedTeamId ?? null, + status: (data.status ?? "assigned") as never, + title: data.title ?? `Testauftrag ${orderSeq}`, + followUpWork: data.followUpWork ?? null, + plannedStart: data.plannedStart, + }, + }); +} + +export async function createTeamWithMember(tenantId: string, name: string, memberUserId: string | null, leaderUserId: string | null = null) { + const team = await prisma.team.create({ data: { tenantId, name, leaderUserId } }); + if (memberUserId) await prisma.teamMember.create({ data: { tenantId, teamId: team.id, userId: memberUserId, validFrom: new Date(Date.now() - 86_400_000) } }); + return team; +} + +export async function disconnect() { + await prisma.$disconnect(); +} + +export { prisma }; diff --git a/scripts/test-stammdaten-customers.ts b/scripts/test-stammdaten-customers.ts new file mode 100644 index 0000000..4c86258 --- /dev/null +++ b/scripts/test-stammdaten-customers.ts @@ -0,0 +1,200 @@ +// L1 Stammdaten — Kunden, Ansprechpartner, vorläufige Kunden, Zusammenführen (Spec §7, US-003): +// (1) Anlage mit Nummernkreis, manuelle Nummer, Eindeutigkeit, Dublettenhinweis, Validierung, Audit +// (2) Bearbeiten, Ansprechpartner, vorläufig → aktiv +// (3) Mandantentrennung: Mandant B kann A weder lesen noch ändern noch zusammenführen +// (4) Rollen/Scope: Monteur ohne Zuweisung → not_found, mit Teamauftrag → sichtbar, keine Schreibrechte +// (5) Zusammenführen: nur mit customer:merge + Bestätigung, hängt Kontakte/Objekte/Aufträge/Dokumente um +// (6) Soft Delete blockiert bei offenen Aufträgen +// +// Lauf: npx tsx scripts/test-stammdaten-customers.ts + +import "dotenv/config"; // must run before any module that constructs the Prisma client + +import { + confirmProvisionalCustomer, + createCustomer, + deleteCustomer, + getCustomer, + listCustomers, + updateCustomer, +} from "../src/server/services/customers/customers"; +import { createContact, deleteContact, updateContact } from "../src/server/services/customers/contacts"; +import { mergeCustomers } from "../src/server/services/customers/merge"; +import { + checker, + cleanupTenants, + createTeamWithMember, + createTenant, + createUser, + createWorkOrder, + ctxFor, + disconnect, + prisma, +} from "./lib-stammdaten-fixtures"; + +const SLUG_A = "zz-l1-cust-a"; +const SLUG_B = "zz-l1-cust-b"; +const DOMAIN = "zz-l1-cust.test"; +const c = checker("Kunden"); + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); + const tA = await createTenant(SLUG_A, "L1 Kunden A"); + const tB = await createTenant(SLUG_B, "L1 Kunden B"); + const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); + const techA = await createUser(tA.id, `tech@${DOMAIN}`, "Monteur A"); + const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); + const ctxA = ctxFor(tA.id, boA.id, "backoffice"); + const ctxB = ctxFor(tB.id, boB.id, "backoffice"); + const ctxTech = ctxFor(tA.id, techA.id, "technician"); + + console.log("— (1) Anlage —"); + const alpha = await createCustomer(ctxA, { companyName: "Alpha Sanitär GmbH", street: "Hafenstraße", houseNumber: "1", postalCode: "20457", city: "Hamburg" }); + c.ok(/^K-\d{5}$/.test(alpha.customerNumber ?? ""), `Kundennummer aus Nummernkreis (${alpha.customerNumber})`); + c.ok(alpha.createdById === boA.id && alpha.status === "active" && alpha.country === "DE", "createdById, Status aktiv, Land DE"); + const audit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "customer", entityId: alpha.id, action: "create" } }); + c.ok(!!audit?.after, "Audit-Eintrag (create, after) geschrieben"); + + const manual = await createCustomer(ctxA, { companyName: "Beta Elektro", customerNumber: "K-00002" }); + c.ok(manual.customerNumber === "K-00002", "manuelle Kundennummer übernommen"); + const auto = await createCustomer(ctxA, { lastName: "Gamma", firstName: "Gerda" }); + c.ok(auto.customerNumber === "K-00003", `Nummernkreis überspringt manuell vergebene Nummer (${auto.customerNumber})`); + await c.expectServiceError(() => createCustomer(ctxA, { companyName: "Delta", customerNumber: "K-00002" }), "conflict", "doppelte Kundennummer → conflict", "number_taken"); + const inB = await createCustomer(ctxB, { companyName: "Delta B", customerNumber: "K-00002" }); + c.ok(inB.customerNumber === "K-00002", "gleiche Kundennummer in anderem Mandanten erlaubt (eindeutig je Mandant)"); + + await c.expectServiceError( + () => createCustomer(ctxA, { companyName: "Alpha Sanitär", city: "Hamburg", street: "Hafen-Str.", houseNumber: "1", postalCode: "20457" }), + "conflict", + "mögliche Dublette ohne Bestätigung → conflict", + "possible_duplicates", + ); + try { + await createCustomer(ctxA, { companyName: "Alpha Sanitär" }); + } catch (err) { + const cands = (err as { details?: { candidates?: { customerId: string }[] } }).details?.candidates ?? []; + c.ok(cands.some((x) => x.customerId === alpha.id), "Dublettenhinweis enthält den bestehenden Kunden"); + } + const ack = await createCustomer(ctxA, { companyName: "Alpha Sanitär" }, { acknowledgeDuplicates: true }); + c.ok(!!ack.id, "nach Bestätigung trotzdem angelegt (Nutzer entscheidet)"); + + await c.expectErrorName(() => createCustomer(ctxA, { city: "Nirgendwo" }), "ZodError", "ohne Firmenname/Nachname → Validierungsfehler"); + await c.expectErrorName(() => createCustomer(ctxA, { companyName: "X", email: "kein-mail" }), "ZodError", "ungültige E-Mail → Validierungsfehler"); + + console.log("\n— (2) Bearbeiten, Ansprechpartner, vorläufig —"); + const updated = await updateCustomer(ctxA, alpha.id, { email: "info@alpha.example", notes: null }); + c.ok(updated.email === "info@alpha.example", "E-Mail geändert"); + const upAudit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "customer", entityId: alpha.id, action: "update" } }); + c.ok(!!upAudit?.before && !!upAudit?.after, "Audit-Eintrag (update, before/after)"); + await c.expectServiceError(() => updateCustomer(ctxA, alpha.id, { companyName: null, lastName: null }), "invalid", "Name darf nicht vollständig entfernt werden", "name_required"); + await c.expectServiceError(() => updateCustomer(ctxA, alpha.id, { customerNumber: "K-00002" }), "conflict", "Änderung auf vergebene Nummer → conflict", "number_taken"); + + const contact = await createContact(ctxA, alpha.id, { name: "Petra Planer", role: "Hausverwaltung", phone: "040 111", preferredChannel: "phone" }); + const contact2 = await createContact(ctxA, alpha.id, { name: "Olaf Objekt", email: "olaf@alpha.example", preferredChannel: "email" }); + c.ok(contact.customerId === alpha.id && contact2.preferredChannel === "email", "mehrere Ansprechpartner mit bevorzugtem Kontaktweg"); + const contactUp = await updateContact(ctxA, contact.id, { name: "Petra Planer", preferredChannel: "mobile", mobile: "0170 1" }); + c.ok(contactUp.preferredChannel === "mobile", "Ansprechpartner bearbeitet"); + await c.expectErrorName(() => createContact(ctxA, alpha.id, { name: "X", preferredChannel: "fax" as never }), "ZodError", "ungültiger Kontaktweg → Validierungsfehler"); + await deleteContact(ctxA, contact2.id); + const detail = await getCustomer(ctxA, alpha.id); + c.ok(detail.contacts.length === 1 && detail.contacts[0].id === contact.id, "gelöschter Ansprechpartner ausgeblendet (Soft Delete)"); + + const prov = await createCustomer(ctxA, { lastName: "Notdienstkunde", status: "provisional" }, { acknowledgeDuplicates: true }); + c.ok(prov.status === "provisional" && prov.isProvisional, "vorläufiger Kunde angelegt"); + const provList = await listCustomers(ctxA, { status: "provisional" }); + c.ok(provList.items.some((x) => x.id === prov.id) && provList.items.every((x) => x.status === "provisional"), "Filter „vorläufig“"); + const confirmed = await confirmProvisionalCustomer(ctxA, prov.id); + c.ok(confirmed.status === "active" && !confirmed.isProvisional, "vorläufig → aktiv bestätigt"); + await c.expectServiceError(() => confirmProvisionalCustomer(ctxA, prov.id), "conflict", "erneutes Bestätigen → conflict", "not_provisional"); + + const search = await listCustomers(ctxA, { q: "alpha", pageSize: 1 }); + c.ok(search.total === 2 && search.items.length === 1 && search.pageSize === 1, "Suche + Paginierung"); + + console.log("\n— (3) Mandantentrennung —"); + await c.expectServiceError(() => getCustomer(ctxB, alpha.id), "not_found", "Mandant B liest Kunden von A → not_found"); + await c.expectServiceError(() => updateCustomer(ctxB, alpha.id, { notes: "gehackt" }), "not_found", "Mandant B ändert Kunden von A → not_found"); + await c.expectServiceError(() => createContact(ctxB, alpha.id, { name: "Fremd" }), "not_found", "Mandant B legt Kontakt an Kunde A an → not_found"); + await c.expectServiceError(() => updateContact(ctxB, contact.id, { name: "Fremd" }), "not_found", "Mandant B ändert Kontakt von A → not_found"); + await c.expectServiceError(() => confirmProvisionalCustomer(ctxB, prov.id), "not_found", "Mandant B bestätigt Kunden von A → not_found"); + await c.expectServiceError(() => deleteCustomer(ctxB, alpha.id), "not_found", "Mandant B löscht Kunden von A → not_found"); + const listB = await listCustomers(ctxB, { pageSize: 100 }); + c.ok(listB.items.every((x) => x.id === inB.id), "Liste von B enthält nur eigene Kunden"); + const stillA = await prisma.customer.findUnique({ where: { id: alpha.id } }); + c.ok(stillA?.notes === null && stillA?.email === "info@alpha.example", "Kunde A unverändert"); + + console.log("\n— (4) Rollen/Scope Monteur —"); + await c.expectServiceError(() => getCustomer(ctxTech, alpha.id), "not_found", "Monteur ohne Zuweisung → not_found"); + c.ok((await listCustomers(ctxTech)).total === 0, "Monteur ohne Zuweisung sieht keine Kunden"); + await c.expectServiceError(() => updateCustomer(ctxTech, alpha.id, { notes: "x" }), "forbidden", "Monteur darf Kunden nicht ändern → forbidden"); + await c.expectServiceError(() => createCustomer(ctxTech, { companyName: "Monteurkunde" }), "forbidden", "Monteur darf keine Kunden anlegen → forbidden"); + const team = await createTeamWithMember(tA.id, "Team Nord", techA.id); + const order = await createWorkOrder(tA.id, { customerId: alpha.id, assignedTeamId: team.id }); + const techView = await getCustomer(ctxTech, alpha.id); + c.ok(techView.id === alpha.id, "Monteur sieht Kunden über Auftrag seines Teams"); + c.ok((await listCustomers(ctxTech)).items.map((x) => x.id).join() === alpha.id, "Monteur-Liste nur mit erreichbaren Kunden"); + + console.log("\n— (5) Zusammenführen —"); + const source = await createCustomer(ctxA, { companyName: "Quelle Haustechnik" }); + const target = await createCustomer(ctxA, { companyName: "Ziel Haustechnik" }); + const sContact = await createContact(ctxA, source.id, { name: "Kontakt Quelle" }); + const sSite = await prisma.site.create({ data: { tenantId: tA.id, customerId: source.id, name: "Objekt Quelle" } }); + const sOrder = await createWorkOrder(tA.id, { customerId: source.id, siteId: sSite.id }); + const sDoc = await prisma.document.create({ + data: { tenantId: tA.id, customerId: source.id, category: "other", fileName: "a.pdf", storageKey: `${tA.id}/x`, mimeType: "application/pdf", fileSize: 1, checksum: "0", lineageId: `lin-${source.id}` }, + }); + + await c.expectServiceError( + () => mergeCustomers(ctxFor(tA.id, boA.id, "backoffice", { remove: ["customer:merge"] }), { sourceId: source.id, targetId: target.id, confirm: true }), + "forbidden", + "ohne customer:merge → forbidden", + ); + await c.expectServiceError(() => mergeCustomers(ctxTech, { sourceId: source.id, targetId: target.id, confirm: true }), "forbidden", "Monteur → forbidden"); + await c.expectErrorName(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: false as true }), "ZodError", "ohne Bestätigung → abgelehnt"); + await c.expectErrorName(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: source.id, confirm: true }), "ZodError", "Quelle = Ziel → abgelehnt"); + await c.expectServiceError(() => mergeCustomers(ctxB, { sourceId: source.id, targetId: target.id, confirm: true }), "not_found", "Mandant B führt Kunden von A zusammen → not_found"); + const bCustomer = await createCustomer(ctxB, { companyName: "B-Kunde" }); + await c.expectServiceError(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: bCustomer.id, confirm: true }), "not_found", "Ziel aus fremdem Mandanten → not_found (keine Kreuzung)"); + await c.expectServiceError(() => mergeCustomers(ctxB, { sourceId: bCustomer.id, targetId: target.id, confirm: true }), "not_found", "Quelle B in Ziel A → not_found"); + c.ok((await prisma.customer.findUnique({ where: { id: source.id } }))?.status === "active", "nach abgelehnten Versuchen ist die Quelle unverändert"); + + const result = await mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: true }); + c.ok( + result.moved.contacts === 1 && result.moved.sites === 1 && result.moved.workOrders === 1 && result.moved.documents === 1, + `umgehängt: ${JSON.stringify(result.moved)}`, + ); + const [srcAfter, contactAfter, siteAfter, orderAfter, docAfter] = await Promise.all([ + prisma.customer.findUnique({ where: { id: source.id } }), + prisma.contact.findUnique({ where: { id: sContact.id } }), + prisma.site.findUnique({ where: { id: sSite.id } }), + prisma.workOrder.findUnique({ where: { id: sOrder.id } }), + prisma.document.findUnique({ where: { id: sDoc.id } }), + ]); + c.ok(srcAfter?.status === "merged" && srcAfter.mergedIntoId === target.id, "Quelle: status merged + mergedIntoId"); + c.ok(contactAfter?.customerId === target.id && siteAfter?.customerId === target.id && docAfter?.customerId === target.id, "Kontakt, Objekt, Dokument hängen am Ziel"); + c.ok(orderAfter?.customerId === target.id && orderAfter.version === sOrder.version + 1, "Auftrag am Ziel, Version erhöht (Offline-Konflikterkennung)"); + const mergeAudits = await prisma.auditLog.count({ where: { tenantId: tA.id, entity: "customer", entityId: { in: [source.id, target.id] }, action: "update" } }); + c.ok(mergeAudits === 2, "volles Audit: Einträge für Quelle und Ziel"); + await c.expectServiceError(() => mergeCustomers(ctxA, { sourceId: source.id, targetId: target.id, confirm: true }), "conflict", "erneutes Zusammenführen → conflict", "already_merged"); + c.ok(!(await listCustomers(ctxA, { pageSize: 100 })).items.some((x) => x.id === source.id), "Standardliste blendet zusammengeführte Kunden aus"); + await c.expectServiceError(() => updateCustomer(ctxA, source.id, { notes: "x" }), "not_found", "zusammengeführter Kunde ist nicht mehr bearbeitbar"); + + console.log("\n— (6) Soft Delete —"); + await c.expectServiceError(() => deleteCustomer(ctxA, alpha.id), "blocked", "Löschen bei offenem Auftrag → blocked", "open_work_orders"); + await prisma.workOrder.update({ where: { id: order.id }, data: { status: "cancelled" } }); + const deleted = await deleteCustomer(ctxA, alpha.id); + c.ok(!!deleted.deletedAt, "Kunde soft-gelöscht (deletedAt gesetzt)"); + c.ok(!!(await prisma.customer.findUnique({ where: { id: alpha.id } })), "Datensatz physisch noch vorhanden"); + await c.expectServiceError(() => getCustomer(ctxA, alpha.id), "not_found", "gelöschter Kunde → not_found"); +} + +main() + .catch((err) => { + console.error(err); + c.ok(false, "unerwarteter Fehler"); + }) + .finally(async () => { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch((e) => console.error("cleanup", e)); + const failures = c.finish(); + await disconnect(); + process.exit(failures === 0 ? 0 : 1); + }); diff --git a/scripts/test-stammdaten-documents.ts b/scripts/test-stammdaten-documents.ts new file mode 100644 index 0000000..0ca7405 --- /dev/null +++ b/scripts/test-stammdaten-documents.ts @@ -0,0 +1,211 @@ +// L1 Stammdaten — Dokumentenablage (ARCHITEKTUR §4.3, Spec §24, §27.4): +// (1) Scanner/Typprüfung: Magic Bytes, Allowlist, Typ-Mismatch +// (2) Dateinamen-Normalisierung +// (3) storeFile: falscher Magic Byte / leer / zu groß / Sichtbarkeit → abgelehnt; SHA-256, Versionierung +// (4) Download-Autorisierung: backoffice_only für Monteur verweigert, fremder Mandant verweigert, +// Auftrags-Scope, Objekt-Scope, Teamleiter-Sichtbarkeit +// +// Lauf: npx tsx scripts/test-stammdaten-documents.ts + +import "dotenv/config"; // must run before any module that constructs the Prisma client + +import { createHash } from "node:crypto"; +import { detectMime, MagicByteScanner } from "../src/server/services/documents/scanner"; +import { normalizeFileName, storeFile, SIZE_LIMITS } from "../src/server/services/documents/store"; +import { authorizeDocumentAccess, deleteDocument, getDownloadUrl, listDocuments, openDocumentContent, updateDocumentMeta } from "../src/server/services/documents/access"; +import { checker, cleanupTenants, createTeamWithMember, createTenant, createUser, createWorkOrder, ctxFor, disconnect, prisma } from "./lib-stammdaten-fixtures"; + +const SLUG_A = "zz-l1-doc-a"; +const SLUG_B = "zz-l1-doc-b"; +const DOMAIN = "zz-l1-doc.test"; +const c = checker("Dokumente"); + +const PDF = new TextEncoder().encode("%PDF-1.7\n1 0 obj<<>>endobj\ntrailer<<>>\n%%EOF\n"); +const PNG = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13]); +const JPEG_HEAD = [0xff, 0xd8, 0xff, 0xe0]; +const EXE = new TextEncoder().encode("MZ\x90\x00this is not an image"); + +async function main() { + console.log("— (1) Scanner —"); + const scanner = new MagicByteScanner(); + c.ok(detectMime(PDF) === "application/pdf" && detectMime(PNG) === "image/png" && detectMime(new Uint8Array(JUMP())) === "image/jpeg", "Magic Bytes PDF/PNG/JPEG erkannt"); + c.ok(detectMime(EXE) === null, "unbekannte Signatur (EXE) → nicht erkannt"); + const okPdf = await scanner.scan({ bytes: PDF, declaredMime: "application/pdf", fileName: "a.pdf" }); + c.ok(okPdf.ok && okPdf.kind === "pdf", "PDF mit passendem Typ akzeptiert"); + const mismatch = await scanner.scan({ bytes: PNG, declaredMime: "application/pdf", fileName: "a.pdf" }); + c.ok(!mismatch.ok && mismatch.reason === "type_mismatch", "PNG-Bytes als PDF deklariert → type_mismatch"); + const exeAsPng = await scanner.scan({ bytes: EXE, declaredMime: "image/png", fileName: "x.png" }); + c.ok(!exeAsPng.ok && exeAsPng.reason === "type_mismatch", "EXE als PNG deklariert → type_mismatch"); + const txt = await scanner.scan({ bytes: PDF, declaredMime: "text/html", fileName: "x.html" }); + c.ok(!txt.ok && txt.reason === "unsupported_type", "nicht erlaubter Typ → unsupported_type"); + const jpgAlias = await scanner.scan({ bytes: new Uint8Array(JUMP()), declaredMime: "image/jpg", fileName: "x.jpg" }); + c.ok(jpgAlias.ok && jpgAlias.detectedMime === "image/jpeg", "MIME-Alias image/jpg → image/jpeg"); + + console.log("\n— (2) Dateinamen —"); + c.ok(normalizeFileName("../../etc/passwd") === "passwd", "Pfadanteile entfernt"); + c.ok(normalizeFileName("C:\\Users\\x\\Plan .pdf") === "Plan _v2_.pdf", "Windows-Pfad und reservierte Zeichen"); + c.ok(normalizeFileName("a\u0000b\u001f.pdf") === "ab.pdf", "Steuerzeichen entfernt"); + c.ok(normalizeFileName(" ") === "datei" && normalizeFileName(".hidden") === "hidden", "leer → „datei“, führender Punkt entfernt"); + const long = normalizeFileName(`${"x".repeat(300)}.pdf`); + c.ok(long.length === 180 && long.endsWith(".pdf"), "Länge begrenzt, Endung erhalten"); + c.ok(normalizeFileName("Grundriss Erdgeschoß.pdf") === "Grundriss Erdgeschoß.pdf", "Umlaute und Leerzeichen bleiben lesbar"); + + console.log("\n— (3) storeFile —"); + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); + const tA = await createTenant(SLUG_A, "L1 Dokumente A"); + const tB = await createTenant(SLUG_B, "L1 Dokumente B"); + const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); + const tech1 = await createUser(tA.id, `tech1@${DOMAIN}`, "Monteur Team X"); + const tech2 = await createUser(tA.id, `tech2@${DOMAIN}`, "Monteur ohne Team"); + const lead = await createUser(tA.id, `lead@${DOMAIN}`, "Teamleiter X"); + const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); + const ctxA = ctxFor(tA.id, boA.id, "backoffice"); + const ctxT1 = ctxFor(tA.id, tech1.id, "technician"); + const ctxT2 = ctxFor(tA.id, tech2.id, "technician"); + const ctxLead = ctxFor(tA.id, lead.id, "team-lead"); + const ctxB = ctxFor(tB.id, boB.id, "backoffice"); + + const customer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-1", companyName: "Dokukunde" } }); + const site = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Objekt mit Plänen" } }); + const teamX = await createTeamWithMember(tA.id, "Team X", tech1.id, lead.id); + const teamY = await createTeamWithMember(tA.id, "Team Y", null); + const woX = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamX.id }); + const woY = await createWorkOrder(tA.id, { customerId: customer.id, assignedTeamId: teamY.id }); + + await c.expectServiceError( + () => storeFile(ctxA, { bytes: PNG, fileName: "plan.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", links: { siteId: site.id } }), + "invalid", + "falscher Magic Byte (PNG als PDF) → abgelehnt", + "type_mismatch", + ); + await c.expectServiceError( + () => storeFile(ctxA, { bytes: EXE, fileName: "virus.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), + "invalid", + "EXE mit .pdf-Endung → abgelehnt", + "type_mismatch", + ); + await c.expectServiceError( + () => storeFile(ctxA, { bytes: new Uint8Array(0), fileName: "leer.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), + "invalid", + "leere Datei → abgelehnt", + "empty_file", + ); + const bigJpeg = new Uint8Array(SIZE_LIMITS.image + 1); + bigJpeg.set(JUMP()); + await c.expectServiceError( + () => storeFile(ctxA, { bytes: bigJpeg, fileName: "gross.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { siteId: site.id } }), + "invalid", + "Bild über 15 MB → abgelehnt", + "too_large", + ); + await c.expectServiceError( + () => storeFile(ctxT1, { bytes: PDF, fileName: "intern.pdf", declaredMime: "application/pdf", category: "other", visibility: "backoffice_only", links: { workOrderId: woX.id } }), + "invalid", + "Monteur darf keine backoffice_only-Datei ablegen", + "visibility_not_allowed", + ); + await c.expectServiceError( + () => storeFile(ctxT1, { bytes: PDF, fileName: "objekt.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), + "forbidden", + "Monteur ohne document:write darf nicht am Objekt ablegen", + ); + await c.expectServiceError( + () => storeFile(ctxT1, { bytes: PDF, fileName: "fremd.pdf", declaredMime: "application/pdf", category: "photo", visibility: "team", links: { workOrderId: woY.id } }), + "not_found", + "Monteur legt an nicht sichtbarem Auftrag ab → not_found", + ); + await c.expectServiceError( + () => storeFile(ctxB, { bytes: PDF, fileName: "x.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { siteId: site.id } }), + "invalid", + "Mandant B legt an Objekt von A ab → abgelehnt", + "site_not_found", + ); + + const planV1 = await storeFile(ctxA, { bytes: PDF, fileName: "../Grundriss EG.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", title: "Grundriss EG", links: { siteId: site.id } }); + c.ok(planV1.version === 1 && planV1.fileName === "Grundriss EG.pdf" && planV1.mimeType === "application/pdf", "PDF gespeichert, Name normalisiert"); + c.ok(planV1.checksum === createHash("sha256").update(PDF).digest("hex") && planV1.fileSize === PDF.byteLength, "SHA-256-Prüfsumme und Größe"); + c.ok(planV1.storageKey.startsWith(`${tA.id}/`) || planV1.storageKey.startsWith("stub://"), "Storage-Key mandantenpräfixiert"); + c.ok(planV1.uploadedById === boA.id, "Ersteller gespeichert"); + c.ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "document", entityId: planV1.id, action: "create" } })), "Audit create"); + const planV2 = await storeFile(ctxA, { bytes: PDF, fileName: "Grundriss EG v2.pdf", declaredMime: "application/pdf", category: "floor_plan", visibility: "team", lineageId: planV1.lineageId }); + c.ok(planV2.version === 2 && planV2.lineageId === planV1.lineageId && planV2.siteId === site.id, "neue Version: version 2, gleiche lineage, Zuordnung übernommen"); + await c.expectServiceError( + () => storeFile(ctxB, { bytes: PDF, fileName: "x.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", lineageId: planV1.lineageId }), + "invalid", + "Mandant B kann keine Version eines A-Dokuments anlegen", + "lineage_not_found", + ); + + console.log("\n— (4) Download-Autorisierung —"); + const internal = await storeFile(ctxA, { bytes: PDF, fileName: "kalkulation.pdf", declaredMime: "application/pdf", category: "other", visibility: "backoffice_only", links: { workOrderId: woX.id } }); + const leadOnly = await storeFile(ctxA, { bytes: PDF, fileName: "teamleitung.pdf", declaredMime: "application/pdf", category: "other", visibility: "team_lead", links: { workOrderId: woX.id } }); + const teamDoc = await storeFile(ctxA, { bytes: PDF, fileName: "montage.pdf", declaredMime: "application/pdf", category: "assembly_instructions", visibility: "team", links: { workOrderId: woX.id } }); + const otherTeamDoc = await storeFile(ctxA, { bytes: PDF, fileName: "fremdteam.pdf", declaredMime: "application/pdf", category: "other", visibility: "team", links: { workOrderId: woY.id } }); + const photo = await storeFile(ctxT1, { bytes: new Uint8Array(JUMP()), fileName: "foto.jpg", declaredMime: "image/jpeg", category: "photo", visibility: "team", links: { workOrderId: woX.id } }); + c.ok(photo.uploadedById === tech1.id, "Monteur legt Foto an sichtbarem Auftrag ab (field:execute)"); + + await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, internal.id), "not_found", "backoffice_only für Monteur verweigert"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, leadOnly.id), "not_found", "team_lead-Dokument für Monteur verweigert"); + c.ok((await authorizeDocumentAccess(ctxLead, leadOnly.id)).id === leadOnly.id, "team_lead-Dokument für Teamleiter erlaubt"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxLead, internal.id), "not_found", "backoffice_only auch für Teamleiter verweigert"); + c.ok((await authorizeDocumentAccess(ctxT1, teamDoc.id)).id === teamDoc.id, "Team-Dokument am eigenen Auftrag erlaubt"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, otherTeamDoc.id), "not_found", "Team-Dokument an fremdem Auftrag verweigert (Auftrags-Scope)"); + c.ok((await authorizeDocumentAccess(ctxT1, planV2.id)).id === planV2.id, "Objekt-Dokument erlaubt, wenn Objekt über Teamauftrag erreichbar"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxT2, planV2.id), "not_found", "Objekt-Dokument für Monteur ohne Zuweisung verweigert"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxT2, teamDoc.id), "not_found", "Auftrags-Dokument für Monteur ohne Zuweisung verweigert"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxB, teamDoc.id), "not_found", "fremder Mandant verweigert (Team-Dokument)"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxB, internal.id), "not_found", "fremder Mandant verweigert (Backoffice-Dokument)"); + await c.expectServiceError(() => openDocumentContent(ctxB, planV1.id), "not_found", "fremder Mandant erhält keinen Inhalt"); + c.ok((await authorizeDocumentAccess(ctxA, internal.id)).id === internal.id, "Backoffice mit document:read_internal liest backoffice_only"); + await c.expectServiceError( + () => authorizeDocumentAccess(ctxFor(tA.id, boA.id, "backoffice", { remove: ["document:read"] }), teamDoc.id), + "not_found", + "ohne document:read → verweigert", + ); + c.ok((await getDownloadUrl(ctxT1, teamDoc.id)) === `/files/${teamDoc.id}`, "Download-Link ist die interne Route /files/"); + + const t1List = await listDocuments(ctxT1, { pageSize: 100 }); + const t1Ids = new Set(t1List.items.map((d) => d.id)); + c.ok(t1Ids.has(teamDoc.id) && t1Ids.has(planV2.id) && t1Ids.has(photo.id), "Monteur-Liste enthält sichtbare Dokumente"); + c.ok(!t1Ids.has(internal.id) && !t1Ids.has(leadOnly.id) && !t1Ids.has(otherTeamDoc.id), "Monteur-Liste ohne interne/fremde Dokumente"); + const latest = await listDocuments(ctxA, { siteId: site.id, latestOnly: true }); + c.ok(latest.items.some((d) => d.id === planV2.id) && !latest.items.some((d) => d.id === planV1.id), "latestOnly zeigt nur die neueste Version"); + const byCustomer = await listDocuments(ctxA, { customerId: customer.id, pageSize: 100 }); + c.ok(byCustomer.items.some((d) => d.id === planV1.id) && byCustomer.items.some((d) => d.id === teamDoc.id), "Filter Kunde umfasst Objekt- und Auftragsdokumente"); + c.ok((await listDocuments(ctxB, { pageSize: 100 })).total === 0, "Mandant B sieht keine Dokumente von A"); + + if (planV1.storageKey.startsWith(`${tA.id}/`)) { + const { content } = await openDocumentContent(ctxT1, planV1.id); + const buf = Buffer.from(await new Response(content.stream).arrayBuffer()); + c.ok(buf.equals(Buffer.from(PDF)), "Inhalt aus dem Objektspeicher byte-identisch"); + } else { + console.log("↷ Byte-Roundtrip übersprungen (kein S3 konfiguriert, Stub-Adapter)"); + } + + await c.expectServiceError(() => updateDocumentMeta(ctxT1, teamDoc.id, { title: "x" }), "forbidden", "Monteur darf Metadaten nicht ändern"); + await c.expectServiceError(() => updateDocumentMeta(ctxLead, teamDoc.id, { visibility: "backoffice_only" }), "forbidden", "Teamleiter ohne document:write → forbidden"); + const meta = await updateDocumentMeta(ctxA, teamDoc.id, { title: "Montageanleitung", visibility: "team_lead" }); + c.ok(meta.visibility === "team_lead", "Backoffice ändert Sichtbarkeit"); + await c.expectServiceError(() => authorizeDocumentAccess(ctxT1, teamDoc.id), "not_found", "nach Umstellung auf team_lead für Monteur verborgen"); + await c.expectServiceError(() => deleteDocument(ctxB, planV2.id), "not_found", "Mandant B löscht Dokument von A → not_found"); + await deleteDocument(ctxA, planV2.id); + await c.expectServiceError(() => authorizeDocumentAccess(ctxA, planV2.id), "not_found", "soft-gelöschtes Dokument nicht mehr abrufbar"); + c.ok(!!(await prisma.document.findUnique({ where: { id: planV2.id } })), "Datensatz bleibt physisch erhalten (Soft Delete)"); +} + +/** Minimal JPEG header bytes. */ +function JUMP(): number[] { + return [...JPEG_HEAD, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00]; +} + +main() + .catch((err) => { + console.error(err); + c.ok(false, "unerwarteter Fehler"); + }) + .finally(async () => { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch((e) => console.error("cleanup", e)); + const failures = c.finish(); + await disconnect(); + process.exit(failures === 0 ? 0 : 1); + }); diff --git a/scripts/test-stammdaten-duplicates.ts b/scripts/test-stammdaten-duplicates.ts new file mode 100644 index 0000000..352fcde --- /dev/null +++ b/scripts/test-stammdaten-duplicates.ts @@ -0,0 +1,117 @@ +// L1 Stammdaten — Dublettenprüfung (Spec §7.3, US-003): +// (1) Normalisierung: Kleinschreibung, Umlaute, Rechtsformen, Straße/Str., Telefon nur Ziffern +// (2) Scoring: Kundennummer, Name, Adresse, E-Mail, Telefon; Schwelle +// (3) findDuplicateCustomers gegen die DB inkl. Mandantentrennung und Monteur-Scope +// +// Lauf: npx tsx scripts/test-stammdaten-duplicates.ts + +import "dotenv/config"; // must run before any module that constructs the Prisma client + +import { + DUPLICATE_THRESHOLD, + nameSimilarity, + normalizeCompanyName, + normalizePhone, + normalizeStreet, + normalizeText, + scoreDuplicate, +} from "../src/lib/customers/duplicates"; +import { findDuplicateCustomers } from "../src/server/services/customers/duplicates"; +import { checker, cleanupTenants, createTenant, createUser, ctxFor, disconnect, prisma } from "./lib-stammdaten-fixtures"; + +const SLUG_A = "zz-l1-dup-a"; +const SLUG_B = "zz-l1-dup-b"; +const DOMAIN = "zz-l1-dup.test"; +const c = checker("Dublettenprüfung"); + +async function main() { + console.log("— (1) Normalisierung —"); + c.ok(normalizeText(" ÄÖÜ ßtraße ") === "aeoeue sstrasse", "Umlaute/ß transliteriert, getrimmt, klein"); + c.ok(normalizeCompanyName("Müller Haustechnik GmbH & Co. KG") === "mueller haustechnik", "Rechtsform „GmbH & Co. KG“ entfernt"); + c.ok(normalizeCompanyName("MUELLER Haustechnik AG") === "mueller haustechnik", "Rechtsform „AG“ entfernt, Groß/Klein egal"); + c.ok(normalizeCompanyName("Bauer e.K.") === "bauer", "Rechtsform „e.K.“ entfernt"); + c.ok(normalizeCompanyName("AGRAR Service") === "agrar service", "„AG“ innerhalb eines Wortes bleibt erhalten"); + c.ok(normalizeStreet("Hafenstraße") === "hafenstr" && normalizeStreet("Hafen-Str.") === "hafenstr" && normalizeStreet("Hafen Strasse") === "hafenstr", "Straße/Str./Strasse vereinheitlicht"); + c.ok(normalizePhone("+49 40 123456-0") === "0401234560" && normalizePhone("040 / 123 456 0") === "0401234560", "Telefon nur Ziffern, +49 → 0"); + c.ok(normalizePhone("12") === "", "zu kurze Nummern werden ignoriert"); + + console.log("\n— (2) Scoring —"); + const existing = { + customerNumber: "K-00042", + companyName: "Müller Haustechnik GmbH", + street: "Hafenstraße", + houseNumber: "12", + postalCode: "20457", + email: "info@mueller.example", + phone: "+49 40 123456-0", + }; + const byNumber = scoreDuplicate({ customerNumber: "k-00042" }, existing); + c.ok(byNumber.score === 1 && byNumber.reasons.includes("customer_number"), "gleiche Kundennummer → Score 1"); + const byNameAddress = scoreDuplicate({ companyName: "Mueller Haustechnik", street: "Hafen-Str.", houseNumber: "12", postalCode: "20457" }, existing); + c.ok(Math.abs(byNameAddress.score - 0.76) < 0.001 && byNameAddress.reasons.join() === "company_name,address", `Name + Adresse → 0.76 (${byNameAddress.score})`); + const byEmail = scoreDuplicate({ email: "INFO@mueller.example " }, existing); + c.ok(byEmail.score === 0.6 && byEmail.reasons.join() === "email", "E-Mail (Groß/Klein, Leerzeichen) → 0.6"); + const byPhone = scoreDuplicate({ mobile: "040 1234560" }, existing); + c.ok(byPhone.score === 0.5 && byPhone.reasons.join() === "phone", "Telefon gegen Mobil-Eingabe → 0.5"); + const similar = scoreDuplicate({ companyName: "Müller Haustechnick" }, existing); + c.ok(similar.reasons.includes("company_name") && similar.score === 0.45, `ähnlicher Name (Tippfehler) → 0.45 (${similar.score})`); + c.ok(nameSimilarity("abc", "xyz") === 0, "unähnliche Namen → Ähnlichkeit 0"); + const unrelated = scoreDuplicate({ companyName: "Nordlicht Elektro", postalCode: "10115", street: "Invalidenstraße" }, existing); + c.ok(unrelated.score === 0 && unrelated.score < DUPLICATE_THRESHOLD, "fremder Kunde → Score 0 (unter Schwelle)"); + const all = scoreDuplicate({ ...existing }, existing); + c.ok(all.score === 1 && all.reasons.length === 5, "alle Merkmale gleich → 5 Gründe, Score 1"); + + console.log("\n— (3) findDuplicateCustomers (DB) —"); + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); + const tA = await createTenant(SLUG_A, "L1 Dubletten A"); + const tB = await createTenant(SLUG_B, "L1 Dubletten B"); + const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); + const techA = await createUser(tA.id, `tech@${DOMAIN}`, "Monteur A"); + const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); + + const custA = await prisma.customer.create({ data: { tenantId: tA.id, ...existing } }); + const otherA = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-00043", companyName: "Schmidt Bedachung", city: "Kiel", postalCode: "24103" } }); + const mergedA = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-00044", companyName: "Müller Haustechnik", status: "merged" } }); + const custB = await prisma.customer.create({ data: { tenantId: tB.id, ...existing } }); + + const ctxA = ctxFor(tA.id, boA.id, "backoffice"); + const ctxB = ctxFor(tB.id, boB.id, "backoffice"); + + const hits = await findDuplicateCustomers(ctxA, { companyName: "Mueller Haustechnik", street: "Hafen-Str.", houseNumber: "12", postalCode: "20457" }); + c.ok(hits.length === 1 && hits[0].customerId === custA.id, "Treffer in Mandant A gefunden (Name + Adresse)"); + c.ok(hits[0]?.score >= 0.76 && hits[0]?.displayName === existing.companyName, "Treffer mit Score und Anzeigename"); + c.ok(!hits.some((h) => h.customerId === custB.id), "Mandantentrennung: gleicher Kunde in Mandant B wird NICHT gefunden"); + c.ok(!hits.some((h) => h.customerId === mergedA.id), "zusammengeführte Kunden werden nicht vorgeschlagen"); + c.ok(!hits.some((h) => h.customerId === otherA.id), "unähnlicher Kunde nicht vorgeschlagen"); + + const phoneHits = await findDuplicateCustomers(ctxA, { phone: "040 1234560" }); + c.ok(phoneHits.length === 1 && phoneHits[0].customerId === custA.id && phoneHits[0].reasons.join() === "phone", "Treffer nur über formatiert gespeicherte Telefonnummer"); + + const numberHits = await findDuplicateCustomers(ctxA, { customerNumber: "k-00042" }); + c.ok(numberHits[0]?.customerId === custA.id && numberHits[0]?.score === 1, "Treffer über Kundennummer (Score 1)"); + + const excluded = await findDuplicateCustomers(ctxA, { customerNumber: "K-00042" }, { excludeId: custA.id }); + c.ok(excluded.length === 0, "excludeId schließt den eigenen Datensatz aus"); + + const hitsB = await findDuplicateCustomers(ctxB, { customerNumber: "K-00043" }); + c.ok(hitsB.length === 0, "Mandant B findet Kunden von A nicht über deren Kundennummer"); + + const techHits = await findDuplicateCustomers(ctxFor(tA.id, techA.id, "technician"), { customerNumber: "K-00042" }); + c.ok(techHits.length === 0, "Monteur ohne sichtbaren Auftrag erhält keine Kundentreffer (Scope)"); + + c.ok((await findDuplicateCustomers(ctxA, {})).length === 0, "leerer Kandidat → keine Treffer"); + + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); +} + +main() + .catch(async (err) => { + console.error(err); + c.ok(false, "unerwarteter Fehler"); + }) + .finally(async () => { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch(() => {}); + const failures = c.finish(); + await disconnect(); + process.exit(failures === 0 ? 0 : 1); + }); diff --git a/scripts/test-stammdaten-sites.ts b/scripts/test-stammdaten-sites.ts new file mode 100644 index 0000000..f78cde6 --- /dev/null +++ b/scripts/test-stammdaten-sites.ts @@ -0,0 +1,173 @@ +// L1 Stammdaten — Objekte und Objekt-Historie (Spec §8, US-005, US-011): +// (1) Objekt anlegen/ändern inkl. Validierung Kunde/Kontakt, Kartenlink, Soft Delete +// (2) Historie: chronologisch, Arbeiten, Material aggregiert, Fotos, Berichte, Unterschrift, Folgearbeiten +// (3) Rollen/Scope: Monteur sieht nur freigegebene Einsätze; ohne sichtbaren Auftrag → not_found +// (4) Mandantentrennung +// +// Lauf: npx tsx scripts/test-stammdaten-sites.ts + +import "dotenv/config"; // must run before any module that constructs the Prisma client + +import { getSiteHistory } from "../src/server/services/sites/history"; +import { siteMapUrl } from "../src/server/services/sites/map-link"; +import { createSite, deleteSite, getSite, listSites, updateSite } from "../src/server/services/sites/sites"; +import { + checker, + cleanupTenants, + createTeamWithMember, + createTenant, + createUser, + createWorkOrder, + ctxFor, + disconnect, + prisma, +} from "./lib-stammdaten-fixtures"; + +const SLUG_A = "zz-l1-site-a"; +const SLUG_B = "zz-l1-site-b"; +const DOMAIN = "zz-l1-site.test"; +const c = checker("Objekte & Historie"); + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); + const tA = await createTenant(SLUG_A, "L1 Objekte A"); + const tB = await createTenant(SLUG_B, "L1 Objekte B"); + const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); + const tech1 = await createUser(tA.id, `tech1@${DOMAIN}`, "Monteur Team X"); + const tech2 = await createUser(tA.id, `tech2@${DOMAIN}`, "Monteur ohne Team"); + const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); + const ctxA = ctxFor(tA.id, boA.id, "backoffice"); + const ctxT1 = ctxFor(tA.id, tech1.id, "technician"); + const ctxT2 = ctxFor(tA.id, tech2.id, "technician"); + const ctxB = ctxFor(tB.id, boB.id, "backoffice"); + + const customer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-1", companyName: "Objektkunde" } }); + const otherCustomer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-2", companyName: "Anderer Kunde" } }); + const otherContact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: otherCustomer.id, name: "Fremdkontakt" } }); + const ownContact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Hausmeister" } }); + const customerB = await prisma.customer.create({ data: { tenantId: tB.id, customerNumber: "K-1", companyName: "B-Kunde" } }); + + console.log("— (1) Objekte —"); + const site = await createSite(ctxA, { + customerId: customer.id, + name: "Wohnanlage Süd", + street: "Hafenstraße", + houseNumber: "12", + postalCode: "20457", + city: "Hamburg", + contactId: ownContact.id, + accessNotes: "Schlüssel beim Hausmeister", + parkingNotes: "Hof", + safetyNotes: "Asbest im Keller", + latitude: "53,5413" as never, + longitude: 9.9841, + }); + c.ok(site.customerId === customer.id && site.latitude === 53.5413 && site.safetyNotes === "Asbest im Keller", "Objekt mit Hinweisen und Koordinaten (Komma-Dezimal) angelegt"); + c.ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "site", entityId: site.id, action: "create" } })), "Audit create"); + await c.expectServiceError(() => createSite(ctxA, { customerId: customer.id, name: "X", contactId: otherContact.id }), "invalid", "Kontakt eines anderen Kunden → invalid", "contact_mismatch"); + await c.expectServiceError(() => createSite(ctxA, { customerId: customerB.id, name: "X" }), "invalid", "Kunde aus fremdem Mandanten → invalid", "customer_not_found"); + await c.expectServiceError(() => createSite(ctxT1, { customerId: customer.id, name: "X" }), "forbidden", "Monteur darf keine Objekte anlegen"); + await c.expectErrorName(() => createSite(ctxA, { customerId: customer.id, name: "" }), "ZodError", "ohne Bezeichnung → Validierungsfehler"); + await c.expectErrorName(() => createSite(ctxA, { customerId: customer.id, name: "X", latitude: 123 }), "ZodError", "Breitengrad außerhalb −90..90 → Validierungsfehler"); + + const moved = await updateSite(ctxA, site.id, { technicalNotes: "Heizung Baujahr 2004" }); + c.ok(moved.technicalNotes === "Heizung Baujahr 2004" && moved.contactId === ownContact.id, "Objekt geändert, Kontakt bleibt"); + const url = siteMapUrl(site); + c.ok(!!url && url.startsWith("https://www.openstreetmap.org/?mlat=53.541300"), `Kartenlink aus Koordinaten (${url})`); + const addrUrl = siteMapUrl({ street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg" }); + c.ok(addrUrl === `https://www.openstreetmap.org/search?query=${encodeURIComponent("Hafenstraße 12, 20457 Hamburg")}`, "Kartenlink aus Adresse (kein Embed)"); + c.ok(siteMapUrl({ street: "Nur Straße" }) === null, "ohne Ort/PLZ kein Kartenlink"); + + console.log("\n— (2) Historie (Backoffice) —"); + const teamX = await createTeamWithMember(tA.id, "Team X", tech1.id); + const teamY = await createTeamWithMember(tA.id, "Team Y", null); + + // WO1: freigegeben, Team Y (nicht Team des Monteurs), mit allen Nachweisen + const wo1 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamY.id, status: "released_for_billing", title: "Wartung Heizung", followUpWork: "Nachkontrolle im Herbst" }); + const session = await prisma.workSession.create({ data: { tenantId: tA.id, workOrderId: wo1.id, userId: boA.id, startedAt: new Date("2026-03-01T08:00:00Z"), status: "ended" } }); + await prisma.activityNote.createMany({ + data: [ + { tenantId: tA.id, workOrderId: wo1.id, kind: "work_done", text: "Heizung gewartet" }, + { tenantId: tA.id, workOrderId: wo1.id, kind: "follow_up", text: "Ventil tauschen" }, + { tenantId: tA.id, workOrderId: wo1.id, kind: "general", text: "INTERN nicht anzeigen" }, + ], + }); + await prisma.materialUsage.createMany({ + data: [ + { tenantId: tA.id, workOrderId: wo1.id, workSessionId: session.id, name: "Ventil", unit: "Stk", actualQuantity: 1, usageStatus: "fully_used" }, + { tenantId: tA.id, workOrderId: wo1.id, name: "ventil ", unit: "Stk", actualQuantity: 1.5, usageStatus: "additional" }, + { tenantId: tA.id, workOrderId: wo1.id, name: "Dichtung", unit: "Stk", actualQuantity: 4, usageStatus: "not_used" }, + ], + }); + for (let i = 0; i < 2; i++) { + const doc = await prisma.document.create({ data: { tenantId: tA.id, workOrderId: wo1.id, category: "photo", fileName: `p${i}.jpg`, storageKey: `${tA.id}/p${i}`, mimeType: "image/jpeg", fileSize: 1, checksum: "0", lineageId: `lin-p${i}-${wo1.id}` } }); + await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo1.id, documentId: doc.id, takenAt: new Date() } }); + } + const report1 = await prisma.report.create({ data: { tenantId: tA.id, workOrderId: wo1.id, type: "completion", reportDate: new Date("2026-03-01"), lineageId: `r-${wo1.id}`, status: "approved", content: {} } }); + await prisma.signature.create({ data: { tenantId: tA.id, reportId: report1.id, outcome: "signed", signerName: "Kunde", signedAt: new Date() } }); + + // WO2: Team X (Monteur 1), in Arbeit, nicht freigegeben + const wo2 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamX.id, status: "in_progress", title: "Leitung verlegen", plannedStart: new Date("2026-04-10T07:00:00Z") }); + await prisma.activityNote.create({ data: { tenantId: tA.id, workOrderId: wo2.id, kind: "work_done", text: "Leitung verlegt" } }); + // WO3: Team Y, Bericht nur eingereicht + const wo3 = await createWorkOrder(tA.id, { customerId: customer.id, siteId: site.id, assignedTeamId: teamY.id, status: "in_review", plannedStart: new Date("2026-02-01T07:00:00Z") }); + await prisma.report.create({ data: { tenantId: tA.id, workOrderId: wo3.id, type: "daily", reportDate: new Date("2026-02-01"), lineageId: `r-${wo3.id}`, status: "submitted", content: {} } }); + // WO an anderem Objekt darf nicht erscheinen + const otherSite = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Anderes Objekt" } }); + await createWorkOrder(tA.id, { customerId: customer.id, siteId: otherSite.id, assignedTeamId: teamY.id }); + + const all = await getSiteHistory(ctxA, site.id, { onlyApproved: false }); + c.ok(all.total === 3 && !all.onlyApproved, `Backoffice sieht alle 3 Einsätze am Objekt (${all.total})`); + c.ok(all.items.map((e) => e.workOrderId).join() === [wo2.id, wo1.id, wo3.id].join(), "chronologisch, neueste zuerst (Einsatzbeginn/Termin)"); + const e1 = all.items.find((e) => e.workOrderId === wo1.id)!; + c.ok(e1.workDone.join() === "Heizung gewartet" && !e1.summary.includes("INTERN"), "durchgeführte Arbeiten aus work_done, interne Notizen nicht enthalten"); + c.ok(e1.materials.length === 1 && e1.materials[0].quantity === 2.5 && e1.materials[0].unit === "Stk", `Material aggregiert, not_used ausgeschlossen (${JSON.stringify(e1.materials)})`); + c.ok(e1.photoCount === 2 && e1.signed && e1.approvedReports.length === 1 && e1.approvedReports[0].id === report1.id, "Fotoanzahl, Unterschrift, freigegebener Bericht"); + c.ok(e1.hasOpenFollowUp && e1.followUps.length === 2, "offene Folgearbeiten (followUpWork + follow_up-Notiz) markiert"); + c.ok(e1.team === "Team Y" && e1.date.toISOString() === "2026-03-01T08:00:00.000Z", "Team und Datum (Einsatzbeginn)"); + const e3 = all.items.find((e) => e.workOrderId === wo3.id)!; + c.ok(e3.approvedReports.length === 0 && !e3.hasOpenFollowUp, "eingereichter Bericht zählt nicht als freigegeben"); + const approvedOnly = await getSiteHistory(ctxA, site.id, { onlyApproved: true }); + c.ok(approvedOnly.total === 1 && approvedOnly.items[0].workOrderId === wo1.id, "onlyApproved=true → nur freigegebener Einsatz"); + const paged = await getSiteHistory(ctxA, site.id, { page: 2, pageSize: 2 }); + c.ok(paged.total === 3 && paged.items.length === 1, "Paginierung der Historie"); + + console.log("\n— (3) Rollen/Scope —"); + const t1 = await getSiteHistory(ctxT1, site.id, { onlyApproved: false }); + c.ok(t1.onlyApproved && t1.total === 1 && t1.items[0].workOrderId === wo1.id, "Monteur (Team X) erhält nur freigegebene Einsätze, auch mit onlyApproved=false"); + c.ok(!t1.items.some((e) => e.workOrderId === wo2.id || e.workOrderId === wo3.id), "nicht freigegebene Einsätze bleiben für Monteur verborgen"); + await c.expectServiceError(() => getSiteHistory(ctxT2, site.id), "not_found", "Monteur ohne sichtbaren Auftrag am Objekt → not_found"); + await c.expectServiceError(() => getSite(ctxT2, site.id), "not_found", "Monteur ohne Zuweisung liest Objekt → not_found"); + c.ok((await getSite(ctxT1, site.id)).id === site.id, "Monteur mit Teamauftrag liest Objekt"); + const t1List = await listSites(ctxT1, { pageSize: 50 }); + c.ok(t1List.items.map((s) => s.id).join() === site.id, "Monteur-Objektliste nur mit erreichbaren Objekten"); + await c.expectServiceError(() => updateSite(ctxT1, site.id, { name: "x" }), "forbidden", "Monteur darf Objekt nicht ändern"); + await c.expectServiceError(() => getSiteHistory(ctxFor(tA.id, tech1.id, "technician", { remove: ["site:read"] }), site.id), "forbidden", "ohne site:read → forbidden"); + + console.log("\n— (4) Mandantentrennung —"); + await c.expectServiceError(() => getSite(ctxB, site.id), "not_found", "Mandant B liest Objekt von A → not_found"); + await c.expectServiceError(() => getSiteHistory(ctxB, site.id), "not_found", "Mandant B liest Historie von A → not_found"); + await c.expectServiceError(() => updateSite(ctxB, site.id, { name: "gehackt" }), "not_found", "Mandant B ändert Objekt von A → not_found"); + await c.expectServiceError(() => deleteSite(ctxB, site.id), "not_found", "Mandant B löscht Objekt von A → not_found"); + c.ok((await listSites(ctxB)).total === 0, "Liste von B leer"); + c.ok((await prisma.site.findUnique({ where: { id: site.id } }))?.name === "Wohnanlage Süd", "Objekt A unverändert"); + + console.log("\n— Soft Delete —"); + await c.expectServiceError(() => deleteSite(ctxA, site.id), "blocked", "Löschen bei offenen Aufträgen → blocked", "open_work_orders"); + const emptySite = await createSite(ctxA, { customerId: customer.id, name: "Leeres Objekt" }); + const del = await deleteSite(ctxA, emptySite.id); + c.ok(!!del.deletedAt, "Objekt ohne Aufträge soft-gelöscht"); + await c.expectServiceError(() => getSite(ctxA, emptySite.id), "not_found", "gelöschtes Objekt → not_found"); +} + +main() + .catch((err) => { + console.error(err); + c.ok(false, "unerwarteter Fehler"); + }) + .finally(async () => { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch((e) => console.error("cleanup", e)); + const failures = c.finish(); + await disconnect(); + process.exit(failures === 0 ? 0 : 1); + }); diff --git a/scripts/test-stammdaten-teams.ts b/scripts/test-stammdaten-teams.ts new file mode 100644 index 0000000..b4f1777 --- /dev/null +++ b/scripts/test-stammdaten-teams.ts @@ -0,0 +1,95 @@ +// L1 Stammdaten — Teams (Spec §11.1): +// (1) Anlage mit Teamleiter/Mitgliedern (gültig ab/bis), Validierung, Audit +// (2) Mitgliedschaft wirkt auf die Auftragssichtbarkeit (activeTeamIds) +// (3) Rollen: Monteur liest, verwaltet aber nicht; Mandantentrennung +// +// Lauf: npx tsx scripts/test-stammdaten-teams.ts + +import "dotenv/config"; // must run before any module that constructs the Prisma client + +import { createTeam, deleteTeam, getTeam, listTeams, updateTeam } from "../src/server/services/teams/teams"; +import { activeTeamIds } from "../src/server/services/work-orders/visibility"; +import { checker, cleanupTenants, createTenant, createUser, createWorkOrder, ctxFor, disconnect, prisma } from "./lib-stammdaten-fixtures"; + +const SLUG_A = "zz-l1-team-a"; +const SLUG_B = "zz-l1-team-b"; +const DOMAIN = "zz-l1-team.test"; +const c = checker("Teams"); + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN); + const tA = await createTenant(SLUG_A, "L1 Teams A"); + const tB = await createTenant(SLUG_B, "L1 Teams B"); + const boA = await createUser(tA.id, `bo@${DOMAIN}`, "Backoffice A"); + const lead = await createUser(tA.id, `lead@${DOMAIN}`, "Tina Teamleiter"); + const tech = await createUser(tA.id, `tech@${DOMAIN}`, "Max Monteur"); + const inactive = await createUser(tA.id, `inactive@${DOMAIN}`, "Ina Inaktiv"); + await prisma.user.update({ where: { id: inactive.id }, data: { status: "DEACTIVATED" } }); + const boB = await createUser(tB.id, `bo-b@${DOMAIN}`, "Backoffice B"); + const foreign = await createUser(tB.id, `foreign@${DOMAIN}`, "Fremd B"); + const ctxA = ctxFor(tA.id, boA.id, "backoffice"); + const ctxTech = ctxFor(tA.id, tech.id, "technician"); + const ctxB = ctxFor(tB.id, boB.id, "backoffice"); + + console.log("— (1) Anlage —"); + const team = await createTeam(ctxA, { + name: "Team Nord", + leaderUserId: lead.id, + phone: "040 555", + vehicle: "HH-CV 101", + area: "Hamburg Nord", + notes: "Schlüssel im Fahrzeug", + members: [{ userId: tech.id, validFrom: "2026-01-01", validTo: "" }], + }); + c.ok(team.leader?.id === lead.id && team.members.length === 1 && team.vehicle === "HH-CV 101", "Team mit Teamleiter, Mitglied, Fahrzeug angelegt"); + c.ok(team.members[0].validTo === null && team.members[0].validFrom.toISOString().startsWith("2026-01-01"), "gültig ab/bis übernommen"); + c.ok(!!(await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "team", entityId: team.id, action: "create" } })), "Audit create"); + await c.expectServiceError(() => createTeam(ctxA, { name: "Team Nord", members: [] }), "conflict", "doppelter Teamname → conflict", "name_taken"); + await c.expectServiceError(() => createTeam(ctxA, { name: "Team Fremd", members: [{ userId: foreign.id }] }), "invalid", "Mitglied aus fremdem Mandanten → invalid", "inactive_user"); + await c.expectServiceError(() => createTeam(ctxA, { name: "Team Inaktiv", leaderUserId: inactive.id, members: [] }), "invalid", "deaktivierter Nutzer als Teamleiter → invalid", "inactive_user"); + await c.expectServiceError(() => createTeam(ctxA, { name: "Team Doppelt", members: [{ userId: tech.id }, { userId: tech.id }] }), "invalid", "Person doppelt → invalid", "duplicate_member"); + await c.expectErrorName(() => createTeam(ctxA, { name: "Team Zeit", members: [{ userId: tech.id, validFrom: "2026-05-01", validTo: "2026-04-01" }] }), "ZodError", "gültig bis vor gültig ab → Validierungsfehler"); + + console.log("\n— (2) Mitgliedschaft & Sichtbarkeit —"); + c.ok((await activeTeamIds(ctxTech)).includes(team.id), "aktives Mitglied → Team zählt für Auftragssichtbarkeit"); + const customer = await prisma.customer.create({ data: { tenantId: tA.id, customerNumber: "K-1", companyName: "Kunde" } }); + const order = await createWorkOrder(tA.id, { customerId: customer.id, assignedTeamId: team.id }); + const ended = await updateTeam(ctxA, team.id, { + name: "Team Nord", + leaderUserId: lead.id, + members: [{ userId: tech.id, validFrom: "2025-01-01", validTo: "2025-12-31" }], + }); + c.ok(ended.members.length === 1 && !!ended.members[0].validTo, "Mitgliedschaft beendet (gültig bis gesetzt)"); + c.ok(!(await activeTeamIds(ctxTech)).includes(team.id), "abgelaufene Mitgliedschaft → Team zählt nicht mehr"); + const upAudit = await prisma.auditLog.findFirst({ where: { tenantId: tA.id, entity: "team", entityId: team.id, action: "update" } }); + c.ok(!!upAudit?.before && !!upAudit?.after, "Audit update mit before/after"); + + console.log("\n— (3) Rollen & Mandantentrennung —"); + c.ok((await listTeams(ctxTech)).some((x) => x.id === team.id), "Monteur darf Teams lesen (team:read)"); + await c.expectServiceError(() => createTeam(ctxTech, { name: "Monteurteam", members: [] }), "forbidden", "Monteur darf keine Teams anlegen"); + await c.expectServiceError(() => updateTeam(ctxTech, team.id, { name: "x", members: [] }), "forbidden", "Monteur darf Teams nicht ändern"); + await c.expectServiceError(() => getTeam(ctxB, team.id), "not_found", "Mandant B liest Team von A → not_found"); + await c.expectServiceError(() => updateTeam(ctxB, team.id, { name: "gehackt", members: [] }), "not_found", "Mandant B ändert Team von A → not_found"); + await c.expectServiceError(() => deleteTeam(ctxB, team.id), "not_found", "Mandant B löscht Team von A → not_found"); + c.ok((await listTeams(ctxB)).length === 0, "Teamliste von B leer"); + + console.log("\n— Soft Delete —"); + await c.expectServiceError(() => deleteTeam(ctxA, team.id), "blocked", "Löschen mit offenem Auftrag → blocked", "open_work_orders"); + await prisma.workOrder.update({ where: { id: order.id }, data: { status: "billed" } }); + const del = await deleteTeam(ctxA, team.id); + c.ok(!!del.deletedAt && del.status === "inactive", "Team soft-gelöscht und inaktiv"); + const again = await createTeam(ctxA, { name: "Team Nord", members: [] }); + c.ok(again.name === "Team Nord", "Name nach Löschen wieder verwendbar"); +} + +main() + .catch((err) => { + console.error(err); + c.ok(false, "unerwarteter Fehler"); + }) + .finally(async () => { + await cleanupTenants([SLUG_A, SLUG_B], DOMAIN).catch((e) => console.error("cleanup", e)); + const failures = c.finish(); + await disconnect(); + process.exit(failures === 0 ? 0 : 1); + }); diff --git a/src/app/(app)/customers/[id]/page.tsx b/src/app/(app)/customers/[id]/page.tsx new file mode 100644 index 0000000..bd04785 --- /dev/null +++ b/src/app/(app)/customers/[id]/page.tsx @@ -0,0 +1,353 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, Mail, Phone, Plus, Smartphone } from "lucide-react"; +import { getFormatter, getTranslations } from "next-intl/server"; +import { PageHead, Pill } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { ActionButtonForm } from "@/components/customers/action-form"; +import { ContactForm } from "@/components/customers/contact-form"; +import { CustomerForm } from "@/components/customers/customer-form"; +import { MergeForm } from "@/components/customers/merge-form"; +import { CustomerStatusPill, OrderStatusPill, orderStatusGroup, SiteStatusPill } from "@/components/customers/status"; +import { Banner, buttonLinkClass, Card, DefinitionList, primaryButtonClass, TabNav } from "@/components/customers/form-ui"; +import { DocumentPanel } from "@/components/documents/document-panel"; +import { confirmCustomerAction, deleteCustomerAction, mergeCustomerAction, updateCustomerAction } from "@/server/actions/customers/customers"; +import { createContactAction, deleteContactAction, updateContactAction } from "@/server/actions/customers/contacts"; +import { requirePageContext } from "@/server/api/context"; +import { can, ServiceError } from "@/server/services/context"; +import { getCustomer, listCustomerWorkOrders } from "@/server/services/customers/customers"; +import { findDuplicateCustomers } from "@/server/services/customers/duplicates"; +import { customerDisplayName, formatAddress } from "@/server/services/customers/format"; +import { listDocuments } from "@/server/services/documents/access"; +import { listSites } from "@/server/services/sites/sites"; + +const TABS = ["master", "contacts", "sites", "orders", "documents"] as const; +type Tab = (typeof TABS)[number]; + +type SearchParams = Promise<{ + tab?: string; + edit?: string; + contact?: string; + merge?: string; + delete?: string; + merged?: string; + docOk?: string; + docError?: string; + docEdit?: string; + docVersion?: string; +}>; + +function toFormValues(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, v === null || v === undefined ? "" : String(v)])); +} + +export default async function CustomerDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: SearchParams }) { + const ctx = await requirePageContext("customers"); + const [{ id }, sp] = await Promise.all([params, searchParams]); + const [t, tc, ts, format] = await Promise.all([getTranslations("customers"), getTranslations("common"), getTranslations("sites"), getFormatter()]); + + let customer; + try { + customer = await getCustomer(ctx, id); + } catch (err) { + if (err instanceof ServiceError) notFound(); + throw err; + } + + const tab: Tab = (TABS as readonly string[]).includes(sp.tab ?? "") ? (sp.tab as Tab) : "master"; + const base = `/customers/${id}`; + const tabHref = (k: Tab) => (k === "master" ? base : `${base}?tab=${k}`); + const here = tabHref(tab); + const withParam = (k: string, v: string) => `${here}${here.includes("?") ? "&" : "?"}${k}=${encodeURIComponent(v)}`; + const isMerged = customer.status === "merged"; + const canWrite = can(ctx, "customer:write") && !isMerged; + const canMerge = can(ctx, "customer:merge") && !isMerged; + const name = customerDisplayName(customer) || "—"; + + const mergedTarget = isMerged && customer.mergedIntoId + ? await ctx.db.customer.findFirst({ where: { id: customer.mergedIntoId }, select: { id: true, companyName: true, firstName: true, lastName: true, customerNumber: true } }) + : null; + + return ( +
+ + {t("detail.back")} + + + + {canWrite && customer.status === "provisional" && ( + + )} + {canWrite && ( + + {t("detail.edit")} + + )} + {canMerge && ( + + {t("detail.merge")} + + )} + {canWrite && ( + + {t("detail.delete")} + + )} + + } + /> + + {sp.merged && {t("detail.mergedOk")}} + {customer.status === "provisional" && {t("provisionalHint")}} + {mergedTarget && ( + + {t("detail.mergedInto")}{" "} + + {customerDisplayName(mergedTarget)} ({mergedTarget.customerNumber}) + + + )} + + ({ key: k, label: t(`tabs.${k}`), href: tabHref(k), count: k === "contacts" ? customer.contacts.length : undefined }))} + /> + + {tab === "master" && ( + + + + )} + + {tab === "contacts" && ( +
+ {canWrite && ( + + {t("contacts.new")} + + )} + {customer.contacts.length === 0 &&

{t("contacts.empty")}

} +
    + {customer.contacts.map((c) => ( +
  • +
    +
    +

    {c.name}

    + {c.role &&

    {c.role}

    } +
    + {c.preferredChannel && {t("contacts.preferred")}: {t(`contacts.channel.${c.preferredChannel}`)}} +
    + + {c.notes &&

    {c.notes}

    } + {canWrite && ( +
    + + {tc("edit")} + + +
    + )} +
  • + ))} +
+
+ )} + + {tab === "sites" && } + + {tab === "orders" && ( + + )} + + {tab === "documents" && ( + + )} + + {canWrite && sp.edit && ( + + + + )} + + {canWrite && sp.contact && (sp.contact === "new" || customer.contacts.some((c) => c.id === sp.contact)) && ( + + c.id === sp.contact)!)} + closeHref={here} + /> + + )} + + {canMerge && sp.merge && ( + + + + )} + + {canWrite && sp.delete && ( + +
+

{t("detail.deleteHint")}

+
+ + + {t("form.cancel")} + +
+
+
+ )} + +
+ ); +} + +async function SitesTab({ customerId, ctx, canCreate }: { customerId: string; ctx: Awaited>; canCreate: boolean }) { + const [t, ts] = await Promise.all([getTranslations("customers"), getTranslations("sites")]); + if (!can(ctx, "site:read")) return

{t("errors.forbidden")}

; + const sites = await listSites(ctx, { customerId, status: "all", pageSize: 100 }); + return ( +
+ {canCreate && ( + + {t("sites.new")} + + )} + {sites.items.length === 0 ? ( +

{t("sites.empty")}

+ ) : ( +
+ + + + {ts("columns.name")} + {ts("columns.address")} + {ts("columns.status")} + + + + {sites.items.map((s) => ( + + + {s.name} + + {formatAddress(s) || "—"} + + + + + ))} + +
+
+ )} +
+ ); +} + +async function OrdersTab({ customerId, ctx }: { customerId: string; ctx: Awaited> }) { + const [t, ts, format] = await Promise.all([getTranslations("customers"), getTranslations("sites"), getFormatter()]); + const orders = await listCustomerWorkOrders(ctx, customerId); + return ( +
+

{t("orders.hint")}

+ {orders.length === 0 ? ( +

{t("orders.empty")}

+ ) : ( +
+ + + + {t("orders.columns.number")} + {t("orders.columns.title")} + {t("orders.columns.site")} + {t("orders.columns.team")} + {t("orders.columns.date")} + {t("orders.columns.status")} + + + + {orders.map((o) => ( + + + {o.number} + + + {o.title} + + {o.site?.name ?? "—"} + {o.team?.name ?? "—"} + {o.plannedStart ? format.dateTime(o.plannedStart, { dateStyle: "medium" }) : "—"} + + + + + ))} + +
+
+ )} +
+ ); +} diff --git a/src/app/(app)/customers/page.tsx b/src/app/(app)/customers/page.tsx index 8c2dac3..18c4dd1 100644 --- a/src/app/(app)/customers/page.tsx +++ b/src/app/(app)/customers/page.tsx @@ -1,5 +1,148 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Plus } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { PageHead } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { CustomerForm } from "@/components/customers/customer-form"; +import { CustomerStatusPill } from "@/components/customers/status"; +import { buttonLinkClass, controlClass, hrefWith, Pagination, paginationSummary, primaryButtonClass } from "@/components/customers/form-ui"; +import { createCustomerAction } from "@/server/actions/customers/customers"; +import { requirePageContext } from "@/server/api/context"; +import { can } from "@/server/services/context"; +import { CUSTOMER_LIST_STATUSES, listCustomers, type CustomerListStatus } from "@/server/services/customers/customers"; +import { customerDisplayName } from "@/server/services/customers/format"; -export default function Page() { - return ; +type SearchParams = Promise<{ q?: string; status?: string; page?: string; new?: string }>; + +/** Customer list (spec §7): search, status filter incl. "provisional", 25 per page, create popup. */ +export default async function CustomersPage({ searchParams }: { searchParams: SearchParams }) { + const ctx = await requirePageContext("customers"); + if (!can(ctx, "customer:read")) notFound(); + const [t, tc] = await Promise.all([getTranslations("customers"), getTranslations("common")]); + const sp = await searchParams; + + const status: CustomerListStatus | "all" = (CUSTOMER_LIST_STATUSES as readonly string[]).includes(sp.status ?? "") + ? (sp.status as CustomerListStatus) + : "all"; + const q = sp.q?.trim() || undefined; + const page = Math.max(1, Math.floor(Number(sp.page)) || 1); + const result = await listCustomers(ctx, { q, status, page, pageSize: 25 }); + const canWrite = can(ctx, "customer:write"); + + const listHref = (p: number) => hrefWith("/customers", { q, status: status === "all" ? undefined : status, page: p > 1 ? p : undefined }); + const closeHref = listHref(page); + + return ( +
+ 1 ? page : undefined, new: 1 })} className={primaryButtonClass}> + {t("new")} + + ) : undefined + } + /> + +
+ + + + {(q || status !== "all") && ( + + {t("filter.reset")} + + )} +
+ +
+ + + + {t("columns.number")} + {t("columns.name")} + {t("columns.city")} + {t("columns.contact")} + {t("columns.sites")} + {t("columns.status")} + + + + {result.items.map((c) => { + const href = `/customers/${c.id}`; + return ( + + + + {c.customerNumber ?? "—"} + + + + + {customerDisplayName(c) || "—"} + + + + + {[c.postalCode, c.city].filter(Boolean).join(" ") || "—"} + + + + + {c.phone || c.email || "—"} + + + {c._count.sites} + + + + + ); + })} + {result.items.length === 0 && ( + + + {t("empty")} + + + )} + +
+
+ + + + {canWrite && sp.new && ( + + + + )} +
+ ); } diff --git a/src/app/(app)/documents/page.tsx b/src/app/(app)/documents/page.tsx index 5efb9ae..1bb7f28 100644 --- a/src/app/(app)/documents/page.tsx +++ b/src/app/(app)/documents/page.tsx @@ -1,5 +1,122 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { DocumentCategory } from "@prisma/client"; +import { PageHead } from "@/components/mockup-ui"; +import { buttonLinkClass, controlClass, hrefWith, Pagination, paginationSummary } from "@/components/customers/form-ui"; +import { DocumentPanel } from "@/components/documents/document-panel"; +import { requirePageContext } from "@/server/api/context"; +import { can } from "@/server/services/context"; +import { customerOptions } from "@/server/services/customers/customers"; +import { customerDisplayName } from "@/server/services/customers/format"; +import { listDocuments } from "@/server/services/documents/access"; +import { DOCUMENT_CATEGORIES } from "@/server/services/documents/store"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; -export default function Page() { - return ; +type SearchParams = Promise<{ + q?: string; + category?: string; + customerId?: string; + siteId?: string; + workOrder?: string; + all?: string; + page?: string; + docOk?: string; + docError?: string; + docEdit?: string; + docVersion?: string; +}>; + +/** Backoffice overview of all documents (spec §24) with filters category / customer / site / work order. */ +export default async function DocumentsPage({ searchParams }: { searchParams: SearchParams }) { + const ctx = await requirePageContext("documents"); + if (!can(ctx, "document:read")) notFound(); + const t = await getTranslations("documents"); + const sp = await searchParams; + + const q = sp.q?.trim() || undefined; + const category = (DOCUMENT_CATEGORIES as string[]).includes(sp.category ?? "") ? (sp.category as DocumentCategory) : undefined; + const customerId = sp.customerId?.trim() || undefined; + const siteId = sp.siteId?.trim() || undefined; + const workOrderNumber = sp.workOrder?.trim() || undefined; + const latestOnly = sp.all !== "1"; + const page = Math.max(1, Math.floor(Number(sp.page)) || 1); + + let workOrderId: string | undefined; + if (workOrderNumber) { + const wo = await ctx.db.workOrder.findFirst({ where: { AND: [{ number: workOrderNumber }, await workOrderScope(ctx)] }, select: { id: true } }); + workOrderId = wo?.id ?? "__none__"; + } + + const [result, customers] = await Promise.all([ + listDocuments(ctx, { q, category, customerId, siteId, workOrderId, latestOnly, page, pageSize: 25 }), + can(ctx, "customer:read") ? customerOptions(ctx) : Promise.resolve([]), + ]); + + const filters = { q, category, customerId, siteId, workOrder: workOrderNumber, all: latestOnly ? undefined : "1" }; + const listHref = (p: number) => hrefWith("/documents", { ...filters, page: p > 1 ? p : undefined }); + + return ( +
+ + +
+ {siteId && } + + + + +
+ + + {Object.values(filters).some(Boolean) && ( + + {t("filter.reset")} + + )} +
+
+ + + + +
+ ); } diff --git a/src/app/(app)/documents/upload/route.ts b/src/app/(app)/documents/upload/route.ts new file mode 100644 index 0000000..341eee7 --- /dev/null +++ b/src/app/(app)/documents/upload/route.ts @@ -0,0 +1,84 @@ +import { DocumentCategory, DocumentVisibility } from "@prisma/client"; +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { ApiError, toErrorResponse } from "@/server/api/respond"; +import { ServiceError } from "@/server/services/context"; +import { storeFile } from "@/server/services/documents/store"; + +/** + * Multipart upload for the backoffice document tabs (customer, site, /documents). + * A route handler instead of a server action because server action bodies are limited to 1 MB. + * Note: with the proxy active, Next.js buffers at most `proxyClientMaxBodySize` (default 10 MB); + * larger bodies fail to parse and are answered with `too_large`. + * + * Browser forms get a 303 redirect back to `returnTo` with `?docOk=1` or `?docError=`; + * clients sending `Accept: application/json` get JSON (`{ data: { id } }` or the error format). + */ +export async function POST(req: Request) { + const wantsJson = (req.headers.get("accept") ?? "").includes("application/json"); + let returnTo = "/documents"; + try { + assertSameOrigin(req); + const ctx = await requireApiContext("documents"); + + let form: FormData; + try { + form = await req.formData(); + } catch { + throw new ServiceError("invalid", "unreadable upload", { reason: "too_large" }); + } + returnTo = safeReturnTo(form.get("returnTo")); + + const file = form.get("file"); + if (!(file instanceof File)) throw new ServiceError("invalid", "file missing", { reason: "empty_file" }); + const str = (k: string) => { + const v = form.get(k); + return typeof v === "string" && v.trim() !== "" ? v.trim() : null; + }; + const category = str("category"); + const visibility = str("visibility"); + if (!category || !(category in DocumentCategory)) throw new ServiceError("invalid", "category", { reason: "invalid_category" }); + if (!visibility || !(visibility in DocumentVisibility)) throw new ServiceError("invalid", "visibility", { reason: "visibility_not_allowed" }); + + const document = await storeFile(ctx, { + bytes: new Uint8Array(await file.arrayBuffer()), + fileName: file.name, + declaredMime: file.type || "application/octet-stream", + category: category as DocumentCategory, + visibility: visibility as DocumentVisibility, + title: str("title"), + lineageId: str("lineageId"), + links: { customerId: str("customerId"), siteId: str("siteId"), workOrderId: str("workOrderId") }, + }); + + if (wantsJson) return Response.json({ data: { id: document.id, version: document.version, lineageId: document.lineageId } }, { status: 201 }); + return redirectTo(returnTo, { docOk: "1" }); + } catch (err) { + if (wantsJson) return toErrorResponse(err); + if (err instanceof ApiError && err.code === "unauthorized") return redirectTo("/login", {}); + const reason = + err instanceof ServiceError + ? String((err.details as { reason?: string } | undefined)?.reason ?? err.code) + : err instanceof ApiError + ? err.code + : "generic"; + if (!(err instanceof ServiceError) && !(err instanceof ApiError)) console.error("[documents/upload] failed", err); + return redirectTo(returnTo, { docError: reason }); + } +} + +/** Only same-app relative paths; everything else falls back to /documents (no open redirect). */ +function safeReturnTo(value: FormDataEntryValue | null): string { + if (typeof value !== "string") return "/documents"; + if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return "/documents"; + return value.slice(0, 500); +} + +function redirectTo(path: string, params: Record): Response { + const [pathname, query = ""] = path.split("?"); + const sp = new URLSearchParams(query); + sp.delete("docOk"); + sp.delete("docError"); + for (const [k, v] of Object.entries(params)) sp.set(k, v); + const qs = sp.toString(); + return new Response(null, { status: 303, headers: { Location: `${pathname}${qs ? `?${qs}` : ""}` } }); +} diff --git a/src/app/(app)/files/[...key]/route.ts b/src/app/(app)/files/[...key]/route.ts deleted file mode 100644 index 5632cb3..0000000 --- a/src/app/(app)/files/[...key]/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { requireSession } from "@/server/auth"; -import { storage } from "@/server/storage/adapter"; - -/** - * Download-Route für Dateien im Objektspeicher (Garage/S3) anhand ihres Storage-Keys. - * - * Mandanten-Isolation: - * Der Key ist mandantenpräfixiert (`/…`). Er MUSS mit dem Tenant der - * aktuellen Session beginnen — ein Fremd-Tenant-Key wird mit 404 abgewiesen - * (keine Existenz-Preisgabe). - * - * TODO(documents): Defense in Depth wiederherstellen — sobald das Craftvia-Document-Modell - * existiert, zusätzlich prüfen, dass der Key in einer mandantengebundenen Referenz - * (Document.storageKey) vorkommt und der Nutzer das Dokument sehen darf - * (document:read bzw. document:read_internal für interne Dokumente). - * - * Auslieferung mit `Content-Disposition: attachment` und `X-Content-Type-Options: - * nosniff` (F-07) — kein Inline-Rendering, kein MIME-Sniffing. - * - * Route-Handler laufen NICHT durch das Layout-Gate; die Auth wird hier eigenständig - * über `requireSession` erzwungen. - */ -export async function GET( - _req: Request, - { params }: { params: Promise<{ key: string[] }> }, -) { - const session = await requireSession(); - const tenantId = session.user.tenantId; - - const { key: segments } = await params; - // Catch-all-Segmente sind bereits URL-dekodiert; zum Objekt-Key zusammenfügen. - const key = (segments ?? []).join("/"); - - // Pfad-Traversal ausschließen und Mandantenpräfix erzwingen. - if ( - !tenantId || - !key || - key.includes("..") || - key.includes("\0") || - !key.startsWith(`${tenantId}/`) - ) { - return new Response("Nicht gefunden.", { status: 404 }); - } - - const content = await storage.get(key); - if (!content) { - // Kein Byte-Backend (Stub) oder Objekt fehlt → 404. - return new Response("Datei nicht verfügbar.", { status: 404 }); - } - - const filename = content.filename.replace(/["\\]/g, "_"); - const headers = new Headers({ - "Content-Type": content.contentType ?? "application/octet-stream", - "Content-Disposition": `attachment; filename="${filename}"`, - "X-Content-Type-Options": "nosniff", - "Cache-Control": "private, no-store", - }); - if (content.size != null) headers.set("Content-Length", String(content.size)); - - return new Response(content.stream, { headers }); -} diff --git a/src/app/(app)/files/[documentId]/route.ts b/src/app/(app)/files/[documentId]/route.ts new file mode 100644 index 0000000..db582de --- /dev/null +++ b/src/app/(app)/files/[documentId]/route.ts @@ -0,0 +1,49 @@ +import { requireApiContext } from "@/server/api/context"; +import { ApiError } from "@/server/api/respond"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { openDocumentContent } from "@/server/services/documents/access"; + +/** + * Document download by id (ARCHITEKTUR §4.3). Replaces the former storage-key route. + * + * Authorization on EVERY request (no public or long-lived links): + * session + DB-authoritative `document:read` → document in the tenant (dbForTenant) → + * visibility allowed for the user → work order in scope (`requireVisibleWorkOrder` semantics via + * `workOrderScope`) or site/customer in `siteScope`/`customerScope`. Everything else → 404 + * without revealing existence. + * + * Delivered as attachment with nosniff (F-07); images still render in tags. + */ +export async function GET(_req: Request, { params }: { params: Promise<{ documentId: string }> }) { + let ctx: ServiceCtx; + try { + ctx = await requireApiContext(null, "document:read"); + } catch (err) { + const status = err instanceof ApiError && err.code === "unauthorized" ? 401 : 403; + return new Response(status === 401 ? "Nicht angemeldet." : "Kein Zugriff.", { status, headers: { "Cache-Control": "no-store" } }); + } + + const { documentId } = await params; + if (!documentId || documentId.length > 64) return notFound(); + + try { + const { document, content } = await openDocumentContent(ctx, documentId); + const asciiName = document.fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + const headers = new Headers({ + "Content-Type": document.mimeType || content.contentType || "application/octet-stream", + "Content-Disposition": `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(document.fileName)}`, + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, no-store", + }); + if (content.size != null) headers.set("Content-Length", String(content.size)); + return new Response(content.stream, { headers }); + } catch (err) { + if (err instanceof ServiceError || (err instanceof Error && /Tenant isolation violation/.test(err.message))) return notFound(); + console.error("[files] download failed", err); + return new Response("Datei nicht verfügbar.", { status: 500, headers: { "Cache-Control": "no-store" } }); + } +} + +function notFound() { + return new Response("Nicht gefunden.", { status: 404, headers: { "Cache-Control": "no-store" } }); +} diff --git a/src/app/(app)/sites/[id]/page.tsx b/src/app/(app)/sites/[id]/page.tsx new file mode 100644 index 0000000..3d0affc --- /dev/null +++ b/src/app/(app)/sites/[id]/page.tsx @@ -0,0 +1,231 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { AlertTriangle, ArrowLeft, Car, KeyRound, MapPin, Wrench } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { PageHead } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { ActionButtonForm } from "@/components/customers/action-form"; +import { SiteStatusPill } from "@/components/customers/status"; +import { Banner, buttonLinkClass, Card, DefinitionList, TabNav } from "@/components/customers/form-ui"; +import { DocumentPanel } from "@/components/documents/document-panel"; +import { SiteForm } from "@/components/sites/site-form"; +import { SiteHistory } from "@/components/sites/site-history"; +import { deleteSiteAction, updateSiteAction } from "@/server/actions/sites/sites"; +import { requirePageContext } from "@/server/api/context"; +import { can, ServiceError } from "@/server/services/context"; +import { customerOptions, getCustomer } from "@/server/services/customers/customers"; +import { customerDisplayName, formatAddress } from "@/server/services/customers/format"; +import { listDocuments } from "@/server/services/documents/access"; +import { getSiteHistory } from "@/server/services/sites/history"; +import { siteMapUrl } from "@/server/services/sites/map-link"; +import { getSite } from "@/server/services/sites/sites"; + +const TABS = ["master", "documents", "history"] as const; +type Tab = (typeof TABS)[number]; + +type SearchParams = Promise<{ + tab?: string; + edit?: string; + delete?: string; + approved?: string; + docOk?: string; + docError?: string; + docEdit?: string; + docVersion?: string; +}>; + +export default async function SiteDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: SearchParams }) { + const ctx = await requirePageContext("sites"); + const [{ id }, sp] = await Promise.all([params, searchParams]); + const [t, tc] = await Promise.all([getTranslations("sites"), getTranslations("common")]); + + let site; + try { + site = await getSite(ctx, id); + } catch (err) { + if (err instanceof ServiceError) notFound(); + throw err; + } + + const tab: Tab = (TABS as readonly string[]).includes(sp.tab ?? "") ? (sp.tab as Tab) : "master"; + const base = `/sites/${id}`; + const tabHref = (k: Tab) => (k === "master" ? base : `${base}?tab=${k}`); + const here = tabHref(tab); + const canWrite = can(ctx, "site:write") && can(ctx, "customer:read"); + const mapUrl = siteMapUrl(site); + const fullHistoryAccess = can(ctx, "work_order:read_all"); + + const notes = [ + { key: "accessNotes", icon: KeyRound, value: site.accessNotes, tone: "info" as const }, + { key: "parkingNotes", icon: Car, value: site.parkingNotes, tone: "info" as const }, + { key: "safetyNotes", icon: AlertTriangle, value: site.safetyNotes, tone: "warn" as const }, + { key: "technicalNotes", icon: Wrench, value: site.technicalNotes, tone: "info" as const }, + ].filter((n) => n.value?.trim()); + + return ( +
+ + {t("detail.back")} + + + + {mapUrl ? ( + + {t("detail.map")} + + ) : ( + {t("detail.noMap")} + )} + {canWrite && ( + + {t("detail.edit")} + + )} + {canWrite && ( + + {t("detail.delete")} + + )} + + } + /> + + ({ key: k, label: t(`tabs.${k}`), href: tabHref(k) }))} /> + + {tab === "master" && ( +
+ + + {customerDisplayName(site.customer)} · {site.customer.customerNumber} + + ) : ( + customerDisplayName(site.customer) + ), + }, + { label: t("sections.address"), value: formatAddress(site, { withCountry: true }) }, + { label: t("fields.contactId"), value: site.contact ? [site.contact.name, site.contact.phone ?? site.contact.mobile, site.contact.email].filter(Boolean).join(" · ") : null }, + { label: t("fields.onSiteContact"), value: site.onSiteContact }, + { label: t("fields.phone"), value: site.phone }, + { + label: `${t("fields.latitude")} / ${t("fields.longitude")}`, + value: site.latitude != null && site.longitude != null ? `${site.latitude}, ${site.longitude}` : null, + }, + ]} + /> + +
+ {notes.length === 0 &&

{t("detail.noNotes")}

} + {notes.map((n) => ( +
+

+ {t(`fields.${n.key}`)} +

+

{n.value}

+
+ ))} +
+
+ )} + + {tab === "documents" && ( + + )} + + {tab === "history" && ( + + )} + + {canWrite && sp.edit && ( + + [k, v === null || typeof v === "object" ? "" : String(v)]))} + closeHref={here} + /> + + )} + + {canWrite && sp.delete && ( + +
+

{t("detail.deleteHint")}

+
+ + + {t("form.cancel")} + +
+
+
+ )} +
+ ); +} + +async function editCustomerOptions(ctx: Awaited>, current: { id: string; customerNumber: string | null; companyName: string | null; firstName: string | null; lastName: string | null }) { + const options = (await customerOptions(ctx)).map((c) => ({ id: c.id, label: `${customerDisplayName(c)} · ${c.customerNumber ?? ""}` })); + if (!options.some((o) => o.id === current.id)) options.unshift({ id: current.id, label: `${customerDisplayName(current)} · ${current.customerNumber ?? ""}` }); + return options; +} + +async function siteContacts(ctx: Awaited>, customerId: string) { + try { + const customer = await getCustomer(ctx, customerId); + return customer.contacts.map((c) => ({ id: c.id, label: c.role ? `${c.name} (${c.role})` : c.name })); + } catch (err) { + if (err instanceof ServiceError) return []; + throw err; + } +} + +async function HistoryTab({ siteId, ctx, onlyApproved, canToggle }: { siteId: string; ctx: Awaited>; onlyApproved: boolean; canToggle: boolean }) { + const t = await getTranslations("sites"); + const history = await getSiteHistory(ctx, siteId, { onlyApproved, pageSize: 100 }); + const base = `/sites/${siteId}?tab=history`; + return ( +
+
+

{t("history.title")}

+ {canToggle && ( +
+ + {t("history.showAll")} + + + {t("history.onlyApproved")} + +
+ )} +
+ {history.onlyApproved && {t("history.approvedOnlyHint")}} + +
+ ); +} diff --git a/src/app/(app)/sites/page.tsx b/src/app/(app)/sites/page.tsx index bfcdf3f..8eaed5f 100644 --- a/src/app/(app)/sites/page.tsx +++ b/src/app/(app)/sites/page.tsx @@ -1,5 +1,152 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Plus } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { PageHead } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { SiteForm } from "@/components/sites/site-form"; +import { SiteStatusPill } from "@/components/customers/status"; +import { buttonLinkClass, controlClass, hrefWith, Pagination, paginationSummary, primaryButtonClass } from "@/components/customers/form-ui"; +import { createSiteAction } from "@/server/actions/sites/sites"; +import { requirePageContext } from "@/server/api/context"; +import { can, ServiceError } from "@/server/services/context"; +import { customerOptions, getCustomer } from "@/server/services/customers/customers"; +import { customerDisplayName, formatAddress } from "@/server/services/customers/format"; +import { listSites, SITE_STATUSES } from "@/server/services/sites/sites"; -export default function Page() { - return ; +type SearchParams = Promise<{ q?: string; status?: string; customerId?: string; page?: string; new?: string }>; + +/** Site list (spec §8): search, status/customer filter, pagination, create popup. */ +export default async function SitesPage({ searchParams }: { searchParams: SearchParams }) { + const ctx = await requirePageContext("sites"); + if (!can(ctx, "site:read")) notFound(); + const [t, tc] = await Promise.all([getTranslations("sites"), getTranslations("common")]); + const sp = await searchParams; + + const status = (SITE_STATUSES as readonly string[]).includes(sp.status ?? "") ? (sp.status as (typeof SITE_STATUSES)[number]) : "all"; + const q = sp.q?.trim() || undefined; + const customerId = sp.customerId?.trim() || undefined; + const page = Math.max(1, Math.floor(Number(sp.page)) || 1); + const canWrite = can(ctx, "site:write") && can(ctx, "customer:read"); + + // with ?new=1 the customerId only preselects the form, it does not filter the list + const filterCustomer = sp.new ? undefined : customerId; + const result = await listSites(ctx, { q, status, customerId: filterCustomer, page, pageSize: 25 }); + const listHref = (p: number) => hrefWith("/sites", { q, status: status === "all" ? undefined : status, customerId: filterCustomer, page: p > 1 ? p : undefined }); + const closeHref = sp.new && customerId ? `/customers/${customerId}?tab=sites` : listHref(page); + + let formCustomers: { id: string; label: string }[] = []; + let contacts: { id: string; label: string }[] | null = null; + if (canWrite && sp.new) { + formCustomers = (await customerOptions(ctx)).map((c) => ({ id: c.id, label: `${customerDisplayName(c)} · ${c.customerNumber ?? ""}${c.city ? ` · ${c.city}` : ""}` })); + if (customerId) { + try { + const customer = await getCustomer(ctx, customerId); + contacts = customer.contacts.map((c) => ({ id: c.id, label: c.role ? `${c.name} (${c.role})` : c.name })); + } catch (err) { + if (!(err instanceof ServiceError)) throw err; + } + } + } + + return ( +
+ + {t("new")} + + ) : undefined + } + /> + +
+ {filterCustomer && } + + + + {(q || status !== "all" || filterCustomer) && ( + + {t("filter.reset")} + + )} +
+ +
+ + + + {t("columns.name")} + {t("columns.customer")} + {t("columns.address")} + {t("columns.orders")} + {t("columns.status")} + + + + {result.items.map((s) => ( + + + + {s.name} + + + + + {customerDisplayName(s.customer)} + + + {formatAddress(s) || "—"} + {s._count.workOrders} + + + + + ))} + {result.items.length === 0 && ( + + + {t("empty")} + + + )} + +
+
+ + + + {canWrite && sp.new && ( + + + + )} +
+ ); } diff --git a/src/app/(app)/teams/page.tsx b/src/app/(app)/teams/page.tsx index c1b698c..340da9e 100644 --- a/src/app/(app)/teams/page.tsx +++ b/src/app/(app)/teams/page.tsx @@ -1,5 +1,168 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Plus } from "lucide-react"; +import { getFormatter, getTranslations } from "next-intl/server"; +import { PageHead } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { ActionButtonForm } from "@/components/customers/action-form"; +import { TeamStatusPill } from "@/components/customers/status"; +import { buttonLinkClass, primaryButtonClass } from "@/components/customers/form-ui"; +import { TeamForm, type TeamFormValues } from "@/components/teams/team-form"; +import { deleteTeamAction, saveTeamAction } from "@/server/actions/teams/teams"; +import { requirePageContext } from "@/server/api/context"; +import { can } from "@/server/services/context"; +import { listTeams, teamUserOptions } from "@/server/services/teams/teams"; -export default function Page() { - return ; +type SearchParams = Promise<{ new?: string; edit?: string; inactive?: string }>; + +const day = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : ""); + +/** Teams (spec §11.1): list with team lead, members (validity), phone, vehicle, area; edit popup. */ +export default async function TeamsPage({ searchParams }: { searchParams: SearchParams }) { + const ctx = await requirePageContext("teams"); + if (!can(ctx, "team:read")) notFound(); + const [t, tc, format] = await Promise.all([getTranslations("teams"), getTranslations("common"), getFormatter()]); + const sp = await searchParams; + const includeInactive = sp.inactive === "1"; + const canManage = can(ctx, "team:manage"); + + const [teams, users] = await Promise.all([listTeams(ctx, { includeInactive }), canManage ? teamUserOptions(ctx) : Promise.resolve([])]); + const listHref = includeInactive ? "/teams?inactive=1" : "/teams"; + const withParam = (k: string, v: string) => `${listHref}${listHref.includes("?") ? "&" : "?"}${k}=${encodeURIComponent(v)}`; + const now = new Date(); + const isCurrent = (m: { validFrom: Date; validTo: Date | null }) => m.validFrom <= now && (!m.validTo || m.validTo > now); + const editTeam = sp.edit ? teams.find((x) => x.id === sp.edit) : undefined; + + const initialFor = (team?: (typeof teams)[number]): TeamFormValues => + team + ? { + name: team.name, + leaderUserId: team.leaderUserId, + status: team.status, + phone: team.phone, + vehicle: team.vehicle, + area: team.area, + notes: team.notes, + members: team.members.map((m) => ({ userId: m.userId, validFrom: day(m.validFrom), validTo: day(m.validTo) })), + } + : { status: "active", members: [] }; + + return ( +
+ + + {includeInactive ? t("hideInactive") : t("showInactive")} + + {canManage && ( + + {t("new")} + + )} + + } + /> + {!canManage &&

{t("readOnly")}

} + +
+ + + + {t("columns.name")} + {t("columns.leader")} + {t("columns.members")} + {t("columns.phone")} + {t("columns.vehicle")} + {t("columns.area")} + {t("columns.status")} + + + + {teams.map((team) => { + const current = team.members.filter(isCurrent); + return ( + + + {canManage ? ( + + {team.name} + + ) : ( + {team.name} + )} + + {team.leader?.name ?? "—"} + + {t("members.count", { count: current.length })} + {team.members.length > 0 && ( +
    + {team.members.map((m) => ( +
  • + {m.user.name} ·{" "} + {isCurrent(m) + ? t("members.current") + : m.validFrom > now + ? t("members.upcoming", { date: format.dateTime(m.validFrom, { dateStyle: "medium" }) }) + : t("members.ended")} +
  • + ))} +
+ )} +
+ {team.phone ?? "—"} + {team.vehicle ?? "—"} + {team.area ?? "—"} + + + +
+ ); + })} + {teams.length === 0 && ( + + + {t("empty")} + + + )} +
+
+
+ + {canManage && sp.new && ( + + + + )} + + {canManage && editTeam && ( + +

{t("form.deleteHint")}

+ + + } + > + +
+ )} +
+ ); } diff --git a/src/app/api/v1/customers/[id]/route.ts b/src/app/api/v1/customers/[id]/route.ts new file mode 100644 index 0000000..3f13787 --- /dev/null +++ b/src/app/api/v1/customers/[id]/route.ts @@ -0,0 +1,22 @@ +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { json, readJson, withApi } from "@/server/api/respond"; +import { getCustomer, updateCustomer } from "@/server/services/customers/customers"; +import type { CustomerPatchInput } from "@/server/services/customers/schemas"; + +type Ctx = { params: Promise<{ id: string }> }; + +/** GET /api/v1/customers/:id — customer incl. contacts (scope applies, otherwise 404). */ +export const GET = withApi(async (_req: Request, { params }: Ctx) => { + const ctx = await requireApiContext("customers", "customer:read"); + const { id } = await params; + return json({ data: await getCustomer(ctx, id) }); +}); + +/** PATCH /api/v1/customers/:id — absent fields unchanged, null clears. */ +export const PATCH = withApi(async (req: Request, { params }: Ctx) => { + assertSameOrigin(req); + const ctx = await requireApiContext("customers", "customer:write"); + const { id } = await params; + const body = (await readJson(req)) as CustomerPatchInput | null; + return json({ data: await updateCustomer(ctx, id, body ?? {}) }); +}); diff --git a/src/app/api/v1/customers/route.ts b/src/app/api/v1/customers/route.ts new file mode 100644 index 0000000..ccf5f00 --- /dev/null +++ b/src/app/api/v1/customers/route.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { json, paginated, parsePagination, readJson, withApi } from "@/server/api/respond"; +import { createCustomer, CUSTOMER_LIST_STATUSES, listCustomers } from "@/server/services/customers/customers"; +import type { CustomerCreateInput } from "@/server/services/customers/schemas"; + +const statusParam = z.enum([...CUSTOMER_LIST_STATUSES, "all"]).optional(); + +/** GET /api/v1/customers?q&status&page&pageSize */ +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("customers", "customer:read"); + const url = new URL(req.url); + const { page, pageSize } = parsePagination(url); + const status = statusParam.parse(url.searchParams.get("status") ?? undefined); + const result = await listCustomers(ctx, { q: url.searchParams.get("q") ?? undefined, status, page, pageSize }); + return paginated(result.items, result.total, result.page, result.pageSize); +}); + +/** + * POST /api/v1/customers — body: customer fields + optional `acknowledgeDuplicates: true`. + * Possible duplicates without acknowledgement → 409 `{ error: { code: "conflict", details: { reason: "possible_duplicates", candidates } } }`. + */ +export const POST = withApi(async (req: Request) => { + assertSameOrigin(req); + const ctx = await requireApiContext("customers", "customer:write"); + const body = (await readJson(req)) as Record | null; + const customer = await createCustomer(ctx, (body ?? {}) as CustomerCreateInput, { + acknowledgeDuplicates: body?.acknowledgeDuplicates === true, + }); + return json({ data: customer }, { status: 201 }); +}); diff --git a/src/app/api/v1/sites/[id]/history/route.ts b/src/app/api/v1/sites/[id]/history/route.ts new file mode 100644 index 0000000..445a982 --- /dev/null +++ b/src/app/api/v1/sites/[id]/history/route.ts @@ -0,0 +1,21 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, parsePagination, withApi } from "@/server/api/respond"; +import { getSiteHistory } from "@/server/services/sites/history"; + +/** + * GET /api/v1/sites/:id/history?onlyApproved=true&page&pageSize + * Field roles always receive released deployments only (see getSiteHistory). + */ +export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("sites", "site:read"); + const { id } = await params; + const url = new URL(req.url); + const { page, pageSize } = parsePagination(url, { pageSize: 50 }); + const flag = url.searchParams.get("onlyApproved"); + const result = await getSiteHistory(ctx, id, { onlyApproved: flag === "true" || flag === "1", page, pageSize }); + return json({ + data: result.items, + pagination: { page: result.page, pageSize: result.pageSize, total: result.total }, + meta: { onlyApproved: result.onlyApproved }, + }); +}); diff --git a/src/app/api/v1/sites/route.ts b/src/app/api/v1/sites/route.ts new file mode 100644 index 0000000..354b5ce --- /dev/null +++ b/src/app/api/v1/sites/route.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { json, paginated, parsePagination, readJson, withApi } from "@/server/api/respond"; +import { createSite, listSites, SITE_STATUSES, type SiteCreateInput } from "@/server/services/sites/sites"; + +const statusParam = z.enum([...SITE_STATUSES, "all"]).optional(); + +/** GET /api/v1/sites?q&customerId&status&page&pageSize */ +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("sites", "site:read"); + const url = new URL(req.url); + const { page, pageSize } = parsePagination(url); + const result = await listSites(ctx, { + q: url.searchParams.get("q") ?? undefined, + customerId: url.searchParams.get("customerId") ?? undefined, + status: statusParam.parse(url.searchParams.get("status") ?? undefined), + page, + pageSize, + }); + return paginated(result.items, result.total, result.page, result.pageSize); +}); + +/** POST /api/v1/sites */ +export const POST = withApi(async (req: Request) => { + assertSameOrigin(req); + const ctx = await requireApiContext("sites", "site:write"); + const body = (await readJson(req)) as SiteCreateInput | null; + return json({ data: await createSite(ctx, body ?? ({} as SiteCreateInput)) }, { status: 201 }); +}); diff --git a/src/components/audit-trail.tsx b/src/components/audit-trail.tsx index 4d056e4..00c0732 100644 --- a/src/components/audit-trail.tsx +++ b/src/components/audit-trail.tsx @@ -59,6 +59,7 @@ const ENTITY_LABEL: Record = { platformAdmin: "Plattform-Admin", // Craftvia-Fachobjekte (Labels vorab, Module folgen) customer: "Kunde", + contact: "Ansprechpartner", site: "Objekt", team: "Team", work_order: "Auftrag", diff --git a/src/components/customers/action-form.tsx b/src/components/customers/action-form.tsx new file mode 100644 index 0000000..0731259 --- /dev/null +++ b/src/components/customers/action-form.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useActionState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { cn } from "@/lib/utils"; +import type { ActionState } from "@/server/api/action-state"; +import { buttonLinkClass, primaryButtonClass } from "@/components/customers/form-ui"; + +export type FormAction = (prev: ActionState, fd: FormData) => Promise; + +export const IDLE_STATE: ActionState = { status: "idle" }; + +/** Translate an action error: specific reason first, then the generic code. */ +export function useErrorText(namespace: string) { + const t = useTranslations(namespace); + return (state: ActionState): string | null => { + if (state.status !== "error") return null; + if (state.reason && t.has(`errors.${state.reason}`)) return t(`errors.${state.reason}`); + return t(`errors.${state.code}`); + }; +} + +/** Field error text (reason code → message, otherwise "invalidField"). */ +export function useFieldError(namespace: string) { + const t = useTranslations(namespace); + return (state: ActionState, field: string): string | undefined => { + if (state.status !== "error" || !state.fieldErrors?.[field]) return undefined; + const code = state.fieldErrors[field]; + return t.has(`errors.${code}`) ? t(`errors.${code}`) : t("errors.invalidField"); + }; +} + +export function FormError({ namespace, state }: { namespace: string; state: ActionState }) { + const text = useErrorText(namespace)(state); + if (!text) return null; + return ( +

+ {text} +

+ ); +} + +/** One-button form for simple mutations (confirm, delete …) with optional browser confirmation. */ +export function ActionButtonForm({ + action, + label, + pendingLabel, + confirmText, + namespace, + tone = "outline", + successHref, + className, +}: { + action: FormAction; + label: string; + pendingLabel?: string; + confirmText?: string; + namespace: string; + tone?: "primary" | "outline" | "danger"; + successHref?: string; + className?: string; +}) { + const router = useRouter(); + const [state, formAction, pending] = useActionState(action, IDLE_STATE); + const errorText = useErrorText(namespace)(state); + + useEffect(() => { + if (state.status === "ok") { + if (successHref) router.push(successHref); + else router.refresh(); + } + }, [state, successHref, router]); + + return ( +
{ + if (confirmText && !window.confirm(confirmText)) e.preventDefault(); + }} + > + + {errorText && ( + + {errorText} + + )} +
+ ); +} diff --git a/src/components/customers/contact-form.tsx b/src/components/customers/contact-form.tsx new file mode 100644 index 0000000..0f06832 --- /dev/null +++ b/src/components/customers/contact-form.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; +import { useActionState, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import type { ActionState } from "@/server/api/action-state"; +import { buttonLinkClass, controlClass, Field, primaryButtonClass, textareaClass } from "@/components/customers/form-ui"; +import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form"; + +type Values = Record; + +export function ContactForm({ action, initial = {}, closeHref }: { action: FormAction; initial?: Values; closeHref: string }) { + const t = useTranslations("customers"); + const router = useRouter(); + const fieldError = useFieldError("customers"); + const [values, setValues] = useState(initial); + const [state, formAction, pending] = useActionState(async (prev, fd) => { + setValues(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)]))); + return action(prev, fd); + }, IDLE_STATE); + + useEffect(() => { + if (state.status === "ok") router.push(closeHref); + }, [state, closeHref, router]); + + const input = (name: string, type = "text", required = false) => ( + + + + ); + + return ( +
+ {input("name", "text", true)} + {input("role")} + {input("phone", "tel")} + {input("mobile", "tel")} + {input("email", "email")} + + + + +