import { z } from "zod"; import { assigneeDoubleBookings, computeTeamDay, isConflict, type ConflictKind, type PlanningConflict, type TeamDay, } from "@/lib/planning/capacity"; import { addDays, dayBounds, dayKeyOf, dayRange, isDayKey, startOfWeek, weekdayIndex } from "@/lib/planning/days"; import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas"; import { ServiceError, type ServiceCtx } from "@/server/services/context"; import { planningAccess, type PlanningAccess } from "@/server/services/planning/access"; import { loadPlannedOrders, loadTeams, loadUnplannedOrders, toBoardOrder, toPlanningOrder, toPlanningTeam, type BoardOrder, } from "@/server/services/planning/data"; import { evaluateDelays } from "@/server/services/planning/watch"; import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared"; /** * Planning board (L13, Plantafel): per crew (team) and day planned orders, crew capacity, utilization, * conflicts (+ crew_incomplete hints) and — when today is in range — delay/at-risk markers. */ export const MAX_BOARD_DAYS = 42; const dayKey = z.string().refine(isDayKey, "invalid_day"); const boardSchema = z.object({ from: dayKey, to: dayKey, teamIds: z.array(z.string().min(1).max(64)).max(100).optional(), orderTypeId: z.string().min(1).max(64).nullish(), priority: z.enum(WORK_ORDER_PRIORITIES).nullish(), }); export type BoardInput = z.input; export type BoardTeamDay = TeamDay; export type BoardTeam = { id: string; name: string; leaderName: string | null; dailyCapacityMinutes: number; workingDays: number; members: { userId: string; name: string }[]; days: BoardTeamDay[]; }; export type PlanningBoard = { from: string; to: string; today: string; timeZone: string; days: { key: string; weekday: number }[]; teams: BoardTeam[]; orders: Record; unplanned: BoardOrder[]; /** Conflicts and hints (severity); `conflictCount` counts conflicts only. */ conflicts: PlanningConflict[]; conflictCount: number; access: PlanningAccess; }; export async function getPlanningBoard(ctx: ServiceCtx, raw: BoardInput, opts: { now?: Date } = {}): Promise { const input = parseInput(boardSchema, raw); if (input.to < input.from || dayRange(input.from, input.to).length > MAX_BOARD_DAYS) throw new ServiceError("invalid", "invalid_range"); const access = await planningAccess(ctx); const timeZone = await tenantTimezone(ctx); const now = opts.now ?? new Date(); const today = dayKeyOf(now, timeZone); let teamIds = access.teamIds; if (input.teamIds?.length) teamIds = teamIds ? teamIds.filter((id) => input.teamIds!.includes(id)) : input.teamIds; const days = dayRange(input.from, input.to); const start = dayBounds(input.from, timeZone).start; const end = dayBounds(input.to, timeZone).end; const teams = await loadTeams(ctx, teamIds); const [rows, unplannedRows] = await Promise.all([ loadPlannedOrders(ctx, { teamIds: teams.map((t) => t.id), start, end }), loadUnplannedOrders(ctx, access.teamIds), ]); const planningOrders = rows.map(toPlanningOrder); const visible = (r: { orderTypeId: string | null; priority: string }) => (!input.orderTypeId || r.orderTypeId === input.orderTypeId) && (!input.priority || r.priority === input.priority); const visibleIds = new Set(rows.filter(visible).map((r) => r.id)); const doubleBookings = days.flatMap((day) => assigneeDoubleBookings(planningOrders, day, timeZone)); const conflicts: PlanningConflict[] = [...doubleBookings]; const boardTeams: BoardTeam[] = teams.map((team) => { const pt = toPlanningTeam(team); const teamOrders = planningOrders.filter((o) => o.teamId === team.id); const members = new Map(); for (const m of team.members) { if (m.user.status === "ACTIVE" && m.validFrom < end && (!m.validTo || m.validTo > start)) members.set(m.userId, m.user.name); } return { id: team.id, name: team.name, leaderName: team.leader?.name ?? null, dailyCapacityMinutes: team.dailyCapacityMinutes, workingDays: team.workingDays, members: [...members].map(([userId, name]) => ({ userId, name })), days: days.map((day) => { const td = computeTeamDay(pt, day, teamOrders, timeZone); conflicts.push(...td.conflicts); return { ...td, orderIds: td.orderIds.filter((id) => visibleIds.has(id)), conflicts: [...td.conflicts, ...doubleBookings.filter((c) => c.day === day && c.teamIds.includes(team.id))], }; }), }; }); const kindsByOrder = new Map>(); for (const c of conflicts.filter(isConflict)) { for (const id of c.orderIds) { const set = kindsByOrder.get(id) ?? new Set(); set.add(c.kind); kindsByOrder.set(id, set); } } const orders: Record = Object.fromEntries(rows.filter(visible).map((r) => [r.id, toBoardOrder(r, timeZone, [...(kindsByOrder.get(r.id) ?? [])])])); // Delay (≥ 80 %) and endangered follow-up orders — only meaningful when today is shown. if (today >= input.from && today <= input.to && teams.length > 0) { const { delays, followups } = await evaluateDelays(ctx, { now, teamIds: teams.map((t) => t.id) }); for (const d of delays) { if (d.level !== "ok" && orders[d.workOrderId]) orders[d.workOrderId].delay = { level: d.level, percent: d.percent, workedMinutes: d.workedMinutes }; } for (const f of followups) { if (orders[f.workOrderId]) orders[f.workOrderId].atRisk = { byOrderId: f.blockedById, byNumber: f.blockedByNumber, reason: f.reason }; } } return { from: input.from, to: input.to, today, timeZone, days: days.map((key) => ({ key, weekday: weekdayIndex(key) })), teams: boardTeams, orders, unplanned: unplannedRows.filter(visible).map((r) => toBoardOrder(r, timeZone)), conflicts, conflictCount: conflicts.filter(isConflict).length, access, }; } /** Mon–Sun of the week containing `day` (default: today in the tenant timezone). */ export async function weekRange(ctx: ServiceCtx, day?: string | null): Promise<{ from: string; to: string; today: string }> { const today = dayKeyOf(new Date(), await tenantTimezone(ctx)); const from = startOfWeek(day && isDayKey(day) ? day : today); return { from, to: addDays(from, 6), today }; } /** Dashboard tile "Konflikte diese Woche" (Mon–Sun of the current week in the tenant timezone). */ export async function countWeekConflicts(ctx: ServiceCtx): Promise<{ count: number; from: string } | null> { try { const today = dayKeyOf(new Date(), await tenantTimezone(ctx)); const from = startOfWeek(today); const board = await getPlanningBoard(ctx, { from, to: addDays(from, 6) }); return { count: board.conflictCount, from }; } catch (err) { if (err instanceof ServiceError && err.code === "forbidden") return null; throw err; } }