Statusmaschine (transitionWorkOrder inkl. eventData), Zuweisung, Completion-Guards, Materialvorgabe, Checklisten/Pflichtfotos, Liste/Dashboard-Presets, Suche, Sync-Konflikte, Einstellungen (Auftragsarten, Vorlagen, Nummernkreise). Tests: Übergangsmatrix je Rolle, Kernlogik, Scope/Mandantentrennung, Nummernkreis-Parallelität. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
220 lines
7.1 KiB
TypeScript
220 lines
7.1 KiB
TypeScript
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<typeof materialPlanInputSchema>;
|
|
|
|
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<typeof checklistItemInputSchema>;
|
|
|
|
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<typeof photoRequirementInputSchema>;
|
|
|
|
/** 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<typeof createWorkOrderSchema>;
|
|
|
|
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<typeof updateWorkOrderSchema>;
|
|
|
|
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<typeof transitionSchema> & {
|
|
/** Extra facts merged into the emitted event's `data` (e.g. reportId from lane reports). Never overrides number/from/to. */
|
|
eventData?: Record<string, string | number | boolean | null>;
|
|
};
|
|
|
|
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<typeof assignSchema>;
|
|
|
|
// ---------- 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<typeof orderTypeSchema>;
|
|
|
|
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<typeof templateItemSchema>;
|
|
export type TemplatePhoto = z.output<typeof templatePhotoSchema>;
|
|
|
|
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<typeof checklistTemplateSchema>;
|
|
|
|
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";
|
|
}
|