From 2311b35d8ce4044446b8e84a4b3262bb09d0db28 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:29:15 +0200 Subject: [PATCH 1/5] =?UTF-8?q?L2=20Auftr=C3=A4ge=20&=20Backoffice:=20Serv?= =?UTF-8?q?ice-Schicht=20Auftr=C3=A4ge=20+=20Tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- scripts/lib-auftraege-fixtures.ts | 172 ++++++++++++++ scripts/test-auftraege-core.ts | 204 ++++++++++++++++ scripts/test-auftraege-numbering.ts | 41 ++++ scripts/test-auftraege-scope.ts | 108 +++++++++ scripts/test-auftraege-status.ts | 138 +++++++++++ src/lib/work-orders/action-state.ts | 17 ++ src/lib/work-orders/defaults.ts | 48 ++++ src/lib/work-orders/filters.ts | 126 ++++++++++ src/lib/work-orders/schemas.ts | 219 ++++++++++++++++++ src/lib/work-orders/time.ts | 86 +++++++ src/server/services/work-orders/_shared.ts | 127 ++++++++++ src/server/services/work-orders/assign.ts | 81 +++++++ src/server/services/work-orders/cancel.ts | 10 + src/server/services/work-orders/checklist.ts | 157 +++++++++++++ src/server/services/work-orders/completion.ts | 69 ++++++ src/server/services/work-orders/conflicts.ts | 89 +++++++ src/server/services/work-orders/create.ts | 145 ++++++++++++ src/server/services/work-orders/dashboard.ts | 29 +++ src/server/services/work-orders/detail.ts | 121 ++++++++++ src/server/services/work-orders/documents.ts | Bin 0 -> 4781 bytes src/server/services/work-orders/list.ts | 161 +++++++++++++ src/server/services/work-orders/materials.ts | 133 +++++++++++ src/server/services/work-orders/options.ts | 81 +++++++ .../services/work-orders/release-billing.ts | 34 +++ src/server/services/work-orders/search.ts | 110 +++++++++ src/server/services/work-orders/settings.ts | 142 ++++++++++++ .../services/work-orders/sync-reapply.ts | 25 ++ src/server/services/work-orders/transition.ts | 107 +++++++++ src/server/services/work-orders/update.ts | 83 +++++++ 29 files changed, 2863 insertions(+) create mode 100644 scripts/lib-auftraege-fixtures.ts create mode 100644 scripts/test-auftraege-core.ts create mode 100644 scripts/test-auftraege-numbering.ts create mode 100644 scripts/test-auftraege-scope.ts create mode 100644 scripts/test-auftraege-status.ts create mode 100644 src/lib/work-orders/action-state.ts create mode 100644 src/lib/work-orders/defaults.ts create mode 100644 src/lib/work-orders/filters.ts create mode 100644 src/lib/work-orders/schemas.ts create mode 100644 src/lib/work-orders/time.ts create mode 100644 src/server/services/work-orders/_shared.ts create mode 100644 src/server/services/work-orders/assign.ts create mode 100644 src/server/services/work-orders/cancel.ts create mode 100644 src/server/services/work-orders/checklist.ts create mode 100644 src/server/services/work-orders/completion.ts create mode 100644 src/server/services/work-orders/conflicts.ts create mode 100644 src/server/services/work-orders/create.ts create mode 100644 src/server/services/work-orders/dashboard.ts create mode 100644 src/server/services/work-orders/detail.ts create mode 100644 src/server/services/work-orders/documents.ts create mode 100644 src/server/services/work-orders/list.ts create mode 100644 src/server/services/work-orders/materials.ts create mode 100644 src/server/services/work-orders/options.ts create mode 100644 src/server/services/work-orders/release-billing.ts create mode 100644 src/server/services/work-orders/search.ts create mode 100644 src/server/services/work-orders/settings.ts create mode 100644 src/server/services/work-orders/sync-reapply.ts create mode 100644 src/server/services/work-orders/transition.ts create mode 100644 src/server/services/work-orders/update.ts diff --git a/scripts/lib-auftraege-fixtures.ts b/scripts/lib-auftraege-fixtures.ts new file mode 100644 index 0000000..6894d4e --- /dev/null +++ b/scripts/lib-auftraege-fixtures.ts @@ -0,0 +1,172 @@ +// Shared fixtures for scripts/test-auftraege-*.ts (lane L2). Not a test itself (no "test-" prefix). +// +// Creates isolated zz-test tenants with the four standard roles, a team with lead + technician, +// an outside technician, a customer and a site; builds ServiceCtx objects exactly like +// ctxFromGuard does (tenant db + DB-authoritative permission set). + +import "dotenv/config"; +import { prisma, dbForTenant } from "../src/server/db"; +import { ROLE_DEFS, type RoleKey } from "../src/server/rbac"; +import type { ServiceCtx } from "../src/server/services/context"; + +export let failures = 0; +export const ok = (cond: boolean, msg: string) => { + console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`); + if (!cond) failures++; +}; + +/** Expect a ServiceError with the given code (e.g. "not_found"). Returns the error. */ +export async function expectCode(fn: () => Promise, code: string, msg: string): Promise { + try { + await fn(); + ok(false, `${msg} — kein Fehler (erwartet ${code})`); + return null; + } catch (e) { + const c = (e as { code?: string }).code; + ok(c === code, `${msg}${c === code ? "" : ` — erhalten ${c ?? (e as Error).message}`}`); + return e; + } +} + +export type Fixture = { + tenantId: string; + users: Record<"admin" | "backoffice" | "lead" | "tech" | "outsider", { id: string }>; + ctx: Record<"admin" | "backoffice" | "lead" | "tech" | "outsider", ServiceCtx>; + teamId: string; + otherTeamId: string; + customerId: string; + siteId: string; + contactId: string; +}; + +const ROLE_OF = { admin: "tenant-admin", backoffice: "backoffice", lead: "team-lead", tech: "technician", outsider: "technician" } as const; + +export function ctxFor(tenantId: string, userId: string, role: RoleKey): ServiceCtx { + return { db: dbForTenant(tenantId), tenantId, userId, permissions: new Set(ROLE_DEFS[role].permissions) }; +} + +export async function cleanupTenants(slugs: string[]) { + const tenants = await prisma.tenant.findMany({ where: { slug: { in: slugs } }, select: { id: true } }); + const ids = tenants.map((t) => t.id); + if (!ids.length) return; + const w = { where: { tenantId: { in: ids } } }; + await prisma.syncOperation.deleteMany(w); + await prisma.notification.deleteMany(w); + await prisma.signature.deleteMany(w); + await prisma.report.deleteMany(w); + await prisma.photo.deleteMany(w); + await prisma.activityNote.deleteMany(w); + await prisma.voiceNote.deleteMany(w); + await prisma.timeEntry.deleteMany(w); + await prisma.materialUsage.deleteMany(w); + await prisma.workSession.deleteMany(w); + await prisma.materialPlan.deleteMany(w); + await prisma.photoRequirement.deleteMany(w); + await prisma.checklistItem.deleteMany(w); + await prisma.workOrderStatusChange.deleteMany(w); + await prisma.workOrderAssignee.deleteMany(w); + await prisma.document.deleteMany(w); + await prisma.workOrder.deleteMany(w); + await prisma.teamMember.deleteMany(w); + await prisma.team.deleteMany(w); + await prisma.site.deleteMany(w); + await prisma.contact.deleteMany(w); + await prisma.customer.deleteMany(w); + await prisma.checklistTemplate.deleteMany(w); + await prisma.orderType.deleteMany(w); + await prisma.numberSequence.deleteMany(w); + await prisma.tenantSettings.deleteMany(w); + await prisma.auditLog.deleteMany(w); + await prisma.user.deleteMany(w); + await prisma.role.deleteMany(w); + await prisma.tenant.deleteMany({ where: { id: { in: ids } } }); + await prisma.identity.deleteMany({ where: { email: { endsWith: "@zz-auftraege.test" }, memberships: { none: {} } } }); +} + +async function ensurePermissionIds(keys: readonly string[]) { + const rows = await Promise.all(keys.map((key) => prisma.permission.upsert({ where: { key }, update: {}, create: { key } }))); + return rows.map((r) => r.id); +} + +export async function createFixture(slug: string): Promise { + const tenant = await prisma.tenant.create({ data: { name: `L2 Test ${slug}`, slug } }); + await prisma.tenantSettings.create({ data: { tenantId: tenant.id, orgName: `L2 Test ${slug}`, timezone: "Europe/Berlin" } }); + const roleIds: Partial> = {}; + for (const key of Object.keys(ROLE_DEFS) as RoleKey[]) { + const permIds = await ensurePermissionIds(ROLE_DEFS[key].permissions); + const role = await prisma.role.create({ + data: { tenantId: tenant.id, key, name: ROLE_DEFS[key].name, rolePermissions: { create: permIds.map((permissionId) => ({ permissionId })) } }, + }); + roleIds[key] = role.id; + } + + const users = {} as Fixture["users"]; + const ctx = {} as Fixture["ctx"]; + for (const name of Object.keys(ROLE_OF) as (keyof typeof ROLE_OF)[]) { + const email = `${name}-${slug}@zz-auftraege.test`; + const identity = await prisma.identity.create({ data: { email, passwordHash: "x" } }); + const user = await prisma.user.create({ + data: { tenantId: tenant.id, identityId: identity.id, email, name: `${name} ${slug}`, userRoles: { create: [{ roleId: roleIds[ROLE_OF[name]]! }] } }, + }); + users[name] = { id: user.id }; + ctx[name] = ctxFor(tenant.id, user.id, ROLE_OF[name]); + } + + const team = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Nord", leaderUserId: users.lead.id } }); + await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: team.id, userId: users.tech.id, validFrom: new Date(Date.now() - 86400000) } }); + const otherTeam = await prisma.team.create({ data: { tenantId: tenant.id, name: "Team Süd" } }); + await prisma.teamMember.create({ data: { tenantId: tenant.id, teamId: otherTeam.id, userId: users.outsider.id, validFrom: new Date(Date.now() - 86400000) } }); + + const customer = await prisma.customer.create({ data: { tenantId: tenant.id, companyName: `Kunde ${slug} GmbH`, customerNumber: `K-${slug}`, city: "Hamburg", street: "Hafenweg" } }); + const site = await prisma.site.create({ data: { tenantId: tenant.id, customerId: customer.id, name: `Objekt ${slug}`, city: "Hamburg", street: "Kaistraße", houseNumber: "7" } }); + const contact = await prisma.contact.create({ data: { tenantId: tenant.id, customerId: customer.id, name: `Frau Kontakt ${slug}` } }); + + return { tenantId: tenant.id, users, ctx, teamId: team.id, otherTeamId: otherTeam.id, customerId: customer.id, siteId: site.id, contactId: contact.id }; +} + +/** Raw work order in a given status, assigned to the fixture team (setup only — bypasses the state machine). */ +export async function rawWorkOrder(f: Fixture, status: string, extra: Record = {}) { + const n = Math.random().toString(36).slice(2, 10); + return prisma.workOrder.create({ + data: { + tenantId: f.tenantId, + number: `T-${n}`, + title: `Testauftrag ${n}`, + customerId: f.customerId, + siteId: f.siteId, + status: status as never, + assignedTeamId: f.teamId, + teamLeadUserId: f.users.lead.id, + signatureRequired: true, + ...extra, + }, + }); +} + +export async function approvedCompletionReport(f: Fixture, workOrderId: string, opts: { status?: string; signature?: boolean } = {}) { + const report = await prisma.report.create({ + data: { + tenantId: f.tenantId, + workOrderId, + type: "completion", + reportDate: new Date(), + lineageId: `lin-${workOrderId}-${Math.random().toString(36).slice(2, 8)}`, + status: (opts.status ?? "approved") as never, + content: {}, + }, + }); + if (opts.signature) { + await prisma.signature.create({ data: { tenantId: f.tenantId, reportId: report.id, outcome: "signed", signerName: "Kunde", signedAt: new Date() } }); + } + return report; +} + +export function finish(cleanup: () => Promise) { + return async () => { + await cleanup(); + await prisma.$disconnect(); + if (failures === 0) console.log("\nOK — alle Nachweise erfüllt."); + else console.log(`\n${failures} FEHLER.`); + process.exit(failures === 0 ? 0 : 1); + }; +} diff --git a/scripts/test-auftraege-core.ts b/scripts/test-auftraege-core.ts new file mode 100644 index 0000000..29129fc --- /dev/null +++ b/scripts/test-auftraege-core.ts @@ -0,0 +1,204 @@ +// L2 Aufträge: Kernlogik der Service-Schicht. +// +// (1) createWorkOrder: Nummer aus Nummernkreis, Übernahme Checkliste/Pflichtfotos/signatureRequired aus Auftragsart +// (2) updateWorkOrder: Version +1, baseVersion-Konflikt, Objekt/Kunde-Konsistenz, Audit before/after +// (3) assignWorkOrder: Team + Einzelpersonen + Teamleiter, draft → assigned, Audit +// (4) Completion-Blocker: Pflicht-Checklistenpunkt, fehlendes Pflichtfoto, laufende Session → blocked +// (5) Abrechnungsfreigabe nur mit freigegebenem Abschlussbericht; Unterschrift vor in_review +// (6) Materialvorgabe-CRUD + Soll/Ist-Übersicht mit Abweichung +// (7) Liste: Filter, Presets, Statusgruppen-Zähler; Dashboard-Kacheln +// (8) Sync-Konflikte: verwerfen / übernehmen +// (9) Einstellungen: Standard-Auftragsarten idempotent, Vorlage, Nummernkreis-Präfix +// +// Lauf: npx tsx scripts/test-auftraege-core.ts + +import "dotenv/config"; +import { randomUUID } from "node:crypto"; +import { prisma } from "../src/server/db"; +import { parseListParams } from "../src/lib/work-orders/filters"; +import { DEFAULT_CHECKLIST_ITEMS, DEFAULT_REQUIRED_PHOTOS } from "../src/lib/work-orders/defaults"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { updateWorkOrder, deleteWorkOrder } from "../src/server/services/work-orders/update"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { getCompletionBlockers } from "../src/server/services/work-orders/completion"; +import { releaseForBilling, rejectForCorrection } from "../src/server/services/work-orders/release-billing"; +import { cancelWorkOrder } from "../src/server/services/work-orders/cancel"; +import { addMaterialPlan, getMaterialOverview, removeMaterialPlan, updateMaterialPlan } from "../src/server/services/work-orders/materials"; +import { addChecklistItem, addPhotoRequirement, removeChecklistItem } from "../src/server/services/work-orders/checklist"; +import { listWorkOrders } from "../src/server/services/work-orders/list"; +import { getDashboardTiles } from "../src/server/services/work-orders/dashboard"; +import { applySyncConflict, discardSyncConflict, listSyncConflicts } from "../src/server/services/work-orders/conflicts"; +import { + createChecklistTemplate, + ensureDefaultOrderTypes, + listNumberSequences, + listOrderTypes, + updateNumberSequence, +} from "../src/server/services/work-orders/settings"; +import { approvedCompletionReport, cleanupTenants, createFixture, expectCode, finish, ok, rawWorkOrder } from "./lib-auftraege-fixtures"; + +const SLUG = "zz-l2-core"; + +async function main() { + await cleanupTenants([SLUG]); + const f = await createFixture(SLUG); + const bo = f.ctx.backoffice; + + console.log("\n— (9a) Standard-Auftragsarten + Vorlage —"); + const types = await listOrderTypes(bo); + ok(types.length === 8 && types.some((t) => t.key === "montage") && types.some((t) => t.key === "notdienst"), "8 Standard-Auftragsarten beim ersten Aufruf angelegt"); + ok((await ensureDefaultOrderTypes(bo.db, bo.tenantId)) === 0, "ensureDefaultOrderTypes ist idempotent"); + const besichtigung = types.find((t) => t.key === "besichtigung")!; + const montage = types.find((t) => t.key === "montage")!; + await expectCode( + () => createChecklistTemplate(f.ctx.tech, { name: "X", items: [], requiredPhotos: [] }), + "forbidden", + "Monteur darf keine Vorlagen pflegen (settings:templates)", + ); + await createChecklistTemplate(bo, { + name: "Montage Standard", + orderTypeId: montage.id, + items: [...DEFAULT_CHECKLIST_ITEMS], + requiredPhotos: DEFAULT_REQUIRED_PHOTOS.slice(0, 2), + }); + + console.log("\n— (1) createWorkOrder —"); + const a = await createWorkOrder(bo, { title: "Heizung montieren", customerId: f.customerId, siteId: f.siteId, contactId: f.contactId, orderTypeId: montage.id, plannedStart: new Date(Date.now() - 2 * 86400000), plannedEnd: new Date(Date.now() - 86400000) }); + ok(/^A-\d{5}$/.test(a.number) && a.status === "draft" && a.version === 1, `Nummer ${a.number}, Status draft, Version 1`); + const aItems = await prisma.checklistItem.count({ where: { workOrderId: a.id } }); + const aPhotos = await prisma.photoRequirement.count({ where: { workOrderId: a.id } }); + ok(aItems === DEFAULT_CHECKLIST_ITEMS.length && aPhotos === 2, "Checkliste und Pflichtfotos aus Auftragsart-Vorlage übernommen"); + const b = await createWorkOrder(bo, { title: "Besichtigung", customerId: f.customerId, orderTypeId: besichtigung.id }); + const bRow = await prisma.workOrder.findUnique({ where: { id: b.id } }); + ok(bRow?.signatureRequired === false && b.number !== a.number, "signatureRequired aus Auftragsart (Besichtigung = false), neue Nummer"); + ok((await prisma.auditLog.count({ where: { entity: "work_order", entityId: a.id, action: "create" } })) === 1, "Audit-Eintrag create"); + await expectCode(() => createWorkOrder(f.ctx.tech, { title: "X", customerId: f.customerId }), "forbidden", "Monteur darf keinen regulären Auftrag anlegen"); + const em = await createWorkOrder(f.ctx.tech, { title: "Rohrbruch", customerId: f.customerId, isEmergency: true, numberKey: "emergency", status: "in_progress" }); + ok(em.number.startsWith("N-"), "Notdienst mit emergency:create angelegt (Nummer N-…)"); + const otherSite = await prisma.site.create({ data: { tenantId: f.tenantId, customerId: (await prisma.customer.create({ data: { tenantId: f.tenantId, companyName: "Fremdkunde" } })).id, name: "Fremdobjekt" } }); + await expectCode(() => createWorkOrder(bo, { title: "X", customerId: f.customerId, siteId: otherSite.id }), "invalid", "Objekt eines anderen Kunden → invalid"); + + console.log("\n— (2) updateWorkOrder —"); + const u1 = await updateWorkOrder(bo, a.id, { title: "Heizung montieren (2 Etagen)", priority: "high" }, 1); + ok(u1.version === 2, "Version nach Update 2"); + await expectCode(() => updateWorkOrder(bo, a.id, { title: "veraltet" }, 1), "conflict", "Update mit veralteter baseVersion → conflict"); + await expectCode(() => updateWorkOrder(bo, a.id, { siteId: otherSite.id }), "invalid", "Update mit fremdem Objekt → invalid"); + const auditU = await prisma.auditLog.findFirst({ where: { entity: "work_order", entityId: a.id, action: "update" }, orderBy: { createdAt: "desc" } }); + ok((auditU?.before as { title?: string })?.title === "Heizung montieren" && (auditU?.after as { title?: string })?.title?.includes("2 Etagen") === true, "Audit before/after enthält Titel alt/neu"); + const d = await createWorkOrder(bo, { title: "Löschen", customerId: f.customerId }); + await deleteWorkOrder(bo, d.id); + ok((await prisma.workOrder.findUnique({ where: { id: d.id } }))?.deletedAt != null, "Entwurf soft-deleted (deletedAt gesetzt)"); + + console.log("\n— (3) assignWorkOrder —"); + const as = await assignWorkOrder(bo, { workOrderId: a.id, teamId: f.teamId, userIds: [f.users.tech.id] }); + const aRow = await prisma.workOrder.findUnique({ where: { id: a.id }, include: { assignees: true } }); + ok(as.status === "assigned" && aRow?.assignedTeamId === f.teamId && aRow.teamLeadUserId === f.users.lead.id && aRow.assignees.length === 1, "Team, Teamleiter (aus Team), Monteur zugewiesen, Status assigned"); + ok(aRow?.version === 3, "Version nach Zuweisung 3"); + await expectCode(() => assignWorkOrder(f.ctx.lead, { workOrderId: a.id, teamId: f.teamId }), "forbidden", "Teamleiter ohne work_order:assign → forbidden"); + + console.log("\n— (4) Completion-Blocker —"); + await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "accepted" }); + await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "in_progress" }); + const session = await prisma.workSession.create({ data: { tenantId: f.tenantId, workOrderId: a.id, userId: f.users.tech.id, status: "running", startedAt: new Date() } }); + const err = (await expectCode(() => transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "technically_completed" }), "blocked", "Abschluss mit offenen Punkten → blocked")) as { details?: { kind: string }[] } | null; + const kinds = new Set((err?.details ?? []).map((x) => x.kind)); + ok(kinds.has("checklist_item") && kinds.has("photo_requirement") && kinds.has("running_session"), "Blocker enthalten Checkliste, Pflichtfoto und laufende Session"); + const requiredItems = await prisma.checklistItem.findMany({ where: { workOrderId: a.id, required: true } }); + const reqs = await prisma.photoRequirement.findMany({ where: { workOrderId: a.id } }); + for (const i of requiredItems) { + await prisma.checklistItem.update({ where: { id: i.id }, data: { checked: true } }); + if (i.requiresPhoto) await prisma.photo.create({ data: { tenantId: f.tenantId, workOrderId: a.id, documentId: randomUUID(), checklistItemId: i.id, takenAt: new Date() } }); + } + for (const r of reqs) await prisma.photo.create({ data: { tenantId: f.tenantId, workOrderId: a.id, documentId: randomUUID(), photoRequirementId: r.id, takenAt: new Date() } }); + const onlySession = await getCompletionBlockers(f.ctx.tech, a.id); + ok(onlySession.length === 1 && onlySession[0].kind === "running_session", "Nach Checkliste/Fotos bleibt nur die laufende Session"); + await prisma.workSession.update({ where: { id: session.id }, data: { status: "ended", endedAt: new Date() } }); + const done = await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "technically_completed" }); + ok(done.status === "technically_completed", "Ohne Blocker → technically_completed"); + + console.log("\n— (5) Unterschrift + Abrechnungsfreigabe —"); + await expectCode(() => transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "in_review" }), "blocked", "in_review ohne Unterschrift (signatureRequired) → blocked"); + const report = await approvedCompletionReport(f, a.id, { status: "submitted", signature: true }); + await transitionWorkOrder(f.ctx.tech, { workOrderId: a.id, to: "in_review" }); + await expectCode(() => releaseForBilling(bo, { workOrderId: a.id }), "blocked", "Freigabe ohne freigegebenen Abschlussbericht → blocked"); + await expectCode(() => releaseForBilling(f.ctx.lead, { workOrderId: a.id }), "forbidden", "Teamleiter darf nicht zur Abrechnung freigeben"); + await expectCode(() => rejectForCorrection(bo, { workOrderId: a.id, reason: "" }), "invalid", "Zurückweisung ohne Grund → invalid"); + await prisma.report.update({ where: { id: report.id }, data: { status: "approved", approvedAt: new Date() } }); + const rel = await releaseForBilling(bo, { workOrderId: a.id }); + ok(rel.status === "released_for_billing", "Mit freigegebenem Abschlussbericht → released_for_billing"); + ok((await prisma.auditLog.count({ where: { entity: "work_order", entityId: a.id, after: { path: ["status"], equals: "released_for_billing" } } })) === 1, "Freigabe protokolliert"); + await expectCode(() => addMaterialPlan(bo, a.id, { name: "Rohr", plannedQuantity: 1, unit: "m" }), "invalid", "Nach Freigabe keine Materialvorgabe mehr (not_editable)"); + + console.log("\n— (6) Materialvorgabe + Soll/Ist —"); + const c = await createWorkOrder(bo, { title: "Material", customerId: f.customerId }); + const plan = await addMaterialPlan(bo, c.id, { name: "Kupferrohr 15mm", plannedQuantity: 10, unit: "m", articleNumber: "CU-15" }); + const plan2 = await addMaterialPlan(bo, c.id, { name: "Fitting", plannedQuantity: 4, unit: "Stk" }); + await updateMaterialPlan(bo, plan2.id, { name: "Fitting 15mm", plannedQuantity: 6, unit: "Stk" }); + ok((await prisma.workOrder.findUnique({ where: { id: c.id } }))?.version === 4, "Jede Materialmutation erhöht die Version (1 → 4)"); + await prisma.materialUsage.create({ data: { tenantId: f.tenantId, workOrderId: c.id, materialPlanId: plan.id, name: plan.name, actualQuantity: 12.5, unit: "m", usageStatus: "fully_used", deviationReason: "Umweg Leitung" } }); + await prisma.materialUsage.create({ data: { tenantId: f.tenantId, workOrderId: c.id, name: "Dichtband", actualQuantity: 1, unit: "Rolle", usageStatus: "additional" } }); + const overview = await getMaterialOverview(bo, c.id); + const row = overview.find((r) => r.planId === plan.id); + ok(row?.deviation === 2.5 && row.reasons[0] === "Umweg Leitung", "Abweichung +2,5 m inkl. Begründung"); + ok(overview.some((r) => r.planId === null && r.name === "Dichtband"), "Zusatzmaterial ohne Vorgabe erscheint"); + await expectCode(() => removeMaterialPlan(bo, plan.id), "invalid", "Vorgabe mit erfasstem Verbrauch nicht löschbar"); + await removeMaterialPlan(bo, plan2.id); + ok((await prisma.materialPlan.count({ where: { workOrderId: c.id } })) === 1, "Unbenutzte Vorgabe entfernt"); + const item = await addChecklistItem(bo, c.id, { label: "Druckprobe durchgeführt", required: true }); + await addPhotoRequirement(bo, c.id, { label: "Typenschild" }); + ok(item.key === "druckprobe_durchgefuehrt", "Checklistenpunkt mit stabilem Schlüssel"); + await removeChecklistItem(bo, item.id); + + console.log("\n— (7) Liste + Dashboard —"); + const overdue = await rawWorkOrder(f, "in_progress", { plannedStart: new Date(Date.now() - 5 * 86400000), plannedEnd: new Date(Date.now() - 3600000) }); + const today = await rawWorkOrder(f, "assigned", { plannedStart: new Date(), plannedEnd: new Date(Date.now() + 3600000), priority: "urgent" }); + await rawWorkOrder(f, "signature_pending"); + const all = await listWorkOrders(bo, parseListParams({})); + ok(all.total === (await prisma.workOrder.count({ where: { tenantId: f.tenantId, deletedAt: null } })), "Liste zeigt alle nicht gelöschten Aufträge"); + const od = await listWorkOrders(bo, parseListParams({ preset: "overdue" })); + ok(od.items.some((i) => i.id === overdue.id) && !od.items.some((i) => i.id === today.id), "Preset überfällig"); + const td = await listWorkOrders(bo, parseListParams({ preset: "today" })); + ok(td.items.some((i) => i.id === today.id), "Preset heute"); + const prio = await listWorkOrders(bo, parseListParams({ priority: "urgent", group: "planned" })); + ok(prio.total === 1 && prio.items[0].id === today.id, "Filter Priorität + Statusgruppe"); + ok(prio.groupCounts.in_progress >= 0 && all.groupCounts.documentation_incomplete >= 1, "Statusgruppen-Zähler vorhanden"); + const byTeam = await listWorkOrders(bo, parseListParams({ teamId: f.otherTeamId })); + ok(byTeam.total === 0, "Filter Team (fremdes Team leer)"); + const paged = await listWorkOrders(bo, parseListParams({ pageSize: "2", page: "2", sort: "number", dir: "desc" })); + ok(paged.items.length <= 2 && paged.page === 2, "Paginierung + Sortierung"); + const tiles = await getDashboardTiles(bo, {}); + ok(tiles.overdue >= 1 && tiles.today >= 1 && tiles.missing_signatures >= 1 && tiles.billing === 1 && tiles.not_accepted >= 1 && tiles.emergency_new >= 1, "Dashboard-Kacheln zählen korrekt"); + + console.log("\n— (8) Sync-Konflikte —"); + const conflictWo = await rawWorkOrder(f, "assigned"); + const op1 = await prisma.syncOperation.create({ data: { tenantId: f.tenantId, userId: f.users.tech.id, clientOpId: randomUUID(), opType: "work_order.transition", entityType: "work_order", entityId: conflictWo.id, baseVersion: 99, payload: { to: "accepted" }, status: "conflict", clientCreatedAt: new Date() } }); + const op2 = await prisma.syncOperation.create({ data: { tenantId: f.tenantId, userId: f.users.tech.id, clientOpId: randomUUID(), opType: "work_order.transition", entityType: "work_order", entityId: conflictWo.id, baseVersion: 98, payload: { to: "en_route" }, status: "conflict", clientCreatedAt: new Date() } }); + const tilesWithConflicts = await getDashboardTiles(bo, {}); + ok((await listSyncConflicts(bo)).length === 2 && tilesWithConflicts.syncConflicts === 2, "Konfliktliste + Kachel zeigen 2 offene Konflikte"); + await expectCode(() => listSyncConflicts(f.ctx.tech), "forbidden", "Monteur sieht keine Konfliktliste"); + await discardSyncConflict(bo, op2.id); + const op2Row = await prisma.syncOperation.findUnique({ where: { id: op2.id } }); + ok(op2Row?.resolvedAt != null && op2Row.resolvedById === f.users.backoffice.id, "Verwerfen setzt resolvedAt/By"); + await expectCode(() => discardSyncConflict(bo, op2.id), "not_found", "Bereits aufgelöster Konflikt nicht erneut bearbeitbar"); + await applySyncConflict(bo, op1.id); + ok((await prisma.workOrder.findUnique({ where: { id: conflictWo.id } }))?.status === "accepted", "Übernehmen wendet die Operation als Gerätenutzer an (assigned → accepted)"); + ok((await prisma.auditLog.count({ where: { entity: "sync_operation", entityId: { in: [op1.id, op2.id] } } })) === 2, "Beide Auflösungen auditiert"); + + console.log("\n— (9b) Nummernkreis-Einstellungen, Storno —"); + await updateNumberSequence(bo, { key: "work_order", prefix: "AU-", padding: 6 }); + const e = await createWorkOrder(bo, { title: "Präfix", customerId: f.customerId }); + ok(/^AU-\d{6}$/.test(e.number), `Neues Präfix/Stellen wirken (${e.number})`); + const seqs = await listNumberSequences(bo); + ok(seqs.find((s) => s.key === "report")?.prefix === "B-" && seqs.find((s) => s.key === "work_order")?.padding === 6, "Nummernkreise inkl. Defaults gelistet"); + const cancelled = await cancelWorkOrder(bo, { workOrderId: e.id, reason: "Kunde abgesagt" }); + ok(cancelled.status === "cancelled", "Storno mit Grund"); + await expectCode(() => updateWorkOrder(bo, e.id, { title: "nach Storno" }), "invalid", "Stornierter Auftrag nicht mehr änderbar"); +} + +main() + .catch((e) => { + console.error(e); + ok(false, "unerwarteter Fehler"); + }) + .finally(finish(() => cleanupTenants([SLUG]))); diff --git a/scripts/test-auftraege-numbering.ts b/scripts/test-auftraege-numbering.ts new file mode 100644 index 0000000..0e350c1 --- /dev/null +++ b/scripts/test-auftraege-numbering.ts @@ -0,0 +1,41 @@ +// L2 Aufträge: Nummernkreis unter Parallelität. +// +// (1) 20 parallele createWorkOrder auf einem frischen Mandanten (auch die erste Zeile der Sequenz +// entsteht im Wettlauf) → 20 eindeutige, lückenlose Nummern A-00001 … A-00020 +// (2) Zweiter Mandant beginnt unabhängig bei A-00001 +// +// Lauf: npx tsx scripts/test-auftraege-numbering.ts + +import "dotenv/config"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { cleanupTenants, createFixture, finish, ok } from "./lib-auftraege-fixtures"; + +const SLUG_A = "zz-l2-num-a"; +const SLUG_B = "zz-l2-num-b"; + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B]); + const A = await createFixture(SLUG_A); + const B = await createFixture(SLUG_B); + + const results = await Promise.allSettled( + Array.from({ length: 20 }, (_, i) => createWorkOrder(A.ctx.backoffice, { title: `Parallel ${i}`, customerId: A.customerId })), + ); + const fulfilled = results.filter((r): r is PromiseFulfilledResult>> => r.status === "fulfilled"); + for (const r of results) if (r.status === "rejected") console.error(r.reason); + ok(fulfilled.length === 20, `20 von 20 parallelen Anlagen erfolgreich (${fulfilled.length})`); + const numbers = fulfilled.map((r) => r.value.number).sort(); + ok(new Set(numbers).size === numbers.length, "Alle Nummern eindeutig"); + const expected = Array.from({ length: 20 }, (_, i) => `A-${String(i + 1).padStart(5, "0")}`); + ok(JSON.stringify(numbers) === JSON.stringify(expected), "Lückenlos A-00001 … A-00020"); + + const b = await createWorkOrder(B.ctx.backoffice, { title: "B", customerId: B.customerId }); + ok(b.number === "A-00001", "Mandant B hat einen eigenen Nummernkreis"); +} + +main() + .catch((e) => { + console.error(e); + ok(false, "unerwarteter Fehler"); + }) + .finally(finish(() => cleanupTenants([SLUG_A, SLUG_B]))); diff --git a/scripts/test-auftraege-scope.ts b/scripts/test-auftraege-scope.ts new file mode 100644 index 0000000..df244cb --- /dev/null +++ b/scripts/test-auftraege-scope.ts @@ -0,0 +1,108 @@ +// L2 Aufträge: Sichtbarkeit (Rollen/Scope) und Mandantentrennung. +// +// (1) Monteur eines fremden Teams: Liste leer, Detail/Übergang/Blocker → not_found +// (2) Team-Mitglied, Teamleiter und einzeln zugewiesener Monteur sehen den Auftrag +// (3) Monteur ohne Rechte: Anlage/Änderung/Zuweisung/Material → forbidden +// (4) Mandant B kann Aufträge von A weder lesen (Liste, Detail, Suche, Dashboard) noch ändern +// (Update, Übergang, Zuweisung, Material, Konflikt) — auch nicht über den Tenant-Guard direkt +// (5) Suche respektiert Scope (Monteur fremdes Team findet nichts) und Mandant +// +// Lauf: npx tsx scripts/test-auftraege-scope.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { parseListParams } from "../src/lib/work-orders/filters"; +import { createWorkOrder } from "../src/server/services/work-orders/create"; +import { updateWorkOrder } from "../src/server/services/work-orders/update"; +import { assignWorkOrder } from "../src/server/services/work-orders/assign"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { getCompletionBlockers } from "../src/server/services/work-orders/completion"; +import { addMaterialPlan } from "../src/server/services/work-orders/materials"; +import { listWorkOrders } from "../src/server/services/work-orders/list"; +import { getWorkOrderDetail } from "../src/server/services/work-orders/detail"; +import { getDashboardTiles } from "../src/server/services/work-orders/dashboard"; +import { searchAll } from "../src/server/services/work-orders/search"; +import { discardSyncConflict } from "../src/server/services/work-orders/conflicts"; +import { cleanupTenants, createFixture, expectCode, finish, ok } from "./lib-auftraege-fixtures"; + +const SLUG_A = "zz-l2-scope-a"; +const SLUG_B = "zz-l2-scope-b"; + +async function main() { + await cleanupTenants([SLUG_A, SLUG_B]); + const A = await createFixture(SLUG_A); + const B = await createFixture(SLUG_B); + + const wo = await createWorkOrder(A.ctx.backoffice, { title: "Wärmepumpe Zauberwort", customerId: A.customerId, siteId: A.siteId }); + await assignWorkOrder(A.ctx.backoffice, { workOrderId: wo.id, teamId: A.teamId }); + await prisma.activityNote.create({ data: { tenantId: A.tenantId, workOrderId: wo.id, text: "Zauberwort im Keller", authorId: A.users.tech.id } }); + const solo = await createWorkOrder(A.ctx.backoffice, { title: "Einzelzuweisung", customerId: A.customerId }); + await assignWorkOrder(A.ctx.backoffice, { workOrderId: solo.id, teamId: A.otherTeamId, userIds: [A.users.tech.id] }); + + console.log("\n— (1) Monteur fremdes Team —"); + const outsiderList = await listWorkOrders(A.ctx.outsider, parseListParams({})); + ok(outsiderList.items.every((i) => i.id !== wo.id), "Liste des fremden Monteurs enthält den Auftrag nicht"); + await expectCode(() => getWorkOrderDetail(A.ctx.outsider, wo.id), "not_found", "Detail → not_found"); + await expectCode(() => transitionWorkOrder(A.ctx.outsider, { workOrderId: wo.id, to: "accepted" }), "not_found", "Übergang → not_found (Existenz nicht preisgegeben)"); + await expectCode(() => getCompletionBlockers(A.ctx.outsider, wo.id), "not_found", "Blocker → not_found"); + + console.log("\n— (2) Berechtigte Feldrollen —"); + const techList = await listWorkOrders(A.ctx.tech, parseListParams({})); + ok(techList.items.some((i) => i.id === wo.id), "Team-Mitglied sieht den Team-Auftrag"); + ok(techList.items.some((i) => i.id === solo.id), "Einzeln zugewiesener Monteur sieht Auftrag eines anderen Teams"); + ok((await getWorkOrderDetail(A.ctx.lead, wo.id)).id === wo.id, "Teamleiter sieht den Auftrag"); + const accepted = await transitionWorkOrder(A.ctx.tech, { workOrderId: wo.id, to: "accepted" }); + ok(accepted.status === "accepted", "Team-Mitglied darf annehmen"); + const outsiderSolo = await listWorkOrders(A.ctx.outsider, parseListParams({})); + ok(outsiderSolo.items.some((i) => i.id === solo.id), "Mitglied des zugewiesenen Teams sieht den Einzelzuweisungs-Auftrag"); + + console.log("\n— (3) Monteur ohne Backoffice-Rechte —"); + await expectCode(() => createWorkOrder(A.ctx.tech, { title: "X", customerId: A.customerId }), "forbidden", "Anlage → forbidden"); + await expectCode(() => updateWorkOrder(A.ctx.tech, wo.id, { title: "X" }), "forbidden", "Änderung → forbidden"); + await expectCode(() => assignWorkOrder(A.ctx.tech, { workOrderId: wo.id, teamId: A.teamId }), "forbidden", "Zuweisung → forbidden"); + await expectCode(() => addMaterialPlan(A.ctx.tech, wo.id, { name: "X", plannedQuantity: 1, unit: "m" }), "forbidden", "Materialvorgabe → forbidden"); + await expectCode(() => transitionWorkOrder(A.ctx.tech, { workOrderId: wo.id, to: "cancelled", reason: "x" }), "forbidden", "Storno → forbidden"); + + console.log("\n— (4) Mandantentrennung —"); + const bList = await listWorkOrders(B.ctx.admin, parseListParams({})); + ok(bList.total === 0, "Mandant B: Liste leer"); + await expectCode(() => getWorkOrderDetail(B.ctx.admin, wo.id), "not_found", "Mandant B: Detail → not_found"); + await expectCode(() => updateWorkOrder(B.ctx.admin, wo.id, { title: "gekapert" }), "not_found", "Mandant B: Update → not_found"); + await expectCode(() => transitionWorkOrder(B.ctx.admin, { workOrderId: wo.id, to: "cancelled", reason: "x" }), "not_found", "Mandant B: Übergang → not_found"); + await expectCode(() => assignWorkOrder(B.ctx.admin, { workOrderId: wo.id, teamId: B.teamId }), "not_found", "Mandant B: Zuweisung → not_found"); + await expectCode(() => addMaterialPlan(B.ctx.admin, wo.id, { name: "X", plannedQuantity: 1, unit: "m" }), "not_found", "Mandant B: Materialvorgabe → not_found"); + await expectCode(() => createWorkOrder(B.ctx.admin, { title: "X", customerId: A.customerId }), "invalid", "Mandant B: Anlage mit Kunde von A → invalid"); + const tilesB = await getDashboardTiles(B.ctx.admin, {}); + ok(Object.values(tilesB).every((v) => v === 0), "Mandant B: alle Dashboard-Kacheln 0"); + const direct = await B.ctx.admin.db.workOrder.findUnique({ where: { id: wo.id } }); + ok(direct === null, "Tenant-Guard: findUnique über Mandant B → null"); + const updMany = await B.ctx.admin.db.workOrder.updateMany({ where: { id: wo.id }, data: { title: "gekapert" } }); + ok(updMany.count === 0, "Tenant-Guard: updateMany über Mandant B ändert 0 Zeilen"); + const opA = await prisma.syncOperation.create({ data: { tenantId: A.tenantId, userId: A.users.tech.id, clientOpId: crypto.randomUUID(), opType: "note.create", payload: {}, status: "conflict", clientCreatedAt: new Date() } }); + await expectCode(() => discardSyncConflict(B.ctx.admin, opA.id), "not_found", "Mandant B: Konflikt von A → not_found"); + const title = (await prisma.workOrder.findUnique({ where: { id: wo.id } }))?.title; + ok(title === "Wärmepumpe Zauberwort", "Auftrag von A unverändert"); + + console.log("\n— (5) Suche —"); + const sA = await searchAll(A.ctx.backoffice, { q: "zauberwort" }); + ok(sA.workOrders.some((w) => w.id === wo.id) && sA.notes.length === 1, "Backoffice A findet Auftrag + Notiz"); + const sCust = await searchAll(A.ctx.backoffice, { q: "Kaistraße" }); + ok(sCust.sites.length === 1 && sCust.workOrders.some((w) => w.id === wo.id), "Adresssuche findet Objekt und Auftrag"); + const sB = await searchAll(B.ctx.admin, { q: "zauberwort" }); + ok(sB.workOrders.length === 0 && sB.notes.length === 0, "Mandant B findet nichts von A"); + const sB2 = await searchAll(B.ctx.admin, { q: SLUG_A }); + ok(sB2.customers.length === 0 && sB2.sites.length === 0 && sB2.contacts.length === 0, "Mandant B findet keine Kunden/Objekte/Kontakte von A"); + const sOut = await searchAll(A.ctx.outsider, { q: "zauberwort" }); + ok(sOut.workOrders.length === 0 && sOut.notes.length === 0, "Monteur fremdes Team findet Auftrag/Notiz nicht"); + const sTech = await searchAll(A.ctx.tech, { q: "zauberwort", statuses: ["accepted"] }); + ok(sTech.workOrders.length === 1, "Team-Mitglied findet Auftrag (mit Statusfilter)"); + const sFiltered = await searchAll(A.ctx.backoffice, { q: "zauberwort", statuses: ["billed"] }); + ok(sFiltered.workOrders.length === 0, "Statusfilter der Suche greift"); +} + +main() + .catch((e) => { + console.error(e); + ok(false, "unerwarteter Fehler"); + }) + .finally(finish(() => cleanupTenants([SLUG_A, SLUG_B]))); diff --git a/scripts/test-auftraege-status.ts b/scripts/test-auftraege-status.ts new file mode 100644 index 0000000..0ec9c83 --- /dev/null +++ b/scripts/test-auftraege-status.ts @@ -0,0 +1,138 @@ +// L2 Aufträge: vollständige Übergangsmatrix des Auftrags-Statusmodells (ARCHITEKTUR §3). +// +// (1) Reine Tabelle: jedes (von, nach)-Paar — erlaubte Paare exakt wie im Vertrag, alle anderen verboten. +// (2) Service je Rolle (tenant-admin, backoffice, team-lead, technician) für JEDEN erlaubten Übergang: +// Erwartung unabhängig aus der Rechte-Tabelle abgeleitet → Erfolg bzw. forbidden; +// zusätzlich Version +1, WorkOrderStatusChange und Audit-Eintrag. +// (3) Verbotene Paare über den Service → invalid (Stichprobe über alle Statuswerte). +// +// Lauf: npx tsx scripts/test-auftraege-status.ts + +import "dotenv/config"; +import { prisma } from "../src/server/db"; +import { WORK_ORDER_STATUSES, canTransition, type WorkOrderStatus } from "../src/lib/work-orders/status"; +import { transitionWorkOrder } from "../src/server/services/work-orders/transition"; +import { approvedCompletionReport, cleanupTenants, createFixture, expectCode, finish, ok, rawWorkOrder } from "./lib-auftraege-fixtures"; + +const SLUG = "zz-l2-status"; + +const CONTRACT: Record = { + draft: ["review_required", "planned", "assigned", "cancelled"], + review_required: ["planned", "assigned", "cancelled"], + planned: ["assigned", "cancelled"], + assigned: ["accepted", "en_route", "in_progress", "planned", "cancelled"], + accepted: ["en_route", "in_progress", "assigned", "cancelled"], + en_route: ["in_progress", "cancelled"], + in_progress: ["paused", "waiting_material", "daily_report_created", "technically_completed", "cancelled"], + paused: ["in_progress", "en_route", "cancelled"], + waiting_material: ["in_progress", "en_route", "cancelled"], + daily_report_created: ["en_route", "in_progress", "cancelled"], + technically_completed: ["signature_pending", "in_review", "in_progress", "cancelled"], + signature_pending: ["in_review", "cancelled"], + in_review: ["released_for_billing", "in_progress", "cancelled"], + released_for_billing: ["billed", "in_review"], + billed: [], + cancelled: [], +}; + +type Role = "admin" | "backoffice" | "lead" | "tech"; + +/** Expected roles per transition — written from the ARCHITEKTUR §3 rights table, NOT from status.ts. */ +function expectedRoles(from: WorkOrderStatus, to: WorkOrderStatus): Role[] { + if (to === "cancelled") return ["admin", "backoffice"]; + if (to === "released_for_billing" || to === "billed" || (from === "released_for_billing" && to === "in_review")) return ["admin", "backoffice"]; + if (from === "in_review" && to === "in_progress") return ["admin", "backoffice", "lead"]; + if (["draft", "review_required", "planned"].includes(from)) return ["admin", "backoffice"]; + if (from === "assigned" && to === "planned") return ["admin", "backoffice"]; + return ["admin", "lead", "tech"]; +} + +const needsReason = (from: WorkOrderStatus, to: WorkOrderStatus) => + to === "cancelled" || (from === "in_review" && to === "in_progress") || (from === "released_for_billing" && to === "in_review"); + +async function main() { + await cleanupTenants([SLUG]); + const f = await createFixture(SLUG); + + console.log("\n— (1) Übergangstabelle —"); + let tableOk = true; + for (const from of WORK_ORDER_STATUSES) { + for (const to of WORK_ORDER_STATUSES) { + if (canTransition(from, to) !== CONTRACT[from].includes(to)) { + tableOk = false; + console.log(` Abweichung ${from} → ${to}`); + } + } + } + ok(tableOk, `Alle ${WORK_ORDER_STATUSES.length ** 2} Paare entsprechen dem Vertrag`); + + console.log("\n— (2) Erlaubte Übergänge je Rolle —"); + let checked = 0; + for (const from of WORK_ORDER_STATUSES) { + for (const to of CONTRACT[from]) { + for (const role of ["admin", "backoffice", "lead", "tech"] as Role[]) { + const wo = await rawWorkOrder(f, from); + if (to === "in_review" && (from === "technically_completed" || from === "signature_pending")) { + await approvedCompletionReport(f, wo.id, { status: "submitted", signature: true }); + } + if (to === "released_for_billing") await approvedCompletionReport(f, wo.id); + const allowed = expectedRoles(from, to).includes(role); + const input = { workOrderId: wo.id, to, reason: needsReason(from, to) ? "Testgrund" : undefined, baseVersion: wo.version }; + checked++; + if (allowed) { + try { + const res = await transitionWorkOrder(f.ctx[role], input); + const after = await prisma.workOrder.findUnique({ where: { id: wo.id } }); + const change = await prisma.workOrderStatusChange.count({ where: { workOrderId: wo.id, fromStatus: from, toStatus: to } }); + const audit = await prisma.auditLog.count({ where: { tenantId: f.tenantId, entity: "work_order", entityId: wo.id } }); + const good = res.status === to && after?.status === to && after.version === wo.version + 1 && change === 1 && audit >= 1; + if (!good) ok(false, `${role}: ${from} → ${to} (Status/Version/Historie/Audit)`); + } catch (e) { + ok(false, `${role}: ${from} → ${to} sollte erlaubt sein — ${(e as { code?: string }).code ?? (e as Error).message}`); + } + } else { + try { + await transitionWorkOrder(f.ctx[role], input); + ok(false, `${role}: ${from} → ${to} sollte verboten sein`); + } catch (e) { + if ((e as { code?: string }).code !== "forbidden") ok(false, `${role}: ${from} → ${to} erwartet forbidden, erhalten ${(e as { code?: string }).code ?? (e as Error).message}`); + const still = await prisma.workOrder.findUnique({ where: { id: wo.id } }); + if (still?.status !== from || still.version !== wo.version) ok(false, `${role}: ${from} → ${to} hat trotz Verbot geschrieben`); + } + } + } + } + } + ok(true, `${checked} Rolle×Übergang-Kombinationen geprüft`); + + console.log("\n— (3) Verbotene Paare über den Service —"); + let invalidOk = true; + for (const from of WORK_ORDER_STATUSES) { + const forbiddenTargets = WORK_ORDER_STATUSES.filter((t) => t !== from && !CONTRACT[from].includes(t)); + const to = forbiddenTargets[(from.length * 7) % forbiddenTargets.length]; + const wo = await rawWorkOrder(f, from); + try { + await transitionWorkOrder(f.ctx.admin, { workOrderId: wo.id, to, reason: "x" }); + invalidOk = false; + console.log(` ${from} → ${to} wurde nicht abgewiesen`); + } catch (e) { + if ((e as { code?: string }).code !== "invalid") { + invalidOk = false; + console.log(` ${from} → ${to}: ${(e as { code?: string }).code}`); + } + } + } + ok(invalidOk, "Nicht in der Tabelle enthaltene Übergänge → invalid (je Ausgangsstatus)"); + + console.log("\n— (4) Begründungspflicht, Versionskonflikt —"); + const c1 = await rawWorkOrder(f, "planned"); + await expectCode(() => transitionWorkOrder(f.ctx.backoffice, { workOrderId: c1.id, to: "cancelled" }), "invalid", "Stornierung ohne Grund → invalid"); + await expectCode(() => transitionWorkOrder(f.ctx.backoffice, { workOrderId: c1.id, to: "assigned", baseVersion: c1.version + 5 }), "conflict", "Veraltete baseVersion → conflict"); +} + +main() + .catch((e) => { + console.error(e); + ok(false, "unerwarteter Fehler"); + }) + .finally(finish(() => cleanupTenants([SLUG]))); diff --git a/src/lib/work-orders/action-state.ts b/src/lib/work-orders/action-state.ts new file mode 100644 index 0000000..ba1ea84 --- /dev/null +++ b/src/lib/work-orders/action-state.ts @@ -0,0 +1,17 @@ +import type { CompletionBlocker } from "@/lib/work-orders/status"; + +/** Result of a work order server action (client-safe; rendered by components/work-orders/action-form.tsx). */ +export type ActionState = + | { status: "idle" } + | { status: "ok"; at: number } + | { + status: "error"; + /** ServiceError code: not_found | forbidden | invalid | conflict | blocked | internal */ + code: string; + /** message key below `errors.` (falls back to the code) */ + message: string; + blockers?: CompletionBlocker[]; + at: number; + }; + +export const IDLE_STATE: ActionState = { status: "idle" }; diff --git a/src/lib/work-orders/defaults.ts b/src/lib/work-orders/defaults.ts new file mode 100644 index 0000000..2fc1b77 --- /dev/null +++ b/src/lib/work-orders/defaults.ts @@ -0,0 +1,48 @@ +import type { TemplateItem, TemplatePhoto } from "@/lib/work-orders/schemas"; + +/** + * Tenant defaults (client-safe data). Labels are German master data (stored per tenant and + * editable afterwards), not UI chrome — therefore not part of the message catalogue. + */ + +/** Mirrors the defaults of src/server/services/numbering.ts (used before a sequence row exists). */ +export const DEFAULT_NUMBER_PREFIX: Record<"customer" | "work_order" | "emergency" | "report", string> = { + customer: "K-", + work_order: "A-", + emergency: "N-", + report: "B-", +}; + +/** Spec §10.2 — created by ensureDefaultOrderTypes on first use per tenant. */ +export const DEFAULT_ORDER_TYPES: ReadonlyArray<{ key: string; name: string; signatureRequired: boolean; sortOrder: number }> = [ + { key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10 }, + { key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20 }, + { key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30 }, + { key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40 }, + { key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50 }, + { key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60 }, + { key: "abnahme", name: "Abnahme", signatureRequired: true, sortOrder: 70 }, + { key: "nacharbeit", name: "Nacharbeit", signatureRequired: true, sortOrder: 80 }, +]; + +/** Spec §12.4 — suggested checklist items. */ +export const DEFAULT_CHECKLIST_ITEMS: readonly TemplateItem[] = [ + { key: "spannungsfrei", label: "Anlage spannungsfrei geschaltet", required: true, requiresPhoto: false }, + { key: "arbeitsbereich_abgesichert", label: "Arbeitsbereich abgesichert", required: true, requiresPhoto: false }, + { key: "material_geprueft", label: "Material geprüft", required: false, requiresPhoto: false }, + { key: "funktionspruefung", label: "Funktionsprüfung durchgeführt", required: true, requiresPhoto: true }, + { key: "arbeitsbereich_gereinigt", label: "Arbeitsbereich gereinigt", required: true, requiresPhoto: false }, + { key: "kunde_eingewiesen", label: "Kunde eingewiesen", required: false, requiresPhoto: false }, + { key: "pflichtfotos_erstellt", label: "Pflichtfotos erstellt", required: true, requiresPhoto: false }, +]; + +/** Spec §14.2 — suggested required photos (keys match PhotoRequirement.key catalogue). */ +export const DEFAULT_REQUIRED_PHOTOS: readonly TemplatePhoto[] = [ + { key: "ausgangszustand", label: "Ausgangszustand" }, + { key: "typenschild", label: "Typenschild" }, + { key: "leitungsverlauf", label: "Leitungsverlauf" }, + { key: "zwischenschritt", label: "Zwischenschritt" }, + { key: "fertige_montage", label: "Fertige Montage" }, + { key: "funktionspruefung", label: "Funktionsprüfung" }, + { key: "arbeitsbereich_abschluss", label: "Arbeitsbereich nach Abschluss" }, +]; diff --git a/src/lib/work-orders/filters.ts b/src/lib/work-orders/filters.ts new file mode 100644 index 0000000..41503d5 --- /dev/null +++ b/src/lib/work-orders/filters.ts @@ -0,0 +1,126 @@ +import { WORK_ORDER_STATUSES, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status"; +import { WORK_ORDER_PRIORITIES, type WorkOrderPriority } from "@/lib/work-orders/schemas"; + +/** + * List/dashboard filter model (spec §21) and its URL representation (client-safe). + * The same parser feeds /work-orders, /dashboard and GET /api/v1/work-orders. + */ + +export const STATUS_GROUPS: readonly StatusGroup[] = [ + "new", + "planned", + "en_route", + "in_progress", + "documentation_incomplete", + "in_review", + "ready_for_billing", + "billed", + "cancelled", +]; + +/** Dashboard tiles (§21) — each tile links to the list with `preset=`. */ +export const PRESETS = [ + "open", + "today", + "running", + "not_accepted", + "overdue", + "reports_in_review", + "completed", + "billing", + "emergency_new", + "missing_signatures", +] as const; +export type Preset = (typeof PRESETS)[number]; + +export const SORT_FIELDS = ["plannedStart", "createdAt", "updatedAt", "number", "priority", "status"] as const; +export type SortField = (typeof SORT_FIELDS)[number]; + +export type WorkOrderFilter = { + from?: Date; + to?: Date; + customerId?: string; + siteId?: string; + teamId?: string; + userId?: string; + statuses?: WorkOrderStatus[]; + group?: StatusGroup; + orderTypeId?: string; + priority?: WorkOrderPriority; + preset?: Preset; + q?: string; +}; + +export type ListParams = WorkOrderFilter & { + sort: SortField; + dir: "asc" | "desc"; + page: number; + pageSize: number; +}; + +type Raw = Record | URLSearchParams; + +function get(raw: Raw, key: string): string | undefined { + const v = raw instanceof URLSearchParams ? raw.get(key) ?? undefined : raw[key]; + const s = Array.isArray(v) ? v[0] : v; + return s && s.trim() ? s.trim() : undefined; +} + +function date(v: string | undefined, endOfDay = false): Date | undefined { + if (!v || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return undefined; + const d = new Date(`${v}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}`); + return Number.isNaN(d.getTime()) ? undefined : d; +} + +const cuidLike = (v: string | undefined) => (v && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : undefined); + +export function parseListParams(raw: Raw): ListParams { + const statusRaw = get(raw, "status"); + const statuses = statusRaw + ?.split(",") + .filter((s): s is WorkOrderStatus => (WORK_ORDER_STATUSES as readonly string[]).includes(s)); + const group = get(raw, "group"); + const priority = get(raw, "priority"); + const preset = get(raw, "preset"); + const sort = get(raw, "sort"); + const q = get(raw, "q"); + return { + from: date(get(raw, "from")), + to: date(get(raw, "to"), true), + customerId: cuidLike(get(raw, "customerId")), + siteId: cuidLike(get(raw, "siteId")), + teamId: cuidLike(get(raw, "teamId")), + userId: cuidLike(get(raw, "userId")), + statuses: statuses?.length ? statuses : undefined, + group: group && (STATUS_GROUPS as readonly string[]).includes(group) ? (group as StatusGroup) : undefined, + orderTypeId: cuidLike(get(raw, "orderTypeId")), + priority: priority && (WORK_ORDER_PRIORITIES as readonly string[]).includes(priority) ? (priority as WorkOrderPriority) : undefined, + preset: preset && (PRESETS as readonly string[]).includes(preset) ? (preset as Preset) : undefined, + q: q ? q.slice(0, 100) : undefined, + sort: sort && (SORT_FIELDS as readonly string[]).includes(sort) ? (sort as SortField) : "plannedStart", + dir: get(raw, "dir") === "desc" ? "desc" : "asc", + page: Math.max(1, Math.min(10_000, Number.parseInt(get(raw, "page") ?? "1", 10) || 1)), + pageSize: Math.max(1, Math.min(100, Number.parseInt(get(raw, "pageSize") ?? "25", 10) || 25)), + }; +} + +/** Serialise filter params back into a query string (drops empty values). */ +export function toQuery(params: Partial>): string { + const sp = new URLSearchParams(); + for (const [k, v] of Object.entries(params)) { + if (v === undefined || v === null || v === "") continue; + if (v instanceof Date) sp.set(k, isoDay(v)); + else if (Array.isArray(v)) { + if (v.length) sp.set(k, v.join(",")); + } else sp.set(k, String(v)); + } + const s = sp.toString(); + return s ? `?${s}` : ""; +} + +export function isoDay(d: Date): string { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, "0"); + const day = String(d.getDate()).padStart(2, "0"); + return `${y}-${m}-${day}`; +} diff --git a/src/lib/work-orders/schemas.ts b/src/lib/work-orders/schemas.ts new file mode 100644 index 0000000..c311e86 --- /dev/null +++ b/src/lib/work-orders/schemas.ts @@ -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; + +export const checklistItemInputSchema = z.object({ + key: z + .string() + .trim() + .max(60) + .regex(/^[a-z0-9_]+$/) + .optional(), + label: text(200).min(1), + required: z.boolean().default(false), + requiresPhoto: z.boolean().default(false), + sortOrder: z.coerce.number().int().min(0).max(10_000).optional(), +}); +export type ChecklistItemInput = z.input; + +export const photoRequirementInputSchema = z.object({ + key: z + .string() + .trim() + .max(60) + .regex(/^[a-z0-9_]+$/) + .optional(), + label: text(200).min(1), + sortOrder: z.coerce.number().int().min(0).max(10_000).optional(), +}); +export type PhotoRequirementInput = z.input; + +/** Statuses a work order may be created in (import → review_required, emergency → in_progress via L8). */ +export const INITIAL_STATUSES = ["draft", "review_required", "planned", "in_progress"] as const; + +export const createWorkOrderSchema = z + .object({ + title: text(200).min(1), + customerId: id, + siteId: optId, + contactId: optId, + orderTypeId: optId, + priority: z.enum(WORK_ORDER_PRIORITIES).default("normal"), + status: z.enum(INITIAL_STATUSES).default("draft"), + description: optText(10_000), + scope: optText(10_000), + plannedStart: optDate, + plannedEnd: optDate, + /** undefined → taken from the order type (default true) */ + signatureRequired: z.boolean().optional(), + billingType: z.enum(BILLING_TYPES).nullish(), + internalNotes: optText(5000), + technicianNotes: optText(5000), + externalOrderNumber: optText(80), + offerNumber: optText(80), + isEmergency: z.boolean().default(false), + emergencyReason: optText(2000), + sourceImportId: optId, + /** "emergency" allocates from the N- sequence (lane emergency). */ + numberKey: z.enum(["work_order", "emergency"]).default("work_order"), + /** Copy checklist / photo requirements from the order type's active template (default true). */ + applyTemplate: z.boolean().default(true), + materials: z.array(materialPlanInputSchema).max(200).optional(), + checklistItems: z.array(checklistItemInputSchema).max(200).optional(), + photoRequirements: z.array(photoRequirementInputSchema).max(50).optional(), + }) + .refine((v) => !v.plannedStart || !v.plannedEnd || v.plannedEnd >= v.plannedStart, { + path: ["plannedEnd"], + message: "plannedEnd_before_start", + }); + +/** Input type of createWorkOrder — used by lanes imports (L3) and emergency (L8). */ +export type CreateWorkOrderInput = z.input; + +export const updateWorkOrderSchema = z + .object({ + title: text(200).min(1).optional(), + customerId: id.optional(), + siteId: optId, + contactId: optId, + orderTypeId: optId, + priority: z.enum(WORK_ORDER_PRIORITIES).optional(), + description: optText(10_000).optional(), + scope: optText(10_000).optional(), + plannedStart: optDate, + plannedEnd: optDate, + signatureRequired: z.boolean().optional(), + billingType: z.enum(BILLING_TYPES).nullish(), + internalNotes: optText(5000).optional(), + technicianNotes: optText(5000).optional(), + externalOrderNumber: optText(80).optional(), + offerNumber: optText(80).optional(), + emergencyReason: optText(2000).optional(), + }) + .strict(); +export type UpdateWorkOrderInput = z.input; + +export const transitionSchema = z.object({ + workOrderId: id, + to: z.enum(WORK_ORDER_STATUSES), + reason: optText(2000), + baseVersion: z.coerce.number().int().positive().optional(), +}); +export type TransitionInput = z.input & { + /** Extra facts merged into the emitted event's `data` (e.g. reportId from lane reports). Never overrides number/from/to. */ + eventData?: Record; +}; + +export const assignSchema = z.object({ + workOrderId: id, + teamId: id, + userIds: z.array(id).max(50).default([]), + teamLeadUserId: optId, + baseVersion: z.coerce.number().int().positive().optional(), +}); +export type AssignInput = z.input; + +// ---------- Settings ---------- + +export const orderTypeSchema = z.object({ + key: z + .string() + .trim() + .min(2) + .max(40) + .regex(/^[a-z0-9_]+$/), + name: text(80).min(1), + signatureRequired: z.boolean().default(true), + active: z.boolean().default(true), + sortOrder: z.coerce.number().int().min(0).max(10_000).default(100), +}); +export type OrderTypeInput = z.input; + +export const templateItemSchema = z.object({ + key: z + .string() + .trim() + .min(1) + .max(60) + .regex(/^[a-z0-9_]+$/), + label: text(200).min(1), + required: z.boolean().default(false), + requiresPhoto: z.boolean().default(false), +}); +export const templatePhotoSchema = z.object({ + key: z + .string() + .trim() + .min(1) + .max(60) + .regex(/^[a-z0-9_]+$/), + label: text(200).min(1), +}); +export type TemplateItem = z.output; +export type TemplatePhoto = z.output; + +export const checklistTemplateSchema = z.object({ + name: text(120).min(1), + orderTypeId: optId, + active: z.boolean().default(true), + items: z.array(templateItemSchema).max(100), + requiredPhotos: z.array(templatePhotoSchema).max(30), +}); +export type ChecklistTemplateInput = z.input; + +export const NUMBER_KEYS = ["work_order", "emergency", "customer", "report"] as const; +export const numberingSchema = z.object({ + key: z.enum(NUMBER_KEYS), + prefix: z + .string() + .trim() + .max(12) + .regex(/^[A-Za-z0-9\-_/]*$/), + padding: z.coerce.number().int().min(1).max(10), +}); + +/** Stable key from a free label (umlauts transliterated). */ +export function slugKey(label: string): string { + const s = label + .toLowerCase() + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/ß/g, "ss") + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") + .slice(0, 60); + return s || "punkt"; +} diff --git a/src/lib/work-orders/time.ts b/src/lib/work-orders/time.ts new file mode 100644 index 0000000..c1b80bd --- /dev/null +++ b/src/lib/work-orders/time.ts @@ -0,0 +1,86 @@ +/** + * Timezone helpers (client-safe, Intl only). Planned dates are entered as wall-clock time in the + * tenant timezone (TenantSettings.timezone) and stored as UTC. + */ + +export const DEFAULT_TIMEZONE = "Europe/Berlin"; + +export function safeTimeZone(tz: string | null | undefined): string { + if (!tz) return DEFAULT_TIMEZONE; + try { + new Intl.DateTimeFormat("en-US", { timeZone: tz }); + return tz; + } catch { + return DEFAULT_TIMEZONE; + } +} + +/** Offset (ms) of `timeZone` relative to UTC at instant `at`. */ +export function tzOffsetMs(at: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).formatToParts(at); + const n = (t: string) => Number(parts.find((p) => p.type === t)?.value); + const asUtc = Date.UTC(n("year"), n("month") - 1, n("day"), n("hour"), n("minute"), n("second")); + return asUtc - Math.floor(at.getTime() / 1000) * 1000; +} + +/** "2026-09-14T08:30" (wall time in tz) → UTC Date. Returns undefined for invalid input. */ +export function wallTimeToUtc(value: string, timeZone: string): Date | undefined { + const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/.exec(value.trim()); + if (!m) return undefined; + const tz = safeTimeZone(timeZone); + const guess = Date.UTC(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0); + const first = guess - tzOffsetMs(new Date(guess), tz); + // second pass corrects DST boundaries + const result = guess - tzOffsetMs(new Date(first), tz); + const d = new Date(result); + return Number.isNaN(d.getTime()) ? undefined : d; +} + +/** UTC Date → "YYYY-MM-DDTHH:mm" wall time in tz (for ). */ +export function toWallTimeInput(date: Date | null | undefined, timeZone: string): string { + if (!date) return ""; + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: safeTimeZone(timeZone), + hourCycle: "h23", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }).formatToParts(date); + const p = (t: string) => parts.find((x) => x.type === t)?.value ?? "00"; + return `${p("year")}-${p("month")}-${p("day")}T${p("hour")}:${p("minute")}`; +} + +/** Start/end of the calendar day containing `now` in the given timezone. */ +export function zonedDayBounds(now: Date, timeZone: string): { start: Date; end: Date } { + const tz = safeTimeZone(timeZone); + const [y, m, d] = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" }) + .format(now) + .split("-") + .map(Number); + const guessStart = Date.UTC(y, m - 1, d, 0, 0, 0); + const start = new Date(guessStart - tzOffsetMs(new Date(guessStart), tz)); + const guessEnd = Date.UTC(y, m - 1, d, 23, 59, 59, 999); + const end = new Date(guessEnd - tzOffsetMs(new Date(guessEnd), tz)); + return { start, end }; +} + +export function formatDateTime(date: Date | null | undefined, locale: string, timeZone: string): string { + if (!date) return ""; + return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium", timeStyle: "short" }).format(date); +} + +export function formatDate(date: Date | null | undefined, locale: string, timeZone: string): string { + if (!date) return ""; + return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium" }).format(date); +} diff --git a/src/server/services/work-orders/_shared.ts b/src/server/services/work-orders/_shared.ts new file mode 100644 index 0000000..b7a21f0 --- /dev/null +++ b/src/server/services/work-orders/_shared.ts @@ -0,0 +1,127 @@ +import { z } from "zod"; +import type { WorkOrderStatus } from "@/lib/work-orders/status"; +import { safeTimeZone } from "@/lib/work-orders/time"; +import { writeAuditLog } from "@/server/audit"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** Parse service input; validation problems become ServiceError("invalid") with flattened issues. */ +export function parseInput(schema: S, input: unknown): z.output { + const res = schema.safeParse(input); + if (!res.success) { + throw new ServiceError( + "invalid", + "validation_failed", + res.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })), + ); + } + return res.data; +} + +/** No business data changes at all in these statuses. */ +export const FINAL_STATUSES: readonly WorkOrderStatus[] = ["billed", "cancelled"]; +/** Planning data (checklist, photos, material plan, assignment) is frozen once released for billing. */ +export const PLANNING_LOCKED: readonly WorkOrderStatus[] = ["released_for_billing", "billed", "cancelled"]; + +export type WorkOrderBase = { + id: string; + number: string; + status: WorkOrderStatus; + version: number; + customerId: string; + siteId: string | null; + signatureRequired: boolean; + isEmergency: boolean; + assignedTeamId: string | null; + teamLeadUserId: string | null; +}; + +const BASE_SELECT = { + id: true, + number: true, + status: true, + version: true, + customerId: true, + siteId: true, + signatureRequired: true, + isEmergency: true, + assignedTeamId: true, + teamLeadUserId: true, +} as const; + +/** Load a work order within the caller's visibility scope or throw not_found (existence is never revealed). */ +export async function loadVisibleWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise { + const scope = await workOrderScope(ctx); + const wo = await ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, scope] }, select: BASE_SELECT }); + if (!wo) throw new ServiceError("not_found", "work_order_not_found"); + return wo as WorkOrderBase; +} + +export function assertBaseVersion(wo: { version: number }, baseVersion?: number) { + if (baseVersion !== undefined && baseVersion !== wo.version) { + throw new ServiceError("conflict", "version_conflict", { currentVersion: wo.version, baseVersion }); + } +} + +/** + * Optimistic write on the work order row: only succeeds while the version is unchanged and + * always increments `version` (offline sync conflict detection relies on it). + */ +export async function writeWithVersion( + ctx: ServiceCtx, + wo: { id: string; version: number }, + data: Record, +): Promise { + const res = await ctx.db.workOrder.updateMany({ + where: { id: wo.id, version: wo.version, deletedAt: null }, + data: { ...data, version: { increment: 1 } }, + }); + if (res.count !== 1) throw new ServiceError("conflict", "version_conflict", { baseVersion: wo.version }); + return wo.version + 1; +} + +/** Version bump after a mutation of a dependent entity (checklist, material plan, …). */ +export async function touchWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise { + await ctx.db.workOrder.updateMany({ where: { id: workOrderId }, data: { version: { increment: 1 } } }); +} + +export async function auditWorkOrder( + ctx: ServiceCtx, + entry: { action: "create" | "update" | "delete"; workOrderId: string; before?: unknown; after?: unknown }, +) { + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: entry.action, + entity: "work_order", + entityId: entry.workOrderId, + before: entry.before, + after: entry.after, + }); +} + +/** Plain JSON copy for audit before/after (Decimals/Dates → strings). */ +export function snapshot(value: T): unknown { + return JSON.parse(JSON.stringify(value)); +} + +export function assertNotLocked(wo: { status: WorkOrderStatus }, locked: readonly WorkOrderStatus[]) { + if (locked.includes(wo.status)) throw new ServiceError("invalid", "not_editable", { status: wo.status }); +} + +/** Unique stable keys for checklist items / photo requirements. */ +export function uniqueKey(base: string, taken: Set): string { + let key = base; + let i = 2; + while (taken.has(key)) key = `${base}_${i++}`.slice(0, 60); + taken.add(key); + return key; +} + +/** Tenant timezone (TenantSettings.timezone, default Europe/Berlin). */ +export async function tenantTimezone(ctx: ServiceCtx): Promise { + const s = await ctx.db.tenantSettings.findFirst({ select: { timezone: true } }); + return safeTimeZone(s?.timezone); +} + +export { zonedDayBounds } from "@/lib/work-orders/time"; diff --git a/src/server/services/work-orders/assign.ts b/src/server/services/work-orders/assign.ts new file mode 100644 index 0000000..4578dfb --- /dev/null +++ b/src/server/services/work-orders/assign.ts @@ -0,0 +1,81 @@ +import { canTransition, type WorkOrderStatus } from "@/lib/work-orders/status"; +import { assignSchema, type AssignInput } from "@/lib/work-orders/schemas"; +import { emitEvent } from "@/server/events"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { + assertBaseVersion, + assertNotLocked, + auditWorkOrder, + loadVisibleWorkOrder, + parseInput, + PLANNING_LOCKED, + writeWithVersion, +} from "@/server/services/work-orders/_shared"; + +/** + * Assign a team (+ optional individual technicians and a responsible team lead) — US-004. + * draft/review_required/planned → assigned; accepted with a changed team → back to assigned. + */ +export async function assignWorkOrder( + ctx: ServiceCtx, + raw: AssignInput, +): Promise<{ id: string; version: number; status: WorkOrderStatus }> { + assertCan(ctx, "work_order:assign"); + const input = parseInput(assignSchema, raw); + const wo = await loadVisibleWorkOrder(ctx, input.workOrderId); + assertNotLocked(wo, PLANNING_LOCKED); + assertBaseVersion(wo, input.baseVersion); + + const team = await ctx.db.team.findFirst({ + where: { id: input.teamId, deletedAt: null, status: "active" }, + select: { id: true, name: true, leaderUserId: true }, + }); + if (!team) throw new ServiceError("invalid", "team_not_found"); + + const userIds = [...new Set(input.userIds)]; + const leadId = input.teamLeadUserId ?? team.leaderUserId ?? null; + const toCheck = [...new Set([...userIds, ...(leadId ? [leadId] : [])])]; + if (toCheck.length) { + const found = await ctx.db.user.count({ where: { id: { in: toCheck }, status: "ACTIVE" } }); + if (found !== toCheck.length) throw new ServiceError("invalid", "user_not_found"); + } + + const previous = await ctx.db.workOrderAssignee.findMany({ where: { workOrderId: wo.id }, select: { userId: true } }); + + let to: WorkOrderStatus | null = null; + if (["draft", "review_required", "planned"].includes(wo.status)) to = "assigned"; + else if (wo.status === "accepted" && wo.assignedTeamId !== team.id) to = "assigned"; + if (to && !canTransition(wo.status, to)) to = null; + + const version = await writeWithVersion(ctx, wo, { + assignedTeamId: team.id, + teamLeadUserId: leadId, + ...(to ? { status: to } : {}), + }); + await ctx.db.workOrderAssignee.deleteMany({ where: { workOrderId: wo.id, userId: { notIn: userIds } } }); + if (userIds.length) { + await ctx.db.workOrderAssignee.createMany({ + data: userIds.map((userId) => ({ tenantId: ctx.tenantId, workOrderId: wo.id, userId })), + skipDuplicates: true, + }); + } + if (to) { + await ctx.db.workOrderStatusChange.create({ + data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: wo.status, toStatus: to, actorId: ctx.userId }, + }); + } + + await auditWorkOrder(ctx, { + action: "update", + workOrderId: wo.id, + before: { assignedTeamId: wo.assignedTeamId, teamLeadUserId: wo.teamLeadUserId, userIds: previous.map((p) => p.userId), status: wo.status }, + after: { assignedTeamId: team.id, teamLeadUserId: leadId, userIds, status: to ?? wo.status, version }, + }); + await emitEvent(ctx, { + type: "work_order.assigned", + entityType: "work_order", + entityId: wo.id, + data: { number: wo.number, teamId: team.id, teamName: team.name }, + }); + return { id: wo.id, version, status: to ?? wo.status }; +} diff --git a/src/server/services/work-orders/cancel.ts b/src/server/services/work-orders/cancel.ts new file mode 100644 index 0000000..d180939 --- /dev/null +++ b/src/server/services/work-orders/cancel.ts @@ -0,0 +1,10 @@ +import type { ServiceCtx } from "@/server/services/context"; +import { transitionWorkOrder, type TransitionResult } from "@/server/services/work-orders/transition"; + +/** Cancel an order (any status except billed) — `work_order:cancel`, reason mandatory. */ +export async function cancelWorkOrder( + ctx: ServiceCtx, + input: { workOrderId: string; reason: string; baseVersion?: number }, +): Promise { + return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "cancelled", reason: input.reason, baseVersion: input.baseVersion }); +} diff --git a/src/server/services/work-orders/checklist.ts b/src/server/services/work-orders/checklist.ts new file mode 100644 index 0000000..3fe4342 --- /dev/null +++ b/src/server/services/work-orders/checklist.ts @@ -0,0 +1,157 @@ +import { z } from "zod"; +import { + checklistItemInputSchema, + photoRequirementInputSchema, + slugKey, + templateItemSchema, + templatePhotoSchema, + type ChecklistItemInput, + type PhotoRequirementInput, +} from "@/lib/work-orders/schemas"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { + assertNotLocked, + auditWorkOrder, + loadVisibleWorkOrder, + parseInput, + PLANNING_LOCKED, + snapshot, + touchWorkOrder, + uniqueKey, +} from "@/server/services/work-orders/_shared"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** Checklist items and required photos on a single order (backoffice maintenance, spec §12.4/§14.2). */ + +async function editableOrder(ctx: ServiceCtx, workOrderId: string) { + assertCan(ctx, "work_order:write"); + const wo = await loadVisibleWorkOrder(ctx, workOrderId); + assertNotLocked(wo, PLANNING_LOCKED); + return wo; +} + +export async function addChecklistItem(ctx: ServiceCtx, workOrderId: string, raw: ChecklistItemInput) { + const input = parseInput(checklistItemInputSchema, raw); + await editableOrder(ctx, workOrderId); + const existing = await ctx.db.checklistItem.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }); + const key = uniqueKey(input.key || slugKey(input.label), new Set(existing.map((e) => e.key))); + const item = await ctx.db.checklistItem.create({ + data: { + tenantId: ctx.tenantId, + workOrderId, + key, + label: input.label, + required: input.required, + requiresPhoto: input.requiresPhoto, + sortOrder: input.sortOrder ?? Math.max(0, ...existing.map((e) => e.sortOrder)) + 10, + }, + }); + await touchWorkOrder(ctx, workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "checklist_item.add", item: snapshot(item) } }); + return item; +} + +export async function updateChecklistItem( + ctx: ServiceCtx, + itemId: string, + raw: { label?: string; required?: boolean; requiresPhoto?: boolean; sortOrder?: number }, +) { + const input = parseInput(checklistItemInputSchema.partial(), raw); + const scope = await workOrderScope(ctx); + const before = await ctx.db.checklistItem.findFirst({ where: { id: itemId, workOrder: scope } }); + if (!before) throw new ServiceError("not_found", "checklist_item_not_found"); + await editableOrder(ctx, before.workOrderId); + const item = await ctx.db.checklistItem.update({ + where: { id: itemId }, + data: { + ...(input.label !== undefined ? { label: input.label } : {}), + ...(raw.required !== undefined ? { required: !!input.required } : {}), + ...(raw.requiresPhoto !== undefined ? { requiresPhoto: !!input.requiresPhoto } : {}), + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + }, + }); + await touchWorkOrder(ctx, before.workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "checklist_item.update", item: snapshot(before) }, after: { item: snapshot(item) } }); + return item; +} + +/** Checked items are evidence and stay. */ +export async function removeChecklistItem(ctx: ServiceCtx, itemId: string): Promise { + const scope = await workOrderScope(ctx); + const before = await ctx.db.checklistItem.findFirst({ where: { id: itemId, workOrder: scope } }); + if (!before) throw new ServiceError("not_found", "checklist_item_not_found"); + await editableOrder(ctx, before.workOrderId); + if (before.checked) throw new ServiceError("invalid", "checklist_item_checked"); + await ctx.db.checklistItem.deleteMany({ where: { id: itemId } }); + await touchWorkOrder(ctx, before.workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "checklist_item.remove", item: snapshot(before) } }); +} + +export async function addPhotoRequirement(ctx: ServiceCtx, workOrderId: string, raw: PhotoRequirementInput) { + const input = parseInput(photoRequirementInputSchema, raw); + await editableOrder(ctx, workOrderId); + const existing = await ctx.db.photoRequirement.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }); + const key = uniqueKey(input.key || slugKey(input.label), new Set(existing.map((e) => e.key))); + const req = await ctx.db.photoRequirement.create({ + data: { + tenantId: ctx.tenantId, + workOrderId, + key, + label: input.label, + sortOrder: input.sortOrder ?? Math.max(0, ...existing.map((e) => e.sortOrder)) + 10, + }, + }); + await touchWorkOrder(ctx, workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "photo_requirement.add", requirement: snapshot(req) } }); + return req; +} + +/** Requirements that already have photos stay (evidence). */ +export async function removePhotoRequirement(ctx: ServiceCtx, requirementId: string): Promise { + const scope = await workOrderScope(ctx); + const before = await ctx.db.photoRequirement.findFirst({ where: { id: requirementId, workOrder: scope } }); + if (!before) throw new ServiceError("not_found", "photo_requirement_not_found"); + await editableOrder(ctx, before.workOrderId); + const photos = await ctx.db.photo.count({ where: { photoRequirementId: requirementId } }); + if (photos > 0) throw new ServiceError("invalid", "photo_requirement_has_photos"); + await ctx.db.photoRequirement.deleteMany({ where: { id: requirementId } }); + await touchWorkOrder(ctx, before.workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId: before.workOrderId, before: { op: "photo_requirement.remove", requirement: snapshot(before) } }); +} + +/** Add the items/photos of a template that the order does not have yet (by key). */ +export async function applyChecklistTemplate(ctx: ServiceCtx, workOrderId: string, templateId: string) { + await editableOrder(ctx, workOrderId); + const tpl = await ctx.db.checklistTemplate.findFirst({ where: { id: templateId, active: true } }); + if (!tpl) throw new ServiceError("invalid", "template_not_found"); + const items = z.array(templateItemSchema).safeParse(tpl.items).data ?? []; + const photos = z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? []; + const [haveItems, havePhotos] = await Promise.all([ + ctx.db.checklistItem.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }), + ctx.db.photoRequirement.findMany({ where: { workOrderId }, select: { key: true, sortOrder: true } }), + ]); + const itemKeys = new Set(haveItems.map((i) => i.key)); + const photoKeys = new Set(havePhotos.map((i) => i.key)); + let sort = Math.max(0, ...haveItems.map((i) => i.sortOrder)); + const newItems = items.filter((i) => !itemKeys.has(i.key)); + let psort = Math.max(0, ...havePhotos.map((i) => i.sortOrder)); + const newPhotos = photos.filter((p) => !photoKeys.has(p.key)); + if (newItems.length) { + await ctx.db.checklistItem.createMany({ + data: newItems.map((i) => ({ tenantId: ctx.tenantId, workOrderId, key: i.key, label: i.label, required: i.required, requiresPhoto: i.requiresPhoto, sortOrder: (sort += 10) })), + }); + } + if (newPhotos.length) { + await ctx.db.photoRequirement.createMany({ + data: newPhotos.map((p) => ({ tenantId: ctx.tenantId, workOrderId, key: p.key, label: p.label, sortOrder: (psort += 10) })), + skipDuplicates: true, + }); + } + await touchWorkOrder(ctx, workOrderId); + await auditWorkOrder(ctx, { + action: "update", + workOrderId, + after: { op: "checklist_template.apply", templateId, items: newItems.length, photos: newPhotos.length }, + }); + return { items: newItems.length, photos: newPhotos.length }; +} diff --git a/src/server/services/work-orders/completion.ts b/src/server/services/work-orders/completion.ts new file mode 100644 index 0000000..717028e --- /dev/null +++ b/src/server/services/work-orders/completion.ts @@ -0,0 +1,69 @@ +import type { CompletionBlocker, WorkOrderStatus } from "@/lib/work-orders/status"; +import type { ServiceCtx } from "@/server/services/context"; +import { loadVisibleWorkOrder } from "@/server/services/work-orders/_shared"; + +/** + * Completion guards (ARCHITEKTUR §3): before `technically_completed` every required checklist + * item is checked (with photo where requested), every PhotoRequirement has ≥ 1 photo and no + * WorkSession is still open. + */ +export async function computeCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise { + const [items, requirements, sessions] = await Promise.all([ + ctx.db.checklistItem.findMany({ + where: { workOrderId, required: true }, + select: { id: true, label: true, checked: true, requiresPhoto: true, _count: { select: { photos: true } } }, + orderBy: { sortOrder: "asc" }, + }), + ctx.db.photoRequirement.findMany({ + where: { workOrderId }, + select: { id: true, label: true, _count: { select: { photos: true } } }, + orderBy: { sortOrder: "asc" }, + }), + ctx.db.workSession.findMany({ + where: { workOrderId, status: { in: ["en_route", "running", "paused"] } }, + select: { id: true, userId: true }, + }), + ]); + + const blockers: CompletionBlocker[] = []; + for (const i of items) { + if (!i.checked || (i.requiresPhoto && i._count.photos === 0)) { + blockers.push({ kind: "checklist_item", itemId: i.id, label: i.label }); + } + } + for (const r of requirements) { + if (r._count.photos === 0) blockers.push({ kind: "photo_requirement", requirementId: r.id, label: r.label }); + } + for (const s of sessions) blockers.push({ kind: "running_session", sessionId: s.id, userId: s.userId }); + return blockers; +} + +/** Public variant with visibility check (UI, API). */ +export async function getCompletionBlockers(ctx: ServiceCtx, workOrderId: string): Promise { + await loadVisibleWorkOrder(ctx, workOrderId); + return computeCompletionBlockers(ctx, workOrderId); +} + +/** Blockers of a concrete transition (completion, signature, billing release). */ +export async function transitionBlockers( + ctx: ServiceCtx, + wo: { id: string; status: WorkOrderStatus; signatureRequired: boolean }, + to: WorkOrderStatus, +): Promise { + if (to === "technically_completed") return computeCompletionBlockers(ctx, wo.id); + + if (to === "in_review" && (wo.status === "technically_completed" || wo.status === "signature_pending") && wo.signatureRequired) { + // Any recorded outcome counts (signed, refused/absent with reason — spec §18.2). + const signatures = await ctx.db.signature.count({ + where: { report: { workOrderId: wo.id, type: "completion", status: { not: "superseded" } } }, + }); + return signatures > 0 ? [] : [{ kind: "missing_field", field: "signature" }]; + } + + if (to === "released_for_billing" && wo.status === "in_review") { + const approved = await ctx.db.report.count({ where: { workOrderId: wo.id, type: "completion", status: "approved" } }); + return approved > 0 ? [] : [{ kind: "missing_field", field: "approved_completion_report" }]; + } + + return []; +} diff --git a/src/server/services/work-orders/conflicts.ts b/src/server/services/work-orders/conflicts.ts new file mode 100644 index 0000000..883dbfa --- /dev/null +++ b/src/server/services/work-orders/conflicts.ts @@ -0,0 +1,89 @@ +import { writeAuditLog } from "@/server/audit"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { reapplySyncOperation } from "@/server/services/work-orders/sync-reapply"; + +/** Backoffice list of offline sync conflicts (ARCHITEKTUR §4.6) — `work_order:write`. */ +export async function listSyncConflicts(ctx: ServiceCtx) { + assertCan(ctx, "work_order:write"); + const ops = await ctx.db.syncOperation.findMany({ + where: { status: "conflict", resolvedAt: null }, + orderBy: { receivedAt: "desc" }, + take: 200, + }); + const userIds = [...new Set(ops.map((o) => o.userId))]; + const woIds = [...new Set(ops.filter((o) => o.entityType === "work_order" && o.entityId).map((o) => o.entityId!))]; + const [users, orders] = await Promise.all([ + userIds.length ? ctx.db.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } }) : [], + woIds.length ? ctx.db.workOrder.findMany({ where: { id: { in: woIds } }, select: { id: true, number: true, title: true, status: true, version: true } }) : [], + ]); + const u = new Map(users.map((x) => [x.id, x.name])); + const w = new Map(orders.map((x) => [x.id, x])); + return ops.map((o) => ({ + ...o, + userName: u.get(o.userId) ?? null, + workOrder: o.entityType === "work_order" && o.entityId ? w.get(o.entityId) ?? null : null, + })); +} + +async function loadOpenConflict(ctx: ServiceCtx, opId: string) { + assertCan(ctx, "work_order:write"); + const op = await ctx.db.syncOperation.findFirst({ where: { id: opId, status: "conflict", resolvedAt: null } }); + if (!op) throw new ServiceError("not_found", "sync_conflict_not_found"); + return op; +} + +/** Discard: mark resolved, nothing is applied. */ +export async function discardSyncConflict(ctx: ServiceCtx, opId: string): Promise { + const op = await loadOpenConflict(ctx, opId); + const now = new Date(); + const res = await ctx.db.syncOperation.updateMany({ + where: { id: op.id, resolvedAt: null }, + data: { status: "rejected", resolvedAt: now, resolvedById: ctx.userId, errorCode: "discarded" }, + }); + if (res.count !== 1) throw new ServiceError("conflict", "sync_conflict_already_resolved"); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "update", + entity: "sync_operation", + entityId: op.id, + before: { status: op.status, resolvedAt: null }, + after: { status: "rejected", resolution: "discarded", resolvedAt: now.toISOString() }, + }); +} + +/** Load DB-authoritative permissions of the device user (the op is re-applied as that user). */ +async function permissionsOf(ctx: ServiceCtx, userId: string): Promise> { + const user = await ctx.db.user.findFirst({ + where: { id: userId, status: "ACTIVE" }, + select: { userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } } }, + }); + if (!user) throw new ServiceError("invalid", "sync_user_inactive"); + return new Set(user.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key))); +} + +/** + * Take over: re-apply the operation against the current state, as the original device user + * (their permissions and scope), then mark it applied + resolved. Audit is written for both + * the domain change (by the service) and the resolution (resolver). + */ +export async function applySyncConflict(ctx: ServiceCtx, opId: string): Promise<{ entityVersion?: number }> { + const op = await loadOpenConflict(ctx, opId); + const opCtx: ServiceCtx = { db: ctx.db, tenantId: ctx.tenantId, userId: op.userId, permissions: await permissionsOf(ctx, op.userId) }; + const result = await reapplySyncOperation(opCtx, op); + const now = new Date(); + await ctx.db.syncOperation.updateMany({ + where: { id: op.id, resolvedAt: null }, + data: { status: "applied", resolvedAt: now, resolvedById: ctx.userId, errorCode: null, result: { reappliedBy: ctx.userId, ...result } }, + }); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "update", + entity: "sync_operation", + entityId: op.id, + before: { status: op.status, resolvedAt: null }, + after: { status: "applied", resolution: "reapplied", resolvedAt: now.toISOString(), ...result }, + }); + return result; +} diff --git a/src/server/services/work-orders/create.ts b/src/server/services/work-orders/create.ts new file mode 100644 index 0000000..316ef6e --- /dev/null +++ b/src/server/services/work-orders/create.ts @@ -0,0 +1,145 @@ +import { z } from "zod"; +import { + createWorkOrderSchema, + slugKey, + templateItemSchema, + templatePhotoSchema, + type CreateWorkOrderInput, +} from "@/lib/work-orders/schemas"; +import { nextNumber } from "@/server/services/numbering"; +import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { auditWorkOrder, parseInput, snapshot, uniqueKey } from "@/server/services/work-orders/_shared"; + +export type { CreateWorkOrderInput } from "@/lib/work-orders/schemas"; + +export type CreatedWorkOrder = { id: string; number: string; status: string; version: number }; + +/** + * Create a work order (backoffice form, PDF import L3, emergency L8). + * - Permission: `work_order:write`; emergency orders alternatively `emergency:create`. + * - Number from the tenant sequence (`work_order` → A-…, `emergency` → N-…). + * - From the order type: signatureRequired (unless given) and the active checklist template + * (checklist items + required photos) unless explicit lists are passed or applyTemplate=false. + */ +export async function createWorkOrder(ctx: ServiceCtx, raw: CreateWorkOrderInput): Promise { + const input = parseInput(createWorkOrderSchema, raw); + if (!(can(ctx, "work_order:write") || (input.isEmergency && can(ctx, "emergency:create")))) { + assertCan(ctx, "work_order:write"); + } + if (input.status === "in_progress" && !input.isEmergency) { + throw new ServiceError("invalid", "initial_status_not_allowed", { status: input.status }); + } + + const customer = await ctx.db.customer.findFirst({ + where: { id: input.customerId, deletedAt: null, status: { not: "merged" } }, + select: { id: true }, + }); + if (!customer) throw new ServiceError("invalid", "customer_not_found"); + if (input.siteId) { + const site = await ctx.db.site.findFirst({ where: { id: input.siteId, deletedAt: null }, select: { customerId: true } }); + if (!site) throw new ServiceError("invalid", "site_not_found"); + if (site.customerId !== input.customerId) throw new ServiceError("invalid", "site_customer_mismatch"); + } + if (input.contactId) { + const contact = await ctx.db.contact.findFirst({ where: { id: input.contactId, deletedAt: null }, select: { customerId: true } }); + if (!contact) throw new ServiceError("invalid", "contact_not_found"); + if (contact.customerId !== input.customerId) throw new ServiceError("invalid", "contact_customer_mismatch"); + } + + let signatureRequired = input.signatureRequired ?? true; + let templateItems: z.output[] = []; + let templatePhotos: z.output[] = []; + if (input.orderTypeId) { + const ot = await ctx.db.orderType.findFirst({ + where: { id: input.orderTypeId, active: true }, + select: { id: true, signatureRequired: true }, + }); + if (!ot) throw new ServiceError("invalid", "order_type_not_found"); + if (input.signatureRequired === undefined) signatureRequired = ot.signatureRequired; + if (input.applyTemplate) { + const tpl = await ctx.db.checklistTemplate.findFirst({ + where: { orderTypeId: ot.id, active: true }, + orderBy: { updatedAt: "desc" }, + select: { items: true, requiredPhotos: true }, + }); + if (tpl) { + templateItems = z.array(templateItemSchema).safeParse(tpl.items).data ?? []; + templatePhotos = z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? []; + } + } + } + + const itemKeys = new Set(); + const items = (input.checklistItems ?? templateItems).map((i, idx) => ({ + tenantId: ctx.tenantId, + key: uniqueKey(i.key || slugKey(i.label), itemKeys), + label: i.label, + required: i.required ?? false, + requiresPhoto: i.requiresPhoto ?? false, + sortOrder: ("sortOrder" in i && typeof i.sortOrder === "number" ? i.sortOrder : (idx + 1) * 10), + })); + const photoKeys = new Set(); + const photos = (input.photoRequirements ?? templatePhotos).map((p, idx) => ({ + tenantId: ctx.tenantId, + key: uniqueKey(p.key || slugKey(p.label), photoKeys), + label: p.label, + sortOrder: ("sortOrder" in p && typeof p.sortOrder === "number" ? p.sortOrder : (idx + 1) * 10), + })); + const materials = (input.materials ?? []).map((m, idx) => ({ + tenantId: ctx.tenantId, + name: m.name, + articleNumber: m.articleNumber ?? null, + plannedQuantity: m.plannedQuantity, + unit: m.unit, + notes: m.notes ?? null, + sortOrder: m.sortOrder ?? (idx + 1) * 10, + })); + + const number = await nextNumber(ctx.db, ctx.tenantId, input.numberKey); + const wo = await ctx.db.workOrder.create({ + data: { + tenantId: ctx.tenantId, + number, + title: input.title, + customerId: input.customerId, + siteId: input.siteId ?? null, + contactId: input.contactId ?? null, + orderTypeId: input.orderTypeId ?? null, + priority: input.priority, + status: input.status, + description: input.description, + scope: input.scope, + plannedStart: input.plannedStart ?? null, + plannedEnd: input.plannedEnd ?? null, + signatureRequired, + billingType: input.billingType ?? null, + internalNotes: input.internalNotes, + technicianNotes: input.technicianNotes, + externalOrderNumber: input.externalOrderNumber, + offerNumber: input.offerNumber, + isEmergency: input.isEmergency, + emergencyReason: input.emergencyReason, + sourceImportId: input.sourceImportId ?? null, + createdById: ctx.userId, + checklistItems: { create: items }, + photoRequirements: { create: photos }, + materialPlans: { create: materials }, + statusHistory: { create: { tenantId: ctx.tenantId, fromStatus: null, toStatus: input.status, actorId: ctx.userId } }, + }, + select: { id: true, number: true, status: true, version: true }, + }); + + await auditWorkOrder(ctx, { + action: "create", + workOrderId: wo.id, + after: snapshot({ + ...input, + number, + signatureRequired, + checklistItems: items.length, + photoRequirements: photos.length, + materials: materials.length, + }), + }); + return wo; +} diff --git a/src/server/services/work-orders/dashboard.ts b/src/server/services/work-orders/dashboard.ts new file mode 100644 index 0000000..c5f3f7f --- /dev/null +++ b/src/server/services/work-orders/dashboard.ts @@ -0,0 +1,29 @@ +import { PRESETS, type Preset, type WorkOrderFilter } from "@/lib/work-orders/filters"; +import { can, type ServiceCtx } from "@/server/services/context"; +import { tenantTimezone } from "@/server/services/work-orders/_shared"; +import { buildWorkOrderWhere, presetWhere } from "@/server/services/work-orders/list"; + +export type DashboardTiles = Record & { reportsToReview: number; syncConflicts: number }; + +/** + * Backoffice dashboard counts (spec §21). Every tile = base filter ∧ preset, within workOrderScope. + * "Berichte zur Prüfung" counts reports (submitted / team_approved); "Sync-Konflikte" counts + * unresolved SyncOperations with status conflict. + */ +export async function getDashboardTiles(ctx: ServiceCtx, filter: WorkOrderFilter): Promise { + const pc = { now: new Date(), timeZone: await tenantTimezone(ctx) }; + const base = await buildWorkOrderWhere(ctx, { ...filter, preset: undefined }, { pc }); + + const counts = await Promise.all( + PRESETS.map((preset) => ctx.db.workOrder.count({ where: { AND: [base, presetWhere(preset, pc)] } })), + ); + const [reportsToReview, syncConflicts] = await Promise.all([ + ctx.db.report.count({ where: { status: { in: ["submitted", "team_approved"] }, workOrder: base } }), + can(ctx, "work_order:write") + ? ctx.db.syncOperation.count({ where: { status: "conflict", resolvedAt: null } }) + : Promise.resolve(0), + ]); + + const tiles = Object.fromEntries(PRESETS.map((p, i) => [p, counts[i]])) as Record; + return { ...tiles, reportsToReview, syncConflicts }; +} diff --git a/src/server/services/work-orders/detail.ts b/src/server/services/work-orders/detail.ts new file mode 100644 index 0000000..60c026f --- /dev/null +++ b/src/server/services/work-orders/detail.ts @@ -0,0 +1,121 @@ +import { allowedTransitions, type CompletionBlocker, type WorkOrderStatus } from "@/lib/work-orders/status"; +import { can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { computeCompletionBlockers } from "@/server/services/work-orders/completion"; +import { getMaterialOverview } from "@/server/services/work-orders/materials"; +import { mayTransition } from "@/server/services/work-orders/transition"; +import { allowedDocumentVisibility, workOrderScope } from "@/server/services/work-orders/visibility"; + +/** Full read model of /work-orders/[id] and GET /api/v1/work-orders/[id]. */ +export async function getWorkOrderDetail(ctx: ServiceCtx, workOrderId: string) { + const scope = await workOrderScope(ctx); + const wo = await ctx.db.workOrder.findFirst({ + where: { AND: [{ id: workOrderId }, scope] }, + include: { + customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, phone: true, email: true, street: true, houseNumber: true, postalCode: true, city: true } }, + site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, accessNotes: true, safetyNotes: true } }, + contact: { select: { id: true, name: true, phone: true, mobile: true, email: true } }, + orderType: { select: { id: true, name: true, key: true } }, + team: { select: { id: true, name: true } }, + teamLead: { select: { id: true, name: true } }, + assignees: { select: { user: { select: { id: true, name: true } } } }, + }, + }); + if (!wo) throw new ServiceError("not_found", "work_order_not_found"); + return wo; +} + +export type WorkOrderDetail = Awaited>; + +/** Transitions the current user may trigger now (UI buttons). */ +export function availableTransitions(ctx: ServiceCtx, status: WorkOrderStatus): WorkOrderStatus[] { + return allowedTransitions(status).filter((to) => mayTransition(ctx, status, to)); +} + +export async function getChecklistTab(ctx: ServiceCtx, workOrderId: string) { + const [items, requirements, blockers] = await Promise.all([ + ctx.db.checklistItem.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" }, include: { _count: { select: { photos: true } } } }), + ctx.db.photoRequirement.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" }, include: { _count: { select: { photos: true } } } }), + computeCompletionBlockers(ctx, workOrderId), + ]); + return { items, requirements, blockers: blockers as CompletionBlocker[] }; +} + +export async function getTimesTab(ctx: ServiceCtx, workOrderId: string) { + const sessions = await ctx.db.workSession.findMany({ + where: { workOrderId }, + orderBy: { startedAt: "desc" }, + include: { user: { select: { id: true, name: true } }, entries: { orderBy: { startedAt: "asc" } } }, + }); + return sessions.map((s) => { + const minutes = s.entries + .filter((e) => e.type !== "break") + .reduce((sum, e) => sum + ((e.endedAt ?? new Date()).getTime() - e.startedAt.getTime()) / 60000, 0); + return { ...s, workMinutes: Math.round(minutes) }; + }); +} + +export async function getPhotosTab(ctx: ServiceCtx, workOrderId: string) { + const photos = await ctx.db.photo.findMany({ + where: { workOrderId }, + orderBy: { takenAt: "asc" }, + include: { photoRequirement: { select: { label: true } }, checklistItem: { select: { label: true } } }, + }); + const docs = photos.length + ? await ctx.db.document.findMany({ + where: { id: { in: photos.map((p) => p.documentId) }, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } }, + select: { id: true, fileName: true, storageKey: true, previewKey: true }, + }) + : []; + const byId = new Map(docs.map((d) => [d.id, d])); + return photos.filter((p) => byId.has(p.documentId)).map((p) => ({ ...p, document: byId.get(p.documentId)! })); +} + +export async function getNotesTab(ctx: ServiceCtx, workOrderId: string) { + const notes = await ctx.db.activityNote.findMany({ where: { workOrderId, deletedAt: null }, orderBy: { createdAt: "desc" } }); + const names = await userNames(ctx, notes.map((n) => n.authorId)); + return notes.map((n) => ({ ...n, authorName: n.authorId ? names.get(n.authorId) ?? null : null })); +} + +export async function getReportsTab(ctx: ServiceCtx, workOrderId: string) { + return ctx.db.report.findMany({ + where: { workOrderId, status: { not: "superseded" } }, + orderBy: [{ reportDate: "desc" }, { version: "desc" }], + select: { id: true, type: true, reportDate: true, version: true, status: true, approvedAt: true, rejectionReason: true, signature: { select: { outcome: true, signerName: true } } }, + }); +} + +export async function getDocumentsTab(ctx: ServiceCtx, workOrderId: string) { + return ctx.db.document.findMany({ + where: { workOrderId, deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) }, category: { notIn: ["photo", "voice_note", "signature"] } }, + orderBy: { createdAt: "desc" }, + select: { id: true, title: true, fileName: true, category: true, visibility: true, version: true, mimeType: true, fileSize: true, storageKey: true, createdAt: true }, + }); +} + +export async function getHistoryTab(ctx: ServiceCtx, workOrderId: string) { + const [changes, audit] = await Promise.all([ + ctx.db.workOrderStatusChange.findMany({ where: { workOrderId }, orderBy: { createdAt: "desc" } }), + can(ctx, "audit:read") || can(ctx, "work_order:read_all") + ? ctx.db.auditLog.findMany({ + where: { entity: "work_order", entityId: workOrderId }, + orderBy: { createdAt: "desc" }, + take: 200, + select: { id: true, action: true, actorId: true, createdAt: true, before: true, after: true }, + }) + : Promise.resolve([]), + ]); + const names = await userNames(ctx, [...changes.map((c) => c.actorId), ...audit.map((a) => a.actorId)]); + return { + changes: changes.map((c) => ({ ...c, actorName: c.actorId ? names.get(c.actorId) ?? null : null })), + audit: audit.map((a) => ({ ...a, actorName: a.actorId ? names.get(a.actorId) ?? null : null })), + }; +} + +export { getMaterialOverview }; + +async function userNames(ctx: ServiceCtx, ids: (string | null)[]): Promise> { + const unique = [...new Set(ids.filter((x): x is string => !!x))]; + 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])); +} diff --git a/src/server/services/work-orders/documents.ts b/src/server/services/work-orders/documents.ts new file mode 100644 index 0000000000000000000000000000000000000000..f4fd854c892974f49d02e23bda51868d5c3b6e5e GIT binary patch literal 4781 zcmbVQ?Q+|=742`mL}7Mjhe{R7cH~{J96R;eN#ogMvlH9x53fgtNKi&Y5gY)rtU8|Q z)3oo=N9mLFT!0`YS>ByCKOFM{xL-K;+zZQTrL<)aEY*Tr@t&L89@D(c)b#T5?36vS zT&pRIN|lLWsuz`2(LqQ0Q+81a96eR(Y${570tFML7rjRE7ir{J7Sb*nMSrTaG*jMB z3yCAGt_NnU(tIM?oqZ$pozVSyq0cj3S)rHDaDzQCT=rN>KhAMNrsA#EO81!auNIxy z`wg1?cGFK)X~q4DJA9T&`&XsEeA1bKvO@99#Z!nz`YRgeIQdf1QPUrpTdqY0$2 z$~^xlry_P)7_v)Q+Wlj#`Jy{S^b`vFidc%mh2bL}BPh5304~d%#p9$9<;32yqeHgG zzWIiYuePt*;o%|MzDJl&uoDD(+g`961cQMWylMpBdchzFcC#+i!p?O0G?ANpc;(MG zKIFL9!0#}V>|l@?d#W8 zFG3Lau5)>(E`R>;>G*VXa(waj&!0Y@y*-DAS5&Qzfk)VsO9V<46}c+5BE2n9?Zqh5 ze2yCO;=E8wkKk0jZ7!+4Mw#Rj&8L13^_=OrSd1jNMbBKB8oy+CF6?3ySlbILtupLd zlLmeqmC6dgQGt{-e!XVg)X6q*+2m3f+#t_XSuC2~8oKS(HRz&F1~-c`Wes;cL+df! zrM(}9Zi&`NIdNTLjmHBJ4m_JIv(Ev`Ysj>lGLaV4>4>jdDLB>51;Z!4AF#V-LTFdp zso{WGX$$ct=n6#l*z0s1+co{)ccds}emK|YIZ>Ce?-4RrfCQg&X*YIqf8M1aS?Xg> z0r96_X}xvS8I5`RMddkAI-&y0^Kb`lVu>RR z0%MtF0`Ox|Yi-oZ8;`c)r=SOJ8l3pnaCO~HWSJJTOqe=|a2>zmhAi(u3K=;OcEf!y zg9A-cU2_r{GTXK(pDEXTT3^FP)Nt*^GE^vS{@99i3*9Lkq{MDINgb?d$rE=0;mFDo zx^));6OFNFvmZi4*o5s7&5u-{BR7#?fO7#oQmr(QaDgWD^Mh}&uBQ@NEhS=k{iq&C zu4+H8pGU(Jf0P=bz9tUX%_W2~BvO9D=H7|0#rf~+A-xsp7c-mUR6F{|5r#yJRp8!P zMI2tI+}~!B$%!yFj&8;Ma%s7-kU-$eYiS~jAD9&-FYQ@2B+5y`SI?1$Z#BU=%#1XJ zm-Yf_N5#|NBQQNWHUq(RBR&OrL?48u096T;D8{yVH7I4V@#ep7| z^@HyiKyjtAdQA`D#CnC^pLj2B0M*LHZo-*+y3#EW?{C5S^PElXB3HB0HP7IuYa(8{ zgu6|DQSj))x#cNjYw6HB!+CUmU={YGW@S_y1dd-&M-FWzYz^8f$< literal 0 HcmV?d00001 diff --git a/src/server/services/work-orders/list.ts b/src/server/services/work-orders/list.ts new file mode 100644 index 0000000..63144de --- /dev/null +++ b/src/server/services/work-orders/list.ts @@ -0,0 +1,161 @@ +import type { Prisma } from "@prisma/client"; +import { OPEN_STATUSES, STATUS_GROUP, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status"; +import type { ListParams, Preset, WorkOrderFilter } from "@/lib/work-orders/filters"; +import type { ServiceCtx } from "@/server/services/context"; +import { tenantTimezone, zonedDayBounds } from "@/server/services/work-orders/_shared"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** Work still to be done (no completion yet) — basis of "overdue". */ +const PENDING_WORK: WorkOrderStatus[] = [ + "draft", + "review_required", + "planned", + "assigned", + "accepted", + "en_route", + "in_progress", + "paused", + "waiting_material", + "daily_report_created", +]; + +export type PresetContext = { now: Date; timeZone: string }; + +/** Dashboard tile definitions (§21) — shared by dashboard counts and the list `preset` filter. */ +export function presetWhere(preset: Preset, pc: PresetContext): Prisma.WorkOrderWhereInput { + const open = { status: { in: [...OPEN_STATUSES] } }; + switch (preset) { + case "open": + return open; + case "today": { + const { start, end } = zonedDayBounds(pc.now, pc.timeZone); + return { + ...open, + plannedStart: { lte: end }, + OR: [{ plannedEnd: { gte: start } }, { plannedEnd: null, plannedStart: { gte: start } }], + }; + } + case "running": + return { status: { in: ["en_route", "in_progress", "paused", "waiting_material"] } }; + case "not_accepted": + return { status: "assigned" }; + case "overdue": + return { status: { in: PENDING_WORK }, plannedEnd: { lt: pc.now } }; + case "reports_in_review": + return { reports: { some: { status: { in: ["submitted", "team_approved"] } } } }; + case "completed": + return { status: { in: ["technically_completed", "signature_pending", "in_review", "released_for_billing", "billed"] } }; + case "billing": + return { status: "released_for_billing" }; + case "emergency_new": + return { + isEmergency: true, + OR: [ + { status: { in: ["draft", "review_required", "in_review"] } }, + { status: { in: [...OPEN_STATUSES] }, createdAt: { gte: new Date(pc.now.getTime() - 24 * 3600 * 1000) } }, + ], + }; + case "missing_signatures": + return { status: "signature_pending" }; + } +} + +function statusesOfGroup(group: StatusGroup): WorkOrderStatus[] { + return (Object.keys(STATUS_GROUP) as WorkOrderStatus[]).filter((s) => STATUS_GROUP[s] === group); +} + +/** + * Filter → where (always AND-ed with workOrderScope). `omitStatus` drops status/group so the + * status-group tabs can show counts for the remaining filter. + */ +export async function buildWorkOrderWhere( + ctx: ServiceCtx, + f: WorkOrderFilter, + opts: { omitStatus?: boolean; pc?: PresetContext } = {}, +): Promise { + const and: Prisma.WorkOrderWhereInput[] = [await workOrderScope(ctx)]; + if (f.from) and.push({ OR: [{ plannedEnd: { gte: f.from } }, { plannedEnd: null, plannedStart: { gte: f.from } }] }); + if (f.to) and.push({ plannedStart: { lte: f.to } }); + if (f.customerId) and.push({ customerId: f.customerId }); + if (f.siteId) and.push({ siteId: f.siteId }); + if (f.teamId) and.push({ assignedTeamId: f.teamId }); + if (f.userId) and.push({ OR: [{ assignees: { some: { userId: f.userId } } }, { teamLeadUserId: f.userId }] }); + if (f.orderTypeId) and.push({ orderTypeId: f.orderTypeId }); + if (f.priority) and.push({ priority: f.priority }); + if (!opts.omitStatus) { + if (f.statuses?.length) and.push({ status: { in: f.statuses } }); + if (f.group) and.push({ status: { in: statusesOfGroup(f.group) } }); + } + if (f.preset) { + const pc = opts.pc ?? { now: new Date(), timeZone: await tenantTimezone(ctx) }; + and.push(presetWhere(f.preset, pc)); + } + if (f.q) { + const q = { contains: f.q, mode: "insensitive" as const }; + and.push({ + OR: [ + { number: q }, + { title: q }, + { externalOrderNumber: q }, + { customer: { companyName: q } }, + { customer: { lastName: q } }, + { site: { name: q } }, + { site: { city: q } }, + ], + }); + } + return { AND: and }; +} + +export const LIST_SELECT = { + id: true, + number: true, + title: true, + status: true, + priority: true, + plannedStart: true, + plannedEnd: true, + isEmergency: true, + version: true, + updatedAt: true, + customer: { select: { id: true, companyName: true, firstName: true, lastName: true } }, + site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true } }, + team: { select: { id: true, name: true } }, + orderType: { select: { id: true, name: true } }, + assignees: { select: { user: { select: { id: true, name: true } } } }, +} satisfies Prisma.WorkOrderSelect; + +export type WorkOrderListItem = Prisma.WorkOrderGetPayload<{ select: typeof LIST_SELECT }>; + +export type WorkOrderListResult = { + items: WorkOrderListItem[]; + total: number; + page: number; + pageSize: number; + groupCounts: Record; +}; + +export async function listWorkOrders(ctx: ServiceCtx, p: ListParams): Promise { + const pc = p.preset ? { now: new Date(), timeZone: await tenantTimezone(ctx) } : undefined; + const [where, whereNoStatus] = await Promise.all([ + buildWorkOrderWhere(ctx, p, { pc }), + buildWorkOrderWhere(ctx, p, { omitStatus: true, pc }), + ]); + const orderBy: Prisma.WorkOrderOrderByWithRelationInput[] = + p.sort === "plannedStart" + ? [{ plannedStart: { sort: p.dir, nulls: "last" } }, { number: "asc" }] + : [{ [p.sort]: p.dir }, { number: "asc" }]; + + const [items, total, grouped] = await Promise.all([ + ctx.db.workOrder.findMany({ where, select: LIST_SELECT, orderBy, skip: (p.page - 1) * p.pageSize, take: p.pageSize }), + ctx.db.workOrder.count({ where }), + ctx.db.workOrder.groupBy({ by: ["status"], where: whereNoStatus, _count: { _all: true } }), + ]); + + const groupCounts = Object.fromEntries( + ["new", "planned", "en_route", "in_progress", "documentation_incomplete", "in_review", "ready_for_billing", "billed", "cancelled"].map((g) => [g, 0]), + ) as Record; + for (const row of grouped) groupCounts[STATUS_GROUP[row.status as WorkOrderStatus]] += row._count._all; + + return { items, total, page: p.page, pageSize: p.pageSize, groupCounts }; +} diff --git a/src/server/services/work-orders/materials.ts b/src/server/services/work-orders/materials.ts new file mode 100644 index 0000000..e1661a5 --- /dev/null +++ b/src/server/services/work-orders/materials.ts @@ -0,0 +1,133 @@ +import { materialPlanInputSchema, type MaterialPlanInput } from "@/lib/work-orders/schemas"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { + assertNotLocked, + auditWorkOrder, + loadVisibleWorkOrder, + parseInput, + PLANNING_LOCKED, + snapshot, + touchWorkOrder, +} from "@/server/services/work-orders/_shared"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; + +/** Material plan (Materialvorgabe, spec §13.1) — maintained by the backoffice (`work_order:write`). */ + +async function loadPlan(ctx: ServiceCtx, planId: string) { + const scope = await workOrderScope(ctx); + const plan = await ctx.db.materialPlan.findFirst({ where: { id: planId, workOrder: scope } }); + if (!plan) throw new ServiceError("not_found", "material_plan_not_found"); + return plan; +} + +export async function addMaterialPlan(ctx: ServiceCtx, workOrderId: string, raw: MaterialPlanInput) { + assertCan(ctx, "work_order:write"); + const input = parseInput(materialPlanInputSchema, raw); + const wo = await loadVisibleWorkOrder(ctx, workOrderId); + assertNotLocked(wo, PLANNING_LOCKED); + const count = await ctx.db.materialPlan.count({ where: { workOrderId } }); + const plan = await ctx.db.materialPlan.create({ + data: { + tenantId: ctx.tenantId, + workOrderId, + name: input.name, + articleNumber: input.articleNumber, + plannedQuantity: input.plannedQuantity, + unit: input.unit, + notes: input.notes, + sortOrder: input.sortOrder ?? (count + 1) * 10, + }, + }); + await touchWorkOrder(ctx, workOrderId); + await auditWorkOrder(ctx, { action: "update", workOrderId, after: { op: "material_plan.add", plan: snapshot(plan) } }); + return plan; +} + +export async function updateMaterialPlan(ctx: ServiceCtx, planId: string, raw: MaterialPlanInput) { + assertCan(ctx, "work_order:write"); + const input = parseInput(materialPlanInputSchema, raw); + const before = await loadPlan(ctx, planId); + const wo = await loadVisibleWorkOrder(ctx, before.workOrderId); + assertNotLocked(wo, PLANNING_LOCKED); + const plan = await ctx.db.materialPlan.update({ + where: { id: planId }, + data: { + name: input.name, + articleNumber: input.articleNumber, + plannedQuantity: input.plannedQuantity, + unit: input.unit, + notes: input.notes, + ...(input.sortOrder !== undefined ? { sortOrder: input.sortOrder } : {}), + }, + }); + await touchWorkOrder(ctx, wo.id); + await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: { op: "material_plan.update", plan: snapshot(before) }, after: { plan: snapshot(plan) } }); + return plan; +} + +/** MaterialPlan has no deletedAt (planning data). Removal is refused once usages reference it. */ +export async function removeMaterialPlan(ctx: ServiceCtx, planId: string): Promise { + assertCan(ctx, "work_order:write"); + const before = await loadPlan(ctx, planId); + const wo = await loadVisibleWorkOrder(ctx, before.workOrderId); + assertNotLocked(wo, PLANNING_LOCKED); + const usages = await ctx.db.materialUsage.count({ where: { materialPlanId: planId } }); + if (usages > 0) throw new ServiceError("invalid", "material_in_use"); + await ctx.db.materialPlan.deleteMany({ where: { id: planId } }); + await touchWorkOrder(ctx, wo.id); + await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: { op: "material_plan.remove", plan: snapshot(before) } }); +} + +export type MaterialRow = { + planId: string | null; + name: string; + articleNumber: string | null; + unit: string; + planned: number | null; + actual: number | null; + deviation: number | null; + statuses: string[]; + reasons: string[]; + notes: string | null; +}; + +/** Planned vs. actual incl. deviations (backoffice read view, US-009). */ +export async function getMaterialOverview(ctx: ServiceCtx, workOrderId: string): Promise { + await loadVisibleWorkOrder(ctx, workOrderId); + const [plans, usages] = await Promise.all([ + ctx.db.materialPlan.findMany({ where: { workOrderId }, orderBy: { sortOrder: "asc" } }), + ctx.db.materialUsage.findMany({ where: { workOrderId }, orderBy: { createdAt: "asc" } }), + ]); + const rows: MaterialRow[] = plans.map((p) => { + const u = usages.filter((x) => x.materialPlanId === p.id); + const planned = Number(p.plannedQuantity); + const actual = u.length ? u.reduce((s, x) => s + Number(x.actualQuantity), 0) : null; + return { + planId: p.id, + name: p.name, + articleNumber: p.articleNumber, + unit: p.unit, + planned, + actual, + deviation: actual === null ? null : Math.round((actual - planned) * 1000) / 1000, + statuses: [...new Set(u.map((x) => x.usageStatus))], + reasons: u.map((x) => x.deviationReason).filter((r): r is string => !!r), + notes: p.notes, + }; + }); + for (const x of usages.filter((u) => !u.materialPlanId)) { + rows.push({ + planId: null, + name: x.name, + articleNumber: x.articleNumber, + unit: x.unit, + planned: null, + actual: Number(x.actualQuantity), + deviation: Number(x.actualQuantity), + statuses: [x.usageStatus], + reasons: x.deviationReason ? [x.deviationReason] : [], + notes: x.notes, + }); + } + return rows; +} diff --git a/src/server/services/work-orders/options.ts b/src/server/services/work-orders/options.ts new file mode 100644 index 0000000..ccb2b09 --- /dev/null +++ b/src/server/services/work-orders/options.ts @@ -0,0 +1,81 @@ +import type { ServiceCtx } from "@/server/services/context"; +import { customerScope } from "@/server/services/work-orders/visibility"; + +/** Lookup data for the create/assign popups and filter bars (read-only, scoped). */ + +export async function searchCustomerOptions(ctx: ServiceCtx, q: string | undefined) { + const scope = await customerScope(ctx); + const term = q?.trim(); + const like = term ? { contains: term.slice(0, 100), mode: "insensitive" as const } : undefined; + return ctx.db.customer.findMany({ + where: { + AND: [ + scope, + { status: { not: "merged" } }, + like ? { OR: [{ companyName: like }, { lastName: like }, { firstName: like }, { customerNumber: like }, { city: like }, { street: like }] } : {}, + ], + }, + select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true, status: true }, + orderBy: { updatedAt: "desc" }, + take: 20, + }); +} + +/** Customer select for filter bars (dashboard / list). */ +export async function customerFilterOptions(ctx: ServiceCtx) { + const scope = await customerScope(ctx); + return ctx.db.customer.findMany({ + where: { AND: [scope, { status: { not: "merged" } }] }, + select: { id: true, companyName: true, firstName: true, lastName: true }, + orderBy: [{ companyName: "asc" }, { lastName: "asc" }], + take: 300, + }); +} + +export async function siteFilterOptions(ctx: ServiceCtx, customerId: string) { + return ctx.db.site.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true }, orderBy: { name: "asc" } }); +} + +export async function customerOption(ctx: ServiceCtx, customerId: string) { + const scope = await customerScope(ctx); + const customer = await ctx.db.customer.findFirst({ + where: { AND: [scope, { id: customerId }] }, + select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true }, + }); + if (!customer) return null; + const [sites, contacts] = await Promise.all([ + ctx.db.site.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true, street: true, houseNumber: true, city: true }, orderBy: { name: "asc" } }), + ctx.db.contact.findMany({ where: { customerId, deletedAt: null }, select: { id: true, name: true, role: true }, orderBy: { name: "asc" } }), + ]); + return { customer, sites, contacts }; +} + +export async function teamOptions(ctx: ServiceCtx) { + const now = new Date(); + return ctx.db.team.findMany({ + where: { deletedAt: null, status: "active" }, + select: { + id: true, + name: true, + leaderUserId: true, + members: { + where: { validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] }, + select: { user: { select: { id: true, name: true } } }, + }, + }, + orderBy: { name: "asc" }, + }); +} + +export async function userOptions(ctx: ServiceCtx) { + return ctx.db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true }, orderBy: { name: "asc" } }); +} + +export async function checklistTemplateOptions(ctx: ServiceCtx) { + return ctx.db.checklistTemplate.findMany({ where: { active: true }, select: { id: true, name: true }, orderBy: { name: "asc" } }); +} + +export function customerDisplayName(c: { companyName?: string | null; firstName?: string | null; lastName?: string | null } | null | undefined): string { + if (!c) return ""; + return c.companyName || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—"; +} diff --git a/src/server/services/work-orders/release-billing.ts b/src/server/services/work-orders/release-billing.ts new file mode 100644 index 0000000..5ad97fb --- /dev/null +++ b/src/server/services/work-orders/release-billing.ts @@ -0,0 +1,34 @@ +import type { ServiceCtx } from "@/server/services/context"; +import { transitionWorkOrder, type TransitionResult } from "@/server/services/work-orders/transition"; + +/** + * Billing workflow (US-009). All paths go through transitionWorkOrder, which enforces + * `work_order:release_billing` and — for the release — an approved completion report. + * The event `work_order.released_for_billing` is emitted by the transition. + */ +export async function releaseForBilling( + ctx: ServiceCtx, + input: { workOrderId: string; baseVersion?: number }, +): Promise { + return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "released_for_billing", baseVersion: input.baseVersion }); +} + +/** Reject: back to the team for correction (in_review → in_progress), reason mandatory. */ +export async function rejectForCorrection( + ctx: ServiceCtx, + input: { workOrderId: string; reason: string; baseVersion?: number }, +): Promise { + return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "in_progress", reason: input.reason, baseVersion: input.baseVersion }); +} + +/** Revoke a billing release (released_for_billing → in_review), reason mandatory. */ +export async function revokeBillingRelease( + ctx: ServiceCtx, + input: { workOrderId: string; reason: string; baseVersion?: number }, +): Promise { + return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "in_review", reason: input.reason, baseVersion: input.baseVersion }); +} + +export async function markBilled(ctx: ServiceCtx, input: { workOrderId: string; baseVersion?: number }): Promise { + return transitionWorkOrder(ctx, { workOrderId: input.workOrderId, to: "billed", baseVersion: input.baseVersion }); +} diff --git a/src/server/services/work-orders/search.ts b/src/server/services/work-orders/search.ts new file mode 100644 index 0000000..61f7fea --- /dev/null +++ b/src/server/services/work-orders/search.ts @@ -0,0 +1,110 @@ +import type { Prisma } from "@prisma/client"; +import type { WorkOrderStatus } from "@/lib/work-orders/status"; +import type { ServiceCtx } from "@/server/services/context"; +import { allowedDocumentVisibility, customerScope, siteScope, workOrderScope } from "@/server/services/work-orders/visibility"; + +export type SearchFilter = { q: string; from?: Date; to?: Date; statuses?: WorkOrderStatus[]; teamId?: string }; + +const TAKE = 20; + +/** + * Tenant search (spec §25) with ILIKE (`contains` + insensitive) — every section is restricted by + * the same scopes as the detail views (work orders, customers, sites, document visibility). + * Period/status/team filters apply to everything that hangs off a work order. + */ +export async function searchAll(ctx: ServiceCtx, f: SearchFilter) { + const q = f.q.trim(); + const empty = { workOrders: [], customers: [], sites: [], contacts: [], documents: [], notes: [] }; + if (q.length < 2) return empty; + const like = { contains: q.slice(0, 100), mode: "insensitive" as const }; + + const [woScope, cScope, sScope] = await Promise.all([workOrderScope(ctx), customerScope(ctx), siteScope(ctx)]); + const woFilter: Prisma.WorkOrderWhereInput[] = [woScope]; + if (f.from) woFilter.push({ OR: [{ plannedEnd: { gte: f.from } }, { plannedEnd: null, plannedStart: { gte: f.from } }] }); + if (f.to) woFilter.push({ plannedStart: { lte: f.to } }); + if (f.statuses?.length) woFilter.push({ status: { in: f.statuses } }); + if (f.teamId) woFilter.push({ assignedTeamId: f.teamId }); + const woWhere: Prisma.WorkOrderWhereInput = { AND: woFilter }; + const hasWoFilter = woFilter.length > 1; + + const [workOrders, customers, sites, contacts, documents, notes] = await Promise.all([ + ctx.db.workOrder.findMany({ + where: { + AND: [ + woWhere, + { + OR: [ + { number: like }, + { externalOrderNumber: like }, + { offerNumber: like }, + { title: like }, + { description: like }, + { scope: like }, + { site: { OR: [{ street: like }, { city: like }, { postalCode: like }] } }, + ], + }, + ], + }, + select: { id: true, number: true, title: true, status: true, plannedStart: true, customer: { select: { companyName: true, firstName: true, lastName: true } }, site: { select: { name: true, city: true } }, team: { select: { name: true } } }, + orderBy: { updatedAt: "desc" }, + take: TAKE, + }), + ctx.db.customer.findMany({ + where: { + AND: [ + cScope, + hasWoFilter ? { workOrders: { some: woWhere } } : {}, + { OR: [{ customerNumber: like }, { companyName: like }, { firstName: like }, { lastName: like }, { street: like }, { city: like }, { postalCode: like }, { email: like }, { phone: like }] }, + ], + }, + select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, street: true, houseNumber: true, postalCode: true, city: true, status: true }, + orderBy: { updatedAt: "desc" }, + take: TAKE, + }), + ctx.db.site.findMany({ + where: { + AND: [ + sScope, + hasWoFilter ? { workOrders: { some: woWhere } } : {}, + { OR: [{ name: like }, { street: like }, { city: like }, { postalCode: like }, { onSiteContact: like }] }, + ], + }, + select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, customer: { select: { companyName: true, firstName: true, lastName: true } } }, + orderBy: { updatedAt: "desc" }, + take: TAKE, + }), + ctx.db.contact.findMany({ + where: { AND: [{ deletedAt: null }, { customer: cScope }, { OR: [{ name: like }, { email: like }, { phone: like }, { mobile: like }] }] }, + select: { id: true, name: true, role: true, phone: true, email: true, customer: { select: { id: true, companyName: true, firstName: true, lastName: true } } }, + take: TAKE, + }), + ctx.db.document.findMany({ + where: { + AND: [ + { deletedAt: null, visibility: { in: allowedDocumentVisibility(ctx) } }, + { OR: [{ fileName: like }, { title: like }] }, + // documents on an order follow the order scope; unlinked-to-order documents need the customer/site scope + { + OR: [ + { workOrder: woWhere }, + ...(hasWoFilter ? [] : [{ workOrderId: null, customer: cScope }, { workOrderId: null, customerId: null, site: sScope }]), + ], + }, + ], + }, + select: { id: true, title: true, fileName: true, category: true, workOrderId: true, customerId: true, siteId: true, createdAt: true }, + orderBy: { createdAt: "desc" }, + take: TAKE, + }), + ctx.db.activityNote.findMany({ + where: { deletedAt: null, text: like, workOrder: woWhere }, + select: { id: true, text: true, kind: true, createdAt: true, workOrder: { select: { id: true, number: true, title: true } } }, + orderBy: { createdAt: "desc" }, + take: TAKE, + }), + ]); + + return { workOrders, customers, sites, contacts, documents, notes }; +} + +export type SearchResults = Awaited>; diff --git a/src/server/services/work-orders/settings.ts b/src/server/services/work-orders/settings.ts new file mode 100644 index 0000000..d4fbc99 --- /dev/null +++ b/src/server/services/work-orders/settings.ts @@ -0,0 +1,142 @@ +import { DEFAULT_NUMBER_PREFIX, DEFAULT_ORDER_TYPES } from "@/lib/work-orders/defaults"; +import { + checklistTemplateSchema, + numberingSchema, + NUMBER_KEYS, + orderTypeSchema, + type ChecklistTemplateInput, + type OrderTypeInput, +} from "@/lib/work-orders/schemas"; +import { writeAuditLog } from "@/server/audit"; +import type { TenantDb } from "@/server/db"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { parseInput, snapshot } from "@/server/services/work-orders/_shared"; + +/** + * Tenant settings of the work order module (`settings:templates`): order types (§10.2), + * checklist templates incl. required photos (§12.4/§14.2) and number sequences. + */ + +/** Seed the default order types once per tenant (only if the tenant has none yet). Idempotent. */ +export async function ensureDefaultOrderTypes(db: TenantDb, tenantId: string): Promise { + const existing = await db.orderType.count(); + if (existing > 0) return 0; + const res = await db.orderType.createMany({ + data: DEFAULT_ORDER_TYPES.map((t) => ({ tenantId, ...t })), + skipDuplicates: true, + }); + return res.count; +} + +async function audit(ctx: ServiceCtx, entity: string, entityId: string, action: "create" | "update", before: unknown, after: unknown) { + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action, entity, entityId, before, after }); +} + +// ---------- Order types ---------- + +export async function listOrderTypes(ctx: ServiceCtx, opts: { activeOnly?: boolean } = {}) { + await ensureDefaultOrderTypes(ctx.db, ctx.tenantId); + return ctx.db.orderType.findMany({ + where: opts.activeOnly ? { active: true } : {}, + orderBy: [{ sortOrder: "asc" }, { name: "asc" }], + }); +} + +export async function createOrderType(ctx: ServiceCtx, raw: OrderTypeInput) { + assertCan(ctx, "settings:templates"); + const input = parseInput(orderTypeSchema, raw); + const dup = await ctx.db.orderType.findFirst({ where: { key: input.key }, select: { id: true } }); + if (dup) throw new ServiceError("invalid", "order_type_key_taken"); + const ot = await ctx.db.orderType.create({ data: { tenantId: ctx.tenantId, ...input } }); + await audit(ctx, "order_type", ot.id, "create", undefined, snapshot(ot)); + return ot; +} + +export async function updateOrderType(ctx: ServiceCtx, id: string, raw: Omit) { + assertCan(ctx, "settings:templates"); + const input = parseInput(orderTypeSchema.omit({ key: true }), raw); + const before = await ctx.db.orderType.findFirst({ where: { id } }); + if (!before) throw new ServiceError("not_found", "order_type_not_found"); + const ot = await ctx.db.orderType.update({ where: { id }, data: input }); + await audit(ctx, "order_type", id, "update", snapshot(before), snapshot(ot)); + return ot; +} + +// ---------- Checklist templates ---------- + +export async function listChecklistTemplates(ctx: ServiceCtx) { + return ctx.db.checklistTemplate.findMany({ + orderBy: [{ active: "desc" }, { name: "asc" }], + include: { orderType: { select: { id: true, name: true } } }, + }); +} + +async function assertOrderType(ctx: ServiceCtx, orderTypeId: string | null | undefined) { + if (!orderTypeId) return; + const ot = await ctx.db.orderType.findFirst({ where: { id: orderTypeId }, select: { id: true } }); + if (!ot) throw new ServiceError("invalid", "order_type_not_found"); +} + +function assertUniqueKeys(list: { key: string }[], code: string) { + if (new Set(list.map((i) => i.key)).size !== list.length) throw new ServiceError("invalid", code); +} + +export async function createChecklistTemplate(ctx: ServiceCtx, raw: ChecklistTemplateInput) { + assertCan(ctx, "settings:templates"); + const input = parseInput(checklistTemplateSchema, raw); + await assertOrderType(ctx, input.orderTypeId); + assertUniqueKeys(input.items, "duplicate_item_key"); + assertUniqueKeys(input.requiredPhotos, "duplicate_photo_key"); + const tpl = await ctx.db.checklistTemplate.create({ + data: { tenantId: ctx.tenantId, name: input.name, orderTypeId: input.orderTypeId ?? null, active: input.active, items: input.items, requiredPhotos: input.requiredPhotos }, + }); + await audit(ctx, "checklist_template", tpl.id, "create", undefined, snapshot(tpl)); + return tpl; +} + +export async function updateChecklistTemplate(ctx: ServiceCtx, id: string, raw: ChecklistTemplateInput) { + assertCan(ctx, "settings:templates"); + const input = parseInput(checklistTemplateSchema, raw); + const before = await ctx.db.checklistTemplate.findFirst({ where: { id } }); + if (!before) throw new ServiceError("not_found", "template_not_found"); + await assertOrderType(ctx, input.orderTypeId); + assertUniqueKeys(input.items, "duplicate_item_key"); + assertUniqueKeys(input.requiredPhotos, "duplicate_photo_key"); + const tpl = await ctx.db.checklistTemplate.update({ + where: { id }, + data: { name: input.name, orderTypeId: input.orderTypeId ?? null, active: input.active, items: input.items, requiredPhotos: input.requiredPhotos }, + }); + await audit(ctx, "checklist_template", id, "update", snapshot(before), snapshot(tpl)); + return tpl; +} + +// ---------- Number sequences ---------- + +export async function listNumberSequences(ctx: ServiceCtx) { + const rows = await ctx.db.numberSequence.findMany(); + return NUMBER_KEYS.map((key) => { + const r = rows.find((x) => x.key === key); + return { key, prefix: r?.prefix ?? DEFAULT_NUMBER_PREFIX[key], padding: r?.padding ?? 5, nextValue: r?.nextValue ?? 1, exists: !!r }; + }); +} + +/** Changes prefix/padding only — the counter itself is never reset (numbers stay unique). */ +export async function updateNumberSequence(ctx: ServiceCtx, raw: { key: string; prefix: string; padding: number | string }) { + assertCan(ctx, "settings:templates"); + const input = parseInput(numberingSchema, raw); + const before = await ctx.db.numberSequence.findFirst({ where: { key: input.key } }); + let row; + if (before) { + row = await ctx.db.numberSequence.update({ where: { id: before.id }, data: { prefix: input.prefix, padding: input.padding } }); + } else { + try { + row = await ctx.db.numberSequence.create({ data: { tenantId: ctx.tenantId, key: input.key, prefix: input.prefix, padding: input.padding } }); + } catch (err) { + if ((err as { code?: string }).code !== "P2002") throw err; + const cur = await ctx.db.numberSequence.findFirst({ where: { key: input.key } }); + row = await ctx.db.numberSequence.update({ where: { id: cur!.id }, data: { prefix: input.prefix, padding: input.padding } }); + } + } + await audit(ctx, "number_sequence", row.id, before ? "update" : "create", before ? snapshot(before) : undefined, snapshot(row)); + return row; +} diff --git a/src/server/services/work-orders/sync-reapply.ts b/src/server/services/work-orders/sync-reapply.ts new file mode 100644 index 0000000..bfc603f --- /dev/null +++ b/src/server/services/work-orders/sync-reapply.ts @@ -0,0 +1,25 @@ +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; + +/** + * STUB (lane L2) until lane L4 delivers `src/server/services/sync/apply.ts`. + * Contract (ARCHITEKTUR §4.6): re-dispatch a stored SyncOperation onto the domain services + * against the CURRENT state (no baseVersion). Replace the body with a call to the L4 dispatcher + * after merge; the signature stays. + * + * MVP scope of the stub: only `work_order.transition` (the only conflict-prone op besides + * `report.submit`, which belongs to lane reports). + */ +export async function reapplySyncOperation( + opCtx: ServiceCtx, + op: { opType: string; entityId: string | null; payload: unknown }, +): Promise<{ entityVersion?: number }> { + if (op.opType === "work_order.transition") { + const p = (op.payload ?? {}) as { to?: string; reason?: string; workOrderId?: string }; + const workOrderId = op.entityId ?? p.workOrderId; + if (!workOrderId || !p.to) throw new ServiceError("invalid", "sync_payload_invalid"); + const res = await transitionWorkOrder(opCtx, { workOrderId, to: p.to as never, reason: p.reason ?? null }); + return { entityVersion: res.version }; + } + throw new ServiceError("invalid", "reapply_unsupported", { opType: op.opType }); +} diff --git a/src/server/services/work-orders/transition.ts b/src/server/services/work-orders/transition.ts new file mode 100644 index 0000000..95d0097 --- /dev/null +++ b/src/server/services/work-orders/transition.ts @@ -0,0 +1,107 @@ +import type { EventType } from "@/lib/events"; +import { canTransition, requiredPermission, type WorkOrderStatus } from "@/lib/work-orders/status"; +import { transitionSchema, type TransitionInput } from "@/lib/work-orders/schemas"; +import { emitEvent } from "@/server/events"; +import { can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { + assertBaseVersion, + auditWorkOrder, + loadVisibleWorkOrder, + parseInput, + writeWithVersion, + type WorkOrderBase, +} from "@/server/services/work-orders/_shared"; +import { transitionBlockers } from "@/server/services/work-orders/completion"; + +/** Permission decision for a single transition (scope is checked separately by loading the order). */ +export function mayTransition(ctx: ServiceCtx, from: WorkOrderStatus, to: WorkOrderStatus): boolean { + if (from === "in_review" && to === "in_progress") return can(ctx, "report:approve_team") || can(ctx, "report:approve"); + return can(ctx, requiredPermission(from, to)); +} + +/** Transitions that need a reason (cancellation, correction request, revoking a billing release). */ +export function reasonRequired(from: WorkOrderStatus, to: WorkOrderStatus): boolean { + return to === "cancelled" || (from === "in_review" && to === "in_progress") || (from === "released_for_billing" && to === "in_review"); +} + +function eventFor(from: WorkOrderStatus, to: WorkOrderStatus): EventType { + switch (to) { + case "cancelled": + return "work_order.cancelled"; + case "daily_report_created": + return "work_order.daily_report_created"; + case "technically_completed": + return "work_order.technically_completed"; + case "signature_pending": + return "work_order.signature_missing"; + case "released_for_billing": + return "work_order.released_for_billing"; + case "in_progress": + return ["assigned", "accepted", "en_route"].includes(from) ? "work_order.started" : "work_order.changed"; + default: + return "work_order.changed"; + } +} + +export type TransitionResult = { id: string; status: WorkOrderStatus; version: number; from: WorkOrderStatus }; + +/** + * The ONLY place that changes WorkOrder.status (ARCHITEKTUR §3). + * Order of checks: scope (not_found) → table (invalid) → permission (forbidden) → version (conflict) + * → reason (invalid) → guards (blocked, CompletionBlocker[]) → optimistic write. + */ +export async function transitionWorkOrder(ctx: ServiceCtx, raw: TransitionInput): Promise { + const input = parseInput(transitionSchema, raw); + const wo = await loadVisibleWorkOrder(ctx, input.workOrderId); + return applyTransition(ctx, wo, input.to, { reason: input.reason ?? null, baseVersion: input.baseVersion, eventData: sanitizeEventData(raw.eventData) }); +} + +function sanitizeEventData(v: unknown): Record | undefined { + if (!v || typeof v !== "object" || Array.isArray(v)) return undefined; + const out: Record = {}; + for (const [k, val] of Object.entries(v).slice(0, 20)) { + if (val === null || typeof val === "boolean" || typeof val === "number") out[k] = val; + else if (typeof val === "string") out[k] = val.slice(0, 500); + } + return out; +} + +/** Internal: transition an already loaded (and scope-checked) order. `extra` is written in the same row update. */ +export async function applyTransition( + ctx: ServiceCtx, + wo: WorkOrderBase, + to: WorkOrderStatus, + opts: { + reason?: string | null; + baseVersion?: number; + extra?: Record; + eventData?: Record; + } = {}, +): Promise { + const from = wo.status; + if (!canTransition(from, to)) throw new ServiceError("invalid", "transition_not_allowed", { from, to }); + if (!mayTransition(ctx, from, to)) throw new ServiceError("forbidden", "transition_forbidden", { from, to }); + assertBaseVersion(wo, opts.baseVersion); + if (reasonRequired(from, to) && !opts.reason?.trim()) throw new ServiceError("invalid", "reason_required", { from, to }); + + const blockers = await transitionBlockers(ctx, wo, to); + if (blockers.length) throw new ServiceError("blocked", "transition_blocked", blockers); + + const version = await writeWithVersion(ctx, wo, { status: to, ...(opts.extra ?? {}) }); + await ctx.db.workOrderStatusChange.create({ + data: { tenantId: ctx.tenantId, workOrderId: wo.id, fromStatus: from, toStatus: to, actorId: ctx.userId, reason: opts.reason ?? null }, + }); + await auditWorkOrder(ctx, { + action: "update", + workOrderId: wo.id, + before: { status: from, version: wo.version }, + after: { status: to, version, reason: opts.reason ?? null, ...(opts.extra ?? {}) }, + }); + await emitEvent(ctx, { + type: eventFor(from, to), + entityType: "work_order", + entityId: wo.id, + data: { ...(opts.eventData ?? {}), number: wo.number, from, to }, + }); + return { id: wo.id, status: to, version, from }; +} diff --git a/src/server/services/work-orders/update.ts b/src/server/services/work-orders/update.ts new file mode 100644 index 0000000..eca9bc3 --- /dev/null +++ b/src/server/services/work-orders/update.ts @@ -0,0 +1,83 @@ +import { updateWorkOrderSchema, type UpdateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { emitEvent } from "@/server/events"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { + assertBaseVersion, + assertNotLocked, + auditWorkOrder, + FINAL_STATUSES, + loadVisibleWorkOrder, + parseInput, + snapshot, + writeWithVersion, +} from "@/server/services/work-orders/_shared"; + +const FIELDS = Object.keys(updateWorkOrderSchema.shape) as (keyof UpdateWorkOrderInput)[]; + +/** Partial update of the order master data (never status — see transition.ts). Only keys present in `raw` change. */ +export async function updateWorkOrder( + ctx: ServiceCtx, + workOrderId: string, + raw: UpdateWorkOrderInput, + baseVersion?: number, +): Promise<{ id: string; version: number }> { + assertCan(ctx, "work_order:write"); + const parsed = parseInput(updateWorkOrderSchema, raw); + const present = FIELDS.filter((k) => Object.prototype.hasOwnProperty.call(raw, k)); + if (present.length === 0) throw new ServiceError("invalid", "nothing_to_update"); + + const wo = await loadVisibleWorkOrder(ctx, workOrderId); + assertNotLocked(wo, FINAL_STATUSES); + assertBaseVersion(wo, baseVersion); + + const full = await ctx.db.workOrder.findFirst({ where: { id: wo.id } }); + if (!full) throw new ServiceError("not_found", "work_order_not_found"); + + const data: Record = {}; + for (const k of present) data[k] = (parsed as Record)[k] ?? null; + if ("title" in data && !data.title) throw new ServiceError("invalid", "validation_failed", [{ path: "title" }]); + if ("priority" in data && !data.priority) delete data.priority; + if ("signatureRequired" in data && typeof data.signatureRequired !== "boolean") delete data.signatureRequired; + if ("customerId" in data && !data.customerId) delete data.customerId; + + const customerId = (data.customerId as string | undefined) ?? full.customerId; + const siteId = "siteId" in data ? (data.siteId as string | null) : full.siteId; + const contactId = "contactId" in data ? (data.contactId as string | null) : full.contactId; + + if (customerId !== full.customerId) { + const c = await ctx.db.customer.findFirst({ where: { id: customerId, deletedAt: null, status: { not: "merged" } }, select: { id: true } }); + if (!c) throw new ServiceError("invalid", "customer_not_found"); + } + if (siteId) { + const s = await ctx.db.site.findFirst({ where: { id: siteId, deletedAt: null }, select: { customerId: true } }); + if (!s) throw new ServiceError("invalid", "site_not_found"); + if (s.customerId !== customerId) throw new ServiceError("invalid", "site_customer_mismatch"); + } + if (contactId) { + const c = await ctx.db.contact.findFirst({ where: { id: contactId, deletedAt: null }, select: { customerId: true } }); + if (!c) throw new ServiceError("invalid", "contact_not_found"); + if (c.customerId !== customerId) throw new ServiceError("invalid", "contact_customer_mismatch"); + } + if ("orderTypeId" in data && data.orderTypeId) { + const ot = await ctx.db.orderType.findFirst({ where: { id: data.orderTypeId as string }, select: { id: true } }); + if (!ot) throw new ServiceError("invalid", "order_type_not_found"); + } + const start = "plannedStart" in data ? (data.plannedStart as Date | null) : full.plannedStart; + const end = "plannedEnd" in data ? (data.plannedEnd as Date | null) : full.plannedEnd; + if (start && end && end < start) throw new ServiceError("invalid", "planned_end_before_start"); + + const before = Object.fromEntries(Object.keys(data).map((k) => [k, (full as Record)[k]])); + const version = await writeWithVersion(ctx, wo, data); + await auditWorkOrder(ctx, { action: "update", workOrderId: wo.id, before: snapshot(before), after: snapshot({ ...data, version }) }); + await emitEvent(ctx, { type: "work_order.changed", entityType: "work_order", entityId: wo.id, data: { number: wo.number } }); + return { id: wo.id, version }; +} + +/** Soft delete (spec §27.5) — only drafts / orders still in review. */ +export async function deleteWorkOrder(ctx: ServiceCtx, workOrderId: string): Promise { + assertCan(ctx, "work_order:write"); + const wo = await loadVisibleWorkOrder(ctx, workOrderId); + if (!["draft", "review_required"].includes(wo.status)) throw new ServiceError("invalid", "delete_only_draft"); + await writeWithVersion(ctx, wo, { deletedAt: new Date() }); + await auditWorkOrder(ctx, { action: "delete", workOrderId: wo.id, before: { status: wo.status, number: wo.number } }); +} From a962fa8be98a1ba3917f10717a78d0af8513dbb8 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:29:42 +0200 Subject: [PATCH 2/5] =?UTF-8?q?L2=20Auftr=C3=A4ge=20&=20Backoffice:=20Serv?= =?UTF-8?q?er=20Actions=20und=20/api/v1/work-orders?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- .../api/v1/work-orders/[id]/assign/route.ts | 22 ++ .../v1/work-orders/[id]/documents/route.ts | 40 +++ .../v1/work-orders/[id]/materials/route.ts | 29 ++ src/app/api/v1/work-orders/[id]/route.ts | 34 ++ .../v1/work-orders/[id]/transition/route.ts | 24 ++ src/app/api/v1/work-orders/_http.ts | 48 +++ src/app/api/v1/work-orders/route.ts | 30 ++ src/server/actions/work_orders/_form.ts | 89 +++++ src/server/actions/work_orders/settings.ts | 135 ++++++++ src/server/actions/work_orders/work-orders.ts | 304 ++++++++++++++++++ 10 files changed, 755 insertions(+) create mode 100644 src/app/api/v1/work-orders/[id]/assign/route.ts create mode 100644 src/app/api/v1/work-orders/[id]/documents/route.ts create mode 100644 src/app/api/v1/work-orders/[id]/materials/route.ts create mode 100644 src/app/api/v1/work-orders/[id]/route.ts create mode 100644 src/app/api/v1/work-orders/[id]/transition/route.ts create mode 100644 src/app/api/v1/work-orders/_http.ts create mode 100644 src/app/api/v1/work-orders/route.ts create mode 100644 src/server/actions/work_orders/_form.ts create mode 100644 src/server/actions/work_orders/settings.ts create mode 100644 src/server/actions/work_orders/work-orders.ts diff --git a/src/app/api/v1/work-orders/[id]/assign/route.ts b/src/app/api/v1/work-orders/[id]/assign/route.ts new file mode 100644 index 0000000..077d166 --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/assign/route.ts @@ -0,0 +1,22 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { assignWorkOrder } from "@/server/services/work-orders/assign"; +import { apiContext, apiError, optionalVersion, readJson } from "../../_http"; + +/** POST /api/v1/work-orders/[id]/assign — body { teamId, userIds?, teamLeadUserId?, baseVersion? }. */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const ctx = await apiContext("work_order:assign"); + const body = await readJson(req); + const res = await assignWorkOrder(ctx, { + workOrderId: id, + teamId: String(body.teamId ?? ""), + userIds: Array.isArray(body.userIds) ? body.userIds.map(String) : [], + teamLeadUserId: typeof body.teamLeadUserId === "string" ? body.teamLeadUserId : null, + baseVersion: optionalVersion(body.baseVersion), + }); + return NextResponse.json(res); + } catch (err) { + return apiError(err); + } +} diff --git a/src/app/api/v1/work-orders/[id]/documents/route.ts b/src/app/api/v1/work-orders/[id]/documents/route.ts new file mode 100644 index 0000000..020ab21 --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/documents/route.ts @@ -0,0 +1,40 @@ +import { NextResponse, type NextRequest } from "next/server"; +import type { DocumentCategory, DocumentVisibility } from "@prisma/client"; +import { ServiceError } from "@/server/services/context"; +import { uploadWorkOrderDocument } from "@/server/services/work-orders/documents"; +import { apiContext, apiError } from "../../_http"; + +/** + * POST /api/v1/work-orders/[id]/documents — multipart upload (file, category, visibility, title?). + * Used by the backoffice form (HTML post → 303 back to the documents tab) and by API clients (JSON). + * A route handler instead of a server action avoids the 1 MB server-action body limit. + */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + const wantsHtml = (req.headers.get("accept") ?? "").includes("text/html"); + try { + // CSRF defence for the cookie-authenticated form post: same-origin only. + const origin = req.headers.get("origin"); + if (origin && origin !== req.nextUrl.origin) throw new ServiceError("forbidden", "cross_origin"); + const ctx = await apiContext("document:write"); + const form = await req.formData(); + const file = form.get("file"); + if (!(file instanceof File) || file.size === 0) throw new ServiceError("invalid", "file_missing"); + const doc = await uploadWorkOrderDocument(ctx, { + workOrderId: id, + bytes: new Uint8Array(await file.arrayBuffer()), + fileName: file.name, + declaredMime: file.type, + category: String(form.get("category") ?? "other") as DocumentCategory, + visibility: String(form.get("visibility") ?? "team") as DocumentVisibility, + title: typeof form.get("title") === "string" && String(form.get("title")).trim() ? String(form.get("title")).trim() : null, + }); + if (wantsHtml) return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploaded=1`, req.nextUrl.origin), 303); + return NextResponse.json(doc, { status: 201 }); + } catch (err) { + if (wantsHtml && err instanceof ServiceError) { + return NextResponse.redirect(new URL(`/work-orders/${encodeURIComponent(id)}?tab=documents&uploadError=${encodeURIComponent(err.message)}`, req.nextUrl.origin), 303); + } + return apiError(err); + } +} diff --git a/src/app/api/v1/work-orders/[id]/materials/route.ts b/src/app/api/v1/work-orders/[id]/materials/route.ts new file mode 100644 index 0000000..72db57e --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/materials/route.ts @@ -0,0 +1,29 @@ +import { NextResponse, type NextRequest } from "next/server"; +import type { MaterialPlanInput } from "@/lib/work-orders/schemas"; +import { addMaterialPlan, getMaterialOverview } from "@/server/services/work-orders/materials"; +import { apiContext, apiError, readJson } from "../../_http"; + +type Params = { params: Promise<{ id: string }> }; + +/** GET /api/v1/work-orders/[id]/materials — planned vs. actual incl. deviations. */ +export async function GET(_req: NextRequest, { params }: Params) { + try { + const { id } = await params; + const ctx = await apiContext(); + return NextResponse.json({ items: await getMaterialOverview(ctx, id) }); + } catch (err) { + return apiError(err); + } +} + +/** POST /api/v1/work-orders/[id]/materials — add a material plan item { name, articleNumber?, plannedQuantity, unit, notes? }. */ +export async function POST(req: NextRequest, { params }: Params) { + try { + const { id } = await params; + const ctx = await apiContext("work_order:write"); + const plan = await addMaterialPlan(ctx, id, (await readJson(req)) as MaterialPlanInput); + return NextResponse.json({ ...plan, plannedQuantity: Number(plan.plannedQuantity) }, { status: 201 }); + } catch (err) { + return apiError(err); + } +} diff --git a/src/app/api/v1/work-orders/[id]/route.ts b/src/app/api/v1/work-orders/[id]/route.ts new file mode 100644 index 0000000..dd77e41 --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/route.ts @@ -0,0 +1,34 @@ +import { NextResponse, type NextRequest } from "next/server"; +import type { UpdateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { computeCompletionBlockers } from "@/server/services/work-orders/completion"; +import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail"; +import { updateWorkOrder } from "@/server/services/work-orders/update"; +import { apiContext, apiError, optionalVersion, readJson } from "../_http"; + +type Params = { params: Promise<{ id: string }> }; + +/** GET /api/v1/work-orders/[id] — detail incl. transitions available to the caller and completion blockers. */ +export async function GET(_req: NextRequest, { params }: Params) { + try { + const { id } = await params; + const ctx = await apiContext(); + const wo = await getWorkOrderDetail(ctx, id); + const [blockers] = await Promise.all([computeCompletionBlockers(ctx, id)]); + return NextResponse.json({ workOrder: wo, availableTransitions: availableTransitions(ctx, wo.status), completionBlockers: blockers }); + } catch (err) { + return apiError(err); + } +} + +/** PATCH /api/v1/work-orders/[id] — body: partial master data + optional baseVersion (409 on mismatch). */ +export async function PATCH(req: NextRequest, { params }: Params) { + try { + const { id } = await params; + const ctx = await apiContext(); + const { baseVersion, ...patch } = await readJson(req); + const res = await updateWorkOrder(ctx, id, patch as UpdateWorkOrderInput, optionalVersion(baseVersion)); + return NextResponse.json(res); + } catch (err) { + return apiError(err); + } +} diff --git a/src/app/api/v1/work-orders/[id]/transition/route.ts b/src/app/api/v1/work-orders/[id]/transition/route.ts new file mode 100644 index 0000000..ab0fdf8 --- /dev/null +++ b/src/app/api/v1/work-orders/[id]/transition/route.ts @@ -0,0 +1,24 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; +import { apiContext, apiError, optionalVersion, readJson } from "../../_http"; + +/** + * POST /api/v1/work-orders/[id]/transition — body { to, reason?, baseVersion? }. + * 403 forbidden · 404 not in scope · 409 version conflict · 422 invalid / blocked (details: CompletionBlocker[]). + */ +export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + try { + const { id } = await params; + const ctx = await apiContext(); + const body = await readJson(req); + const res = await transitionWorkOrder(ctx, { + workOrderId: id, + to: String(body.to ?? "") as never, + reason: typeof body.reason === "string" ? body.reason : null, + baseVersion: optionalVersion(body.baseVersion), + }); + return NextResponse.json(res); + } catch (err) { + return apiError(err); + } +} diff --git a/src/app/api/v1/work-orders/_http.ts b/src/app/api/v1/work-orders/_http.ts new file mode 100644 index 0000000..76f29f3 --- /dev/null +++ b/src/app/api/v1/work-orders/_http.ts @@ -0,0 +1,48 @@ +import { NextResponse } from "next/server"; +import { moduleGuard } from "@/server/action-guard"; +import { ModuleDisabledError } from "@/server/modules"; +import { ForbiddenError, type Permission } from "@/server/rbac"; +import { ctxFromGuard, ServiceError, type ServiceCtx } from "@/server/services/context"; + +/** + * /api/v1/work-orders helpers (lane L2). Authentication/authorisation reuses the DB-authoritative + * moduleGuard (session cookie); the fundament's generic `requireApiContext` does not exist yet — + * see docs/craftvia/lanes/auftraege.md. + */ +export async function apiContext(...permissions: Permission[]): Promise { + const g = await moduleGuard("work_orders")(...permissions); + return ctxFromGuard(g); +} + +const STATUS: Record = { + not_found: 404, + forbidden: 403, + invalid: 422, + conflict: 409, + blocked: 422, +}; + +export function apiError(err: unknown): NextResponse { + if (err instanceof ServiceError) { + return NextResponse.json({ error: { code: err.code, message: err.message, details: err.details ?? null } }, { status: STATUS[err.code] }); + } + if (err instanceof ForbiddenError) return NextResponse.json({ error: { code: "forbidden", message: "forbidden" } }, { status: 403 }); + if (err instanceof ModuleDisabledError) return NextResponse.json({ error: { code: "forbidden", message: "module_disabled" } }, { status: 403 }); + if (err instanceof SyntaxError) return NextResponse.json({ error: { code: "invalid", message: "invalid_json" } }, { status: 400 }); + const msg = err instanceof Error ? err.message : ""; + if (/Nicht angemeldet|nicht aktiv|nicht mehr gueltig|Passwortwechsel/.test(msg)) { + return NextResponse.json({ error: { code: "unauthorized", message: "unauthorized" } }, { status: 401 }); + } + console.error("[api/v1/work-orders]", err); + return NextResponse.json({ error: { code: "internal", message: "internal" } }, { status: 500 }); +} + +export async function readJson(req: Request): Promise> { + const body = (await req.json()) as unknown; + if (!body || typeof body !== "object" || Array.isArray(body)) throw new ServiceError("invalid", "body_must_be_object"); + return body as Record; +} + +export function optionalVersion(v: unknown): number | undefined { + return typeof v === "number" && Number.isInteger(v) && v > 0 ? v : undefined; +} diff --git a/src/app/api/v1/work-orders/route.ts b/src/app/api/v1/work-orders/route.ts new file mode 100644 index 0000000..9d580f3 --- /dev/null +++ b/src/app/api/v1/work-orders/route.ts @@ -0,0 +1,30 @@ +import { NextResponse, type NextRequest } from "next/server"; +import { parseListParams } from "@/lib/work-orders/filters"; +import type { CreateWorkOrderInput } from "@/lib/work-orders/schemas"; +import { createWorkOrder } from "@/server/services/work-orders/create"; +import { listWorkOrders } from "@/server/services/work-orders/list"; +import { apiContext, apiError, readJson } from "./_http"; + +/** GET /api/v1/work-orders — filters as in the backoffice list (§21), always within workOrderScope. */ +export async function GET(req: NextRequest) { + try { + const ctx = await apiContext(); + return NextResponse.json(await listWorkOrders(ctx, parseListParams(req.nextUrl.searchParams))); + } catch (err) { + return apiError(err); + } +} + +/** POST /api/v1/work-orders — body: CreateWorkOrderInput (dates as ISO strings). */ +export async function POST(req: NextRequest) { + try { + const ctx = await apiContext(); + const body = await readJson(req); + // Import linkage and number keys are reserved for the import/emergency services. + delete body.sourceImportId; + const created = await createWorkOrder(ctx, body as CreateWorkOrderInput); + return NextResponse.json(created, { status: 201 }); + } catch (err) { + return apiError(err); + } +} diff --git a/src/server/actions/work_orders/_form.ts b/src/server/actions/work_orders/_form.ts new file mode 100644 index 0000000..e57ac76 --- /dev/null +++ b/src/server/actions/work_orders/_form.ts @@ -0,0 +1,89 @@ +import { unstable_rethrow } from "next/navigation"; +import type { ActionState } from "@/lib/work-orders/action-state"; +import type { CompletionBlocker } from "@/lib/work-orders/status"; +import { wallTimeToUtc } from "@/lib/work-orders/time"; +import { writeAuditLog } from "@/server/audit"; +import { ModuleDisabledError } from "@/server/modules"; +import { ForbiddenError } from "@/server/rbac"; +import { ServiceError } from "@/server/services/context"; + +/** + * Internal helpers of the work_orders actions (file starts with "_": not an action module, + * skipped by scripts/check-module-guards.ts). Maps thrown errors to a displayable ActionState + * without leaking internals (F-16) and audits RBAC denials. + */ +export async function toErrorState( + err: unknown, + audit: { tenantId?: string; actorId?: string; entity: string; entityId?: string }, +): Promise { + unstable_rethrow(err); + const at = Date.now(); + if (err instanceof ServiceError) { + return { + status: "error", + code: err.code, + message: err.message, + blockers: err.code === "blocked" ? (err.details as CompletionBlocker[]) : undefined, + at, + }; + } + if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) { + if (err instanceof ForbiddenError && audit.tenantId) { + try { + await writeAuditLog({ tenantId: audit.tenantId, actorId: audit.actorId, action: "denied", entity: audit.entity, entityId: audit.entityId, after: { reason: err.message } }); + } catch (auditErr) { + console.error("[work_orders] audit-write-failed", auditErr); + } + } + return { status: "error", code: "forbidden", message: "forbidden", at }; + } + console.error(`[work_orders] ${audit.entity} action failed`, err); + return { status: "error", code: "internal", message: "internal", at }; +} + +export const ok = (): ActionState => ({ status: "ok", at: Date.now() }); + +export function str(fd: FormData, key: string): string | undefined { + const v = fd.get(key); + if (typeof v !== "string") return undefined; + const t = v.trim(); + return t ? t : undefined; +} + +/** Present field → string or null (empty); absent field → undefined. */ +export function nullable(fd: FormData, key: string): string | null | undefined { + if (!fd.has(key)) return undefined; + return str(fd, key) ?? null; +} + +export function bool(fd: FormData, key: string): boolean { + const v = fd.get(key); + return v === "on" || v === "true" || v === "1"; +} + +/** "yes" | "no" | "" select → boolean | undefined */ +export function triState(fd: FormData, key: string): boolean | undefined { + const v = str(fd, key); + return v === "yes" ? true : v === "no" ? false : undefined; +} + +export function int(fd: FormData, key: string): number | undefined { + const v = str(fd, key); + if (v === undefined) return undefined; + const n = Number.parseInt(v, 10); + return Number.isFinite(n) ? n : undefined; +} + +/** datetime-local in tenant timezone → Date; empty → null; absent → undefined. */ +export function wallTime(fd: FormData, key: string, tz: string): Date | null | undefined { + if (!fd.has(key)) return undefined; + const v = str(fd, key); + if (!v) return null; + return wallTimeToUtc(v, tz) ?? null; +} + +/** Only internal detail links are allowed as return targets. */ +export function detailPath(workOrderId: string, tab?: string): string { + const safeTab = tab && /^[a-z]{3,12}$/.test(tab) ? `?tab=${tab}` : ""; + return `/work-orders/${encodeURIComponent(workOrderId)}${safeTab}`; +} diff --git a/src/server/actions/work_orders/settings.ts b/src/server/actions/work_orders/settings.ts new file mode 100644 index 0000000..2a1dbdc --- /dev/null +++ b/src/server/actions/work_orders/settings.ts @@ -0,0 +1,135 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import type { ActionState } from "@/lib/work-orders/action-state"; +import { slugKey } from "@/lib/work-orders/schemas"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard } from "@/server/services/context"; +import { uniqueKey } from "@/server/services/work-orders/_shared"; +import { + createChecklistTemplate, + createOrderType, + updateChecklistTemplate, + updateNumberSequence, + updateOrderType, +} from "@/server/services/work-orders/settings"; +import { bool, int, ok, str, toErrorState } from "./_form"; + +/** Settings of the work order module (order types, checklist templates, numbering) — `settings:templates`. */ +const guard = moduleGuard("work_orders"); + +export async function createOrderTypeAction(_prev: ActionState, fd: FormData): Promise { + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("settings:templates"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + const name = str(fd, "name") ?? ""; + await createOrderType(ctxFromGuard(g), { + key: str(fd, "key") ?? slugKey(name), + name, + signatureRequired: bool(fd, "signatureRequired"), + active: true, + sortOrder: int(fd, "sortOrder") ?? 100, + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "order_type" }); + } + revalidatePath("/settings/order-types"); + return ok(); +} + +export async function updateOrderTypeAction(_prev: ActionState, fd: FormData): Promise { + const id = str(fd, "id") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("settings:templates"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await updateOrderType(ctxFromGuard(g), id, { + name: str(fd, "name") ?? "", + signatureRequired: bool(fd, "signatureRequired"), + active: bool(fd, "active"), + sortOrder: int(fd, "sortOrder") ?? 100, + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "order_type", entityId: id }); + } + revalidatePath("/settings/order-types"); + return ok(); +} + +/** Textarea lines → template items: "*" prefix = required, "[Foto]" suffix = requires photo. */ +function parseItems(text: string) { + const keys = new Set(); + return text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .slice(0, 100) + .map((line) => { + let label = line; + const required = label.startsWith("*"); + if (required) label = label.slice(1).trim(); + const requiresPhoto = /\[foto\]$/i.test(label); + if (requiresPhoto) label = label.replace(/\[foto\]$/i, "").trim(); + return { key: uniqueKey(slugKey(label), keys), label: label.slice(0, 200), required, requiresPhoto }; + }) + .filter((i) => i.label.length > 0); +} + +function parsePhotos(text: string) { + const keys = new Set(); + return text + .split(/\r?\n/) + .map((l) => l.trim()) + .filter(Boolean) + .slice(0, 30) + .map((label) => ({ key: uniqueKey(slugKey(label), keys), label: label.slice(0, 200) })); +} + +export async function saveChecklistTemplateAction(_prev: ActionState, fd: FormData): Promise { + const id = str(fd, "id"); + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("settings:templates"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + const input = { + name: str(fd, "name") ?? "", + orderTypeId: str(fd, "orderTypeId") ?? null, + active: bool(fd, "active"), + items: parseItems(String(fd.get("items") ?? "")), + requiredPhotos: parsePhotos(String(fd.get("requiredPhotos") ?? "")), + }; + const ctx = ctxFromGuard(g); + if (id) await updateChecklistTemplate(ctx, id, input); + else await createChecklistTemplate(ctx, input); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "checklist_template", entityId: id }); + } + revalidatePath("/settings/checklists"); + return ok(); +} + +export async function updateNumberingAction(_prev: ActionState, fd: FormData): Promise { + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("settings:templates"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await updateNumberSequence(ctxFromGuard(g), { + key: str(fd, "key") ?? "", + prefix: String(fd.get("prefix") ?? "").trim(), + padding: str(fd, "padding") ?? "", + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "number_sequence" }); + } + revalidatePath("/settings/numbering"); + return ok(); +} diff --git a/src/server/actions/work_orders/work-orders.ts b/src/server/actions/work_orders/work-orders.ts new file mode 100644 index 0000000..0fe70f0 --- /dev/null +++ b/src/server/actions/work_orders/work-orders.ts @@ -0,0 +1,304 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import type { ActionState } from "@/lib/work-orders/action-state"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard } from "@/server/services/context"; +import { tenantTimezone } from "@/server/services/work-orders/_shared"; +import { assignWorkOrder } from "@/server/services/work-orders/assign"; +import { addChecklistItem, addPhotoRequirement, applyChecklistTemplate, removeChecklistItem, removePhotoRequirement } from "@/server/services/work-orders/checklist"; +import { applySyncConflict, discardSyncConflict } from "@/server/services/work-orders/conflicts"; +import { createWorkOrder } from "@/server/services/work-orders/create"; +import { archiveWorkOrderDocument } from "@/server/services/work-orders/documents"; +import { addMaterialPlan, removeMaterialPlan } from "@/server/services/work-orders/materials"; +import { transitionWorkOrder } from "@/server/services/work-orders/transition"; +import { updateWorkOrder } from "@/server/services/work-orders/update"; +import { bool, detailPath, int, nullable, ok, str, toErrorState, triState, wallTime } from "./_form"; + +/** + * Backoffice server actions of module "work_orders" — thin adapters: + * moduleGuard → FormData → service (permission, scope, Zod, audit, event) → revalidate/redirect. + */ +const guard = moduleGuard("work_orders"); + +export async function createWorkOrderAction(_prev: ActionState, fd: FormData): Promise { + let id: string; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + const ctx = ctxFromGuard(g); + const tz = await tenantTimezone(ctx); + const created = await createWorkOrder(ctx, { + title: str(fd, "title") ?? "", + customerId: str(fd, "customerId") ?? "", + siteId: str(fd, "siteId") ?? null, + contactId: str(fd, "contactId") ?? null, + orderTypeId: str(fd, "orderTypeId") ?? null, + priority: (str(fd, "priority") as never) ?? "normal", + status: str(fd, "status") === "planned" ? "planned" : "draft", + description: str(fd, "description"), + scope: str(fd, "scope"), + plannedStart: wallTime(fd, "plannedStart", tz) ?? null, + plannedEnd: wallTime(fd, "plannedEnd", tz) ?? null, + signatureRequired: triState(fd, "signatureRequired"), + billingType: (str(fd, "billingType") as never) ?? null, + internalNotes: str(fd, "internalNotes"), + technicianNotes: str(fd, "technicianNotes"), + externalOrderNumber: str(fd, "externalOrderNumber"), + offerNumber: str(fd, "offerNumber"), + }); + id = created.id; + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order" }); + } + revalidatePath("/work-orders"); + redirect(detailPath(id)); +} + +export async function updateWorkOrderAction(workOrderId: string, _prev: ActionState, fd: FormData): Promise { + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + const ctx = ctxFromGuard(g); + const tz = await tenantTimezone(ctx); + const patch: Record = {}; + for (const k of ["title", "priority", "billingType"]) if (fd.has(k)) patch[k] = str(fd, k) ?? null; + for (const k of ["siteId", "contactId", "orderTypeId", "description", "scope", "internalNotes", "technicianNotes", "externalOrderNumber", "offerNumber"]) { + const v = nullable(fd, k); + if (v !== undefined) patch[k] = v; + } + for (const k of ["plannedStart", "plannedEnd"]) { + const v = wallTime(fd, k, tz); + if (v !== undefined) patch[k] = v; + } + const signature = triState(fd, "signatureRequired"); + if (signature !== undefined) patch.signatureRequired = signature; + if (patch.title === null) patch.title = ""; + await updateWorkOrder(ctx, workOrderId, patch, int(fd, "baseVersion")); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + redirect(detailPath(workOrderId)); +} + +export async function transitionAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + // Permission depends on the concrete transition → decided by the service (mayTransition). + const g = await guard(); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await transitionWorkOrder(ctxFromGuard(g), { + workOrderId, + to: (str(fd, "to") ?? "") as never, + reason: str(fd, "reason") ?? null, + baseVersion: int(fd, "baseVersion"), + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath("/work-orders"); + redirect(detailPath(workOrderId, str(fd, "returnTab"))); +} + +export async function assignAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:assign"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await assignWorkOrder(ctxFromGuard(g), { + workOrderId, + teamId: str(fd, "teamId") ?? "", + userIds: fd.getAll("userIds").filter((v): v is string => typeof v === "string" && v.length > 0), + teamLeadUserId: str(fd, "teamLeadUserId") ?? null, + baseVersion: int(fd, "baseVersion"), + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath("/work-orders"); + redirect(detailPath(workOrderId)); +} + +export async function addMaterialPlanAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await addMaterialPlan(ctxFromGuard(g), workOrderId, { + name: str(fd, "name") ?? "", + articleNumber: str(fd, "articleNumber"), + plannedQuantity: (str(fd, "plannedQuantity") ?? "").replace(",", ".") as never, + unit: str(fd, "unit") ?? "", + notes: str(fd, "notes"), + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function removeMaterialPlanAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await removeMaterialPlan(ctxFromGuard(g), str(fd, "planId") ?? ""); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function addChecklistItemAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await addChecklistItem(ctxFromGuard(g), workOrderId, { + label: str(fd, "label") ?? "", + required: bool(fd, "required"), + requiresPhoto: bool(fd, "requiresPhoto"), + }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function removeChecklistItemAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await removeChecklistItem(ctxFromGuard(g), str(fd, "itemId") ?? ""); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function addPhotoRequirementAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await addPhotoRequirement(ctxFromGuard(g), workOrderId, { label: str(fd, "label") ?? "" }); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function removePhotoRequirementAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await removePhotoRequirement(ctxFromGuard(g), str(fd, "requirementId") ?? ""); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function applyChecklistTemplateAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await applyChecklistTemplate(ctxFromGuard(g), workOrderId, str(fd, "templateId") ?? ""); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "work_order", entityId: workOrderId }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function archiveDocumentAction(_prev: ActionState, fd: FormData): Promise { + const workOrderId = str(fd, "workOrderId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("document:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await archiveWorkOrderDocument(ctxFromGuard(g), str(fd, "documentId") ?? ""); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "document", entityId: str(fd, "documentId") }); + } + revalidatePath(`/work-orders/${workOrderId}`); + return ok(); +} + +export async function applySyncConflictAction(_prev: ActionState, fd: FormData): Promise { + const opId = str(fd, "opId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await applySyncConflict(ctxFromGuard(g), opId); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "sync_operation", entityId: opId }); + } + revalidatePath("/work-orders/conflicts"); + return ok(); +} + +export async function discardSyncConflictAction(_prev: ActionState, fd: FormData): Promise { + const opId = str(fd, "opId") ?? ""; + let tenantId: string | undefined; + let actorId: string | undefined; + try { + const g = await guard("work_order:write"); + tenantId = g.session.user.tenantId; + actorId = g.session.user.id; + await discardSyncConflict(ctxFromGuard(g), opId); + } catch (err) { + return toErrorState(err, { tenantId, actorId, entity: "sync_operation", entityId: opId }); + } + revalidatePath("/work-orders/conflicts"); + return ok(); +} From b96016d593dfd4a30723c19b174604217cc88c08 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:30:32 +0200 Subject: [PATCH 3/5] =?UTF-8?q?L2=20Auftr=C3=A4ge=20&=20Backoffice:=20Back?= =?UTF-8?q?office-UI=20(Liste,=20Detail,=20Konflikte,=20Dashboard,=20Suche?= =?UTF-8?q?,=20Einstellungen)=20+=20Texte=20de/en?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 --- messages/de/dashboard.json | 33 +- messages/de/search.json | 28 + messages/de/settingsTemplates.json | 73 +++ messages/de/workOrders.json | 445 ++++++++++++++ messages/en/dashboard.json | 33 +- messages/en/search.json | 28 + messages/en/settingsTemplates.json | 73 +++ messages/en/workOrders.json | 445 ++++++++++++++ src/app/(app)/dashboard/page.tsx | 180 +++++- src/app/(app)/search/page.tsx | 166 ++++++ src/app/(app)/settings/checklists/page.tsx | 122 ++++ src/app/(app)/settings/numbering/page.tsx | 44 ++ src/app/(app)/settings/order-types/page.tsx | 59 ++ src/app/(app)/work-orders/[id]/page.tsx | 296 +++++++++ src/app/(app)/work-orders/conflicts/page.tsx | 83 +++ src/app/(app)/work-orders/page.tsx | 354 ++++++++++- src/components/work-orders/action-form.tsx | 109 ++++ src/components/work-orders/detail-tabs.tsx | 562 ++++++++++++++++++ src/components/work-orders/page-context.ts | 21 + src/components/work-orders/settings-nav.tsx | 27 + src/components/work-orders/ui.tsx | 148 +++++ .../work-orders/work-order-fields.tsx | 144 +++++ .../work-orders/work-order-list.tsx | 106 ++++ 23 files changed, 3545 insertions(+), 34 deletions(-) create mode 100644 messages/de/search.json create mode 100644 messages/de/settingsTemplates.json create mode 100644 messages/de/workOrders.json create mode 100644 messages/en/search.json create mode 100644 messages/en/settingsTemplates.json create mode 100644 messages/en/workOrders.json create mode 100644 src/app/(app)/search/page.tsx create mode 100644 src/app/(app)/settings/checklists/page.tsx create mode 100644 src/app/(app)/settings/numbering/page.tsx create mode 100644 src/app/(app)/settings/order-types/page.tsx create mode 100644 src/app/(app)/work-orders/[id]/page.tsx create mode 100644 src/app/(app)/work-orders/conflicts/page.tsx create mode 100644 src/components/work-orders/action-form.tsx create mode 100644 src/components/work-orders/detail-tabs.tsx create mode 100644 src/components/work-orders/page-context.ts create mode 100644 src/components/work-orders/settings-nav.tsx create mode 100644 src/components/work-orders/ui.tsx create mode 100644 src/components/work-orders/work-order-fields.tsx create mode 100644 src/components/work-orders/work-order-list.tsx diff --git a/messages/de/dashboard.json b/messages/de/dashboard.json index 6d8db7b..33e7ba1 100644 --- a/messages/de/dashboard.json +++ b/messages/de/dashboard.json @@ -2,7 +2,34 @@ "title": "Dashboard", "crumb": "Übersicht", "subtitle": "Willkommen, {name} — {tenant}", - "placeholderTitle": "Dashboard in Umsetzung", - "placeholder": "Hier erscheinen künftig offene Aufträge, heutige Einsätze, freizugebende Berichte und Notdienst-Meldungen.", - "moduleDisabled": "Das angeforderte Modul ist für Ihren Betrieb nicht freigeschaltet." + "moduleDisabled": "Das angeforderte Modul ist für Ihren Betrieb nicht freigeschaltet.", + "workOrdersDisabled": "Das Modul Aufträge ist nicht freigeschaltet – es gibt keine Kennzahlen.", + "filterTitle": "Kennzahlen eingrenzen", + "tiles": { + "open": "Offene Aufträge", + "today": "Heutige Einsätze", + "running": "Laufende Einsätze", + "not_accepted": "Nicht angenommen", + "overdue": "Überfällig", + "reports_in_review": "Berichte zur Prüfung", + "completed": "Abgeschlossen", + "billing": "Zur Abrechnung", + "emergency_new": "Neue Notdienste", + "missing_signatures": "Fehlende Unterschriften", + "sync_conflicts": "Sync-Konflikte" + }, + "hints": { + "open": "Noch nicht abgerechnet oder storniert", + "today": "Für heute geplant", + "running": "Unterwegs, in Arbeit, pausiert", + "not_accepted": "Zugewiesen, noch nicht angenommen", + "overdue": "Geplantes Ende überschritten", + "reports_in_review": "Eingereicht, noch nicht freigegeben", + "completed": "Technisch abgeschlossen oder weiter", + "billing": "Bereit zur Abrechnung", + "emergency_new": "Neu oder zur Prüfung", + "missing_signatures": "Unterschrift ausstehend", + "sync_conflicts": "Offline-Änderungen prüfen" + }, + "open": "Anzeigen" } diff --git a/messages/de/search.json b/messages/de/search.json new file mode 100644 index 0000000..8874899 --- /dev/null +++ b/messages/de/search.json @@ -0,0 +1,28 @@ +{ + "crumb": "Suche", + "title": "Suche", + "placeholder": "Aufträge, Kunden, Objekte, Adressen, Dokumente, Notizen", + "submit": "Suchen", + "hint": "Mindestens 2 Zeichen eingeben.", + "noResults": "Keine Treffer für „{q}“.", + "resultsFor": "Treffer für „{q}“", + "filters": { + "from": "Von", + "to": "Bis", + "status": "Status", + "team": "Team", + "any": "Alle", + "apply": "Filtern", + "reset": "Filter zurücksetzen", + "note": "Zeitraum, Status und Team grenzen Aufträge und alles am Auftrag ein." + }, + "sections": { + "workOrders": "Aufträge", + "customers": "Kunden", + "sites": "Objekte", + "contacts": "Ansprechpartner", + "documents": "Dokumente", + "notes": "Notizen" + }, + "maxHint": "Es werden höchstens 20 Treffer je Bereich angezeigt." +} diff --git a/messages/de/settingsTemplates.json b/messages/de/settingsTemplates.json new file mode 100644 index 0000000..d84479c --- /dev/null +++ b/messages/de/settingsTemplates.json @@ -0,0 +1,73 @@ +{ + "crumb": "Einstellungen", + "nav": { + "orderTypes": "Auftragsarten", + "checklists": "Checklisten-Vorlagen", + "numbering": "Nummernkreise" + }, + "orderTypes": { + "title": "Auftragsarten", + "sub": "Auftragsarten je Betrieb – mit Vorgabe, ob eine Kundenunterschrift nötig ist", + "key": "Schlüssel", + "name": "Bezeichnung", + "signatureRequired": "Unterschrift erforderlich", + "active": "Aktiv", + "sortOrder": "Reihenfolge", + "create": "Auftragsart anlegen", + "save": "Speichern", + "keyHint": "Kleinbuchstaben, Ziffern, Unterstrich – nicht änderbar", + "inactive": "Inaktiv", + "yes": "Ja", + "no": "Nein" + }, + "checklists": { + "title": "Checklisten-Vorlagen", + "sub": "Vorlagen je Auftragsart – werden beim Anlegen eines Auftrags übernommen", + "empty": "Noch keine Vorlagen.", + "new": "Neue Vorlage", + "edit": "Bearbeiten", + "name": "Name", + "orderType": "Auftragsart", + "noOrderType": "— keine —", + "active": "Aktiv", + "items": "Checklistenpunkte", + "itemsHint": "Eine Zeile je Punkt. Zusätze: „*“ am Anfang = Pflicht, „[Foto]“ am Ende = mit Foto.", + "photos": "Pflichtfotos", + "photosHint": "Eine Zeile je Pflichtfoto.", + "suggestion": "Standardvorschlag einfügen", + "suggestionHint": "Vorschlag aus den Standardpunkten; vor dem Speichern anpassen.", + "save": "Vorlage speichern", + "itemCount": "{count, plural, =0 {Keine Punkte} one {# Punkt} other {# Punkte}}", + "photoCount": "{count, plural, =0 {keine Pflichtfotos} one {# Pflichtfoto} other {# Pflichtfotos}}", + "inactive": "Inaktiv", + "photoSuffix": "[Foto]" + }, + "numbering": { + "title": "Nummernkreise", + "sub": "Präfix und Stellenzahl der fortlaufenden Nummern. Der Zähler läuft immer weiter.", + "key": "Nummernkreis", + "prefix": "Präfix", + "padding": "Stellen", + "next": "Nächste Nummer", + "save": "Speichern", + "keys": { + "work_order": "Aufträge", + "emergency": "Notdienste", + "customer": "Kunden", + "report": "Berichte" + } + }, + "saved": "Gespeichert.", + "errors": { + "forbidden": "Dafür fehlt die Berechtigung.", + "invalid": "Eingaben prüfen.", + "not_found": "Nicht gefunden.", + "internal": "Vorgang nicht möglich.", + "validation_failed": "Pflichtfelder prüfen.", + "order_type_key_taken": "Dieser Schlüssel ist bereits vergeben.", + "order_type_not_found": "Auftragsart nicht gefunden.", + "template_not_found": "Vorlage nicht gefunden.", + "duplicate_item_key": "Zwei Checklistenpunkte haben dieselbe Bezeichnung.", + "duplicate_photo_key": "Zwei Pflichtfotos haben dieselbe Bezeichnung." + } +} diff --git a/messages/de/workOrders.json b/messages/de/workOrders.json new file mode 100644 index 0000000..d25a8c0 --- /dev/null +++ b/messages/de/workOrders.json @@ -0,0 +1,445 @@ +{ + "crumb": "Aufträge", + "title": "Aufträge", + "subtitle": "Planen, zuweisen, prüfen und zur Abrechnung freigeben", + "new": "Neuer Auftrag", + "conflictsLink": "Sync-Konflikte", + "allGroups": "Alle", + "empty": "Keine Aufträge für diese Auswahl.", + "resultCount": "{count, plural, =0 {Keine Aufträge} one {# Auftrag} other {# Aufträge}}", + "presetActive": "Kachel-Filter: {name}", + "removePreset": "Kachel-Filter entfernen", + "view": { "cards": "Karten", "table": "Tabelle" }, + "pagination": { "prev": "Zurück", "next": "Weiter", "page": "Seite {page} von {pages}" }, + "filter": { + "title": "Filter", + "q": "Nummer, Titel, Kunde, Ort", + "from": "Von", + "to": "Bis", + "customer": "Kunde", + "site": "Objekt", + "team": "Team", + "user": "Monteur", + "status": "Status", + "orderType": "Auftragsart", + "priority": "Priorität", + "sort": "Sortierung", + "any": "Alle", + "apply": "Filtern", + "reset": "Zurücksetzen" + }, + "sort": { + "plannedStart": "Geplanter Beginn", + "createdAt": "Angelegt", + "updatedAt": "Zuletzt geändert", + "number": "Nummer", + "priority": "Priorität", + "status": "Status", + "asc": "aufsteigend", + "desc": "absteigend" + }, + "columns": { + "number": "Nummer", + "title": "Titel", + "customer": "Kunde", + "site": "Objekt", + "planned": "Geplant", + "team": "Team", + "status": "Status", + "priority": "Priorität" + }, + "status": { + "draft": "Entwurf", + "review_required": "Prüfung erforderlich", + "planned": "Geplant", + "assigned": "Zugewiesen", + "accepted": "Angenommen", + "en_route": "In Anfahrt", + "in_progress": "In Bearbeitung", + "paused": "Pausiert", + "waiting_material": "Wartet auf Material", + "daily_report_created": "Tagesbericht erstellt", + "technically_completed": "Technisch abgeschlossen", + "signature_pending": "Unterschrift ausstehend", + "in_review": "Zur Prüfung", + "released_for_billing": "Zur Abrechnung freigegeben", + "billed": "Abgerechnet", + "cancelled": "Storniert" + }, + "statusGroup": { + "new": "Neu", + "planned": "Geplant", + "en_route": "Unterwegs", + "in_progress": "In Arbeit", + "documentation_incomplete": "Dokumentation unvollständig", + "in_review": "Zur Prüfung", + "ready_for_billing": "Bereit zur Abrechnung", + "billed": "Abgerechnet", + "cancelled": "Storniert" + }, + "priority": { "low": "Niedrig", "normal": "Normal", "high": "Hoch", "urgent": "Dringend" }, + "billingType": { + "fixed": "Pauschal", + "time_material": "Nach Aufwand", + "maintenance_contract": "Wartungsvertrag", + "warranty": "Gewährleistung" + }, + "emergency": "Notdienst", + "unassigned": "Nicht zugewiesen", + "noDate": "Ohne Termin", + "create": { + "title": "Neuer Auftrag", + "sub": "Kunde wählen, Auftrag erfassen", + "stepCustomer": "1. Kunde suchen", + "customerSearch": "Firma, Name, Kundennummer oder Ort", + "searchButton": "Suchen", + "noCustomers": "Kein Kunde gefunden. Kunden werden unter „Kunden“ angelegt.", + "chooseCustomer": "Auswählen", + "changeCustomer": "Anderen Kunden wählen", + "stepDetails": "2. Auftrag erfassen", + "submit": "Auftrag anlegen", + "pending": "Lege an…" + }, + "fields": { + "title": "Titel", + "customer": "Kunde", + "site": "Objekt", + "contact": "Ansprechpartner", + "none": "— keine Auswahl —", + "orderType": "Auftragsart", + "priority": "Priorität", + "plannedStart": "Geplanter Beginn", + "plannedEnd": "Geplantes Ende", + "description": "Beschreibung", + "scope": "Leistungsumfang", + "internalNotes": "Hinweise intern (nur Büro)", + "technicianNotes": "Hinweise für Monteure", + "signatureRequired": "Unterschrift erforderlich", + "signatureFromType": "Wie Auftragsart", + "yes": "Ja", + "no": "Nein", + "billingType": "Abrechnungsart", + "externalOrderNumber": "Externe Auftragsnummer", + "offerNumber": "Angebotsnummer", + "status": "Anlegen als", + "timezoneHint": "Zeiten in {tz}" + }, + "detail": { + "back": "Zurück zu Aufträgen", + "version": "Version {version}", + "nextStep": "Nächster Schritt", + "moreActions": "Weitere Statusänderungen", + "edit": "Bearbeiten", + "assign": "Zuweisen", + "reassign": "Zuweisung ändern", + "cancel": "Stornieren", + "noActions": "Für Sie ist gerade keine Statusänderung möglich.", + "blockersTitle": "Vor dem Abschluss fehlt noch:", + "tabs": { + "overview": "Übersicht", + "checklist": "Checkliste & Pflichtfotos", + "material": "Material", + "times": "Zeiten", + "photos": "Fotos", + "notes": "Notizen", + "reports": "Berichte", + "documents": "Dokumente", + "history": "Verlauf" + } + }, + "overview": { + "customer": "Kunde", + "site": "Objekt", + "contact": "Ansprechpartner", + "planning": "Planung", + "assignment": "Zuweisung", + "team": "Team", + "teamLead": "Teamleiter", + "assignees": "Monteure", + "order": "Auftrag", + "created": "Angelegt", + "updated": "Geändert", + "accessNotes": "Zugang", + "safetyNotes": "Sicherheit" + }, + "checklist": { + "items": "Checkliste", + "photos": "Pflichtfotos", + "empty": "Keine Checklistenpunkte.", + "emptyPhotos": "Keine Pflichtfotos.", + "required": "Pflicht", + "optional": "Optional", + "requiresPhoto": "mit Foto", + "done": "Erledigt", + "open": "Offen", + "photoCount": "{count, plural, =0 {Kein Foto} one {# Foto} other {# Fotos}}", + "addItem": "Punkt hinzufügen", + "addPhoto": "Pflichtfoto hinzufügen", + "label": "Bezeichnung", + "applyTemplate": "Vorlage übernehmen", + "template": "Vorlage", + "applyButton": "Übernehmen", + "remove": "Entfernen", + "complete": "Alle Abschluss-Voraussetzungen erfüllt." + }, + "material": { + "title": "Material – geplant und tatsächlich", + "empty": "Kein Material geplant oder erfasst.", + "name": "Bezeichnung", + "articleNumber": "Artikelnummer", + "planned": "Geplant", + "actual": "Tatsächlich", + "deviation": "Abweichung", + "unit": "Einheit", + "notes": "Bemerkung", + "reason": "Begründung", + "additional": "Zusätzlich", + "notRecorded": "Noch nicht erfasst", + "add": "Material planen", + "remove": "Entfernen", + "usage": { + "fully_used": "Vollständig verwendet", + "partially_used": "Teilweise verwendet", + "not_used": "Nicht verwendet", + "additional": "Zusätzlich verwendet" + } + }, + "times": { + "empty": "Noch keine Zeiten erfasst.", + "person": "Person", + "start": "Beginn", + "end": "Ende", + "duration": "Arbeitszeit", + "minutes": "{minutes} min", + "corrected": "Korrigiert: {reason}", + "sessionStatus": { + "en_route": "Unterwegs", + "running": "Läuft", + "paused": "Pausiert", + "ended": "Beendet" + }, + "entryType": { + "travel": "Anfahrt", + "work": "Arbeit", + "break": "Pause", + "material_procurement": "Materialbeschaffung", + "return_travel": "Rückfahrt", + "interruption": "Unterbrechung" + } + }, + "photos": { + "empty": "Noch keine Fotos.", + "phase": { "before": "Vorher", "during": "Während", "after": "Nachher" }, + "open": "Öffnen", + "inReport": "Im Bericht" + }, + "notes": { + "empty": "Keine Notizen.", + "internal": "Hinweise intern", + "technician": "Hinweise für Monteure", + "kind": { + "work_done": "Ausgeführt", + "deviation": "Abweichung", + "problem": "Problem", + "additional_work": "Zusatzarbeit", + "not_executable": "Nicht ausführbar", + "follow_up": "Folgearbeit", + "recommendation": "Empfehlung", + "customer_note": "Kundenhinweis", + "general": "Allgemein" + } + }, + "reports": { + "empty": "Noch keine Berichte.", + "type": { "daily": "Tagesbericht", "completion": "Abschlussbericht" }, + "status": { + "draft": "Entwurf", + "submitted": "Zur Prüfung", + "team_approved": "Vom Teamleiter freigegeben", + "approved": "Freigegeben", + "rejected": "Zurückgewiesen", + "superseded": "Ersetzt" + }, + "open": "Bericht öffnen", + "signature": "Unterschrift: {outcome}", + "signatureOutcome": { + "signed": "unterschrieben", + "customer_absent": "Kunde nicht anwesend", + "refused": "verweigert", + "later": "später", + "not_required": "nicht erforderlich" + }, + "billingTitle": "Abrechnung", + "billingHint": "Freigabe nur mit freigegebenem Abschlussbericht.", + "release": "Zur Abrechnung freigeben", + "reject": "Zur Korrektur zurückweisen", + "markBilled": "Als abgerechnet markieren", + "revoke": "Freigabe zurücknehmen" + }, + "documents": { + "empty": "Keine Dokumente am Auftrag.", + "upload": "Dokument hochladen", + "file": "Datei (PDF, JPG, PNG, WebP)", + "title": "Titel (optional)", + "category": "Kategorie", + "visibility": "Sichtbarkeit", + "submit": "Hochladen", + "archive": "Entfernen", + "download": "Herunterladen", + "stubHint": "Dateien werden bis zur Anbindung der Dokumentenablage direkt im Objektspeicher abgelegt.", + "categories": { + "order_confirmation": "Auftragsbestätigung", + "technical_drawing": "Technische Zeichnung", + "floor_plan": "Grundriss", + "wiring_diagram": "Schaltplan", + "assembly_instructions": "Montageanleitung", + "safety_document": "Sicherheitsdokument", + "product_document": "Produktunterlage", + "customer_note": "Kundennotiz", + "work_record": "Arbeitsnachweis", + "daily_report": "Tagesbericht", + "completion_report": "Abschlussbericht", + "customer_approval": "Kundenfreigabe", + "photo": "Foto", + "voice_note": "Sprachnotiz", + "signature": "Unterschrift", + "other": "Sonstiges" + }, + "visibilities": { + "backoffice_only": "Nur Büro", + "team_lead": "Teamleiter", + "team": "Team", + "customer_report": "Kundenbericht" + } + }, + "history": { + "statusChanges": "Statusänderungen", + "audit": "Protokoll", + "empty": "Noch keine Einträge.", + "created": "Angelegt als {status}", + "change": "{from} → {to}", + "by": "von {name}", + "system": "System", + "action": { "create": "Erstellt", "update": "Geändert", "delete": "Gelöscht" } + }, + "assign": { + "title": "Auftrag zuweisen", + "sub": "Team wählen, optional einzelne Monteure und Teamleiter", + "team": "Team", + "chooseTeam": "— Team wählen —", + "members": "Einzelne Monteure (optional)", + "teamLead": "Zuständiger Teamleiter", + "teamLeadDefault": "Teamleiter des Teams", + "noTeams": "Keine aktiven Teams. Teams werden unter „Teams“ angelegt.", + "submit": "Zuweisen", + "pending": "Weise zu…" + }, + "transition": { + "to": { + "draft": "Als Entwurf", + "review_required": "Zur Prüfung markieren", + "planned": "Einplanen", + "assigned": "Zuweisen", + "accepted": "Annehmen", + "en_route": "Anfahrt starten", + "in_progress": "Arbeit aufnehmen", + "paused": "Pausieren", + "waiting_material": "Wartet auf Material", + "daily_report_created": "Tagesbericht erstellt", + "technically_completed": "Technisch abschließen", + "signature_pending": "Unterschrift ausstehend", + "in_review": "Zur Prüfung geben", + "released_for_billing": "Zur Abrechnung freigeben", + "billed": "Als abgerechnet markieren", + "cancelled": "Stornieren" + }, + "correction": "Zur Korrektur zurückweisen", + "revoke": "Freigabe zurücknehmen", + "reasonTitle": "Begründung erforderlich", + "reason": "Begründung", + "reasonPlaceholder": "Kurz beschreiben, warum", + "confirm": "Bestätigen", + "pending": "Speichere…", + "done": "Status geändert." + }, + "edit": { + "title": "Auftrag bearbeiten", + "submit": "Speichern", + "pending": "Speichere…", + "saved": "Gespeichert." + }, + "conflicts": { + "crumb": "Aufträge", + "title": "Sync-Konflikte", + "sub": "Offline erfasste Änderungen, die nicht automatisch übernommen werden konnten", + "empty": "Keine offenen Konflikte.", + "received": "Eingegangen", + "user": "Gerät / Person", + "operation": "Vorgang", + "entity": "Auftrag", + "baseVersion": "Stand Gerät: Version {base} · aktuell: Version {current}", + "payload": "Inhalt", + "apply": "Übernehmen", + "discard": "Verwerfen", + "applied": "Übernommen.", + "discarded": "Verworfen.", + "applyHint": "Übernehmen wendet den Vorgang erneut auf den aktuellen Stand an – im Namen der Person, die ihn erfasst hat.", + "stubHint": "Übernehmen ist bis zur Anbindung der Synchronisation nur für Statusänderungen möglich." + }, + "errors": { + "not_found": "Nicht gefunden oder keine Berechtigung.", + "forbidden": "Dafür fehlt die Berechtigung.", + "invalid": "Eingaben prüfen.", + "conflict": "Der Auftrag wurde inzwischen geändert. Seite neu laden und erneut versuchen.", + "blocked": "Noch nicht möglich – es fehlen Angaben.", + "internal": "Vorgang nicht möglich.", + "validation_failed": "Pflichtfelder prüfen.", + "work_order_not_found": "Auftrag nicht gefunden.", + "customer_not_found": "Kunde nicht gefunden.", + "site_not_found": "Objekt nicht gefunden.", + "contact_not_found": "Ansprechpartner nicht gefunden.", + "site_customer_mismatch": "Das Objekt gehört zu einem anderen Kunden.", + "contact_customer_mismatch": "Der Ansprechpartner gehört zu einem anderen Kunden.", + "order_type_not_found": "Auftragsart nicht gefunden.", + "planned_end_before_start": "Das Ende liegt vor dem Beginn.", + "nothing_to_update": "Keine Änderungen.", + "not_editable": "Der Auftrag kann in diesem Status nicht mehr geändert werden.", + "delete_only_draft": "Nur Entwürfe können gelöscht werden.", + "transition_not_allowed": "Dieser Statuswechsel ist nicht vorgesehen.", + "transition_forbidden": "Diesen Statuswechsel dürfen Sie nicht ausführen.", + "transition_blocked": "Noch nicht möglich – es fehlen Angaben.", + "reason_required": "Bitte eine Begründung angeben.", + "version_conflict": "Der Auftrag wurde inzwischen geändert. Seite neu laden und erneut versuchen.", + "team_not_found": "Team nicht gefunden oder nicht aktiv.", + "user_not_found": "Mindestens eine Person ist nicht aktiv.", + "material_plan_not_found": "Materialposition nicht gefunden.", + "material_in_use": "Zu dieser Position wurde bereits Material erfasst.", + "checklist_item_not_found": "Checklistenpunkt nicht gefunden.", + "checklist_item_checked": "Erledigte Punkte bleiben als Nachweis erhalten.", + "photo_requirement_not_found": "Pflichtfoto nicht gefunden.", + "photo_requirement_has_photos": "Zu diesem Pflichtfoto gibt es bereits Fotos.", + "template_not_found": "Vorlage nicht gefunden.", + "sync_conflict_not_found": "Konflikt nicht gefunden oder bereits erledigt.", + "sync_conflict_already_resolved": "Konflikt wurde bereits erledigt.", + "sync_payload_invalid": "Der Vorgang ist unvollständig.", + "sync_user_inactive": "Die Person ist nicht mehr aktiv.", + "reapply_unsupported": "Dieser Vorgang kann noch nicht übernommen werden.", + "document_not_found": "Dokument nicht gefunden.", + "file_type_not_allowed": "Dateityp nicht erlaubt.", + "file_too_large": "Datei ist leer oder zu groß.", + "file_missing": "Bitte eine Datei wählen.", + "category_invalid": "Kategorie nicht erlaubt.", + "visibility_forbidden": "Diese Sichtbarkeit dürfen Sie nicht vergeben.", + "initial_status_not_allowed": "Dieser Anfangsstatus ist nicht erlaubt." + }, + "blockers": { + "checklist_item": "Checkliste: {label}", + "photo_requirement": "Pflichtfoto: {label}", + "running_session": "Ein Einsatz läuft noch", + "field": { + "signature": "Unterschrift fehlt", + "approved_completion_report": "Abschlussbericht ist noch nicht freigegeben" + } + }, + "saved": "Gespeichert." +} diff --git a/messages/en/dashboard.json b/messages/en/dashboard.json index 01f1e6c..eee1199 100644 --- a/messages/en/dashboard.json +++ b/messages/en/dashboard.json @@ -2,7 +2,34 @@ "title": "Dashboard", "crumb": "Overview", "subtitle": "Welcome, {name} — {tenant}", - "placeholderTitle": "Dashboard under construction", - "placeholder": "Open work orders, today's jobs, reports awaiting approval and emergency calls will appear here.", - "moduleDisabled": "The requested module is not enabled for your company." + "moduleDisabled": "The requested module is not enabled for your company.", + "workOrdersDisabled": "The work orders module is not enabled – there are no figures.", + "filterTitle": "Narrow down figures", + "tiles": { + "open": "Open work orders", + "today": "Today's jobs", + "running": "Running jobs", + "not_accepted": "Not accepted", + "overdue": "Overdue", + "reports_in_review": "Reports to review", + "completed": "Completed", + "billing": "Ready for billing", + "emergency_new": "New emergencies", + "missing_signatures": "Missing signatures", + "sync_conflicts": "Sync conflicts" + }, + "hints": { + "open": "Not yet billed or cancelled", + "today": "Planned for today", + "running": "En route, in progress, paused", + "not_accepted": "Assigned, not yet accepted", + "overdue": "Planned end exceeded", + "reports_in_review": "Submitted, not yet approved", + "completed": "Technically completed or beyond", + "billing": "Ready to invoice", + "emergency_new": "New or in review", + "missing_signatures": "Signature pending", + "sync_conflicts": "Review offline changes" + }, + "open": "Show" } diff --git a/messages/en/search.json b/messages/en/search.json new file mode 100644 index 0000000..548274e --- /dev/null +++ b/messages/en/search.json @@ -0,0 +1,28 @@ +{ + "crumb": "Search", + "title": "Search", + "placeholder": "Work orders, customers, sites, addresses, documents, notes", + "submit": "Search", + "hint": "Enter at least 2 characters.", + "noResults": "No results for “{q}”.", + "resultsFor": "Results for “{q}”", + "filters": { + "from": "From", + "to": "To", + "status": "Status", + "team": "Team", + "any": "All", + "apply": "Apply", + "reset": "Reset filters", + "note": "Period, status and team narrow work orders and everything attached to them." + }, + "sections": { + "workOrders": "Work orders", + "customers": "Customers", + "sites": "Sites", + "contacts": "Contact persons", + "documents": "Documents", + "notes": "Notes" + }, + "maxHint": "At most 20 results per section are shown." +} diff --git a/messages/en/settingsTemplates.json b/messages/en/settingsTemplates.json new file mode 100644 index 0000000..a9daf08 --- /dev/null +++ b/messages/en/settingsTemplates.json @@ -0,0 +1,73 @@ +{ + "crumb": "Settings", + "nav": { + "orderTypes": "Order types", + "checklists": "Checklist templates", + "numbering": "Number sequences" + }, + "orderTypes": { + "title": "Order types", + "sub": "Order types per company – including whether a customer signature is required", + "key": "Key", + "name": "Name", + "signatureRequired": "Signature required", + "active": "Active", + "sortOrder": "Order", + "create": "Create order type", + "save": "Save", + "keyHint": "Lower-case letters, digits, underscore – cannot be changed", + "inactive": "Inactive", + "yes": "Yes", + "no": "No" + }, + "checklists": { + "title": "Checklist templates", + "sub": "Templates per order type – applied when a work order is created", + "empty": "No templates yet.", + "new": "New template", + "edit": "Edit", + "name": "Name", + "orderType": "Order type", + "noOrderType": "— none —", + "active": "Active", + "items": "Checklist items", + "itemsHint": "One line per item. Prefix “*” = required, suffix “[Foto]” = with photo.", + "photos": "Required photos", + "photosHint": "One line per required photo.", + "suggestion": "Insert default suggestion", + "suggestionHint": "Suggestion from the default items; adjust before saving.", + "save": "Save template", + "itemCount": "{count, plural, =0 {No items} one {# item} other {# items}}", + "photoCount": "{count, plural, =0 {no required photos} one {# required photo} other {# required photos}}", + "inactive": "Inactive", + "photoSuffix": "[Foto]" + }, + "numbering": { + "title": "Number sequences", + "sub": "Prefix and number of digits of running numbers. The counter always continues.", + "key": "Sequence", + "prefix": "Prefix", + "padding": "Digits", + "next": "Next number", + "save": "Save", + "keys": { + "work_order": "Work orders", + "emergency": "Emergencies", + "customer": "Customers", + "report": "Reports" + } + }, + "saved": "Saved.", + "errors": { + "forbidden": "You lack the permission for this.", + "invalid": "Please check your input.", + "not_found": "Not found.", + "internal": "Operation not possible.", + "validation_failed": "Please check required fields.", + "order_type_key_taken": "This key is already taken.", + "order_type_not_found": "Order type not found.", + "template_not_found": "Template not found.", + "duplicate_item_key": "Two checklist items have the same label.", + "duplicate_photo_key": "Two required photos have the same label." + } +} diff --git a/messages/en/workOrders.json b/messages/en/workOrders.json new file mode 100644 index 0000000..bdeed36 --- /dev/null +++ b/messages/en/workOrders.json @@ -0,0 +1,445 @@ +{ + "crumb": "Work orders", + "title": "Work orders", + "subtitle": "Plan, assign, review and release for billing", + "new": "New work order", + "conflictsLink": "Sync conflicts", + "allGroups": "All", + "empty": "No work orders for this selection.", + "resultCount": "{count, plural, =0 {No work orders} one {# work order} other {# work orders}}", + "presetActive": "Tile filter: {name}", + "removePreset": "Remove tile filter", + "view": { "cards": "Cards", "table": "Table" }, + "pagination": { "prev": "Previous", "next": "Next", "page": "Page {page} of {pages}" }, + "filter": { + "title": "Filter", + "q": "Number, title, customer, city", + "from": "From", + "to": "To", + "customer": "Customer", + "site": "Site", + "team": "Team", + "user": "Technician", + "status": "Status", + "orderType": "Order type", + "priority": "Priority", + "sort": "Sort", + "any": "All", + "apply": "Apply", + "reset": "Reset" + }, + "sort": { + "plannedStart": "Planned start", + "createdAt": "Created", + "updatedAt": "Last changed", + "number": "Number", + "priority": "Priority", + "status": "Status", + "asc": "ascending", + "desc": "descending" + }, + "columns": { + "number": "Number", + "title": "Title", + "customer": "Customer", + "site": "Site", + "planned": "Planned", + "team": "Team", + "status": "Status", + "priority": "Priority" + }, + "status": { + "draft": "Draft", + "review_required": "Review required", + "planned": "Planned", + "assigned": "Assigned", + "accepted": "Accepted", + "en_route": "En route", + "in_progress": "In progress", + "paused": "Paused", + "waiting_material": "Waiting for material", + "daily_report_created": "Daily report created", + "technically_completed": "Technically completed", + "signature_pending": "Signature pending", + "in_review": "In review", + "released_for_billing": "Released for billing", + "billed": "Billed", + "cancelled": "Cancelled" + }, + "statusGroup": { + "new": "New", + "planned": "Planned", + "en_route": "En route", + "in_progress": "In progress", + "documentation_incomplete": "Documentation incomplete", + "in_review": "In review", + "ready_for_billing": "Ready for billing", + "billed": "Billed", + "cancelled": "Cancelled" + }, + "priority": { "low": "Low", "normal": "Normal", "high": "High", "urgent": "Urgent" }, + "billingType": { + "fixed": "Fixed price", + "time_material": "Time and material", + "maintenance_contract": "Maintenance contract", + "warranty": "Warranty" + }, + "emergency": "Emergency", + "unassigned": "Not assigned", + "noDate": "No date", + "create": { + "title": "New work order", + "sub": "Choose a customer, enter the order", + "stepCustomer": "1. Find customer", + "customerSearch": "Company, name, customer number or city", + "searchButton": "Search", + "noCustomers": "No customer found. Customers are created under “Customers”.", + "chooseCustomer": "Select", + "changeCustomer": "Choose another customer", + "stepDetails": "2. Enter order", + "submit": "Create work order", + "pending": "Creating…" + }, + "fields": { + "title": "Title", + "customer": "Customer", + "site": "Site", + "contact": "Contact person", + "none": "— none —", + "orderType": "Order type", + "priority": "Priority", + "plannedStart": "Planned start", + "plannedEnd": "Planned end", + "description": "Description", + "scope": "Scope of work", + "internalNotes": "Internal notes (office only)", + "technicianNotes": "Notes for technicians", + "signatureRequired": "Signature required", + "signatureFromType": "As order type", + "yes": "Yes", + "no": "No", + "billingType": "Billing type", + "externalOrderNumber": "External order number", + "offerNumber": "Offer number", + "status": "Create as", + "timezoneHint": "Times in {tz}" + }, + "detail": { + "back": "Back to work orders", + "version": "Version {version}", + "nextStep": "Next step", + "moreActions": "Other status changes", + "edit": "Edit", + "assign": "Assign", + "reassign": "Change assignment", + "cancel": "Cancel order", + "noActions": "No status change is available for you right now.", + "blockersTitle": "Still missing before completion:", + "tabs": { + "overview": "Overview", + "checklist": "Checklist & required photos", + "material": "Material", + "times": "Times", + "photos": "Photos", + "notes": "Notes", + "reports": "Reports", + "documents": "Documents", + "history": "History" + } + }, + "overview": { + "customer": "Customer", + "site": "Site", + "contact": "Contact person", + "planning": "Planning", + "assignment": "Assignment", + "team": "Team", + "teamLead": "Team lead", + "assignees": "Technicians", + "order": "Order", + "created": "Created", + "updated": "Changed", + "accessNotes": "Access", + "safetyNotes": "Safety" + }, + "checklist": { + "items": "Checklist", + "photos": "Required photos", + "empty": "No checklist items.", + "emptyPhotos": "No required photos.", + "required": "Required", + "optional": "Optional", + "requiresPhoto": "with photo", + "done": "Done", + "open": "Open", + "photoCount": "{count, plural, =0 {No photo} one {# photo} other {# photos}}", + "addItem": "Add item", + "addPhoto": "Add required photo", + "label": "Label", + "applyTemplate": "Apply template", + "template": "Template", + "applyButton": "Apply", + "remove": "Remove", + "complete": "All completion requirements met." + }, + "material": { + "title": "Material – planned and actual", + "empty": "No material planned or recorded.", + "name": "Name", + "articleNumber": "Article number", + "planned": "Planned", + "actual": "Actual", + "deviation": "Deviation", + "unit": "Unit", + "notes": "Remark", + "reason": "Reason", + "additional": "Additional", + "notRecorded": "Not recorded yet", + "add": "Plan material", + "remove": "Remove", + "usage": { + "fully_used": "Fully used", + "partially_used": "Partially used", + "not_used": "Not used", + "additional": "Additionally used" + } + }, + "times": { + "empty": "No times recorded yet.", + "person": "Person", + "start": "Start", + "end": "End", + "duration": "Working time", + "minutes": "{minutes} min", + "corrected": "Corrected: {reason}", + "sessionStatus": { + "en_route": "En route", + "running": "Running", + "paused": "Paused", + "ended": "Ended" + }, + "entryType": { + "travel": "Travel", + "work": "Work", + "break": "Break", + "material_procurement": "Material procurement", + "return_travel": "Return travel", + "interruption": "Interruption" + } + }, + "photos": { + "empty": "No photos yet.", + "phase": { "before": "Before", "during": "During", "after": "After" }, + "open": "Open", + "inReport": "In report" + }, + "notes": { + "empty": "No notes.", + "internal": "Internal notes", + "technician": "Notes for technicians", + "kind": { + "work_done": "Work done", + "deviation": "Deviation", + "problem": "Problem", + "additional_work": "Additional work", + "not_executable": "Not executable", + "follow_up": "Follow-up", + "recommendation": "Recommendation", + "customer_note": "Customer note", + "general": "General" + } + }, + "reports": { + "empty": "No reports yet.", + "type": { "daily": "Daily report", "completion": "Completion report" }, + "status": { + "draft": "Draft", + "submitted": "In review", + "team_approved": "Approved by team lead", + "approved": "Approved", + "rejected": "Rejected", + "superseded": "Superseded" + }, + "open": "Open report", + "signature": "Signature: {outcome}", + "signatureOutcome": { + "signed": "signed", + "customer_absent": "customer absent", + "refused": "refused", + "later": "later", + "not_required": "not required" + }, + "billingTitle": "Billing", + "billingHint": "Release requires an approved completion report.", + "release": "Release for billing", + "reject": "Send back for correction", + "markBilled": "Mark as billed", + "revoke": "Revoke release" + }, + "documents": { + "empty": "No documents on this order.", + "upload": "Upload document", + "file": "File (PDF, JPG, PNG, WebP)", + "title": "Title (optional)", + "category": "Category", + "visibility": "Visibility", + "submit": "Upload", + "archive": "Remove", + "download": "Download", + "stubHint": "Until the document store is connected, files are stored directly in object storage.", + "categories": { + "order_confirmation": "Order confirmation", + "technical_drawing": "Technical drawing", + "floor_plan": "Floor plan", + "wiring_diagram": "Wiring diagram", + "assembly_instructions": "Assembly instructions", + "safety_document": "Safety document", + "product_document": "Product document", + "customer_note": "Customer note", + "work_record": "Work record", + "daily_report": "Daily report", + "completion_report": "Completion report", + "customer_approval": "Customer approval", + "photo": "Photo", + "voice_note": "Voice note", + "signature": "Signature", + "other": "Other" + }, + "visibilities": { + "backoffice_only": "Office only", + "team_lead": "Team lead", + "team": "Team", + "customer_report": "Customer report" + } + }, + "history": { + "statusChanges": "Status changes", + "audit": "Log", + "empty": "No entries yet.", + "created": "Created as {status}", + "change": "{from} → {to}", + "by": "by {name}", + "system": "System", + "action": { "create": "Created", "update": "Changed", "delete": "Deleted" } + }, + "assign": { + "title": "Assign work order", + "sub": "Choose a team, optionally individual technicians and a team lead", + "team": "Team", + "chooseTeam": "— choose team —", + "members": "Individual technicians (optional)", + "teamLead": "Responsible team lead", + "teamLeadDefault": "Team lead of the team", + "noTeams": "No active teams. Teams are created under “Teams”.", + "submit": "Assign", + "pending": "Assigning…" + }, + "transition": { + "to": { + "draft": "As draft", + "review_required": "Mark for review", + "planned": "Schedule", + "assigned": "Assign", + "accepted": "Accept", + "en_route": "Start travel", + "in_progress": "Start work", + "paused": "Pause", + "waiting_material": "Waiting for material", + "daily_report_created": "Daily report created", + "technically_completed": "Complete technically", + "signature_pending": "Signature pending", + "in_review": "Submit for review", + "released_for_billing": "Release for billing", + "billed": "Mark as billed", + "cancelled": "Cancel order" + }, + "correction": "Send back for correction", + "revoke": "Revoke release", + "reasonTitle": "Reason required", + "reason": "Reason", + "reasonPlaceholder": "Briefly describe why", + "confirm": "Confirm", + "pending": "Saving…", + "done": "Status changed." + }, + "edit": { + "title": "Edit work order", + "submit": "Save", + "pending": "Saving…", + "saved": "Saved." + }, + "conflicts": { + "crumb": "Work orders", + "title": "Sync conflicts", + "sub": "Changes recorded offline that could not be applied automatically", + "empty": "No open conflicts.", + "received": "Received", + "user": "Device / person", + "operation": "Operation", + "entity": "Work order", + "baseVersion": "Device state: version {base} · current: version {current}", + "payload": "Content", + "apply": "Apply", + "discard": "Discard", + "applied": "Applied.", + "discarded": "Discarded.", + "applyHint": "Apply re-runs the operation against the current state – on behalf of the person who recorded it.", + "stubHint": "Until sync is connected, only status changes can be applied." + }, + "errors": { + "not_found": "Not found or no permission.", + "forbidden": "You lack the permission for this.", + "invalid": "Please check your input.", + "conflict": "The order was changed in the meantime. Reload and try again.", + "blocked": "Not possible yet – information is missing.", + "internal": "Operation not possible.", + "validation_failed": "Please check required fields.", + "work_order_not_found": "Work order not found.", + "customer_not_found": "Customer not found.", + "site_not_found": "Site not found.", + "contact_not_found": "Contact person not found.", + "site_customer_mismatch": "The site belongs to another customer.", + "contact_customer_mismatch": "The contact person belongs to another customer.", + "order_type_not_found": "Order type not found.", + "planned_end_before_start": "The end is before the start.", + "nothing_to_update": "No changes.", + "not_editable": "The order can no longer be changed in this status.", + "delete_only_draft": "Only drafts can be deleted.", + "transition_not_allowed": "This status change is not intended.", + "transition_forbidden": "You may not perform this status change.", + "transition_blocked": "Not possible yet – information is missing.", + "reason_required": "Please give a reason.", + "version_conflict": "The order was changed in the meantime. Reload and try again.", + "team_not_found": "Team not found or not active.", + "user_not_found": "At least one person is not active.", + "material_plan_not_found": "Material item not found.", + "material_in_use": "Material has already been recorded for this item.", + "checklist_item_not_found": "Checklist item not found.", + "checklist_item_checked": "Completed items are kept as evidence.", + "photo_requirement_not_found": "Required photo not found.", + "photo_requirement_has_photos": "Photos already exist for this requirement.", + "template_not_found": "Template not found.", + "sync_conflict_not_found": "Conflict not found or already resolved.", + "sync_conflict_already_resolved": "Conflict already resolved.", + "sync_payload_invalid": "The operation is incomplete.", + "sync_user_inactive": "The person is no longer active.", + "reapply_unsupported": "This operation cannot be applied yet.", + "document_not_found": "Document not found.", + "file_type_not_allowed": "File type not allowed.", + "file_too_large": "File is empty or too large.", + "file_missing": "Please choose a file.", + "category_invalid": "Category not allowed.", + "visibility_forbidden": "You may not assign this visibility.", + "initial_status_not_allowed": "This initial status is not allowed." + }, + "blockers": { + "checklist_item": "Checklist: {label}", + "photo_requirement": "Required photo: {label}", + "running_session": "A work session is still running", + "field": { + "signature": "Signature missing", + "approved_completion_report": "Completion report not approved yet" + } + }, + "saved": "Saved." +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 0b3318e..68bcef3 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -1,40 +1,170 @@ -import { LayoutDashboard } from "lucide-react"; +import Link from "next/link"; +import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; -import { requireSession } from "@/server/auth"; +import { + AlarmClock, + CalendarDays, + CheckCheck, + ClipboardCheck, + ClipboardList, + Hourglass, + PenLine, + Receipt, + RefreshCw, + Siren, + Wrench, + type LucideIcon, +} from "lucide-react"; import { PageHead } from "@/components/mockup-ui"; +import { buttonCls } from "@/components/work-orders/action-form"; +import { pageContext } from "@/components/work-orders/page-context"; +import { Field, inputCls } from "@/components/work-orders/ui"; +import { isoDay, parseListParams, toQuery, type Preset } from "@/lib/work-orders/filters"; +import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas"; +import { getDashboardTiles } from "@/server/services/work-orders/dashboard"; +import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options"; +import { listOrderTypes } from "@/server/services/work-orders/settings"; -/** - * Neutrales Dashboard (Platzhalter). Die Kacheln (offene Aufträge, heutige Einsätze, - * freizugebende Berichte, Notdienst) liefern die Fachmodule. - */ -export default async function DashboardPage({ - searchParams, -}: { - searchParams: Promise<{ module?: string }>; -}) { - const session = await requireSession(); +type SP = Record; + +const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[] = [ + { key: "open", icon: ClipboardList, tone: "var(--ui-primary)" }, + { key: "today", icon: CalendarDays, tone: "var(--info)" }, + { key: "running", icon: Wrench, tone: "var(--ui-accent)" }, + { key: "not_accepted", icon: Hourglass, tone: "var(--warn)" }, + { key: "overdue", icon: AlarmClock, tone: "var(--risk)" }, + { key: "reports_in_review", icon: ClipboardCheck, tone: "var(--info)" }, + { key: "completed", icon: CheckCheck, tone: "var(--ok)" }, + { key: "billing", icon: Receipt, tone: "var(--ok)" }, + { key: "emergency_new", icon: Siren, tone: "var(--risk)" }, + { key: "missing_signatures", icon: PenLine, tone: "var(--warn)" }, + { key: "sync_conflicts", icon: RefreshCw, tone: "var(--risk)" }, +]; + +/** Backoffice dashboard (spec §21). Field roles are sent to the mobile start page. */ +export default async function DashboardPage({ searchParams }: { searchParams: Promise }) { + const { session, ctx, can } = await pageContext(); + if (!can("work_order:read_all")) redirect("/m"); const sp = await searchParams; const t = await getTranslations("dashboard"); + const tw = await getTranslations("workOrders"); + + const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } }); + const moduleEnabled = !moduleRow || moduleRow.enabled; + + const f = parseListParams(sp); + const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority }; + const [tiles, customers, teams, users, orderTypes] = moduleEnabled + ? await Promise.all([getDashboardTiles(ctx, filter), customerFilterOptions(ctx), teamOptions(ctx), userOptions(ctx), listOrderTypes(ctx)]) + : [null, [], [], [], []]; return ( -
- +
+ {sp.module === "disabled" && (

{t("moduleDisabled")}

)} -
- -
-

