Files
craftvia/src/lib/work-orders/status.ts
T
msolarczekandClaude Opus 5 bf4456718e Architektur: Craftvia-Domänenmodell, Verträge und Team-Schnitte
- Migration 0002_craftvia_domain: 27 Fachtabellen inkl. RLS (enable_tenant_rls)
- TENANT_MODELS (db.ts, backup/topology.ts) um alle Fachmodelle ergänzt
- moduleGuard liefert DB-autoritative Rechte; ServiceCtx für Domänen-Services
- Verträge: Statusmaschine, Events, Nummernkreise, Sichtbarkeits-Scopes,
  Job-Queues + Worker, KI-Provider-Interfaces, Sync-Envelope
- docs/craftvia/ARCHITEKTUR.md mit Lanes, Ownership und DoD

Gate: tsc, lint, build, 22/22 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:49:21 +02:00

130 lines
4.6 KiB
TypeScript

// Work order status machine (client-safe: no server imports).
// Server enforcement lives in src/server/services/work-orders/transition.ts,
// which MUST call canTransition()/requiredPermission() — never update status directly.
export const WORK_ORDER_STATUSES = [
"draft",
"review_required",
"planned",
"assigned",
"accepted",
"en_route",
"in_progress",
"paused",
"waiting_material",
"daily_report_created",
"technically_completed",
"signature_pending",
"in_review",
"released_for_billing",
"billed",
"cancelled",
] as const;
export type WorkOrderStatus = (typeof WORK_ORDER_STATUSES)[number];
const T: Record<WorkOrderStatus, readonly WorkOrderStatus[]> = {
draft: ["review_required", "planned", "assigned", "cancelled"],
review_required: ["planned", "assigned", "cancelled"],
planned: ["assigned", "cancelled"],
assigned: ["accepted", "en_route", "in_progress", "planned", "cancelled"],
accepted: ["en_route", "in_progress", "assigned", "cancelled"],
en_route: ["in_progress", "cancelled"],
in_progress: ["paused", "waiting_material", "daily_report_created", "technically_completed", "cancelled"],
paused: ["in_progress", "en_route", "cancelled"],
waiting_material: ["in_progress", "en_route", "cancelled"],
daily_report_created: ["en_route", "in_progress", "cancelled"],
technically_completed: ["signature_pending", "in_review", "in_progress", "cancelled"],
signature_pending: ["in_review", "cancelled"],
in_review: ["released_for_billing", "in_progress", "cancelled"],
released_for_billing: ["billed", "in_review"],
billed: [],
cancelled: [],
};
export function allowedTransitions(from: WorkOrderStatus): readonly WorkOrderStatus[] {
return T[from];
}
export function canTransition(from: WorkOrderStatus, to: WorkOrderStatus): boolean {
return T[from].includes(to);
}
/** Permission required for a transition (server additionally checks work order scope for field roles). */
export function requiredPermission(from: WorkOrderStatus, to: WorkOrderStatus): string {
if (to === "cancelled") return "work_order:cancel";
if (to === "released_for_billing" || to === "billed") return "work_order:release_billing";
if (from === "released_for_billing" && to === "in_review") return "work_order:release_billing";
if (from === "in_review" && to === "in_progress") return "report:approve_team"; // or report:approve (checked server-side)
if (["draft", "review_required", "planned"].includes(from)) return to === "assigned" ? "work_order:assign" : "work_order:write";
if (from === "assigned" && to === "planned") return "work_order:assign";
return "field:execute";
}
/** Statuses in which field users may record time/material/photos/notes. */
export const FIELD_EDITABLE: readonly WorkOrderStatus[] = [
"assigned",
"accepted",
"en_route",
"in_progress",
"paused",
"waiting_material",
"daily_report_created",
"technically_completed",
"signature_pending",
];
export const OPEN_STATUSES: readonly WorkOrderStatus[] = WORK_ORDER_STATUSES.filter(
(s) => !["billed", "cancelled", "released_for_billing"].includes(s),
);
/** UI groups per Brandbook §12.3 — label keys live in messages/<locale>/workOrders.json → statusGroup.<key>. */
export const STATUS_GROUP: Record<WorkOrderStatus, StatusGroup> = {
draft: "new",
review_required: "new",
planned: "planned",
assigned: "planned",
accepted: "planned",
en_route: "en_route",
in_progress: "in_progress",
paused: "in_progress",
waiting_material: "in_progress",
daily_report_created: "in_progress",
technically_completed: "documentation_incomplete",
signature_pending: "documentation_incomplete",
in_review: "in_review",
released_for_billing: "ready_for_billing",
billed: "billed",
cancelled: "cancelled",
};
export type StatusGroup =
| "new"
| "planned"
| "en_route"
| "in_progress"
| "documentation_incomplete"
| "in_review"
| "ready_for_billing"
| "billed"
| "cancelled";
/** Semantic tone for badges (always combined with text, Brandbook §11.4). */
export const STATUS_GROUP_TONE: Record<StatusGroup, "neutral" | "info" | "accent" | "warning" | "success" | "danger"> = {
new: "neutral",
planned: "info",
en_route: "accent",
in_progress: "accent",
documentation_incomplete: "warning",
in_review: "info",
ready_for_billing: "success",
billed: "success",
cancelled: "danger",
};
export type CompletionBlocker =
| { kind: "checklist_item"; itemId: string; label: string }
| { kind: "photo_requirement"; requirementId: string; label: string }
| { kind: "running_session"; sessionId: string; userId: string }
| { kind: "missing_field"; field: string };