L13 Planung: Datenmodell (Kolonnenkapazität, Dauer, Geocoding-Cache) und Planungsregeln

Migration 20260915120000_planung (nur Spalten): Team.dailyCapacityMinutes/workingDays, OrderType.defaultDurationMinutes (+ Backfill Standardarten), WorkOrder.plannedDurationMinutes, Site.geocodedAt/geocodeStatus/geocodeQuery. Client-sichere Regeln: Haversine, Tage/Werktage, Dauer, Kolonnenkapazität, Konflikte inkl. Hinweis crew_incomplete, Vereinigung von Arbeitssegmenten, Fahrzeitschätzung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:13:18 +02:00
co-authored by Claude Opus 5
parent d3bc7f2d29
commit 604f2bfdc5
7 changed files with 424 additions and 10 deletions
@@ -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');
+13
View File
@@ -472,6 +472,8 @@ model OrderType {
active Boolean @default(true) active Boolean @default(true)
signatureRequired Boolean @default(true) @map("signature_required") signatureRequired Boolean @default(true) @map("signature_required")
sortOrder Int @default(0) @map("sort_order") 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") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
@@ -597,6 +599,11 @@ model Site {
status SiteStatus @default(active) status SiteStatus @default(active)
latitude Float? latitude Float?
longitude 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") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
@@ -627,6 +634,10 @@ model Team {
vehicle String? vehicle String?
area String? area String?
notes 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") createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at") updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at") deletedAt DateTime? @map("deleted_at")
@@ -700,6 +711,8 @@ model WorkOrder {
scope String? // Leistungsumfang scope String? // Leistungsumfang
plannedStart DateTime? @map("planned_start") plannedStart DateTime? @map("planned_start")
plannedEnd DateTime? @map("planned_end") plannedEnd DateTime? @map("planned_end")
// L13 Planung: explicit duration; fallback plannedStart/End → OrderType.defaultDurationMinutes → 120
plannedDurationMinutes Int? @map("planned_duration_minutes")
assignedTeamId String? @map("assigned_team_id") assignedTeamId String? @map("assigned_team_id")
teamLeadUserId String? @map("team_lead_user_id") teamLeadUserId String? @map("team_lead_user_id")
signatureRequired Boolean @default(true) @map("signature_required") signatureRequired Boolean @default(true) @map("signature_required")
+37
View File
@@ -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`;
}
+216
View File
@@ -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<PlanningConflict, "severity">) => 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<PlanningOrder, "plannedStart" | "plannedEnd">, 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<string, { o: PlanningOrder; w: { start: number; end: number } }[]>();
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);
}
+91
View File
@@ -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")}`;
}
+24
View File
@@ -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/<locale>/planning.json). */
export function fillTemplate(template: string, vars: Record<string, string | number>): 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")}.`;
}
+10 -9
View File
@@ -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. */ /** 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 }> = [ // defaultDurationMinutes: L13 Planung (capacity planning default per order type)
{ key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10 }, export const DEFAULT_ORDER_TYPES: ReadonlyArray<{ key: string; name: string; signatureRequired: boolean; sortOrder: number; defaultDurationMinutes: number }> = [
{ key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20 }, { key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10, defaultDurationMinutes: 480 },
{ key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30 }, { key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20, defaultDurationMinutes: 180 },
{ key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40 }, { key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30, defaultDurationMinutes: 120 },
{ key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50 }, { key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40, defaultDurationMinutes: 120 },
{ key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60 }, { key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50, defaultDurationMinutes: 120 },
{ key: "abnahme", name: "Abnahme", signatureRequired: true, sortOrder: 70 }, { key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60, defaultDurationMinutes: 60 },
{ key: "nacharbeit", name: "Nacharbeit", signatureRequired: true, sortOrder: 80 }, { 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. */ /** Spec §12.4 — suggested checklist items. */