From a962fa8be98a1ba3917f10717a78d0af8513dbb8 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:29:42 +0200 Subject: [PATCH] =?UTF-8?q?L2=20Auftr=C3=A4ge=20&=20Backoffice:=20Server?= =?UTF-8?q?=20Actions=20und=20/api/v1/work-orders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../api/v1/work-orders/[id]/assign/route.ts | 22 ++ .../v1/work-orders/[id]/documents/route.ts | 40 +++ .../v1/work-orders/[id]/materials/route.ts | 29 ++ src/app/api/v1/work-orders/[id]/route.ts | 34 ++ .../v1/work-orders/[id]/transition/route.ts | 24 ++ src/app/api/v1/work-orders/_http.ts | 48 +++ src/app/api/v1/work-orders/route.ts | 30 ++ src/server/actions/work_orders/_form.ts | 89 +++++ src/server/actions/work_orders/settings.ts | 135 ++++++++ src/server/actions/work_orders/work-orders.ts | 304 ++++++++++++++++++ 10 files changed, 755 insertions(+) create mode 100644 src/app/api/v1/work-orders/[id]/assign/route.ts create mode 100644 src/app/api/v1/work-orders/[id]/documents/route.ts create mode 100644 src/app/api/v1/work-orders/[id]/materials/route.ts create mode 100644 src/app/api/v1/work-orders/[id]/route.ts create mode 100644 src/app/api/v1/work-orders/[id]/transition/route.ts create mode 100644 src/app/api/v1/work-orders/_http.ts create mode 100644 src/app/api/v1/work-orders/route.ts create mode 100644 src/server/actions/work_orders/_form.ts create mode 100644 src/server/actions/work_orders/settings.ts create mode 100644 src/server/actions/work_orders/work-orders.ts diff --git a/src/app/api/v1/work-orders/[id]/assign/route.ts b/src/app/api/v1/work-orders/[id]/assign/route.ts new file mode 100644 index 0000000..077d166 --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/assign/route.ts @@ -0,0 +1,22 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assignWorkOrder } from "@/server/services/work-orders/assign"; +import { apiContext, apiError, optionalVersion, readJson } from "../../_http"; + +/** POST /api/v1/work-orders/[id]/assign — body { teamId, userIds?, teamLeadUserId?, baseVersion? }. */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const ctx = await apiContext("work_order:assign"); + const body = await readJson(req); + const res = await assignWorkOrder(ctx, { + workOrderId: id, + teamId: String(body.teamId ?? ""), + userIds: Array.isArray(body.userIds) ? body.userIds.map(String) : [], + teamLeadUserId: typeof body.teamLeadUserId === "string" ? body.teamLeadUserId : null, + baseVersion: optionalVersion(body.baseVersion), + }); + return NextResponse.json(res); + } catch (err) { + return apiError(err); + } +} diff --git a/src/app/api/v1/work-orders/[id]/documents/route.ts b/src/app/api/v1/work-orders/[id]/documents/route.ts new file mode 100644 index 0000000..020ab21 --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/documents/route.ts @@ -0,0 +1,40 @@ +import { NextResponse, type NextRequest } from "next/server"; +import type { DocumentCategory, DocumentVisibility } from "@prisma/client"; +import { ServiceError } from "@/server/services/context"; +import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents"; +import { apiContext, apiError } from "../../_http"; + +/** + * POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?). + * Used by the backoffice form (HTML post → 303 back to the documents tab) and by API clients (JSON). + * A route handler instead of a server action avoids the 1 MB server-action body limit. + */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html"); + try { + // CSRF defence for the cookie-authenticated form post: same-origin only. + const origin = req.headers.get("origin"); + if (origin && origin !== req.nextUrl.origin) throw new ServiceError("forbidden", "cross_origin"); + const ctx = await apiContext("document:write"); + const form = await req.formData(); + const file = form.get("file"); + if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing"); + const doc = await uploadWorkOrderDocument(ctx, { + workOrderId: id, + bytes: new Uint8Array(await file.arrayBuffer()), + fileName: file.name, + declaredMime: file.type, + category: String(form.get("category") ?? "other") as DocumentCategory, + visibility: String(form.get("visibility") ?? "team") as DocumentVisibility, + title: typeof form.get("title") === "string" && String(form.get("title")).trim() ? String(form.get("title")).trim() : null, + }); + if (wantsHtml) return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploaded=1`, req.nextUrl.origin), 303); + return NextResponse.json(doc, { status: 201 }); + } catch (err) { + if (wantsHtml && err instanceof ServiceError) { + return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploadError=${encodeURIComponent(err.message)}`, req.nextUrl.origin), 303); + } + return apiError(err); + } +} diff --git a/src/app/api/v1/work-orders/[id]/materials/route.ts b/src/app/api/v1/work-orders/[id]/materials/route.ts new file mode 100644 index 0000000..72db57e --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/materials/route.ts @@ -0,0 +1,29 @@ +import { NextResponse, type NextRequest } from "next/server"; +import type { MaterialPlanInput } from "@/lib/work-orders/schemas"; +import { addMaterialPlan, getMaterialOverview } from "@/server/services/work-orders/materials"; +import { apiContext, apiError, readJson } from "../../_http"; + +type Params = { params: Promise<{ id: string }> }; + +/** GET /api/v1/work-orders/[id]/materials — planned vs. actual incl. deviations. */ +export async function GET(_req: NextRequest, { params }: Params) { + try { + const { id } = await params; + const ctx = await apiContext(); + return NextResponse.json({ items: await getMaterialOverview(ctx, id) }); + } catch (err) { + return apiError(err); + } +} + +/** POST /api/v1/work-orders/[id]/materials — add a material plan item { name, articleNumber?, plannedQuantity, unit, notes? }. */ +export async function POST(req: NextRequest, { params }: Params) { + try { + const { id } = await params; + const ctx = await apiContext("work_order:write"); + const plan = await addMaterialPlan(ctx, id, (await readJson(req)) as MaterialPlanInput); + return NextResponse.json({ ...plan, plannedQuantity: Number(plan.plannedQuantity) }, { status: 201 }); + } catch (err) { + return apiError(err); + } +} diff --git a/src/app/api/v1/work-orders/[id]/route.ts b/src/app/api/v1/work-orders/[id]/route.ts new file mode 100644 index 0000000..dd77e41 --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/route.ts @@ -0,0 +1,34 @@ +import { NextResponse, type NextRequest } from "next/server"; +import type { UpdateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { computeCompletionBlockers } from "@/server/services/work-orders/completion"; +import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail"; +import { updateWorkOrder } from "@/server/services/work-orders/update"; +import { apiContext, apiError, optionalVersion, readJson } from "../_http"; + +type Params = { params: Promise<{ id: string }> }; + +/** GET /api/v1/work-orders/[id] — detail incl. transitions available to the caller and completion blockers. */ +export async function GET(_req: NextRequest, { params }: Params) { + try { + const { id } = await params; + const ctx = await apiContext(); + const wo = await getWorkOrderDetail(ctx, id); + const [blockers] = await Promise.all([computeCompletionBlockers(ctx, id)]); + return NextResponse.json({ workOrder: wo, availableTransitions: availableTransitions(ctx, wo.status), completionBlockers: blockers }); + } catch (err) { + return apiError(err); + } +} + +/** PATCH /api/v1/work-orders/[id] — body: partial master data + optional baseVersion (409 on mismatch). */ +export async function PATCH(req: NextRequest, { params }: Params) { + try { + const { id } = await params; + const ctx = await apiContext(); + const { baseVersion, ...patch } = await readJson(req); + const res = await updateWorkOrder(ctx, id, patch as UpdateWorkOrderInput, optionalVersion(baseVersion)); + return NextResponse.json(res); + } catch (err) { + return apiError(err); + } +} diff --git a/src/app/api/v1/work-orders/[id]/transition/route.ts b/src/app/api/v1/work-orders/[id]/transition/route.ts new file mode 100644 index 0000000..ab0fdf8 --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/transition/route.ts @@ -0,0 +1,24 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; +import { apiContext, apiError, optionalVersion, readJson } from "../../_http"; + +/** + * POST /api/v1/work-orders/[id]/transition — body { to, reason?, baseVersion? }. + * 403 forbidden · 404 not in scope · 409 version conflict · 422 invalid / blocked (details: CompletionBlocker[]). + */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const ctx = await apiContext(); + const body = await readJson(req); + const res = await transitionWorkOrder(ctx, { + workOrderId: id, + to: String(body.to ?? "") as never, + reason: typeof body.reason === "string" ? body.reason : null, + baseVersion: optionalVersion(body.baseVersion), + }); + return NextResponse.json(res); + } catch (err) { + return apiError(err); + } +} diff --git a/src/app/api/v1/work-orders/_http.ts b/src/app/api/v1/work-orders/_http.ts new file mode 100644 index 0000000..76f29f3 --- /dev/null +++ b/src/app/api/v1/work-orders/_http.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { moduleGuard } from "@/server/action-guard"; +import { ModuleDisabledError } from "@/server/modules"; +import { ForbiddenError, type Permission } from "@/server/rbac"; +import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context"; + +/** + * /api/v1/work-orders helpers (lane L2). Authentication/authorisation reuses the DB-authoritative + * moduleGuard (session cookie); the fundament's generic `requireApiContext` does not exist yet — + * see docs/craftvia/lanes/auftraege.md. + */ +export async function apiContext(...permissions: Permission[]): Promise { + const g = await moduleGuard("work_orders")(...permissions); + return ctxFromGuard(g); +} + +const STATUS: Record = { + not_found: 404, + forbidden: 403, + invalid: 422, + conflict: 409, + blocked: 422, +}; + +export function apiError(err: unknown): NextResponse { + if (err instanceof ServiceError) { + return NextResponse.json({ error: { code: err.code, message: err.message, details: err.details ?? null } }, { status: STATUS[err.code] }); + } + if (err instanceof ForbiddenError) return NextResponse.json({ error: { code: "forbidden", message: "forbidden" } }, { status: 403 }); + if (err instanceof ModuleDisabledError) return NextResponse.json({ error: { code: "forbidden", message: "module_disabled" } }, { status: 403 }); + if (err instanceof SyntaxError) return NextResponse.json({ error: { code: "invalid", message: "invalid_json" } }, { status: 400 }); + const msg = err instanceof Error ? err.message : ""; + if (/Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(msg)) { + return NextResponse.json({ error: { code: "unauthorized", message: "unauthorized" } }, { status: 401 }); + } + console.error("[api/v1/work-orders]", err); + return NextResponse.json({ error: { code: "internal", message: "internal" } }, { status: 500 }); +} + +export async function readJson(req: Request): Promise> { + const body = (await req.json()) as unknown; + if (!body || typeof body !== "object" || Array.isArray(body)) throw new ServiceError("invalid", "body_must_be_object"); + return body as Record; +} + +export function optionalVersion(v: unknown): number | undefined { + return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined; +} diff --git a/src/app/api/v1/work-orders/route.ts b/src/app/api/v1/work-orders/route.ts new file mode 100644 index 0000000..9d580f3 --- /dev/null +++ b/src/app/api/v1/work-orders/route.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { parseListParams } from "@/lib/work-orders/filters"; +import type { CreateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { createWorkOrder } from "@/server/services/work-orders/create"; +import { listWorkOrders } from "@/server/services/work-orders/list"; +import { apiContext, apiError, readJson } from "./_http"; + +/** GET /api/v1/work-orders — filters as in the backoffice list (§21), always within workOrderScope. */ +export async function GET(req: NextRequest) { + try { + const ctx = await apiContext(); + return NextResponse.json(await listWorkOrders(ctx, parseListParams(req.nextUrl.searchParams))); + } catch (err) { + return apiError(err); + } +} + +/** POST /api/v1/work-orders — body: CreateWorkOrderInput (dates as ISO strings). */ +export async function POST(req: NextRequest) { + try { + const ctx = await apiContext(); + const body = await readJson(req); + // Import linkage and number keys are reserved for the import/emergency services. + delete body.sourceImportId; + const created = await createWorkOrder(ctx, body as CreateWorkOrderInput); + return NextResponse.json(created, { status: 201 }); + } catch (err) { + return apiError(err); + } +} diff --git a/src/server/actions/work_orders/_form.ts b/src/server/actions/work_orders/_form.ts new file mode 100644 index 0000000..e57ac76 --- /dev/null +++ b/src/server/actions/work_orders/_form.ts @@ -0,0 +1,89 @@ +import { unstable_rethrow } from "next/navigation"; +import type { ActionState } from "@/lib/work-orders/action-state"; +import type { CompletionBlocker } from "@/lib/work-orders/status"; +import { wallTimeToUtc } from "@/lib/work-orders/time"; +import { writeAuditLog } from "@/server/audit"; +import { ModuleDisabledError } from "@/server/modules"; +import { ForbiddenError } from "@/server/rbac"; +import { ServiceError } from "@/server/services/context"; + +/** + * Internal helpers of the work_orders actions (file starts with "_": not an action module, + * skipped by scripts/check-module-guards.ts). Maps thrown errors to a displayable ActionState + * without leaking internals (F-16) and audits RBAC denials. + */ +export async function toErrorState( + err: unknown, + audit: { tenantId?: string; actorId?: string; entity: string; entityId?: string }, +): Promise { + unstable_rethrow(err); + const at = Date.now(); + if (err instanceof ServiceError) { + return { + status: "error", + code: err.code, + message: err.message, + blockers: err.code === "blocked" ? (err.details as CompletionBlocker[]) : undefined, + at, + }; + } + if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) { + if (err instanceof ForbiddenError && audit.tenantId) { + try { + await writeAuditLog({ tenantId: audit.tenantId, actorId: audit.actorId, action: "denied", entity: audit.entity, entityId: audit.entityId, after: { reason: err.message } }); + } catch (auditErr) { + console.error("[work_orders] audit-write-failed", auditErr); + } + } + return { status: "error", code: "forbidden", message: "forbidden", at }; + } + console.error(`[work_orders] ${audit.entity} action failed`, err); + return { status: "error", code: "internal", message: "internal", at }; +} + +export const ok = (): ActionState => ({ status: "ok", at: Date.now() }); + +export function str(fd: FormData, key: string): string | undefined { + const v = fd.get(key); + if (typeof v !== "string") return undefined; + const t = v.trim(); + return t ? t : undefined; +} + +/** Present field → string or null (empty); absent field → undefined. */ +export function nullable(fd: FormData, key: string): string | null | undefined { + if (!fd.has(key)) return undefined; + return str(fd, key) ?? null; +} + +export function bool(fd: FormData, key: string): boolean { + const v = fd.get(key); + return v === "on" || v === "true" || v === "1"; +} + +/** "yes" | "no" | "" select → boolean | undefined */ +export function triState(fd: FormData, key: string): boolean | undefined { + const v = str(fd, key); + return v === "yes" ? true : v === "no" ? false : undefined; +} + +export function int(fd: FormData, key: string): number | undefined { + const v = str(fd, key); + if (v === undefined) return undefined; + const n = Number.parseInt(v, 10); + return Number.isFinite(n) ? n : undefined; +} + +/** datetime-local in tenant timezone → Date; empty → null; absent → undefined. */ +export function wallTime(fd: FormData, key: string, tz: string): Date | null | undefined { + if (!fd.has(key)) return undefined; + const v = str(fd, key); + if (!v) return null; + return wallTimeToUtc(v, tz) ?? null; +} + +/** Only internal detail links are allowed as return targets. */ +export function detailPath(workOrderId: string, tab?: string): string { + const safeTab = tab && /^[a-z]{3,12}$/.test(tab) ? `?tab=${tab}` : ""; + return `/work-orders/${encodeURIComponent(workOrderId)}${safeTab}`; +} diff --git a/src/server/actions/work_orders/settings.ts b/src/server/actions/work_orders/settings.ts new file mode 100644 index 0000000..2a1dbdc --- /dev/null +++ b/src/server/actions/work_orders/settings.ts @@ -0,0 +1,135 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import type { ActionState } from "@/lib/work-orders/action-state"; +import { slugKey } from "@/lib/work-orders/schemas"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard } from "@/server/services/context"; +import { uniqueKey } from "@/server/services/work-orders/_shared"; +import { + createChecklistTemplate, + createOrderType, + updateChecklistTemplate, + updateNumberSequence, + updateOrderType, +} from "@/server/services/work-orders/settings"; +import { bool, int, ok, str, toErrorState } from "./_form"; + +/** Settings of the work order module (order types, checklist templates, numbering) — `settings:templates`. */ +const guard = moduleGuard("work_orders"); + +export async function createOrderTypeAction(_prev: ActionState, fd: FormData): Promise { + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("settings:templates"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + const name = str(fd, "name") ?? ""; + await createOrderType(ctxFromGuard(g), { + key: str(fd, "key") ?? slugKey(name), + name, + signatureRequired: bool(fd, "signatureRequired"), + active: true, + sortOrder: int(fd, "sortOrder") ?? 100, + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "order_type" }); + } + revalidatePath("/settings/order-types"); + return ok(); +} + +export async function updateOrderTypeAction(_prev: ActionState, fd: FormData): Promise { + const id = str(fd, "id") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("settings:templates"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await updateOrderType(ctxFromGuard(g), id, { + name: str(fd, "name") ?? "", + signatureRequired: bool(fd, "signatureRequired"), + active: bool(fd, "active"), + sortOrder: int(fd, "sortOrder") ?? 100, + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "order_type", entityId: id }); + } + revalidatePath("/settings/order-types"); + return ok(); +} + +/** Textarea lines → template items: "*" prefix = required, "[Foto]" suffix = requires photo. */ +function parseItems(text: string) { + const keys = new Set(); + return text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .slice(0, 100) + .map((line) => { + let label = line; + const required = label.startsWith("*"); + if (required) label = label.slice(1).trim(); + const requiresPhoto = /\[foto\]$/i.test(label); + if (requiresPhoto) label = label.replace(/\[foto\]$/i, "").trim(); + return { key: uniqueKey(slugKey(label), keys), label: label.slice(0, 200), required, requiresPhoto }; + }) + .filter((i) => i.label.length > 0); +} + +function parsePhotos(text: string) { + const keys = new Set(); + return text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .slice(0, 30) + .map((label) => ({ key: uniqueKey(slugKey(label), keys), label: label.slice(0, 200) })); +} + +export async function saveChecklistTemplateAction(_prev: ActionState, fd: FormData): Promise { + const id = str(fd, "id"); + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("settings:templates"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + const input = { + name: str(fd, "name") ?? "", + orderTypeId: str(fd, "orderTypeId") ?? null, + active: bool(fd, "active"), + items: parseItems(String(fd.get("items") ?? "")), + requiredPhotos: parsePhotos(String(fd.get("requiredPhotos") ?? "")), + }; + const ctx = ctxFromGuard(g); + if (id) await updateChecklistTemplate(ctx, id, input); + else await createChecklistTemplate(ctx, input); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "checklist_template", entityId: id }); + } + revalidatePath("/settings/checklists"); + return ok(); +} + +export async function updateNumberingAction(_prev: ActionState, fd: FormData): Promise { + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("settings:templates"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await updateNumberSequence(ctxFromGuard(g), { + key: str(fd, "key") ?? "", + prefix: String(fd.get("prefix") ?? "").trim(), + padding: str(fd, "padding") ?? "", + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "number_sequence" }); + } + revalidatePath("/settings/numbering"); + return ok(); +} diff --git a/src/server/actions/work_orders/work-orders.ts b/src/server/actions/work_orders/work-orders.ts new file mode 100644 index 0000000..0fe70f0 --- /dev/null +++ b/src/server/actions/work_orders/work-orders.ts @@ -0,0 +1,304 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import type { ActionState } from "@/lib/work-orders/action-state"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard } from "@/server/services/context"; +import { tenantTimezone } from "@/server/services/work-orders/_shared"; +import { assignWorkOrder } from "@/server/services/work-orders/assign"; +import { addChecklistItem, addPhotoRequirement, applyChecklistTemplate, removeChecklistItem, removePhotoRequirement } from "@/server/services/work-orders/checklist"; +import { applySyncConflict, discardSyncConflict } from "@/server/services/work-orders/conflicts"; +import { createWorkOrder } from "@/server/services/work-orders/create"; +import { archiveWorkOrderDocument } from "@/server/services/work-orders/documents"; +import { addMaterialPlan, removeMaterialPlan } from "@/server/services/work-orders/materials"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; +import { updateWorkOrder } from "@/server/services/work-orders/update"; +import { bool, detailPath, int, nullable, ok, str, toErrorState, triState, wallTime } from "./_form"; + +/** + * Backoffice server actions of module "work_orders" — thin adapters: + * moduleGuard → FormData → service (permission, scope, Zod, audit, event) → revalidate/redirect. + */ +const guard = moduleGuard("work_orders"); + +export async function createWorkOrderAction(_prev: ActionState, fd: FormData): Promise { + let id: string; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + const ctx = ctxFromGuard(g); + const tz = await tenantTimezone(ctx); + const created = await createWorkOrder(ctx, { + title: str(fd, "title") ?? "", + customerId: str(fd, "customerId") ?? "", + siteId: str(fd, "siteId") ?? null, + contactId: str(fd, "contactId") ?? null, + orderTypeId: str(fd, "orderTypeId") ?? null, + priority: (str(fd, "priority") as never) ?? "normal", + status: str(fd, "status") === "planned" ? "planned" : "draft", + description: str(fd, "description"), + scope: str(fd, "scope"), + plannedStart: wallTime(fd, "plannedStart", tz) ?? null, + plannedEnd: wallTime(fd, "plannedEnd", tz) ?? null, + signatureRequired: triState(fd, "signatureRequired"), + billingType: (str(fd, "billingType") as never) ?? null, + internalNotes: str(fd, "internalNotes"), + technicianNotes: str(fd, "technicianNotes"), + externalOrderNumber: str(fd, "externalOrderNumber"), + offerNumber: str(fd, "offerNumber"), + }); + id = created.id; + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order" }); + } + revalidatePath("/work-orders"); + redirect(detailPath(id)); +} + +export async function updateWorkOrderAction(workOrderId: string, _prev: ActionState, fd: FormData): Promise { + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + const ctx = ctxFromGuard(g); + const tz = await tenantTimezone(ctx); + const patch: Record = {}; + for (const k of ["title", "priority", "billingType"]) if (fd.has(k)) patch[k] = str(fd, k) ?? null; + for (const k of ["siteId", "contactId", "orderTypeId", "description", "scope", "internalNotes", "technicianNotes", "externalOrderNumber", "offerNumber"]) { + const v = nullable(fd, k); + if (v !== undefined) patch[k] = v; + } + for (const k of ["plannedStart", "plannedEnd"]) { + const v = wallTime(fd, k, tz); + if (v !== undefined) patch[k] = v; + } + const signature = triState(fd, "signatureRequired"); + if (signature !== undefined) patch.signatureRequired = signature; + if (patch.title === null) patch.title = ""; + await updateWorkOrder(ctx, workOrderId, patch, int(fd, "baseVersion")); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + redirect(detailPath(workOrderId)); +} + +export async function transitionAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + // Permission depends on the concrete transition → decided by the service (mayTransition). + const g = await guard(); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await transitionWorkOrder(ctxFromGuard(g), { + workOrderId, + to: (str(fd, "to") ?? "") as never, + reason: str(fd, "reason") ?? null, + baseVersion: int(fd, "baseVersion"), + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath("/work-orders"); + redirect(detailPath(workOrderId, str(fd, "returnTab"))); +} + +export async function assignAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:assign"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await assignWorkOrder(ctxFromGuard(g), { + workOrderId, + teamId: str(fd, "teamId") ?? "", + userIds: fd.getAll("userIds").filter((v): v is string => typeof v === "string" && v.length > 0), + teamLeadUserId: str(fd, "teamLeadUserId") ?? null, + baseVersion: int(fd, "baseVersion"), + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath("/work-orders"); + redirect(detailPath(workOrderId)); +} + +export async function addMaterialPlanAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await addMaterialPlan(ctxFromGuard(g), workOrderId, { + name: str(fd, "name") ?? "", + articleNumber: str(fd, "articleNumber"), + plannedQuantity: (str(fd, "plannedQuantity") ?? "").replace(",", ".") as never, + unit: str(fd, "unit") ?? "", + notes: str(fd, "notes"), + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function removeMaterialPlanAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await removeMaterialPlan(ctxFromGuard(g), str(fd, "planId") ?? ""); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function addChecklistItemAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await addChecklistItem(ctxFromGuard(g), workOrderId, { + label: str(fd, "label") ?? "", + required: bool(fd, "required"), + requiresPhoto: bool(fd, "requiresPhoto"), + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function removeChecklistItemAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await removeChecklistItem(ctxFromGuard(g), str(fd, "itemId") ?? ""); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function addPhotoRequirementAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await addPhotoRequirement(ctxFromGuard(g), workOrderId, { label: str(fd, "label") ?? "" }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function removePhotoRequirementAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await removePhotoRequirement(ctxFromGuard(g), str(fd, "requirementId") ?? ""); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function applyChecklistTemplateAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await applyChecklistTemplate(ctxFromGuard(g), workOrderId, str(fd, "templateId") ?? ""); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function archiveDocumentAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("document:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await archiveWorkOrderDocument(ctxFromGuard(g), str(fd, "documentId") ?? ""); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "document", entityId: str(fd, "documentId") }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function applySyncConflictAction(_prev: ActionState, fd: FormData): Promise { + const opId = str(fd, "opId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await applySyncConflict(ctxFromGuard(g), opId); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "sync_operation", entityId: opId }); + } + revalidatePath("/work-orders/conflicts"); + return ok(); +} + +export async function discardSyncConflictAction(_prev: ActionState, fd: FormData): Promise { + const opId = str(fd, "opId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await discardSyncConflict(ctxFromGuard(g), opId); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "sync_operation", entityId: opId }); + } + revalidatePath("/work-orders/conflicts"); + return ok(); +}