Merge lane/stammdaten in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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/<documentId>` |
|
||||||
|
| 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|<id>`) · 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) |
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<unknown>, 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<unknown>, 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<string, { deleteMany: (a: unknown) => Promise<unknown> }>;
|
||||||
|
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<string>(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 };
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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 <v2>.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/<id>");
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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<string, unknown>): Record<string, string> {
|
||||||
|
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 (
|
||||||
|
<main className="flex-1 p-4 sm:p-6">
|
||||||
|
<Link href="/customers" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
|
||||||
|
</Link>
|
||||||
|
<PageHead
|
||||||
|
crumb={`${t("title")} · ${customer.customerNumber ?? ""}`}
|
||||||
|
title={name}
|
||||||
|
sub={formatAddress(customer, { withCountry: true }) || undefined}
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<CustomerStatusPill status={customer.status} label={t(`status.${customer.status}`)} />
|
||||||
|
{canWrite && customer.status === "provisional" && (
|
||||||
|
<ActionButtonForm action={confirmCustomerAction.bind(null, id)} label={t("detail.confirm")} tone="primary" namespace="customers" />
|
||||||
|
)}
|
||||||
|
{canWrite && (
|
||||||
|
<Link href={withParam("edit", "1")} className={buttonLinkClass}>
|
||||||
|
{t("detail.edit")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{canMerge && (
|
||||||
|
<Link href={withParam("merge", "1")} className={buttonLinkClass}>
|
||||||
|
{t("detail.merge")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{canWrite && (
|
||||||
|
<Link href={withParam("delete", "1")} className={buttonLinkClass}>
|
||||||
|
{t("detail.delete")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sp.merged && <Banner tone="ok">{t("detail.mergedOk")}</Banner>}
|
||||||
|
{customer.status === "provisional" && <Banner tone="warn">{t("provisionalHint")}</Banner>}
|
||||||
|
{mergedTarget && (
|
||||||
|
<Banner tone="info">
|
||||||
|
{t("detail.mergedInto")}{" "}
|
||||||
|
<Link href={`/customers/${mergedTarget.id}`} className="underline">
|
||||||
|
{customerDisplayName(mergedTarget)} ({mergedTarget.customerNumber})
|
||||||
|
</Link>
|
||||||
|
</Banner>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TabNav
|
||||||
|
label={t("title")}
|
||||||
|
active={tab}
|
||||||
|
tabs={TABS.map((k) => ({ key: k, label: t(`tabs.${k}`), href: tabHref(k), count: k === "contacts" ? customer.contacts.length : undefined }))}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{tab === "master" && (
|
||||||
|
<Card>
|
||||||
|
<DefinitionList
|
||||||
|
items={[
|
||||||
|
{ label: t("fields.customerNumber"), value: customer.customerNumber },
|
||||||
|
{ label: t("fields.status"), value: t(`status.${customer.status}`) },
|
||||||
|
{ label: t("fields.companyName"), value: customer.companyName },
|
||||||
|
{ label: `${t("fields.salutation")} / ${t("fields.firstName")} / ${t("fields.lastName")}`, value: [customer.salutation, customer.firstName, customer.lastName].filter(Boolean).join(" ") },
|
||||||
|
{ label: t("sections.address"), value: formatAddress(customer) },
|
||||||
|
{ label: t("fields.country"), value: customer.country },
|
||||||
|
{ label: t("fields.phone"), value: customer.phone },
|
||||||
|
{ label: t("fields.mobile"), value: customer.mobile },
|
||||||
|
{ label: t("fields.email"), value: customer.email },
|
||||||
|
{ label: t("fields.notes"), value: customer.notes },
|
||||||
|
{ label: t("fields.billingNotes"), value: customer.billingNotes },
|
||||||
|
{ label: t("fields.createdAt"), value: format.dateTime(customer.createdAt, { dateStyle: "medium", timeStyle: "short" }) },
|
||||||
|
{ label: t("fields.updatedAt"), value: format.dateTime(customer.updatedAt, { dateStyle: "medium", timeStyle: "short" }) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "contacts" && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{canWrite && (
|
||||||
|
<Link href={withParam("contact", "new")} className={primaryButtonClass}>
|
||||||
|
<Plus className="size-4" aria-hidden /> {t("contacts.new")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{customer.contacts.length === 0 && <p className="text-[13px] text-muted-foreground">{t("contacts.empty")}</p>}
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
|
{customer.contacts.map((c) => (
|
||||||
|
<li key={c.id} className="shadow-card rounded-xl border bg-card p-4">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="font-semibold">{c.name}</p>
|
||||||
|
{c.role && <p className="text-[12.5px] text-muted-foreground">{c.role}</p>}
|
||||||
|
</div>
|
||||||
|
{c.preferredChannel && <Pill tone="info">{t("contacts.preferred")}: {t(`contacts.channel.${c.preferredChannel}`)}</Pill>}
|
||||||
|
</div>
|
||||||
|
<ul className="mt-2 space-y-1 text-[13px]">
|
||||||
|
{c.phone && (
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Phone className="size-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
<a href={`tel:${c.phone}`} className="inline-flex min-h-8 items-center hover:underline">{c.phone}</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{c.mobile && (
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Smartphone className="size-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
<a href={`tel:${c.mobile}`} className="inline-flex min-h-8 items-center hover:underline">{c.mobile}</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
{c.email && (
|
||||||
|
<li className="flex items-center gap-2">
|
||||||
|
<Mail className="size-3.5 text-muted-foreground" aria-hidden />
|
||||||
|
<a href={`mailto:${c.email}`} className="inline-flex min-h-8 items-center break-all hover:underline">{c.email}</a>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
{c.notes && <p className="mt-2 text-[12.5px] whitespace-pre-line text-muted-foreground">{c.notes}</p>}
|
||||||
|
{canWrite && (
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
<Link href={withParam("contact", c.id)} className={buttonLinkClass}>
|
||||||
|
{tc("edit")}
|
||||||
|
</Link>
|
||||||
|
<ActionButtonForm
|
||||||
|
action={deleteContactAction.bind(null, c.id, id)}
|
||||||
|
label={t("contacts.delete")}
|
||||||
|
confirmText={t("contacts.deleteConfirm")}
|
||||||
|
namespace="customers"
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "sites" && <SitesTab customerId={id} ctx={ctx} canCreate={can(ctx, "site:write") && !isMerged} />}
|
||||||
|
|
||||||
|
{tab === "orders" && (
|
||||||
|
<OrdersTab customerId={id} ctx={ctx} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "documents" && (
|
||||||
|
<DocumentPanel
|
||||||
|
ctx={ctx}
|
||||||
|
rows={(await listDocuments(ctx, { customerId: id, pageSize: 500 })).items}
|
||||||
|
baseHref={here}
|
||||||
|
links={{ customerId: id }}
|
||||||
|
searchParams={sp}
|
||||||
|
defaultCategory="order_confirmation"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canWrite && sp.edit && (
|
||||||
|
<Modal title={t("form.editTitle")} sub={name} closeHref={here} closeLabel={tc("close")}>
|
||||||
|
<CustomerForm mode="edit" action={updateCustomerAction.bind(null, id)} initial={toFormValues(customer)} closeHref={here} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canWrite && sp.contact && (sp.contact === "new" || customer.contacts.some((c) => c.id === sp.contact)) && (
|
||||||
|
<Modal title={sp.contact === "new" ? t("contacts.createTitle") : t("contacts.editTitle")} sub={name} closeHref={here} closeLabel={tc("close")}>
|
||||||
|
<ContactForm
|
||||||
|
action={sp.contact === "new" ? createContactAction.bind(null, id) : updateContactAction.bind(null, sp.contact, id)}
|
||||||
|
initial={sp.contact === "new" ? {} : toFormValues(customer.contacts.find((c) => c.id === sp.contact)!)}
|
||||||
|
closeHref={here}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canMerge && sp.merge && (
|
||||||
|
<Modal title={t("merge.title")} sub={t("merge.sub")} closeHref={here} closeLabel={tc("close")}>
|
||||||
|
<MergeForm
|
||||||
|
action={mergeCustomerAction.bind(null, id)}
|
||||||
|
source={{ displayName: name, customerNumber: customer.customerNumber }}
|
||||||
|
candidates={await findDuplicateCustomers(ctx, customer, { excludeId: id })}
|
||||||
|
closeHref={here}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canWrite && sp.delete && (
|
||||||
|
<Modal title={t("detail.deleteTitle")} sub={name} closeHref={here} closeLabel={tc("close")}>
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
|
<p className="text-[13px]">{t("detail.deleteHint")}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ActionButtonForm action={deleteCustomerAction.bind(null, id)} label={t("detail.deleteConfirm")} tone="danger" namespace="customers" />
|
||||||
|
<Link href={here} className={buttonLinkClass}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
<span hidden>{ts("title")}</span>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function SitesTab({ customerId, ctx, canCreate }: { customerId: string; ctx: Awaited<ReturnType<typeof requirePageContext>>; canCreate: boolean }) {
|
||||||
|
const [t, ts] = await Promise.all([getTranslations("customers"), getTranslations("sites")]);
|
||||||
|
if (!can(ctx, "site:read")) return <p className="text-[13px] text-muted-foreground">{t("errors.forbidden")}</p>;
|
||||||
|
const sites = await listSites(ctx, { customerId, status: "all", pageSize: 100 });
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{canCreate && (
|
||||||
|
<Link href={`/sites?new=1&customerId=${customerId}`} className={primaryButtonClass}>
|
||||||
|
<Plus className="size-4" aria-hidden /> {t("sites.new")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{sites.items.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-muted-foreground">{t("sites.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{ts("columns.name")}</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">{ts("columns.address")}</TableHead>
|
||||||
|
<TableHead>{ts("columns.status")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{sites.items.map((s) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell className="p-0">
|
||||||
|
<Link href={`/sites/${s.id}`} className="block px-3 py-3 font-semibold">{s.name}</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden text-muted-foreground sm:table-cell">{formatAddress(s) || "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<SiteStatusPill status={s.status} label={ts(`status.${s.status}`)} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function OrdersTab({ customerId, ctx }: { customerId: string; ctx: Awaited<ReturnType<typeof requirePageContext>> }) {
|
||||||
|
const [t, ts, format] = await Promise.all([getTranslations("customers"), getTranslations("sites"), getFormatter()]);
|
||||||
|
const orders = await listCustomerWorkOrders(ctx, customerId);
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-[12.5px] text-muted-foreground">{t("orders.hint")}</p>
|
||||||
|
{orders.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-muted-foreground">{t("orders.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("orders.columns.number")}</TableHead>
|
||||||
|
<TableHead>{t("orders.columns.title")}</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">{t("orders.columns.site")}</TableHead>
|
||||||
|
<TableHead className="hidden lg:table-cell">{t("orders.columns.team")}</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">{t("orders.columns.date")}</TableHead>
|
||||||
|
<TableHead>{t("orders.columns.status")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{orders.map((o) => (
|
||||||
|
<TableRow key={o.id}>
|
||||||
|
<TableCell className="p-0">
|
||||||
|
<Link href={`/work-orders/${o.id}`} className="block px-3 py-3 font-mono text-[12.5px] font-semibold">{o.number}</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-0">
|
||||||
|
<Link href={`/work-orders/${o.id}`} className="block px-3 py-3">{o.title}</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">{o.site?.name ?? "—"}</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">{o.team?.name ?? "—"}</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell">{o.plannedStart ? format.dateTime(o.plannedStart, { dateStyle: "medium" }) : "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<OrderStatusPill status={o.status} label={ts(`statusGroup.${orderStatusGroup(o.status)}`)} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
type SearchParams = Promise<{ q?: string; status?: string; page?: string; new?: string }>;
|
||||||
return <ModulePlaceholder moduleKey="customers" />;
|
|
||||||
|
/** 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 (
|
||||||
|
<main className="flex-1 p-4 sm:p-6">
|
||||||
|
<PageHead
|
||||||
|
crumb={t("crumb")}
|
||||||
|
title={t("title")}
|
||||||
|
sub={t("sub")}
|
||||||
|
actions={
|
||||||
|
canWrite ? (
|
||||||
|
<Link href={hrefWith("/customers", { q, status: status === "all" ? undefined : status, page: page > 1 ? page : undefined, new: 1 })} className={primaryButtonClass}>
|
||||||
|
<Plus className="size-4" aria-hidden /> {t("new")}
|
||||||
|
</Link>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form method="get" action="/customers" className="mb-4 flex flex-wrap items-end gap-2" role="search">
|
||||||
|
<label className="flex w-full min-w-0 flex-col gap-1 text-[12.5px] font-semibold sm:w-auto sm:min-w-[14rem] sm:flex-1">
|
||||||
|
{t("searchLabel")}
|
||||||
|
<input name="q" type="search" defaultValue={q ?? ""} placeholder={t("searchPlaceholder")} className={controlClass} />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||||
|
{t("filter.status")}
|
||||||
|
<select name="status" defaultValue={status} className={controlClass}>
|
||||||
|
<option value="all">{t("filter.all")}</option>
|
||||||
|
{CUSTOMER_LIST_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t(`status.${s}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className={buttonLinkClass}>
|
||||||
|
{t("filter.apply")}
|
||||||
|
</button>
|
||||||
|
{(q || status !== "all") && (
|
||||||
|
<Link href="/customers" className={buttonLinkClass}>
|
||||||
|
{t("filter.reset")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("columns.number")}</TableHead>
|
||||||
|
<TableHead>{t("columns.name")}</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">{t("columns.city")}</TableHead>
|
||||||
|
<TableHead className="hidden lg:table-cell">{t("columns.contact")}</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">{t("columns.sites")}</TableHead>
|
||||||
|
<TableHead>{t("columns.status")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{result.items.map((c) => {
|
||||||
|
const href = `/customers/${c.id}`;
|
||||||
|
return (
|
||||||
|
<TableRow key={c.id}>
|
||||||
|
<TableCell className="p-0">
|
||||||
|
<Link href={href} className="block px-3 py-3 font-mono text-[12.5px]">
|
||||||
|
{c.customerNumber ?? "—"}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="p-0">
|
||||||
|
<Link href={href} className="block px-3 py-3 font-semibold">
|
||||||
|
{customerDisplayName(c) || "—"}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden p-0 md:table-cell">
|
||||||
|
<Link href={href} className="block px-3 py-3 text-muted-foreground">
|
||||||
|
{[c.postalCode, c.city].filter(Boolean).join(" ") || "—"}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden p-0 text-[12.5px] lg:table-cell">
|
||||||
|
<Link href={href} className="block px-3 py-3 text-muted-foreground">
|
||||||
|
{c.phone || c.email || "—"}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell">{c._count.sites}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<CustomerStatusPill status={c.status} label={t(`status.${c.status}`)} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{result.items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={6} className="py-6 text-center text-muted-foreground">
|
||||||
|
{t("empty")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={result.page}
|
||||||
|
pageSize={result.pageSize}
|
||||||
|
total={result.total}
|
||||||
|
hrefFor={listHref}
|
||||||
|
labels={{ prev: t("pagination.prev"), next: t("pagination.next"), summary: t("pagination.summary", paginationSummary(result.page, result.pageSize, result.total)) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{canWrite && sp.new && (
|
||||||
|
<Modal title={t("form.createTitle")} sub={t("form.createSub")} closeHref={closeHref} closeLabel={tc("close")}>
|
||||||
|
<CustomerForm mode="create" action={createCustomerAction} closeHref={closeHref} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
type SearchParams = Promise<{
|
||||||
return <ModulePlaceholder moduleKey="documents" />;
|
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 (
|
||||||
|
<main className="flex-1 p-4 sm:p-6">
|
||||||
|
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||||
|
|
||||||
|
<form method="get" action="/documents" className="mb-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1fr_auto]" role="search">
|
||||||
|
{siteId && <input type="hidden" name="siteId" value={siteId} />}
|
||||||
|
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||||
|
{t("filter.q")}
|
||||||
|
<input name="q" type="search" defaultValue={q ?? ""} className={controlClass} />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||||
|
{t("filter.category")}
|
||||||
|
<select name="category" defaultValue={category ?? ""} className={controlClass}>
|
||||||
|
<option value="">{t("filter.allCategories")}</option>
|
||||||
|
{DOCUMENT_CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{t(`category.${c}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||||
|
{t("filter.customer")}
|
||||||
|
<select name="customerId" defaultValue={customerId ?? ""} className={controlClass}>
|
||||||
|
<option value="">{t("filter.allCustomers")}</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{customerDisplayName(c)} · {c.customerNumber}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||||
|
{t("filter.workOrder")}
|
||||||
|
<input name="workOrder" defaultValue={workOrderNumber ?? ""} className={controlClass} />
|
||||||
|
</label>
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="flex min-h-11 items-center gap-2 text-[12.5px] font-semibold">
|
||||||
|
<input type="checkbox" name="all" value="1" defaultChecked={!latestOnly} className="size-4" />
|
||||||
|
{t("versions.older", { count: 2 }).replace(/^\d+\s*/, "")}
|
||||||
|
</label>
|
||||||
|
<button type="submit" className={buttonLinkClass}>
|
||||||
|
{t("filter.apply")}
|
||||||
|
</button>
|
||||||
|
{Object.values(filters).some(Boolean) && (
|
||||||
|
<Link href="/documents" className={buttonLinkClass}>
|
||||||
|
{t("filter.reset")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<DocumentPanel ctx={ctx} rows={result.items} baseHref={listHref(page)} links={{}} searchParams={sp} showLinks />
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={result.page}
|
||||||
|
pageSize={result.pageSize}
|
||||||
|
total={result.total}
|
||||||
|
hrefFor={listHref}
|
||||||
|
labels={{ prev: t("pagination.prev"), next: t("pagination.next"), summary: t("pagination.summary", paginationSummary(result.page, result.pageSize, result.total)) }}
|
||||||
|
/>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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=<reason>`;
|
||||||
|
* 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<string, string>): 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}` : ""}` } });
|
||||||
|
}
|
||||||
@@ -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 (`<tenantId>/…`). 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 });
|
|
||||||
}
|
|
||||||
@@ -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 <img> 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" } });
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<main className="flex-1 p-4 sm:p-6">
|
||||||
|
<Link href="/sites" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
|
||||||
|
</Link>
|
||||||
|
<PageHead
|
||||||
|
crumb={`${t("title")} · ${customerDisplayName(site.customer)}`}
|
||||||
|
title={site.name}
|
||||||
|
sub={formatAddress(site, { withCountry: true }) || undefined}
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<SiteStatusPill status={site.status} label={t(`status.${site.status}`)} />
|
||||||
|
{mapUrl ? (
|
||||||
|
<a href={mapUrl} target="_blank" rel="noopener noreferrer" className={buttonLinkClass}>
|
||||||
|
<MapPin className="size-4" aria-hidden /> {t("detail.map")}
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-[12px] text-muted-foreground">{t("detail.noMap")}</span>
|
||||||
|
)}
|
||||||
|
{canWrite && (
|
||||||
|
<Link href={`${here}${here.includes("?") ? "&" : "?"}edit=1`} className={buttonLinkClass}>
|
||||||
|
{t("detail.edit")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{canWrite && (
|
||||||
|
<Link href={`${here}${here.includes("?") ? "&" : "?"}delete=1`} className={buttonLinkClass}>
|
||||||
|
{t("detail.delete")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TabNav label={t("title")} active={tab} tabs={TABS.map((k) => ({ key: k, label: t(`tabs.${k}`), href: tabHref(k) }))} />
|
||||||
|
|
||||||
|
{tab === "master" && (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
|
||||||
|
<Card>
|
||||||
|
<DefinitionList
|
||||||
|
items={[
|
||||||
|
{
|
||||||
|
label: t("detail.customer"),
|
||||||
|
value: can(ctx, "customer:read") ? (
|
||||||
|
<Link href={`/customers/${site.customer.id}`} className="font-semibold hover:underline">
|
||||||
|
{customerDisplayName(site.customer)} · {site.customer.customerNumber}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<section aria-label={t("sections.notes")} className="space-y-3">
|
||||||
|
{notes.length === 0 && <p className="text-[13px] text-muted-foreground">{t("detail.noNotes")}</p>}
|
||||||
|
{notes.map((n) => (
|
||||||
|
<div
|
||||||
|
key={n.key}
|
||||||
|
className={
|
||||||
|
n.tone === "warn"
|
||||||
|
? "shadow-card rounded-xl border border-l-4 border-l-[var(--warn)] bg-card p-4"
|
||||||
|
: "shadow-card rounded-xl border bg-card p-4"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className={`flex items-center gap-2 text-[12.5px] font-semibold ${n.tone === "warn" ? "text-[var(--warn)]" : "text-muted-foreground"}`}>
|
||||||
|
<n.icon className="size-4" aria-hidden /> {t(`fields.${n.key}`)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm whitespace-pre-line">{n.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "documents" && (
|
||||||
|
<DocumentPanel
|
||||||
|
ctx={ctx}
|
||||||
|
rows={(await listDocuments(ctx, { siteId: id, pageSize: 500 })).items}
|
||||||
|
baseHref={here}
|
||||||
|
links={{ siteId: id, customerId: site.customer.id }}
|
||||||
|
searchParams={sp}
|
||||||
|
defaultCategory="technical_drawing"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{tab === "history" && (
|
||||||
|
<HistoryTab siteId={id} ctx={ctx} onlyApproved={!fullHistoryAccess || sp.approved === "1"} canToggle={fullHistoryAccess} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canWrite && sp.edit && (
|
||||||
|
<Modal title={t("form.editTitle")} sub={site.name} closeHref={here} closeLabel={tc("close")}>
|
||||||
|
<SiteForm
|
||||||
|
mode="edit"
|
||||||
|
action={updateSiteAction.bind(null, id)}
|
||||||
|
customers={await editCustomerOptions(ctx, site.customer)}
|
||||||
|
contacts={await siteContacts(ctx, site.customer.id)}
|
||||||
|
initial={Object.fromEntries(Object.entries(site).map(([k, v]) => [k, v === null || typeof v === "object" ? "" : String(v)]))}
|
||||||
|
closeHref={here}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canWrite && sp.delete && (
|
||||||
|
<Modal title={t("detail.deleteTitle")} sub={site.name} closeHref={here} closeLabel={tc("close")}>
|
||||||
|
<div className="space-y-4 p-5">
|
||||||
|
<p className="text-[13px]">{t("detail.deleteHint")}</p>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<ActionButtonForm action={deleteSiteAction.bind(null, id)} label={t("detail.deleteConfirm")} tone="danger" namespace="sites" />
|
||||||
|
<Link href={here} className={buttonLinkClass}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editCustomerOptions(ctx: Awaited<ReturnType<typeof requirePageContext>>, 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<ReturnType<typeof requirePageContext>>, 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<ReturnType<typeof requirePageContext>>; 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 (
|
||||||
|
<section aria-label={t("history.title")} className="space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 className="font-heading text-[15px] font-semibold">{t("history.title")}</h2>
|
||||||
|
{canToggle && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link href={base} aria-current={!onlyApproved ? "page" : undefined} className={`${buttonLinkClass} ${!onlyApproved ? "border-[var(--ui-accent)]" : ""}`}>
|
||||||
|
{t("history.showAll")}
|
||||||
|
</Link>
|
||||||
|
<Link href={`${base}&approved=1`} aria-current={onlyApproved ? "page" : undefined} className={`${buttonLinkClass} ${onlyApproved ? "border-[var(--ui-accent)]" : ""}`}>
|
||||||
|
{t("history.onlyApproved")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{history.onlyApproved && <Banner tone="info">{t("history.approvedOnlyHint")}</Banner>}
|
||||||
|
<SiteHistory entries={history.items} />
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
type SearchParams = Promise<{ q?: string; status?: string; customerId?: string; page?: string; new?: string }>;
|
||||||
return <ModulePlaceholder moduleKey="sites" />;
|
|
||||||
|
/** 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 (
|
||||||
|
<main className="flex-1 p-4 sm:p-6">
|
||||||
|
<PageHead
|
||||||
|
crumb={t("crumb")}
|
||||||
|
title={t("title")}
|
||||||
|
sub={t("sub")}
|
||||||
|
actions={
|
||||||
|
canWrite ? (
|
||||||
|
<Link href={hrefWith("/sites", { q, status: status === "all" ? undefined : status, customerId: filterCustomer, new: 1 })} className={primaryButtonClass}>
|
||||||
|
<Plus className="size-4" aria-hidden /> {t("new")}
|
||||||
|
</Link>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<form method="get" action="/sites" className="mb-4 flex flex-wrap items-end gap-2" role="search">
|
||||||
|
{filterCustomer && <input type="hidden" name="customerId" value={filterCustomer} />}
|
||||||
|
<label className="flex w-full min-w-0 flex-col gap-1 text-[12.5px] font-semibold sm:w-auto sm:min-w-[14rem] sm:flex-1">
|
||||||
|
{t("searchLabel")}
|
||||||
|
<input name="q" type="search" defaultValue={q ?? ""} placeholder={t("searchPlaceholder")} className={controlClass} />
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||||
|
{t("filter.status")}
|
||||||
|
<select name="status" defaultValue={status} className={controlClass}>
|
||||||
|
<option value="all">{t("filter.all")}</option>
|
||||||
|
{SITE_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t(`status.${s}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button type="submit" className={buttonLinkClass}>
|
||||||
|
{t("filter.apply")}
|
||||||
|
</button>
|
||||||
|
{(q || status !== "all" || filterCustomer) && (
|
||||||
|
<Link href="/sites" className={buttonLinkClass}>
|
||||||
|
{t("filter.reset")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("columns.name")}</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">{t("columns.customer")}</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">{t("columns.address")}</TableHead>
|
||||||
|
<TableHead className="hidden lg:table-cell">{t("columns.orders")}</TableHead>
|
||||||
|
<TableHead>{t("columns.status")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{result.items.map((s) => (
|
||||||
|
<TableRow key={s.id}>
|
||||||
|
<TableCell className="p-0">
|
||||||
|
<Link href={`/sites/${s.id}`} className="block px-3 py-3 font-semibold">
|
||||||
|
{s.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden p-0 sm:table-cell">
|
||||||
|
<Link href={`/sites/${s.id}`} className="block px-3 py-3 text-muted-foreground">
|
||||||
|
{customerDisplayName(s.customer)}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden text-muted-foreground md:table-cell">{formatAddress(s) || "—"}</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">{s._count.workOrders}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<SiteStatusPill status={s.status} label={t(`status.${s.status}`)} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
{result.items.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="py-6 text-center text-muted-foreground">
|
||||||
|
{t("empty")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Pagination
|
||||||
|
page={result.page}
|
||||||
|
pageSize={result.pageSize}
|
||||||
|
total={result.total}
|
||||||
|
hrefFor={listHref}
|
||||||
|
labels={{ prev: t("pagination.prev"), next: t("pagination.next"), summary: t("pagination.summary", paginationSummary(result.page, result.pageSize, result.total)) }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{canWrite && sp.new && (
|
||||||
|
<Modal title={t("form.createTitle")} sub={t("form.createSub")} closeHref={closeHref} closeLabel={tc("close")}>
|
||||||
|
<SiteForm mode="create" action={createSiteAction} customers={formCustomers} contacts={contacts} initial={{ customerId: customerId ?? "" }} closeHref={closeHref} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
type SearchParams = Promise<{ new?: string; edit?: string; inactive?: string }>;
|
||||||
return <ModulePlaceholder moduleKey="teams" />;
|
|
||||||
|
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 (
|
||||||
|
<main className="flex-1 p-4 sm:p-6">
|
||||||
|
<PageHead
|
||||||
|
crumb={t("crumb")}
|
||||||
|
title={t("title")}
|
||||||
|
sub={t("sub")}
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link href={includeInactive ? "/teams" : "/teams?inactive=1"} className={buttonLinkClass}>
|
||||||
|
{includeInactive ? t("hideInactive") : t("showInactive")}
|
||||||
|
</Link>
|
||||||
|
{canManage && (
|
||||||
|
<Link href={withParam("new", "1")} className={primaryButtonClass}>
|
||||||
|
<Plus className="size-4" aria-hidden /> {t("new")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{!canManage && <p className="mb-3 text-[12.5px] text-muted-foreground">{t("readOnly")}</p>}
|
||||||
|
|
||||||
|
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("columns.name")}</TableHead>
|
||||||
|
<TableHead className="hidden sm:table-cell">{t("columns.leader")}</TableHead>
|
||||||
|
<TableHead>{t("columns.members")}</TableHead>
|
||||||
|
<TableHead className="hidden md:table-cell">{t("columns.phone")}</TableHead>
|
||||||
|
<TableHead className="hidden lg:table-cell">{t("columns.vehicle")}</TableHead>
|
||||||
|
<TableHead className="hidden lg:table-cell">{t("columns.area")}</TableHead>
|
||||||
|
<TableHead>{t("columns.status")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{teams.map((team) => {
|
||||||
|
const current = team.members.filter(isCurrent);
|
||||||
|
return (
|
||||||
|
<TableRow key={team.id}>
|
||||||
|
<TableCell className="p-0">
|
||||||
|
{canManage ? (
|
||||||
|
<Link href={withParam("edit", team.id)} className="block px-3 py-3 font-semibold">
|
||||||
|
{team.name}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="block px-3 py-3 font-semibold">{team.name}</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden sm:table-cell">{team.leader?.name ?? "—"}</TableCell>
|
||||||
|
<TableCell className="whitespace-normal">
|
||||||
|
<span className="text-[12.5px] font-semibold">{t("members.count", { count: current.length })}</span>
|
||||||
|
{team.members.length > 0 && (
|
||||||
|
<ul className="mt-0.5 text-[12px] text-muted-foreground">
|
||||||
|
{team.members.map((m) => (
|
||||||
|
<li key={m.id}>
|
||||||
|
{m.user.name} ·{" "}
|
||||||
|
{isCurrent(m)
|
||||||
|
? t("members.current")
|
||||||
|
: m.validFrom > now
|
||||||
|
? t("members.upcoming", { date: format.dateTime(m.validFrom, { dateStyle: "medium" }) })
|
||||||
|
: t("members.ended")}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="hidden md:table-cell">{team.phone ?? "—"}</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">{team.vehicle ?? "—"}</TableCell>
|
||||||
|
<TableCell className="hidden lg:table-cell">{team.area ?? "—"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<TeamStatusPill status={team.status} label={t(`status.${team.status}`)} />
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{teams.length === 0 && (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={7} className="py-6 text-center text-muted-foreground">
|
||||||
|
{t("empty")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canManage && sp.new && (
|
||||||
|
<Modal title={t("form.createTitle")} sub={t("form.sub")} closeHref={listHref} closeLabel={tc("close")}>
|
||||||
|
<TeamForm mode="create" action={saveTeamAction.bind(null, null)} initial={initialFor()} users={users} closeHref={listHref} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canManage && editTeam && (
|
||||||
|
<Modal
|
||||||
|
title={t("form.editTitle")}
|
||||||
|
sub={editTeam.name}
|
||||||
|
closeHref={listHref}
|
||||||
|
closeLabel={tc("close")}
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="text-[12px] text-muted-foreground">{t("form.deleteHint")}</p>
|
||||||
|
<ActionButtonForm
|
||||||
|
action={deleteTeamAction.bind(null, editTeam.id)}
|
||||||
|
label={t("form.delete")}
|
||||||
|
confirmText={t("form.deleteConfirm")}
|
||||||
|
namespace="teams"
|
||||||
|
tone="danger"
|
||||||
|
successHref={listHref}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TeamForm mode="edit" action={saveTeamAction.bind(null, editTeam.id)} initial={initialFor(editTeam)} users={users} closeHref={listHref} />
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ?? {}) });
|
||||||
|
});
|
||||||
@@ -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<string, unknown> | null;
|
||||||
|
const customer = await createCustomer(ctx, (body ?? {}) as CustomerCreateInput, {
|
||||||
|
acknowledgeDuplicates: body?.acknowledgeDuplicates === true,
|
||||||
|
});
|
||||||
|
return json({ data: customer }, { status: 201 });
|
||||||
|
});
|
||||||
@@ -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 },
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -59,6 +59,7 @@ const ENTITY_LABEL: Record<string, string> = {
|
|||||||
platformAdmin: "Plattform-Admin",
|
platformAdmin: "Plattform-Admin",
|
||||||
// Craftvia-Fachobjekte (Labels vorab, Module folgen)
|
// Craftvia-Fachobjekte (Labels vorab, Module folgen)
|
||||||
customer: "Kunde",
|
customer: "Kunde",
|
||||||
|
contact: "Ansprechpartner",
|
||||||
site: "Objekt",
|
site: "Objekt",
|
||||||
team: "Team",
|
team: "Team",
|
||||||
work_order: "Auftrag",
|
work_order: "Auftrag",
|
||||||
|
|||||||
@@ -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<ActionState>;
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<p role="alert" className="rounded-lg border-l-4 border-[var(--risk)] bg-card px-3 py-2 text-[13px] font-semibold text-[var(--risk)]">
|
||||||
|
{text}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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 (
|
||||||
|
<form
|
||||||
|
action={formAction}
|
||||||
|
className={cn("flex flex-col gap-1", className)}
|
||||||
|
onSubmit={(e) => {
|
||||||
|
if (confirmText && !window.confirm(confirmText)) e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={pending}
|
||||||
|
className={cn(
|
||||||
|
tone === "primary" ? primaryButtonClass : buttonLinkClass,
|
||||||
|
tone === "danger" && "border-[var(--risk)] text-[var(--risk)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{pending && pendingLabel ? pendingLabel : label}
|
||||||
|
</button>
|
||||||
|
{errorText && (
|
||||||
|
<span role="alert" className="text-[12px] font-semibold text-[var(--risk)]">
|
||||||
|
{errorText}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string | null | undefined>;
|
||||||
|
|
||||||
|
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<Values>(initial);
|
||||||
|
const [state, formAction, pending] = useActionState<ActionState, FormData>(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) => (
|
||||||
|
<Field id={`ct-${name}`} label={t(`contacts.fields.${name}`)} required={required} error={fieldError(state, name)}>
|
||||||
|
<input
|
||||||
|
id={`ct-${name}`}
|
||||||
|
name={name}
|
||||||
|
type={type}
|
||||||
|
required={required}
|
||||||
|
defaultValue={values[name] ?? ""}
|
||||||
|
aria-invalid={fieldError(state, name) ? true : undefined}
|
||||||
|
className={controlClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="grid gap-3 p-5 sm:grid-cols-2">
|
||||||
|
{input("name", "text", true)}
|
||||||
|
{input("role")}
|
||||||
|
{input("phone", "tel")}
|
||||||
|
{input("mobile", "tel")}
|
||||||
|
{input("email", "email")}
|
||||||
|
<Field id="ct-preferredChannel" label={t("contacts.fields.preferredChannel")} error={fieldError(state, "preferredChannel")}>
|
||||||
|
<select id="ct-preferredChannel" name="preferredChannel" defaultValue={values.preferredChannel ?? ""} className={controlClass}>
|
||||||
|
<option value="">{t("contacts.channel.none")}</option>
|
||||||
|
{(["phone", "mobile", "email"] as const).map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{t(`contacts.channel.${c}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field id="ct-notes" label={t("contacts.fields.notes")} className="sm:col-span-2">
|
||||||
|
<textarea id="ct-notes" name="notes" defaultValue={values.notes ?? ""} className={textareaClass} />
|
||||||
|
</Field>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<FormError namespace="customers" state={state} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 border-t pt-4 sm:col-span-2">
|
||||||
|
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||||
|
{pending ? t("form.saving") : t("form.save")}
|
||||||
|
</button>
|
||||||
|
<Link href={closeHref} className={buttonLinkClass}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
"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 { CustomerFormState } from "@/server/actions/customers/customers";
|
||||||
|
import type { ActionState } from "@/server/api/action-state";
|
||||||
|
import { Pill } from "@/components/mockup-ui";
|
||||||
|
import { buttonLinkClass, controlClass, Field, FormSection, primaryButtonClass, textareaClass } from "@/components/customers/form-ui";
|
||||||
|
import { FormError, useFieldError } from "@/components/customers/action-form";
|
||||||
|
|
||||||
|
type Values = Record<string, string | null | undefined>;
|
||||||
|
type CreateAction = (prev: CustomerFormState, fd: FormData) => Promise<CustomerFormState>;
|
||||||
|
|
||||||
|
const STATUSES = ["active", "inactive", "provisional"] as const;
|
||||||
|
|
||||||
|
export function CustomerForm({
|
||||||
|
mode,
|
||||||
|
action,
|
||||||
|
initial = {},
|
||||||
|
closeHref,
|
||||||
|
}: {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
action: CreateAction | ((prev: ActionState, fd: FormData) => Promise<ActionState>);
|
||||||
|
initial?: Values;
|
||||||
|
closeHref: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("customers");
|
||||||
|
const router = useRouter();
|
||||||
|
const fieldError = useFieldError("customers");
|
||||||
|
const [values, setValues] = useState<Values>(initial);
|
||||||
|
const [state, formAction, pending] = useActionState<CustomerFormState, FormData>(async (prev, fd) => {
|
||||||
|
setValues(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)])));
|
||||||
|
return (action as CreateAction)(prev, fd);
|
||||||
|
}, { status: "idle" });
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status === "ok") router.push(closeHref);
|
||||||
|
}, [state, closeHref, router]);
|
||||||
|
|
||||||
|
const plain: ActionState = state.status === "duplicates" ? { status: "idle" } : state;
|
||||||
|
const err = (name: string) => fieldError(plain, name);
|
||||||
|
const text = (name: string, opts: { required?: boolean; type?: string; autoComplete?: string; className?: string; hint?: string } = {}) => (
|
||||||
|
<Field id={`c-${name}`} label={t(`fields.${name}`)} required={opts.required} error={err(name)} hint={opts.hint} className={opts.className}>
|
||||||
|
<input
|
||||||
|
id={`c-${name}`}
|
||||||
|
name={name}
|
||||||
|
type={opts.type ?? "text"}
|
||||||
|
autoComplete={opts.autoComplete ?? "off"}
|
||||||
|
defaultValue={values[name] ?? ""}
|
||||||
|
aria-invalid={err(name) ? true : undefined}
|
||||||
|
aria-describedby={err(name) ? `c-${name}-error` : undefined}
|
||||||
|
className={controlClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-5 p-5">
|
||||||
|
{state.status === "duplicates" && (
|
||||||
|
<div role="alert" className="rounded-xl border-l-4 border-[var(--warn)] bg-[var(--surface-soft)] p-4">
|
||||||
|
<p className="font-heading text-sm font-semibold text-[var(--warn)]">{t("duplicates.title")}</p>
|
||||||
|
<p className="mt-1 text-[13px] text-muted-foreground">{t("duplicates.hint")}</p>
|
||||||
|
<ul className="mt-3 space-y-2">
|
||||||
|
{state.candidates.map((c) => (
|
||||||
|
<li key={c.customerId} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border bg-card p-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{c.displayName} <span className="text-muted-foreground">· {c.customerNumber}</span>
|
||||||
|
{c.city && <span className="text-muted-foreground"> · {c.city}</span>}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 flex flex-wrap gap-1.5 text-[12px]">
|
||||||
|
<Pill tone="warn">{t("duplicates.score", { percent: Math.round(c.score * 100) })}</Pill>
|
||||||
|
{c.reasons.map((r) => (
|
||||||
|
<Pill key={r} tone="mut">{t(`duplicates.reasons.${r}`)}</Pill>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href={`/customers/${c.customerId}`} className={buttonLinkClass}>
|
||||||
|
{t("duplicates.open")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<input type="hidden" name="acknowledgeDuplicates" value="1" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FormSection title={t("sections.customer")}>
|
||||||
|
{text("customerNumber", { hint: mode === "create" ? t("fields.customerNumberHint") : undefined })}
|
||||||
|
<Field id="c-status" label={t("fields.status")} error={err("status")}>
|
||||||
|
<select id="c-status" name="status" defaultValue={values.status ?? "active"} className={controlClass}>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t(`status.${s}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{text("companyName", { className: "sm:col-span-2", autoComplete: "organization" })}
|
||||||
|
{text("salutation", { autoComplete: "honorific-prefix" })}
|
||||||
|
<div className="hidden sm:block" />
|
||||||
|
{text("firstName", { autoComplete: "given-name" })}
|
||||||
|
{text("lastName", { autoComplete: "family-name" })}
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title={t("sections.address")}>
|
||||||
|
{text("street", { autoComplete: "address-line1" })}
|
||||||
|
{text("houseNumber")}
|
||||||
|
{text("postalCode", { autoComplete: "postal-code" })}
|
||||||
|
{text("city", { autoComplete: "address-level2" })}
|
||||||
|
{text("country", { hint: "DE, AT, CH …" })}
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title={t("sections.contact")}>
|
||||||
|
{text("phone", { type: "tel", autoComplete: "tel" })}
|
||||||
|
{text("mobile", { type: "tel" })}
|
||||||
|
{text("email", { type: "email", autoComplete: "email", className: "sm:col-span-2" })}
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title={t("sections.notes")}>
|
||||||
|
{(["notes", "billingNotes"] as const).map((name) => (
|
||||||
|
<Field key={name} id={`c-${name}`} label={t(`fields.${name}`)} error={err(name)} className="sm:col-span-2">
|
||||||
|
<textarea id={`c-${name}`} name={name} defaultValue={values[name] ?? ""} className={textareaClass} />
|
||||||
|
</Field>
|
||||||
|
))}
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormError namespace="customers" state={plain} />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 border-t pt-4">
|
||||||
|
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||||
|
{pending ? t("form.saving") : state.status === "duplicates" ? t("duplicates.createAnyway") : mode === "create" ? t("form.create") : t("form.save")}
|
||||||
|
</button>
|
||||||
|
<Link href={closeHref} className={buttonLinkClass}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared, server-safe building blocks of the master-data screens (customers, sites, teams,
|
||||||
|
* documents). Controls are 44 px high (Brandbook §12.2 touch targets); colors only via tokens.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const controlClass =
|
||||||
|
"h-11 w-full min-w-0 rounded-lg border border-input bg-card px-3 text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 disabled:opacity-60";
|
||||||
|
|
||||||
|
export const textareaClass =
|
||||||
|
"min-h-24 w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive";
|
||||||
|
|
||||||
|
export const buttonLinkClass =
|
||||||
|
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg border border-border bg-background px-4 font-heading text-sm font-semibold transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 outline-none";
|
||||||
|
|
||||||
|
export const primaryButtonClass =
|
||||||
|
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg bg-[var(--ui-accent)] px-4 font-heading text-sm font-semibold text-[var(--ui-accent-foreground)] transition-opacity hover:opacity-90 focus-visible:ring-3 focus-visible:ring-ring/50 outline-none disabled:opacity-60";
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
error,
|
||||||
|
hint,
|
||||||
|
required,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
error?: string;
|
||||||
|
hint?: string;
|
||||||
|
required?: boolean;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("flex flex-col gap-1", className)}>
|
||||||
|
<label htmlFor={id} className="text-[12.5px] font-semibold text-foreground">
|
||||||
|
{label}
|
||||||
|
{required && <span aria-hidden className="text-[var(--risk)]"> *</span>}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{hint && !error && <p className="text-[12px] text-muted-foreground">{hint}</p>}
|
||||||
|
{error && (
|
||||||
|
<p id={`${id}-error`} role="alert" className="text-[12px] font-semibold text-[var(--risk)]">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FormSection({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<fieldset className={cn("grid gap-3 sm:grid-cols-2", className)}>
|
||||||
|
<legend className="mb-2 font-heading text-[13px] font-semibold tracking-wide text-muted-foreground uppercase">{title}</legend>
|
||||||
|
{children}
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabNav({ tabs, active, label }: { tabs: { key: string; label: string; href: string; count?: number }[]; active: string; label: string }) {
|
||||||
|
return (
|
||||||
|
<nav aria-label={label} className="mb-4 flex gap-1 overflow-x-auto border-b">
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = tab.key === active;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.key}
|
||||||
|
href={tab.href}
|
||||||
|
aria-current={isActive ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"-mb-px inline-flex min-h-11 items-center gap-1.5 border-b-2 px-3 text-[13.5px] font-semibold whitespace-nowrap transition-colors",
|
||||||
|
isActive ? "border-[var(--ui-accent)] text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{typeof tab.count === "number" && (
|
||||||
|
<span className="rounded-full bg-muted px-1.5 text-[11px] font-bold text-muted-foreground">{tab.count}</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
hrefFor,
|
||||||
|
labels,
|
||||||
|
}: {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
hrefFor: (page: number) => string;
|
||||||
|
labels: { prev: string; next: string; summary: string };
|
||||||
|
}) {
|
||||||
|
if (total <= pageSize && page === 1) return total > 0 ? <p className="mt-3 text-[12.5px] text-muted-foreground">{labels.summary}</p> : null;
|
||||||
|
const last = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
return (
|
||||||
|
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p className="text-[12.5px] text-muted-foreground">{labels.summary}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{page > 1 ? (
|
||||||
|
<Link href={hrefFor(page - 1)} className={buttonLinkClass} rel="prev">
|
||||||
|
{labels.prev}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span aria-disabled className={cn(buttonLinkClass, "pointer-events-none opacity-50")}>{labels.prev}</span>
|
||||||
|
)}
|
||||||
|
{page < last ? (
|
||||||
|
<Link href={hrefFor(page + 1)} className={buttonLinkClass} rel="next">
|
||||||
|
{labels.next}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span aria-disabled className={cn(buttonLinkClass, "pointer-events-none opacity-50")}>{labels.next}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Banner({ tone, children }: { tone: "ok" | "warn" | "risk" | "info"; children: React.ReactNode }) {
|
||||||
|
const tones = {
|
||||||
|
ok: "border-[var(--ok)] text-[var(--ok)]",
|
||||||
|
warn: "border-[var(--warn)] text-[var(--warn)]",
|
||||||
|
risk: "border-[var(--risk)] text-[var(--risk)]",
|
||||||
|
info: "border-[var(--info)] text-[var(--info)]",
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<div role={tone === "risk" ? "alert" : "status"} className={cn("mb-4 rounded-lg border-l-4 bg-card px-4 py-3 text-[13px] font-semibold", tones[tone])}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Label/value list for read views. */
|
||||||
|
export function DefinitionList({ items }: { items: { label: string; value: React.ReactNode }[] }) {
|
||||||
|
return (
|
||||||
|
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||||
|
{items.map((it) => (
|
||||||
|
<div key={it.label} className="min-w-0">
|
||||||
|
<dt className="text-[12px] font-semibold text-muted-foreground">{it.label}</dt>
|
||||||
|
<dd className="mt-0.5 text-sm break-words whitespace-pre-line">{it.value || "—"}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return <div className={cn("shadow-card rounded-xl border bg-card p-5", className)}>{children}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paginationSummary(page: number, pageSize: number, total: number) {
|
||||||
|
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
|
const to = Math.min(total, page * pageSize);
|
||||||
|
return { from, to, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build `path?…` from params, dropping empty values. */
|
||||||
|
export function hrefWith(path: string, params: Record<string, string | number | undefined | null>): string {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
|
||||||
|
const qs = sp.toString();
|
||||||
|
return qs ? `${path}?${qs}` : path;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Pill } from "@/components/mockup-ui";
|
||||||
|
import { buttonLinkClass, controlClass, Field, primaryButtonClass } from "@/components/customers/form-ui";
|
||||||
|
import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form";
|
||||||
|
|
||||||
|
export type MergeCandidateView = {
|
||||||
|
customerId: string;
|
||||||
|
displayName: string;
|
||||||
|
customerNumber: string | null;
|
||||||
|
city: string | null;
|
||||||
|
score: number;
|
||||||
|
reasons: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function MergeForm({
|
||||||
|
action,
|
||||||
|
source,
|
||||||
|
candidates,
|
||||||
|
closeHref,
|
||||||
|
}: {
|
||||||
|
action: FormAction;
|
||||||
|
source: { displayName: string; customerNumber: string | null };
|
||||||
|
candidates: MergeCandidateView[];
|
||||||
|
closeHref: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("customers");
|
||||||
|
const fieldError = useFieldError("customers");
|
||||||
|
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
|
||||||
|
const confirmError = state.status === "error" && state.fieldErrors?.confirm ? t("errors.confirm_required") : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-4 p-5">
|
||||||
|
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||||
|
<p className="text-[12px] font-semibold text-muted-foreground">{t("merge.source")}</p>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
{source.displayName} <span className="text-muted-foreground">· {source.customerNumber}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend className="mb-2 text-[12.5px] font-semibold">{t("merge.candidates")}</legend>
|
||||||
|
{candidates.length === 0 ? (
|
||||||
|
<p className="text-[13px] text-muted-foreground">{t("merge.noCandidates")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{candidates.map((c) => (
|
||||||
|
<label key={c.customerId} className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border bg-card p-3 has-[:checked]:border-[var(--ui-accent)]">
|
||||||
|
<input type="radio" name="targetId" value={c.customerId} className="mt-1 size-4" />
|
||||||
|
<span className="min-w-0">
|
||||||
|
<span className="block text-sm font-semibold">
|
||||||
|
{c.displayName} <span className="text-muted-foreground">· {c.customerNumber}</span>
|
||||||
|
{c.city && <span className="text-muted-foreground"> · {c.city}</span>}
|
||||||
|
</span>
|
||||||
|
<span className="mt-1 flex flex-wrap gap-1.5">
|
||||||
|
<Pill tone="warn">{t("duplicates.score", { percent: Math.round(c.score * 100) })}</Pill>
|
||||||
|
{c.reasons.map((r) => (
|
||||||
|
<Pill key={r} tone="mut">{t(`duplicates.reasons.${r}`)}</Pill>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{fieldError(state, "targetId") && <p role="alert" className="mt-1 text-[12px] font-semibold text-[var(--risk)]">{fieldError(state, "targetId")}</p>}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<Field id="m-targetNumber" label={t("merge.targetNumber")} error={fieldError(state, "targetNumber")}>
|
||||||
|
<input id="m-targetNumber" name="targetNumber" className={controlClass} autoComplete="off" />
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<label className="flex min-h-11 items-start gap-3 rounded-lg border border-[var(--warn)] bg-card p-3 text-[13px]">
|
||||||
|
<input type="checkbox" name="confirm" className="mt-0.5 size-4" aria-invalid={confirmError ? true : undefined} />
|
||||||
|
<span>{t("merge.confirm")}</span>
|
||||||
|
</label>
|
||||||
|
{confirmError && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)]">{confirmError}</p>}
|
||||||
|
|
||||||
|
<FormError namespace="customers" state={state} />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 border-t pt-4">
|
||||||
|
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||||
|
{pending ? t("form.saving") : t("merge.submit")}
|
||||||
|
</button>
|
||||||
|
<Link href={closeHref} className={buttonLinkClass}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
import { Pill } from "@/components/mockup-ui";
|
||||||
|
import { STATUS_GROUP, STATUS_GROUP_TONE, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
|
||||||
|
// Status pills always carry text (Brandbook §11.4: color is never the only signal).
|
||||||
|
|
||||||
|
const CUSTOMER_TONE = { active: "ok", inactive: "mut", provisional: "warn", merged: "info" } as const;
|
||||||
|
const SITE_TONE = { active: "ok", inactive: "mut", provisional: "warn" } as const;
|
||||||
|
const TEAM_TONE = { active: "ok", inactive: "mut" } as const;
|
||||||
|
|
||||||
|
export function CustomerStatusPill({ status, label }: { status: keyof typeof CUSTOMER_TONE; label: string }) {
|
||||||
|
return <Pill tone={CUSTOMER_TONE[status]}>{label}</Pill>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SiteStatusPill({ status, label }: { status: keyof typeof SITE_TONE; label: string }) {
|
||||||
|
return <Pill tone={SITE_TONE[status]}>{label}</Pill>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeamStatusPill({ status, label }: { status: keyof typeof TEAM_TONE; label: string }) {
|
||||||
|
return <Pill tone={TEAM_TONE[status]}>{label}</Pill>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_PILL = { neutral: "mut", info: "info", accent: "orange", warning: "warn", success: "ok", danger: "risk" } as const;
|
||||||
|
|
||||||
|
/** Work order status as Brandbook status group; `label` comes from messages sites.statusGroup.<group>. */
|
||||||
|
export function orderStatusGroup(status: WorkOrderStatus) {
|
||||||
|
return STATUS_GROUP[status];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OrderStatusPill({ status, label }: { status: WorkOrderStatus; label: string }) {
|
||||||
|
return <Pill tone={GROUP_PILL[STATUS_GROUP_TONE[STATUS_GROUP[status]]]}>{label}</Pill>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useActionState, useEffect } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { buttonLinkClass, controlClass, Field, primaryButtonClass } from "@/components/customers/form-ui";
|
||||||
|
import { FormError, IDLE_STATE, type FormAction } from "@/components/customers/action-form";
|
||||||
|
|
||||||
|
export function DocumentEditForm({
|
||||||
|
action,
|
||||||
|
initial,
|
||||||
|
categories,
|
||||||
|
visibilities,
|
||||||
|
closeHref,
|
||||||
|
}: {
|
||||||
|
action: FormAction;
|
||||||
|
initial: { title: string; category: string; visibility: string };
|
||||||
|
categories: string[];
|
||||||
|
visibilities: string[];
|
||||||
|
closeHref: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("documents");
|
||||||
|
const router = useRouter();
|
||||||
|
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state.status === "ok") router.push(closeHref);
|
||||||
|
}, [state, closeHref, router]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="grid gap-3 p-5 sm:grid-cols-2">
|
||||||
|
<Field id="d-title" label={t("edit.titleField")} className="sm:col-span-2">
|
||||||
|
<input id="d-title" name="title" defaultValue={initial.title} className={controlClass} />
|
||||||
|
</Field>
|
||||||
|
<Field id="d-category" label={t("upload.category")}>
|
||||||
|
<select id="d-category" name="category" defaultValue={initial.category} className={controlClass}>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{t(`category.${c}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field id="d-visibility" label={t("upload.visibility")}>
|
||||||
|
<select id="d-visibility" name="visibility" defaultValue={initial.visibility} className={controlClass}>
|
||||||
|
{visibilities.map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{t(`visibility.${v}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<FormError namespace="documents" state={state} />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 border-t pt-4 sm:col-span-2">
|
||||||
|
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||||
|
{pending ? t("edit.saving") : t("edit.save")}
|
||||||
|
</button>
|
||||||
|
<Link href={closeHref} className={buttonLinkClass}>
|
||||||
|
{t("edit.cancel")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Modal } from "@/components/modal";
|
||||||
|
import { Card } from "@/components/customers/form-ui";
|
||||||
|
import { DocumentTable, type DocumentRow } from "@/components/documents/document-table";
|
||||||
|
import { DocumentUploadForm, UploadFeedback } from "@/components/documents/document-upload-form";
|
||||||
|
import { DocumentEditForm } from "@/components/documents/document-edit-form";
|
||||||
|
import { updateDocumentAction } from "@/server/actions/documents/documents";
|
||||||
|
import { can, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { DOCUMENT_CATEGORIES } from "@/server/services/documents/store";
|
||||||
|
import { allowedDocumentVisibility } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents tab for customer/site detail pages: upload (new document or new version), grouped
|
||||||
|
* list with versions, edit popup. Search params: docOk, docError, docEdit, docVersion.
|
||||||
|
*/
|
||||||
|
export async function DocumentPanel({
|
||||||
|
ctx,
|
||||||
|
rows,
|
||||||
|
baseHref,
|
||||||
|
links,
|
||||||
|
searchParams,
|
||||||
|
categories = DOCUMENT_CATEGORIES,
|
||||||
|
defaultCategory,
|
||||||
|
showLinks = false,
|
||||||
|
}: {
|
||||||
|
ctx: ServiceCtx;
|
||||||
|
rows: DocumentRow[];
|
||||||
|
baseHref: string;
|
||||||
|
links: { customerId?: string; siteId?: string };
|
||||||
|
searchParams: { docOk?: string; docError?: string; docEdit?: string; docVersion?: string };
|
||||||
|
categories?: readonly string[];
|
||||||
|
defaultCategory?: string;
|
||||||
|
showLinks?: boolean;
|
||||||
|
}) {
|
||||||
|
const t = await getTranslations("documents");
|
||||||
|
const tc = await getTranslations("common");
|
||||||
|
const canWrite = can(ctx, "document:write");
|
||||||
|
const visibilities = allowedDocumentVisibility(ctx);
|
||||||
|
const editDoc = searchParams.docEdit ? rows.find((r) => r.id === searchParams.docEdit) : undefined;
|
||||||
|
const versionOf = searchParams.docVersion ? rows.find((r) => r.lineageId === searchParams.docVersion) : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<UploadFeedback ok={searchParams.docOk} error={searchParams.docError} />
|
||||||
|
{canWrite && (
|
||||||
|
<Card>
|
||||||
|
<DocumentUploadForm
|
||||||
|
heading={t("upload.title")}
|
||||||
|
returnTo={baseHref}
|
||||||
|
links={links}
|
||||||
|
categories={[...categories]}
|
||||||
|
visibilities={visibilities}
|
||||||
|
defaultCategory={defaultCategory}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<DocumentTable rows={rows} baseHref={baseHref} canWrite={canWrite} showLinks={showLinks} />
|
||||||
|
|
||||||
|
{canWrite && editDoc && (
|
||||||
|
<Modal title={t("edit.title")} sub={editDoc.fileName} closeHref={baseHref} closeLabel={tc("close")}>
|
||||||
|
<DocumentEditForm
|
||||||
|
action={updateDocumentAction.bind(null, editDoc.id, baseHref)}
|
||||||
|
initial={{ title: editDoc.title ?? "", category: editDoc.category, visibility: editDoc.visibility }}
|
||||||
|
categories={[...DOCUMENT_CATEGORIES]}
|
||||||
|
visibilities={visibilities}
|
||||||
|
closeHref={baseHref}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
{canWrite && versionOf && (
|
||||||
|
<Modal title={t("upload.newVersionTitle")} sub={t("upload.newVersionOf", { name: versionOf.title || versionOf.fileName })} closeHref={baseHref} closeLabel={tc("close")}>
|
||||||
|
<div className="p-5">
|
||||||
|
<DocumentUploadForm
|
||||||
|
returnTo={baseHref}
|
||||||
|
lineageId={versionOf.lineageId}
|
||||||
|
categories={[...DOCUMENT_CATEGORIES]}
|
||||||
|
visibilities={visibilities}
|
||||||
|
defaultCategory={versionOf.category}
|
||||||
|
defaultVisibility={versionOf.visibility}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { Download, FileText, Image as ImageIcon, Mic } from "lucide-react";
|
||||||
|
import { getFormatter, getTranslations } from "next-intl/server";
|
||||||
|
import { Pill } from "@/components/mockup-ui";
|
||||||
|
import { ActionButtonForm } from "@/components/customers/action-form";
|
||||||
|
import { buttonLinkClass } from "@/components/customers/form-ui";
|
||||||
|
import { deleteDocumentAction } from "@/server/actions/documents/documents";
|
||||||
|
import { documentHref } from "@/server/services/documents/access";
|
||||||
|
import { customerDisplayName } from "@/server/services/customers/format";
|
||||||
|
|
||||||
|
export type DocumentRow = {
|
||||||
|
id: string;
|
||||||
|
title: string | null;
|
||||||
|
fileName: string;
|
||||||
|
category: string;
|
||||||
|
visibility: string;
|
||||||
|
mimeType: string;
|
||||||
|
fileSize: number;
|
||||||
|
checksum: string;
|
||||||
|
version: number;
|
||||||
|
lineageId: string;
|
||||||
|
createdAt: Date;
|
||||||
|
customer: { id: string; customerNumber: string | null; companyName: string | null; firstName: string | null; lastName: string | null } | null;
|
||||||
|
site: { id: string; name: string } | null;
|
||||||
|
workOrder: { id: string; number: string; title: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VISIBILITY_TONE = { backoffice_only: "risk", team_lead: "warn", team: "info", customer_report: "ok" } as const;
|
||||||
|
|
||||||
|
function fileIcon(mime: string) {
|
||||||
|
if (mime.startsWith("image/")) return ImageIcon;
|
||||||
|
if (mime.startsWith("audio/")) return Mic;
|
||||||
|
return FileText;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document list. `groupVersions` shows the newest version per lineage and lists older versions
|
||||||
|
* underneath (spec §24.2). Edit/new-version links open popups on `baseHref` (?docEdit / ?docVersion).
|
||||||
|
*/
|
||||||
|
export async function DocumentTable({
|
||||||
|
rows,
|
||||||
|
baseHref,
|
||||||
|
canWrite,
|
||||||
|
groupVersions = true,
|
||||||
|
showLinks = true,
|
||||||
|
}: {
|
||||||
|
rows: DocumentRow[];
|
||||||
|
baseHref: string;
|
||||||
|
canWrite: boolean;
|
||||||
|
groupVersions?: boolean;
|
||||||
|
showLinks?: boolean;
|
||||||
|
}) {
|
||||||
|
const t = await getTranslations("documents");
|
||||||
|
const format = await getFormatter();
|
||||||
|
if (rows.length === 0) return <p className="text-[13px] text-muted-foreground">{t("empty")}</p>;
|
||||||
|
|
||||||
|
const sep = baseHref.includes("?") ? "&" : "?";
|
||||||
|
const groups = new Map<string, DocumentRow[]>();
|
||||||
|
for (const r of rows) {
|
||||||
|
const key = groupVersions ? r.lineageId : r.id;
|
||||||
|
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||||
|
}
|
||||||
|
const size = (bytes: number) =>
|
||||||
|
bytes >= 1024 * 1024 ? `${format.number(bytes / 1024 / 1024, { maximumFractionDigits: 1 })} MB` : `${format.number(Math.max(1, Math.round(bytes / 1024)))} KB`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ul className="divide-y rounded-xl border bg-card">
|
||||||
|
{[...groups.values()].map((versions) => {
|
||||||
|
const sorted = [...versions].sort((a, b) => b.version - a.version);
|
||||||
|
const doc = sorted[0];
|
||||||
|
const older = sorted.slice(1);
|
||||||
|
const Icon = fileIcon(doc.mimeType);
|
||||||
|
return (
|
||||||
|
<li key={doc.id} className="p-4">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 gap-3">
|
||||||
|
<Icon className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<a href={documentHref(doc.id)} className="font-semibold break-words hover:underline">
|
||||||
|
{doc.title || doc.fileName}
|
||||||
|
</a>
|
||||||
|
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
||||||
|
{doc.title ? `${doc.fileName} · ` : ""}
|
||||||
|
{t("versions.label", { version: doc.version })} · {size(doc.fileSize)} · {format.dateTime(doc.createdAt, { dateStyle: "medium", timeStyle: "short" })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
<Pill tone="mut">{t(`category.${doc.category}`)}</Pill>
|
||||||
|
<Pill tone={VISIBILITY_TONE[doc.visibility as keyof typeof VISIBILITY_TONE] ?? "mut"}>{t(`visibility.${doc.visibility}`)}</Pill>
|
||||||
|
</p>
|
||||||
|
{showLinks && (doc.customer || doc.site || doc.workOrder) && (
|
||||||
|
<p className="mt-1.5 text-[12px] text-muted-foreground">
|
||||||
|
{doc.customer && (
|
||||||
|
<>
|
||||||
|
{t("link.customer")}:{" "}
|
||||||
|
<Link className="font-semibold hover:underline" href={`/customers/${doc.customer.id}`}>
|
||||||
|
{customerDisplayName(doc.customer)}
|
||||||
|
</Link>{" "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{doc.site && (
|
||||||
|
<>
|
||||||
|
{t("link.site")}:{" "}
|
||||||
|
<Link className="font-semibold hover:underline" href={`/sites/${doc.site.id}`}>
|
||||||
|
{doc.site.name}
|
||||||
|
</Link>{" "}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{doc.workOrder && (
|
||||||
|
<>
|
||||||
|
{t("link.workOrder")}:{" "}
|
||||||
|
<Link className="font-semibold hover:underline" href={`/work-orders/${doc.workOrder.id}`}>
|
||||||
|
{doc.workOrder.number}
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1 font-mono text-[11px] break-all text-muted-foreground" title="SHA-256">
|
||||||
|
{doc.checksum.slice(0, 16)}…
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-start gap-2">
|
||||||
|
<a href={documentHref(doc.id)} className={buttonLinkClass}>
|
||||||
|
<Download className="size-4" aria-hidden /> {t("actions.download")}
|
||||||
|
</a>
|
||||||
|
{canWrite && (
|
||||||
|
<>
|
||||||
|
<Link href={`${baseHref}${sep}docEdit=${doc.id}`} className={buttonLinkClass}>
|
||||||
|
{t("actions.edit")}
|
||||||
|
</Link>
|
||||||
|
<Link href={`${baseHref}${sep}docVersion=${doc.lineageId}`} className={buttonLinkClass}>
|
||||||
|
{t("upload.newVersion")}
|
||||||
|
</Link>
|
||||||
|
<ActionButtonForm
|
||||||
|
action={deleteDocumentAction.bind(null, doc.id, baseHref)}
|
||||||
|
label={t("actions.delete")}
|
||||||
|
confirmText={t("actions.deleteConfirm")}
|
||||||
|
namespace="documents"
|
||||||
|
tone="danger"
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{older.length > 0 && (
|
||||||
|
<details className="mt-3 ml-8">
|
||||||
|
<summary className="min-h-11 cursor-pointer text-[12.5px] font-semibold text-muted-foreground">{t("versions.older", { count: older.length })}</summary>
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{older.map((o) => (
|
||||||
|
<li key={o.id} className="flex flex-wrap items-center gap-2 text-[12.5px]">
|
||||||
|
<a href={documentHref(o.id)} className="inline-flex min-h-11 items-center font-semibold hover:underline">
|
||||||
|
{t("versions.label", { version: o.version })} · {o.fileName}
|
||||||
|
</a>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{size(o.fileSize)} · {format.dateTime(o.createdAt, { dateStyle: "medium" })}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { controlClass, Field, primaryButtonClass } from "@/components/customers/form-ui";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plain multipart form (works without JavaScript) posting to /documents/upload, which redirects
|
||||||
|
* back to `returnTo` with ?docOk=1 or ?docError=<reason> (see UploadFeedback).
|
||||||
|
*/
|
||||||
|
export async function DocumentUploadForm({
|
||||||
|
returnTo,
|
||||||
|
links = {},
|
||||||
|
lineageId,
|
||||||
|
categories,
|
||||||
|
visibilities,
|
||||||
|
defaultCategory = "other",
|
||||||
|
defaultVisibility = "team",
|
||||||
|
heading,
|
||||||
|
}: {
|
||||||
|
returnTo: string;
|
||||||
|
links?: { customerId?: string; siteId?: string; workOrderId?: string };
|
||||||
|
lineageId?: string;
|
||||||
|
categories: string[];
|
||||||
|
visibilities: string[];
|
||||||
|
defaultCategory?: string;
|
||||||
|
defaultVisibility?: string;
|
||||||
|
heading?: string;
|
||||||
|
}) {
|
||||||
|
const t = await getTranslations("documents");
|
||||||
|
const idp = lineageId ? `v-${lineageId.slice(0, 6)}` : "up";
|
||||||
|
return (
|
||||||
|
<form action="/documents/upload" method="post" encType="multipart/form-data" className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{heading && <p className="font-heading text-sm font-semibold sm:col-span-2">{heading}</p>}
|
||||||
|
<input type="hidden" name="returnTo" value={returnTo} />
|
||||||
|
{links.customerId && <input type="hidden" name="customerId" value={links.customerId} />}
|
||||||
|
{links.siteId && <input type="hidden" name="siteId" value={links.siteId} />}
|
||||||
|
{links.workOrderId && <input type="hidden" name="workOrderId" value={links.workOrderId} />}
|
||||||
|
{lineageId && <input type="hidden" name="lineageId" value={lineageId} />}
|
||||||
|
<Field id={`${idp}-file`} label={t("upload.file")} required hint={t("upload.fileHint")} className="sm:col-span-2">
|
||||||
|
<input
|
||||||
|
id={`${idp}-file`}
|
||||||
|
name="file"
|
||||||
|
type="file"
|
||||||
|
required
|
||||||
|
accept="application/pdf,image/jpeg,image/png,image/webp,image/heic,audio/*"
|
||||||
|
className={`${controlClass} py-2 file:mr-3 file:font-semibold`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field id={`${idp}-title`} label={t("upload.titleField")} className="sm:col-span-2">
|
||||||
|
<input id={`${idp}-title`} name="title" className={controlClass} />
|
||||||
|
</Field>
|
||||||
|
<Field id={`${idp}-category`} label={t("upload.category")} required>
|
||||||
|
<select id={`${idp}-category`} name="category" defaultValue={defaultCategory} className={controlClass}>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{t(`category.${c}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field id={`${idp}-visibility`} label={t("upload.visibility")} required>
|
||||||
|
<select id={`${idp}-visibility`} name="visibility" defaultValue={defaultVisibility} className={controlClass}>
|
||||||
|
{visibilities.map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{t(`visibility.${v}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<button type="submit" className={primaryButtonClass}>
|
||||||
|
{t("upload.submit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result banner of the upload redirect. */
|
||||||
|
export async function UploadFeedback({ ok, error }: { ok?: string; error?: string }) {
|
||||||
|
if (!ok && !error) return null;
|
||||||
|
const t = await getTranslations("documents");
|
||||||
|
if (ok) {
|
||||||
|
return (
|
||||||
|
<p role="status" className="mb-3 rounded-lg border-l-4 border-[var(--ok)] bg-card px-3 py-2 text-[13px] font-semibold text-[var(--ok)]">
|
||||||
|
{t("upload.ok")}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const key = t.has(`uploadErrors.${error}`) ? `uploadErrors.${error}` : "uploadErrors.generic";
|
||||||
|
return (
|
||||||
|
<p role="alert" className="mb-3 rounded-lg border-l-4 border-[var(--risk)] bg-card px-3 py-2 text-[13px] font-semibold text-[var(--risk)]">
|
||||||
|
{t(key)}
|
||||||
|
</p>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
"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, FormSection, primaryButtonClass, textareaClass } from "@/components/customers/form-ui";
|
||||||
|
import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form";
|
||||||
|
|
||||||
|
type Values = Record<string, string | null | undefined>;
|
||||||
|
type Option = { id: string; label: string };
|
||||||
|
|
||||||
|
const STATUSES = ["active", "inactive", "provisional"] as const;
|
||||||
|
|
||||||
|
export function SiteForm({
|
||||||
|
mode,
|
||||||
|
action,
|
||||||
|
initial = {},
|
||||||
|
customers,
|
||||||
|
contacts,
|
||||||
|
closeHref,
|
||||||
|
}: {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
action: FormAction;
|
||||||
|
initial?: Values;
|
||||||
|
customers: Option[];
|
||||||
|
/** Contacts of the (fixed) customer; null = customer not yet known. */
|
||||||
|
contacts: Option[] | null;
|
||||||
|
closeHref: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("sites");
|
||||||
|
const router = useRouter();
|
||||||
|
const fieldError = useFieldError("sites");
|
||||||
|
const [values, setValues] = useState<Values>(initial);
|
||||||
|
const [state, formAction, pending] = useActionState<ActionState, FormData>(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 err = (name: string) => fieldError(state, name);
|
||||||
|
const input = (name: string, opts: { required?: boolean; type?: string; className?: string; inputMode?: "decimal" } = {}) => (
|
||||||
|
<Field id={`s-${name}`} label={t(`fields.${name}`)} required={opts.required} error={err(name)} className={opts.className}>
|
||||||
|
<input
|
||||||
|
id={`s-${name}`}
|
||||||
|
name={name}
|
||||||
|
type={opts.type ?? "text"}
|
||||||
|
inputMode={opts.inputMode}
|
||||||
|
required={opts.required}
|
||||||
|
defaultValue={values[name] ?? ""}
|
||||||
|
aria-invalid={err(name) ? true : undefined}
|
||||||
|
className={controlClass}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
const area = (name: string) => (
|
||||||
|
<Field id={`s-${name}`} label={t(`fields.${name}`)} error={err(name)} className="sm:col-span-2">
|
||||||
|
<textarea id={`s-${name}`} name={name} defaultValue={values[name] ?? ""} className={textareaClass} />
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-5 p-5">
|
||||||
|
<FormSection title={t("sections.base")}>
|
||||||
|
<Field id="s-customerId" label={t("fields.customerId")} required error={err("customerId")} className="sm:col-span-2">
|
||||||
|
<select id="s-customerId" name="customerId" required defaultValue={values.customerId ?? ""} className={controlClass}>
|
||||||
|
<option value="" disabled>
|
||||||
|
{t("fields.selectCustomer")}
|
||||||
|
</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{input("name", { required: true })}
|
||||||
|
<Field id="s-status" label={t("fields.status")} error={err("status")}>
|
||||||
|
<select id="s-status" name="status" defaultValue={values.status ?? "active"} className={controlClass}>
|
||||||
|
{STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t(`status.${s}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title={t("sections.address")}>
|
||||||
|
{input("street")}
|
||||||
|
{input("houseNumber")}
|
||||||
|
{input("postalCode")}
|
||||||
|
{input("city")}
|
||||||
|
{input("country")}
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title={t("sections.onSite")}>
|
||||||
|
{contacts ? (
|
||||||
|
<Field id="s-contactId" label={t("fields.contactId")} error={err("contactId")}>
|
||||||
|
<select id="s-contactId" name="contactId" defaultValue={values.contactId ?? ""} className={controlClass}>
|
||||||
|
<option value="">{t("fields.noContact")}</option>
|
||||||
|
{contacts.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
) : (
|
||||||
|
<p className="self-end text-[12px] text-muted-foreground">{t("form.contactHint")}</p>
|
||||||
|
)}
|
||||||
|
{input("onSiteContact")}
|
||||||
|
{input("phone", { type: "tel" })}
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title={t("sections.notes")}>
|
||||||
|
{area("accessNotes")}
|
||||||
|
{area("parkingNotes")}
|
||||||
|
{area("safetyNotes")}
|
||||||
|
{area("technicalNotes")}
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormSection title={t("sections.geo")}>
|
||||||
|
{input("latitude", { inputMode: "decimal" })}
|
||||||
|
{input("longitude", { inputMode: "decimal" })}
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<FormError namespace="sites" state={state} />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 border-t pt-4">
|
||||||
|
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||||
|
{pending ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
|
||||||
|
</button>
|
||||||
|
<Link href={closeHref} className={buttonLinkClass}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { AlertTriangle, Camera, CheckCircle2, FileText, MinusCircle, Siren } from "lucide-react";
|
||||||
|
import { getFormatter, getTranslations } from "next-intl/server";
|
||||||
|
import type { SiteHistoryEntry } from "@/server/services/sites/history";
|
||||||
|
import { OrderStatusPill, orderStatusGroup } from "@/components/customers/status";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Site history list (spec §8.3): newest first; open follow-up work is highlighted with a warning
|
||||||
|
* border, icon and text (never color alone). Also reused read-only by the field lane.
|
||||||
|
*/
|
||||||
|
export async function SiteHistory({ entries, linkOrders = true }: { entries: SiteHistoryEntry[]; linkOrders?: boolean }) {
|
||||||
|
const t = await getTranslations("sites");
|
||||||
|
const format = await getFormatter();
|
||||||
|
|
||||||
|
if (entries.length === 0) return <p className="text-[13px] text-muted-foreground">{t("history.empty")}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ol className="space-y-3">
|
||||||
|
{entries.map((e) => (
|
||||||
|
<li
|
||||||
|
key={e.workOrderId}
|
||||||
|
className={
|
||||||
|
e.hasOpenFollowUp
|
||||||
|
? "shadow-card rounded-xl border border-l-4 border-l-[var(--warn)] bg-card p-4"
|
||||||
|
: "shadow-card rounded-xl border bg-card p-4"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[12px] font-semibold text-muted-foreground">
|
||||||
|
<time dateTime={e.date.toISOString()}>{format.dateTime(e.date, { dateStyle: "medium" })}</time>
|
||||||
|
{" · "}
|
||||||
|
{e.orderType ?? "—"}
|
||||||
|
{" · "}
|
||||||
|
{e.team ?? t("history.noTeam")}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 font-heading text-[15px] font-semibold">
|
||||||
|
{linkOrders ? (
|
||||||
|
<Link href={`/work-orders/${e.workOrderId}`} className="hover:underline">
|
||||||
|
{e.number} · {e.title}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{e.number} · {e.title}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
{e.isEmergency && (
|
||||||
|
<span className="inline-flex items-center gap-1 text-[12px] font-semibold text-[var(--risk)]">
|
||||||
|
<Siren className="size-3.5" aria-hidden /> {t("history.emergency")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<OrderStatusPill status={e.status} label={t(`statusGroup.${orderStatusGroup(e.status)}`)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{e.hasOpenFollowUp && (
|
||||||
|
<div className="mt-3 flex gap-2 rounded-lg bg-[var(--surface-soft)] p-3 text-[13px]">
|
||||||
|
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-[var(--warn)]" aria-hidden />
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-[var(--warn)]">{t("history.followUp")}</p>
|
||||||
|
<ul className="mt-1 list-disc space-y-0.5 pl-4">
|
||||||
|
{e.followUps.map((f, i) => (
|
||||||
|
<li key={i} className="whitespace-pre-line">{f}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 grid gap-4 md:grid-cols-3">
|
||||||
|
<section>
|
||||||
|
<h4 className="text-[12px] font-semibold text-muted-foreground">{t("history.workDone")}</h4>
|
||||||
|
{e.workDone.length ? (
|
||||||
|
<ul className="mt-1 space-y-1 text-[13px]">
|
||||||
|
{e.workDone.map((w, i) => (
|
||||||
|
<li key={i} className="whitespace-pre-line">{w}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-[13px] text-muted-foreground">{t("history.noWorkDone")}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4 className="text-[12px] font-semibold text-muted-foreground">{t("history.materials")}</h4>
|
||||||
|
{e.materials.length ? (
|
||||||
|
<ul className="mt-1 space-y-0.5 text-[13px]">
|
||||||
|
{e.materials.map((m) => (
|
||||||
|
<li key={`${m.name}|${m.unit}`}>
|
||||||
|
{format.number(m.quantity, { maximumFractionDigits: 3 })} {m.unit} · {m.name}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-[13px] text-muted-foreground">{t("history.noMaterials")}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h4 className="text-[12px] font-semibold text-muted-foreground">{t("history.reports")}</h4>
|
||||||
|
{e.approvedReports.length ? (
|
||||||
|
<ul className="mt-1 space-y-1 text-[13px]">
|
||||||
|
{e.approvedReports.map((r) => (
|
||||||
|
<li key={r.id}>
|
||||||
|
<Link href={`/reports/${r.id}`} className="inline-flex min-h-8 items-center gap-1.5 font-semibold hover:underline">
|
||||||
|
<FileText className="size-3.5" aria-hidden />
|
||||||
|
{t("history.reportLink", {
|
||||||
|
type: t(`history.reportType.${r.type}`),
|
||||||
|
version: r.version,
|
||||||
|
date: format.dateTime(r.reportDate, { dateStyle: "medium" }),
|
||||||
|
})}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-[13px] text-muted-foreground">{t("history.noReports")}</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-2 flex items-center gap-1.5 text-[13px]">
|
||||||
|
<Camera className="size-3.5 text-muted-foreground" aria-hidden /> {t("history.photos", { count: e.photoCount })}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 flex items-center gap-1.5 text-[13px]">
|
||||||
|
{e.signed ? (
|
||||||
|
<>
|
||||||
|
<CheckCircle2 className="size-3.5 text-[var(--ok)]" aria-hidden /> {t("history.signed")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<MinusCircle className="size-3.5 text-muted-foreground" aria-hidden /> {t("history.notSigned")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useActionState, useEffect, useRef, useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
|
import type { ActionState } from "@/server/api/action-state";
|
||||||
|
import { buttonLinkClass, controlClass, Field, FormSection, primaryButtonClass, textareaClass } from "@/components/customers/form-ui";
|
||||||
|
import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form";
|
||||||
|
|
||||||
|
export type TeamFormValues = {
|
||||||
|
name?: string | null;
|
||||||
|
leaderUserId?: string | null;
|
||||||
|
status?: "active" | "inactive";
|
||||||
|
phone?: string | null;
|
||||||
|
vehicle?: string | null;
|
||||||
|
area?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
members: { userId: string; validFrom: string; validTo: string }[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Row = { key: number; userId: string; validFrom: string; validTo: string };
|
||||||
|
|
||||||
|
export function TeamForm({
|
||||||
|
mode,
|
||||||
|
action,
|
||||||
|
initial,
|
||||||
|
users,
|
||||||
|
closeHref,
|
||||||
|
}: {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
action: FormAction;
|
||||||
|
initial: TeamFormValues;
|
||||||
|
users: { id: string; name: string; email: string }[];
|
||||||
|
closeHref: string;
|
||||||
|
}) {
|
||||||
|
const t = useTranslations("teams");
|
||||||
|
const router = useRouter();
|
||||||
|
const fieldError = useFieldError("teams");
|
||||||
|
const nextKey = useRef(initial.members.length);
|
||||||
|
const [rows, setRows] = useState<Row[]>(initial.members.map((m, i) => ({ key: i, ...m })));
|
||||||
|
const [values, setValues] = useState<Record<string, string>>(
|
||||||
|
Object.fromEntries(Object.entries(initial).filter(([k]) => k !== "members").map(([k, v]) => [k, String(v ?? "")])),
|
||||||
|
);
|
||||||
|
const [state, formAction, pending] = useActionState<ActionState, FormData>(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 err = (name: string) => fieldError(state, name);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const input = (name: string, type = "text", required = false) => (
|
||||||
|
<Field id={`t-${name}`} label={t(`fields.${name}`)} required={required} error={err(name)}>
|
||||||
|
<input id={`t-${name}`} name={name} type={type} required={required} defaultValue={values[name] ?? ""} aria-invalid={err(name) ? true : undefined} className={controlClass} />
|
||||||
|
</Field>
|
||||||
|
);
|
||||||
|
const updateRow = (key: number, patch: Partial<Row>) => setRows((rs) => rs.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className="space-y-5 p-5">
|
||||||
|
<FormSection title={t("form.sub")}>
|
||||||
|
{input("name", "text", true)}
|
||||||
|
<Field id="t-status" label={t("fields.status")}>
|
||||||
|
<select id="t-status" name="status" defaultValue={values.status || "active"} className={controlClass}>
|
||||||
|
<option value="active">{t("status.active")}</option>
|
||||||
|
<option value="inactive">{t("status.inactive")}</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field id="t-leaderUserId" label={t("fields.leaderUserId")} error={err("leaderUserId")}>
|
||||||
|
<select id="t-leaderUserId" name="leaderUserId" defaultValue={values.leaderUserId ?? ""} className={controlClass}>
|
||||||
|
<option value="">{t("fields.noLeader")}</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name} ({u.email})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{input("phone", "tel")}
|
||||||
|
{input("vehicle")}
|
||||||
|
{input("area")}
|
||||||
|
<Field id="t-notes" label={t("fields.notes")} className="sm:col-span-2">
|
||||||
|
<textarea id="t-notes" name="notes" defaultValue={values.notes ?? ""} className={textareaClass} />
|
||||||
|
</Field>
|
||||||
|
</FormSection>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend className="mb-2 font-heading text-[13px] font-semibold tracking-wide text-muted-foreground uppercase">{t("members.title")}</legend>
|
||||||
|
{rows.length === 0 && <p className="mb-2 text-[13px] text-muted-foreground">{t("members.empty")}</p>}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{rows.map((row, idx) => (
|
||||||
|
<div key={row.key} className="grid gap-2 rounded-lg border bg-[var(--surface-soft)] p-3 sm:grid-cols-[1fr_10rem_10rem_auto] sm:items-end">
|
||||||
|
<Field id={`m-user-${row.key}`} label={t("members.user")}>
|
||||||
|
<select
|
||||||
|
id={`m-user-${row.key}`}
|
||||||
|
name="memberUserId"
|
||||||
|
required
|
||||||
|
value={row.userId}
|
||||||
|
onChange={(e) => updateRow(row.key, { userId: e.target.value })}
|
||||||
|
className={controlClass}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{t("members.selectUser")}
|
||||||
|
</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field id={`m-from-${row.key}`} label={t("members.validFrom")}>
|
||||||
|
<input id={`m-from-${row.key}`} type="date" name="memberValidFrom" value={row.validFrom} onChange={(e) => updateRow(row.key, { validFrom: e.target.value })} className={controlClass} />
|
||||||
|
</Field>
|
||||||
|
<Field id={`m-to-${row.key}`} label={t("members.validTo")} hint={idx === 0 ? t("members.validToHint") : undefined}>
|
||||||
|
<input id={`m-to-${row.key}`} type="date" name="memberValidTo" value={row.validTo} onChange={(e) => updateRow(row.key, { validTo: e.target.value })} className={controlClass} />
|
||||||
|
</Field>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setRows((rs) => rs.filter((r) => r.key !== row.key))}
|
||||||
|
className={`${buttonLinkClass} self-end`}
|
||||||
|
aria-label={`${t("members.remove")}: ${users.find((u) => u.id === row.userId)?.name ?? idx + 1}`}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" aria-hidden />
|
||||||
|
<span className="sm:sr-only">{t("members.remove")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{err("members") && <p role="alert" className="mt-2 text-[12px] font-semibold text-[var(--risk)]">{err("members")}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`${buttonLinkClass} mt-2`}
|
||||||
|
onClick={() => setRows((rs) => [...rs, { key: nextKey.current++, userId: "", validFrom: today, validTo: "" }])}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" aria-hidden /> {t("members.add")}
|
||||||
|
</button>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<FormError namespace="teams" state={state} />
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2 border-t pt-4">
|
||||||
|
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||||
|
{pending ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
|
||||||
|
</button>
|
||||||
|
<Link href={closeHref} className={buttonLinkClass}>
|
||||||
|
{t("form.cancel")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
// Customer duplicate detection — pure, client-safe normalization and scoring (spec §7.3, US-003).
|
||||||
|
// The DB lookup lives in src/server/services/customers/duplicates.ts#findDuplicateCustomers.
|
||||||
|
// Used by lane "imports" (review mask) and the manual create flow. NEVER merges automatically.
|
||||||
|
|
||||||
|
export type DuplicateReason = "customer_number" | "company_name" | "address" | "email" | "phone";
|
||||||
|
|
||||||
|
/** Fields of a new/imported customer that are compared against existing customers. */
|
||||||
|
export type DuplicateCandidateInput = {
|
||||||
|
customerNumber?: string | null;
|
||||||
|
companyName?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
street?: string | null;
|
||||||
|
houseNumber?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
email?: string | null;
|
||||||
|
phone?: string | null;
|
||||||
|
mobile?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DuplicateMatch = { score: number; reasons: DuplicateReason[] };
|
||||||
|
|
||||||
|
/** Minimum score for a record to be reported as "possible duplicate". */
|
||||||
|
export const DUPLICATE_THRESHOLD = 0.4;
|
||||||
|
|
||||||
|
/** Signal weights; combined as probabilistic OR: 1 - Π(1 - w). */
|
||||||
|
export const DUPLICATE_WEIGHTS = {
|
||||||
|
customerNumber: 1,
|
||||||
|
email: 0.6,
|
||||||
|
phone: 0.5,
|
||||||
|
companyExact: 0.6,
|
||||||
|
companySimilar: 0.45,
|
||||||
|
addressExact: 0.4,
|
||||||
|
addressStreetOnly: 0.25,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Legal forms, longest first so "gmbh & co kg" is removed before "gmbh"/"kg".
|
||||||
|
const LEGAL_FORMS = [
|
||||||
|
"gmbh & co. kgaa",
|
||||||
|
"gmbh & co. kg",
|
||||||
|
"gmbh & co kg",
|
||||||
|
"gmbh und co kg",
|
||||||
|
"ug (haftungsbeschraenkt)",
|
||||||
|
"ug haftungsbeschraenkt",
|
||||||
|
"e. k.",
|
||||||
|
"e.k.",
|
||||||
|
"e. v.",
|
||||||
|
"e.v.",
|
||||||
|
"kgaa",
|
||||||
|
"gmbh",
|
||||||
|
"mbh",
|
||||||
|
"ohg",
|
||||||
|
"gbr",
|
||||||
|
"partg",
|
||||||
|
"ltd.",
|
||||||
|
"ltd",
|
||||||
|
"inc.",
|
||||||
|
"inc",
|
||||||
|
"ag",
|
||||||
|
"kg",
|
||||||
|
"ug",
|
||||||
|
"se",
|
||||||
|
"ek",
|
||||||
|
"ev",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Lowercase, transliterate German umlauts, strip remaining diacritics, collapse whitespace. */
|
||||||
|
export function normalizeText(value: string | null | undefined): string {
|
||||||
|
if (!value) return "";
|
||||||
|
return value
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, "ae")
|
||||||
|
.replace(/ö/g, "oe")
|
||||||
|
.replace(/ü/g, "ue")
|
||||||
|
.replace(/ß/g, "ss")
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/\p{M}+/gu, "")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeRegExp(s: string): string {
|
||||||
|
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Company name without legal form and punctuation, e.g. "Müller GmbH & Co. KG" → "mueller". */
|
||||||
|
export function normalizeCompanyName(value: string | null | undefined): string {
|
||||||
|
let s = normalizeText(value);
|
||||||
|
if (!s) return "";
|
||||||
|
for (const form of LEGAL_FORMS) {
|
||||||
|
s = s.replace(new RegExp(`(^|[\\s,])${escapeRegExp(form)}(?=$|[\\s,])`, "g"), " ");
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
.replace(/&/g, " ")
|
||||||
|
.replace(/\bund\b/g, " ")
|
||||||
|
.replace(/[^a-z0-9]+/g, " ")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Street with unified abbreviation: "Hafenstraße" / "Hafen-Str." / "Hafen Strasse" → "hafenstr". */
|
||||||
|
export function normalizeStreet(value: string | null | undefined): string {
|
||||||
|
return normalizeText(value)
|
||||||
|
.replace(/strasse\b|str\.?(?=\s|$)/g, "str")
|
||||||
|
.replace(/[^a-z0-9]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHouseNumber(value: string | null | undefined): string {
|
||||||
|
return normalizeText(value).replace(/[^a-z0-9]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizePostalCode(value: string | null | undefined): string {
|
||||||
|
return (value ?? "").replace(/\s+/g, "").toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeEmail(value: string | null | undefined): string {
|
||||||
|
return (value ?? "").trim().toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone digits only; international German prefix unified to national form
|
||||||
|
* (+49 40 … / 0049 40 … / 040 … → "040…"). Numbers with < 6 digits are ignored.
|
||||||
|
*/
|
||||||
|
export function normalizePhone(value: string | null | undefined): string {
|
||||||
|
let digits = (value ?? "").replace(/\D+/g, "");
|
||||||
|
if (digits.startsWith("0049")) digits = "0" + digits.slice(4);
|
||||||
|
else if (digits.startsWith("49") && (value ?? "").trim().startsWith("+")) digits = "0" + digits.slice(2);
|
||||||
|
return digits.length >= 6 ? digits : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Display/compare name: company without legal form, otherwise "first last". */
|
||||||
|
export function normalizedPartyName(c: Pick<DuplicateCandidateInput, "companyName" | "firstName" | "lastName">): string {
|
||||||
|
const company = normalizeCompanyName(c.companyName);
|
||||||
|
if (company) return company;
|
||||||
|
return normalizeText([c.firstName, c.lastName].filter(Boolean).join(" ")).replace(/[^a-z0-9 ]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function bigrams(s: string): Map<string, number> {
|
||||||
|
const compact = s.replace(/\s+/g, " ");
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (let i = 0; i < compact.length - 1; i++) {
|
||||||
|
const g = compact.slice(i, i + 2);
|
||||||
|
map.set(g, (map.get(g) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sørensen–Dice coefficient over character bigrams (0..1). */
|
||||||
|
export function nameSimilarity(a: string, b: string): number {
|
||||||
|
if (!a || !b) return 0;
|
||||||
|
if (a === b) return 1;
|
||||||
|
if (a.length < 2 || b.length < 2) return 0;
|
||||||
|
const ba = bigrams(a);
|
||||||
|
const bb = bigrams(b);
|
||||||
|
let overlap = 0;
|
||||||
|
for (const [g, n] of ba) overlap += Math.min(n, bb.get(g) ?? 0);
|
||||||
|
const total = a.length - 1 + (b.length - 1);
|
||||||
|
return (2 * overlap) / total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Score one existing customer against a candidate. Pure — safe for client and tests. */
|
||||||
|
export function scoreDuplicate(candidate: DuplicateCandidateInput, existing: DuplicateCandidateInput): DuplicateMatch {
|
||||||
|
const reasons: DuplicateReason[] = [];
|
||||||
|
const weights: number[] = [];
|
||||||
|
|
||||||
|
const numA = normalizeText(candidate.customerNumber).replace(/\s+/g, "");
|
||||||
|
const numB = normalizeText(existing.customerNumber).replace(/\s+/g, "");
|
||||||
|
if (numA && numA === numB) {
|
||||||
|
reasons.push("customer_number");
|
||||||
|
weights.push(DUPLICATE_WEIGHTS.customerNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameA = normalizedPartyName(candidate);
|
||||||
|
const nameB = normalizedPartyName(existing);
|
||||||
|
if (nameA && nameB) {
|
||||||
|
if (nameA === nameB) {
|
||||||
|
reasons.push("company_name");
|
||||||
|
weights.push(DUPLICATE_WEIGHTS.companyExact);
|
||||||
|
} else if (nameSimilarity(nameA, nameB) >= 0.8) {
|
||||||
|
reasons.push("company_name");
|
||||||
|
weights.push(DUPLICATE_WEIGHTS.companySimilar);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const streetA = normalizeStreet(candidate.street);
|
||||||
|
const streetB = normalizeStreet(existing.street);
|
||||||
|
const plzA = normalizePostalCode(candidate.postalCode);
|
||||||
|
const plzB = normalizePostalCode(existing.postalCode);
|
||||||
|
if (streetA && streetA === streetB && plzA && plzA === plzB) {
|
||||||
|
const hnA = normalizeHouseNumber(candidate.houseNumber);
|
||||||
|
const hnB = normalizeHouseNumber(existing.houseNumber);
|
||||||
|
reasons.push("address");
|
||||||
|
weights.push(hnA && hnA === hnB ? DUPLICATE_WEIGHTS.addressExact : DUPLICATE_WEIGHTS.addressStreetOnly);
|
||||||
|
}
|
||||||
|
|
||||||
|
const mailA = normalizeEmail(candidate.email);
|
||||||
|
if (mailA && mailA === normalizeEmail(existing.email)) {
|
||||||
|
reasons.push("email");
|
||||||
|
weights.push(DUPLICATE_WEIGHTS.email);
|
||||||
|
}
|
||||||
|
|
||||||
|
const phonesA = [normalizePhone(candidate.phone), normalizePhone(candidate.mobile)].filter(Boolean);
|
||||||
|
const phonesB = new Set([normalizePhone(existing.phone), normalizePhone(existing.mobile)].filter(Boolean));
|
||||||
|
if (phonesA.some((p) => phonesB.has(p))) {
|
||||||
|
reasons.push("phone");
|
||||||
|
weights.push(DUPLICATE_WEIGHTS.phone);
|
||||||
|
}
|
||||||
|
|
||||||
|
const score = 1 - weights.reduce((acc, w) => acc * (1 - w), 1);
|
||||||
|
return { score: Math.round(Math.min(1, score) * 1000) / 1000, reasons };
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ctxFromGuard } from "@/server/services/context";
|
||||||
|
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
|
||||||
|
import { createContact, deleteContact, updateContact } from "@/server/services/customers/contacts";
|
||||||
|
|
||||||
|
const guard = moduleGuard("customers");
|
||||||
|
const CONTACT_KEYS = ["name", "role", "phone", "mobile", "email", "preferredChannel", "notes"] as const;
|
||||||
|
|
||||||
|
function contactValues(fd: FormData) {
|
||||||
|
const v = formObject(fd, CONTACT_KEYS);
|
||||||
|
return { ...v, name: v.name ?? "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createContactAction(customerId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("customer:write"));
|
||||||
|
await createContact(ctx, customerId, contactValues(fd));
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath(`/customers/${customerId}`);
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateContactAction(contactId: string, customerId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("customer:write"));
|
||||||
|
await updateContact(ctx, contactId, contactValues(fd));
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath(`/customers/${customerId}`);
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteContactAction(contactId: string, customerId: string): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("customer:write"));
|
||||||
|
await deleteContact(ctx, contactId);
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath(`/customers/${customerId}`);
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ctxFromGuard, ServiceError } from "@/server/services/context";
|
||||||
|
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
|
||||||
|
import {
|
||||||
|
confirmProvisionalCustomer,
|
||||||
|
createCustomer,
|
||||||
|
deleteCustomer,
|
||||||
|
updateCustomer,
|
||||||
|
} from "@/server/services/customers/customers";
|
||||||
|
import { mergeCustomers } from "@/server/services/customers/merge";
|
||||||
|
import type { DuplicateCandidate } from "@/server/services/customers/duplicates";
|
||||||
|
|
||||||
|
const guard = moduleGuard("customers");
|
||||||
|
|
||||||
|
const CUSTOMER_KEYS = [
|
||||||
|
"customerNumber",
|
||||||
|
"companyName",
|
||||||
|
"salutation",
|
||||||
|
"firstName",
|
||||||
|
"lastName",
|
||||||
|
"street",
|
||||||
|
"houseNumber",
|
||||||
|
"postalCode",
|
||||||
|
"city",
|
||||||
|
"country",
|
||||||
|
"phone",
|
||||||
|
"mobile",
|
||||||
|
"email",
|
||||||
|
"notes",
|
||||||
|
"billingNotes",
|
||||||
|
"status",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type CustomerFormState =
|
||||||
|
| ActionState
|
||||||
|
| { status: "duplicates"; candidates: DuplicateCandidate[]; values: Record<string, string | undefined> };
|
||||||
|
|
||||||
|
/** Create; on possible duplicates the form shows the candidates and may resubmit with acknowledgeDuplicates=1. */
|
||||||
|
export async function createCustomerAction(_prev: CustomerFormState, fd: FormData): Promise<CustomerFormState> {
|
||||||
|
const values = formObject(fd, CUSTOMER_KEYS);
|
||||||
|
let id: string;
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("customer:write"));
|
||||||
|
const customer = await createCustomer(ctx, values, { acknowledgeDuplicates: fd.get("acknowledgeDuplicates") === "1" });
|
||||||
|
id = customer.id;
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceError && (err.details as { reason?: string } | undefined)?.reason === "possible_duplicates") {
|
||||||
|
return { status: "duplicates", candidates: (err.details as { candidates: DuplicateCandidate[] }).candidates, values };
|
||||||
|
}
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath("/customers");
|
||||||
|
redirect(`/customers/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCustomerAction(id: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("customer:write"));
|
||||||
|
const values = formObject(fd, CUSTOMER_KEYS);
|
||||||
|
// full form submit: empty inputs clear the field
|
||||||
|
const patch = Object.fromEntries(CUSTOMER_KEYS.map((k) => [k, values[k] ?? (k === "status" || k === "country" ? undefined : null)]));
|
||||||
|
await updateCustomer(ctx, id, patch);
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath("/customers");
|
||||||
|
revalidatePath(`/customers/${id}`);
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteCustomerAction(id: string): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("customer:write"));
|
||||||
|
await deleteCustomer(ctx, id);
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath("/customers");
|
||||||
|
redirect("/customers");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmCustomerAction(id: string): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("customer:write"));
|
||||||
|
await confirmProvisionalCustomer(ctx, id);
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath("/customers");
|
||||||
|
revalidatePath(`/customers/${id}`);
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Merge `sourceId` into the selected target. Requires customer:merge and the confirmation checkbox. */
|
||||||
|
export async function mergeCustomerAction(sourceId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
let targetId: string;
|
||||||
|
try {
|
||||||
|
const g = await guard("customer:read", "customer:merge");
|
||||||
|
const ctx = ctxFromGuard(g);
|
||||||
|
const values = formObject(fd, ["targetId", "targetNumber"]);
|
||||||
|
let target = values.targetId;
|
||||||
|
if (!target && values.targetNumber) {
|
||||||
|
const byNumber = await ctx.db.customer.findFirst({
|
||||||
|
where: { customerNumber: values.targetNumber, deletedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!byNumber) throw new ServiceError("invalid", "target not found", { field: "targetNumber", reason: "target_not_found" });
|
||||||
|
target = byNumber.id;
|
||||||
|
}
|
||||||
|
if (!target) throw new ServiceError("invalid", "target required", { field: "targetId", reason: "target_required" });
|
||||||
|
const confirmed = fd.get("confirm") === "on" || fd.get("confirm") === "1";
|
||||||
|
await mergeCustomers(ctx, { sourceId, targetId: target, confirm: confirmed as true });
|
||||||
|
targetId = target;
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath("/customers");
|
||||||
|
redirect(`/customers/${targetId}?merged=1`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ctxFromGuard } from "@/server/services/context";
|
||||||
|
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
|
||||||
|
import { deleteDocument, updateDocumentMeta } from "@/server/services/documents/access";
|
||||||
|
import type { DocumentCategory, DocumentVisibility } from "@prisma/client";
|
||||||
|
|
||||||
|
const guard = moduleGuard("documents");
|
||||||
|
|
||||||
|
/** Only revalidate same-app paths passed by our own pages. */
|
||||||
|
function safePath(path: string): string {
|
||||||
|
return path.startsWith("/") && !path.startsWith("//") ? path.split("?")[0] : "/documents";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateDocumentAction(documentId: string, returnPath: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("document:write"));
|
||||||
|
const v = formObject(fd, ["title", "category", "visibility"]);
|
||||||
|
await updateDocumentMeta(ctx, documentId, {
|
||||||
|
title: v.title ?? null,
|
||||||
|
category: v.category as DocumentCategory | undefined,
|
||||||
|
visibility: v.visibility as DocumentVisibility | undefined,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath(safePath(returnPath));
|
||||||
|
revalidatePath("/documents");
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteDocumentAction(documentId: string, returnPath: string): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("document:write"));
|
||||||
|
await deleteDocument(ctx, documentId);
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath(safePath(returnPath));
|
||||||
|
revalidatePath("/documents");
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ctxFromGuard } from "@/server/services/context";
|
||||||
|
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
|
||||||
|
import { createSite, deleteSite, updateSite } from "@/server/services/sites/sites";
|
||||||
|
|
||||||
|
const guard = moduleGuard("sites");
|
||||||
|
|
||||||
|
const SITE_KEYS = [
|
||||||
|
"customerId",
|
||||||
|
"name",
|
||||||
|
"street",
|
||||||
|
"houseNumber",
|
||||||
|
"postalCode",
|
||||||
|
"city",
|
||||||
|
"country",
|
||||||
|
"contactId",
|
||||||
|
"onSiteContact",
|
||||||
|
"phone",
|
||||||
|
"accessNotes",
|
||||||
|
"parkingNotes",
|
||||||
|
"safetyNotes",
|
||||||
|
"technicalNotes",
|
||||||
|
"status",
|
||||||
|
"latitude",
|
||||||
|
"longitude",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export async function createSiteAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
let id: string;
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("site:write"));
|
||||||
|
const v = formObject(fd, SITE_KEYS);
|
||||||
|
const site = await createSite(ctx, { ...v, customerId: v.customerId ?? "", name: v.name ?? "" });
|
||||||
|
id = site.id;
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath("/sites");
|
||||||
|
redirect(`/sites/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSiteAction(id: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("site:write"));
|
||||||
|
const v = formObject(fd, SITE_KEYS);
|
||||||
|
const patch = Object.fromEntries(
|
||||||
|
SITE_KEYS.map((k) => [k, v[k] ?? (k === "status" || k === "country" || k === "customerId" || k === "name" ? undefined : null)]),
|
||||||
|
);
|
||||||
|
await updateSite(ctx, id, patch);
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath("/sites");
|
||||||
|
revalidatePath(`/sites/${id}`);
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSiteAction(id: string): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("site:write"));
|
||||||
|
await deleteSite(ctx, id);
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath("/sites");
|
||||||
|
redirect("/sites");
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ctxFromGuard } from "@/server/services/context";
|
||||||
|
import { formObject, toActionError, type ActionState } from "@/server/api/action-state";
|
||||||
|
import { createTeam, deleteTeam, updateTeam, type TeamInput } from "@/server/services/teams/teams";
|
||||||
|
|
||||||
|
const guard = moduleGuard("teams");
|
||||||
|
const TEAM_KEYS = ["name", "leaderUserId", "status", "phone", "vehicle", "area", "notes"] as const;
|
||||||
|
|
||||||
|
function teamValues(fd: FormData): TeamInput {
|
||||||
|
const v = formObject(fd, TEAM_KEYS);
|
||||||
|
const userIds = fd.getAll("memberUserId").map(String);
|
||||||
|
const froms = fd.getAll("memberValidFrom").map(String);
|
||||||
|
const tos = fd.getAll("memberValidTo").map(String);
|
||||||
|
const members = userIds
|
||||||
|
.map((userId, i) => ({ userId: userId.trim(), validFrom: froms[i] ?? "", validTo: tos[i] ?? "" }))
|
||||||
|
.filter((m) => m.userId);
|
||||||
|
return { ...v, name: v.name ?? "", leaderUserId: v.leaderUserId ?? null, members };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create (id = null) or fully update a team incl. its member list. */
|
||||||
|
export async function saveTeamAction(id: string | null, _prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("team:manage"));
|
||||||
|
const input = teamValues(fd);
|
||||||
|
if (id) await updateTeam(ctx, id, input);
|
||||||
|
else await createTeam(ctx, input);
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath("/teams");
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTeamAction(id: string): Promise<ActionState> {
|
||||||
|
try {
|
||||||
|
const ctx = ctxFromGuard(await guard("team:manage"));
|
||||||
|
await deleteTeam(ctx, id);
|
||||||
|
} catch (err) {
|
||||||
|
return toActionError(err);
|
||||||
|
}
|
||||||
|
revalidatePath("/teams");
|
||||||
|
return { status: "ok" };
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { ZodError } from "zod";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { ForbiddenError } from "@/server/rbac";
|
||||||
|
import { ModuleDisabledError } from "@/server/modules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uniform result shape for form-based server actions of the master-data lanes.
|
||||||
|
* Errors are CODES (translated in the client via messages/<ns>.json → errors.<code>),
|
||||||
|
* never raw server messages (CWE-209). Field errors map field name → issue code.
|
||||||
|
*/
|
||||||
|
export type ActionErrorCode = "invalid" | "not_found" | "forbidden" | "conflict" | "blocked" | "generic";
|
||||||
|
|
||||||
|
export type ActionState<T = undefined> =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "ok"; data?: T }
|
||||||
|
| { status: "error"; code: ActionErrorCode; reason?: string; fieldErrors?: Record<string, string> };
|
||||||
|
|
||||||
|
export const IDLE: ActionState = { status: "idle" };
|
||||||
|
|
||||||
|
export function toActionError(err: unknown): Extract<ActionState, { status: "error" }> {
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
const fieldErrors: Record<string, string> = {};
|
||||||
|
for (const issue of err.issues) {
|
||||||
|
const key = issue.path.join(".") || "_";
|
||||||
|
if (!fieldErrors[key]) fieldErrors[key] = issue.code === "too_small" && issue.minimum === 1 ? "required" : "invalid";
|
||||||
|
}
|
||||||
|
return { status: "error", code: "invalid", fieldErrors };
|
||||||
|
}
|
||||||
|
if (err instanceof ServiceError) {
|
||||||
|
const details = err.details as { field?: string; reason?: string } | undefined;
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
code: err.code,
|
||||||
|
reason: details?.reason ?? err.message,
|
||||||
|
...(details?.field ? { fieldErrors: { [details.field]: details.reason ?? err.code } } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return { status: "error", code: "forbidden" };
|
||||||
|
if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return { status: "error", code: "not_found" };
|
||||||
|
console.error("[action] unexpected error", err);
|
||||||
|
return { status: "error", code: "generic" };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** FormData → plain object of trimmed strings; empty strings become undefined. */
|
||||||
|
export function formObject(fd: FormData, keys: readonly string[]): Record<string, string | undefined> {
|
||||||
|
const out: Record<string, string | undefined> = {};
|
||||||
|
for (const k of keys) {
|
||||||
|
const v = fd.get(k);
|
||||||
|
const s = typeof v === "string" ? v.trim() : "";
|
||||||
|
out[k] = s === "" ? undefined : s;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { dbForTenant, prisma } from "@/server/db";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { isTokenStillValid } from "@/server/sessions";
|
||||||
|
import { assertModuleEnabled, requireModule } from "@/server/modules";
|
||||||
|
import type { Permission } from "@/server/rbac";
|
||||||
|
import type { ModuleKey } from "@/lib/modules";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { ApiError } from "@/server/api/respond";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service context for /api/v1 route handlers and other route handlers (e.g. /files/<id>).
|
||||||
|
*
|
||||||
|
* Same authority model as `moduleGuard` (src/server/action-guard.ts, F-06): session cookie
|
||||||
|
* (Auth.js), then membership status, identity status, session kill switch, forced password
|
||||||
|
* change and the EFFECTIVE permissions are read from the database — never from the JWT.
|
||||||
|
* Differences: failures are thrown as `ApiError` (401/403) so handlers can answer with JSON,
|
||||||
|
* and `moduleKey` may be `null` for cross-module endpoints (document downloads are needed
|
||||||
|
* by field, reports and documents alike).
|
||||||
|
*/
|
||||||
|
export async function requireApiContext(moduleKey: ModuleKey | null, ...permissions: Permission[]): Promise<ServiceCtx> {
|
||||||
|
let session;
|
||||||
|
try {
|
||||||
|
session = await requireSession();
|
||||||
|
} catch {
|
||||||
|
throw new ApiError("unauthorized", "authentication required");
|
||||||
|
}
|
||||||
|
const tenantId = session.user.tenantId;
|
||||||
|
const db = dbForTenant(tenantId);
|
||||||
|
|
||||||
|
const account = await db.user.findFirst({
|
||||||
|
where: { id: session.user.id, status: "ACTIVE" },
|
||||||
|
select: {
|
||||||
|
userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const identity = session.user.identityId
|
||||||
|
? await prisma.identity.findUnique({
|
||||||
|
where: { id: session.user.identityId },
|
||||||
|
select: { status: true, mustChangePassword: true, sessionsValidAfter: true },
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
if (!account || !identity || identity.status !== "ACTIVE") {
|
||||||
|
await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "account_inactive", entityId: session.user.id });
|
||||||
|
throw new ApiError("unauthorized", "account inactive");
|
||||||
|
}
|
||||||
|
if (!isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) {
|
||||||
|
throw new ApiError("unauthorized", "session invalidated");
|
||||||
|
}
|
||||||
|
if (identity.mustChangePassword) throw new ApiError("forbidden", "password change required");
|
||||||
|
|
||||||
|
const effective = new Set(account.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key)));
|
||||||
|
for (const p of permissions) {
|
||||||
|
if (!effective.has(p)) {
|
||||||
|
await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "api", entityId: p });
|
||||||
|
throw new ApiError("forbidden", "forbidden");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403
|
||||||
|
|
||||||
|
return { db, tenantId, userId: session.user.id, permissions: effective };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose
|
||||||
|
* Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in.
|
||||||
|
*/
|
||||||
|
export function assertSameOrigin(req: Request): void {
|
||||||
|
const site = req.headers.get("sec-fetch-site");
|
||||||
|
if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request");
|
||||||
|
const origin = req.headers.get("origin");
|
||||||
|
if (origin) {
|
||||||
|
const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host");
|
||||||
|
let originHost: string | null = null;
|
||||||
|
try {
|
||||||
|
originHost = new URL(origin).host;
|
||||||
|
} catch {
|
||||||
|
originHost = null;
|
||||||
|
}
|
||||||
|
if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read context for server components (pages). Uses the session's permission set (JWT), which
|
||||||
|
* is the documented behaviour for read paths (AGENTS.md "Rollen"); mutations go through
|
||||||
|
* moduleGuard / requireApiContext with DB-authoritative permissions. Also enforces the module gate.
|
||||||
|
*/
|
||||||
|
export async function requirePageContext(moduleKey: ModuleKey): Promise<ServiceCtx> {
|
||||||
|
const session = await requireModule(moduleKey);
|
||||||
|
return {
|
||||||
|
db: dbForTenant(session.user.tenantId),
|
||||||
|
tenantId: session.user.tenantId,
|
||||||
|
userId: session.user.id,
|
||||||
|
permissions: new Set(session.user.permissions ?? []),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { ZodError } from "zod";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { ForbiddenError } from "@/server/rbac";
|
||||||
|
import { ModuleDisabledError } from "@/server/modules";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JSON response helpers for /api/v1 route handlers (spec §29.2).
|
||||||
|
* Error format: `{ error: { code, message, details? } }`; list format:
|
||||||
|
* `{ data: [...], pagination: { page, pageSize, total } }`.
|
||||||
|
* Internal error details never leave the server (CWE-209).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ApiErrorCode =
|
||||||
|
| "unauthorized"
|
||||||
|
| "forbidden"
|
||||||
|
| "not_found"
|
||||||
|
| "invalid"
|
||||||
|
| "conflict"
|
||||||
|
| "blocked"
|
||||||
|
| "payload_too_large"
|
||||||
|
| "internal";
|
||||||
|
|
||||||
|
const STATUS: Record<ApiErrorCode, number> = {
|
||||||
|
unauthorized: 401,
|
||||||
|
forbidden: 403,
|
||||||
|
not_found: 404,
|
||||||
|
invalid: 422,
|
||||||
|
conflict: 409,
|
||||||
|
blocked: 409,
|
||||||
|
payload_too_large: 413,
|
||||||
|
internal: 500,
|
||||||
|
};
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(
|
||||||
|
public code: ApiErrorCode,
|
||||||
|
message: string,
|
||||||
|
public details?: unknown,
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ApiError";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorResponse(code: ApiErrorCode, message: string, details?: unknown): Response {
|
||||||
|
return Response.json(
|
||||||
|
{ error: { code, message, ...(details !== undefined ? { details } : {}) } },
|
||||||
|
{ status: STATUS[code], headers: { "Cache-Control": "no-store" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Map any thrown error to a JSON error response. */
|
||||||
|
export function toErrorResponse(err: unknown): Response {
|
||||||
|
if (err instanceof ApiError) return errorResponse(err.code, err.message, err.details);
|
||||||
|
if (err instanceof ServiceError) return errorResponse(err.code, err.message, err.details);
|
||||||
|
if (err instanceof ZodError) {
|
||||||
|
return errorResponse(
|
||||||
|
"invalid",
|
||||||
|
"validation failed",
|
||||||
|
err.issues.map((i) => ({ path: i.path.join("."), code: i.code })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden");
|
||||||
|
if (err instanceof ModuleDisabledError) return errorResponse("forbidden", "module disabled");
|
||||||
|
if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found");
|
||||||
|
console.error("[api] unhandled error", err);
|
||||||
|
return errorResponse("internal", "internal error");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function json(data: unknown, init?: { status?: number }): Response {
|
||||||
|
return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function paginated<T>(items: T[], total: number, page: number, pageSize: number): Response {
|
||||||
|
return json({ data: items, pagination: { page, pageSize, total } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `?page&pageSize` with sane bounds (pageSize 1..100, default 25). */
|
||||||
|
export function parsePagination(url: URL | string, defaults = { pageSize: 25 }): { page: number; pageSize: number } {
|
||||||
|
const u = typeof url === "string" ? new URL(url) : url;
|
||||||
|
const page = Math.max(1, Math.floor(Number(u.searchParams.get("page")) || 1));
|
||||||
|
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(u.searchParams.get("pageSize")) || defaults.pageSize)));
|
||||||
|
return { page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Wrap a handler so every thrown error becomes a JSON error response. */
|
||||||
|
export function withApi<A extends unknown[]>(handler: (...args: A) => Promise<Response>) {
|
||||||
|
return async (...args: A): Promise<Response> => {
|
||||||
|
try {
|
||||||
|
return await handler(...args);
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorResponse(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read a JSON body; malformed JSON → 422. */
|
||||||
|
export async function readJson(req: Request): Promise<unknown> {
|
||||||
|
try {
|
||||||
|
return await req.json();
|
||||||
|
} catch {
|
||||||
|
throw new ApiError("invalid", "malformed JSON body");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { customerScope } from "@/server/services/work-orders/visibility";
|
||||||
|
import { contactSchema, type ContactInput } from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
async function requireWritableCustomer(ctx: ServiceCtx, customerId: string) {
|
||||||
|
const customer = await ctx.db.customer.findFirst({
|
||||||
|
where: { AND: [{ id: customerId }, await customerScope(ctx), { status: { not: "merged" } }] },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new ServiceError("not_found", "customer not found");
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireContact(ctx: ServiceCtx, contactId: string) {
|
||||||
|
const contact = await ctx.db.contact.findFirst({
|
||||||
|
where: { id: contactId, deletedAt: null, customer: { AND: [await customerScope(ctx), { status: { not: "merged" } }] } },
|
||||||
|
});
|
||||||
|
if (!contact) throw new ServiceError("not_found", "contact not found");
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createContact(ctx: ServiceCtx, customerId: string, input: ContactInput) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const data = contactSchema.parse(input);
|
||||||
|
await requireWritableCustomer(ctx, customerId);
|
||||||
|
const contact = await ctx.db.contact.create({ data: { ...data, tenantId: ctx.tenantId, customerId } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "contact", entityId: contact.id, after: contact });
|
||||||
|
return contact;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateContact(ctx: ServiceCtx, contactId: string, input: ContactInput) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const data = contactSchema.parse(input);
|
||||||
|
const before = await requireContact(ctx, contactId);
|
||||||
|
const after = await ctx.db.contact.update({ where: { id: contactId }, data });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "contact", entityId: contactId, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteContact(ctx: ServiceCtx, contactId: string) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const before = await requireContact(ctx, contactId);
|
||||||
|
const after = await ctx.db.contact.update({ where: { id: contactId }, data: { deletedAt: new Date() } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "contact", entityId: contactId, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { nextNumber } from "@/server/services/numbering";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { customerScope, workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
import { findDuplicateCustomers } from "@/server/services/customers/duplicates";
|
||||||
|
import {
|
||||||
|
customerCreateSchema,
|
||||||
|
customerPatchSchema,
|
||||||
|
type CustomerCreateInput,
|
||||||
|
type CustomerPatchInput,
|
||||||
|
} from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
export const CUSTOMER_LIST_STATUSES = ["active", "inactive", "provisional", "merged"] as const;
|
||||||
|
export type CustomerListStatus = (typeof CUSTOMER_LIST_STATUSES)[number];
|
||||||
|
|
||||||
|
const CLOSED_ORDER_STATUSES = ["billed", "cancelled"] as const;
|
||||||
|
|
||||||
|
function isUniqueViolation(err: unknown): boolean {
|
||||||
|
return (err as { code?: string })?.code === "P2002";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer ids are only visible within the user's scope; everything else is "not found". */
|
||||||
|
async function findVisibleCustomer(ctx: ServiceCtx, id: string, extra: Prisma.CustomerWhereInput = {}) {
|
||||||
|
return ctx.db.customer.findFirst({ where: { AND: [{ id }, await customerScope(ctx), extra] } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listCustomers(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
opts: { q?: string; status?: CustomerListStatus | "all"; page?: number; pageSize?: number } = {},
|
||||||
|
) {
|
||||||
|
assertCan(ctx, "customer:read");
|
||||||
|
const page = Math.max(1, opts.page ?? 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 25));
|
||||||
|
const q = opts.q?.trim();
|
||||||
|
const statusFilter: Prisma.CustomerWhereInput =
|
||||||
|
!opts.status || opts.status === "all" ? { status: { not: "merged" } } : { status: opts.status };
|
||||||
|
const where: Prisma.CustomerWhereInput = {
|
||||||
|
AND: [
|
||||||
|
await customerScope(ctx),
|
||||||
|
statusFilter,
|
||||||
|
q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ customerNumber: { contains: q, mode: "insensitive" } },
|
||||||
|
{ companyName: { contains: q, mode: "insensitive" } },
|
||||||
|
{ firstName: { contains: q, mode: "insensitive" } },
|
||||||
|
{ lastName: { contains: q, mode: "insensitive" } },
|
||||||
|
{ city: { contains: q, mode: "insensitive" } },
|
||||||
|
{ email: { contains: q, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
ctx.db.customer.count({ where }),
|
||||||
|
ctx.db.customer.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ companyName: "asc" }, { lastName: "asc" }, { createdAt: "asc" }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
customerNumber: true,
|
||||||
|
companyName: true,
|
||||||
|
salutation: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
postalCode: true,
|
||||||
|
city: true,
|
||||||
|
phone: true,
|
||||||
|
email: true,
|
||||||
|
status: true,
|
||||||
|
updatedAt: true,
|
||||||
|
_count: { select: { sites: { where: { deletedAt: null } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCustomer(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "customer:read");
|
||||||
|
const customer = await ctx.db.customer.findFirst({
|
||||||
|
where: { AND: [{ id }, await customerScope(ctx)] },
|
||||||
|
include: { contacts: { where: { deletedAt: null }, orderBy: { name: "asc" } } },
|
||||||
|
});
|
||||||
|
if (!customer) throw new ServiceError("not_found", "customer not found");
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight options for selects (sites form, merge target). */
|
||||||
|
export async function customerOptions(ctx: ServiceCtx, opts: { take?: number } = {}) {
|
||||||
|
assertCan(ctx, "customer:read");
|
||||||
|
return ctx.db.customer.findMany({
|
||||||
|
where: { AND: [await customerScope(ctx), { status: { in: ["active", "provisional"] } }] },
|
||||||
|
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true },
|
||||||
|
orderBy: [{ companyName: "asc" }, { lastName: "asc" }],
|
||||||
|
take: opts.take ?? 500,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertNumberFree(ctx: ServiceCtx, customerNumber: string, exceptId?: string) {
|
||||||
|
const clash = await ctx.db.customer.findFirst({
|
||||||
|
where: { customerNumber, ...(exceptId ? { id: { not: exceptId } } : {}) },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (clash) throw new ServiceError("conflict", "customer number taken", { field: "customerNumber", reason: "number_taken" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Next free sequence number; skips values already taken by manually entered numbers. */
|
||||||
|
async function allocateCustomerNumber(ctx: ServiceCtx): Promise<string> {
|
||||||
|
for (let i = 0; i < 20; i++) {
|
||||||
|
const candidate = await nextNumber(ctx.db, ctx.tenantId, "customer");
|
||||||
|
const taken = await ctx.db.customer.findFirst({ where: { customerNumber: candidate }, select: { id: true } });
|
||||||
|
if (!taken) return candidate;
|
||||||
|
}
|
||||||
|
throw new ServiceError("conflict", "could not allocate customer number", { reason: "number_allocation" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a customer. Runs the duplicate check first; if possible duplicates exist and the caller
|
||||||
|
* has not acknowledged them, throws `conflict` with `details.reason = "possible_duplicates"` and
|
||||||
|
* `details.candidates` — the UI shows "Mögliche Dublette" and lets the user decide.
|
||||||
|
*/
|
||||||
|
export async function createCustomer(ctx: ServiceCtx, input: CustomerCreateInput, opts: { acknowledgeDuplicates?: boolean } = {}) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const data = customerCreateSchema.parse(input);
|
||||||
|
|
||||||
|
// a taken number is a hard conflict — acknowledging a duplicate hint could not resolve it
|
||||||
|
if (data.customerNumber) await assertNumberFree(ctx, data.customerNumber);
|
||||||
|
|
||||||
|
if (!opts.acknowledgeDuplicates) {
|
||||||
|
const candidates = await findDuplicateCustomers(ctx, data);
|
||||||
|
if (candidates.length > 0) {
|
||||||
|
throw new ServiceError("conflict", "possible duplicates", { reason: "possible_duplicates", candidates });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerNumber = data.customerNumber ?? (await allocateCustomerNumber(ctx));
|
||||||
|
const status = data.status ?? "active";
|
||||||
|
|
||||||
|
let customer;
|
||||||
|
try {
|
||||||
|
customer = await ctx.db.customer.create({
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
customerNumber,
|
||||||
|
country: data.country ?? "DE",
|
||||||
|
status,
|
||||||
|
isProvisional: status === "provisional",
|
||||||
|
createdById: ctx.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) throw new ServiceError("conflict", "customer number taken", { field: "customerNumber", reason: "number_taken" });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "customer", entityId: customer.id, after: customer });
|
||||||
|
return customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateCustomer(ctx: ServiceCtx, id: string, patch: CustomerPatchInput) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const data = customerPatchSchema.parse(patch);
|
||||||
|
const before = await findVisibleCustomer(ctx, id, { status: { not: "merged" } });
|
||||||
|
if (!before) throw new ServiceError("not_found", "customer not found");
|
||||||
|
|
||||||
|
const merged = { companyName: before.companyName, lastName: before.lastName, ...data };
|
||||||
|
if (!merged.companyName && !merged.lastName) {
|
||||||
|
throw new ServiceError("invalid", "name required", { field: "companyName", reason: "name_required" });
|
||||||
|
}
|
||||||
|
if (data.customerNumber === null) delete data.customerNumber; // the number can be changed, not removed
|
||||||
|
if (data.customerNumber && data.customerNumber !== before.customerNumber) await assertNumberFree(ctx, data.customerNumber, id);
|
||||||
|
|
||||||
|
let after;
|
||||||
|
try {
|
||||||
|
after = await ctx.db.customer.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...data,
|
||||||
|
...(data.status ? { isProvisional: data.status === "provisional" } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
if (isUniqueViolation(err)) throw new ServiceError("conflict", "customer number taken", { field: "customerNumber", reason: "number_taken" });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: id, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft delete (spec §27.5). Blocked while open work orders reference the customer. */
|
||||||
|
export async function deleteCustomer(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const before = await findVisibleCustomer(ctx, id);
|
||||||
|
if (!before) throw new ServiceError("not_found", "customer not found");
|
||||||
|
const open = await ctx.db.workOrder.count({
|
||||||
|
where: { customerId: id, deletedAt: null, status: { notIn: [...CLOSED_ORDER_STATUSES] } },
|
||||||
|
});
|
||||||
|
if (open > 0) throw new ServiceError("blocked", "customer has open work orders", { reason: "open_work_orders", count: open });
|
||||||
|
const after = await ctx.db.customer.update({ where: { id }, data: { deletedAt: new Date(), status: "inactive" } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "customer", entityId: id, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** provisional → active (used by the emergency lane's backoffice review). */
|
||||||
|
export async function confirmProvisionalCustomer(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "customer:write");
|
||||||
|
const before = await findVisibleCustomer(ctx, id);
|
||||||
|
if (!before) throw new ServiceError("not_found", "customer not found");
|
||||||
|
if (before.status !== "provisional") throw new ServiceError("conflict", "customer is not provisional", { reason: "not_provisional" });
|
||||||
|
const after = await ctx.db.customer.update({ where: { id }, data: { status: "active", isProvisional: false } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: id, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Read-only order list for the customer detail tab (work order scope applies). */
|
||||||
|
export async function listCustomerWorkOrders(ctx: ServiceCtx, customerId: string, opts: { take?: number } = {}) {
|
||||||
|
await getCustomer(ctx, customerId);
|
||||||
|
return ctx.db.workOrder.findMany({
|
||||||
|
where: { AND: [{ customerId }, await workOrderScope(ctx)] },
|
||||||
|
orderBy: [{ plannedStart: "desc" }, { createdAt: "desc" }],
|
||||||
|
take: opts.take ?? 100,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
number: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
plannedStart: true,
|
||||||
|
createdAt: true,
|
||||||
|
site: { select: { id: true, name: true } },
|
||||||
|
team: { select: { name: true } },
|
||||||
|
orderType: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { assertCan, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { customerScope } from "@/server/services/work-orders/visibility";
|
||||||
|
import {
|
||||||
|
DUPLICATE_THRESHOLD,
|
||||||
|
normalizeCompanyName,
|
||||||
|
normalizePhone,
|
||||||
|
normalizeText,
|
||||||
|
scoreDuplicate,
|
||||||
|
type DuplicateCandidateInput,
|
||||||
|
type DuplicateReason,
|
||||||
|
} from "@/lib/customers/duplicates";
|
||||||
|
import { customerDisplayName } from "@/server/services/customers/format";
|
||||||
|
|
||||||
|
export type DuplicateCandidate = {
|
||||||
|
customerId: string;
|
||||||
|
score: number;
|
||||||
|
reasons: DuplicateReason[];
|
||||||
|
customerNumber: string | null;
|
||||||
|
displayName: string;
|
||||||
|
city: string | null;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PREFILTER_LIMIT = 200;
|
||||||
|
|
||||||
|
/** A raw (non-transliterated) significant word of the company name for a DB `contains` prefilter. */
|
||||||
|
function rawNameTokens(companyName: string | null | undefined): string[] {
|
||||||
|
const normalized = normalizeCompanyName(companyName);
|
||||||
|
const raw = (companyName ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length >= 3);
|
||||||
|
const norm = normalized.split(" ").filter((w) => w.length >= 3);
|
||||||
|
const legal = new Set(["gmbh", "mbh", "kgaa", "ohg", "gbr", "und", "co"]);
|
||||||
|
return [...new Set([...raw, ...norm])].filter((w) => !legal.has(normalizeText(w))).slice(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Possible duplicates of `candidate` among the tenant's customers (spec §7.3, US-003).
|
||||||
|
* Returns candidates with score ≥ DUPLICATE_THRESHOLD, best first. Never merges.
|
||||||
|
* Contract (ARCHITEKTUR §6, used by lane imports): `findDuplicateCustomers(ctx, candidate) → Candidate[]`.
|
||||||
|
*/
|
||||||
|
export async function findDuplicateCustomers(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
candidate: DuplicateCandidateInput,
|
||||||
|
opts: { excludeId?: string; limit?: number } = {},
|
||||||
|
): Promise<DuplicateCandidate[]> {
|
||||||
|
assertCan(ctx, "customer:read");
|
||||||
|
|
||||||
|
const or: Prisma.CustomerWhereInput[] = [];
|
||||||
|
const insensitive = "insensitive" as const;
|
||||||
|
if (candidate.customerNumber?.trim()) or.push({ customerNumber: { equals: candidate.customerNumber.trim(), mode: insensitive } });
|
||||||
|
if (candidate.email?.trim()) or.push({ email: { equals: candidate.email.trim(), mode: insensitive } });
|
||||||
|
if (candidate.postalCode?.trim()) or.push({ postalCode: candidate.postalCode.replace(/\s+/g, "") });
|
||||||
|
for (const token of rawNameTokens(candidate.companyName)) or.push({ companyName: { contains: token, mode: insensitive } });
|
||||||
|
if (candidate.lastName?.trim()) or.push({ lastName: { equals: candidate.lastName.trim(), mode: insensitive } });
|
||||||
|
const scope = await customerScope(ctx);
|
||||||
|
const baseFilter: Prisma.CustomerWhereInput[] = [scope, { status: { not: "merged" } }, opts.excludeId ? { id: { not: opts.excludeId } } : {}];
|
||||||
|
|
||||||
|
// Stored phone numbers carry arbitrary formatting ("+49 40 123456-0"), so a SQL `contains` is
|
||||||
|
// unreliable: compare normalized digits over the (narrow) phone columns in memory instead.
|
||||||
|
const wantedPhones = new Set([normalizePhone(candidate.phone), normalizePhone(candidate.mobile)].filter(Boolean));
|
||||||
|
if (wantedPhones.size) {
|
||||||
|
const phoneRows = await ctx.db.customer.findMany({
|
||||||
|
where: { AND: [...baseFilter, { OR: [{ phone: { not: null } }, { mobile: { not: null } }] }] },
|
||||||
|
select: { id: true, phone: true, mobile: true },
|
||||||
|
take: 10_000,
|
||||||
|
});
|
||||||
|
const ids = phoneRows.filter((r) => wantedPhones.has(normalizePhone(r.phone)) || wantedPhones.has(normalizePhone(r.mobile))).map((r) => r.id);
|
||||||
|
if (ids.length) or.push({ id: { in: ids } });
|
||||||
|
}
|
||||||
|
if (or.length === 0) return [];
|
||||||
|
|
||||||
|
const rows = await ctx.db.customer.findMany({
|
||||||
|
where: { AND: [...baseFilter, { OR: or }] },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
customerNumber: true,
|
||||||
|
companyName: true,
|
||||||
|
firstName: true,
|
||||||
|
lastName: true,
|
||||||
|
street: true,
|
||||||
|
houseNumber: true,
|
||||||
|
postalCode: true,
|
||||||
|
city: true,
|
||||||
|
email: true,
|
||||||
|
phone: true,
|
||||||
|
mobile: true,
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
take: PREFILTER_LIMIT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.map((r) => {
|
||||||
|
const m = scoreDuplicate(candidate, r);
|
||||||
|
return {
|
||||||
|
customerId: r.id,
|
||||||
|
score: m.score,
|
||||||
|
reasons: m.reasons,
|
||||||
|
customerNumber: r.customerNumber,
|
||||||
|
displayName: customerDisplayName(r),
|
||||||
|
city: r.city,
|
||||||
|
status: r.status,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((c) => c.score >= DUPLICATE_THRESHOLD)
|
||||||
|
.sort((a, b) => b.score - a.score)
|
||||||
|
.slice(0, opts.limit ?? 10);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
// Pure display helpers (no server imports) — usable from server and client components.
|
||||||
|
|
||||||
|
export type CustomerNameFields = {
|
||||||
|
companyName?: string | null;
|
||||||
|
salutation?: string | null;
|
||||||
|
firstName?: string | null;
|
||||||
|
lastName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function customerDisplayName(c: CustomerNameFields): string {
|
||||||
|
if (c.companyName?.trim()) return c.companyName.trim();
|
||||||
|
return [c.firstName, c.lastName].filter((s) => s && s.trim()).join(" ").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AddressFields = {
|
||||||
|
street?: string | null;
|
||||||
|
houseNumber?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatAddress(a: AddressFields, opts: { withCountry?: boolean } = {}): string {
|
||||||
|
const line1 = [a.street, a.houseNumber].filter(Boolean).join(" ");
|
||||||
|
const line2 = [a.postalCode, a.city].filter(Boolean).join(" ");
|
||||||
|
const parts = [line1, line2];
|
||||||
|
if (opts.withCountry && a.country && a.country !== "DE") parts.push(a.country);
|
||||||
|
return parts.filter(Boolean).join(", ");
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { mergeSchema, type MergeInput } from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge two customers (spec §7.3). Only with `customer:merge` and an explicit `confirm: true`.
|
||||||
|
* Contacts, sites, work orders and documents of the source are moved to the target; the source
|
||||||
|
* becomes status `merged` with `mergedIntoId`. Both records must belong to the caller's tenant
|
||||||
|
* (dbForTenant) — ids of another tenant are "not found". Never triggered automatically.
|
||||||
|
*/
|
||||||
|
export async function mergeCustomers(ctx: ServiceCtx, input: MergeInput) {
|
||||||
|
assertCan(ctx, "customer:merge");
|
||||||
|
const { sourceId, targetId } = mergeSchema.parse(input);
|
||||||
|
|
||||||
|
const [source, target] = await Promise.all([
|
||||||
|
ctx.db.customer.findFirst({ where: { id: sourceId, deletedAt: null } }),
|
||||||
|
ctx.db.customer.findFirst({ where: { id: targetId, deletedAt: null } }),
|
||||||
|
]);
|
||||||
|
if (!source) throw new ServiceError("not_found", "source customer not found", { field: "sourceId", reason: "not_found" });
|
||||||
|
if (!target) throw new ServiceError("not_found", "target customer not found", { field: "targetId", reason: "not_found" });
|
||||||
|
if (source.status === "merged" || target.status === "merged") {
|
||||||
|
throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const [contacts, sites, workOrders, documents, mergedSource] = await ctx.db.$transaction([
|
||||||
|
ctx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }),
|
||||||
|
ctx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }),
|
||||||
|
// version bump: offline clients must not overwrite the re-parented order with stale data
|
||||||
|
ctx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId, version: { increment: 1 } } }),
|
||||||
|
ctx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }),
|
||||||
|
ctx.db.customer.update({
|
||||||
|
where: { id: sourceId },
|
||||||
|
data: { status: "merged", mergedIntoId: targetId, isProvisional: false },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const moved = { contacts: contacts.count, sites: sites.count, workOrders: workOrders.count, documents: documents.count };
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "update",
|
||||||
|
entity: "customer",
|
||||||
|
entityId: sourceId,
|
||||||
|
before: source,
|
||||||
|
after: { ...mergedSource, merge: { role: "source", targetId, moved } },
|
||||||
|
});
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "update",
|
||||||
|
entity: "customer",
|
||||||
|
entityId: targetId,
|
||||||
|
before: target,
|
||||||
|
after: { merge: { role: "target", sourceId, moved } },
|
||||||
|
});
|
||||||
|
return { sourceId, targetId, moved };
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
/** Empty strings become null; strings are trimmed and length-limited. */
|
||||||
|
export const optStr = (max: number) =>
|
||||||
|
z.preprocess((v) => (typeof v === "string" && v.trim() === "" ? null : v), z.string().trim().max(max).nullable().optional());
|
||||||
|
|
||||||
|
export const optEmail = () =>
|
||||||
|
z.preprocess(
|
||||||
|
(v) => (typeof v === "string" && v.trim() === "" ? null : typeof v === "string" ? v.trim().toLowerCase() : v),
|
||||||
|
z.string().max(200).email().nullable().optional(),
|
||||||
|
);
|
||||||
|
|
||||||
|
export const CUSTOMER_EDITABLE_STATUSES = ["active", "inactive", "provisional"] as const;
|
||||||
|
|
||||||
|
const customerFields = {
|
||||||
|
customerNumber: optStr(40),
|
||||||
|
companyName: optStr(200),
|
||||||
|
salutation: optStr(40),
|
||||||
|
firstName: optStr(100),
|
||||||
|
lastName: optStr(100),
|
||||||
|
street: optStr(200),
|
||||||
|
houseNumber: optStr(20),
|
||||||
|
postalCode: optStr(12),
|
||||||
|
city: optStr(100),
|
||||||
|
country: z.preprocess(
|
||||||
|
(v) => (typeof v === "string" && v.trim() === "" ? undefined : typeof v === "string" ? v.trim().toUpperCase() : v),
|
||||||
|
z.string().regex(/^[A-Z]{2}$/).optional(),
|
||||||
|
),
|
||||||
|
phone: optStr(50),
|
||||||
|
mobile: optStr(50),
|
||||||
|
email: optEmail(),
|
||||||
|
notes: optStr(5000),
|
||||||
|
billingNotes: optStr(5000),
|
||||||
|
status: z.enum(CUSTOMER_EDITABLE_STATUSES).optional(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const nameRequired = (v: { companyName?: string | null; lastName?: string | null }) => Boolean(v.companyName || v.lastName);
|
||||||
|
|
||||||
|
export const customerCreateSchema = z
|
||||||
|
.object(customerFields)
|
||||||
|
.refine(nameRequired, { message: "name_required", path: ["companyName"] });
|
||||||
|
|
||||||
|
/** PATCH semantics: absent = unchanged, null = cleared. */
|
||||||
|
export const customerPatchSchema = z.object(customerFields).partial();
|
||||||
|
|
||||||
|
export type CustomerCreateInput = z.input<typeof customerCreateSchema>;
|
||||||
|
export type CustomerPatchInput = z.input<typeof customerPatchSchema>;
|
||||||
|
|
||||||
|
export const CONTACT_CHANNELS = ["phone", "mobile", "email"] as const;
|
||||||
|
|
||||||
|
export const contactSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
role: optStr(100),
|
||||||
|
phone: optStr(50),
|
||||||
|
mobile: optStr(50),
|
||||||
|
email: optEmail(),
|
||||||
|
preferredChannel: z.preprocess((v) => (v === "" ? null : v), z.enum(CONTACT_CHANNELS).nullable().optional()),
|
||||||
|
notes: optStr(2000),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type ContactInput = z.input<typeof contactSchema>;
|
||||||
|
|
||||||
|
export const mergeSchema = z
|
||||||
|
.object({
|
||||||
|
sourceId: z.string().min(1),
|
||||||
|
targetId: z.string().min(1),
|
||||||
|
// explicit confirmation is mandatory (spec §7.3: never merge without confirmation)
|
||||||
|
confirm: z.literal(true),
|
||||||
|
})
|
||||||
|
.refine((v) => v.sourceId !== v.targetId, { message: "same_customer", path: ["targetId"] });
|
||||||
|
|
||||||
|
export type MergeInput = z.input<typeof mergeSchema>;
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { DocumentCategory, DocumentVisibility, type Prisma } from "@prisma/client";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { storage, type StoredContent } from "@/server/storage/adapter";
|
||||||
|
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { allowedDocumentVisibility, customerScope, siteScope, workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read side of the document service: visibility filter, download authorization, listing,
|
||||||
|
* metadata changes and soft delete (spec §24.3, ARCHITEKTUR §2 "Dokument-Sichtbarkeit").
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Documents the user may read:
|
||||||
|
* - `document:read` required;
|
||||||
|
* - visibility ∈ allowedDocumentVisibility(ctx) (backoffice_only needs document:read_internal);
|
||||||
|
* - users without `work_order:read_all`: linked work order in `workOrderScope`, or — for documents
|
||||||
|
* without an order — site in `siteScope` / customer in `customerScope`. Unlinked documents
|
||||||
|
* (e.g. import originals) are backoffice-only.
|
||||||
|
*/
|
||||||
|
export async function documentReadWhere(ctx: ServiceCtx): Promise<Prisma.DocumentWhereInput> {
|
||||||
|
if (!can(ctx, "document:read")) return { id: "__none__" };
|
||||||
|
const base: Prisma.DocumentWhereInput = {
|
||||||
|
deletedAt: null,
|
||||||
|
uploadStatus: "uploaded",
|
||||||
|
visibility: { in: allowedDocumentVisibility(ctx) },
|
||||||
|
};
|
||||||
|
if (can(ctx, "work_order:read_all")) return base;
|
||||||
|
const [wo, site, customer] = await Promise.all([workOrderScope(ctx), siteScope(ctx), customerScope(ctx)]);
|
||||||
|
return {
|
||||||
|
AND: [
|
||||||
|
base,
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ workOrderId: { not: null }, workOrder: { is: wo } },
|
||||||
|
{ workOrderId: null, siteId: { not: null }, site: { is: site } },
|
||||||
|
{ workOrderId: null, siteId: null, customerId: { not: null }, customer: { is: customer } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load a document the user may read, or throw `not_found` (never reveals existence). */
|
||||||
|
export async function authorizeDocumentAccess(ctx: ServiceCtx, documentId: string) {
|
||||||
|
const document = await ctx.db.document.findFirst({ where: { AND: [{ id: documentId }, await documentReadWhere(ctx)] } });
|
||||||
|
if (!document) throw new ServiceError("not_found", "document not found");
|
||||||
|
return document;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal download link — authorization happens again on every request to the route. */
|
||||||
|
export async function getDownloadUrl(ctx: ServiceCtx, documentId: string): Promise<string> {
|
||||||
|
const document = await authorizeDocumentAccess(ctx, documentId);
|
||||||
|
return documentHref(document.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function documentHref(documentId: string): string {
|
||||||
|
return `/files/${encodeURIComponent(documentId)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Authorize and open the stored bytes (used by /files/[documentId]). */
|
||||||
|
export async function openDocumentContent(ctx: ServiceCtx, documentId: string): Promise<{ document: Awaited<ReturnType<typeof authorizeDocumentAccess>>; content: StoredContent }> {
|
||||||
|
const document = await authorizeDocumentAccess(ctx, documentId);
|
||||||
|
// defense in depth: the key must carry the tenant prefix
|
||||||
|
if (!document.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "document content not available");
|
||||||
|
const content = await storage.get(document.storageKey);
|
||||||
|
if (!content) throw new ServiceError("not_found", "document content not available");
|
||||||
|
return { document, content };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DocumentListFilter = {
|
||||||
|
category?: DocumentCategory;
|
||||||
|
customerId?: string;
|
||||||
|
siteId?: string;
|
||||||
|
workOrderId?: string;
|
||||||
|
q?: string;
|
||||||
|
/** Only the newest version of each lineage. */
|
||||||
|
latestOnly?: boolean;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listDocuments(ctx: ServiceCtx, filter: DocumentListFilter = {}) {
|
||||||
|
const page = Math.max(1, filter.page ?? 1);
|
||||||
|
const pageSize = Math.min(500, Math.max(1, filter.pageSize ?? 25));
|
||||||
|
const and: Prisma.DocumentWhereInput[] = [await documentReadWhere(ctx)];
|
||||||
|
if (filter.category) and.push({ category: filter.category });
|
||||||
|
if (filter.workOrderId) and.push({ workOrderId: filter.workOrderId });
|
||||||
|
if (filter.siteId) and.push({ OR: [{ siteId: filter.siteId }, { workOrder: { is: { siteId: filter.siteId } } }] });
|
||||||
|
if (filter.customerId) {
|
||||||
|
and.push({
|
||||||
|
OR: [
|
||||||
|
{ customerId: filter.customerId },
|
||||||
|
{ site: { is: { customerId: filter.customerId } } },
|
||||||
|
{ workOrder: { is: { customerId: filter.customerId } } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (filter.q?.trim()) {
|
||||||
|
const q = filter.q.trim();
|
||||||
|
and.push({ OR: [{ fileName: { contains: q, mode: "insensitive" } }, { title: { contains: q, mode: "insensitive" } }] });
|
||||||
|
}
|
||||||
|
|
||||||
|
let where: Prisma.DocumentWhereInput = { AND: and };
|
||||||
|
if (filter.latestOnly) {
|
||||||
|
const groups = await ctx.db.document.groupBy({ by: ["lineageId"], where, _max: { version: true } });
|
||||||
|
where = { AND: [where, { OR: groups.map((g) => ({ lineageId: g.lineageId, version: g._max.version ?? 1 })) }] };
|
||||||
|
if (groups.length === 0) return { items: [], total: 0, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
ctx.db.document.count({ where }),
|
||||||
|
ctx.db.document.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ createdAt: "desc" }, { version: "desc" }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
fileName: true,
|
||||||
|
category: true,
|
||||||
|
visibility: true,
|
||||||
|
mimeType: true,
|
||||||
|
fileSize: true,
|
||||||
|
checksum: true,
|
||||||
|
version: true,
|
||||||
|
lineageId: true,
|
||||||
|
approvalStatus: true,
|
||||||
|
uploadedById: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true,
|
||||||
|
customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true } },
|
||||||
|
site: { select: { id: true, name: true } },
|
||||||
|
workOrder: { select: { id: true, number: true, title: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
const metaPatchSchema = z.object({
|
||||||
|
title: z.preprocess((v) => (typeof v === "string" && v.trim() === "" ? null : v), z.string().trim().max(300).nullable().optional()),
|
||||||
|
category: z.enum(DocumentCategory).optional(),
|
||||||
|
visibility: z.enum(DocumentVisibility).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function updateDocumentMeta(ctx: ServiceCtx, documentId: string, input: z.input<typeof metaPatchSchema>) {
|
||||||
|
assertCan(ctx, "document:write");
|
||||||
|
const data = metaPatchSchema.parse(input);
|
||||||
|
const before = await authorizeDocumentAccess(ctx, documentId);
|
||||||
|
if (data.visibility && !allowedDocumentVisibility(ctx).includes(data.visibility)) {
|
||||||
|
throw new ServiceError("invalid", "visibility not allowed", { field: "visibility", reason: "visibility_not_allowed" });
|
||||||
|
}
|
||||||
|
const after = await ctx.db.document.update({ where: { id: documentId }, data });
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "update",
|
||||||
|
entity: "document",
|
||||||
|
entityId: documentId,
|
||||||
|
before: { title: before.title, category: before.category, visibility: before.visibility },
|
||||||
|
after: { title: after.title, category: after.category, visibility: after.visibility },
|
||||||
|
});
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft delete of one document version. */
|
||||||
|
export async function deleteDocument(ctx: ServiceCtx, documentId: string) {
|
||||||
|
assertCan(ctx, "document:write");
|
||||||
|
const before = await authorizeDocumentAccess(ctx, documentId);
|
||||||
|
const after = await ctx.db.document.update({ where: { id: documentId }, data: { deletedAt: new Date() } });
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "delete",
|
||||||
|
entity: "document",
|
||||||
|
entityId: documentId,
|
||||||
|
before: { fileName: before.fileName, version: before.version, lineageId: before.lineageId },
|
||||||
|
after: { deletedAt: after.deletedAt },
|
||||||
|
});
|
||||||
|
return after;
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { connect } from "node:net";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* File scanning (ARCHITEKTUR §4.3, spec §27.4). MVP: magic-byte/type verification against an
|
||||||
|
* allowlist. If CLAMAV_HOST is set, the bytes are additionally streamed to clamd (INSTREAM).
|
||||||
|
* Scanners never throw for bad content — they return a structured verdict.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type DetectedKind = "pdf" | "image" | "audio";
|
||||||
|
|
||||||
|
export type ScanVerdict =
|
||||||
|
| { ok: true; detectedMime: string; kind: DetectedKind }
|
||||||
|
| { ok: false; reason: "unsupported_type" | "type_mismatch" | "malware" | "scanner_unavailable"; detail?: string };
|
||||||
|
|
||||||
|
export interface FileScanner {
|
||||||
|
name: string;
|
||||||
|
scan(input: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise<ScanVerdict>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Allowlisted MIME types → kind. */
|
||||||
|
export const ALLOWED_MIME: Record<string, DetectedKind> = {
|
||||||
|
"application/pdf": "pdf",
|
||||||
|
"image/jpeg": "image",
|
||||||
|
"image/png": "image",
|
||||||
|
"image/webp": "image",
|
||||||
|
"image/heic": "image",
|
||||||
|
"audio/webm": "audio",
|
||||||
|
"audio/ogg": "audio",
|
||||||
|
"audio/mp4": "audio",
|
||||||
|
"audio/mpeg": "audio",
|
||||||
|
"audio/wav": "audio",
|
||||||
|
};
|
||||||
|
|
||||||
|
const MIME_ALIASES: Record<string, string> = {
|
||||||
|
"image/jpg": "image/jpeg",
|
||||||
|
"image/pjpeg": "image/jpeg",
|
||||||
|
"image/heif": "image/heic",
|
||||||
|
"audio/x-wav": "audio/wav",
|
||||||
|
"audio/wave": "audio/wav",
|
||||||
|
"audio/x-m4a": "audio/mp4",
|
||||||
|
"audio/m4a": "audio/mp4",
|
||||||
|
"audio/mp3": "audio/mpeg",
|
||||||
|
"video/webm": "audio/webm", // MediaRecorder often labels audio-only recordings as video/webm
|
||||||
|
};
|
||||||
|
|
||||||
|
export function canonicalMime(mime: string): string {
|
||||||
|
const base = mime.split(";")[0].trim().toLowerCase();
|
||||||
|
return MIME_ALIASES[base] ?? base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startsWith(bytes: Uint8Array, sig: number[], offset = 0): boolean {
|
||||||
|
if (bytes.length < offset + sig.length) return false;
|
||||||
|
return sig.every((b, i) => bytes[offset + i] === b);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ascii(bytes: Uint8Array, start: number, end: number): string {
|
||||||
|
return String.fromCharCode(...bytes.slice(start, end));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Detect the real MIME type from the leading bytes; null if not on the allowlist. */
|
||||||
|
export function detectMime(bytes: Uint8Array): string | null {
|
||||||
|
if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"; // %PDF-
|
||||||
|
if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg";
|
||||||
|
if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png";
|
||||||
|
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "image/webp";
|
||||||
|
if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WAVE") return "audio/wav";
|
||||||
|
if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return "audio/webm"; // EBML (WebM/Matroska)
|
||||||
|
if (ascii(bytes, 0, 4) === "OggS") return "audio/ogg";
|
||||||
|
if (ascii(bytes, 0, 3) === "ID3" || startsWith(bytes, [0xff, 0xfb]) || startsWith(bytes, [0xff, 0xf3])) return "audio/mpeg";
|
||||||
|
if (ascii(bytes, 4, 8) === "ftyp") {
|
||||||
|
const brand = ascii(bytes, 8, 12);
|
||||||
|
if (["heic", "heix", "mif1", "msf1", "heim", "heis"].includes(brand)) return "image/heic";
|
||||||
|
if (["M4A ", "mp42", "isom", "dash", "iso5", "iso6"].includes(brand)) return "audio/mp4";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MagicByteScanner implements FileScanner {
|
||||||
|
name = "magic-bytes";
|
||||||
|
|
||||||
|
async scan({ bytes, declaredMime }: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise<ScanVerdict> {
|
||||||
|
const declared = canonicalMime(declaredMime);
|
||||||
|
if (!ALLOWED_MIME[declared]) return { ok: false, reason: "unsupported_type", detail: declared };
|
||||||
|
const detected = detectMime(bytes);
|
||||||
|
if (!detected) return { ok: false, reason: "type_mismatch", detail: "unknown signature" };
|
||||||
|
if (detected !== declared) return { ok: false, reason: "type_mismatch", detail: `${declared} ≠ ${detected}` };
|
||||||
|
return { ok: true, detectedMime: detected, kind: ALLOWED_MIME[detected] };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** clamd INSTREAM client (only active if CLAMAV_HOST is configured). */
|
||||||
|
export class ClamAvScanner implements FileScanner {
|
||||||
|
name = "clamav";
|
||||||
|
constructor(
|
||||||
|
private host: string,
|
||||||
|
private port: number,
|
||||||
|
private timeoutMs = 15_000,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
scan({ bytes }: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise<ScanVerdict> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const socket = connect({ host: this.host, port: this.port });
|
||||||
|
let response = "";
|
||||||
|
const done = (v: ScanVerdict) => {
|
||||||
|
socket.destroy();
|
||||||
|
resolve(v);
|
||||||
|
};
|
||||||
|
socket.setTimeout(this.timeoutMs, () => done({ ok: false, reason: "scanner_unavailable", detail: "timeout" }));
|
||||||
|
socket.on("error", (err) => done({ ok: false, reason: "scanner_unavailable", detail: err.message }));
|
||||||
|
socket.on("data", (chunk) => (response += chunk.toString("utf8")));
|
||||||
|
socket.on("end", () => {
|
||||||
|
if (/OK\s*\0?$/.test(response.trim())) done({ ok: true, detectedMime: "", kind: "pdf" });
|
||||||
|
else if (/FOUND/.test(response)) done({ ok: false, reason: "malware", detail: response.trim() });
|
||||||
|
else done({ ok: false, reason: "scanner_unavailable", detail: response.trim() });
|
||||||
|
});
|
||||||
|
socket.on("connect", () => {
|
||||||
|
socket.write("zINSTREAM\0");
|
||||||
|
const chunkSize = 64 * 1024;
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize);
|
||||||
|
const len = Buffer.alloc(4);
|
||||||
|
len.writeUInt32BE(chunk.length, 0);
|
||||||
|
socket.write(len);
|
||||||
|
socket.write(chunk);
|
||||||
|
}
|
||||||
|
socket.write(Buffer.alloc(4)); // zero-length chunk terminates the stream
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Magic bytes first (cheap, authoritative for the stored MIME), then optional ClamAV. */
|
||||||
|
export class CompositeScanner implements FileScanner {
|
||||||
|
name: string;
|
||||||
|
constructor(private primary: FileScanner, private extra: FileScanner[]) {
|
||||||
|
this.name = [primary.name, ...extra.map((s) => s.name)].join("+");
|
||||||
|
}
|
||||||
|
|
||||||
|
async scan(input: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise<ScanVerdict> {
|
||||||
|
const first = await this.primary.scan(input);
|
||||||
|
if (!first.ok) return first;
|
||||||
|
for (const s of this.extra) {
|
||||||
|
const v = await s.scan(input);
|
||||||
|
if (!v.ok) return v;
|
||||||
|
}
|
||||||
|
return first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let scanner: FileScanner | null = null;
|
||||||
|
|
||||||
|
export function getFileScanner(): FileScanner {
|
||||||
|
if (scanner) return scanner;
|
||||||
|
const host = process.env.CLAMAV_HOST?.trim();
|
||||||
|
const magic = new MagicByteScanner();
|
||||||
|
scanner = host ? new CompositeScanner(magic, [new ClamAvScanner(host, Number(process.env.CLAMAV_PORT ?? 3310))]) : magic;
|
||||||
|
return scanner;
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
import { z } from "zod";
|
||||||
|
import { DocumentCategory, DocumentVisibility, type Document } from "@prisma/client";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { storage } from "@/server/storage/adapter";
|
||||||
|
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { allowedDocumentVisibility, requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||||
|
import { getFileScanner, type DetectedKind, type FileScanner } from "@/server/services/documents/scanner";
|
||||||
|
import { documentReadWhere } from "@/server/services/documents/access";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Document storage service (ARCHITEKTUR §4.3, spec §24, §27.4). Owned by lane "stammdaten";
|
||||||
|
* every lane stores files ONLY through `storeFile` and links downloads via `getDownloadUrl`
|
||||||
|
* (see ./access.ts).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
/** Size limits per detected kind (ARCHITEKTUR §4.3). */
|
||||||
|
export const SIZE_LIMITS: Record<DetectedKind, number> = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB };
|
||||||
|
export const MAX_UPLOAD_BYTES = Math.max(...Object.values(SIZE_LIMITS));
|
||||||
|
|
||||||
|
export const DOCUMENT_CATEGORIES = Object.values(DocumentCategory);
|
||||||
|
export const DOCUMENT_VISIBILITIES = Object.values(DocumentVisibility);
|
||||||
|
|
||||||
|
export type StoreFileInput = {
|
||||||
|
bytes: Uint8Array;
|
||||||
|
fileName: string;
|
||||||
|
declaredMime: string;
|
||||||
|
category: DocumentCategory;
|
||||||
|
visibility: DocumentVisibility;
|
||||||
|
title?: string | null;
|
||||||
|
links?: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
|
||||||
|
/** Existing lineage → stored as the next version of that document. */
|
||||||
|
lineageId?: string | null;
|
||||||
|
approvalStatus?: "draft" | "approved" | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const metaSchema = z.object({
|
||||||
|
fileName: z.string().min(1).max(500),
|
||||||
|
declaredMime: z.string().min(1).max(200),
|
||||||
|
category: z.enum(DocumentCategory),
|
||||||
|
visibility: z.enum(DocumentVisibility),
|
||||||
|
title: z.string().trim().max(300).nullable().optional(),
|
||||||
|
lineageId: z.string().min(1).max(64).nullable().optional(),
|
||||||
|
approvalStatus: z.enum(["draft", "approved"]).nullable().optional(),
|
||||||
|
links: z
|
||||||
|
.object({
|
||||||
|
customerId: z.string().min(1).nullable().optional(),
|
||||||
|
siteId: z.string().min(1).nullable().optional(),
|
||||||
|
workOrderId: z.string().min(1).nullable().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a user-supplied file name: strip any path, control and reserved characters, unify
|
||||||
|
* Unicode (NFC), collapse whitespace, keep the extension, limit length. Never empty.
|
||||||
|
*/
|
||||||
|
export function normalizeFileName(name: string): string {
|
||||||
|
const base = name.split(/[\\/]/).pop() ?? "";
|
||||||
|
const cleaned = base
|
||||||
|
.normalize("NFC")
|
||||||
|
.replace(/[\x00-\x1f\x7f]/g, "")
|
||||||
|
.replace(/[<>:"|?*]/g, "_")
|
||||||
|
.replace(/\s+/g, " ")
|
||||||
|
.replace(/^[.\s]+/, "")
|
||||||
|
.trim();
|
||||||
|
if (!cleaned) return "datei";
|
||||||
|
const MAX = 180;
|
||||||
|
if (cleaned.length <= MAX) return cleaned;
|
||||||
|
const dot = cleaned.lastIndexOf(".");
|
||||||
|
const ext = dot > 0 && cleaned.length - dot <= 10 ? cleaned.slice(dot) : "";
|
||||||
|
return cleaned.slice(0, MAX - ext.length) + ext;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sha256Hex(bytes: Uint8Array): string {
|
||||||
|
return createHash("sha256").update(bytes).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Who may attach a file where (the caller's own action guard stays in place in addition). */
|
||||||
|
async function assertMayAttach(ctx: ServiceCtx, links: NonNullable<StoreFileInput["links"]>) {
|
||||||
|
if (links.workOrderId) {
|
||||||
|
// field roles attach photos/voice notes/signatures to orders in their scope
|
||||||
|
if (!["document:write", "field:execute", "report:write", "emergency:create"].some((p) => can(ctx, p))) {
|
||||||
|
throw new ServiceError("forbidden", "missing permission to attach documents");
|
||||||
|
}
|
||||||
|
await requireVisibleWorkOrder(ctx, links.workOrderId, { id: true });
|
||||||
|
} else if (links.siteId || links.customerId) {
|
||||||
|
assertCan(ctx, "document:write");
|
||||||
|
} else if (!can(ctx, "document:write") && !can(ctx, "import:write")) {
|
||||||
|
// unlinked originals (e.g. PDF imports) are backoffice material
|
||||||
|
throw new ServiceError("forbidden", "missing permission document:write");
|
||||||
|
}
|
||||||
|
if (links.siteId) {
|
||||||
|
const site = await ctx.db.site.findFirst({ where: { id: links.siteId, deletedAt: null }, select: { id: true } });
|
||||||
|
if (!site) throw new ServiceError("invalid", "site not found", { field: "siteId", reason: "site_not_found" });
|
||||||
|
}
|
||||||
|
if (links.customerId) {
|
||||||
|
const customer = await ctx.db.customer.findFirst({ where: { id: links.customerId, deletedAt: null }, select: { id: true } });
|
||||||
|
if (!customer) throw new ServiceError("invalid", "customer not found", { field: "customerId", reason: "customer_not_found" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and store a file, creating a `Document` row.
|
||||||
|
* Order: metadata → size → magic bytes/scanner → permission/links → visibility → storage → DB → audit.
|
||||||
|
* Rejections are `ServiceError("invalid", …, { reason })` with reason
|
||||||
|
* `empty_file | too_large | unsupported_type | type_mismatch | malware | scanner_unavailable |
|
||||||
|
* visibility_not_allowed | lineage_not_found`.
|
||||||
|
*/
|
||||||
|
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput, deps: { scanner?: FileScanner } = {}): Promise<Document> {
|
||||||
|
const meta = metaSchema.parse({ ...input, bytes: undefined });
|
||||||
|
const bytes = input.bytes;
|
||||||
|
if (!bytes || bytes.byteLength === 0) throw new ServiceError("invalid", "empty file", { field: "file", reason: "empty_file" });
|
||||||
|
if (bytes.byteLength > MAX_UPLOAD_BYTES) throw new ServiceError("invalid", "file too large", { field: "file", reason: "too_large" });
|
||||||
|
|
||||||
|
const fileName = normalizeFileName(meta.fileName);
|
||||||
|
const verdict = await (deps.scanner ?? getFileScanner()).scan({ bytes, declaredMime: meta.declaredMime, fileName });
|
||||||
|
if (!verdict.ok) throw new ServiceError("invalid", `file rejected: ${verdict.reason}`, { field: "file", reason: verdict.reason });
|
||||||
|
if (bytes.byteLength > SIZE_LIMITS[verdict.kind]) {
|
||||||
|
throw new ServiceError("invalid", "file too large", { field: "file", reason: "too_large", limit: SIZE_LIMITS[verdict.kind] });
|
||||||
|
}
|
||||||
|
|
||||||
|
let links = { customerId: meta.links?.customerId ?? null, siteId: meta.links?.siteId ?? null, workOrderId: meta.links?.workOrderId ?? null };
|
||||||
|
let lineageId: string = randomUUID();
|
||||||
|
let version = 1;
|
||||||
|
if (meta.lineageId) {
|
||||||
|
// a new version is only possible for a document the user may read
|
||||||
|
const previous = await ctx.db.document.findFirst({
|
||||||
|
where: { AND: [{ lineageId: meta.lineageId }, await documentReadWhere(ctx)] },
|
||||||
|
orderBy: { version: "desc" },
|
||||||
|
});
|
||||||
|
if (!previous) throw new ServiceError("invalid", "lineage not found", { field: "lineageId", reason: "lineage_not_found" });
|
||||||
|
lineageId = previous.lineageId;
|
||||||
|
version = previous.version + 1;
|
||||||
|
if (!links.customerId && !links.siteId && !links.workOrderId) {
|
||||||
|
links = { customerId: previous.customerId, siteId: previous.siteId, workOrderId: previous.workOrderId };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await assertMayAttach(ctx, links);
|
||||||
|
if (!allowedDocumentVisibility(ctx).includes(meta.visibility)) {
|
||||||
|
throw new ServiceError("invalid", "visibility not allowed", { field: "visibility", reason: "visibility_not_allowed" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const checksum = sha256Hex(bytes);
|
||||||
|
const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: verdict.detectedMime, bytes });
|
||||||
|
|
||||||
|
let document: Document | null = null;
|
||||||
|
for (let attempt = 0; attempt < 2 && !document; attempt++) {
|
||||||
|
try {
|
||||||
|
document = await ctx.db.document.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
...links,
|
||||||
|
category: meta.category,
|
||||||
|
title: meta.title ?? null,
|
||||||
|
fileName,
|
||||||
|
storageKey: stored.storageKey,
|
||||||
|
mimeType: verdict.detectedMime,
|
||||||
|
fileSize: bytes.byteLength,
|
||||||
|
checksum,
|
||||||
|
version,
|
||||||
|
lineageId,
|
||||||
|
visibility: meta.visibility,
|
||||||
|
approvalStatus: meta.approvalStatus ?? null,
|
||||||
|
uploadStatus: "uploaded",
|
||||||
|
uploadedById: ctx.userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// concurrent new version of the same lineage → take the next number once
|
||||||
|
if ((err as { code?: string }).code !== "P2002" || attempt > 0 || !meta.lineageId) throw err;
|
||||||
|
const latest = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } });
|
||||||
|
version = (latest?.version ?? version) + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!document) throw new ServiceError("conflict", "could not store document version");
|
||||||
|
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "create",
|
||||||
|
entity: "document",
|
||||||
|
entityId: document.id,
|
||||||
|
after: {
|
||||||
|
fileName,
|
||||||
|
category: document.category,
|
||||||
|
visibility: document.visibility,
|
||||||
|
mimeType: document.mimeType,
|
||||||
|
fileSize: document.fileSize,
|
||||||
|
checksum,
|
||||||
|
version,
|
||||||
|
lineageId,
|
||||||
|
links,
|
||||||
|
scanner: (deps.scanner ?? getFileScanner()).name,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return document;
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import type { Prisma, WorkOrderStatus } from "@prisma/client";
|
||||||
|
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { siteScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
export type SiteHistoryEntry = {
|
||||||
|
workOrderId: string;
|
||||||
|
number: string;
|
||||||
|
title: string;
|
||||||
|
date: Date;
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
isEmergency: boolean;
|
||||||
|
orderType: string | null;
|
||||||
|
team: string | null;
|
||||||
|
/** Texts of ActivityNote kind work_done, oldest first. */
|
||||||
|
workDone: string[];
|
||||||
|
/** Short summary for list views (≤ 280 chars). */
|
||||||
|
summary: string;
|
||||||
|
materials: { name: string; unit: string; quantity: number }[];
|
||||||
|
photoCount: number;
|
||||||
|
approvedReports: { id: string; type: "daily" | "completion"; reportDate: Date; version: number }[];
|
||||||
|
signed: boolean;
|
||||||
|
followUps: string[];
|
||||||
|
hasOpenFollowUp: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SUMMARY_MAX = 280;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chronological deployment history of a site (spec §8.3, US-005, US-011) — newest first.
|
||||||
|
*
|
||||||
|
* Access: the site itself must be visible (`siteScope`: backoffice all, field roles only via a
|
||||||
|
* visible work order at the site — otherwise `not_found`).
|
||||||
|
* Field roles (no `work_order:read_all`) ALWAYS get only released deployments — work orders with an
|
||||||
|
* approved report — regardless of `onlyApproved` (US-011 "Liste aller freigegebenen Einsätze");
|
||||||
|
* this is the history of the site, so released deployments of other teams are included (US-005).
|
||||||
|
* Internal notes are never part of the result. Backoffice may pass `onlyApproved=false`.
|
||||||
|
*/
|
||||||
|
export async function getSiteHistory(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
siteId: string,
|
||||||
|
opts: { onlyApproved?: boolean; page?: number; pageSize?: number } = {},
|
||||||
|
): Promise<{ items: SiteHistoryEntry[]; total: number; page: number; pageSize: number; onlyApproved: boolean }> {
|
||||||
|
assertCan(ctx, "site:read");
|
||||||
|
const site = await ctx.db.site.findFirst({ where: { AND: [{ id: siteId }, await siteScope(ctx)] }, select: { id: true } });
|
||||||
|
if (!site) throw new ServiceError("not_found", "site not found");
|
||||||
|
|
||||||
|
const onlyApproved = !can(ctx, "work_order:read_all") || opts.onlyApproved === true;
|
||||||
|
const page = Math.max(1, opts.page ?? 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 50));
|
||||||
|
|
||||||
|
const where: Prisma.WorkOrderWhereInput = {
|
||||||
|
siteId,
|
||||||
|
deletedAt: null,
|
||||||
|
...(onlyApproved ? { reports: { some: { status: "approved" } } } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const orders = await ctx.db.workOrder.findMany({
|
||||||
|
where,
|
||||||
|
take: 1000,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
number: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
isEmergency: true,
|
||||||
|
plannedStart: true,
|
||||||
|
createdAt: true,
|
||||||
|
followUpWork: true,
|
||||||
|
orderType: { select: { name: true } },
|
||||||
|
team: { select: { name: true } },
|
||||||
|
workSessions: { select: { startedAt: true }, orderBy: { startedAt: "asc" }, take: 1 },
|
||||||
|
notes: {
|
||||||
|
where: { deletedAt: null, kind: { in: ["work_done", "follow_up"] } },
|
||||||
|
select: { kind: true, text: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
},
|
||||||
|
materialUsages: { where: { usageStatus: { not: "not_used" } }, select: { name: true, unit: true, actualQuantity: true } },
|
||||||
|
_count: { select: { photos: true } },
|
||||||
|
reports: {
|
||||||
|
where: { status: { not: "superseded" } },
|
||||||
|
select: { id: true, type: true, reportDate: true, version: true, status: true, signature: { select: { outcome: true } } },
|
||||||
|
orderBy: { reportDate: "asc" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const entries: SiteHistoryEntry[] = orders.map((o) => {
|
||||||
|
const workDone = o.notes.filter((n) => n.kind === "work_done").map((n) => n.text);
|
||||||
|
const followUps = [
|
||||||
|
...(o.followUpWork?.trim() ? [o.followUpWork.trim()] : []),
|
||||||
|
...o.notes.filter((n) => n.kind === "follow_up").map((n) => n.text),
|
||||||
|
];
|
||||||
|
const materialMap = new Map<string, { name: string; unit: string; quantity: number }>();
|
||||||
|
for (const m of o.materialUsages) {
|
||||||
|
const key = `${m.name.trim().toLowerCase()}|${m.unit.trim().toLowerCase()}`;
|
||||||
|
const entry = materialMap.get(key) ?? { name: m.name.trim(), unit: m.unit.trim(), quantity: 0 };
|
||||||
|
entry.quantity = Math.round((entry.quantity + Number(m.actualQuantity.toString())) * 1000) / 1000;
|
||||||
|
materialMap.set(key, entry);
|
||||||
|
}
|
||||||
|
const joined = workDone.join(" · ");
|
||||||
|
const reports = onlyApproved ? o.reports.filter((r) => r.status === "approved") : o.reports;
|
||||||
|
return {
|
||||||
|
workOrderId: o.id,
|
||||||
|
number: o.number,
|
||||||
|
title: o.title,
|
||||||
|
date: o.workSessions[0]?.startedAt ?? o.plannedStart ?? o.createdAt,
|
||||||
|
status: o.status,
|
||||||
|
isEmergency: o.isEmergency,
|
||||||
|
orderType: o.orderType?.name ?? null,
|
||||||
|
team: o.team?.name ?? null,
|
||||||
|
workDone,
|
||||||
|
summary: joined.length > SUMMARY_MAX ? `${joined.slice(0, SUMMARY_MAX - 1)}…` : joined,
|
||||||
|
materials: [...materialMap.values()].sort((a, b) => a.name.localeCompare(b.name, "de")),
|
||||||
|
photoCount: o._count.photos,
|
||||||
|
approvedReports: o.reports
|
||||||
|
.filter((r) => r.status === "approved")
|
||||||
|
.map((r) => ({ id: r.id, type: r.type, reportDate: r.reportDate, version: r.version })),
|
||||||
|
signed: reports.some((r) => r.signature?.outcome === "signed"),
|
||||||
|
followUps,
|
||||||
|
hasOpenFollowUp: followUps.length > 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
entries.sort((a, b) => b.date.getTime() - a.date.getTime());
|
||||||
|
const total = entries.length;
|
||||||
|
return { items: entries.slice((page - 1) * pageSize, page * pageSize), total, page, pageSize, onlyApproved };
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// OpenStreetMap link for a site (spec §8.1) — plain URL, no embed (no third-party requests from the app).
|
||||||
|
|
||||||
|
export function siteMapUrl(site: {
|
||||||
|
street?: string | null;
|
||||||
|
houseNumber?: string | null;
|
||||||
|
postalCode?: string | null;
|
||||||
|
city?: string | null;
|
||||||
|
country?: string | null;
|
||||||
|
latitude?: number | null;
|
||||||
|
longitude?: number | null;
|
||||||
|
}): string | null {
|
||||||
|
if (typeof site.latitude === "number" && typeof site.longitude === "number") {
|
||||||
|
const lat = site.latitude.toFixed(6);
|
||||||
|
const lon = site.longitude.toFixed(6);
|
||||||
|
return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=18/${lat}/${lon}`;
|
||||||
|
}
|
||||||
|
const line = [[site.street, site.houseNumber].filter(Boolean).join(" "), [site.postalCode, site.city].filter(Boolean).join(" "), site.country]
|
||||||
|
.filter((s) => s && String(s).trim())
|
||||||
|
.join(", ");
|
||||||
|
if (!site.city && !site.postalCode) return null;
|
||||||
|
return `https://www.openstreetmap.org/search?query=${encodeURIComponent(line)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { siteScope } from "@/server/services/work-orders/visibility";
|
||||||
|
import { optStr } from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
export const SITE_STATUSES = ["active", "inactive", "provisional"] as const;
|
||||||
|
|
||||||
|
const optCoord = (min: number, max: number) =>
|
||||||
|
z.preprocess((v) => (v === "" || v === undefined ? undefined : v === null ? null : Number(String(v).replace(",", "."))), z.number().min(min).max(max).nullable().optional());
|
||||||
|
|
||||||
|
const siteFields = {
|
||||||
|
customerId: z.string().min(1),
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
street: optStr(200),
|
||||||
|
houseNumber: optStr(20),
|
||||||
|
postalCode: optStr(12),
|
||||||
|
city: optStr(100),
|
||||||
|
country: z.preprocess(
|
||||||
|
(v) => (typeof v === "string" && v.trim() === "" ? undefined : typeof v === "string" ? v.trim().toUpperCase() : v),
|
||||||
|
z.string().regex(/^[A-Z]{2}$/).optional(),
|
||||||
|
),
|
||||||
|
contactId: optStr(64),
|
||||||
|
onSiteContact: optStr(200),
|
||||||
|
phone: optStr(50),
|
||||||
|
accessNotes: optStr(5000),
|
||||||
|
parkingNotes: optStr(5000),
|
||||||
|
safetyNotes: optStr(5000),
|
||||||
|
technicalNotes: optStr(5000),
|
||||||
|
status: z.enum(SITE_STATUSES).optional(),
|
||||||
|
latitude: optCoord(-90, 90),
|
||||||
|
longitude: optCoord(-180, 180),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const siteCreateSchema = z.object(siteFields);
|
||||||
|
export const sitePatchSchema = z.object(siteFields).partial();
|
||||||
|
export type SiteCreateInput = z.input<typeof siteCreateSchema>;
|
||||||
|
export type SitePatchInput = z.input<typeof sitePatchSchema>;
|
||||||
|
|
||||||
|
const CLOSED_ORDER_STATUSES = ["billed", "cancelled"] as const;
|
||||||
|
|
||||||
|
async function assertCustomerAndContact(ctx: ServiceCtx, customerId: string, contactId: string | null | undefined) {
|
||||||
|
const customer = await ctx.db.customer.findFirst({
|
||||||
|
where: { id: customerId, deletedAt: null, status: { not: "merged" } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new ServiceError("invalid", "customer not found", { field: "customerId", reason: "customer_not_found" });
|
||||||
|
if (contactId) {
|
||||||
|
const contact = await ctx.db.contact.findFirst({ where: { id: contactId, customerId, deletedAt: null }, select: { id: true } });
|
||||||
|
if (!contact) throw new ServiceError("invalid", "contact does not belong to customer", { field: "contactId", reason: "contact_mismatch" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listSites(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
opts: { q?: string; customerId?: string; status?: (typeof SITE_STATUSES)[number] | "all"; page?: number; pageSize?: number } = {},
|
||||||
|
) {
|
||||||
|
assertCan(ctx, "site:read");
|
||||||
|
const page = Math.max(1, opts.page ?? 1);
|
||||||
|
const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 25));
|
||||||
|
const q = opts.q?.trim();
|
||||||
|
const where: Prisma.SiteWhereInput = {
|
||||||
|
AND: [
|
||||||
|
await siteScope(ctx),
|
||||||
|
opts.status && opts.status !== "all" ? { status: opts.status } : {},
|
||||||
|
opts.customerId ? { customerId: opts.customerId } : {},
|
||||||
|
q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: q, mode: "insensitive" } },
|
||||||
|
{ street: { contains: q, mode: "insensitive" } },
|
||||||
|
{ city: { contains: q, mode: "insensitive" } },
|
||||||
|
{ postalCode: { contains: q } },
|
||||||
|
{ customer: { companyName: { contains: q, mode: "insensitive" } } },
|
||||||
|
{ customer: { lastName: { contains: q, mode: "insensitive" } } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const [total, items] = await Promise.all([
|
||||||
|
ctx.db.site.count({ where }),
|
||||||
|
ctx.db.site.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ name: "asc" }, { createdAt: "asc" }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
street: true,
|
||||||
|
houseNumber: true,
|
||||||
|
postalCode: true,
|
||||||
|
city: true,
|
||||||
|
status: true,
|
||||||
|
customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true } },
|
||||||
|
_count: { select: { workOrders: { where: { deletedAt: null } } } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getSite(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "site:read");
|
||||||
|
const site = await ctx.db.site.findFirst({
|
||||||
|
where: { AND: [{ id }, await siteScope(ctx)] },
|
||||||
|
include: {
|
||||||
|
customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, status: true } },
|
||||||
|
contact: { select: { id: true, name: true, phone: true, mobile: true, email: true, preferredChannel: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!site) throw new ServiceError("not_found", "site not found");
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSite(ctx: ServiceCtx, input: SiteCreateInput) {
|
||||||
|
assertCan(ctx, "site:write");
|
||||||
|
const data = siteCreateSchema.parse(input);
|
||||||
|
await assertCustomerAndContact(ctx, data.customerId, data.contactId);
|
||||||
|
const site = await ctx.db.site.create({ data: { ...data, tenantId: ctx.tenantId, country: data.country ?? "DE" } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "site", entityId: site.id, after: site });
|
||||||
|
return site;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSite(ctx: ServiceCtx, id: string, patch: SitePatchInput) {
|
||||||
|
assertCan(ctx, "site:write");
|
||||||
|
const data = sitePatchSchema.parse(patch);
|
||||||
|
const before = await ctx.db.site.findFirst({ where: { AND: [{ id }, await siteScope(ctx)] } });
|
||||||
|
if (!before) throw new ServiceError("not_found", "site not found");
|
||||||
|
const customerId = data.customerId ?? before.customerId;
|
||||||
|
const contactId = data.contactId === undefined ? (data.customerId ? null : before.contactId) : data.contactId;
|
||||||
|
await assertCustomerAndContact(ctx, customerId, contactId);
|
||||||
|
const after = await ctx.db.site.update({ where: { id }, data: { ...data, contactId } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "site", entityId: id, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteSite(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "site:write");
|
||||||
|
const before = await ctx.db.site.findFirst({ where: { AND: [{ id }, await siteScope(ctx)] } });
|
||||||
|
if (!before) throw new ServiceError("not_found", "site not found");
|
||||||
|
const open = await ctx.db.workOrder.count({ where: { siteId: id, deletedAt: null, status: { notIn: [...CLOSED_ORDER_STATUSES] } } });
|
||||||
|
if (open > 0) throw new ServiceError("blocked", "site has open work orders", { reason: "open_work_orders", count: open });
|
||||||
|
const after = await ctx.db.site.update({ where: { id }, data: { deletedAt: new Date(), status: "inactive" } });
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "site", entityId: id, before, after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { optStr } from "@/server/services/customers/schemas";
|
||||||
|
|
||||||
|
export const TEAM_STATUSES = ["active", "inactive"] as const;
|
||||||
|
|
||||||
|
const dateInput = z.preprocess((v) => (v === "" || v === null || v === undefined ? undefined : v), z.coerce.date().optional());
|
||||||
|
const optDate = z.preprocess((v) => (v === "" || v === undefined ? undefined : v), z.coerce.date().nullable().optional());
|
||||||
|
|
||||||
|
export const teamMemberSchema = z
|
||||||
|
.object({
|
||||||
|
userId: z.string().min(1),
|
||||||
|
validFrom: dateInput,
|
||||||
|
validTo: optDate,
|
||||||
|
})
|
||||||
|
.refine((m) => !m.validTo || !m.validFrom || m.validTo >= m.validFrom, { message: "valid_to_before_from", path: ["validTo"] });
|
||||||
|
|
||||||
|
export const teamSchema = z.object({
|
||||||
|
name: z.string().trim().min(1).max(120),
|
||||||
|
leaderUserId: optStr(64),
|
||||||
|
status: z.enum(TEAM_STATUSES).optional(),
|
||||||
|
phone: optStr(50),
|
||||||
|
vehicle: optStr(120),
|
||||||
|
area: optStr(200),
|
||||||
|
notes: optStr(5000),
|
||||||
|
members: z.array(teamMemberSchema).max(100).default([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TeamInput = z.input<typeof teamSchema>;
|
||||||
|
|
||||||
|
const teamInclude = {
|
||||||
|
leader: { select: { id: true, name: true } },
|
||||||
|
members: { include: { user: { select: { id: true, name: true, email: true, status: true } } }, orderBy: { validFrom: "asc" as const } },
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listTeams(ctx: ServiceCtx, opts: { includeInactive?: boolean } = {}) {
|
||||||
|
assertCan(ctx, "team:read");
|
||||||
|
return ctx.db.team.findMany({
|
||||||
|
where: { deletedAt: null, ...(opts.includeInactive ? {} : { status: "active" }) },
|
||||||
|
include: teamInclude,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTeam(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "team:read");
|
||||||
|
const team = await ctx.db.team.findFirst({ where: { id, deletedAt: null }, include: teamInclude });
|
||||||
|
if (!team) throw new ServiceError("not_found", "team not found");
|
||||||
|
return team;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Active members of the tenant for leader/member selects. */
|
||||||
|
export async function teamUserOptions(ctx: ServiceCtx) {
|
||||||
|
assertCan(ctx, "team:manage");
|
||||||
|
return ctx.db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true, email: true }, orderBy: { name: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
type ParsedTeam = z.output<typeof teamSchema>;
|
||||||
|
|
||||||
|
async function validateTeam(ctx: ServiceCtx, data: ParsedTeam, exceptId?: string) {
|
||||||
|
const memberIds = data.members.map((m) => m.userId);
|
||||||
|
if (new Set(memberIds).size !== memberIds.length) {
|
||||||
|
throw new ServiceError("invalid", "duplicate member", { field: "members", reason: "duplicate_member" });
|
||||||
|
}
|
||||||
|
const ids = [...new Set([...memberIds, ...(data.leaderUserId ? [data.leaderUserId] : [])])];
|
||||||
|
if (ids.length) {
|
||||||
|
// dbForTenant restricts to the tenant: users of other tenants are simply not found.
|
||||||
|
const found = await ctx.db.user.count({ where: { id: { in: ids }, status: "ACTIVE" } });
|
||||||
|
if (found !== ids.length) throw new ServiceError("invalid", "unknown or inactive user", { field: "members", reason: "inactive_user" });
|
||||||
|
}
|
||||||
|
const clash = await ctx.db.team.findFirst({ where: { name: data.name, ...(exceptId ? { id: { not: exceptId } } : {}) }, select: { id: true } });
|
||||||
|
if (clash) throw new ServiceError("conflict", "team name taken", { field: "name", reason: "name_taken" });
|
||||||
|
}
|
||||||
|
|
||||||
|
function memberRows(ctx: ServiceCtx, teamId: string, data: ParsedTeam) {
|
||||||
|
const now = new Date();
|
||||||
|
return data.members.map((m) => ({ tenantId: ctx.tenantId, teamId, userId: m.userId, validFrom: m.validFrom ?? now, validTo: m.validTo ?? null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(team: { members: { userId: string; validFrom: Date; validTo: Date | null }[] } & Record<string, unknown>) {
|
||||||
|
const { members, ...rest } = team;
|
||||||
|
return { ...rest, members: members.map((m) => ({ userId: m.userId, validFrom: m.validFrom, validTo: m.validTo })) };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTeam(ctx: ServiceCtx, input: TeamInput) {
|
||||||
|
assertCan(ctx, "team:manage");
|
||||||
|
const data = teamSchema.parse(input);
|
||||||
|
await validateTeam(ctx, data);
|
||||||
|
const { members, ...fields } = data;
|
||||||
|
void members;
|
||||||
|
const team = await ctx.db.team.create({ data: { ...fields, tenantId: ctx.tenantId } });
|
||||||
|
if (data.members.length) await ctx.db.teamMember.createMany({ data: memberRows(ctx, team.id, data) });
|
||||||
|
const after = await getTeam(ctx, team.id);
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "team", entityId: team.id, after: snapshot(after) });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Full replace of team data and membership list (validity periods included). */
|
||||||
|
export async function updateTeam(ctx: ServiceCtx, id: string, input: TeamInput) {
|
||||||
|
assertCan(ctx, "team:manage");
|
||||||
|
const data = teamSchema.parse(input);
|
||||||
|
const before = await getTeam(ctx, id);
|
||||||
|
await validateTeam(ctx, data, id);
|
||||||
|
const { members, ...fields } = data;
|
||||||
|
void members;
|
||||||
|
await ctx.db.$transaction([
|
||||||
|
ctx.db.team.update({ where: { id }, data: fields }),
|
||||||
|
ctx.db.teamMember.deleteMany({ where: { teamId: id } }),
|
||||||
|
ctx.db.teamMember.createMany({ data: memberRows(ctx, id, data) }),
|
||||||
|
]);
|
||||||
|
const after = await getTeam(ctx, id);
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "team", entityId: id, before: snapshot(before), after: snapshot(after) });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft delete; the unique name is released by suffixing it. Blocked while open orders are assigned. */
|
||||||
|
export async function deleteTeam(ctx: ServiceCtx, id: string) {
|
||||||
|
assertCan(ctx, "team:manage");
|
||||||
|
const before = await getTeam(ctx, id);
|
||||||
|
const open = await ctx.db.workOrder.count({ where: { assignedTeamId: id, deletedAt: null, status: { notIn: ["billed", "cancelled"] } } });
|
||||||
|
if (open > 0) throw new ServiceError("blocked", "team has open work orders", { reason: "open_work_orders", count: open });
|
||||||
|
const after = await ctx.db.team.update({
|
||||||
|
where: { id },
|
||||||
|
data: { deletedAt: new Date(), status: "inactive", name: `${before.name} · ${id.slice(-6)}` },
|
||||||
|
});
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "team", entityId: id, before: snapshot(before), after });
|
||||||
|
return after;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user