diff --git a/prisma/migrations/20260915120000_planung/migration.sql b/prisma/migrations/20260915120000_planung/migration.sql new file mode 100644 index 0000000..f48a6d3 --- /dev/null +++ b/prisma/migrations/20260915120000_planung/migration.sql @@ -0,0 +1,32 @@ +-- L13 Planung: capacity planning + geocoding cache. Columns only (no new table → RLS unchanged, +-- no TENANT_MODELS change). No personal references (no DSGVO pii-fields change). + +-- AlterTable +ALTER TABLE "order_types" ADD COLUMN "default_duration_minutes" INTEGER; + +-- AlterTable +ALTER TABLE "sites" ADD COLUMN "geocode_query" TEXT, +ADD COLUMN "geocode_status" TEXT, +ADD COLUMN "geocoded_at" TIMESTAMP(3); + +-- AlterTable +-- Team = crew (Kolonne): capacity is the crew's working day in minutes, not person-hours. +ALTER TABLE "teams" ADD COLUMN "daily_capacity_minutes" INTEGER NOT NULL DEFAULT 480, +ADD COLUMN "working_days" INTEGER NOT NULL DEFAULT 31; + +-- AlterTable +ALTER TABLE "work_orders" ADD COLUMN "planned_duration_minutes" INTEGER; + +-- Backfill: default durations of the standard order types (spec §10.2) for existing tenants. +UPDATE "order_types" SET "default_duration_minutes" = CASE "key" + WHEN 'montage' THEN 480 + WHEN 'reparatur' THEN 180 + WHEN 'wartung' THEN 120 + WHEN 'stoerung' THEN 120 + WHEN 'notdienst' THEN 120 + WHEN 'besichtigung' THEN 60 + WHEN 'abnahme' THEN 60 + WHEN 'nacharbeit' THEN 120 + END +WHERE "default_duration_minutes" IS NULL + AND "key" IN ('montage', 'reparatur', 'wartung', 'stoerung', 'notdienst', 'besichtigung', 'abnahme', 'nacharbeit'); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index bc29a6c..2e10223 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -472,6 +472,8 @@ model OrderType { active Boolean @default(true) signatureRequired Boolean @default(true) @map("signature_required") sortOrder Int @default(0) @map("sort_order") + // L13 Planung: default duration for capacity planning when the order has no own duration + defaultDurationMinutes Int? @map("default_duration_minutes") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -597,6 +599,11 @@ model Site { status SiteStatus @default(active) latitude Float? longitude Float? + // L13 Planung: geocoding cache (services/geo) — status ok | not_found | failed | skipped, + // geocodeQuery = normalized address the coordinates belong to (change detection) + geocodedAt DateTime? @map("geocoded_at") + geocodeStatus String? @map("geocode_status") + geocodeQuery String? @map("geocode_query") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") @@ -627,6 +634,10 @@ model Team { vehicle String? area String? notes String? + // L13 Planung: a team is a crew (Kolonne) working together — capacity = crew working day in minutes + // (not person-hours); workingDays = bit mask Mon=1 … Sun=64 (31 = Mo–Fr) + dailyCapacityMinutes Int @default(480) @map("daily_capacity_minutes") + workingDays Int @default(31) @map("working_days") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") deletedAt DateTime? @map("deleted_at") @@ -700,7 +711,9 @@ model WorkOrder { scope String? // Leistungsumfang plannedStart DateTime? @map("planned_start") plannedEnd DateTime? @map("planned_end") - assignedTeamId String? @map("assigned_team_id") + // L13 Planung: explicit duration; fallback plannedStart/End → OrderType.defaultDurationMinutes → 120 + plannedDurationMinutes Int? @map("planned_duration_minutes") + assignedTeamId String? @map("assigned_team_id") teamLeadUserId String? @map("team_lead_user_id") signatureRequired Boolean @default(true) @map("signature_required") billingType String? @map("billing_type") // fixed | time_material | maintenance_contract | warranty diff --git a/src/lib/geo/distance.ts b/src/lib/geo/distance.ts new file mode 100644 index 0000000..3dff175 --- /dev/null +++ b/src/lib/geo/distance.ts @@ -0,0 +1,37 @@ +/** + * Geo helpers (client-safe, no server imports). Straight-line ("Luftlinie") distances only — + * no routing, no device positions (L13 Planung). + */ + +export type LatLng = { latitude: number; longitude: number }; + +const EARTH_RADIUS_KM = 6371.0088; + +export function isValidLatLng(p: { latitude?: number | null; longitude?: number | null } | null | undefined): p is LatLng { + return ( + !!p && + typeof p.latitude === "number" && + typeof p.longitude === "number" && + Number.isFinite(p.latitude) && + Number.isFinite(p.longitude) && + Math.abs(p.latitude) <= 90 && + Math.abs(p.longitude) <= 180 + ); +} + +const rad = (deg: number) => (deg * Math.PI) / 180; + +/** Great-circle distance in kilometres (haversine formula). */ +export function haversineKm(a: LatLng, b: LatLng): number { + const dLat = rad(b.latitude - a.latitude); + const dLng = rad(b.longitude - a.longitude); + const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.latitude)) * Math.cos(rad(b.latitude)) * Math.sin(dLng / 2) ** 2; + return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** "3,2 km" / "850 m" in the given locale. */ +export function formatDistance(km: number, locale: string): string { + const tag = locale === "en" ? "en-GB" : "de-DE"; + if (km < 1) return `${new Intl.NumberFormat(tag, { maximumFractionDigits: 0 }).format(Math.round(km * 1000))} m`; + return `${new Intl.NumberFormat(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(km)} km`; +} diff --git a/src/lib/planning/capacity.ts b/src/lib/planning/capacity.ts new file mode 100644 index 0000000..0e7aee9 --- /dev/null +++ b/src/lib/planning/capacity.ts @@ -0,0 +1,216 @@ +import { dayBounds, dayKeyOf, dayRange, isWorkingDay, minutesOfDay } from "@/lib/planning/days"; +import type { WorkOrderStatus } from "@/lib/work-orders/status"; + +/** + * Capacity, utilization and conflict rules of the planning board (client-safe, pure functions, + * unit-tested in scripts/test-planung-core.ts). + * + * A team is a crew ("Kolonne", 2+ people travelling and working together). Capacity is the crew's + * working day in minutes (`Team.dailyCapacityMinutes`, default 480) on its working days — NOT + * person-hours. Order durations are crew time as well. + * + * Duration of an order (minutes): plannedDurationMinutes → plannedStart/End on the same day with a + * time of day → OrderType.defaultDurationMinutes → 120. + * Multi-day orders (plannedEnd on a later day) occupy every day of the range: an explicit duration is + * split evenly, otherwise each day counts with the order type default (or a full day of 480 min). + */ + +/** Statuses the planning board may (re)schedule — running or finished orders are never moved. */ +export const SCHEDULABLE_STATUSES: readonly WorkOrderStatus[] = ["draft", "review_required", "planned", "assigned", "accepted"]; + +export const FALLBACK_DURATION_MINUTES = 120; +export const FULL_DAY_MINUTES = 480; +/** Below this number of active members a crew is flagged as incomplete (hint, not a conflict). */ +export const MIN_CREW_SIZE = 2; + +/** Travel time estimate: straight line × 1.3 detour at 50 km/h, at least 10 min. */ +export const TRAVEL_DETOUR_FACTOR = 1.3; +export const TRAVEL_SPEED_KMH = 50; +export const MIN_TRAVEL_MINUTES = 10; + +export type PlanningOrder = { + id: string; + teamId: string | null; + plannedStart: Date | null; + plannedEnd: Date | null; + plannedDurationMinutes: number | null; + orderTypeDefaultMinutes: number | null; + assigneeIds: string[]; +}; + +export type PlanningTeam = { + id: string; + dailyCapacityMinutes: number; + workingDays: number; + members: { userId: string; validFrom: Date; validTo: Date | null; active: boolean }[]; +}; + +export const CONFLICT_KINDS = ["overbooked", "overlap", "assignee_double_booked", "outside_working_days", "crew_incomplete"] as const; +export type ConflictKind = (typeof CONFLICT_KINDS)[number]; + +export type PlanningConflict = { + kind: ConflictKind; + /** "hint" (crew_incomplete) is shown but not counted as a conflict. */ + severity: "conflict" | "hint"; + day: string; + teamIds: string[]; + orderIds: string[]; + userId?: string; + minutesOver?: number; + memberCount?: number; +}; + +export const isConflict = (c: Pick) => c.severity === "conflict"; + +/** An instant at exactly local midnight counts as "date only" (no time of day entered). */ +export function hasTimeOfDay(date: Date, timeZone: string): boolean { + return minutesOfDay(date, timeZone) !== 0; +} + +/** Days (keys) the order occupies; an end at exactly local midnight belongs to the previous day. */ +export function orderDays(o: Pick, timeZone: string): string[] { + if (!o.plannedStart) return []; + const first = dayKeyOf(o.plannedStart, timeZone); + if (!o.plannedEnd || o.plannedEnd <= o.plannedStart) return [first]; + const endMinusOne = new Date(o.plannedEnd.getTime() - (hasTimeOfDay(o.plannedEnd, timeZone) ? 0 : 1)); + const last = dayKeyOf(endMinusOne, timeZone); + return last < first ? [first] : dayRange(first, last); +} + +export function effectiveDurationMinutes(o: PlanningOrder, timeZone: string): number { + if (o.plannedDurationMinutes && o.plannedDurationMinutes > 0) return o.plannedDurationMinutes; + if (o.plannedStart && o.plannedEnd && o.plannedEnd > o.plannedStart && hasTimeOfDay(o.plannedStart, timeZone) && orderDays(o, timeZone).length === 1) { + return Math.round((o.plannedEnd.getTime() - o.plannedStart.getTime()) / 60_000); + } + if (o.orderTypeDefaultMinutes && o.orderTypeDefaultMinutes > 0) return o.orderTypeDefaultMinutes; + return FALLBACK_DURATION_MINUTES; +} + +/** Minutes the order occupies on `day` (0 if it is not planned on that day). */ +export function minutesOnDay(o: PlanningOrder, day: string, timeZone: string): number { + const days = orderDays(o, timeZone); + if (!days.includes(day)) return 0; + if (days.length === 1) return effectiveDurationMinutes(o, timeZone); + if (o.plannedDurationMinutes && o.plannedDurationMinutes > 0) return Math.ceil(o.plannedDurationMinutes / days.length); + return o.orderTypeDefaultMinutes && o.orderTypeDefaultMinutes > 0 ? o.orderTypeDefaultMinutes : FULL_DAY_MINUTES; +} + +/** Time window in ms for single-day orders with a time of day, otherwise null (no overlap check). */ +export function timeWindow(o: PlanningOrder, timeZone: string): { start: number; end: number } | null { + if (!o.plannedStart || !hasTimeOfDay(o.plannedStart, timeZone) || orderDays(o, timeZone).length !== 1) return null; + const start = o.plannedStart.getTime(); + const end = o.plannedEnd && o.plannedEnd > o.plannedStart ? o.plannedEnd.getTime() : start + effectiveDurationMinutes(o, timeZone) * 60_000; + return { start, end }; +} + +const overlaps = (a: { start: number; end: number }, b: { start: number; end: number }) => a.start < b.end && b.start < a.end; + +/** Distinct active members (user active, membership valid on the day). */ +export function activeMemberCount(team: PlanningTeam, day: string, timeZone: string): number { + const { start, end } = dayBounds(day, timeZone); + return new Set(team.members.filter((m) => m.active && m.validFrom < end && (!m.validTo || m.validTo > start)).map((m) => m.userId)).size; +} + +/** Crew capacity: the crew working day on working days if anyone is active, otherwise 0. */ +export function teamCapacityMinutes(team: PlanningTeam, day: string, timeZone: string): number { + if (!isWorkingDay(team.workingDays, day)) return 0; + return activeMemberCount(team, day, timeZone) > 0 ? team.dailyCapacityMinutes : 0; +} + +export function utilizationPercent(plannedMinutes: number, capacityMinutes: number): number | null { + return capacityMinutes > 0 ? Math.round((plannedMinutes / capacityMinutes) * 100) : null; +} + +export type TeamDay = { + day: string; + workingDay: boolean; + memberCount: number; + capacityMinutes: number; + plannedMinutes: number; + utilization: number | null; + orderIds: string[]; + conflicts: PlanningConflict[]; +}; + +/** Load + team-local conflicts (overbooked, overlap, outside_working_days) + crew_incomplete hint of one team on one day. */ +export function computeTeamDay(team: PlanningTeam, day: string, teamOrders: PlanningOrder[], timeZone: string): TeamDay { + const onDay = teamOrders.filter((o) => o.teamId === team.id && minutesOnDay(o, day, timeZone) > 0); + const workingDay = isWorkingDay(team.workingDays, day); + const memberCount = activeMemberCount(team, day, timeZone); + const capacityMinutes = teamCapacityMinutes(team, day, timeZone); + const plannedMinutes = onDay.reduce((sum, o) => sum + minutesOnDay(o, day, timeZone), 0); + const ids = onDay.map((o) => o.id); + const conflicts: PlanningConflict[] = []; + if (!workingDay && onDay.length > 0) { + conflicts.push({ kind: "outside_working_days", severity: "conflict", day, teamIds: [team.id], orderIds: ids }); + } else if (workingDay && plannedMinutes > capacityMinutes && onDay.length > 0) { + conflicts.push({ kind: "overbooked", severity: "conflict", day, teamIds: [team.id], orderIds: ids, minutesOver: plannedMinutes - capacityMinutes }); + } + const timed = onDay.map((o) => ({ o, w: timeWindow(o, timeZone) })).filter((x): x is { o: PlanningOrder; w: { start: number; end: number } } => !!x.w); + for (let i = 0; i < timed.length; i++) { + for (let j = i + 1; j < timed.length; j++) { + if (overlaps(timed[i].w, timed[j].w)) conflicts.push({ kind: "overlap", severity: "conflict", day, teamIds: [team.id], orderIds: [timed[i].o.id, timed[j].o.id] }); + } + } + if (workingDay && memberCount < MIN_CREW_SIZE) { + conflicts.push({ kind: "crew_incomplete", severity: "hint", day, teamIds: [team.id], orderIds: ids, memberCount }); + } + return { day, workingDay, memberCount, capacityMinutes, plannedMinutes, utilization: utilizationPercent(plannedMinutes, capacityMinutes), orderIds: ids, conflicts }; +} + +/** A technician individually assigned to two orders (any team) whose time windows overlap on `day`. */ +export function assigneeDoubleBookings(orders: PlanningOrder[], day: string, timeZone: string): PlanningConflict[] { + const byUser = new Map(); + for (const o of orders) { + if (!orderDays(o, timeZone).includes(day)) continue; + const w = timeWindow(o, timeZone); + if (!w) continue; + for (const userId of new Set(o.assigneeIds)) { + const list = byUser.get(userId) ?? []; + list.push({ o, w }); + byUser.set(userId, list); + } + } + const out: PlanningConflict[] = []; + for (const [userId, list] of byUser) { + for (let i = 0; i < list.length; i++) { + for (let j = i + 1; j < list.length; j++) { + if (overlaps(list[i].w, list[j].w)) { + const teamIds = [...new Set([list[i].o.teamId, list[j].o.teamId].filter((t): t is string => !!t))]; + out.push({ kind: "assignee_double_booked", severity: "conflict", day, teamIds, orderIds: [list[i].o.id, list[j].o.id], userId }); + } + } + } + } + return out; +} + +/** Length of the union of time intervals in minutes (crew time: parallel segments count once). */ +export function unionMinutes(intervals: { start: number; end: number }[]): number { + const sorted = intervals.filter((i) => i.end > i.start).sort((a, b) => a.start - b.start); + let total = 0; + let curStart = -Infinity; + let curEnd = -Infinity; + for (const i of sorted) { + if (i.start > curEnd) { + if (curEnd > curStart) total += curEnd - curStart; + curStart = i.start; + curEnd = i.end; + } else if (i.end > curEnd) { + curEnd = i.end; + } + } + if (curEnd > curStart) total += curEnd - curStart; + return Math.round(total / 60_000); +} + +/** Estimated travel minutes for a straight-line distance (null = unknown → minimum). */ +export function estimateTravelMinutes(km: number | null): number { + if (km === null || !Number.isFinite(km)) return MIN_TRAVEL_MINUTES; + return Math.max(MIN_TRAVEL_MINUTES, Math.round(((km * TRAVEL_DETOUR_FACTOR) / TRAVEL_SPEED_KMH) * 60)); +} + +/** Overrun notification bucket: 0 at ≥ 100 %, 1 at ≥ 150 %, 2 at ≥ 200 % … (-1 below 100 %). */ +export function overrunBucket(ratio: number): number { + return ratio < 1 ? -1 : Math.floor((ratio - 1) / 0.5 + 1e-9); +} diff --git a/src/lib/planning/days.ts b/src/lib/planning/days.ts new file mode 100644 index 0000000..0aff969 --- /dev/null +++ b/src/lib/planning/days.ts @@ -0,0 +1,91 @@ +import { safeTimeZone, wallTimeToUtc } from "@/lib/work-orders/time"; + +/** + * Calendar-day helpers for the planning board (client-safe). A "day key" is `YYYY-MM-DD` in the + * tenant timezone; weekday index 0 = Monday … 6 = Sunday. Team working days are a bit mask + * (Mon = 1, Tue = 2, … Sun = 64; default 31 = Mon–Fri). + */ + +export const DEFAULT_WORKING_DAYS = 31; +export const WEEKDAY_BITS = [1, 2, 4, 8, 16, 32, 64] as const; + +const DAY_KEY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; + +export function isDayKey(value: unknown): value is string { + if (typeof value !== "string") return false; + const m = DAY_KEY_RE.exec(value); + if (!m) return false; + const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])); + return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3]; +} + +/** Day key of an instant in the given timezone. */ +export function dayKeyOf(date: Date, timeZone: string): string { + return new Intl.DateTimeFormat("en-CA", { timeZone: safeTimeZone(timeZone), year: "numeric", month: "2-digit", day: "2-digit" }).format(date); +} + +/** Pure calendar arithmetic on day keys (no timezone involved). */ +export function addDays(dayKey: string, days: number): string { + const [y, m, d] = dayKey.split("-").map(Number); + return new Date(Date.UTC(y, m - 1, d + days)).toISOString().slice(0, 10); +} + +/** 0 = Monday … 6 = Sunday. */ +export function weekdayIndex(dayKey: string): number { + const [y, m, d] = dayKey.split("-").map(Number); + return (new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7; +} + +export function startOfWeek(dayKey: string): string { + return addDays(dayKey, -weekdayIndex(dayKey)); +} + +/** Inclusive list of day keys. */ +export function dayRange(from: string, to: string): string[] { + const out: string[] = []; + for (let k = from; k <= to && out.length < 400; k = addDays(k, 1)) out.push(k); + return out; +} + +/** [start, end) instants of a calendar day in the timezone (DST-safe). */ +export function dayBounds(dayKey: string, timeZone: string): { start: Date; end: Date } { + const start = wallTimeToUtc(dayKey, timeZone); + const end = wallTimeToUtc(addDays(dayKey, 1), timeZone); + if (!start || !end) throw new Error(`invalid day key ${dayKey}`); + return { start, end }; +} + +export function isWorkingDay(mask: number, dayKey: string): boolean { + return (mask & WEEKDAY_BITS[weekdayIndex(dayKey)]) !== 0; +} + +/** `start` (always, even on a weekend) followed by the next working days — `count` keys in total. */ +export function nextWorkingDays(start: string, count: number, mask = DEFAULT_WORKING_DAYS): string[] { + const out = [start]; + for (let k = addDays(start, 1); out.length < count && out.length < 400; k = addDays(k, 1)) if (isWorkingDay(mask, k)) out.push(k); + return out; +} + +/** Move by `n` working days (negative = backwards); non-working start days count from the next one. */ +export function addWorkingDays(day: string, n: number, mask = DEFAULT_WORKING_DAYS): string { + let k = day; + const step = n < 0 ? -1 : 1; + for (let left = Math.abs(n); left > 0; ) { + k = addDays(k, step); + if (isWorkingDay(mask, k)) left--; + } + return k; +} + +/** Minutes since local midnight of an instant in the timezone. */ +export function minutesOfDay(date: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat("en-GB", { timeZone: safeTimeZone(timeZone), hourCycle: "h23", hour: "2-digit", minute: "2-digit" }).formatToParts(date); + const n = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? 0); + return n("hour") * 60 + n("minute"); +} + +/** Local wall time "HH:mm" of an instant. */ +export function wallClock(date: Date, timeZone: string): string { + const m = minutesOfDay(date, timeZone); + return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`; +} diff --git a/src/lib/planning/text.ts b/src/lib/planning/text.ts new file mode 100644 index 0000000..fe83d6a --- /dev/null +++ b/src/lib/planning/text.ts @@ -0,0 +1,24 @@ +/** Small client-safe text helpers of the planning module (no ICU runtime needed on the server). */ + +const tag = (locale: string) => (locale === "en" ? "en-GB" : "de-DE"); + +/** Replaces `{name}` placeholders (simple message templates from messages//planning.json). */ +export function fillTemplate(template: string, vars: Record): string { + return template.replace(/\{(\w+)\}/g, (match, key: string) => (key in vars ? String(vars[key]) : match)); +} + +/** "45 min", "4 h", "4,5 h". */ +export function formatMinutes(minutes: number, locale: string): string { + const m = Math.round(minutes); + if (Math.abs(m) < 60) return `${m} min`; + return `${new Intl.NumberFormat(tag(locale), { maximumFractionDigits: 1 }).format(m / 60)} h`; +} + +/** "Di 16.09." (de) / "Tue 16 Sep" (en) for a day key. */ +export function formatDayShort(dayKey: string, locale: string): string { + const [y, m, d] = dayKey.split("-").map(Number); + const date = new Date(Date.UTC(y, m - 1, d, 12)); + const weekday = new Intl.DateTimeFormat(tag(locale), { weekday: "short", timeZone: "UTC" }).format(date).replace(/\.$/, ""); + if (locale === "en") return `${weekday} ${new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", timeZone: "UTC" }).format(date)}`; + return `${weekday} ${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.`; +} diff --git a/src/lib/work-orders/defaults.ts b/src/lib/work-orders/defaults.ts index 2fc1b77..6349c14 100644 --- a/src/lib/work-orders/defaults.ts +++ b/src/lib/work-orders/defaults.ts @@ -14,15 +14,16 @@ export const DEFAULT_NUMBER_PREFIX: Record<"customer" | "work_order" | "emergenc }; /** 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 }, +// defaultDurationMinutes: L13 Planung (capacity planning default per order type) +export const DEFAULT_ORDER_TYPES: ReadonlyArray<{ key: string; name: string; signatureRequired: boolean; sortOrder: number; defaultDurationMinutes: number }> = [ + { key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10, defaultDurationMinutes: 480 }, + { key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20, defaultDurationMinutes: 180 }, + { key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30, defaultDurationMinutes: 120 }, + { key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40, defaultDurationMinutes: 120 }, + { key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50, defaultDurationMinutes: 120 }, + { key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60, defaultDurationMinutes: 60 }, + { key: "abnahme", name: "Abnahme", signatureRequired: true, sortOrder: 70, defaultDurationMinutes: 60 }, + { key: "nacharbeit", name: "Nacharbeit", signatureRequired: true, sortOrder: 80, defaultDurationMinutes: 120 }, ]; /** Spec §12.4 — suggested checklist items. */