L5 Berichte & Unterschrift: Backoffice- und Mobil-Oberflächen

/reports mit Filtern (zur Prüfung zuerst), /reports/[id] mit Aktionen, Versionen und PDF-Link;
mobile Komponenten für Bericht, Prüfung und Unterschrift inkl. Signature-Pad; Texte de/en.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:22:40 +02:00
co-authored by Claude Opus 5
parent 8f53df6208
commit 94092ade3f
20 changed files with 1805 additions and 3 deletions
+236
View File
@@ -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"
}
}
+236
View File
@@ -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"
}
}
@@ -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} />;
}
+131
View File
@@ -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>
);
}
+121 -3
View File
@@ -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() { /** /reports — in review first; filters type/status/team/period (GET form, shareable URL). */
return <ModulePlaceholder moduleKey="reports" />; 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>
);
} }
+35
View File
@@ -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>
);
}
+39
View File
@@ -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>
);
}
+136
View File
@@ -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>
);
}
+53
View File
@@ -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>
);
}
+229
View File
@@ -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>
);
}
+81
View File
@@ -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>
);
}
+144
View File
@@ -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>
);
}
+23
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}