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:
2026-09-14 13:12:33 +02:00
co-authored by Claude Opus 5
parent ef61754a0a
commit dece70c129
10 changed files with 532 additions and 1 deletions
+1
View File
@@ -1,6 +1,7 @@
{
"dashboard": "Dashboard",
"workOrders": "Aufträge",
"emergencyReview": "Notdienst-Prüfung",
"imports": "Import",
"customers": "Kunden",
"sites": "Objekte",
+1
View File
@@ -1,6 +1,7 @@
{
"dashboard": "Dashboard",
"workOrders": "Work orders",
"emergencyReview": "Emergency review",
"imports": "Import",
"customers": "Customers",
"sites": "Sites",
+1 -1
View File
@@ -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">
{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 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 (
<li key={key}>
<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>
);
}
+1
View File
@@ -66,6 +66,7 @@ const ENTITY_LABEL: Record<string, string> = {
import: "Auftragsimport",
report: "Bericht",
emergency: "Notdienst",
emergency_customer_search: "Notdienst-Kundensuche", emergency_site_lookup: "Notdienst-Objektauswahl",
signature: "Unterschrift",
import_job: "Auftragsimport",
material_usage: "Material",
+57
View File
@@ -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>
);
}
+2
View File
@@ -12,6 +12,7 @@ import {
History,
Mail,
ListChecks,
Siren,
type LucideIcon,
} from "lucide-react";
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"],
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: "/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" },