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
@@ -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() {
return <ModulePlaceholder moduleKey="reports" />;
/** /reports — in review first; filters type/status/team/period (GET form, shareable URL). */
export default async function ReportsPage({ searchParams }: { searchParams: Promise<ReportListFilters> }) {
const [t, format, ctx, sp] = await Promise.all([getTranslations("reports"), getFormatter(), readCtx(), searchParams]);
const [{ items, truncated }, teams] = await Promise.all([listReports(ctx, sp), teamOptions(ctx)]);
const select = "h-11 w-full rounded-lg border border-input bg-card px-2.5 text-[14px]";
const label = "text-[12px] font-semibold text-muted-foreground";
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
<form method="get" className="shadow-card grid gap-3 rounded-xl border bg-card p-4 sm:grid-cols-2 lg:grid-cols-6 lg:items-end">
<label className="grid gap-1">
<span className={label}>{t("field.type")}</span>
<select name="type" defaultValue={sp.type ?? ""} className={select}>
<option value="">{t("list.all")}</option>
{REPORT_TYPES.map((v) => (
<option key={v} value={v}>
{t(`type.${v}`)}
</option>
))}
</select>
</label>
<label className="grid gap-1">
<span className={label}>{t("field.status")}</span>
<select name="status" defaultValue={sp.status ?? ""} className={select}>
<option value="">{t("list.all")}</option>
{REPORT_STATUSES.map((v) => (
<option key={v} value={v}>
{t(`status.${v}`)}
</option>
))}
<option value="all">{t("list.allWithSuperseded")}</option>
</select>
</label>
<label className="grid gap-1">
<span className={label}>{t("field.team")}</span>
<select name="teamId" defaultValue={sp.teamId ?? ""} className={select}>
<option value="">{t("list.all")}</option>
{teams.map((tm) => (
<option key={tm.id} value={tm.id}>
{tm.name}
</option>
))}
</select>
</label>
<label className="grid gap-1">
<span className={label}>{t("list.from")}</span>
<input type="date" name="from" defaultValue={sp.from ?? ""} className={select} />
</label>
<label className="grid gap-1">
<span className={label}>{t("list.to")}</span>
<input type="date" name="to" defaultValue={sp.to ?? ""} className={select} />
</label>
<div className="flex gap-2">
<Button type="submit" className="h-11 flex-1 px-4">
{t("list.filter")}
</Button>
<Button variant="outline" className="h-11 px-4" nativeButton={false} render={<Link href="/reports" />}>
{t("list.reset")}
</Button>
</div>
</form>
<p className="mt-4 text-[12.5px] text-muted-foreground" aria-live="polite">
{t("list.count", { count: items.length })}
{truncated ? ` · ${t("list.truncated", { count: items.length })}` : ""}
</p>
{items.length === 0 ? (
<p className="shadow-card mt-2 rounded-xl border bg-card p-5 text-[13.5px] text-muted-foreground">{t("list.empty")}</p>
) : (
<div className="shadow-card mt-2 overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[720px] text-[13px]">
<thead>
<tr className="border-b text-left text-[12px] text-muted-foreground">
<th className="px-4 py-2.5 font-semibold">{t("field.reportNumber")}</th>
<th className="px-4 py-2.5 font-semibold">{t("field.workOrder")}</th>
<th className="px-4 py-2.5 font-semibold">{t("field.type")}</th>
<th className="px-4 py-2.5 font-semibold">{t("field.reportDate")}</th>
<th className="px-4 py-2.5 font-semibold">{t("field.team")}</th>
<th className="px-4 py-2.5 font-semibold">{t("field.status")}</th>
</tr>
</thead>
<tbody>
{items.map((r) => (
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/50">
<td className="px-4 py-2.5">
<Link href={`/reports/${r.id}`} className="font-semibold text-[var(--primary)] hover:underline">
{r.reportNumber}
</Link>
<span className="text-muted-foreground"> · v{r.version}</span>
{r.aiDrafted ? <Sparkles className="ml-1 inline size-3.5 text-[var(--ui-accent)]" aria-label={t("detail.aiDrafted")} /> : null}
</td>
<td className="px-4 py-2.5">
<span className="font-semibold">{r.workOrder.number}</span> · {r.workOrder.title}
<span className="block text-[12px] text-muted-foreground">{r.customerName}</span>
</td>
<td className="px-4 py-2.5">{t(`type.${r.type}`)}</td>
<td className="px-4 py-2.5 whitespace-nowrap">{format.dateTime(r.reportDate, { dateStyle: "medium", timeZone: "UTC" })}</td>
<td className="px-4 py-2.5">{r.teamName ?? "—"}</td>
<td className="px-4 py-2.5">
<ReportStatusBadge status={r.status} label={t(`status.${r.status}`)} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
);
}