From f173563424aaa3def6d76108e5a8a06657e8edd0 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 13:12:33 +0200 Subject: [PATCH 1/5] =?UTF-8?q?L8=20Notdienst:=20Services=20f=C3=BCr=20Erf?= =?UTF-8?q?assung,=20Suche,=20Abschluss-Event=20und=20Sync-Op?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createEmergencyOrder in einer Transaktion (vorläufiger Kunde/Objekt, Auftrag N-… in_progress, Team/Zuweisung, WorkSession), searchCustomersForEmergency mit minimalem Feldumfang + Audit, emergency.completed nach Abschlussbericht, Sync-Op emergency.create (Registry + Zod-Schema), Review-Services. Co-Authored-By: Claude Opus 5 --- src/lib/emergency/schemas.ts | 107 +++++++ src/lib/sync/ops.ts | 3 +- src/server/services/emergency/completion.ts | 66 ++++ src/server/services/emergency/create.ts | 290 ++++++++++++++++++ src/server/services/emergency/lookup.ts | 102 +++++++ src/server/services/emergency/review.ts | 316 ++++++++++++++++++++ src/server/services/emergency/sync-ops.ts | 26 ++ src/server/services/reports/submit.ts | 2 + src/server/services/sync/external-ops.ts | 2 +- 9 files changed, 912 insertions(+), 2 deletions(-) create mode 100644 src/lib/emergency/schemas.ts create mode 100644 src/server/services/emergency/completion.ts create mode 100644 src/server/services/emergency/create.ts create mode 100644 src/server/services/emergency/lookup.ts create mode 100644 src/server/services/emergency/review.ts create mode 100644 src/server/services/emergency/sync-ops.ts diff --git a/src/lib/emergency/schemas.ts b/src/lib/emergency/schemas.ts new file mode 100644 index 0000000..0497cf9 --- /dev/null +++ b/src/lib/emergency/schemas.ts @@ -0,0 +1,107 @@ +import { z } from "zod"; + +/** + * Client-safe input schemas of the emergency module (lane L8, spec §19). + * `emergencyCreatePayload` is both the sync op payload (`emergency.create`, src/lib/sync/ops.ts) + * and the input of `createEmergencyOrder`. The client ids make the op idempotent: a replay with + * the same `clientIds.session` returns the order that was already created. + */ + +const id = z.string().min(1).max(64); +const clientId = z.string().uuid(); +const req = (max: number) => z.string().trim().min(1).max(max); +const opt = (max: number) => + z + .string() + .trim() + .max(max) + .nullish() + .transform((v) => (v ? v : null)); + +export const EMERGENCY_REASON_MAX = 2000; + +const newCustomer = z.object({ + mode: z.literal("new"), + companyName: opt(200), + firstName: opt(100), + lastName: opt(100), + phone: req(50), + email: z + .string() + .trim() + .max(200) + .nullish() + .transform((v) => (v ? v.toLowerCase() : null)) + .pipe(z.email().nullable()), + street: opt(200), + houseNumber: opt(20), + postalCode: opt(12), + city: opt(100), +}); + +const existingCustomer = z.object({ mode: z.literal("existing"), customerId: id }); + +const newSite = z.object({ + mode: z.literal("new"), + name: opt(200), + street: req(200), + houseNumber: opt(20), + postalCode: opt(12), + city: req(100), +}); + +const existingSite = z.object({ mode: z.literal("existing"), siteId: id }); + +export const emergencyCreatePayload = z + .object({ + clientIds: z.object({ + workOrder: clientId, + session: clientId, + customer: clientId.optional(), + site: clientId.optional(), + }), + customer: z.discriminatedUnion("mode", [existingCustomer, newCustomer]), + site: z.discriminatedUnion("mode", [existingSite, newSite]), + onSiteContact: z.object({ name: req(200), phone: req(50) }), + reason: req(EMERGENCY_REASON_MAX), + /** Einsatzbeginn (default: now) */ + startedAt: z.string().datetime({ offset: true }).optional(), + /** null/undefined → the user's own team (first active team) */ + teamId: id.nullish(), + /** colleagues of the chosen team; the creating user is always assigned */ + assigneeIds: z.array(id).max(20).default([]), + offline: z.boolean().default(false), + deviceInfo: z.string().max(200).optional(), + }) + .superRefine((v, c) => { + if (v.customer.mode === "new" && !v.customer.companyName && !v.customer.lastName) { + c.addIssue({ code: "custom", path: ["customer", "lastName"], message: "name_required" }); + } + }); + +export type EmergencyCreateInput = z.input; +export type EmergencyCreateParsed = z.output; + +/** Result keys of the op's idMap besides the client ids. */ +export type EmergencyCreateResult = { + workOrderId: string; + number: string; + customerId: string; + siteId: string; + contactId: string; + sessionId: string; + version: number; + replayed: boolean; +}; + +/** Backoffice: "Auftrag ergänzen". */ +export const emergencyOrderPatchSchema = z.object({ + title: req(200), + description: opt(10_000), + orderTypeId: id.nullish(), + billingType: z.enum(["fixed", "time_material", "maintenance_contract", "warranty"]).nullish(), +}); + +/** The five review steps shown as "x von 5 Prüfschritten erledigt". */ +export const REVIEW_STEPS = ["customer", "site", "order", "report", "billing"] as const; +export type ReviewStep = (typeof REVIEW_STEPS)[number]; diff --git a/src/lib/sync/ops.ts b/src/lib/sync/ops.ts index 3f8301e..6bcd6e6 100644 --- a/src/lib/sync/ops.ts +++ b/src/lib/sync/ops.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { SyncOpType } from "./envelope"; import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status"; +import { emergencyCreatePayload } from "@/lib/emergency/schemas"; /** * Payload schemas per sync opType (ARCHITEKTUR §4.6). Client-safe: used by the mobile UI to @@ -129,7 +130,7 @@ export const OP_PAYLOAD_SCHEMAS = { "report.save_draft": passthrough, "report.submit": passthrough, "signature.capture": passthrough, - "emergency.create": passthrough, + "emergency.create": emergencyCreatePayload, } satisfies Record; export type OpPayload = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>; diff --git a/src/server/services/emergency/completion.ts b/src/server/services/emergency/completion.ts new file mode 100644 index 0000000..1d58f85 --- /dev/null +++ b/src/server/services/emergency/completion.ts @@ -0,0 +1,66 @@ +import { emitEvent } from "@/server/events"; +import type { ServiceCtx } from "@/server/services/context"; +import { customerDisplayName } from "@/server/services/customers/format"; + +/** + * Spec §19.4: after the completion report of an emergency order was submitted, the backoffice is + * informed automatically (event `emergency.completed`, mandatory mail via L6). + * Data: Monteur, Kunde, Einsatzbeginn, Einsatzende, Status. + */ + +export type EmergencyCompletedData = { + number: string; + technician: string | null; + customer: string; + startedAt: string; + endedAt: string; + status: string; +}; + +export async function buildEmergencyCompletedData(ctx: ServiceCtx, workOrderId: string): Promise { + const wo = await ctx.db.workOrder.findFirst({ + where: { id: workOrderId, isEmergency: true, deletedAt: null }, + select: { + number: true, + status: true, + createdAt: true, + plannedStart: true, + createdById: true, + customer: { select: { companyName: true, firstName: true, lastName: true } }, + workSessions: { select: { startedAt: true, endedAt: true }, orderBy: { startedAt: "asc" } }, + }, + }); + if (!wo) return null; + const creator = wo.createdById ? await ctx.db.user.findFirst({ where: { id: wo.createdById }, select: { name: true } }) : null; + const starts = wo.workSessions.map((s) => s.startedAt.getTime()); + const ends = wo.workSessions.map((s) => (s.endedAt ?? new Date()).getTime()); + const startedAt = starts.length ? new Date(Math.min(...starts)) : (wo.plannedStart ?? wo.createdAt); + const endedAt = ends.length ? new Date(Math.max(...ends)) : new Date(); + return { + number: wo.number, + technician: creator?.name ?? null, + customer: customerDisplayName(wo.customer), + startedAt: startedAt.toISOString(), + endedAt: endedAt.toISOString(), + status: wo.status, + }; +} + +/** + * Hook called by the reports lane after a completion report (v1) was submitted and the order was + * advanced (→ in_review / signature_pending). No-op for regular orders; never throws. + */ +export async function onCompletionReportSubmitted(ctx: ServiceCtx, workOrderId: string, occurrenceId?: string): Promise { + try { + const data = await buildEmergencyCompletedData(ctx, workOrderId); + if (!data) return; + await emitEvent(ctx, { + type: "emergency.completed", + entityType: "work_order", + entityId: workOrderId, + data: { ...data, ...(occurrenceId ? { occurrenceId } : {}) }, + }); + } catch (err) { + console.error(`[emergency] completion hook for ${workOrderId} failed:`, (err as Error).message); + } +} diff --git a/src/server/services/emergency/create.ts b/src/server/services/emergency/create.ts new file mode 100644 index 0000000..24e42dc --- /dev/null +++ b/src/server/services/emergency/create.ts @@ -0,0 +1,290 @@ +import { ZodError } from "zod"; +import { emergencyCreatePayload, type EmergencyCreateInput, type EmergencyCreateResult } from "@/lib/emergency/schemas"; +import { writeAuditLog } from "@/server/audit"; +import { emitEvent } from "@/server/events"; +import { assertCan, can, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { startSession } from "@/server/services/field/sessions"; +import { createWorkOrder as realCreateWorkOrder } from "@/server/services/work-orders/create"; +import { ensureDefaultOrderTypes } from "@/server/services/work-orders/settings"; +import { activeTeamIds } from "@/server/services/work-orders/visibility"; + +/** + * Notdienst-Erfassung (spec §19, US-010, ARCHITEKTUR §6 L8). + * + * `createEmergencyOrder` runs in ONE transaction (inTransaction): + * provisional customer (status provisional, no customer number until confirmation) or existing customer + * → provisional site (Einsatzadresse) or existing site of the customer + * → on-site contact (reused if name + phone match) + * → work order via createWorkOrder (isEmergency, order type `notdienst`, number from `emergency`, status in_progress) + * → team / assignees → WorkSession start (L4 startSession, idempotent over clientIds.session). + * Afterwards the event `emergency.created` is emitted. A replay with the same session client id returns + * the order created before (offline sync retries with a new clientOpId). + */ + +export type CreateEmergencyDeps = { createWorkOrder: typeof realCreateWorkOrder }; +const DEFAULT_DEPS: CreateEmergencyDeps = { createWorkOrder: realCreateWorkOrder }; + +function parse(raw: unknown) { + try { + return emergencyCreatePayload.parse(raw); + } catch (err) { + if (err instanceof ZodError) { + throw new ServiceError( + "invalid", + "validation_failed", + err.issues.map((i) => ({ path: i.path.join("."), code: i.message })), + ); + } + throw err; + } +} + +/** Client timestamp of the start; future values (clock skew) are clamped to now. */ +function startTime(at: string | undefined): Date { + const now = new Date(); + if (!at) return now; + const d = new Date(at); + return d.getTime() > now.getTime() + 60_000 ? now : d; +} + +function firstLine(text: string, max = 120): string { + const line = text.split(/\r?\n/).find((l) => l.trim())?.trim() ?? text.trim(); + return line.length > max ? `${line.slice(0, max - 1)}…` : line; +} + +async function replay(ctx: ServiceCtx, sessionClientId: string): Promise { + const session = await ctx.db.workSession.findFirst({ + where: { clientId: sessionClientId }, + select: { + id: true, + userId: true, + workOrder: { select: { id: true, number: true, customerId: true, siteId: true, contactId: true, version: true, isEmergency: true } }, + }, + }); + if (!session) return null; + const wo = session.workOrder; + if (session.userId !== ctx.userId || !wo.isEmergency) throw new ServiceError("invalid", "client_id_already_used"); + return { + workOrderId: wo.id, + number: wo.number, + customerId: wo.customerId, + siteId: wo.siteId ?? "", + contactId: wo.contactId ?? "", + sessionId: session.id, + version: wo.version, + replayed: true, + }; +} + +async function resolveTeam(ctx: ServiceCtx, requested: string | null | undefined) { + const own = await activeTeamIds(ctx); + let teamId: string | null = requested ?? null; + if (teamId && !own.includes(teamId) && !can(ctx, "work_order:read_all")) { + throw new ServiceError("invalid", "team_not_allowed", { field: "teamId", reason: "team_not_allowed" }); + } + if (!teamId && own.length) { + const teams = await ctx.db.team.findMany({ where: { id: { in: own } }, orderBy: { name: "asc" }, select: { id: true } }); + teamId = teams[0]?.id ?? null; + } + if (!teamId) return null; + const team = await ctx.db.team.findFirst({ + where: { id: teamId, status: "active", deletedAt: null }, + select: { id: true, leaderUserId: true }, + }); + if (!team) throw new ServiceError("invalid", "team_not_allowed", { field: "teamId", reason: "team_not_allowed" }); + return team; +} + +async function resolveAssignees(ctx: ServiceCtx, team: { id: string; leaderUserId: string | null } | null, requested: string[]) { + const others = [...new Set(requested.filter((u) => u !== ctx.userId))]; + if (others.length === 0) return [ctx.userId]; + if (!team) throw new ServiceError("invalid", "assignee_not_allowed", { field: "assigneeIds", reason: "assignee_not_allowed" }); + const now = new Date(); + const members = await ctx.db.teamMember.findMany({ + where: { + teamId: team.id, + userId: { in: others }, + validFrom: { lte: now }, + OR: [{ validTo: null }, { validTo: { gt: now } }], + user: { status: "ACTIVE" }, + }, + select: { userId: true }, + }); + const allowed = new Set(members.map((m) => m.userId)); + if (team.leaderUserId) allowed.add(team.leaderUserId); + if (others.some((u) => !allowed.has(u))) { + throw new ServiceError("invalid", "assignee_not_allowed", { field: "assigneeIds", reason: "assignee_not_allowed" }); + } + return [ctx.userId, ...others]; +} + +export async function createEmergencyOrder( + ctx: ServiceCtx, + raw: EmergencyCreateInput, + deps: CreateEmergencyDeps = DEFAULT_DEPS, +): Promise { + assertCan(ctx, "emergency:create"); + assertCan(ctx, "field:execute"); + const input = parse(raw); + + const prior = await replay(ctx, input.clientIds.session); + if (prior) return prior; + + const startedAt = startTime(input.startedAt); + const team = await resolveTeam(ctx, input.teamId); + const assignees = await resolveAssignees(ctx, team, input.assigneeIds); + await ensureDefaultOrderTypes(ctx.db, ctx.tenantId); + const orderType = await ctx.db.orderType.findFirst({ where: { key: "notdienst", active: true }, select: { id: true } }); + + const audits: Parameters[0][] = []; + const audit = (entity: string, entityId: string, after: unknown) => + audits.push({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity, entityId, after }); + + const result = await inTransaction(ctx, async (tx) => { + // 1. customer + let customerId: string; + if (input.customer.mode === "existing") { + // technicians may pick ANY customer of the tenant for an emergency (searchCustomersForEmergency) + const c = await tx.db.customer.findFirst({ + where: { id: input.customer.customerId, deletedAt: null, status: { in: ["active", "provisional"] } }, + select: { id: true }, + }); + if (!c) throw new ServiceError("invalid", "customer_not_found", { field: "customerId", reason: "customer_not_found" }); + customerId = c.id; + } else { + const { mode: _mode, ...fields } = input.customer; + void _mode; + const c = await tx.db.customer.create({ + data: { + ...fields, + tenantId: ctx.tenantId, + customerNumber: null, // allocated on confirmation by the backoffice + country: "DE", + status: "provisional", + isProvisional: true, + createdById: ctx.userId, + }, + }); + customerId = c.id; + audit("customer", c.id, { ...c, source: "emergency" }); + } + + // 2. on-site contact (Ansprechpartner vor Ort) — reused when name and phone match + const contactName = input.onSiteContact.name; + const contactPhone = input.onSiteContact.phone; + const existingContact = await tx.db.contact.findFirst({ + where: { customerId, deletedAt: null, name: { equals: contactName, mode: "insensitive" }, OR: [{ phone: contactPhone }, { mobile: contactPhone }] }, + select: { id: true }, + }); + let contactId = existingContact?.id; + if (!contactId) { + const contact = await tx.db.contact.create({ + data: { tenantId: ctx.tenantId, customerId, name: contactName, phone: contactPhone, preferredChannel: "phone" }, + }); + contactId = contact.id; + audit("contact", contact.id, { ...contact, source: "emergency" }); + } + + // 3. site (Objekt oder Einsatzadresse) + let siteId: string; + if (input.site.mode === "existing") { + const s = await tx.db.site.findFirst({ where: { id: input.site.siteId, customerId, deletedAt: null }, select: { id: true } }); + if (!s) throw new ServiceError("invalid", "site_not_found", { field: "siteId", reason: "site_not_found" }); + siteId = s.id; + } else { + const { mode: _mode, name, ...address } = input.site; + void _mode; + const label = name ?? [[address.street, address.houseNumber].filter(Boolean).join(" "), address.city].filter(Boolean).join(", "); + const s = await tx.db.site.create({ + data: { + ...address, + tenantId: ctx.tenantId, + customerId, + name: label.slice(0, 200), + country: "DE", + contactId, + onSiteContact: contactName, + phone: contactPhone, + status: "provisional", + }, + }); + siteId = s.id; + audit("site", s.id, { ...s, source: "emergency" }); + } + + // 4. work order (L2) — number N-…, status in_progress + const wo = await deps.createWorkOrder(tx, { + title: firstLine(input.reason), + customerId, + siteId, + contactId, + orderTypeId: orderType?.id ?? null, + priority: "urgent", + status: "in_progress", + description: input.reason, + plannedStart: startedAt, + isEmergency: true, + emergencyReason: input.reason, + numberKey: "emergency", + }); + + // 5. team + assignees (the creator is always assigned) + await tx.db.workOrder.update({ + where: { id: wo.id }, + data: { + assignedTeamId: team?.id ?? null, + teamLeadUserId: team?.leaderUserId ?? null, + assignees: { create: assignees.map((userId) => ({ tenantId: ctx.tenantId, userId })) }, + }, + }); + + // 6. work session (L4) — order already in_progress, so no further transition + const session = await startSession(tx, { + workOrderId: wo.id, + clientId: input.clientIds.session, + mode: "work", + at: startedAt.toISOString(), + offline: input.offline, + deviceInfo: input.deviceInfo, + }); + + return { + workOrderId: wo.id, + number: wo.number, + customerId, + siteId, + contactId, + sessionId: session.sessionId, + version: session.workOrderVersion, + replayed: false, + } satisfies EmergencyCreateResult; + }); + + for (const a of audits) await writeAuditLog(a); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "create", + entity: "emergency", + entityId: result.workOrderId, + after: { + number: result.number, + customerId: result.customerId, + customerMode: input.customer.mode, + siteId: result.siteId, + siteMode: input.site.mode, + contactId: result.contactId, + teamId: team?.id ?? null, + assignees, + startedAt, + offline: input.offline, + }, + }); + await emitEvent(ctx, { + type: "emergency.created", + entityType: "work_order", + entityId: result.workOrderId, + data: { number: result.number, startedAt: startedAt.toISOString() }, + }); + return result; +} diff --git a/src/server/services/emergency/lookup.ts b/src/server/services/emergency/lookup.ts new file mode 100644 index 0000000..3970ad1 --- /dev/null +++ b/src/server/services/emergency/lookup.ts @@ -0,0 +1,102 @@ +import { z } from "zod"; +import { writeAuditLog } from "@/server/audit"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { customerDisplayName, formatAddress } from "@/server/services/customers/format"; +import { activeTeamIds } from "@/server/services/work-orders/visibility"; + +/** + * Slim lookups for the emergency capture (spec §19.2). A technician may search ALL customers of + * the tenant for an emergency call-out, so these services deliberately return a minimal field set + * (name, city, customer number / site address) and audit every access. Tenant isolation comes from + * ctx.db (dbForTenant + RLS). + */ + +export type EmergencyCustomerHit = { id: string; customerNumber: string | null; name: string; city: string | null; provisional: boolean }; +export type EmergencySiteHit = { id: string; name: string; address: string }; + +const querySchema = z.string().trim().min(2).max(100); + +export async function searchCustomersForEmergency(ctx: ServiceCtx, rawQuery: string): Promise { + assertCan(ctx, "emergency:create"); + const parsed = querySchema.safeParse(rawQuery); + if (!parsed.success) return []; + const q = parsed.data; + const contains = { contains: q, mode: "insensitive" as const }; + const rows = await ctx.db.customer.findMany({ + where: { + deletedAt: null, + status: { in: ["active", "provisional"] }, + OR: [{ customerNumber: contains }, { companyName: contains }, { firstName: contains }, { lastName: contains }, { city: contains }], + }, + orderBy: [{ companyName: "asc" }, { lastName: "asc" }], + take: 10, + select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true, status: true }, + }); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "export", + entity: "emergency_customer_search", + after: { query: q, resultCount: rows.length, customerIds: rows.map((r) => r.id) }, + }); + return rows.map((r) => ({ + id: r.id, + customerNumber: r.customerNumber, + name: customerDisplayName(r), + city: r.city, + provisional: r.status === "provisional", + })); +} + +export async function listSitesForEmergency(ctx: ServiceCtx, customerId: string): Promise { + assertCan(ctx, "emergency:create"); + const customer = await ctx.db.customer.findFirst({ + where: { id: customerId, deletedAt: null, status: { in: ["active", "provisional"] } }, + select: { id: true }, + }); + if (!customer) throw new ServiceError("not_found", "customer not found"); + const sites = await ctx.db.site.findMany({ + where: { customerId, deletedAt: null, status: { not: "inactive" } }, + orderBy: { name: "asc" }, + take: 50, + select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true }, + }); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "export", + entity: "emergency_site_lookup", + entityId: customerId, + after: { resultCount: sites.length, siteIds: sites.map((s) => s.id) }, + }); + return sites.map((s) => ({ id: s.id, name: s.name, address: formatAddress(s) })); +} + +export type EmergencyTeamOption = { id: string; name: string; members: { id: string; name: string }[] }; + +/** Teams the user may book the call-out on (own active teams) incl. current members. */ +export async function emergencyTeamOptions(ctx: ServiceCtx): Promise { + assertCan(ctx, "emergency:create"); + const ids = await activeTeamIds(ctx); + if (!ids.length) return []; + const now = new Date(); + const teams = await ctx.db.team.findMany({ + where: { id: { in: ids } }, + orderBy: { name: "asc" }, + select: { + id: true, + name: true, + leader: { select: { id: true, name: true, status: true } }, + members: { + where: { validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }], user: { status: "ACTIVE" } }, + select: { user: { select: { id: true, name: true } } }, + }, + }, + }); + return teams.map((t) => { + const members = new Map(); + if (t.leader && t.leader.status === "ACTIVE") members.set(t.leader.id, t.leader.name ?? ""); + for (const m of t.members) members.set(m.user.id, m.user.name ?? ""); + return { id: t.id, name: t.name, members: [...members].map(([id, name]) => ({ id, name })) }; + }); +} diff --git a/src/server/services/emergency/review.ts b/src/server/services/emergency/review.ts new file mode 100644 index 0000000..779cd8c --- /dev/null +++ b/src/server/services/emergency/review.ts @@ -0,0 +1,316 @@ +import type { Prisma, WorkOrderStatus } from "@prisma/client"; +import { ZodError } from "zod"; +import { emergencyOrderPatchSchema, REVIEW_STEPS, type ReviewStep } from "@/lib/emergency/schemas"; +import { writeAuditLog } from "@/server/audit"; +import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { confirmProvisionalCustomer } from "@/server/services/customers/customers"; +import { findDuplicateCustomers, type DuplicateCandidate } from "@/server/services/customers/duplicates"; +import { customerDisplayName } from "@/server/services/customers/format"; +import { mergeCustomers } from "@/server/services/customers/merge"; +import { nextNumber } from "@/server/services/numbering"; +import { deleteSite, updateSite, type SitePatchInput } from "@/server/services/sites/sites"; +import { releaseForBilling } from "@/server/services/work-orders/release-billing"; +import { updateWorkOrder } from "@/server/services/work-orders/update"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** + * Backoffice-Nachbearbeitung von Notdiensteinsätzen (spec §19.3, §39; route /work-orders/emergency-review). + * Permission `emergency:review` on every function; the individual steps additionally require the + * permission of the service they delegate to (L1 customers/sites, L2 work orders). + */ + +const BILLING_DONE: WorkOrderStatus[] = ["released_for_billing", "billed"]; +const REVIEW_OPEN_STATUSES: WorkOrderStatus[] = ["in_review", "technically_completed", "signature_pending"]; + +export type ReviewProgress = { done: Record; count: number; total: number }; + +type ProgressInput = { + status: WorkOrderStatus; + orderTypeId: string | null; + billingType: string | null; + customer: { status: string }; + site: { status: string } | null; + reports: { type: string; status: string }[]; +}; + +export function reviewProgress(wo: ProgressInput): ReviewProgress { + const done: Record = { + customer: wo.customer.status !== "provisional", + site: !wo.site || wo.site.status !== "provisional", + order: Boolean(wo.orderTypeId && wo.billingType), + report: wo.reports.some((r) => r.type === "completion" && r.status === "approved"), + billing: BILLING_DONE.includes(wo.status), + }; + return { done, count: REVIEW_STEPS.filter((s) => done[s]).length, total: REVIEW_STEPS.length }; +} + +const LIST_SELECT = { + id: true, + number: true, + title: true, + status: true, + version: true, + orderTypeId: true, + billingType: true, + plannedStart: true, + createdAt: true, + createdById: true, + customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, status: true } }, + site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, status: true } }, + team: { select: { name: true } }, + reports: { where: { status: { not: "superseded" } }, select: { id: true, type: true, status: true, version: true }, orderBy: { createdAt: "desc" } }, +} satisfies Prisma.WorkOrderSelect; + +export type EmergencyReviewFilter = "open" | "all"; + +export async function listEmergencyReviews(ctx: ServiceCtx, opts: { filter?: EmergencyReviewFilter } = {}) { + assertCan(ctx, "emergency:review"); + const open: Prisma.WorkOrderWhereInput = { + OR: [{ customer: { status: "provisional" } }, { site: { status: "provisional" } }, { status: { in: REVIEW_OPEN_STATUSES } }], + status: { notIn: ["cancelled", "billed"] }, + }; + const rows = await ctx.db.workOrder.findMany({ + where: { AND: [await workOrderScope(ctx), { isEmergency: true }, opts.filter === "all" ? {} : open] }, + orderBy: { createdAt: "desc" }, + take: 200, + select: LIST_SELECT, + }); + const creators = await userNames(ctx, rows.map((r) => r.createdById)); + return rows.map((r) => ({ + ...r, + customerName: customerDisplayName(r.customer), + technician: r.createdById ? (creators.get(r.createdById) ?? null) : null, + progress: reviewProgress(r), + })); +} + +async function userNames(ctx: ServiceCtx, ids: (string | null)[]) { + const unique = [...new Set(ids.filter((i): i is string => !!i))]; + if (!unique.length) return new Map(); + const users = await ctx.db.user.findMany({ where: { id: { in: unique } }, select: { id: true, name: true } }); + return new Map(users.map((u) => [u.id, u.name ?? ""])); +} + +/** Loads an emergency order in scope or throws not_found. */ +async function requireEmergencyOrder(ctx: ServiceCtx, workOrderId: string) { + assertCan(ctx, "emergency:review"); + const wo = await ctx.db.workOrder.findFirst({ + where: { AND: [{ id: workOrderId, isEmergency: true }, await workOrderScope(ctx)] }, + select: { + ...LIST_SELECT, + description: true, + emergencyReason: true, + customerId: true, + siteId: true, + contact: { select: { id: true, name: true, phone: true } }, + orderType: { select: { id: true, name: true } }, + customer: { + select: { + id: true, + customerNumber: true, + companyName: true, + firstName: true, + lastName: true, + status: true, + street: true, + houseNumber: true, + postalCode: true, + city: true, + phone: true, + mobile: true, + email: true, + }, + }, + workSessions: { select: { startedAt: true, endedAt: true, userId: true }, orderBy: { startedAt: "asc" } }, + }, + }); + if (!wo) throw new ServiceError("not_found", "emergency order not found"); + return wo; +} + +export async function getEmergencyReview(ctx: ServiceCtx, workOrderId: string) { + const wo = await requireEmergencyOrder(ctx, workOrderId); + const c = wo.customer; + const duplicates: DuplicateCandidate[] = + c.status === "merged" + ? [] + : await findDuplicateCustomers( + ctx, + { companyName: c.companyName, firstName: c.firstName, lastName: c.lastName, street: c.street, houseNumber: c.houseNumber, postalCode: c.postalCode, city: c.city, phone: c.phone, mobile: c.mobile, email: c.email }, + { excludeId: c.id, limit: 10 }, + ); + const siteOptions = await ctx.db.site.findMany({ + where: { customerId: wo.customerId, deletedAt: null, status: { not: "inactive" }, ...(wo.siteId ? { id: { not: wo.siteId } } : {}) }, + orderBy: { name: "asc" }, + take: 100, + select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, status: true }, + }); + const site = wo.siteId + ? await ctx.db.site.findFirst({ + where: { id: wo.siteId }, + select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, status: true, onSiteContact: true, phone: true, accessNotes: true }, + }) + : null; + const names = await userNames(ctx, [wo.createdById]); + const starts = wo.workSessions.map((s) => s.startedAt.getTime()); + const ends = wo.workSessions.filter((s) => s.endedAt).map((s) => s.endedAt!.getTime()); + return { + ...wo, + site, + customerName: customerDisplayName(c), + technician: wo.createdById ? (names.get(wo.createdById) ?? null) : null, + startedAt: starts.length ? new Date(Math.min(...starts)) : wo.plannedStart, + endedAt: ends.length && wo.workSessions.every((s) => s.endedAt) ? new Date(Math.max(...ends)) : null, + duplicates, + siteOptions, + progress: reviewProgress({ ...wo, site }), + }; +} +export type EmergencyReviewDetail = Awaited>; + +async function auditReview(ctx: ServiceCtx, workOrderId: string, step: string, before: unknown, after: unknown) { + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "emergency", entityId: workOrderId, before, after: { step, ...(after as object) } }); +} + +/** Allocate the next free customer number (skips numbers entered manually). */ +async function allocateCustomerNumber(ctx: ServiceCtx): Promise { + for (let i = 0; i < 20; i++) { + const candidate = await nextNumber(ctx.db, ctx.tenantId, "customer"); + const taken = await ctx.db.customer.findFirst({ where: { customerNumber: candidate }, select: { id: true } }); + if (!taken) return candidate; + } + throw new ServiceError("conflict", "could not allocate customer number", { reason: "number_allocation" }); +} + +/** Step 1a — Kunden bestätigen: provisional → active, customer number is allocated now. */ +export async function confirmEmergencyCustomer(ctx: ServiceCtx, workOrderId: string) { + assertCan(ctx, "customer:write"); + const wo = await requireEmergencyOrder(ctx, workOrderId); + if (wo.customer.status !== "provisional") throw new ServiceError("conflict", "customer is not provisional", { reason: "not_provisional" }); + const result = await inTransaction(ctx, async (tx) => { + let customerNumber = wo.customer.customerNumber; + if (!customerNumber) { + customerNumber = await allocateCustomerNumber(tx); + const before = await tx.db.customer.findFirstOrThrow({ where: { id: wo.customerId } }); + const after = await tx.db.customer.update({ where: { id: wo.customerId }, data: { customerNumber } }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: wo.customerId, before, after }); + } + await confirmProvisionalCustomer(tx, wo.customerId); + return { customerId: wo.customerId, customerNumber }; + }); + await auditReview(ctx, workOrderId, "customer_confirmed", { customerStatus: "provisional" }, { customerStatus: "active", customerNumber: result.customerNumber }); + return result; +} + +/** + * Step 1b — bestehendem Kunden zuordnen: re-parent the provisional customer's sites, contacts, orders and + * documents to the existing customer; the provisional record becomes `merged` (mergedIntoId). Only for + * provisional customers; never automatic. + */ +export async function assignEmergencyToCustomer(ctx: ServiceCtx, input: { workOrderId: string; targetCustomerId: string }) { + assertCan(ctx, "customer:write"); + assertCan(ctx, "work_order:write"); + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + const sourceId = wo.customerId; + if (wo.customer.status !== "provisional") throw new ServiceError("conflict", "customer is not provisional", { reason: "not_provisional" }); + if (input.targetCustomerId === sourceId) throw new ServiceError("invalid", "same customer", { field: "targetCustomerId", reason: "same_customer" }); + const target = await ctx.db.customer.findFirst({ + where: { id: input.targetCustomerId, deletedAt: null, status: { in: ["active", "inactive", "provisional"] } }, + select: { id: true, customerNumber: true }, + }); + if (!target) throw new ServiceError("invalid", "target not found", { field: "targetCustomerId", reason: "target_not_found" }); + + const moved = await inTransaction(ctx, async (tx) => { + const source = await tx.db.customer.findFirstOrThrow({ where: { id: sourceId } }); + const [sites, contacts, workOrders, documents] = [ + await tx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id } }), + await tx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id } }), + // version bump: offline clients must not overwrite the re-parented order with stale data + await tx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id, version: { increment: 1 } } }), + await tx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: target.id } }), + ]; + const after = await tx.db.customer.update({ where: { id: sourceId }, data: { status: "merged", mergedIntoId: target.id, isProvisional: false } }); + const counts = { sites: sites.count, contacts: contacts.count, workOrders: workOrders.count, documents: documents.count }; + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: sourceId, before: source, after: { ...after, assignedTo: target.id, moved: counts } }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: target.id, after: { assignedFrom: sourceId, moved: counts } }); + return counts; + }); + await auditReview(ctx, input.workOrderId, "customer_assigned", { customerId: sourceId }, { customerId: target.id, moved }); + return { sourceId, targetId: target.id, moved }; +} + +/** Step 1c — Dublette zusammenführen (L1 merge, `customer:merge` + explicit confirmation). */ +export async function mergeEmergencyCustomer(ctx: ServiceCtx, input: { workOrderId: string; targetCustomerId: string; confirm: boolean }) { + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + if (!input.confirm) throw new ServiceError("invalid", "confirmation required", { field: "confirm", reason: "confirm_required" }); + const result = await mergeCustomers(ctx, { sourceId: wo.customerId, targetId: input.targetCustomerId, confirm: true }); + await auditReview(ctx, input.workOrderId, "customer_merged", { customerId: wo.customerId }, { customerId: input.targetCustomerId, moved: result.moved }); + return result; +} + +/** Step 2a — Objekt korrigieren: update the site fields; `confirm` sets a provisional site to active. */ +export async function correctEmergencySite(ctx: ServiceCtx, input: { workOrderId: string; patch: SitePatchInput; confirm: boolean }) { + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + if (!wo.siteId) throw new ServiceError("invalid", "order has no site", { reason: "no_site" }); + const { customerId: _ignored, status: _status, ...patch } = input.patch; + void _ignored; + void _status; + const after = await updateSite(ctx, wo.siteId, { ...patch, ...(input.confirm ? { status: "active" } : {}) }); + await auditReview(ctx, input.workOrderId, "site_corrected", { siteId: wo.siteId }, { siteId: wo.siteId, status: after.status }); + return after; +} + +/** Step 2b — Objekt zuordnen: move the order to an existing site of its customer; an unused provisional site is soft-deleted. */ +export async function assignEmergencySite(ctx: ServiceCtx, input: { workOrderId: string; siteId: string }) { + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + if (input.siteId === wo.siteId) throw new ServiceError("invalid", "same site", { field: "siteId", reason: "same_site" }); + const oldSiteId = wo.siteId; + const result = await inTransaction(ctx, async (tx) => { + const updated = await updateWorkOrder(tx, wo.id, { siteId: input.siteId }); + let removed = false; + if (oldSiteId) { + const old = await tx.db.site.findFirst({ where: { id: oldSiteId }, select: { status: true, _count: { select: { workOrders: { where: { deletedAt: null } } } } } }); + if (old?.status === "provisional" && old._count.workOrders === 0) { + await deleteSite(tx, oldSiteId); + removed = true; + } + } + return { version: updated.version, removed }; + }); + await auditReview(ctx, input.workOrderId, "site_assigned", { siteId: oldSiteId }, { siteId: input.siteId, provisionalSiteRemoved: result.removed }); + return result; +} + +/** Step 3 — Auftrag ergänzen (title, description, order type, billing type) via L2 updateWorkOrder. */ +export async function completeEmergencyOrderData(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number } & Record) { + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + let patch; + try { + patch = emergencyOrderPatchSchema.parse(input); + } catch (err) { + if (err instanceof ZodError) throw new ServiceError("invalid", "validation_failed", err.issues.map((i) => ({ path: i.path.join(".") }))); + throw err; + } + const result = await updateWorkOrder( + ctx, + wo.id, + { title: patch.title, description: patch.description, orderTypeId: patch.orderTypeId ?? null, billingType: patch.billingType ?? null }, + input.baseVersion, + ); + await auditReview(ctx, input.workOrderId, "order_completed", { orderTypeId: wo.orderTypeId, billingType: wo.billingType }, { orderTypeId: patch.orderTypeId ?? null, billingType: patch.billingType ?? null }); + return result; +} + +/** + * Step 5 — zur Abrechnung freigeben (L2 releaseForBilling: `work_order:release_billing` + approved completion report). + * Additionally blocked while customer or site are still provisional. + */ +export async function releaseEmergencyForBilling(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number }) { + const wo = await requireEmergencyOrder(ctx, input.workOrderId); + const progress = reviewProgress(wo); + const open = (["customer", "site"] as const).filter((s) => !progress.done[s]); + if (open.length) throw new ServiceError("blocked", "master data still provisional", { reason: "master_data_open", steps: open }); + const result = await releaseForBilling(ctx, { workOrderId: wo.id, baseVersion: input.baseVersion }); + await auditReview(ctx, input.workOrderId, "billing_released", { status: wo.status }, { status: result.status }); + return result; +} diff --git a/src/server/services/emergency/sync-ops.ts b/src/server/services/emergency/sync-ops.ts new file mode 100644 index 0000000..d300970 --- /dev/null +++ b/src/server/services/emergency/sync-ops.ts @@ -0,0 +1,26 @@ +import type { SyncOperationInput } from "@/lib/sync/envelope"; +import { emergencyCreatePayload } from "@/lib/emergency/schemas"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import type { ExternalOpResult } from "@/server/services/sync/external-ops"; +import { createEmergencyOrder } from "./create"; + +/** + * Sync op `emergency.create` (ARCHITEKTUR §4.6), registered in services/sync/external-ops.ts. + * The sync route is gated by the module "field"; the emergency module is checked here. + * idMap maps every client id of the payload to the server id. + */ +export async function applySyncOp(ctx: ServiceCtx, op: SyncOperationInput): Promise { + if (op.opType !== "emergency.create") throw new ServiceError("invalid", `unsupported op ${op.opType}`); + const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "emergency" }, select: { enabled: true } }); + if (moduleRow && !moduleRow.enabled) throw new ServiceError("forbidden", "module emergency disabled"); + + const parsed = emergencyCreatePayload.safeParse(op.payload); + if (!parsed.success) throw new ServiceError("invalid", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; ").slice(0, 500)); + const r = await createEmergencyOrder(ctx, op.payload as never); + + const ids = parsed.data.clientIds; + const idMap: Record = { [ids.workOrder]: r.workOrderId, [ids.session]: r.sessionId }; + if (ids.customer) idMap[ids.customer] = r.customerId; + if (ids.site && r.siteId) idMap[ids.site] = r.siteId; + return { idMap, entityVersion: r.version }; +} diff --git a/src/server/services/reports/submit.ts b/src/server/services/reports/submit.ts index 79bc2a4..ede2cb2 100644 --- a/src/server/services/reports/submit.ts +++ b/src/server/services/reports/submit.ts @@ -8,6 +8,7 @@ import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/cont import { getCompletionBlockers } from "@/server/services/work-orders/completion"; import { transitionWorkOrder } from "@/server/services/work-orders/transition"; import { auditReport, assertEditable, orderNumberOf, refreshContent, reportAuditView, requireVisibleReport } from "./common"; +import { onCompletionReportSubmitted } from "@/server/services/emergency/completion"; export const submitReportSchema = z.object({ reportId: z.string().min(1).max(64), @@ -88,6 +89,7 @@ export async function submitReport(ctx: ServiceCtx, raw: SubmitReportInput): Pro if (report.type === "completion" && report.version === 1) { await advanceOrder(ctx, wo.id, wo.status as WorkOrderStatus, wo.signatureRequired, content.signature, occurrenceId); + await onCompletionReportSubmitted(ctx, wo.id, occurrenceId); // lane emergency (L8): event emergency.completed, no-op for regular orders const sig = content.signature; if (wo.signatureRequired && sig && (sig.outcome === "refused" || sig.outcome === "customer_absent")) { await emitEvent(ctx, { type: "work_order.signature_missing", entityType: "work_order", entityId: wo.id, data: { number: wo.number, outcome: sig.outcome, occurrenceId } }); diff --git a/src/server/services/sync/external-ops.ts b/src/server/services/sync/external-ops.ts index facc0d7..61ef872 100644 --- a/src/server/services/sync/external-ops.ts +++ b/src/server/services/sync/external-ops.ts @@ -26,5 +26,5 @@ export const EXTERNAL_OPS: Partial Promise import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), // lane-reports: "report.submit": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), // lane-reports: "signature.capture": () => import("@/server/services/reports/sync-ops").then((m) => m.applySyncOp), - // lane-emergency: "emergency.create": () => import("@/server/services/emergency/sync-ops").then((m) => m.applySyncOp), + "emergency.create": () => import("@/server/services/emergency/sync-ops").then((m) => m.applySyncOp), }; From ef61754a0a53ed9893e2950d4bd5a0908a60b435 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 13:12:33 +0200 Subject: [PATCH 2/5] L8 Notdienst: mobile Erfassung /m/emergency in drei Schritten MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kunde suchen oder vorläufig anlegen, Einsatzort mit Ansprechpartner, Grund mit optionaler Sprachnotiz, Beginn und Team; Start über die Sync-Op. Co-Authored-By: Claude Opus 5 --- messages/de/emergency.json | 236 +++++++ messages/en/emergency.json | 236 +++++++ src/app/(field)/m/emergency/page.tsx | 26 +- src/components/emergency/emergency-wizard.tsx | 618 ++++++++++++++++++ src/server/actions/emergency/capture.ts | 38 ++ src/server/actions/emergency/review.ts | 125 ++++ 6 files changed, 1276 insertions(+), 3 deletions(-) create mode 100644 messages/de/emergency.json create mode 100644 messages/en/emergency.json create mode 100644 src/components/emergency/emergency-wizard.tsx create mode 100644 src/server/actions/emergency/capture.ts create mode 100644 src/server/actions/emergency/review.ts diff --git a/messages/de/emergency.json b/messages/de/emergency.json new file mode 100644 index 0000000..189ad4e --- /dev/null +++ b/messages/de/emergency.json @@ -0,0 +1,236 @@ +{ + "capture": { + "title": "Notdienst", + "sub": "Einsatz in 3 Schritten anlegen und sofort starten.", + "noAccess": "Die Notdienst-Erfassung ist für Ihre Rolle nicht freigeschaltet.", + "stepOf": "Schritt {step} von 3", + "steps": { + "customer": "Kunde", + "site": "Einsatzort", + "reason": "Grund & Start" + }, + "next": "Weiter", + "back": "Zurück", + "required": "Bitte die markierten Pflichtfelder ausfüllen.", + "start": "Einsatz starten", + "starting": "Einsatz wird angelegt …", + "queued": "Einsatz ist gespeichert und wird übertragen, sobald wieder Verbindung besteht.", + "customer": { + "existing": "Bestehender Kunde", + "new": "Neuer Kunde", + "search": "Kunde suchen", + "searchPlaceholder": "Name, Ort oder Kundennummer", + "searchHint": "Mindestens 2 Zeichen eingeben.", + "searching": "Suche läuft …", + "offline": "Suche gerade nicht möglich. Ohne Verbindung bitte einen neuen Kunden anlegen.", + "noHits": "Kein Kunde gefunden. Legen Sie einen neuen Kunden an.", + "provisional": "Vorläufig", + "change": "Anderen Kunden wählen", + "selectRequired": "Bitte einen Kunden auswählen oder neu anlegen.", + "newHint": "Wird als vorläufiger Kunde angelegt. Das Büro prüft ihn am nächsten Arbeitstag.", + "companyName": "Firma", + "firstName": "Vorname", + "lastName": "Nachname", + "nameHint": "Firma oder Nachname angeben.", + "phone": "Telefon", + "email": "E-Mail (optional)", + "street": "Straße", + "houseNumber": "Nr.", + "postalCode": "PLZ", + "city": "Ort" + }, + "site": { + "loading": "Objekte werden geladen …", + "existing": "Objekt des Kunden", + "newAddress": "Andere Einsatzadresse", + "none": "Für diesen Kunden ist kein Objekt hinterlegt. Bitte die Einsatzadresse erfassen.", + "newHint": "Die Einsatzadresse wird als vorläufiges Objekt angelegt.", + "useCustomerAddress": "Kundenadresse übernehmen", + "name": "Bezeichnung (optional)", + "contactTitle": "Ansprechpartner vor Ort", + "contactName": "Name", + "contactPhone": "Telefon" + }, + "reason": { + "label": "Grund des Notdiensteinsatzes", + "placeholder": "z. B. Wasserrohrbruch im Keller, Heizung ausgefallen", + "voice": "Sprachnotiz (optional)", + "voiceRecord": "Sprachnotiz aufnehmen", + "voiceRecording": "Aufnahme läuft · {seconds} s", + "voiceStop": "Aufnahme beenden", + "voiceDiscard": "Aufnahme verwerfen", + "voiceHint": "Wird nach dem Start am Einsatz gespeichert.", + "voiceDenied": "Kein Zugriff auf das Mikrofon. Bitte in den Geräteeinstellungen erlauben.", + "startedAt": "Beginn", + "team": "Team", + "noTeam": "Ohne Team", + "technicians": "Monteure", + "me": "Ich (immer dabei)" + }, + "errors": { + "not_found": "Kunde oder Objekt nicht gefunden. Bitte Auswahl prüfen.", + "forbidden": "Dafür fehlt Ihnen die Berechtigung.", + "invalid": "Bitte die Eingaben prüfen.", + "conflict": "Der Einsatz wurde bereits angelegt. Bitte die Auftragsliste prüfen.", + "blocked": "Der Einsatz kann gerade nicht angelegt werden.", + "internal": "Einsatz konnte nicht angelegt werden. Bitte erneut versuchen.", + "network": "Keine Verbindung. Bitte erneut auf „Einsatz starten“ tippen." + } + }, + "review": { + "crumb": "Aufträge · Notdienst", + "title": "Notdienst-Prüfung", + "sub": "Notdiensteinsätze mit vorläufigen Stammdaten oder zur Prüfung.", + "filterLabel": "Filter Notdiensteinsätze", + "filter": { + "open": "Zu prüfen", + "all": "Alle Notdienste" + }, + "emptyOpen": "Keine Notdiensteinsätze zu prüfen.", + "emptyAll": "Noch keine Notdiensteinsätze.", + "customer": "Kunde", + "site": "Objekt", + "technician": "Monteur", + "startedAt": "Beginn", + "provisional": "Vorläufig", + "progress": "{count} von {total} Prüfschritten erledigt", + "back": "Zur Notdienst-Prüfung", + "detailSub": "Monteur: {technician} · Einsatzbeginn: {start} · Einsatzende: {end}", + "running": "läuft noch", + "openOrder": "Auftrag öffnen", + "steps": { + "customer": "Kunde klären", + "site": "Objekt klären", + "order": "Auftrag ergänzen", + "report": "Bericht freigeben", + "billing": "Abrechnung freigeben" + }, + "stepDone": "Erledigt", + "stepOpen": "Offen", + "reason": "Grund des Notdiensteinsatzes", + "customerName": "Name", + "customerNumber": "Kundennummer", + "numberOnConfirm": "wird bei Bestätigung vergeben", + "status": "Status", + "customerStatus": { + "active": "Aktiv", + "inactive": "Inaktiv", + "provisional": "Vorläufig – Prüfung durch Backoffice erforderlich", + "merged": "Zusammengeführt" + }, + "phone": "Telefon", + "email": "E-Mail", + "address": "Adresse", + "contact": "Ansprechpartner", + "provisionalHint": "Vorläufiger Kunde: bestätigen, einem bestehenden Kunden zuordnen oder mit einer Dublette zusammenführen.", + "saved": "Gespeichert.", + "confirmCustomer": { + "title": "Kunden bestätigen", + "hint": "Der Kunde wird aktiv und erhält eine Kundennummer.", + "submit": "Kunden bestätigen" + }, + "duplicates": { + "title": "Mögliche Dubletten", + "none": "Keine ähnlichen Kunden gefunden.", + "score": "Übereinstimmung {score} %" + }, + "target": { + "select": "Bestehender Kunde", + "none": "Bitte wählen", + "number": "oder Kundennummer" + }, + "assignCustomer": { + "title": "Bestehendem Kunden zuordnen", + "hint": "Auftrag, Objekt und Ansprechpartner werden zum gewählten Kunden umgehängt. Der vorläufige Datensatz wird als zusammengeführt markiert.", + "submit": "Zuordnen" + }, + "mergeCustomer": { + "title": "Dublette zusammenführen", + "hint": "Alle Ansprechpartner, Objekte, Aufträge und Dokumente dieses Kunden gehen auf den gewählten Kunden über. Nicht umkehrbar.", + "confirm": "Ich habe geprüft, dass es derselbe Kunde ist.", + "submit": "Zusammenführen" + }, + "siteName": "Bezeichnung", + "siteStatus": { + "active": "Aktiv", + "inactive": "Inaktiv", + "provisional": "Vorläufig – Prüfung durch Backoffice erforderlich" + }, + "onSiteContact": "Ansprechpartner vor Ort", + "noSite": "Kein Objekt am Auftrag.", + "street": "Straße", + "houseNumber": "Nr.", + "postalCode": "PLZ", + "city": "Ort", + "accessNotes": "Zugangshinweise", + "correctSite": { + "title": "Objekt korrigieren", + "confirm": "Objekt bestätigen (vorläufig → aktiv)", + "submit": "Objekt speichern" + }, + "assignSite": { + "title": "Bestehendes Objekt zuordnen", + "hint": "Der Auftrag wird dem gewählten Objekt zugeordnet. Ein nicht mehr genutztes vorläufiges Objekt wird entfernt.", + "select": "Objekt des Kunden", + "none": "Der Kunde hat keine weiteren Objekte.", + "submit": "Objekt zuordnen" + }, + "completeOrder": { + "title": "Titel", + "description": "Beschreibung", + "orderType": "Auftragsart", + "billingType": "Abrechnungsart", + "choose": "Bitte wählen", + "hint": "Erledigt, sobald Auftragsart und Abrechnungsart gesetzt sind.", + "submit": "Auftrag speichern" + }, + "billingTypes": { + "fixed": "Pauschal", + "time_material": "Nach Aufwand", + "maintenance_contract": "Wartungsvertrag", + "warranty": "Gewährleistung" + }, + "report": { + "none": "Noch kein Bericht eingereicht.", + "open": "Bericht prüfen", + "hint": "Freigabe und Zurückweisung erfolgen auf der Berichtsseite.", + "version": "Version {version}", + "type": { + "daily": "Tagesbericht", + "completion": "Abschlussbericht" + }, + "status": { + "draft": "Entwurf", + "submitted": "Eingereicht", + "team_approved": "Vom Teamleiter geprüft", + "approved": "Freigegeben", + "rejected": "Zurückgewiesen", + "superseded": "Ersetzt" + } + }, + "billing": { + "hint": "Möglich, wenn Kunde und Objekt geklärt sind und der Abschlussbericht freigegeben ist.", + "submit": "Zur Abrechnung freigeben", + "current": "Aktueller Status" + }, + "errors": { + "generic": "Das hat nicht geklappt. Bitte erneut versuchen.", + "invalid": "Bitte die Eingaben prüfen.", + "not_found": "Nicht gefunden.", + "forbidden": "Dafür fehlt Ihnen die Berechtigung.", + "conflict": "Der Datensatz wurde inzwischen geändert. Bitte Seite neu laden.", + "blocked": "Noch nicht möglich: Voraussetzungen fehlen.", + "not_provisional": "Der Kunde ist nicht mehr vorläufig.", + "same_customer": "Bitte einen anderen Kunden wählen.", + "same_site": "Der Auftrag ist diesem Objekt bereits zugeordnet.", + "target_not_found": "Kunde nicht gefunden. Bitte Auswahl oder Kundennummer prüfen.", + "not_found_target": "Kunde nicht gefunden.", + "confirm_required": "Bitte die Prüfung bestätigen.", + "already_merged": "Einer der Kunden wurde bereits zusammengeführt.", + "no_site": "Der Auftrag hat kein Objekt.", + "master_data_open": "Erst Kunde und Objekt klären, dann zur Abrechnung freigeben.", + "number_allocation": "Kundennummer konnte nicht vergeben werden. Bitte erneut versuchen.", + "number_taken": "Diese Kundennummer ist bereits vergeben." + } + } +} diff --git a/messages/en/emergency.json b/messages/en/emergency.json new file mode 100644 index 0000000..a8f8460 --- /dev/null +++ b/messages/en/emergency.json @@ -0,0 +1,236 @@ +{ + "capture": { + "title": "Emergency call-out", + "sub": "Create the call-out in 3 steps and start right away.", + "noAccess": "Emergency capture is not enabled for your role.", + "stepOf": "Step {step} of 3", + "steps": { + "customer": "Customer", + "site": "Location", + "reason": "Reason & start" + }, + "next": "Next", + "back": "Back", + "required": "Please fill in the marked required fields.", + "start": "Start call-out", + "starting": "Creating call-out …", + "queued": "Call-out saved. It will be transferred once you are back online.", + "customer": { + "existing": "Existing customer", + "new": "New customer", + "search": "Search customer", + "searchPlaceholder": "Name, city or customer number", + "searchHint": "Enter at least 2 characters.", + "searching": "Searching …", + "offline": "Search is not available right now. Without a connection, please create a new customer.", + "noHits": "No customer found. Create a new customer.", + "provisional": "Provisional", + "change": "Choose another customer", + "selectRequired": "Please select a customer or create a new one.", + "newHint": "Will be created as a provisional customer. The office reviews it on the next working day.", + "companyName": "Company", + "firstName": "First name", + "lastName": "Last name", + "nameHint": "Enter company or last name.", + "phone": "Phone", + "email": "E-mail (optional)", + "street": "Street", + "houseNumber": "No.", + "postalCode": "Postcode", + "city": "City" + }, + "site": { + "loading": "Loading sites …", + "existing": "Customer site", + "newAddress": "Other call-out address", + "none": "No site on file for this customer. Please enter the call-out address.", + "newHint": "The call-out address is created as a provisional site.", + "useCustomerAddress": "Use customer address", + "name": "Label (optional)", + "contactTitle": "Contact on site", + "contactName": "Name", + "contactPhone": "Phone" + }, + "reason": { + "label": "Reason for the emergency call-out", + "placeholder": "e.g. burst water pipe in the basement, heating failure", + "voice": "Voice note (optional)", + "voiceRecord": "Record voice note", + "voiceRecording": "Recording · {seconds} s", + "voiceStop": "Stop recording", + "voiceDiscard": "Discard recording", + "voiceHint": "Saved to the call-out after it has started.", + "voiceDenied": "No microphone access. Please allow it in the device settings.", + "startedAt": "Start", + "team": "Team", + "noTeam": "No team", + "technicians": "Technicians", + "me": "Me (always included)" + }, + "errors": { + "not_found": "Customer or site not found. Please check your selection.", + "forbidden": "You do not have permission for this.", + "invalid": "Please check your entries.", + "conflict": "The call-out has already been created. Please check the order list.", + "blocked": "The call-out cannot be created right now.", + "internal": "The call-out could not be created. Please try again.", + "network": "No connection. Please tap “Start call-out” again." + } + }, + "review": { + "crumb": "Work orders · Emergency", + "title": "Emergency review", + "sub": "Emergency call-outs with provisional master data or awaiting review.", + "filterLabel": "Filter emergency call-outs", + "filter": { + "open": "To review", + "all": "All call-outs" + }, + "emptyOpen": "No emergency call-outs to review.", + "emptyAll": "No emergency call-outs yet.", + "customer": "Customer", + "site": "Site", + "technician": "Technician", + "startedAt": "Start", + "provisional": "Provisional", + "progress": "{count} of {total} review steps done", + "back": "Back to emergency review", + "detailSub": "Technician: {technician} · Start: {start} · End: {end}", + "running": "still running", + "openOrder": "Open work order", + "steps": { + "customer": "Clarify customer", + "site": "Clarify site", + "order": "Complete order", + "report": "Approve report", + "billing": "Release for billing" + }, + "stepDone": "Done", + "stepOpen": "Open", + "reason": "Reason for the emergency call-out", + "customerName": "Name", + "customerNumber": "Customer number", + "numberOnConfirm": "assigned on confirmation", + "status": "Status", + "customerStatus": { + "active": "Active", + "inactive": "Inactive", + "provisional": "Provisional – review by back office required", + "merged": "Merged" + }, + "phone": "Phone", + "email": "E-mail", + "address": "Address", + "contact": "Contact", + "provisionalHint": "Provisional customer: confirm, assign to an existing customer or merge with a duplicate.", + "saved": "Saved.", + "confirmCustomer": { + "title": "Confirm customer", + "hint": "The customer becomes active and receives a customer number.", + "submit": "Confirm customer" + }, + "duplicates": { + "title": "Possible duplicates", + "none": "No similar customers found.", + "score": "Match {score} %" + }, + "target": { + "select": "Existing customer", + "none": "Please choose", + "number": "or customer number" + }, + "assignCustomer": { + "title": "Assign to existing customer", + "hint": "Order, site and contacts move to the chosen customer. The provisional record is marked as merged.", + "submit": "Assign" + }, + "mergeCustomer": { + "title": "Merge duplicate", + "hint": "All contacts, sites, orders and documents of this customer move to the chosen customer. Cannot be undone.", + "confirm": "I have checked that this is the same customer.", + "submit": "Merge" + }, + "siteName": "Label", + "siteStatus": { + "active": "Active", + "inactive": "Inactive", + "provisional": "Provisional – review by back office required" + }, + "onSiteContact": "Contact on site", + "noSite": "No site on this order.", + "street": "Street", + "houseNumber": "No.", + "postalCode": "Postcode", + "city": "City", + "accessNotes": "Access notes", + "correctSite": { + "title": "Correct site", + "confirm": "Confirm site (provisional → active)", + "submit": "Save site" + }, + "assignSite": { + "title": "Assign existing site", + "hint": "The order is assigned to the chosen site. A provisional site that is no longer used is removed.", + "select": "Customer site", + "none": "The customer has no other sites.", + "submit": "Assign site" + }, + "completeOrder": { + "title": "Title", + "description": "Description", + "orderType": "Order type", + "billingType": "Billing type", + "choose": "Please choose", + "hint": "Done once order type and billing type are set.", + "submit": "Save order" + }, + "billingTypes": { + "fixed": "Fixed price", + "time_material": "Time and material", + "maintenance_contract": "Maintenance contract", + "warranty": "Warranty" + }, + "report": { + "none": "No report submitted yet.", + "open": "Review report", + "hint": "Approval and rejection happen on the report page.", + "version": "Version {version}", + "type": { + "daily": "Daily report", + "completion": "Completion report" + }, + "status": { + "draft": "Draft", + "submitted": "Submitted", + "team_approved": "Checked by team lead", + "approved": "Approved", + "rejected": "Rejected", + "superseded": "Superseded" + } + }, + "billing": { + "hint": "Possible once customer and site are clarified and the completion report is approved.", + "submit": "Release for billing", + "current": "Current status" + }, + "errors": { + "generic": "That did not work. Please try again.", + "invalid": "Please check your entries.", + "not_found": "Not found.", + "forbidden": "You do not have permission for this.", + "conflict": "The record has changed in the meantime. Please reload the page.", + "blocked": "Not possible yet: prerequisites are missing.", + "not_provisional": "The customer is no longer provisional.", + "same_customer": "Please choose a different customer.", + "same_site": "The order is already assigned to this site.", + "target_not_found": "Customer not found. Please check the selection or customer number.", + "not_found_target": "Customer not found.", + "confirm_required": "Please confirm the check.", + "already_merged": "One of the customers has already been merged.", + "no_site": "The order has no site.", + "master_data_open": "Clarify customer and site first, then release for billing.", + "number_allocation": "The customer number could not be assigned. Please try again.", + "number_taken": "This customer number is already taken." + } + } +} diff --git a/src/app/(field)/m/emergency/page.tsx b/src/app/(field)/m/emergency/page.tsx index 5064860..f96b880 100644 --- a/src/app/(field)/m/emergency/page.tsx +++ b/src/app/(field)/m/emergency/page.tsx @@ -1,5 +1,25 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import { getTranslations } from "next-intl/server"; +import { moduleGuard } from "@/server/action-guard"; +import { can, ctxFromGuard } from "@/server/services/context"; +import { emergencyTeamOptions } from "@/server/services/emergency/lookup"; +import { EmergencyWizard } from "@/components/emergency/emergency-wizard"; -export default function Page() { - return ; +/** `/m/emergency` — Notdienst erfassen (spec §19.2, US-010): max. 3 steps, then straight into the call-out. */ +export default async function EmergencyPage() { + const ctx = ctxFromGuard(await moduleGuard("emergency")()); + const t = await getTranslations("emergency.capture"); + if (!can(ctx, "emergency:create") || !can(ctx, "field:execute")) { + return

{t("noAccess")}

; + } + const teams = await emergencyTeamOptions(ctx); + + return ( +
+
+

{t("title")}

+

{t("sub")}

+
+ +
+ ); } diff --git a/src/components/emergency/emergency-wizard.tsx b/src/components/emergency/emergency-wizard.tsx new file mode 100644 index 0000000..9e89f1b --- /dev/null +++ b/src/components/emergency/emergency-wizard.tsx @@ -0,0 +1,618 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { Building2, Check, ChevronLeft, ChevronRight, LoaderCircle, MapPin, Mic, Search, Siren, Square, TriangleAlert, UserPlus, Users } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; +import { uploadFieldFile } from "@/lib/field/upload"; +import type { EmergencyCreateInput } from "@/lib/emergency/schemas"; +import { btnPrimary, btnSecondary, card, chip, inputClass, noticeError, noticeWarn } from "@/components/field/ui"; +import { listEmergencySitesAction, searchEmergencyCustomersAction } from "@/server/actions/emergency/capture"; +import type { EmergencyCustomerHit, EmergencySiteHit, EmergencyTeamOption } from "@/server/services/emergency/lookup"; + +type Step = 1 | 2 | 3; +type NewCustomer = { companyName: string; firstName: string; lastName: string; phone: string; email: string; street: string; houseNumber: string; postalCode: string; city: string }; +type Address = { name: string; street: string; houseNumber: string; postalCode: string; city: string }; + +const EMPTY_CUSTOMER: NewCustomer = { companyName: "", firstName: "", lastName: "", phone: "", email: "", street: "", houseNumber: "", postalCode: "", city: "" }; +const EMPTY_ADDRESS: Address = { name: "", street: "", houseNumber: "", postalCode: "", city: "" }; +const MAX_VOICE_SECONDS = 300; + +function localNow(): string { + const d = new Date(); + d.setMinutes(d.getMinutes() - d.getTimezoneOffset()); + return d.toISOString().slice(0, 16); +} + +function TextField({ + id, + label, + value, + onChange, + type = "text", + required, + autoComplete, + inputMode, + className, + invalid, +}: { + id: string; + label: string; + value: string; + onChange: (v: string) => void; + type?: string; + required?: boolean; + autoComplete?: string; + inputMode?: React.HTMLAttributes["inputMode"]; + className?: string; + invalid?: boolean; +}) { + return ( +
+ + onChange(e.target.value)} + required={required} + autoComplete={autoComplete} + inputMode={inputMode} + aria-invalid={invalid || undefined} + className={cn(inputClass, invalid && "border-[var(--risk)]")} + /> +
+ ); +} + +export function EmergencyWizard({ teams, userId }: { teams: EmergencyTeamOption[]; userId: string }) { + const t = useTranslations("emergency.capture"); + const router = useRouter(); + const [step, setStep] = useState(1); + const [showErrors, setShowErrors] = useState(false); + + // step 1 — customer + const [customerMode, setCustomerMode] = useState<"existing" | "new">("existing"); + const [query, setQuery] = useState(""); + const [hits, setHits] = useState([]); + const [searching, startSearch] = useTransition(); + const [searchFailed, setSearchFailed] = useState(false); + const [selected, setSelected] = useState(null); + const [newCustomer, setNewCustomer] = useState(EMPTY_CUSTOMER); + + // step 2 — site + const [sites, setSites] = useState(null); + const [siteMode, setSiteMode] = useState<"existing" | "new">("new"); + const [siteId, setSiteId] = useState(""); + const [address, setAddress] = useState
(EMPTY_ADDRESS); + const [contact, setContact] = useState({ name: "", phone: "" }); + + // step 3 — reason / start / team + const [reason, setReason] = useState(""); + const [startedAt, setStartedAt] = useState(""); + const [teamId, setTeamId] = useState(teams[0]?.id ?? ""); + const [colleagues, setColleagues] = useState([]); + + // voice note (kept locally, uploaded after the call-out exists) + const [recState, setRecState] = useState<"idle" | "recording" | "recorded">("idle"); + const [recSeconds, setRecSeconds] = useState(0); + const [clip, setClip] = useState<{ blob: Blob; url: string } | null>(null); + const recorder = useRef(null); + const timer = useRef | null>(null); + + // submit + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + // stable client ids → a retry after a network error is idempotent (same session client id) + const clientIds = useRef({ workOrder: newClientId(), session: newClientId(), customer: newClientId(), site: newClientId() }); + + const searchTimer = useRef | null>(null); + const selectionSeq = useRef(0); + + useEffect( + () => () => { + if (timer.current) clearInterval(timer.current); + if (searchTimer.current) clearTimeout(searchTimer.current); + recorder.current?.stream.getTracks().forEach((tr) => tr.stop()); + }, + [], + ); + useEffect(() => () => (clip ? URL.revokeObjectURL(clip.url) : undefined), [clip]); + + /** Debounced customer search (event-driven, no state updates inside effects). */ + function onQueryChange(value: string) { + setQuery(value); + if (searchTimer.current) clearTimeout(searchTimer.current); + const q = value.trim(); + if (q.length < 2) { + setHits([]); + setSearchFailed(false); + return; + } + searchTimer.current = setTimeout(() => { + startSearch(async () => { + const res = await searchEmergencyCustomersAction(q); + setSearchFailed(!res.ok); + setHits(res.ok ? res.items : []); + }); + }, 300); + } + + function resetSites() { + selectionSeq.current++; + setSites(null); + setSiteMode("new"); + setSiteId(""); + } + + /** Select a customer and load its sites for step 2. */ + function selectCustomer(hit: EmergencyCustomerHit | null) { + setSelected(hit); + resetSites(); + if (!hit) return; + const seq = selectionSeq.current; + listEmergencySitesAction(hit.id).then((res) => { + if (seq !== selectionSeq.current) return; + const items = res.ok ? res.items : []; + setSites(items); + if (items.length) { + setSiteMode("existing"); + setSiteId(items[0].id); + } + }); + } + + function switchCustomerMode(mode: "existing" | "new") { + setCustomerMode(mode); + selectCustomer(null); + } + + const team = useMemo(() => teams.find((x) => x.id === teamId) ?? null, [teams, teamId]); + + const step1Valid = + customerMode === "existing" ? !!selected : !!(newCustomer.companyName.trim() || newCustomer.lastName.trim()) && !!newCustomer.phone.trim(); + const step2Valid = + (siteMode === "existing" ? !!siteId : !!address.street.trim() && !!address.city.trim()) && !!contact.name.trim() && !!contact.phone.trim(); + const step3Valid = !!reason.trim() && !!startedAt; + + function go(next: Step) { + const valid = step === 1 ? step1Valid : step === 2 ? step2Valid : step3Valid; + if (next > step && !valid) { + setShowErrors(true); + return; + } + setShowErrors(false); + setError(null); + if (next === 2 && customerMode === "new" && !contact.name && !contact.phone) { + const name = [newCustomer.firstName, newCustomer.lastName].filter((s) => s.trim()).join(" ") || newCustomer.companyName; + setContact({ name, phone: newCustomer.phone }); + } + if (next === 3 && !startedAt) setStartedAt(localNow()); // default "now" when the step opens + setStep(next); + window.scrollTo({ top: 0 }); + } + + function useCustomerAddress() { + setAddress((a) => ({ ...a, street: newCustomer.street, houseNumber: newCustomer.houseNumber, postalCode: newCustomer.postalCode, city: newCustomer.city })); + } + + async function startRecording() { + try { + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + const types = ["audio/webm;codecs=opus", "audio/webm", "audio/mp4", "audio/ogg;codecs=opus"]; + const mimeType = typeof MediaRecorder !== "undefined" ? types.find((x) => MediaRecorder.isTypeSupported(x)) : undefined; + const rec = new MediaRecorder(stream, mimeType ? { mimeType } : undefined); + const chunks: BlobPart[] = []; + rec.ondataavailable = (e) => e.data.size > 0 && chunks.push(e.data); + rec.onstop = () => { + stream.getTracks().forEach((tr) => tr.stop()); + const blob = new Blob(chunks, { type: rec.mimeType || mimeType || "audio/webm" }); + setClip({ blob, url: URL.createObjectURL(blob) }); + setRecState("recorded"); + }; + recorder.current = rec; + rec.start(1000); + setRecSeconds(0); + setRecState("recording"); + timer.current = setInterval(() => { + setRecSeconds((s) => { + if (s + 1 >= MAX_VOICE_SECONDS) stopRecording(); + return Math.min(s + 1, MAX_VOICE_SECONDS); + }); + }, 1000); + } catch { + setError(t("reason.voiceDenied")); + } + } + + function stopRecording() { + if (timer.current) clearInterval(timer.current); + timer.current = null; + if (recorder.current?.state === "recording") recorder.current.stop(); + } + + async function submit() { + if (!step3Valid) { + setShowErrors(true); + return; + } + setSubmitting(true); + setError(null); + const payload: EmergencyCreateInput = { + clientIds: clientIds.current, + customer: + customerMode === "existing" && selected + ? { mode: "existing", customerId: selected.id } + : { mode: "new", ...newCustomer }, + site: siteMode === "existing" ? { mode: "existing", siteId } : { mode: "new", ...address }, + onSiteContact: contact, + reason, + startedAt: new Date(startedAt).toISOString(), + teamId: teamId || null, + assigneeIds: colleagues.filter((c) => c !== userId), + offline: typeof navigator !== "undefined" && !navigator.onLine, + deviceInfo: typeof navigator !== "undefined" ? navigator.userAgent.slice(0, 200) : undefined, + }; + const result = await submitOp({ opType: "emergency.create", payload }); + const workOrderId = result.idMap?.[clientIds.current.workOrder]; + if (!isSuccess(result) || !workOrderId) { + setSubmitting(false); + const key = isSuccess(result) ? "queued" : errorKey(result); + setError(key === "queued" ? t("queued") : t(`errors.${key}`)); + return; + } + if (clip) { + const type = clip.blob.type || "audio/webm"; + const ext = type.includes("mp4") ? "m4a" : type.includes("ogg") ? "ogg" : "webm"; + const up = await uploadFieldFile({ workOrderId, kind: "voice_note", clientId: newClientId(), file: clip.blob, fileName: `notdienst.${ext}` }); + if (up.ok) { + await submitOp({ + opType: "voice.attach", + payload: { workOrderId, clientId: newClientId(), documentId: up.documentId, durationSeconds: recSeconds, recordedAt: new Date().toISOString(), kind: "problem" }, + }); + } + } + router.push(`/m/orders/${workOrderId}`); + } + + const stepLabels = [t("steps.customer"), t("steps.site"), t("steps.reason")]; + const err = (cond: boolean) => showErrors && cond; + + return ( +
+
    + {stepLabels.map((label, i) => { + const n = (i + 1) as Step; + const state = n < step ? "done" : n === step ? "current" : "todo"; + return ( +
  1. + + {state === "done" ? : n} + + {label} +
  2. + ); + })} +
+

+ {t("stepOf", { step })}: {stepLabels[step - 1]} +

+ + {step === 1 && ( +
+

{t("steps.customer")}

+
+ + +
+ + {customerMode === "existing" && + (selected ? ( +
+
+ +
+

{selected.name}

+

+ {[selected.customerNumber, selected.city].filter(Boolean).join(" · ")} + {selected.provisional && ` · ${t("customer.provisional")}`} +

+
+
+ +
+ ) : ( +
+
+ + onQueryChange(e.target.value)} + placeholder={t("customer.searchPlaceholder")} + autoComplete="off" + className={inputClass} + /> +

{t("customer.searchHint")}

+
+ {searching && ( +

+ + {t("customer.searching")} +

+ )} + {searchFailed &&

{t("customer.offline")}

} + {!searching && query.trim().length >= 2 && hits.length === 0 && !searchFailed && ( +

{t("customer.noHits")}

+ )} +
    + {hits.map((h) => ( +
  • + +
  • + ))} +
+ {err(!selected) &&

{t("customer.selectRequired")}

} +
+ ))} + + {customerMode === "new" && ( +
+

{t("customer.newHint")}

+ setNewCustomer({ ...newCustomer, companyName: v })} autoComplete="organization" /> +
+ setNewCustomer({ ...newCustomer, firstName: v })} autoComplete="given-name" /> + setNewCustomer({ ...newCustomer, lastName: v })} + autoComplete="family-name" + invalid={err(!newCustomer.companyName.trim() && !newCustomer.lastName.trim())} + /> +
+

{t("customer.nameHint")}

+ setNewCustomer({ ...newCustomer, phone: v })} invalid={err(!newCustomer.phone.trim())} /> + setNewCustomer({ ...newCustomer, email: v })} /> +
+ setNewCustomer({ ...newCustomer, street: v })} autoComplete="address-line1" /> + setNewCustomer({ ...newCustomer, houseNumber: v })} /> +
+
+ setNewCustomer({ ...newCustomer, postalCode: v })} /> + setNewCustomer({ ...newCustomer, city: v })} autoComplete="address-level2" /> +
+ {err(!step1Valid) &&

{t("required")}

} +
+ )} + + +
+ )} + + {step === 2 && ( +
+

{t("steps.site")}

+ {customerMode === "existing" && sites === null && ( +

+ + {t("site.loading")} +

+ )} + {customerMode === "existing" && sites !== null && sites.length > 0 && ( +
+ {t("site.existing")} + {sites.map((s) => ( + + ))} + +
+ )} + {customerMode === "existing" && sites !== null && sites.length === 0 &&

{t("site.none")}

} + + {siteMode === "new" && (sites !== null || customerMode === "new") && ( +
+

{t("site.newHint")}

+ {customerMode === "new" && (newCustomer.street || newCustomer.city) && ( + + )} +
+ setAddress({ ...address, street: v })} autoComplete="address-line1" invalid={err(!address.street.trim())} /> + setAddress({ ...address, houseNumber: v })} /> +
+
+ setAddress({ ...address, postalCode: v })} /> + setAddress({ ...address, city: v })} autoComplete="address-level2" invalid={err(!address.city.trim())} /> +
+ setAddress({ ...address, name: v })} /> +
+ )} + +
+ {t("site.contactTitle")} + setContact({ ...contact, name: v })} autoComplete="name" invalid={err(!contact.name.trim())} /> + setContact({ ...contact, phone: v })} invalid={err(!contact.phone.trim())} /> +
+ {err(!step2Valid) &&

{t("required")}

} + +
+ + +
+
+ )} + + {step === 3 && ( +
+

{t("steps.reason")}

+
+ +