diff --git a/docs/craftvia/lanes/auftraege.md b/docs/craftvia/lanes/auftraege.md new file mode 100644 index 0000000..419153b --- /dev/null +++ b/docs/craftvia/lanes/auftraege.md @@ -0,0 +1,115 @@ +# Lane L2 – Aufträge & Backoffice + +Branch `lane/auftraege` (Basis `feature/craftvia-mvp` @ bf44567). Keine Schemaänderung, **keine neue Migration**, keine neuen npm-Abhängigkeiten. + +## 1. Umfang / erfüllte Spec-Punkte + +| Spec | Umsetzung | +|---|---| +| §10.1 Auftragsdaten | Anlage/Bearbeitung aller Felder (Kunde, Objekt, Ansprechpartner, Auftragsart, Priorität, Zeitraum, Beschreibung, Leistungsumfang, Hinweise intern/Monteure, Unterschrift erforderlich, Abrechnungsart, externe Auftrags-/Angebotsnummer). Soft Delete nur für Entwürfe. | +| §10.2 Auftragsarten | `/settings/order-types`; `ensureDefaultOrderTypes` legt die 8 Standardarten beim ersten Zugriff je Mandant an (idempotent). | +| §10.3 Statusmodell / ARCHITEKTUR §3 | `transition.ts#transitionWorkOrder` ist der einzige Status-Schreibpfad: Übergangstabelle (`status.ts`), Rechte je Übergang, Scope, Begründungspflicht (Storno, Korrektur, Freigabe zurücknehmen), Guards → `ServiceError("blocked", …, CompletionBlocker[])`, optimistische Versionsprüfung (`baseVersion`), `WorkOrderStatusChange`, Audit, Event. | +| §10.4 / US-004 Teamzuweisung | `assign.ts#assignWorkOrder`: Team + optionale Einzelpersonen + Teamleiter (Default: Teamleiter des Teams), draft/review_required/planned → assigned, Teamwechsel nach „angenommen“ → assigned, Event `work_order.assigned`, Audit. Popup im Detail. | +| §12.4 Checklisten, §14.2 Pflichtfotos | Vorlagen je Auftragsart (`/settings/checklists`, Standardvorschlag aus §12.4/§14.2); Übernahme beim Anlegen; Pflege am Auftrag (Punkte/Pflichtfotos hinzufügen/entfernen, Vorlage nachträglich übernehmen). Erledigte Punkte / Pflichtfotos mit Fotos bleiben als Nachweis. | +| Completion-Guards | `completion.ts#getCompletionBlockers`: offene Pflichtpunkte (inkl. „mit Foto“ ohne Foto), Pflichtfotos ohne Foto, offene WorkSession. Zusätzlich: `→ in_review` nur mit erfasster Unterschrift (jede Outcome-Art, §18.2) wenn `signatureRequired`; `→ released_for_billing` nur mit freigegebenem Abschlussbericht. | +| §13.1 Materialvorgabe | CRUD (`materials.ts`), Tab „Material“ mit Soll/Ist inkl. Abweichung, Begründung und Zusatzmaterial. | +| §21 Dashboard | `/dashboard`: 11 Kacheln (offen, heute, laufend, nicht angenommen, überfällig, Berichte zur Prüfung, abgeschlossen, zur Abrechnung, neue Notdienste, fehlende Unterschriften, Sync-Konflikte), Filter Zeitraum/Kunde/Team/Monteur/Auftragsart/Priorität; jede Kachel verlinkt auf `/work-orders?preset=…` (bzw. Konfliktliste). Feldrollen (ohne `work_order:read_all`) → Redirect `/m`. „Heute“ rechnet in der Mandanten-Zeitzone. | +| §21 Liste | `/work-orders`: Statusgruppen-Tabs mit Zählern, Filterleiste (Zeitraum, Kunde, Objekt, Team, Monteur, Status, Auftragsart, Priorität, Freitext), Sortierung, Paginierung, Karten- (Statuskante) und Tabellenansicht, Anlage-Popup mit Kundensuche → Objekt/Kontakt-Auswahl. | +| Detail | `/work-orders/[id]`: Kopf mit Status (Text + Icon), nächster primärer Aktion (CTA), weiteren Übergängen, Bearbeiten/Zuweisen/Stornieren; Tabs Übersicht · Checkliste & Pflichtfotos · Material · Zeiten (Lesesicht) · Fotos (Galerie) · Notizen · Berichte (Liste + Abrechnungsaktionen) · Dokumente (Upload) · Verlauf (Statusänderungen + Audit). | +| US-009 Abrechnung | Freigabe, Zurückweisung zur Korrektur (mit Grund), Freigabe zurücknehmen, „abgerechnet“ – protokolliert, Event `work_order.released_for_billing`. | +| §23.5 Konflikte | `/work-orders/conflicts`: offene `SyncOperation(status=conflict)` mit Payload-Vorschau; „Übernehmen“ (Op erneut gegen aktuellen Stand als ursprünglicher Gerätenutzer) / „Verwerfen“ (resolvedAt/By + Audit). | +| §25 Suche | `/search?q=` + Header-Suchfeld: Aufträge (Nummer, externe/Angebotsnummer, Titel, Beschreibung, Leistungsumfang, Objektadresse), Kunden (inkl. Adresse), Objekte, Ansprechpartner, Dokumentnamen, Notizen; ILIKE mit `workOrderScope/customerScope/siteScope` und Dokument-Sichtbarkeit; Filter Zeitraum/Status/Team. | +| Nummernkreise | `/settings/numbering` (Präfix/Stellen; Zähler wird nie zurückgesetzt). | +| API | `GET/POST /api/v1/work-orders`, `GET/PATCH /api/v1/work-orders/[id]`, `POST …/assign`, `POST …/transition`, `GET/POST …/materials`, zusätzlich `POST …/documents` (multipart). Fehler: 401/403/404/409/422 (blocked mit `details: CompletionBlocker[]`). | + +Jede Mutation: Permission (Guard + Service) · Scope (`workOrderScope`) · Zod · `writeAuditLog` (before/after) · `version + 1` · ggf. `emitEvent`. Fachdaten ausschließlich über `ctx.db`. + +## 2. Verträge für andere Lanes + +Exakte Signaturen (Stubs von L3/L5 darauf umstellen): + +```ts +// src/server/services/work-orders/create.ts +createWorkOrder(ctx: ServiceCtx, raw: CreateWorkOrderInput): Promise<{ id: string; number: string; status: string; version: number }> +// CreateWorkOrderInput (src/lib/work-orders/schemas.ts): title, customerId, siteId?, contactId?, orderTypeId?, priority?, +// status?: "draft" | "review_required" | "planned" | "in_progress" (Default draft; in_progress nur mit isEmergency), +// description?, scope?, plannedStart?, plannedEnd?, signatureRequired?, billingType?, internalNotes?, technicianNotes?, +// externalOrderNumber?, offerNumber?, isEmergency?, emergencyReason?, sourceImportId?, numberKey?: "work_order" | "emergency", +// applyTemplate? (Default true), materials?, checklistItems?, photoRequirements? + +// src/server/services/work-orders/transition.ts +transitionWorkOrder(ctx: ServiceCtx, raw: { workOrderId: string; to: WorkOrderStatus; reason?: string | null; baseVersion?: number; + eventData?: Record }): Promise<{ id: string; status: WorkOrderStatus; version: number; from: WorkOrderStatus }> +// eventData wird in event.data gemischt; number/from/to werden nie überschrieben. + +// src/server/services/work-orders/completion.ts +getCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise // mit Scope-Prüfung (not_found) +computeCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise // ohne Scope-Prüfung, für bereits geladene Aufträge +``` + +- **Transaktionen:** L2 verwendet **kein `$transaction`**. Die Services arbeiten ausschließlich über `ctx.db`. Ein `ctx`, dessen `db` ein Transaktions-Client ist (künftig `inTransaction(ctx, fn)`), wird unverändert unterstützt. Ausnahme: `writeAuditLog` schreibt über den Owner-Client (Fundament) außerhalb der Transaktion. `createWorkOrder` legt Auftrag, Checkliste, Pflichtfotos, Material und die erste Statushistorie in **einem** verschachtelten `create` an (atomar). Die Nummernvergabe (`nextNumber`) läuft davor. Nicht atomare Mehrschritt-Schreibvorgänge, die nach dem Fundament-Merge in `inTransaction` gehören: `applyTransition` (Versions-Update → StatusChange → Audit), `assignWorkOrder` (Auftrag → Assignees löschen/anlegen → StatusChange), Material-/Checklisten-Mutationen (Zeile → `touchWorkOrder`), `applySyncConflict` (Re-Apply → SyncOperation-Update). +- L3 Import: `status: "review_required"` oder `"planned"`, `sourceImportId`, optional `materials`, `checklistItems`, `photoRequirements`. + - L8 Notdienst: `isEmergency: true`, `numberKey: "emergency"` (N-…), `status: "in_progress"` erlaubt nur mit `isEmergency`; Recht `work_order:write` **oder** `emergency:create`. +- `transitionWorkOrder(ctx, { workOrderId, to, reason?, baseVersion? })` – für L4 (Sync `work_order.transition`) und L5 (Berichte). +- `getCompletionBlockers(ctx, id)`, `assignWorkOrder`, `cancelWorkOrder`, `releaseForBilling` / `rejectForCorrection` / `revokeBillingRelease` / `markBilled`. +- `ensureDefaultOrderTypes(db, tenantId)` – kann in `provision.ts` aufgerufen werden (Fundament, nicht angefasst). + +## 3. Dateien + +**Service/Lib (neu):** `src/lib/work-orders/{schemas,defaults,filters,time,action-state}.ts`; `src/server/services/work-orders/{_shared,create,update,transition,completion,assign,cancel,release-billing,materials,checklist,list,dashboard,detail,search,conflicts,settings,options,documents,sync-reapply}.ts` + +**Actions:** `src/server/actions/work_orders/{_form,work-orders,settings}.ts` + +**UI:** `src/app/(app)/work-orders/{page.tsx,[id]/page.tsx,conflicts/page.tsx}`, `src/app/(app)/dashboard/page.tsx`, `src/app/(app)/search/page.tsx`, `src/app/(app)/settings/{order-types,checklists,numbering}/page.tsx`, `src/components/work-orders/{action-form,ui,page-context,work-order-list,work-order-fields,detail-tabs,settings-nav}.tsx|ts` + +**API:** `src/app/api/v1/work-orders/{_http.ts,route.ts,[id]/route.ts,[id]/assign,[id]/transition,[id]/materials,[id]/documents}` + +**Texte:** `messages/{de,en}/{workOrders,dashboard,search,settingsTemplates}.json` + +**Tests:** `scripts/test-auftraege-{status,core,scope,numbering}.ts`, Fixtures `scripts/lib-auftraege-fixtures.ts` (kein `test-`-Präfix → nicht vom Runner ausgeführt) + +**Erlaubte Fremd-Einzeiler:** +- `src/lib/nav.ts`: Eintrag „Auftragsvorlagen“ → `/settings/order-types` (`settings:templates`) + Label `templates` in `messages/{de,en}/nav.json` +- `src/app/(app)/layout.tsx`: Header-Suchfeld → `GET /search` +- `src/components/audit-trail.tsx`: Entity-Labels `order_type`, `checklist_template`, `number_sequence`, `sync_operation` + +## 4. Tests + +`npm run gate` **grün**: prisma generate, tsc, lint (0 Fehler; 2 Warnungen im Fundament-Platzhalter `services/notifications/handle-event.ts`), build inkl. Modul-Guard-Check (15 Action-Dateien), **26/26 Testskripte grün**, davon 4 neue L2-Skripte (alle Nachweise erfüllt). + +Hinweis Lane-DB: `scripts/test-rls-enforcement.ts` nutzt ohne `RLS_DATABASE_URL` die Standard-DB `craftvia`. In der lokalen `.env` des Worktrees (nicht eingecheckt) ist `RLS_DATABASE_URL` auf `craftvia_auftraege` gesetzt (Rolle `craftvia_app`, lokales Testpasswort laut Testskript). + +| Skript | Inhalt | +|---|---| +| `test-auftraege-status.ts` | alle 256 Statuspaare gegen den Vertrag; **180 Rolle×Übergang-Kombinationen** (tenant-admin, backoffice, team-lead, technician) mit unabhängig aus der Rechte-Tabelle abgeleiteter Erwartung (Erfolg inkl. Version/Historie/Audit bzw. forbidden ohne Schreibwirkung); verbotene Paare → invalid; Begründungspflicht; Versionskonflikt | +| `test-auftraege-core.ts` | Anlage (Nummer, Vorlage/Unterschrift aus Auftragsart, Notdienst N-), Update (Version, Konflikt, Kunde/Objekt-Konsistenz, Audit before/after, Soft Delete), Zuweisung, Completion-Blocker (Checkliste/Pflichtfoto/laufende Session), Unterschrift vor in_review, Freigabe nur mit freigegebenem Abschlussbericht, Material-CRUD + Soll/Ist, Checklistenpflege, Liste (Presets, Filter, Gruppen, Paginierung), Dashboard-Kacheln, Konflikte verwerfen/übernehmen, Auftragsarten/Vorlagen/Nummernkreis | +| `test-auftraege-scope.ts` | Monteur fremdes Team → not_found (Liste/Detail/Übergang/Blocker); Team-Mitglied/Teamleiter/Einzelzuweisung sehen; Monteur ohne Backoffice-Rechte → forbidden; **Mandant B**: Liste/Detail/Update/Übergang/Zuweisung/Material/Konflikt/Dashboard/Suche ohne Zugriff auf A, Tenant-Guard direkt; Suche respektiert Scope und Filter | +| `test-auftraege-numbering.ts` | 20 parallele `createWorkOrder` auf frischem Mandanten → eindeutig und lückenlos A-00001…A-00020; zweiter Mandant eigener Kreis | + +## 5. Stubs / Abhängigkeiten + +| Stub (in L2-Pfad) | Ersetzt durch | Vorgehen nach Merge | +|---|---|---| +| `services/work-orders/documents.ts#uploadWorkOrderDocument` (Allowlist PDF/JPEG/PNG/WebP, Magic Bytes, Größenlimit, SHA-256, `storage.put`, `Document`) | L1 `services/documents/store.ts#storeFile` | Rumpf durch `storeFile(ctx, { …, links: { workOrderId, customerId, siteId } })` ersetzen | +| `services/work-orders/sync-reapply.ts#reapplySyncOperation` (nur `work_order.transition`) | L4 `services/sync/apply.ts` | an L4-Dispatcher delegieren; Signatur bleibt | +| Tab „Berichte“: einfache Liste mit Link `/reports/[id]` | L5-Komponente | optional L5-Komponente einbinden | +| Dateilinks `/files/` | Umstellung auf `/files/` (ARCHITEKTUR §4.3, L1) | Links in `detail-tabs.tsx` anpassen | + +Events werden über `emitEvent` gesendet (`work_order.assigned|changed|cancelled|started|daily_report_created|technically_completed|signature_missing|released_for_billing`); Empfängerauflösung liefert L6. + +## 6. Bekannte Lücken / Hinweise an Fundament/Architekt + +- **`requireApiContext` existiert nicht** (in `context.ts` referenziert). `/api/v1/work-orders` nutzt `moduleGuard("work_orders")` (Session-Cookie, DB-autoritative Rechte). Token-Auth für Mobile/Integrationen fehlt; `src/proxy.ts` leitet `/api/v1/**` ohne Cookie per 307 auf `/login` statt 401 → Fundament-Bedarf. +- Upload-Größe: Dokument-Upload läuft über Route-Handler (kein 1-MB-Server-Action-Limit), aber `proxyClientMaxBodySize` (Default 10 MB) begrenzt – für PDFs bis 25 MB in `next.config.ts` erhöhen (Fundament). +- Einstellungsseiten liegen ohne Modul-Layout unter `/settings/*`; die Actions laufen über `moduleGuard("work_orders")` (bei deaktiviertem Modul nicht speicherbar). +- `PII_REFERENCE_FIELDS` (`src/server/dsgvo/pii-fields.ts`, Fundament) enthält die Personen-Referenzen des Auftragsmodells noch nicht (`WorkOrder.createdById/teamLeadUserId`, `WorkOrderAssignee.userId`, `WorkOrderStatusChange.actorId`, `SyncOperation.resolvedById`) → Architekt/Fundament. +- Suche in Berichtsinhalten (JSON) nicht umgesetzt (Berichte gehören L5). +- Dashboard „Berichte zur Prüfung“ zählt Berichte (`submitted`/`team_approved`); die Kachel verlinkt auf Aufträge mit solchen Berichten. +- Bearbeiten-Popup ändert den Kunden nicht (API `PATCH` kann es, inkl. Konsistenzprüfung). +- Browser-Smoke mit Anmeldung nicht automatisiert (siehe §7). + +## 7. Screens / Routen + +`/dashboard` · `/work-orders` (`?group=`, `?preset=`, `?view=table`, `?new=1`) · `/work-orders/[id]` (`?tab=overview|checklist|material|times|photos|notes|reports|documents|history`, `?assign=1`, `?edit=1`, `?transition=`) · `/work-orders/conflicts` · `/search?q=` · `/settings/order-types` · `/settings/checklists` · `/settings/numbering` + +**Smoke (Dev-Server Port 3102, curl):** alle Seiten oben sowie `GET /api/v1/work-orders`, `GET /api/v1/work-orders/[id]` und `POST …/transition` antworten ohne Session mit 307 → `/login?callbackUrl=…` (Proxy-Gate greift, Server startet fehlerfrei). Alle Seiten und Route-Handler werden im Production-Build fehlerfrei kompiliert. Die i18n-Schlüssel sind statisch geprüft: 808 Verwendungen, alle in de und en vorhanden. **Nicht durchgeführt:** Smoke mit Anmeldung / visuelle Prüfung (Responsive 1024/768 px). Der Agent gibt keine Passwörter in Login-Abläufe ein; bitte manuell mit einem Seed-Nutzer prüfen (`backoffice@demo.example` → `/dashboard`, `/work-orders`; `monteur@demo.example` → Redirect `/m`). diff --git a/messages/de/dashboard.json b/messages/de/dashboard.json index 6d8db7b..33e7ba1 100644 --- a/messages/de/dashboard.json +++ b/messages/de/dashboard.json @@ -2,7 +2,34 @@ "title": "Dashboard", "crumb": "Übersicht", "subtitle": "Willkommen, {name} — {tenant}", - "placeholderTitle": "Dashboard in Umsetzung", - "placeholder": "Hier erscheinen künftig offene Aufträge, heutige Einsätze, freizugebende Berichte und Notdienst-Meldungen.", - "moduleDisabled": "Das angeforderte Modul ist für Ihren Betrieb nicht freigeschaltet." + "moduleDisabled": "Das angeforderte Modul ist für Ihren Betrieb nicht freigeschaltet.", + "workOrdersDisabled": "Das Modul Aufträge ist nicht freigeschaltet – es gibt keine Kennzahlen.", + "filterTitle": "Kennzahlen eingrenzen", + "tiles": { + "open": "Offene Aufträge", + "today": "Heutige Einsätze", + "running": "Laufende Einsätze", + "not_accepted": "Nicht angenommen", + "overdue": "Überfällig", + "reports_in_review": "Berichte zur Prüfung", + "completed": "Abgeschlossen", + "billing": "Zur Abrechnung", + "emergency_new": "Neue Notdienste", + "missing_signatures": "Fehlende Unterschriften", + "sync_conflicts": "Sync-Konflikte" + }, + "hints": { + "open": "Noch nicht abgerechnet oder storniert", + "today": "Für heute geplant", + "running": "Unterwegs, in Arbeit, pausiert", + "not_accepted": "Zugewiesen, noch nicht angenommen", + "overdue": "Geplantes Ende überschritten", + "reports_in_review": "Eingereicht, noch nicht freigegeben", + "completed": "Technisch abgeschlossen oder weiter", + "billing": "Bereit zur Abrechnung", + "emergency_new": "Neu oder zur Prüfung", + "missing_signatures": "Unterschrift ausstehend", + "sync_conflicts": "Offline-Änderungen prüfen" + }, + "open": "Anzeigen" } diff --git a/messages/de/nav.json b/messages/de/nav.json index c9996f9..12b90ae 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -7,6 +7,7 @@ "teams": "Teams", "reports": "Berichte", "documents": "Dokumente", + "templates": "Auftragsvorlagen", "settings": "Einstellungen", "notifications": "Benachrichtigungen", "audit": "Audit-Protokoll", diff --git a/messages/de/search.json b/messages/de/search.json new file mode 100644 index 0000000..8874899 --- /dev/null +++ b/messages/de/search.json @@ -0,0 +1,28 @@ +{ + "crumb": "Suche", + "title": "Suche", + "placeholder": "Aufträge, Kunden, Objekte, Adressen, Dokumente, Notizen", + "submit": "Suchen", + "hint": "Mindestens 2 Zeichen eingeben.", + "noResults": "Keine Treffer für „{q}“.", + "resultsFor": "Treffer für „{q}“", + "filters": { + "from": "Von", + "to": "Bis", + "status": "Status", + "team": "Team", + "any": "Alle", + "apply": "Filtern", + "reset": "Filter zurücksetzen", + "note": "Zeitraum, Status und Team grenzen Aufträge und alles am Auftrag ein." + }, + "sections": { + "workOrders": "Aufträge", + "customers": "Kunden", + "sites": "Objekte", + "contacts": "Ansprechpartner", + "documents": "Dokumente", + "notes": "Notizen" + }, + "maxHint": "Es werden höchstens 20 Treffer je Bereich angezeigt." +} diff --git a/messages/de/settingsTemplates.json b/messages/de/settingsTemplates.json new file mode 100644 index 0000000..d84479c --- /dev/null +++ b/messages/de/settingsTemplates.json @@ -0,0 +1,73 @@ +{ + "crumb": "Einstellungen", + "nav": { + "orderTypes": "Auftragsarten", + "checklists": "Checklisten-Vorlagen", + "numbering": "Nummernkreise" + }, + "orderTypes": { + "title": "Auftragsarten", + "sub": "Auftragsarten je Betrieb – mit Vorgabe, ob eine Kundenunterschrift nötig ist", + "key": "Schlüssel", + "name": "Bezeichnung", + "signatureRequired": "Unterschrift erforderlich", + "active": "Aktiv", + "sortOrder": "Reihenfolge", + "create": "Auftragsart anlegen", + "save": "Speichern", + "keyHint": "Kleinbuchstaben, Ziffern, Unterstrich – nicht änderbar", + "inactive": "Inaktiv", + "yes": "Ja", + "no": "Nein" + }, + "checklists": { + "title": "Checklisten-Vorlagen", + "sub": "Vorlagen je Auftragsart – werden beim Anlegen eines Auftrags übernommen", + "empty": "Noch keine Vorlagen.", + "new": "Neue Vorlage", + "edit": "Bearbeiten", + "name": "Name", + "orderType": "Auftragsart", + "noOrderType": "— keine —", + "active": "Aktiv", + "items": "Checklistenpunkte", + "itemsHint": "Eine Zeile je Punkt. Zusätze: „*“ am Anfang = Pflicht, „[Foto]“ am Ende = mit Foto.", + "photos": "Pflichtfotos", + "photosHint": "Eine Zeile je Pflichtfoto.", + "suggestion": "Standardvorschlag einfügen", + "suggestionHint": "Vorschlag aus den Standardpunkten; vor dem Speichern anpassen.", + "save": "Vorlage speichern", + "itemCount": "{count, plural, =0 {Keine Punkte} one {# Punkt} other {# Punkte}}", + "photoCount": "{count, plural, =0 {keine Pflichtfotos} one {# Pflichtfoto} other {# Pflichtfotos}}", + "inactive": "Inaktiv", + "photoSuffix": "[Foto]" + }, + "numbering": { + "title": "Nummernkreise", + "sub": "Präfix und Stellenzahl der fortlaufenden Nummern. Der Zähler läuft immer weiter.", + "key": "Nummernkreis", + "prefix": "Präfix", + "padding": "Stellen", + "next": "Nächste Nummer", + "save": "Speichern", + "keys": { + "work_order": "Aufträge", + "emergency": "Notdienste", + "customer": "Kunden", + "report": "Berichte" + } + }, + "saved": "Gespeichert.", + "errors": { + "forbidden": "Dafür fehlt die Berechtigung.", + "invalid": "Eingaben prüfen.", + "not_found": "Nicht gefunden.", + "internal": "Vorgang nicht möglich.", + "validation_failed": "Pflichtfelder prüfen.", + "order_type_key_taken": "Dieser Schlüssel ist bereits vergeben.", + "order_type_not_found": "Auftragsart nicht gefunden.", + "template_not_found": "Vorlage nicht gefunden.", + "duplicate_item_key": "Zwei Checklistenpunkte haben dieselbe Bezeichnung.", + "duplicate_photo_key": "Zwei Pflichtfotos haben dieselbe Bezeichnung." + } +} diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json new file mode 100644 index 0000000..d25a8c0 --- /dev/null +++ b/messages/de/workOrders.json @@ -0,0 +1,445 @@ +{ + "crumb": "Aufträge", + "title": "Aufträge", + "subtitle": "Planen, zuweisen, prüfen und zur Abrechnung freigeben", + "new": "Neuer Auftrag", + "conflictsLink": "Sync-Konflikte", + "allGroups": "Alle", + "empty": "Keine Aufträge für diese Auswahl.", + "resultCount": "{count, plural, =0 {Keine Aufträge} one {# Auftrag} other {# Aufträge}}", + "presetActive": "Kachel-Filter: {name}", + "removePreset": "Kachel-Filter entfernen", + "view": { "cards": "Karten", "table": "Tabelle" }, + "pagination": { "prev": "Zurück", "next": "Weiter", "page": "Seite {page} von {pages}" }, + "filter": { + "title": "Filter", + "q": "Nummer, Titel, Kunde, Ort", + "from": "Von", + "to": "Bis", + "customer": "Kunde", + "site": "Objekt", + "team": "Team", + "user": "Monteur", + "status": "Status", + "orderType": "Auftragsart", + "priority": "Priorität", + "sort": "Sortierung", + "any": "Alle", + "apply": "Filtern", + "reset": "Zurücksetzen" + }, + "sort": { + "plannedStart": "Geplanter Beginn", + "createdAt": "Angelegt", + "updatedAt": "Zuletzt geändert", + "number": "Nummer", + "priority": "Priorität", + "status": "Status", + "asc": "aufsteigend", + "desc": "absteigend" + }, + "columns": { + "number": "Nummer", + "title": "Titel", + "customer": "Kunde", + "site": "Objekt", + "planned": "Geplant", + "team": "Team", + "status": "Status", + "priority": "Priorität" + }, + "status": { + "draft": "Entwurf", + "review_required": "Prüfung erforderlich", + "planned": "Geplant", + "assigned": "Zugewiesen", + "accepted": "Angenommen", + "en_route": "In Anfahrt", + "in_progress": "In Bearbeitung", + "paused": "Pausiert", + "waiting_material": "Wartet auf Material", + "daily_report_created": "Tagesbericht erstellt", + "technically_completed": "Technisch abgeschlossen", + "signature_pending": "Unterschrift ausstehend", + "in_review": "Zur Prüfung", + "released_for_billing": "Zur Abrechnung freigegeben", + "billed": "Abgerechnet", + "cancelled": "Storniert" + }, + "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" + }, + "priority": { "low": "Niedrig", "normal": "Normal", "high": "Hoch", "urgent": "Dringend" }, + "billingType": { + "fixed": "Pauschal", + "time_material": "Nach Aufwand", + "maintenance_contract": "Wartungsvertrag", + "warranty": "Gewährleistung" + }, + "emergency": "Notdienst", + "unassigned": "Nicht zugewiesen", + "noDate": "Ohne Termin", + "create": { + "title": "Neuer Auftrag", + "sub": "Kunde wählen, Auftrag erfassen", + "stepCustomer": "1. Kunde suchen", + "customerSearch": "Firma, Name, Kundennummer oder Ort", + "searchButton": "Suchen", + "noCustomers": "Kein Kunde gefunden. Kunden werden unter „Kunden“ angelegt.", + "chooseCustomer": "Auswählen", + "changeCustomer": "Anderen Kunden wählen", + "stepDetails": "2. Auftrag erfassen", + "submit": "Auftrag anlegen", + "pending": "Lege an…" + }, + "fields": { + "title": "Titel", + "customer": "Kunde", + "site": "Objekt", + "contact": "Ansprechpartner", + "none": "— keine Auswahl —", + "orderType": "Auftragsart", + "priority": "Priorität", + "plannedStart": "Geplanter Beginn", + "plannedEnd": "Geplantes Ende", + "description": "Beschreibung", + "scope": "Leistungsumfang", + "internalNotes": "Hinweise intern (nur Büro)", + "technicianNotes": "Hinweise für Monteure", + "signatureRequired": "Unterschrift erforderlich", + "signatureFromType": "Wie Auftragsart", + "yes": "Ja", + "no": "Nein", + "billingType": "Abrechnungsart", + "externalOrderNumber": "Externe Auftragsnummer", + "offerNumber": "Angebotsnummer", + "status": "Anlegen als", + "timezoneHint": "Zeiten in {tz}" + }, + "detail": { + "back": "Zurück zu Aufträgen", + "version": "Version {version}", + "nextStep": "Nächster Schritt", + "moreActions": "Weitere Statusänderungen", + "edit": "Bearbeiten", + "assign": "Zuweisen", + "reassign": "Zuweisung ändern", + "cancel": "Stornieren", + "noActions": "Für Sie ist gerade keine Statusänderung möglich.", + "blockersTitle": "Vor dem Abschluss fehlt noch:", + "tabs": { + "overview": "Übersicht", + "checklist": "Checkliste & Pflichtfotos", + "material": "Material", + "times": "Zeiten", + "photos": "Fotos", + "notes": "Notizen", + "reports": "Berichte", + "documents": "Dokumente", + "history": "Verlauf" + } + }, + "overview": { + "customer": "Kunde", + "site": "Objekt", + "contact": "Ansprechpartner", + "planning": "Planung", + "assignment": "Zuweisung", + "team": "Team", + "teamLead": "Teamleiter", + "assignees": "Monteure", + "order": "Auftrag", + "created": "Angelegt", + "updated": "Geändert", + "accessNotes": "Zugang", + "safetyNotes": "Sicherheit" + }, + "checklist": { + "items": "Checkliste", + "photos": "Pflichtfotos", + "empty": "Keine Checklistenpunkte.", + "emptyPhotos": "Keine Pflichtfotos.", + "required": "Pflicht", + "optional": "Optional", + "requiresPhoto": "mit Foto", + "done": "Erledigt", + "open": "Offen", + "photoCount": "{count, plural, =0 {Kein Foto} one {# Foto} other {# Fotos}}", + "addItem": "Punkt hinzufügen", + "addPhoto": "Pflichtfoto hinzufügen", + "label": "Bezeichnung", + "applyTemplate": "Vorlage übernehmen", + "template": "Vorlage", + "applyButton": "Übernehmen", + "remove": "Entfernen", + "complete": "Alle Abschluss-Voraussetzungen erfüllt." + }, + "material": { + "title": "Material – geplant und tatsächlich", + "empty": "Kein Material geplant oder erfasst.", + "name": "Bezeichnung", + "articleNumber": "Artikelnummer", + "planned": "Geplant", + "actual": "Tatsächlich", + "deviation": "Abweichung", + "unit": "Einheit", + "notes": "Bemerkung", + "reason": "Begründung", + "additional": "Zusätzlich", + "notRecorded": "Noch nicht erfasst", + "add": "Material planen", + "remove": "Entfernen", + "usage": { + "fully_used": "Vollständig verwendet", + "partially_used": "Teilweise verwendet", + "not_used": "Nicht verwendet", + "additional": "Zusätzlich verwendet" + } + }, + "times": { + "empty": "Noch keine Zeiten erfasst.", + "person": "Person", + "start": "Beginn", + "end": "Ende", + "duration": "Arbeitszeit", + "minutes": "{minutes} min", + "corrected": "Korrigiert: {reason}", + "sessionStatus": { + "en_route": "Unterwegs", + "running": "Läuft", + "paused": "Pausiert", + "ended": "Beendet" + }, + "entryType": { + "travel": "Anfahrt", + "work": "Arbeit", + "break": "Pause", + "material_procurement": "Materialbeschaffung", + "return_travel": "Rückfahrt", + "interruption": "Unterbrechung" + } + }, + "photos": { + "empty": "Noch keine Fotos.", + "phase": { "before": "Vorher", "during": "Während", "after": "Nachher" }, + "open": "Öffnen", + "inReport": "Im Bericht" + }, + "notes": { + "empty": "Keine Notizen.", + "internal": "Hinweise intern", + "technician": "Hinweise für Monteure", + "kind": { + "work_done": "Ausgeführt", + "deviation": "Abweichung", + "problem": "Problem", + "additional_work": "Zusatzarbeit", + "not_executable": "Nicht ausführbar", + "follow_up": "Folgearbeit", + "recommendation": "Empfehlung", + "customer_note": "Kundenhinweis", + "general": "Allgemein" + } + }, + "reports": { + "empty": "Noch keine Berichte.", + "type": { "daily": "Tagesbericht", "completion": "Abschlussbericht" }, + "status": { + "draft": "Entwurf", + "submitted": "Zur Prüfung", + "team_approved": "Vom Teamleiter freigegeben", + "approved": "Freigegeben", + "rejected": "Zurückgewiesen", + "superseded": "Ersetzt" + }, + "open": "Bericht öffnen", + "signature": "Unterschrift: {outcome}", + "signatureOutcome": { + "signed": "unterschrieben", + "customer_absent": "Kunde nicht anwesend", + "refused": "verweigert", + "later": "später", + "not_required": "nicht erforderlich" + }, + "billingTitle": "Abrechnung", + "billingHint": "Freigabe nur mit freigegebenem Abschlussbericht.", + "release": "Zur Abrechnung freigeben", + "reject": "Zur Korrektur zurückweisen", + "markBilled": "Als abgerechnet markieren", + "revoke": "Freigabe zurücknehmen" + }, + "documents": { + "empty": "Keine Dokumente am Auftrag.", + "upload": "Dokument hochladen", + "file": "Datei (PDF, JPG, PNG, WebP)", + "title": "Titel (optional)", + "category": "Kategorie", + "visibility": "Sichtbarkeit", + "submit": "Hochladen", + "archive": "Entfernen", + "download": "Herunterladen", + "stubHint": "Dateien werden bis zur Anbindung der Dokumentenablage direkt im Objektspeicher abgelegt.", + "categories": { + "order_confirmation": "Auftragsbestätigung", + "technical_drawing": "Technische Zeichnung", + "floor_plan": "Grundriss", + "wiring_diagram": "Schaltplan", + "assembly_instructions": "Montageanleitung", + "safety_document": "Sicherheitsdokument", + "product_document": "Produktunterlage", + "customer_note": "Kundennotiz", + "work_record": "Arbeitsnachweis", + "daily_report": "Tagesbericht", + "completion_report": "Abschlussbericht", + "customer_approval": "Kundenfreigabe", + "photo": "Foto", + "voice_note": "Sprachnotiz", + "signature": "Unterschrift", + "other": "Sonstiges" + }, + "visibilities": { + "backoffice_only": "Nur Büro", + "team_lead": "Teamleiter", + "team": "Team", + "customer_report": "Kundenbericht" + } + }, + "history": { + "statusChanges": "Statusänderungen", + "audit": "Protokoll", + "empty": "Noch keine Einträge.", + "created": "Angelegt als {status}", + "change": "{from} → {to}", + "by": "von {name}", + "system": "System", + "action": { "create": "Erstellt", "update": "Geändert", "delete": "Gelöscht" } + }, + "assign": { + "title": "Auftrag zuweisen", + "sub": "Team wählen, optional einzelne Monteure und Teamleiter", + "team": "Team", + "chooseTeam": "— Team wählen —", + "members": "Einzelne Monteure (optional)", + "teamLead": "Zuständiger Teamleiter", + "teamLeadDefault": "Teamleiter des Teams", + "noTeams": "Keine aktiven Teams. Teams werden unter „Teams“ angelegt.", + "submit": "Zuweisen", + "pending": "Weise zu…" + }, + "transition": { + "to": { + "draft": "Als Entwurf", + "review_required": "Zur Prüfung markieren", + "planned": "Einplanen", + "assigned": "Zuweisen", + "accepted": "Annehmen", + "en_route": "Anfahrt starten", + "in_progress": "Arbeit aufnehmen", + "paused": "Pausieren", + "waiting_material": "Wartet auf Material", + "daily_report_created": "Tagesbericht erstellt", + "technically_completed": "Technisch abschließen", + "signature_pending": "Unterschrift ausstehend", + "in_review": "Zur Prüfung geben", + "released_for_billing": "Zur Abrechnung freigeben", + "billed": "Als abgerechnet markieren", + "cancelled": "Stornieren" + }, + "correction": "Zur Korrektur zurückweisen", + "revoke": "Freigabe zurücknehmen", + "reasonTitle": "Begründung erforderlich", + "reason": "Begründung", + "reasonPlaceholder": "Kurz beschreiben, warum", + "confirm": "Bestätigen", + "pending": "Speichere…", + "done": "Status geändert." + }, + "edit": { + "title": "Auftrag bearbeiten", + "submit": "Speichern", + "pending": "Speichere…", + "saved": "Gespeichert." + }, + "conflicts": { + "crumb": "Aufträge", + "title": "Sync-Konflikte", + "sub": "Offline erfasste Änderungen, die nicht automatisch übernommen werden konnten", + "empty": "Keine offenen Konflikte.", + "received": "Eingegangen", + "user": "Gerät / Person", + "operation": "Vorgang", + "entity": "Auftrag", + "baseVersion": "Stand Gerät: Version {base} · aktuell: Version {current}", + "payload": "Inhalt", + "apply": "Übernehmen", + "discard": "Verwerfen", + "applied": "Übernommen.", + "discarded": "Verworfen.", + "applyHint": "Übernehmen wendet den Vorgang erneut auf den aktuellen Stand an – im Namen der Person, die ihn erfasst hat.", + "stubHint": "Übernehmen ist bis zur Anbindung der Synchronisation nur für Statusänderungen möglich." + }, + "errors": { + "not_found": "Nicht gefunden oder keine Berechtigung.", + "forbidden": "Dafür fehlt die Berechtigung.", + "invalid": "Eingaben prüfen.", + "conflict": "Der Auftrag wurde inzwischen geändert. Seite neu laden und erneut versuchen.", + "blocked": "Noch nicht möglich – es fehlen Angaben.", + "internal": "Vorgang nicht möglich.", + "validation_failed": "Pflichtfelder prüfen.", + "work_order_not_found": "Auftrag nicht gefunden.", + "customer_not_found": "Kunde nicht gefunden.", + "site_not_found": "Objekt nicht gefunden.", + "contact_not_found": "Ansprechpartner nicht gefunden.", + "site_customer_mismatch": "Das Objekt gehört zu einem anderen Kunden.", + "contact_customer_mismatch": "Der Ansprechpartner gehört zu einem anderen Kunden.", + "order_type_not_found": "Auftragsart nicht gefunden.", + "planned_end_before_start": "Das Ende liegt vor dem Beginn.", + "nothing_to_update": "Keine Änderungen.", + "not_editable": "Der Auftrag kann in diesem Status nicht mehr geändert werden.", + "delete_only_draft": "Nur Entwürfe können gelöscht werden.", + "transition_not_allowed": "Dieser Statuswechsel ist nicht vorgesehen.", + "transition_forbidden": "Diesen Statuswechsel dürfen Sie nicht ausführen.", + "transition_blocked": "Noch nicht möglich – es fehlen Angaben.", + "reason_required": "Bitte eine Begründung angeben.", + "version_conflict": "Der Auftrag wurde inzwischen geändert. Seite neu laden und erneut versuchen.", + "team_not_found": "Team nicht gefunden oder nicht aktiv.", + "user_not_found": "Mindestens eine Person ist nicht aktiv.", + "material_plan_not_found": "Materialposition nicht gefunden.", + "material_in_use": "Zu dieser Position wurde bereits Material erfasst.", + "checklist_item_not_found": "Checklistenpunkt nicht gefunden.", + "checklist_item_checked": "Erledigte Punkte bleiben als Nachweis erhalten.", + "photo_requirement_not_found": "Pflichtfoto nicht gefunden.", + "photo_requirement_has_photos": "Zu diesem Pflichtfoto gibt es bereits Fotos.", + "template_not_found": "Vorlage nicht gefunden.", + "sync_conflict_not_found": "Konflikt nicht gefunden oder bereits erledigt.", + "sync_conflict_already_resolved": "Konflikt wurde bereits erledigt.", + "sync_payload_invalid": "Der Vorgang ist unvollständig.", + "sync_user_inactive": "Die Person ist nicht mehr aktiv.", + "reapply_unsupported": "Dieser Vorgang kann noch nicht übernommen werden.", + "document_not_found": "Dokument nicht gefunden.", + "file_type_not_allowed": "Dateityp nicht erlaubt.", + "file_too_large": "Datei ist leer oder zu groß.", + "file_missing": "Bitte eine Datei wählen.", + "category_invalid": "Kategorie nicht erlaubt.", + "visibility_forbidden": "Diese Sichtbarkeit dürfen Sie nicht vergeben.", + "initial_status_not_allowed": "Dieser Anfangsstatus ist nicht erlaubt." + }, + "blockers": { + "checklist_item": "Checkliste: {label}", + "photo_requirement": "Pflichtfoto: {label}", + "running_session": "Ein Einsatz läuft noch", + "field": { + "signature": "Unterschrift fehlt", + "approved_completion_report": "Abschlussbericht ist noch nicht freigegeben" + } + }, + "saved": "Gespeichert." +} diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 01f1e6c..eee1199 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -2,7 +2,34 @@ "title": "Dashboard", "crumb": "Overview", "subtitle": "Welcome, {name} — {tenant}", - "placeholderTitle": "Dashboard under construction", - "placeholder": "Open work orders, today's jobs, reports awaiting approval and emergency calls will appear here.", - "moduleDisabled": "The requested module is not enabled for your company." + "moduleDisabled": "The requested module is not enabled for your company.", + "workOrdersDisabled": "The work orders module is not enabled – there are no figures.", + "filterTitle": "Narrow down figures", + "tiles": { + "open": "Open work orders", + "today": "Today's jobs", + "running": "Running jobs", + "not_accepted": "Not accepted", + "overdue": "Overdue", + "reports_in_review": "Reports to review", + "completed": "Completed", + "billing": "Ready for billing", + "emergency_new": "New emergencies", + "missing_signatures": "Missing signatures", + "sync_conflicts": "Sync conflicts" + }, + "hints": { + "open": "Not yet billed or cancelled", + "today": "Planned for today", + "running": "En route, in progress, paused", + "not_accepted": "Assigned, not yet accepted", + "overdue": "Planned end exceeded", + "reports_in_review": "Submitted, not yet approved", + "completed": "Technically completed or beyond", + "billing": "Ready to invoice", + "emergency_new": "New or in review", + "missing_signatures": "Signature pending", + "sync_conflicts": "Review offline changes" + }, + "open": "Show" } diff --git a/messages/en/nav.json b/messages/en/nav.json index ebd2d6e..fdeec50 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -7,6 +7,7 @@ "teams": "Teams", "reports": "Reports", "documents": "Documents", + "templates": "Order templates", "settings": "Settings", "notifications": "Notifications", "audit": "Audit log", diff --git a/messages/en/search.json b/messages/en/search.json new file mode 100644 index 0000000..548274e --- /dev/null +++ b/messages/en/search.json @@ -0,0 +1,28 @@ +{ + "crumb": "Search", + "title": "Search", + "placeholder": "Work orders, customers, sites, addresses, documents, notes", + "submit": "Search", + "hint": "Enter at least 2 characters.", + "noResults": "No results for “{q}”.", + "resultsFor": "Results for “{q}”", + "filters": { + "from": "From", + "to": "To", + "status": "Status", + "team": "Team", + "any": "All", + "apply": "Apply", + "reset": "Reset filters", + "note": "Period, status and team narrow work orders and everything attached to them." + }, + "sections": { + "workOrders": "Work orders", + "customers": "Customers", + "sites": "Sites", + "contacts": "Contact persons", + "documents": "Documents", + "notes": "Notes" + }, + "maxHint": "At most 20 results per section are shown." +} diff --git a/messages/en/settingsTemplates.json b/messages/en/settingsTemplates.json new file mode 100644 index 0000000..a9daf08 --- /dev/null +++ b/messages/en/settingsTemplates.json @@ -0,0 +1,73 @@ +{ + "crumb": "Settings", + "nav": { + "orderTypes": "Order types", + "checklists": "Checklist templates", + "numbering": "Number sequences" + }, + "orderTypes": { + "title": "Order types", + "sub": "Order types per company – including whether a customer signature is required", + "key": "Key", + "name": "Name", + "signatureRequired": "Signature required", + "active": "Active", + "sortOrder": "Order", + "create": "Create order type", + "save": "Save", + "keyHint": "Lower-case letters, digits, underscore – cannot be changed", + "inactive": "Inactive", + "yes": "Yes", + "no": "No" + }, + "checklists": { + "title": "Checklist templates", + "sub": "Templates per order type – applied when a work order is created", + "empty": "No templates yet.", + "new": "New template", + "edit": "Edit", + "name": "Name", + "orderType": "Order type", + "noOrderType": "— none —", + "active": "Active", + "items": "Checklist items", + "itemsHint": "One line per item. Prefix “*” = required, suffix “[Foto]” = with photo.", + "photos": "Required photos", + "photosHint": "One line per required photo.", + "suggestion": "Insert default suggestion", + "suggestionHint": "Suggestion from the default items; adjust before saving.", + "save": "Save template", + "itemCount": "{count, plural, =0 {No items} one {# item} other {# items}}", + "photoCount": "{count, plural, =0 {no required photos} one {# required photo} other {# required photos}}", + "inactive": "Inactive", + "photoSuffix": "[Foto]" + }, + "numbering": { + "title": "Number sequences", + "sub": "Prefix and number of digits of running numbers. The counter always continues.", + "key": "Sequence", + "prefix": "Prefix", + "padding": "Digits", + "next": "Next number", + "save": "Save", + "keys": { + "work_order": "Work orders", + "emergency": "Emergencies", + "customer": "Customers", + "report": "Reports" + } + }, + "saved": "Saved.", + "errors": { + "forbidden": "You lack the permission for this.", + "invalid": "Please check your input.", + "not_found": "Not found.", + "internal": "Operation not possible.", + "validation_failed": "Please check required fields.", + "order_type_key_taken": "This key is already taken.", + "order_type_not_found": "Order type not found.", + "template_not_found": "Template not found.", + "duplicate_item_key": "Two checklist items have the same label.", + "duplicate_photo_key": "Two required photos have the same label." + } +} diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json new file mode 100644 index 0000000..bdeed36 --- /dev/null +++ b/messages/en/workOrders.json @@ -0,0 +1,445 @@ +{ + "crumb": "Work orders", + "title": "Work orders", + "subtitle": "Plan, assign, review and release for billing", + "new": "New work order", + "conflictsLink": "Sync conflicts", + "allGroups": "All", + "empty": "No work orders for this selection.", + "resultCount": "{count, plural, =0 {No work orders} one {# work order} other {# work orders}}", + "presetActive": "Tile filter: {name}", + "removePreset": "Remove tile filter", + "view": { "cards": "Cards", "table": "Table" }, + "pagination": { "prev": "Previous", "next": "Next", "page": "Page {page} of {pages}" }, + "filter": { + "title": "Filter", + "q": "Number, title, customer, city", + "from": "From", + "to": "To", + "customer": "Customer", + "site": "Site", + "team": "Team", + "user": "Technician", + "status": "Status", + "orderType": "Order type", + "priority": "Priority", + "sort": "Sort", + "any": "All", + "apply": "Apply", + "reset": "Reset" + }, + "sort": { + "plannedStart": "Planned start", + "createdAt": "Created", + "updatedAt": "Last changed", + "number": "Number", + "priority": "Priority", + "status": "Status", + "asc": "ascending", + "desc": "descending" + }, + "columns": { + "number": "Number", + "title": "Title", + "customer": "Customer", + "site": "Site", + "planned": "Planned", + "team": "Team", + "status": "Status", + "priority": "Priority" + }, + "status": { + "draft": "Draft", + "review_required": "Review required", + "planned": "Planned", + "assigned": "Assigned", + "accepted": "Accepted", + "en_route": "En route", + "in_progress": "In progress", + "paused": "Paused", + "waiting_material": "Waiting for material", + "daily_report_created": "Daily report created", + "technically_completed": "Technically completed", + "signature_pending": "Signature pending", + "in_review": "In review", + "released_for_billing": "Released for billing", + "billed": "Billed", + "cancelled": "Cancelled" + }, + "statusGroup": { + "new": "New", + "planned": "Planned", + "en_route": "En route", + "in_progress": "In progress", + "documentation_incomplete": "Documentation incomplete", + "in_review": "In review", + "ready_for_billing": "Ready for billing", + "billed": "Billed", + "cancelled": "Cancelled" + }, + "priority": { "low": "Low", "normal": "Normal", "high": "High", "urgent": "Urgent" }, + "billingType": { + "fixed": "Fixed price", + "time_material": "Time and material", + "maintenance_contract": "Maintenance contract", + "warranty": "Warranty" + }, + "emergency": "Emergency", + "unassigned": "Not assigned", + "noDate": "No date", + "create": { + "title": "New work order", + "sub": "Choose a customer, enter the order", + "stepCustomer": "1. Find customer", + "customerSearch": "Company, name, customer number or city", + "searchButton": "Search", + "noCustomers": "No customer found. Customers are created under “Customers”.", + "chooseCustomer": "Select", + "changeCustomer": "Choose another customer", + "stepDetails": "2. Enter order", + "submit": "Create work order", + "pending": "Creating…" + }, + "fields": { + "title": "Title", + "customer": "Customer", + "site": "Site", + "contact": "Contact person", + "none": "— none —", + "orderType": "Order type", + "priority": "Priority", + "plannedStart": "Planned start", + "plannedEnd": "Planned end", + "description": "Description", + "scope": "Scope of work", + "internalNotes": "Internal notes (office only)", + "technicianNotes": "Notes for technicians", + "signatureRequired": "Signature required", + "signatureFromType": "As order type", + "yes": "Yes", + "no": "No", + "billingType": "Billing type", + "externalOrderNumber": "External order number", + "offerNumber": "Offer number", + "status": "Create as", + "timezoneHint": "Times in {tz}" + }, + "detail": { + "back": "Back to work orders", + "version": "Version {version}", + "nextStep": "Next step", + "moreActions": "Other status changes", + "edit": "Edit", + "assign": "Assign", + "reassign": "Change assignment", + "cancel": "Cancel order", + "noActions": "No status change is available for you right now.", + "blockersTitle": "Still missing before completion:", + "tabs": { + "overview": "Overview", + "checklist": "Checklist & required photos", + "material": "Material", + "times": "Times", + "photos": "Photos", + "notes": "Notes", + "reports": "Reports", + "documents": "Documents", + "history": "History" + } + }, + "overview": { + "customer": "Customer", + "site": "Site", + "contact": "Contact person", + "planning": "Planning", + "assignment": "Assignment", + "team": "Team", + "teamLead": "Team lead", + "assignees": "Technicians", + "order": "Order", + "created": "Created", + "updated": "Changed", + "accessNotes": "Access", + "safetyNotes": "Safety" + }, + "checklist": { + "items": "Checklist", + "photos": "Required photos", + "empty": "No checklist items.", + "emptyPhotos": "No required photos.", + "required": "Required", + "optional": "Optional", + "requiresPhoto": "with photo", + "done": "Done", + "open": "Open", + "photoCount": "{count, plural, =0 {No photo} one {# photo} other {# photos}}", + "addItem": "Add item", + "addPhoto": "Add required photo", + "label": "Label", + "applyTemplate": "Apply template", + "template": "Template", + "applyButton": "Apply", + "remove": "Remove", + "complete": "All completion requirements met." + }, + "material": { + "title": "Material – planned and actual", + "empty": "No material planned or recorded.", + "name": "Name", + "articleNumber": "Article number", + "planned": "Planned", + "actual": "Actual", + "deviation": "Deviation", + "unit": "Unit", + "notes": "Remark", + "reason": "Reason", + "additional": "Additional", + "notRecorded": "Not recorded yet", + "add": "Plan material", + "remove": "Remove", + "usage": { + "fully_used": "Fully used", + "partially_used": "Partially used", + "not_used": "Not used", + "additional": "Additionally used" + } + }, + "times": { + "empty": "No times recorded yet.", + "person": "Person", + "start": "Start", + "end": "End", + "duration": "Working time", + "minutes": "{minutes} min", + "corrected": "Corrected: {reason}", + "sessionStatus": { + "en_route": "En route", + "running": "Running", + "paused": "Paused", + "ended": "Ended" + }, + "entryType": { + "travel": "Travel", + "work": "Work", + "break": "Break", + "material_procurement": "Material procurement", + "return_travel": "Return travel", + "interruption": "Interruption" + } + }, + "photos": { + "empty": "No photos yet.", + "phase": { "before": "Before", "during": "During", "after": "After" }, + "open": "Open", + "inReport": "In report" + }, + "notes": { + "empty": "No notes.", + "internal": "Internal notes", + "technician": "Notes for technicians", + "kind": { + "work_done": "Work done", + "deviation": "Deviation", + "problem": "Problem", + "additional_work": "Additional work", + "not_executable": "Not executable", + "follow_up": "Follow-up", + "recommendation": "Recommendation", + "customer_note": "Customer note", + "general": "General" + } + }, + "reports": { + "empty": "No reports yet.", + "type": { "daily": "Daily report", "completion": "Completion report" }, + "status": { + "draft": "Draft", + "submitted": "In review", + "team_approved": "Approved by team lead", + "approved": "Approved", + "rejected": "Rejected", + "superseded": "Superseded" + }, + "open": "Open report", + "signature": "Signature: {outcome}", + "signatureOutcome": { + "signed": "signed", + "customer_absent": "customer absent", + "refused": "refused", + "later": "later", + "not_required": "not required" + }, + "billingTitle": "Billing", + "billingHint": "Release requires an approved completion report.", + "release": "Release for billing", + "reject": "Send back for correction", + "markBilled": "Mark as billed", + "revoke": "Revoke release" + }, + "documents": { + "empty": "No documents on this order.", + "upload": "Upload document", + "file": "File (PDF, JPG, PNG, WebP)", + "title": "Title (optional)", + "category": "Category", + "visibility": "Visibility", + "submit": "Upload", + "archive": "Remove", + "download": "Download", + "stubHint": "Until the document store is connected, files are stored directly in object storage.", + "categories": { + "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" + }, + "visibilities": { + "backoffice_only": "Office only", + "team_lead": "Team lead", + "team": "Team", + "customer_report": "Customer report" + } + }, + "history": { + "statusChanges": "Status changes", + "audit": "Log", + "empty": "No entries yet.", + "created": "Created as {status}", + "change": "{from} → {to}", + "by": "by {name}", + "system": "System", + "action": { "create": "Created", "update": "Changed", "delete": "Deleted" } + }, + "assign": { + "title": "Assign work order", + "sub": "Choose a team, optionally individual technicians and a team lead", + "team": "Team", + "chooseTeam": "— choose team —", + "members": "Individual technicians (optional)", + "teamLead": "Responsible team lead", + "teamLeadDefault": "Team lead of the team", + "noTeams": "No active teams. Teams are created under “Teams”.", + "submit": "Assign", + "pending": "Assigning…" + }, + "transition": { + "to": { + "draft": "As draft", + "review_required": "Mark for review", + "planned": "Schedule", + "assigned": "Assign", + "accepted": "Accept", + "en_route": "Start travel", + "in_progress": "Start work", + "paused": "Pause", + "waiting_material": "Waiting for material", + "daily_report_created": "Daily report created", + "technically_completed": "Complete technically", + "signature_pending": "Signature pending", + "in_review": "Submit for review", + "released_for_billing": "Release for billing", + "billed": "Mark as billed", + "cancelled": "Cancel order" + }, + "correction": "Send back for correction", + "revoke": "Revoke release", + "reasonTitle": "Reason required", + "reason": "Reason", + "reasonPlaceholder": "Briefly describe why", + "confirm": "Confirm", + "pending": "Saving…", + "done": "Status changed." + }, + "edit": { + "title": "Edit work order", + "submit": "Save", + "pending": "Saving…", + "saved": "Saved." + }, + "conflicts": { + "crumb": "Work orders", + "title": "Sync conflicts", + "sub": "Changes recorded offline that could not be applied automatically", + "empty": "No open conflicts.", + "received": "Received", + "user": "Device / person", + "operation": "Operation", + "entity": "Work order", + "baseVersion": "Device state: version {base} · current: version {current}", + "payload": "Content", + "apply": "Apply", + "discard": "Discard", + "applied": "Applied.", + "discarded": "Discarded.", + "applyHint": "Apply re-runs the operation against the current state – on behalf of the person who recorded it.", + "stubHint": "Until sync is connected, only status changes can be applied." + }, + "errors": { + "not_found": "Not found or no permission.", + "forbidden": "You lack the permission for this.", + "invalid": "Please check your input.", + "conflict": "The order was changed in the meantime. Reload and try again.", + "blocked": "Not possible yet – information is missing.", + "internal": "Operation not possible.", + "validation_failed": "Please check required fields.", + "work_order_not_found": "Work order not found.", + "customer_not_found": "Customer not found.", + "site_not_found": "Site not found.", + "contact_not_found": "Contact person not found.", + "site_customer_mismatch": "The site belongs to another customer.", + "contact_customer_mismatch": "The contact person belongs to another customer.", + "order_type_not_found": "Order type not found.", + "planned_end_before_start": "The end is before the start.", + "nothing_to_update": "No changes.", + "not_editable": "The order can no longer be changed in this status.", + "delete_only_draft": "Only drafts can be deleted.", + "transition_not_allowed": "This status change is not intended.", + "transition_forbidden": "You may not perform this status change.", + "transition_blocked": "Not possible yet – information is missing.", + "reason_required": "Please give a reason.", + "version_conflict": "The order was changed in the meantime. Reload and try again.", + "team_not_found": "Team not found or not active.", + "user_not_found": "At least one person is not active.", + "material_plan_not_found": "Material item not found.", + "material_in_use": "Material has already been recorded for this item.", + "checklist_item_not_found": "Checklist item not found.", + "checklist_item_checked": "Completed items are kept as evidence.", + "photo_requirement_not_found": "Required photo not found.", + "photo_requirement_has_photos": "Photos already exist for this requirement.", + "template_not_found": "Template not found.", + "sync_conflict_not_found": "Conflict not found or already resolved.", + "sync_conflict_already_resolved": "Conflict already resolved.", + "sync_payload_invalid": "The operation is incomplete.", + "sync_user_inactive": "The person is no longer active.", + "reapply_unsupported": "This operation cannot be applied yet.", + "document_not_found": "Document not found.", + "file_type_not_allowed": "File type not allowed.", + "file_too_large": "File is empty or too large.", + "file_missing": "Please choose a file.", + "category_invalid": "Category not allowed.", + "visibility_forbidden": "You may not assign this visibility.", + "initial_status_not_allowed": "This initial status is not allowed." + }, + "blockers": { + "checklist_item": "Checklist: {label}", + "photo_requirement": "Required photo: {label}", + "running_session": "A work session is still running", + "field": { + "signature": "Signature missing", + "approved_completion_report": "Completion report not approved yet" + } + }, + "saved": "Saved." +} diff --git a/scripts/lib-auftraege-fixtures.ts b/scripts/lib-auftraege-fixtures.ts new file mode 100644 index 0000000..6894d4e --- /dev/null +++ b/scripts/lib-auftraege-fixtures.ts @@ -0,0 +1,172 @@ +// Shared fixtures for scripts/test-auftraege-*.ts (lane L2). Not a test itself (no "test-" prefix). +// +// Creates isolated zz-test tenants with the four standard roles, a team with lead + technician, +// an outside technician, a customer and a site; builds ServiceCtx objects exactly like +// ctxFromGuard does (tenant db + DB-authoritative permission set). + +import "dotenv/config"; +import { prisma, dbForTenant } from "../src/server/db"; +import { ROLE_DEFS, type RoleKey } from "../src/server/rbac"; +import type { ServiceCtx } from "../src/server/services/context"; + +export let failures = 0; +export const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +/** Expect a ServiceError with the given code (e.g. "not_found"). Returns the error. */ +export async function expectCode(fn: () => Promise, code: string, msg: string): Promise { + try { + await fn(); + ok(false, `${msg} — kein Fehler (erwartet ${code})`); + return null; + } catch (e) { + const c = (e as { code?: string }).code; + ok(c === code, `${msg}${c === code ? "" : ` — erhalten ${c ?? (e as Error).message}`}`); + return e; + } +} + +export type Fixture = { + tenantId: string; + users: Record<"admin" | "backoffice" | "lead" | "tech" | "outsider", { id: string }>; + ctx: Record<"admin" | "backoffice" | "lead" | "tech" | "outsider", ServiceCtx>; + teamId: string; + otherTeamId: string; + customerId: string; + siteId: string; + contactId: string; +}; + +const ROLE_OF = { admin: "tenant-admin", backoffice: "backoffice", lead: "team-lead", tech: "technician", outsider: "technician" } as const; + +export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx { + return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions) }; +} + +export async function cleanupTenants(slugs: string[]) { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (!ids.length) return; + const w = { where: { tenantId: { in: ids } } }; + await prisma.syncOperation.deleteMany(w); + await prisma.notification.deleteMany(w); + await prisma.signature.deleteMany(w); + await prisma.report.deleteMany(w); + await prisma.photo.deleteMany(w); + await prisma.activityNote.deleteMany(w); + await prisma.voiceNote.deleteMany(w); + await prisma.timeEntry.deleteMany(w); + await prisma.materialUsage.deleteMany(w); + await prisma.workSession.deleteMany(w); + await prisma.materialPlan.deleteMany(w); + await prisma.photoRequirement.deleteMany(w); + await prisma.checklistItem.deleteMany(w); + await prisma.workOrderStatusChange.deleteMany(w); + await prisma.workOrderAssignee.deleteMany(w); + await prisma.document.deleteMany(w); + await prisma.workOrder.deleteMany(w); + await prisma.teamMember.deleteMany(w); + await prisma.team.deleteMany(w); + await prisma.site.deleteMany(w); + await prisma.contact.deleteMany(w); + await prisma.customer.deleteMany(w); + await prisma.checklistTemplate.deleteMany(w); + await prisma.orderType.deleteMany(w); + await prisma.numberSequence.deleteMany(w); + await prisma.tenantSettings.deleteMany(w); + await prisma.auditLog.deleteMany(w); + await prisma.user.deleteMany(w); + await prisma.role.deleteMany(w); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + await prisma.identity.deleteMany({ where: { email: { endsWith: "@zz-auftraege.test" }, memberships: { none: {} } } }); +} + +async function ensurePermissionIds(keys: readonly string[]) { + const rows = await Promise.all(keys.map((key) => prisma.permission.upsert({ where: { key }, update: {}, create: { key } }))); + return rows.map((r) => r.id); +} + +export async function createFixture(slug: string): Promise { + const tenant = await prisma.tenant.create({ data: { name: `L2 Test ${slug}`, slug } }); + await prisma.tenantSettings.create({ data: { tenantId: tenant.id, orgName: `L2 Test ${slug}`, timezone: "Europe/Berlin" } }); + const roleIds: Partial> = {}; + for (const key of Object.keys(ROLE_DEFS) as RoleKey[]) { + const permIds = await ensurePermissionIds(ROLE_DEFS[key].permissions); + const role = await prisma.role.create({ + data: { tenantId: tenant.id, key, name: ROLE_DEFS[key].name, rolePermissions: { create: permIds.map((permissionId) => ({ permissionId })) } }, + }); + roleIds[key] = role.id; + } + + const users = {} as Fixture["users"]; + const ctx = {} as Fixture["ctx"]; + for (const name of Object.keys(ROLE_OF) as (keyof typeof ROLE_OF)[]) { + const email = `${name}-${slug}@zz-auftraege.test`; + const identity = await prisma.identity.create({ data: { email, passwordHash: "x" } }); + const user = await prisma.user.create({ + data: { tenantId: tenant.id, identityId: identity.id, email, name: `${name} ${slug}`, userRoles: { create: [{ roleId: roleIds[ROLE_OF[name]]! }] } }, + }); + users[name] = { id: user.id }; + ctx[name] = ctxFor(tenant.id, user.id, ROLE_OF[name]); + } + + const team = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Nord", leaderUserId: users.lead.id } }); + await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: team.id, userId: users.tech.id, validFrom: new Date(Date.now() - 86400000) } }); + const otherTeam = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Süd" } }); + await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: otherTeam.id, userId: users.outsider.id, validFrom: new Date(Date.now() - 86400000) } }); + + const customer = await prisma.customer.create({ data: { tenantId: tenant.id, companyName: `Kunde ${slug} GmbH`, customerNumber: `K-${slug}`, city: "Hamburg", street: "Hafenweg" } }); + const site = await prisma.site.create({ data: { tenantId: tenant.id, customerId: customer.id, name: `Objekt ${slug}`, city: "Hamburg", street: "Kaistraße", houseNumber: "7" } }); + const contact = await prisma.contact.create({ data: { tenantId: tenant.id, customerId: customer.id, name: `Frau Kontakt ${slug}` } }); + + return { tenantId: tenant.id, users, ctx, teamId: team.id, otherTeamId: otherTeam.id, customerId: customer.id, siteId: site.id, contactId: contact.id }; +} + +/** Raw work order in a given status, assigned to the fixture team (setup only — bypasses the state machine). */ +export async function rawWorkOrder(f: Fixture, status: string, extra: Record = {}) { + const n = Math.random().toString(36).slice(2, 10); + return prisma.workOrder.create({ + data: { + tenantId: f.tenantId, + number: `T-${n}`, + title: `Testauftrag ${n}`, + customerId: f.customerId, + siteId: f.siteId, + status: status as never, + assignedTeamId: f.teamId, + teamLeadUserId: f.users.lead.id, + signatureRequired: true, + ...extra, + }, + }); +} + +export async function approvedCompletionReport(f: Fixture, workOrderId: string, opts: { status?: string; signature?: boolean } = {}) { + const report = await prisma.report.create({ + data: { + tenantId: f.tenantId, + workOrderId, + type: "completion", + reportDate: new Date(), + lineageId: `lin-${workOrderId}-${Math.random().toString(36).slice(2, 8)}`, + status: (opts.status ?? "approved") as never, + content: {}, + }, + }); + if (opts.signature) { + await prisma.signature.create({ data: { tenantId: f.tenantId, reportId: report.id, outcome: "signed", signerName: "Kunde", signedAt: new Date() } }); + } + return report; +} + +export function finish(cleanup: () => Promise) { + return async () => { + await cleanup(); + await prisma.$disconnect(); + if (failures === 0) console.log("\nOK — alle Nachweise erfüllt."); + else console.log(`\n${failures} FEHLER.`); + process.exit(failures === 0 ? 0 : 1); + }; +} diff --git a/scripts/test-auftraege-core.ts b/scripts/test-auftraege-core.ts new file mode 100644 index 0000000..29129fc --- /dev/null +++ b/scripts/test-auftraege-core.ts @@ -0,0 +1,204 @@ +// L2 Aufträge: Kernlogik der Service-Schicht. +// +// (1) createWorkOrder: Nummer aus Nummernkreis, Übernahme Checkliste/Pflichtfotos/signatureRequired aus Auftragsart +// (2) updateWorkOrder: Version +1, baseVersion-Konflikt, Objekt/Kunde-Konsistenz, Audit before/after +// (3) assignWorkOrder: Team + Einzelpersonen + Teamleiter, draft → assigned, Audit +// (4) Completion-Blocker: Pflicht-Checklistenpunkt, fehlendes Pflichtfoto, laufende Session → blocked +// (5) Abrechnungsfreigabe nur mit freigegebenem Abschlussbericht; Unterschrift vor in_review +// (6) Materialvorgabe-CRUD + Soll/Ist-Übersicht mit Abweichung +// (7) Liste: Filter, Presets, Statusgruppen-Zähler; Dashboard-Kacheln +// (8) Sync-Konflikte: verwerfen / übernehmen +// (9) Einstellungen: Standard-Auftragsarten idempotent, Vorlage, Nummernkreis-Präfix +// +// Lauf: npx tsx scripts/test-auftraege-core.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { parseListParams } from "../src/lib/work-orders/filters"; +import { DEFAULT_CHECKLIST_ITEMS, DEFAULT_REQUIRED_PHOTOS } from "../src/lib/work-orders/defaults"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { updateWorkOrder, deleteWorkOrder } from "../src/server/services/work-orders/update"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { getCompletionBlockers } from "../src/server/services/work-orders/completion"; +import { releaseForBilling, rejectForCorrection } from "../src/server/services/work-orders/release-billing"; +import { cancelWorkOrder } from "../src/server/services/work-orders/cancel"; +import { addMaterialPlan, getMaterialOverview, removeMaterialPlan, updateMaterialPlan } from "../src/server/services/work-orders/materials"; +import { addChecklistItem, addPhotoRequirement, removeChecklistItem } from "../src/server/services/work-orders/checklist"; +import { listWorkOrders } from "../src/server/services/work-orders/list"; +import { getDashboardTiles } from "../src/server/services/work-orders/dashboard"; +import { applySyncConflict, discardSyncConflict, listSyncConflicts } from "../src/server/services/work-orders/conflicts"; +import { + createChecklistTemplate, + ensureDefaultOrderTypes, + listNumberSequences, + listOrderTypes, + updateNumberSequence, +} from "../src/server/services/work-orders/settings"; +import { approvedCompletionReport, cleanupTenants, createFixture, expectCode, finish, ok, rawWorkOrder } from "./lib-auftraege-fixtures"; + +const SLUG = "zz-l2-core"; + +async function main() { + await cleanupTenants([SLUG]); + const f = await createFixture(SLUG); + const bo = f.ctx.backoffice; + + console.log("\n— (9a) Standard-Auftragsarten + Vorlage —"); + const types = await listOrderTypes(bo); + ok(types.length === 8 && types.some((t) => t.key === "montage") && types.some((t) => t.key === "notdienst"), "8 Standard-Auftragsarten beim ersten Aufruf angelegt"); + ok((await ensureDefaultOrderTypes(bo.db, bo.tenantId)) === 0, "ensureDefaultOrderTypes ist idempotent"); + const besichtigung = types.find((t) => t.key === "besichtigung")!; + const montage = types.find((t) => t.key === "montage")!; + await expectCode( + () => createChecklistTemplate(f.ctx.tech, { name: "X", items: [], requiredPhotos: [] }), + "forbidden", + "Monteur darf keine Vorlagen pflegen (settings:templates)", + ); + await createChecklistTemplate(bo, { + name: "Montage Standard", + orderTypeId: montage.id, + items: [...DEFAULT_CHECKLIST_ITEMS], + requiredPhotos: DEFAULT_REQUIRED_PHOTOS.slice(0, 2), + }); + + console.log("\n— (1) createWorkOrder —"); + const a = await createWorkOrder(bo, { title: "Heizung montieren", customerId: f.customerId, siteId: f.siteId, contactId: f.contactId, orderTypeId: montage.id, plannedStart: new Date(Date.now() - 2 * 86400000), plannedEnd: new Date(Date.now() - 86400000) }); + ok(/^A-\d{5}$/.test(a.number) && a.status === "draft" && a.version === 1, `Nummer ${a.number}, Status draft, Version 1`); + const aItems = await prisma.checklistItem.count({ where: { workOrderId: a.id } }); + const aPhotos = await prisma.photoRequirement.count({ where: { workOrderId: a.id } }); + ok(aItems === DEFAULT_CHECKLIST_ITEMS.length && aPhotos === 2, "Checkliste und Pflichtfotos aus Auftragsart-Vorlage übernommen"); + const b = await createWorkOrder(bo, { title: "Besichtigung", customerId: f.customerId, orderTypeId: besichtigung.id }); + const bRow = await prisma.workOrder.findUnique({ where: { id: b.id } }); + ok(bRow?.signatureRequired === false && b.number !== a.number, "signatureRequired aus Auftragsart (Besichtigung = false), neue Nummer"); + ok((await prisma.auditLog.count({ where: { entity: "work_order", entityId: a.id, action: "create" } })) === 1, "Audit-Eintrag create"); + await expectCode(() => createWorkOrder(f.ctx.tech, { title: "X", customerId: f.customerId }), "forbidden", "Monteur darf keinen regulären Auftrag anlegen"); + const em = await createWorkOrder(f.ctx.tech, { title: "Rohrbruch", customerId: f.customerId, isEmergency: true, numberKey: "emergency", status: "in_progress" }); + ok(em.number.startsWith("N-"), "Notdienst mit emergency:create angelegt (Nummer N-…)"); + const otherSite = await prisma.site.create({ data: { tenantId: f.tenantId, customerId: (await prisma.customer.create({ data: { tenantId: f.tenantId, companyName: "Fremdkunde" } })).id, name: "Fremdobjekt" } }); + await expectCode(() => createWorkOrder(bo, { title: "X", customerId: f.customerId, siteId: otherSite.id }), "invalid", "Objekt eines anderen Kunden → invalid"); + + console.log("\n— (2) updateWorkOrder —"); + const u1 = await updateWorkOrder(bo, a.id, { title: "Heizung montieren (2 Etagen)", priority: "high" }, 1); + ok(u1.version === 2, "Version nach Update 2"); + await expectCode(() => updateWorkOrder(bo, a.id, { title: "veraltet" }, 1), "conflict", "Update mit veralteter baseVersion → conflict"); + await expectCode(() => updateWorkOrder(bo, a.id, { siteId: otherSite.id }), "invalid", "Update mit fremdem Objekt → invalid"); + const auditU = await prisma.auditLog.findFirst({ where: { entity: "work_order", entityId: a.id, action: "update" }, orderBy: { createdAt: "desc" } }); + ok((auditU?.before as { title?: string })?.title === "Heizung montieren" && (auditU?.after as { title?: string })?.title?.includes("2 Etagen") === true, "Audit before/after enthält Titel alt/neu"); + const d = await createWorkOrder(bo, { title: "Löschen", customerId: f.customerId }); + await deleteWorkOrder(bo, d.id); + ok((await prisma.workOrder.findUnique({ where: { id: d.id } }))?.deletedAt != null, "Entwurf soft-deleted (deletedAt gesetzt)"); + + console.log("\n— (3) assignWorkOrder —"); + const as = await assignWorkOrder(bo, { workOrderId: a.id, teamId: f.teamId, userIds: [f.users.tech.id] }); + const aRow = await prisma.workOrder.findUnique({ where: { id: a.id }, include: { assignees: true } }); + ok(as.status === "assigned" && aRow?.assignedTeamId === f.teamId && aRow.teamLeadUserId === f.users.lead.id && aRow.assignees.length === 1, "Team, Teamleiter (aus Team), Monteur zugewiesen, Status assigned"); + ok(aRow?.version === 3, "Version nach Zuweisung 3"); + await expectCode(() => assignWorkOrder(f.ctx.lead, { workOrderId: a.id, teamId: f.teamId }), "forbidden", "Teamleiter ohne work_order:assign → forbidden"); + + console.log("\n— (4) Completion-Blocker —"); + await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "accepted" }); + await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "in_progress" }); + const session = await prisma.workSession.create({ data: { tenantId: f.tenantId, workOrderId: a.id, userId: f.users.tech.id, status: "running", startedAt: new Date() } }); + const err = (await expectCode(() => transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "technically_completed" }), "blocked", "Abschluss mit offenen Punkten → blocked")) as { details?: { kind: string }[] } | null; + const kinds = new Set((err?.details ?? []).map((x) => x.kind)); + ok(kinds.has("checklist_item") && kinds.has("photo_requirement") && kinds.has("running_session"), "Blocker enthalten Checkliste, Pflichtfoto und laufende Session"); + const requiredItems = await prisma.checklistItem.findMany({ where: { workOrderId: a.id, required: true } }); + const reqs = await prisma.photoRequirement.findMany({ where: { workOrderId: a.id } }); + for (const i of requiredItems) { + await prisma.checklistItem.update({ where: { id: i.id }, data: { checked: true } }); + if (i.requiresPhoto) await prisma.photo.create({ data: { tenantId: f.tenantId, workOrderId: a.id, documentId: randomUUID(), checklistItemId: i.id, takenAt: new Date() } }); + } + for (const r of reqs) await prisma.photo.create({ data: { tenantId: f.tenantId, workOrderId: a.id, documentId: randomUUID(), photoRequirementId: r.id, takenAt: new Date() } }); + const onlySession = await getCompletionBlockers(f.ctx.tech, a.id); + ok(onlySession.length === 1 && onlySession[0].kind === "running_session", "Nach Checkliste/Fotos bleibt nur die laufende Session"); + await prisma.workSession.update({ where: { id: session.id }, data: { status: "ended", endedAt: new Date() } }); + const done = await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "technically_completed" }); + ok(done.status === "technically_completed", "Ohne Blocker → technically_completed"); + + console.log("\n— (5) Unterschrift + Abrechnungsfreigabe —"); + await expectCode(() => transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "in_review" }), "blocked", "in_review ohne Unterschrift (signatureRequired) → blocked"); + const report = await approvedCompletionReport(f, a.id, { status: "submitted", signature: true }); + await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "in_review" }); + await expectCode(() => releaseForBilling(bo, { workOrderId: a.id }), "blocked", "Freigabe ohne freigegebenen Abschlussbericht → blocked"); + await expectCode(() => releaseForBilling(f.ctx.lead, { workOrderId: a.id }), "forbidden", "Teamleiter darf nicht zur Abrechnung freigeben"); + await expectCode(() => rejectForCorrection(bo, { workOrderId: a.id, reason: "" }), "invalid", "Zurückweisung ohne Grund → invalid"); + await prisma.report.update({ where: { id: report.id }, data: { status: "approved", approvedAt: new Date() } }); + const rel = await releaseForBilling(bo, { workOrderId: a.id }); + ok(rel.status === "released_for_billing", "Mit freigegebenem Abschlussbericht → released_for_billing"); + ok((await prisma.auditLog.count({ where: { entity: "work_order", entityId: a.id, after: { path: ["status"], equals: "released_for_billing" } } })) === 1, "Freigabe protokolliert"); + await expectCode(() => addMaterialPlan(bo, a.id, { name: "Rohr", plannedQuantity: 1, unit: "m" }), "invalid", "Nach Freigabe keine Materialvorgabe mehr (not_editable)"); + + console.log("\n— (6) Materialvorgabe + Soll/Ist —"); + const c = await createWorkOrder(bo, { title: "Material", customerId: f.customerId }); + const plan = await addMaterialPlan(bo, c.id, { name: "Kupferrohr 15mm", plannedQuantity: 10, unit: "m", articleNumber: "CU-15" }); + const plan2 = await addMaterialPlan(bo, c.id, { name: "Fitting", plannedQuantity: 4, unit: "Stk" }); + await updateMaterialPlan(bo, plan2.id, { name: "Fitting 15mm", plannedQuantity: 6, unit: "Stk" }); + ok((await prisma.workOrder.findUnique({ where: { id: c.id } }))?.version === 4, "Jede Materialmutation erhöht die Version (1 → 4)"); + await prisma.materialUsage.create({ data: { tenantId: f.tenantId, workOrderId: c.id, materialPlanId: plan.id, name: plan.name, actualQuantity: 12.5, unit: "m", usageStatus: "fully_used", deviationReason: "Umweg Leitung" } }); + await prisma.materialUsage.create({ data: { tenantId: f.tenantId, workOrderId: c.id, name: "Dichtband", actualQuantity: 1, unit: "Rolle", usageStatus: "additional" } }); + const overview = await getMaterialOverview(bo, c.id); + const row = overview.find((r) => r.planId === plan.id); + ok(row?.deviation === 2.5 && row.reasons[0] === "Umweg Leitung", "Abweichung +2,5 m inkl. Begründung"); + ok(overview.some((r) => r.planId === null && r.name === "Dichtband"), "Zusatzmaterial ohne Vorgabe erscheint"); + await expectCode(() => removeMaterialPlan(bo, plan.id), "invalid", "Vorgabe mit erfasstem Verbrauch nicht löschbar"); + await removeMaterialPlan(bo, plan2.id); + ok((await prisma.materialPlan.count({ where: { workOrderId: c.id } })) === 1, "Unbenutzte Vorgabe entfernt"); + const item = await addChecklistItem(bo, c.id, { label: "Druckprobe durchgeführt", required: true }); + await addPhotoRequirement(bo, c.id, { label: "Typenschild" }); + ok(item.key === "druckprobe_durchgefuehrt", "Checklistenpunkt mit stabilem Schlüssel"); + await removeChecklistItem(bo, item.id); + + console.log("\n— (7) Liste + Dashboard —"); + const overdue = await rawWorkOrder(f, "in_progress", { plannedStart: new Date(Date.now() - 5 * 86400000), plannedEnd: new Date(Date.now() - 3600000) }); + const today = await rawWorkOrder(f, "assigned", { plannedStart: new Date(), plannedEnd: new Date(Date.now() + 3600000), priority: "urgent" }); + await rawWorkOrder(f, "signature_pending"); + const all = await listWorkOrders(bo, parseListParams({})); + ok(all.total === (await prisma.workOrder.count({ where: { tenantId: f.tenantId, deletedAt: null } })), "Liste zeigt alle nicht gelöschten Aufträge"); + const od = await listWorkOrders(bo, parseListParams({ preset: "overdue" })); + ok(od.items.some((i) => i.id === overdue.id) && !od.items.some((i) => i.id === today.id), "Preset überfällig"); + const td = await listWorkOrders(bo, parseListParams({ preset: "today" })); + ok(td.items.some((i) => i.id === today.id), "Preset heute"); + const prio = await listWorkOrders(bo, parseListParams({ priority: "urgent", group: "planned" })); + ok(prio.total === 1 && prio.items[0].id === today.id, "Filter Priorität + Statusgruppe"); + ok(prio.groupCounts.in_progress >= 0 && all.groupCounts.documentation_incomplete >= 1, "Statusgruppen-Zähler vorhanden"); + const byTeam = await listWorkOrders(bo, parseListParams({ teamId: f.otherTeamId })); + ok(byTeam.total === 0, "Filter Team (fremdes Team leer)"); + const paged = await listWorkOrders(bo, parseListParams({ pageSize: "2", page: "2", sort: "number", dir: "desc" })); + ok(paged.items.length <= 2 && paged.page === 2, "Paginierung + Sortierung"); + const tiles = await getDashboardTiles(bo, {}); + ok(tiles.overdue >= 1 && tiles.today >= 1 && tiles.missing_signatures >= 1 && tiles.billing === 1 && tiles.not_accepted >= 1 && tiles.emergency_new >= 1, "Dashboard-Kacheln zählen korrekt"); + + console.log("\n— (8) Sync-Konflikte —"); + const conflictWo = await rawWorkOrder(f, "assigned"); + const op1 = await prisma.syncOperation.create({ data: { tenantId: f.tenantId, userId: f.users.tech.id, clientOpId: randomUUID(), opType: "work_order.transition", entityType: "work_order", entityId: conflictWo.id, baseVersion: 99, payload: { to: "accepted" }, status: "conflict", clientCreatedAt: new Date() } }); + const op2 = await prisma.syncOperation.create({ data: { tenantId: f.tenantId, userId: f.users.tech.id, clientOpId: randomUUID(), opType: "work_order.transition", entityType: "work_order", entityId: conflictWo.id, baseVersion: 98, payload: { to: "en_route" }, status: "conflict", clientCreatedAt: new Date() } }); + const tilesWithConflicts = await getDashboardTiles(bo, {}); + ok((await listSyncConflicts(bo)).length === 2 && tilesWithConflicts.syncConflicts === 2, "Konfliktliste + Kachel zeigen 2 offene Konflikte"); + await expectCode(() => listSyncConflicts(f.ctx.tech), "forbidden", "Monteur sieht keine Konfliktliste"); + await discardSyncConflict(bo, op2.id); + const op2Row = await prisma.syncOperation.findUnique({ where: { id: op2.id } }); + ok(op2Row?.resolvedAt != null && op2Row.resolvedById === f.users.backoffice.id, "Verwerfen setzt resolvedAt/By"); + await expectCode(() => discardSyncConflict(bo, op2.id), "not_found", "Bereits aufgelöster Konflikt nicht erneut bearbeitbar"); + await applySyncConflict(bo, op1.id); + ok((await prisma.workOrder.findUnique({ where: { id: conflictWo.id } }))?.status === "accepted", "Übernehmen wendet die Operation als Gerätenutzer an (assigned → accepted)"); + ok((await prisma.auditLog.count({ where: { entity: "sync_operation", entityId: { in: [op1.id, op2.id] } } })) === 2, "Beide Auflösungen auditiert"); + + console.log("\n— (9b) Nummernkreis-Einstellungen, Storno —"); + await updateNumberSequence(bo, { key: "work_order", prefix: "AU-", padding: 6 }); + const e = await createWorkOrder(bo, { title: "Präfix", customerId: f.customerId }); + ok(/^AU-\d{6}$/.test(e.number), `Neues Präfix/Stellen wirken (${e.number})`); + const seqs = await listNumberSequences(bo); + ok(seqs.find((s) => s.key === "report")?.prefix === "B-" && seqs.find((s) => s.key === "work_order")?.padding === 6, "Nummernkreise inkl. Defaults gelistet"); + const cancelled = await cancelWorkOrder(bo, { workOrderId: e.id, reason: "Kunde abgesagt" }); + ok(cancelled.status === "cancelled", "Storno mit Grund"); + await expectCode(() => updateWorkOrder(bo, e.id, { title: "nach Storno" }), "invalid", "Stornierter Auftrag nicht mehr änderbar"); +} + +main() + .catch((e) => { + console.error(e); + ok(false, "unerwarteter Fehler"); + }) + .finally(finish(() => cleanupTenants([SLUG]))); diff --git a/scripts/test-auftraege-numbering.ts b/scripts/test-auftraege-numbering.ts new file mode 100644 index 0000000..0e350c1 --- /dev/null +++ b/scripts/test-auftraege-numbering.ts @@ -0,0 +1,41 @@ +// L2 Aufträge: Nummernkreis unter Parallelität. +// +// (1) 20 parallele createWorkOrder auf einem frischen Mandanten (auch die erste Zeile der Sequenz +// entsteht im Wettlauf) → 20 eindeutige, lückenlose Nummern A-00001 … A-00020 +// (2) Zweiter Mandant beginnt unabhängig bei A-00001 +// +// Lauf: npx tsx scripts/test-auftraege-numbering.ts + +import "dotenv/config"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { cleanupTenants, createFixture, finish, ok } from "./lib-auftraege-fixtures"; + +const SLUG_A = "zz-l2-num-a"; +const SLUG_B = "zz-l2-num-b"; + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B]); + const A = await createFixture(SLUG_A); + const B = await createFixture(SLUG_B); + + const results = await Promise.allSettled( + Array.from({ length: 20 }, (_, i) => createWorkOrder(A.ctx.backoffice, { title: `Parallel ${i}`, customerId: A.customerId })), + ); + const fulfilled = results.filter((r): r is PromiseFulfilledResult>> => r.status === "fulfilled"); + for (const r of results) if (r.status === "rejected") console.error(r.reason); + ok(fulfilled.length === 20, `20 von 20 parallelen Anlagen erfolgreich (${fulfilled.length})`); + const numbers = fulfilled.map((r) => r.value.number).sort(); + ok(new Set(numbers).size === numbers.length, "Alle Nummern eindeutig"); + const expected = Array.from({ length: 20 }, (_, i) => `A-${String(i + 1).padStart(5, "0")}`); + ok(JSON.stringify(numbers) === JSON.stringify(expected), "Lückenlos A-00001 … A-00020"); + + const b = await createWorkOrder(B.ctx.backoffice, { title: "B", customerId: B.customerId }); + ok(b.number === "A-00001", "Mandant B hat einen eigenen Nummernkreis"); +} + +main() + .catch((e) => { + console.error(e); + ok(false, "unerwarteter Fehler"); + }) + .finally(finish(() => cleanupTenants([SLUG_A, SLUG_B]))); diff --git a/scripts/test-auftraege-scope.ts b/scripts/test-auftraege-scope.ts new file mode 100644 index 0000000..df244cb --- /dev/null +++ b/scripts/test-auftraege-scope.ts @@ -0,0 +1,108 @@ +// L2 Aufträge: Sichtbarkeit (Rollen/Scope) und Mandantentrennung. +// +// (1) Monteur eines fremden Teams: Liste leer, Detail/Übergang/Blocker → not_found +// (2) Team-Mitglied, Teamleiter und einzeln zugewiesener Monteur sehen den Auftrag +// (3) Monteur ohne Rechte: Anlage/Änderung/Zuweisung/Material → forbidden +// (4) Mandant B kann Aufträge von A weder lesen (Liste, Detail, Suche, Dashboard) noch ändern +// (Update, Übergang, Zuweisung, Material, Konflikt) — auch nicht über den Tenant-Guard direkt +// (5) Suche respektiert Scope (Monteur fremdes Team findet nichts) und Mandant +// +// Lauf: npx tsx scripts/test-auftraege-scope.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { parseListParams } from "../src/lib/work-orders/filters"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { updateWorkOrder } from "../src/server/services/work-orders/update"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { getCompletionBlockers } from "../src/server/services/work-orders/completion"; +import { addMaterialPlan } from "../src/server/services/work-orders/materials"; +import { listWorkOrders } from "../src/server/services/work-orders/list"; +import { getWorkOrderDetail } from "../src/server/services/work-orders/detail"; +import { getDashboardTiles } from "../src/server/services/work-orders/dashboard"; +import { searchAll } from "../src/server/services/work-orders/search"; +import { discardSyncConflict } from "../src/server/services/work-orders/conflicts"; +import { cleanupTenants, createFixture, expectCode, finish, ok } from "./lib-auftraege-fixtures"; + +const SLUG_A = "zz-l2-scope-a"; +const SLUG_B = "zz-l2-scope-b"; + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B]); + const A = await createFixture(SLUG_A); + const B = await createFixture(SLUG_B); + + const wo = await createWorkOrder(A.ctx.backoffice, { title: "Wärmepumpe Zauberwort", customerId: A.customerId, siteId: A.siteId }); + await assignWorkOrder(A.ctx.backoffice, { workOrderId: wo.id, teamId: A.teamId }); + await prisma.activityNote.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, text: "Zauberwort im Keller", authorId: A.users.tech.id } }); + const solo = await createWorkOrder(A.ctx.backoffice, { title: "Einzelzuweisung", customerId: A.customerId }); + await assignWorkOrder(A.ctx.backoffice, { workOrderId: solo.id, teamId: A.otherTeamId, userIds: [A.users.tech.id] }); + + console.log("\n— (1) Monteur fremdes Team —"); + const outsiderList = await listWorkOrders(A.ctx.outsider, parseListParams({})); + ok(outsiderList.items.every((i) => i.id !== wo.id), "Liste des fremden Monteurs enthält den Auftrag nicht"); + await expectCode(() => getWorkOrderDetail(A.ctx.outsider, wo.id), "not_found", "Detail → not_found"); + await expectCode(() => transitionWorkOrder(A.ctx.outsider, { workOrderId: wo.id, to: "accepted" }), "not_found", "Übergang → not_found (Existenz nicht preisgegeben)"); + await expectCode(() => getCompletionBlockers(A.ctx.outsider, wo.id), "not_found", "Blocker → not_found"); + + console.log("\n— (2) Berechtigte Feldrollen —"); + const techList = await listWorkOrders(A.ctx.tech, parseListParams({})); + ok(techList.items.some((i) => i.id === wo.id), "Team-Mitglied sieht den Team-Auftrag"); + ok(techList.items.some((i) => i.id === solo.id), "Einzeln zugewiesener Monteur sieht Auftrag eines anderen Teams"); + ok((await getWorkOrderDetail(A.ctx.lead, wo.id)).id === wo.id, "Teamleiter sieht den Auftrag"); + const accepted = await transitionWorkOrder(A.ctx.tech, { workOrderId: wo.id, to: "accepted" }); + ok(accepted.status === "accepted", "Team-Mitglied darf annehmen"); + const outsiderSolo = await listWorkOrders(A.ctx.outsider, parseListParams({})); + ok(outsiderSolo.items.some((i) => i.id === solo.id), "Mitglied des zugewiesenen Teams sieht den Einzelzuweisungs-Auftrag"); + + console.log("\n— (3) Monteur ohne Backoffice-Rechte —"); + await expectCode(() => createWorkOrder(A.ctx.tech, { title: "X", customerId: A.customerId }), "forbidden", "Anlage → forbidden"); + await expectCode(() => updateWorkOrder(A.ctx.tech, wo.id, { title: "X" }), "forbidden", "Änderung → forbidden"); + await expectCode(() => assignWorkOrder(A.ctx.tech, { workOrderId: wo.id, teamId: A.teamId }), "forbidden", "Zuweisung → forbidden"); + await expectCode(() => addMaterialPlan(A.ctx.tech, wo.id, { name: "X", plannedQuantity: 1, unit: "m" }), "forbidden", "Materialvorgabe → forbidden"); + await expectCode(() => transitionWorkOrder(A.ctx.tech, { workOrderId: wo.id, to: "cancelled", reason: "x" }), "forbidden", "Storno → forbidden"); + + console.log("\n— (4) Mandantentrennung —"); + const bList = await listWorkOrders(B.ctx.admin, parseListParams({})); + ok(bList.total === 0, "Mandant B: Liste leer"); + await expectCode(() => getWorkOrderDetail(B.ctx.admin, wo.id), "not_found", "Mandant B: Detail → not_found"); + await expectCode(() => updateWorkOrder(B.ctx.admin, wo.id, { title: "gekapert" }), "not_found", "Mandant B: Update → not_found"); + await expectCode(() => transitionWorkOrder(B.ctx.admin, { workOrderId: wo.id, to: "cancelled", reason: "x" }), "not_found", "Mandant B: Übergang → not_found"); + await expectCode(() => assignWorkOrder(B.ctx.admin, { workOrderId: wo.id, teamId: B.teamId }), "not_found", "Mandant B: Zuweisung → not_found"); + await expectCode(() => addMaterialPlan(B.ctx.admin, wo.id, { name: "X", plannedQuantity: 1, unit: "m" }), "not_found", "Mandant B: Materialvorgabe → not_found"); + await expectCode(() => createWorkOrder(B.ctx.admin, { title: "X", customerId: A.customerId }), "invalid", "Mandant B: Anlage mit Kunde von A → invalid"); + const tilesB = await getDashboardTiles(B.ctx.admin, {}); + ok(Object.values(tilesB).every((v) => v === 0), "Mandant B: alle Dashboard-Kacheln 0"); + const direct = await B.ctx.admin.db.workOrder.findUnique({ where: { id: wo.id } }); + ok(direct === null, "Tenant-Guard: findUnique über Mandant B → null"); + const updMany = await B.ctx.admin.db.workOrder.updateMany({ where: { id: wo.id }, data: { title: "gekapert" } }); + ok(updMany.count === 0, "Tenant-Guard: updateMany über Mandant B ändert 0 Zeilen"); + const opA = await prisma.syncOperation.create({ data: { tenantId: A.tenantId, userId: A.users.tech.id, clientOpId: crypto.randomUUID(), opType: "note.create", payload: {}, status: "conflict", clientCreatedAt: new Date() } }); + await expectCode(() => discardSyncConflict(B.ctx.admin, opA.id), "not_found", "Mandant B: Konflikt von A → not_found"); + const title = (await prisma.workOrder.findUnique({ where: { id: wo.id } }))?.title; + ok(title === "Wärmepumpe Zauberwort", "Auftrag von A unverändert"); + + console.log("\n— (5) Suche —"); + const sA = await searchAll(A.ctx.backoffice, { q: "zauberwort" }); + ok(sA.workOrders.some((w) => w.id === wo.id) && sA.notes.length === 1, "Backoffice A findet Auftrag + Notiz"); + const sCust = await searchAll(A.ctx.backoffice, { q: "Kaistraße" }); + ok(sCust.sites.length === 1 && sCust.workOrders.some((w) => w.id === wo.id), "Adresssuche findet Objekt und Auftrag"); + const sB = await searchAll(B.ctx.admin, { q: "zauberwort" }); + ok(sB.workOrders.length === 0 && sB.notes.length === 0, "Mandant B findet nichts von A"); + const sB2 = await searchAll(B.ctx.admin, { q: SLUG_A }); + ok(sB2.customers.length === 0 && sB2.sites.length === 0 && sB2.contacts.length === 0, "Mandant B findet keine Kunden/Objekte/Kontakte von A"); + const sOut = await searchAll(A.ctx.outsider, { q: "zauberwort" }); + ok(sOut.workOrders.length === 0 && sOut.notes.length === 0, "Monteur fremdes Team findet Auftrag/Notiz nicht"); + const sTech = await searchAll(A.ctx.tech, { q: "zauberwort", statuses: ["accepted"] }); + ok(sTech.workOrders.length === 1, "Team-Mitglied findet Auftrag (mit Statusfilter)"); + const sFiltered = await searchAll(A.ctx.backoffice, { q: "zauberwort", statuses: ["billed"] }); + ok(sFiltered.workOrders.length === 0, "Statusfilter der Suche greift"); +} + +main() + .catch((e) => { + console.error(e); + ok(false, "unerwarteter Fehler"); + }) + .finally(finish(() => cleanupTenants([SLUG_A, SLUG_B]))); diff --git a/scripts/test-auftraege-status.ts b/scripts/test-auftraege-status.ts new file mode 100644 index 0000000..0ec9c83 --- /dev/null +++ b/scripts/test-auftraege-status.ts @@ -0,0 +1,138 @@ +// L2 Aufträge: vollständige Übergangsmatrix des Auftrags-Statusmodells (ARCHITEKTUR §3). +// +// (1) Reine Tabelle: jedes (von, nach)-Paar — erlaubte Paare exakt wie im Vertrag, alle anderen verboten. +// (2) Service je Rolle (tenant-admin, backoffice, team-lead, technician) für JEDEN erlaubten Übergang: +// Erwartung unabhängig aus der Rechte-Tabelle abgeleitet → Erfolg bzw. forbidden; +// zusätzlich Version +1, WorkOrderStatusChange und Audit-Eintrag. +// (3) Verbotene Paare über den Service → invalid (Stichprobe über alle Statuswerte). +// +// Lauf: npx tsx scripts/test-auftraege-status.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { WORK_ORDER_STATUSES, canTransition, type WorkOrderStatus } from "../src/lib/work-orders/status"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { approvedCompletionReport, cleanupTenants, createFixture, expectCode, finish, ok, rawWorkOrder } from "./lib-auftraege-fixtures"; + +const SLUG = "zz-l2-status"; + +const CONTRACT: Record = { + draft: ["review_required", "planned", "assigned", "cancelled"], + review_required: ["planned", "assigned", "cancelled"], + planned: ["assigned", "cancelled"], + assigned: ["accepted", "en_route", "in_progress", "planned", "cancelled"], + accepted: ["en_route", "in_progress", "assigned", "cancelled"], + en_route: ["in_progress", "cancelled"], + in_progress: ["paused", "waiting_material", "daily_report_created", "technically_completed", "cancelled"], + paused: ["in_progress", "en_route", "cancelled"], + waiting_material: ["in_progress", "en_route", "cancelled"], + daily_report_created: ["en_route", "in_progress", "cancelled"], + technically_completed: ["signature_pending", "in_review", "in_progress", "cancelled"], + signature_pending: ["in_review", "cancelled"], + in_review: ["released_for_billing", "in_progress", "cancelled"], + released_for_billing: ["billed", "in_review"], + billed: [], + cancelled: [], +}; + +type Role = "admin" | "backoffice" | "lead" | "tech"; + +/** Expected roles per transition — written from the ARCHITEKTUR §3 rights table, NOT from status.ts. */ +function expectedRoles(from: WorkOrderStatus, to: WorkOrderStatus): Role[] { + if (to === "cancelled") return ["admin", "backoffice"]; + if (to === "released_for_billing" || to === "billed" || (from === "released_for_billing" && to === "in_review")) return ["admin", "backoffice"]; + if (from === "in_review" && to === "in_progress") return ["admin", "backoffice", "lead"]; + if (["draft", "review_required", "planned"].includes(from)) return ["admin", "backoffice"]; + if (from === "assigned" && to === "planned") return ["admin", "backoffice"]; + return ["admin", "lead", "tech"]; +} + +const needsReason = (from: WorkOrderStatus, to: WorkOrderStatus) => + to === "cancelled" || (from === "in_review" && to === "in_progress") || (from === "released_for_billing" && to === "in_review"); + +async function main() { + await cleanupTenants([SLUG]); + const f = await createFixture(SLUG); + + console.log("\n— (1) Übergangstabelle —"); + let tableOk = true; + for (const from of WORK_ORDER_STATUSES) { + for (const to of WORK_ORDER_STATUSES) { + if (canTransition(from, to) !== CONTRACT[from].includes(to)) { + tableOk = false; + console.log(` Abweichung ${from} → ${to}`); + } + } + } + ok(tableOk, `Alle ${WORK_ORDER_STATUSES.length ** 2} Paare entsprechen dem Vertrag`); + + console.log("\n— (2) Erlaubte Übergänge je Rolle —"); + let checked = 0; + for (const from of WORK_ORDER_STATUSES) { + for (const to of CONTRACT[from]) { + for (const role of ["admin", "backoffice", "lead", "tech"] as Role[]) { + const wo = await rawWorkOrder(f, from); + if (to === "in_review" && (from === "technically_completed" || from === "signature_pending")) { + await approvedCompletionReport(f, wo.id, { status: "submitted", signature: true }); + } + if (to === "released_for_billing") await approvedCompletionReport(f, wo.id); + const allowed = expectedRoles(from, to).includes(role); + const input = { workOrderId: wo.id, to, reason: needsReason(from, to) ? "Testgrund" : undefined, baseVersion: wo.version }; + checked++; + if (allowed) { + try { + const res = await transitionWorkOrder(f.ctx[role], input); + const after = await prisma.workOrder.findUnique({ where: { id: wo.id } }); + const change = await prisma.workOrderStatusChange.count({ where: { workOrderId: wo.id, fromStatus: from, toStatus: to } }); + const audit = await prisma.auditLog.count({ where: { tenantId: f.tenantId, entity: "work_order", entityId: wo.id } }); + const good = res.status === to && after?.status === to && after.version === wo.version + 1 && change === 1 && audit >= 1; + if (!good) ok(false, `${role}: ${from} → ${to} (Status/Version/Historie/Audit)`); + } catch (e) { + ok(false, `${role}: ${from} → ${to} sollte erlaubt sein — ${(e as { code?: string }).code ?? (e as Error).message}`); + } + } else { + try { + await transitionWorkOrder(f.ctx[role], input); + ok(false, `${role}: ${from} → ${to} sollte verboten sein`); + } catch (e) { + if ((e as { code?: string }).code !== "forbidden") ok(false, `${role}: ${from} → ${to} erwartet forbidden, erhalten ${(e as { code?: string }).code ?? (e as Error).message}`); + const still = await prisma.workOrder.findUnique({ where: { id: wo.id } }); + if (still?.status !== from || still.version !== wo.version) ok(false, `${role}: ${from} → ${to} hat trotz Verbot geschrieben`); + } + } + } + } + } + ok(true, `${checked} Rolle×Übergang-Kombinationen geprüft`); + + console.log("\n— (3) Verbotene Paare über den Service —"); + let invalidOk = true; + for (const from of WORK_ORDER_STATUSES) { + const forbiddenTargets = WORK_ORDER_STATUSES.filter((t) => t !== from && !CONTRACT[from].includes(t)); + const to = forbiddenTargets[(from.length * 7) % forbiddenTargets.length]; + const wo = await rawWorkOrder(f, from); + try { + await transitionWorkOrder(f.ctx.admin, { workOrderId: wo.id, to, reason: "x" }); + invalidOk = false; + console.log(` ${from} → ${to} wurde nicht abgewiesen`); + } catch (e) { + if ((e as { code?: string }).code !== "invalid") { + invalidOk = false; + console.log(` ${from} → ${to}: ${(e as { code?: string }).code}`); + } + } + } + ok(invalidOk, "Nicht in der Tabelle enthaltene Übergänge → invalid (je Ausgangsstatus)"); + + console.log("\n— (4) Begründungspflicht, Versionskonflikt —"); + const c1 = await rawWorkOrder(f, "planned"); + await expectCode(() => transitionWorkOrder(f.ctx.backoffice, { workOrderId: c1.id, to: "cancelled" }), "invalid", "Stornierung ohne Grund → invalid"); + await expectCode(() => transitionWorkOrder(f.ctx.backoffice, { workOrderId: c1.id, to: "assigned", baseVersion: c1.version + 5 }), "conflict", "Veraltete baseVersion → conflict"); +} + +main() + .catch((e) => { + console.error(e); + ok(false, "unerwarteter Fehler"); + }) + .finally(finish(() => cleanupTenants([SLUG]))); diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 0b3318e..68bcef3 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -1,40 +1,170 @@ -import { LayoutDashboard } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; -import { requireSession } from "@/server/auth"; +import { + AlarmClock, + CalendarDays, + CheckCheck, + ClipboardCheck, + ClipboardList, + Hourglass, + PenLine, + Receipt, + RefreshCw, + Siren, + Wrench, + type LucideIcon, +} from "lucide-react"; import { PageHead } from "@/components/mockup-ui"; +import { buttonCls } from "@/components/work-orders/action-form"; +import { pageContext } from "@/components/work-orders/page-context"; +import { Field, inputCls } from "@/components/work-orders/ui"; +import { isoDay, parseListParams, toQuery, type Preset } from "@/lib/work-orders/filters"; +import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas"; +import { getDashboardTiles } from "@/server/services/work-orders/dashboard"; +import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options"; +import { listOrderTypes } from "@/server/services/work-orders/settings"; -/** - * Neutrales Dashboard (Platzhalter). Die Kacheln (offene Aufträge, heutige Einsätze, - * freizugebende Berichte, Notdienst) liefern die Fachmodule. - */ -export default async function DashboardPage({ - searchParams, -}: { - searchParams: Promise<{ module?: string }>; -}) { - const session = await requireSession(); +type SP = Record; + +const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[] = [ + { key: "open", icon: ClipboardList, tone: "var(--ui-primary)" }, + { key: "today", icon: CalendarDays, tone: "var(--info)" }, + { key: "running", icon: Wrench, tone: "var(--ui-accent)" }, + { key: "not_accepted", icon: Hourglass, tone: "var(--warn)" }, + { key: "overdue", icon: AlarmClock, tone: "var(--risk)" }, + { key: "reports_in_review", icon: ClipboardCheck, tone: "var(--info)" }, + { key: "completed", icon: CheckCheck, tone: "var(--ok)" }, + { key: "billing", icon: Receipt, tone: "var(--ok)" }, + { key: "emergency_new", icon: Siren, tone: "var(--risk)" }, + { key: "missing_signatures", icon: PenLine, tone: "var(--warn)" }, + { key: "sync_conflicts", icon: RefreshCw, tone: "var(--risk)" }, +]; + +/** Backoffice dashboard (spec §21). Field roles are sent to the mobile start page. */ +export default async function DashboardPage({ searchParams }: { searchParams: Promise }) { + const { session, ctx, can } = await pageContext(); + if (!can("work_order:read_all")) redirect("/m"); const sp = await searchParams; const t = await getTranslations("dashboard"); + const tw = await getTranslations("workOrders"); + + const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } }); + const moduleEnabled = !moduleRow || moduleRow.enabled; + + const f = parseListParams(sp); + const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority }; + const [tiles, customers, teams, users, orderTypes] = moduleEnabled + ? await Promise.all([getDashboardTiles(ctx, filter), customerFilterOptions(ctx), teamOptions(ctx), userOptions(ctx), listOrderTypes(ctx)]) + : [null, [], [], [], []]; return ( -
- +
+ {sp.module === "disabled" && (

{t("moduleDisabled")}

)} -
- -
-

