import { z } from "zod"; import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status"; /** * Zod input schemas of the work order module (client-safe). Services parse with these; * server actions and /api/v1 handlers only map FormData/JSON onto them. */ export const WORK_ORDER_PRIORITIES = ["low", "normal", "high", "urgent"] as const; export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITIES)[number]; export const BILLING_TYPES = ["fixed", "time_material", "maintenance_contract", "warranty"] as const; export type BillingType = (typeof BILLING_TYPES)[number]; const id = z.string().min(1).max(64); const optId = id.nullish(); const text = (max: number) => z.string().trim().max(max); const optText = (max: number) => z .string() .trim() .max(max) .nullish() .transform((v) => (v ? v : null)); const optDate = z.coerce.date().nullish(); export const materialPlanInputSchema = z.object({ name: text(200).min(1), articleNumber: optText(80), plannedQuantity: z.coerce.number().positive().max(1_000_000), unit: text(20).min(1), notes: optText(1000), sortOrder: z.coerce.number().int().min(0).max(10_000).optional(), }); export type MaterialPlanInput = z.input; export const checklistItemInputSchema = z.object({ key: z .string() .trim() .max(60) .regex(/^[a-z0-9_]+$/) .optional(), label: text(200).min(1), required: z.boolean().default(false), requiresPhoto: z.boolean().default(false), sortOrder: z.coerce.number().int().min(0).max(10_000).optional(), }); export type ChecklistItemInput = z.input; export const photoRequirementInputSchema = z.object({ key: z .string() .trim() .max(60) .regex(/^[a-z0-9_]+$/) .optional(), label: text(200).min(1), sortOrder: z.coerce.number().int().min(0).max(10_000).optional(), }); export type PhotoRequirementInput = z.input; /** Statuses a work order may be created in (import → review_required, emergency → in_progress via L8). */ export const INITIAL_STATUSES = ["draft", "review_required", "planned", "in_progress"] as const; export const createWorkOrderSchema = z .object({ title: text(200).min(1), customerId: id, siteId: optId, contactId: optId, orderTypeId: optId, priority: z.enum(WORK_ORDER_PRIORITIES).default("normal"), status: z.enum(INITIAL_STATUSES).default("draft"), description: optText(10_000), scope: optText(10_000), plannedStart: optDate, plannedEnd: optDate, /** undefined → taken from the order type (default true) */ signatureRequired: z.boolean().optional(), billingType: z.enum(BILLING_TYPES).nullish(), internalNotes: optText(5000), technicianNotes: optText(5000), externalOrderNumber: optText(80), offerNumber: optText(80), isEmergency: z.boolean().default(false), emergencyReason: optText(2000), sourceImportId: optId, /** "emergency" allocates from the N- sequence (lane emergency). */ numberKey: z.enum(["work_order", "emergency"]).default("work_order"), /** Copy checklist / photo requirements from the order type's active template (default true). */ applyTemplate: z.boolean().default(true), materials: z.array(materialPlanInputSchema).max(200).optional(), checklistItems: z.array(checklistItemInputSchema).max(200).optional(), photoRequirements: z.array(photoRequirementInputSchema).max(50).optional(), }) .refine((v) => !v.plannedStart || !v.plannedEnd || v.plannedEnd >= v.plannedStart, { path: ["plannedEnd"], message: "plannedEnd_before_start", }); /** Input type of createWorkOrder — used by lanes imports (L3) and emergency (L8). */ export type CreateWorkOrderInput = z.input; export const updateWorkOrderSchema = z .object({ title: text(200).min(1).optional(), customerId: id.optional(), siteId: optId, contactId: optId, orderTypeId: optId, priority: z.enum(WORK_ORDER_PRIORITIES).optional(), description: optText(10_000).optional(), scope: optText(10_000).optional(), plannedStart: optDate, plannedEnd: optDate, signatureRequired: z.boolean().optional(), billingType: z.enum(BILLING_TYPES).nullish(), internalNotes: optText(5000).optional(), technicianNotes: optText(5000).optional(), externalOrderNumber: optText(80).optional(), offerNumber: optText(80).optional(), emergencyReason: optText(2000).optional(), }) .strict(); export type UpdateWorkOrderInput = z.input; export const transitionSchema = z.object({ workOrderId: id, to: z.enum(WORK_ORDER_STATUSES), reason: optText(2000), baseVersion: z.coerce.number().int().positive().optional(), }); export type TransitionInput = z.input & { /** Extra facts merged into the emitted event's `data` (e.g. reportId from lane reports). Never overrides number/from/to. */ eventData?: Record; }; export const assignSchema = z.object({ workOrderId: id, teamId: id, userIds: z.array(id).max(50).default([]), teamLeadUserId: optId, baseVersion: z.coerce.number().int().positive().optional(), }); export type AssignInput = z.input; // ---------- Settings ---------- export const orderTypeSchema = z.object({ key: z .string() .trim() .min(2) .max(40) .regex(/^[a-z0-9_]+$/), name: text(80).min(1), signatureRequired: z.boolean().default(true), active: z.boolean().default(true), sortOrder: z.coerce.number().int().min(0).max(10_000).default(100), }); export type OrderTypeInput = z.input; export const templateItemSchema = z.object({ key: z .string() .trim() .min(1) .max(60) .regex(/^[a-z0-9_]+$/), label: text(200).min(1), required: z.boolean().default(false), requiresPhoto: z.boolean().default(false), }); export const templatePhotoSchema = z.object({ key: z .string() .trim() .min(1) .max(60) .regex(/^[a-z0-9_]+$/), label: text(200).min(1), }); export type TemplateItem = z.output; export type TemplatePhoto = z.output; export const checklistTemplateSchema = z.object({ name: text(120).min(1), orderTypeId: optId, active: z.boolean().default(true), items: z.array(templateItemSchema).max(100), requiredPhotos: z.array(templatePhotoSchema).max(30), }); export type ChecklistTemplateInput = z.input; export const NUMBER_KEYS = ["work_order", "emergency", "customer", "report"] as const; export const numberingSchema = z.object({ key: z.enum(NUMBER_KEYS), prefix: z .string() .trim() .max(12) .regex(/^[A-Za-z0-9\-_/]*$/), padding: z.coerce.number().int().min(1).max(10), }); /** Stable key from a free label (umlauts transliterated). */ export function slugKey(label: string): string { const s = label .toLowerCase() .replace(/ä/g, "ae") .replace(/ö/g, "oe") .replace(/ü/g, "ue") .replace(/ß/g, "ss") .replace(/[^a-z0-9]+/g, "_") .replace(/^_+|_+$/g, "") .slice(0, 60); return s || "punkt"; }