Merge lane/auftraege in feature/craftvia-mvp
Konflikte gelöst: Header mit Suche (L2) und Glocke (L6), Audit-Labels vereinigt (ohne doppeltes sync_operation), Navigation mit Benachrichtigungen und Auftragsvorlagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string | number | boolean | null> }): 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<CompletionBlocker[]> // mit Scope-Prüfung (not_found)
|
||||||
|
computeCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise<CompletionBlocker[]> // 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/<storageKey>` | Umstellung auf `/files/<documentId>` (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=<status>`) · `/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`).
|
||||||
@@ -2,7 +2,34 @@
|
|||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"crumb": "Übersicht",
|
"crumb": "Übersicht",
|
||||||
"subtitle": "Willkommen, {name} — {tenant}",
|
"subtitle": "Willkommen, {name} — {tenant}",
|
||||||
"placeholderTitle": "Dashboard in Umsetzung",
|
"moduleDisabled": "Das angeforderte Modul ist für Ihren Betrieb nicht freigeschaltet.",
|
||||||
"placeholder": "Hier erscheinen künftig offene Aufträge, heutige Einsätze, freizugebende Berichte und Notdienst-Meldungen.",
|
"workOrdersDisabled": "Das Modul Aufträge ist nicht freigeschaltet – es gibt keine Kennzahlen.",
|
||||||
"moduleDisabled": "Das angeforderte Modul ist für Ihren Betrieb nicht freigeschaltet."
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"teams": "Teams",
|
"teams": "Teams",
|
||||||
"reports": "Berichte",
|
"reports": "Berichte",
|
||||||
"documents": "Dokumente",
|
"documents": "Dokumente",
|
||||||
|
"templates": "Auftragsvorlagen",
|
||||||
"settings": "Einstellungen",
|
"settings": "Einstellungen",
|
||||||
"notifications": "Benachrichtigungen",
|
"notifications": "Benachrichtigungen",
|
||||||
"audit": "Audit-Protokoll",
|
"audit": "Audit-Protokoll",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -2,7 +2,34 @@
|
|||||||
"title": "Dashboard",
|
"title": "Dashboard",
|
||||||
"crumb": "Overview",
|
"crumb": "Overview",
|
||||||
"subtitle": "Welcome, {name} — {tenant}",
|
"subtitle": "Welcome, {name} — {tenant}",
|
||||||
"placeholderTitle": "Dashboard under construction",
|
"moduleDisabled": "The requested module is not enabled for your company.",
|
||||||
"placeholder": "Open work orders, today's jobs, reports awaiting approval and emergency calls will appear here.",
|
"workOrdersDisabled": "The work orders module is not enabled – there are no figures.",
|
||||||
"moduleDisabled": "The requested module is not enabled for your company."
|
"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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"teams": "Teams",
|
"teams": "Teams",
|
||||||
"reports": "Reports",
|
"reports": "Reports",
|
||||||
"documents": "Documents",
|
"documents": "Documents",
|
||||||
|
"templates": "Order templates",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"audit": "Audit log",
|
"audit": "Audit log",
|
||||||
|
|||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."
|
||||||
|
}
|
||||||
@@ -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<unknown>, code: string, msg: string): Promise<unknown> {
|
||||||
|
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<string>(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<Fixture> {
|
||||||
|
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<Record<RoleKey, string>> = {};
|
||||||
|
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<string, unknown> = {}) {
|
||||||
|
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<void>) {
|
||||||
|
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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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])));
|
||||||
@@ -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<Awaited<ReturnType<typeof createWorkOrder>>> => 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])));
|
||||||
@@ -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])));
|
||||||
@@ -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<WorkOrderStatus, WorkOrderStatus[]> = {
|
||||||
|
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])));
|
||||||
@@ -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 { 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 { 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";
|
||||||
|
|
||||||
/**
|
type SP = Record<string, string | string[] | undefined>;
|
||||||
* Neutrales Dashboard (Platzhalter). Die Kacheln (offene Aufträge, heutige Einsätze,
|
|
||||||
* freizugebende Berichte, Notdienst) liefern die Fachmodule.
|
const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[] = [
|
||||||
*/
|
{ key: "open", icon: ClipboardList, tone: "var(--ui-primary)" },
|
||||||
export default async function DashboardPage({
|
{ key: "today", icon: CalendarDays, tone: "var(--info)" },
|
||||||
searchParams,
|
{ key: "running", icon: Wrench, tone: "var(--ui-accent)" },
|
||||||
}: {
|
{ key: "not_accepted", icon: Hourglass, tone: "var(--warn)" },
|
||||||
searchParams: Promise<{ module?: string }>;
|
{ key: "overdue", icon: AlarmClock, tone: "var(--risk)" },
|
||||||
}) {
|
{ key: "reports_in_review", icon: ClipboardCheck, tone: "var(--info)" },
|
||||||
const session = await requireSession();
|
{ 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<SP> }) {
|
||||||
|
const { session, ctx, can } = await pageContext();
|
||||||
|
if (!can("work_order:read_all")) redirect("/m");
|
||||||
const sp = await searchParams;
|
const sp = await searchParams;
|
||||||
const t = await getTranslations("dashboard");
|
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 (
|
return (
|
||||||
<main className="flex-1 p-6">
|
<main className="flex-1 p-4 md:p-6">
|
||||||
<PageHead
|
<PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })} />
|
||||||
crumb={t("crumb")}
|
|
||||||
title={t("title")}
|
|
||||||
sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })}
|
|
||||||
/>
|
|
||||||
{sp.module === "disabled" && (
|
{sp.module === "disabled" && (
|
||||||
<p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]">
|
<p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]">
|
||||||
{t("moduleDisabled")}
|
{t("moduleDisabled")}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
<div className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5">
|
|
||||||
<LayoutDashboard className="mt-0.5 size-5 shrink-0 text-[var(--primary)]" aria-hidden />
|
{!tiles ? (
|
||||||
<div>
|
<p className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">{t("workOrdersDisabled")}</p>
|
||||||
<p className="font-heading text-sm font-semibold">{t("placeholderTitle")}</p>
|
) : (
|
||||||
<p className="mt-1 text-[13px] text-muted-foreground">{t("placeholder")}</p>
|
<>
|
||||||
</div>
|
<details className="shadow-card mb-4 rounded-xl border bg-card" open={Object.values(filter).some(Boolean)}>
|
||||||
</div>
|
<summary className="flex min-h-11 cursor-pointer items-center px-4 font-heading text-sm font-semibold">{t("filterTitle")}</summary>
|
||||||
|
<form method="get" action="/dashboard" className="grid gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Field label={tw("filter.from")} htmlFor="d-from">
|
||||||
|
<input id="d-from" type="date" name="from" defaultValue={f.from ? isoDay(f.from) : ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={tw("filter.to")} htmlFor="d-to">
|
||||||
|
<input id="d-to" type="date" name="to" defaultValue={f.to ? isoDay(f.to) : ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={tw("filter.customer")} htmlFor="d-customer">
|
||||||
|
<select id="d-customer" name="customerId" defaultValue={f.customerId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{tw("filter.any")}</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{customerDisplayName(c)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={tw("filter.team")} htmlFor="d-team">
|
||||||
|
<select id="d-team" name="teamId" defaultValue={f.teamId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{tw("filter.any")}</option>
|
||||||
|
{teams.map((x) => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={tw("filter.user")} htmlFor="d-user">
|
||||||
|
<select id="d-user" name="userId" defaultValue={f.userId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{tw("filter.any")}</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={tw("filter.orderType")} htmlFor="d-type">
|
||||||
|
<select id="d-type" name="orderTypeId" defaultValue={f.orderTypeId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{tw("filter.any")}</option>
|
||||||
|
{orderTypes.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={tw("filter.priority")} htmlFor="d-prio">
|
||||||
|
<select id="d-prio" name="priority" defaultValue={f.priority ?? ""} className={inputCls}>
|
||||||
|
<option value="">{tw("filter.any")}</option>
|
||||||
|
{WORK_ORDER_PRIORITIES.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{tw(`priority.${p}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<button type="submit" className={buttonCls("default")}>
|
||||||
|
{tw("filter.apply")}
|
||||||
|
</button>
|
||||||
|
<Link href="/dashboard" className={buttonCls("ghost")}>
|
||||||
|
{tw("filter.reset")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
|
{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 (
|
||||||
|
<li key={key}>
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
className="shadow-card flex h-full min-h-24 items-start gap-3 rounded-xl border border-l-4 bg-card p-4 transition-colors hover:bg-muted/40 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||||
|
style={{ borderLeftColor: count > 0 ? tone : "var(--line)" }}
|
||||||
|
>
|
||||||
|
<Icon className="mt-0.5 size-5 shrink-0" style={{ color: tone }} aria-hidden />
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-[13px] font-semibold text-muted-foreground">{t(`tiles.${key}`)}</p>
|
||||||
|
<p className="font-heading text-3xl leading-tight font-bold text-foreground">{count}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{t(`hints.${key}`)}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,8 +88,7 @@ export default async function AppLayout({
|
|||||||
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col">
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
<header className="sticky top-0 z-10 flex items-center gap-4 border-b bg-[var(--panel)] px-6 py-2.5 backdrop-blur-md">
|
<header className="sticky top-0 z-10 flex items-center gap-4 border-b bg-[var(--panel)] px-6 py-2.5 backdrop-blur-md">
|
||||||
{/* TODO(craftvia): globale Suche (Aufträge/Kunden/Objekte) — Andockpunkt für die Fachmodule. */}
|
<form action="/search" role="search" className="flex-1"><input type="search" name="q" aria-label={tc("search")} placeholder={tc("search")} className="h-10 w-full max-w-md rounded-lg border border-input bg-background px-3 text-sm" /></form>
|
||||||
<div className="flex-1" />
|
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
<UiLocaleSwitcher current={identity.uiLocale} />
|
<UiLocaleSwitcher current={identity.uiLocale} />
|
||||||
<Link href="/account" className="flex items-center gap-3">
|
<Link href="/account" className="flex items-center gap-3">
|
||||||
|
|||||||
@@ -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<string, string | string[] | undefined>;
|
||||||
|
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<SP> }) {
|
||||||
|
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 (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<PageHead crumb={t("crumb")} title={q ? t("resultsFor", { q }) : t("title")} />
|
||||||
|
|
||||||
|
<form method="get" action="/search" role="search" className="shadow-card rounded-xl border bg-card p-4">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<input type="search" name="q" defaultValue={q} minLength={2} maxLength={100} aria-label={t("placeholder")} placeholder={t("placeholder")} className={`${inputCls} min-w-0 flex-1`} autoFocus={!q} />
|
||||||
|
<button type="submit" className={buttonCls("primary")}>
|
||||||
|
<Search className="size-4" aria-hidden />
|
||||||
|
{t("submit")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<details className="mt-3" open={hasFilter}>
|
||||||
|
<summary className="flex min-h-11 cursor-pointer items-center text-sm font-semibold">{t("filters.note")}</summary>
|
||||||
|
<div className="grid gap-3 pt-2 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<Field label={t("filters.from")} htmlFor="s-from">
|
||||||
|
<input id="s-from" type="date" name="from" defaultValue={f.from ? isoDay(f.from) : ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("filters.to")} htmlFor="s-to">
|
||||||
|
<input id="s-to" type="date" name="to" defaultValue={f.to ? isoDay(f.to) : ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("filters.status")} htmlFor="s-status">
|
||||||
|
<select id="s-status" name="status" defaultValue={f.statuses?.[0] ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("filters.any")}</option>
|
||||||
|
{WORK_ORDER_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{tw(`status.${s}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("filters.team")} htmlFor="s-team">
|
||||||
|
<select id="s-team" name="teamId" defaultValue={f.teamId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("filters.any")}</option>
|
||||||
|
{teams.map((x) => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{hasFilter && (
|
||||||
|
<Link href={`/search?q=${encodeURIComponent(q)}`} className="mt-2 inline-flex min-h-11 items-center text-sm font-semibold text-[var(--primary)] hover:underline">
|
||||||
|
{t("filters.reset")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</details>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="mt-4 space-y-4">
|
||||||
|
{!results ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t("hint")}</p>
|
||||||
|
) : total === 0 ? (
|
||||||
|
<Empty>{t("noResults", { q })}</Empty>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ResultSection title={t("sections.workOrders")} list={results.workOrders}>
|
||||||
|
{(w: SearchResults["workOrders"][number]) => (
|
||||||
|
<Link href={`/work-orders/${w.id}`} className="flex flex-wrap items-center justify-between gap-2 py-2 hover:underline">
|
||||||
|
<span>
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{w.number}</span> <span className="font-semibold">{w.title}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">
|
||||||
|
{[customerDisplayName(w.customer), w.site?.name, w.site?.city, w.team?.name, formatDate(w.plannedStart, locale, tz)].filter(Boolean).join(" · ")}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<StatusBadge status={w.status} label={tw(`status.${w.status}`)} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</ResultSection>
|
||||||
|
<ResultSection title={t("sections.customers")} list={results.customers}>
|
||||||
|
{(c: SearchResults["customers"][number]) => (
|
||||||
|
<Link href={`/customers/${c.id}`} className="block py-2 hover:underline">
|
||||||
|
<span className="font-semibold">{customerDisplayName(c)}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{[c.customerNumber, [c.street, c.houseNumber].filter(Boolean).join(" "), [c.postalCode, c.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ")}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</ResultSection>
|
||||||
|
<ResultSection title={t("sections.sites")} list={results.sites}>
|
||||||
|
{(s: SearchResults["sites"][number]) => (
|
||||||
|
<Link href={`/sites/${s.id}`} className="block py-2 hover:underline">
|
||||||
|
<span className="font-semibold">{s.name}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{[customerDisplayName(s.customer), [s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ")}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</ResultSection>
|
||||||
|
<ResultSection title={t("sections.contacts")} list={results.contacts}>
|
||||||
|
{(c: SearchResults["contacts"][number]) => (
|
||||||
|
<Link href={`/customers/${c.customer.id}`} className="block py-2 hover:underline">
|
||||||
|
<span className="font-semibold">{c.name}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{[c.role, customerDisplayName(c.customer), c.phone, c.email].filter(Boolean).join(" · ")}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</ResultSection>
|
||||||
|
<ResultSection title={t("sections.documents")} list={results.documents}>
|
||||||
|
{(d: SearchResults["documents"][number]) => (
|
||||||
|
<Link
|
||||||
|
href={d.workOrderId ? `/work-orders/${d.workOrderId}?tab=documents` : d.customerId ? `/customers/${d.customerId}` : `/sites/${d.siteId}`}
|
||||||
|
className="block py-2 hover:underline"
|
||||||
|
>
|
||||||
|
<span className="font-semibold">{d.title || d.fileName}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{[td(`categories.${d.category}`), formatDate(d.createdAt, locale, tz)].join(" · ")}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</ResultSection>
|
||||||
|
<ResultSection title={t("sections.notes")} list={results.notes}>
|
||||||
|
{(n: SearchResults["notes"][number]) => (
|
||||||
|
<Link href={`/work-orders/${n.workOrder.id}?tab=notes`} className="block py-2 hover:underline">
|
||||||
|
<span className="line-clamp-2 text-sm">{n.text}</span>
|
||||||
|
<span className="block text-xs text-muted-foreground">{[n.workOrder.number, n.workOrder.title, formatDateTime(n.createdAt, locale, tz)].join(" · ")}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</ResultSection>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("maxHint")}</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResultSection<T extends { id: string }>({ title, list, children }: { title: string; list: T[]; children: (item: T) => React.ReactNode }) {
|
||||||
|
if (list.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<Section title={`${title} (${list.length})`}>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{list.map((item) => (
|
||||||
|
<li key={item.id}>{children(item)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string | string[] | undefined>;
|
||||||
|
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<SP> }) {
|
||||||
|
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 (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<PageHead
|
||||||
|
crumb={t("crumb")}
|
||||||
|
title={t("checklists.title")}
|
||||||
|
sub={t("checklists.sub")}
|
||||||
|
actions={
|
||||||
|
<Link href={`${base}?new=1&suggest=1`} scroll={false} className={buttonCls("primary")}>
|
||||||
|
<Plus className="size-4" aria-hidden />
|
||||||
|
{t("checklists.new")}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SettingsTemplatesNav active="checklists" />
|
||||||
|
|
||||||
|
{parsed.length === 0 ? (
|
||||||
|
<Empty>{t("checklists.empty")}</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
|
{parsed.map((tpl) => (
|
||||||
|
<li key={tpl.id}>
|
||||||
|
<Section title={tpl.name} actions={!tpl.active ? <Pill tone="mut">{t("checklists.inactive")}</Pill> : undefined}>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{tpl.orderType?.name ?? t("checklists.noOrderType")} · {t("checklists.itemCount", { count: tpl.itemList.length })} · {t("checklists.photoCount", { count: tpl.photoList.length })}
|
||||||
|
</p>
|
||||||
|
<Link href={`${base}?edit=${tpl.id}`} scroll={false} className={`${buttonCls("outline")} mt-3`}>
|
||||||
|
{t("checklists.edit")}
|
||||||
|
</Link>
|
||||||
|
</Section>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(isNew || editing) && (
|
||||||
|
<Modal title={editing ? editing.name : t("checklists.new")} sub={t("checklists.itemsHint")} closeHref={base} closeLabel={tc("close")}>
|
||||||
|
<ActionForm action={saveChecklistTemplateAction} namespace="settingsTemplates" submitLabel={t("checklists.save")} variant="primary" successText={t("saved")} className="grid gap-4 p-5 md:grid-cols-2">
|
||||||
|
{editing && <input type="hidden" name="id" value={editing.id} />}
|
||||||
|
<Field label={`${t("checklists.name")} *`} htmlFor="tpl-name">
|
||||||
|
<input id="tpl-name" name="name" required maxLength={120} defaultValue={editing?.name ?? ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("checklists.orderType")} htmlFor="tpl-type">
|
||||||
|
<select id="tpl-type" name="orderTypeId" defaultValue={editing?.orderTypeId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("checklists.noOrderType")}</option>
|
||||||
|
{orderTypes.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("checklists.items")} htmlFor="tpl-items" hint={t("checklists.itemsHint")} className="md:col-span-1">
|
||||||
|
<textarea
|
||||||
|
id="tpl-items"
|
||||||
|
name="items"
|
||||||
|
rows={10}
|
||||||
|
defaultValue={editing ? itemsToText(editing.itemList, t("checklists.photoSuffix")) : suggest ? itemsToText(DEFAULT_CHECKLIST_ITEMS, t("checklists.photoSuffix")) : ""}
|
||||||
|
className={`${inputCls} py-2 font-mono text-xs`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("checklists.photos")} htmlFor="tpl-photos" hint={t("checklists.photosHint")}>
|
||||||
|
<textarea
|
||||||
|
id="tpl-photos"
|
||||||
|
name="requiredPhotos"
|
||||||
|
rows={10}
|
||||||
|
defaultValue={editing ? photosToText(editing.photoList) : suggest ? photosToText(DEFAULT_REQUIRED_PHOTOS) : ""}
|
||||||
|
className={`${inputCls} py-2 font-mono text-xs`}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
<Check id="tpl-active" name="active" label={t("checklists.active")} defaultChecked={editing ? editing.active : true} />
|
||||||
|
{!editing && suggest && <p className="self-center text-xs text-muted-foreground">{t("checklists.suggestionHint")}</p>}
|
||||||
|
{!editing && !suggest && (
|
||||||
|
<Link href={`${base}?new=1&suggest=1`} scroll={false} className="self-center text-sm font-semibold text-[var(--primary)] hover:underline">
|
||||||
|
{t("checklists.suggestion")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</ActionForm>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { ActionForm } from "@/components/work-orders/action-form";
|
||||||
|
import { SettingsTemplatesNav, templatesPageContext } from "@/components/work-orders/settings-nav";
|
||||||
|
import { Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||||
|
import { updateNumberingAction } from "@/server/actions/work_orders/settings";
|
||||||
|
import { listNumberSequences } from "@/server/services/work-orders/settings";
|
||||||
|
|
||||||
|
/** Number sequences: prefix and digits (the counter itself is never reset). */
|
||||||
|
export default async function NumberingPage() {
|
||||||
|
const { ctx } = await templatesPageContext();
|
||||||
|
const t = await getTranslations("settingsTemplates");
|
||||||
|
const sequences = await listNumberSequences(ctx);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<PageHead crumb={t("crumb")} title={t("numbering.title")} sub={t("numbering.sub")} />
|
||||||
|
<SettingsTemplatesNav active="numbering" />
|
||||||
|
<Section>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{sequences.map((s) => (
|
||||||
|
<li key={s.key} className="py-3">
|
||||||
|
<ActionForm action={updateNumberingAction} namespace="settingsTemplates" submitLabel={t("numbering.save")} variant="outline" successText={t("saved")} className="grid items-end gap-3 sm:grid-cols-2 md:grid-cols-[1fr_140px_120px_1fr_auto]" footerClassName="mt-0">
|
||||||
|
<input type="hidden" name="key" value={s.key} />
|
||||||
|
<Field label={t("numbering.key")}>
|
||||||
|
<p className="flex min-h-11 items-center font-semibold">{t(`numbering.keys.${s.key}`)}</p>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("numbering.prefix")} htmlFor={`n-prefix-${s.key}`}>
|
||||||
|
<input id={`n-prefix-${s.key}`} name="prefix" maxLength={12} pattern="[A-Za-z0-9\-_/]*" defaultValue={s.prefix} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("numbering.padding")} htmlFor={`n-pad-${s.key}`}>
|
||||||
|
<input id={`n-pad-${s.key}`} name="padding" type="number" min={1} max={10} defaultValue={s.padding} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("numbering.next")}>
|
||||||
|
<p className="flex min-h-11 items-center font-mono text-sm">{`${s.prefix}${String(s.nextValue).padStart(s.padding, "0")}`}</p>
|
||||||
|
</Field>
|
||||||
|
</ActionForm>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { ActionForm } from "@/components/work-orders/action-form";
|
||||||
|
import { SettingsTemplatesNav, templatesPageContext } from "@/components/work-orders/settings-nav";
|
||||||
|
import { Check, Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||||
|
import { createOrderTypeAction, updateOrderTypeAction } from "@/server/actions/work_orders/settings";
|
||||||
|
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||||
|
|
||||||
|
/** Order types per tenant (spec §10.2). Defaults are created on first access. */
|
||||||
|
export default async function OrderTypesPage() {
|
||||||
|
const { ctx } = await templatesPageContext();
|
||||||
|
const t = await getTranslations("settingsTemplates");
|
||||||
|
const types = await listOrderTypes(ctx);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<PageHead crumb={t("crumb")} title={t("orderTypes.title")} sub={t("orderTypes.sub")} />
|
||||||
|
<SettingsTemplatesNav active="order-types" />
|
||||||
|
|
||||||
|
<Section>
|
||||||
|
<ul className="divide-y">
|
||||||
|
{types.map((ot) => (
|
||||||
|
<li key={ot.id} className="py-3">
|
||||||
|
<ActionForm action={updateOrderTypeAction} namespace="settingsTemplates" submitLabel={t("orderTypes.save")} variant="outline" successText={t("saved")} className="grid items-end gap-3 md:grid-cols-[1fr_2fr_auto_auto_120px_auto]" footerClassName="mt-0">
|
||||||
|
<input type="hidden" name="id" value={ot.id} />
|
||||||
|
<Field label={t("orderTypes.key")}>
|
||||||
|
<p className="flex min-h-11 items-center font-mono text-xs text-muted-foreground">{ot.key}</p>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("orderTypes.name")} htmlFor={`ot-name-${ot.id}`}>
|
||||||
|
<input id={`ot-name-${ot.id}`} name="name" required maxLength={80} defaultValue={ot.name} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Check id={`ot-sig-${ot.id}`} name="signatureRequired" label={t("orderTypes.signatureRequired")} defaultChecked={ot.signatureRequired} />
|
||||||
|
<Check id={`ot-act-${ot.id}`} name="active" label={t("orderTypes.active")} defaultChecked={ot.active} />
|
||||||
|
<Field label={t("orderTypes.sortOrder")} htmlFor={`ot-sort-${ot.id}`}>
|
||||||
|
<input id={`ot-sort-${ot.id}`} name="sortOrder" type="number" min={0} max={10000} defaultValue={ot.sortOrder} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
</ActionForm>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={t("orderTypes.create")} className="mt-4">
|
||||||
|
<ActionForm action={createOrderTypeAction} namespace="settingsTemplates" submitLabel={t("orderTypes.create")} variant="primary" successText={t("saved")} className="grid items-end gap-3 md:grid-cols-[1fr_2fr_auto_120px]">
|
||||||
|
<Field label={t("orderTypes.key")} htmlFor="ot-new-key" hint={t("orderTypes.keyHint")}>
|
||||||
|
<input id="ot-new-key" name="key" pattern="[a-z0-9_]{2,40}" maxLength={40} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={`${t("orderTypes.name")} *`} htmlFor="ot-new-name">
|
||||||
|
<input id="ot-new-name" name="name" required maxLength={80} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Check id="ot-new-sig" name="signatureRequired" label={t("orderTypes.signatureRequired")} defaultChecked />
|
||||||
|
<Field label={t("orderTypes.sortOrder")} htmlFor="ot-new-sort">
|
||||||
|
<input id="ot-new-sort" name="sortOrder" type="number" min={0} max={10000} defaultValue={100} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
</ActionForm>
|
||||||
|
</Section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ArrowLeft, Pencil, Siren, UsersRound } from "lucide-react";
|
||||||
|
import { Modal } from "@/components/modal";
|
||||||
|
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
|
||||||
|
import {
|
||||||
|
ChecklistTab,
|
||||||
|
DocumentsTab,
|
||||||
|
HistoryTab,
|
||||||
|
MaterialTab,
|
||||||
|
NotesTab,
|
||||||
|
OverviewTab,
|
||||||
|
PhotosTab,
|
||||||
|
ReportsTab,
|
||||||
|
TimesTab,
|
||||||
|
} from "@/components/work-orders/detail-tabs";
|
||||||
|
import { pageContext } from "@/components/work-orders/page-context";
|
||||||
|
import { Field, inputCls, LinkTabs, StatusBadge } from "@/components/work-orders/ui";
|
||||||
|
import { WorkOrderFields } from "@/components/work-orders/work-order-fields";
|
||||||
|
import { WORK_ORDER_STATUSES, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { assignAction, transitionAction, updateWorkOrderAction } from "@/server/actions/work_orders/work-orders";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_shared";
|
||||||
|
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
|
||||||
|
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
|
||||||
|
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||||
|
import { reasonRequired } from "@/server/services/work-orders/transition";
|
||||||
|
|
||||||
|
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "documents", "history"] as const;
|
||||||
|
type Tab = (typeof TABS)[number];
|
||||||
|
type SP = Record<string, string | string[] | undefined>;
|
||||||
|
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||||
|
const idx = (s: WorkOrderStatus) => WORK_ORDER_STATUSES.indexOf(s);
|
||||||
|
|
||||||
|
/** Backoffice work order detail: header with status + next primary action, tabs, popups (assign/edit/reason). */
|
||||||
|
export default async function WorkOrderDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<SP> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const sp = await searchParams;
|
||||||
|
const { ctx, locale, tz, can } = await pageContext();
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const tc = await getTranslations("common");
|
||||||
|
|
||||||
|
let wo;
|
||||||
|
try {
|
||||||
|
wo = await getWorkOrderDetail(ctx, id);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceError && err.code === "not_found") notFound();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabParam = one(sp.tab);
|
||||||
|
const tab: Tab = (TABS as readonly string[]).includes(tabParam ?? "") ? (tabParam as Tab) : "overview";
|
||||||
|
const selfHref = `/work-orders/${wo.id}${tab === "overview" ? "" : `?tab=${tab}`}`;
|
||||||
|
const withParam = (k: string, v: string) => `/work-orders/${wo.id}?${tab === "overview" ? "" : `tab=${tab}&`}${k}=${v}`;
|
||||||
|
|
||||||
|
const transitions = availableTransitions(ctx, wo.status);
|
||||||
|
const canAssign = can("work_order:assign") && !PLANNING_LOCKED.includes(wo.status);
|
||||||
|
const canWrite = can("work_order:write") && !FINAL_STATUSES.includes(wo.status);
|
||||||
|
const canEditPlanning = can("work_order:write") && !PLANNING_LOCKED.includes(wo.status);
|
||||||
|
const assignFirst = canAssign && ["draft", "review_required", "planned"].includes(wo.status);
|
||||||
|
|
||||||
|
// Primary = furthest forward step (never cancel/backward); assignment goes through the popup.
|
||||||
|
const forward = transitions.filter((to) => to !== "cancelled" && idx(to) > idx(wo.status) && !(assignFirst && to === "assigned"));
|
||||||
|
const primary = assignFirst ? null : forward.sort((a, b) => idx(b) - idx(a))[0] ?? null;
|
||||||
|
const secondary = transitions.filter((to) => to !== primary && to !== "cancelled" && !(assignFirst && to === "assigned"));
|
||||||
|
|
||||||
|
const label = (to: WorkOrderStatus) =>
|
||||||
|
wo.status === "in_review" && to === "in_progress"
|
||||||
|
? t("transition.correction")
|
||||||
|
: wo.status === "released_for_billing" && to === "in_review"
|
||||||
|
? t("transition.revoke")
|
||||||
|
: t(`transition.to.${to}`);
|
||||||
|
|
||||||
|
const transitionButton = (to: WorkOrderStatus, variant: "primary" | "outline" | "danger") =>
|
||||||
|
reasonRequired(wo.status, to) ? (
|
||||||
|
<Link key={to} href={withParam("transition", to)} scroll={false} className={buttonCls(variant)}>
|
||||||
|
{label(to)}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<ActionForm key={to} action={transitionAction} submitLabel={label(to)} pendingLabel={t("transition.pending")} variant={variant} footerClassName="mt-0">
|
||||||
|
<input type="hidden" name="workOrderId" value={wo.id} />
|
||||||
|
<input type="hidden" name="to" value={to} />
|
||||||
|
<input type="hidden" name="baseVersion" value={wo.version} />
|
||||||
|
<input type="hidden" name="returnTab" value={tab} />
|
||||||
|
</ActionForm>
|
||||||
|
);
|
||||||
|
|
||||||
|
// Popups
|
||||||
|
const reasonTo = one(sp.transition) as WorkOrderStatus | undefined;
|
||||||
|
const showReason = reasonTo && transitions.includes(reasonTo) && reasonRequired(wo.status, reasonTo);
|
||||||
|
const showAssign = canAssign && one(sp.assign) === "1";
|
||||||
|
const showEdit = canWrite && one(sp.edit) === "1";
|
||||||
|
const [teams, users, editData, orderTypes] = await Promise.all([
|
||||||
|
showAssign ? teamOptions(ctx) : Promise.resolve([]),
|
||||||
|
showAssign ? userOptions(ctx) : Promise.resolve([]),
|
||||||
|
showEdit ? customerOption(ctx, wo.customerId) : Promise.resolve(null),
|
||||||
|
showEdit ? listOrderTypes(ctx) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const billingTransitions = transitions.filter((to) => ["released_for_billing", "billed"].includes(to) || (wo.status === "in_review" && to === "in_progress") || (wo.status === "released_for_billing" && to === "in_review"));
|
||||||
|
|
||||||
|
const tabProps = { ctx, wo, locale, tz, canEdit: canEditPlanning };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<Link href="/work-orders" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="size-4" aria-hidden />
|
||||||
|
{t("detail.back")}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<header className="shadow-card mt-1 rounded-xl border border-l-4 bg-card p-4 md:p-5" style={{ borderLeftColor: "var(--ui-primary)" }}>
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
<span className="font-mono">{wo.number}</span>
|
||||||
|
<span>· {t("detail.version", { version: wo.version })}</span>
|
||||||
|
{wo.isEmergency && (
|
||||||
|
<span className="inline-flex items-center gap-1 font-semibold text-[var(--risk)]">
|
||||||
|
<Siren className="size-3.5" aria-hidden />
|
||||||
|
{t("emergency")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-[22px] break-words">{wo.title}</h1>
|
||||||
|
<p className="mt-1 text-sm">
|
||||||
|
{customerDisplayName(wo.customer)}
|
||||||
|
{wo.site && <span className="text-muted-foreground"> · {wo.site.name}</span>}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||||
|
<StatusBadge status={wo.status} label={t(`status.${wo.status}`)} className="text-sm" />
|
||||||
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<UsersRound className="size-3.5" aria-hidden />
|
||||||
|
{wo.team?.name ?? t("unassigned")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-stretch gap-2 sm:items-end">
|
||||||
|
<span className="text-xs font-semibold text-muted-foreground">{t("detail.nextStep")}</span>
|
||||||
|
<div className="flex flex-wrap gap-2 sm:justify-end">
|
||||||
|
{assignFirst && (
|
||||||
|
<Link href={withParam("assign", "1")} scroll={false} className={buttonCls("primary")}>
|
||||||
|
{t("detail.assign")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{primary && transitionButton(primary, "primary")}
|
||||||
|
{!assignFirst && !primary && <span className="text-sm text-muted-foreground">{t("detail.noActions")}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center gap-2 border-t pt-3">
|
||||||
|
{secondary.map((to) => transitionButton(to, "outline"))}
|
||||||
|
{canAssign && !assignFirst && (
|
||||||
|
<Link href={withParam("assign", "1")} scroll={false} className={buttonCls("outline")}>
|
||||||
|
<UsersRound className="size-4" aria-hidden />
|
||||||
|
{t("detail.reassign")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{canWrite && (
|
||||||
|
<Link href={withParam("edit", "1")} scroll={false} className={buttonCls("outline")}>
|
||||||
|
<Pencil className="size-4" aria-hidden />
|
||||||
|
{t("detail.edit")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{transitions.includes("cancelled") && (
|
||||||
|
<Link href={withParam("transition", "cancelled")} scroll={false} className={`${buttonCls("danger")} sm:ml-auto`}>
|
||||||
|
{t("detail.cancel")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<LinkTabs
|
||||||
|
label={t("detail.tabs.overview")}
|
||||||
|
items={TABS.map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
{tab === "overview" && <OverviewTab {...tabProps} />}
|
||||||
|
{tab === "checklist" && <ChecklistTab {...tabProps} />}
|
||||||
|
{tab === "material" && <MaterialTab {...tabProps} />}
|
||||||
|
{tab === "times" && <TimesTab {...tabProps} />}
|
||||||
|
{tab === "photos" && <PhotosTab {...tabProps} />}
|
||||||
|
{tab === "notes" && <NotesTab {...tabProps} />}
|
||||||
|
{tab === "reports" && (
|
||||||
|
<ReportsTab
|
||||||
|
{...tabProps}
|
||||||
|
billing={
|
||||||
|
billingTransitions.length > 0 ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{billingTransitions.map((to) => transitionButton(to, to === "released_for_billing" || to === "billed" ? "primary" : "outline"))}
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
|
||||||
|
{tab === "history" && <HistoryTab {...tabProps} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showReason && reasonTo && (
|
||||||
|
<Modal title={label(reasonTo)} sub={t("transition.reasonTitle")} closeHref={selfHref} closeLabel={tc("close")}>
|
||||||
|
<ActionForm action={transitionAction} submitLabel={t("transition.confirm")} pendingLabel={t("transition.pending")} variant={reasonTo === "cancelled" ? "danger" : "default"} className="p-5">
|
||||||
|
<input type="hidden" name="workOrderId" value={wo.id} />
|
||||||
|
<input type="hidden" name="to" value={reasonTo} />
|
||||||
|
<input type="hidden" name="baseVersion" value={wo.version} />
|
||||||
|
<input type="hidden" name="returnTab" value={tab} />
|
||||||
|
<Field label={`${t("transition.reason")} *`} htmlFor="tr-reason">
|
||||||
|
<textarea id="tr-reason" name="reason" required rows={4} maxLength={2000} placeholder={t("transition.reasonPlaceholder")} className={`${inputCls} py-2`} autoFocus />
|
||||||
|
</Field>
|
||||||
|
</ActionForm>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showAssign && (
|
||||||
|
<Modal title={t("assign.title")} sub={t("assign.sub")} closeHref={selfHref} closeLabel={tc("close")}>
|
||||||
|
{teams.length === 0 ? (
|
||||||
|
<p className="p-5 text-sm text-muted-foreground">{t("assign.noTeams")}</p>
|
||||||
|
) : (
|
||||||
|
<ActionForm action={assignAction} submitLabel={t("assign.submit")} pendingLabel={t("assign.pending")} variant="primary" className="grid gap-4 p-5 md:grid-cols-2">
|
||||||
|
<input type="hidden" name="workOrderId" value={wo.id} />
|
||||||
|
<input type="hidden" name="baseVersion" value={wo.version} />
|
||||||
|
<Field label={`${t("assign.team")} *`} htmlFor="as-team">
|
||||||
|
<select id="as-team" name="teamId" required defaultValue={wo.team?.id ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("assign.chooseTeam")}</option>
|
||||||
|
{teams.map((tm) => (
|
||||||
|
<option key={tm.id} value={tm.id}>
|
||||||
|
{tm.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("assign.teamLead")} htmlFor="as-lead">
|
||||||
|
<select id="as-lead" name="teamLeadUserId" defaultValue={wo.teamLead?.id ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("assign.teamLeadDefault")}</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<fieldset className="md:col-span-2">
|
||||||
|
<legend className="mb-1 text-[13px] font-semibold">{t("assign.members")}</legend>
|
||||||
|
<div className="grid gap-x-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{users.map((u) => (
|
||||||
|
<label key={u.id} className="flex min-h-11 items-center gap-2.5 text-sm">
|
||||||
|
<input type="checkbox" name="userIds" value={u.id} defaultChecked={wo.assignees.some((a) => a.user.id === u.id)} className="size-5 accent-[var(--ui-primary)]" />
|
||||||
|
{u.name}
|
||||||
|
<span className="text-xs text-muted-foreground">{teams.filter((tm) => tm.members.some((m) => m.user.id === u.id)).map((tm) => tm.name).join(", ")}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEdit && editData && (
|
||||||
|
<Modal title={t("edit.title")} sub={`${wo.number} · ${customerDisplayName(wo.customer)}`} closeHref={selfHref} closeLabel={tc("close")}>
|
||||||
|
<ActionForm action={updateWorkOrderAction.bind(null, wo.id)} submitLabel={t("edit.submit")} pendingLabel={t("edit.pending")} className="p-5">
|
||||||
|
<input type="hidden" name="baseVersion" value={wo.version} />
|
||||||
|
<WorkOrderFields
|
||||||
|
mode="edit"
|
||||||
|
tz={tz}
|
||||||
|
sites={editData.sites.map((s) => ({ id: s.id, name: [s.name, s.city].filter(Boolean).join(" · ") }))}
|
||||||
|
contacts={editData.contacts.map((c) => ({ id: c.id, name: c.name }))}
|
||||||
|
orderTypes={orderTypes.map((o) => ({ id: o.id, name: o.active ? o.name : `${o.name} (–)` }))}
|
||||||
|
defaults={{
|
||||||
|
title: wo.title,
|
||||||
|
siteId: wo.siteId,
|
||||||
|
contactId: wo.contactId,
|
||||||
|
orderTypeId: wo.orderTypeId,
|
||||||
|
priority: wo.priority,
|
||||||
|
plannedStart: wo.plannedStart,
|
||||||
|
plannedEnd: wo.plannedEnd,
|
||||||
|
description: wo.description,
|
||||||
|
scope: wo.scope,
|
||||||
|
internalNotes: wo.internalNotes,
|
||||||
|
technicianNotes: wo.technicianNotes,
|
||||||
|
signatureRequired: wo.signatureRequired,
|
||||||
|
billingType: wo.billingType,
|
||||||
|
externalOrderNumber: wo.externalOrderNumber,
|
||||||
|
offerNumber: wo.offerNumber,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</ActionForm>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { ActionForm } from "@/components/work-orders/action-form";
|
||||||
|
import { pageContext } from "@/components/work-orders/page-context";
|
||||||
|
import { Empty, Section, StatusBadge } from "@/components/work-orders/ui";
|
||||||
|
import { formatDateTime } from "@/lib/work-orders/time";
|
||||||
|
import { applySyncConflictAction, discardSyncConflictAction } from "@/server/actions/work_orders/work-orders";
|
||||||
|
import { listSyncConflicts } from "@/server/services/work-orders/conflicts";
|
||||||
|
|
||||||
|
/** Offline sync conflicts (ARCHITEKTUR §4.6) — apply against current state or discard. */
|
||||||
|
export default async function SyncConflictsPage() {
|
||||||
|
const { ctx, locale, tz, can } = await pageContext();
|
||||||
|
if (!can("work_order:write")) redirect("/work-orders");
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const conflicts = await listSyncConflicts(ctx);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<Link href="/work-orders" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
|
||||||
|
<ArrowLeft className="size-4" aria-hidden />
|
||||||
|
{t("detail.back")}
|
||||||
|
</Link>
|
||||||
|
<PageHead crumb={t("conflicts.crumb")} title={t("conflicts.title")} sub={t("conflicts.sub")} />
|
||||||
|
<p className="mb-4 text-sm text-muted-foreground">
|
||||||
|
{t("conflicts.applyHint")} {t("conflicts.stubHint")}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{conflicts.length === 0 ? (
|
||||||
|
<Empty>{t("conflicts.empty")}</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-3">
|
||||||
|
{conflicts.map((c) => {
|
||||||
|
const payload = JSON.stringify(c.payload, null, 2);
|
||||||
|
return (
|
||||||
|
<li key={c.id}>
|
||||||
|
<Section
|
||||||
|
title={`${c.opType}`}
|
||||||
|
actions={<span className="text-xs text-muted-foreground">{t("conflicts.received")}: {formatDateTime(c.receivedAt, locale, tz)}</span>}
|
||||||
|
>
|
||||||
|
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
<p>
|
||||||
|
<span className="text-muted-foreground">{t("conflicts.user")}: </span>
|
||||||
|
{c.userName ?? c.userId}
|
||||||
|
</p>
|
||||||
|
{c.workOrder && (
|
||||||
|
<p className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">{t("conflicts.entity")}: </span>
|
||||||
|
<Link href={`/work-orders/${c.workOrder.id}`} className="font-semibold text-[var(--primary)] hover:underline">
|
||||||
|
{c.workOrder.number} · {c.workOrder.title}
|
||||||
|
</Link>
|
||||||
|
<StatusBadge status={c.workOrder.status} label={t(`status.${c.workOrder.status}`)} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{c.baseVersion !== null && c.workOrder && (
|
||||||
|
<p className="text-xs font-semibold text-[var(--warn)]">{t("conflicts.baseVersion", { base: c.baseVersion, current: c.workOrder.version })}</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2">
|
||||||
|
<ActionForm action={applySyncConflictAction} submitLabel={t("conflicts.apply")} variant="primary" successText={t("conflicts.applied")} footerClassName="mt-0">
|
||||||
|
<input type="hidden" name="opId" value={c.id} />
|
||||||
|
</ActionForm>
|
||||||
|
<ActionForm action={discardSyncConflictAction} submitLabel={t("conflicts.discard")} variant="outline" successText={t("conflicts.discarded")} footerClassName="mt-0">
|
||||||
|
<input type="hidden" name="opId" value={c.id} />
|
||||||
|
</ActionForm>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-1 text-xs font-semibold text-muted-foreground">{t("conflicts.payload")}</p>
|
||||||
|
<pre className="max-h-56 overflow-auto rounded-lg bg-muted p-3 text-xs">{payload.length > 2000 ? `${payload.slice(0, 2000)}…` : payload}</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,353 @@
|
|||||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { LayoutGrid, Plus, RefreshCw, Rows3, X } from "lucide-react";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { Modal } from "@/components/modal";
|
||||||
|
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
|
||||||
|
import { pageContext } from "@/components/work-orders/page-context";
|
||||||
|
import { Empty, Field, GroupIcon, inputCls, LinkTabs } from "@/components/work-orders/ui";
|
||||||
|
import { WorkOrderFields } from "@/components/work-orders/work-order-fields";
|
||||||
|
import { WorkOrderCards, WorkOrderTable } from "@/components/work-orders/work-order-list";
|
||||||
|
import { isoDay, parseListParams, SORT_FIELDS, STATUS_GROUPS, toQuery, type ListParams } from "@/lib/work-orders/filters";
|
||||||
|
import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
|
||||||
|
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
|
||||||
|
import { createWorkOrderAction } from "@/server/actions/work_orders/work-orders";
|
||||||
|
import { listWorkOrders } from "@/server/services/work-orders/list";
|
||||||
|
import {
|
||||||
|
customerDisplayName,
|
||||||
|
customerFilterOptions,
|
||||||
|
customerOption,
|
||||||
|
searchCustomerOptions,
|
||||||
|
siteFilterOptions,
|
||||||
|
teamOptions,
|
||||||
|
userOptions,
|
||||||
|
} from "@/server/services/work-orders/options";
|
||||||
|
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||||
|
|
||||||
export default function Page() {
|
type SP = Record<string, string | string[] | undefined>;
|
||||||
return <ModulePlaceholder moduleKey="work_orders" />;
|
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||||
|
|
||||||
|
function filterQuery(p: ListParams, extra: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
q: p.q,
|
||||||
|
from: p.from,
|
||||||
|
to: p.to,
|
||||||
|
customerId: p.customerId,
|
||||||
|
siteId: p.siteId,
|
||||||
|
teamId: p.teamId,
|
||||||
|
userId: p.userId,
|
||||||
|
status: p.statuses,
|
||||||
|
group: p.group,
|
||||||
|
orderTypeId: p.orderTypeId,
|
||||||
|
priority: p.priority,
|
||||||
|
preset: p.preset,
|
||||||
|
sort: p.sort === "plannedStart" ? undefined : p.sort,
|
||||||
|
dir: p.dir === "asc" ? undefined : p.dir,
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backoffice work order list (§21 filters, status group tabs, cards/table, create popup). */
|
||||||
|
export default async function WorkOrdersPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||||
|
const { ctx, locale, tz, can } = await pageContext();
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const td = await getTranslations("dashboard");
|
||||||
|
const tc = await getTranslations("common");
|
||||||
|
const sp = await searchParams;
|
||||||
|
const p = parseListParams(sp);
|
||||||
|
const view = one(sp.view) === "table" ? "table" : "cards";
|
||||||
|
const readAll = can("work_order:read_all");
|
||||||
|
|
||||||
|
const [result, teams, users, orderTypes, customers, sites] = await Promise.all([
|
||||||
|
listWorkOrders(ctx, p),
|
||||||
|
teamOptions(ctx),
|
||||||
|
readAll ? userOptions(ctx) : Promise.resolve([]),
|
||||||
|
listOrderTypes(ctx),
|
||||||
|
readAll ? customerFilterOptions(ctx) : Promise.resolve([]),
|
||||||
|
p.customerId ? siteFilterOptions(ctx, p.customerId) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const base = filterQuery(p, { view: view === "table" ? "table" : undefined });
|
||||||
|
const pages = Math.max(1, Math.ceil(result.total / p.pageSize));
|
||||||
|
const allCount = Object.values(result.groupCounts).reduce((a, b) => a + b, 0);
|
||||||
|
const listHref = `/work-orders${toQuery(base)}`;
|
||||||
|
|
||||||
|
// ---- create popup ----
|
||||||
|
const canCreate = can("work_order:write");
|
||||||
|
const showNew = canCreate && one(sp.new) === "1";
|
||||||
|
const newCustomerId = one(sp.customerId_new);
|
||||||
|
const cq = one(sp.cq);
|
||||||
|
const [customerHits, chosen, activeTypes] = showNew
|
||||||
|
? await Promise.all([
|
||||||
|
newCustomerId ? Promise.resolve([]) : searchCustomerOptions(ctx, cq),
|
||||||
|
newCustomerId ? customerOption(ctx, newCustomerId) : Promise.resolve(null),
|
||||||
|
listOrderTypes(ctx, { activeOnly: true }),
|
||||||
|
])
|
||||||
|
: [[], null, []];
|
||||||
|
const newBase = { ...base, new: "1" };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<PageHead
|
||||||
|
crumb={t("crumb")}
|
||||||
|
title={t("title")}
|
||||||
|
sub={t("subtitle")}
|
||||||
|
actions={
|
||||||
|
<>
|
||||||
|
{can("work_order:write") && (
|
||||||
|
<Link href="/work-orders/conflicts" className={buttonCls("outline")}>
|
||||||
|
<RefreshCw className="size-4" aria-hidden />
|
||||||
|
{t("conflictsLink")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
{canCreate && (
|
||||||
|
<Link href={`/work-orders${toQuery(newBase)}`} className={buttonCls("primary")} scroll={false}>
|
||||||
|
<Plus className="size-4" aria-hidden />
|
||||||
|
{t("new")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LinkTabs
|
||||||
|
label={t("filter.status")}
|
||||||
|
items={[
|
||||||
|
{ href: `/work-orders${toQuery({ ...base, group: undefined, status: undefined })}`, label: t("allGroups"), active: !p.group && !p.statuses, count: allCount },
|
||||||
|
...STATUS_GROUPS.map((g) => ({
|
||||||
|
href: `/work-orders${toQuery({ ...base, group: g, status: undefined })}`,
|
||||||
|
label: t(`statusGroup.${g}`),
|
||||||
|
active: p.group === g,
|
||||||
|
count: result.groupCounts[g],
|
||||||
|
icon: <GroupIcon group={g} className="size-4" />,
|
||||||
|
})),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<details className="shadow-card mt-4 rounded-xl border bg-card" open={Boolean(p.q || p.from || p.to || p.customerId || p.teamId || p.userId || p.statuses || p.orderTypeId || p.priority)}>
|
||||||
|
<summary className="flex min-h-11 cursor-pointer items-center px-4 font-heading text-sm font-semibold">{t("filter.title")}</summary>
|
||||||
|
<form method="get" action="/work-orders" className="grid gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{p.group && <input type="hidden" name="group" value={p.group} />}
|
||||||
|
{p.preset && <input type="hidden" name="preset" value={p.preset} />}
|
||||||
|
{view === "table" && <input type="hidden" name="view" value="table" />}
|
||||||
|
<Field label={t("filter.q")} htmlFor="f-q" className="sm:col-span-2">
|
||||||
|
<input id="f-q" type="search" name="q" defaultValue={p.q ?? ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("filter.from")} htmlFor="f-from">
|
||||||
|
<input id="f-from" type="date" name="from" defaultValue={p.from ? isoDay(p.from) : ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("filter.to")} htmlFor="f-to">
|
||||||
|
<input id="f-to" type="date" name="to" defaultValue={p.to ? isoDay(p.to) : ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
{readAll && (
|
||||||
|
<Field label={t("filter.customer")} htmlFor="f-customer">
|
||||||
|
<select id="f-customer" name="customerId" defaultValue={p.customerId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("filter.any")}</option>
|
||||||
|
{customers.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{customerDisplayName(c)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
{sites.length > 0 && (
|
||||||
|
<Field label={t("filter.site")} htmlFor="f-site">
|
||||||
|
<select id="f-site" name="siteId" defaultValue={p.siteId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("filter.any")}</option>
|
||||||
|
{sites.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label={t("filter.team")} htmlFor="f-team">
|
||||||
|
<select id="f-team" name="teamId" defaultValue={p.teamId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("filter.any")}</option>
|
||||||
|
{teams.map((tm) => (
|
||||||
|
<option key={tm.id} value={tm.id}>
|
||||||
|
{tm.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
{readAll && (
|
||||||
|
<Field label={t("filter.user")} htmlFor="f-user">
|
||||||
|
<select id="f-user" name="userId" defaultValue={p.userId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("filter.any")}</option>
|
||||||
|
{users.map((u) => (
|
||||||
|
<option key={u.id} value={u.id}>
|
||||||
|
{u.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
<Field label={t("filter.status")} htmlFor="f-status">
|
||||||
|
<select id="f-status" name="status" defaultValue={p.statuses?.[0] ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("filter.any")}</option>
|
||||||
|
{WORK_ORDER_STATUSES.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t(`status.${s}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("filter.orderType")} htmlFor="f-type">
|
||||||
|
<select id="f-type" name="orderTypeId" defaultValue={p.orderTypeId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("filter.any")}</option>
|
||||||
|
{orderTypes.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("filter.priority")} htmlFor="f-prio">
|
||||||
|
<select id="f-prio" name="priority" defaultValue={p.priority ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("filter.any")}</option>
|
||||||
|
{WORK_ORDER_PRIORITIES.map((pr) => (
|
||||||
|
<option key={pr} value={pr}>
|
||||||
|
{t(`priority.${pr}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("filter.sort")} htmlFor="f-sort">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select id="f-sort" name="sort" defaultValue={p.sort} className={inputCls}>
|
||||||
|
{SORT_FIELDS.map((s) => (
|
||||||
|
<option key={s} value={s}>
|
||||||
|
{t(`sort.${s}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<select name="dir" aria-label={t("filter.sort")} defaultValue={p.dir} className={inputCls}>
|
||||||
|
<option value="asc">{t("sort.asc")}</option>
|
||||||
|
<option value="desc">{t("sort.desc")}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Field>
|
||||||
|
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-4">
|
||||||
|
<button type="submit" className={buttonCls("default")}>
|
||||||
|
{t("filter.apply")}
|
||||||
|
</button>
|
||||||
|
<Link href="/work-orders" className={buttonCls("ghost")}>
|
||||||
|
{t("filter.reset")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-sm">
|
||||||
|
<span className="font-semibold">{t("resultCount", { count: result.total })}</span>
|
||||||
|
{p.preset && (
|
||||||
|
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold">
|
||||||
|
{t("presetActive", { name: td(`tiles.${p.preset}`) })}
|
||||||
|
<Link href={`/work-orders${toQuery({ ...base, preset: undefined })}`} aria-label={t("removePreset")} className="inline-flex size-6 items-center justify-center rounded-full hover:bg-muted">
|
||||||
|
<X className="size-3.5" aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1" role="group" aria-label={t("view.cards")}>
|
||||||
|
<Link href={`/work-orders${toQuery({ ...base, view: undefined, page: p.page > 1 ? p.page : undefined })}`} aria-current={view === "cards" ? "true" : undefined} className={buttonCls(view === "cards" ? "default" : "outline")}>
|
||||||
|
<LayoutGrid className="size-4" aria-hidden />
|
||||||
|
{t("view.cards")}
|
||||||
|
</Link>
|
||||||
|
<Link href={`/work-orders${toQuery({ ...base, view: "table", page: p.page > 1 ? p.page : undefined })}`} aria-current={view === "table" ? "true" : undefined} className={buttonCls(view === "table" ? "default" : "outline")}>
|
||||||
|
<Rows3 className="size-4" aria-hidden />
|
||||||
|
{t("view.table")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
{result.items.length === 0 ? (
|
||||||
|
<Empty>{t("empty")}</Empty>
|
||||||
|
) : view === "table" ? (
|
||||||
|
<WorkOrderTable items={result.items} locale={locale} tz={tz} />
|
||||||
|
) : (
|
||||||
|
<WorkOrderCards items={result.items} locale={locale} tz={tz} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pages > 1 && (
|
||||||
|
<nav aria-label={t("pagination.page", { page: p.page, pages })} className="mt-4 flex items-center justify-center gap-3 text-sm">
|
||||||
|
{p.page > 1 && (
|
||||||
|
<Link href={`/work-orders${toQuery({ ...base, page: p.page - 1 })}`} className={buttonCls("outline")}>
|
||||||
|
{t("pagination.prev")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
<span>{t("pagination.page", { page: p.page, pages })}</span>
|
||||||
|
{p.page < pages && (
|
||||||
|
<Link href={`/work-orders${toQuery({ ...base, page: p.page + 1 })}`} className={buttonCls("outline")}>
|
||||||
|
{t("pagination.next")}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showNew && (
|
||||||
|
<Modal title={t("create.title")} sub={t("create.sub")} closeHref={listHref} closeLabel={tc("close")}>
|
||||||
|
{!chosen ? (
|
||||||
|
<div className="p-5">
|
||||||
|
<h3 className="mb-2 font-heading text-sm font-semibold">{t("create.stepCustomer")}</h3>
|
||||||
|
<form method="get" action="/work-orders" className="flex flex-wrap gap-2">
|
||||||
|
<input type="hidden" name="new" value="1" />
|
||||||
|
<input type="search" name="cq" defaultValue={cq ?? ""} aria-label={t("create.customerSearch")} placeholder={t("create.customerSearch")} className={`${inputCls} max-w-md flex-1`} autoFocus />
|
||||||
|
<button type="submit" className={buttonCls("default")}>
|
||||||
|
{t("create.searchButton")}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{customerHits.length === 0 ? (
|
||||||
|
<p className="mt-4 text-sm text-muted-foreground">{t("create.noCustomers")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-4 divide-y rounded-lg border">
|
||||||
|
{customerHits.map((c) => (
|
||||||
|
<li key={c.id} className="flex flex-wrap items-center justify-between gap-2 px-3 py-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold">{customerDisplayName(c)}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{[c.customerNumber, c.city].filter(Boolean).join(" · ")}</p>
|
||||||
|
</div>
|
||||||
|
<Link href={`/work-orders?new=1&customerId_new=${c.id}`} scroll={false} className={buttonCls("outline")}>
|
||||||
|
{t("create.chooseCustomer")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-5">
|
||||||
|
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 rounded-lg bg-muted/50 px-3 py-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className="text-muted-foreground">{t("fields.customer")}: </span>
|
||||||
|
<span className="font-semibold">{customerDisplayName(chosen.customer)}</span>
|
||||||
|
</p>
|
||||||
|
<Link href="/work-orders?new=1" scroll={false} className="text-sm font-semibold text-[var(--primary)] hover:underline">
|
||||||
|
{t("create.changeCustomer")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
<h3 className="mb-3 font-heading text-sm font-semibold">{t("create.stepDetails")}</h3>
|
||||||
|
<ActionForm action={createWorkOrderAction} submitLabel={t("create.submit")} pendingLabel={t("create.pending")} variant="primary">
|
||||||
|
<input type="hidden" name="customerId" value={chosen.customer.id} />
|
||||||
|
<WorkOrderFields
|
||||||
|
mode="create"
|
||||||
|
tz={tz}
|
||||||
|
sites={chosen.sites.map((s) => ({ id: s.id, name: [s.name, [s.street, s.houseNumber].filter(Boolean).join(" "), s.city].filter(Boolean).join(" · ") }))}
|
||||||
|
contacts={chosen.contacts.map((c) => ({ id: c.id, name: c.role ? `${c.name} (${c.role})` : c.name }))}
|
||||||
|
orderTypes={activeTypes.map((o) => ({ id: o.id, name: o.name }))}
|
||||||
|
defaults={{ siteId: chosen.sites.length === 1 ? chosen.sites[0].id : null }}
|
||||||
|
/>
|
||||||
|
</ActionForm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { assignWorkOrder } from "@/server/services/work-orders/assign";
|
||||||
|
import { apiContext, apiError, optionalVersion, readJson } from "../../_http";
|
||||||
|
|
||||||
|
/** POST /api/v1/work-orders/[id]/assign — body { teamId, userIds?, teamLeadUserId?, baseVersion? }. */
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const ctx = await apiContext("work_order:assign");
|
||||||
|
const body = await readJson(req);
|
||||||
|
const res = await assignWorkOrder(ctx, {
|
||||||
|
workOrderId: id,
|
||||||
|
teamId: String(body.teamId ?? ""),
|
||||||
|
userIds: Array.isArray(body.userIds) ? body.userIds.map(String) : [],
|
||||||
|
teamLeadUserId: typeof body.teamLeadUserId === "string" ? body.teamLeadUserId : null,
|
||||||
|
baseVersion: optionalVersion(body.baseVersion),
|
||||||
|
});
|
||||||
|
return NextResponse.json(res);
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import type { DocumentCategory, DocumentVisibility } from "@prisma/client";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents";
|
||||||
|
import { apiContext, apiError } from "../../_http";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?).
|
||||||
|
* Used by the backoffice form (HTML post → 303 back to the documents tab) and by API clients (JSON).
|
||||||
|
* A route handler instead of a server action avoids the 1 MB server-action body limit.
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = await params;
|
||||||
|
const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html");
|
||||||
|
try {
|
||||||
|
// CSRF defence for the cookie-authenticated form post: same-origin only.
|
||||||
|
const origin = req.headers.get("origin");
|
||||||
|
if (origin && origin !== req.nextUrl.origin) throw new ServiceError("forbidden", "cross_origin");
|
||||||
|
const ctx = await apiContext("document:write");
|
||||||
|
const form = await req.formData();
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing");
|
||||||
|
const doc = await uploadWorkOrderDocument(ctx, {
|
||||||
|
workOrderId: id,
|
||||||
|
bytes: new Uint8Array(await file.arrayBuffer()),
|
||||||
|
fileName: file.name,
|
||||||
|
declaredMime: file.type,
|
||||||
|
category: String(form.get("category") ?? "other") as DocumentCategory,
|
||||||
|
visibility: String(form.get("visibility") ?? "team") as DocumentVisibility,
|
||||||
|
title: typeof form.get("title") === "string" && String(form.get("title")).trim() ? String(form.get("title")).trim() : null,
|
||||||
|
});
|
||||||
|
if (wantsHtml) return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploaded=1`, req.nextUrl.origin), 303);
|
||||||
|
return NextResponse.json(doc, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
if (wantsHtml && err instanceof ServiceError) {
|
||||||
|
return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploadError=${encodeURIComponent(err.message)}`, req.nextUrl.origin), 303);
|
||||||
|
}
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import type { MaterialPlanInput } from "@/lib/work-orders/schemas";
|
||||||
|
import { addMaterialPlan, getMaterialOverview } from "@/server/services/work-orders/materials";
|
||||||
|
import { apiContext, apiError, readJson } from "../../_http";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** GET /api/v1/work-orders/[id]/materials — planned vs. actual incl. deviations. */
|
||||||
|
export async function GET(_req: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const ctx = await apiContext();
|
||||||
|
return NextResponse.json({ items: await getMaterialOverview(ctx, id) });
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/work-orders/[id]/materials — add a material plan item { name, articleNumber?, plannedQuantity, unit, notes? }. */
|
||||||
|
export async function POST(req: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const ctx = await apiContext("work_order:write");
|
||||||
|
const plan = await addMaterialPlan(ctx, id, (await readJson(req)) as MaterialPlanInput);
|
||||||
|
return NextResponse.json({ ...plan, plannedQuantity: Number(plan.plannedQuantity) }, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import type { UpdateWorkOrderInput } from "@/lib/work-orders/schemas";
|
||||||
|
import { computeCompletionBlockers } from "@/server/services/work-orders/completion";
|
||||||
|
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
|
||||||
|
import { updateWorkOrder } from "@/server/services/work-orders/update";
|
||||||
|
import { apiContext, apiError, optionalVersion, readJson } from "../_http";
|
||||||
|
|
||||||
|
type Params = { params: Promise<{ id: string }> };
|
||||||
|
|
||||||
|
/** GET /api/v1/work-orders/[id] — detail incl. transitions available to the caller and completion blockers. */
|
||||||
|
export async function GET(_req: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const ctx = await apiContext();
|
||||||
|
const wo = await getWorkOrderDetail(ctx, id);
|
||||||
|
const [blockers] = await Promise.all([computeCompletionBlockers(ctx, id)]);
|
||||||
|
return NextResponse.json({ workOrder: wo, availableTransitions: availableTransitions(ctx, wo.status), completionBlockers: blockers });
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH /api/v1/work-orders/[id] — body: partial master data + optional baseVersion (409 on mismatch). */
|
||||||
|
export async function PATCH(req: NextRequest, { params }: Params) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const ctx = await apiContext();
|
||||||
|
const { baseVersion, ...patch } = await readJson(req);
|
||||||
|
const res = await updateWorkOrder(ctx, id, patch as UpdateWorkOrderInput, optionalVersion(baseVersion));
|
||||||
|
return NextResponse.json(res);
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||||
|
import { apiContext, apiError, optionalVersion, readJson } from "../../_http";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/work-orders/[id]/transition — body { to, reason?, baseVersion? }.
|
||||||
|
* 403 forbidden · 404 not in scope · 409 version conflict · 422 invalid / blocked (details: CompletionBlocker[]).
|
||||||
|
*/
|
||||||
|
export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
try {
|
||||||
|
const { id } = await params;
|
||||||
|
const ctx = await apiContext();
|
||||||
|
const body = await readJson(req);
|
||||||
|
const res = await transitionWorkOrder(ctx, {
|
||||||
|
workOrderId: id,
|
||||||
|
to: String(body.to ?? "") as never,
|
||||||
|
reason: typeof body.reason === "string" ? body.reason : null,
|
||||||
|
baseVersion: optionalVersion(body.baseVersion),
|
||||||
|
});
|
||||||
|
return NextResponse.json(res);
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ModuleDisabledError } from "@/server/modules";
|
||||||
|
import { ForbiddenError, type Permission } from "@/server/rbac";
|
||||||
|
import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* /api/v1/work-orders helpers (lane L2). Authentication/authorisation reuses the DB-authoritative
|
||||||
|
* moduleGuard (session cookie); the fundament's generic `requireApiContext` does not exist yet —
|
||||||
|
* see docs/craftvia/lanes/auftraege.md.
|
||||||
|
*/
|
||||||
|
export async function apiContext(...permissions: Permission[]): Promise<ServiceCtx> {
|
||||||
|
const g = await moduleGuard("work_orders")(...permissions);
|
||||||
|
return ctxFromGuard(g);
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS: Record<ServiceError["code"], number> = {
|
||||||
|
not_found: 404,
|
||||||
|
forbidden: 403,
|
||||||
|
invalid: 422,
|
||||||
|
conflict: 409,
|
||||||
|
blocked: 422,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function apiError(err: unknown): NextResponse {
|
||||||
|
if (err instanceof ServiceError) {
|
||||||
|
return NextResponse.json({ error: { code: err.code, message: err.message, details: err.details ?? null } }, { status: STATUS[err.code] });
|
||||||
|
}
|
||||||
|
if (err instanceof ForbiddenError) return NextResponse.json({ error: { code: "forbidden", message: "forbidden" } }, { status: 403 });
|
||||||
|
if (err instanceof ModuleDisabledError) return NextResponse.json({ error: { code: "forbidden", message: "module_disabled" } }, { status: 403 });
|
||||||
|
if (err instanceof SyntaxError) return NextResponse.json({ error: { code: "invalid", message: "invalid_json" } }, { status: 400 });
|
||||||
|
const msg = err instanceof Error ? err.message : "";
|
||||||
|
if (/Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(msg)) {
|
||||||
|
return NextResponse.json({ error: { code: "unauthorized", message: "unauthorized" } }, { status: 401 });
|
||||||
|
}
|
||||||
|
console.error("[api/v1/work-orders]", err);
|
||||||
|
return NextResponse.json({ error: { code: "internal", message: "internal" } }, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJson(req: Request): Promise<Record<string, unknown>> {
|
||||||
|
const body = (await req.json()) as unknown;
|
||||||
|
if (!body || typeof body !== "object" || Array.isArray(body)) throw new ServiceError("invalid", "body_must_be_object");
|
||||||
|
return body as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function optionalVersion(v: unknown): number | undefined {
|
||||||
|
return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { NextResponse, type NextRequest } from "next/server";
|
||||||
|
import { parseListParams } from "@/lib/work-orders/filters";
|
||||||
|
import type { CreateWorkOrderInput } from "@/lib/work-orders/schemas";
|
||||||
|
import { createWorkOrder } from "@/server/services/work-orders/create";
|
||||||
|
import { listWorkOrders } from "@/server/services/work-orders/list";
|
||||||
|
import { apiContext, apiError, readJson } from "./_http";
|
||||||
|
|
||||||
|
/** GET /api/v1/work-orders — filters as in the backoffice list (§21), always within workOrderScope. */
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const ctx = await apiContext();
|
||||||
|
return NextResponse.json(await listWorkOrders(ctx, parseListParams(req.nextUrl.searchParams)));
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/v1/work-orders — body: CreateWorkOrderInput (dates as ISO strings). */
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
try {
|
||||||
|
const ctx = await apiContext();
|
||||||
|
const body = await readJson(req);
|
||||||
|
// Import linkage and number keys are reserved for the import/emergency services.
|
||||||
|
delete body.sourceImportId;
|
||||||
|
const created = await createWorkOrder(ctx, body as CreateWorkOrderInput);
|
||||||
|
return NextResponse.json(created, { status: 201 });
|
||||||
|
} catch (err) {
|
||||||
|
return apiError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,6 +79,9 @@ const ENTITY_LABEL: Record<string, string> = {
|
|||||||
document: "Dokument",
|
document: "Dokument",
|
||||||
checklist_item: "Checklistenpunkt",
|
checklist_item: "Checklistenpunkt",
|
||||||
activity_note: "Tätigkeitsnotiz",
|
activity_note: "Tätigkeitsnotiz",
|
||||||
|
order_type: "Auftragsart",
|
||||||
|
checklist_template: "Checklisten-Vorlage",
|
||||||
|
number_sequence: "Nummernkreis",
|
||||||
};
|
};
|
||||||
|
|
||||||
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
const fmt = new Intl.DateTimeFormat("de-DE", { dateStyle: "medium", timeStyle: "short" });
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
import { IDLE_STATE, type ActionState } from "@/lib/work-orders/action-state";
|
||||||
|
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
|
||||||
|
|
||||||
|
const VARIANTS = {
|
||||||
|
primary: "bg-cta text-cta-foreground hover:opacity-90",
|
||||||
|
default: "bg-primary text-primary-foreground hover:opacity-90",
|
||||||
|
outline: "border border-border bg-background hover:bg-muted",
|
||||||
|
danger: "border border-[var(--risk)] bg-background text-[var(--risk)] hover:bg-muted",
|
||||||
|
ghost: "hover:bg-muted text-muted-foreground",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export const buttonCls = (variant: keyof typeof VARIANTS = "default") =>
|
||||||
|
cn(
|
||||||
|
"inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-4 font-heading text-sm font-semibold transition-colors focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none disabled:opacity-50",
|
||||||
|
VARIANTS[variant],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Form bound to a work order server action. Shows translated errors (incl. structured
|
||||||
|
* completion blockers) and an optional success note. Children are server-rendered fields.
|
||||||
|
*/
|
||||||
|
export function ActionForm({
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
submitLabel,
|
||||||
|
pendingLabel,
|
||||||
|
successText,
|
||||||
|
variant = "default",
|
||||||
|
namespace = "workOrders",
|
||||||
|
className,
|
||||||
|
footerClassName,
|
||||||
|
hideSubmit,
|
||||||
|
}: {
|
||||||
|
action: Action;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
submitLabel: string;
|
||||||
|
pendingLabel?: string;
|
||||||
|
successText?: string;
|
||||||
|
variant?: keyof typeof VARIANTS;
|
||||||
|
namespace?: "workOrders" | "settingsTemplates";
|
||||||
|
className?: string;
|
||||||
|
footerClassName?: string;
|
||||||
|
hideSubmit?: boolean;
|
||||||
|
}) {
|
||||||
|
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
|
||||||
|
const t = useTranslations(namespace);
|
||||||
|
const tw = useTranslations("workOrders");
|
||||||
|
|
||||||
|
let errorText = "";
|
||||||
|
if (state.status === "error") {
|
||||||
|
const key = `errors.${state.message}`;
|
||||||
|
errorText = t.has(key) ? t(key) : t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.internal");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className={className}>
|
||||||
|
{children}
|
||||||
|
{state.status === "error" && (
|
||||||
|
<div role="alert" className="mt-3 rounded-lg border border-[var(--risk)] bg-card px-3 py-2 text-sm text-[var(--risk)]">
|
||||||
|
<p className="flex items-center gap-2 font-semibold">
|
||||||
|
<AlertCircle className="size-4 shrink-0" aria-hidden />
|
||||||
|
{errorText}
|
||||||
|
</p>
|
||||||
|
{state.blockers && state.blockers.length > 0 && (
|
||||||
|
<ul className="mt-1.5 list-disc space-y-0.5 pl-6 text-foreground">
|
||||||
|
{state.blockers.map((b, i) => (
|
||||||
|
<li key={i}>{blockerText(tw, b)}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{state.status === "ok" && successText && (
|
||||||
|
<p role="status" className="mt-3 flex items-center gap-2 text-sm text-[var(--ok)]">
|
||||||
|
<CheckCircle2 className="size-4" aria-hidden />
|
||||||
|
{successText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{!hideSubmit && (
|
||||||
|
<div className={cn("mt-3 flex flex-wrap gap-2", footerClassName)}>
|
||||||
|
<button type="submit" disabled={pending} className={buttonCls(variant)}>
|
||||||
|
{pending ? pendingLabel ?? submitLabel : submitLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function blockerText(t: ReturnType<typeof useTranslations>, b: CompletionBlocker): string {
|
||||||
|
switch (b.kind) {
|
||||||
|
case "checklist_item":
|
||||||
|
return t("blockers.checklist_item", { label: b.label });
|
||||||
|
case "photo_requirement":
|
||||||
|
return t("blockers.photo_requirement", { label: b.label });
|
||||||
|
case "running_session":
|
||||||
|
return t("blockers.running_session");
|
||||||
|
case "missing_field":
|
||||||
|
return t.has(`blockers.field.${b.field}`) ? t(`blockers.field.${b.field}`) : b.field;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { Camera, Download, FileText } from "lucide-react";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import {
|
||||||
|
getChecklistTab,
|
||||||
|
getDocumentsTab,
|
||||||
|
getHistoryTab,
|
||||||
|
getMaterialOverview,
|
||||||
|
getNotesTab,
|
||||||
|
getPhotosTab,
|
||||||
|
getReportsTab,
|
||||||
|
getTimesTab,
|
||||||
|
type WorkOrderDetail,
|
||||||
|
} from "@/server/services/work-orders/detail";
|
||||||
|
import { UPLOAD_CATEGORIES } from "@/server/services/work-orders/documents";
|
||||||
|
import { checklistTemplateOptions, customerDisplayName } from "@/server/services/work-orders/options";
|
||||||
|
import { allowedDocumentVisibility } from "@/server/services/work-orders/visibility";
|
||||||
|
import {
|
||||||
|
addChecklistItemAction,
|
||||||
|
addMaterialPlanAction,
|
||||||
|
addPhotoRequirementAction,
|
||||||
|
applyChecklistTemplateAction,
|
||||||
|
archiveDocumentAction,
|
||||||
|
removeChecklistItemAction,
|
||||||
|
removeMaterialPlanAction,
|
||||||
|
removePhotoRequirementAction,
|
||||||
|
} from "@/server/actions/work_orders/work-orders";
|
||||||
|
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
|
||||||
|
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
|
||||||
|
import { Check, Dl, Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
|
||||||
|
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||||
|
|
||||||
|
type TabProps = { ctx: ServiceCtx; wo: WorkOrderDetail; locale: string; tz: string; canEdit: boolean };
|
||||||
|
|
||||||
|
const Hidden = ({ name, value }: { name: string; value: string }) => <input type="hidden" name={name} value={value} />;
|
||||||
|
|
||||||
|
function address(x: { street?: string | null; houseNumber?: string | null; postalCode?: string | null; city?: string | null } | null) {
|
||||||
|
if (!x) return "";
|
||||||
|
return [[x.street, x.houseNumber].filter(Boolean).join(" "), [x.postalCode, x.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function OverviewTab({ wo, locale, tz }: TabProps) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Section title={t("overview.customer")}>
|
||||||
|
<Dl
|
||||||
|
rows={[
|
||||||
|
[t("overview.customer"), <Link key="c" href={`/customers/${wo.customer.id}`} className="font-semibold text-[var(--primary)] hover:underline">{customerDisplayName(wo.customer)}</Link>],
|
||||||
|
[t("overview.site"), wo.site ? <Link key="s" href={`/sites/${wo.site.id}`} className="text-[var(--primary)] hover:underline">{[wo.site.name, address(wo.site)].filter(Boolean).join(" · ")}</Link> : null],
|
||||||
|
[t("overview.contact"), wo.contact ? [wo.contact.name, wo.contact.phone ?? wo.contact.mobile, wo.contact.email].filter(Boolean).join(" · ") : null],
|
||||||
|
[t("overview.accessNotes"), wo.site?.accessNotes ?? null],
|
||||||
|
[t("overview.safetyNotes"), wo.site?.safetyNotes ?? null],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title={t("overview.planning")}>
|
||||||
|
<Dl
|
||||||
|
rows={[
|
||||||
|
[t("fields.orderType"), wo.orderType?.name ?? null],
|
||||||
|
[t("fields.priority"), t(`priority.${wo.priority}`)],
|
||||||
|
[t("fields.plannedStart"), formatDateTime(wo.plannedStart, locale, tz) || t("noDate")],
|
||||||
|
[t("fields.plannedEnd"), formatDateTime(wo.plannedEnd, locale, tz)],
|
||||||
|
[t("fields.signatureRequired"), wo.signatureRequired ? t("fields.yes") : t("fields.no")],
|
||||||
|
[t("fields.billingType"), wo.billingType ? t(`billingType.${wo.billingType}`) : null],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title={t("overview.assignment")}>
|
||||||
|
<Dl
|
||||||
|
rows={[
|
||||||
|
[t("overview.team"), wo.team?.name ?? t("unassigned")],
|
||||||
|
[t("overview.teamLead"), wo.teamLead?.name ?? null],
|
||||||
|
[t("overview.assignees"), wo.assignees.map((a) => a.user.name).join(", ")],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title={t("overview.order")}>
|
||||||
|
<Dl
|
||||||
|
rows={[
|
||||||
|
[t("fields.externalOrderNumber"), wo.externalOrderNumber],
|
||||||
|
[t("fields.offerNumber"), wo.offerNumber],
|
||||||
|
[t("overview.created"), formatDateTime(wo.createdAt, locale, tz)],
|
||||||
|
[t("overview.updated"), formatDateTime(wo.updatedAt, locale, tz)],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</Section>
|
||||||
|
<Section title={t("fields.description")} className="lg:col-span-2">
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{wo.description || "—"}</p>
|
||||||
|
<h3 className="mt-4 mb-1 font-heading text-sm font-semibold">{t("fields.scope")}</h3>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{wo.scope || "—"}</p>
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ChecklistTab({ ctx, wo, canEdit }: TabProps) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const [{ items, requirements, blockers }, templates] = await Promise.all([getChecklistTab(ctx, wo.id), canEdit ? checklistTemplateOptions(ctx) : Promise.resolve([])]);
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<BlockerSummary blockers={blockers} />
|
||||||
|
<Section title={t("checklist.items")}>
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Empty>{t("checklist.empty")}</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{items.map((i) => (
|
||||||
|
<li key={i.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold">
|
||||||
|
<span className={i.checked ? "text-[var(--ok)]" : "text-muted-foreground"}>{i.checked ? `✓ ${t("checklist.done")}` : `○ ${t("checklist.open")}`}</span> · {i.label}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{i.required ? t("checklist.required") : t("checklist.optional")}
|
||||||
|
{i.requiresPhoto && ` · ${t("checklist.requiresPhoto")} (${t("checklist.photoCount", { count: i._count.photos })})`}
|
||||||
|
{i.comment && ` · ${i.comment}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{canEdit && !i.checked && (
|
||||||
|
<ActionForm action={removeChecklistItemAction} submitLabel={t("checklist.remove")} variant="ghost" footerClassName="mt-0">
|
||||||
|
<Hidden name="workOrderId" value={wo.id} />
|
||||||
|
<Hidden name="itemId" value={i.id} />
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<ActionForm action={addChecklistItemAction} submitLabel={t("checklist.addItem")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
|
||||||
|
<Hidden name="workOrderId" value={wo.id} />
|
||||||
|
<Field label={t("checklist.label")} htmlFor="cl-label">
|
||||||
|
<input id="cl-label" name="label" required maxLength={200} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<div className="flex flex-wrap gap-4">
|
||||||
|
<Check name="required" id="cl-req" label={t("checklist.required")} />
|
||||||
|
<Check name="requiresPhoto" id="cl-photo" label={t("checklist.requiresPhoto")} />
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
<Section title={t("checklist.photos")}>
|
||||||
|
{requirements.length === 0 ? (
|
||||||
|
<Empty>{t("checklist.emptyPhotos")}</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{requirements.map((r) => (
|
||||||
|
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
|
||||||
|
<p className="text-sm">
|
||||||
|
<span className={r._count.photos > 0 ? "font-semibold text-[var(--ok)]" : "font-semibold text-[var(--warn)]"}>
|
||||||
|
{r._count.photos > 0 ? "✓" : "!"} {t("checklist.photoCount", { count: r._count.photos })}
|
||||||
|
</span>{" "}
|
||||||
|
· {r.label}
|
||||||
|
</p>
|
||||||
|
{canEdit && r._count.photos === 0 && (
|
||||||
|
<ActionForm action={removePhotoRequirementAction} submitLabel={t("checklist.remove")} variant="ghost" footerClassName="mt-0">
|
||||||
|
<Hidden name="workOrderId" value={wo.id} />
|
||||||
|
<Hidden name="requirementId" value={r.id} />
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<>
|
||||||
|
<ActionForm action={addPhotoRequirementAction} submitLabel={t("checklist.addPhoto")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
|
||||||
|
<Hidden name="workOrderId" value={wo.id} />
|
||||||
|
<Field label={t("checklist.label")} htmlFor="pr-label">
|
||||||
|
<input id="pr-label" name="label" required maxLength={200} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
</ActionForm>
|
||||||
|
{templates.length > 0 && (
|
||||||
|
<ActionForm action={applyChecklistTemplateAction} submitLabel={t("checklist.applyButton")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
|
||||||
|
<Hidden name="workOrderId" value={wo.id} />
|
||||||
|
<Field label={t("checklist.applyTemplate")} htmlFor="tpl">
|
||||||
|
<select id="tpl" name="templateId" className={inputCls}>
|
||||||
|
{templates.map((x) => (
|
||||||
|
<option key={x.id} value={x.id}>
|
||||||
|
{x.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function BlockerSummary({ blockers }: { blockers: CompletionBlocker[] }) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
if (blockers.length === 0) {
|
||||||
|
return <p className="rounded-lg border border-[var(--ok)] bg-card px-4 py-2 text-sm font-semibold text-[var(--ok)] lg:col-span-2">✓ {t("checklist.complete")}</p>;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm lg:col-span-2" role="status">
|
||||||
|
<p className="font-semibold text-[var(--warn)]">! {t("detail.blockersTitle")}</p>
|
||||||
|
<ul className="mt-1 list-disc pl-6">
|
||||||
|
{blockers.map((b, i) => (
|
||||||
|
<li key={i}>
|
||||||
|
{b.kind === "checklist_item"
|
||||||
|
? t("blockers.checklist_item", { label: b.label })
|
||||||
|
: b.kind === "photo_requirement"
|
||||||
|
? t("blockers.photo_requirement", { label: b.label })
|
||||||
|
: b.kind === "running_session"
|
||||||
|
? t("blockers.running_session")
|
||||||
|
: t(`blockers.field.${b.field}` as "blockers.field.signature")}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function MaterialTab({ ctx, wo, locale, canEdit }: TabProps) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const rows = await getMaterialOverview(ctx, wo.id);
|
||||||
|
const nf = new Intl.NumberFormat(locale === "en" ? "en-GB" : "de-DE", { maximumFractionDigits: 3 });
|
||||||
|
return (
|
||||||
|
<Section title={t("material.title")}>
|
||||||
|
{rows.length === 0 ? (
|
||||||
|
<Empty>{t("material.empty")}</Empty>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[720px] text-sm">
|
||||||
|
<thead className="border-b text-left text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-2 pr-3">{t("material.name")}</th>
|
||||||
|
<th className="py-2 pr-3 text-right">{t("material.planned")}</th>
|
||||||
|
<th className="py-2 pr-3 text-right">{t("material.actual")}</th>
|
||||||
|
<th className="py-2 pr-3 text-right">{t("material.deviation")}</th>
|
||||||
|
<th className="py-2 pr-3">{t("material.reason")}</th>
|
||||||
|
<th className="py-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r, idx) => (
|
||||||
|
<tr key={r.planId ?? `u${idx}`} className="border-b last:border-0 align-top">
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<p className="font-semibold">{r.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{[r.articleNumber, r.planId === null ? t("material.additional") : null, ...r.statuses.map((s) => t(`material.usage.${s}` as "material.usage.fully_used"))].filter(Boolean).join(" · ")}
|
||||||
|
</p>
|
||||||
|
{r.notes && <p className="text-xs text-muted-foreground">{r.notes}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-right whitespace-nowrap">{r.planned === null ? "—" : `${nf.format(r.planned)} ${r.unit}`}</td>
|
||||||
|
<td className="py-2 pr-3 text-right whitespace-nowrap">{r.actual === null ? <span className="text-muted-foreground">{t("material.notRecorded")}</span> : `${nf.format(r.actual)} ${r.unit}`}</td>
|
||||||
|
<td className={`py-2 pr-3 text-right font-semibold whitespace-nowrap ${r.deviation ? "text-[var(--warn)]" : ""}`}>
|
||||||
|
{r.deviation === null ? "—" : `${r.deviation > 0 ? "+" : ""}${nf.format(r.deviation)} ${r.unit}`}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-xs">{r.reasons.join("; ") || "—"}</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
{canEdit && r.planId && r.actual === null && (
|
||||||
|
<ActionForm action={removeMaterialPlanAction} submitLabel={t("material.remove")} variant="ghost" footerClassName="mt-0 justify-end">
|
||||||
|
<Hidden name="workOrderId" value={wo.id} />
|
||||||
|
<Hidden name="planId" value={r.planId} />
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<ActionForm action={addMaterialPlanAction} submitLabel={t("material.add")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
|
||||||
|
<Hidden name="workOrderId" value={wo.id} />
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
<Field label={`${t("material.name")} *`} htmlFor="m-name" className="lg:col-span-2">
|
||||||
|
<input id="m-name" name="name" required maxLength={200} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("material.articleNumber")} htmlFor="m-art">
|
||||||
|
<input id="m-art" name="articleNumber" maxLength={80} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={`${t("material.planned")} *`} htmlFor="m-qty">
|
||||||
|
<input id="m-qty" name="plannedQuantity" required inputMode="decimal" pattern="[0-9]+([.,][0-9]{1,3})?" className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={`${t("material.unit")} *`} htmlFor="m-unit">
|
||||||
|
<input id="m-unit" name="unit" required maxLength={20} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("material.notes")} htmlFor="m-notes" className="sm:col-span-2 lg:col-span-5">
|
||||||
|
<input id="m-notes" name="notes" maxLength={1000} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const sessions = await getTimesTab(ctx, wo.id);
|
||||||
|
if (sessions.length === 0) return <Empty>{t("times.empty")}</Empty>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sessions.map((s) => (
|
||||||
|
<Section key={s.id} title={`${s.user.name} · ${t(`times.sessionStatus.${s.status}`)}`} actions={<span className="text-sm font-semibold">{t("times.duration")}: {t("times.minutes", { minutes: s.workMinutes })}</span>}>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full min-w-[520px] text-sm">
|
||||||
|
<thead className="border-b text-left text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
<th className="py-1.5 pr-3">{t("columns.status")}</th>
|
||||||
|
<th className="py-1.5 pr-3">{t("times.start")}</th>
|
||||||
|
<th className="py-1.5 pr-3">{t("times.end")}</th>
|
||||||
|
<th className="py-1.5" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{s.entries.map((e) => (
|
||||||
|
<tr key={e.id} className="border-b last:border-0">
|
||||||
|
<td className="py-1.5 pr-3">{t(`times.entryType.${e.type}`)}</td>
|
||||||
|
<td className="py-1.5 pr-3">{formatDateTime(e.startedAt, locale, tz)}</td>
|
||||||
|
<td className="py-1.5 pr-3">{formatDateTime(e.endedAt, locale, tz) || "…"}</td>
|
||||||
|
<td className="py-1.5 text-xs text-[var(--warn)]">{e.corrected ? t("times.corrected", { reason: e.correctionReason ?? "" }) : ""}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PhotosTab({ ctx, wo, locale, tz }: TabProps) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const photos = await getPhotosTab(ctx, wo.id);
|
||||||
|
if (photos.length === 0) return <Empty>{t("photos.empty")}</Empty>;
|
||||||
|
return (
|
||||||
|
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{photos.map((p) => (
|
||||||
|
<li key={p.id} className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||||
|
<a href={`/files/${p.document.storageKey}`} className="block aspect-[4/3] bg-muted">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element -- tenant file route, no next/image optimisation for private files */}
|
||||||
|
<img src={`/files/${p.document.previewKey ?? p.document.storageKey}`} alt={p.comment ?? p.photoRequirement?.label ?? p.document.fileName} className="size-full object-cover" loading="lazy" />
|
||||||
|
</a>
|
||||||
|
<div className="p-2 text-xs">
|
||||||
|
<p className="flex items-center gap-1 font-semibold">
|
||||||
|
<Camera className="size-3.5" aria-hidden />
|
||||||
|
{p.photoRequirement?.label ?? p.checklistItem?.label ?? (p.phase ? t(`photos.phase.${p.phase}`) : p.document.fileName)}
|
||||||
|
</p>
|
||||||
|
{p.comment && <p className="text-muted-foreground">{p.comment}</p>}
|
||||||
|
<p className="text-muted-foreground">{formatDateTime(p.takenAt, locale, tz)}</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function NotesTab({ ctx, wo, locale, tz }: TabProps) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const notes = await getNotesTab(ctx, wo.id);
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{ctx.permissions.has("work_order:read_all") && (
|
||||||
|
<Section title={t("notes.internal")}>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{wo.internalNotes || "—"}</p>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
<Section title={t("notes.technician")}>
|
||||||
|
<p className="text-sm whitespace-pre-wrap">{wo.technicianNotes || "—"}</p>
|
||||||
|
</Section>
|
||||||
|
<Section title={t("detail.tabs.notes")} className="lg:col-span-2">
|
||||||
|
{notes.length === 0 ? (
|
||||||
|
<Empty>{t("notes.empty")}</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{notes.map((n) => (
|
||||||
|
<li key={n.id} className="py-2.5">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
<span className="font-semibold text-foreground">{t(`notes.kind.${n.kind}`)}</span> · {n.authorName ?? t("history.system")} · {formatDateTime(n.createdAt, locale, tz)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-0.5 text-sm whitespace-pre-wrap">{n.text}</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ReportsTab({ ctx, wo, locale, tz, billing }: TabProps & { billing?: React.ReactNode }) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const reports = await getReportsTab(ctx, wo.id);
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{billing && (
|
||||||
|
<Section title={t("reports.billingTitle")}>
|
||||||
|
<p className="mb-2 text-sm text-muted-foreground">{t("reports.billingHint")}</p>
|
||||||
|
{billing}
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
<Section title={t("detail.tabs.reports")}>
|
||||||
|
{reports.length === 0 ? (
|
||||||
|
<Empty>{t("reports.empty")}</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{reports.map((r) => (
|
||||||
|
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
|
||||||
|
<div>
|
||||||
|
<p className="flex items-center gap-1.5 text-sm font-semibold">
|
||||||
|
<FileText className="size-4" aria-hidden />
|
||||||
|
{t(`reports.type.${r.type}`)} · {formatDate(r.reportDate, locale, tz)} · v{r.version}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(`reports.status.${r.status}`)}
|
||||||
|
{r.signature && ` · ${t("reports.signature", { outcome: t(`reports.signatureOutcome.${r.signature.outcome}`) })}`}
|
||||||
|
{r.rejectionReason && ` · ${r.rejectionReason}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Link href={`/reports/${r.id}`} className={buttonCls("outline")}>
|
||||||
|
{t("reports.open")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DocumentsTab({ ctx, wo, locale, tz, uploadError, uploaded }: TabProps & { uploadError?: string; uploaded?: boolean }) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const docs = await getDocumentsTab(ctx, wo.id);
|
||||||
|
const canUpload = ctx.permissions.has("document:write");
|
||||||
|
const visibilities = allowedDocumentVisibility(ctx);
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
|
||||||
|
<Section title={t("detail.tabs.documents")}>
|
||||||
|
{docs.length === 0 ? (
|
||||||
|
<Empty>{t("documents.empty")}</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y">
|
||||||
|
{docs.map((d) => (
|
||||||
|
<li key={d.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-semibold">{d.title || d.fileName}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t(`documents.categories.${d.category}`)} · {t(`documents.visibilities.${d.visibility}`)} · v{d.version} · {formatDateTime(d.createdAt, locale, tz)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<a href={`/files/${d.storageKey}`} className={buttonCls("outline")}>
|
||||||
|
<Download className="size-4" aria-hidden />
|
||||||
|
{t("documents.download")}
|
||||||
|
</a>
|
||||||
|
{canUpload && (
|
||||||
|
<ActionForm action={archiveDocumentAction} submitLabel={t("documents.archive")} variant="ghost" footerClassName="mt-0">
|
||||||
|
<Hidden name="workOrderId" value={wo.id} />
|
||||||
|
<Hidden name="documentId" value={d.id} />
|
||||||
|
</ActionForm>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
{canUpload && (
|
||||||
|
<Section title={t("documents.upload")}>
|
||||||
|
{uploadError && (
|
||||||
|
<p role="alert" className="mb-3 rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)]">
|
||||||
|
{t.has(`errors.${uploadError}`) ? t(`errors.${uploadError}` as "errors.internal") : t("errors.internal")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{uploaded && <p role="status" className="mb-3 text-sm text-[var(--ok)]">✓ {t("saved")}</p>}
|
||||||
|
<form method="post" encType="multipart/form-data" action={`/api/v1/work-orders/${wo.id}/documents`} className="space-y-3">
|
||||||
|
<Field label={`${t("documents.file")} *`} htmlFor="doc-file">
|
||||||
|
<input id="doc-file" type="file" name="file" required accept="application/pdf,image/jpeg,image/png,image/webp" className={`${inputCls} py-2`} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("documents.title")} htmlFor="doc-title">
|
||||||
|
<input id="doc-title" name="title" maxLength={200} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("documents.category")} htmlFor="doc-cat">
|
||||||
|
<select id="doc-cat" name="category" defaultValue="technical_drawing" className={inputCls}>
|
||||||
|
{UPLOAD_CATEGORIES.map((c) => (
|
||||||
|
<option key={c} value={c}>
|
||||||
|
{t(`documents.categories.${c}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("documents.visibility")} htmlFor="doc-vis">
|
||||||
|
<select id="doc-vis" name="visibility" defaultValue="team" className={inputCls}>
|
||||||
|
{visibilities.map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{t(`documents.visibilities.${v}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<button type="submit" className={buttonCls("default")}>
|
||||||
|
{t("documents.submit")}
|
||||||
|
</button>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("documents.stubHint")}</p>
|
||||||
|
</form>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function HistoryTab({ ctx, wo, locale, tz }: TabProps) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const { changes, audit } = await getHistoryTab(ctx, wo.id);
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Section title={t("history.statusChanges")}>
|
||||||
|
{changes.length === 0 ? (
|
||||||
|
<Empty>{t("history.empty")}</Empty>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{changes.map((c) => (
|
||||||
|
<li key={c.id} className="border-l-2 pl-3 text-sm">
|
||||||
|
<p className="font-semibold">
|
||||||
|
{c.fromStatus ? t("history.change", { from: t(`status.${c.fromStatus}`), to: t(`status.${c.toStatus}`) }) : t("history.created", { status: t(`status.${c.toStatus}`) })}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(c.createdAt, locale, tz)} · {t("history.by", { name: c.actorName ?? t("history.system") })}
|
||||||
|
</p>
|
||||||
|
{c.reason && <p className="text-xs">{c.reason}</p>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
{audit.length > 0 && (
|
||||||
|
<Section title={t("history.audit")}>
|
||||||
|
<ol className="space-y-2">
|
||||||
|
{audit.map((a) => {
|
||||||
|
const after = (a.after ?? {}) as Record<string, unknown>;
|
||||||
|
const summary = typeof after.op === "string" ? after.op : Object.keys(after).filter((k) => k !== "version").join(", ");
|
||||||
|
return (
|
||||||
|
<li key={a.id} className="border-l-2 pl-3 text-sm">
|
||||||
|
<p className="font-semibold">
|
||||||
|
{t.has(`history.action.${a.action}`) ? t(`history.action.${a.action}` as "history.action.update") : a.action}
|
||||||
|
{summary && <span className="font-normal text-muted-foreground"> · {summary}</span>}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatDateTime(a.createdAt, locale, tz)} · {t("history.by", { name: a.actorName ?? t("history.system") })}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</Section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { getLocale } from "next-intl/server";
|
||||||
|
import { requireSession } from "@/server/auth";
|
||||||
|
import { dbForTenant } from "@/server/db";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-path context for server-rendered pages of the work order module. Permissions come from
|
||||||
|
* the session (read paths; AGENTS.md) — every mutation re-checks DB-authoritatively via moduleGuard.
|
||||||
|
*/
|
||||||
|
export async function pageContext() {
|
||||||
|
const session = await requireSession();
|
||||||
|
const ctx: ServiceCtx = {
|
||||||
|
db: dbForTenant(session.user.tenantId),
|
||||||
|
tenantId: session.user.tenantId,
|
||||||
|
userId: session.user.id,
|
||||||
|
permissions: new Set(session.user.permissions ?? []),
|
||||||
|
};
|
||||||
|
const [locale, tz] = await Promise.all([getLocale(), tenantTimezone(ctx)]);
|
||||||
|
return { session, ctx, locale, tz, can: (p: string) => ctx.permissions.has(p) };
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { pageContext } from "@/components/work-orders/page-context";
|
||||||
|
import { LinkTabs } from "@/components/work-orders/ui";
|
||||||
|
|
||||||
|
/** Access check for the template settings pages (`settings:templates`). */
|
||||||
|
export async function templatesPageContext() {
|
||||||
|
const pc = await pageContext();
|
||||||
|
if (!pc.can("settings:templates")) redirect("/dashboard");
|
||||||
|
return pc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function SettingsTemplatesNav({ active }: { active: "order-types" | "checklists" | "numbering" }) {
|
||||||
|
const t = await getTranslations("settingsTemplates");
|
||||||
|
return (
|
||||||
|
<div className="mb-4">
|
||||||
|
<LinkTabs
|
||||||
|
label={t("crumb")}
|
||||||
|
items={[
|
||||||
|
{ href: "/settings/order-types", label: t("nav.orderTypes"), active: active === "order-types" },
|
||||||
|
{ href: "/settings/checklists", label: t("nav.checklists"), active: active === "checklists" },
|
||||||
|
{ href: "/settings/numbering", label: t("nav.numbering"), active: active === "numbering" },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
Ban,
|
||||||
|
CalendarClock,
|
||||||
|
CheckCheck,
|
||||||
|
CircleDashed,
|
||||||
|
ClipboardCheck,
|
||||||
|
Receipt,
|
||||||
|
TriangleAlert,
|
||||||
|
Truck,
|
||||||
|
Wrench,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { STATUS_GROUP, STATUS_GROUP_TONE, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Presentational building blocks of the work order module (server-safe, no hooks).
|
||||||
|
* Colours only via CSS tokens; status is always text + icon, never colour alone.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const TONE_VAR: Record<(typeof STATUS_GROUP_TONE)[StatusGroup], string> = {
|
||||||
|
neutral: "var(--txt-muted)",
|
||||||
|
info: "var(--info)",
|
||||||
|
accent: "var(--ui-accent)",
|
||||||
|
warning: "var(--warn)",
|
||||||
|
success: "var(--ok)",
|
||||||
|
danger: "var(--risk)",
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUP_ICON: Record<StatusGroup, LucideIcon> = {
|
||||||
|
new: CircleDashed,
|
||||||
|
planned: CalendarClock,
|
||||||
|
en_route: Truck,
|
||||||
|
in_progress: Wrench,
|
||||||
|
documentation_incomplete: TriangleAlert,
|
||||||
|
in_review: ClipboardCheck,
|
||||||
|
ready_for_billing: Receipt,
|
||||||
|
billed: CheckCheck,
|
||||||
|
cancelled: Ban,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function statusColor(status: WorkOrderStatus): string {
|
||||||
|
return TONE_VAR[STATUS_GROUP_TONE[STATUS_GROUP[status]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBadge({ status, label, className }: { status: WorkOrderStatus; label: string; className?: string }) {
|
||||||
|
const Icon = GROUP_ICON[STATUS_GROUP[status]];
|
||||||
|
const color = statusColor(status);
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={cn("inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap", className)}
|
||||||
|
style={{ color, borderColor: color, background: `color-mix(in oklch, ${color} 10%, transparent)` }}
|
||||||
|
>
|
||||||
|
<Icon className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GroupIcon({ group, className }: { group: StatusGroup; className?: string }) {
|
||||||
|
const Icon = GROUP_ICON[group];
|
||||||
|
return <Icon className={className} aria-hidden />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const inputCls =
|
||||||
|
"min-h-11 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
|
||||||
|
|
||||||
|
export function Field({ label, htmlFor, hint, className, children }: { label: string; htmlFor?: string; hint?: string; className?: string; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<label htmlFor={htmlFor} className="mb-1 block text-[13px] font-semibold">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
{children}
|
||||||
|
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Check({ name, label, defaultChecked, value, id }: { name: string; label: string; defaultChecked?: boolean; value?: string; id?: string }) {
|
||||||
|
return (
|
||||||
|
<label htmlFor={id} className="flex min-h-11 cursor-pointer items-center gap-2.5 text-sm">
|
||||||
|
<input id={id} type="checkbox" name={name} value={value} defaultChecked={defaultChecked} className="size-5 accent-[var(--ui-primary)]" />
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TabItem = { href: string; label: string; active: boolean; count?: number; icon?: React.ReactNode };
|
||||||
|
|
||||||
|
/** Link-based tabs (server rendering, deep-linkable, keyboard accessible). */
|
||||||
|
export function LinkTabs({ items, label }: { items: TabItem[]; label: string }) {
|
||||||
|
return (
|
||||||
|
<nav aria-label={label} className="-mx-1 overflow-x-auto">
|
||||||
|
<ul className="flex min-w-max gap-1 border-b px-1">
|
||||||
|
{items.map((i) => (
|
||||||
|
<li key={i.href}>
|
||||||
|
<Link
|
||||||
|
href={i.href}
|
||||||
|
scroll={false}
|
||||||
|
aria-current={i.active ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"-mb-px inline-flex min-h-11 items-center gap-1.5 border-b-2 px-3 text-[13px] font-semibold whitespace-nowrap transition-colors",
|
||||||
|
i.active ? "border-[var(--ui-accent)] text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{i.icon}
|
||||||
|
{i.label}
|
||||||
|
{i.count !== undefined && <span className="rounded-full bg-muted px-1.5 text-[11px] text-muted-foreground">{i.count}</span>}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Section({ title, actions, children, className }: { title?: string; actions?: React.ReactNode; children: React.ReactNode; className?: string }) {
|
||||||
|
return (
|
||||||
|
<section className={cn("shadow-card rounded-xl border bg-card p-4 md:p-5", className)}>
|
||||||
|
{(title || actions) && (
|
||||||
|
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
|
||||||
|
{title && <h2 className="font-heading text-[15px] font-semibold">{title}</h2>}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Empty({ children }: { children: React.ReactNode }) {
|
||||||
|
return <p className="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dl({ rows }: { rows: [string, React.ReactNode][] }) {
|
||||||
|
return (
|
||||||
|
<dl className="grid grid-cols-[minmax(110px,auto)_1fr] gap-x-4 gap-y-1.5 text-sm">
|
||||||
|
{rows.map(([k, v]) => (
|
||||||
|
<div key={k} className="contents">
|
||||||
|
<dt className="text-muted-foreground">{k}</dt>
|
||||||
|
<dd className="min-w-0 break-words">{v || "—"}</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { BILLING_TYPES, WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
|
||||||
|
import { toWallTimeInput } from "@/lib/work-orders/time";
|
||||||
|
import { Field, inputCls } from "@/components/work-orders/ui";
|
||||||
|
|
||||||
|
type Option = { id: string; name: string };
|
||||||
|
|
||||||
|
export type WorkOrderDefaults = {
|
||||||
|
title?: string;
|
||||||
|
siteId?: string | null;
|
||||||
|
contactId?: string | null;
|
||||||
|
orderTypeId?: string | null;
|
||||||
|
priority?: string;
|
||||||
|
plannedStart?: Date | null;
|
||||||
|
plannedEnd?: Date | null;
|
||||||
|
description?: string | null;
|
||||||
|
scope?: string | null;
|
||||||
|
internalNotes?: string | null;
|
||||||
|
technicianNotes?: string | null;
|
||||||
|
signatureRequired?: boolean;
|
||||||
|
billingType?: string | null;
|
||||||
|
externalOrderNumber?: string | null;
|
||||||
|
offerNumber?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Shared fields of the create and edit popups (server-rendered, posted to server actions). */
|
||||||
|
export async function WorkOrderFields({
|
||||||
|
mode,
|
||||||
|
sites,
|
||||||
|
contacts,
|
||||||
|
orderTypes,
|
||||||
|
tz,
|
||||||
|
defaults = {},
|
||||||
|
}: {
|
||||||
|
mode: "create" | "edit";
|
||||||
|
sites: Option[];
|
||||||
|
contacts: Option[];
|
||||||
|
orderTypes: Option[];
|
||||||
|
tz: string;
|
||||||
|
defaults?: WorkOrderDefaults;
|
||||||
|
}) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
const d = defaults;
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<Field label={`${t("fields.title")} *`} htmlFor="wo-title" className="md:col-span-2">
|
||||||
|
<input id="wo-title" name="title" required maxLength={200} defaultValue={d.title ?? ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.site")} htmlFor="wo-site">
|
||||||
|
<select id="wo-site" name="siteId" defaultValue={d.siteId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("fields.none")}</option>
|
||||||
|
{sites.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{s.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.contact")} htmlFor="wo-contact">
|
||||||
|
<select id="wo-contact" name="contactId" defaultValue={d.contactId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("fields.none")}</option>
|
||||||
|
{contacts.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.orderType")} htmlFor="wo-type">
|
||||||
|
<select id="wo-type" name="orderTypeId" defaultValue={d.orderTypeId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("fields.none")}</option>
|
||||||
|
{orderTypes.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.priority")} htmlFor="wo-priority">
|
||||||
|
<select id="wo-priority" name="priority" defaultValue={d.priority ?? "normal"} className={inputCls}>
|
||||||
|
{WORK_ORDER_PRIORITIES.map((p) => (
|
||||||
|
<option key={p} value={p}>
|
||||||
|
{t(`priority.${p}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.plannedStart")} htmlFor="wo-start" hint={t("fields.timezoneHint", { tz })}>
|
||||||
|
<input id="wo-start" type="datetime-local" name="plannedStart" defaultValue={toWallTimeInput(d.plannedStart, tz)} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.plannedEnd")} htmlFor="wo-end">
|
||||||
|
<input id="wo-end" type="datetime-local" name="plannedEnd" defaultValue={toWallTimeInput(d.plannedEnd, tz)} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.description")} htmlFor="wo-desc" className="md:col-span-2">
|
||||||
|
<textarea id="wo-desc" name="description" rows={3} maxLength={10000} defaultValue={d.description ?? ""} className={`${inputCls} py-2`} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.scope")} htmlFor="wo-scope" className="md:col-span-2">
|
||||||
|
<textarea id="wo-scope" name="scope" rows={3} maxLength={10000} defaultValue={d.scope ?? ""} className={`${inputCls} py-2`} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.technicianNotes")} htmlFor="wo-tech">
|
||||||
|
<textarea id="wo-tech" name="technicianNotes" rows={3} maxLength={5000} defaultValue={d.technicianNotes ?? ""} className={`${inputCls} py-2`} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.internalNotes")} htmlFor="wo-internal">
|
||||||
|
<textarea id="wo-internal" name="internalNotes" rows={3} maxLength={5000} defaultValue={d.internalNotes ?? ""} className={`${inputCls} py-2`} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.signatureRequired")} htmlFor="wo-sig">
|
||||||
|
<select
|
||||||
|
id="wo-sig"
|
||||||
|
name="signatureRequired"
|
||||||
|
defaultValue={mode === "create" ? "" : d.signatureRequired === false ? "no" : "yes"}
|
||||||
|
className={inputCls}
|
||||||
|
>
|
||||||
|
{mode === "create" && <option value="">{t("fields.signatureFromType")}</option>}
|
||||||
|
<option value="yes">{t("fields.yes")}</option>
|
||||||
|
<option value="no">{t("fields.no")}</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.billingType")} htmlFor="wo-billing">
|
||||||
|
<select id="wo-billing" name="billingType" defaultValue={d.billingType ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("fields.none")}</option>
|
||||||
|
{BILLING_TYPES.map((b) => (
|
||||||
|
<option key={b} value={b}>
|
||||||
|
{t(`billingType.${b}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.externalOrderNumber")} htmlFor="wo-ext">
|
||||||
|
<input id="wo-ext" name="externalOrderNumber" maxLength={80} defaultValue={d.externalOrderNumber ?? ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("fields.offerNumber")} htmlFor="wo-offer">
|
||||||
|
<input id="wo-offer" name="offerNumber" maxLength={80} defaultValue={d.offerNumber ?? ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
{mode === "create" && (
|
||||||
|
<Field label={t("fields.status")} htmlFor="wo-status">
|
||||||
|
<select id="wo-status" name="status" defaultValue="draft" className={inputCls}>
|
||||||
|
<option value="draft">{t("status.draft")}</option>
|
||||||
|
<option value="planned">{t("status.planned")}</option>
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { MapPin, Siren, UsersRound } from "lucide-react";
|
||||||
|
import type { WorkOrderListItem } from "@/server/services/work-orders/list";
|
||||||
|
import { customerDisplayName } from "@/server/services/work-orders/options";
|
||||||
|
import { formatDateTime } from "@/lib/work-orders/time";
|
||||||
|
import { StatusBadge, statusColor } from "@/components/work-orders/ui";
|
||||||
|
|
||||||
|
function planned(i: WorkOrderListItem, locale: string, tz: string, noDate: string) {
|
||||||
|
if (!i.plannedStart) return noDate;
|
||||||
|
const start = formatDateTime(i.plannedStart, locale, tz);
|
||||||
|
return i.plannedEnd ? `${start} – ${formatDateTime(i.plannedEnd, locale, tz)}` : start;
|
||||||
|
}
|
||||||
|
|
||||||
|
function siteLine(i: WorkOrderListItem) {
|
||||||
|
if (!i.site) return "";
|
||||||
|
const addr = [[i.site.street, i.site.houseNumber].filter(Boolean).join(" "), i.site.city].filter(Boolean).join(", ");
|
||||||
|
return addr ? `${i.site.name} · ${addr}` : i.site.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cards with status edge (Brandbook §12.2). */
|
||||||
|
export async function WorkOrderCards({ items, locale, tz }: { items: WorkOrderListItem[]; locale: string; tz: string }) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
return (
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
{items.map((i) => (
|
||||||
|
<li key={i.id}>
|
||||||
|
<Link
|
||||||
|
href={`/work-orders/${i.id}`}
|
||||||
|
className="shadow-card block h-full rounded-xl border border-l-4 bg-card p-4 transition-colors hover:bg-muted/40 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
|
||||||
|
style={{ borderLeftColor: statusColor(i.status) }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span className="font-mono text-xs text-muted-foreground">{i.number}</span>
|
||||||
|
<StatusBadge status={i.status} label={t(`status.${i.status}`)} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1.5 font-heading text-[15px] font-semibold text-foreground">{i.title}</p>
|
||||||
|
<p className="mt-0.5 text-[13px]">{customerDisplayName(i.customer)}</p>
|
||||||
|
{i.site && (
|
||||||
|
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
<MapPin className="size-3.5 shrink-0" aria-hidden />
|
||||||
|
{siteLine(i)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||||
|
<span>{planned(i, locale, tz, t("noDate"))}</span>
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<UsersRound className="size-3.5" aria-hidden />
|
||||||
|
{i.team?.name ?? t("unassigned")}
|
||||||
|
</span>
|
||||||
|
{i.priority !== "normal" && <span className="font-semibold text-foreground">{t(`priority.${i.priority}`)}</span>}
|
||||||
|
{i.isEmergency && (
|
||||||
|
<span className="flex items-center gap-1 font-semibold text-[var(--risk)]">
|
||||||
|
<Siren className="size-3.5" aria-hidden />
|
||||||
|
{t("emergency")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function WorkOrderTable({ items, locale, tz }: { items: WorkOrderListItem[]; locale: string; tz: string }) {
|
||||||
|
const t = await getTranslations("workOrders");
|
||||||
|
return (
|
||||||
|
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
|
||||||
|
<table className="w-full min-w-[860px] text-sm">
|
||||||
|
<thead className="border-b bg-muted/40 text-left text-xs text-muted-foreground">
|
||||||
|
<tr>
|
||||||
|
{(["number", "title", "customer", "site", "planned", "team", "priority", "status"] as const).map((c) => (
|
||||||
|
<th key={c} scope="col" className="px-3 py-2.5 font-semibold">
|
||||||
|
{t(`columns.${c}`)}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((i) => (
|
||||||
|
<tr key={i.id} className="border-b last:border-0 hover:bg-muted/30">
|
||||||
|
<td className="border-l-4 px-3 py-2.5 font-mono text-xs" style={{ borderLeftColor: statusColor(i.status) }}>
|
||||||
|
<Link href={`/work-orders/${i.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--primary)] hover:underline">
|
||||||
|
{i.number}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
{i.title}
|
||||||
|
{i.isEmergency && <span className="ml-2 text-xs font-semibold text-[var(--risk)]">{t("emergency")}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2.5">{customerDisplayName(i.customer)}</td>
|
||||||
|
<td className="px-3 py-2.5 text-muted-foreground">{siteLine(i)}</td>
|
||||||
|
<td className="px-3 py-2.5 text-xs">{planned(i, locale, tz, t("noDate"))}</td>
|
||||||
|
<td className="px-3 py-2.5">{i.team?.name ?? t("unassigned")}</td>
|
||||||
|
<td className="px-3 py-2.5">{t(`priority.${i.priority}`)}</td>
|
||||||
|
<td className="px-3 py-2.5">
|
||||||
|
<StatusBadge status={i.status} label={t(`status.${i.status}`)} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Bell,
|
Bell,
|
||||||
History,
|
History,
|
||||||
Mail,
|
Mail,
|
||||||
|
ListChecks,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ModuleKey } from "@/lib/modules";
|
import type { ModuleKey } from "@/lib/modules";
|
||||||
@@ -53,6 +54,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
{ href: "/reports", label: "reports", icon: FileText, module: "reports", permissions: ["report:read"], section: "main" },
|
{ href: "/reports", label: "reports", icon: FileText, module: "reports", permissions: ["report:read"], section: "main" },
|
||||||
{ href: "/documents", label: "documents", icon: FolderOpen, module: "documents", permissions: ["document:read"], section: "main" },
|
{ href: "/documents", label: "documents", icon: FolderOpen, module: "documents", permissions: ["document:read"], section: "main" },
|
||||||
{ href: "/notifications", label: "notifications", icon: Bell, module: "notifications", permissions: ["notification:read"], section: "main" },
|
{ href: "/notifications", label: "notifications", icon: Bell, module: "notifications", permissions: ["notification:read"], section: "main" },
|
||||||
|
{ href: "/settings/order-types", label: "templates", icon: ListChecks, permissions: ["settings:templates"], section: "admin" },
|
||||||
{ href: "/settings", label: "settings", icon: Settings, permissions: ["tenant:manage"], section: "admin" },
|
{ href: "/settings", label: "settings", icon: Settings, permissions: ["tenant:manage"], section: "admin" },
|
||||||
{ href: "/settings/email", label: "email", icon: Mail, module: "notifications", permissions: ["tenant:manage"], section: "admin" },
|
{ href: "/settings/email", label: "email", icon: Mail, module: "notifications", permissions: ["tenant:manage"], section: "admin" },
|
||||||
{ href: "/settings/audit", label: "audit", icon: History, permissions: ["audit:read"], section: "admin" },
|
{ href: "/settings/audit", label: "audit", icon: History, permissions: ["audit:read"], section: "admin" },
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||||
|
|
||||||
|
/** Result of a work order server action (client-safe; rendered by components/work-orders/action-form.tsx). */
|
||||||
|
export type ActionState =
|
||||||
|
| { status: "idle" }
|
||||||
|
| { status: "ok"; at: number }
|
||||||
|
| {
|
||||||
|
status: "error";
|
||||||
|
/** ServiceError code: not_found | forbidden | invalid | conflict | blocked | internal */
|
||||||
|
code: string;
|
||||||
|
/** message key below `errors.` (falls back to the code) */
|
||||||
|
message: string;
|
||||||
|
blockers?: CompletionBlocker[];
|
||||||
|
at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const IDLE_STATE: ActionState = { status: "idle" };
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import type { TemplateItem, TemplatePhoto } from "@/lib/work-orders/schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant defaults (client-safe data). Labels are German master data (stored per tenant and
|
||||||
|
* editable afterwards), not UI chrome — therefore not part of the message catalogue.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Mirrors the defaults of src/server/services/numbering.ts (used before a sequence row exists). */
|
||||||
|
export const DEFAULT_NUMBER_PREFIX: Record<"customer" | "work_order" | "emergency" | "report", string> = {
|
||||||
|
customer: "K-",
|
||||||
|
work_order: "A-",
|
||||||
|
emergency: "N-",
|
||||||
|
report: "B-",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Spec §10.2 — created by ensureDefaultOrderTypes on first use per tenant. */
|
||||||
|
export const DEFAULT_ORDER_TYPES: ReadonlyArray<{ key: string; name: string; signatureRequired: boolean; sortOrder: number }> = [
|
||||||
|
{ key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10 },
|
||||||
|
{ key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20 },
|
||||||
|
{ key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30 },
|
||||||
|
{ key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40 },
|
||||||
|
{ key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50 },
|
||||||
|
{ key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60 },
|
||||||
|
{ key: "abnahme", name: "Abnahme", signatureRequired: true, sortOrder: 70 },
|
||||||
|
{ key: "nacharbeit", name: "Nacharbeit", signatureRequired: true, sortOrder: 80 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Spec §12.4 — suggested checklist items. */
|
||||||
|
export const DEFAULT_CHECKLIST_ITEMS: readonly TemplateItem[] = [
|
||||||
|
{ key: "spannungsfrei", label: "Anlage spannungsfrei geschaltet", required: true, requiresPhoto: false },
|
||||||
|
{ key: "arbeitsbereich_abgesichert", label: "Arbeitsbereich abgesichert", required: true, requiresPhoto: false },
|
||||||
|
{ key: "material_geprueft", label: "Material geprüft", required: false, requiresPhoto: false },
|
||||||
|
{ key: "funktionspruefung", label: "Funktionsprüfung durchgeführt", required: true, requiresPhoto: true },
|
||||||
|
{ key: "arbeitsbereich_gereinigt", label: "Arbeitsbereich gereinigt", required: true, requiresPhoto: false },
|
||||||
|
{ key: "kunde_eingewiesen", label: "Kunde eingewiesen", required: false, requiresPhoto: false },
|
||||||
|
{ key: "pflichtfotos_erstellt", label: "Pflichtfotos erstellt", required: true, requiresPhoto: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Spec §14.2 — suggested required photos (keys match PhotoRequirement.key catalogue). */
|
||||||
|
export const DEFAULT_REQUIRED_PHOTOS: readonly TemplatePhoto[] = [
|
||||||
|
{ key: "ausgangszustand", label: "Ausgangszustand" },
|
||||||
|
{ key: "typenschild", label: "Typenschild" },
|
||||||
|
{ key: "leitungsverlauf", label: "Leitungsverlauf" },
|
||||||
|
{ key: "zwischenschritt", label: "Zwischenschritt" },
|
||||||
|
{ key: "fertige_montage", label: "Fertige Montage" },
|
||||||
|
{ key: "funktionspruefung", label: "Funktionsprüfung" },
|
||||||
|
{ key: "arbeitsbereich_abschluss", label: "Arbeitsbereich nach Abschluss" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import { WORK_ORDER_STATUSES, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { WORK_ORDER_PRIORITIES, type WorkOrderPriority } from "@/lib/work-orders/schemas";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List/dashboard filter model (spec §21) and its URL representation (client-safe).
|
||||||
|
* The same parser feeds /work-orders, /dashboard and GET /api/v1/work-orders.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const STATUS_GROUPS: readonly StatusGroup[] = [
|
||||||
|
"new",
|
||||||
|
"planned",
|
||||||
|
"en_route",
|
||||||
|
"in_progress",
|
||||||
|
"documentation_incomplete",
|
||||||
|
"in_review",
|
||||||
|
"ready_for_billing",
|
||||||
|
"billed",
|
||||||
|
"cancelled",
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Dashboard tiles (§21) — each tile links to the list with `preset=<key>`. */
|
||||||
|
export const PRESETS = [
|
||||||
|
"open",
|
||||||
|
"today",
|
||||||
|
"running",
|
||||||
|
"not_accepted",
|
||||||
|
"overdue",
|
||||||
|
"reports_in_review",
|
||||||
|
"completed",
|
||||||
|
"billing",
|
||||||
|
"emergency_new",
|
||||||
|
"missing_signatures",
|
||||||
|
] as const;
|
||||||
|
export type Preset = (typeof PRESETS)[number];
|
||||||
|
|
||||||
|
export const SORT_FIELDS = ["plannedStart", "createdAt", "updatedAt", "number", "priority", "status"] as const;
|
||||||
|
export type SortField = (typeof SORT_FIELDS)[number];
|
||||||
|
|
||||||
|
export type WorkOrderFilter = {
|
||||||
|
from?: Date;
|
||||||
|
to?: Date;
|
||||||
|
customerId?: string;
|
||||||
|
siteId?: string;
|
||||||
|
teamId?: string;
|
||||||
|
userId?: string;
|
||||||
|
statuses?: WorkOrderStatus[];
|
||||||
|
group?: StatusGroup;
|
||||||
|
orderTypeId?: string;
|
||||||
|
priority?: WorkOrderPriority;
|
||||||
|
preset?: Preset;
|
||||||
|
q?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ListParams = WorkOrderFilter & {
|
||||||
|
sort: SortField;
|
||||||
|
dir: "asc" | "desc";
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Raw = Record<string, string | string[] | undefined> | URLSearchParams;
|
||||||
|
|
||||||
|
function get(raw: Raw, key: string): string | undefined {
|
||||||
|
const v = raw instanceof URLSearchParams ? raw.get(key) ?? undefined : raw[key];
|
||||||
|
const s = Array.isArray(v) ? v[0] : v;
|
||||||
|
return s && s.trim() ? s.trim() : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function date(v: string | undefined, endOfDay = false): Date | undefined {
|
||||||
|
if (!v || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return undefined;
|
||||||
|
const d = new Date(`${v}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}`);
|
||||||
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cuidLike = (v: string | undefined) => (v && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : undefined);
|
||||||
|
|
||||||
|
export function parseListParams(raw: Raw): ListParams {
|
||||||
|
const statusRaw = get(raw, "status");
|
||||||
|
const statuses = statusRaw
|
||||||
|
?.split(",")
|
||||||
|
.filter((s): s is WorkOrderStatus => (WORK_ORDER_STATUSES as readonly string[]).includes(s));
|
||||||
|
const group = get(raw, "group");
|
||||||
|
const priority = get(raw, "priority");
|
||||||
|
const preset = get(raw, "preset");
|
||||||
|
const sort = get(raw, "sort");
|
||||||
|
const q = get(raw, "q");
|
||||||
|
return {
|
||||||
|
from: date(get(raw, "from")),
|
||||||
|
to: date(get(raw, "to"), true),
|
||||||
|
customerId: cuidLike(get(raw, "customerId")),
|
||||||
|
siteId: cuidLike(get(raw, "siteId")),
|
||||||
|
teamId: cuidLike(get(raw, "teamId")),
|
||||||
|
userId: cuidLike(get(raw, "userId")),
|
||||||
|
statuses: statuses?.length ? statuses : undefined,
|
||||||
|
group: group && (STATUS_GROUPS as readonly string[]).includes(group) ? (group as StatusGroup) : undefined,
|
||||||
|
orderTypeId: cuidLike(get(raw, "orderTypeId")),
|
||||||
|
priority: priority && (WORK_ORDER_PRIORITIES as readonly string[]).includes(priority) ? (priority as WorkOrderPriority) : undefined,
|
||||||
|
preset: preset && (PRESETS as readonly string[]).includes(preset) ? (preset as Preset) : undefined,
|
||||||
|
q: q ? q.slice(0, 100) : undefined,
|
||||||
|
sort: sort && (SORT_FIELDS as readonly string[]).includes(sort) ? (sort as SortField) : "plannedStart",
|
||||||
|
dir: get(raw, "dir") === "desc" ? "desc" : "asc",
|
||||||
|
page: Math.max(1, Math.min(10_000, Number.parseInt(get(raw, "page") ?? "1", 10) || 1)),
|
||||||
|
pageSize: Math.max(1, Math.min(100, Number.parseInt(get(raw, "pageSize") ?? "25", 10) || 25)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Serialise filter params back into a query string (drops empty values). */
|
||||||
|
export function toQuery(params: Partial<Record<keyof ListParams | "view" | "new" | "status", unknown>>): string {
|
||||||
|
const sp = new URLSearchParams();
|
||||||
|
for (const [k, v] of Object.entries(params)) {
|
||||||
|
if (v === undefined || v === null || v === "") continue;
|
||||||
|
if (v instanceof Date) sp.set(k, isoDay(v));
|
||||||
|
else if (Array.isArray(v)) {
|
||||||
|
if (v.length) sp.set(k, v.join(","));
|
||||||
|
} else sp.set(k, String(v));
|
||||||
|
}
|
||||||
|
const s = sp.toString();
|
||||||
|
return s ? `?${s}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoDay(d: Date): string {
|
||||||
|
const y = d.getFullYear();
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(d.getDate()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zod input schemas of the work order module (client-safe). Services parse with these;
|
||||||
|
* server actions and /api/v1 handlers only map FormData/JSON onto them.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const WORK_ORDER_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
|
||||||
|
export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITIES)[number];
|
||||||
|
|
||||||
|
export const BILLING_TYPES = ["fixed", "time_material", "maintenance_contract", "warranty"] as const;
|
||||||
|
export type BillingType = (typeof BILLING_TYPES)[number];
|
||||||
|
|
||||||
|
const id = z.string().min(1).max(64);
|
||||||
|
const optId = id.nullish();
|
||||||
|
const text = (max: number) => z.string().trim().max(max);
|
||||||
|
const optText = (max: number) =>
|
||||||
|
z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(max)
|
||||||
|
.nullish()
|
||||||
|
.transform((v) => (v ? v : null));
|
||||||
|
const optDate = z.coerce.date().nullish();
|
||||||
|
|
||||||
|
export const materialPlanInputSchema = z.object({
|
||||||
|
name: text(200).min(1),
|
||||||
|
articleNumber: optText(80),
|
||||||
|
plannedQuantity: z.coerce.number().positive().max(1_000_000),
|
||||||
|
unit: text(20).min(1),
|
||||||
|
notes: optText(1000),
|
||||||
|
sortOrder: z.coerce.number().int().min(0).max(10_000).optional(),
|
||||||
|
});
|
||||||
|
export type MaterialPlanInput = z.input<typeof materialPlanInputSchema>;
|
||||||
|
|
||||||
|
export const checklistItemInputSchema = z.object({
|
||||||
|
key: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(60)
|
||||||
|
.regex(/^[a-z0-9_]+$/)
|
||||||
|
.optional(),
|
||||||
|
label: text(200).min(1),
|
||||||
|
required: z.boolean().default(false),
|
||||||
|
requiresPhoto: z.boolean().default(false),
|
||||||
|
sortOrder: z.coerce.number().int().min(0).max(10_000).optional(),
|
||||||
|
});
|
||||||
|
export type ChecklistItemInput = z.input<typeof checklistItemInputSchema>;
|
||||||
|
|
||||||
|
export const photoRequirementInputSchema = z.object({
|
||||||
|
key: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(60)
|
||||||
|
.regex(/^[a-z0-9_]+$/)
|
||||||
|
.optional(),
|
||||||
|
label: text(200).min(1),
|
||||||
|
sortOrder: z.coerce.number().int().min(0).max(10_000).optional(),
|
||||||
|
});
|
||||||
|
export type PhotoRequirementInput = z.input<typeof photoRequirementInputSchema>;
|
||||||
|
|
||||||
|
/** Statuses a work order may be created in (import → review_required, emergency → in_progress via L8). */
|
||||||
|
export const INITIAL_STATUSES = ["draft", "review_required", "planned", "in_progress"] as const;
|
||||||
|
|
||||||
|
export const createWorkOrderSchema = z
|
||||||
|
.object({
|
||||||
|
title: text(200).min(1),
|
||||||
|
customerId: id,
|
||||||
|
siteId: optId,
|
||||||
|
contactId: optId,
|
||||||
|
orderTypeId: optId,
|
||||||
|
priority: z.enum(WORK_ORDER_PRIORITIES).default("normal"),
|
||||||
|
status: z.enum(INITIAL_STATUSES).default("draft"),
|
||||||
|
description: optText(10_000),
|
||||||
|
scope: optText(10_000),
|
||||||
|
plannedStart: optDate,
|
||||||
|
plannedEnd: optDate,
|
||||||
|
/** undefined → taken from the order type (default true) */
|
||||||
|
signatureRequired: z.boolean().optional(),
|
||||||
|
billingType: z.enum(BILLING_TYPES).nullish(),
|
||||||
|
internalNotes: optText(5000),
|
||||||
|
technicianNotes: optText(5000),
|
||||||
|
externalOrderNumber: optText(80),
|
||||||
|
offerNumber: optText(80),
|
||||||
|
isEmergency: z.boolean().default(false),
|
||||||
|
emergencyReason: optText(2000),
|
||||||
|
sourceImportId: optId,
|
||||||
|
/** "emergency" allocates from the N- sequence (lane emergency). */
|
||||||
|
numberKey: z.enum(["work_order", "emergency"]).default("work_order"),
|
||||||
|
/** Copy checklist / photo requirements from the order type's active template (default true). */
|
||||||
|
applyTemplate: z.boolean().default(true),
|
||||||
|
materials: z.array(materialPlanInputSchema).max(200).optional(),
|
||||||
|
checklistItems: z.array(checklistItemInputSchema).max(200).optional(),
|
||||||
|
photoRequirements: z.array(photoRequirementInputSchema).max(50).optional(),
|
||||||
|
})
|
||||||
|
.refine((v) => !v.plannedStart || !v.plannedEnd || v.plannedEnd >= v.plannedStart, {
|
||||||
|
path: ["plannedEnd"],
|
||||||
|
message: "plannedEnd_before_start",
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Input type of createWorkOrder — used by lanes imports (L3) and emergency (L8). */
|
||||||
|
export type CreateWorkOrderInput = z.input<typeof createWorkOrderSchema>;
|
||||||
|
|
||||||
|
export const updateWorkOrderSchema = z
|
||||||
|
.object({
|
||||||
|
title: text(200).min(1).optional(),
|
||||||
|
customerId: id.optional(),
|
||||||
|
siteId: optId,
|
||||||
|
contactId: optId,
|
||||||
|
orderTypeId: optId,
|
||||||
|
priority: z.enum(WORK_ORDER_PRIORITIES).optional(),
|
||||||
|
description: optText(10_000).optional(),
|
||||||
|
scope: optText(10_000).optional(),
|
||||||
|
plannedStart: optDate,
|
||||||
|
plannedEnd: optDate,
|
||||||
|
signatureRequired: z.boolean().optional(),
|
||||||
|
billingType: z.enum(BILLING_TYPES).nullish(),
|
||||||
|
internalNotes: optText(5000).optional(),
|
||||||
|
technicianNotes: optText(5000).optional(),
|
||||||
|
externalOrderNumber: optText(80).optional(),
|
||||||
|
offerNumber: optText(80).optional(),
|
||||||
|
emergencyReason: optText(2000).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export type UpdateWorkOrderInput = z.input<typeof updateWorkOrderSchema>;
|
||||||
|
|
||||||
|
export const transitionSchema = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
to: z.enum(WORK_ORDER_STATUSES),
|
||||||
|
reason: optText(2000),
|
||||||
|
baseVersion: z.coerce.number().int().positive().optional(),
|
||||||
|
});
|
||||||
|
export type TransitionInput = z.input<typeof transitionSchema> & {
|
||||||
|
/** Extra facts merged into the emitted event's `data` (e.g. reportId from lane reports). Never overrides number/from/to. */
|
||||||
|
eventData?: Record<string, string | number | boolean | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const assignSchema = z.object({
|
||||||
|
workOrderId: id,
|
||||||
|
teamId: id,
|
||||||
|
userIds: z.array(id).max(50).default([]),
|
||||||
|
teamLeadUserId: optId,
|
||||||
|
baseVersion: z.coerce.number().int().positive().optional(),
|
||||||
|
});
|
||||||
|
export type AssignInput = z.input<typeof assignSchema>;
|
||||||
|
|
||||||
|
// ---------- Settings ----------
|
||||||
|
|
||||||
|
export const orderTypeSchema = z.object({
|
||||||
|
key: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(2)
|
||||||
|
.max(40)
|
||||||
|
.regex(/^[a-z0-9_]+$/),
|
||||||
|
name: text(80).min(1),
|
||||||
|
signatureRequired: z.boolean().default(true),
|
||||||
|
active: z.boolean().default(true),
|
||||||
|
sortOrder: z.coerce.number().int().min(0).max(10_000).default(100),
|
||||||
|
});
|
||||||
|
export type OrderTypeInput = z.input<typeof orderTypeSchema>;
|
||||||
|
|
||||||
|
export const templateItemSchema = z.object({
|
||||||
|
key: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(60)
|
||||||
|
.regex(/^[a-z0-9_]+$/),
|
||||||
|
label: text(200).min(1),
|
||||||
|
required: z.boolean().default(false),
|
||||||
|
requiresPhoto: z.boolean().default(false),
|
||||||
|
});
|
||||||
|
export const templatePhotoSchema = z.object({
|
||||||
|
key: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.min(1)
|
||||||
|
.max(60)
|
||||||
|
.regex(/^[a-z0-9_]+$/),
|
||||||
|
label: text(200).min(1),
|
||||||
|
});
|
||||||
|
export type TemplateItem = z.output<typeof templateItemSchema>;
|
||||||
|
export type TemplatePhoto = z.output<typeof templatePhotoSchema>;
|
||||||
|
|
||||||
|
export const checklistTemplateSchema = z.object({
|
||||||
|
name: text(120).min(1),
|
||||||
|
orderTypeId: optId,
|
||||||
|
active: z.boolean().default(true),
|
||||||
|
items: z.array(templateItemSchema).max(100),
|
||||||
|
requiredPhotos: z.array(templatePhotoSchema).max(30),
|
||||||
|
});
|
||||||
|
export type ChecklistTemplateInput = z.input<typeof checklistTemplateSchema>;
|
||||||
|
|
||||||
|
export const NUMBER_KEYS = ["work_order", "emergency", "customer", "report"] as const;
|
||||||
|
export const numberingSchema = z.object({
|
||||||
|
key: z.enum(NUMBER_KEYS),
|
||||||
|
prefix: z
|
||||||
|
.string()
|
||||||
|
.trim()
|
||||||
|
.max(12)
|
||||||
|
.regex(/^[A-Za-z0-9\-_/]*$/),
|
||||||
|
padding: z.coerce.number().int().min(1).max(10),
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Stable key from a free label (umlauts transliterated). */
|
||||||
|
export function slugKey(label: string): string {
|
||||||
|
const s = label
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/ä/g, "ae")
|
||||||
|
.replace(/ö/g, "oe")
|
||||||
|
.replace(/ü/g, "ue")
|
||||||
|
.replace(/ß/g, "ss")
|
||||||
|
.replace(/[^a-z0-9]+/g, "_")
|
||||||
|
.replace(/^_+|_+$/g, "")
|
||||||
|
.slice(0, 60);
|
||||||
|
return s || "punkt";
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* Timezone helpers (client-safe, Intl only). Planned dates are entered as wall-clock time in the
|
||||||
|
* tenant timezone (TenantSettings.timezone) and stored as UTC.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||||
|
|
||||||
|
export function safeTimeZone(tz: string | null | undefined): string {
|
||||||
|
if (!tz) return DEFAULT_TIMEZONE;
|
||||||
|
try {
|
||||||
|
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
||||||
|
return tz;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_TIMEZONE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Offset (ms) of `timeZone` relative to UTC at instant `at`. */
|
||||||
|
export function tzOffsetMs(at: Date, timeZone: string): number {
|
||||||
|
const parts = new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone,
|
||||||
|
hourCycle: "h23",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
second: "2-digit",
|
||||||
|
}).formatToParts(at);
|
||||||
|
const n = (t: string) => Number(parts.find((p) => p.type === t)?.value);
|
||||||
|
const asUtc = Date.UTC(n("year"), n("month") - 1, n("day"), n("hour"), n("minute"), n("second"));
|
||||||
|
return asUtc - Math.floor(at.getTime() / 1000) * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2026-09-14T08:30" (wall time in tz) → UTC Date. Returns undefined for invalid input. */
|
||||||
|
export function wallTimeToUtc(value: string, timeZone: string): Date | undefined {
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/.exec(value.trim());
|
||||||
|
if (!m) return undefined;
|
||||||
|
const tz = safeTimeZone(timeZone);
|
||||||
|
const guess = Date.UTC(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0);
|
||||||
|
const first = guess - tzOffsetMs(new Date(guess), tz);
|
||||||
|
// second pass corrects DST boundaries
|
||||||
|
const result = guess - tzOffsetMs(new Date(first), tz);
|
||||||
|
const d = new Date(result);
|
||||||
|
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UTC Date → "YYYY-MM-DDTHH:mm" wall time in tz (for <input type="datetime-local">). */
|
||||||
|
export function toWallTimeInput(date: Date | null | undefined, timeZone: string): string {
|
||||||
|
if (!date) return "";
|
||||||
|
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: safeTimeZone(timeZone),
|
||||||
|
hourCycle: "h23",
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
}).formatToParts(date);
|
||||||
|
const p = (t: string) => parts.find((x) => x.type === t)?.value ?? "00";
|
||||||
|
return `${p("year")}-${p("month")}-${p("day")}T${p("hour")}:${p("minute")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Start/end of the calendar day containing `now` in the given timezone. */
|
||||||
|
export function zonedDayBounds(now: Date, timeZone: string): { start: Date; end: Date } {
|
||||||
|
const tz = safeTimeZone(timeZone);
|
||||||
|
const [y, m, d] = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" })
|
||||||
|
.format(now)
|
||||||
|
.split("-")
|
||||||
|
.map(Number);
|
||||||
|
const guessStart = Date.UTC(y, m - 1, d, 0, 0, 0);
|
||||||
|
const start = new Date(guessStart - tzOffsetMs(new Date(guessStart), tz));
|
||||||
|
const guessEnd = Date.UTC(y, m - 1, d, 23, 59, 59, 999);
|
||||||
|
const end = new Date(guessEnd - tzOffsetMs(new Date(guessEnd), tz));
|
||||||
|
return { start, end };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(date: Date | null | undefined, locale: string, timeZone: string): string {
|
||||||
|
if (!date) return "";
|
||||||
|
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date | null | undefined, locale: string, timeZone: string): string {
|
||||||
|
if (!date) return "";
|
||||||
|
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium" }).format(date);
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { unstable_rethrow } from "next/navigation";
|
||||||
|
import type { ActionState } from "@/lib/work-orders/action-state";
|
||||||
|
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||||
|
import { wallTimeToUtc } from "@/lib/work-orders/time";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { ModuleDisabledError } from "@/server/modules";
|
||||||
|
import { ForbiddenError } from "@/server/rbac";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Internal helpers of the work_orders actions (file starts with "_": not an action module,
|
||||||
|
* skipped by scripts/check-module-guards.ts). Maps thrown errors to a displayable ActionState
|
||||||
|
* without leaking internals (F-16) and audits RBAC denials.
|
||||||
|
*/
|
||||||
|
export async function toErrorState(
|
||||||
|
err: unknown,
|
||||||
|
audit: { tenantId?: string; actorId?: string; entity: string; entityId?: string },
|
||||||
|
): Promise<ActionState> {
|
||||||
|
unstable_rethrow(err);
|
||||||
|
const at = Date.now();
|
||||||
|
if (err instanceof ServiceError) {
|
||||||
|
return {
|
||||||
|
status: "error",
|
||||||
|
code: err.code,
|
||||||
|
message: err.message,
|
||||||
|
blockers: err.code === "blocked" ? (err.details as CompletionBlocker[]) : undefined,
|
||||||
|
at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) {
|
||||||
|
if (err instanceof ForbiddenError && audit.tenantId) {
|
||||||
|
try {
|
||||||
|
await writeAuditLog({ tenantId: audit.tenantId, actorId: audit.actorId, action: "denied", entity: audit.entity, entityId: audit.entityId, after: { reason: err.message } });
|
||||||
|
} catch (auditErr) {
|
||||||
|
console.error("[work_orders] audit-write-failed", auditErr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { status: "error", code: "forbidden", message: "forbidden", at };
|
||||||
|
}
|
||||||
|
console.error(`[work_orders] ${audit.entity} action failed`, err);
|
||||||
|
return { status: "error", code: "internal", message: "internal", at };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ok = (): ActionState => ({ status: "ok", at: Date.now() });
|
||||||
|
|
||||||
|
export function str(fd: FormData, key: string): string | undefined {
|
||||||
|
const v = fd.get(key);
|
||||||
|
if (typeof v !== "string") return undefined;
|
||||||
|
const t = v.trim();
|
||||||
|
return t ? t : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Present field → string or null (empty); absent field → undefined. */
|
||||||
|
export function nullable(fd: FormData, key: string): string | null | undefined {
|
||||||
|
if (!fd.has(key)) return undefined;
|
||||||
|
return str(fd, key) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bool(fd: FormData, key: string): boolean {
|
||||||
|
const v = fd.get(key);
|
||||||
|
return v === "on" || v === "true" || v === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "yes" | "no" | "" select → boolean | undefined */
|
||||||
|
export function triState(fd: FormData, key: string): boolean | undefined {
|
||||||
|
const v = str(fd, key);
|
||||||
|
return v === "yes" ? true : v === "no" ? false : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function int(fd: FormData, key: string): number | undefined {
|
||||||
|
const v = str(fd, key);
|
||||||
|
if (v === undefined) return undefined;
|
||||||
|
const n = Number.parseInt(v, 10);
|
||||||
|
return Number.isFinite(n) ? n : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** datetime-local in tenant timezone → Date; empty → null; absent → undefined. */
|
||||||
|
export function wallTime(fd: FormData, key: string, tz: string): Date | null | undefined {
|
||||||
|
if (!fd.has(key)) return undefined;
|
||||||
|
const v = str(fd, key);
|
||||||
|
if (!v) return null;
|
||||||
|
return wallTimeToUtc(v, tz) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Only internal detail links are allowed as return targets. */
|
||||||
|
export function detailPath(workOrderId: string, tab?: string): string {
|
||||||
|
const safeTab = tab && /^[a-z]{3,12}$/.test(tab) ? `?tab=${tab}` : "";
|
||||||
|
return `/work-orders/${encodeURIComponent(workOrderId)}${safeTab}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import type { ActionState } from "@/lib/work-orders/action-state";
|
||||||
|
import { slugKey } from "@/lib/work-orders/schemas";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ctxFromGuard } from "@/server/services/context";
|
||||||
|
import { uniqueKey } from "@/server/services/work-orders/_shared";
|
||||||
|
import {
|
||||||
|
createChecklistTemplate,
|
||||||
|
createOrderType,
|
||||||
|
updateChecklistTemplate,
|
||||||
|
updateNumberSequence,
|
||||||
|
updateOrderType,
|
||||||
|
} from "@/server/services/work-orders/settings";
|
||||||
|
import { bool, int, ok, str, toErrorState } from "./_form";
|
||||||
|
|
||||||
|
/** Settings of the work order module (order types, checklist templates, numbering) — `settings:templates`. */
|
||||||
|
const guard = moduleGuard("work_orders");
|
||||||
|
|
||||||
|
export async function createOrderTypeAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("settings:templates");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
const name = str(fd, "name") ?? "";
|
||||||
|
await createOrderType(ctxFromGuard(g), {
|
||||||
|
key: str(fd, "key") ?? slugKey(name),
|
||||||
|
name,
|
||||||
|
signatureRequired: bool(fd, "signatureRequired"),
|
||||||
|
active: true,
|
||||||
|
sortOrder: int(fd, "sortOrder") ?? 100,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "order_type" });
|
||||||
|
}
|
||||||
|
revalidatePath("/settings/order-types");
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrderTypeAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const id = str(fd, "id") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("settings:templates");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await updateOrderType(ctxFromGuard(g), id, {
|
||||||
|
name: str(fd, "name") ?? "",
|
||||||
|
signatureRequired: bool(fd, "signatureRequired"),
|
||||||
|
active: bool(fd, "active"),
|
||||||
|
sortOrder: int(fd, "sortOrder") ?? 100,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "order_type", entityId: id });
|
||||||
|
}
|
||||||
|
revalidatePath("/settings/order-types");
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Textarea lines → template items: "*" prefix = required, "[Foto]" suffix = requires photo. */
|
||||||
|
function parseItems(text: string) {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 100)
|
||||||
|
.map((line) => {
|
||||||
|
let label = line;
|
||||||
|
const required = label.startsWith("*");
|
||||||
|
if (required) label = label.slice(1).trim();
|
||||||
|
const requiresPhoto = /\[foto\]$/i.test(label);
|
||||||
|
if (requiresPhoto) label = label.replace(/\[foto\]$/i, "").trim();
|
||||||
|
return { key: uniqueKey(slugKey(label), keys), label: label.slice(0, 200), required, requiresPhoto };
|
||||||
|
})
|
||||||
|
.filter((i) => i.label.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePhotos(text: string) {
|
||||||
|
const keys = new Set<string>();
|
||||||
|
return text
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(0, 30)
|
||||||
|
.map((label) => ({ key: uniqueKey(slugKey(label), keys), label: label.slice(0, 200) }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveChecklistTemplateAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const id = str(fd, "id");
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("settings:templates");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
const input = {
|
||||||
|
name: str(fd, "name") ?? "",
|
||||||
|
orderTypeId: str(fd, "orderTypeId") ?? null,
|
||||||
|
active: bool(fd, "active"),
|
||||||
|
items: parseItems(String(fd.get("items") ?? "")),
|
||||||
|
requiredPhotos: parsePhotos(String(fd.get("requiredPhotos") ?? "")),
|
||||||
|
};
|
||||||
|
const ctx = ctxFromGuard(g);
|
||||||
|
if (id) await updateChecklistTemplate(ctx, id, input);
|
||||||
|
else await createChecklistTemplate(ctx, input);
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "checklist_template", entityId: id });
|
||||||
|
}
|
||||||
|
revalidatePath("/settings/checklists");
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateNumberingAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("settings:templates");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await updateNumberSequence(ctxFromGuard(g), {
|
||||||
|
key: str(fd, "key") ?? "",
|
||||||
|
prefix: String(fd.get("prefix") ?? "").trim(),
|
||||||
|
padding: str(fd, "padding") ?? "",
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "number_sequence" });
|
||||||
|
}
|
||||||
|
revalidatePath("/settings/numbering");
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import type { ActionState } from "@/lib/work-orders/action-state";
|
||||||
|
import { moduleGuard } from "@/server/action-guard";
|
||||||
|
import { ctxFromGuard } from "@/server/services/context";
|
||||||
|
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||||
|
import { assignWorkOrder } from "@/server/services/work-orders/assign";
|
||||||
|
import { addChecklistItem, addPhotoRequirement, applyChecklistTemplate, removeChecklistItem, removePhotoRequirement } from "@/server/services/work-orders/checklist";
|
||||||
|
import { applySyncConflict, discardSyncConflict } from "@/server/services/work-orders/conflicts";
|
||||||
|
import { createWorkOrder } from "@/server/services/work-orders/create";
|
||||||
|
import { archiveWorkOrderDocument } from "@/server/services/work-orders/documents";
|
||||||
|
import { addMaterialPlan, removeMaterialPlan } from "@/server/services/work-orders/materials";
|
||||||
|
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||||
|
import { updateWorkOrder } from "@/server/services/work-orders/update";
|
||||||
|
import { bool, detailPath, int, nullable, ok, str, toErrorState, triState, wallTime } from "./_form";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice server actions of module "work_orders" — thin adapters:
|
||||||
|
* moduleGuard → FormData → service (permission, scope, Zod, audit, event) → revalidate/redirect.
|
||||||
|
*/
|
||||||
|
const guard = moduleGuard("work_orders");
|
||||||
|
|
||||||
|
export async function createWorkOrderAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
let id: string;
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
const ctx = ctxFromGuard(g);
|
||||||
|
const tz = await tenantTimezone(ctx);
|
||||||
|
const created = await createWorkOrder(ctx, {
|
||||||
|
title: str(fd, "title") ?? "",
|
||||||
|
customerId: str(fd, "customerId") ?? "",
|
||||||
|
siteId: str(fd, "siteId") ?? null,
|
||||||
|
contactId: str(fd, "contactId") ?? null,
|
||||||
|
orderTypeId: str(fd, "orderTypeId") ?? null,
|
||||||
|
priority: (str(fd, "priority") as never) ?? "normal",
|
||||||
|
status: str(fd, "status") === "planned" ? "planned" : "draft",
|
||||||
|
description: str(fd, "description"),
|
||||||
|
scope: str(fd, "scope"),
|
||||||
|
plannedStart: wallTime(fd, "plannedStart", tz) ?? null,
|
||||||
|
plannedEnd: wallTime(fd, "plannedEnd", tz) ?? null,
|
||||||
|
signatureRequired: triState(fd, "signatureRequired"),
|
||||||
|
billingType: (str(fd, "billingType") as never) ?? null,
|
||||||
|
internalNotes: str(fd, "internalNotes"),
|
||||||
|
technicianNotes: str(fd, "technicianNotes"),
|
||||||
|
externalOrderNumber: str(fd, "externalOrderNumber"),
|
||||||
|
offerNumber: str(fd, "offerNumber"),
|
||||||
|
});
|
||||||
|
id = created.id;
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "work_order" });
|
||||||
|
}
|
||||||
|
revalidatePath("/work-orders");
|
||||||
|
redirect(detailPath(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateWorkOrderAction(workOrderId: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
const ctx = ctxFromGuard(g);
|
||||||
|
const tz = await tenantTimezone(ctx);
|
||||||
|
const patch: Record<string, unknown> = {};
|
||||||
|
for (const k of ["title", "priority", "billingType"]) if (fd.has(k)) patch[k] = str(fd, k) ?? null;
|
||||||
|
for (const k of ["siteId", "contactId", "orderTypeId", "description", "scope", "internalNotes", "technicianNotes", "externalOrderNumber", "offerNumber"]) {
|
||||||
|
const v = nullable(fd, k);
|
||||||
|
if (v !== undefined) patch[k] = v;
|
||||||
|
}
|
||||||
|
for (const k of ["plannedStart", "plannedEnd"]) {
|
||||||
|
const v = wallTime(fd, k, tz);
|
||||||
|
if (v !== undefined) patch[k] = v;
|
||||||
|
}
|
||||||
|
const signature = triState(fd, "signatureRequired");
|
||||||
|
if (signature !== undefined) patch.signatureRequired = signature;
|
||||||
|
if (patch.title === null) patch.title = "";
|
||||||
|
await updateWorkOrder(ctx, workOrderId, patch, int(fd, "baseVersion"));
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
|
||||||
|
}
|
||||||
|
revalidatePath(`/work-orders/${workOrderId}`);
|
||||||
|
redirect(detailPath(workOrderId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function transitionAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const workOrderId = str(fd, "workOrderId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
// Permission depends on the concrete transition → decided by the service (mayTransition).
|
||||||
|
const g = await guard();
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await transitionWorkOrder(ctxFromGuard(g), {
|
||||||
|
workOrderId,
|
||||||
|
to: (str(fd, "to") ?? "") as never,
|
||||||
|
reason: str(fd, "reason") ?? null,
|
||||||
|
baseVersion: int(fd, "baseVersion"),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
|
||||||
|
}
|
||||||
|
revalidatePath("/work-orders");
|
||||||
|
redirect(detailPath(workOrderId, str(fd, "returnTab")));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const workOrderId = str(fd, "workOrderId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:assign");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await assignWorkOrder(ctxFromGuard(g), {
|
||||||
|
workOrderId,
|
||||||
|
teamId: str(fd, "teamId") ?? "",
|
||||||
|
userIds: fd.getAll("userIds").filter((v): v is string => typeof v === "string" && v.length > 0),
|
||||||
|
teamLeadUserId: str(fd, "teamLeadUserId") ?? null,
|
||||||
|
baseVersion: int(fd, "baseVersion"),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
|
||||||
|
}
|
||||||
|
revalidatePath("/work-orders");
|
||||||
|
redirect(detailPath(workOrderId));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMaterialPlanAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const workOrderId = str(fd, "workOrderId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await addMaterialPlan(ctxFromGuard(g), workOrderId, {
|
||||||
|
name: str(fd, "name") ?? "",
|
||||||
|
articleNumber: str(fd, "articleNumber"),
|
||||||
|
plannedQuantity: (str(fd, "plannedQuantity") ?? "").replace(",", ".") as never,
|
||||||
|
unit: str(fd, "unit") ?? "",
|
||||||
|
notes: str(fd, "notes"),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
|
||||||
|
}
|
||||||
|
revalidatePath(`/work-orders/${workOrderId}`);
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeMaterialPlanAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const workOrderId = str(fd, "workOrderId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await removeMaterialPlan(ctxFromGuard(g), str(fd, "planId") ?? "");
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
|
||||||
|
}
|
||||||
|
revalidatePath(`/work-orders/${workOrderId}`);
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addChecklistItemAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const workOrderId = str(fd, "workOrderId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await addChecklistItem(ctxFromGuard(g), workOrderId, {
|
||||||
|
label: str(fd, "label") ?? "",
|
||||||
|
required: bool(fd, "required"),
|
||||||
|
requiresPhoto: bool(fd, "requiresPhoto"),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
|
||||||
|
}
|
||||||
|
revalidatePath(`/work-orders/${workOrderId}`);
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeChecklistItemAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const workOrderId = str(fd, "workOrderId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await removeChecklistItem(ctxFromGuard(g), str(fd, "itemId") ?? "");
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
|
||||||
|
}
|
||||||
|
revalidatePath(`/work-orders/${workOrderId}`);
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPhotoRequirementAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const workOrderId = str(fd, "workOrderId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await addPhotoRequirement(ctxFromGuard(g), workOrderId, { label: str(fd, "label") ?? "" });
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
|
||||||
|
}
|
||||||
|
revalidatePath(`/work-orders/${workOrderId}`);
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removePhotoRequirementAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const workOrderId = str(fd, "workOrderId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await removePhotoRequirement(ctxFromGuard(g), str(fd, "requirementId") ?? "");
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
|
||||||
|
}
|
||||||
|
revalidatePath(`/work-orders/${workOrderId}`);
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyChecklistTemplateAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const workOrderId = str(fd, "workOrderId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await applyChecklistTemplate(ctxFromGuard(g), workOrderId, str(fd, "templateId") ?? "");
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId });
|
||||||
|
}
|
||||||
|
revalidatePath(`/work-orders/${workOrderId}`);
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function archiveDocumentAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const workOrderId = str(fd, "workOrderId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("document:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await archiveWorkOrderDocument(ctxFromGuard(g), str(fd, "documentId") ?? "");
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "document", entityId: str(fd, "documentId") });
|
||||||
|
}
|
||||||
|
revalidatePath(`/work-orders/${workOrderId}`);
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applySyncConflictAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const opId = str(fd, "opId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await applySyncConflict(ctxFromGuard(g), opId);
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "sync_operation", entityId: opId });
|
||||||
|
}
|
||||||
|
revalidatePath("/work-orders/conflicts");
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function discardSyncConflictAction(_prev: ActionState, fd: FormData): Promise<ActionState> {
|
||||||
|
const opId = str(fd, "opId") ?? "";
|
||||||
|
let tenantId: string | undefined;
|
||||||
|
let actorId: string | undefined;
|
||||||
|
try {
|
||||||
|
const g = await guard("work_order:write");
|
||||||
|
tenantId = g.session.user.tenantId;
|
||||||
|
actorId = g.session.user.id;
|
||||||
|
await discardSyncConflict(ctxFromGuard(g), opId);
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorState(err, { tenantId, actorId, entity: "sync_operation", entityId: opId });
|
||||||
|
}
|
||||||
|
revalidatePath("/work-orders/conflicts");
|
||||||
|
return ok();
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { safeTimeZone } from "@/lib/work-orders/time";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
/** Parse service input; validation problems become ServiceError("invalid") with flattened issues. */
|
||||||
|
export function parseInput<S extends z.ZodType>(schema: S, input: unknown): z.output<S> {
|
||||||
|
const res = schema.safeParse(input);
|
||||||
|
if (!res.success) {
|
||||||
|
throw new ServiceError(
|
||||||
|
"invalid",
|
||||||
|
"validation_failed",
|
||||||
|
res.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No business data changes at all in these statuses. */
|
||||||
|
export const FINAL_STATUSES: readonly WorkOrderStatus[] = ["billed", "cancelled"];
|
||||||
|
/** Planning data (checklist, photos, material plan, assignment) is frozen once released for billing. */
|
||||||
|
export const PLANNING_LOCKED: readonly WorkOrderStatus[] = ["released_for_billing", "billed", "cancelled"];
|
||||||
|
|
||||||
|
export type WorkOrderBase = {
|
||||||
|
id: string;
|
||||||
|
number: string;
|
||||||
|
status: WorkOrderStatus;
|
||||||
|
version: number;
|
||||||
|
customerId: string;
|
||||||
|
siteId: string | null;
|
||||||
|
signatureRequired: boolean;
|
||||||
|
isEmergency: boolean;
|
||||||
|
assignedTeamId: string | null;
|
||||||
|
teamLeadUserId: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const BASE_SELECT = {
|
||||||
|
id: true,
|
||||||
|
number: true,
|
||||||
|
status: true,
|
||||||
|
version: true,
|
||||||
|
customerId: true,
|
||||||
|
siteId: true,
|
||||||
|
signatureRequired: true,
|
||||||
|
isEmergency: true,
|
||||||
|
assignedTeamId: true,
|
||||||
|
teamLeadUserId: true,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** Load a work order within the caller's visibility scope or throw not_found (existence is never revealed). */
|
||||||
|
export async function loadVisibleWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise<WorkOrderBase> {
|
||||||
|
const scope = await workOrderScope(ctx);
|
||||||
|
const wo = await ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, scope] }, select: BASE_SELECT });
|
||||||
|
if (!wo) throw new ServiceError("not_found", "work_order_not_found");
|
||||||
|
return wo as WorkOrderBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertBaseVersion(wo: { version: number }, baseVersion?: number) {
|
||||||
|
if (baseVersion !== undefined && baseVersion !== wo.version) {
|
||||||
|
throw new ServiceError("conflict", "version_conflict", { currentVersion: wo.version, baseVersion });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optimistic write on the work order row: only succeeds while the version is unchanged and
|
||||||
|
* always increments `version` (offline sync conflict detection relies on it).
|
||||||
|
*/
|
||||||
|
export async function writeWithVersion(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
wo: { id: string; version: number },
|
||||||
|
data: Record<string, unknown>,
|
||||||
|
): Promise<number> {
|
||||||
|
const res = await ctx.db.workOrder.updateMany({
|
||||||
|
where: { id: wo.id, version: wo.version, deletedAt: null },
|
||||||
|
data: { ...data, version: { increment: 1 } },
|
||||||
|
});
|
||||||
|
if (res.count !== 1) throw new ServiceError("conflict", "version_conflict", { baseVersion: wo.version });
|
||||||
|
return wo.version + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Version bump after a mutation of a dependent entity (checklist, material plan, …). */
|
||||||
|
export async function touchWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise<void> {
|
||||||
|
await ctx.db.workOrder.updateMany({ where: { id: workOrderId }, data: { version: { increment: 1 } } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function auditWorkOrder(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
entry: { action: "create" | "update" | "delete"; workOrderId: string; before?: unknown; after?: unknown },
|
||||||
|
) {
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: entry.action,
|
||||||
|
entity: "work_order",
|
||||||
|
entityId: entry.workOrderId,
|
||||||
|
before: entry.before,
|
||||||
|
after: entry.after,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plain JSON copy for audit before/after (Decimals/Dates → strings). */
|
||||||
|
export function snapshot<T>(value: T): unknown {
|
||||||
|
return JSON.parse(JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function assertNotLocked(wo: { status: WorkOrderStatus }, locked: readonly WorkOrderStatus[]) {
|
||||||
|
if (locked.includes(wo.status)) throw new ServiceError("invalid", "not_editable", { status: wo.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unique stable keys for checklist items / photo requirements. */
|
||||||
|
export function uniqueKey(base: string, taken: Set<string>): string {
|
||||||
|
let key = base;
|
||||||
|
let i = 2;
|
||||||
|
while (taken.has(key)) key = `${base}_${i++}`.slice(0, 60);
|
||||||
|
taken.add(key);
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tenant timezone (TenantSettings.timezone, default Europe/Berlin). */
|
||||||
|
export async function tenantTimezone(ctx: ServiceCtx): Promise<string> {
|
||||||
|
const s = await ctx.db.tenantSettings.findFirst({ select: { timezone: true } });
|
||||||
|
return safeTimeZone(s?.timezone);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { zonedDayBounds } from "@/lib/work-orders/time";
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { assignSchema, type AssignInput } from "@/lib/work-orders/schemas";
|
||||||
|
import { emitEvent } from "@/server/events";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import {
|
||||||
|
assertBaseVersion,
|
||||||
|
assertNotLocked,
|
||||||
|
auditWorkOrder,
|
||||||
|
loadVisibleWorkOrder,
|
||||||
|
parseInput,
|
||||||
|
PLANNING_LOCKED,
|
||||||
|
writeWithVersion,
|
||||||
|
} from "@/server/services/work-orders/_shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign a team (+ optional individual technicians and a responsible team lead) — US-004.
|
||||||
|
* draft/review_required/planned → assigned; accepted with a changed team → back to assigned.
|
||||||
|
*/
|
||||||
|
export async function assignWorkOrder(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
raw: AssignInput,
|
||||||
|
): Promise<{ id: string; version: number; status: WorkOrderStatus }> {
|
||||||
|
assertCan(ctx, "work_order:assign");
|
||||||
|
const input = parseInput(assignSchema, raw);
|
||||||
|
const wo = await loadVisibleWorkOrder(ctx, input.workOrderId);
|
||||||
|
assertNotLocked(wo, PLANNING_LOCKED);
|
||||||
|
assertBaseVersion(wo, input.baseVersion);
|
||||||
|
|
||||||
|
const team = await ctx.db.team.findFirst({
|
||||||
|
where: { id: input.teamId, deletedAt: null, status: "active" },
|
||||||
|
select: { id: true, name: true, leaderUserId: true },
|
||||||
|
});
|
||||||
|
if (!team) throw new ServiceError("invalid", "team_not_found");
|
||||||
|
|
||||||
|
const userIds = [...new Set(input.userIds)];
|
||||||
|
const leadId = input.teamLeadUserId ?? team.leaderUserId ?? null;
|
||||||
|
const toCheck = [...new Set([...userIds, ...(leadId ? [leadId] : [])])];
|
||||||
|
if (toCheck.length) {
|
||||||
|
const found = await ctx.db.user.count({ where: { id: { in: toCheck }, status: "ACTIVE" } });
|
||||||
|
if (found !== toCheck.length) throw new ServiceError("invalid", "user_not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const previous = await ctx.db.workOrderAssignee.findMany({ where: { workOrderId: wo.id }, select: { userId: true } });
|
||||||
|
|
||||||
|
let to: WorkOrderStatus | null = null;
|
||||||
|
if (["draft", "review_required", "planned"].includes(wo.status)) to = "assigned";
|
||||||
|
else if (wo.status === "accepted" && wo.assignedTeamId !== team.id) to = "assigned";
|
||||||
|
if (to && !canTransition(wo.status, to)) to = null;
|
||||||
|
|
||||||
|
const version = await writeWithVersion(ctx, wo, {
|
||||||
|
assignedTeamId: team.id,
|
||||||
|
teamLeadUserId: leadId,
|
||||||
|
...(to ? { status: to } : {}),
|
||||||
|
});
|
||||||
|
await ctx.db.workOrderAssignee.deleteMany({ where: { workOrderId: wo.id, userId: { notIn: userIds } } });
|
||||||
|
if (userIds.length) {
|
||||||
|
await ctx.db.workOrderAssignee.createMany({
|
||||||
|
data: userIds.map((userId) => ({ tenantId: ctx.tenantId, workOrderId: wo.id, userId })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (to) {
|
||||||
|
await ctx.db.workOrderStatusChange.create({
|
||||||
|
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: wo.status, toStatus: to, actorId: ctx.userId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await auditWorkOrder(ctx, {
|
||||||
|
action: "update",
|
||||||
|
workOrderId: wo.id,
|
||||||
|
before: { assignedTeamId: wo.assignedTeamId, teamLeadUserId: wo.teamLeadUserId, userIds: previous.map((p) => p.userId), status: wo.status },
|
||||||
|
after: { assignedTeamId: team.id, teamLeadUserId: leadId, userIds, status: to ?? wo.status, version },
|
||||||
|
});
|
||||||
|
await emitEvent(ctx, {
|
||||||
|
type: "work_order.assigned",
|
||||||
|
entityType: "work_order",
|
||||||
|
entityId: wo.id,
|
||||||
|
data: { number: wo.number, teamId: team.id, teamName: team.name },
|
||||||
|
});
|
||||||
|
return { id: wo.id, version, status: to ?? wo.status };
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { transitionWorkOrder, type TransitionResult } from "@/server/services/work-orders/transition";
|
||||||
|
|
||||||
|
/** Cancel an order (any status except billed) — `work_order:cancel`, reason mandatory. */
|
||||||
|
export async function cancelWorkOrder(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
input: { workOrderId: string; reason: string; baseVersion?: number },
|
||||||
|
): Promise<TransitionResult> {
|
||||||
|
return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "cancelled", reason: input.reason, baseVersion: input.baseVersion });
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
checklistItemInputSchema,
|
||||||
|
photoRequirementInputSchema,
|
||||||
|
slugKey,
|
||||||
|
templateItemSchema,
|
||||||
|
templatePhotoSchema,
|
||||||
|
type ChecklistItemInput,
|
||||||
|
type PhotoRequirementInput,
|
||||||
|
} from "@/lib/work-orders/schemas";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import {
|
||||||
|
assertNotLocked,
|
||||||
|
auditWorkOrder,
|
||||||
|
loadVisibleWorkOrder,
|
||||||
|
parseInput,
|
||||||
|
PLANNING_LOCKED,
|
||||||
|
snapshot,
|
||||||
|
touchWorkOrder,
|
||||||
|
uniqueKey,
|
||||||
|
} from "@/server/services/work-orders/_shared";
|
||||||
|
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
/** Checklist items and required photos on a single order (backoffice maintenance, spec §12.4/§14.2). */
|
||||||
|
|
||||||
|
async function editableOrder(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
assertCan(ctx, "work_order:write");
|
||||||
|
const wo = await loadVisibleWorkOrder(ctx, workOrderId);
|
||||||
|
assertNotLocked(wo, PLANNING_LOCKED);
|
||||||
|
return wo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addChecklistItem(ctx: ServiceCtx, workOrderId: string, raw: ChecklistItemInput) {
|
||||||
|
const input = parseInput(checklistItemInputSchema, raw);
|
||||||
|
await editableOrder(ctx, workOrderId);
|
||||||
|
const existing = await ctx.db.checklistItem.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } });
|
||||||
|
const key = uniqueKey(input.key || slugKey(input.label), new Set(existing.map((e) => e.key)));
|
||||||
|
const item = await ctx.db.checklistItem.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
workOrderId,
|
||||||
|
key,
|
||||||
|
label: input.label,
|
||||||
|
required: input.required,
|
||||||
|
requiresPhoto: input.requiresPhoto,
|
||||||
|
sortOrder: input.sortOrder ?? Math.max(0, ...existing.map((e) => e.sortOrder)) + 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await touchWorkOrder(ctx, workOrderId);
|
||||||
|
await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "checklist_item.add", item: snapshot(item) } });
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChecklistItem(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
itemId: string,
|
||||||
|
raw: { label?: string; required?: boolean; requiresPhoto?: boolean; sortOrder?: number },
|
||||||
|
) {
|
||||||
|
const input = parseInput(checklistItemInputSchema.partial(), raw);
|
||||||
|
const scope = await workOrderScope(ctx);
|
||||||
|
const before = await ctx.db.checklistItem.findFirst({ where: { id: itemId, workOrder: scope } });
|
||||||
|
if (!before) throw new ServiceError("not_found", "checklist_item_not_found");
|
||||||
|
await editableOrder(ctx, before.workOrderId);
|
||||||
|
const item = await ctx.db.checklistItem.update({
|
||||||
|
where: { id: itemId },
|
||||||
|
data: {
|
||||||
|
...(input.label !== undefined ? { label: input.label } : {}),
|
||||||
|
...(raw.required !== undefined ? { required: !!input.required } : {}),
|
||||||
|
...(raw.requiresPhoto !== undefined ? { requiresPhoto: !!input.requiresPhoto } : {}),
|
||||||
|
...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await touchWorkOrder(ctx, before.workOrderId);
|
||||||
|
await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "checklist_item.update", item: snapshot(before) }, after: { item: snapshot(item) } });
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Checked items are evidence and stay. */
|
||||||
|
export async function removeChecklistItem(ctx: ServiceCtx, itemId: string): Promise<void> {
|
||||||
|
const scope = await workOrderScope(ctx);
|
||||||
|
const before = await ctx.db.checklistItem.findFirst({ where: { id: itemId, workOrder: scope } });
|
||||||
|
if (!before) throw new ServiceError("not_found", "checklist_item_not_found");
|
||||||
|
await editableOrder(ctx, before.workOrderId);
|
||||||
|
if (before.checked) throw new ServiceError("invalid", "checklist_item_checked");
|
||||||
|
await ctx.db.checklistItem.deleteMany({ where: { id: itemId } });
|
||||||
|
await touchWorkOrder(ctx, before.workOrderId);
|
||||||
|
await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "checklist_item.remove", item: snapshot(before) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addPhotoRequirement(ctx: ServiceCtx, workOrderId: string, raw: PhotoRequirementInput) {
|
||||||
|
const input = parseInput(photoRequirementInputSchema, raw);
|
||||||
|
await editableOrder(ctx, workOrderId);
|
||||||
|
const existing = await ctx.db.photoRequirement.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } });
|
||||||
|
const key = uniqueKey(input.key || slugKey(input.label), new Set(existing.map((e) => e.key)));
|
||||||
|
const req = await ctx.db.photoRequirement.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
workOrderId,
|
||||||
|
key,
|
||||||
|
label: input.label,
|
||||||
|
sortOrder: input.sortOrder ?? Math.max(0, ...existing.map((e) => e.sortOrder)) + 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await touchWorkOrder(ctx, workOrderId);
|
||||||
|
await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "photo_requirement.add", requirement: snapshot(req) } });
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Requirements that already have photos stay (evidence). */
|
||||||
|
export async function removePhotoRequirement(ctx: ServiceCtx, requirementId: string): Promise<void> {
|
||||||
|
const scope = await workOrderScope(ctx);
|
||||||
|
const before = await ctx.db.photoRequirement.findFirst({ where: { id: requirementId, workOrder: scope } });
|
||||||
|
if (!before) throw new ServiceError("not_found", "photo_requirement_not_found");
|
||||||
|
await editableOrder(ctx, before.workOrderId);
|
||||||
|
const photos = await ctx.db.photo.count({ where: { photoRequirementId: requirementId } });
|
||||||
|
if (photos > 0) throw new ServiceError("invalid", "photo_requirement_has_photos");
|
||||||
|
await ctx.db.photoRequirement.deleteMany({ where: { id: requirementId } });
|
||||||
|
await touchWorkOrder(ctx, before.workOrderId);
|
||||||
|
await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "photo_requirement.remove", requirement: snapshot(before) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add the items/photos of a template that the order does not have yet (by key). */
|
||||||
|
export async function applyChecklistTemplate(ctx: ServiceCtx, workOrderId: string, templateId: string) {
|
||||||
|
await editableOrder(ctx, workOrderId);
|
||||||
|
const tpl = await ctx.db.checklistTemplate.findFirst({ where: { id: templateId, active: true } });
|
||||||
|
if (!tpl) throw new ServiceError("invalid", "template_not_found");
|
||||||
|
const items = z.array(templateItemSchema).safeParse(tpl.items).data ?? [];
|
||||||
|
const photos = z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? [];
|
||||||
|
const [haveItems, havePhotos] = await Promise.all([
|
||||||
|
ctx.db.checklistItem.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }),
|
||||||
|
ctx.db.photoRequirement.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }),
|
||||||
|
]);
|
||||||
|
const itemKeys = new Set(haveItems.map((i) => i.key));
|
||||||
|
const photoKeys = new Set(havePhotos.map((i) => i.key));
|
||||||
|
let sort = Math.max(0, ...haveItems.map((i) => i.sortOrder));
|
||||||
|
const newItems = items.filter((i) => !itemKeys.has(i.key));
|
||||||
|
let psort = Math.max(0, ...havePhotos.map((i) => i.sortOrder));
|
||||||
|
const newPhotos = photos.filter((p) => !photoKeys.has(p.key));
|
||||||
|
if (newItems.length) {
|
||||||
|
await ctx.db.checklistItem.createMany({
|
||||||
|
data: newItems.map((i) => ({ tenantId: ctx.tenantId, workOrderId, key: i.key, label: i.label, required: i.required, requiresPhoto: i.requiresPhoto, sortOrder: (sort += 10) })),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (newPhotos.length) {
|
||||||
|
await ctx.db.photoRequirement.createMany({
|
||||||
|
data: newPhotos.map((p) => ({ tenantId: ctx.tenantId, workOrderId, key: p.key, label: p.label, sortOrder: (psort += 10) })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await touchWorkOrder(ctx, workOrderId);
|
||||||
|
await auditWorkOrder(ctx, {
|
||||||
|
action: "update",
|
||||||
|
workOrderId,
|
||||||
|
after: { op: "checklist_template.apply", templateId, items: newItems.length, photos: newPhotos.length },
|
||||||
|
});
|
||||||
|
return { items: newItems.length, photos: newPhotos.length };
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { loadVisibleWorkOrder } from "@/server/services/work-orders/_shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Completion guards (ARCHITEKTUR §3): before `technically_completed` every required checklist
|
||||||
|
* item is checked (with photo where requested), every PhotoRequirement has ≥ 1 photo and no
|
||||||
|
* WorkSession is still open.
|
||||||
|
*/
|
||||||
|
export async function computeCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise<CompletionBlocker[]> {
|
||||||
|
const [items, requirements, sessions] = await Promise.all([
|
||||||
|
ctx.db.checklistItem.findMany({
|
||||||
|
where: { workOrderId, required: true },
|
||||||
|
select: { id: true, label: true, checked: true, requiresPhoto: true, _count: { select: { photos: true } } },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
}),
|
||||||
|
ctx.db.photoRequirement.findMany({
|
||||||
|
where: { workOrderId },
|
||||||
|
select: { id: true, label: true, _count: { select: { photos: true } } },
|
||||||
|
orderBy: { sortOrder: "asc" },
|
||||||
|
}),
|
||||||
|
ctx.db.workSession.findMany({
|
||||||
|
where: { workOrderId, status: { in: ["en_route", "running", "paused"] } },
|
||||||
|
select: { id: true, userId: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const blockers: CompletionBlocker[] = [];
|
||||||
|
for (const i of items) {
|
||||||
|
if (!i.checked || (i.requiresPhoto && i._count.photos === 0)) {
|
||||||
|
blockers.push({ kind: "checklist_item", itemId: i.id, label: i.label });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const r of requirements) {
|
||||||
|
if (r._count.photos === 0) blockers.push({ kind: "photo_requirement", requirementId: r.id, label: r.label });
|
||||||
|
}
|
||||||
|
for (const s of sessions) blockers.push({ kind: "running_session", sessionId: s.id, userId: s.userId });
|
||||||
|
return blockers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Public variant with visibility check (UI, API). */
|
||||||
|
export async function getCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise<CompletionBlocker[]> {
|
||||||
|
await loadVisibleWorkOrder(ctx, workOrderId);
|
||||||
|
return computeCompletionBlockers(ctx, workOrderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Blockers of a concrete transition (completion, signature, billing release). */
|
||||||
|
export async function transitionBlockers(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
wo: { id: string; status: WorkOrderStatus; signatureRequired: boolean },
|
||||||
|
to: WorkOrderStatus,
|
||||||
|
): Promise<CompletionBlocker[]> {
|
||||||
|
if (to === "technically_completed") return computeCompletionBlockers(ctx, wo.id);
|
||||||
|
|
||||||
|
if (to === "in_review" && (wo.status === "technically_completed" || wo.status === "signature_pending") && wo.signatureRequired) {
|
||||||
|
// Any recorded outcome counts (signed, refused/absent with reason — spec §18.2).
|
||||||
|
const signatures = await ctx.db.signature.count({
|
||||||
|
where: { report: { workOrderId: wo.id, type: "completion", status: { not: "superseded" } } },
|
||||||
|
});
|
||||||
|
return signatures > 0 ? [] : [{ kind: "missing_field", field: "signature" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (to === "released_for_billing" && wo.status === "in_review") {
|
||||||
|
const approved = await ctx.db.report.count({ where: { workOrderId: wo.id, type: "completion", status: "approved" } });
|
||||||
|
return approved > 0 ? [] : [{ kind: "missing_field", field: "approved_completion_report" }];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { reapplySyncOperation } from "@/server/services/work-orders/sync-reapply";
|
||||||
|
|
||||||
|
/** Backoffice list of offline sync conflicts (ARCHITEKTUR §4.6) — `work_order:write`. */
|
||||||
|
export async function listSyncConflicts(ctx: ServiceCtx) {
|
||||||
|
assertCan(ctx, "work_order:write");
|
||||||
|
const ops = await ctx.db.syncOperation.findMany({
|
||||||
|
where: { status: "conflict", resolvedAt: null },
|
||||||
|
orderBy: { receivedAt: "desc" },
|
||||||
|
take: 200,
|
||||||
|
});
|
||||||
|
const userIds = [...new Set(ops.map((o) => o.userId))];
|
||||||
|
const woIds = [...new Set(ops.filter((o) => o.entityType === "work_order" && o.entityId).map((o) => o.entityId!))];
|
||||||
|
const [users, orders] = await Promise.all([
|
||||||
|
userIds.length ? ctx.db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } }) : [],
|
||||||
|
woIds.length ? ctx.db.workOrder.findMany({ where: { id: { in: woIds } }, select: { id: true, number: true, title: true, status: true, version: true } }) : [],
|
||||||
|
]);
|
||||||
|
const u = new Map(users.map((x) => [x.id, x.name]));
|
||||||
|
const w = new Map(orders.map((x) => [x.id, x]));
|
||||||
|
return ops.map((o) => ({
|
||||||
|
...o,
|
||||||
|
userName: u.get(o.userId) ?? null,
|
||||||
|
workOrder: o.entityType === "work_order" && o.entityId ? w.get(o.entityId) ?? null : null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadOpenConflict(ctx: ServiceCtx, opId: string) {
|
||||||
|
assertCan(ctx, "work_order:write");
|
||||||
|
const op = await ctx.db.syncOperation.findFirst({ where: { id: opId, status: "conflict", resolvedAt: null } });
|
||||||
|
if (!op) throw new ServiceError("not_found", "sync_conflict_not_found");
|
||||||
|
return op;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Discard: mark resolved, nothing is applied. */
|
||||||
|
export async function discardSyncConflict(ctx: ServiceCtx, opId: string): Promise<void> {
|
||||||
|
const op = await loadOpenConflict(ctx, opId);
|
||||||
|
const now = new Date();
|
||||||
|
const res = await ctx.db.syncOperation.updateMany({
|
||||||
|
where: { id: op.id, resolvedAt: null },
|
||||||
|
data: { status: "rejected", resolvedAt: now, resolvedById: ctx.userId, errorCode: "discarded" },
|
||||||
|
});
|
||||||
|
if (res.count !== 1) throw new ServiceError("conflict", "sync_conflict_already_resolved");
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "update",
|
||||||
|
entity: "sync_operation",
|
||||||
|
entityId: op.id,
|
||||||
|
before: { status: op.status, resolvedAt: null },
|
||||||
|
after: { status: "rejected", resolution: "discarded", resolvedAt: now.toISOString() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load DB-authoritative permissions of the device user (the op is re-applied as that user). */
|
||||||
|
async function permissionsOf(ctx: ServiceCtx, userId: string): Promise<Set<string>> {
|
||||||
|
const user = await ctx.db.user.findFirst({
|
||||||
|
where: { id: userId, status: "ACTIVE" },
|
||||||
|
select: { userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } } },
|
||||||
|
});
|
||||||
|
if (!user) throw new ServiceError("invalid", "sync_user_inactive");
|
||||||
|
return new Set(user.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take over: re-apply the operation against the current state, as the original device user
|
||||||
|
* (their permissions and scope), then mark it applied + resolved. Audit is written for both
|
||||||
|
* the domain change (by the service) and the resolution (resolver).
|
||||||
|
*/
|
||||||
|
export async function applySyncConflict(ctx: ServiceCtx, opId: string): Promise<{ entityVersion?: number }> {
|
||||||
|
const op = await loadOpenConflict(ctx, opId);
|
||||||
|
const opCtx: ServiceCtx = { db: ctx.db, tenantId: ctx.tenantId, userId: op.userId, permissions: await permissionsOf(ctx, op.userId) };
|
||||||
|
const result = await reapplySyncOperation(opCtx, op);
|
||||||
|
const now = new Date();
|
||||||
|
await ctx.db.syncOperation.updateMany({
|
||||||
|
where: { id: op.id, resolvedAt: null },
|
||||||
|
data: { status: "applied", resolvedAt: now, resolvedById: ctx.userId, errorCode: null, result: { reappliedBy: ctx.userId, ...result } },
|
||||||
|
});
|
||||||
|
await writeAuditLog({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
actorId: ctx.userId,
|
||||||
|
action: "update",
|
||||||
|
entity: "sync_operation",
|
||||||
|
entityId: op.id,
|
||||||
|
before: { status: op.status, resolvedAt: null },
|
||||||
|
after: { status: "applied", resolution: "reapplied", resolvedAt: now.toISOString(), ...result },
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import {
|
||||||
|
createWorkOrderSchema,
|
||||||
|
slugKey,
|
||||||
|
templateItemSchema,
|
||||||
|
templatePhotoSchema,
|
||||||
|
type CreateWorkOrderInput,
|
||||||
|
} from "@/lib/work-orders/schemas";
|
||||||
|
import { nextNumber } from "@/server/services/numbering";
|
||||||
|
import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { auditWorkOrder, parseInput, snapshot, uniqueKey } from "@/server/services/work-orders/_shared";
|
||||||
|
|
||||||
|
export type { CreateWorkOrderInput } from "@/lib/work-orders/schemas";
|
||||||
|
|
||||||
|
export type CreatedWorkOrder = { id: string; number: string; status: string; version: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a work order (backoffice form, PDF import L3, emergency L8).
|
||||||
|
* - Permission: `work_order:write`; emergency orders alternatively `emergency:create`.
|
||||||
|
* - Number from the tenant sequence (`work_order` → A-…, `emergency` → N-…).
|
||||||
|
* - From the order type: signatureRequired (unless given) and the active checklist template
|
||||||
|
* (checklist items + required photos) unless explicit lists are passed or applyTemplate=false.
|
||||||
|
*/
|
||||||
|
export async function createWorkOrder(ctx: ServiceCtx, raw: CreateWorkOrderInput): Promise<CreatedWorkOrder> {
|
||||||
|
const input = parseInput(createWorkOrderSchema, raw);
|
||||||
|
if (!(can(ctx, "work_order:write") || (input.isEmergency && can(ctx, "emergency:create")))) {
|
||||||
|
assertCan(ctx, "work_order:write");
|
||||||
|
}
|
||||||
|
if (input.status === "in_progress" && !input.isEmergency) {
|
||||||
|
throw new ServiceError("invalid", "initial_status_not_allowed", { status: input.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
const customer = await ctx.db.customer.findFirst({
|
||||||
|
where: { id: input.customerId, deletedAt: null, status: { not: "merged" } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!customer) throw new ServiceError("invalid", "customer_not_found");
|
||||||
|
if (input.siteId) {
|
||||||
|
const site = await ctx.db.site.findFirst({ where: { id: input.siteId, deletedAt: null }, select: { customerId: true } });
|
||||||
|
if (!site) throw new ServiceError("invalid", "site_not_found");
|
||||||
|
if (site.customerId !== input.customerId) throw new ServiceError("invalid", "site_customer_mismatch");
|
||||||
|
}
|
||||||
|
if (input.contactId) {
|
||||||
|
const contact = await ctx.db.contact.findFirst({ where: { id: input.contactId, deletedAt: null }, select: { customerId: true } });
|
||||||
|
if (!contact) throw new ServiceError("invalid", "contact_not_found");
|
||||||
|
if (contact.customerId !== input.customerId) throw new ServiceError("invalid", "contact_customer_mismatch");
|
||||||
|
}
|
||||||
|
|
||||||
|
let signatureRequired = input.signatureRequired ?? true;
|
||||||
|
let templateItems: z.output<typeof templateItemSchema>[] = [];
|
||||||
|
let templatePhotos: z.output<typeof templatePhotoSchema>[] = [];
|
||||||
|
if (input.orderTypeId) {
|
||||||
|
const ot = await ctx.db.orderType.findFirst({
|
||||||
|
where: { id: input.orderTypeId, active: true },
|
||||||
|
select: { id: true, signatureRequired: true },
|
||||||
|
});
|
||||||
|
if (!ot) throw new ServiceError("invalid", "order_type_not_found");
|
||||||
|
if (input.signatureRequired === undefined) signatureRequired = ot.signatureRequired;
|
||||||
|
if (input.applyTemplate) {
|
||||||
|
const tpl = await ctx.db.checklistTemplate.findFirst({
|
||||||
|
where: { orderTypeId: ot.id, active: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
select: { items: true, requiredPhotos: true },
|
||||||
|
});
|
||||||
|
if (tpl) {
|
||||||
|
templateItems = z.array(templateItemSchema).safeParse(tpl.items).data ?? [];
|
||||||
|
templatePhotos = z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemKeys = new Set<string>();
|
||||||
|
const items = (input.checklistItems ?? templateItems).map((i, idx) => ({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
key: uniqueKey(i.key || slugKey(i.label), itemKeys),
|
||||||
|
label: i.label,
|
||||||
|
required: i.required ?? false,
|
||||||
|
requiresPhoto: i.requiresPhoto ?? false,
|
||||||
|
sortOrder: ("sortOrder" in i && typeof i.sortOrder === "number" ? i.sortOrder : (idx + 1) * 10),
|
||||||
|
}));
|
||||||
|
const photoKeys = new Set<string>();
|
||||||
|
const photos = (input.photoRequirements ?? templatePhotos).map((p, idx) => ({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
key: uniqueKey(p.key || slugKey(p.label), photoKeys),
|
||||||
|
label: p.label,
|
||||||
|
sortOrder: ("sortOrder" in p && typeof p.sortOrder === "number" ? p.sortOrder : (idx + 1) * 10),
|
||||||
|
}));
|
||||||
|
const materials = (input.materials ?? []).map((m, idx) => ({
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
name: m.name,
|
||||||
|
articleNumber: m.articleNumber ?? null,
|
||||||
|
plannedQuantity: m.plannedQuantity,
|
||||||
|
unit: m.unit,
|
||||||
|
notes: m.notes ?? null,
|
||||||
|
sortOrder: m.sortOrder ?? (idx + 1) * 10,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const number = await nextNumber(ctx.db, ctx.tenantId, input.numberKey);
|
||||||
|
const wo = await ctx.db.workOrder.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
number,
|
||||||
|
title: input.title,
|
||||||
|
customerId: input.customerId,
|
||||||
|
siteId: input.siteId ?? null,
|
||||||
|
contactId: input.contactId ?? null,
|
||||||
|
orderTypeId: input.orderTypeId ?? null,
|
||||||
|
priority: input.priority,
|
||||||
|
status: input.status,
|
||||||
|
description: input.description,
|
||||||
|
scope: input.scope,
|
||||||
|
plannedStart: input.plannedStart ?? null,
|
||||||
|
plannedEnd: input.plannedEnd ?? null,
|
||||||
|
signatureRequired,
|
||||||
|
billingType: input.billingType ?? null,
|
||||||
|
internalNotes: input.internalNotes,
|
||||||
|
technicianNotes: input.technicianNotes,
|
||||||
|
externalOrderNumber: input.externalOrderNumber,
|
||||||
|
offerNumber: input.offerNumber,
|
||||||
|
isEmergency: input.isEmergency,
|
||||||
|
emergencyReason: input.emergencyReason,
|
||||||
|
sourceImportId: input.sourceImportId ?? null,
|
||||||
|
createdById: ctx.userId,
|
||||||
|
checklistItems: { create: items },
|
||||||
|
photoRequirements: { create: photos },
|
||||||
|
materialPlans: { create: materials },
|
||||||
|
statusHistory: { create: { tenantId: ctx.tenantId, fromStatus: null, toStatus: input.status, actorId: ctx.userId } },
|
||||||
|
},
|
||||||
|
select: { id: true, number: true, status: true, version: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
await auditWorkOrder(ctx, {
|
||||||
|
action: "create",
|
||||||
|
workOrderId: wo.id,
|
||||||
|
after: snapshot({
|
||||||
|
...input,
|
||||||
|
number,
|
||||||
|
signatureRequired,
|
||||||
|
checklistItems: items.length,
|
||||||
|
photoRequirements: photos.length,
|
||||||
|
materials: materials.length,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
return wo;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { PRESETS, type Preset, type WorkOrderFilter } from "@/lib/work-orders/filters";
|
||||||
|
import { can, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { tenantTimezone } from "@/server/services/work-orders/_shared";
|
||||||
|
import { buildWorkOrderWhere, presetWhere } from "@/server/services/work-orders/list";
|
||||||
|
|
||||||
|
export type DashboardTiles = Record<Preset, number> & { reportsToReview: number; syncConflicts: number };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backoffice dashboard counts (spec §21). Every tile = base filter ∧ preset, within workOrderScope.
|
||||||
|
* "Berichte zur Prüfung" counts reports (submitted / team_approved); "Sync-Konflikte" counts
|
||||||
|
* unresolved SyncOperations with status conflict.
|
||||||
|
*/
|
||||||
|
export async function getDashboardTiles(ctx: ServiceCtx, filter: WorkOrderFilter): Promise<DashboardTiles> {
|
||||||
|
const pc = { now: new Date(), timeZone: await tenantTimezone(ctx) };
|
||||||
|
const base = await buildWorkOrderWhere(ctx, { ...filter, preset: undefined }, { pc });
|
||||||
|
|
||||||
|
const counts = await Promise.all(
|
||||||
|
PRESETS.map((preset) => ctx.db.workOrder.count({ where: { AND: [base, presetWhere(preset, pc)] } })),
|
||||||
|
);
|
||||||
|
const [reportsToReview, syncConflicts] = await Promise.all([
|
||||||
|
ctx.db.report.count({ where: { status: { in: ["submitted", "team_approved"] }, workOrder: base } }),
|
||||||
|
can(ctx, "work_order:write")
|
||||||
|
? ctx.db.syncOperation.count({ where: { status: "conflict", resolvedAt: null } })
|
||||||
|
: Promise.resolve(0),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const tiles = Object.fromEntries(PRESETS.map((p, i) => [p, counts[i]])) as Record<Preset, number>;
|
||||||
|
return { ...tiles, reportsToReview, syncConflicts };
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { allowedTransitions, type CompletionBlocker, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { computeCompletionBlockers } from "@/server/services/work-orders/completion";
|
||||||
|
import { getMaterialOverview } from "@/server/services/work-orders/materials";
|
||||||
|
import { mayTransition } from "@/server/services/work-orders/transition";
|
||||||
|
import { allowedDocumentVisibility, workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
/** Full read model of /work-orders/[id] and GET /api/v1/work-orders/[id]. */
|
||||||
|
export async function getWorkOrderDetail(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
const scope = await workOrderScope(ctx);
|
||||||
|
const wo = await ctx.db.workOrder.findFirst({
|
||||||
|
where: { AND: [{ id: workOrderId }, scope] },
|
||||||
|
include: {
|
||||||
|
customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, phone: true, email: true, street: true, houseNumber: true, postalCode: true, city: true } },
|
||||||
|
site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, accessNotes: true, safetyNotes: true } },
|
||||||
|
contact: { select: { id: true, name: true, phone: true, mobile: true, email: true } },
|
||||||
|
orderType: { select: { id: true, name: true, key: true } },
|
||||||
|
team: { select: { id: true, name: true } },
|
||||||
|
teamLead: { select: { id: true, name: true } },
|
||||||
|
assignees: { select: { user: { select: { id: true, name: true } } } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!wo) throw new ServiceError("not_found", "work_order_not_found");
|
||||||
|
return wo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorkOrderDetail = Awaited<ReturnType<typeof getWorkOrderDetail>>;
|
||||||
|
|
||||||
|
/** Transitions the current user may trigger now (UI buttons). */
|
||||||
|
export function availableTransitions(ctx: ServiceCtx, status: WorkOrderStatus): WorkOrderStatus[] {
|
||||||
|
return allowedTransitions(status).filter((to) => mayTransition(ctx, status, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getChecklistTab(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
const [items, requirements, blockers] = await Promise.all([
|
||||||
|
ctx.db.checklistItem.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" }, include: { _count: { select: { photos: true } } } }),
|
||||||
|
ctx.db.photoRequirement.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" }, include: { _count: { select: { photos: true } } } }),
|
||||||
|
computeCompletionBlockers(ctx, workOrderId),
|
||||||
|
]);
|
||||||
|
return { items, requirements, blockers: blockers as CompletionBlocker[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTimesTab(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
const sessions = await ctx.db.workSession.findMany({
|
||||||
|
where: { workOrderId },
|
||||||
|
orderBy: { startedAt: "desc" },
|
||||||
|
include: { user: { select: { id: true, name: true } }, entries: { orderBy: { startedAt: "asc" } } },
|
||||||
|
});
|
||||||
|
return sessions.map((s) => {
|
||||||
|
const minutes = s.entries
|
||||||
|
.filter((e) => e.type !== "break")
|
||||||
|
.reduce((sum, e) => sum + ((e.endedAt ?? new Date()).getTime() - e.startedAt.getTime()) / 60000, 0);
|
||||||
|
return { ...s, workMinutes: Math.round(minutes) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPhotosTab(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
const photos = await ctx.db.photo.findMany({
|
||||||
|
where: { workOrderId },
|
||||||
|
orderBy: { takenAt: "asc" },
|
||||||
|
include: { photoRequirement: { select: { label: true } }, checklistItem: { select: { label: true } } },
|
||||||
|
});
|
||||||
|
const docs = photos.length
|
||||||
|
? await ctx.db.document.findMany({
|
||||||
|
where: { id: { in: photos.map((p) => p.documentId) }, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } },
|
||||||
|
select: { id: true, fileName: true, storageKey: true, previewKey: true },
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
const byId = new Map(docs.map((d) => [d.id, d]));
|
||||||
|
return photos.filter((p) => byId.has(p.documentId)).map((p) => ({ ...p, document: byId.get(p.documentId)! }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getNotesTab(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
const notes = await ctx.db.activityNote.findMany({ where: { workOrderId, deletedAt: null }, orderBy: { createdAt: "desc" } });
|
||||||
|
const names = await userNames(ctx, notes.map((n) => n.authorId));
|
||||||
|
return notes.map((n) => ({ ...n, authorName: n.authorId ? names.get(n.authorId) ?? null : null }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getReportsTab(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
return ctx.db.report.findMany({
|
||||||
|
where: { workOrderId, status: { not: "superseded" } },
|
||||||
|
orderBy: [{ reportDate: "desc" }, { version: "desc" }],
|
||||||
|
select: { id: true, type: true, reportDate: true, version: true, status: true, approvedAt: true, rejectionReason: true, signature: { select: { outcome: true, signerName: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDocumentsTab(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
return ctx.db.document.findMany({
|
||||||
|
where: { workOrderId, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) }, category: { notIn: ["photo", "voice_note", "signature"] } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { id: true, title: true, fileName: true, category: true, visibility: true, version: true, mimeType: true, fileSize: true, storageKey: true, createdAt: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getHistoryTab(ctx: ServiceCtx, workOrderId: string) {
|
||||||
|
const [changes, audit] = await Promise.all([
|
||||||
|
ctx.db.workOrderStatusChange.findMany({ where: { workOrderId }, orderBy: { createdAt: "desc" } }),
|
||||||
|
can(ctx, "audit:read") || can(ctx, "work_order:read_all")
|
||||||
|
? ctx.db.auditLog.findMany({
|
||||||
|
where: { entity: "work_order", entityId: workOrderId },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 200,
|
||||||
|
select: { id: true, action: true, actorId: true, createdAt: true, before: true, after: true },
|
||||||
|
})
|
||||||
|
: Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
const names = await userNames(ctx, [...changes.map((c) => c.actorId), ...audit.map((a) => a.actorId)]);
|
||||||
|
return {
|
||||||
|
changes: changes.map((c) => ({ ...c, actorName: c.actorId ? names.get(c.actorId) ?? null : null })),
|
||||||
|
audit: audit.map((a) => ({ ...a, actorName: a.actorId ? names.get(a.actorId) ?? null : null })),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { getMaterialOverview };
|
||||||
|
|
||||||
|
async function userNames(ctx: ServiceCtx, ids: (string | null)[]): Promise<Map<string, string>> {
|
||||||
|
const unique = [...new Set(ids.filter((x): x is string => !!x))];
|
||||||
|
if (!unique.length) return new Map();
|
||||||
|
const users = await ctx.db.user.findMany({ where: { id: { in: unique } }, select: { id: true, name: true } });
|
||||||
|
return new Map(users.map((u) => [u.id, u.name]));
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,161 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import { OPEN_STATUSES, STATUS_GROUP, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import type { ListParams, Preset, WorkOrderFilter } from "@/lib/work-orders/filters";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { tenantTimezone, zonedDayBounds } from "@/server/services/work-orders/_shared";
|
||||||
|
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
/** Work still to be done (no completion yet) — basis of "overdue". */
|
||||||
|
const PENDING_WORK: WorkOrderStatus[] = [
|
||||||
|
"draft",
|
||||||
|
"review_required",
|
||||||
|
"planned",
|
||||||
|
"assigned",
|
||||||
|
"accepted",
|
||||||
|
"en_route",
|
||||||
|
"in_progress",
|
||||||
|
"paused",
|
||||||
|
"waiting_material",
|
||||||
|
"daily_report_created",
|
||||||
|
];
|
||||||
|
|
||||||
|
export type PresetContext = { now: Date; timeZone: string };
|
||||||
|
|
||||||
|
/** Dashboard tile definitions (§21) — shared by dashboard counts and the list `preset` filter. */
|
||||||
|
export function presetWhere(preset: Preset, pc: PresetContext): Prisma.WorkOrderWhereInput {
|
||||||
|
const open = { status: { in: [...OPEN_STATUSES] } };
|
||||||
|
switch (preset) {
|
||||||
|
case "open":
|
||||||
|
return open;
|
||||||
|
case "today": {
|
||||||
|
const { start, end } = zonedDayBounds(pc.now, pc.timeZone);
|
||||||
|
return {
|
||||||
|
...open,
|
||||||
|
plannedStart: { lte: end },
|
||||||
|
OR: [{ plannedEnd: { gte: start } }, { plannedEnd: null, plannedStart: { gte: start } }],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case "running":
|
||||||
|
return { status: { in: ["en_route", "in_progress", "paused", "waiting_material"] } };
|
||||||
|
case "not_accepted":
|
||||||
|
return { status: "assigned" };
|
||||||
|
case "overdue":
|
||||||
|
return { status: { in: PENDING_WORK }, plannedEnd: { lt: pc.now } };
|
||||||
|
case "reports_in_review":
|
||||||
|
return { reports: { some: { status: { in: ["submitted", "team_approved"] } } } };
|
||||||
|
case "completed":
|
||||||
|
return { status: { in: ["technically_completed", "signature_pending", "in_review", "released_for_billing", "billed"] } };
|
||||||
|
case "billing":
|
||||||
|
return { status: "released_for_billing" };
|
||||||
|
case "emergency_new":
|
||||||
|
return {
|
||||||
|
isEmergency: true,
|
||||||
|
OR: [
|
||||||
|
{ status: { in: ["draft", "review_required", "in_review"] } },
|
||||||
|
{ status: { in: [...OPEN_STATUSES] }, createdAt: { gte: new Date(pc.now.getTime() - 24 * 3600 * 1000) } },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
case "missing_signatures":
|
||||||
|
return { status: "signature_pending" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusesOfGroup(group: StatusGroup): WorkOrderStatus[] {
|
||||||
|
return (Object.keys(STATUS_GROUP) as WorkOrderStatus[]).filter((s) => STATUS_GROUP[s] === group);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter → where (always AND-ed with workOrderScope). `omitStatus` drops status/group so the
|
||||||
|
* status-group tabs can show counts for the remaining filter.
|
||||||
|
*/
|
||||||
|
export async function buildWorkOrderWhere(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
f: WorkOrderFilter,
|
||||||
|
opts: { omitStatus?: boolean; pc?: PresetContext } = {},
|
||||||
|
): Promise<Prisma.WorkOrderWhereInput> {
|
||||||
|
const and: Prisma.WorkOrderWhereInput[] = [await workOrderScope(ctx)];
|
||||||
|
if (f.from) and.push({ OR: [{ plannedEnd: { gte: f.from } }, { plannedEnd: null, plannedStart: { gte: f.from } }] });
|
||||||
|
if (f.to) and.push({ plannedStart: { lte: f.to } });
|
||||||
|
if (f.customerId) and.push({ customerId: f.customerId });
|
||||||
|
if (f.siteId) and.push({ siteId: f.siteId });
|
||||||
|
if (f.teamId) and.push({ assignedTeamId: f.teamId });
|
||||||
|
if (f.userId) and.push({ OR: [{ assignees: { some: { userId: f.userId } } }, { teamLeadUserId: f.userId }] });
|
||||||
|
if (f.orderTypeId) and.push({ orderTypeId: f.orderTypeId });
|
||||||
|
if (f.priority) and.push({ priority: f.priority });
|
||||||
|
if (!opts.omitStatus) {
|
||||||
|
if (f.statuses?.length) and.push({ status: { in: f.statuses } });
|
||||||
|
if (f.group) and.push({ status: { in: statusesOfGroup(f.group) } });
|
||||||
|
}
|
||||||
|
if (f.preset) {
|
||||||
|
const pc = opts.pc ?? { now: new Date(), timeZone: await tenantTimezone(ctx) };
|
||||||
|
and.push(presetWhere(f.preset, pc));
|
||||||
|
}
|
||||||
|
if (f.q) {
|
||||||
|
const q = { contains: f.q, mode: "insensitive" as const };
|
||||||
|
and.push({
|
||||||
|
OR: [
|
||||||
|
{ number: q },
|
||||||
|
{ title: q },
|
||||||
|
{ externalOrderNumber: q },
|
||||||
|
{ customer: { companyName: q } },
|
||||||
|
{ customer: { lastName: q } },
|
||||||
|
{ site: { name: q } },
|
||||||
|
{ site: { city: q } },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { AND: and };
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LIST_SELECT = {
|
||||||
|
id: true,
|
||||||
|
number: true,
|
||||||
|
title: true,
|
||||||
|
status: true,
|
||||||
|
priority: true,
|
||||||
|
plannedStart: true,
|
||||||
|
plannedEnd: true,
|
||||||
|
isEmergency: true,
|
||||||
|
version: true,
|
||||||
|
updatedAt: true,
|
||||||
|
customer: { select: { id: true, companyName: true, firstName: true, lastName: true } },
|
||||||
|
site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true } },
|
||||||
|
team: { select: { id: true, name: true } },
|
||||||
|
orderType: { select: { id: true, name: true } },
|
||||||
|
assignees: { select: { user: { select: { id: true, name: true } } } },
|
||||||
|
} satisfies Prisma.WorkOrderSelect;
|
||||||
|
|
||||||
|
export type WorkOrderListItem = Prisma.WorkOrderGetPayload<{ select: typeof LIST_SELECT }>;
|
||||||
|
|
||||||
|
export type WorkOrderListResult = {
|
||||||
|
items: WorkOrderListItem[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
groupCounts: Record<StatusGroup, number>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function listWorkOrders(ctx: ServiceCtx, p: ListParams): Promise<WorkOrderListResult> {
|
||||||
|
const pc = p.preset ? { now: new Date(), timeZone: await tenantTimezone(ctx) } : undefined;
|
||||||
|
const [where, whereNoStatus] = await Promise.all([
|
||||||
|
buildWorkOrderWhere(ctx, p, { pc }),
|
||||||
|
buildWorkOrderWhere(ctx, p, { omitStatus: true, pc }),
|
||||||
|
]);
|
||||||
|
const orderBy: Prisma.WorkOrderOrderByWithRelationInput[] =
|
||||||
|
p.sort === "plannedStart"
|
||||||
|
? [{ plannedStart: { sort: p.dir, nulls: "last" } }, { number: "asc" }]
|
||||||
|
: [{ [p.sort]: p.dir }, { number: "asc" }];
|
||||||
|
|
||||||
|
const [items, total, grouped] = await Promise.all([
|
||||||
|
ctx.db.workOrder.findMany({ where, select: LIST_SELECT, orderBy, skip: (p.page - 1) * p.pageSize, take: p.pageSize }),
|
||||||
|
ctx.db.workOrder.count({ where }),
|
||||||
|
ctx.db.workOrder.groupBy({ by: ["status"], where: whereNoStatus, _count: { _all: true } }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groupCounts = Object.fromEntries(
|
||||||
|
["new", "planned", "en_route", "in_progress", "documentation_incomplete", "in_review", "ready_for_billing", "billed", "cancelled"].map((g) => [g, 0]),
|
||||||
|
) as Record<StatusGroup, number>;
|
||||||
|
for (const row of grouped) groupCounts[STATUS_GROUP[row.status as WorkOrderStatus]] += row._count._all;
|
||||||
|
|
||||||
|
return { items, total, page: p.page, pageSize: p.pageSize, groupCounts };
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { materialPlanInputSchema, type MaterialPlanInput } from "@/lib/work-orders/schemas";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import {
|
||||||
|
assertNotLocked,
|
||||||
|
auditWorkOrder,
|
||||||
|
loadVisibleWorkOrder,
|
||||||
|
parseInput,
|
||||||
|
PLANNING_LOCKED,
|
||||||
|
snapshot,
|
||||||
|
touchWorkOrder,
|
||||||
|
} from "@/server/services/work-orders/_shared";
|
||||||
|
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
/** Material plan (Materialvorgabe, spec §13.1) — maintained by the backoffice (`work_order:write`). */
|
||||||
|
|
||||||
|
async function loadPlan(ctx: ServiceCtx, planId: string) {
|
||||||
|
const scope = await workOrderScope(ctx);
|
||||||
|
const plan = await ctx.db.materialPlan.findFirst({ where: { id: planId, workOrder: scope } });
|
||||||
|
if (!plan) throw new ServiceError("not_found", "material_plan_not_found");
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addMaterialPlan(ctx: ServiceCtx, workOrderId: string, raw: MaterialPlanInput) {
|
||||||
|
assertCan(ctx, "work_order:write");
|
||||||
|
const input = parseInput(materialPlanInputSchema, raw);
|
||||||
|
const wo = await loadVisibleWorkOrder(ctx, workOrderId);
|
||||||
|
assertNotLocked(wo, PLANNING_LOCKED);
|
||||||
|
const count = await ctx.db.materialPlan.count({ where: { workOrderId } });
|
||||||
|
const plan = await ctx.db.materialPlan.create({
|
||||||
|
data: {
|
||||||
|
tenantId: ctx.tenantId,
|
||||||
|
workOrderId,
|
||||||
|
name: input.name,
|
||||||
|
articleNumber: input.articleNumber,
|
||||||
|
plannedQuantity: input.plannedQuantity,
|
||||||
|
unit: input.unit,
|
||||||
|
notes: input.notes,
|
||||||
|
sortOrder: input.sortOrder ?? (count + 1) * 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await touchWorkOrder(ctx, workOrderId);
|
||||||
|
await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "material_plan.add", plan: snapshot(plan) } });
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMaterialPlan(ctx: ServiceCtx, planId: string, raw: MaterialPlanInput) {
|
||||||
|
assertCan(ctx, "work_order:write");
|
||||||
|
const input = parseInput(materialPlanInputSchema, raw);
|
||||||
|
const before = await loadPlan(ctx, planId);
|
||||||
|
const wo = await loadVisibleWorkOrder(ctx, before.workOrderId);
|
||||||
|
assertNotLocked(wo, PLANNING_LOCKED);
|
||||||
|
const plan = await ctx.db.materialPlan.update({
|
||||||
|
where: { id: planId },
|
||||||
|
data: {
|
||||||
|
name: input.name,
|
||||||
|
articleNumber: input.articleNumber,
|
||||||
|
plannedQuantity: input.plannedQuantity,
|
||||||
|
unit: input.unit,
|
||||||
|
notes: input.notes,
|
||||||
|
...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await touchWorkOrder(ctx, wo.id);
|
||||||
|
await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: { op: "material_plan.update", plan: snapshot(before) }, after: { plan: snapshot(plan) } });
|
||||||
|
return plan;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MaterialPlan has no deletedAt (planning data). Removal is refused once usages reference it. */
|
||||||
|
export async function removeMaterialPlan(ctx: ServiceCtx, planId: string): Promise<void> {
|
||||||
|
assertCan(ctx, "work_order:write");
|
||||||
|
const before = await loadPlan(ctx, planId);
|
||||||
|
const wo = await loadVisibleWorkOrder(ctx, before.workOrderId);
|
||||||
|
assertNotLocked(wo, PLANNING_LOCKED);
|
||||||
|
const usages = await ctx.db.materialUsage.count({ where: { materialPlanId: planId } });
|
||||||
|
if (usages > 0) throw new ServiceError("invalid", "material_in_use");
|
||||||
|
await ctx.db.materialPlan.deleteMany({ where: { id: planId } });
|
||||||
|
await touchWorkOrder(ctx, wo.id);
|
||||||
|
await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: { op: "material_plan.remove", plan: snapshot(before) } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MaterialRow = {
|
||||||
|
planId: string | null;
|
||||||
|
name: string;
|
||||||
|
articleNumber: string | null;
|
||||||
|
unit: string;
|
||||||
|
planned: number | null;
|
||||||
|
actual: number | null;
|
||||||
|
deviation: number | null;
|
||||||
|
statuses: string[];
|
||||||
|
reasons: string[];
|
||||||
|
notes: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Planned vs. actual incl. deviations (backoffice read view, US-009). */
|
||||||
|
export async function getMaterialOverview(ctx: ServiceCtx, workOrderId: string): Promise<MaterialRow[]> {
|
||||||
|
await loadVisibleWorkOrder(ctx, workOrderId);
|
||||||
|
const [plans, usages] = await Promise.all([
|
||||||
|
ctx.db.materialPlan.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" } }),
|
||||||
|
ctx.db.materialUsage.findMany({ where: { workOrderId }, orderBy: { createdAt: "asc" } }),
|
||||||
|
]);
|
||||||
|
const rows: MaterialRow[] = plans.map((p) => {
|
||||||
|
const u = usages.filter((x) => x.materialPlanId === p.id);
|
||||||
|
const planned = Number(p.plannedQuantity);
|
||||||
|
const actual = u.length ? u.reduce((s, x) => s + Number(x.actualQuantity), 0) : null;
|
||||||
|
return {
|
||||||
|
planId: p.id,
|
||||||
|
name: p.name,
|
||||||
|
articleNumber: p.articleNumber,
|
||||||
|
unit: p.unit,
|
||||||
|
planned,
|
||||||
|
actual,
|
||||||
|
deviation: actual === null ? null : Math.round((actual - planned) * 1000) / 1000,
|
||||||
|
statuses: [...new Set(u.map((x) => x.usageStatus))],
|
||||||
|
reasons: u.map((x) => x.deviationReason).filter((r): r is string => !!r),
|
||||||
|
notes: p.notes,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
for (const x of usages.filter((u) => !u.materialPlanId)) {
|
||||||
|
rows.push({
|
||||||
|
planId: null,
|
||||||
|
name: x.name,
|
||||||
|
articleNumber: x.articleNumber,
|
||||||
|
unit: x.unit,
|
||||||
|
planned: null,
|
||||||
|
actual: Number(x.actualQuantity),
|
||||||
|
deviation: Number(x.actualQuantity),
|
||||||
|
statuses: [x.usageStatus],
|
||||||
|
reasons: x.deviationReason ? [x.deviationReason] : [],
|
||||||
|
notes: x.notes,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { customerScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
/** Lookup data for the create/assign popups and filter bars (read-only, scoped). */
|
||||||
|
|
||||||
|
export async function searchCustomerOptions(ctx: ServiceCtx, q: string | undefined) {
|
||||||
|
const scope = await customerScope(ctx);
|
||||||
|
const term = q?.trim();
|
||||||
|
const like = term ? { contains: term.slice(0, 100), mode: "insensitive" as const } : undefined;
|
||||||
|
return ctx.db.customer.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
scope,
|
||||||
|
{ status: { not: "merged" } },
|
||||||
|
like ? { OR: [{ companyName: like }, { lastName: like }, { firstName: like }, { customerNumber: like }, { city: like }, { street: like }] } : {},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true, status: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: 20,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Customer select for filter bars (dashboard / list). */
|
||||||
|
export async function customerFilterOptions(ctx: ServiceCtx) {
|
||||||
|
const scope = await customerScope(ctx);
|
||||||
|
return ctx.db.customer.findMany({
|
||||||
|
where: { AND: [scope, { status: { not: "merged" } }] },
|
||||||
|
select: { id: true, companyName: true, firstName: true, lastName: true },
|
||||||
|
orderBy: [{ companyName: "asc" }, { lastName: "asc" }],
|
||||||
|
take: 300,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function siteFilterOptions(ctx: ServiceCtx, customerId: string) {
|
||||||
|
return ctx.db.site.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true }, orderBy: { name: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function customerOption(ctx: ServiceCtx, customerId: string) {
|
||||||
|
const scope = await customerScope(ctx);
|
||||||
|
const customer = await ctx.db.customer.findFirst({
|
||||||
|
where: { AND: [scope, { id: customerId }] },
|
||||||
|
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true },
|
||||||
|
});
|
||||||
|
if (!customer) return null;
|
||||||
|
const [sites, contacts] = await Promise.all([
|
||||||
|
ctx.db.site.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true, street: true, houseNumber: true, city: true }, orderBy: { name: "asc" } }),
|
||||||
|
ctx.db.contact.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true, role: true }, orderBy: { name: "asc" } }),
|
||||||
|
]);
|
||||||
|
return { customer, sites, contacts };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function teamOptions(ctx: ServiceCtx) {
|
||||||
|
const now = new Date();
|
||||||
|
return ctx.db.team.findMany({
|
||||||
|
where: { deletedAt: null, status: "active" },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
leaderUserId: true,
|
||||||
|
members: {
|
||||||
|
where: { validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] },
|
||||||
|
select: { user: { select: { id: true, name: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function userOptions(ctx: ServiceCtx) {
|
||||||
|
return ctx.db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checklistTemplateOptions(ctx: ServiceCtx) {
|
||||||
|
return ctx.db.checklistTemplate.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function customerDisplayName(c: { companyName?: string | null; firstName?: string | null; lastName?: string | null } | null | undefined): string {
|
||||||
|
if (!c) return "";
|
||||||
|
return c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—";
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { transitionWorkOrder, type TransitionResult } from "@/server/services/work-orders/transition";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Billing workflow (US-009). All paths go through transitionWorkOrder, which enforces
|
||||||
|
* `work_order:release_billing` and — for the release — an approved completion report.
|
||||||
|
* The event `work_order.released_for_billing` is emitted by the transition.
|
||||||
|
*/
|
||||||
|
export async function releaseForBilling(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
input: { workOrderId: string; baseVersion?: number },
|
||||||
|
): Promise<TransitionResult> {
|
||||||
|
return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "released_for_billing", baseVersion: input.baseVersion });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reject: back to the team for correction (in_review → in_progress), reason mandatory. */
|
||||||
|
export async function rejectForCorrection(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
input: { workOrderId: string; reason: string; baseVersion?: number },
|
||||||
|
): Promise<TransitionResult> {
|
||||||
|
return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "in_progress", reason: input.reason, baseVersion: input.baseVersion });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Revoke a billing release (released_for_billing → in_review), reason mandatory. */
|
||||||
|
export async function revokeBillingRelease(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
input: { workOrderId: string; reason: string; baseVersion?: number },
|
||||||
|
): Promise<TransitionResult> {
|
||||||
|
return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "in_review", reason: input.reason, baseVersion: input.baseVersion });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markBilled(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number }): Promise<TransitionResult> {
|
||||||
|
return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "billed", baseVersion: input.baseVersion });
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import type { Prisma } from "@prisma/client";
|
||||||
|
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import type { ServiceCtx } from "@/server/services/context";
|
||||||
|
import { allowedDocumentVisibility, customerScope, siteScope, workOrderScope } from "@/server/services/work-orders/visibility";
|
||||||
|
|
||||||
|
export type SearchFilter = { q: string; from?: Date; to?: Date; statuses?: WorkOrderStatus[]; teamId?: string };
|
||||||
|
|
||||||
|
const TAKE = 20;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant search (spec §25) with ILIKE (`contains` + insensitive) — every section is restricted by
|
||||||
|
* the same scopes as the detail views (work orders, customers, sites, document visibility).
|
||||||
|
* Period/status/team filters apply to everything that hangs off a work order.
|
||||||
|
*/
|
||||||
|
export async function searchAll(ctx: ServiceCtx, f: SearchFilter) {
|
||||||
|
const q = f.q.trim();
|
||||||
|
const empty = { workOrders: [], customers: [], sites: [], contacts: [], documents: [], notes: [] };
|
||||||
|
if (q.length < 2) return empty;
|
||||||
|
const like = { contains: q.slice(0, 100), mode: "insensitive" as const };
|
||||||
|
|
||||||
|
const [woScope, cScope, sScope] = await Promise.all([workOrderScope(ctx), customerScope(ctx), siteScope(ctx)]);
|
||||||
|
const woFilter: Prisma.WorkOrderWhereInput[] = [woScope];
|
||||||
|
if (f.from) woFilter.push({ OR: [{ plannedEnd: { gte: f.from } }, { plannedEnd: null, plannedStart: { gte: f.from } }] });
|
||||||
|
if (f.to) woFilter.push({ plannedStart: { lte: f.to } });
|
||||||
|
if (f.statuses?.length) woFilter.push({ status: { in: f.statuses } });
|
||||||
|
if (f.teamId) woFilter.push({ assignedTeamId: f.teamId });
|
||||||
|
const woWhere: Prisma.WorkOrderWhereInput = { AND: woFilter };
|
||||||
|
const hasWoFilter = woFilter.length > 1;
|
||||||
|
|
||||||
|
const [workOrders, customers, sites, contacts, documents, notes] = await Promise.all([
|
||||||
|
ctx.db.workOrder.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
woWhere,
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ number: like },
|
||||||
|
{ externalOrderNumber: like },
|
||||||
|
{ offerNumber: like },
|
||||||
|
{ title: like },
|
||||||
|
{ description: like },
|
||||||
|
{ scope: like },
|
||||||
|
{ site: { OR: [{ street: like }, { city: like }, { postalCode: like }] } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, number: true, title: true, status: true, plannedStart: true, customer: { select: { companyName: true, firstName: true, lastName: true } }, site: { select: { name: true, city: true } }, team: { select: { name: true } } },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: TAKE,
|
||||||
|
}),
|
||||||
|
ctx.db.customer.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
cScope,
|
||||||
|
hasWoFilter ? { workOrders: { some: woWhere } } : {},
|
||||||
|
{ OR: [{ customerNumber: like }, { companyName: like }, { firstName: like }, { lastName: like }, { street: like }, { city: like }, { postalCode: like }, { email: like }, { phone: like }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, city: true, status: true },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: TAKE,
|
||||||
|
}),
|
||||||
|
ctx.db.site.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
sScope,
|
||||||
|
hasWoFilter ? { workOrders: { some: woWhere } } : {},
|
||||||
|
{ OR: [{ name: like }, { street: like }, { city: like }, { postalCode: like }, { onSiteContact: like }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, customer: { select: { companyName: true, firstName: true, lastName: true } } },
|
||||||
|
orderBy: { updatedAt: "desc" },
|
||||||
|
take: TAKE,
|
||||||
|
}),
|
||||||
|
ctx.db.contact.findMany({
|
||||||
|
where: { AND: [{ deletedAt: null }, { customer: cScope }, { OR: [{ name: like }, { email: like }, { phone: like }, { mobile: like }] }] },
|
||||||
|
select: { id: true, name: true, role: true, phone: true, email: true, customer: { select: { id: true, companyName: true, firstName: true, lastName: true } } },
|
||||||
|
take: TAKE,
|
||||||
|
}),
|
||||||
|
ctx.db.document.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
{ deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } },
|
||||||
|
{ OR: [{ fileName: like }, { title: like }] },
|
||||||
|
// documents on an order follow the order scope; unlinked-to-order documents need the customer/site scope
|
||||||
|
{
|
||||||
|
OR: [
|
||||||
|
{ workOrder: woWhere },
|
||||||
|
...(hasWoFilter ? [] : [{ workOrderId: null, customer: cScope }, { workOrderId: null, customerId: null, site: sScope }]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
select: { id: true, title: true, fileName: true, category: true, workOrderId: true, customerId: true, siteId: true, createdAt: true },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: TAKE,
|
||||||
|
}),
|
||||||
|
ctx.db.activityNote.findMany({
|
||||||
|
where: { deletedAt: null, text: like, workOrder: woWhere },
|
||||||
|
select: { id: true, text: true, kind: true, createdAt: true, workOrder: { select: { id: true, number: true, title: true } } },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: TAKE,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { workOrders, customers, sites, contacts, documents, notes };
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SearchResults = Awaited<ReturnType<typeof searchAll>>;
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { DEFAULT_NUMBER_PREFIX, DEFAULT_ORDER_TYPES } from "@/lib/work-orders/defaults";
|
||||||
|
import {
|
||||||
|
checklistTemplateSchema,
|
||||||
|
numberingSchema,
|
||||||
|
NUMBER_KEYS,
|
||||||
|
orderTypeSchema,
|
||||||
|
type ChecklistTemplateInput,
|
||||||
|
type OrderTypeInput,
|
||||||
|
} from "@/lib/work-orders/schemas";
|
||||||
|
import { writeAuditLog } from "@/server/audit";
|
||||||
|
import type { TenantDb } from "@/server/db";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { parseInput, snapshot } from "@/server/services/work-orders/_shared";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tenant settings of the work order module (`settings:templates`): order types (§10.2),
|
||||||
|
* checklist templates incl. required photos (§12.4/§14.2) and number sequences.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Seed the default order types once per tenant (only if the tenant has none yet). Idempotent. */
|
||||||
|
export async function ensureDefaultOrderTypes(db: TenantDb, tenantId: string): Promise<number> {
|
||||||
|
const existing = await db.orderType.count();
|
||||||
|
if (existing > 0) return 0;
|
||||||
|
const res = await db.orderType.createMany({
|
||||||
|
data: DEFAULT_ORDER_TYPES.map((t) => ({ tenantId, ...t })),
|
||||||
|
skipDuplicates: true,
|
||||||
|
});
|
||||||
|
return res.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function audit(ctx: ServiceCtx, entity: string, entityId: string, action: "create" | "update", before: unknown, after: unknown) {
|
||||||
|
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action, entity, entityId, before, after });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Order types ----------
|
||||||
|
|
||||||
|
export async function listOrderTypes(ctx: ServiceCtx, opts: { activeOnly?: boolean } = {}) {
|
||||||
|
await ensureDefaultOrderTypes(ctx.db, ctx.tenantId);
|
||||||
|
return ctx.db.orderType.findMany({
|
||||||
|
where: opts.activeOnly ? { active: true } : {},
|
||||||
|
orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createOrderType(ctx: ServiceCtx, raw: OrderTypeInput) {
|
||||||
|
assertCan(ctx, "settings:templates");
|
||||||
|
const input = parseInput(orderTypeSchema, raw);
|
||||||
|
const dup = await ctx.db.orderType.findFirst({ where: { key: input.key }, select: { id: true } });
|
||||||
|
if (dup) throw new ServiceError("invalid", "order_type_key_taken");
|
||||||
|
const ot = await ctx.db.orderType.create({ data: { tenantId: ctx.tenantId, ...input } });
|
||||||
|
await audit(ctx, "order_type", ot.id, "create", undefined, snapshot(ot));
|
||||||
|
return ot;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrderType(ctx: ServiceCtx, id: string, raw: Omit<OrderTypeInput, "key">) {
|
||||||
|
assertCan(ctx, "settings:templates");
|
||||||
|
const input = parseInput(orderTypeSchema.omit({ key: true }), raw);
|
||||||
|
const before = await ctx.db.orderType.findFirst({ where: { id } });
|
||||||
|
if (!before) throw new ServiceError("not_found", "order_type_not_found");
|
||||||
|
const ot = await ctx.db.orderType.update({ where: { id }, data: input });
|
||||||
|
await audit(ctx, "order_type", id, "update", snapshot(before), snapshot(ot));
|
||||||
|
return ot;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Checklist templates ----------
|
||||||
|
|
||||||
|
export async function listChecklistTemplates(ctx: ServiceCtx) {
|
||||||
|
return ctx.db.checklistTemplate.findMany({
|
||||||
|
orderBy: [{ active: "desc" }, { name: "asc" }],
|
||||||
|
include: { orderType: { select: { id: true, name: true } } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertOrderType(ctx: ServiceCtx, orderTypeId: string | null | undefined) {
|
||||||
|
if (!orderTypeId) return;
|
||||||
|
const ot = await ctx.db.orderType.findFirst({ where: { id: orderTypeId }, select: { id: true } });
|
||||||
|
if (!ot) throw new ServiceError("invalid", "order_type_not_found");
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertUniqueKeys(list: { key: string }[], code: string) {
|
||||||
|
if (new Set(list.map((i) => i.key)).size !== list.length) throw new ServiceError("invalid", code);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createChecklistTemplate(ctx: ServiceCtx, raw: ChecklistTemplateInput) {
|
||||||
|
assertCan(ctx, "settings:templates");
|
||||||
|
const input = parseInput(checklistTemplateSchema, raw);
|
||||||
|
await assertOrderType(ctx, input.orderTypeId);
|
||||||
|
assertUniqueKeys(input.items, "duplicate_item_key");
|
||||||
|
assertUniqueKeys(input.requiredPhotos, "duplicate_photo_key");
|
||||||
|
const tpl = await ctx.db.checklistTemplate.create({
|
||||||
|
data: { tenantId: ctx.tenantId, name: input.name, orderTypeId: input.orderTypeId ?? null, active: input.active, items: input.items, requiredPhotos: input.requiredPhotos },
|
||||||
|
});
|
||||||
|
await audit(ctx, "checklist_template", tpl.id, "create", undefined, snapshot(tpl));
|
||||||
|
return tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateChecklistTemplate(ctx: ServiceCtx, id: string, raw: ChecklistTemplateInput) {
|
||||||
|
assertCan(ctx, "settings:templates");
|
||||||
|
const input = parseInput(checklistTemplateSchema, raw);
|
||||||
|
const before = await ctx.db.checklistTemplate.findFirst({ where: { id } });
|
||||||
|
if (!before) throw new ServiceError("not_found", "template_not_found");
|
||||||
|
await assertOrderType(ctx, input.orderTypeId);
|
||||||
|
assertUniqueKeys(input.items, "duplicate_item_key");
|
||||||
|
assertUniqueKeys(input.requiredPhotos, "duplicate_photo_key");
|
||||||
|
const tpl = await ctx.db.checklistTemplate.update({
|
||||||
|
where: { id },
|
||||||
|
data: { name: input.name, orderTypeId: input.orderTypeId ?? null, active: input.active, items: input.items, requiredPhotos: input.requiredPhotos },
|
||||||
|
});
|
||||||
|
await audit(ctx, "checklist_template", id, "update", snapshot(before), snapshot(tpl));
|
||||||
|
return tpl;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Number sequences ----------
|
||||||
|
|
||||||
|
export async function listNumberSequences(ctx: ServiceCtx) {
|
||||||
|
const rows = await ctx.db.numberSequence.findMany();
|
||||||
|
return NUMBER_KEYS.map((key) => {
|
||||||
|
const r = rows.find((x) => x.key === key);
|
||||||
|
return { key, prefix: r?.prefix ?? DEFAULT_NUMBER_PREFIX[key], padding: r?.padding ?? 5, nextValue: r?.nextValue ?? 1, exists: !!r };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Changes prefix/padding only — the counter itself is never reset (numbers stay unique). */
|
||||||
|
export async function updateNumberSequence(ctx: ServiceCtx, raw: { key: string; prefix: string; padding: number | string }) {
|
||||||
|
assertCan(ctx, "settings:templates");
|
||||||
|
const input = parseInput(numberingSchema, raw);
|
||||||
|
const before = await ctx.db.numberSequence.findFirst({ where: { key: input.key } });
|
||||||
|
let row;
|
||||||
|
if (before) {
|
||||||
|
row = await ctx.db.numberSequence.update({ where: { id: before.id }, data: { prefix: input.prefix, padding: input.padding } });
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
row = await ctx.db.numberSequence.create({ data: { tenantId: ctx.tenantId, key: input.key, prefix: input.prefix, padding: input.padding } });
|
||||||
|
} catch (err) {
|
||||||
|
if ((err as { code?: string }).code !== "P2002") throw err;
|
||||||
|
const cur = await ctx.db.numberSequence.findFirst({ where: { key: input.key } });
|
||||||
|
row = await ctx.db.numberSequence.update({ where: { id: cur!.id }, data: { prefix: input.prefix, padding: input.padding } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await audit(ctx, "number_sequence", row.id, before ? "update" : "create", before ? snapshot(before) : undefined, snapshot(row));
|
||||||
|
return row;
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import { transitionWorkOrder } from "@/server/services/work-orders/transition";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* STUB (lane L2) until lane L4 delivers `src/server/services/sync/apply.ts`.
|
||||||
|
* Contract (ARCHITEKTUR §4.6): re-dispatch a stored SyncOperation onto the domain services
|
||||||
|
* against the CURRENT state (no baseVersion). Replace the body with a call to the L4 dispatcher
|
||||||
|
* after merge; the signature stays.
|
||||||
|
*
|
||||||
|
* MVP scope of the stub: only `work_order.transition` (the only conflict-prone op besides
|
||||||
|
* `report.submit`, which belongs to lane reports).
|
||||||
|
*/
|
||||||
|
export async function reapplySyncOperation(
|
||||||
|
opCtx: ServiceCtx,
|
||||||
|
op: { opType: string; entityId: string | null; payload: unknown },
|
||||||
|
): Promise<{ entityVersion?: number }> {
|
||||||
|
if (op.opType === "work_order.transition") {
|
||||||
|
const p = (op.payload ?? {}) as { to?: string; reason?: string; workOrderId?: string };
|
||||||
|
const workOrderId = op.entityId ?? p.workOrderId;
|
||||||
|
if (!workOrderId || !p.to) throw new ServiceError("invalid", "sync_payload_invalid");
|
||||||
|
const res = await transitionWorkOrder(opCtx, { workOrderId, to: p.to as never, reason: p.reason ?? null });
|
||||||
|
return { entityVersion: res.version };
|
||||||
|
}
|
||||||
|
throw new ServiceError("invalid", "reapply_unsupported", { opType: op.opType });
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import type { EventType } from "@/lib/events";
|
||||||
|
import { canTransition, requiredPermission, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||||
|
import { transitionSchema, type TransitionInput } from "@/lib/work-orders/schemas";
|
||||||
|
import { emitEvent } from "@/server/events";
|
||||||
|
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import {
|
||||||
|
assertBaseVersion,
|
||||||
|
auditWorkOrder,
|
||||||
|
loadVisibleWorkOrder,
|
||||||
|
parseInput,
|
||||||
|
writeWithVersion,
|
||||||
|
type WorkOrderBase,
|
||||||
|
} from "@/server/services/work-orders/_shared";
|
||||||
|
import { transitionBlockers } from "@/server/services/work-orders/completion";
|
||||||
|
|
||||||
|
/** Permission decision for a single transition (scope is checked separately by loading the order). */
|
||||||
|
export function mayTransition(ctx: ServiceCtx, from: WorkOrderStatus, to: WorkOrderStatus): boolean {
|
||||||
|
if (from === "in_review" && to === "in_progress") return can(ctx, "report:approve_team") || can(ctx, "report:approve");
|
||||||
|
return can(ctx, requiredPermission(from, to));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Transitions that need a reason (cancellation, correction request, revoking a billing release). */
|
||||||
|
export function reasonRequired(from: WorkOrderStatus, to: WorkOrderStatus): boolean {
|
||||||
|
return to === "cancelled" || (from === "in_review" && to === "in_progress") || (from === "released_for_billing" && to === "in_review");
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventFor(from: WorkOrderStatus, to: WorkOrderStatus): EventType {
|
||||||
|
switch (to) {
|
||||||
|
case "cancelled":
|
||||||
|
return "work_order.cancelled";
|
||||||
|
case "daily_report_created":
|
||||||
|
return "work_order.daily_report_created";
|
||||||
|
case "technically_completed":
|
||||||
|
return "work_order.technically_completed";
|
||||||
|
case "signature_pending":
|
||||||
|
return "work_order.signature_missing";
|
||||||
|
case "released_for_billing":
|
||||||
|
return "work_order.released_for_billing";
|
||||||
|
case "in_progress":
|
||||||
|
return ["assigned", "accepted", "en_route"].includes(from) ? "work_order.started" : "work_order.changed";
|
||||||
|
default:
|
||||||
|
return "work_order.changed";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TransitionResult = { id: string; status: WorkOrderStatus; version: number; from: WorkOrderStatus };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ONLY place that changes WorkOrder.status (ARCHITEKTUR §3).
|
||||||
|
* Order of checks: scope (not_found) → table (invalid) → permission (forbidden) → version (conflict)
|
||||||
|
* → reason (invalid) → guards (blocked, CompletionBlocker[]) → optimistic write.
|
||||||
|
*/
|
||||||
|
export async function transitionWorkOrder(ctx: ServiceCtx, raw: TransitionInput): Promise<TransitionResult> {
|
||||||
|
const input = parseInput(transitionSchema, raw);
|
||||||
|
const wo = await loadVisibleWorkOrder(ctx, input.workOrderId);
|
||||||
|
return applyTransition(ctx, wo, input.to, { reason: input.reason ?? null, baseVersion: input.baseVersion, eventData: sanitizeEventData(raw.eventData) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeEventData(v: unknown): Record<string, string | number | boolean | null> | undefined {
|
||||||
|
if (!v || typeof v !== "object" || Array.isArray(v)) return undefined;
|
||||||
|
const out: Record<string, string | number | boolean | null> = {};
|
||||||
|
for (const [k, val] of Object.entries(v).slice(0, 20)) {
|
||||||
|
if (val === null || typeof val === "boolean" || typeof val === "number") out[k] = val;
|
||||||
|
else if (typeof val === "string") out[k] = val.slice(0, 500);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Internal: transition an already loaded (and scope-checked) order. `extra` is written in the same row update. */
|
||||||
|
export async function applyTransition(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
wo: WorkOrderBase,
|
||||||
|
to: WorkOrderStatus,
|
||||||
|
opts: {
|
||||||
|
reason?: string | null;
|
||||||
|
baseVersion?: number;
|
||||||
|
extra?: Record<string, unknown>;
|
||||||
|
eventData?: Record<string, string | number | boolean | null>;
|
||||||
|
} = {},
|
||||||
|
): Promise<TransitionResult> {
|
||||||
|
const from = wo.status;
|
||||||
|
if (!canTransition(from, to)) throw new ServiceError("invalid", "transition_not_allowed", { from, to });
|
||||||
|
if (!mayTransition(ctx, from, to)) throw new ServiceError("forbidden", "transition_forbidden", { from, to });
|
||||||
|
assertBaseVersion(wo, opts.baseVersion);
|
||||||
|
if (reasonRequired(from, to) && !opts.reason?.trim()) throw new ServiceError("invalid", "reason_required", { from, to });
|
||||||
|
|
||||||
|
const blockers = await transitionBlockers(ctx, wo, to);
|
||||||
|
if (blockers.length) throw new ServiceError("blocked", "transition_blocked", blockers);
|
||||||
|
|
||||||
|
const version = await writeWithVersion(ctx, wo, { status: to, ...(opts.extra ?? {}) });
|
||||||
|
await ctx.db.workOrderStatusChange.create({
|
||||||
|
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: to, actorId: ctx.userId, reason: opts.reason ?? null },
|
||||||
|
});
|
||||||
|
await auditWorkOrder(ctx, {
|
||||||
|
action: "update",
|
||||||
|
workOrderId: wo.id,
|
||||||
|
before: { status: from, version: wo.version },
|
||||||
|
after: { status: to, version, reason: opts.reason ?? null, ...(opts.extra ?? {}) },
|
||||||
|
});
|
||||||
|
await emitEvent(ctx, {
|
||||||
|
type: eventFor(from, to),
|
||||||
|
entityType: "work_order",
|
||||||
|
entityId: wo.id,
|
||||||
|
data: { ...(opts.eventData ?? {}), number: wo.number, from, to },
|
||||||
|
});
|
||||||
|
return { id: wo.id, status: to, version, from };
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { updateWorkOrderSchema, type UpdateWorkOrderInput } from "@/lib/work-orders/schemas";
|
||||||
|
import { emitEvent } from "@/server/events";
|
||||||
|
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||||
|
import {
|
||||||
|
assertBaseVersion,
|
||||||
|
assertNotLocked,
|
||||||
|
auditWorkOrder,
|
||||||
|
FINAL_STATUSES,
|
||||||
|
loadVisibleWorkOrder,
|
||||||
|
parseInput,
|
||||||
|
snapshot,
|
||||||
|
writeWithVersion,
|
||||||
|
} from "@/server/services/work-orders/_shared";
|
||||||
|
|
||||||
|
const FIELDS = Object.keys(updateWorkOrderSchema.shape) as (keyof UpdateWorkOrderInput)[];
|
||||||
|
|
||||||
|
/** Partial update of the order master data (never status — see transition.ts). Only keys present in `raw` change. */
|
||||||
|
export async function updateWorkOrder(
|
||||||
|
ctx: ServiceCtx,
|
||||||
|
workOrderId: string,
|
||||||
|
raw: UpdateWorkOrderInput,
|
||||||
|
baseVersion?: number,
|
||||||
|
): Promise<{ id: string; version: number }> {
|
||||||
|
assertCan(ctx, "work_order:write");
|
||||||
|
const parsed = parseInput(updateWorkOrderSchema, raw);
|
||||||
|
const present = FIELDS.filter((k) => Object.prototype.hasOwnProperty.call(raw, k));
|
||||||
|
if (present.length === 0) throw new ServiceError("invalid", "nothing_to_update");
|
||||||
|
|
||||||
|
const wo = await loadVisibleWorkOrder(ctx, workOrderId);
|
||||||
|
assertNotLocked(wo, FINAL_STATUSES);
|
||||||
|
assertBaseVersion(wo, baseVersion);
|
||||||
|
|
||||||
|
const full = await ctx.db.workOrder.findFirst({ where: { id: wo.id } });
|
||||||
|
if (!full) throw new ServiceError("not_found", "work_order_not_found");
|
||||||
|
|
||||||
|
const data: Record<string, unknown> = {};
|
||||||
|
for (const k of present) data[k] = (parsed as Record<string, unknown>)[k] ?? null;
|
||||||
|
if ("title" in data && !data.title) throw new ServiceError("invalid", "validation_failed", [{ path: "title" }]);
|
||||||
|
if ("priority" in data && !data.priority) delete data.priority;
|
||||||
|
if ("signatureRequired" in data && typeof data.signatureRequired !== "boolean") delete data.signatureRequired;
|
||||||
|
if ("customerId" in data && !data.customerId) delete data.customerId;
|
||||||
|
|
||||||
|
const customerId = (data.customerId as string | undefined) ?? full.customerId;
|
||||||
|
const siteId = "siteId" in data ? (data.siteId as string | null) : full.siteId;
|
||||||
|
const contactId = "contactId" in data ? (data.contactId as string | null) : full.contactId;
|
||||||
|
|
||||||
|
if (customerId !== full.customerId) {
|
||||||
|
const c = await ctx.db.customer.findFirst({ where: { id: customerId, deletedAt: null, status: { not: "merged" } }, select: { id: true } });
|
||||||
|
if (!c) throw new ServiceError("invalid", "customer_not_found");
|
||||||
|
}
|
||||||
|
if (siteId) {
|
||||||
|
const s = await ctx.db.site.findFirst({ where: { id: siteId, deletedAt: null }, select: { customerId: true } });
|
||||||
|
if (!s) throw new ServiceError("invalid", "site_not_found");
|
||||||
|
if (s.customerId !== customerId) throw new ServiceError("invalid", "site_customer_mismatch");
|
||||||
|
}
|
||||||
|
if (contactId) {
|
||||||
|
const c = await ctx.db.contact.findFirst({ where: { id: contactId, deletedAt: null }, select: { customerId: true } });
|
||||||
|
if (!c) throw new ServiceError("invalid", "contact_not_found");
|
||||||
|
if (c.customerId !== customerId) throw new ServiceError("invalid", "contact_customer_mismatch");
|
||||||
|
}
|
||||||
|
if ("orderTypeId" in data && data.orderTypeId) {
|
||||||
|
const ot = await ctx.db.orderType.findFirst({ where: { id: data.orderTypeId as string }, select: { id: true } });
|
||||||
|
if (!ot) throw new ServiceError("invalid", "order_type_not_found");
|
||||||
|
}
|
||||||
|
const start = "plannedStart" in data ? (data.plannedStart as Date | null) : full.plannedStart;
|
||||||
|
const end = "plannedEnd" in data ? (data.plannedEnd as Date | null) : full.plannedEnd;
|
||||||
|
if (start && end && end < start) throw new ServiceError("invalid", "planned_end_before_start");
|
||||||
|
|
||||||
|
const before = Object.fromEntries(Object.keys(data).map((k) => [k, (full as Record<string, unknown>)[k]]));
|
||||||
|
const version = await writeWithVersion(ctx, wo, data);
|
||||||
|
await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: snapshot(before), after: snapshot({ ...data, version }) });
|
||||||
|
await emitEvent(ctx, { type: "work_order.changed", entityType: "work_order", entityId: wo.id, data: { number: wo.number } });
|
||||||
|
return { id: wo.id, version };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Soft delete (spec §27.5) — only drafts / orders still in review. */
|
||||||
|
export async function deleteWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise<void> {
|
||||||
|
assertCan(ctx, "work_order:write");
|
||||||
|
const wo = await loadVisibleWorkOrder(ctx, workOrderId);
|
||||||
|
if (!["draft", "review_required"].includes(wo.status)) throw new ServiceError("invalid", "delete_only_draft");
|
||||||
|
await writeWithVersion(ctx, wo, { deletedAt: new Date() });
|
||||||
|
await auditWorkOrder(ctx, { action: "delete", workOrderId: wo.id, before: { status: wo.status, number: wo.number } });
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user