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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user