{t("placeholderTitle")}

-

{t("placeholder")}

-
-
+ + {!tiles ? ( +

{t("workOrdersDisabled")}

+ ) : ( + <> +
+ {t("filterTitle")} +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + {tw("filter.reset")} + +
+
+
+ +
    + {TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => { + const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; + const href = key === "sync_conflicts" ? "/work-orders/conflicts" : `/work-orders${toQuery({ ...filter, preset: key })}`; + return ( +
  • + 0 ? tone : "var(--line)" }} + > + +
    +

    {t(`tiles.${key}`)}

    +

    {count}

    +

    {t(`hints.${key}`)}

    +
    + +
  • + ); + })} +
+ + )}
); } diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index f7ad927..a30979f 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -88,8 +88,7 @@ export default async function AppLayout({
- {/* TODO(craftvia): globale Suche (Aufträge/Kunden/Objekte) — Andockpunkt für die Fachmodule. */} -
+
diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx new file mode 100644 index 0000000..46cc945 --- /dev/null +++ b/src/app/(app)/search/page.tsx @@ -0,0 +1,166 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { Search } from "lucide-react"; +import { PageHead } from "@/components/mockup-ui"; +import { buttonCls } from "@/components/work-orders/action-form"; +import { pageContext } from "@/components/work-orders/page-context"; +import { Empty, Field, inputCls, Section, StatusBadge } from "@/components/work-orders/ui"; +import { isoDay, parseListParams } from "@/lib/work-orders/filters"; +import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status"; +import { formatDate, formatDateTime } from "@/lib/work-orders/time"; +import { customerDisplayName, teamOptions } from "@/server/services/work-orders/options"; +import { searchAll, type SearchResults } from "@/server/services/work-orders/search"; + +type SP = Record; +const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v); + +/** Tenant search (spec §25) — ILIKE across all areas, restricted by the caller's scopes. */ +export default async function SearchPage({ searchParams }: { searchParams: Promise }) { + const { ctx, locale, tz } = await pageContext(); + const t = await getTranslations("search"); + const tw = await getTranslations("workOrders"); + const td = await getTranslations("workOrders.documents"); + const sp = await searchParams; + const q = (one(sp.q) ?? "").trim().slice(0, 100); + const f = parseListParams(sp); + + const [results, teams] = await Promise.all([ + q.length >= 2 ? searchAll(ctx, { q, from: f.from, to: f.to, statuses: f.statuses, teamId: f.teamId }) : Promise.resolve(null), + teamOptions(ctx), + ]); + const total = results ? Object.values(results).reduce((n, list) => n + list.length, 0) : 0; + const hasFilter = Boolean(f.from || f.to || f.statuses || f.teamId); + + return ( +
+ + +
+
+ + +
+
+ {t("filters.note")} +
+ + + + + + + + + + + + +
+ {hasFilter && ( + + {t("filters.reset")} + + )} +
+
+ +
+ {!results ? ( +

{t("hint")}

+ ) : total === 0 ? ( + {t("noResults", { q })} + ) : ( + <> + + {(w: SearchResults["workOrders"][number]) => ( + + + {w.number} {w.title} + + {[customerDisplayName(w.customer), w.site?.name, w.site?.city, w.team?.name, formatDate(w.plannedStart, locale, tz)].filter(Boolean).join(" · ")} + + + + + )} + + + {(c: SearchResults["customers"][number]) => ( + + {customerDisplayName(c)} + {[c.customerNumber, [c.street, c.houseNumber].filter(Boolean).join(" "), [c.postalCode, c.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ")} + + )} + + + {(s: SearchResults["sites"][number]) => ( + + {s.name} + {[customerDisplayName(s.customer), [s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ")} + + )} + + + {(c: SearchResults["contacts"][number]) => ( + + {c.name} + {[c.role, customerDisplayName(c.customer), c.phone, c.email].filter(Boolean).join(" · ")} + + )} + + + {(d: SearchResults["documents"][number]) => ( + + {d.title || d.fileName} + {[td(`categories.${d.category}`), formatDate(d.createdAt, locale, tz)].join(" · ")} + + )} + + + {(n: SearchResults["notes"][number]) => ( + + {n.text} + {[n.workOrder.number, n.workOrder.title, formatDateTime(n.createdAt, locale, tz)].join(" · ")} + + )} + +

{t("maxHint")}

+ + )} +
+
+ ); +} + +function ResultSection({ title, list, children }: { title: string; list: T[]; children: (item: T) => React.ReactNode }) { + if (list.length === 0) return null; + return ( +
+
    + {list.map((item) => ( +
  • {children(item)}
  • + ))} +
+
+ ); +} diff --git a/src/app/(app)/settings/checklists/page.tsx b/src/app/(app)/settings/checklists/page.tsx new file mode 100644 index 0000000..9c50ca3 --- /dev/null +++ b/src/app/(app)/settings/checklists/page.tsx @@ -0,0 +1,122 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { z } from "zod"; +import { Plus } from "lucide-react"; +import { PageHead, Pill } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { ActionForm, buttonCls } from "@/components/work-orders/action-form"; +import { SettingsTemplatesNav, templatesPageContext } from "@/components/work-orders/settings-nav"; +import { Check, Empty, Field, inputCls, Section } from "@/components/work-orders/ui"; +import { DEFAULT_CHECKLIST_ITEMS, DEFAULT_REQUIRED_PHOTOS } from "@/lib/work-orders/defaults"; +import { templateItemSchema, templatePhotoSchema, type TemplateItem, type TemplatePhoto } from "@/lib/work-orders/schemas"; +import { saveChecklistTemplateAction } from "@/server/actions/work_orders/settings"; +import { listChecklistTemplates, listOrderTypes } from "@/server/services/work-orders/settings"; + +type SP = Record; +const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v); + +const itemsToText = (items: readonly TemplateItem[], suffix: string) => + items.map((i) => `${i.required ? "* " : ""}${i.label}${i.requiresPhoto ? ` ${suffix}` : ""}`).join("\n"); +const photosToText = (photos: readonly TemplatePhoto[]) => photos.map((p) => p.label).join("\n"); + +/** Checklist templates per order type incl. required photos (spec §12.4 / §14.2). */ +export default async function ChecklistTemplatesPage({ searchParams }: { searchParams: Promise }) { + const { ctx } = await templatesPageContext(); + const t = await getTranslations("settingsTemplates"); + const tc = await getTranslations("common"); + const sp = await searchParams; + const [templates, orderTypes] = await Promise.all([listChecklistTemplates(ctx), listOrderTypes(ctx)]); + + const parsed = templates.map((tpl) => ({ + ...tpl, + itemList: z.array(templateItemSchema).safeParse(tpl.items).data ?? [], + photoList: z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? [], + })); + const editId = one(sp.edit); + const editing = editId ? parsed.find((x) => x.id === editId) : undefined; + const isNew = one(sp.new) === "1"; + const suggest = one(sp.suggest) === "1"; + const base = "/settings/checklists"; + + return ( +
+ + + {t("checklists.new")} + + } + /> + + + {parsed.length === 0 ? ( + {t("checklists.empty")} + ) : ( +
    + {parsed.map((tpl) => ( +
  • +
    {t("checklists.inactive")} : undefined}> +

    + {tpl.orderType?.name ?? t("checklists.noOrderType")} · {t("checklists.itemCount", { count: tpl.itemList.length })} · {t("checklists.photoCount", { count: tpl.photoList.length })} +

    + + {t("checklists.edit")} + +
    +
  • + ))} +
+ )} + + {(isNew || editing) && ( + + + {editing && } + + + + + + + +