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:
@@ -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');
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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")}`;
|
||||||
|
}
|
||||||
@@ -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")}.`;
|
||||||
|
}
|
||||||
@@ -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. */
|
||||||
|
|||||||
Reference in New Issue
Block a user