- Datenmodell: Tenant.tier (Default PROFI), lotseChatSeats, lotseChatHardLimit;
Tabelle lotse_chat_seats (RLS, TENANT_MODELS, pii-fields), Index für die
Monatszählung der Chat-Nachrichten (Migration 20260921100000_pakete)
- src/lib/plans.ts: Stufenregeln, 150 Chats je Platz, Mehrverbrauch in 100er-Paketen
- src/server/plan.ts: effektive Freischaltung = Stufe UND TenantModule, genutzt von
requireModule, assertModuleEnabled, API, Sync (Offline-Op → rejected mit Klartext),
Navigation, isLotseEnabled und planningAccess (Planung nur in Profi)
- Lotse-Chat: Platzprüfung (no_seat), Testphase ohne Platz, Kontingent mit
hartem Limit (quota_exhausted); Platzvergabe durch den Mandanten-Admin
- Betreiber: Stufe/Plätze/hartes Limit im Mandantendetail mit Bestätigung
und Plattform-Audit, Verbrauch laufender Monat/Vormonat, Stufe als Badge
- Demo-Seed: demo = Profi mit 3 Plätzen, demo2 = Basis
- Tests: test-pakete-{rules,gates,seats}
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
311 lines
16 KiB
TypeScript
311 lines
16 KiB
TypeScript
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 { PlanningSuggestions } from "@/components/planning/suggestions-section";
|
||
import { SCHEDULABLE_STATUSES } from "@/lib/planning/capacity";
|
||
import { ActionForm } from "@/components/work-orders/action-form";
|
||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||
import {
|
||
ChecklistTab,
|
||
DocumentsTab,
|
||
HistoryTab,
|
||
MaterialTab,
|
||
NotesTab,
|
||
OverviewTab,
|
||
PhotosTab,
|
||
ReportsTab,
|
||
TimesTab,
|
||
} from "@/components/work-orders/detail-tabs";
|
||
import { WorkOrderBillingTab } from "@/components/billing/work-order-billing-tab";
|
||
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 { billingModuleActive, reasonRequired } from "@/server/services/work-orders/transition";
|
||
|
||
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "billing", "documents", "history"] as const; // L14: billing
|
||
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([]),
|
||
]);
|
||
|
||
// L14: with the billing overview active, "billed" is set only there (snapshot + position assignment) → no direct button
|
||
const billingOverviewActive = await billingModuleActive(ctx); // L17: incl. package tier
|
||
const billingTransitions = transitions.filter(
|
||
(to) =>
|
||
(to === "released_for_billing" || (to === "billed" && !billingOverviewActive)) ||
|
||
(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>
|
||
|
||
{tab === "overview" && canAssign && SCHEDULABLE_STATUSES.includes(wo.status) && <PlanningSuggestions ctx={ctx} workOrderId={wo.id} locale={locale} tz={tz} />}
|
||
|
||
<div className="mt-4">
|
||
<LinkTabs
|
||
label={t("detail.tabs.overview")}
|
||
items={TABS.filter((k) => k !== "billing" || billingOverviewActive).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 === "billing" && billingOverviewActive && <WorkOrderBillingTab ctx={ctx} wo={wo} locale={locale} tz={tz} />}
|
||
{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>
|
||
);
|
||
}
|