Merge lane/berichte in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+24
@@ -103,3 +103,27 @@ CMD ["node", "server.js"]
|
||||
# GARAGE_RPC_SECRET/GARAGE_ADMIN_TOKEN); nur die secret-freie Basiskonfig wird kopiert.
|
||||
FROM dxflrs/garage:v1.2.0 AS garage
|
||||
COPY deploy/garage.toml /etc/garage.toml
|
||||
|
||||
# --- Worker-Stage (Vorschlag Lane L5 Berichte): Craftvia-Job-Worker inkl. Chromium für PDF ---
|
||||
# ARCHITEKTUR §1: HTML → PDF läuft über playwright-core + Chromium NUR im Worker, nie im App-Container.
|
||||
# Debian-Chromium aus dem Paketspiegel statt Playwright-Download (reproduzierbar, Updates über das Base-Image);
|
||||
# render.ts nutzt PDF_CHROMIUM_PATH. fonts-dejavu/-liberation als Fallback, Inter wird eingebettet (src/app/fonts).
|
||||
# tsx + src/messages/prisma werden wie in der migrate-Stage zur Laufzeit gebraucht (Worker läuft über tsx).
|
||||
FROM node:22.14.0-slim AS worker
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV PDF_CHROMIUM_PATH=/usr/bin/chromium
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssl ca-certificates chromium fonts-dejavu-core fonts-liberation \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json package-lock.json prisma.config.ts tsconfig.json ./
|
||||
COPY prisma ./prisma
|
||||
COPY scripts ./scripts
|
||||
COPY src ./src
|
||||
COPY messages ./messages
|
||||
ENV DATABASE_URL="postgresql://build:build@localhost:5432/build?schema=public"
|
||||
RUN npx prisma generate
|
||||
RUN groupadd --system --gid 1001 app \
|
||||
&& useradd --system --uid 1001 --gid app --home-dir /app app
|
||||
USER app
|
||||
CMD ["npx", "tsx", "scripts/craftvia-worker.ts"]
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
# Lane L5 – Berichte & Unterschrift
|
||||
|
||||
Branch `lane/berichte` (Basis `bf44567`, `feature/craftvia-mvp`). Spec §16, §17, §18, §32, US-007/008/009, ARCHITEKTUR §4.7.
|
||||
|
||||
## Umfang / erfüllte Spec-Punkte
|
||||
|
||||
| Spec | Umsetzung |
|
||||
|---|---|
|
||||
| §16 Tagesbericht | `createDailyReport`: Entwurf je Auftrag + Kalendertag (Mandanten-Zeitzone), Inhalte nur des Tages (Zeiten, Notizen, Fotos, Material). Auftrag → `daily_report_created` über `transitionWorkOrder` (aus `paused`/`waiting_material` über `in_progress`), Auftrag bleibt offen. Idempotent je Tag. Event `work_order.daily_report_created`. |
|
||||
| §17.1/17.2 Abschlussbericht | `createCompletionReport` prüft `getCompletionBlockers` (Checkliste, Pflichtfotos, laufende Zeiten) → `ServiceError("blocked", details: CompletionBlocker[])`. Snapshot mit allen Inhalten aus §17.2 (`ReportContent`). |
|
||||
| Pflichtangaben | Absenden verlangt „Ausgeführte Leistungen“ (`REPORT_REQUIRED_TEXTS`) + erneut Blocker-Prüfung → strukturierte Liste. |
|
||||
| §17.3 PDF | Nach Freigabe (`report:approve`) Job `report-pdf` → `generateReportPdf`: HTML-Template (React SSR) → Chromium/Playwright → Dokument (Kategorie `daily_report`/`completion_report`, Sichtbarkeit `customer_report`, an Auftrag/Kunde/Objekt → erscheint in Objekt-Historie), `pdfDocumentId` + SHA-256 `pdfChecksum`. Bestehendes PDF wird nie ersetzt. |
|
||||
| §17.4 Versionierung | `createNewVersion` (nur `report:approve`): neue Version gleiche `lineageId`, `version+1`, Entwurf aus freigegebenem Snapshot. Die freigegebene Version bleibt unverändert und wird erst bei Freigabe der Nachfolgerin `superseded` (so existiert immer ein gültiges freigegebenes PDF). |
|
||||
| §18.1 Unterschrift | `captureSignature`: Name, Funktion, Datum/Uhrzeit (`signedAt`), Bestätigungstext (messages, mit Berichts-/Auftragsnummer + Datum), Bezug Bericht, erfassender Nutzer. Canvas-Pad mit Pointer Events (Maus/Touch/Stift), Löschen, PNG-Export → Server Action → Dokument `signature`. |
|
||||
| §18.2 Ausnahmen | `customer_absent`/`refused`/`later` → Begründung Pflicht; `not_required` nur bei `signatureRequired=false` oder `report:approve`. Erfasste Unterschrift (`signed`) wird nie überschrieben; `later` kann nachgereicht werden (Auftrag `signature_pending` → `in_review`). |
|
||||
| §32 PDF-Merkmale | Bericht-ID, Versionsnummer, Erstellungs-/Freigabedatum, Freigabestatus, Prüfsumme (Fuß: SHA-256 des Inhalts-Snapshots; SHA-256 der PDF-Datei am Bericht/Dokument), A4, Seitenumbrüche, Fotoraster 2-spaltig, Kopf-/Fußzeile mit Seitenzahl, Mandantenlogo sonst Firmenname, Inter eingebettet. |
|
||||
| Freigabe | Teamleiter (`report:approve_team`) → `team_approved`; Backoffice (`report:approve`) → `approved` (Inhalt final eingefroren, Event `report.approved`, PDF-Job). Zurückweisen mit Pflichtgrund → `rejected`, Abschluss-Auftrag `in_review` → `in_progress`, Event `report.rejected`. |
|
||||
| Auftragsstatus bei Absenden | Abschlussbericht v1: … → `in_progress` → `technically_completed` → `signature_pending` (keine Unterschrift/`later`) bzw. `in_review` (`signed`, `not_required`, `refused`, `customer_absent`; bei den letzten beiden zusätzlich Event `work_order.signature_missing`). Folgeversionen ändern den Auftragsstatus nicht. |
|
||||
| US-009 Berichtsseite | `/reports` (zur Prüfung zuerst, Filter Typ/Status/Team/Zeitraum), `/reports/[id]` (strukturierte Ansicht, PDF-Link, Versionen, Aktionen, Badge „Lotse-Entwurf“ bei `aiDrafted`). |
|
||||
|
||||
## Dateien
|
||||
|
||||
- Vertrag/Client-safe: `src/lib/reports/content.ts` (Zod `ReportContent`, Status-/Outcome-Konstanten), `src/lib/reports/dates.ts` (Tagesfenster Zeitzone), `src/lib/reports/action-state.ts`
|
||||
- Services `src/server/services/reports/`: `build-content.ts`, `common.ts` (Scope `reportScope`/`requireVisibleReport`, Audit, Refresh), `create.ts`, `edit.ts`, `submit.ts`, `approve.ts`, `reject.ts`, `new-version.ts`, `signature.ts`, `pdf.ts`, `files.ts` (Datei-Auslieferung nur für im Snapshot referenzierte Dokumente), `queries.ts` (Liste/Detail/Mobil), `read-ctx.ts`, `http.ts` (API-Adapter), `_stubs/{work-orders,documents}.ts`
|
||||
- PDF: `src/server/pdf/render.ts`, `src/server/pdf/templates/report.tsx`, Processor `src/server/jobs/processors/report-pdf.ts`
|
||||
- Actions `src/server/actions/reports/`: `workflow.ts` (create/save/submit/approve/reject/newVersion/regeneratePdf), `signature.ts`, `_state.ts`
|
||||
- API: `POST /api/v1/work-orders/[id]/daily-report`, `POST /api/v1/work-orders/[id]/completion-report`, `POST /api/v1/reports/[id]/approve`, `GET /api/v1/reports/[id]/pdf`, zusätzlich `GET /api/v1/reports/[id]/files/[documentId]` (Fotos/Unterschrift in Ansicht)
|
||||
- UI Backoffice: `src/app/(app)/reports/page.tsx`, `src/app/(app)/reports/[id]/page.tsx`; Komponenten `src/components/reports/{report-view,review-actions,reject-form,status-badge,blocker-list,action-message,step-indicator,signature-pad}.tsx`
|
||||
- UI Mobil: `src/components/reports/mobile/{report-editor,report-review,sign-flow,report-screen,sign-screen,create-report-form}.tsx`; dünne Seiten `src/app/(app)/m/(field)/orders/[id]/{report,sign}/page.tsx`
|
||||
- Texte: `messages/de/reports.json`, `messages/en/reports.json`
|
||||
- Tests: `scripts/test-berichte-flow.ts`, `scripts/test-berichte-pdf.ts`
|
||||
|
||||
Fremd-Einzeiler/erlaubte Eingriffe: `src/server/jobs/processors/index.ts` (Registrierung `report-pdf`, mit `turbopackIgnore`), `Dockerfile` (neue Stage `worker` am Ende). `src/lib/nav.ts` war bereits eingetragen. `package.json`/`package-lock.json`: neue Abhängigkeit `playwright-core`.
|
||||
|
||||
## Abhängigkeit `playwright-core`
|
||||
|
||||
`playwright-core@^1.63` (Apache-2.0, ~8 MB entpackt, kein postinstall-Download, keine transitiven Laufzeit-Abhängigkeiten). Vom Architektur-Vertrag vorgegeben (§1 PDF). Wird nur im Worker geladen (`turbopackIgnore` im Processor-Registry-Eintrag), nicht im App-Bundle. Browser-Auflösung: `PDF_CHROMIUM_PATH` → Playwright-Chromium → lokal installiertes Google Chrome (`channel: "chrome"`, Entwicklerrechner).
|
||||
|
||||
**Worker-Image:** braucht Chromium + Schriften. Vorschlag als Stage `worker` im `Dockerfile` (Debian-Paket `chromium`, `fonts-dejavu-core`, `fonts-liberation`, `PDF_CHROMIUM_PATH=/usr/bin/chromium`, Start `npx tsx scripts/craftvia-worker.ts`). Compose-Service (`target: worker`, `REDIS_URL`, `DATABASE_URL`, `S3_*`) ist noch einzutragen (nicht Lane-Ownership).
|
||||
|
||||
## Tests
|
||||
|
||||
| Skript | Prüfungen | Ergebnis |
|
||||
|---|---|---|
|
||||
| `scripts/test-berichte-flow.ts` | 70: Content-Builder (Tagesfilter Zeiten/Notizen/Fotos/Material, Materialabweichungen Menge/nicht verwendet/zusätzlich/undokumentiert, Kopfdaten), Tagesbericht + Auftragsstatus + Idempotenz + Nummernkreis, Abschluss-Blocker, Pflichtangaben, Unterschrift-Validierung je outcome, Statusfolgen submit/team_approve/reject/resubmit/approve inkl. Auftragsstatus, Einfrieren nach Freigabe, Versionierung (v1 nie überschrieben, superseded erst bei Freigabe v2), Mandantentrennung (lesen/ändern/freigeben/Liste/direkter DB-Update), Rollen/Scope (Monteur ohne Zuweisung, Teamleiter anderes Team, Monteur ohne Freigaberecht), Audit before/after | grün |
|
||||
| `scripts/test-berichte-pdf.ts` | 11: PDF gerendert, Dokument > 0 Bytes, beginnt mit `%PDF`, Kategorie/Sichtbarkeit/Auftragsbezug, SHA-256 gespeichert und = Bytes, zweiter Lauf überschreibt nicht, Download eigener Mandant, Mandant B → not_found, nicht referenziertes Dokument → not_found. Ohne startbaren Browser: Skip mit Meldung (Exit 0) | grün (lokal über Google Chrome) |
|
||||
|
||||
Gate: siehe Rückmeldung an den Architekten (`npm run gate` grün).
|
||||
|
||||
## Stubs / Abhängigkeiten zu anderen Lanes
|
||||
|
||||
| Stub | Vertrag | Ersetzen durch |
|
||||
|---|---|---|
|
||||
| `services/reports/_stubs/work-orders.ts#transitionWorkOrder` | ARCHITEKTUR §3 (canTransition + requiredPermission, Scope, `WorkOrderStatusChange`, Version+1, Audit, Event) | L2 `services/work-orders/transition.ts` |
|
||||
| `services/reports/_stubs/work-orders.ts#getCompletionBlockers` | §3 Guards vor Abschluss → `CompletionBlocker[]` | L2 (Datei gemäß L2, z. B. `services/work-orders/guards.ts`) |
|
||||
| `services/reports/_stubs/documents.ts#storeFile/readFileBytes` | §4.3 `services/documents/store.ts` (Allowlist, Magic Bytes, Größenlimit, SHA-256, Lineage) | Architekt/Dokumente (`services/documents/store.ts`) |
|
||||
| Unterschrift-Upload über Server Action (Data-URL) | §4.6 `POST /api/v1/uploads` | L4 – für Offline-Sync (`signature.capture` referenziert `documentId`) |
|
||||
|
||||
Imports sind mit `TODO(merge …)` markiert. `/api/v1/reports/...` nutzt einen eigenen Adapter (`http.ts`, `moduleGuard("reports")`), da noch kein gemeinsames `requireApiContext` existiert.
|
||||
|
||||
L4 bindet die mobilen Seiten ein: Link „Bericht“ im Auftragsdetail → `/m/orders/[id]/report` (Tabs Abschluss/Tag), Abschluss → `/m/orders/[id]/sign`. Sync-Ops `report.save_draft`/`report.submit`/`signature.capture` können direkt `updateReportTexts`/`submitReport` (`expectedWorkOrderVersion`)/`captureSignature` (`clientId`) aufrufen.
|
||||
|
||||
## Bekannte Lücken / offene Punkte
|
||||
|
||||
- **Schema unverändert** (keine Migration). Mandantenlogo: `TenantSettings.logoKey` ist kein `Document` → `logoDocumentId` bleibt `null`, PDF zeigt Firmennamen, bis ein Logo-Upload als Dokument existiert.
|
||||
- Berichtsnummer (`B-…`) liegt im Snapshot (`content.reportNumber`), nicht als Spalte – Suche nach Nummer braucht ggf. eine Spalte (Architekt).
|
||||
- PDF wird ohne laufenden Worker nicht erzeugt, solange `REDIS_URL` gesetzt ist (Job bleibt in der Queue). Ohne Redis läuft `dispatchJob` inline, der Processor ist im App-Bundle aber absichtlich nicht enthalten → Fehler wird geloggt, Freigabe bleibt gültig, „PDF erzeugen“ auf der Detailseite stößt den Job erneut an.
|
||||
- `generateReportPdf` rendert Fotos in Originalgröße (Data-URI); bei vielen großen Fotos ggf. auf Derivate (L4 `image-derivatives`) umstellen.
|
||||
- E-Mail-Versand des PDF (§17.3 „optional“) und Empfänger der Events liegen bei L6.
|
||||
- Mobile-Seiten sind ohne L4-Shell nur über direkte URL erreichbar; Offline-Fähigkeit (L7) nicht Teil dieser Lane.
|
||||
- `src/server/dsgvo/pii-fields.ts`: `Report.createdById/approvedById/teamApprovedById`, `Signature.capturedById`, `signerName` sollten vom Architekten eingetragen werden (Fundament-Datei, nicht geändert).
|
||||
|
||||
## Screens / Routen
|
||||
|
||||
| Route | Rolle | Inhalt |
|
||||
|---|---|---|
|
||||
| `/reports` | Backoffice, Teamleiter (Scope) | Liste, zur Prüfung zuerst, Filter Typ/Status/Team/Von–Bis |
|
||||
| `/reports/[id]` | Backoffice, Teamleiter (Scope) | Strukturierte Ansicht, Metadaten, PDF-Link, Versionen, Freigeben/Als Teamleiter prüfen/Zurückweisen (Popup `?reject=1`)/Neue Version/PDF erzeugen |
|
||||
| `/m/orders/[id]/report?type=completion\|daily` | Monteur, Teamleiter | Blocker → Bericht erstellen → prüfen/ergänzen → (Tag) absenden / (Abschluss) weiter zur Unterschrift |
|
||||
| `/m/orders/[id]/sign` | Monteur, Teamleiter | Unterschrift (Pad) oder Grund, dann Bericht absenden |
|
||||
@@ -0,0 +1,236 @@
|
||||
{
|
||||
"title": "Berichte",
|
||||
"crumb": "Einsatz & Nachweis",
|
||||
"sub": "Tages- und Abschlussberichte prüfen, freigeben und als PDF ablegen.",
|
||||
"type": {
|
||||
"daily": "Tagesbericht",
|
||||
"completion": "Abschlussbericht"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Entwurf",
|
||||
"submitted": "Zur Prüfung",
|
||||
"team_approved": "Vom Teamleiter geprüft",
|
||||
"approved": "Freigegeben",
|
||||
"rejected": "Zurückgewiesen",
|
||||
"superseded": "Ersetzt"
|
||||
},
|
||||
"outcome": {
|
||||
"signed": "Unterschrieben",
|
||||
"customer_absent": "Kunde nicht anwesend",
|
||||
"refused": "Kunde verweigert Unterschrift",
|
||||
"later": "Unterschrift wird später eingeholt",
|
||||
"not_required": "Unterschrift nicht erforderlich"
|
||||
},
|
||||
"timeType": {
|
||||
"travel": "Anfahrt",
|
||||
"work": "Arbeitszeit",
|
||||
"break": "Pause",
|
||||
"material_procurement": "Materialbeschaffung",
|
||||
"return_travel": "Rückfahrt",
|
||||
"interruption": "Unterbrechung"
|
||||
},
|
||||
"phase": {
|
||||
"before": "Vorher",
|
||||
"during": "Während der Arbeit",
|
||||
"after": "Nachher"
|
||||
},
|
||||
"materialStatus": {
|
||||
"fully_used": "Vollständig verwendet",
|
||||
"partially_used": "Teilweise verwendet",
|
||||
"not_used": "Nicht verwendet",
|
||||
"additional": "Zusätzlich"
|
||||
},
|
||||
"field": {
|
||||
"customer": "Kunde",
|
||||
"customerNumber": "Kundennummer",
|
||||
"site": "Objekt",
|
||||
"contact": "Ansprechpartner",
|
||||
"workOrder": "Auftrag",
|
||||
"orderNumber": "Auftragsnummer",
|
||||
"externalOrderNumber": "Externe Auftragsnummer",
|
||||
"orderType": "Auftragsart",
|
||||
"description": "Auftragsbeschreibung",
|
||||
"scope": "Leistungsumfang",
|
||||
"reportDate": "Berichtsdatum",
|
||||
"workDates": "Einsatzdatum",
|
||||
"staff": "Eingesetzte Mitarbeiter",
|
||||
"technician": "Monteur",
|
||||
"version": "Version",
|
||||
"reportNumber": "Berichtsnummer",
|
||||
"status": "Status",
|
||||
"team": "Team",
|
||||
"type": "Typ",
|
||||
"period": "Zeitraum",
|
||||
"updated": "Zuletzt geändert"
|
||||
},
|
||||
"texts": {
|
||||
"workPerformed": "Ausgeführte Leistungen",
|
||||
"deviations": "Abweichungen",
|
||||
"additionalWork": "Zusatzleistungen",
|
||||
"problems": "Probleme",
|
||||
"openItems": "Offene Punkte und Restarbeiten",
|
||||
"nextSteps": "Nächste Schritte",
|
||||
"hints": "Hinweise"
|
||||
},
|
||||
"section": {
|
||||
"header": "Auftrag und Kunde",
|
||||
"time": "Arbeitszeiten",
|
||||
"texts": "Tätigkeiten und Hinweise",
|
||||
"materials": "Material",
|
||||
"photos": "Fotos",
|
||||
"checklist": "Checkliste",
|
||||
"signature": "Kundenunterschrift",
|
||||
"versions": "Versionen",
|
||||
"blockers": "Vor dem Abschluss fehlt noch"
|
||||
},
|
||||
"time": {
|
||||
"person": "Mitarbeiter",
|
||||
"type": "Art",
|
||||
"duration": "Dauer",
|
||||
"total": "Summe ohne Pausen",
|
||||
"totalByType": "Summen je Art",
|
||||
"hoursMinutes": "{hours} h {minutes} min",
|
||||
"running": "Enthält laufende Zeiten, Stand {time}",
|
||||
"empty": "Keine Zeiten erfasst."
|
||||
},
|
||||
"materials": {
|
||||
"used": "Verwendet",
|
||||
"notUsed": "Nicht verwendet",
|
||||
"additional": "Zusätzlich verwendet",
|
||||
"name": "Material",
|
||||
"planned": "Geplant",
|
||||
"actual": "Ist",
|
||||
"reason": "Abweichungsgrund",
|
||||
"deviation": "Abweichung",
|
||||
"undocumented": "Nicht dokumentiert",
|
||||
"empty": "Kein Material erfasst."
|
||||
},
|
||||
"photos": {
|
||||
"empty": "Keine Fotos für den Bericht ausgewählt.",
|
||||
"requirement": "Pflichtfoto: {label}",
|
||||
"alt": "Foto {index}"
|
||||
},
|
||||
"checklist": {
|
||||
"done": "Erledigt",
|
||||
"open": "Offen",
|
||||
"required": "Pflicht",
|
||||
"empty": "Keine Checkliste."
|
||||
},
|
||||
"signature": {
|
||||
"signer": "Unterzeichner",
|
||||
"role": "Funktion",
|
||||
"signedAt": "Datum und Uhrzeit",
|
||||
"reason": "Begründung",
|
||||
"capturedBy": "Aufgenommen von",
|
||||
"confirmation": "Bestätigungstext",
|
||||
"none": "Noch keine Unterschrift erfasst.",
|
||||
"image": "Unterschrift von {name}"
|
||||
},
|
||||
"list": {
|
||||
"empty": "Keine Berichte für diese Auswahl.",
|
||||
"filter": "Filtern",
|
||||
"reset": "Zurücksetzen",
|
||||
"all": "Alle",
|
||||
"allWithSuperseded": "Alle inkl. ersetzte",
|
||||
"from": "Von",
|
||||
"to": "Bis",
|
||||
"count": "{count, plural, one {# Bericht} other {# Berichte}}",
|
||||
"truncated": "Es werden die neuesten {count} Berichte angezeigt. Bitte Filter nutzen.",
|
||||
"open": "Öffnen"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Zurück zu Berichten",
|
||||
"pdf": "PDF öffnen",
|
||||
"pdfPending": "PDF wird erzeugt. Seite später neu laden.",
|
||||
"aiDrafted": "Lotse-Entwurf",
|
||||
"aiDraftedHint": "Texte wurden mit Lotse vorbereitet und vom Monteur geprüft.",
|
||||
"rejectionReason": "Grund der Zurückweisung",
|
||||
"submittedAt": "Eingereicht am",
|
||||
"teamApprovedAt": "Vom Teamleiter geprüft am",
|
||||
"approvedAt": "Freigegeben am",
|
||||
"current": "Diese Version",
|
||||
"checksum": "Prüfsumme (PDF)",
|
||||
"generatedAt": "Stand der Daten"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "Freigeben",
|
||||
"approveTeam": "Als Teamleiter prüfen",
|
||||
"reject": "Zurückweisen",
|
||||
"rejectTitle": "Bericht zurückweisen",
|
||||
"rejectSub": "Der Monteur sieht den Grund und korrigiert den Bericht.",
|
||||
"rejectReason": "Grund",
|
||||
"rejectPlaceholder": "Was muss korrigiert werden?",
|
||||
"newVersion": "Neue Version anlegen",
|
||||
"newVersionHint": "Die freigegebene Version bleibt unverändert, bis die neue Version freigegeben ist.",
|
||||
"regeneratePdf": "PDF erzeugen",
|
||||
"cancel": "Abbrechen",
|
||||
"close": "Schließen",
|
||||
"done": "Erledigt."
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Vorgang nicht möglich.",
|
||||
"not_found": "Bericht nicht gefunden.",
|
||||
"forbidden": "Dafür fehlt die Berechtigung.",
|
||||
"conflict": "Der Bericht wurde inzwischen geändert. Bitte Seite neu laden.",
|
||||
"invalid": "Bitte Eingaben prüfen.",
|
||||
"blocked": "Es fehlen noch Angaben.",
|
||||
"reasonRequired": "Bitte einen Grund angeben.",
|
||||
"signerRequired": "Bitte den Namen des Unterzeichners angeben.",
|
||||
"imageRequired": "Bitte zuerst unterschreiben lassen.",
|
||||
"notRequiredForbidden": "Für diesen Auftrag ist eine Unterschrift erforderlich."
|
||||
},
|
||||
"blocker": {
|
||||
"checklist_item": "Checkliste offen: {label}",
|
||||
"photo_requirement": "Pflichtfoto fehlt: {label}",
|
||||
"running_session": "Zeiterfassung läuft noch",
|
||||
"missing_field": "Pflichtangabe fehlt: {field}"
|
||||
},
|
||||
"mobile": {
|
||||
"title": "Bericht",
|
||||
"backToOrder": "Zurück zum Auftrag",
|
||||
"createDaily": "Tagesbericht erstellen",
|
||||
"createCompletion": "Abschlussbericht erstellen",
|
||||
"dailyHint": "Arbeitstag abschließen. Der Auftrag bleibt offen.",
|
||||
"completionHint": "Einsatz abschließen: Bericht prüfen, unterschreiben lassen, absenden.",
|
||||
"blockersHint": "Erst diese Punkte erledigen, dann den Abschlussbericht erstellen.",
|
||||
"stepReview": "Prüfen",
|
||||
"stepEdit": "Ergänzen",
|
||||
"stepSign": "Unterschrift",
|
||||
"stepSubmit": "Absenden",
|
||||
"stepOf": "Schritt {current} von {total}",
|
||||
"edit": "Bericht ergänzen",
|
||||
"review": "Bericht prüfen",
|
||||
"save": "Entwurf speichern",
|
||||
"saved": "Entwurf gespeichert.",
|
||||
"toSign": "Weiter zur Unterschrift",
|
||||
"submit": "Bericht absenden",
|
||||
"submitted": "Bericht ist beim Büro zur Prüfung.",
|
||||
"readOnly": "Der Bericht ist eingereicht und kann nicht mehr geändert werden.",
|
||||
"rejected": "Zurückgewiesen: {reason}",
|
||||
"signatureMissing": "Unterschrift oder Grund fehlt noch.",
|
||||
"noCompletion": "Noch kein Abschlussbericht angelegt."
|
||||
},
|
||||
"sign": {
|
||||
"title": "Unterschrift",
|
||||
"outcomeLabel": "Unterschrift",
|
||||
"signerName": "Name des Unterzeichners",
|
||||
"signerRole": "Funktion (optional)",
|
||||
"reason": "Begründung",
|
||||
"clear": "Löschen",
|
||||
"padLabel": "Unterschriftsfeld. Mit Finger oder Stift unterschreiben.",
|
||||
"padEmpty": "Hier unterschreiben",
|
||||
"confirmation": "Ich bestätige die Ausführung der im Bericht {reportNumber} zum Auftrag {orderNumber} dokumentierten Arbeiten. Datum: {date}.",
|
||||
"save": "Speichern",
|
||||
"saved": "Gespeichert.",
|
||||
"alreadySigned": "Unterschrift liegt vor.",
|
||||
"noReport": "Zuerst den Abschlussbericht erstellen."
|
||||
},
|
||||
"pdf": {
|
||||
"page": "Seite {page} von {pages}",
|
||||
"reportId": "Bericht-ID",
|
||||
"checksum": "Prüfsumme",
|
||||
"approvalStatus": "Freigabestatus",
|
||||
"createdAt": "Erstellt am",
|
||||
"notApproved": "Nicht freigegeben"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
{
|
||||
"title": "Reports",
|
||||
"crumb": "Field work & records",
|
||||
"sub": "Review daily and completion reports, approve them and file them as PDF.",
|
||||
"type": {
|
||||
"daily": "Daily report",
|
||||
"completion": "Completion report"
|
||||
},
|
||||
"status": {
|
||||
"draft": "Draft",
|
||||
"submitted": "In review",
|
||||
"team_approved": "Checked by team lead",
|
||||
"approved": "Approved",
|
||||
"rejected": "Rejected",
|
||||
"superseded": "Superseded"
|
||||
},
|
||||
"outcome": {
|
||||
"signed": "Signed",
|
||||
"customer_absent": "Customer not present",
|
||||
"refused": "Customer refused to sign",
|
||||
"later": "Signature to be obtained later",
|
||||
"not_required": "Signature not required"
|
||||
},
|
||||
"timeType": {
|
||||
"travel": "Travel",
|
||||
"work": "Work",
|
||||
"break": "Break",
|
||||
"material_procurement": "Material procurement",
|
||||
"return_travel": "Return travel",
|
||||
"interruption": "Interruption"
|
||||
},
|
||||
"phase": {
|
||||
"before": "Before",
|
||||
"during": "During work",
|
||||
"after": "After"
|
||||
},
|
||||
"materialStatus": {
|
||||
"fully_used": "Fully used",
|
||||
"partially_used": "Partially used",
|
||||
"not_used": "Not used",
|
||||
"additional": "Additional"
|
||||
},
|
||||
"field": {
|
||||
"customer": "Customer",
|
||||
"customerNumber": "Customer number",
|
||||
"site": "Site",
|
||||
"contact": "Contact person",
|
||||
"workOrder": "Work order",
|
||||
"orderNumber": "Order number",
|
||||
"externalOrderNumber": "External order number",
|
||||
"orderType": "Order type",
|
||||
"description": "Order description",
|
||||
"scope": "Scope of work",
|
||||
"reportDate": "Report date",
|
||||
"workDates": "Date of work",
|
||||
"staff": "Staff on site",
|
||||
"technician": "Technician",
|
||||
"version": "Version",
|
||||
"reportNumber": "Report number",
|
||||
"status": "Status",
|
||||
"team": "Team",
|
||||
"type": "Type",
|
||||
"period": "Period",
|
||||
"updated": "Last changed"
|
||||
},
|
||||
"texts": {
|
||||
"workPerformed": "Work performed",
|
||||
"deviations": "Deviations",
|
||||
"additionalWork": "Additional work",
|
||||
"problems": "Problems",
|
||||
"openItems": "Open items and remaining work",
|
||||
"nextSteps": "Next steps",
|
||||
"hints": "Notes"
|
||||
},
|
||||
"section": {
|
||||
"header": "Work order and customer",
|
||||
"time": "Working hours",
|
||||
"texts": "Activities and notes",
|
||||
"materials": "Material",
|
||||
"photos": "Photos",
|
||||
"checklist": "Checklist",
|
||||
"signature": "Customer signature",
|
||||
"versions": "Versions",
|
||||
"blockers": "Still missing before completion"
|
||||
},
|
||||
"time": {
|
||||
"person": "Staff member",
|
||||
"type": "Type",
|
||||
"duration": "Duration",
|
||||
"total": "Total excluding breaks",
|
||||
"totalByType": "Totals by type",
|
||||
"hoursMinutes": "{hours} h {minutes} min",
|
||||
"running": "Includes running time, as of {time}",
|
||||
"empty": "No time recorded."
|
||||
},
|
||||
"materials": {
|
||||
"used": "Used",
|
||||
"notUsed": "Not used",
|
||||
"additional": "Additionally used",
|
||||
"name": "Material",
|
||||
"planned": "Planned",
|
||||
"actual": "Actual",
|
||||
"reason": "Reason for deviation",
|
||||
"deviation": "Deviation",
|
||||
"undocumented": "Not documented",
|
||||
"empty": "No material recorded."
|
||||
},
|
||||
"photos": {
|
||||
"empty": "No photos selected for the report.",
|
||||
"requirement": "Required photo: {label}",
|
||||
"alt": "Photo {index}"
|
||||
},
|
||||
"checklist": {
|
||||
"done": "Done",
|
||||
"open": "Open",
|
||||
"required": "Required",
|
||||
"empty": "No checklist."
|
||||
},
|
||||
"signature": {
|
||||
"signer": "Signed by",
|
||||
"role": "Role",
|
||||
"signedAt": "Date and time",
|
||||
"reason": "Reason",
|
||||
"capturedBy": "Captured by",
|
||||
"confirmation": "Confirmation text",
|
||||
"none": "No signature captured yet.",
|
||||
"image": "Signature of {name}"
|
||||
},
|
||||
"list": {
|
||||
"empty": "No reports for this selection.",
|
||||
"filter": "Filter",
|
||||
"reset": "Reset",
|
||||
"all": "All",
|
||||
"allWithSuperseded": "All incl. superseded",
|
||||
"from": "From",
|
||||
"to": "To",
|
||||
"count": "{count, plural, one {# report} other {# reports}}",
|
||||
"truncated": "Showing the latest {count} reports. Please use the filters.",
|
||||
"open": "Open"
|
||||
},
|
||||
"detail": {
|
||||
"back": "Back to reports",
|
||||
"pdf": "Open PDF",
|
||||
"pdfPending": "PDF is being generated. Reload the page later.",
|
||||
"aiDrafted": "Lotse draft",
|
||||
"aiDraftedHint": "Texts were prepared with Lotse and checked by the technician.",
|
||||
"rejectionReason": "Reason for rejection",
|
||||
"submittedAt": "Submitted on",
|
||||
"teamApprovedAt": "Checked by team lead on",
|
||||
"approvedAt": "Approved on",
|
||||
"current": "This version",
|
||||
"checksum": "Checksum (PDF)",
|
||||
"generatedAt": "Data as of"
|
||||
},
|
||||
"actions": {
|
||||
"approve": "Approve",
|
||||
"approveTeam": "Check as team lead",
|
||||
"reject": "Reject",
|
||||
"rejectTitle": "Reject report",
|
||||
"rejectSub": "The technician sees the reason and corrects the report.",
|
||||
"rejectReason": "Reason",
|
||||
"rejectPlaceholder": "What needs to be corrected?",
|
||||
"newVersion": "Create new version",
|
||||
"newVersionHint": "The approved version stays unchanged until the new version is approved.",
|
||||
"regeneratePdf": "Generate PDF",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close",
|
||||
"done": "Done."
|
||||
},
|
||||
"errors": {
|
||||
"generic": "Action not possible.",
|
||||
"not_found": "Report not found.",
|
||||
"forbidden": "You are not allowed to do this.",
|
||||
"conflict": "The report has changed in the meantime. Please reload the page.",
|
||||
"invalid": "Please check your input.",
|
||||
"blocked": "Some information is still missing.",
|
||||
"reasonRequired": "Please enter a reason.",
|
||||
"signerRequired": "Please enter the name of the signer.",
|
||||
"imageRequired": "Please have the customer sign first.",
|
||||
"notRequiredForbidden": "A signature is required for this work order."
|
||||
},
|
||||
"blocker": {
|
||||
"checklist_item": "Checklist open: {label}",
|
||||
"photo_requirement": "Required photo missing: {label}",
|
||||
"running_session": "Time tracking is still running",
|
||||
"missing_field": "Required information missing: {field}"
|
||||
},
|
||||
"mobile": {
|
||||
"title": "Report",
|
||||
"backToOrder": "Back to work order",
|
||||
"createDaily": "Create daily report",
|
||||
"createCompletion": "Create completion report",
|
||||
"dailyHint": "Finish the working day. The work order stays open.",
|
||||
"completionHint": "Finish the job: check the report, get it signed, submit.",
|
||||
"blockersHint": "Complete these items first, then create the completion report.",
|
||||
"stepReview": "Check",
|
||||
"stepEdit": "Complete",
|
||||
"stepSign": "Signature",
|
||||
"stepSubmit": "Submit",
|
||||
"stepOf": "Step {current} of {total}",
|
||||
"edit": "Complete report",
|
||||
"review": "Check report",
|
||||
"save": "Save draft",
|
||||
"saved": "Draft saved.",
|
||||
"toSign": "Continue to signature",
|
||||
"submit": "Submit report",
|
||||
"submitted": "The office is reviewing the report.",
|
||||
"readOnly": "The report has been submitted and can no longer be changed.",
|
||||
"rejected": "Rejected: {reason}",
|
||||
"signatureMissing": "Signature or reason still missing.",
|
||||
"noCompletion": "No completion report yet."
|
||||
},
|
||||
"sign": {
|
||||
"title": "Signature",
|
||||
"outcomeLabel": "Signature",
|
||||
"signerName": "Name of signer",
|
||||
"signerRole": "Role (optional)",
|
||||
"reason": "Reason",
|
||||
"clear": "Clear",
|
||||
"padLabel": "Signature field. Sign with finger or stylus.",
|
||||
"padEmpty": "Sign here",
|
||||
"confirmation": "I confirm that the work documented in report {reportNumber} for work order {orderNumber} has been carried out. Date: {date}.",
|
||||
"save": "Save",
|
||||
"saved": "Saved.",
|
||||
"alreadySigned": "Signature captured.",
|
||||
"noReport": "Create the completion report first."
|
||||
},
|
||||
"pdf": {
|
||||
"page": "Page {page} of {pages}",
|
||||
"reportId": "Report ID",
|
||||
"checksum": "Checksum",
|
||||
"approvalStatus": "Approval status",
|
||||
"createdAt": "Created on",
|
||||
"notApproved": "Not approved"
|
||||
}
|
||||
}
|
||||
Generated
+15
-2
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "isms-tool",
|
||||
"name": "craftvia",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "isms-tool",
|
||||
"name": "craftvia",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.115.0",
|
||||
@@ -27,6 +27,7 @@
|
||||
"next-intl": "^4.13.1",
|
||||
"nodemailer": "^8.0.11",
|
||||
"otplib": "^13.4.1",
|
||||
"playwright-core": "^1.63.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
@@ -11179,6 +11180,18 @@
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.63.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz",
|
||||
"integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
}
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"next-intl": "^4.13.1",
|
||||
"nodemailer": "^8.0.11",
|
||||
"otplib": "^13.4.1",
|
||||
"playwright-core": "^1.63.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
// L5 Berichte & Unterschrift — Service-Tests gegen die lokale DB.
|
||||
//
|
||||
// Deckt ab: Content-Builder (Tagesfilter, Materialabweichungen), Statusfolgen create/submit/approve/reject,
|
||||
// Auftragsstatus je Unterschriftsstand, Unterschrift-Validierung je outcome, Versionierung (freigegebene
|
||||
// Version wird nie überschrieben), Mandantentrennung (B sieht/ändert nichts von A) und Rollen/Scope
|
||||
// (Monteur ohne Zuweisung → not_found, fehlende Rechte → forbidden), Audit-Einträge.
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-berichte-flow.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
|
||||
import { ServiceError, type ServiceCtx } from "../src/server/services/context";
|
||||
import { buildReportContent } from "../src/server/services/reports/build-content";
|
||||
import { createCompletionReport, createDailyReport } from "../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../src/server/services/reports/edit";
|
||||
import { captureSignature } from "../src/server/services/reports/signature";
|
||||
import { submitReport } from "../src/server/services/reports/submit";
|
||||
import { approveReport, type ApproveDeps } from "../src/server/services/reports/approve";
|
||||
import { rejectReport } from "../src/server/services/reports/reject";
|
||||
import { createNewVersion } from "../src/server/services/reports/new-version";
|
||||
import { requireVisibleReport } from "../src/server/services/reports/common";
|
||||
import { listReports } from "../src/server/services/reports/queries";
|
||||
import { storeFile } from "../src/server/services/reports/_stubs/documents";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
async function expectCode(fn: () => Promise<unknown>, code: ServiceError["code"], msg: string) {
|
||||
try {
|
||||
await fn();
|
||||
ok(false, `${msg} — kein Fehler`);
|
||||
} catch (err) {
|
||||
const got = err instanceof ServiceError ? err.code : (err as Error).message;
|
||||
ok(got === code, `${msg} (${got})`);
|
||||
}
|
||||
}
|
||||
|
||||
const SLUG_A = "zz-berichte-a";
|
||||
const SLUG_B = "zz-berichte-b";
|
||||
const EMAIL = (s: string) => `${s}@zz-berichte.test`;
|
||||
const DAY1 = "2026-09-10";
|
||||
const DAY2 = "2026-09-11";
|
||||
// Europe/Berlin = UTC+2 in September
|
||||
const at = (day: string, hhmm: string) => new Date(`${day}T${hhmm}:00+02:00`);
|
||||
const PNG_1PX = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64");
|
||||
|
||||
async function cleanup() {
|
||||
const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } });
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (ids.length) {
|
||||
const w = { where: { tenantId: { in: ids } } };
|
||||
await prisma.signature.deleteMany(w);
|
||||
await prisma.report.deleteMany(w);
|
||||
await prisma.photo.deleteMany(w);
|
||||
await prisma.materialUsage.deleteMany(w);
|
||||
await prisma.materialPlan.deleteMany(w);
|
||||
await prisma.timeEntry.deleteMany(w);
|
||||
await prisma.workSession.deleteMany(w);
|
||||
await prisma.activityNote.deleteMany(w);
|
||||
await prisma.checklistItem.deleteMany(w);
|
||||
await prisma.photoRequirement.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.numberSequence.deleteMany(w);
|
||||
await prisma.auditLog.deleteMany(w);
|
||||
await prisma.tenantSettings.deleteMany(w);
|
||||
await prisma.user.deleteMany(w);
|
||||
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
await prisma.identity.deleteMany({ where: { email: { endsWith: "@zz-berichte.test" } } });
|
||||
}
|
||||
|
||||
async function mkUser(tenantId: string, key: string, name: string) {
|
||||
const identity = await prisma.identity.create({ data: { email: EMAIL(`${key}-${tenantId.slice(-6)}`), passwordHash: "x" } });
|
||||
return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name } });
|
||||
}
|
||||
|
||||
const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({
|
||||
db: dbForTenant(tenantId),
|
||||
tenantId,
|
||||
userId,
|
||||
permissions: new Set(ROLE_DEFS[role].permissions),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
await cleanup();
|
||||
|
||||
// ---------- fixtures ----------
|
||||
const tA = await prisma.tenant.create({ data: { name: "Berichte-Test A", slug: SLUG_A } });
|
||||
const tB = await prisma.tenant.create({ data: { name: "Berichte-Test B", slug: SLUG_B } });
|
||||
await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Musterbetrieb A GmbH", address: "Weg 1, 20095 Hamburg" } });
|
||||
await prisma.tenantSettings.create({ data: { tenantId: tB.id, orgName: "Betrieb B" } });
|
||||
|
||||
const tech = await mkUser(tA.id, "tech", "Max Monteur");
|
||||
const tech2 = await mkUser(tA.id, "tech2", "Fremd Monteur");
|
||||
const lead = await mkUser(tA.id, "lead", "Tina Teamleiter");
|
||||
const lead2 = await mkUser(tA.id, "lead2", "Andere Teamleiterin");
|
||||
const office = await mkUser(tA.id, "office", "Bernd Büro");
|
||||
const officeB = await mkUser(tB.id, "officeb", "Zoe Büro B");
|
||||
|
||||
const techCtx = ctxOf(tA.id, tech.id, "technician");
|
||||
const tech2Ctx = ctxOf(tA.id, tech2.id, "technician");
|
||||
const leadCtx = ctxOf(tA.id, lead.id, "team-lead");
|
||||
const lead2Ctx = ctxOf(tA.id, lead2.id, "team-lead");
|
||||
const officeCtx = ctxOf(tA.id, office.id, "backoffice");
|
||||
const officeBCtx = ctxOf(tB.id, officeB.id, "backoffice");
|
||||
|
||||
const team = await prisma.team.create({ data: { tenantId: tA.id, name: "Team Nord", leaderUserId: lead.id } });
|
||||
await prisma.teamMember.create({ data: { tenantId: tA.id, teamId: team.id, userId: tech.id, validFrom: new Date("2026-01-01") } });
|
||||
const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kunde GmbH", customerNumber: "K-00001", street: "Hauptstr.", houseNumber: "5", postalCode: "20095", city: "Hamburg" } });
|
||||
const contact = await prisma.contact.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Frau Kontakt", role: "Hausverwaltung" } });
|
||||
const site = await prisma.site.create({ data: { tenantId: tA.id, customerId: customer.id, name: "Wohnanlage Süd", city: "Hamburg" } });
|
||||
const wo = await prisma.workOrder.create({
|
||||
data: {
|
||||
tenantId: tA.id,
|
||||
number: "A-09001",
|
||||
customerId: customer.id,
|
||||
siteId: site.id,
|
||||
contactId: contact.id,
|
||||
title: "Elektroinstallation Keller",
|
||||
description: "Leitungen und Dosen setzen",
|
||||
status: "in_progress",
|
||||
assignedTeamId: team.id,
|
||||
signatureRequired: true,
|
||||
assignees: { create: [{ tenantId: tA.id, userId: tech.id }] },
|
||||
},
|
||||
});
|
||||
const item = await prisma.checklistItem.create({ data: { tenantId: tA.id, workOrderId: wo.id, key: "fi", label: "FI-Schutzschalter geprüft", required: true } });
|
||||
const req = await prisma.photoRequirement.create({ data: { tenantId: tA.id, workOrderId: wo.id, key: "typenschild", label: "Typenschild" } });
|
||||
const pKabel = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo.id, name: "NYM-J 3x1,5", plannedQuantity: new Prisma.Decimal(10), unit: "m" } });
|
||||
const pDose = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo.id, name: "Abzweigdose", plannedQuantity: new Prisma.Decimal(2), unit: "Stk", sortOrder: 1 } });
|
||||
const pUnused = await prisma.materialPlan.create({ data: { tenantId: tA.id, workOrderId: wo.id, name: "Kabelbinder", plannedQuantity: new Prisma.Decimal(5), unit: "Stk", sortOrder: 2 } });
|
||||
|
||||
const session = await prisma.workSession.create({ data: { tenantId: tA.id, workOrderId: wo.id, userId: tech.id, teamId: team.id, status: "ended", startedAt: at(DAY1, "08:00"), endedAt: at(DAY2, "09:00") } });
|
||||
await prisma.timeEntry.createMany({
|
||||
data: [
|
||||
{ tenantId: tA.id, workSessionId: session.id, userId: tech.id, type: "work", startedAt: at(DAY1, "08:00"), endedAt: at(DAY1, "12:00") },
|
||||
{ tenantId: tA.id, workSessionId: session.id, userId: tech.id, type: "break", startedAt: at(DAY1, "12:00"), endedAt: at(DAY1, "12:30") },
|
||||
{ tenantId: tA.id, workSessionId: session.id, userId: tech.id, type: "work", startedAt: at(DAY2, "07:00"), endedAt: at(DAY2, "09:00") },
|
||||
],
|
||||
});
|
||||
await prisma.activityNote.createMany({
|
||||
data: [
|
||||
{ tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "work_done", text: "Tag1 Leitungen verlegt", createdAt: at(DAY1, "11:00") },
|
||||
{ tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "work_done", text: "Tag2 Dosen gesetzt", createdAt: at(DAY2, "08:30") },
|
||||
{ tenantId: tA.id, workOrderId: wo.id, authorId: tech.id, kind: "deviation", text: "Tag2 Dose falsche Größe", createdAt: at(DAY2, "08:40") },
|
||||
],
|
||||
});
|
||||
const photoDoc = async (name: string) =>
|
||||
storeFile(techCtx, { bytes: PNG_1PX, fileName: name, declaredMime: "image/png", category: "photo", visibility: "team", links: { workOrderId: wo.id } });
|
||||
const d1 = await photoDoc("tag1.png");
|
||||
const d2 = await photoDoc("tag2.png");
|
||||
const d3 = await photoDoc("tag2-intern.png");
|
||||
await prisma.photo.createMany({
|
||||
data: [
|
||||
{ tenantId: tA.id, workOrderId: wo.id, documentId: d1.id, phase: "before", takenAt: at(DAY1, "08:05"), includeInReport: true },
|
||||
{ tenantId: tA.id, workOrderId: wo.id, documentId: d2.id, phase: "after", comment: "Fertig", takenAt: at(DAY2, "08:55"), includeInReport: true },
|
||||
{ tenantId: tA.id, workOrderId: wo.id, documentId: d3.id, phase: "during", takenAt: at(DAY2, "08:10"), includeInReport: false },
|
||||
],
|
||||
});
|
||||
await prisma.materialUsage.createMany({
|
||||
data: [
|
||||
{ tenantId: tA.id, workOrderId: wo.id, materialPlanId: pKabel.id, name: pKabel.name, actualQuantity: new Prisma.Decimal(12), unit: "m", usageStatus: "fully_used", deviationReason: "Umweg über Schacht", createdAt: at(DAY1, "11:30") },
|
||||
{ tenantId: tA.id, workOrderId: wo.id, materialPlanId: pDose.id, name: pDose.name, actualQuantity: new Prisma.Decimal(0), unit: "Stk", usageStatus: "not_used", deviationReason: "Falsche Größe geliefert", createdAt: at(DAY2, "08:45") },
|
||||
{ tenantId: tA.id, workOrderId: wo.id, name: "Muffe", actualQuantity: new Prisma.Decimal(3), unit: "Stk", usageStatus: "additional", deviationReason: "Zusätzlich benötigt", createdAt: at(DAY2, "08:50") },
|
||||
],
|
||||
});
|
||||
|
||||
// ---------- content builder ----------
|
||||
console.log("\n— Content-Builder —");
|
||||
const c1 = await buildReportContent(techCtx, { workOrderId: wo.id, type: "daily", reportDate: DAY1, reportNumber: "B-T", version: 1, technicianUserId: tech.id });
|
||||
ok(c1.time.totalsByType.work === 240 && c1.time.totalsByType.break === 30 && c1.time.totalMinutes === 240, "Tagesbericht Tag 1: nur Zeiten des Tages, Pause nicht in Summe");
|
||||
ok(c1.texts.workPerformed.includes("Tag1") && !c1.texts.workPerformed.includes("Tag2"), "Tagesbericht Tag 1: nur Notizen des Tages");
|
||||
ok(c1.photos.length === 1 && c1.photos[0].documentId === d1.id, "Tagesbericht Tag 1: nur Fotos des Tages");
|
||||
ok(c1.materials.used.length === 1 && c1.materials.used[0].deviation && c1.materials.used[0].deviationReason === "Umweg über Schacht", "Materialabweichung (Menge) mit Grund enthalten");
|
||||
ok(c1.materials.notUsed.length === 0 && c1.materials.additional.length === 0, "Tagesbericht Tag 1: keine Material-Einträge anderer Tage");
|
||||
ok(c1.customer.name === "Kunde GmbH" && c1.site?.name === "Wohnanlage Süd" && c1.contact?.name === "Frau Kontakt" && c1.tenant.name === "Musterbetrieb A GmbH", "Kopfdaten Mandant/Kunde/Objekt/Ansprechpartner");
|
||||
|
||||
const c2 = await buildReportContent(techCtx, { workOrderId: wo.id, type: "daily", reportDate: DAY2, reportNumber: "B-T", version: 1, technicianUserId: tech.id });
|
||||
ok(c2.photos.length === 1 && c2.photos[0].documentId === d2.id, "Tag 2: Foto ohne includeInReport ausgeschlossen");
|
||||
ok(c2.materials.notUsed[0]?.deviationReason === "Falsche Größe geliefert" && c2.materials.additional[0]?.name === "Muffe", "Tag 2: nicht verwendet + zusätzlich mit Gründen");
|
||||
ok(c2.texts.deviations.includes("falsche Größe"), "Tag 2: Abweichungsnotiz vorbelegt");
|
||||
|
||||
const cc = await buildReportContent(techCtx, { workOrderId: wo.id, type: "completion", reportDate: DAY2, reportNumber: "B-T", version: 1, technicianUserId: tech.id });
|
||||
ok(cc.time.totalMinutes === 360 && cc.workDates.join(",") === `${DAY1},${DAY2}`, "Abschluss: alle Zeiten + Einsatzdaten");
|
||||
ok(cc.materials.notUsed.some((m) => m.planId === pUnused.id && !m.documented), "Abschluss: geplantes, nicht dokumentiertes Material als Abweichung");
|
||||
ok(cc.photos.length === 2, "Abschluss: alle Berichtsfotos");
|
||||
|
||||
// ---------- scope & tenant isolation ----------
|
||||
console.log("\n— Rollen/Scope & Mandantentrennung —");
|
||||
await expectCode(() => createDailyReport(tech2Ctx, { workOrderId: wo.id, reportDate: DAY2 }), "not_found", "Monteur ohne Zuweisung: Tagesbericht anlegen → not_found");
|
||||
await expectCode(() => buildReportContent(tech2Ctx, { workOrderId: wo.id, type: "daily", reportDate: DAY2, reportNumber: "x", version: 1, technicianUserId: null }), "not_found", "Monteur ohne Zuweisung: Content lesen → not_found");
|
||||
await expectCode(() => createDailyReport(officeBCtx, { workOrderId: wo.id, reportDate: DAY2 }), "not_found", "Mandant B: Tagesbericht am Auftrag von A → not_found");
|
||||
|
||||
// ---------- daily report ----------
|
||||
console.log("\n— Tagesbericht —");
|
||||
const daily = await createDailyReport(techCtx, { workOrderId: wo.id, reportDate: DAY2 });
|
||||
ok(daily.created && daily.report.status === "draft", "Tagesbericht-Entwurf angelegt");
|
||||
const woAfterDaily = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
|
||||
ok(woAfterDaily.status === "daily_report_created", `Auftrag → daily_report_created (${woAfterDaily.status})`);
|
||||
const dailyAgain = await createDailyReport(techCtx, { workOrderId: wo.id, reportDate: DAY2 });
|
||||
ok(!dailyAgain.created && dailyAgain.report.id === daily.report.id, "Tagesbericht idempotent je Tag");
|
||||
ok(/^B-\d+$/.test((daily.report.content as { reportNumber: string }).reportNumber), "Berichtsnummer aus Nummernkreis (B-)");
|
||||
|
||||
await expectCode(() => requireVisibleReport(tech2Ctx, daily.report.id), "not_found", "Monteur ohne Zuweisung: Bericht lesen → not_found");
|
||||
await expectCode(() => requireVisibleReport(officeBCtx, daily.report.id), "not_found", "Mandant B: Bericht von A lesen → not_found");
|
||||
await expectCode(() => updateReportTexts(officeBCtx, { reportId: daily.report.id, texts: { hints: "B war hier" } }), "not_found", "Mandant B: Bericht von A ändern → not_found");
|
||||
await expectCode(() => approveReport(officeBCtx, { reportId: daily.report.id }, { dispatchPdf: async () => {} }), "not_found", "Mandant B: Bericht von A freigeben → not_found");
|
||||
let isoThrow = false;
|
||||
try {
|
||||
await dbForTenant(tB.id).report.update({ where: { id: daily.report.id }, data: { status: "approved" } });
|
||||
} catch {
|
||||
isoThrow = true;
|
||||
}
|
||||
ok(isoThrow, "Mandant B: direkter DB-Update auf Bericht von A wird vom Tenant-Guard abgewiesen");
|
||||
const listB = await listReports(officeBCtx, {});
|
||||
ok(listB.items.length === 0, "Mandant B: Liste enthält keine Berichte von A");
|
||||
const listTech2 = await listReports(tech2Ctx, {});
|
||||
ok(listTech2.items.length === 0, "Monteur ohne Zuweisung: Liste leer");
|
||||
|
||||
// ---------- completion report ----------
|
||||
console.log("\n— Abschlussbericht, Blocker, Unterschrift —");
|
||||
try {
|
||||
await createCompletionReport(techCtx, { workOrderId: wo.id });
|
||||
ok(false, "Abschlussbericht mit offenen Pflichtpunkten → blocked erwartet");
|
||||
} catch (err) {
|
||||
const blockers = (err as ServiceError).details as Array<{ kind: string }>;
|
||||
ok(err instanceof ServiceError && err.code === "blocked" && blockers.some((b) => b.kind === "checklist_item") && blockers.some((b) => b.kind === "photo_requirement"), "Abschluss blockiert: Checkliste + Pflichtfoto als Liste");
|
||||
}
|
||||
await prisma.checklistItem.update({ where: { id: item.id }, data: { checked: true, checkedById: tech.id, checkedAt: new Date() } });
|
||||
const d4 = await photoDoc("typenschild.png");
|
||||
await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo.id, documentId: d4.id, photoRequirementId: req.id, phase: "after", takenAt: at(DAY2, "08:58") } });
|
||||
|
||||
const completion = (await createCompletionReport(techCtx, { workOrderId: wo.id })).report;
|
||||
ok(completion.status === "draft" && completion.type === "completion", "Abschlussbericht-Entwurf angelegt");
|
||||
|
||||
await updateReportTexts(techCtx, { reportId: completion.id, texts: { workPerformed: "" } });
|
||||
await expectCode(() => submitReport(techCtx, { reportId: completion.id }), "blocked", "Absenden ohne Pflichtangabe (Ausgeführte Leistungen) → blocked");
|
||||
await updateReportTexts(techCtx, { reportId: completion.id, texts: { workPerformed: "Leitungen verlegt, Dosen gesetzt.", nextSteps: "Abnahme" } });
|
||||
|
||||
const confirmationText = "Bestätigt.";
|
||||
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "signed", imageDocumentId: d4.id, confirmationText }), "invalid", "signed ohne Name → invalid");
|
||||
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "signed", signerName: "Herr Kunde", confirmationText }), "invalid", "signed ohne Bild → invalid");
|
||||
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "signed", signerName: "Herr Kunde", imageDocumentId: d4.id, confirmationText }), "invalid", "signed mit Nicht-Unterschrift-Dokument → invalid");
|
||||
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "refused", confirmationText }), "invalid", "refused ohne Begründung → invalid");
|
||||
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "customer_absent", reason: " ", confirmationText }), "invalid", "customer_absent mit leerer Begründung → invalid");
|
||||
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "later", confirmationText }), "invalid", "later ohne Begründung → invalid");
|
||||
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "not_required", confirmationText }), "forbidden", "not_required bei Pflicht-Unterschrift durch Monteur → forbidden");
|
||||
await expectCode(() => captureSignature(tech2Ctx, { reportId: completion.id, outcome: "later", reason: "x", confirmationText }), "not_found", "Monteur ohne Zuweisung: Unterschrift → not_found");
|
||||
await captureSignature(techCtx, { reportId: completion.id, outcome: "later", reason: "Kunde erst morgen vor Ort", confirmationText });
|
||||
ok((await prisma.signature.count({ where: { reportId: completion.id, outcome: "later" } })) === 1, "later mit Begründung gespeichert");
|
||||
|
||||
const submitted = await submitReport(techCtx, { reportId: completion.id });
|
||||
const woPending = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
|
||||
ok(submitted.status === "submitted", "Bericht → submitted");
|
||||
ok(woPending.status === "signature_pending", `Auftrag ohne Unterschrift → signature_pending (${woPending.status})`);
|
||||
const history = await prisma.workOrderStatusChange.findMany({ where: { workOrderId: wo.id }, orderBy: { createdAt: "asc" }, select: { toStatus: true } });
|
||||
ok(history.map((h) => h.toStatus).join(">").endsWith("in_progress>technically_completed>signature_pending"), "Statusfolge über transitionWorkOrder protokolliert");
|
||||
await expectCode(() => updateReportTexts(techCtx, { reportId: completion.id, texts: { hints: "nachträglich" } }), "conflict", "Bearbeiten nach Absenden → conflict");
|
||||
|
||||
const sigDoc = await storeFile(techCtx, { bytes: PNG_1PX, fileName: "sig.png", declaredMime: "image/png", category: "signature", visibility: "customer_report", links: { workOrderId: wo.id } });
|
||||
await captureSignature(techCtx, { reportId: completion.id, outcome: "signed", signerName: "Herr Kunde", signerRole: "Eigentümer", imageDocumentId: sigDoc.id, confirmationText });
|
||||
const woSigned = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
|
||||
ok(woSigned.status === "in_review", `Unterschrift nachgereicht → Auftrag in_review (${woSigned.status})`);
|
||||
await expectCode(() => captureSignature(techCtx, { reportId: completion.id, outcome: "refused", reason: "x", confirmationText }), "conflict", "Erfasste Unterschrift wird nicht überschrieben");
|
||||
|
||||
// ---------- review ----------
|
||||
console.log("\n— Prüfung: Teamleiter, Zurückweisen, Freigabe —");
|
||||
const dispatched: string[] = [];
|
||||
const deps: ApproveDeps = { dispatchPdf: async (_c, id) => void dispatched.push(id) };
|
||||
await expectCode(() => approveReport(techCtx, { reportId: completion.id }, deps), "forbidden", "Monteur darf nicht freigeben");
|
||||
await expectCode(() => approveReport(lead2Ctx, { reportId: completion.id }, deps), "not_found", "Teamleiter eines anderen Teams → not_found");
|
||||
const teamApproved = await approveReport(leadCtx, { reportId: completion.id }, deps);
|
||||
ok(teamApproved.status === "team_approved" && teamApproved.teamApprovedById === lead.id, "Teamleiter → team_approved");
|
||||
ok(dispatched.length === 0, "Teamleiter-Prüfung erzeugt kein PDF");
|
||||
|
||||
await expectCode(() => rejectReport(officeCtx, { reportId: completion.id, reason: "" }), "invalid", "Zurückweisen ohne Grund → invalid");
|
||||
const rejected = await rejectReport(officeCtx, { reportId: completion.id, reason: "Materialliste unvollständig" });
|
||||
const woRejected = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
|
||||
ok(rejected.status === "rejected" && rejected.rejectionReason === "Materialliste unvollständig", "Backoffice → rejected mit Grund");
|
||||
ok(woRejected.status === "in_progress", `Auftrag → in_progress (Korrektur) (${woRejected.status})`);
|
||||
|
||||
await updateReportTexts(techCtx, { reportId: completion.id, texts: { hints: "Materialliste ergänzt" } });
|
||||
await submitReport(techCtx, { reportId: completion.id });
|
||||
const woResubmitted = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
|
||||
ok(woResubmitted.status === "in_review", `Erneut abgesendet mit Unterschrift → in_review (${woResubmitted.status})`);
|
||||
|
||||
const approved = await approveReport(officeCtx, { reportId: completion.id }, deps);
|
||||
ok(approved.status === "approved" && approved.approvedById === office.id, "Backoffice → approved");
|
||||
ok(dispatched.includes(completion.id), "Freigabe stößt PDF-Job an");
|
||||
const frozen = JSON.stringify(approved.content);
|
||||
const approvedAt = approved.approvedAt?.getTime();
|
||||
ok((approved.content as { signature: { outcome: string } }).signature.outcome === "signed", "Freigegebener Inhalt enthält Unterschrift");
|
||||
await expectCode(() => updateReportTexts(officeCtx, { reportId: completion.id, texts: { hints: "überschreiben" } }), "conflict", "Freigegebener Bericht nicht editierbar");
|
||||
await expectCode(() => submitReport(techCtx, { reportId: completion.id }), "conflict", "Freigegebener Bericht nicht erneut absendbar");
|
||||
await expectCode(() => captureSignature(officeCtx, { reportId: completion.id, outcome: "not_required", confirmationText }), "conflict", "Freigegebener Bericht: keine Unterschriftsänderung");
|
||||
|
||||
// ---------- versioning ----------
|
||||
console.log("\n— Versionierung —");
|
||||
await expectCode(() => createNewVersion(techCtx, { reportId: completion.id }), "forbidden", "Monteur darf keine neue Version anlegen");
|
||||
const v2 = await createNewVersion(officeCtx, { reportId: completion.id });
|
||||
ok(v2.version === 2 && v2.lineageId === completion.lineageId && v2.status === "draft", "Neue Version: gleiche Lineage, Version 2, Entwurf");
|
||||
await expectCode(() => createNewVersion(officeCtx, { reportId: completion.id }), "conflict", "Zweite neue Version aus alter Version → conflict");
|
||||
const v1AfterNew = await prisma.report.findUniqueOrThrow({ where: { id: completion.id } });
|
||||
ok(v1AfterNew.status === "approved" && JSON.stringify(v1AfterNew.content) === frozen, "Version 1 bleibt unverändert freigegeben");
|
||||
await updateReportTexts(officeCtx, { reportId: v2.id, texts: { hints: "Korrektur nach Freigabe" } });
|
||||
await submitReport(officeCtx, { reportId: v2.id });
|
||||
const v2Approved = await approveReport(officeCtx, { reportId: v2.id }, deps);
|
||||
const v1Final = await prisma.report.findUniqueOrThrow({ where: { id: completion.id } });
|
||||
ok(v2Approved.status === "approved", "Version 2 freigegeben");
|
||||
ok(v1Final.status === "superseded", "Version 1 → superseded");
|
||||
ok(JSON.stringify(v1Final.content) === frozen && v1Final.approvedAt?.getTime() === approvedAt, "Version 1 Inhalt/Freigabezeit nie überschrieben");
|
||||
ok((v2Approved.content as { texts: { hints: string } }).texts.hints === "Korrektur nach Freigabe", "Version 2 enthält die Korrektur");
|
||||
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } })).status === "in_review", "Neue Version ändert den Auftragsstatus nicht");
|
||||
|
||||
// ---------- audit ----------
|
||||
const audits = await prisma.auditLog.findMany({ where: { tenantId: tA.id, entity: { in: ["report", "signature"] } } });
|
||||
ok(audits.length >= 10 && audits.some((a) => a.before !== null && a.after !== null), `Audit-Einträge mit before/after (${audits.length})`);
|
||||
const officeList = await listReports(officeCtx, {});
|
||||
ok(officeList.items.every((i) => i.status !== "superseded") && officeList.items.some((i) => i.id === v2.id), "Liste: ersetzte Versionen standardmäßig ausgeblendet");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
failures++;
|
||||
})
|
||||
.finally(async () => {
|
||||
await cleanup().catch((e) => console.error("cleanup failed", e));
|
||||
await prisma.$disconnect();
|
||||
console.log(failures ? `\n✗ ${failures} Fehler` : "\n✓ Alle Berichte-Flow-Tests grün");
|
||||
process.exit(failures ? 1 : 0);
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// L5 Berichte — PDF-Render-Smoke: Freigabe → generateReportPdf → Datei beginnt mit %PDF, Checksumme gespeichert,
|
||||
// PDF unveränderlich (zweiter Lauf überspringt), Download nur im eigenen Mandanten.
|
||||
// Ohne startbaren Chromium/Chrome wird der Render-Teil mit klarer Meldung übersprungen (Exit 0).
|
||||
//
|
||||
// Lauf: npx tsx scripts/test-berichte-pdf.ts
|
||||
|
||||
import "dotenv/config";
|
||||
import { prisma, dbForTenant } from "../src/server/db";
|
||||
import { ROLE_DEFS, type RoleKey } from "../src/server/rbac";
|
||||
import { ServiceError, type ServiceCtx } from "../src/server/services/context";
|
||||
import { pdfRendererAvailable } from "../src/server/pdf/render";
|
||||
import { createDailyReport } from "../src/server/services/reports/create";
|
||||
import { updateReportTexts } from "../src/server/services/reports/edit";
|
||||
import { submitReport } from "../src/server/services/reports/submit";
|
||||
import { approveReport } from "../src/server/services/reports/approve";
|
||||
import { generateReportPdf } from "../src/server/services/reports/pdf";
|
||||
import { openReportFile } from "../src/server/services/reports/files";
|
||||
import { readFileBytes, sha256Hex, storeFile } from "../src/server/services/reports/_stubs/documents";
|
||||
|
||||
let failures = 0;
|
||||
const ok = (cond: boolean, msg: string) => {
|
||||
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
|
||||
if (!cond) failures++;
|
||||
};
|
||||
|
||||
const SLUG_A = "zz-berichte-pdf-a";
|
||||
const SLUG_B = "zz-berichte-pdf-b";
|
||||
const PNG_1PX = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", "base64");
|
||||
|
||||
async function cleanup() {
|
||||
const tenants = await prisma.tenant.findMany({ where: { slug: { in: [SLUG_A, SLUG_B] } }, select: { id: true } });
|
||||
const ids = tenants.map((t) => t.id);
|
||||
if (ids.length) {
|
||||
const w = { where: { tenantId: { in: ids } } };
|
||||
await prisma.signature.deleteMany(w);
|
||||
await prisma.report.deleteMany(w);
|
||||
await prisma.photo.deleteMany(w);
|
||||
await prisma.workOrderStatusChange.deleteMany(w);
|
||||
await prisma.workOrderAssignee.deleteMany(w);
|
||||
await prisma.document.deleteMany(w);
|
||||
await prisma.workOrder.deleteMany(w);
|
||||
await prisma.customer.deleteMany(w);
|
||||
await prisma.numberSequence.deleteMany(w);
|
||||
await prisma.auditLog.deleteMany(w);
|
||||
await prisma.tenantSettings.deleteMany(w);
|
||||
await prisma.user.deleteMany(w);
|
||||
await prisma.tenant.deleteMany({ where: { id: { in: ids } } });
|
||||
}
|
||||
await prisma.identity.deleteMany({ where: { email: { endsWith: "@zz-berichte-pdf.test" } } });
|
||||
}
|
||||
|
||||
const ctxOf = (tenantId: string, userId: string, role: RoleKey): ServiceCtx => ({
|
||||
db: dbForTenant(tenantId),
|
||||
tenantId,
|
||||
userId,
|
||||
permissions: new Set(ROLE_DEFS[role].permissions),
|
||||
});
|
||||
|
||||
async function main() {
|
||||
await cleanup();
|
||||
const available = await pdfRendererAvailable();
|
||||
if (!available.ok) {
|
||||
console.log(`⚠ ÜBERSPRUNGEN: kein Chromium startbar (${available.reason}). Abhilfe: PDF_CHROMIUM_PATH setzen oder "npx playwright-core install chromium".`);
|
||||
return;
|
||||
}
|
||||
|
||||
const tA = await prisma.tenant.create({ data: { name: "PDF A", slug: SLUG_A } });
|
||||
const tB = await prisma.tenant.create({ data: { name: "PDF B", slug: SLUG_B } });
|
||||
await prisma.tenantSettings.create({ data: { tenantId: tA.id, orgName: "Musterbetrieb PDF GmbH", address: "Hafenstraße 12, 20457 Hamburg", phone: "+49 40 1", email: "info@pdf.example" } });
|
||||
const mk = async (tenantId: string, key: string) => {
|
||||
const identity = await prisma.identity.create({ data: { email: `${key}@zz-berichte-pdf.test`, passwordHash: "x" } });
|
||||
return prisma.user.create({ data: { tenantId, identityId: identity.id, email: identity.email, name: `Nutzer ${key}` } });
|
||||
};
|
||||
const tech = await mk(tA.id, "tech");
|
||||
const office = await mk(tA.id, "office");
|
||||
const officeB = await mk(tB.id, "officeb");
|
||||
const techCtx = ctxOf(tA.id, tech.id, "technician");
|
||||
const officeCtx = ctxOf(tA.id, office.id, "backoffice");
|
||||
const officeBCtx = ctxOf(tB.id, officeB.id, "backoffice");
|
||||
|
||||
const customer = await prisma.customer.create({ data: { tenantId: tA.id, companyName: "Kundin & Söhne <GmbH>" } });
|
||||
const wo = await prisma.workOrder.create({
|
||||
data: { tenantId: tA.id, number: "A-PDF1", customerId: customer.id, title: "Heizung warten", status: "in_progress", assignees: { create: [{ tenantId: tA.id, userId: tech.id }] } },
|
||||
});
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const doc = await storeFile(techCtx, { bytes: PNG_1PX, fileName: `foto-${i}.png`, declaredMime: "image/png", category: "photo", visibility: "team", links: { workOrderId: wo.id } });
|
||||
await prisma.photo.create({ data: { tenantId: tA.id, workOrderId: wo.id, documentId: doc.id, phase: "after", comment: `Foto ${i}`, takenAt: new Date() } });
|
||||
}
|
||||
|
||||
const { report } = await createDailyReport(techCtx, { workOrderId: wo.id });
|
||||
await updateReportTexts(techCtx, { reportId: report.id, texts: { workPerformed: "Brenner gereinigt.\nDruck geprüft.".repeat(40), hints: "Filter in 6 Monaten tauschen" } });
|
||||
await submitReport(techCtx, { reportId: report.id });
|
||||
const approved = await approveReport(officeCtx, { reportId: report.id }, { dispatchPdf: async () => {} });
|
||||
ok(approved.status === "approved" && !approved.pdfDocumentId, "Bericht freigegeben, PDF noch nicht erzeugt");
|
||||
|
||||
const t0 = Date.now();
|
||||
const res = await generateReportPdf(officeCtx, report.id);
|
||||
ok(!res.skipped, `PDF gerendert (${Date.now() - t0} ms)`);
|
||||
const stored = await prisma.report.findUniqueOrThrow({ where: { id: report.id } });
|
||||
const doc = await prisma.document.findUniqueOrThrow({ where: { id: stored.pdfDocumentId! } });
|
||||
ok(doc.fileSize > 0 && doc.mimeType === "application/pdf", `Dokument gespeichert (${doc.fileSize} Bytes)`);
|
||||
ok(doc.category === "daily_report" && doc.visibility === "customer_report" && doc.workOrderId === wo.id, "Kategorie daily_report, Sichtbarkeit customer_report, am Auftrag");
|
||||
ok(stored.pdfChecksum === doc.checksum && /^[0-9a-f]{64}$/.test(stored.pdfChecksum ?? ""), "SHA-256-Prüfsumme am Bericht gespeichert");
|
||||
const bytes = await readFileBytes(doc.storageKey);
|
||||
if (bytes) {
|
||||
ok(Buffer.from(bytes).subarray(0, 4).toString() === "%PDF", "Datei beginnt mit %PDF");
|
||||
ok(sha256Hex(bytes) === stored.pdfChecksum, "Prüfsumme entspricht den gespeicherten Bytes");
|
||||
} else {
|
||||
console.log("⚠ Storage-Stub ohne Bytes (S3_* nicht gesetzt) — Byte-Prüfung übersprungen");
|
||||
}
|
||||
|
||||
const again = await generateReportPdf(officeCtx, report.id);
|
||||
ok(again.skipped && again.documentId === doc.id, "Zweiter Lauf überschreibt das freigegebene PDF nicht");
|
||||
|
||||
if (bytes) {
|
||||
const file = await openReportFile(officeCtx, report.id, "pdf");
|
||||
ok(file.mimeType === "application/pdf", "Download im eigenen Mandanten möglich");
|
||||
}
|
||||
try {
|
||||
await openReportFile(officeBCtx, report.id, "pdf");
|
||||
ok(false, "Mandant B: PDF von A → not_found erwartet");
|
||||
} catch (err) {
|
||||
ok(err instanceof ServiceError && err.code === "not_found", "Mandant B: PDF von A → not_found");
|
||||
}
|
||||
try {
|
||||
await openReportFile(officeCtx, report.id, doc.id === "x" ? "y" : "unrelated-document-id");
|
||||
ok(false, "Nicht referenziertes Dokument → not_found erwartet");
|
||||
} catch (err) {
|
||||
ok(err instanceof ServiceError && err.code === "not_found", "Nicht referenziertes Dokument über Berichtsroute → not_found");
|
||||
}
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
failures++;
|
||||
})
|
||||
.finally(async () => {
|
||||
await cleanup().catch((e) => console.error("cleanup failed", e));
|
||||
await prisma.$disconnect();
|
||||
console.log(failures ? `\n✗ ${failures} Fehler` : "\n✓ PDF-Smoke abgeschlossen");
|
||||
process.exit(failures ? 1 : 0);
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { ReportScreen } from "@/components/reports/mobile/report-screen";
|
||||
|
||||
/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. */
|
||||
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ type?: string }> }) {
|
||||
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||
return <ReportScreen workOrderId={id} type={sp.type === "daily" ? "daily" : "completion"} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SignScreen } from "@/components/reports/mobile/sign-screen";
|
||||
|
||||
/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. */
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <SignScreen workOrderId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, FileText, Sparkles } from "lucide-react";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { RejectForm } from "@/components/reports/reject-form";
|
||||
import { ReportView } from "@/components/reports/report-view";
|
||||
import { ReviewActions } from "@/components/reports/review-actions";
|
||||
import { ReportStatusBadge } from "@/components/reports/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { tenantTimeZone } from "@/server/services/reports/build-content";
|
||||
import { getReportDetail } from "@/server/services/reports/queries";
|
||||
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||
|
||||
/** /reports/[id] — structured view, PDF, versions, approve/reject/new version (reject as popup ?reject=1). */
|
||||
export default async function ReportDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ reject?: string }> }) {
|
||||
const [{ id }, sp, t, format, ctx] = await Promise.all([params, searchParams, getTranslations("reports"), getFormatter(), readCtx()]);
|
||||
let detail: Awaited<ReturnType<typeof getReportDetail>>;
|
||||
try {
|
||||
detail = await getReportDetail(ctx, id);
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError && err.code === "not_found") notFound();
|
||||
throw err;
|
||||
}
|
||||
const timeZone = await tenantTimeZone(ctx);
|
||||
const { report, content, versions, workOrder, permissions } = detail;
|
||||
const dt = (d: Date | null) => (d ? format.dateTime(d, { dateStyle: "medium", timeStyle: "short", timeZone }) : null);
|
||||
const base = `/reports/${id}`;
|
||||
|
||||
const meta: Array<[string, string | null]> = [
|
||||
[t("field.workOrder"), `${workOrder.number}`],
|
||||
[t("field.reportDate"), format.dateTime(report.reportDate, { dateStyle: "medium", timeZone: "UTC" })],
|
||||
[t("detail.submittedAt"), dt(report.submittedAt)],
|
||||
[t("detail.teamApprovedAt"), report.teamApprovedAt ? `${dt(report.teamApprovedAt)}${detail.teamApprovedByName ? ` · ${detail.teamApprovedByName}` : ""}` : null],
|
||||
[t("detail.approvedAt"), report.approvedAt ? `${dt(report.approvedAt)}${detail.approvedByName ? ` · ${detail.approvedByName}` : ""}` : null],
|
||||
[t("detail.generatedAt"), dt(new Date(content.generatedAt))],
|
||||
[t("detail.checksum"), report.pdfChecksum],
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<Link href="/reports" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
|
||||
</Link>
|
||||
<PageHead
|
||||
crumb={`${t("title")} · ${content.customer.name}`}
|
||||
title={`${t(`type.${report.type}`)} ${content.reportNumber}`}
|
||||
sub={`${workOrder.number} · ${content.workOrder.title}`}
|
||||
actions={
|
||||
report.pdfDocumentId ? (
|
||||
<Button className="h-11 px-4" nativeButton={false} render={<a href={`/api/v1/reports/${id}/pdf`} target="_blank" rel="noopener noreferrer" />}>
|
||||
<FileText aria-hidden />
|
||||
{t("detail.pdf")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<ReportStatusBadge status={report.status} label={t(`status.${report.status}`)} />
|
||||
<Pill tone="mut">
|
||||
{t("field.version")} {report.version}
|
||||
</Pill>
|
||||
{report.aiDrafted && (
|
||||
<span title={t("detail.aiDraftedHint")}>
|
||||
<Pill tone="orange">
|
||||
<Sparkles className="size-3.5" aria-hidden />
|
||||
{t("detail.aiDrafted")}
|
||||
</Pill>
|
||||
</span>
|
||||
)}
|
||||
{report.status === "approved" && !report.pdfDocumentId && <span className="text-[12.5px] text-muted-foreground">{t("detail.pdfPending")}</span>}
|
||||
</div>
|
||||
|
||||
{report.status === "rejected" && report.rejectionReason && (
|
||||
<p role="alert" className="mb-4 rounded-xl border border-[var(--risk)] bg-card p-3 text-[13.5px]">
|
||||
<span className="font-semibold text-[var(--risk)]">{t("detail.rejectionReason")}:</span> {report.rejectionReason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<ReviewActions reportId={id} can={permissions} rejectHref={`${base}?reject=1`} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<ReportView content={content} reportId={id} timeZone={timeZone} />
|
||||
<aside className="space-y-4">
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<dl className="space-y-2 text-[13px]">
|
||||
{meta
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<dt className="text-[11.5px] font-semibold text-muted-foreground">{k}</dt>
|
||||
<dd className="break-all">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<h2 className="font-heading text-[15px] font-semibold">{t("section.versions")}</h2>
|
||||
<ol className="mt-2 space-y-1.5">
|
||||
{versions.map((v) => (
|
||||
<li key={v.id} className="flex flex-wrap items-center justify-between gap-2 text-[13px]">
|
||||
{v.id === id ? (
|
||||
<span className="font-semibold">
|
||||
v{v.version} · {t("detail.current")}
|
||||
</span>
|
||||
) : (
|
||||
<Link href={`/reports/${v.id}`} className="font-semibold text-[var(--primary)] hover:underline">
|
||||
v{v.version}
|
||||
</Link>
|
||||
)}
|
||||
<ReportStatusBadge status={v.status} label={t(`status.${v.status}`)} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{sp.reject && permissions.reject && (
|
||||
<Modal title={t("actions.rejectTitle")} sub={t("actions.rejectSub")} closeHref={base} closeLabel={t("actions.close")}>
|
||||
<RejectForm reportId={id} closeHref={base} />
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,123 @@
|
||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
||||
import Link from "next/link";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { ReportStatusBadge } from "@/components/reports/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { REPORT_STATUSES, REPORT_TYPES } from "@/lib/reports/content";
|
||||
import { listReports, teamOptions, type ReportListFilters } from "@/server/services/reports/queries";
|
||||
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||
|
||||
export default function Page() {
|
||||
return <ModulePlaceholder moduleKey="reports" />;
|
||||
/** /reports — in review first; filters type/status/team/period (GET form, shareable URL). */
|
||||
export default async function ReportsPage({ searchParams }: { searchParams: Promise<ReportListFilters> }) {
|
||||
const [t, format, ctx, sp] = await Promise.all([getTranslations("reports"), getFormatter(), readCtx(), searchParams]);
|
||||
const [{ items, truncated }, teams] = await Promise.all([listReports(ctx, sp), teamOptions(ctx)]);
|
||||
const select = "h-11 w-full rounded-lg border border-input bg-card px-2.5 text-[14px]";
|
||||
const label = "text-[12px] font-semibold text-muted-foreground";
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||
|
||||
<form method="get" className="shadow-card grid gap-3 rounded-xl border bg-card p-4 sm:grid-cols-2 lg:grid-cols-6 lg:items-end">
|
||||
<label className="grid gap-1">
|
||||
<span className={label}>{t("field.type")}</span>
|
||||
<select name="type" defaultValue={sp.type ?? ""} className={select}>
|
||||
<option value="">{t("list.all")}</option>
|
||||
{REPORT_TYPES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{t(`type.${v}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1">
|
||||
<span className={label}>{t("field.status")}</span>
|
||||
<select name="status" defaultValue={sp.status ?? ""} className={select}>
|
||||
<option value="">{t("list.all")}</option>
|
||||
{REPORT_STATUSES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{t(`status.${v}`)}
|
||||
</option>
|
||||
))}
|
||||
<option value="all">{t("list.allWithSuperseded")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1">
|
||||
<span className={label}>{t("field.team")}</span>
|
||||
<select name="teamId" defaultValue={sp.teamId ?? ""} className={select}>
|
||||
<option value="">{t("list.all")}</option>
|
||||
{teams.map((tm) => (
|
||||
<option key={tm.id} value={tm.id}>
|
||||
{tm.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1">
|
||||
<span className={label}>{t("list.from")}</span>
|
||||
<input type="date" name="from" defaultValue={sp.from ?? ""} className={select} />
|
||||
</label>
|
||||
<label className="grid gap-1">
|
||||
<span className={label}>{t("list.to")}</span>
|
||||
<input type="date" name="to" defaultValue={sp.to ?? ""} className={select} />
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="h-11 flex-1 px-4">
|
||||
{t("list.filter")}
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 px-4" nativeButton={false} render={<Link href="/reports" />}>
|
||||
{t("list.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-[12.5px] text-muted-foreground" aria-live="polite">
|
||||
{t("list.count", { count: items.length })}
|
||||
{truncated ? ` · ${t("list.truncated", { count: items.length })}` : ""}
|
||||
</p>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="shadow-card mt-2 rounded-xl border bg-card p-5 text-[13.5px] text-muted-foreground">{t("list.empty")}</p>
|
||||
) : (
|
||||
<div className="shadow-card mt-2 overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[720px] text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[12px] text-muted-foreground">
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.reportNumber")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.workOrder")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.type")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.reportDate")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.team")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.status")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link href={`/reports/${r.id}`} className="font-semibold text-[var(--primary)] hover:underline">
|
||||
{r.reportNumber}
|
||||
</Link>
|
||||
<span className="text-muted-foreground"> · v{r.version}</span>
|
||||
{r.aiDrafted ? <Sparkles className="ml-1 inline size-3.5 text-[var(--ui-accent)]" aria-label={t("detail.aiDrafted")} /> : null}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="font-semibold">{r.workOrder.number}</span> · {r.workOrder.title}
|
||||
<span className="block text-[12px] text-muted-foreground">{r.customerName}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">{t(`type.${r.type}`)}</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap">{format.dateTime(r.reportDate, { dateStyle: "medium", timeZone: "UTC" })}</td>
|
||||
<td className="px-4 py-2.5">{r.teamName ?? "—"}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<ReportStatusBadge status={r.status} label={t(`status.${r.status}`)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { approveReport } from "@/server/services/reports/approve";
|
||||
import { reportDto, withReportsApi } from "@/server/services/reports/http";
|
||||
|
||||
/** POST /api/v1/reports/:id/approve — team lead → team_approved, backoffice → approved (+ PDF job). */
|
||||
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withReportsApi(["report:read"], async (ctx) => {
|
||||
const report = await approveReport(ctx, { reportId: id });
|
||||
return Response.json({ report: reportDto(report) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { fileResponse, openReportFile } from "@/server/services/reports/files";
|
||||
import { withReportsApi } from "@/server/services/reports/http";
|
||||
|
||||
/** GET /api/v1/reports/:id/files/:documentId — photo/signature/logo referenced by the report snapshot. */
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string; documentId: string }> }) {
|
||||
const { id, documentId } = await params;
|
||||
const download = new URL(req.url).searchParams.get("download") === "1";
|
||||
return withReportsApi(["report:read"], async (ctx) => fileResponse(await openReportFile(ctx, id, documentId), { download }));
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { fileResponse, openReportFile } from "@/server/services/reports/files";
|
||||
import { withReportsApi } from "@/server/services/reports/http";
|
||||
|
||||
/** GET /api/v1/reports/:id/pdf — the immutable PDF of an approved report (?download=1 for attachment). */
|
||||
export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
const download = new URL(req.url).searchParams.get("download") === "1";
|
||||
return withReportsApi(["report:read"], async (ctx) => fileResponse(await openReportFile(ctx, id, "pdf"), { download }));
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createCompletionReport } from "@/server/services/reports/create";
|
||||
import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http";
|
||||
|
||||
/** POST /api/v1/work-orders/:id/completion-report — create (or return) the completion report draft; 422 + blockers if blocked. */
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withReportsApi(["report:write"], async (ctx) => {
|
||||
const body = await readJson(req);
|
||||
const res = await createCompletionReport(ctx, { ...body, workOrderId: id } as Parameters<typeof createCompletionReport>[1]);
|
||||
return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createDailyReport } from "@/server/services/reports/create";
|
||||
import { readJson, reportDto, withReportsApi } from "@/server/services/reports/http";
|
||||
|
||||
/** POST /api/v1/work-orders/:id/daily-report — create (or return) the daily report draft. Body: { reportDate?, clientId? } */
|
||||
export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return withReportsApi(["report:write"], async (ctx) => {
|
||||
const body = await readJson(req);
|
||||
const res = await createDailyReport(ctx, { ...body, workOrderId: id } as Parameters<typeof createDailyReport>[1]);
|
||||
return Response.json({ report: reportDto(res.report), created: res.created }, { status: res.created ? 201 : 200 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"use client";
|
||||
|
||||
import { CheckCircle2, XCircle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { ReportActionState } from "@/lib/reports/action-state";
|
||||
import { BlockerList } from "./blocker-list";
|
||||
|
||||
const FIELD_MESSAGES: Record<string, string> = {
|
||||
reason: "errors.reasonRequired",
|
||||
signerName: "errors.signerRequired",
|
||||
image: "errors.imageRequired",
|
||||
};
|
||||
|
||||
/** Inline feedback for report actions (errors directly at the form, Brandbook §12.5). */
|
||||
export function ActionMessage({ state, okText }: { state: ReportActionState; okText?: string }) {
|
||||
const t = useTranslations("reports");
|
||||
if (state.status === "ok") {
|
||||
if (!okText) return null;
|
||||
return (
|
||||
<p role="status" className="flex items-center gap-2 text-[13px] font-semibold text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-4" aria-hidden />
|
||||
{okText}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (state.status !== "error") return null;
|
||||
if (state.blockers?.length) return <BlockerList blockers={state.blockers} title={t("errors.blocked")} />;
|
||||
const key = state.field && FIELD_MESSAGES[state.field] ? FIELD_MESSAGES[state.field] : state.code === "forbidden" && state.field === undefined ? "errors.forbidden" : `errors.${state.code}`;
|
||||
return (
|
||||
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-[13px] font-semibold text-[var(--risk)]">
|
||||
<XCircle className="size-4 shrink-0" aria-hidden />
|
||||
{t(key)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||
|
||||
/** Structured list of what is still missing (checklist, required photos, running time, required fields). */
|
||||
export function BlockerList({ blockers, title }: { blockers: CompletionBlocker[]; title?: string }) {
|
||||
const t = useTranslations("reports");
|
||||
if (!blockers.length) return null;
|
||||
const label = (b: CompletionBlocker) => {
|
||||
switch (b.kind) {
|
||||
case "checklist_item":
|
||||
return t("blocker.checklist_item", { label: b.label });
|
||||
case "photo_requirement":
|
||||
return t("blocker.photo_requirement", { label: b.label });
|
||||
case "running_session":
|
||||
return t("blocker.running_session");
|
||||
case "missing_field":
|
||||
return t("blocker.missing_field", { field: t.has(`texts.${b.field}`) ? t(`texts.${b.field}`) : b.field });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border border-[var(--warn)] bg-card p-4">
|
||||
<p className="flex items-center gap-2 font-heading text-sm font-semibold text-[var(--warn)]">
|
||||
<AlertTriangle className="size-4.5" aria-hidden />
|
||||
{title ?? t("section.blockers")}
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1.5 text-[13.5px]">
|
||||
{blockers.map((b, i) => (
|
||||
<li key={i} className="flex gap-2">
|
||||
<span aria-hidden>•</span>
|
||||
{label(b)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { FilePlus2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IDLE } from "@/lib/reports/action-state";
|
||||
import { createReportAction } from "@/server/actions/reports/workflow";
|
||||
import { ActionMessage } from "../action-message";
|
||||
|
||||
export function CreateReportForm({ workOrderId, type, label, disabled }: { workOrderId: string; type: "daily" | "completion"; label: string; disabled?: boolean }) {
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(createReportAction, IDLE);
|
||||
useEffect(() => {
|
||||
if (state.status === "ok") router.refresh();
|
||||
}, [state, router]);
|
||||
return (
|
||||
<form action={action} className="space-y-3">
|
||||
<input type="hidden" name="workOrderId" value={workOrderId} />
|
||||
<input type="hidden" name="type" value={type} />
|
||||
<Button type="submit" disabled={pending || disabled} className="h-12 w-full text-[15px]">
|
||||
<FilePlus2 aria-hidden />
|
||||
{label}
|
||||
</Button>
|
||||
<ActionMessage state={state} />
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { ArrowRight, Save, Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { IDLE } from "@/lib/reports/action-state";
|
||||
import { REPORT_REQUIRED_TEXTS, REPORT_TEXT_FIELDS, TEXT_MAX, type ReportTexts, type ReportType } from "@/lib/reports/content";
|
||||
import { saveReportTextsAction, submitReportAction } from "@/server/actions/reports/workflow";
|
||||
import { ActionMessage } from "../action-message";
|
||||
|
||||
/**
|
||||
* Mobile report editor: technician checks/extends the prefilled texts.
|
||||
* Daily report: save or submit directly (signature optional). Completion: save and continue to signature.
|
||||
*/
|
||||
export function ReportEditor({ reportId, type, texts, signHref }: { reportId: string; type: ReportType; texts: ReportTexts; signHref?: string }) {
|
||||
const t = useTranslations("reports");
|
||||
const router = useRouter();
|
||||
const [intent, setIntent] = useState<"save" | "sign">("save");
|
||||
const [saveState, save, saving] = useActionState(saveReportTextsAction, IDLE);
|
||||
const [submitState, submit, submitting] = useActionState(submitReportAction, IDLE);
|
||||
const required = new Set<string>(REPORT_REQUIRED_TEXTS[type]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState.status === "ok" && intent === "sign" && signHref) router.push(signHref);
|
||||
}, [saveState, intent, router, signHref]);
|
||||
useEffect(() => {
|
||||
if (submitState.status === "ok") router.refresh();
|
||||
}, [submitState, router]);
|
||||
|
||||
return (
|
||||
<form action={save} className="shadow-card space-y-4 rounded-xl border bg-card p-4">
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<h2 className="font-heading text-[15px] font-semibold">{t("mobile.edit")}</h2>
|
||||
{REPORT_TEXT_FIELDS.map((f) => (
|
||||
<div key={f}>
|
||||
<Label htmlFor={`rt-${f}`} className="text-[13px]">
|
||||
{t(`texts.${f}`)}
|
||||
{required.has(f) ? " *" : ""}
|
||||
</Label>
|
||||
<Textarea
|
||||
id={`rt-${f}`}
|
||||
name={f}
|
||||
defaultValue={texts[f]}
|
||||
maxLength={TEXT_MAX}
|
||||
rows={f === "workPerformed" ? 5 : 2}
|
||||
className="mt-1 min-h-12 text-base"
|
||||
aria-invalid={submitState.status === "error" && submitState.blockers?.some((b) => b.kind === "missing_field" && b.field === f)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<ActionMessage state={saveState} okText={intent === "save" ? t("mobile.saved") : undefined} />
|
||||
<ActionMessage state={submitState} okText={t("mobile.submitted")} />
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<Button type="submit" variant="outline" disabled={saving || submitting} onClick={() => setIntent("save")} className="h-12 flex-1 text-[15px]">
|
||||
<Save aria-hidden />
|
||||
{t("mobile.save")}
|
||||
</Button>
|
||||
{type === "completion" && signHref ? (
|
||||
<Button type="submit" disabled={saving || submitting} onClick={() => setIntent("sign")} className="h-12 flex-1 text-[15px]">
|
||||
{t("mobile.toSign")}
|
||||
<ArrowRight aria-hidden />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" formAction={submit} disabled={saving || submitting} className="h-12 flex-1 text-[15px]">
|
||||
<Send aria-hidden />
|
||||
{t("mobile.submit")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import type { ReportContent } from "@/lib/reports/content";
|
||||
import { ReportView } from "../report-view";
|
||||
|
||||
/** Mobile read-only review of the report as the customer/office will see it. */
|
||||
export async function ReportReview({ content, reportId, timeZone }: { content: ReportContent; reportId: string; timeZone: string }) {
|
||||
const t = await getTranslations("reports");
|
||||
return (
|
||||
<section aria-label={t("mobile.review")} className="space-y-2">
|
||||
<h2 className="font-heading text-[15px] font-semibold">{t("mobile.review")}</h2>
|
||||
<ReportView content={content} reportId={reportId} timeZone={timeZone} compact />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { REPORT_EDITABLE, type ReportStatus } from "@/lib/reports/content";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { getMobileReportState } from "@/server/services/reports/queries";
|
||||
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||
import { BlockerList } from "../blocker-list";
|
||||
import { ReportStatusBadge } from "../status-badge";
|
||||
import { StepIndicator } from "../step-indicator";
|
||||
import { CreateReportForm } from "./create-report-form";
|
||||
import { ReportEditor } from "./report-editor";
|
||||
import { ReportReview } from "./report-review";
|
||||
|
||||
/** /m/orders/[id]/report — blockers → create → check/extend → (completion) continue to signature. */
|
||||
export async function ReportScreen({ workOrderId, type }: { workOrderId: string; type: "daily" | "completion" }) {
|
||||
const t = await getTranslations("reports");
|
||||
const ctx = await readCtx();
|
||||
let state: Awaited<ReturnType<typeof getMobileReportState>>;
|
||||
try {
|
||||
state = await getMobileReportState(ctx, workOrderId, type);
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError && err.code === "not_found") notFound();
|
||||
throw err;
|
||||
}
|
||||
const { workOrder, report, content, blockers, timeZone } = state;
|
||||
const base = `/m/orders/${workOrderId}`;
|
||||
const editable = report ? REPORT_EDITABLE.includes(report.status as ReportStatus) : false;
|
||||
const steps = [t("mobile.stepReview"), t("mobile.stepEdit"), t("mobile.stepSign"), t("mobile.stepSubmit")];
|
||||
const tab = (active: boolean) =>
|
||||
cn("flex h-12 flex-1 items-center justify-center rounded-lg border text-[14px] font-semibold", active ? "border-[var(--ui-accent)] text-foreground" : "text-muted-foreground");
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-2xl flex-1 space-y-4 p-4">
|
||||
<Link href={base} 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("mobile.backToOrder")}
|
||||
</Link>
|
||||
<header>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{workOrder.number} · {workOrder.title}
|
||||
</p>
|
||||
<h1 className="text-[22px]">{t("mobile.title")}</h1>
|
||||
</header>
|
||||
|
||||
<nav className="flex gap-2" aria-label={t("field.type")}>
|
||||
<Link href={`${base}/report?type=completion`} className={tab(type === "completion")} aria-current={type === "completion" ? "page" : undefined}>
|
||||
{t("type.completion")}
|
||||
</Link>
|
||||
<Link href={`${base}/report?type=daily`} className={tab(type === "daily")} aria-current={type === "daily" ? "page" : undefined}>
|
||||
{t("type.daily")}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{!report && (
|
||||
<section className="shadow-card space-y-3 rounded-xl border bg-card p-4">
|
||||
<p className="text-[14px]">{type === "daily" ? t("mobile.dailyHint") : t("mobile.completionHint")}</p>
|
||||
{type === "completion" && blockers.length > 0 && (
|
||||
<>
|
||||
<BlockerList blockers={blockers} />
|
||||
<p className="text-[13px] text-muted-foreground">{t("mobile.blockersHint")}</p>
|
||||
</>
|
||||
)}
|
||||
<CreateReportForm
|
||||
workOrderId={workOrderId}
|
||||
type={type}
|
||||
label={type === "daily" ? t("mobile.createDaily") : t("mobile.createCompletion")}
|
||||
disabled={type === "completion" && blockers.length > 0}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{report && content && (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ReportStatusBadge status={report.status as ReportStatus} label={t(`status.${report.status}`)} />
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
{t(`type.${report.type}`)} {content.reportNumber} · {t("field.version")} {report.version}
|
||||
</span>
|
||||
</div>
|
||||
{report.status === "rejected" && report.rejectionReason && (
|
||||
<p role="alert" className="rounded-xl border border-[var(--risk)] bg-card p-3 text-[14px] text-[var(--risk)]">
|
||||
{t("mobile.rejected", { reason: report.rejectionReason })}
|
||||
</p>
|
||||
)}
|
||||
{type === "completion" && editable && (
|
||||
<StepIndicator steps={steps} current={2} label={t("mobile.stepOf", { current: 2, total: steps.length })} />
|
||||
)}
|
||||
{editable && blockers.length > 0 && <BlockerList blockers={blockers} />}
|
||||
{editable ? (
|
||||
<ReportEditor reportId={report.id} type={report.type} texts={content.texts} signHref={type === "completion" ? `${base}/sign` : undefined} />
|
||||
) : (
|
||||
<p role="status" className="shadow-card rounded-xl border bg-card p-3 text-[14px]">
|
||||
{report.status === "approved" || report.status === "submitted" || report.status === "team_approved" ? t("mobile.submitted") : t("mobile.readOnly")}
|
||||
</p>
|
||||
)}
|
||||
<ReportReview content={content} reportId={report.id} timeZone={timeZone} />
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useCallback, useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckCircle2, PenLine, Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { IDLE } from "@/lib/reports/action-state";
|
||||
import { SIGNATURE_OUTCOMES, SIGNATURE_REASON_REQUIRED, type SignatureOutcome } from "@/lib/reports/content";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { captureSignatureAction } from "@/server/actions/reports/signature";
|
||||
import { submitReportAction } from "@/server/actions/reports/workflow";
|
||||
import { ActionMessage } from "../action-message";
|
||||
import { SignaturePad } from "../signature-pad";
|
||||
|
||||
/**
|
||||
* Mobile completion step 3+4: signature or documented reason (Spec §18.2), then submit.
|
||||
*/
|
||||
export function SignFlow({
|
||||
reportId,
|
||||
reportNumber,
|
||||
orderNumber,
|
||||
dateLabel,
|
||||
canNotRequired,
|
||||
existing,
|
||||
editable,
|
||||
doneHref,
|
||||
}: {
|
||||
reportId: string;
|
||||
reportNumber: string;
|
||||
orderNumber: string;
|
||||
dateLabel: string;
|
||||
canNotRequired: boolean;
|
||||
existing: { outcome: SignatureOutcome; signerName: string | null; reason: string | null } | null;
|
||||
editable: boolean;
|
||||
doneHref: string;
|
||||
}) {
|
||||
const t = useTranslations("reports");
|
||||
const router = useRouter();
|
||||
const [outcome, setOutcome] = useState<SignatureOutcome>(existing && existing.outcome !== "signed" ? existing.outcome : "signed");
|
||||
const [png, setPng] = useState<string | null>(null);
|
||||
const [sigState, capture, capturing] = useActionState(captureSignatureAction, IDLE);
|
||||
const [submitState, submit, submitting] = useActionState(submitReportAction, IDLE);
|
||||
const onPad = useCallback((v: string | null) => setPng(v), []);
|
||||
const confirmationText = t("sign.confirmation", { reportNumber, orderNumber, date: dateLabel });
|
||||
const signed = existing?.outcome === "signed";
|
||||
const options = SIGNATURE_OUTCOMES.filter((o) => o !== "not_required" || canNotRequired);
|
||||
|
||||
useEffect(() => {
|
||||
if (sigState.status === "ok") router.refresh();
|
||||
}, [sigState, router]);
|
||||
useEffect(() => {
|
||||
if (submitState.status === "ok") router.push(doneHref);
|
||||
}, [submitState, router, doneHref]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{signed ? (
|
||||
<p role="status" className="shadow-card flex items-center gap-2 rounded-xl border bg-card p-4 text-[14px] font-semibold text-[var(--ok)]">
|
||||
<CheckCircle2 className="size-5" aria-hidden />
|
||||
{t("sign.alreadySigned")} {existing?.signerName ? `· ${existing.signerName}` : ""}
|
||||
</p>
|
||||
) : (
|
||||
<form action={capture} className="shadow-card space-y-4 rounded-xl border bg-card p-4">
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<input type="hidden" name="outcome" value={outcome} />
|
||||
<input type="hidden" name="confirmationText" value={confirmationText} />
|
||||
<input type="hidden" name="signaturePng" value={outcome === "signed" ? (png ?? "") : ""} />
|
||||
|
||||
<fieldset>
|
||||
<legend className="font-heading text-[15px] font-semibold">{t("sign.outcomeLabel")}</legend>
|
||||
<div className="mt-2 grid gap-2">
|
||||
{options.map((o) => (
|
||||
<label
|
||||
key={o}
|
||||
className={cn(
|
||||
"flex min-h-12 cursor-pointer items-center gap-3 rounded-lg border px-3 text-[14px]",
|
||||
outcome === o && "border-[var(--ui-accent)] font-semibold",
|
||||
)}
|
||||
>
|
||||
<input type="radio" name="outcomeChoice" value={o} checked={outcome === o} onChange={() => setOutcome(o)} className="size-5 accent-[var(--ui-accent)]" />
|
||||
{t(`outcome.${o}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
{outcome === "signed" && (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div>
|
||||
<Label htmlFor="sig-name">{t("sign.signerName")} *</Label>
|
||||
<Input id="sig-name" name="signerName" required autoComplete="name" className="mt-1 h-12 text-base" aria-invalid={sigState.status === "error" && sigState.field === "signerName"} />
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="sig-role">{t("sign.signerRole")}</Label>
|
||||
<Input id="sig-role" name="signerRole" className="mt-1 h-12 text-base" />
|
||||
</div>
|
||||
</div>
|
||||
<SignaturePad onChange={onPad} labels={{ clear: t("sign.clear"), padLabel: t("sign.padLabel"), padEmpty: t("sign.padEmpty") }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{(SIGNATURE_REASON_REQUIRED as readonly string[]).includes(outcome) && (
|
||||
<div>
|
||||
<Label htmlFor="sig-reason">{t("sign.reason")} *</Label>
|
||||
<Textarea id="sig-reason" name="reason" required rows={3} defaultValue={existing?.reason ?? ""} className="mt-1 min-h-24 text-base" aria-invalid={sigState.status === "error" && sigState.field === "reason"} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="rounded-lg bg-muted p-3 text-[12.5px] text-muted-foreground">{confirmationText}</p>
|
||||
<ActionMessage state={sigState} okText={t("sign.saved")} />
|
||||
<Button type="submit" disabled={capturing || (outcome === "signed" && !png)} className="h-12 w-full text-[15px]">
|
||||
<PenLine aria-hidden />
|
||||
{t("sign.save")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{editable && (
|
||||
<form action={submit} className="space-y-2">
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
{!existing && <p className="text-[13px] text-[var(--warn)]">{t("mobile.signatureMissing")}</p>}
|
||||
<ActionMessage state={submitState} okText={t("mobile.submitted")} />
|
||||
<Button type="submit" disabled={submitting || !existing} className="h-12 w-full text-[15px]">
|
||||
<Send aria-hidden />
|
||||
{t("mobile.submit")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft } from "lucide-react";
|
||||
import { REPORT_EDITABLE, type ReportStatus } from "@/lib/reports/content";
|
||||
import { can, ServiceError } from "@/server/services/context";
|
||||
import { getMobileReportState } from "@/server/services/reports/queries";
|
||||
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||
import { ReportStatusBadge } from "../status-badge";
|
||||
import { StepIndicator } from "../step-indicator";
|
||||
import { SignFlow } from "./sign-flow";
|
||||
|
||||
/** /m/orders/[id]/sign — signature or documented reason, then submit the completion report. */
|
||||
export async function SignScreen({ workOrderId }: { workOrderId: string }) {
|
||||
const t = await getTranslations("reports");
|
||||
const format = await getFormatter();
|
||||
const ctx = await readCtx();
|
||||
let state: Awaited<ReturnType<typeof getMobileReportState>>;
|
||||
try {
|
||||
state = await getMobileReportState(ctx, workOrderId, "completion");
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError && err.code === "not_found") notFound();
|
||||
throw err;
|
||||
}
|
||||
const { workOrder, report, content, timeZone } = state;
|
||||
const base = `/m/orders/${workOrderId}`;
|
||||
const steps = [t("mobile.stepReview"), t("mobile.stepEdit"), t("mobile.stepSign"), t("mobile.stepSubmit")];
|
||||
|
||||
return (
|
||||
<main className="mx-auto w-full max-w-2xl flex-1 space-y-4 p-4">
|
||||
<Link href={`${base}/report?type=completion`} 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("mobile.review")}
|
||||
</Link>
|
||||
<header>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{workOrder.number} · {workOrder.title}
|
||||
</p>
|
||||
<h1 className="text-[22px]">{t("sign.title")}</h1>
|
||||
</header>
|
||||
|
||||
{!report || !content ? (
|
||||
<p className="shadow-card rounded-xl border bg-card p-4 text-[14px]">
|
||||
{t("sign.noReport")}{" "}
|
||||
<Link href={`${base}/report?type=completion`} className="font-semibold underline">
|
||||
{t("mobile.createCompletion")}
|
||||
</Link>
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<StepIndicator steps={steps} current={3} label={t("mobile.stepOf", { current: 3, total: steps.length })} />
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ReportStatusBadge status={report.status as ReportStatus} label={t(`status.${report.status}`)} />
|
||||
<span className="text-[13px] text-muted-foreground">
|
||||
{content.reportNumber} · {t("field.version")} {report.version}
|
||||
</span>
|
||||
</div>
|
||||
<SignFlow
|
||||
reportId={report.id}
|
||||
reportNumber={content.reportNumber}
|
||||
orderNumber={workOrder.number}
|
||||
dateLabel={format.dateTime(new Date(), { dateStyle: "medium", timeZone })}
|
||||
canNotRequired={!workOrder.signatureRequired || can(ctx, "report:approve")}
|
||||
existing={content.signature ? { outcome: content.signature.outcome, signerName: content.signature.signerName, reason: content.signature.reason } : null}
|
||||
editable={REPORT_EDITABLE.includes(report.status as ReportStatus)}
|
||||
doneHref={`${base}/report?type=completion`}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { IDLE } from "@/lib/reports/action-state";
|
||||
import { rejectReportAction } from "@/server/actions/reports/workflow";
|
||||
import { ActionMessage } from "./action-message";
|
||||
|
||||
export function RejectForm({ reportId, closeHref }: { reportId: string; closeHref: string }) {
|
||||
const t = useTranslations("reports");
|
||||
const router = useRouter();
|
||||
const [state, action, pending] = useActionState(rejectReportAction, IDLE);
|
||||
useEffect(() => {
|
||||
if (state.status === "ok") {
|
||||
router.push(closeHref);
|
||||
router.refresh();
|
||||
}
|
||||
}, [state, router, closeHref]);
|
||||
|
||||
return (
|
||||
<form action={action} className="space-y-3 p-5">
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<div>
|
||||
<Label htmlFor="reject-reason">{t("actions.rejectReason")} *</Label>
|
||||
<Textarea
|
||||
id="reject-reason"
|
||||
name="reason"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={2000}
|
||||
rows={4}
|
||||
className="mt-1 min-h-28"
|
||||
placeholder={t("actions.rejectPlaceholder")}
|
||||
aria-invalid={state.status === "error" && state.field === "reason"}
|
||||
/>
|
||||
</div>
|
||||
<ActionMessage state={state} />
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" className="h-11 px-4" nativeButton={false} render={<Link href={closeHref} scroll={false} />}>
|
||||
{t("actions.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={pending} className="h-11 px-4">
|
||||
{t("actions.reject")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { CheckCircle2, Circle } from "lucide-react";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { REPORT_TEXT_FIELDS, splitMinutes, type MaterialLine, type ReportContent } from "@/lib/reports/content";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Structured, read-only rendering of a report snapshot (backoffice detail + mobile review).
|
||||
* Images are served through /api/v1/reports/:id/files/:documentId (authorization per report).
|
||||
*/
|
||||
export async function ReportView({ content: c, reportId, timeZone, compact = false }: { content: ReportContent; reportId: string; timeZone: string; compact?: boolean }) {
|
||||
const t = await getTranslations("reports");
|
||||
const format = await getFormatter();
|
||||
const day = (key: string) => format.dateTime(new Date(`${key}T00:00:00Z`), { dateStyle: "medium", timeZone: "UTC" });
|
||||
const dateTime = (iso: string) => format.dateTime(new Date(iso), { dateStyle: "medium", timeStyle: "short", timeZone });
|
||||
const duration = (m: number) => {
|
||||
const s = splitMinutes(m);
|
||||
return t("time.hoursMinutes", { hours: s.hours, minutes: String(s.minutes).padStart(2, "0") });
|
||||
};
|
||||
const addr = (a: { line1: string | null; line2: string | null }) => [a.line1, a.line2].filter(Boolean).join(", ");
|
||||
const fileUrl = (documentId: string) => `/api/v1/reports/${reportId}/files/${documentId}`;
|
||||
const card = "shadow-card rounded-xl border bg-card p-4 md:p-5";
|
||||
const h2 = "font-heading text-[15px] font-semibold";
|
||||
|
||||
const kv: Array<[string, string | null | undefined]> = [
|
||||
[t("field.customer"), [c.customer.name, addr(c.customer.address)].filter(Boolean).join(", ")],
|
||||
[t("field.customerNumber"), c.customer.number],
|
||||
[t("field.site"), c.site ? [c.site.name, addr(c.site.address)].filter(Boolean).join(", ") : null],
|
||||
[t("field.contact"), c.contact ? [c.contact.name, c.contact.role, c.contact.phone, c.contact.email].filter(Boolean).join(" · ") : null],
|
||||
[t("field.orderNumber"), [c.workOrder.number, c.workOrder.externalOrderNumber].filter(Boolean).join(" / ")],
|
||||
[t("field.orderType"), c.workOrder.orderType],
|
||||
[t("field.workOrder"), c.workOrder.title],
|
||||
[t("field.workDates"), c.workDates.map(day).join(", ")],
|
||||
[t("field.staff"), c.staff.map((s) => s.name).join(", ")],
|
||||
[t("field.technician"), c.technician?.name],
|
||||
];
|
||||
|
||||
const materialGroups: Array<[string, MaterialLine[]]> = [
|
||||
[t("materials.used"), c.materials.used],
|
||||
[t("materials.notUsed"), c.materials.notUsed],
|
||||
[t("materials.additional"), c.materials.additional],
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<section className={card} aria-labelledby={`rv-head-${reportId}`}>
|
||||
<h2 id={`rv-head-${reportId}`} className={h2}>
|
||||
{t("section.header")}
|
||||
</h2>
|
||||
<dl className={cn("mt-3 grid gap-x-6 gap-y-2.5", compact ? "grid-cols-1" : "sm:grid-cols-2")}>
|
||||
{kv
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<dt className="text-[11.5px] font-semibold text-muted-foreground">{k}</dt>
|
||||
<dd className="text-[13.5px]">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{c.workOrder.description ? (
|
||||
<div className="mt-3">
|
||||
<p className="text-[11.5px] font-semibold text-muted-foreground">{t("field.description")}</p>
|
||||
<p className="text-[13.5px] whitespace-pre-wrap">{c.workOrder.description}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.time")}</h2>
|
||||
{c.time.entries.length === 0 ? (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">{t("time.empty")}</p>
|
||||
) : (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[11.5px] text-muted-foreground">
|
||||
<th className="py-1.5 pr-3 font-semibold">{t("time.person")}</th>
|
||||
<th className="py-1.5 pr-3 font-semibold">{t("time.type")}</th>
|
||||
<th className="py-1.5 text-right font-semibold">{t("time.duration")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{c.time.entries.map((e) => (
|
||||
<tr key={`${e.userId}-${e.type}`} className="border-b last:border-0">
|
||||
<td className="py-1.5 pr-3">{e.name}</td>
|
||||
<td className="py-1.5 pr-3">{t(`timeType.${e.type}`)}</td>
|
||||
<td className="py-1.5 text-right whitespace-nowrap">{duration(e.minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colSpan={2} className="pt-2 font-semibold">
|
||||
{t("time.total")}
|
||||
</td>
|
||||
<td className="pt-2 text-right font-semibold whitespace-nowrap">{duration(c.time.totalMinutes)}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{c.time.hasRunningEntries ? <p className="mt-2 text-[12px] text-[var(--warn)]">{t("time.running", { time: dateTime(c.generatedAt) })}</p> : null}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.texts")}</h2>
|
||||
<dl className="mt-3 space-y-3">
|
||||
{REPORT_TEXT_FIELDS.map((f) => (
|
||||
<div key={f}>
|
||||
<dt className="text-[11.5px] font-semibold text-muted-foreground">{t(`texts.${f}`)}</dt>
|
||||
<dd className={cn("text-[13.5px] whitespace-pre-wrap", !c.texts[f].trim() && "text-muted-foreground")}>{c.texts[f].trim() || "—"}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.materials")}</h2>
|
||||
{materialGroups.every(([, l]) => l.length === 0) ? <p className="mt-2 text-[13px] text-muted-foreground">{t("materials.empty")}</p> : null}
|
||||
{materialGroups
|
||||
.filter(([, lines]) => lines.length)
|
||||
.map(([label, lines]) => (
|
||||
<div key={label} className="mt-3">
|
||||
<h3 className="text-[12.5px] font-semibold">{label}</h3>
|
||||
<ul className="mt-1.5 divide-y text-[13px]">
|
||||
{lines.map((m, i) => (
|
||||
<li key={`${m.usageId ?? m.planId ?? i}`} className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-0.5 py-1.5">
|
||||
<span>
|
||||
{m.name}
|
||||
{m.articleNumber ? <span className="text-muted-foreground"> · {m.articleNumber}</span> : null}
|
||||
</span>
|
||||
<span className="text-muted-foreground">
|
||||
{m.plannedQuantity ? `${t("materials.planned")} ${m.plannedQuantity} ${m.unit}` : ""}
|
||||
{m.plannedQuantity && m.actualQuantity ? " · " : ""}
|
||||
{m.actualQuantity ? `${t("materials.actual")} ${m.actualQuantity} ${m.unit}` : ""}
|
||||
{!m.documented ? t("materials.undocumented") : ""}
|
||||
</span>
|
||||
{m.deviation ? (
|
||||
<span className="w-full text-[12px] font-semibold text-[var(--warn)]">
|
||||
{t("materials.deviation")}
|
||||
{m.status ? ` · ${t(`materialStatus.${m.status}`)}` : ""}
|
||||
{m.deviationReason ? ` · ${m.deviationReason}` : ""}
|
||||
</span>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
{c.checklist.length ? (
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.checklist")}</h2>
|
||||
<ul className="mt-2 space-y-1.5 text-[13px]">
|
||||
{c.checklist.map((i, idx) => (
|
||||
<li key={idx} className="flex items-start gap-2">
|
||||
{i.checked ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-[var(--ok)]" aria-hidden /> : <Circle className="mt-0.5 size-4 shrink-0 text-muted-foreground" aria-hidden />}
|
||||
<span>
|
||||
{i.label}
|
||||
<span className="text-muted-foreground"> · {i.checked ? t("checklist.done") : t("checklist.open")}</span>
|
||||
{i.required ? <span className="text-muted-foreground"> · {t("checklist.required")}</span> : null}
|
||||
{i.comment ? <span className="block text-muted-foreground">{i.comment}</span> : null}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.photos")}</h2>
|
||||
{c.photos.length === 0 ? (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">{t("photos.empty")}</p>
|
||||
) : (
|
||||
<ul className={cn("mt-3 grid gap-3", compact ? "grid-cols-2" : "grid-cols-2 lg:grid-cols-3")}>
|
||||
{c.photos.map((p, idx) => (
|
||||
<li key={p.photoId} className="overflow-hidden rounded-lg border">
|
||||
<a href={fileUrl(p.documentId)} target="_blank" rel="noopener noreferrer">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- authorized API stream, not optimizable */}
|
||||
<img src={fileUrl(p.documentId)} alt={p.comment || t("photos.alt", { index: idx + 1 })} className="aspect-[4/3] w-full bg-muted object-cover" loading="lazy" />
|
||||
</a>
|
||||
<div className="p-2 text-[11.5px]">
|
||||
{p.phase ? <span className="font-semibold">{t(`phase.${p.phase}`)}</span> : null}
|
||||
{p.requirement ? <span className="block text-muted-foreground">{t("photos.requirement", { label: p.requirement })}</span> : null}
|
||||
{p.comment ? <span className="block">{p.comment}</span> : null}
|
||||
<span className="block text-muted-foreground">{dateTime(p.takenAt)}</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className={card}>
|
||||
<h2 className={h2}>{t("section.signature")}</h2>
|
||||
{!c.signature ? (
|
||||
<p className="mt-2 text-[13px] text-muted-foreground">{t("signature.none")}</p>
|
||||
) : (
|
||||
<div className="mt-2 text-[13px]">
|
||||
<p className="font-semibold">{t(`outcome.${c.signature.outcome}`)}</p>
|
||||
{c.signature.imageDocumentId ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element -- authorized API stream
|
||||
<img src={fileUrl(c.signature.imageDocumentId)} alt={t("signature.image", { name: c.signature.signerName ?? "" })} className="my-2 max-h-32 rounded-md border bg-white" />
|
||||
) : null}
|
||||
<dl className="grid gap-x-6 gap-y-1.5 sm:grid-cols-2">
|
||||
{(
|
||||
[
|
||||
[t("signature.signer"), c.signature.signerName],
|
||||
[t("signature.role"), c.signature.signerRole],
|
||||
[t("signature.signedAt"), dateTime(c.signature.signedAt)],
|
||||
[t("signature.capturedBy"), c.signature.capturedByName],
|
||||
[t("signature.reason"), c.signature.reason],
|
||||
] as Array<[string, string | null]>
|
||||
)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<dt className="text-[11.5px] font-semibold text-muted-foreground">{k}</dt>
|
||||
<dd>{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
{c.signature.confirmationText ? <p className="mt-2 text-[12px] text-muted-foreground">{c.signature.confirmationText}</p> : null}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { CheckCircle2, CopyPlus, FileDown, UserCheck, XCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { IDLE } from "@/lib/reports/action-state";
|
||||
import { approveReportAction, newVersionAction, regeneratePdfAction } from "@/server/actions/reports/workflow";
|
||||
import { ActionMessage } from "./action-message";
|
||||
|
||||
/** Backoffice/team lead review actions on /reports/[id]. Visibility is comfort; the services enforce rights. */
|
||||
export function ReviewActions({
|
||||
reportId,
|
||||
can,
|
||||
rejectHref,
|
||||
}: {
|
||||
reportId: string;
|
||||
can: { approve: boolean; approveTeam: boolean; reject: boolean; newVersion: boolean; regeneratePdf: boolean };
|
||||
rejectHref: string;
|
||||
}) {
|
||||
const t = useTranslations("reports");
|
||||
const router = useRouter();
|
||||
const [approveState, approve, approving] = useActionState(approveReportAction, IDLE);
|
||||
const [versionState, newVersion, creating] = useActionState(newVersionAction, IDLE);
|
||||
const [pdfState, regenerate, regenerating] = useActionState(regeneratePdfAction, IDLE);
|
||||
|
||||
useEffect(() => {
|
||||
if (versionState.status === "ok" && versionState.reportId) router.push(`/reports/${versionState.reportId}`);
|
||||
}, [versionState, router]);
|
||||
useEffect(() => {
|
||||
if (approveState.status === "ok" || pdfState.status === "ok") router.refresh();
|
||||
}, [approveState, pdfState, router]);
|
||||
|
||||
if (!Object.values(can).some(Boolean)) return null;
|
||||
const big = "h-11 px-4";
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(can.approve || can.approveTeam) && (
|
||||
<form action={approve}>
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<Button type="submit" disabled={approving} className={big}>
|
||||
{can.approve ? <CheckCircle2 aria-hidden /> : <UserCheck aria-hidden />}
|
||||
{can.approve ? t("actions.approve") : t("actions.approveTeam")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{can.reject && (
|
||||
<Button variant="outline" className={big} nativeButton={false} render={<Link href={rejectHref} scroll={false} />}>
|
||||
<XCircle aria-hidden />
|
||||
{t("actions.reject")}
|
||||
</Button>
|
||||
)}
|
||||
{can.newVersion && (
|
||||
<form action={newVersion}>
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<Button type="submit" variant="outline" disabled={creating} className={big} title={t("actions.newVersionHint")}>
|
||||
<CopyPlus aria-hidden />
|
||||
{t("actions.newVersion")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{can.regeneratePdf && (
|
||||
<form action={regenerate}>
|
||||
<input type="hidden" name="reportId" value={reportId} />
|
||||
<Button type="submit" variant="outline" disabled={regenerating} className={big}>
|
||||
<FileDown aria-hidden />
|
||||
{t("actions.regeneratePdf")}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
<ActionMessage state={approveState} />
|
||||
<ActionMessage state={versionState} />
|
||||
<ActionMessage state={pdfState} okText={t("detail.pdfPending")} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Eraser } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
/**
|
||||
* Signature canvas (Spec §18.1): Pointer Events (mouse, touch, stylus), smoothed strokes, clear, PNG export.
|
||||
* Emits a PNG data URL (max. 1200 px wide) after every stroke, or null when cleared.
|
||||
* Stroke color is read from the CSS token --brand-graphit (no hard-coded colors).
|
||||
*/
|
||||
export function SignaturePad({
|
||||
onChange,
|
||||
labels,
|
||||
disabled = false,
|
||||
}: {
|
||||
onChange: (pngDataUrl: string | null) => void;
|
||||
labels: { clear: string; padLabel: string; padEmpty: string };
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const drawing = useRef(false);
|
||||
const last = useRef<{ x: number; y: number } | null>(null);
|
||||
const [empty, setEmpty] = useState(true);
|
||||
|
||||
const setup = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = Math.round(rect.width * dpr);
|
||||
canvas.height = Math.round(rect.height * dpr);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
ctx.scale(dpr, dpr);
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
ctx.lineWidth = 2.4;
|
||||
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue("--brand-graphit").trim() || "currentColor";
|
||||
setEmpty(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setup();
|
||||
const onResize = () => {
|
||||
setup();
|
||||
onChange(null);
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
return () => window.removeEventListener("resize", onResize);
|
||||
}, [setup, onChange]);
|
||||
|
||||
const point = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||
};
|
||||
|
||||
const exportPng = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const maxW = 1200;
|
||||
if (canvas.width <= maxW) return onChange(canvas.toDataURL("image/png"));
|
||||
const scaled = document.createElement("canvas");
|
||||
scaled.width = maxW;
|
||||
scaled.height = Math.round((canvas.height / canvas.width) * maxW);
|
||||
scaled.getContext("2d")?.drawImage(canvas, 0, 0, scaled.width, scaled.height);
|
||||
onChange(scaled.toDataURL("image/png"));
|
||||
};
|
||||
|
||||
const down = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (disabled) return;
|
||||
e.preventDefault();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
drawing.current = true;
|
||||
last.current = point(e);
|
||||
const ctx = e.currentTarget.getContext("2d");
|
||||
if (ctx && last.current) {
|
||||
ctx.beginPath();
|
||||
ctx.arc(last.current.x, last.current.y, ctx.lineWidth / 2, 0, Math.PI * 2);
|
||||
ctx.fillStyle = ctx.strokeStyle;
|
||||
ctx.fill();
|
||||
}
|
||||
setEmpty(false);
|
||||
};
|
||||
|
||||
const move = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawing.current || !last.current) return;
|
||||
const ctx = e.currentTarget.getContext("2d");
|
||||
if (!ctx) return;
|
||||
const events = typeof e.nativeEvent.getCoalescedEvents === "function" ? e.nativeEvent.getCoalescedEvents() : [e.nativeEvent];
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
for (const ev of events) {
|
||||
const p = { x: ev.clientX - rect.left, y: ev.clientY - rect.top };
|
||||
const mid = { x: (last.current.x + p.x) / 2, y: (last.current.y + p.y) / 2 };
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(last.current.x, last.current.y);
|
||||
ctx.quadraticCurveTo(last.current.x, last.current.y, mid.x, mid.y);
|
||||
ctx.lineTo(p.x, p.y);
|
||||
ctx.stroke();
|
||||
last.current = p;
|
||||
}
|
||||
};
|
||||
|
||||
const up = (e: React.PointerEvent<HTMLCanvasElement>) => {
|
||||
if (!drawing.current) return;
|
||||
drawing.current = false;
|
||||
last.current = null;
|
||||
if (e.currentTarget.hasPointerCapture(e.pointerId)) e.currentTarget.releasePointerCapture(e.pointerId);
|
||||
exportPng();
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
setup();
|
||||
onChange(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="relative rounded-xl border-2 border-dashed border-input bg-card">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
role="img"
|
||||
aria-label={labels.padLabel}
|
||||
className="block h-52 w-full touch-none select-none sm:h-60"
|
||||
onPointerDown={down}
|
||||
onPointerMove={move}
|
||||
onPointerUp={up}
|
||||
onPointerCancel={up}
|
||||
/>
|
||||
{empty && (
|
||||
<span className="pointer-events-none absolute inset-x-0 bottom-6 mx-6 border-t border-input pt-1 text-center text-[13px] text-muted-foreground">
|
||||
{labels.padEmpty}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-2 flex justify-end">
|
||||
<Button type="button" variant="outline" onClick={clear} disabled={disabled || empty} className="h-12 px-4">
|
||||
<Eraser aria-hidden />
|
||||
{labels.clear}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { CheckCircle2, Clock, FilePen, History, UserCheck, XCircle, type LucideIcon } from "lucide-react";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { REPORT_STATUS_TONE, type ReportStatus } from "@/lib/reports/content";
|
||||
|
||||
const ICONS: Record<ReportStatus, LucideIcon> = {
|
||||
draft: FilePen,
|
||||
submitted: Clock,
|
||||
team_approved: UserCheck,
|
||||
approved: CheckCircle2,
|
||||
rejected: XCircle,
|
||||
superseded: History,
|
||||
};
|
||||
|
||||
/** Report status pill: icon + text, never color alone (Brandbook §11.4). */
|
||||
export function ReportStatusBadge({ status, label }: { status: ReportStatus; label: string }) {
|
||||
const Icon = ICONS[status];
|
||||
return (
|
||||
<Pill tone={REPORT_STATUS_TONE[status]}>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
{label}
|
||||
</Pill>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** Progress for the multi-step mobile completion (Brandbook §12.2). `current` is 1-based. */
|
||||
export function StepIndicator({ steps, current, label }: { steps: string[]; current: number; label: string }) {
|
||||
return (
|
||||
<nav aria-label={label}>
|
||||
<ol className="flex items-center gap-1.5">
|
||||
{steps.map((s, i) => {
|
||||
const n = i + 1;
|
||||
const done = n < current;
|
||||
const active = n === current;
|
||||
return (
|
||||
<li key={s} className="flex min-w-0 flex-1 flex-col items-center gap-1" aria-current={active ? "step" : undefined}>
|
||||
<span
|
||||
className={cn(
|
||||
"grid size-7 place-items-center rounded-full border text-[12px] font-bold",
|
||||
done && "border-[var(--ok)] text-[var(--ok)]",
|
||||
active && "border-[var(--ui-accent)] bg-[var(--ui-accent)] text-[var(--ui-accent-foreground)]",
|
||||
!done && !active && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{done ? <Check className="size-3.5" aria-hidden /> : n}
|
||||
</span>
|
||||
<span className={cn("truncate text-[11px]", active ? "font-semibold" : "text-muted-foreground")}>{s}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
<p className="sr-only">{label}</p>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||
|
||||
/** Result of report server actions (client-safe; "use server" files may only export async functions). */
|
||||
export type ReportActionErrorCode = "generic" | "not_found" | "forbidden" | "conflict" | "invalid" | "blocked";
|
||||
|
||||
export type ReportActionState =
|
||||
| { status: "idle" }
|
||||
| { status: "ok"; reportId?: string; at: number }
|
||||
| { status: "error"; code: ReportActionErrorCode; field?: string; blockers?: CompletionBlocker[]; at: number };
|
||||
|
||||
export const IDLE: ReportActionState = { status: "idle" };
|
||||
@@ -0,0 +1,194 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Report content snapshot (ARCHITEKTUR §4.7, Spec §16.2/§17.2). Client-safe.
|
||||
*
|
||||
* Built from the database by src/server/services/reports/build-content.ts when a report is
|
||||
* created, refreshed on submit/approve and frozen once the report is approved. The editable
|
||||
* free-text block (`texts`) is owned by the technician and survives every refresh.
|
||||
*/
|
||||
|
||||
export const REPORT_TYPES = ["daily", "completion"] as const;
|
||||
export type ReportType = (typeof REPORT_TYPES)[number];
|
||||
|
||||
export const REPORT_STATUSES = ["draft", "submitted", "team_approved", "approved", "rejected", "superseded"] as const;
|
||||
export type ReportStatus = (typeof REPORT_STATUSES)[number];
|
||||
|
||||
/** Statuses in which the technician may still edit texts / capture a signature. */
|
||||
export const REPORT_EDITABLE: readonly ReportStatus[] = ["draft", "rejected"];
|
||||
/** Statuses waiting for a reviewer ("Zur Prüfung"). */
|
||||
export const REPORT_IN_REVIEW: readonly ReportStatus[] = ["submitted", "team_approved"];
|
||||
|
||||
/** Badge tone per report status — always rendered together with the status text. */
|
||||
export const REPORT_STATUS_TONE: Record<ReportStatus, "mut" | "info" | "warn" | "ok" | "risk"> = {
|
||||
draft: "mut",
|
||||
submitted: "info",
|
||||
team_approved: "info",
|
||||
approved: "ok",
|
||||
rejected: "risk",
|
||||
superseded: "mut",
|
||||
};
|
||||
|
||||
export const SIGNATURE_OUTCOMES = ["signed", "customer_absent", "refused", "later", "not_required"] as const;
|
||||
export type SignatureOutcome = (typeof SIGNATURE_OUTCOMES)[number];
|
||||
/** Outcomes that require a written reason (Spec §18.2). */
|
||||
export const SIGNATURE_REASON_REQUIRED: readonly SignatureOutcome[] = ["customer_absent", "refused", "later"];
|
||||
|
||||
export const TIME_ENTRY_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const;
|
||||
export const PHOTO_PHASES = ["before", "during", "after"] as const;
|
||||
export const MATERIAL_USAGE_STATUSES = ["fully_used", "partially_used", "not_used", "additional"] as const;
|
||||
|
||||
/** Editable free-text fields (technician / Lotse draft). */
|
||||
export const REPORT_TEXT_FIELDS = ["workPerformed", "deviations", "additionalWork", "problems", "openItems", "nextSteps", "hints"] as const;
|
||||
export type ReportTextField = (typeof REPORT_TEXT_FIELDS)[number];
|
||||
/** Text fields that must not be empty before submit ("Pflichtangaben"). */
|
||||
export const REPORT_REQUIRED_TEXTS: Record<ReportType, readonly ReportTextField[]> = {
|
||||
daily: ["workPerformed"],
|
||||
completion: ["workPerformed"],
|
||||
};
|
||||
|
||||
export const TEXT_MAX = 10_000;
|
||||
|
||||
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||
const isoDateTime = z.string().datetime({ offset: true });
|
||||
const nullableText = z.string().max(2_000).nullable();
|
||||
|
||||
export const addressSchema = z.object({
|
||||
line1: nullableText,
|
||||
line2: nullableText,
|
||||
});
|
||||
|
||||
export const reportTextsSchema = z.object({
|
||||
workPerformed: z.string().max(TEXT_MAX),
|
||||
deviations: z.string().max(TEXT_MAX),
|
||||
additionalWork: z.string().max(TEXT_MAX),
|
||||
problems: z.string().max(TEXT_MAX),
|
||||
openItems: z.string().max(TEXT_MAX),
|
||||
nextSteps: z.string().max(TEXT_MAX),
|
||||
hints: z.string().max(TEXT_MAX),
|
||||
});
|
||||
export type ReportTexts = z.infer<typeof reportTextsSchema>;
|
||||
|
||||
export const materialLineSchema = z.object({
|
||||
usageId: z.string().nullable(),
|
||||
planId: z.string().nullable(),
|
||||
name: z.string(),
|
||||
articleNumber: nullableText,
|
||||
plannedQuantity: z.string().nullable(),
|
||||
actualQuantity: z.string().nullable(),
|
||||
unit: z.string(),
|
||||
status: z.enum(MATERIAL_USAGE_STATUSES).nullable(),
|
||||
/** planned vs. actual differs (quantity or status) */
|
||||
deviation: z.boolean(),
|
||||
deviationReason: nullableText,
|
||||
notes: nullableText,
|
||||
/** false = planned material without any documented usage */
|
||||
documented: z.boolean(),
|
||||
});
|
||||
export type MaterialLine = z.infer<typeof materialLineSchema>;
|
||||
|
||||
export const photoLineSchema = z.object({
|
||||
photoId: z.string(),
|
||||
documentId: z.string(),
|
||||
phase: z.enum(PHOTO_PHASES).nullable(),
|
||||
comment: nullableText,
|
||||
requirement: nullableText,
|
||||
takenAt: isoDateTime,
|
||||
});
|
||||
export type PhotoLine = z.infer<typeof photoLineSchema>;
|
||||
|
||||
export const timeLineSchema = z.object({
|
||||
userId: z.string(),
|
||||
name: z.string(),
|
||||
type: z.enum(TIME_ENTRY_TYPES),
|
||||
minutes: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
export const signatureBlockSchema = z.object({
|
||||
outcome: z.enum(SIGNATURE_OUTCOMES),
|
||||
signerName: nullableText,
|
||||
signerRole: nullableText,
|
||||
signedAt: isoDateTime,
|
||||
reason: nullableText,
|
||||
confirmationText: nullableText,
|
||||
imageDocumentId: z.string().nullable(),
|
||||
capturedByName: nullableText,
|
||||
});
|
||||
export type SignatureBlock = z.infer<typeof signatureBlockSchema>;
|
||||
|
||||
export const reportContentSchema = z.object({
|
||||
schemaVersion: z.literal(1),
|
||||
type: z.enum(REPORT_TYPES),
|
||||
reportNumber: z.string(),
|
||||
version: z.number().int().positive(),
|
||||
reportDate: isoDate,
|
||||
generatedAt: isoDateTime,
|
||||
tenant: z.object({
|
||||
name: z.string(),
|
||||
address: nullableText,
|
||||
phone: nullableText,
|
||||
email: nullableText,
|
||||
logoDocumentId: z.string().nullable(),
|
||||
}),
|
||||
customer: z.object({
|
||||
id: z.string(),
|
||||
number: nullableText,
|
||||
name: z.string(),
|
||||
address: addressSchema,
|
||||
}),
|
||||
site: z.object({ id: z.string(), name: z.string(), address: addressSchema }).nullable(),
|
||||
contact: z.object({ name: z.string(), role: nullableText, phone: nullableText, email: nullableText }).nullable(),
|
||||
workOrder: z.object({
|
||||
id: z.string(),
|
||||
number: z.string(),
|
||||
externalOrderNumber: nullableText,
|
||||
title: z.string(),
|
||||
description: z.string().nullable(),
|
||||
scope: z.string().nullable(),
|
||||
orderType: nullableText,
|
||||
signatureRequired: z.boolean(),
|
||||
}),
|
||||
/** Dates (YYYY-MM-DD, tenant time zone) with documented work; daily report = [reportDate]. */
|
||||
workDates: z.array(isoDate),
|
||||
staff: z.array(z.object({ userId: z.string(), name: z.string() })),
|
||||
time: z.object({
|
||||
entries: z.array(timeLineSchema),
|
||||
totalsByType: z.record(z.string(), z.number().int().nonnegative()),
|
||||
totalsByPerson: z.array(z.object({ userId: z.string(), name: z.string(), minutes: z.number().int().nonnegative() })),
|
||||
/** billable total = all types except break */
|
||||
totalMinutes: z.number().int().nonnegative(),
|
||||
/** true if an entry was still running while the snapshot was built */
|
||||
hasRunningEntries: z.boolean(),
|
||||
}),
|
||||
texts: reportTextsSchema,
|
||||
materials: z.object({
|
||||
used: z.array(materialLineSchema),
|
||||
notUsed: z.array(materialLineSchema),
|
||||
additional: z.array(materialLineSchema),
|
||||
}),
|
||||
photos: z.array(photoLineSchema),
|
||||
checklist: z.array(z.object({ label: z.string(), required: z.boolean(), checked: z.boolean(), comment: nullableText })),
|
||||
signature: signatureBlockSchema.nullable(),
|
||||
technician: z.object({ userId: z.string(), name: z.string() }).nullable(),
|
||||
});
|
||||
|
||||
export type ReportContent = z.infer<typeof reportContentSchema>;
|
||||
|
||||
export function emptyTexts(): ReportTexts {
|
||||
return { workPerformed: "", deviations: "", additionalWork: "", problems: "", openItems: "", nextSteps: "", hints: "" };
|
||||
}
|
||||
|
||||
/** Parse stored JSON; throws on schema drift so broken snapshots never render silently. */
|
||||
export function parseReportContent(json: unknown): ReportContent {
|
||||
return reportContentSchema.parse(json);
|
||||
}
|
||||
|
||||
/** Missing required text fields for submit. */
|
||||
export function missingRequiredTexts(content: Pick<ReportContent, "type" | "texts">): ReportTextField[] {
|
||||
return REPORT_REQUIRED_TEXTS[content.type].filter((f) => !content.texts[f].trim());
|
||||
}
|
||||
|
||||
/** "7 h 05 min" style duration without locale dependency (labels come from messages). */
|
||||
export function splitMinutes(minutes: number): { hours: number; minutes: number } {
|
||||
return { hours: Math.floor(minutes / 60), minutes: minutes % 60 };
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Calendar-day helpers in the tenant time zone (client-safe, no dependencies).
|
||||
* A daily report covers [start of reportDate, start of next day) in `timeZone`.
|
||||
*/
|
||||
|
||||
/** Offset (ms) of `timeZone` relative to UTC at the given instant. */
|
||||
function tzOffsetMs(instant: 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(instant);
|
||||
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value);
|
||||
const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second"));
|
||||
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD of an instant in `timeZone`. */
|
||||
export function localDateKey(instant: Date, timeZone: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(instant);
|
||||
}
|
||||
|
||||
/** UTC instants [start, end) of a local calendar day. */
|
||||
export function dayWindow(dateKey: string, timeZone: string): { start: Date; end: Date } {
|
||||
const [y, m, d] = dateKey.split("-").map(Number);
|
||||
const startGuess = Date.UTC(y, m - 1, d);
|
||||
const endGuess = Date.UTC(y, m - 1, d + 1);
|
||||
const start = new Date(startGuess - tzOffsetMs(new Date(startGuess), timeZone));
|
||||
const end = new Date(endGuess - tzOffsetMs(new Date(endGuess), timeZone));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
/** Date-only column value (Prisma @db.Date) for a YYYY-MM-DD key. */
|
||||
export function dateKeyToDbDate(dateKey: string): Date {
|
||||
return new Date(`${dateKey}T00:00:00.000Z`);
|
||||
}
|
||||
|
||||
/** YYYY-MM-DD from a Prisma @db.Date value. */
|
||||
export function dbDateToKey(d: Date): string {
|
||||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ZodError } from "zod";
|
||||
import type { ReportActionState } from "@/lib/reports/action-state";
|
||||
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { ForbiddenError } from "@/server/rbac";
|
||||
import { ModuleDisabledError } from "@/server/modules";
|
||||
|
||||
/** Map thrown errors of report actions to a displayable state (no internals leak to the client). */
|
||||
export function errorState(err: unknown): ReportActionState {
|
||||
const at = Date.now();
|
||||
if (err instanceof ServiceError) {
|
||||
const details = err.details as { field?: string } | CompletionBlocker[] | undefined;
|
||||
return {
|
||||
status: "error",
|
||||
code: err.code,
|
||||
field: details && !Array.isArray(details) ? details.field : undefined,
|
||||
blockers: Array.isArray(details) ? details : undefined,
|
||||
at,
|
||||
};
|
||||
}
|
||||
if (err instanceof ZodError) return { status: "error", code: "invalid", field: String(err.issues[0]?.path[0] ?? ""), at };
|
||||
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return { status: "error", code: "forbidden", at };
|
||||
console.error("[actions/reports]", err);
|
||||
return { status: "error", code: "generic", at };
|
||||
}
|
||||
|
||||
export const okState = (reportId?: string): ReportActionState => ({ status: "ok", reportId, at: Date.now() });
|
||||
|
||||
export const str = (fd: FormData, key: string): string | undefined => {
|
||||
const v = fd.get(key);
|
||||
return typeof v === "string" ? v : undefined;
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { ReportActionState } from "@/lib/reports/action-state";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ctxFromGuard, ServiceError } from "@/server/services/context";
|
||||
import { requireVisibleReport } from "@/server/services/reports/common";
|
||||
import { captureSignature } from "@/server/services/reports/signature";
|
||||
// TODO(merge L4): signature PNG could go through POST /api/v1/uploads once available
|
||||
import { storeFile } from "@/server/services/reports/_stubs/documents";
|
||||
import { errorState, okState, str } from "./_state";
|
||||
|
||||
const guard = moduleGuard("reports");
|
||||
const MAX_PNG_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Capture signature or documented exception. Form fields: reportId, outcome, signerName, signerRole, reason,
|
||||
* confirmationText, signaturePng (data:image/png;base64,… from the signature pad; only for outcome=signed).
|
||||
*/
|
||||
export async function captureSignatureAction(_prev: ReportActionState, fd: FormData): Promise<ReportActionState> {
|
||||
try {
|
||||
const ctx = ctxFromGuard(await guard("report:write"));
|
||||
const reportId = str(fd, "reportId") ?? "";
|
||||
const outcome = str(fd, "outcome") ?? "";
|
||||
const report = await requireVisibleReport(ctx, reportId);
|
||||
|
||||
let imageDocumentId: string | null = null;
|
||||
const png = str(fd, "signaturePng") ?? "";
|
||||
if (outcome === "signed") {
|
||||
const m = /^data:image\/png;base64,([A-Za-z0-9+/=]+)$/.exec(png);
|
||||
if (!m) throw new ServiceError("invalid", "signature image required", { field: "image" });
|
||||
const bytes = Buffer.from(m[1], "base64");
|
||||
if (bytes.byteLength > MAX_PNG_BYTES) throw new ServiceError("invalid", "signature image too large", { field: "image" });
|
||||
if (!str(fd, "signerName")?.trim()) throw new ServiceError("invalid", "signer name required", { field: "signerName" });
|
||||
const wo = await ctx.db.workOrder.findFirstOrThrow({ where: { id: report.workOrderId }, select: { id: true, customerId: true, siteId: true } });
|
||||
const doc = await storeFile(ctx, {
|
||||
bytes,
|
||||
fileName: `unterschrift-${report.id}.png`,
|
||||
declaredMime: "image/png",
|
||||
category: "signature",
|
||||
visibility: "customer_report",
|
||||
links: { customerId: wo.customerId, siteId: wo.siteId, workOrderId: wo.id },
|
||||
});
|
||||
imageDocumentId = doc.id;
|
||||
}
|
||||
|
||||
try {
|
||||
await captureSignature(ctx, {
|
||||
reportId,
|
||||
outcome: outcome as Parameters<typeof captureSignature>[1]["outcome"],
|
||||
signerName: str(fd, "signerName"),
|
||||
signerRole: str(fd, "signerRole"),
|
||||
reason: str(fd, "reason"),
|
||||
confirmationText: str(fd, "confirmationText") ?? "",
|
||||
imageDocumentId,
|
||||
});
|
||||
} catch (err) {
|
||||
if (imageDocumentId) await ctx.db.document.update({ where: { id: imageDocumentId }, data: { deletedAt: new Date() } });
|
||||
throw err;
|
||||
}
|
||||
revalidatePath(`/m/orders/${report.workOrderId}/sign`);
|
||||
revalidatePath(`/m/orders/${report.workOrderId}/report`);
|
||||
revalidatePath(`/reports/${report.id}`);
|
||||
return okState(report.id);
|
||||
} catch (err) {
|
||||
return errorState(err);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { REPORT_TEXT_FIELDS, type ReportTexts } from "@/lib/reports/content";
|
||||
import type { ReportActionState } from "@/lib/reports/action-state";
|
||||
import { moduleGuard } from "@/server/action-guard";
|
||||
import { ctxFromGuard } from "@/server/services/context";
|
||||
import { approveReport } from "@/server/services/reports/approve";
|
||||
import { createCompletionReport, createDailyReport } from "@/server/services/reports/create";
|
||||
import { updateReportTexts } from "@/server/services/reports/edit";
|
||||
import { createNewVersion } from "@/server/services/reports/new-version";
|
||||
import { rejectReport } from "@/server/services/reports/reject";
|
||||
import { submitReport } from "@/server/services/reports/submit";
|
||||
import { requireVisibleReport } from "@/server/services/reports/common";
|
||||
import { defaultApproveDeps } from "@/server/services/reports/approve";
|
||||
import { errorState, okState, str } from "./_state";
|
||||
|
||||
const guard = moduleGuard("reports");
|
||||
|
||||
function revalidateReport(reportId: string, workOrderId?: string) {
|
||||
revalidatePath("/reports");
|
||||
revalidatePath(`/reports/${reportId}`);
|
||||
if (workOrderId) {
|
||||
revalidatePath(`/m/orders/${workOrderId}/report`);
|
||||
revalidatePath(`/m/orders/${workOrderId}/sign`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mobile: create daily or completion report draft (form fields: workOrderId, type). */
|
||||
export async function createReportAction(_prev: ReportActionState, fd: FormData): Promise<ReportActionState> {
|
||||
try {
|
||||
const ctx = ctxFromGuard(await guard("report:write"));
|
||||
const workOrderId = str(fd, "workOrderId") ?? "";
|
||||
const input = { workOrderId, clientId: str(fd, "clientId") || undefined };
|
||||
const res = str(fd, "type") === "daily" ? await createDailyReport(ctx, input) : await createCompletionReport(ctx, input);
|
||||
revalidateReport(res.report.id, workOrderId);
|
||||
return okState(res.report.id);
|
||||
} catch (err) {
|
||||
return errorState(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mobile: save edited text fields of a draft. */
|
||||
export async function saveReportTextsAction(_prev: ReportActionState, fd: FormData): Promise<ReportActionState> {
|
||||
try {
|
||||
const ctx = ctxFromGuard(await guard("report:write"));
|
||||
const texts: Partial<ReportTexts> = {};
|
||||
for (const f of REPORT_TEXT_FIELDS) {
|
||||
const v = str(fd, f);
|
||||
if (v !== undefined) texts[f] = v;
|
||||
}
|
||||
const report = await updateReportTexts(ctx, { reportId: str(fd, "reportId") ?? "", texts });
|
||||
revalidateReport(report.id, report.workOrderId);
|
||||
return okState(report.id);
|
||||
} catch (err) {
|
||||
return errorState(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Mobile: submit report for review (texts in the same form are saved first). */
|
||||
export async function submitReportAction(_prev: ReportActionState, fd: FormData): Promise<ReportActionState> {
|
||||
try {
|
||||
const ctx = ctxFromGuard(await guard("report:write"));
|
||||
const reportId = str(fd, "reportId") ?? "";
|
||||
const texts: Partial<ReportTexts> = {};
|
||||
for (const f of REPORT_TEXT_FIELDS) {
|
||||
const v = str(fd, f);
|
||||
if (v !== undefined) texts[f] = v;
|
||||
}
|
||||
if (Object.keys(texts).length) await updateReportTexts(ctx, { reportId, texts });
|
||||
const report = await submitReport(ctx, { reportId });
|
||||
revalidateReport(report.id, report.workOrderId);
|
||||
return okState(report.id);
|
||||
} catch (err) {
|
||||
return errorState(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Backoffice/team lead: approve (level derived from permissions in the service). */
|
||||
export async function approveReportAction(_prev: ReportActionState, fd: FormData): Promise<ReportActionState> {
|
||||
try {
|
||||
const ctx = ctxFromGuard(await guard("report:read"));
|
||||
const report = await approveReport(ctx, { reportId: str(fd, "reportId") ?? "" });
|
||||
revalidateReport(report.id, report.workOrderId);
|
||||
return okState(report.id);
|
||||
} catch (err) {
|
||||
return errorState(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Backoffice/team lead: reject with mandatory reason. */
|
||||
export async function rejectReportAction(_prev: ReportActionState, fd: FormData): Promise<ReportActionState> {
|
||||
try {
|
||||
const ctx = ctxFromGuard(await guard("report:read"));
|
||||
const report = await rejectReport(ctx, { reportId: str(fd, "reportId") ?? "", reason: str(fd, "reason") ?? "" });
|
||||
revalidateReport(report.id, report.workOrderId);
|
||||
return okState(report.id);
|
||||
} catch (err) {
|
||||
return errorState(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Backoffice: new draft version of an approved report. */
|
||||
export async function newVersionAction(_prev: ReportActionState, fd: FormData): Promise<ReportActionState> {
|
||||
try {
|
||||
const ctx = ctxFromGuard(await guard("report:approve", "report:write"));
|
||||
const report = await createNewVersion(ctx, { reportId: str(fd, "reportId") ?? "" });
|
||||
revalidateReport(report.id, report.workOrderId);
|
||||
return okState(report.id);
|
||||
} catch (err) {
|
||||
return errorState(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Backoffice: re-queue PDF rendering for an approved report without PDF (e.g. failed job). */
|
||||
export async function regeneratePdfAction(_prev: ReportActionState, fd: FormData): Promise<ReportActionState> {
|
||||
try {
|
||||
const ctx = ctxFromGuard(await guard("report:approve"));
|
||||
const report = await requireVisibleReport(ctx, str(fd, "reportId") ?? "");
|
||||
if (report.status === "approved" && !report.pdfDocumentId) await defaultApproveDeps.dispatchPdf(ctx, report.id);
|
||||
revalidateReport(report.id);
|
||||
return okState(report.id);
|
||||
} catch (err) {
|
||||
return errorState(err);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ export type JobProcessor = (payload: JobPayload) => Promise<void>;
|
||||
export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor>>> = {
|
||||
"import-extraction": () => import("./import-extraction").then((m) => m.process),
|
||||
// lane-lotse: "transcription": () => import("./transcription").then((m) => m.process),
|
||||
// lane-reports: "report-pdf": () => import("./report-pdf").then((m) => m.process),
|
||||
"report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle
|
||||
// lane-field: "image-derivatives": () => import("./image-derivatives").then((m) => m.process),
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { generateReportPdf } from "@/server/services/reports/pdf";
|
||||
import type { JobPayload } from "../queues";
|
||||
|
||||
/**
|
||||
* Queue "report-pdf": renders the PDF of an approved report (lane L5).
|
||||
* System context: tenant-bound db, read access to all reports of the tenant — nothing else.
|
||||
*/
|
||||
export async function process(payload: JobPayload): Promise<void> {
|
||||
const ctx: ServiceCtx = {
|
||||
db: dbForTenant(payload.tenantId),
|
||||
tenantId: payload.tenantId,
|
||||
userId: payload.actorId ?? "system",
|
||||
permissions: new Set(["report:read", "work_order:read_all", "document:read_internal"]),
|
||||
};
|
||||
const res = await generateReportPdf(ctx, payload.entityId);
|
||||
console.info(`[report-pdf] ${payload.entityId}: ${res.skipped ? "already rendered" : `stored ${res.documentId}`}`);
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import { existsSync } from "node:fs";
|
||||
|
||||
/**
|
||||
* HTML → PDF via playwright-core + Chromium (ARCHITEKTUR §1: runs in the worker, never in the app container).
|
||||
*
|
||||
* Browser resolution (first match wins):
|
||||
* 1. PDF_CHROMIUM_PATH — explicit executable (Docker worker image: /usr/bin/chromium)
|
||||
* 2. Playwright-managed Chromium (`npx playwright-core install chromium`)
|
||||
* 3. Locally installed Google Chrome (developer machines, channel "chrome")
|
||||
* Throws PdfRendererUnavailableError if none can be launched.
|
||||
*/
|
||||
|
||||
export class PdfRendererUnavailableError extends Error {
|
||||
constructor(cause: string) {
|
||||
super(`PDF renderer unavailable: ${cause}`);
|
||||
this.name = "PdfRendererUnavailableError";
|
||||
}
|
||||
}
|
||||
|
||||
export type RenderPdfOptions = {
|
||||
headerHtml?: string;
|
||||
footerHtml?: string;
|
||||
/** mm margins */
|
||||
margin?: { top: string; bottom: string; left: string; right: string };
|
||||
};
|
||||
|
||||
type Browser = import("playwright-core").Browser;
|
||||
|
||||
async function launch(): Promise<Browser> {
|
||||
const { chromium } = await import("playwright-core");
|
||||
const errors: string[] = [];
|
||||
const explicit = process.env.PDF_CHROMIUM_PATH?.trim();
|
||||
const attempts: Array<() => Promise<Browser>> = [];
|
||||
if (explicit) attempts.push(() => chromium.launch({ executablePath: explicit, args: ["--no-sandbox", "--disable-dev-shm-usage"] }));
|
||||
attempts.push(async () => {
|
||||
const path = chromium.executablePath();
|
||||
if (!path || !existsSync(path)) throw new Error("playwright chromium not installed");
|
||||
return chromium.launch({ args: ["--disable-dev-shm-usage"] });
|
||||
});
|
||||
attempts.push(() => chromium.launch({ channel: "chrome" }));
|
||||
for (const attempt of attempts) {
|
||||
try {
|
||||
return await attempt();
|
||||
} catch (err) {
|
||||
errors.push((err as Error).message.split("\n")[0]);
|
||||
}
|
||||
}
|
||||
throw new PdfRendererUnavailableError(errors.join(" | "));
|
||||
}
|
||||
|
||||
/** Render a full HTML document to an A4 PDF (print backgrounds, header/footer with page numbers). */
|
||||
export async function renderHtmlToPdf(html: string, opts: RenderPdfOptions = {}): Promise<Buffer> {
|
||||
const browser = await launch();
|
||||
try {
|
||||
const context = await browser.newContext({ javaScriptEnabled: false });
|
||||
const page = await context.newPage();
|
||||
// No network: all assets (photos, logo, signature, fonts) are inlined as data: URIs.
|
||||
await page.route("**/*", (route) => (route.request().url().startsWith("data:") ? route.continue() : route.abort()));
|
||||
await page.setContent(html, { waitUntil: "load" });
|
||||
const pdf = await page.pdf({
|
||||
format: "A4",
|
||||
printBackground: true,
|
||||
displayHeaderFooter: Boolean(opts.headerHtml || opts.footerHtml),
|
||||
headerTemplate: opts.headerHtml ?? "<span></span>",
|
||||
footerTemplate: opts.footerHtml ?? "<span></span>",
|
||||
margin: opts.margin ?? { top: "22mm", bottom: "20mm", left: "16mm", right: "16mm" },
|
||||
preferCSSPageSize: false,
|
||||
});
|
||||
await context.close();
|
||||
return pdf;
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** true if a browser can be launched (tests skip the render smoke otherwise). */
|
||||
export async function pdfRendererAvailable(): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
try {
|
||||
const b = await launch();
|
||||
await b.close();
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
return { ok: false, reason: (err as Error).message };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
/* eslint-disable @next/next/no-head-element, @next/next/no-img-element -- standalone print document for Chromium, not a Next.js page */
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { REPORT_TEXT_FIELDS, splitMinutes, type MaterialLine, type ReportContent } from "@/lib/reports/content";
|
||||
import { DOCUMENT_THEME, documentFooterLine } from "@/lib/document-brand";
|
||||
|
||||
/**
|
||||
* Report PDF template (React SSR → static HTML, rendered by src/server/pdf/render.ts).
|
||||
* Craftvia document CD from src/lib/document-brand.ts; tenant logo if available, else company name.
|
||||
* All labels come from messages/<locale>/reports.json (passed in as `t`).
|
||||
*/
|
||||
|
||||
export type Translate = (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
export type ReportPdfInput = {
|
||||
content: ReportContent;
|
||||
reportId: string;
|
||||
status: string;
|
||||
approvedAt: Date | null;
|
||||
t: Translate;
|
||||
locale: string;
|
||||
timeZone: string;
|
||||
/** documentId → data: URI (photos, signature image, logo) */
|
||||
images: Record<string, string>;
|
||||
logoDataUri?: string | null;
|
||||
/** SHA-256 of the canonical content snapshot (the PDF's own checksum is stored on the report) */
|
||||
contentChecksum: string;
|
||||
fontDataUri?: string | null;
|
||||
};
|
||||
|
||||
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
|
||||
function css(fontDataUri?: string | null) {
|
||||
const th = DOCUMENT_THEME;
|
||||
return `
|
||||
${fontDataUri ? `@font-face{font-family:"CraftviaInter";src:url(${fontDataUri}) format("truetype");font-weight:100 900;}` : ""}
|
||||
@page{size:A4;}
|
||||
*{box-sizing:border-box;}
|
||||
html,body{margin:0;padding:0;}
|
||||
body{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.bodyFont};color:${th.text};font-size:9.5pt;line-height:1.45;background:${th.pageBackground};}
|
||||
h1{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.headingFont};color:${th.accent};font-size:17pt;margin:0 0 2mm;}
|
||||
h2{font-family:${fontDataUri ? `"CraftviaInter",` : ""}${th.headingFont};color:${th.accent};font-size:11pt;margin:6mm 0 2mm;padding-bottom:1mm;border-bottom:0.6pt solid ${th.rule};break-after:avoid;}
|
||||
h3{font-size:9.5pt;margin:3mm 0 1mm;color:${th.text};break-after:avoid;}
|
||||
.muted{color:${th.textMuted};}
|
||||
.head{display:flex;justify-content:space-between;align-items:flex-start;gap:8mm;border-bottom:2pt solid ${th.accentStrong};padding-bottom:3mm;margin-bottom:4mm;}
|
||||
.logo{max-height:16mm;max-width:60mm;}
|
||||
.org{font-weight:700;color:${th.accent};font-size:12pt;}
|
||||
.meta{text-align:right;font-size:8.5pt;}
|
||||
.grid{display:grid;grid-template-columns:1fr 1fr;gap:1.5mm 8mm;}
|
||||
.kv dt{font-size:7.5pt;text-transform:uppercase;letter-spacing:.04em;color:${th.textMuted};margin:0;}
|
||||
.kv dd{margin:0 0 1.5mm;}
|
||||
table{width:100%;border-collapse:collapse;margin:1mm 0 2mm;}
|
||||
th{background:${th.tableHeaderBackground};text-align:left;font-size:8pt;padding:1.2mm 1.5mm;border-bottom:0.6pt solid ${th.rule};}
|
||||
td{padding:1.2mm 1.5mm;border-bottom:0.4pt solid ${th.rule};vertical-align:top;}
|
||||
tr{break-inside:avoid;}
|
||||
td.num,th.num{text-align:right;white-space:nowrap;}
|
||||
.text{white-space:pre-wrap;break-inside:avoid-page;}
|
||||
.photos{display:grid;grid-template-columns:1fr 1fr;gap:4mm;}
|
||||
.photo{break-inside:avoid;border:0.4pt solid ${th.rule};padding:1.5mm;}
|
||||
.photo img{width:100%;height:62mm;object-fit:contain;background:${th.tableHeaderBackground};display:block;}
|
||||
.photo .cap{font-size:8pt;margin-top:1mm;}
|
||||
.sig{break-inside:avoid;border:0.6pt solid ${th.rule};padding:3mm;}
|
||||
.sig img{max-height:30mm;max-width:90mm;display:block;margin:2mm 0;}
|
||||
.badge{display:inline-block;border:0.6pt solid ${th.accent};color:${th.accent};border-radius:2mm;padding:.3mm 2mm;font-size:8pt;font-weight:700;}
|
||||
.dev{color:${th.accentStrong};font-weight:700;}
|
||||
`;
|
||||
}
|
||||
|
||||
function Kv({ label, value }: { label: string; value?: string | null }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<div>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDuration(t: Translate, minutes: number) {
|
||||
const s = splitMinutes(minutes);
|
||||
return t("time.hoursMinutes", { hours: s.hours, minutes: String(s.minutes).padStart(2, "0") });
|
||||
}
|
||||
|
||||
function MaterialTable({ t, lines }: { t: Translate; lines: MaterialLine[] }) {
|
||||
return (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("materials.name")}</th>
|
||||
<th className="num">{t("materials.planned")}</th>
|
||||
<th className="num">{t("materials.actual")}</th>
|
||||
<th>{t("field.status")}</th>
|
||||
<th>{t("materials.reason")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{lines.map((m, i) => (
|
||||
<tr key={`${m.usageId ?? m.planId ?? i}`}>
|
||||
<td>
|
||||
{m.name}
|
||||
{m.articleNumber ? <span className="muted"> · {m.articleNumber}</span> : null}
|
||||
</td>
|
||||
<td className="num">{m.plannedQuantity ? `${m.plannedQuantity} ${m.unit}` : "—"}</td>
|
||||
<td className="num">{m.actualQuantity ? `${m.actualQuantity} ${m.unit}` : "—"}</td>
|
||||
<td>
|
||||
{m.status ? t(`materialStatus.${m.status}`) : t("materials.undocumented")}
|
||||
{m.deviation ? <span className="dev"> · {t("materials.deviation")}</span> : null}
|
||||
</td>
|
||||
<td>{m.deviationReason ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
function ReportDocument(input: ReportPdfInput) {
|
||||
const { content: c, t } = input;
|
||||
const dateFmt = new Intl.DateTimeFormat(input.locale, { timeZone: input.timeZone, dateStyle: "medium" });
|
||||
const dateTimeFmt = new Intl.DateTimeFormat(input.locale, { timeZone: input.timeZone, dateStyle: "medium", timeStyle: "short" });
|
||||
const dayFmt = (key: string) => new Intl.DateTimeFormat(input.locale, { timeZone: "UTC", dateStyle: "medium" }).format(new Date(`${key}T00:00:00Z`));
|
||||
const addr = (a: { line1: string | null; line2: string | null }) => [a.line1, a.line2].filter(Boolean).join(", ");
|
||||
const texts = REPORT_TEXT_FIELDS.filter((f) => c.texts[f].trim());
|
||||
const hasMaterial = c.materials.used.length + c.materials.notUsed.length + c.materials.additional.length > 0;
|
||||
|
||||
return (
|
||||
<html lang={input.locale}>
|
||||
<head>
|
||||
<meta charSet="utf-8" />
|
||||
<title>{`${t(`type.${c.type}`)} ${c.reportNumber}`}</title>
|
||||
<style dangerouslySetInnerHTML={{ __html: css(input.fontDataUri) }} />
|
||||
</head>
|
||||
<body>
|
||||
<div className="head">
|
||||
<div>
|
||||
{input.logoDataUri ? <img className="logo" src={input.logoDataUri} alt={c.tenant.name} /> : <div className="org">{c.tenant.name}</div>}
|
||||
<div className="muted">{[c.tenant.address, c.tenant.phone, c.tenant.email].filter(Boolean).join(" · ")}</div>
|
||||
</div>
|
||||
<div className="meta">
|
||||
<h1>{t(`type.${c.type}`)}</h1>
|
||||
<div>
|
||||
{t("field.reportNumber")}: <strong>{c.reportNumber}</strong> · {t("field.version")} {c.version}
|
||||
</div>
|
||||
<div>
|
||||
{t("field.reportDate")}: {dayFmt(c.reportDate)}
|
||||
</div>
|
||||
<div>
|
||||
{t("pdf.approvalStatus")}:{" "}
|
||||
<span className="badge">
|
||||
{t(`status.${input.status}`)}
|
||||
{input.approvedAt ? ` · ${dateFmt.format(input.approvedAt)}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>{t("section.header")}</h2>
|
||||
<dl className="kv grid">
|
||||
<Kv label={t("field.customer")} value={[c.customer.name, addr(c.customer.address)].filter(Boolean).join(", ")} />
|
||||
<Kv label={t("field.customerNumber")} value={c.customer.number} />
|
||||
<Kv label={t("field.site")} value={c.site ? [c.site.name, addr(c.site.address)].filter(Boolean).join(", ") : null} />
|
||||
<Kv label={t("field.contact")} value={c.contact ? [c.contact.name, c.contact.role, c.contact.phone, c.contact.email].filter(Boolean).join(" · ") : null} />
|
||||
<Kv label={t("field.orderNumber")} value={[c.workOrder.number, c.workOrder.externalOrderNumber].filter(Boolean).join(" / ")} />
|
||||
<Kv label={t("field.orderType")} value={c.workOrder.orderType} />
|
||||
<Kv label={t("field.workOrder")} value={c.workOrder.title} />
|
||||
<Kv label={t("field.workDates")} value={c.workDates.map(dayFmt).join(", ")} />
|
||||
<Kv label={t("field.staff")} value={c.staff.map((s) => s.name).join(", ")} />
|
||||
<Kv label={t("field.technician")} value={c.technician?.name} />
|
||||
</dl>
|
||||
{c.workOrder.description ? (
|
||||
<>
|
||||
<h3>{t("field.description")}</h3>
|
||||
<div className="text">{c.workOrder.description}</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<h2>{t("section.time")}</h2>
|
||||
{c.time.entries.length === 0 ? (
|
||||
<p className="muted">{t("time.empty")}</p>
|
||||
) : (
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("time.person")}</th>
|
||||
<th>{t("time.type")}</th>
|
||||
<th className="num">{t("time.duration")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{c.time.entries.map((e) => (
|
||||
<tr key={`${e.userId}-${e.type}`}>
|
||||
<td>{e.name}</td>
|
||||
<td>{t(`timeType.${e.type}`)}</td>
|
||||
<td className="num">{fmtDuration(t, e.minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
{Object.entries(c.time.totalsByType).map(([type, minutes]) => (
|
||||
<tr key={`sum-${type}`}>
|
||||
<td className="muted">{t("time.totalByType")}</td>
|
||||
<td>{t(`timeType.${type}`)}</td>
|
||||
<td className="num">{fmtDuration(t, minutes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr>
|
||||
<td colSpan={2}>
|
||||
<strong>{t("time.total")}</strong>
|
||||
</td>
|
||||
<td className="num">
|
||||
<strong>{fmtDuration(t, c.time.totalMinutes)}</strong>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{c.time.hasRunningEntries ? <p className="muted">{t("time.running", { time: dateTimeFmt.format(new Date(c.generatedAt)) })}</p> : null}
|
||||
|
||||
{texts.length ? <h2>{t("section.texts")}</h2> : null}
|
||||
{texts.map((f) => (
|
||||
<div key={f}>
|
||||
<h3>{t(`texts.${f}`)}</h3>
|
||||
<div className="text">{c.texts[f]}</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<h2>{t("section.materials")}</h2>
|
||||
{!hasMaterial ? <p className="muted">{t("materials.empty")}</p> : null}
|
||||
{c.materials.used.length ? (
|
||||
<>
|
||||
<h3>{t("materials.used")}</h3>
|
||||
<MaterialTable t={t} lines={c.materials.used} />
|
||||
</>
|
||||
) : null}
|
||||
{c.materials.notUsed.length ? (
|
||||
<>
|
||||
<h3>{t("materials.notUsed")}</h3>
|
||||
<MaterialTable t={t} lines={c.materials.notUsed} />
|
||||
</>
|
||||
) : null}
|
||||
{c.materials.additional.length ? (
|
||||
<>
|
||||
<h3>{t("materials.additional")}</h3>
|
||||
<MaterialTable t={t} lines={c.materials.additional} />
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{c.checklist.length ? (
|
||||
<>
|
||||
<h2>{t("section.checklist")}</h2>
|
||||
<table>
|
||||
<tbody>
|
||||
{c.checklist.map((i, idx) => (
|
||||
<tr key={idx}>
|
||||
<td>
|
||||
{i.label}
|
||||
{i.required ? <span className="muted"> · {t("checklist.required")}</span> : null}
|
||||
{i.comment ? <div className="muted">{i.comment}</div> : null}
|
||||
</td>
|
||||
<td className="num">{i.checked ? `✓ ${t("checklist.done")}` : `○ ${t("checklist.open")}`}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<h2>{t("section.photos")}</h2>
|
||||
{c.photos.length === 0 ? (
|
||||
<p className="muted">{t("photos.empty")}</p>
|
||||
) : (
|
||||
<div className="photos">
|
||||
{c.photos.map((p, idx) => (
|
||||
<div className="photo" key={p.photoId}>
|
||||
{input.images[p.documentId] ? <img src={input.images[p.documentId]} alt={t("photos.alt", { index: idx + 1 })} /> : null}
|
||||
<div className="cap">
|
||||
<strong>{idx + 1}.</strong> {p.phase ? t(`phase.${p.phase}`) : ""}
|
||||
{p.requirement ? ` · ${t("photos.requirement", { label: p.requirement })}` : ""} · {dateTimeFmt.format(new Date(p.takenAt))}
|
||||
{p.comment ? <div>{p.comment}</div> : null}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<h2>{t("section.signature")}</h2>
|
||||
{!c.signature ? (
|
||||
<p className="muted">{t("signature.none")}</p>
|
||||
) : (
|
||||
<div className="sig">
|
||||
<div>
|
||||
<strong>{t(`outcome.${c.signature.outcome}`)}</strong>
|
||||
</div>
|
||||
{c.signature.imageDocumentId && input.images[c.signature.imageDocumentId] ? (
|
||||
<img src={input.images[c.signature.imageDocumentId]} alt={t("signature.image", { name: c.signature.signerName ?? "" })} />
|
||||
) : null}
|
||||
<dl className="kv grid">
|
||||
<Kv label={t("signature.signer")} value={c.signature.signerName} />
|
||||
<Kv label={t("signature.role")} value={c.signature.signerRole} />
|
||||
<Kv label={t("signature.signedAt")} value={dateTimeFmt.format(new Date(c.signature.signedAt))} />
|
||||
<Kv label={t("signature.capturedBy")} value={c.signature.capturedByName} />
|
||||
<Kv label={t("signature.reason")} value={c.signature.reason} />
|
||||
</dl>
|
||||
{c.signature.confirmationText ? <div className="text muted">{c.signature.confirmationText}</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
/** Returns the document HTML plus Chromium header/footer templates (page numbers, report id, version, checksum). */
|
||||
export function renderReportHtml(input: ReportPdfInput): { html: string; headerHtml: string; footerHtml: string } {
|
||||
const { content: c, t } = input;
|
||||
const html = "<!doctype html>" + renderToStaticMarkup(<ReportDocument {...input} />);
|
||||
const small = `font-family:${DOCUMENT_THEME.bodyFont};font-size:7pt;color:${DOCUMENT_THEME.textMuted};width:100%;padding:0 16mm;display:flex;justify-content:space-between;gap:6mm;`;
|
||||
const headerHtml = `<div style="${small}"><span>${esc(c.tenant.name)}</span><span>${esc(t(`type.${c.type}`))} ${esc(c.reportNumber)} · ${esc(t("field.version"))} ${c.version}</span></div>`;
|
||||
const page = esc(t("pdf.page", { page: "__P__", pages: "__N__" }))
|
||||
.replace("__P__", '<span class="pageNumber"></span>')
|
||||
.replace("__N__", '<span class="totalPages"></span>');
|
||||
const footerHtml =
|
||||
`<div style="${small}"><span>${esc(t("pdf.reportId"))}: ${esc(input.reportId)} · ${esc(t("field.version"))} ${c.version} · ` +
|
||||
`${esc(t("pdf.checksum"))}: ${esc(input.contentChecksum)}<br/>${esc(documentFooterLine())}</span><span style="white-space:nowrap">${page}</span></div>`;
|
||||
return { html, headerHtml, footerHtml };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* STUB (lane L5 „Berichte") for the shared file contract ARCHITEKTUR §4.3
|
||||
* `src/server/services/documents/store.ts#storeFile` (not yet provided by the architect / documents lane).
|
||||
*
|
||||
* Minimal implementation behind the contracted interface: allowlist + magic bytes + size limit,
|
||||
* SHA-256, storage.put, Document row, lineage versioning. At merge the import in
|
||||
* services/reports/*.ts is switched to the real store and this file is deleted.
|
||||
*/
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import type { Document, DocumentCategory, DocumentVisibility } from "@prisma/client";
|
||||
import { storage } from "@/server/storage/adapter";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
|
||||
export type StoreFileInput = {
|
||||
bytes: Uint8Array;
|
||||
fileName: string;
|
||||
declaredMime: string;
|
||||
category: DocumentCategory;
|
||||
visibility: DocumentVisibility;
|
||||
links: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null };
|
||||
lineageId?: string;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
const LIMITS: Record<string, number> = { "application/pdf": 25 * 1024 * 1024, "image/png": 15 * 1024 * 1024, "image/jpeg": 15 * 1024 * 1024, "image/webp": 15 * 1024 * 1024 };
|
||||
|
||||
export function sniffMime(bytes: Uint8Array): string | null {
|
||||
const b = bytes;
|
||||
if (b.length >= 5 && b[0] === 0x25 && b[1] === 0x50 && b[2] === 0x44 && b[3] === 0x46 && b[4] === 0x2d) return "application/pdf";
|
||||
if (b.length >= 8 && b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return "image/png";
|
||||
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return "image/jpeg";
|
||||
if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 && b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) return "image/webp";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function sha256Hex(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
export async function storeFile(ctx: ServiceCtx, input: StoreFileInput): Promise<Document> {
|
||||
const mime = sniffMime(input.bytes);
|
||||
if (!mime || mime !== input.declaredMime) throw new ServiceError("invalid", "file type not allowed or does not match content");
|
||||
if (input.bytes.byteLength === 0 || input.bytes.byteLength > (LIMITS[mime] ?? 0)) throw new ServiceError("invalid", "file size not allowed");
|
||||
const fileName = input.fileName.normalize("NFC").replace(/[^\w.\- ]+/g, "_").slice(0, 120) || "datei";
|
||||
const checksum = sha256Hex(input.bytes);
|
||||
const put = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: mime, bytes: input.bytes });
|
||||
|
||||
let version = 1;
|
||||
const lineageId = input.lineageId ?? randomUUID();
|
||||
if (input.lineageId) {
|
||||
const last = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } });
|
||||
version = (last?.version ?? 0) + 1;
|
||||
}
|
||||
return ctx.db.document.create({
|
||||
data: {
|
||||
tenantId: ctx.tenantId,
|
||||
customerId: input.links.customerId ?? null,
|
||||
siteId: input.links.siteId ?? null,
|
||||
workOrderId: input.links.workOrderId ?? null,
|
||||
category: input.category,
|
||||
title: input.title ?? null,
|
||||
fileName,
|
||||
storageKey: put.storageKey,
|
||||
mimeType: mime,
|
||||
fileSize: input.bytes.byteLength,
|
||||
checksum,
|
||||
version,
|
||||
lineageId,
|
||||
visibility: input.visibility,
|
||||
uploadStatus: "uploaded",
|
||||
uploadedById: ctx.userId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Read the bytes of a stored document (worker/PDF rendering). null if the backend has no bytes. */
|
||||
export async function readFileBytes(storageKey: string): Promise<Uint8Array | null> {
|
||||
const obj = await storage.get(storageKey);
|
||||
if (!obj) return null;
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = obj.stream.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* STUB (lane L5 „Berichte") for contracts owned by lane L2 „Aufträge":
|
||||
* - services/work-orders/transition.ts#transitionWorkOrder
|
||||
* - services/work-orders/guards.ts#getCompletionBlockers (ARCHITEKTUR §3 „Guards vor Abschluss")
|
||||
*
|
||||
* Interface follows ARCHITEKTUR §3. At merge the architect replaces the imports in
|
||||
* services/reports/*.ts with the L2 implementations and deletes this file.
|
||||
* Contract extension used by L5 (for L6 mail deduplication): optional `eventData` is merged into the emitted event's
|
||||
* `data` (e.g. `{ occurrenceId }`) — the L2 implementation should accept it as well.
|
||||
*/
|
||||
import type { WorkOrderStatus as DbWorkOrderStatus } from "@prisma/client";
|
||||
import type { DomainEvent } from "@/lib/events";
|
||||
import { canTransition, requiredPermission, type CompletionBlocker, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||
|
||||
export type TransitionInput = {
|
||||
workOrderId: string;
|
||||
to: WorkOrderStatus;
|
||||
reason?: string | null;
|
||||
/** optimistic concurrency (offline sync) */
|
||||
expectedVersion?: number;
|
||||
/** extra event data, e.g. { occurrenceId } for repeatable events */
|
||||
eventData?: DomainEvent["data"];
|
||||
};
|
||||
|
||||
export async function transitionWorkOrder(ctx: ServiceCtx, input: TransitionInput): Promise<{ id: string; status: WorkOrderStatus; version: number }> {
|
||||
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, status: true, version: true, number: true });
|
||||
const from = wo.status as WorkOrderStatus;
|
||||
if (!canTransition(from, input.to)) throw new ServiceError("invalid", `transition ${from} → ${input.to} not allowed`);
|
||||
const perm = requiredPermission(from, input.to);
|
||||
const allowed = perm === "report:approve_team" ? can(ctx, "report:approve_team") || can(ctx, "report:approve") : can(ctx, perm);
|
||||
if (!allowed) throw new ServiceError("forbidden", `missing permission ${perm}`);
|
||||
if (input.expectedVersion !== undefined && input.expectedVersion !== wo.version) {
|
||||
throw new ServiceError("conflict", "work order version changed");
|
||||
}
|
||||
const res = await ctx.db.workOrder.updateMany({
|
||||
where: { id: wo.id, version: wo.version },
|
||||
data: { status: input.to as DbWorkOrderStatus, version: { increment: 1 } },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "work order changed concurrently");
|
||||
await ctx.db.workOrderStatusChange.create({
|
||||
data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: input.to, actorId: ctx.userId, reason: input.reason ?? null },
|
||||
});
|
||||
await writeAuditLog({
|
||||
tenantId: ctx.tenantId,
|
||||
actorId: ctx.userId,
|
||||
action: "update",
|
||||
entity: "work_order",
|
||||
entityId: wo.id,
|
||||
before: { status: from },
|
||||
after: { status: input.to, reason: input.reason ?? null },
|
||||
});
|
||||
const eventType =
|
||||
input.to === "daily_report_created"
|
||||
? "work_order.daily_report_created"
|
||||
: input.to === "technically_completed"
|
||||
? "work_order.technically_completed"
|
||||
: input.to === "signature_pending"
|
||||
? "work_order.signature_missing"
|
||||
: "work_order.changed";
|
||||
await emitEvent(ctx, { type: eventType, entityType: "work_order", entityId: wo.id, data: { number: wo.number, from, to: input.to, ...input.eventData } });
|
||||
return { id: wo.id, status: input.to, version: wo.version + 1 };
|
||||
}
|
||||
|
||||
export async function getCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise<CompletionBlocker[]> {
|
||||
await requireVisibleWorkOrder(ctx, workOrderId, { id: true });
|
||||
const [items, requirements, running] = await Promise.all([
|
||||
ctx.db.checklistItem.findMany({ where: { workOrderId, required: true, checked: false }, orderBy: { sortOrder: "asc" }, select: { id: true, label: true } }),
|
||||
ctx.db.photoRequirement.findMany({ where: { workOrderId, photos: { none: {} } }, orderBy: { sortOrder: "asc" }, select: { id: true, label: true } }),
|
||||
ctx.db.workSession.findMany({ where: { workOrderId, status: { in: ["en_route", "running", "paused"] } }, select: { id: true, userId: true } }),
|
||||
]);
|
||||
return [
|
||||
...items.map((i): CompletionBlocker => ({ kind: "checklist_item", itemId: i.id, label: i.label })),
|
||||
...requirements.map((r): CompletionBlocker => ({ kind: "photo_requirement", requirementId: r.id, label: r.label })),
|
||||
...running.map((s): CompletionBlocker => ({ kind: "running_session", sessionId: s.id, userId: s.userId })),
|
||||
];
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Report } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { auditReport, contentOf, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common";
|
||||
|
||||
export const approveReportSchema = z.object({ reportId: z.string().min(1).max(64) });
|
||||
export type ApproveReportInput = z.input<typeof approveReportSchema>;
|
||||
|
||||
export type ApproveDeps = {
|
||||
/** queue PDF rendering (worker); injectable for tests */
|
||||
dispatchPdf: (ctx: ServiceCtx, reportId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
export const defaultApproveDeps: ApproveDeps = {
|
||||
async dispatchPdf(ctx, reportId) {
|
||||
try {
|
||||
const [{ dispatchJob }, { JOB_QUEUES }] = await Promise.all([import("@/server/jobs/dispatch"), import("@/server/jobs/queues")]);
|
||||
await dispatchJob(JOB_QUEUES.reportPdf, { tenantId: ctx.tenantId, entityId: reportId, actorId: ctx.userId });
|
||||
} catch (err) {
|
||||
// Approval stays valid; the PDF can be regenerated from the report detail page.
|
||||
console.error(`[reports] pdf job for ${reportId} failed:`, (err as Error).message);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Freigabe (ARCHITEKTUR §2):
|
||||
* - report:approve → approved (from submitted/team_approved): content frozen, older approved versions superseded, PDF job, event
|
||||
* - report:approve_team → team_approved (from submitted); the report now waits for backoffice → report.submitted (approvalStage "backoffice")
|
||||
*/
|
||||
export async function approveReport(ctx: ServiceCtx, raw: ApproveReportInput, deps: ApproveDeps = defaultApproveDeps): Promise<Report> {
|
||||
const input = approveReportSchema.parse(raw);
|
||||
const final = can(ctx, "report:approve");
|
||||
if (!final && !can(ctx, "report:approve_team")) throw new ServiceError("forbidden", "missing permission report:approve");
|
||||
const report = await requireVisibleReport(ctx, input.reportId);
|
||||
|
||||
if (!final) {
|
||||
if (report.status !== "submitted") throw new ServiceError("conflict", `report is ${report.status}`);
|
||||
const teamApprovedAt = new Date();
|
||||
const res = await ctx.db.report.updateMany({
|
||||
where: { id: report.id, status: "submitted" },
|
||||
data: { status: "team_approved", teamApprovedById: ctx.userId, teamApprovedAt },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "report changed concurrently");
|
||||
const updated = await ctx.db.report.findFirstOrThrow({ where: { id: report.id } });
|
||||
await auditReport(ctx, "update", report.id, reportAuditView(report), reportAuditView(updated));
|
||||
await emitEvent(ctx, {
|
||||
type: "report.submitted",
|
||||
entityType: "report",
|
||||
entityId: report.id,
|
||||
data: {
|
||||
reportType: report.type,
|
||||
number: contentOf(updated).reportNumber,
|
||||
workOrderNumber: await orderNumberOf(ctx, report.workOrderId),
|
||||
version: report.version,
|
||||
approvalStage: "backoffice",
|
||||
occurrenceId: `${report.id}:team_approved:${teamApprovedAt.getTime()}`,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
if (report.status !== "submitted" && report.status !== "team_approved") throw new ServiceError("conflict", `report is ${report.status}`);
|
||||
const content = await refreshContent(ctx, report);
|
||||
const res = await ctx.db.report.updateMany({
|
||||
where: { id: report.id, status: { in: ["submitted", "team_approved"] } },
|
||||
data: { status: "approved", approvedById: ctx.userId, approvedAt: new Date(), content },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "report changed concurrently");
|
||||
const superseded = await ctx.db.report.findMany({
|
||||
where: { lineageId: report.lineageId, id: { not: report.id }, status: "approved", version: { lt: report.version } },
|
||||
select: { id: true },
|
||||
});
|
||||
if (superseded.length) {
|
||||
await ctx.db.report.updateMany({ where: { id: { in: superseded.map((s) => s.id) } }, data: { status: "superseded" } });
|
||||
for (const s of superseded) await auditReport(ctx, "update", s.id, { status: "approved" }, { status: "superseded", supersededBy: report.id });
|
||||
}
|
||||
const updated = await ctx.db.report.findFirstOrThrow({ where: { id: report.id } });
|
||||
await auditReport(ctx, "update", report.id, reportAuditView(report), reportAuditView(updated));
|
||||
await emitEvent(ctx, {
|
||||
type: "report.approved",
|
||||
entityType: "report",
|
||||
entityId: report.id,
|
||||
data: {
|
||||
reportType: report.type,
|
||||
number: contentOf(updated).reportNumber,
|
||||
workOrderNumber: await orderNumberOf(ctx, report.workOrderId),
|
||||
version: report.version,
|
||||
occurrenceId: report.id, // approval happens once per report version
|
||||
},
|
||||
});
|
||||
await deps.dispatchPdf(ctx, report.id);
|
||||
return updated;
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
emptyTexts,
|
||||
type MaterialLine,
|
||||
type ReportContent,
|
||||
type ReportTexts,
|
||||
type ReportType,
|
||||
type SignatureBlock,
|
||||
} from "@/lib/reports/content";
|
||||
import { dayWindow, localDateKey } from "@/lib/reports/dates";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||
|
||||
export type BuildContentInput = {
|
||||
workOrderId: string;
|
||||
type: ReportType;
|
||||
/** YYYY-MM-DD in tenant time zone */
|
||||
reportDate: string;
|
||||
reportNumber: string;
|
||||
version: number;
|
||||
/** keep edited texts; when omitted texts are prefilled from activity notes */
|
||||
texts?: ReportTexts;
|
||||
/** id of the report whose Signature row feeds the signature block */
|
||||
reportId?: string | null;
|
||||
/** fallback when the report has no Signature row (e.g. copied into a new version) */
|
||||
previousSignature?: SignatureBlock | null;
|
||||
technicianUserId: string | null;
|
||||
now?: Date;
|
||||
};
|
||||
|
||||
export async function tenantTimeZone(ctx: ServiceCtx): Promise<string> {
|
||||
const s = await ctx.db.tenantSettings.findFirst({ select: { timezone: true } });
|
||||
return s?.timezone || "Europe/Berlin";
|
||||
}
|
||||
|
||||
const joinLines = (...parts: Array<string | null | undefined>) => parts.filter((p) => p && p.trim()).join("\n");
|
||||
const dec = (v: Prisma.Decimal | null | undefined) => (v == null ? null : v.toString());
|
||||
|
||||
function address(street?: string | null, houseNumber?: string | null, postalCode?: string | null, city?: string | null) {
|
||||
const line1 = [street, houseNumber].filter(Boolean).join(" ") || null;
|
||||
const line2 = [postalCode, city].filter(Boolean).join(" ") || null;
|
||||
return { line1, line2 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the report snapshot from the database (ARCHITEKTUR §4.7).
|
||||
* Daily report: only time entries, notes, photos and material usages of `reportDate`.
|
||||
* Completion report: the whole work order.
|
||||
* Access: the work order must be visible to the caller (workOrderScope).
|
||||
*/
|
||||
export async function buildReportContent(ctx: ServiceCtx, input: BuildContentInput): Promise<ReportContent> {
|
||||
const now = input.now ?? new Date();
|
||||
await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true });
|
||||
const timeZone = await tenantTimeZone(ctx);
|
||||
const daily = input.type === "daily";
|
||||
const win = dayWindow(input.reportDate, timeZone);
|
||||
const inDay = daily ? { gte: win.start, lt: win.end } : undefined;
|
||||
|
||||
const [wo, settings, tenant, entries, notes, photos, usages, plans, checklist, signature, technician] = await Promise.all([
|
||||
ctx.db.workOrder.findFirstOrThrow({
|
||||
where: { id: input.workOrderId },
|
||||
include: {
|
||||
customer: true,
|
||||
site: true,
|
||||
contact: true,
|
||||
orderType: { select: { name: true } },
|
||||
assignees: { include: { user: { select: { id: true, name: true } } } },
|
||||
},
|
||||
}),
|
||||
ctx.db.tenantSettings.findFirst({ select: { orgName: true, address: true, phone: true, email: true } }),
|
||||
ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { tenant: { select: { name: true } } } }),
|
||||
ctx.db.timeEntry.findMany({
|
||||
where: { workSession: { workOrderId: input.workOrderId }, ...(inDay ? { startedAt: inDay } : {}) },
|
||||
orderBy: { startedAt: "asc" },
|
||||
select: { userId: true, type: true, startedAt: true, endedAt: true },
|
||||
}),
|
||||
ctx.db.activityNote.findMany({
|
||||
where: { workOrderId: input.workOrderId, deletedAt: null, ...(inDay ? { createdAt: inDay } : {}) },
|
||||
orderBy: { createdAt: "asc" },
|
||||
select: { kind: true, text: true },
|
||||
}),
|
||||
ctx.db.photo.findMany({
|
||||
where: { workOrderId: input.workOrderId, includeInReport: true, ...(inDay ? { takenAt: inDay } : {}) },
|
||||
orderBy: { takenAt: "asc" },
|
||||
include: { photoRequirement: { select: { label: true } } },
|
||||
}),
|
||||
ctx.db.materialUsage.findMany({
|
||||
where: { workOrderId: input.workOrderId, ...(inDay ? { createdAt: inDay } : {}) },
|
||||
orderBy: { createdAt: "asc" },
|
||||
include: { materialPlan: true },
|
||||
}),
|
||||
ctx.db.materialPlan.findMany({ where: { workOrderId: input.workOrderId }, orderBy: { sortOrder: "asc" } }),
|
||||
ctx.db.checklistItem.findMany({ where: { workOrderId: input.workOrderId }, orderBy: { sortOrder: "asc" } }),
|
||||
input.reportId ? ctx.db.signature.findFirst({ where: { reportId: input.reportId } }) : Promise.resolve(null),
|
||||
input.technicianUserId ? ctx.db.user.findFirst({ where: { id: input.technicianUserId }, select: { id: true, name: true } }) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
// ---- people & time ----
|
||||
const userIds = new Set<string>(entries.map((e) => e.userId));
|
||||
if (signature?.capturedById) userIds.add(signature.capturedById);
|
||||
const users = await ctx.db.user.findMany({ where: { id: { in: [...userIds] } }, select: { id: true, name: true } });
|
||||
const nameOf = new Map(users.map((u) => [u.id, u.name]));
|
||||
for (const a of wo.assignees) nameOf.set(a.user.id, a.user.name);
|
||||
|
||||
const perKey = new Map<string, { userId: string; name: string; type: (typeof entries)[number]["type"]; minutes: number }>();
|
||||
let hasRunningEntries = false;
|
||||
for (const e of entries) {
|
||||
if (!e.endedAt) hasRunningEntries = true;
|
||||
const minutes = Math.max(0, Math.round(((e.endedAt ?? now).getTime() - e.startedAt.getTime()) / 60_000));
|
||||
const key = `${e.userId}|${e.type}`;
|
||||
const cur = perKey.get(key) ?? { userId: e.userId, name: nameOf.get(e.userId) ?? "—", type: e.type, minutes: 0 };
|
||||
cur.minutes += minutes;
|
||||
perKey.set(key, cur);
|
||||
}
|
||||
const timeLines = [...perKey.values()];
|
||||
const totalsByType: Record<string, number> = {};
|
||||
const byPerson = new Map<string, { userId: string; name: string; minutes: number }>();
|
||||
let totalMinutes = 0;
|
||||
for (const l of timeLines) {
|
||||
totalsByType[l.type] = (totalsByType[l.type] ?? 0) + l.minutes;
|
||||
if (l.type === "break") continue;
|
||||
totalMinutes += l.minutes;
|
||||
const p = byPerson.get(l.userId) ?? { userId: l.userId, name: l.name, minutes: 0 };
|
||||
p.minutes += l.minutes;
|
||||
byPerson.set(l.userId, p);
|
||||
}
|
||||
|
||||
const staffIds = [...new Set(entries.map((e) => e.userId))];
|
||||
const staff = (staffIds.length ? staffIds : wo.assignees.map((a) => a.userId)).map((id) => ({ userId: id, name: nameOf.get(id) ?? "—" }));
|
||||
|
||||
const workDates = daily
|
||||
? [input.reportDate]
|
||||
: [...new Set(entries.map((e) => localDateKey(e.startedAt, timeZone)))].sort();
|
||||
|
||||
// ---- texts (prefill from notes on create) ----
|
||||
const byKind = (...kinds: string[]) => joinLines(...notes.filter((n) => kinds.includes(n.kind)).map((n) => n.text));
|
||||
const texts: ReportTexts = input.texts ?? {
|
||||
...emptyTexts(),
|
||||
workPerformed: byKind("work_done", "general"),
|
||||
deviations: byKind("deviation"),
|
||||
additionalWork: byKind("additional_work"),
|
||||
problems: byKind("problem", "not_executable"),
|
||||
openItems: byKind("follow_up"),
|
||||
nextSteps: "",
|
||||
hints: byKind("recommendation", "customer_note"),
|
||||
};
|
||||
|
||||
// ---- materials ----
|
||||
const used: MaterialLine[] = [];
|
||||
const notUsed: MaterialLine[] = [];
|
||||
const additional: MaterialLine[] = [];
|
||||
const plansWithUsage = new Set<string>();
|
||||
for (const u of usages) {
|
||||
if (u.materialPlanId) plansWithUsage.add(u.materialPlanId);
|
||||
const planned = u.materialPlan ? dec(u.materialPlan.plannedQuantity) : null;
|
||||
const quantityDiffers = u.materialPlan ? !u.materialPlan.plannedQuantity.equals(u.actualQuantity) : false;
|
||||
const line: MaterialLine = {
|
||||
usageId: u.id,
|
||||
planId: u.materialPlanId,
|
||||
name: u.name,
|
||||
articleNumber: u.articleNumber,
|
||||
plannedQuantity: planned,
|
||||
actualQuantity: dec(u.actualQuantity),
|
||||
unit: u.unit,
|
||||
status: u.usageStatus,
|
||||
deviation: u.usageStatus !== "fully_used" || quantityDiffers,
|
||||
deviationReason: u.deviationReason,
|
||||
notes: u.notes,
|
||||
documented: true,
|
||||
};
|
||||
if (u.usageStatus === "additional" || !u.materialPlanId) additional.push({ ...line, deviation: true });
|
||||
else if (u.usageStatus === "not_used") notUsed.push(line);
|
||||
else used.push(line);
|
||||
}
|
||||
if (!daily) {
|
||||
for (const p of plans) {
|
||||
if (plansWithUsage.has(p.id)) continue;
|
||||
notUsed.push({
|
||||
usageId: null,
|
||||
planId: p.id,
|
||||
name: p.name,
|
||||
articleNumber: p.articleNumber,
|
||||
plannedQuantity: dec(p.plannedQuantity),
|
||||
actualQuantity: null,
|
||||
unit: p.unit,
|
||||
status: null,
|
||||
deviation: true,
|
||||
deviationReason: null,
|
||||
notes: p.notes,
|
||||
documented: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ---- signature ----
|
||||
let signatureBlock: SignatureBlock | null = input.previousSignature ?? null;
|
||||
if (signature) {
|
||||
signatureBlock = {
|
||||
outcome: signature.outcome,
|
||||
signerName: signature.signerName,
|
||||
signerRole: signature.signerRole,
|
||||
signedAt: signature.signedAt.toISOString(),
|
||||
reason: signature.reason,
|
||||
confirmationText: signature.confirmationText,
|
||||
imageDocumentId: signature.imageDocumentId,
|
||||
capturedByName: signature.capturedById ? (nameOf.get(signature.capturedById) ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
const customerName =
|
||||
wo.customer.companyName || [wo.customer.firstName, wo.customer.lastName].filter(Boolean).join(" ") || wo.customer.customerNumber || "—";
|
||||
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
type: input.type,
|
||||
reportNumber: input.reportNumber,
|
||||
version: input.version,
|
||||
reportDate: input.reportDate,
|
||||
generatedAt: now.toISOString(),
|
||||
tenant: {
|
||||
name: settings?.orgName || tenant?.tenant.name || "—",
|
||||
address: settings?.address ?? null,
|
||||
phone: settings?.phone ?? null,
|
||||
email: settings?.email ?? null,
|
||||
logoDocumentId: null, // TODO(settings): TenantSettings.logoKey is not a Document yet
|
||||
},
|
||||
customer: {
|
||||
id: wo.customer.id,
|
||||
number: wo.customer.customerNumber,
|
||||
name: customerName,
|
||||
address: address(wo.customer.street, wo.customer.houseNumber, wo.customer.postalCode, wo.customer.city),
|
||||
},
|
||||
site: wo.site ? { id: wo.site.id, name: wo.site.name, address: address(wo.site.street, wo.site.houseNumber, wo.site.postalCode, wo.site.city) } : null,
|
||||
contact: wo.contact
|
||||
? { name: wo.contact.name, role: wo.contact.role, phone: wo.contact.phone ?? wo.contact.mobile, email: wo.contact.email }
|
||||
: null,
|
||||
workOrder: {
|
||||
id: wo.id,
|
||||
number: wo.number,
|
||||
externalOrderNumber: wo.externalOrderNumber,
|
||||
title: wo.title,
|
||||
description: wo.description,
|
||||
scope: wo.scope,
|
||||
orderType: wo.orderType?.name ?? null,
|
||||
signatureRequired: wo.signatureRequired,
|
||||
},
|
||||
workDates,
|
||||
staff,
|
||||
time: {
|
||||
entries: timeLines,
|
||||
totalsByType,
|
||||
totalsByPerson: [...byPerson.values()],
|
||||
totalMinutes,
|
||||
hasRunningEntries,
|
||||
},
|
||||
texts,
|
||||
materials: { used, notUsed, additional },
|
||||
photos: photos.map((p) => ({
|
||||
photoId: p.id,
|
||||
documentId: p.documentId,
|
||||
phase: p.phase,
|
||||
comment: p.comment,
|
||||
requirement: p.photoRequirement?.label ?? null,
|
||||
takenAt: p.takenAt.toISOString(),
|
||||
})),
|
||||
checklist: checklist.map((c) => ({ label: c.label, required: c.required, checked: c.checked, comment: c.comment })),
|
||||
signature: signatureBlock,
|
||||
technician: technician ? { userId: technician.id, name: technician.name } : null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { Prisma, Report } from "@prisma/client";
|
||||
import { parseReportContent, type ReportContent } from "@/lib/reports/content";
|
||||
import { dbDateToKey } from "@/lib/reports/dates";
|
||||
import { writeAuditLog } from "@/server/audit";
|
||||
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { workOrderScope } from "@/server/services/work-orders/visibility";
|
||||
import { buildReportContent } from "./build-content";
|
||||
|
||||
/** Report where-clause restricted to work orders the caller may see (ARCHITEKTUR §2). */
|
||||
export async function reportScope(ctx: ServiceCtx): Promise<Prisma.ReportWhereInput> {
|
||||
if (!can(ctx, "report:read")) return { id: "__none__" };
|
||||
return { workOrder: await workOrderScope(ctx) };
|
||||
}
|
||||
|
||||
/** Load a report visible to the caller or throw not_found (never reveal existence). */
|
||||
export async function requireVisibleReport(ctx: ServiceCtx, reportId: string): Promise<Report> {
|
||||
const scope = await reportScope(ctx);
|
||||
const report = await ctx.db.report.findFirst({ where: { AND: [{ id: reportId }, scope] } });
|
||||
if (!report) throw new ServiceError("not_found", "report not found");
|
||||
return report;
|
||||
}
|
||||
|
||||
export function contentOf(report: Pick<Report, "content">): ReportContent {
|
||||
return parseReportContent(report.content);
|
||||
}
|
||||
|
||||
/** Rebuild DB-derived parts of a report snapshot while keeping number, version and edited texts. */
|
||||
export async function refreshContent(ctx: ServiceCtx, report: Report): Promise<ReportContent> {
|
||||
const current = contentOf(report);
|
||||
return buildReportContent(ctx, {
|
||||
workOrderId: report.workOrderId,
|
||||
type: report.type,
|
||||
reportDate: dbDateToKey(report.reportDate),
|
||||
reportNumber: current.reportNumber,
|
||||
version: report.version,
|
||||
texts: current.texts,
|
||||
reportId: report.id,
|
||||
previousSignature: current.signature,
|
||||
technicianUserId: report.createdById,
|
||||
});
|
||||
}
|
||||
|
||||
/** Compact, PII-light audit projection of a report. */
|
||||
export function reportAuditView(r: Pick<Report, "id" | "status" | "type" | "version" | "lineageId" | "workOrderId"> & { rejectionReason?: string | null }) {
|
||||
return { id: r.id, status: r.status, type: r.type, version: r.version, lineageId: r.lineageId, workOrderId: r.workOrderId, rejectionReason: r.rejectionReason ?? null };
|
||||
}
|
||||
|
||||
export async function auditReport(
|
||||
ctx: ServiceCtx,
|
||||
action: "create" | "update",
|
||||
entityId: string,
|
||||
before: unknown,
|
||||
after: unknown,
|
||||
entity: "report" | "signature" = "report",
|
||||
) {
|
||||
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action, entity, entityId, before, after });
|
||||
}
|
||||
|
||||
export function assertEditable(report: Pick<Report, "status">) {
|
||||
if (report.status !== "draft" && report.status !== "rejected") throw new ServiceError("conflict", `report is ${report.status}`);
|
||||
}
|
||||
|
||||
/** Customer-facing work order numbers for events/templates. */
|
||||
export async function orderNumberOf(ctx: ServiceCtx, workOrderId: string): Promise<string> {
|
||||
const wo = await ctx.db.workOrder.findFirst({ where: { id: workOrderId }, select: { number: true } });
|
||||
return wo?.number ?? "";
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Report } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { FIELD_EDITABLE, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { dateKeyToDbDate, localDateKey } from "@/lib/reports/dates";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { nextNumber } from "@/server/services/numbering";
|
||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||
// TODO(merge L2): import from "@/server/services/work-orders/transition" and ".../guards"
|
||||
import { getCompletionBlockers, transitionWorkOrder } from "./_stubs/work-orders";
|
||||
import { buildReportContent, tenantTimeZone } from "./build-content";
|
||||
import { auditReport, reportAuditView } from "./common";
|
||||
|
||||
export const createReportSchema = z.object({
|
||||
workOrderId: z.string().min(1).max(64),
|
||||
reportDate: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
clientId: z.string().min(1).max(64).optional(),
|
||||
});
|
||||
export type CreateReportInput = z.input<typeof createReportSchema>;
|
||||
export type CreateReportResult = { report: Report; created: boolean };
|
||||
|
||||
async function byClientId(ctx: ServiceCtx, clientId?: string): Promise<Report | null> {
|
||||
if (!clientId) return null;
|
||||
return ctx.db.report.findFirst({ where: { clientId } });
|
||||
}
|
||||
|
||||
async function createDraft(
|
||||
ctx: ServiceCtx,
|
||||
args: { workOrderId: string; type: "daily" | "completion"; dateKey: string; clientId?: string },
|
||||
): Promise<Report> {
|
||||
const reportNumber = await nextNumber(ctx.db, ctx.tenantId, "report");
|
||||
const content = await buildReportContent(ctx, {
|
||||
workOrderId: args.workOrderId,
|
||||
type: args.type,
|
||||
reportDate: args.dateKey,
|
||||
reportNumber,
|
||||
version: 1,
|
||||
technicianUserId: ctx.userId,
|
||||
});
|
||||
const report = await ctx.db.report.create({
|
||||
data: {
|
||||
tenantId: ctx.tenantId,
|
||||
workOrderId: args.workOrderId,
|
||||
type: args.type,
|
||||
reportDate: dateKeyToDbDate(args.dateKey),
|
||||
version: 1,
|
||||
lineageId: randomUUID(),
|
||||
status: "draft",
|
||||
content,
|
||||
createdById: ctx.userId,
|
||||
clientId: args.clientId ?? null,
|
||||
},
|
||||
});
|
||||
await auditReport(ctx, "create", report.id, null, { ...reportAuditView(report), reportNumber });
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tagesbericht (Spec §16.3): draft for one calendar day, work order → daily_report_created, order stays open.
|
||||
* Idempotent per (work order, day): an existing draft/rejected report of that day is returned.
|
||||
*/
|
||||
export async function createDailyReport(ctx: ServiceCtx, raw: CreateReportInput): Promise<CreateReportResult> {
|
||||
assertCan(ctx, "report:write");
|
||||
const input = createReportSchema.parse(raw);
|
||||
const dup = await byClientId(ctx, input.clientId);
|
||||
if (dup) return { report: dup, created: false };
|
||||
|
||||
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, status: true });
|
||||
const dateKey = input.reportDate ?? localDateKey(new Date(), await tenantTimeZone(ctx));
|
||||
|
||||
const existing = await ctx.db.report.findFirst({
|
||||
where: { workOrderId: wo.id, type: "daily", reportDate: dateKeyToDbDate(dateKey), status: { not: "superseded" } },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.status === "draft" || existing.status === "rejected") return { report: existing, created: false };
|
||||
throw new ServiceError("conflict", "daily report for this day already submitted");
|
||||
}
|
||||
|
||||
const status = wo.status as WorkOrderStatus;
|
||||
// one daily report per order and day → stable occurrence id for L6 mail deduplication
|
||||
const eventData = { occurrenceId: `${wo.id}:${dateKey}` };
|
||||
if (status === "paused" || status === "waiting_material") {
|
||||
await transitionWorkOrder(ctx, { workOrderId: wo.id, to: "in_progress" });
|
||||
await transitionWorkOrder(ctx, { workOrderId: wo.id, to: "daily_report_created", eventData });
|
||||
} else if (status === "in_progress") {
|
||||
await transitionWorkOrder(ctx, { workOrderId: wo.id, to: "daily_report_created", eventData });
|
||||
} else if (status !== "daily_report_created") {
|
||||
throw new ServiceError("invalid", `work order is ${status}`);
|
||||
}
|
||||
|
||||
const report = await createDraft(ctx, { workOrderId: wo.id, type: "daily", dateKey, clientId: input.clientId });
|
||||
return { report, created: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Abschlussbericht (Spec §17): checks completion guards first (blocked → CompletionBlocker[] in details).
|
||||
* One completion lineage per work order; changes after approval go through createNewVersion.
|
||||
*/
|
||||
export async function createCompletionReport(ctx: ServiceCtx, raw: CreateReportInput): Promise<CreateReportResult> {
|
||||
assertCan(ctx, "report:write");
|
||||
const input = createReportSchema.parse(raw);
|
||||
const dup = await byClientId(ctx, input.clientId);
|
||||
if (dup) return { report: dup, created: false };
|
||||
|
||||
const wo = await requireVisibleWorkOrder(ctx, input.workOrderId, { id: true, status: true });
|
||||
const existing = await ctx.db.report.findFirst({
|
||||
where: { workOrderId: wo.id, type: "completion", status: { not: "superseded" } },
|
||||
orderBy: { version: "desc" },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.status === "draft" || existing.status === "rejected") return { report: existing, created: false };
|
||||
throw new ServiceError("conflict", `completion report is ${existing.status}`);
|
||||
}
|
||||
if (!FIELD_EDITABLE.includes(wo.status as WorkOrderStatus)) throw new ServiceError("invalid", `work order is ${wo.status}`);
|
||||
|
||||
const blockers = await getCompletionBlockers(ctx, wo.id);
|
||||
if (blockers.length) throw new ServiceError("blocked", "completion blocked", blockers);
|
||||
|
||||
const dateKey = input.reportDate ?? localDateKey(new Date(), await tenantTimeZone(ctx));
|
||||
const report = await createDraft(ctx, { workOrderId: wo.id, type: "completion", dateKey, clientId: input.clientId });
|
||||
return { report, created: true };
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Report } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { reportTextsSchema } from "@/lib/reports/content";
|
||||
import { assertCan, type ServiceCtx } from "@/server/services/context";
|
||||
import { assertEditable, auditReport, contentOf, requireVisibleReport } from "./common";
|
||||
|
||||
export const editReportSchema = z.object({
|
||||
reportId: z.string().min(1).max(64),
|
||||
texts: reportTextsSchema.partial(),
|
||||
});
|
||||
export type EditReportInput = z.input<typeof editReportSchema>;
|
||||
|
||||
/** Edit the free-text block of a draft/rejected report (technician check & completion, Spec §16.3 step 4). */
|
||||
export async function updateReportTexts(ctx: ServiceCtx, raw: EditReportInput): Promise<Report> {
|
||||
assertCan(ctx, "report:write");
|
||||
const input = editReportSchema.parse(raw);
|
||||
const report = await requireVisibleReport(ctx, input.reportId);
|
||||
assertEditable(report);
|
||||
const content = contentOf(report);
|
||||
const texts = { ...content.texts, ...input.texts };
|
||||
const updated = await ctx.db.report.update({
|
||||
where: { id: report.id },
|
||||
data: { content: { ...content, texts } },
|
||||
});
|
||||
await auditReport(ctx, "update", report.id, { texts: content.texts }, { texts });
|
||||
return updated;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { storage, type StoredContent } from "@/server/storage/adapter";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { allowedDocumentVisibility } from "@/server/services/work-orders/visibility";
|
||||
import { contentOf, requireVisibleReport } from "./common";
|
||||
|
||||
export type ReportFile = StoredContent & { mimeType: string; fileName: string; checksum: string };
|
||||
|
||||
/**
|
||||
* Open a file that belongs to a report the caller may see: the report PDF, a photo, the signature image or
|
||||
* the tenant logo referenced by the snapshot. Anything else → not_found (no generic file oracle).
|
||||
*/
|
||||
export async function openReportFile(ctx: ServiceCtx, reportId: string, documentId: string | "pdf"): Promise<ReportFile> {
|
||||
const report = await requireVisibleReport(ctx, reportId);
|
||||
let id: string | null;
|
||||
if (documentId === "pdf") {
|
||||
id = report.pdfDocumentId;
|
||||
} else {
|
||||
const c = contentOf(report);
|
||||
const referenced = new Set<string>([
|
||||
...c.photos.map((p) => p.documentId),
|
||||
...(c.signature?.imageDocumentId ? [c.signature.imageDocumentId] : []),
|
||||
...(c.tenant.logoDocumentId ? [c.tenant.logoDocumentId] : []),
|
||||
...(report.pdfDocumentId ? [report.pdfDocumentId] : []),
|
||||
]);
|
||||
id = referenced.has(documentId) ? documentId : null;
|
||||
}
|
||||
if (!id) throw new ServiceError("not_found", "file not found");
|
||||
const doc = await ctx.db.document.findFirst({
|
||||
where: { id, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } },
|
||||
select: { storageKey: true, mimeType: true, fileName: true, checksum: true },
|
||||
});
|
||||
if (!doc || !doc.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "file not found");
|
||||
const obj = await storage.get(doc.storageKey);
|
||||
if (!obj) throw new ServiceError("not_found", "file not available");
|
||||
return { ...obj, mimeType: doc.mimeType, fileName: doc.fileName, checksum: doc.checksum };
|
||||
}
|
||||
|
||||
export function fileResponse(file: ReportFile, opts: { download?: boolean } = {}): Response {
|
||||
const inlineOk = file.mimeType === "application/pdf" || file.mimeType.startsWith("image/");
|
||||
const disposition = opts.download || !inlineOk ? "attachment" : "inline";
|
||||
const headers = new Headers({
|
||||
"Content-Type": file.mimeType,
|
||||
"Content-Disposition": `${disposition}; filename="${file.fileName.replace(/["\\\r\n]/g, "_")}"`,
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Checksum-SHA256": file.checksum,
|
||||
});
|
||||
if (file.size != null) headers.set("Content-Length", String(file.size));
|
||||
return new Response(file.stream, { headers });
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ZodError } from "zod";
|
||||
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";
|
||||
|
||||
/**
|
||||
* Thin adapter for /api/v1 report route handlers: module gate + DB-authoritative permissions → ServiceCtx,
|
||||
* uniform JSON error mapping. (No shared requireApiContext exists yet; replace at merge if the architect adds one.)
|
||||
*/
|
||||
const guard = moduleGuard("reports");
|
||||
|
||||
const STATUS: Record<ServiceError["code"], number> = { not_found: 404, forbidden: 403, invalid: 400, conflict: 409, blocked: 422 };
|
||||
|
||||
export function apiError(err: unknown): Response {
|
||||
if (err instanceof ServiceError) return Response.json({ error: err.code, details: err.details ?? null }, { status: STATUS[err.code] });
|
||||
if (err instanceof ZodError) return Response.json({ error: "invalid", details: err.issues.map((i) => ({ path: i.path.join("."), code: i.code })) }, { status: 400 });
|
||||
if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return Response.json({ error: "forbidden" }, { status: 403 });
|
||||
if (err instanceof Error && /Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(err.message)) {
|
||||
return Response.json({ error: "unauthorized" }, { status: 401 });
|
||||
}
|
||||
console.error("[api/reports]", err);
|
||||
return Response.json({ error: "internal" }, { status: 500 });
|
||||
}
|
||||
|
||||
export async function withReportsApi(permissions: Permission[], handler: (ctx: ServiceCtx) => Promise<Response>): Promise<Response> {
|
||||
try {
|
||||
const g = await guard(...permissions);
|
||||
return await handler(ctxFromGuard(g));
|
||||
} catch (err) {
|
||||
return apiError(err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readJson(req: Request): Promise<Record<string, unknown>> {
|
||||
const text = await req.text();
|
||||
if (!text.trim()) return {};
|
||||
try {
|
||||
const v = JSON.parse(text);
|
||||
return v && typeof v === "object" && !Array.isArray(v) ? v : {};
|
||||
} catch {
|
||||
throw new ServiceError("invalid", "body must be JSON");
|
||||
}
|
||||
}
|
||||
|
||||
export function reportDto(r: { id: string; type: string; status: string; version: number; workOrderId: string; lineageId: string; pdfDocumentId?: string | null }) {
|
||||
return { id: r.id, type: r.type, status: r.status, version: r.version, workOrderId: r.workOrderId, lineageId: r.lineageId, hasPdf: Boolean(r.pdfDocumentId) };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Report } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { dbDateToKey } from "@/lib/reports/dates";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
import { buildReportContent } from "./build-content";
|
||||
import { auditReport, contentOf, reportAuditView, requireVisibleReport } from "./common";
|
||||
|
||||
export const newVersionSchema = z.object({ reportId: z.string().min(1).max(64) });
|
||||
export type NewVersionInput = z.input<typeof newVersionSchema>;
|
||||
|
||||
/**
|
||||
* Änderungen nach Freigabe (Spec §17.4): a new draft version (same lineage, version+1) is created from the
|
||||
* approved snapshot. The approved version is NEVER modified here; it becomes `superseded` only when the
|
||||
* successor is approved (approveReport), so a valid approved PDF exists at all times.
|
||||
*/
|
||||
export async function createNewVersion(ctx: ServiceCtx, raw: NewVersionInput): Promise<Report> {
|
||||
assertCan(ctx, "report:approve");
|
||||
assertCan(ctx, "report:write");
|
||||
const input = newVersionSchema.parse(raw);
|
||||
const report = await requireVisibleReport(ctx, input.reportId);
|
||||
if (report.status !== "approved") throw new ServiceError("conflict", `report is ${report.status}`);
|
||||
const latest = await ctx.db.report.findFirst({ where: { lineageId: report.lineageId }, orderBy: { version: "desc" }, select: { id: true } });
|
||||
if (latest?.id !== report.id) throw new ServiceError("conflict", "a newer version already exists");
|
||||
|
||||
const approved = contentOf(report);
|
||||
const version = report.version + 1;
|
||||
const content = await buildReportContent(ctx, {
|
||||
workOrderId: report.workOrderId,
|
||||
type: report.type,
|
||||
reportDate: dbDateToKey(report.reportDate),
|
||||
reportNumber: approved.reportNumber,
|
||||
version,
|
||||
texts: approved.texts,
|
||||
reportId: null,
|
||||
previousSignature: approved.signature,
|
||||
technicianUserId: report.createdById,
|
||||
});
|
||||
const created = await ctx.db.report.create({
|
||||
data: {
|
||||
tenantId: ctx.tenantId,
|
||||
workOrderId: report.workOrderId,
|
||||
type: report.type,
|
||||
reportDate: report.reportDate,
|
||||
version,
|
||||
lineageId: report.lineageId,
|
||||
status: "draft",
|
||||
content,
|
||||
createdById: report.createdById,
|
||||
},
|
||||
});
|
||||
await auditReport(ctx, "create", created.id, null, { ...reportAuditView(created), previousVersionId: report.id });
|
||||
return created;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { createTranslator } from "next-intl";
|
||||
import { ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
// TODO(merge documents): import from "@/server/services/documents/store"
|
||||
import { readFileBytes, storeFile } from "./_stubs/documents";
|
||||
import { tenantTimeZone } from "./build-content";
|
||||
import { auditReport, contentOf, requireVisibleReport } from "./common";
|
||||
|
||||
async function loadReportMessages(locale: string): Promise<Record<string, unknown>> {
|
||||
const file = join(process.cwd(), "messages", locale, "reports.json");
|
||||
const fallback = join(process.cwd(), "messages", "de", "reports.json");
|
||||
return JSON.parse(await readFile(existsSync(file) ? file : fallback, "utf8"));
|
||||
}
|
||||
|
||||
async function dataUri(ctx: ServiceCtx, documentId: string): Promise<string | null> {
|
||||
const doc = await ctx.db.document.findFirst({ where: { id: documentId, deletedAt: null }, select: { storageKey: true, mimeType: true } });
|
||||
if (!doc || !doc.mimeType.startsWith("image/")) return null;
|
||||
const bytes = await readFileBytes(doc.storageKey).catch(() => null);
|
||||
return bytes ? `data:${doc.mimeType};base64,${Buffer.from(bytes).toString("base64")}` : null;
|
||||
}
|
||||
|
||||
/** SHA-256 of the canonical content snapshot (printed in the PDF footer). */
|
||||
export function contentChecksum(content: unknown): string {
|
||||
return createHash("sha256").update(JSON.stringify(content)).digest("hex");
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the PDF of an APPROVED report and file it as Document (category daily_report/completion_report,
|
||||
* visibility customer_report). Immutable: an existing pdfDocumentId is never replaced.
|
||||
* Runs in the worker (jobs/processors/report-pdf.ts).
|
||||
*/
|
||||
export async function generateReportPdf(ctx: ServiceCtx, reportId: string): Promise<{ documentId: string; checksum: string; skipped: boolean }> {
|
||||
const report = await requireVisibleReport(ctx, reportId);
|
||||
if (report.status !== "approved" && report.status !== "superseded") throw new ServiceError("invalid", `report is ${report.status}`);
|
||||
if (report.pdfDocumentId) return { documentId: report.pdfDocumentId, checksum: report.pdfChecksum ?? "", skipped: true };
|
||||
|
||||
const content = contentOf(report);
|
||||
const settings = await ctx.db.tenantSettings.findFirst({ select: { locale: true } });
|
||||
const locale = settings?.locale === "en" ? "en" : "de";
|
||||
const timeZone = await tenantTimeZone(ctx);
|
||||
const t = createTranslator({ locale, messages: await loadReportMessages(locale) }) as unknown as (key: string, values?: Record<string, string | number>) => string;
|
||||
|
||||
const imageIds = [...content.photos.map((p) => p.documentId), ...(content.signature?.imageDocumentId ? [content.signature.imageDocumentId] : [])];
|
||||
const images: Record<string, string> = {};
|
||||
for (const id of imageIds) {
|
||||
const uri = await dataUri(ctx, id);
|
||||
if (uri) images[id] = uri;
|
||||
}
|
||||
const fontPath = join(process.cwd(), "src", "app", "fonts", "inter-variable.ttf");
|
||||
const fontDataUri = existsSync(fontPath) ? `data:font/ttf;base64,${(await readFile(fontPath)).toString("base64")}` : null;
|
||||
|
||||
const [{ renderReportHtml }, { renderHtmlToPdf }] = await Promise.all([import("@/server/pdf/templates/report"), import("@/server/pdf/render")]);
|
||||
const { html, headerHtml, footerHtml } = renderReportHtml({
|
||||
content,
|
||||
reportId: report.id,
|
||||
status: report.status,
|
||||
approvedAt: report.approvedAt,
|
||||
t: (key, values) => t(key, values),
|
||||
locale,
|
||||
timeZone,
|
||||
images,
|
||||
logoDataUri: content.tenant.logoDocumentId ? await dataUri(ctx, content.tenant.logoDocumentId) : null,
|
||||
contentChecksum: contentChecksum(content),
|
||||
fontDataUri,
|
||||
});
|
||||
const pdf = await renderHtmlToPdf(html, { headerHtml, footerHtml });
|
||||
|
||||
const doc = await storeFile(ctx, {
|
||||
bytes: pdf,
|
||||
fileName: `${content.reportNumber}-v${report.version}.pdf`,
|
||||
declaredMime: "application/pdf",
|
||||
category: report.type === "daily" ? "daily_report" : "completion_report",
|
||||
visibility: "customer_report",
|
||||
links: { customerId: content.customer.id, siteId: content.site?.id ?? null, workOrderId: report.workOrderId },
|
||||
title: `${t(`type.${report.type}`)} ${content.reportNumber} v${report.version}`,
|
||||
});
|
||||
await ctx.db.document.update({ where: { id: doc.id }, data: { approvalStatus: "approved" } });
|
||||
|
||||
const res = await ctx.db.report.updateMany({ where: { id: report.id, pdfDocumentId: null }, data: { pdfDocumentId: doc.id, pdfChecksum: doc.checksum } });
|
||||
if (res.count !== 1) {
|
||||
// a concurrent run won — keep the first PDF, retire ours
|
||||
await ctx.db.document.update({ where: { id: doc.id }, data: { deletedAt: new Date() } });
|
||||
const current = await ctx.db.report.findFirstOrThrow({ where: { id: report.id }, select: { pdfDocumentId: true, pdfChecksum: true } });
|
||||
return { documentId: current.pdfDocumentId ?? doc.id, checksum: current.pdfChecksum ?? "", skipped: true };
|
||||
}
|
||||
await auditReport(ctx, "update", report.id, { pdfDocumentId: null }, { pdfDocumentId: doc.id, pdfChecksum: doc.checksum });
|
||||
return { documentId: doc.id, checksum: doc.checksum, skipped: false };
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { Prisma, ReportStatus, ReportType } from "@prisma/client";
|
||||
import { REPORT_STATUSES, REPORT_TYPES, type ReportContent } from "@/lib/reports/content";
|
||||
import { dateKeyToDbDate, localDateKey } from "@/lib/reports/dates";
|
||||
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||
import { can, type ServiceCtx } from "@/server/services/context";
|
||||
import { requireVisibleWorkOrder } from "@/server/services/work-orders/visibility";
|
||||
// TODO(merge L2): import from "@/server/services/work-orders/guards"
|
||||
import { getCompletionBlockers } from "./_stubs/work-orders";
|
||||
import { tenantTimeZone } from "./build-content";
|
||||
import { contentOf, reportScope, requireVisibleReport } from "./common";
|
||||
|
||||
export type ReportListFilters = { type?: string; status?: string; teamId?: string; from?: string; to?: string };
|
||||
export const LIST_LIMIT = 200;
|
||||
|
||||
const REVIEW_RANK: Record<ReportStatus, number> = { submitted: 0, team_approved: 1, rejected: 2, draft: 3, approved: 4, superseded: 5 };
|
||||
const isDate = (s?: string) => Boolean(s && /^\d{4}-\d{2}-\d{2}$/.test(s));
|
||||
|
||||
/** Backoffice list: in-review first, then most recently changed; filters type/status/team/period. */
|
||||
export async function listReports(ctx: ServiceCtx, f: ReportListFilters) {
|
||||
const and: Prisma.ReportWhereInput[] = [await reportScope(ctx)];
|
||||
if (f.type && (REPORT_TYPES as readonly string[]).includes(f.type)) and.push({ type: f.type as ReportType });
|
||||
if (f.status === "all") {
|
||||
// include superseded
|
||||
} else if (f.status && (REPORT_STATUSES as readonly string[]).includes(f.status)) {
|
||||
and.push({ status: f.status as ReportStatus });
|
||||
} else {
|
||||
and.push({ status: { not: "superseded" } });
|
||||
}
|
||||
if (f.teamId) and.push({ workOrder: { assignedTeamId: f.teamId } });
|
||||
if (isDate(f.from)) and.push({ reportDate: { gte: dateKeyToDbDate(f.from!) } });
|
||||
if (isDate(f.to)) and.push({ reportDate: { lte: dateKeyToDbDate(f.to!) } });
|
||||
|
||||
const rows = await ctx.db.report.findMany({
|
||||
where: { AND: and },
|
||||
orderBy: { updatedAt: "desc" },
|
||||
take: LIST_LIMIT,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
status: true,
|
||||
version: true,
|
||||
reportDate: true,
|
||||
updatedAt: true,
|
||||
aiDrafted: true,
|
||||
content: true,
|
||||
workOrder: { select: { id: true, number: true, title: true, team: { select: { name: true } } } },
|
||||
},
|
||||
});
|
||||
const items = rows
|
||||
.map((r) => {
|
||||
const c = r.content as Partial<ReportContent>;
|
||||
return {
|
||||
id: r.id,
|
||||
type: r.type,
|
||||
status: r.status,
|
||||
version: r.version,
|
||||
reportDate: r.reportDate,
|
||||
updatedAt: r.updatedAt,
|
||||
aiDrafted: r.aiDrafted,
|
||||
reportNumber: c.reportNumber ?? "—",
|
||||
customerName: c.customer?.name ?? "—",
|
||||
workOrder: { id: r.workOrder.id, number: r.workOrder.number, title: r.workOrder.title },
|
||||
teamName: r.workOrder.team?.name ?? null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => REVIEW_RANK[a.status] - REVIEW_RANK[b.status] || b.updatedAt.getTime() - a.updatedAt.getTime());
|
||||
return { items, truncated: rows.length === LIST_LIMIT };
|
||||
}
|
||||
|
||||
export async function teamOptions(ctx: ServiceCtx) {
|
||||
return ctx.db.team.findMany({ where: { deletedAt: null }, orderBy: { name: "asc" }, select: { id: true, name: true } });
|
||||
}
|
||||
|
||||
/** Detail incl. visible versions of the lineage and resolved actor names. */
|
||||
export async function getReportDetail(ctx: ServiceCtx, reportId: string) {
|
||||
const report = await requireVisibleReport(ctx, reportId);
|
||||
const scope = await reportScope(ctx);
|
||||
const [versions, workOrder] = await Promise.all([
|
||||
ctx.db.report.findMany({
|
||||
where: { AND: [{ lineageId: report.lineageId }, scope] },
|
||||
orderBy: { version: "desc" },
|
||||
select: { id: true, version: true, status: true, approvedAt: true, updatedAt: true, pdfDocumentId: true },
|
||||
}),
|
||||
ctx.db.workOrder.findFirstOrThrow({ where: { id: report.workOrderId }, select: { id: true, number: true, status: true } }),
|
||||
]);
|
||||
const actorIds = [report.teamApprovedById, report.approvedById].filter((x): x is string => Boolean(x));
|
||||
const actors = actorIds.length ? await ctx.db.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, name: true } }) : [];
|
||||
const nameOf = (id: string | null) => (id ? (actors.find((a) => a.id === id)?.name ?? null) : null);
|
||||
const latestVersion = versions[0]?.version ?? report.version;
|
||||
return {
|
||||
report,
|
||||
content: contentOf(report),
|
||||
versions,
|
||||
workOrder,
|
||||
teamApprovedByName: nameOf(report.teamApprovedById),
|
||||
approvedByName: nameOf(report.approvedById),
|
||||
permissions: {
|
||||
approve: can(ctx, "report:approve") && (report.status === "submitted" || report.status === "team_approved"),
|
||||
approveTeam: !can(ctx, "report:approve") && can(ctx, "report:approve_team") && report.status === "submitted",
|
||||
reject: (can(ctx, "report:approve") && (report.status === "submitted" || report.status === "team_approved")) || (can(ctx, "report:approve_team") && report.status === "submitted"),
|
||||
newVersion: can(ctx, "report:approve") && can(ctx, "report:write") && report.status === "approved" && report.version === latestVersion,
|
||||
regeneratePdf: can(ctx, "report:approve") && report.status === "approved" && !report.pdfDocumentId,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Mobile screen state for /m/orders/[id]/report and /sign. */
|
||||
export async function getMobileReportState(ctx: ServiceCtx, workOrderId: string, type: "daily" | "completion") {
|
||||
const wo = await requireVisibleWorkOrder(ctx, workOrderId, { id: true, number: true, title: true, status: true, signatureRequired: true });
|
||||
const timeZone = await tenantTimeZone(ctx);
|
||||
const today = localDateKey(new Date(), timeZone);
|
||||
const report = await ctx.db.report.findFirst({
|
||||
where: {
|
||||
AND: [
|
||||
await reportScope(ctx),
|
||||
{ workOrderId: wo.id, type, status: { not: "superseded" } },
|
||||
type === "daily" ? { reportDate: dateKeyToDbDate(today) } : {},
|
||||
],
|
||||
},
|
||||
orderBy: [{ version: "desc" }],
|
||||
});
|
||||
let blockers: CompletionBlocker[] = [];
|
||||
if (type === "completion" && (!report || report.status === "draft" || report.status === "rejected")) {
|
||||
blockers = await getCompletionBlockers(ctx, wo.id);
|
||||
}
|
||||
return { workOrder: wo, report, content: report ? contentOf(report) : null, blockers, today, timeZone };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { requireSession } from "@/server/auth";
|
||||
import { dbForTenant } from "@/server/db";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
|
||||
/**
|
||||
* ServiceCtx for READ paths in server components. Permissions come from the session (JWT) as documented in
|
||||
* AGENTS.md („Rechte im JWT wirken für Lesepfade"); mutations always go through moduleGuard (DB-authoritative).
|
||||
*/
|
||||
export async function readCtx(): Promise<ServiceCtx> {
|
||||
const session = await requireSession();
|
||||
return {
|
||||
db: dbForTenant(session.user.tenantId),
|
||||
tenantId: session.user.tenantId,
|
||||
userId: session.user.id,
|
||||
permissions: new Set(session.user.permissions ?? []),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Report } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
// TODO(merge L2): import from "@/server/services/work-orders/transition"
|
||||
import { transitionWorkOrder } from "./_stubs/work-orders";
|
||||
import { auditReport, contentOf, orderNumberOf, reportAuditView, requireVisibleReport } from "./common";
|
||||
|
||||
export const rejectReportSchema = z.object({
|
||||
reportId: z.string().min(1).max(64),
|
||||
reason: z.string().trim().min(3).max(2000),
|
||||
});
|
||||
export type RejectReportInput = z.input<typeof rejectReportSchema>;
|
||||
|
||||
/** Zurückweisen mit Pflichtgrund: report → rejected, completion order in_review → in_progress (Korrektur). */
|
||||
export async function rejectReport(ctx: ServiceCtx, raw: RejectReportInput): Promise<Report> {
|
||||
const final = can(ctx, "report:approve");
|
||||
if (!final && !can(ctx, "report:approve_team")) throw new ServiceError("forbidden", "missing permission report:approve");
|
||||
const parsed = rejectReportSchema.safeParse(raw);
|
||||
if (!parsed.success) throw new ServiceError("invalid", "reason required", { field: "reason" });
|
||||
const input = parsed.data;
|
||||
const report = await requireVisibleReport(ctx, input.reportId);
|
||||
const from = final ? (["submitted", "team_approved"] as const) : (["submitted"] as const);
|
||||
if (!(from as readonly string[]).includes(report.status)) throw new ServiceError("conflict", `report is ${report.status}`);
|
||||
|
||||
const res = await ctx.db.report.updateMany({
|
||||
where: { id: report.id, status: { in: [...from] } },
|
||||
data: { status: "rejected", rejectionReason: input.reason },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "report changed concurrently");
|
||||
const updated = await ctx.db.report.findFirstOrThrow({ where: { id: report.id } });
|
||||
// a report can be rejected more than once → one occurrence per rejection (L6 mail deduplication)
|
||||
const occurrenceId = `${report.id}:rejected:${updated.updatedAt.getTime()}`;
|
||||
|
||||
if (report.type === "completion") {
|
||||
const wo = await ctx.db.workOrder.findFirstOrThrow({ where: { id: report.workOrderId }, select: { status: true } });
|
||||
if (wo.status === "in_review") await transitionWorkOrder(ctx, { workOrderId: report.workOrderId, to: "in_progress", reason: input.reason, eventData: { occurrenceId } });
|
||||
}
|
||||
|
||||
await auditReport(ctx, "update", report.id, reportAuditView(report), reportAuditView(updated));
|
||||
await emitEvent(ctx, {
|
||||
type: "report.rejected",
|
||||
entityType: "report",
|
||||
entityId: report.id,
|
||||
data: { reportType: report.type, number: contentOf(updated).reportNumber, workOrderNumber: await orderNumberOf(ctx, report.workOrderId), reason: input.reason, occurrenceId },
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Signature } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { SIGNATURE_OUTCOMES, SIGNATURE_REASON_REQUIRED, type SignatureBlock } from "@/lib/reports/content";
|
||||
import { can, assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
// TODO(merge L2): import from "@/server/services/work-orders/transition"
|
||||
import { transitionWorkOrder } from "./_stubs/work-orders";
|
||||
import { auditReport, contentOf, requireVisibleReport } from "./common";
|
||||
|
||||
const optText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.nullish()
|
||||
.transform((v) => (v ? v : null));
|
||||
|
||||
export const captureSignatureSchema = z.object({
|
||||
reportId: z.string().min(1).max(64),
|
||||
outcome: z.enum(SIGNATURE_OUTCOMES),
|
||||
signerName: optText(200),
|
||||
signerRole: optText(200),
|
||||
imageDocumentId: optText(64),
|
||||
reason: optText(2000),
|
||||
confirmationText: z.string().trim().min(1).max(2000),
|
||||
clientId: z.string().min(1).max(64).optional(),
|
||||
});
|
||||
export type CaptureSignatureInput = z.input<typeof captureSignatureSchema>;
|
||||
|
||||
/** Outcomes that count as "signature documented" for the order flow (→ in_review). */
|
||||
export const SIGNATURE_DOCUMENTED = ["signed", "not_required", "customer_absent", "refused"] as const;
|
||||
|
||||
/**
|
||||
* Digitale Kundenunterschrift bzw. begründete Ausnahme (Spec §18).
|
||||
* - signed: signer name + PNG image document required
|
||||
* - customer_absent / refused / later: reason required
|
||||
* - not_required: only if the work order does not require a signature or caller has report:approve
|
||||
* A signed signature is never overwritten; other outcomes may be replaced (e.g. "later" → "signed").
|
||||
*/
|
||||
export async function captureSignature(ctx: ServiceCtx, raw: CaptureSignatureInput): Promise<Signature> {
|
||||
assertCan(ctx, "report:write");
|
||||
const input = captureSignatureSchema.parse(raw);
|
||||
if (input.clientId) {
|
||||
const dup = await ctx.db.signature.findFirst({ where: { clientId: input.clientId } });
|
||||
if (dup) return dup;
|
||||
}
|
||||
const report = await requireVisibleReport(ctx, input.reportId);
|
||||
if (report.status === "approved" || report.status === "superseded") throw new ServiceError("conflict", `report is ${report.status}`);
|
||||
const wo = await ctx.db.workOrder.findFirstOrThrow({ where: { id: report.workOrderId }, select: { id: true, status: true, signatureRequired: true } });
|
||||
|
||||
if (input.outcome === "signed") {
|
||||
if (!input.signerName) throw new ServiceError("invalid", "signer name required", { field: "signerName" });
|
||||
if (!input.imageDocumentId) throw new ServiceError("invalid", "signature image required", { field: "image" });
|
||||
const doc = await ctx.db.document.findFirst({
|
||||
where: { id: input.imageDocumentId, category: "signature", mimeType: "image/png", workOrderId: wo.id, deletedAt: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!doc) throw new ServiceError("invalid", "signature image not found", { field: "image" });
|
||||
} else if (input.imageDocumentId) {
|
||||
throw new ServiceError("invalid", "image only allowed for signed outcome", { field: "image" });
|
||||
}
|
||||
if (SIGNATURE_REASON_REQUIRED.includes(input.outcome) && !input.reason) {
|
||||
throw new ServiceError("invalid", "reason required", { field: "reason" });
|
||||
}
|
||||
if (input.outcome === "not_required" && wo.signatureRequired && !can(ctx, "report:approve")) {
|
||||
throw new ServiceError("forbidden", "signature is required for this work order");
|
||||
}
|
||||
|
||||
const existing = await ctx.db.signature.findFirst({ where: { reportId: report.id } });
|
||||
if (existing?.outcome === "signed") throw new ServiceError("conflict", "signature already captured");
|
||||
|
||||
const data = {
|
||||
outcome: input.outcome,
|
||||
signerName: input.signerName,
|
||||
signerRole: input.signerRole,
|
||||
imageDocumentId: input.outcome === "signed" ? input.imageDocumentId : null,
|
||||
confirmationText: input.confirmationText,
|
||||
reason: input.reason,
|
||||
signedAt: new Date(),
|
||||
capturedById: ctx.userId,
|
||||
};
|
||||
const signature = existing
|
||||
? await ctx.db.signature.update({ where: { id: existing.id }, data })
|
||||
: await ctx.db.signature.create({ data: { ...data, tenantId: ctx.tenantId, reportId: report.id, clientId: input.clientId ?? null } });
|
||||
|
||||
const me = await ctx.db.user.findFirst({ where: { id: ctx.userId }, select: { name: true } });
|
||||
const block: SignatureBlock = {
|
||||
outcome: signature.outcome,
|
||||
signerName: signature.signerName,
|
||||
signerRole: signature.signerRole,
|
||||
signedAt: signature.signedAt.toISOString(),
|
||||
reason: signature.reason,
|
||||
confirmationText: signature.confirmationText,
|
||||
imageDocumentId: signature.imageDocumentId,
|
||||
capturedByName: me?.name ?? null,
|
||||
};
|
||||
const content = contentOf(report);
|
||||
await ctx.db.report.update({ where: { id: report.id }, data: { content: { ...content, signature: block } } });
|
||||
|
||||
const view = (s: Pick<Signature, "outcome" | "signerName" | "signerRole" | "reason" | "imageDocumentId"> | null) =>
|
||||
s && { reportId: report.id, outcome: s.outcome, signerName: s.signerName, signerRole: s.signerRole, reason: s.reason, imageDocumentId: s.imageDocumentId };
|
||||
await auditReport(ctx, existing ? "update" : "create", signature.id, view(existing), view(signature), "signature");
|
||||
|
||||
// Signature captured after submit: order waiting for it can move on to review.
|
||||
if (
|
||||
report.type === "completion" &&
|
||||
wo.status === "signature_pending" &&
|
||||
(SIGNATURE_DOCUMENTED as readonly string[]).includes(signature.outcome) &&
|
||||
can(ctx, "field:execute")
|
||||
) {
|
||||
await transitionWorkOrder(ctx, { workOrderId: wo.id, to: "in_review", eventData: { occurrenceId: `${report.id}:signature:${signature.id}` } });
|
||||
}
|
||||
return signature;
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { Report } from "@prisma/client";
|
||||
import { z } from "zod";
|
||||
import { missingRequiredTexts, type SignatureBlock } from "@/lib/reports/content";
|
||||
import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { emitEvent } from "@/server/events";
|
||||
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
|
||||
// TODO(merge L2): import from "@/server/services/work-orders/transition" and ".../guards"
|
||||
import { getCompletionBlockers, transitionWorkOrder } from "./_stubs/work-orders";
|
||||
import { auditReport, assertEditable, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common";
|
||||
|
||||
export const submitReportSchema = z.object({
|
||||
reportId: z.string().min(1).max(64),
|
||||
/** WorkOrder.version seen by the device (offline sync conflict detection) */
|
||||
expectedWorkOrderVersion: z.number().int().positive().optional(),
|
||||
});
|
||||
export type SubmitReportInput = z.input<typeof submitReportSchema>;
|
||||
|
||||
function signaturePending(signatureRequired: boolean, sig: SignatureBlock | null): boolean {
|
||||
return signatureRequired && (!sig || sig.outcome === "later");
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the work order after a completion report was submitted (ARCHITEKTUR §3):
|
||||
* … → in_progress → technically_completed → signature_pending | in_review.
|
||||
* Statuses beyond review (new report versions) are left untouched.
|
||||
*/
|
||||
async function advanceOrder(ctx: ServiceCtx, workOrderId: string, status: WorkOrderStatus, signatureRequired: boolean, sig: SignatureBlock | null, occurrenceId: string) {
|
||||
const pending = signaturePending(signatureRequired, sig);
|
||||
const eventData = { occurrenceId };
|
||||
let s = status;
|
||||
if (s === "paused" || s === "waiting_material" || s === "daily_report_created") {
|
||||
await transitionWorkOrder(ctx, { workOrderId, to: "in_progress", eventData });
|
||||
s = "in_progress";
|
||||
}
|
||||
if (s === "in_progress") {
|
||||
await transitionWorkOrder(ctx, { workOrderId, to: "technically_completed", eventData });
|
||||
s = "technically_completed";
|
||||
}
|
||||
if (s === "technically_completed") {
|
||||
await transitionWorkOrder(ctx, { workOrderId, to: pending ? "signature_pending" : "in_review", eventData });
|
||||
} else if (s === "signature_pending" && !pending) {
|
||||
await transitionWorkOrder(ctx, { workOrderId, to: "in_review", eventData });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Review stage the submitted report waits for (L6 recipient rules): "team" if the order has a team lead
|
||||
* (explicit or leader of the assigned team), otherwise "backoffice".
|
||||
*/
|
||||
export async function approvalStageFor(ctx: ServiceCtx, workOrderId: string): Promise<"team" | "backoffice"> {
|
||||
const wo = await ctx.db.workOrder.findFirst({
|
||||
where: { id: workOrderId },
|
||||
select: { teamLeadUserId: true, team: { select: { leaderUserId: true } } },
|
||||
});
|
||||
return wo?.teamLeadUserId || wo?.team?.leaderUserId ? "team" : "backoffice";
|
||||
}
|
||||
|
||||
/** Technician submits a draft/rejected report for review ("Zur Prüfung"). */
|
||||
export async function submitReport(ctx: ServiceCtx, raw: SubmitReportInput): Promise<Report> {
|
||||
assertCan(ctx, "report:write");
|
||||
const input = submitReportSchema.parse(raw);
|
||||
const report = await requireVisibleReport(ctx, input.reportId);
|
||||
assertEditable(report);
|
||||
|
||||
const wo = await ctx.db.workOrder.findFirstOrThrow({
|
||||
where: { id: report.workOrderId },
|
||||
select: { id: true, status: true, version: true, signatureRequired: true, number: true },
|
||||
});
|
||||
if (input.expectedWorkOrderVersion !== undefined && input.expectedWorkOrderVersion !== wo.version) {
|
||||
throw new ServiceError("conflict", "work order version changed");
|
||||
}
|
||||
|
||||
const content = await refreshContent(ctx, report);
|
||||
const blockers: CompletionBlocker[] = missingRequiredTexts(content).map((field) => ({ kind: "missing_field", field }));
|
||||
if (report.type === "completion" && report.version === 1) blockers.push(...(await getCompletionBlockers(ctx, wo.id)));
|
||||
if (blockers.length) throw new ServiceError("blocked", "report incomplete", blockers);
|
||||
|
||||
const submittedAt = new Date();
|
||||
const res = await ctx.db.report.updateMany({
|
||||
where: { id: report.id, status: { in: ["draft", "rejected"] } },
|
||||
data: { status: "submitted", submittedAt, content },
|
||||
});
|
||||
if (res.count !== 1) throw new ServiceError("conflict", "report changed concurrently");
|
||||
const updated = await ctx.db.report.findFirstOrThrow({ where: { id: report.id } });
|
||||
// a report can be submitted again after rejection → one occurrence per submission
|
||||
const occurrenceId = `${report.id}:${submittedAt.getTime()}`;
|
||||
|
||||
if (report.type === "completion" && report.version === 1) {
|
||||
await advanceOrder(ctx, wo.id, wo.status as WorkOrderStatus, wo.signatureRequired, content.signature, occurrenceId);
|
||||
const sig = content.signature;
|
||||
if (wo.signatureRequired && sig && (sig.outcome === "refused" || sig.outcome === "customer_absent")) {
|
||||
await emitEvent(ctx, { type: "work_order.signature_missing", entityType: "work_order", entityId: wo.id, data: { number: wo.number, outcome: sig.outcome, occurrenceId } });
|
||||
}
|
||||
}
|
||||
|
||||
await auditReport(ctx, "update", report.id, reportAuditView(report), reportAuditView(updated));
|
||||
await emitEvent(ctx, {
|
||||
type: "report.submitted",
|
||||
entityType: "report",
|
||||
entityId: report.id,
|
||||
data: {
|
||||
reportType: report.type,
|
||||
number: content.reportNumber,
|
||||
workOrderNumber: await orderNumberOf(ctx, wo.id),
|
||||
version: report.version,
|
||||
approvalStage: await approvalStageFor(ctx, wo.id),
|
||||
occurrenceId,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
Reference in New Issue
Block a user