L2 Aufträge & Backoffice: Server Actions und /api/v1/work-orders

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:29:42 +02:00
co-authored by Claude Opus 5
parent 2311b35d8c
commit a962fa8be9
10 changed files with 755 additions and 0 deletions
@@ -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);
}
}
@@ -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);
}
}
@@ -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);
}
}
+34
View File
@@ -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);
}
}
@@ -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);
}
}
+48
View File
@@ -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<ServiceCtx> {
const g = await moduleGuard("work_orders")(...permissions);
return ctxFromGuard(g);
}
const STATUS: Record<ServiceError["code"], number> = {
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<Record<string, unknown>> {
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<string, unknown>;
}
export function optionalVersion(v: unknown): number | undefined {
return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined;
}
+30
View File
@@ -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);
}
}
+89
View File
@@ -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<ActionState> {
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}`;
}
+135
View File
@@ -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<ActionState> {
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<ActionState> {
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<string>();
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<string>();
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<ActionState> {
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<ActionState> {
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();
}
@@ -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<ActionState> {
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<ActionState> {
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<string, unknown> = {};
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<ActionState> {
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<ActionState> {
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<ActionState> {
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<ActionState> {
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<ActionState> {
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<ActionState> {
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<ActionState> {
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<ActionState> {
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<ActionState> {
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<ActionState> {
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<ActionState> {
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<ActionState> {
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();
}