L2 Aufträge & Backoffice: Backoffice-UI (Liste, Detail, Konflikte, Dashboard, Suche, Einstellungen) + Texte de/en

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:30:32 +02:00
co-authored by Claude Opus 5
parent a962fa8be9
commit b96016d593
23 changed files with 3545 additions and 34 deletions
+296
View File
@@ -0,0 +1,296 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ArrowLeft, Pencil, Siren, UsersRound } from "lucide-react";
import { Modal } from "@/components/modal";
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
import {
ChecklistTab,
DocumentsTab,
HistoryTab,
MaterialTab,
NotesTab,
OverviewTab,
PhotosTab,
ReportsTab,
TimesTab,
} from "@/components/work-orders/detail-tabs";
import { pageContext } from "@/components/work-orders/page-context";
import { Field, inputCls, LinkTabs, StatusBadge } from "@/components/work-orders/ui";
import { WorkOrderFields } from "@/components/work-orders/work-order-fields";
import { WORK_ORDER_STATUSES, type WorkOrderStatus } from "@/lib/work-orders/status";
import { assignAction, transitionAction, updateWorkOrderAction } from "@/server/actions/work_orders/work-orders";
import { ServiceError } from "@/server/services/context";
import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_shared";
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
import { reasonRequired } from "@/server/services/work-orders/transition";
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "documents", "history"] as const;
type Tab = (typeof TABS)[number];
type SP = Record<string, string | string[] | undefined>;
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
const idx = (s: WorkOrderStatus) => WORK_ORDER_STATUSES.indexOf(s);
/** Backoffice work order detail: header with status + next primary action, tabs, popups (assign/edit/reason). */
export default async function WorkOrderDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<SP> }) {
const { id } = await params;
const sp = await searchParams;
const { ctx, locale, tz, can } = await pageContext();
const t = await getTranslations("workOrders");
const tc = await getTranslations("common");
let wo;
try {
wo = await getWorkOrderDetail(ctx, id);
} catch (err) {
if (err instanceof ServiceError && err.code === "not_found") notFound();
throw err;
}
const tabParam = one(sp.tab);
const tab: Tab = (TABS as readonly string[]).includes(tabParam ?? "") ? (tabParam as Tab) : "overview";
const selfHref = `/work-orders/${wo.id}${tab === "overview" ? "" : `?tab=${tab}`}`;
const withParam = (k: string, v: string) => `/work-orders/${wo.id}?${tab === "overview" ? "" : `tab=${tab}&`}${k}=${v}`;
const transitions = availableTransitions(ctx, wo.status);
const canAssign = can("work_order:assign") && !PLANNING_LOCKED.includes(wo.status);
const canWrite = can("work_order:write") && !FINAL_STATUSES.includes(wo.status);
const canEditPlanning = can("work_order:write") && !PLANNING_LOCKED.includes(wo.status);
const assignFirst = canAssign && ["draft", "review_required", "planned"].includes(wo.status);
// Primary = furthest forward step (never cancel/backward); assignment goes through the popup.
const forward = transitions.filter((to) => to !== "cancelled" && idx(to) > idx(wo.status) && !(assignFirst && to === "assigned"));
const primary = assignFirst ? null : forward.sort((a, b) => idx(b) - idx(a))[0] ?? null;
const secondary = transitions.filter((to) => to !== primary && to !== "cancelled" && !(assignFirst && to === "assigned"));
const label = (to: WorkOrderStatus) =>
wo.status === "in_review" && to === "in_progress"
? t("transition.correction")
: wo.status === "released_for_billing" && to === "in_review"
? t("transition.revoke")
: t(`transition.to.${to}`);
const transitionButton = (to: WorkOrderStatus, variant: "primary" | "outline" | "danger") =>
reasonRequired(wo.status, to) ? (
<Link key={to} href={withParam("transition", to)} scroll={false} className={buttonCls(variant)}>
{label(to)}
</Link>
) : (
<ActionForm key={to} action={transitionAction} submitLabel={label(to)} pendingLabel={t("transition.pending")} variant={variant} footerClassName="mt-0">
<input type="hidden" name="workOrderId" value={wo.id} />
<input type="hidden" name="to" value={to} />
<input type="hidden" name="baseVersion" value={wo.version} />
<input type="hidden" name="returnTab" value={tab} />
</ActionForm>
);
// Popups
const reasonTo = one(sp.transition) as WorkOrderStatus | undefined;
const showReason = reasonTo && transitions.includes(reasonTo) && reasonRequired(wo.status, reasonTo);
const showAssign = canAssign && one(sp.assign) === "1";
const showEdit = canWrite && one(sp.edit) === "1";
const [teams, users, editData, orderTypes] = await Promise.all([
showAssign ? teamOptions(ctx) : Promise.resolve([]),
showAssign ? userOptions(ctx) : Promise.resolve([]),
showEdit ? customerOption(ctx, wo.customerId) : Promise.resolve(null),
showEdit ? listOrderTypes(ctx) : Promise.resolve([]),
]);
const billingTransitions = transitions.filter((to) => ["released_for_billing", "billed"].includes(to) || (wo.status === "in_review" && to === "in_progress") || (wo.status === "released_for_billing" && to === "in_review"));
const tabProps = { ctx, wo, locale, tz, canEdit: canEditPlanning };
return (
<main className="flex-1 p-4 md:p-6">
<Link href="/work-orders" 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("detail.back")}
</Link>
<header className="shadow-card mt-1 rounded-xl border border-l-4 bg-card p-4 md:p-5" style={{ borderLeftColor: "var(--ui-primary)" }}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{wo.number}</span>
<span>· {t("detail.version", { version: wo.version })}</span>
{wo.isEmergency && (
<span className="inline-flex items-center gap-1 font-semibold text-[var(--risk)]">
<Siren className="size-3.5" aria-hidden />
{t("emergency")}
</span>
)}
</p>
<h1 className="mt-1 text-[22px] break-words">{wo.title}</h1>
<p className="mt-1 text-sm">
{customerDisplayName(wo.customer)}
{wo.site && <span className="text-muted-foreground"> · {wo.site.name}</span>}
</p>
<div className="mt-2 flex flex-wrap items-center gap-3">
<StatusBadge status={wo.status} label={t(`status.${wo.status}`)} className="text-sm" />
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<UsersRound className="size-3.5" aria-hidden />
{wo.team?.name ?? t("unassigned")}
</span>
</div>
</div>
<div className="flex flex-col items-stretch gap-2 sm:items-end">
<span className="text-xs font-semibold text-muted-foreground">{t("detail.nextStep")}</span>
<div className="flex flex-wrap gap-2 sm:justify-end">
{assignFirst && (
<Link href={withParam("assign", "1")} scroll={false} className={buttonCls("primary")}>
{t("detail.assign")}
</Link>
)}
{primary && transitionButton(primary, "primary")}
{!assignFirst && !primary && <span className="text-sm text-muted-foreground">{t("detail.noActions")}</span>}
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t pt-3">
{secondary.map((to) => transitionButton(to, "outline"))}
{canAssign && !assignFirst && (
<Link href={withParam("assign", "1")} scroll={false} className={buttonCls("outline")}>
<UsersRound className="size-4" aria-hidden />
{t("detail.reassign")}
</Link>
)}
{canWrite && (
<Link href={withParam("edit", "1")} scroll={false} className={buttonCls("outline")}>
<Pencil className="size-4" aria-hidden />
{t("detail.edit")}
</Link>
)}
{transitions.includes("cancelled") && (
<Link href={withParam("transition", "cancelled")} scroll={false} className={`${buttonCls("danger")} sm:ml-auto`}>
{t("detail.cancel")}
</Link>
)}
</div>
</header>
<div className="mt-4">
<LinkTabs
label={t("detail.tabs.overview")}
items={TABS.map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
/>
</div>
<div className="mt-4">
{tab === "overview" && <OverviewTab {...tabProps} />}
{tab === "checklist" && <ChecklistTab {...tabProps} />}
{tab === "material" && <MaterialTab {...tabProps} />}
{tab === "times" && <TimesTab {...tabProps} />}
{tab === "photos" && <PhotosTab {...tabProps} />}
{tab === "notes" && <NotesTab {...tabProps} />}
{tab === "reports" && (
<ReportsTab
{...tabProps}
billing={
billingTransitions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{billingTransitions.map((to) => transitionButton(to, to === "released_for_billing" || to === "billed" ? "primary" : "outline"))}
</div>
) : undefined
}
/>
)}
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
{tab === "history" && <HistoryTab {...tabProps} />}
</div>
{showReason && reasonTo && (
<Modal title={label(reasonTo)} sub={t("transition.reasonTitle")} closeHref={selfHref} closeLabel={tc("close")}>
<ActionForm action={transitionAction} submitLabel={t("transition.confirm")} pendingLabel={t("transition.pending")} variant={reasonTo === "cancelled" ? "danger" : "default"} className="p-5">
<input type="hidden" name="workOrderId" value={wo.id} />
<input type="hidden" name="to" value={reasonTo} />
<input type="hidden" name="baseVersion" value={wo.version} />
<input type="hidden" name="returnTab" value={tab} />
<Field label={`${t("transition.reason")} *`} htmlFor="tr-reason">
<textarea id="tr-reason" name="reason" required rows={4} maxLength={2000} placeholder={t("transition.reasonPlaceholder")} className={`${inputCls} py-2`} autoFocus />
</Field>
</ActionForm>
</Modal>
)}
{showAssign && (
<Modal title={t("assign.title")} sub={t("assign.sub")} closeHref={selfHref} closeLabel={tc("close")}>
{teams.length === 0 ? (
<p className="p-5 text-sm text-muted-foreground">{t("assign.noTeams")}</p>
) : (
<ActionForm action={assignAction} submitLabel={t("assign.submit")} pendingLabel={t("assign.pending")} variant="primary" className="grid gap-4 p-5 md:grid-cols-2">
<input type="hidden" name="workOrderId" value={wo.id} />
<input type="hidden" name="baseVersion" value={wo.version} />
<Field label={`${t("assign.team")} *`} htmlFor="as-team">
<select id="as-team" name="teamId" required defaultValue={wo.team?.id ?? ""} className={inputCls}>
<option value="">{t("assign.chooseTeam")}</option>
{teams.map((tm) => (
<option key={tm.id} value={tm.id}>
{tm.name}
</option>
))}
</select>
</Field>
<Field label={t("assign.teamLead")} htmlFor="as-lead">
<select id="as-lead" name="teamLeadUserId" defaultValue={wo.teamLead?.id ?? ""} className={inputCls}>
<option value="">{t("assign.teamLeadDefault")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
<fieldset className="md:col-span-2">
<legend className="mb-1 text-[13px] font-semibold">{t("assign.members")}</legend>
<div className="grid gap-x-4 sm:grid-cols-2 lg:grid-cols-3">
{users.map((u) => (
<label key={u.id} className="flex min-h-11 items-center gap-2.5 text-sm">
<input type="checkbox" name="userIds" value={u.id} defaultChecked={wo.assignees.some((a) => a.user.id === u.id)} className="size-5 accent-[var(--ui-primary)]" />
{u.name}
<span className="text-xs text-muted-foreground">{teams.filter((tm) => tm.members.some((m) => m.user.id === u.id)).map((tm) => tm.name).join(", ")}</span>
</label>
))}
</div>
</fieldset>
</ActionForm>
)}
</Modal>
)}
{showEdit && editData && (
<Modal title={t("edit.title")} sub={`${wo.number} · ${customerDisplayName(wo.customer)}`} closeHref={selfHref} closeLabel={tc("close")}>
<ActionForm action={updateWorkOrderAction.bind(null, wo.id)} submitLabel={t("edit.submit")} pendingLabel={t("edit.pending")} className="p-5">
<input type="hidden" name="baseVersion" value={wo.version} />
<WorkOrderFields
mode="edit"
tz={tz}
sites={editData.sites.map((s) => ({ id: s.id, name: [s.name, s.city].filter(Boolean).join(" · ") }))}
contacts={editData.contacts.map((c) => ({ id: c.id, name: c.name }))}
orderTypes={orderTypes.map((o) => ({ id: o.id, name: o.active ? o.name : `${o.name} (–)` }))}
defaults={{
title: wo.title,
siteId: wo.siteId,
contactId: wo.contactId,
orderTypeId: wo.orderTypeId,
priority: wo.priority,
plannedStart: wo.plannedStart,
plannedEnd: wo.plannedEnd,
description: wo.description,
scope: wo.scope,
internalNotes: wo.internalNotes,
technicianNotes: wo.technicianNotes,
signatureRequired: wo.signatureRequired,
billingType: wo.billingType,
externalOrderNumber: wo.externalOrderNumber,
offerNumber: wo.offerNumber,
}}
/>
</ActionForm>
</Modal>
)}
</main>
);
}