diff --git a/messages/de/reports.json b/messages/de/reports.json new file mode 100644 index 0000000..6007564 --- /dev/null +++ b/messages/de/reports.json @@ -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" + } +} diff --git a/messages/en/reports.json b/messages/en/reports.json new file mode 100644 index 0000000..df3a928 --- /dev/null +++ b/messages/en/reports.json @@ -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" + } +} diff --git a/src/app/(app)/m/(field)/orders/[id]/report/page.tsx b/src/app/(app)/m/(field)/orders/[id]/report/page.tsx new file mode 100644 index 0000000..9211595 --- /dev/null +++ b/src/app/(app)/m/(field)/orders/[id]/report/page.tsx @@ -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 ; +} diff --git a/src/app/(app)/m/(field)/orders/[id]/sign/page.tsx b/src/app/(app)/m/(field)/orders/[id]/sign/page.tsx new file mode 100644 index 0000000..7c5bc4e --- /dev/null +++ b/src/app/(app)/m/(field)/orders/[id]/sign/page.tsx @@ -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 ; +} diff --git a/src/app/(app)/reports/[id]/page.tsx b/src/app/(app)/reports/[id]/page.tsx new file mode 100644 index 0000000..69f65f1 --- /dev/null +++ b/src/app/(app)/reports/[id]/page.tsx @@ -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>; + 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 ( +
+ + {t("detail.back")} + + }> + + {t("detail.pdf")} + + ) : undefined + } + /> + +
+ + + {t("field.version")} {report.version} + + {report.aiDrafted && ( + + + + {t("detail.aiDrafted")} + + + )} + {report.status === "approved" && !report.pdfDocumentId && {t("detail.pdfPending")}} +
+ + {report.status === "rejected" && report.rejectionReason && ( +

+ {t("detail.rejectionReason")}: {report.rejectionReason} +

+ )} + +
+ +
+ +
+ + +
+ + {sp.reject && permissions.reject && ( + + + + )} +
+ ); +} diff --git a/src/app/(app)/reports/page.tsx b/src/app/(app)/reports/page.tsx index 69f2e28..3afccd0 100644 --- a/src/app/(app)/reports/page.tsx +++ b/src/app/(app)/reports/page.tsx @@ -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 ; +/** /reports — in review first; filters type/status/team/period (GET form, shareable URL). */ +export default async function ReportsPage({ searchParams }: { searchParams: Promise }) { + 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 ( +
+ + +
+ + + + + +
+ + +
+
+ +

+ {t("list.count", { count: items.length })} + {truncated ? ` · ${t("list.truncated", { count: items.length })}` : ""} +

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

{t("list.empty")}

+ ) : ( +
+ + + + + + + + + + + + + {items.map((r) => ( + + + + + + + + + ))} + +
{t("field.reportNumber")}{t("field.workOrder")}{t("field.type")}{t("field.reportDate")}{t("field.team")}{t("field.status")}
+ + {r.reportNumber} + + · v{r.version} + {r.aiDrafted ? : null} + + {r.workOrder.number} · {r.workOrder.title} + {r.customerName} + {t(`type.${r.type}`)}{format.dateTime(r.reportDate, { dateStyle: "medium", timeZone: "UTC" })}{r.teamName ?? "—"} + +
+
+ )} +
+ ); } diff --git a/src/components/reports/action-message.tsx b/src/components/reports/action-message.tsx new file mode 100644 index 0000000..74ab74b --- /dev/null +++ b/src/components/reports/action-message.tsx @@ -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 = { + 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 ( +

+ + {okText} +

+ ); + } + if (state.status !== "error") return null; + if (state.blockers?.length) return ; + const key = state.field && FIELD_MESSAGES[state.field] ? FIELD_MESSAGES[state.field] : state.code === "forbidden" && state.field === undefined ? "errors.forbidden" : `errors.${state.code}`; + return ( +

+ + {t(key)} +

+ ); +} diff --git a/src/components/reports/blocker-list.tsx b/src/components/reports/blocker-list.tsx new file mode 100644 index 0000000..cf6c1d9 --- /dev/null +++ b/src/components/reports/blocker-list.tsx @@ -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 ( +
+

+ + {title ?? t("section.blockers")} +

+
    + {blockers.map((b, i) => ( +
  • + • + {label(b)} +
  • + ))} +
+
+ ); +} diff --git a/src/components/reports/mobile/create-report-form.tsx b/src/components/reports/mobile/create-report-form.tsx new file mode 100644 index 0000000..7dc6083 --- /dev/null +++ b/src/components/reports/mobile/create-report-form.tsx @@ -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 ( +
+ + + + + + ); +} diff --git a/src/components/reports/mobile/report-editor.tsx b/src/components/reports/mobile/report-editor.tsx new file mode 100644 index 0000000..727ad9b --- /dev/null +++ b/src/components/reports/mobile/report-editor.tsx @@ -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(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 ( +
+ +

{t("mobile.edit")}

+ {REPORT_TEXT_FIELDS.map((f) => ( +
+ +