import { z } from "zod"; import { NOTE_KINDS, TIME_TYPES } from "@/lib/sync/ops"; import { LOTSE_TEXT_FIELDS, LOTSE_TEXT_MAX } from "@/lib/lotse/content"; /** * Lotse chat for technicians (lane L16) — client-safe contract: proposal kinds and their payload * schemas, structured message parts, action state of the chat server actions. * * Principle: the Lotse never executes anything. Proposal tools create a `LotseActionProposal` * whose payload is validated with these schemas; only the owner's confirmation runs the existing * services (services/lotse/chat/confirm.ts). */ export const PROPOSAL_TTL_MINUTES = 30; export const CHAT_TEXT_MAX = 4_000; export const PROPOSAL_KINDS = ["transition_work_order", "book_time", "record_material", "add_note", "suggest_report_fields"] as const; export type ProposalKind = (typeof PROPOSAL_KINDS)[number]; /** Execution order of „Alle bestätigen": bookings first, the status change (completion) last. */ export const PROPOSAL_ORDER: Record = { book_time: 1, record_material: 2, add_note: 3, suggest_report_fields: 4, transition_work_order: 5, }; export const TRANSITION_ACTIONS = ["accept", "start_travel", "start_work", "pause", "resume", "complete"] as const; export type TransitionAction = (typeof TRANSITION_ACTIONS)[number]; /** Time types the chat may book (breaks are not booked via chat). */ export const CHAT_TIME_TYPES = ["work", "travel", "return_travel", "material_procurement"] as const satisfies readonly (typeof TIME_TYPES)[number][]; const id = z.string().min(1).max(64); const number = z.string().min(1).max(64); const dateKey = z.string().regex(/^\d{4}-\d{2}-\d{2}$/); const clock = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/); export const transitionPayloadSchema = z.object({ workOrderId: id, number, action: z.enum(TRANSITION_ACTIONS), /** L12 auto switch: number of the order whose running clock is paused on confirmation */ switchFrom: number.nullish(), }); export const bookTimePayloadSchema = z .object({ workOrderId: id, number, type: z.enum(CHAT_TIME_TYPES), date: dateKey, /** local time HH:MM (tenant time zone) — either from/to or durationMinutes */ from: clock.nullish(), to: clock.nullish(), durationMinutes: z.number().int().min(1).max(16 * 60).nullish(), /** L12: mandatory reason of a manual entry */ reason: z.string().trim().min(3).max(500), note: z.string().trim().max(2000).nullish(), }) .refine((v) => (v.from && v.to) || v.durationMinutes, { message: "from/to or durationMinutes required", path: ["durationMinutes"] }); export const recordMaterialPayloadSchema = z.object({ workOrderId: id, number, materialPlanId: id.nullish(), name: z.string().trim().min(1).max(200), articleNumber: z.string().trim().max(100).nullish(), quantity: z.number().min(0).max(1_000_000), unit: z.string().trim().min(1).max(20), usageStatus: z.enum(["fully_used", "partially_used", "not_used", "additional"]), deviationReason: z.string().trim().max(2000).nullish(), }); export const addNotePayloadSchema = z.object({ workOrderId: id, number, kind: z.enum(NOTE_KINDS), text: z.string().trim().min(1).max(10_000), }); export const reportFieldsPayloadSchema = z.object({ workOrderId: id, number, reportId: id, reportType: z.enum(["daily", "completion"]), fields: z .array(z.object({ field: z.enum(LOTSE_TEXT_FIELDS), text: z.string().trim().min(1).max(LOTSE_TEXT_MAX) })) .min(1) .max(LOTSE_TEXT_FIELDS.length), }); export const PROPOSAL_SCHEMAS = { transition_work_order: transitionPayloadSchema, book_time: bookTimePayloadSchema, record_material: recordMaterialPayloadSchema, add_note: addNotePayloadSchema, suggest_report_fields: reportFieldsPayloadSchema, } as const; export type ProposalPayload = z.output<(typeof PROPOSAL_SCHEMAS)[K]>; /** Fields a technician may change before confirming („Bearbeiten"); ids/number stay fixed. */ export const EDITABLE_FIELDS: Record = { transition_work_order: [], book_time: ["type", "date", "from", "to", "durationMinutes", "reason", "note"], record_material: ["name", "quantity", "unit", "deviationReason"], add_note: ["kind", "text"], suggest_report_fields: ["fields"], }; export const PROPOSAL_STATUSES = ["proposed", "confirmed", "discarded", "expired", "failed"] as const; export type ProposalStatus = (typeof PROPOSAL_STATUSES)[number]; /** Chip = quick answer; `value` is sent as the next user message. */ export type ChatChip = { label: string; value: string }; /** Jump target (e.g. missing mandatory photo → /m/orders//photos). `labelKey` = messages lotse.chat.links.* */ export type ChatLink = { labelKey: string; label?: string; href: string }; export type ChatMessageContent = { chips?: ChatChip[]; proposalIds?: string[]; links?: ChatLink[]; /** assistant text exactly as produced by the model (with placeholders) — history for the next turn */ modelText?: string; /** user text as sent to the model (minimised) */ sentText?: string; /** i18n key for server-generated messages (lotse.chat.system.*) */ noticeKey?: string; noticeValues?: Record; /** rounds of the tool loop (transparency) */ rounds?: number; }; export type ChatProposalView = { id: string; kind: ProposalKind; status: ProposalStatus; payload: Record; expiresAt: string; errorCode: string | null; result: Record | null; workOrderId: string | null; }; export type ChatMessageView = { id: string; role: "user" | "assistant" | "tool"; text: string; createdAt: string; content: ChatMessageContent; proposals: ChatProposalView[]; }; export type ChatView = { conversationId: string | null; workOrder: { id: string; number: string; title: string } | null; messages: ChatMessageView[]; }; export const LOTSE_CHAT_ERROR_CODES = [ "generic", "not_found", "forbidden", "disabled", "chat_disabled", "not_configured", "provider_failed", "budget_exceeded", "invalid", "conflict", "expired", "already_decided", "tampered", "blocked", "offline", ] as const; export type LotseChatErrorCode = (typeof LOTSE_CHAT_ERROR_CODES)[number]; export type LotseChatActionState = | { status: "idle" } | { status: "ok"; at: number; view: ChatView } | { status: "error"; code: LotseChatErrorCode; at: number; view?: ChatView }; export const LOTSE_CHAT_IDLE: LotseChatActionState = { status: "idle" };