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:
@@ -0,0 +1,7 @@
|
||||
import { ReportScreen } from "@/components/reports/mobile/report-screen";
|
||||
|
||||
/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. */
|
||||
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ type?: string }> }) {
|
||||
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||
return <ReportScreen workOrderId={id} type={sp.type === "daily" ? "daily" : "completion"} />;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SignScreen } from "@/components/reports/mobile/sign-screen";
|
||||
|
||||
/** Thin wrapper (lane L5) — the screen lives in src/components/reports/mobile. */
|
||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
return <SignScreen workOrderId={id} />;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { ArrowLeft, FileText, Sparkles } from "lucide-react";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { PageHead, Pill } from "@/components/mockup-ui";
|
||||
import { RejectForm } from "@/components/reports/reject-form";
|
||||
import { ReportView } from "@/components/reports/report-view";
|
||||
import { ReviewActions } from "@/components/reports/review-actions";
|
||||
import { ReportStatusBadge } from "@/components/reports/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { tenantTimeZone } from "@/server/services/reports/build-content";
|
||||
import { getReportDetail } from "@/server/services/reports/queries";
|
||||
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||
|
||||
/** /reports/[id] — structured view, PDF, versions, approve/reject/new version (reject as popup ?reject=1). */
|
||||
export default async function ReportDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ reject?: string }> }) {
|
||||
const [{ id }, sp, t, format, ctx] = await Promise.all([params, searchParams, getTranslations("reports"), getFormatter(), readCtx()]);
|
||||
let detail: Awaited<ReturnType<typeof getReportDetail>>;
|
||||
try {
|
||||
detail = await getReportDetail(ctx, id);
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError && err.code === "not_found") notFound();
|
||||
throw err;
|
||||
}
|
||||
const timeZone = await tenantTimeZone(ctx);
|
||||
const { report, content, versions, workOrder, permissions } = detail;
|
||||
const dt = (d: Date | null) => (d ? format.dateTime(d, { dateStyle: "medium", timeStyle: "short", timeZone }) : null);
|
||||
const base = `/reports/${id}`;
|
||||
|
||||
const meta: Array<[string, string | null]> = [
|
||||
[t("field.workOrder"), `${workOrder.number}`],
|
||||
[t("field.reportDate"), format.dateTime(report.reportDate, { dateStyle: "medium", timeZone: "UTC" })],
|
||||
[t("detail.submittedAt"), dt(report.submittedAt)],
|
||||
[t("detail.teamApprovedAt"), report.teamApprovedAt ? `${dt(report.teamApprovedAt)}${detail.teamApprovedByName ? ` · ${detail.teamApprovedByName}` : ""}` : null],
|
||||
[t("detail.approvedAt"), report.approvedAt ? `${dt(report.approvedAt)}${detail.approvedByName ? ` · ${detail.approvedByName}` : ""}` : null],
|
||||
[t("detail.generatedAt"), dt(new Date(content.generatedAt))],
|
||||
[t("detail.checksum"), report.pdfChecksum],
|
||||
];
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<Link href="/reports" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
|
||||
</Link>
|
||||
<PageHead
|
||||
crumb={`${t("title")} · ${content.customer.name}`}
|
||||
title={`${t(`type.${report.type}`)} ${content.reportNumber}`}
|
||||
sub={`${workOrder.number} · ${content.workOrder.title}`}
|
||||
actions={
|
||||
report.pdfDocumentId ? (
|
||||
<Button className="h-11 px-4" nativeButton={false} render={<a href={`/api/v1/reports/${id}/pdf`} target="_blank" rel="noopener noreferrer" />}>
|
||||
<FileText aria-hidden />
|
||||
{t("detail.pdf")}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2">
|
||||
<ReportStatusBadge status={report.status} label={t(`status.${report.status}`)} />
|
||||
<Pill tone="mut">
|
||||
{t("field.version")} {report.version}
|
||||
</Pill>
|
||||
{report.aiDrafted && (
|
||||
<span title={t("detail.aiDraftedHint")}>
|
||||
<Pill tone="orange">
|
||||
<Sparkles className="size-3.5" aria-hidden />
|
||||
{t("detail.aiDrafted")}
|
||||
</Pill>
|
||||
</span>
|
||||
)}
|
||||
{report.status === "approved" && !report.pdfDocumentId && <span className="text-[12.5px] text-muted-foreground">{t("detail.pdfPending")}</span>}
|
||||
</div>
|
||||
|
||||
{report.status === "rejected" && report.rejectionReason && (
|
||||
<p role="alert" className="mb-4 rounded-xl border border-[var(--risk)] bg-card p-3 text-[13.5px]">
|
||||
<span className="font-semibold text-[var(--risk)]">{t("detail.rejectionReason")}:</span> {report.rejectionReason}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="mb-4">
|
||||
<ReviewActions reportId={id} can={permissions} rejectHref={`${base}?reject=1`} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,1fr)_280px]">
|
||||
<ReportView content={content} reportId={id} timeZone={timeZone} />
|
||||
<aside className="space-y-4">
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<dl className="space-y-2 text-[13px]">
|
||||
{meta
|
||||
.filter(([, v]) => v)
|
||||
.map(([k, v]) => (
|
||||
<div key={k}>
|
||||
<dt className="text-[11.5px] font-semibold text-muted-foreground">{k}</dt>
|
||||
<dd className="break-all">{v}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</section>
|
||||
<section className="shadow-card rounded-xl border bg-card p-4">
|
||||
<h2 className="font-heading text-[15px] font-semibold">{t("section.versions")}</h2>
|
||||
<ol className="mt-2 space-y-1.5">
|
||||
{versions.map((v) => (
|
||||
<li key={v.id} className="flex flex-wrap items-center justify-between gap-2 text-[13px]">
|
||||
{v.id === id ? (
|
||||
<span className="font-semibold">
|
||||
v{v.version} · {t("detail.current")}
|
||||
</span>
|
||||
) : (
|
||||
<Link href={`/reports/${v.id}`} className="font-semibold text-[var(--primary)] hover:underline">
|
||||
v{v.version}
|
||||
</Link>
|
||||
)}
|
||||
<ReportStatusBadge status={v.status} label={t(`status.${v.status}`)} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{sp.reject && permissions.reject && (
|
||||
<Modal title={t("actions.rejectTitle")} sub={t("actions.rejectSub")} closeHref={base} closeLabel={t("actions.close")}>
|
||||
<RejectForm reportId={id} closeHref={base} />
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,123 @@
|
||||
import { ModulePlaceholder } from "@/components/module-placeholder";
|
||||
import Link from "next/link";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { Sparkles } from "lucide-react";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { ReportStatusBadge } from "@/components/reports/status-badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { REPORT_STATUSES, REPORT_TYPES } from "@/lib/reports/content";
|
||||
import { listReports, teamOptions, type ReportListFilters } from "@/server/services/reports/queries";
|
||||
import { readCtx } from "@/server/services/reports/read-ctx";
|
||||
|
||||
export default function Page() {
|
||||
return <ModulePlaceholder moduleKey="reports" />;
|
||||
/** /reports — in review first; filters type/status/team/period (GET form, shareable URL). */
|
||||
export default async function ReportsPage({ searchParams }: { searchParams: Promise<ReportListFilters> }) {
|
||||
const [t, format, ctx, sp] = await Promise.all([getTranslations("reports"), getFormatter(), readCtx(), searchParams]);
|
||||
const [{ items, truncated }, teams] = await Promise.all([listReports(ctx, sp), teamOptions(ctx)]);
|
||||
const select = "h-11 w-full rounded-lg border border-input bg-card px-2.5 text-[14px]";
|
||||
const label = "text-[12px] font-semibold text-muted-foreground";
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||
|
||||
<form method="get" className="shadow-card grid gap-3 rounded-xl border bg-card p-4 sm:grid-cols-2 lg:grid-cols-6 lg:items-end">
|
||||
<label className="grid gap-1">
|
||||
<span className={label}>{t("field.type")}</span>
|
||||
<select name="type" defaultValue={sp.type ?? ""} className={select}>
|
||||
<option value="">{t("list.all")}</option>
|
||||
{REPORT_TYPES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{t(`type.${v}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1">
|
||||
<span className={label}>{t("field.status")}</span>
|
||||
<select name="status" defaultValue={sp.status ?? ""} className={select}>
|
||||
<option value="">{t("list.all")}</option>
|
||||
{REPORT_STATUSES.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{t(`status.${v}`)}
|
||||
</option>
|
||||
))}
|
||||
<option value="all">{t("list.allWithSuperseded")}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1">
|
||||
<span className={label}>{t("field.team")}</span>
|
||||
<select name="teamId" defaultValue={sp.teamId ?? ""} className={select}>
|
||||
<option value="">{t("list.all")}</option>
|
||||
{teams.map((tm) => (
|
||||
<option key={tm.id} value={tm.id}>
|
||||
{tm.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1">
|
||||
<span className={label}>{t("list.from")}</span>
|
||||
<input type="date" name="from" defaultValue={sp.from ?? ""} className={select} />
|
||||
</label>
|
||||
<label className="grid gap-1">
|
||||
<span className={label}>{t("list.to")}</span>
|
||||
<input type="date" name="to" defaultValue={sp.to ?? ""} className={select} />
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" className="h-11 flex-1 px-4">
|
||||
{t("list.filter")}
|
||||
</Button>
|
||||
<Button variant="outline" className="h-11 px-4" nativeButton={false} render={<Link href="/reports" />}>
|
||||
{t("list.reset")}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-[12.5px] text-muted-foreground" aria-live="polite">
|
||||
{t("list.count", { count: items.length })}
|
||||
{truncated ? ` · ${t("list.truncated", { count: items.length })}` : ""}
|
||||
</p>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<p className="shadow-card mt-2 rounded-xl border bg-card p-5 text-[13.5px] text-muted-foreground">{t("list.empty")}</p>
|
||||
) : (
|
||||
<div className="shadow-card mt-2 overflow-x-auto rounded-xl border bg-card">
|
||||
<table className="w-full min-w-[720px] text-[13px]">
|
||||
<thead>
|
||||
<tr className="border-b text-left text-[12px] text-muted-foreground">
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.reportNumber")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.workOrder")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.type")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.reportDate")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.team")}</th>
|
||||
<th className="px-4 py-2.5 font-semibold">{t("field.status")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((r) => (
|
||||
<tr key={r.id} className="border-b last:border-0 hover:bg-muted/50">
|
||||
<td className="px-4 py-2.5">
|
||||
<Link href={`/reports/${r.id}`} className="font-semibold text-[var(--primary)] hover:underline">
|
||||
{r.reportNumber}
|
||||
</Link>
|
||||
<span className="text-muted-foreground"> · v{r.version}</span>
|
||||
{r.aiDrafted ? <Sparkles className="ml-1 inline size-3.5 text-[var(--ui-accent)]" aria-label={t("detail.aiDrafted")} /> : null}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span className="font-semibold">{r.workOrder.number}</span> · {r.workOrder.title}
|
||||
<span className="block text-[12px] text-muted-foreground">{r.customerName}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">{t(`type.${r.type}`)}</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap">{format.dateTime(r.reportDate, { dateStyle: "medium", timeZone: "UTC" })}</td>
|
||||
<td className="px-4 py-2.5">{r.teamName ?? "—"}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<ReportStatusBadge status={r.status} label={t(`status.${r.status}`)} />
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user