L2 Aufträge & Backoffice: Service-Schicht Aufträge + Tests
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>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
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";
|
||||
}
|
||||
Reference in New Issue
Block a user