{t("placeholderTitle")}

-

{t("placeholder")}

-
-
+ + {!tiles ? ( +

{t("workOrdersDisabled")}

+ ) : ( + <> +
+ {t("filterTitle")} +
+ + + + + + + + + + + + + + + + + + + + + +
+ + + {tw("filter.reset")} + +
+
+
+ +
    + {TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => { + const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key]; + const href = key === "sync_conflicts" ? "/work-orders/conflicts" : `/work-orders${toQuery({ ...filter, preset: key })}`; + return ( +
  • + 0 ? tone : "var(--line)" }} + > + +
    +

    {t(`tiles.${key}`)}

    +

    {count}

    +

    {t(`hints.${key}`)}

    +
    + +
  • + ); + })} +
+ + )}
); } diff --git a/src/app/(app)/search/page.tsx b/src/app/(app)/search/page.tsx new file mode 100644 index 0000000..46cc945 --- /dev/null +++ b/src/app/(app)/search/page.tsx @@ -0,0 +1,166 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { Search } from "lucide-react"; +import { PageHead } from "@/components/mockup-ui"; +import { buttonCls } from "@/components/work-orders/action-form"; +import { pageContext } from "@/components/work-orders/page-context"; +import { Empty, Field, inputCls, Section, StatusBadge } from "@/components/work-orders/ui"; +import { isoDay, parseListParams } from "@/lib/work-orders/filters"; +import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status"; +import { formatDate, formatDateTime } from "@/lib/work-orders/time"; +import { customerDisplayName, teamOptions } from "@/server/services/work-orders/options"; +import { searchAll, type SearchResults } from "@/server/services/work-orders/search"; + +type SP = Record; +const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v); + +/** Tenant search (spec §25) — ILIKE across all areas, restricted by the caller's scopes. */ +export default async function SearchPage({ searchParams }: { searchParams: Promise }) { + const { ctx, locale, tz } = await pageContext(); + const t = await getTranslations("search"); + const tw = await getTranslations("workOrders"); + const td = await getTranslations("workOrders.documents"); + const sp = await searchParams; + const q = (one(sp.q) ?? "").trim().slice(0, 100); + const f = parseListParams(sp); + + const [results, teams] = await Promise.all([ + q.length >= 2 ? searchAll(ctx, { q, from: f.from, to: f.to, statuses: f.statuses, teamId: f.teamId }) : Promise.resolve(null), + teamOptions(ctx), + ]); + const total = results ? Object.values(results).reduce((n, list) => n + list.length, 0) : 0; + const hasFilter = Boolean(f.from || f.to || f.statuses || f.teamId); + + return ( +
+ + +
+
+ + +
+
+ {t("filters.note")} +
+ + + + + + + + + + + + +
+ {hasFilter && ( + + {t("filters.reset")} + + )} +
+
+ +
+ {!results ? ( +

{t("hint")}

+ ) : total === 0 ? ( + {t("noResults", { q })} + ) : ( + <> + + {(w: SearchResults["workOrders"][number]) => ( + + + {w.number} {w.title} + + {[customerDisplayName(w.customer), w.site?.name, w.site?.city, w.team?.name, formatDate(w.plannedStart, locale, tz)].filter(Boolean).join(" · ")} + + + + + )} + + + {(c: SearchResults["customers"][number]) => ( + + {customerDisplayName(c)} + {[c.customerNumber, [c.street, c.houseNumber].filter(Boolean).join(" "), [c.postalCode, c.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ")} + + )} + + + {(s: SearchResults["sites"][number]) => ( + + {s.name} + {[customerDisplayName(s.customer), [s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ")} + + )} + + + {(c: SearchResults["contacts"][number]) => ( + + {c.name} + {[c.role, customerDisplayName(c.customer), c.phone, c.email].filter(Boolean).join(" · ")} + + )} + + + {(d: SearchResults["documents"][number]) => ( + + {d.title || d.fileName} + {[td(`categories.${d.category}`), formatDate(d.createdAt, locale, tz)].join(" · ")} + + )} + + + {(n: SearchResults["notes"][number]) => ( + + {n.text} + {[n.workOrder.number, n.workOrder.title, formatDateTime(n.createdAt, locale, tz)].join(" · ")} + + )} + +

{t("maxHint")}

+ + )} +
+
+ ); +} + +function ResultSection({ title, list, children }: { title: string; list: T[]; children: (item: T) => React.ReactNode }) { + if (list.length === 0) return null; + return ( +
+
    + {list.map((item) => ( +
  • {children(item)}
  • + ))} +
+
+ ); +} diff --git a/src/app/(app)/settings/checklists/page.tsx b/src/app/(app)/settings/checklists/page.tsx new file mode 100644 index 0000000..9c50ca3 --- /dev/null +++ b/src/app/(app)/settings/checklists/page.tsx @@ -0,0 +1,122 @@ +import Link from "next/link"; +import { getTranslations } from "next-intl/server"; +import { z } from "zod"; +import { Plus } from "lucide-react"; +import { PageHead, Pill } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { ActionForm, buttonCls } from "@/components/work-orders/action-form"; +import { SettingsTemplatesNav, templatesPageContext } from "@/components/work-orders/settings-nav"; +import { Check, Empty, Field, inputCls, Section } from "@/components/work-orders/ui"; +import { DEFAULT_CHECKLIST_ITEMS, DEFAULT_REQUIRED_PHOTOS } from "@/lib/work-orders/defaults"; +import { templateItemSchema, templatePhotoSchema, type TemplateItem, type TemplatePhoto } from "@/lib/work-orders/schemas"; +import { saveChecklistTemplateAction } from "@/server/actions/work_orders/settings"; +import { listChecklistTemplates, listOrderTypes } from "@/server/services/work-orders/settings"; + +type SP = Record; +const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v); + +const itemsToText = (items: readonly TemplateItem[], suffix: string) => + items.map((i) => `${i.required ? "* " : ""}${i.label}${i.requiresPhoto ? ` ${suffix}` : ""}`).join("\n"); +const photosToText = (photos: readonly TemplatePhoto[]) => photos.map((p) => p.label).join("\n"); + +/** Checklist templates per order type incl. required photos (spec §12.4 / §14.2). */ +export default async function ChecklistTemplatesPage({ searchParams }: { searchParams: Promise }) { + const { ctx } = await templatesPageContext(); + const t = await getTranslations("settingsTemplates"); + const tc = await getTranslations("common"); + const sp = await searchParams; + const [templates, orderTypes] = await Promise.all([listChecklistTemplates(ctx), listOrderTypes(ctx)]); + + const parsed = templates.map((tpl) => ({ + ...tpl, + itemList: z.array(templateItemSchema).safeParse(tpl.items).data ?? [], + photoList: z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? [], + })); + const editId = one(sp.edit); + const editing = editId ? parsed.find((x) => x.id === editId) : undefined; + const isNew = one(sp.new) === "1"; + const suggest = one(sp.suggest) === "1"; + const base = "/settings/checklists"; + + return ( +
+ + + {t("checklists.new")} + + } + /> + + + {parsed.length === 0 ? ( + {t("checklists.empty")} + ) : ( +
    + {parsed.map((tpl) => ( +
  • +
    {t("checklists.inactive")} : undefined}> +

    + {tpl.orderType?.name ?? t("checklists.noOrderType")} · {t("checklists.itemCount", { count: tpl.itemList.length })} · {t("checklists.photoCount", { count: tpl.photoList.length })} +

    + + {t("checklists.edit")} + +
    +
  • + ))} +
+ )} + + {(isNew || editing) && ( + + + {editing && } + + + + + + + +