L8 Notdienst: Backoffice-Nachbearbeitung /work-orders/emergency-review
Liste und Detail mit fünf Prüfschritten (Kunde, Objekt, Auftrag, Bericht, Abrechnung), Navigationseintrag, Dashboard-Kachel verlinkt auf die Prüfung. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"workOrders": "Aufträge",
|
"workOrders": "Aufträge",
|
||||||
|
"emergencyReview": "Notdienst-Prüfung",
|
||||||
"imports": "Import",
|
"imports": "Import",
|
||||||
"customers": "Kunden",
|
"customers": "Kunden",
|
||||||
"sites": "Objekte",
|
"sites": "Objekte",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"dashboard": "Dashboard",
|
"dashboard": "Dashboard",
|
||||||
"workOrders": "Work orders",
|
"workOrders": "Work orders",
|
||||||
|
"emergencyReview": "Emergency review",
|
||||||
"imports": "Import",
|
"imports": "Import",
|
||||||
"customers": "Customers",
|
"customers": "Customers",
|
||||||
"sites": "Sites",
|
"sites": "Sites",
|
||||||
|
|||||||
@@ -144,7 +144,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
|
|||||||
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||||
{TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => {
|
{TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => {
|
||||||
const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
|
const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
|
||||||
const href = key === "sync_conflicts" ? "/work-orders/conflicts" : `/work-orders${toQuery({ ...filter, preset: key })}`;
|
const href = key === "sync_conflicts" ? "/work-orders/conflicts" : key === "emergency_new" && can("emergency:review") ? "/work-orders/emergency-review" : `/work-orders${toQuery({ ...filter, preset: key })}`;
|
||||||
return (
|
return (
|
||||||
<li key={key}>
|
<li key={key}>
|
||||||
<Link
|
<Link
|
||||||
|
|||||||
@@ -0,0 +1,342 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { notFound, redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ArrowLeft, ExternalLink } from "lucide-react";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { pageContext } from "@/components/work-orders/page-context";
|
||||||
|
import { Check, Dl, Empty, Field, inputCls, Section, StatusBadge } from "@/components/work-orders/ui";
|
||||||
|
import { ReviewForm } from "@/components/emergency/review-form";
|
||||||
|
import { ReviewProgressBar, StepState } from "@/components/emergency/review-progress";
|
||||||
|
import { REVIEW_STEPS } from "@/lib/emergency/schemas";
|
||||||
|
import { BILLING_TYPES } from "@/lib/work-orders/schemas";
|
||||||
|
import { formatDateTime } from "@/lib/work-orders/time";
|
||||||
|
import {
|
||||||
|
assignCustomerAction,
|
||||||
|
assignSiteAction,
|
||||||
|
completeOrderAction,
|
||||||
|
confirmCustomerAction,
|
||||||
|
correctSiteAction,
|
||||||
|
mergeCustomerAction,
|
||||||
|
releaseBillingAction,
|
||||||
|
} from "@/server/actions/emergency/review";
|
||||||
|
import { ServiceError } from "@/server/services/context";
|
||||||
|
import { formatAddress } from "@/server/services/customers/format";
|
||||||
|
import { getEmergencyReview } from "@/server/services/emergency/review";
|
||||||
|
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||||
|
|
||||||
|
const areaCls = `${inputCls} min-h-24 py-2`;
|
||||||
|
|
||||||
|
/** `/work-orders/emergency-review/[id]` — Prüfschritte eines Notdiensteinsatzes (spec §19.3, §39). */
|
||||||
|
export default async function EmergencyReviewDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { ctx, locale, tz, can } = await pageContext();
|
||||||
|
if (!can("emergency:review")) redirect("/work-orders");
|
||||||
|
const { id } = await params;
|
||||||
|
let r;
|
||||||
|
try {
|
||||||
|
r = await getEmergencyReview(ctx, id);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ServiceError && err.code === "not_found") notFound();
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
const t = await getTranslations("emergency.review");
|
||||||
|
const tw = await getTranslations("workOrders");
|
||||||
|
const orderTypes = can("work_order:write") ? await listOrderTypes(ctx, { activeOnly: true }) : [];
|
||||||
|
const customerProvisional = r.customer.status === "provisional";
|
||||||
|
const siteProvisional = r.site?.status === "provisional";
|
||||||
|
const completion = r.reports.find((x) => x.type === "completion");
|
||||||
|
const stepState = (done: boolean) => <StepState done={done} doneLabel={t("stepDone")} openLabel={t("stepOpen")} />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<Link href="/work-orders/emergency-review" 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("back")}
|
||||||
|
</Link>
|
||||||
|
<PageHead
|
||||||
|
crumb={`${t("crumb")} · ${r.number}`}
|
||||||
|
title={r.title}
|
||||||
|
sub={t("detailSub", {
|
||||||
|
technician: r.technician ?? "—",
|
||||||
|
start: r.startedAt ? formatDateTime(r.startedAt, locale, tz) : "—",
|
||||||
|
end: r.endedAt ? formatDateTime(r.endedAt, locale, tz) : t("running"),
|
||||||
|
})}
|
||||||
|
actions={
|
||||||
|
<Link href={`/work-orders/${r.id}`} className="inline-flex min-h-11 items-center gap-1.5 rounded-lg border px-4 text-sm font-semibold hover:bg-muted">
|
||||||
|
<ExternalLink className="size-4" aria-hidden />
|
||||||
|
{t("openOrder")}
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Section className="mb-4">
|
||||||
|
<div className="flex flex-wrap items-center gap-4">
|
||||||
|
<StatusBadge status={r.status} label={tw(`status.${r.status}`)} />
|
||||||
|
<ReviewProgressBar className="min-w-56" count={r.progress.count} total={r.progress.total} label={t("progress", { count: r.progress.count, total: r.progress.total })} />
|
||||||
|
</div>
|
||||||
|
<ol className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
|
||||||
|
{REVIEW_STEPS.map((s, idx) => (
|
||||||
|
<li key={s} className="rounded-lg border px-3 py-2">
|
||||||
|
<p className="text-[13px] font-semibold">
|
||||||
|
{idx + 1}. {t(`steps.${s}`)}
|
||||||
|
</p>
|
||||||
|
{stepState(r.progress.done[s])}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
{r.emergencyReason && (
|
||||||
|
<div className="mt-3 text-sm">
|
||||||
|
<p className="text-muted-foreground">{t("reason")}</p>
|
||||||
|
<p className="whitespace-pre-wrap">{r.emergencyReason}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
{/* 1 — Kunde */}
|
||||||
|
<Section title={`1. ${t("steps.customer")}`} actions={stepState(r.progress.done.customer)}>
|
||||||
|
<Dl
|
||||||
|
rows={[
|
||||||
|
[t("customerName"), r.customerName],
|
||||||
|
[t("customerNumber"), r.customer.customerNumber ?? t("numberOnConfirm")],
|
||||||
|
[t("status"), t(`customerStatus.${r.customer.status}`)],
|
||||||
|
[t("phone"), r.customer.phone ?? r.customer.mobile],
|
||||||
|
[t("email"), r.customer.email],
|
||||||
|
[t("address"), formatAddress(r.customer)],
|
||||||
|
[t("contact"), r.contact ? `${r.contact.name}${r.contact.phone ? ` · ${r.contact.phone}` : ""}` : null],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
{customerProvisional && (
|
||||||
|
<p className="mt-3 rounded-lg border border-[var(--warn)] px-3 py-2 text-sm">{t("provisionalHint")}</p>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 space-y-5">
|
||||||
|
{customerProvisional && can("customer:write") && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-1 text-sm font-semibold">{t("confirmCustomer.title")}</h3>
|
||||||
|
<p className="mb-2 text-xs text-muted-foreground">{t("confirmCustomer.hint")}</p>
|
||||||
|
<ReviewForm action={confirmCustomerAction.bind(null, r.id)} submitLabel={t("confirmCustomer.submit")} successText={t("saved")} variant="primary" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-1 text-sm font-semibold">{t("duplicates.title")}</h3>
|
||||||
|
{r.duplicates.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t("duplicates.none")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mb-2 space-y-1 text-sm">
|
||||||
|
{r.duplicates.map((d) => (
|
||||||
|
<li key={d.customerId} className="flex flex-wrap items-center gap-2">
|
||||||
|
<Link href={`/customers/${d.customerId}`} className="font-semibold text-[var(--primary)] hover:underline">
|
||||||
|
{d.displayName}
|
||||||
|
</Link>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{[d.customerNumber, d.city, t("duplicates.score", { score: Math.round(d.score * 100) })].filter(Boolean).join(" · ")}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customerProvisional && can("customer:write") && can("work_order:write") && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-1 text-sm font-semibold">{t("assignCustomer.title")}</h3>
|
||||||
|
<p className="mb-2 text-xs text-muted-foreground">{t("assignCustomer.hint")}</p>
|
||||||
|
<ReviewForm action={assignCustomerAction.bind(null, r.id)} submitLabel={t("assignCustomer.submit")} successText={t("saved")} variant="outline">
|
||||||
|
<TargetFields idPrefix="assign" candidates={r.duplicates} labels={{ select: t("target.select"), none: t("target.none"), number: t("target.number") }} />
|
||||||
|
</ReviewForm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{can("customer:merge") && r.customer.status !== "merged" && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-1 text-sm font-semibold">{t("mergeCustomer.title")}</h3>
|
||||||
|
<p className="mb-2 text-xs text-muted-foreground">{t("mergeCustomer.hint")}</p>
|
||||||
|
<ReviewForm action={mergeCustomerAction.bind(null, r.id)} submitLabel={t("mergeCustomer.submit")} successText={t("saved")} variant="danger">
|
||||||
|
<TargetFields idPrefix="merge" candidates={r.duplicates} labels={{ select: t("target.select"), none: t("target.none"), number: t("target.number") }} />
|
||||||
|
<Check id="merge-confirm" name="confirm" label={t("mergeCustomer.confirm")} />
|
||||||
|
</ReviewForm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* 2 — Objekt */}
|
||||||
|
<Section title={`2. ${t("steps.site")}`} actions={stepState(r.progress.done.site)}>
|
||||||
|
{r.site ? (
|
||||||
|
<Dl
|
||||||
|
rows={[
|
||||||
|
[t("siteName"), r.site.name],
|
||||||
|
[t("address"), formatAddress(r.site)],
|
||||||
|
[t("status"), t(`siteStatus.${r.site.status}`)],
|
||||||
|
[t("onSiteContact"), [r.site.onSiteContact, r.site.phone].filter(Boolean).join(" · ")],
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Empty>{t("noSite")}</Empty>
|
||||||
|
)}
|
||||||
|
<div className="mt-4 space-y-5">
|
||||||
|
{r.site && can("site:write") && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-2 text-sm font-semibold">{t("correctSite.title")}</h3>
|
||||||
|
<ReviewForm action={correctSiteAction.bind(null, r.id)} submitLabel={t("correctSite.submit")} successText={t("saved")} variant={siteProvisional ? "primary" : "outline"}>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label={t("siteName")} htmlFor="site-name" className="sm:col-span-2">
|
||||||
|
<input id="site-name" name="name" defaultValue={r.site.name} required className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("street")} htmlFor="site-street">
|
||||||
|
<input id="site-street" name="street" defaultValue={r.site.street ?? ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("houseNumber")} htmlFor="site-no">
|
||||||
|
<input id="site-no" name="houseNumber" defaultValue={r.site.houseNumber ?? ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("postalCode")} htmlFor="site-zip">
|
||||||
|
<input id="site-zip" name="postalCode" defaultValue={r.site.postalCode ?? ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("city")} htmlFor="site-city">
|
||||||
|
<input id="site-city" name="city" defaultValue={r.site.city ?? ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("onSiteContact")} htmlFor="site-contact">
|
||||||
|
<input id="site-contact" name="onSiteContact" defaultValue={r.site.onSiteContact ?? ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("phone")} htmlFor="site-phone">
|
||||||
|
<input id="site-phone" name="phone" type="tel" defaultValue={r.site.phone ?? ""} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("accessNotes")} htmlFor="site-access" className="sm:col-span-2">
|
||||||
|
<textarea id="site-access" name="accessNotes" defaultValue={r.site.accessNotes ?? ""} className={areaCls} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
{siteProvisional && <Check id="site-confirm" name="confirm" label={t("correctSite.confirm")} defaultChecked />}
|
||||||
|
</ReviewForm>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{can("work_order:write") && (
|
||||||
|
<div>
|
||||||
|
<h3 className="mb-1 text-sm font-semibold">{t("assignSite.title")}</h3>
|
||||||
|
{r.siteOptions.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">{t("assignSite.none")}</p>
|
||||||
|
) : (
|
||||||
|
<ReviewForm action={assignSiteAction.bind(null, r.id)} submitLabel={t("assignSite.submit")} successText={t("saved")} variant="outline">
|
||||||
|
<p className="text-xs text-muted-foreground">{t("assignSite.hint")}</p>
|
||||||
|
<Field label={t("assignSite.select")} htmlFor="assign-site">
|
||||||
|
<select id="assign-site" name="siteId" required className={inputCls}>
|
||||||
|
{r.siteOptions.map((s) => (
|
||||||
|
<option key={s.id} value={s.id}>
|
||||||
|
{[s.name, formatAddress(s)].filter(Boolean).join(" · ")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</ReviewForm>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* 3 — Auftrag ergänzen */}
|
||||||
|
<Section title={`3. ${t("steps.order")}`} actions={stepState(r.progress.done.order)}>
|
||||||
|
{can("work_order:write") ? (
|
||||||
|
<ReviewForm action={completeOrderAction.bind(null, r.id)} submitLabel={t("completeOrder.submit")} successText={t("saved")} variant={r.progress.done.order ? "outline" : "primary"}>
|
||||||
|
<input type="hidden" name="baseVersion" value={r.version} />
|
||||||
|
<Field label={t("completeOrder.title")} htmlFor="order-title">
|
||||||
|
<input id="order-title" name="title" defaultValue={r.title} required maxLength={200} className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
<Field label={t("completeOrder.description")} htmlFor="order-description">
|
||||||
|
<textarea id="order-description" name="description" defaultValue={r.description ?? ""} className={areaCls} />
|
||||||
|
</Field>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label={t("completeOrder.orderType")} htmlFor="order-type">
|
||||||
|
<select id="order-type" name="orderTypeId" defaultValue={r.orderTypeId ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("completeOrder.choose")}</option>
|
||||||
|
{orderTypes.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{o.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={t("completeOrder.billingType")} htmlFor="order-billing">
|
||||||
|
<select id="order-billing" name="billingType" defaultValue={r.billingType ?? ""} className={inputCls}>
|
||||||
|
<option value="">{t("completeOrder.choose")}</option>
|
||||||
|
{BILLING_TYPES.map((b) => (
|
||||||
|
<option key={b} value={b}>
|
||||||
|
{t(`billingTypes.${b}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t("completeOrder.hint")}</p>
|
||||||
|
</ReviewForm>
|
||||||
|
) : (
|
||||||
|
<Dl rows={[[t("completeOrder.orderType"), r.orderType?.name], [t("completeOrder.billingType"), r.billingType ? t(`billingTypes.${r.billingType}`) : null]]} />
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
{/* 4 — Bericht, 5 — Abrechnung */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Section title={`4. ${t("steps.report")}`} actions={stepState(r.progress.done.report)}>
|
||||||
|
{r.reports.length === 0 ? (
|
||||||
|
<Empty>{t("report.none")}</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2 text-sm">
|
||||||
|
{r.reports.map((rep) => (
|
||||||
|
<li key={rep.id} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2">
|
||||||
|
<span>
|
||||||
|
{t(`report.type.${rep.type}`)} · {t("report.version", { version: rep.version })} · <strong>{t(`report.status.${rep.status}`)}</strong>
|
||||||
|
</span>
|
||||||
|
<Link href={`/reports/${rep.id}`} className="inline-flex min-h-11 items-center gap-1.5 font-semibold text-[var(--primary)] hover:underline">
|
||||||
|
{t("report.open")}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{completion && completion.status !== "approved" && <p className="mt-2 text-xs text-muted-foreground">{t("report.hint")}</p>}
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title={`5. ${t("steps.billing")}`} actions={stepState(r.progress.done.billing)}>
|
||||||
|
<p className="mb-3 text-sm text-muted-foreground">{t("billing.hint")}</p>
|
||||||
|
{r.status === "in_review" && can("work_order:release_billing") ? (
|
||||||
|
<ReviewForm action={releaseBillingAction.bind(null, r.id)} submitLabel={t("billing.submit")} successText={t("saved")} variant="primary">
|
||||||
|
<input type="hidden" name="baseVersion" value={r.version} />
|
||||||
|
</ReviewForm>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm">
|
||||||
|
{t("billing.current")}: <StatusBadge status={r.status} label={tw(`status.${r.status}`)} />
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</Section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TargetFields({
|
||||||
|
idPrefix,
|
||||||
|
candidates,
|
||||||
|
labels,
|
||||||
|
}: {
|
||||||
|
idPrefix: string;
|
||||||
|
candidates: { customerId: string; displayName: string; customerNumber: string | null; city: string | null }[];
|
||||||
|
labels: { select: string; none: string; number: string };
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Field label={labels.select} htmlFor={`${idPrefix}-target`}>
|
||||||
|
<select id={`${idPrefix}-target`} name="targetCustomerId" defaultValue="" className={inputCls}>
|
||||||
|
<option value="">{labels.none}</option>
|
||||||
|
{candidates.map((c) => (
|
||||||
|
<option key={c.customerId} value={c.customerId}>
|
||||||
|
{[c.displayName, c.customerNumber, c.city].filter(Boolean).join(" · ")}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</Field>
|
||||||
|
<Field label={labels.number} htmlFor={`${idPrefix}-number`}>
|
||||||
|
<input id={`${idPrefix}-number`} name="targetCustomerNumber" className={inputCls} />
|
||||||
|
</Field>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { requireModule } from "@/server/modules";
|
||||||
|
|
||||||
|
/** Modul-Gate „emergency" für die Backoffice-Nachbearbeitung (zusätzlich zum work_orders-Gate des Elternordners). */
|
||||||
|
export default async function EmergencyReviewLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||||
|
await requireModule("emergency");
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { getTranslations } from "next-intl/server";
|
||||||
|
import { ChevronRight, CircleAlert, Siren } from "lucide-react";
|
||||||
|
import { PageHead } from "@/components/mockup-ui";
|
||||||
|
import { pageContext } from "@/components/work-orders/page-context";
|
||||||
|
import { Empty, LinkTabs, StatusBadge } from "@/components/work-orders/ui";
|
||||||
|
import { ReviewProgressBar } from "@/components/emergency/review-progress";
|
||||||
|
import { formatDateTime } from "@/lib/work-orders/time";
|
||||||
|
import { listEmergencyReviews } from "@/server/services/emergency/review";
|
||||||
|
|
||||||
|
type SP = Record<string, string | string[] | undefined>;
|
||||||
|
|
||||||
|
/** `/work-orders/emergency-review` — Notdiensteinsätze mit vorläufigen Stammdaten oder zur Prüfung (spec §19.3). */
|
||||||
|
export default async function EmergencyReviewListPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||||
|
const { ctx, locale, tz, can } = await pageContext();
|
||||||
|
if (!can("emergency:review")) redirect("/work-orders");
|
||||||
|
const sp = await searchParams;
|
||||||
|
const filter = sp.filter === "all" ? "all" : "open";
|
||||||
|
const t = await getTranslations("emergency.review");
|
||||||
|
const tw = await getTranslations("workOrders");
|
||||||
|
const items = await listEmergencyReviews(ctx, { filter });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="flex-1 p-4 md:p-6">
|
||||||
|
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||||
|
<div className="mb-4">
|
||||||
|
<LinkTabs
|
||||||
|
label={t("filterLabel")}
|
||||||
|
items={[
|
||||||
|
{ href: "/work-orders/emergency-review", label: t("filter.open"), active: filter === "open", ...(filter === "open" ? { count: items.length } : {}) },
|
||||||
|
{ href: "/work-orders/emergency-review?filter=all", label: t("filter.all"), active: filter === "all", ...(filter === "all" ? { count: items.length } : {}) },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<Empty>{filter === "open" ? t("emptyOpen") : t("emptyAll")}</Empty>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 lg:grid-cols-2">
|
||||||
|
{items.map((i) => (
|
||||||
|
<li key={i.id}>
|
||||||
|
<Link href={`/work-orders/emergency-review/${i.id}`} className="shadow-card flex h-full flex-col gap-3 rounded-xl border border-l-4 border-l-[var(--risk)] bg-card p-4 transition-colors hover:bg-muted/40">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="flex items-center gap-1.5 font-mono text-xs text-muted-foreground">
|
||||||
|
<Siren className="size-3.5 text-[var(--risk)]" aria-hidden />
|
||||||
|
{i.number}
|
||||||
|
</p>
|
||||||
|
<p className="font-heading text-[15px] font-semibold break-words">{i.title}</p>
|
||||||
|
</div>
|
||||||
|
<StatusBadge status={i.status} label={tw(`status.${i.status}`)} />
|
||||||
|
</div>
|
||||||
|
<dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 text-sm">
|
||||||
|
<dt className="text-muted-foreground">{t("customer")}</dt>
|
||||||
|
<dd className="min-w-0 break-words">
|
||||||
|
{i.customerName}
|
||||||
|
{i.customer.status === "provisional" && (
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1 text-xs font-semibold text-[var(--warn)]">
|
||||||
|
<CircleAlert className="size-3.5" aria-hidden />
|
||||||
|
{t("provisional")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted-foreground">{t("site")}</dt>
|
||||||
|
<dd className="min-w-0 break-words">
|
||||||
|
{i.site?.name ?? "—"}
|
||||||
|
{i.site?.status === "provisional" && (
|
||||||
|
<span className="ml-2 inline-flex items-center gap-1 text-xs font-semibold text-[var(--warn)]">
|
||||||
|
<CircleAlert className="size-3.5" aria-hidden />
|
||||||
|
{t("provisional")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted-foreground">{t("technician")}</dt>
|
||||||
|
<dd>{i.technician ?? "—"}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t("startedAt")}</dt>
|
||||||
|
<dd>{formatDateTime(i.plannedStart ?? i.createdAt, locale, tz)}</dd>
|
||||||
|
</dl>
|
||||||
|
<div className="mt-auto flex items-center gap-3">
|
||||||
|
<ReviewProgressBar count={i.progress.count} total={i.progress.total} label={t("progress", { count: i.progress.count, total: i.progress.total })} />
|
||||||
|
<ChevronRight className="size-4.5 shrink-0 text-muted-foreground" aria-hidden />
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -66,6 +66,7 @@ const ENTITY_LABEL: Record<string, string> = {
|
|||||||
import: "Auftragsimport",
|
import: "Auftragsimport",
|
||||||
report: "Bericht",
|
report: "Bericht",
|
||||||
emergency: "Notdienst",
|
emergency: "Notdienst",
|
||||||
|
emergency_customer_search: "Notdienst-Kundensuche", emergency_site_lookup: "Notdienst-Objektauswahl",
|
||||||
signature: "Unterschrift",
|
signature: "Unterschrift",
|
||||||
import_job: "Auftragsimport",
|
import_job: "Auftragsimport",
|
||||||
material_usage: "Material",
|
material_usage: "Material",
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useActionState } from "react";
|
||||||
|
import { useTranslations } from "next-intl";
|
||||||
|
import { AlertCircle, CheckCircle2 } from "lucide-react";
|
||||||
|
import type { ActionState } from "@/server/api/action-state";
|
||||||
|
import { buttonCls } from "@/components/work-orders/action-form";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
|
||||||
|
|
||||||
|
/** Form bound to an emergency review server action; errors are translated codes (emergency.review.errors.*). */
|
||||||
|
export function ReviewForm({
|
||||||
|
action,
|
||||||
|
children,
|
||||||
|
submitLabel,
|
||||||
|
successText,
|
||||||
|
variant = "default",
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
action: Action;
|
||||||
|
children?: React.ReactNode;
|
||||||
|
submitLabel: string;
|
||||||
|
successText?: string;
|
||||||
|
variant?: "primary" | "default" | "outline" | "danger";
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const [state, formAction, pending] = useActionState(action, { status: "idle" } as ActionState);
|
||||||
|
const t = useTranslations("emergency.review");
|
||||||
|
|
||||||
|
let errorText = "";
|
||||||
|
if (state.status === "error") {
|
||||||
|
const reasonKey = state.reason ? `errors.${state.reason}` : "";
|
||||||
|
errorText = reasonKey && t.has(reasonKey) ? t(reasonKey) : t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.generic");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form action={formAction} className={cn("space-y-3", className)}>
|
||||||
|
{children}
|
||||||
|
{state.status === "error" && (
|
||||||
|
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] bg-card px-3 py-2 text-sm font-semibold text-[var(--risk)]">
|
||||||
|
<AlertCircle className="size-4 shrink-0" aria-hidden />
|
||||||
|
{errorText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{state.status === "ok" && successText && (
|
||||||
|
<p role="status" className="flex items-center gap-2 text-sm text-[var(--ok)]">
|
||||||
|
<CheckCircle2 className="size-4" aria-hidden />
|
||||||
|
{successText}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<button type="submit" disabled={pending} className={buttonCls(variant)}>
|
||||||
|
{submitLabel}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { CheckCircle2, Circle } from "lucide-react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/** "x von 5 Prüfschritten erledigt" — bar plus text (never colour alone). */
|
||||||
|
export function ReviewProgressBar({ count, total, label, className }: { count: number; total: number; label: string; className?: string }) {
|
||||||
|
const pct = total ? Math.round((count / total) * 100) : 0;
|
||||||
|
return (
|
||||||
|
<div className={cn("flex-1", className)}>
|
||||||
|
<p className="mb-1 text-xs font-semibold">{label}</p>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted" role="progressbar" aria-valuemin={0} aria-valuemax={total} aria-valuenow={count} aria-label={label}>
|
||||||
|
<div className="h-full rounded-full bg-[var(--ok)]" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StepState({ done, doneLabel, openLabel }: { done: boolean; doneLabel: string; openLabel: string }) {
|
||||||
|
return done ? (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-[var(--ok)]">
|
||||||
|
<CheckCircle2 className="size-4" aria-hidden />
|
||||||
|
{doneLabel}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1.5 text-xs font-semibold text-muted-foreground">
|
||||||
|
<Circle className="size-4" aria-hidden />
|
||||||
|
{openLabel}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
History,
|
History,
|
||||||
Mail,
|
Mail,
|
||||||
ListChecks,
|
ListChecks,
|
||||||
|
Siren,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { ModuleKey } from "@/lib/modules";
|
import type { ModuleKey } from "@/lib/modules";
|
||||||
@@ -47,6 +48,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
|||||||
permissions: ["work_order:read_all", "work_order:read_team"],
|
permissions: ["work_order:read_all", "work_order:read_team"],
|
||||||
section: "main",
|
section: "main",
|
||||||
},
|
},
|
||||||
|
{ href: "/work-orders/emergency-review", label: "emergencyReview", icon: Siren, module: "emergency", permissions: ["emergency:review"], section: "main" },
|
||||||
{ href: "/imports", label: "imports", icon: FileInput, module: "imports", permissions: ["import:write"], section: "main" },
|
{ href: "/imports", label: "imports", icon: FileInput, module: "imports", permissions: ["import:write"], section: "main" },
|
||||||
{ href: "/customers", label: "customers", icon: Users, module: "customers", permissions: ["customer:read"], section: "main" },
|
{ href: "/customers", label: "customers", icon: Users, module: "customers", permissions: ["customer:read"], section: "main" },
|
||||||
{ href: "/sites", label: "sites", icon: Building2, module: "sites", permissions: ["site:read"], section: "main" },
|
{ href: "/sites", label: "sites", icon: Building2, module: "sites", permissions: ["site:read"], section: "main" },
|
||||||
|
|||||||
Reference in New Issue
Block a user