Merge lane/planung in feature/craftvia-mvp

Konflikte gelöst (additiv): Benachrichtigungstexte (Zeiterfassung + Planung, JSON zusammengeführt),
Event-Typen, Navigation, Empfänger-Felder, handle-event (reason inkl. rejectionReason + Planungsfelder),
Smoke-Prüfungen (Teamleiter: Zeiten + Planung).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:15:47 +02:00
co-authored by Claude Opus 5
77 changed files with 6326 additions and 21 deletions
+2
View File
@@ -17,6 +17,7 @@ import {
type LucideIcon,
} from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { PlanningTiles } from "@/components/planning/conflicts-tile";
import { buttonCls } from "@/components/work-orders/button-cls";
import { pageContext } from "@/components/work-orders/page-context";
import { Field, inputCls } from "@/components/work-orders/ui";
@@ -164,6 +165,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
</li>
);
})}
<PlanningTiles ctx={ctx} />
</ul>
</>
)}
+2 -1
View File
@@ -67,7 +67,8 @@ export default async function AppLayout({
</div>
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
{mainNav.map((item) => (
<NavLink key={item.href} href={item.href}>
<NavLink key={`${item.href}:${item.label}`} href={item.href} exact={item.exact}>
{item.sub && <span className="w-4 shrink-0" aria-hidden />}
<item.icon className="size-[18px] opacity-85" />
{t(item.label)}
</NavLink>
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „work_orders" für Plantafel und Live-Lage (L13 Planung). */
export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("work_orders");
return <>{children}</>;
}
+34
View File
@@ -0,0 +1,34 @@
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ShieldCheck } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { LiveSituationView } from "@/components/planning/live-situation";
import { pageContext } from "@/components/work-orders/page-context";
import { ServiceError } from "@/server/services/context";
import { mapConfig } from "@/server/services/geo/config";
import { getLiveSituation } from "@/server/services/planning/live";
/** Live-Lage (L13): technicians at the site of their running order — no GPS tracking. */
export default async function PlanningLivePage() {
const { ctx, locale } = await pageContext();
let initial;
try {
initial = await getLiveSituation(ctx);
} catch (err) {
if (err instanceof ServiceError && err.code === "forbidden") notFound();
throw err;
}
const t = await getTranslations("planning");
const map = mapConfig();
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("live.title")} sub={t("live.sub")} />
<p className="mb-3 flex items-center gap-2 rounded-lg border border-[var(--info)] bg-card px-3 py-2 text-sm">
<ShieldCheck className="size-4 shrink-0 text-[var(--info)]" aria-hidden />
{t("live.privacy")}
</p>
<LiveSituationView initial={initial} tileUrl={map.tileUrl} attribution={map.attribution} locale={locale} />
</main>
);
}
+295
View File
@@ -0,0 +1,295 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ChevronLeft, ChevronRight, Info, MonitorSmartphone, TriangleAlert } from "lucide-react";
import { Modal } from "@/components/modal";
import { PageHead } from "@/components/mockup-ui";
import { CapacityForm } from "@/components/planning/capacity-form";
import { PlanningBoardView, type TeamLive } from "@/components/planning/planning-board";
import { buttonCls } from "@/components/work-orders/button-cls";
import { pageContext } from "@/components/work-orders/page-context";
import { Field, inputCls } from "@/components/work-orders/ui";
import { addDays, addWorkingDays, dayKeyOf, dayRange, isDayKey, nextWorkingDays, startOfWeek, wallClock } from "@/lib/planning/days";
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
import { cn } from "@/lib/utils";
import { saveTeamCapacityAction } from "@/server/actions/work_orders/planning";
import { ServiceError } from "@/server/services/context";
import { planningAccess } from "@/server/services/planning/access";
import { getPlanningBoard } from "@/server/services/planning/board";
import { loadOrder, loadTeams, toBoardOrder } from "@/server/services/planning/data";
import { getLiveSituation } from "@/server/services/planning/live";
import { listOrderTypes } from "@/server/services/work-orders/settings";
type SP = Record<string, string | string[] | undefined>;
type View = "today" | "days" | "week";
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
/**
* Planung (L13): default "Heute + nächste 4 Werktage" (teams as rows, today highlighted with the live
* status of each crew); switcher Heute (day view, teams as parallel columns) · 5 Tage · Woche · Nächste Woche.
*/
export default async function PlanningPage({ searchParams }: { searchParams: Promise<SP> }) {
const { ctx, locale, tz } = await pageContext();
const sp = await searchParams;
let access;
try {
access = await planningAccess(ctx);
} catch (err) {
if (err instanceof ServiceError) notFound();
throw err;
}
const [t, tw, tc] = await Promise.all([getTranslations("planning"), getTranslations("workOrders"), getTranslations("common")]);
const today = dayKeyOf(new Date(), tz);
const viewParam = one(sp.view);
const view: View = viewParam === "today" || viewParam === "day" ? "today" : viewParam === "week" ? "week" : "days";
const date = isDayKey(one(sp.date)) ? (one(sp.date) as string) : today;
const visibleDays = view === "today" ? [date] : view === "week" ? dayRange(startOfWeek(date), addDays(startOfWeek(date), 6)) : nextWorkingDays(date, 5);
const from = visibleDays[0];
const to = visibleDays[visibleDays.length - 1];
const teamId = one(sp.teamId) || undefined;
const orderTypeId = one(sp.orderTypeId) || undefined;
const priorityParam = one(sp.priority);
const priority = (WORK_ORDER_PRIORITIES as readonly string[]).includes(priorityParam ?? "") ? (priorityParam as (typeof WORK_ORDER_PRIORITIES)[number]) : undefined;
const includesToday = visibleDays.includes(today);
const [board, teams, orderTypes, live] = await Promise.all([
getPlanningBoard(ctx, { from, to, teamIds: teamId ? [teamId] : undefined, orderTypeId, priority }),
loadTeams(ctx, access.teamIds),
listOrderTypes(ctx, { activeOnly: true }),
includesToday ? getLiveSituation(ctx, { teamId }).catch(() => null) : Promise.resolve(null),
]);
const teamLive: Record<string, TeamLive> | null = live
? Object.fromEntries(
live.crews.map((c) => [c.teamId, { status: c.status, delay: c.delay, freedMinutes: c.freedMinutes, active: c.members.filter((m) => m.status !== "free").length }]),
)
: null;
const query = (overrides: Record<string, string | undefined>) => {
const qs = new URLSearchParams();
const base: Record<string, string | undefined> = { view: view === "days" ? undefined : view, date: date === today ? undefined : date, teamId, orderTypeId, priority, ...overrides };
for (const [k, v] of Object.entries(base)) if (v) qs.set(k, v);
const s = qs.toString();
return `/planning${s ? `?${s}` : ""}`;
};
const selfHref = query({});
const prevDate = view === "today" ? addDays(date, -1) : view === "week" ? addDays(date, -7) : addWorkingDays(date, -5);
const nextDate = view === "today" ? addDays(date, 1) : view === "week" ? addDays(date, 7) : addWorkingDays(to, 1);
const thisWeek = startOfWeek(today);
const nextWeek = addDays(thisWeek, 7);
const switcher: { key: "today" | "days" | "week" | "nextWeek"; href: string; active: boolean }[] = [
{ key: "today", href: query({ view: "today", date: undefined }), active: view === "today" && date === today },
{ key: "days", href: query({ view: undefined, date: undefined }), active: view === "days" && date === today },
{ key: "week", href: query({ view: "week", date: undefined }), active: view === "week" && startOfWeek(date) === thisWeek },
{ key: "nextWeek", href: query({ view: "week", date: nextWeek }), active: view === "week" && startOfWeek(date) === nextWeek },
];
const navLabels = view === "today" ? [t("board.prevDay"), t("board.nextDay")] : view === "week" ? [t("board.prevWeek"), t("board.nextWeek")] : [t("board.prevDays"), t("board.nextDays")];
// Prefill from the work order detail ("Übernehmen"), live situation ("Vorziehen") or deep links.
let prefill = null;
const scheduleId = one(sp.schedule);
if (scheduleId && access.canSchedule) {
const row = await loadOrder(ctx, scheduleId);
const prefillTeam = one(sp.scheduleTeam);
if (row) {
prefill = {
order: board.orders[row.id] ?? board.unplanned.find((o) => o.id === row.id) ?? toBoardOrder(row, tz),
teamId: prefillTeam && board.teams.some((tm) => tm.id === prefillTeam) ? prefillTeam : null,
day: isDayKey(one(sp.day)) ? (one(sp.day) as string) : null,
time: TIME_RE.test(one(sp.time) ?? "") ? (one(sp.time) as string) : null,
};
}
}
const capacityTeam = access.canManageTeams ? teams.find((tm) => tm.id === one(sp.capacity)) : undefined;
const listDays = board.days.filter((d) => visibleDays.includes(d.key));
return (
<main className="flex-1 p-4 md:p-6">
<PageHead
crumb={t("crumb")}
title={t("board.title")}
sub={t("board.sub")}
actions={
<div className="flex flex-wrap items-center gap-2">
<nav aria-label={t("board.viewLabel")} className="flex flex-wrap rounded-lg border p-0.5">
{switcher.map((s) => (
<Link
key={s.key}
href={s.href}
aria-current={s.active ? "page" : undefined}
className={cn("inline-flex min-h-10 items-center rounded-md px-3 text-sm font-semibold", s.active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}
>
{t(`board.view.${s.key}`)}
</Link>
))}
</nav>
<nav aria-label={t("board.navLabel")} className="flex items-center gap-1">
<Link href={query({ date: prevDate })} aria-label={navLabels[0]} title={navLabels[0]} className={buttonCls("outline")}>
<ChevronLeft className="size-4" aria-hidden />
</Link>
<Link href={query({ date: undefined })} className={buttonCls("outline")}>
{t("board.today")}
</Link>
<Link href={query({ date: nextDate })} aria-label={navLabels[1]} title={navLabels[1]} className={buttonCls("outline")}>
<ChevronRight className="size-4" aria-hidden />
</Link>
</nav>
</div>
}
/>
<p className="mb-3 text-sm font-semibold">
{view === "today"
? date === today
? t("board.todayLabel", { day: formatDayShort(date, locale) })
: formatDayShort(date, locale)
: t("board.range", { from: formatDayShort(from, locale), to: formatDayShort(to, locale) })}
</p>
{!access.all && (
<p className="mb-3 flex items-center gap-1.5 text-[13px] text-muted-foreground">
<Info className="size-4" aria-hidden />
{t("board.readOnly")}
</p>
)}
<details className="shadow-card mb-3 rounded-xl border bg-card" open={Boolean(teamId || orderTypeId || priority)}>
<summary className="flex min-h-11 cursor-pointer items-center px-4 font-heading text-sm font-semibold">{t("filters.title")}</summary>
<form method="get" action="/planning" className="grid gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-4">
{view !== "days" && <input type="hidden" name="view" value={view} />}
{date !== today && <input type="hidden" name="date" value={date} />}
<Field label={t("filters.team")} htmlFor="pf-team">
<select id="pf-team" name="teamId" defaultValue={teamId ?? ""} className={inputCls}>
<option value="">{t("filters.any")}</option>
{teams.map((tm) => (
<option key={tm.id} value={tm.id}>
{tm.name}
</option>
))}
</select>
</Field>
<Field label={t("filters.orderType")} htmlFor="pf-type">
<select id="pf-type" name="orderTypeId" defaultValue={orderTypeId ?? ""} className={inputCls}>
<option value="">{t("filters.any")}</option>
{orderTypes.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</Field>
<Field label={t("filters.priority")} htmlFor="pf-prio">
<select id="pf-prio" name="priority" defaultValue={priority ?? ""} className={inputCls}>
<option value="">{t("filters.any")}</option>
{WORK_ORDER_PRIORITIES.map((p) => (
<option key={p} value={p}>
{tw(`priority.${p}`)}
</option>
))}
</select>
</Field>
<div className="flex items-end gap-2">
<button type="submit" className={buttonCls("default")}>
{t("filters.apply")}
</button>
<Link href={query({ teamId: undefined, orderTypeId: undefined, priority: undefined })} className={buttonCls("ghost")}>
{t("filters.reset")}
</Link>
</div>
</form>
</details>
<p className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<TriangleAlert className="size-3.5" aria-hidden />
{t("conflicts.count", { count: board.conflictCount })}
{board.access.canSchedule && <span className="hidden lg:inline"> · {t("board.dragHint")}</span>}
</p>
<div className="hidden lg:block">
<PlanningBoardView board={board} view={view} visibleDays={visibleDays} locale={locale} prefill={prefill} capacityHref={selfHref} freed={live?.freed ?? null} teamLive={teamLive} />
</div>
{/* Below 1024 px: hint + simple list (no drag & drop). */}
<div className="space-y-3 lg:hidden">
<p className="flex items-start gap-2 rounded-lg border border-[var(--info)] bg-card px-3 py-2 text-sm">
<MonitorSmartphone className="mt-0.5 size-4 shrink-0 text-[var(--info)]" aria-hidden />
{t("board.narrowHint")}
</p>
{listDays.map((d) => (
<section key={d.key} className="shadow-card rounded-xl border bg-card p-3">
<h2 className="font-heading text-[15px] font-semibold">
{formatDayShort(d.key, locale)}
{d.key === today && ` · ${t("board.today")}`}
</h2>
<ul className="mt-1 space-y-2">
{board.teams.map((team) => {
const td = team.days.find((x) => x.day === d.key)!;
const liveTeam = d.key === today ? teamLive?.[team.id] : undefined;
return (
<li key={team.id} className="text-sm">
<p className="font-semibold">
{team.name} ·{" "}
<span className="font-normal text-muted-foreground">
{td.utilization === null
? t("board.loadNoCapacity", { planned: formatMinutes(td.plannedMinutes, locale) })
: t("board.load", { planned: formatMinutes(td.plannedMinutes, locale), capacity: formatMinutes(td.capacityMinutes, locale), percent: td.utilization })}
</span>
{liveTeam && <span className="font-normal"> · {t(`live.status.${liveTeam.status}`)}</span>}
</p>
{td.conflicts.length > 0 && (
<p className="flex items-center gap-1 text-xs font-semibold text-[var(--risk)]">
<TriangleAlert className="size-3.5" aria-hidden />
{td.conflicts.map((c) => t(`conflicts.short.${c.kind}`)).join(", ")}
</p>
)}
{td.orderIds.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("board.listDayEmpty")}</p>
) : (
<ul className="text-xs">
{td.orderIds.map((id) => {
const o = board.orders[id];
return (
<li key={id}>
<Link href={`/work-orders/${id}`} className="inline-flex min-h-11 items-center gap-1.5">
{!o.allDay && o.plannedStart && <span className="tabular-nums">{wallClock(new Date(o.plannedStart), tz)}</span>}
<span className="font-mono font-semibold">{o.number}</span>
<span>{o.customerName}</span>
{o.delay && <span className="font-semibold text-[var(--warn)]">{o.delay.percent} %</span>}
</Link>
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
</section>
))}
<section className="shadow-card rounded-xl border bg-card p-3">
<h2 className="font-heading text-[15px] font-semibold">
{t("unplanned.title")} ({board.unplanned.length})
</h2>
<ul className="mt-1 text-sm">
{board.unplanned.map((o) => (
<li key={o.id}>
<Link href={`/work-orders/${o.id}`} className="inline-flex min-h-11 items-center gap-1.5">
<span className="font-mono font-semibold">{o.number}</span> {o.customerName}
</Link>
</li>
))}
</ul>
</section>
</div>
{capacityTeam && (
<Modal title={t("capacity.title")} sub={`${capacityTeam.name} · ${t("capacity.sub")}`} closeHref={selfHref} closeLabel={tc("close")}>
<CapacityForm action={saveTeamCapacityAction.bind(null, capacityTeam.id, selfHref)} minutes={capacityTeam.dailyCapacityMinutes} workingDays={capacityTeam.workingDays} closeHref={selfHref} />
</Modal>
)}
</main>
);
}
+4
View File
@@ -3,6 +3,8 @@ import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ArrowLeft, Pencil, Siren, UsersRound } from "lucide-react";
import { Modal } from "@/components/modal";
import { PlanningSuggestions } from "@/components/planning/suggestions-section";
import { SCHEDULABLE_STATUSES } from "@/lib/planning/capacity";
import { ActionForm } from "@/components/work-orders/action-form";
import { buttonCls } from "@/components/work-orders/button-cls";
import {
@@ -172,6 +174,8 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
</div>
</header>
{tab === "overview" && canAssign && SCHEDULABLE_STATUSES.includes(wo.status) && <PlanningSuggestions ctx={ctx} workOrderId={wo.id} locale={locale} tz={tz} />}
<div className="mt-4">
<LinkTabs
label={t("detail.tabs.overview")}
+24
View File
@@ -0,0 +1,24 @@
import { requireApiContext } from "@/server/api/context";
import { json, withApi } from "@/server/api/respond";
import { getPlanningBoard, weekRange } from "@/server/services/planning/board";
/**
* GET /api/v1/planning/board?from=YYYY-MM-DD&to=YYYY-MM-DD&teamId=…&orderTypeId=…&priority=…
* Default range: current week (Mon–Sun, tenant timezone), max. 42 days. Backoffice: all teams;
* team leads: own teams (read-only). Access is checked in the service.
*/
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders");
const sp = new URL(req.url).searchParams;
const week = await weekRange(ctx, sp.get("from"));
const teamIds = sp.getAll("teamId").filter(Boolean);
return json(
await getPlanningBoard(ctx, {
from: sp.get("from") ?? week.from,
to: sp.get("to") ?? week.to,
teamIds: teamIds.length ? teamIds : undefined,
orderTypeId: sp.get("orderTypeId") || undefined,
priority: (sp.get("priority") || undefined) as "low" | "normal" | "high" | "urgent" | undefined,
}),
);
});
+19
View File
@@ -0,0 +1,19 @@
import { requireApiContext } from "@/server/api/context";
import { json, withApi } from "@/server/api/respond";
import { getLiveSituation, LIVE_STATUSES, type LiveStatus } from "@/server/services/planning/live";
/**
* GET /api/v1/planning/live?teamId=…&status=en_route|working|paused|free — polled every 30 s by
* /planning/live. Location = site of the running order; never device coordinates.
*/
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders");
const sp = new URL(req.url).searchParams;
const status = sp.get("status");
return json(
await getLiveSituation(ctx, {
teamId: sp.get("teamId") || undefined,
status: status && (LIVE_STATUSES as readonly string[]).includes(status) ? (status as LiveStatus) : undefined,
}),
);
});
@@ -0,0 +1,25 @@
import { requireApiContext } from "@/server/api/context";
import { ApiError, json, withApi } from "@/server/api/respond";
import { findNearbyUnplanned, recommendAssignments } from "@/server/services/planning/recommend";
/**
* GET /api/v1/planning/recommendations?workOrderId=…&days=10&radiusKm=25&nearbyRadiusKm=5&locale=de
* Suggestions only (straight-line distance + free capacity); nothing is saved.
*/
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders", "work_order:assign");
const sp = new URL(req.url).searchParams;
const workOrderId = sp.get("workOrderId");
if (!workOrderId) throw new ApiError("invalid", "workOrderId required");
const locale = sp.get("locale") === "en" ? "en" : "de";
const [recommendations, nearby] = await Promise.all([
recommendAssignments(ctx, {
workOrderId,
days: sp.get("days") ?? undefined,
radiusKm: sp.get("radiusKm") ?? undefined,
locale,
}),
findNearbyUnplanned(ctx, { workOrderId, radiusKm: sp.get("nearbyRadiusKm") ?? undefined }),
]);
return json({ ...recommendations, nearby });
});
+15
View File
@@ -0,0 +1,15 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { scheduleWorkOrder, type ScheduleInput } from "@/server/services/planning/schedule";
/**
* POST /api/v1/planning/schedule — body { workOrderId, teamId, plannedStart, plannedEnd?, plannedDurationMinutes?, baseVersion }.
* Assign + dates in one transaction (L2 services, event work_order.assigned, audit). 409 conflict when
* the order changed meanwhile ("Auftrag wurde zwischenzeitlich geändert"). Response contains the
* conflicts of the target team on the target day(s).
*/
export const POST = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders", "work_order:assign");
const body = await readJsonObject(req);
return json(await scheduleWorkOrder(ctx, body as ScheduleInput));
});
+4 -1
View File
@@ -7,15 +7,18 @@ import { cn } from "@/lib/utils";
export function NavLink({
href,
match,
exact,
children,
}: {
href: string;
match?: string[];
/** L13: only the exact path is active (sub entries such as /planning vs. /planning/live) */
exact?: boolean;
children: React.ReactNode;
}) {
const pathname = usePathname();
const active = (match ?? [href]).some(
(m) => pathname === m || pathname.startsWith(m + "/")
(m) => pathname === m || (!exact && pathname.startsWith(m + "/"))
);
return (
+52
View File
@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { useTranslations } from "next-intl";
import { AlertCircle } from "lucide-react";
import { buttonCls } from "@/components/work-orders/button-cls";
import { Field, inputCls } from "@/components/work-orders/ui";
import type { ActionState } from "@/server/api/action-state";
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
/** Team capacity popup form (L13): minutes per member per working day + working days. */
export function CapacityForm({ action, minutes, workingDays, closeHref }: { action: Action; minutes: number; workingDays: number; closeHref: string }) {
const t = useTranslations("planning");
const [state, formAction, pending] = useActionState(action, { status: "idle" } as ActionState);
const errorText =
state.status === "error" ? (t.has(`capacity.errors.${state.code}`) ? t(`capacity.errors.${state.code}`) : t("capacity.errors.generic")) : null;
return (
<form action={formAction} className="grid gap-4 p-5">
<Field label={t("capacity.minutes")} htmlFor="cap-minutes" hint={t("capacity.minutesHint")}>
<input id="cap-minutes" name="minutes" type="number" min={0} max={1440} step={15} required defaultValue={minutes} className={inputCls} autoFocus />
</Field>
<fieldset>
<legend className="mb-1 text-[13px] font-semibold">{t("capacity.days")}</legend>
<div className="flex flex-wrap gap-x-4">
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<label key={i} className="flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" name="day" value={1 << i} defaultChecked={(workingDays & (1 << i)) !== 0} className="size-5 accent-[var(--ui-primary)]" />
{t(`weekday.${i}`)}
</label>
))}
</div>
</fieldset>
{errorText && (
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)]">
<AlertCircle className="size-4 shrink-0" aria-hidden />
{errorText}
</p>
)}
<div className="flex flex-wrap gap-2">
<button type="submit" disabled={pending} className={buttonCls("primary")}>
{pending ? t("capacity.saving") : t("capacity.save")}
</button>
<Link href={closeHref} className={buttonCls("ghost")}>
{t("dialog.cancel")}
</Link>
</div>
</form>
);
}
@@ -0,0 +1,47 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { CalendarClock, CalendarRange } from "lucide-react";
import type { ServiceCtx } from "@/server/services/context";
import { countWeekConflicts } from "@/server/services/planning/board";
import { getPlanningToday } from "@/server/services/planning/summary";
const tileCls =
"shadow-card flex h-full min-h-24 items-start gap-3 rounded-xl border border-l-4 bg-card p-4 transition-colors hover:bg-muted/40 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none";
/** Dashboard tiles of the planning lane (L13, additive): "Planung heute" + "Konflikte diese Woche"; hidden without access. */
export async function PlanningTiles({ ctx }: { ctx: ServiceCtx }) {
const [today, week] = await Promise.all([getPlanningToday(ctx).catch(() => null), countWeekConflicts(ctx).catch(() => null)]);
if (!today && !week) return null;
const t = await getTranslations("planning");
return (
<>
{today && (
<li>
<Link href="/planning" className={tileCls} style={{ borderLeftColor: today.conflicts > 0 ? "var(--risk)" : "var(--ui-primary)" }}>
<CalendarClock className="mt-0.5 size-5 shrink-0" style={{ color: "var(--ui-primary)" }} aria-hidden />
<div className="min-w-0">
<p className="text-[13px] font-semibold text-muted-foreground">{t("dashboardTile.todayTitle")}</p>
<p className="font-heading text-3xl leading-tight font-bold text-foreground">
{today.teamsWorking}
<span className="text-base font-semibold text-muted-foreground"> / {today.teamsTotal}</span>
</p>
<p className="text-xs text-muted-foreground">{t("dashboardTile.todayHint", { conflicts: today.conflicts, unplanned: today.unplanned })}</p>
</div>
</Link>
</li>
)}
{week && (
<li>
<Link href={`/planning?view=week&date=${week.from}`} className={tileCls} style={{ borderLeftColor: week.count > 0 ? "var(--risk)" : "var(--line)" }}>
<CalendarRange className="mt-0.5 size-5 shrink-0" style={{ color: week.count > 0 ? "var(--risk)" : "var(--txt-muted)" }} aria-hidden />
<div className="min-w-0">
<p className="text-[13px] font-semibold text-muted-foreground">{t("dashboardTile.title")}</p>
<p className="font-heading text-3xl leading-tight font-bold text-foreground">{week.count}</p>
<p className="text-xs text-muted-foreground">{t("dashboardTile.hint")}</p>
</div>
</Link>
</li>
)}
</>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import "leaflet/dist/leaflet.css";
import { useEffect, useRef, useState } from "react";
import type { LayerGroup, Map as LeafletMap } from "leaflet";
import { STATUS_TONE } from "./live-tone";
/**
* Leaflet map of the live situation (L13). Tiles from MAP_TILE_URL (default OpenStreetMap, CSP img-src
* allows only that host), attribution always visible, marker icons as inline SVG (no CDN assets).
* One marker per site; several people at the same site are bundled with a count.
*/
export type LiveMarker = {
key: string;
latitude: number;
longitude: number;
status: "en_route" | "working" | "paused";
count: number;
label: string;
lines: string[];
/** delay likely / exceeded → red ring (text is part of label and popup) */
alert?: boolean;
};
export { STATUS_TONE };
const GLYPH: Record<LiveMarker["status"], string> = {
// truck
en_route: '<path d="M3 7h10v8H3zM13 10h4l3 3v2h-7z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/><circle cx="7" cy="16.5" r="1.6" fill="#fff"/><circle cx="16.5" cy="16.5" r="1.6" fill="#fff"/>',
// wrench
working: '<path d="M14.5 5.5a4 4 0 0 0-5 5L4.5 15.5l2 2 5-5a4 4 0 0 0 5-5l-2.3 2.3-2-2z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/>',
// pause
paused: '<rect x="7" y="6" width="3.2" height="12" rx="1" fill="#fff"/><rect x="13.8" y="6" width="3.2" height="12" rx="1" fill="#fff"/>',
};
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string);
function iconHtml(m: LiveMarker): string {
const tone = STATUS_TONE[m.status];
const badge =
m.count > 1
? `<span style="position:absolute;top:-6px;right:-8px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:var(--brand-graphit);color:#fff;font:700 11px/20px system-ui,sans-serif;text-align:center;border:2px solid #fff">${m.count}</span>`
: "";
return `<div role="img" aria-label="${escapeHtml(m.label)}" style="position:relative;width:34px;height:34px;color:${tone}"><svg width="34" height="34" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="currentColor" stroke="${m.alert ? "var(--risk)" : "#fff"}" stroke-width="${m.alert ? 3 : 2}"/><g transform="translate(1.2 0.6) scale(0.9)">${GLYPH[m.status]}</g></svg>${badge}</div>`;
}
export function LiveMap({ markers, tileUrl, attribution, label, loadingLabel }: { markers: LiveMarker[]; tileUrl: string; attribution: string; label: string; loadingLabel: string }) {
const container = useRef<HTMLDivElement>(null);
const mapRef = useRef<LeafletMap | null>(null);
const layerRef = useRef<LayerGroup | null>(null);
const leafletRef = useRef<typeof import("leaflet") | null>(null);
const fittedRef = useRef<string>("");
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
import("leaflet").then((mod) => {
const L = (mod as unknown as { default?: typeof import("leaflet") }).default ?? mod;
if (cancelled || !container.current || mapRef.current) return;
leafletRef.current = L;
const map = L.map(container.current, { center: [51.1657, 10.4515], zoom: 6, scrollWheelZoom: true });
L.tileLayer(tileUrl, {
maxZoom: 19,
attribution: `${escapeHtml(attribution)} (<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">ODbL</a>)`,
}).addTo(map);
layerRef.current = L.layerGroup().addTo(map);
mapRef.current = map;
setReady(true);
});
return () => {
cancelled = true;
mapRef.current?.remove();
mapRef.current = null;
layerRef.current = null;
};
}, [tileUrl, attribution]);
useEffect(() => {
const L = leafletRef.current;
const map = mapRef.current;
const layer = layerRef.current;
if (!ready || !L || !map || !layer) return;
layer.clearLayers();
for (const m of markers) {
const marker = L.marker([m.latitude, m.longitude], {
icon: L.divIcon({ html: iconHtml(m), className: "", iconSize: [34, 34], iconAnchor: [17, 17], popupAnchor: [0, -16] }),
title: m.label,
alt: m.label,
keyboard: true,
});
marker.bindPopup(`<div style="font:13px/1.4 system-ui,sans-serif">${m.lines.map((l) => `<div>${escapeHtml(l)}</div>`).join("")}</div>`);
marker.addTo(layer);
}
const signature = markers.map((m) => m.key).sort().join("|");
if (markers.length && signature !== fittedRef.current) {
fittedRef.current = signature;
map.fitBounds(L.latLngBounds(markers.map((m) => [m.latitude, m.longitude] as [number, number])), { padding: [40, 40], maxZoom: 14 });
}
}, [markers, ready]);
return (
<div className="relative">
<div ref={container} role="region" aria-label={label} className="h-[62vh] min-h-80 w-full overflow-hidden rounded-xl border bg-muted" />
{!ready && (
<p role="status" className="absolute inset-0 grid place-items-center text-sm text-muted-foreground">
{loadingLabel}
</p>
)}
</div>
);
}
+361
View File
@@ -0,0 +1,361 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { AlarmClock, CircleDashed, Coffee, EyeOff, Hourglass, MapPinOff, RefreshCw, Timer, Truck, UsersRound, Wrench, type LucideIcon } from "lucide-react";
import { formatDistance } from "@/lib/geo/distance";
import { wallClock } from "@/lib/planning/days";
import { formatMinutes } from "@/lib/planning/text";
import { cn } from "@/lib/utils";
import { buttonCls } from "@/components/work-orders/button-cls";
import { inputCls } from "@/components/work-orders/ui";
import type { LiveCrew, LiveDelay, LiveSituation, LiveStatus } from "@/server/services/planning/live";
import { STATUS_TONE, type LiveMarker } from "./live-map";
const LiveMap = dynamic(() => import("./live-map").then((m) => m.LiveMap), { ssr: false });
const STATUS_ICON: Record<LiveStatus, LucideIcon> = { en_route: Truck, working: Wrench, paused: Coffee, free: CircleDashed };
const STATUSES: LiveStatus[] = ["en_route", "working", "paused", "free"];
const POLL_MS = 30_000;
/**
* Live situation (L13): one marker/card per crew at the site of its running order (members with their
* own status), technicians without team individually; delay badges, crews that finished early with
* pull-forward links, filters, polling every 30 s, "zuletzt aktualisiert". No GPS.
*/
export function LiveSituationView({ initial, tileUrl, attribution, locale }: { initial: LiveSituation; tileUrl: string; attribution: string; locale: string }) {
const t = useTranslations("planning");
const [data, setData] = useState(initial);
const [teamId, setTeamId] = useState("");
const [status, setStatus] = useState<LiveStatus | "">("");
const [tab, setTab] = useState<"map" | "list">("map");
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const tz = data.timeZone;
const fetchLive = useCallback(async (filters: { teamId: string; status: LiveStatus | "" }) => {
setLoading(true);
try {
const qs = new URLSearchParams();
if (filters.teamId) qs.set("teamId", filters.teamId);
if (filters.status) qs.set("status", filters.status);
const res = await fetch(`/api/v1/planning/live?${qs}`, { cache: "no-store" });
if (!res.ok) throw new Error(String(res.status));
setData((await res.json()) as LiveSituation);
setFailed(false);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
}, []);
const load = useCallback(() => fetchLive({ teamId, status }), [fetchLive, teamId, status]);
// Polling every 30 s (skipped while the tab is hidden); setState happens in the fetch callback.
useEffect(() => {
const timer = window.setInterval(() => {
if (!document.hidden) void load();
}, POLL_MS);
return () => window.clearInterval(timer);
}, [load]);
const changeFilter = (next: { teamId?: string; status?: LiveStatus | "" }) => {
const filters = { teamId: next.teamId ?? teamId, status: next.status ?? status };
setTeamId(filters.teamId);
setStatus(filters.status);
void fetchLive(filters);
};
const delayText = useCallback(
(d: LiveDelay) => (d.level === "overrun" ? t("live.delayOverrun", { percent: d.percent }) : t("live.delayRisk", { percent: d.percent })),
[t],
);
const solo = data.technicians.filter((x) => !x.crewId);
const markers = useMemo<LiveMarker[]>(() => {
const out: LiveMarker[] = [];
for (const crew of data.crews) {
if (!crew.current || crew.current.latitude === null || crew.current.longitude === null || crew.status === "free") continue;
const active = crew.members.filter((m) => m.status !== "free").length;
const head = `${crew.teamName} · ${crew.current.number} · ${t(`live.status.${crew.status}`)}${crew.delay ? ` · ${delayText(crew.delay)}` : ""}`;
out.push({
key: `crew-${crew.teamId}`,
latitude: crew.current.latitude,
longitude: crew.current.longitude,
status: crew.status as LiveMarker["status"],
count: Math.max(1, active),
alert: !!crew.delay,
label: `${head}: ${t("live.markerCount", { count: active })}`,
lines: [head, crew.current.siteName ?? "", ...crew.members.map((m) => `${m.name} – ${t(`live.status.${m.status}`)}${m.orderNumber && m.orderNumber !== crew.current?.number ? ` (${m.orderNumber})` : ""}`)].filter(Boolean),
});
}
for (const tech of solo) {
if (!tech.current?.visible || tech.current.latitude === null || tech.current.longitude === null || tech.status === "free") continue;
const line = `${tech.name} – ${t(`live.status.${tech.status}`)} – ${tech.current.number}${tech.delay ? ` · ${delayText(tech.delay)}` : ""}`;
out.push({ key: `solo-${tech.userId}`, latitude: tech.current.latitude, longitude: tech.current.longitude, status: tech.status as LiveMarker["status"], count: 1, alert: !!tech.delay, label: line, lines: [tech.current.siteName ?? tech.current.number, line] });
}
return out;
}, [data.crews, solo, t, delayText]);
const statusBadge = (s: LiveStatus) => {
const Icon = STATUS_ICON[s];
return (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold" style={{ color: STATUS_TONE[s], borderColor: STATUS_TONE[s] }}>
<Icon className="size-3.5" aria-hidden />
{t(`live.status.${s}`)}
</span>
);
};
const delayBadge = (d: LiveDelay | null) =>
d && (
<span className={cn("inline-flex items-center gap-1 text-xs font-semibold", d.level === "overrun" ? "text-[var(--risk)]" : "text-[var(--warn)]")} title={t("live.worked", { worked: formatMinutes(d.workedMinutes, locale), planned: formatMinutes(d.plannedMinutes, locale) })}>
<Hourglass className="size-3.5" aria-hidden />
{delayText(d)}
</span>
);
const crewCard = (crew: LiveCrew) => (
<li key={crew.teamId} className="shadow-card rounded-xl border border-l-4 bg-card p-3 text-sm" style={{ borderLeftColor: crew.delay ? (crew.delay.level === "overrun" ? "var(--risk)" : "var(--warn)") : STATUS_TONE[crew.status] }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="flex items-center gap-1.5 font-semibold">
<UsersRound className="size-4" aria-hidden />
{crew.teamName}
</p>
{statusBadge(crew.status)}
</div>
{delayBadge(crew.delay)}
{crew.freedMinutes !== null && (
<p className="flex items-center gap-1 text-xs font-semibold text-[var(--ok)]">
<Timer className="size-3.5" aria-hidden />
{t("freed.badge", { team: crew.teamName, minutes: formatMinutes(crew.freedMinutes, locale) })}
</p>
)}
{crew.current ? (
<div className="mt-1">
<Link href={`/work-orders/${crew.current.id}`} className="font-mono text-xs font-semibold underline-offset-2 hover:underline">
{crew.current.number}
</Link>{" "}
<span className="text-xs">{crew.current.title}</span>
<p className="text-xs">{crew.current.customerName}</p>
<p className="flex items-center gap-1 text-xs text-muted-foreground">
{crew.current.latitude === null && <MapPinOff className="size-3.5" aria-hidden />}
{crew.current.address ?? crew.current.siteName ?? ""}
{crew.current.latitude === null && ` · ${t("live.noLocation")}`}
</p>
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">{t("live.freeHint")}</p>
)}
<p className="mt-1.5 border-t pt-1.5 text-[11px] font-semibold text-muted-foreground">{t("live.crewMembers")}</p>
<ul className="space-y-0.5">
{crew.members.map((m) => (
<li key={m.userId} className="flex flex-wrap items-center gap-x-2 text-xs">
<span className="font-semibold">{m.name}</span>
<span style={{ color: STATUS_TONE[m.status] }}>{t(`live.status.${m.status}`)}</span>
{m.since && <span className="text-muted-foreground">{t("live.since", { time: wallClock(new Date(m.since), tz) })}</span>}
{m.orderNumber && m.orderNumber !== crew.current?.number && <span className="text-muted-foreground">{t("live.memberAt", { number: m.orderNumber })}</span>}
</li>
))}
</ul>
</li>
);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<label className="grid gap-1 text-[13px] font-semibold">
{t("live.filterTeam")}
<select value={teamId} onChange={(e) => changeFilter({ teamId: e.target.value })} className={`${inputCls} min-w-44`}>
<option value="">{t("live.all")}</option>
{data.teams.map((tm) => (
<option key={tm.id} value={tm.id}>
{tm.name}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-[13px] font-semibold">
{t("live.filterStatus")}
<select value={status} onChange={(e) => changeFilter({ status: e.target.value as LiveStatus | "" })} className={`${inputCls} min-w-40`}>
<option value="">{t("live.all")}</option>
{STATUSES.map((s) => (
<option key={s} value={s}>
{t(`live.status.${s}`)}
</option>
))}
</select>
</label>
<button type="button" onClick={() => void load()} disabled={loading} className={buttonCls("outline")}>
<RefreshCw className={cn("size-4", loading && "animate-spin")} aria-hidden />
{loading ? t("live.refreshing") : t("live.refresh")}
</button>
<p role="status" aria-live="polite" className="min-h-11 content-center text-xs text-muted-foreground">
{t("live.updated", { time: wallClock(new Date(data.generatedAt), tz) })}
{failed && <span className="ml-2 font-semibold text-[var(--risk)]">{t("live.updateFailed")}</span>}
</p>
</div>
<ul className="flex flex-wrap gap-2" aria-label={t("live.filterStatus")}>
{STATUSES.map((s) => {
const Icon = STATUS_ICON[s];
return (
<li key={s} className="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold" style={{ color: STATUS_TONE[s], borderColor: STATUS_TONE[s] }}>
<Icon className="size-3.5" aria-hidden />
{t(`live.status.${s}`)}
<span className="text-foreground tabular-nums">{data.counts[s]}</span>
</li>
);
})}
</ul>
{data.freed.length > 0 && (
<section aria-labelledby="live-freed" className="shadow-card rounded-xl border border-l-4 border-l-[var(--ok)] bg-card p-3">
<h2 id="live-freed" className="flex items-center gap-1.5 font-heading text-[15px] font-semibold">
<Timer className="size-4 text-[var(--ok)]" aria-hidden />
{t("freed.title")}
</h2>
<p className="text-xs text-muted-foreground">{t("freed.hint")}</p>
<ul className="mt-1 space-y-2">
{data.freed.map((f) => (
<li key={f.teamId} className="text-sm">
<p className="font-semibold text-[var(--ok)]">{t("freed.badge", { team: f.teamName, minutes: formatMinutes(f.earlyMinutes, locale) })}</p>
{f.availableMinutes > 0 && <p className="text-xs">{t("freed.available", { minutes: formatMinutes(f.availableMinutes, locale), time: wallClock(new Date(f.availableFrom), tz) })}</p>}
{f.suggestions.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("freed.noSuggestions")}</p>
) : (
<ul className="mt-0.5 space-y-0.5">
{f.suggestions.map((s) => (
<li key={`${s.kind}-${s.order.id}`} className="flex flex-wrap items-center gap-2 text-xs">
<span className="font-mono font-semibold">{s.order.number}</span>
<span>{s.order.customerName}</span>
<span className="text-muted-foreground">
{s.kind === "pull_forward" ? t("freed.kindPullForward", { team: f.teamName }) : t("freed.kindNearby", { distance: formatDistance(s.distanceKm ?? 0, locale) })}
</span>
<Link
href={`/planning?view=day&schedule=${s.order.id}&scheduleTeam=${f.teamId}&day=${s.day}&time=${wallClock(new Date(s.suggestedStart), tz)}`}
className="ml-auto inline-flex min-h-11 items-center font-semibold underline-offset-2 hover:underline"
>
{t("freed.pullForward")}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</section>
)}
<div role="tablist" aria-label={t("live.tabsLabel")} className="flex gap-1 border-b lg:hidden">
{(["map", "list"] as const).map((k) => (
<button
key={k}
type="button"
role="tab"
aria-selected={tab === k}
onClick={() => setTab(k)}
className={cn("-mb-px min-h-12 border-b-2 px-4 text-sm font-semibold", tab === k ? "border-[var(--ui-accent)]" : "border-transparent text-muted-foreground")}
>
{t(`live.tabs.${k}`)}
</button>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
<div className={cn(tab !== "map" && "hidden lg:block")}>
<LiveMap markers={markers} tileUrl={tileUrl} attribution={attribution} label={t("live.mapLabel")} loadingLabel={t("live.mapLoading")} />
<p className="mt-1 text-[11px] text-muted-foreground">
{attribution} ·{" "}
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="underline">
openstreetmap.org/copyright
</a>
</p>
{data.withoutLocation > 0 && (
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPinOff className="size-3.5" aria-hidden />
{t("live.withoutLocation", { count: data.withoutLocation })}
</p>
)}
</div>
<div className={cn("space-y-4", tab !== "list" && "hidden lg:block")}>
{data.crews.length === 0 && solo.length === 0 ? (
<p className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">{t("live.empty")}</p>
) : (
<>
{data.crews.length > 0 && (
<section aria-labelledby="live-crews">
<h2 id="live-crews" className="mb-1 font-heading text-[15px] font-semibold">
{t("live.crewsTitle")}
</h2>
<ul className="space-y-2">{data.crews.map(crewCard)}</ul>
</section>
)}
{solo.length > 0 && (
<section aria-labelledby="live-solo">
<h2 id="live-solo" className="mb-1 font-heading text-[15px] font-semibold">
{t("live.soloTitle")}
</h2>
<ul className="space-y-2">
{solo.map((tech) => (
<li key={tech.userId} className="shadow-card rounded-xl border border-l-4 bg-card p-3 text-sm" style={{ borderLeftColor: STATUS_TONE[tech.status] }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-semibold">{tech.name}</p>
{statusBadge(tech.status)}
</div>
{tech.since && <p className="text-xs text-muted-foreground">{t("live.since", { time: wallClock(new Date(tech.since), tz) })}</p>}
{delayBadge(tech.delay)}
{tech.current === null && <p className="mt-1 text-xs text-muted-foreground">{t("live.freeHint")}</p>}
{tech.current && !tech.current.visible && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<EyeOff className="size-3.5" aria-hidden />
{t("live.hiddenOrder")}
</p>
)}
{tech.current?.visible && (
<p className="mt-1 text-xs">
<Link href={`/work-orders/${tech.current.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
{tech.current.number}
</Link>{" "}
{tech.current.customerName} · {tech.current.address ?? t("live.noLocation")}
</p>
)}
</li>
))}
</ul>
</section>
)}
</>
)}
<section aria-labelledby="live-attention" className="shadow-card rounded-xl border bg-card p-3">
<h2 id="live-attention" className="flex items-center gap-1.5 font-heading text-[15px] font-semibold">
<AlarmClock className="size-4 text-[var(--warn)]" aria-hidden />
{t("live.attentionTitle")}
</h2>
{data.attention.length === 0 ? (
<p className="mt-1 text-xs text-muted-foreground">{t("live.attentionEmpty")}</p>
) : (
<ul className="mt-1 space-y-1.5">
{data.attention.map((a) => (
<li key={a.id} className="text-xs">
<span className="font-semibold text-[var(--warn)]">{t(`live.reason.${a.reason}`)}</span> ·{" "}
<span className="tabular-nums">{wallClock(new Date(a.plannedStart), tz)}</span>{" "}
<Link href={`/work-orders/${a.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
{a.number}
</Link>{" "}
{a.customerName}
{a.teamName && ` · ${a.teamName}`}
</li>
))}
</ul>
)}
</section>
</div>
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { CircleDashed, Coffee, Truck, Wrench, type LucideIcon } from "lucide-react";
/** Live status tones + icons (L13), shared by the live map, the live list and the planning board. Always combined with text. */
export type LiveStatusKey = "en_route" | "working" | "paused" | "free";
export const STATUS_TONE: Record<LiveStatusKey, string> = {
en_route: "var(--info)",
working: "var(--ui-accent)",
paused: "var(--warn)",
free: "var(--txt-muted)",
};
export const STATUS_ICON: Record<LiveStatusKey, LucideIcon> = { en_route: Truck, working: Wrench, paused: Coffee, free: CircleDashed };
+689
View File
@@ -0,0 +1,689 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { DndContext, KeyboardSensor, PointerSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { CalendarPlus, CheckCircle2, ChevronDown, ChevronRight, GripVertical, Hourglass, Info, Lightbulb, MapPinOff, Settings2, Siren, Timer, TriangleAlert } from "lucide-react";
import { formatDistance } from "@/lib/geo/distance";
import { SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/planning/capacity";
import { dayKeyOf, minutesOfDay, wallClock } from "@/lib/planning/days";
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
import { STATUS_GROUP_TONE } from "@/lib/work-orders/status";
import { cn } from "@/lib/utils";
import { buttonCls } from "@/components/work-orders/button-cls";
import { GroupIcon, TONE_VAR } from "@/components/work-orders/ui";
import type { PlanningBoard } from "@/server/services/planning/board";
import type { BoardOrder } from "@/server/services/planning/data";
import type { LiveDelay, LiveStatus } from "@/server/services/planning/live";
import type { FreedTeam } from "@/server/services/planning/watch";
import { STATUS_ICON, STATUS_TONE } from "./live-tone";
import { RecommendationsPanel, type Recommendation } from "./recommendations-panel";
import { ScheduleDialog, type DialogState, type ScheduleResponse } from "./schedule-dialog";
/**
* Planning board (L13, desktop ≥ 1024 px).
* - "days" (default: today + next 4 working days) / "week": rows = crews (teams) with member short list,
* columns = days (today highlighted, with the live status of each crew), cells with order cards,
* crew utilization bar, conflicts (red edge + icon + text), hints (amber), delay / at-risk badges.
* - "today": crews as PARALLEL COLUMNS, time running vertically (6–20 h), overlapping orders side by side.
* Unplanned orders in the sidebar; crews that finished early with pull-forward suggestions. Drag & drop
* (dnd-kit, mouse + keyboard sensor on the grip) or "Einplanen …" open the confirmation popover — nothing
* is ever saved or moved automatically.
*/
const HOUR_PX = 72;
const START_H = 6;
const END_H = 20;
const HOURS = Array.from({ length: END_H - START_H }, (_, i) => START_H + i);
export type TeamLive = { status: LiveStatus; delay: LiveDelay | null; freedMinutes: number | null; active: number };
type Prefill = { order: BoardOrder; teamId: string | null; day: string | null; time: string | null } | null;
type T = ReturnType<typeof useTranslations<"planning">>;
type TeamDayT = PlanningBoard["teams"][number]["days"][number];
function layoutLanes(orders: BoardOrder[], tz: string) {
const lo = START_H * 60;
const hi = END_H * 60;
const sorted = orders
.map((o) => {
const s = minutesOfDay(new Date(o.plannedStart!), tz);
return { o, s, e: s + o.durationMinutes };
})
.sort((a, b) => a.s - b.s);
const laneEnds: number[] = [];
const items = sorted.map((it) => {
let lane = laneEnds.findIndex((end) => end <= it.s);
if (lane < 0) {
lane = laneEnds.length;
laneEnds.push(it.e);
} else {
laneEnds[lane] = it.e;
}
const s = Math.min(Math.max(it.s, lo), hi - 60);
const e = Math.max(Math.min(it.e, hi), s + 60);
return { o: it.o, lane, top: ((s - lo) / 60) * HOUR_PX, height: ((e - s) / 60) * HOUR_PX };
});
return { items, lanes: Math.max(1, laneEnds.length) };
}
export function PlanningBoardView({
board,
view,
visibleDays,
locale,
prefill,
capacityHref,
freed,
teamLive,
}: {
board: PlanningBoard;
view: "today" | "days" | "week";
visibleDays: string[];
locale: string;
prefill: Prefill;
capacityHref: string;
freed: FreedTeam[] | null;
teamLive: Record<string, TeamLive> | null;
}) {
const t = useTranslations("planning");
const tw = useTranslations("workOrders");
const router = useRouter();
const tz = board.timeZone;
const canSchedule = board.access.canSchedule;
const initialDialog = (order: BoardOrder, teamId: string | null, day: string | null, time: string | null): DialogState => {
const start = order.plannedStart ? new Date(order.plannedStart) : null;
return {
order,
teamId: teamId ?? order.teamId ?? board.teams[0]?.id ?? "",
day: day ?? (start ? dayKeyOf(start, tz) : board.from),
time: time ?? (start && !order.allDay ? wallClock(start, tz) : "08:00"),
duration: order.plannedDurationMinutes ?? order.durationMinutes,
};
};
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [dialog, setDialog] = useState<DialogState | null>(() => (prefill && canSchedule ? initialDialog(prefill.order, prefill.teamId, prefill.day, prefill.time) : null));
const [panelFor, setPanelFor] = useState<string | null>(null);
const [notice, setNotice] = useState<{ tone: "ok" | "warn" | "error"; text: string } | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor));
const allOrders = useMemo(() => {
const m = new Map<string, BoardOrder>(Object.entries(board.orders));
for (const o of board.unplanned) m.set(o.id, o);
for (const f of freed ?? []) for (const s of f.suggestions) if (!m.has(s.order.id)) m.set(s.order.id, s.order);
if (prefill) m.set(prefill.order.id, prefill.order);
return m;
}, [board, prefill, freed]);
const userNames = useMemo(() => new Map(board.teams.flatMap((tm) => tm.members.map((m) => [m.userId, m.name] as const))), [board.teams]);
const teamOptions = board.teams.map((tm) => ({ id: tm.id, name: tm.name }));
const dayCols = board.days.filter((d) => visibleDays.includes(d.key));
const open = (order: BoardOrder, teamId: string | null, day: string | null, time: string | null) => {
if (!canSchedule || !SCHEDULABLE_STATUSES.includes(order.status)) return;
setNotice(null);
setDialog(initialDialog(order, teamId, day, time));
};
const onDragEnd = (e: DragEndEvent) => {
const order = allOrders.get(String(e.active.id).replace(/^order:/, ""));
if (!order || !e.over) return;
const [kind, teamId, day, hour] = String(e.over.id).split(":");
if (kind === "cell") open(order, teamId, day, null);
if (kind === "slot") open(order, teamId, day, `${hour.padStart(2, "0")}:00`);
};
const num = (id?: string) => (id && allOrders.get(id)?.number) || "…";
const conflictText = (c: PlanningConflict) => {
switch (c.kind) {
case "overbooked":
return t("conflicts.overbooked", { over: formatMinutes(c.minutesOver ?? 0, locale) });
case "overlap":
return t("conflicts.overlap", { a: num(c.orderIds[0]), b: num(c.orderIds[1]) });
case "assignee_double_booked":
return t("conflicts.assignee_double_booked", { user: userNames.get(c.userId ?? "") ?? "—", a: num(c.orderIds[0]), b: num(c.orderIds[1]) });
case "crew_incomplete":
return t("conflicts.crew_incomplete", { count: c.memberCount ?? 0 });
default:
return t("conflicts.outside_working_days");
}
};
const onSaved = (res: ScheduleResponse) => {
setDialog(null);
const vars = { number: res.number, team: board.teams.find((tm) => tm.id === res.teamId)?.name ?? "", day: formatDayShort(dayKeyOf(new Date(res.plannedStart), tz), locale) };
const count = res.conflicts.filter((c) => c.severity === "conflict").length;
setNotice(count ? { tone: "warn", text: t("board.savedWithConflicts", { ...vars, count }) } : { tone: "ok", text: t("board.saved", vars) });
router.refresh();
};
const onConflict = (text: string) => {
setDialog(null);
setNotice({ tone: "error", text });
router.refresh();
};
const toggle = (teamId: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(teamId)) next.delete(teamId);
else next.add(teamId);
return next;
});
const cardProps = { canSchedule, locale, tz, onOpen: open, t, tw };
const loadLine = (td: TeamDayT) => {
if (!td.workingDay && td.plannedMinutes === 0) return <p className="text-[11px] text-muted-foreground">{t("board.notWorkingDay")}</p>;
const tone = td.utilization === null || td.utilization > 100 ? "var(--risk)" : td.utilization >= 85 ? "var(--warn)" : "var(--ok)";
const label =
td.utilization === null
? t("board.loadNoCapacity", { planned: formatMinutes(td.plannedMinutes, locale) })
: t("board.load", { planned: formatMinutes(td.plannedMinutes, locale), capacity: formatMinutes(td.capacityMinutes, locale), percent: td.utilization });
return (
<div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
<div className="h-full rounded-full" style={{ width: `${Math.min(100, td.utilization ?? 100)}%`, background: tone }} />
</div>
<p className="mt-0.5 text-[11px] font-semibold tabular-nums">{label}</p>
</div>
);
};
const conflictList = (conflicts: PlanningConflict[]) =>
conflicts.length > 0 && (
<ul className="space-y-0.5">
{conflicts.map((c, i) => {
const hint = c.severity === "hint";
const Icon = hint ? Info : TriangleAlert;
return (
<li key={i} className={cn("flex items-start gap-1 text-[11px] font-semibold", hint ? "text-[var(--warn)]" : "text-[var(--risk)]")} title={conflictText(c)}>
<Icon className="mt-px size-3.5 shrink-0" aria-hidden />
<span>
<span className="sr-only">{hint ? t("conflicts.hintLabel") : t("conflicts.label")}: </span>
{conflictText(c)}
</span>
</li>
);
})}
</ul>
);
const hasConflict = (conflicts: PlanningConflict[]) => conflicts.some((c) => c.severity === "conflict");
const liveLine = (teamId: string, teamName: string) => {
const live = teamLive?.[teamId];
if (!live) return null;
const Icon = STATUS_ICON[live.status];
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 rounded-md bg-muted/60 px-1.5 py-0.5 text-[11px] font-semibold">
<span className="inline-flex items-center gap-1" style={{ color: STATUS_TONE[live.status] }}>
<Icon className="size-3.5" aria-hidden />
{t(`live.status.${live.status}`)}
{live.active > 0 && <span className="text-foreground">· {live.active}</span>}
</span>
{live.delay && (
<span className={cn("inline-flex items-center gap-1", live.delay.level === "overrun" ? "text-[var(--risk)]" : "text-[var(--warn)]")}>
<Hourglass className="size-3.5" aria-hidden />
{live.delay.level === "overrun" ? t("board.delayOverrun", { percent: live.delay.percent }) : t("board.delayRisk", { percent: live.delay.percent })}
</span>
)}
{live.freedMinutes !== null && (
<span className="inline-flex items-center gap-1 text-[var(--ok)]">
<Timer className="size-3.5" aria-hidden />
{t("freed.badge", { team: teamName, minutes: formatMinutes(live.freedMinutes, locale) })}
</span>
)}
</div>
);
};
const memberOrders = (userId: string, day: string) =>
Object.values(board.orders).filter((o) => o.assigneeIds.includes(userId) && o.plannedStart && dayKeyOf(new Date(o.plannedStart), tz) === day);
const showFreed = freed && freed.length > 0 && (view === "today" || visibleDays.includes(board.today));
return (
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
<div data-planning-view={view} data-day-count={view === "today" ? 1 : dayCols.length}>
<p role="status" aria-live="polite" className={cn("min-h-6 text-sm font-semibold", notice?.tone === "ok" && "text-[var(--ok)]", notice?.tone === "warn" && "text-[var(--warn)]", notice?.tone === "error" && "text-[var(--risk)]")}>
{notice && (
<span className="inline-flex items-center gap-1.5">
{notice.tone === "ok" ? <CheckCircle2 className="size-4" aria-hidden /> : <TriangleAlert className="size-4" aria-hidden />}
{notice.text}
</span>
)}
</p>
{showFreed && (
<section aria-labelledby="freed-title" className="shadow-card mb-3 rounded-xl border border-l-4 border-l-[var(--ok)] bg-card p-3">
<h2 id="freed-title" className="flex items-center gap-1.5 font-heading text-[15px] font-semibold">
<Timer className="size-4 text-[var(--ok)]" aria-hidden />
{t("freed.title")}
</h2>
<p className="text-xs text-muted-foreground">{t("freed.hint")}</p>
<ul className="mt-2 grid gap-3 xl:grid-cols-2">
{freed!.map((f) => (
<li key={f.teamId} className="rounded-lg border p-2 text-sm">
<p className="font-semibold text-[var(--ok)]">{t("freed.badge", { team: f.teamName, minutes: formatMinutes(f.earlyMinutes, locale) })}</p>
{f.availableMinutes > 0 && <p className="text-xs">{t("freed.available", { minutes: formatMinutes(f.availableMinutes, locale), time: wallClock(new Date(f.availableFrom), tz) })}</p>}
{f.suggestions.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("freed.noSuggestions")}</p>
) : (
<ul className="mt-1 space-y-1">
{f.suggestions.map((s) => (
<li key={`${s.kind}-${s.order.id}`} className="flex flex-wrap items-center gap-2 text-xs">
<Link href={`/work-orders/${s.order.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
{s.order.number}
</Link>
<span>{s.order.customerName}</span>
<span className="text-muted-foreground">
· {formatMinutes(s.order.durationMinutes, locale)} ·{" "}
{s.kind === "pull_forward" ? t("freed.kindPullForward", { team: f.teamName }) : t("freed.kindNearby", { distance: formatDistance(s.distanceKm ?? 0, locale) })}
</span>
{canSchedule && SCHEDULABLE_STATUSES.includes(s.order.status) && (
<button type="button" onClick={() => open(s.order, f.teamId, s.day, wallClock(new Date(s.suggestedStart), tz))} className={`${buttonCls("outline")} ml-auto`}>
{t("freed.pullForward")}
</button>
)}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</section>
)}
<div className="flex items-start gap-4">
<div className="shadow-card min-w-0 flex-1 overflow-x-auto rounded-xl border bg-card">
{board.teams.length === 0 ? (
<p className="p-6 text-sm text-muted-foreground">{t("board.noTeams")}</p>
) : view !== "today" ? (
<div role="table" aria-label={t("board.title")} className="grid min-w-[980px]" style={{ gridTemplateColumns: `220px repeat(${dayCols.length}, minmax(150px, 1fr))` }}>
<div role="row" className="contents">
<div role="columnheader" className="sticky left-0 z-10 border-b bg-card p-2 text-xs font-semibold text-muted-foreground">
{t("board.team")}
</div>
{dayCols.map((d) => (
<div role="columnheader" key={d.key} className={cn("border-b border-l p-2 text-xs font-semibold", d.key === board.today && "border-t-4 border-t-[var(--ui-accent)] bg-[var(--ui-primary-soft)]")}>
{d.key === board.today && <span className="mr-1 text-[var(--ui-primary)]">{t("board.today")} ·</span>}
{formatDayShort(d.key, locale)}
</div>
))}
</div>
{board.teams.map((team) => (
<TeamRows key={team.id}>
<div role="row" className="contents">
<TeamHeader team={team} expanded={expanded.has(team.id)} onToggle={() => toggle(team.id)} canManage={board.access.canManageTeams} capacityHref={capacityHref} t={t} locale={locale} />
{team.days
.filter((td) => visibleDays.includes(td.day))
.map((td) => (
<DropCell
key={td.day}
id={`cell:${team.id}:${td.day}`}
disabled={!canSchedule}
className={cn(
"flex min-h-32 flex-col gap-1.5 border-b border-l p-1.5",
!td.workingDay && "bg-muted/50",
td.day === board.today && "bg-[var(--ui-primary-soft)]",
hasConflict(td.conflicts) && "border-2 border-[var(--risk)]",
)}
>
{td.day === board.today && liveLine(team.id, team.name)}
{loadLine(td)}
{conflictList(td.conflicts)}
{td.orderIds.map((id) => board.orders[id] && <OrderCard key={id} order={board.orders[id]} {...cardProps} />)}
</DropCell>
))}
</div>
{expanded.has(team.id) &&
team.members.map((m) => (
<div role="row" key={m.userId} className="contents">
<div role="rowheader" className="sticky left-0 z-10 border-b bg-card py-1.5 pr-2 pl-6 text-xs">
<p className="font-semibold">{m.name}</p>
<p className="text-muted-foreground">{t("board.memberHint")}</p>
</div>
{dayCols.map((d) => (
<div role="cell" key={d.key} className="border-b border-l p-1.5 text-[11px]">
{memberOrders(m.userId, d.key).map((o) => (
<Link key={o.id} href={`/work-orders/${o.id}`} className="flex min-h-11 items-center gap-1 rounded px-1 hover:bg-muted">
<span className="font-mono font-semibold">{o.number}</span>
{!o.allDay && o.plannedStart && <span>{wallClock(new Date(o.plannedStart), tz)}</span>}
</Link>
))}
</div>
))}
</div>
))}
</TeamRows>
))}
</div>
) : (
<div role="table" aria-label={t("board.title")} className="grid min-w-max" style={{ gridTemplateColumns: `56px repeat(${board.teams.length}, minmax(240px, 1fr))` }}>
<div role="row" className="contents">
<div role="columnheader" className="sticky left-0 z-20 border-b bg-card p-2 text-[11px] font-semibold text-muted-foreground">
{t("board.time")}
</div>
{board.teams.map((team) => {
const td = team.days[0];
return (
<div role="columnheader" key={team.id} className={cn("space-y-1 border-b border-l p-2", hasConflict(td.conflicts) && "border-t-4 border-t-[var(--risk)]")}>
<TeamHeader team={team} expanded={false} onToggle={null} canManage={board.access.canManageTeams} capacityHref={capacityHref} t={t} locale={locale} bare />
{td.day === board.today && liveLine(team.id, team.name)}
{loadLine(td)}
{conflictList(td.conflicts)}
</div>
);
})}
</div>
<div role="row" className="contents">
<div role="rowheader" className="sticky left-0 z-10 border-b bg-card p-1 text-[11px] font-semibold text-muted-foreground">
{t("board.allDay")}
</div>
{board.teams.map((team) => {
const td = team.days[0];
const untimed = td.orderIds.map((id) => board.orders[id]).filter((o) => o && (o.allDay || o.multiDay));
return (
<DropCell key={team.id} id={`cell:${team.id}:${td.day}`} disabled={!canSchedule} className="flex min-h-14 flex-col gap-1 border-b border-l p-1">
{untimed.map((o) => (
<OrderCard key={o.id} order={o} {...cardProps} />
))}
</DropCell>
);
})}
</div>
<div role="row" className="contents">
<div role="rowheader" className="sticky left-0 z-10 bg-card" style={{ height: HOURS.length * HOUR_PX, position: "sticky" }}>
{HOURS.map((h, i) => (
<span key={h} className="absolute right-1 text-[11px] text-muted-foreground tabular-nums" style={{ top: i * HOUR_PX + 2 }}>
{h}:00
</span>
))}
</div>
{board.teams.map((team) => {
const td = team.days[0];
const timed = td.orderIds.map((id) => board.orders[id]).filter((o) => o && !o.allDay && !o.multiDay && o.plannedStart);
const { items, lanes } = layoutLanes(timed, tz);
return (
<div role="cell" key={team.id} className="relative border-l" style={{ height: HOURS.length * HOUR_PX }}>
{HOURS.map((h, i) => (
<DropCell key={h} plain id={`slot:${team.id}:${td.day}:${h}`} disabled={!canSchedule} label={`${team.name} ${h}:00`} className="absolute inset-x-0 border-t" style={{ top: i * HOUR_PX, height: HOUR_PX }} />
))}
{items.map(({ o, lane, top, height }) => (
<div key={o.id} className="absolute z-10 p-0.5" style={{ top, height, left: `${(lane / lanes) * 100}%`, width: `${100 / lanes}%` }}>
<OrderCard order={o} {...cardProps} compact />
</div>
))}
</div>
);
})}
</div>
</div>
)}
</div>
<aside aria-labelledby="unplanned-title" className="w-80 shrink-0 space-y-2">
<h2 id="unplanned-title" className="font-heading text-[15px] font-semibold">
{t("unplanned.title")} <span className="text-muted-foreground">({board.unplanned.length})</span>
</h2>
{board.unplanned.length === 0 && <p className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">{t("unplanned.empty")}</p>}
<ul className="space-y-3">
{board.unplanned.map((o) => (
<li key={o.id} className="space-y-1.5">
<OrderCard order={o} {...cardProps} unplanned />
{canSchedule && (
<button type="button" aria-expanded={panelFor === o.id} onClick={() => setPanelFor(panelFor === o.id ? null : o.id)} className={`${buttonCls("ghost")} w-full`}>
<Lightbulb className="size-4" aria-hidden />
{t("unplanned.suggestions")}
</button>
)}
{panelFor === o.id && (
<RecommendationsPanel
workOrderId={o.id}
locale={locale}
onClose={() => setPanelFor(null)}
onTake={(r: Recommendation) => open(o, r.teamId, r.day, wallClock(new Date(r.suggestedStart), tz))}
onPlanTogether={(nearby, r, required) =>
open(nearby, r?.teamId ?? null, r?.day ?? null, r ? wallClock(new Date(new Date(r.suggestedStart).getTime() + required * 60_000), tz) : null)
}
/>
)}
</li>
))}
</ul>
</aside>
</div>
</div>
{dialog && (
<ScheduleDialog key={`${dialog.order.id}-${dialog.teamId}-${dialog.day}-${dialog.time}`} state={dialog} teams={teamOptions} timeZone={tz} locale={locale} onClose={() => setDialog(null)} onSaved={onSaved} onConflict={onConflict} />
)}
</DndContext>
);
}
function TeamRows({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function TeamHeader({
team,
expanded,
onToggle,
canManage,
capacityHref,
t,
locale,
bare,
}: {
team: PlanningBoard["teams"][number];
expanded: boolean;
onToggle: (() => void) | null;
canManage: boolean;
capacityHref: string;
t: T;
locale: string;
bare?: boolean;
}) {
const names = team.members.map((m) => m.name).join(", ");
const shortNames = team.members.map((m) => m.name.split(" ")[0]).join(", ");
const body = (
<>
<div className="flex items-start justify-between gap-1">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{team.name}</p>
{shortNames && (
<p className="truncate text-[11px]" title={names}>
{shortNames}
</p>
)}
<p className="text-[11px] text-muted-foreground">{t("board.crewCapacity", { capacity: formatMinutes(team.dailyCapacityMinutes, locale), count: team.members.length })}</p>
</div>
{canManage && (
<Link href={`${capacityHref}${capacityHref.includes("?") ? "&" : "?"}capacity=${team.id}`} scroll={false} aria-label={t("board.capacityOf", { team: team.name })} title={t("board.capacity")} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
<Settings2 className="size-4" aria-hidden />
</Link>
)}
</div>
{onToggle && team.members.length > 0 && (
<button type="button" aria-expanded={expanded} onClick={onToggle} className="mt-1 inline-flex min-h-11 items-center gap-1 rounded-md px-1 text-xs font-semibold text-muted-foreground hover:bg-muted hover:text-foreground">
{expanded ? <ChevronDown className="size-3.5" aria-hidden /> : <ChevronRight className="size-3.5" aria-hidden />}
{expanded ? t("board.hideMembers", { team: team.name }) : t("board.showMembers", { team: team.name })}
</button>
)}
</>
);
if (bare) return body;
return (
<div role="rowheader" className="sticky left-0 z-10 border-b bg-card p-2">
{body}
</div>
);
}
function DropCell({
id,
disabled,
className,
style,
label,
plain,
children,
}: {
id: string;
disabled: boolean;
className?: string;
style?: React.CSSProperties;
label?: string;
plain?: boolean;
children?: React.ReactNode;
}) {
const { setNodeRef, isOver } = useDroppable({ id, disabled });
return (
<div ref={setNodeRef} role={plain ? undefined : "cell"} aria-label={label} className={cn(className, isOver && "bg-[var(--ui-primary-soft)] outline-2 outline-[var(--ui-accent)]")} style={style}>
{children}
</div>
);
}
function OrderCard({
order,
canSchedule,
locale,
tz,
onOpen,
t,
tw,
unplanned,
compact,
}: {
order: BoardOrder;
canSchedule: boolean;
locale: string;
tz: string;
onOpen: (order: BoardOrder, teamId: string | null, day: string | null, time: string | null) => void;
t: T;
tw: ReturnType<typeof useTranslations<"workOrders">>;
unplanned?: boolean;
compact?: boolean;
}) {
const movable = canSchedule && SCHEDULABLE_STATUSES.includes(order.status);
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `order:${order.id}`, disabled: !movable });
const conflict = order.conflictKinds.length > 0;
const alert = conflict || !!order.atRisk || order.delay?.level === "overrun";
const tone = TONE_VAR[STATUS_GROUP_TONE[order.statusGroup]];
const style: React.CSSProperties = {
borderLeftColor: alert ? "var(--risk)" : order.delay ? "var(--warn)" : tone,
...(transform ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, zIndex: 60, position: "relative" } : {}),
};
const time = order.plannedStart && !order.allDay ? wallClock(new Date(order.plannedStart), tz) : null;
const conflictLabel = order.conflictKinds.map((k) => t(`conflicts.short.${k}` as "conflicts.short.overbooked")).join(", ");
const grip = movable && (
<button type="button" {...listeners} {...attributes} aria-label={`${order.number} – ${t("board.move")}`} className="-m-0.5 grid size-8 shrink-0 cursor-grab touch-none place-items-center rounded text-muted-foreground hover:bg-muted active:cursor-grabbing">
<GripVertical className="size-4" aria-hidden />
</button>
);
const delayLine = order.delay && (
<p className={cn("inline-flex items-center gap-1 font-semibold", order.delay.level === "overrun" ? "text-[var(--risk)]" : "text-[var(--warn)]")}>
<Hourglass className="size-3.5 shrink-0" aria-hidden />
{order.delay.level === "overrun" ? t("board.delayOverrun", { percent: order.delay.percent }) : t("board.delayRisk", { percent: order.delay.percent })}
</p>
);
const atRiskLine = order.atRisk && (
<p className="flex items-center gap-1 font-semibold text-[var(--risk)]">
<TriangleAlert className="size-3.5 shrink-0" aria-hidden />
{t("board.atRisk", { number: order.atRisk.byNumber })}
</p>
);
if (compact) {
return (
<article ref={setNodeRef} style={style} className={cn("flex h-full flex-col overflow-hidden rounded-md border border-l-4 bg-card px-1 text-[11px] shadow-sm", conflict && "border-[var(--risk)]", isDragging && "opacity-80 shadow-lg")}>
<div className="flex items-center gap-0.5">
{grip}
<Link href={`/work-orders/${order.id}`} className="font-mono font-semibold underline-offset-2 hover:underline" title={order.title}>
{order.number}
</Link>
{conflict && (
<span className="text-[var(--risk)]" title={conflictLabel}>
<TriangleAlert className="size-3.5" aria-hidden />
<span className="sr-only">{conflictLabel}</span>
</span>
)}
{movable && (
<button type="button" onClick={() => onOpen(order, null, null, null)} aria-label={`${order.number} – ${t("board.move")}`} title={t("board.move")} className="ml-auto grid size-11 shrink-0 place-items-center rounded hover:bg-muted">
<CalendarPlus className="size-4" aria-hidden />
</button>
)}
</div>
<p className="truncate font-semibold">{order.customerName}</p>
<p className="truncate text-muted-foreground">
{time} · {formatMinutes(order.durationMinutes, locale)} · <span style={{ color: tone }}>{tw(`statusGroup.${order.statusGroup}`)}</span>
</p>
{delayLine}
{atRiskLine}
</article>
);
}
return (
<article ref={setNodeRef} style={style} className={cn("rounded-lg border border-l-4 bg-card p-1.5 text-xs shadow-sm", conflict && "border-[var(--risk)]", isDragging && "opacity-80 shadow-lg")}>
<div className="flex items-start gap-1">
{grip}
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1">
<Link href={`/work-orders/${order.id}`} className="font-mono font-semibold underline-offset-2 hover:underline" title={t("board.open")}>
{order.number}
</Link>
{order.isEmergency && <Siren className="size-3.5 text-[var(--risk)]" aria-label={t("board.emergency")} />}
{conflict && (
<span className="ml-auto inline-flex items-center gap-0.5 font-semibold text-[var(--risk)]" title={conflictLabel}>
<TriangleAlert className="size-3.5" aria-hidden />
<span className="sr-only">{conflictLabel}</span>
</span>
)}
</p>
<p className="truncate font-semibold" title={order.title}>
{order.customerName}
</p>
<p className="truncate text-muted-foreground" title={order.siteAddress ?? order.siteName ?? ""}>
{order.siteAddress ?? order.siteName ?? "—"}
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-muted-foreground">
{time && <span className="tabular-nums">{time}</span>}
<span>{formatMinutes(order.durationMinutes, locale)}</span>
{order.multiDay && <span>· {t("board.multiDay")}</span>}
<span className="inline-flex items-center gap-0.5 font-semibold" style={{ color: tone }}>
<GroupIcon group={order.statusGroup} className="size-3" />
{tw(`statusGroup.${order.statusGroup}`)}
</span>
{(order.priority === "high" || order.priority === "urgent") && <span className="font-semibold text-[var(--warn)]">{tw(`priority.${order.priority}`)}</span>}
</p>
{delayLine}
{atRiskLine}
{unplanned && (
<p className="mt-0.5 flex flex-wrap gap-x-2 text-[11px] text-muted-foreground">
{!order.teamId && <span>{t("unplanned.noTeam")}</span>}
{!order.plannedStart && <span>{t("unplanned.noDate")}</span>}
{!order.hasCoordinates && (
<span className="inline-flex items-center gap-0.5">
<MapPinOff className="size-3" aria-hidden />
{t("unplanned.noLocation")}
</span>
)}
</p>
)}
</div>
</div>
{movable && (
<button type="button" onClick={() => onOpen(order, null, null, null)} className="mt-1 inline-flex min-h-11 w-full items-center justify-center gap-1 rounded-md border text-[11px] font-semibold hover:bg-muted">
<CalendarPlus className="size-3.5" aria-hidden />
{order.plannedStart && order.teamId ? t("board.move") : t("board.schedule")}
</button>
)}
</article>
);
}
@@ -0,0 +1,148 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Info, Lightbulb, MapPin, X } from "lucide-react";
import { formatDistance } from "@/lib/geo/distance";
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
import { buttonCls } from "@/components/work-orders/button-cls";
import type { BoardOrder } from "@/server/services/planning/data";
export type Recommendation = {
teamId: string;
teamName: string;
day: string;
distanceKm: number;
nearOrder: { id: string; number: string };
freeMinutes: number;
requiredMinutes: number;
tight: boolean;
suggestedStart: string;
text: string;
};
type Response = {
status: "ok" | "no_coordinates" | "not_schedulable";
hint: string | null;
requiredMinutes: number;
recommendations: Recommendation[];
nearby: { status: string; radiusKm: number; items: (BoardOrder & { distanceKm: number })[] };
};
/** Suggestions for one unplanned order (L13). "Übernehmen" only prefills the schedule popover. */
export function RecommendationsPanel({
workOrderId,
locale,
onTake,
onPlanTogether,
onClose,
}: {
workOrderId: string;
locale: string;
onTake: (rec: Recommendation) => void;
onPlanTogether: (order: BoardOrder, rec: Recommendation | null, requiredMinutes: number) => void;
onClose: () => void;
}) {
const t = useTranslations("planning");
const [data, setData] = useState<Response | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/v1/planning/recommendations?workOrderId=${encodeURIComponent(workOrderId)}&locale=${locale === "en" ? "en" : "de"}`, { signal: ctrl.signal })
.then((r) => (r.ok ? (r.json() as Promise<Response>) : Promise.reject(new Error(String(r.status)))))
.then(setData)
.catch(() => {
if (!ctrl.signal.aborted) setFailed(true);
});
return () => ctrl.abort();
}, [workOrderId, locale]);
const headingId = `rec-${workOrderId}`;
return (
<section aria-labelledby={headingId} className="rounded-lg border border-[var(--info)] bg-background p-3 text-sm">
<div className="flex items-start justify-between gap-2">
<div>
<h3 id={headingId} className="flex items-center gap-1.5 font-heading font-semibold">
<Lightbulb className="size-4 text-[var(--info)]" aria-hidden />
{t("recommend.title")}
</h3>
<p className="text-xs text-muted-foreground">{t("recommend.sub")}</p>
</div>
<button type="button" onClick={onClose} aria-label={t("recommend.close")} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted">
<X className="size-4" aria-hidden />
</button>
</div>
{!data && !failed && (
<p role="status" className="mt-2 text-xs text-muted-foreground">
{t("recommend.loading")}
</p>
)}
{failed && (
<p role="alert" className="mt-2 text-xs text-[var(--risk)]">
{t("recommend.failed")}
</p>
)}
{data?.hint && (
<p className="mt-2 flex items-start gap-1.5 text-xs">
<Info className="mt-0.5 size-3.5 shrink-0 text-[var(--info)]" aria-hidden />
{data.hint}
</p>
)}
{data && data.recommendations.length > 0 && (
<ul className="mt-2 space-y-2">
{data.recommendations.map((r) => (
<li key={`${r.teamId}-${r.day}`} className="rounded-md border p-2" title={r.text}>
<p className="text-[13px] font-semibold">
{t("recommend.card", {
day: formatDayShort(r.day, locale),
team: r.teamName,
distance: formatDistance(r.distanceKm, locale),
number: r.nearOrder.number,
free: formatMinutes(r.freeMinutes, locale),
})}
</p>
{r.tight && <p className="text-xs font-semibold text-[var(--warn)]">{t("recommend.tight")}</p>}
<button type="button" onClick={() => onTake(r)} className={`${buttonCls("outline")} mt-1.5 w-full`}>
{t("recommend.take")}
</button>
</li>
))}
</ul>
)}
{data && data.nearby.status === "ok" && (
<div className="mt-3 border-t pt-2">
<h4 className="text-xs font-semibold">{t("recommend.nearbyTitle")}</h4>
{data.nearby.items.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("recommend.nearbyEmpty")}</p>
) : (
<>
<p className="text-xs text-muted-foreground">{t("recommend.nearbyQuestion", { radius: data.nearby.radiusKm })}</p>
<ul className="mt-1 space-y-1.5">
{data.nearby.items.map((o) => (
<li key={o.id} className="rounded-md border p-2">
<p className="flex items-center gap-1 text-xs">
<MapPin className="size-3.5 shrink-0" aria-hidden />
<Link href={`/work-orders/${o.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
{o.number}
</Link>
· {formatDistance(o.distanceKm, locale)}
</p>
<p className="truncate text-xs text-muted-foreground">{o.customerName}</p>
<button type="button" onClick={() => onPlanTogether(o, data.recommendations[0] ?? null, data.requiredMinutes)} className={`${buttonCls("ghost")} mt-1 w-full`}>
{t("recommend.planTogether")}
</button>
</li>
))}
</ul>
</>
)}
</div>
)}
</section>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { AlertCircle, X } from "lucide-react";
import type { PlanningConflict } from "@/lib/planning/capacity";
import { formatMinutes } from "@/lib/planning/text";
import { wallTimeToUtc } from "@/lib/work-orders/time";
import { buttonCls } from "@/components/work-orders/button-cls";
import { Field, inputCls } from "@/components/work-orders/ui";
import type { BoardOrder } from "@/server/services/planning/data";
export type DialogState = { order: BoardOrder; teamId: string; day: string; time: string; duration: number };
export type ScheduleResponse = {
id: string;
number: string;
version: number;
status: string;
teamId: string;
plannedStart: string;
plannedEnd: string | null;
plannedDurationMinutes: number | null;
conflicts: PlanningConflict[];
};
/**
* Confirmation popover for drag & drop and the keyboard path ("Einplanen …"): team, date, start and
* duration are prefilled; nothing is saved without "Speichern". Escape / backdrop / × close it and focus
* returns to the element that opened it.
*/
export function ScheduleDialog({
state,
teams,
timeZone,
locale,
onClose,
onSaved,
onConflict,
}: {
state: DialogState;
teams: { id: string; name: string }[];
timeZone: string;
locale: string;
onClose: () => void;
onSaved: (res: ScheduleResponse) => void;
onConflict: (message: string) => void;
}) {
const t = useTranslations("planning");
const [teamId, setTeamId] = useState(state.teamId);
const [day, setDay] = useState(state.day);
const [time, setTime] = useState(state.time || "08:00");
const [allDay, setAllDay] = useState(state.time === "");
const [duration, setDuration] = useState(state.duration);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const closeRef = useRef(onClose);
useEffect(() => {
closeRef.current = onClose;
}, [onClose]);
useEffect(() => {
const opener = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeRef.current();
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
opener?.focus?.();
};
}, []);
const order = state.order;
async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const start = wallTimeToUtc(allDay ? day : `${day}T${time}`, timeZone);
if (!start || !teamId || !Number.isFinite(duration) || duration < 15) {
setError(t("errors.invalid"));
return;
}
const body: Record<string, unknown> = { workOrderId: order.id, teamId, plannedStart: start.toISOString(), baseVersion: order.version };
if (!order.multiDay) {
body.plannedDurationMinutes = Math.round(duration);
body.plannedEnd = allDay ? null : new Date(start.getTime() + Math.round(duration) * 60_000).toISOString();
}
setPending(true);
try {
const res = await fetch("/api/v1/planning/schedule", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const json = (await res.json().catch(() => null)) as (ScheduleResponse & { error?: { code?: string; message?: string } }) | null;
if (res.ok && json) {
onSaved(json);
return;
}
if (res.status === 409) {
onConflict(t("errors.conflict"));
return;
}
const message = json?.error?.message ?? "";
const code = json?.error?.code ?? "";
setError(t.has(`errors.${message}`) ? t(`errors.${message}`) : t.has(`errors.${code}`) ? t(`errors.${code}`) : t("errors.generic"));
} catch {
setError(t("errors.network"));
} finally {
setPending(false);
}
}
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-6 backdrop-blur-[2px]"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div role="dialog" aria-modal="true" aria-labelledby="schedule-title" className="shadow-card my-auto w-full max-w-lg rounded-2xl border bg-card">
<div className="flex items-start justify-between gap-3 border-b p-5">
<div className="min-w-0">
<h2 id="schedule-title" className="font-heading text-lg font-semibold">
{t("dialog.title")}
</h2>
<p className="text-sm">
<span className="font-mono">{order.number}</span> · {order.customerName}
</p>
{(order.siteAddress || order.siteName) && <p className="truncate text-xs text-muted-foreground">{order.siteAddress ?? order.siteName}</p>}
</div>
<button type="button" onClick={onClose} aria-label={t("dialog.close")} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
<X className="size-4.5" aria-hidden />
</button>
</div>
<form onSubmit={submit} className="grid gap-4 p-5 sm:grid-cols-2">
<Field label={t("dialog.team")} htmlFor="sd-team" className="sm:col-span-2">
<select id="sd-team" value={teamId} onChange={(e) => setTeamId(e.target.value)} required className={inputCls} autoFocus>
{teams.map((tm) => (
<option key={tm.id} value={tm.id}>
{tm.name}
</option>
))}
</select>
</Field>
<Field label={t("dialog.date")} htmlFor="sd-day">
<input id="sd-day" type="date" value={day} onChange={(e) => setDay(e.target.value)} required className={inputCls} />
</Field>
<Field label={t("dialog.time")} htmlFor="sd-time">
<input id="sd-time" type="time" step={900} value={time} onChange={(e) => setTime(e.target.value)} disabled={allDay} required={!allDay} className={inputCls} />
</Field>
<label className="flex min-h-11 items-center gap-2.5 text-sm sm:col-span-2">
<input type="checkbox" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} className="size-5 accent-[var(--ui-primary)]" />
{t("dialog.noTime")}
</label>
{!order.multiDay && (
<Field label={t("dialog.duration")} htmlFor="sd-duration" hint={t("dialog.durationHint", { duration: formatMinutes(order.durationMinutes, locale) })} className="sm:col-span-2">
<input id="sd-duration" type="number" min={15} max={20160} step={15} value={duration} onChange={(e) => setDuration(Number(e.target.value))} required className={inputCls} />
</Field>
)}
{error && (
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)] sm:col-span-2">
<AlertCircle className="size-4 shrink-0" aria-hidden />
{error}
</p>
)}
<div className="flex flex-wrap gap-2 sm:col-span-2">
<button type="submit" disabled={pending} className={buttonCls("primary")}>
{pending ? t("dialog.saving") : t("dialog.save")}
</button>
<button type="button" onClick={onClose} className={buttonCls("ghost")}>
{t("dialog.cancel")}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,98 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Info, Lightbulb, MapPin } from "lucide-react";
import { formatDistance } from "@/lib/geo/distance";
import { wallClock } from "@/lib/planning/days";
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
import { buttonCls } from "@/components/work-orders/button-cls";
import { Section } from "@/components/work-orders/ui";
import type { ServiceCtx } from "@/server/services/context";
import { findNearbyUnplanned, recommendAssignments } from "@/server/services/planning/recommend";
/**
* "Einsatz-Vorschläge" in the backoffice work order detail (L13, additive). Never breaks the page:
* any error hides the section. "Übernehmen" opens the planning board with a prefilled popover.
*/
export async function PlanningSuggestions({ ctx, workOrderId, locale, tz }: { ctx: ServiceCtx; workOrderId: string; locale: string; tz: string }) {
let rec: Awaited<ReturnType<typeof recommendAssignments>>;
let nearby: Awaited<ReturnType<typeof findNearbyUnplanned>>;
try {
[rec, nearby] = await Promise.all([recommendAssignments(ctx, { workOrderId, locale: locale === "en" ? "en" : "de" }), findNearbyUnplanned(ctx, { workOrderId })]);
} catch {
return null;
}
if (rec.status === "not_schedulable") return null;
const t = await getTranslations("planning");
const boardLink = (day: string | null, teamId: string | null, time: string | null, id = workOrderId) => {
const qs = new URLSearchParams({ schedule: id });
if (day) {
qs.set("date", day);
qs.set("day", day);
}
if (teamId) qs.set("scheduleTeam", teamId);
if (time) qs.set("time", time);
return `/planning?${qs}`;
};
return (
<Section
title={t("recommend.sectionTitle")}
className="mt-4"
actions={
<Link href="/planning" className={buttonCls("ghost")}>
{t("recommend.openBoard")}
</Link>
}
>
<p className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<Lightbulb className="size-3.5 text-[var(--info)]" aria-hidden />
{t("recommend.sub")}
</p>
{rec.hint && (
<p className="mb-2 flex items-start gap-1.5 text-sm">
<Info className="mt-0.5 size-4 shrink-0 text-[var(--info)]" aria-hidden />
{rec.hint}
</p>
)}
{rec.recommendations.length > 0 && (
<ul className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{rec.recommendations.map((r) => (
<li key={`${r.teamId}-${r.day}`} className="rounded-lg border p-3 text-sm" title={r.text}>
<p className="font-semibold">
{t("recommend.card", { day: formatDayShort(r.day, locale), team: r.teamName, distance: formatDistance(r.distanceKm, locale), number: r.nearOrder.number, free: formatMinutes(r.freeMinutes, locale) })}
</p>
{r.tight && <p className="text-xs font-semibold text-[var(--warn)]">{t("recommend.tight")}</p>}
<Link href={boardLink(r.day, r.teamId, wallClock(new Date(r.suggestedStart), tz))} className={`${buttonCls("outline")} mt-2 w-full`}>
{t("recommend.take")}
</Link>
</li>
))}
</ul>
)}
{nearby.status === "ok" && nearby.items.length > 0 && (
<div className="mt-3 border-t pt-3">
<h3 className="text-sm font-semibold">{t("recommend.nearbyTitle")}</h3>
<p className="text-xs text-muted-foreground">{t("recommend.nearbyQuestion", { radius: nearby.radiusKm })}</p>
<ul className="mt-1.5 space-y-1">
{nearby.items.map((o) => {
const first = rec.recommendations[0];
return (
<li key={o.id} className="flex flex-wrap items-center gap-2 text-sm">
<MapPin className="size-3.5" aria-hidden />
<Link href={`/work-orders/${o.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
{o.number}
</Link>
<span>{o.customerName}</span>
<span className="text-muted-foreground">· {formatDistance(o.distanceKm, locale)}</span>
<Link href={boardLink(first?.day ?? null, first?.teamId ?? null, null, o.id)} className="ml-auto inline-flex min-h-11 items-center text-xs font-semibold underline-offset-2 hover:underline">
{t("recommend.planTogether")}
</Link>
</li>
);
})}
</ul>
</div>
)}
</Section>
);
}
+134
View File
@@ -1106,6 +1106,139 @@ const paths: Record<string, Record<string, Schema>> = {
responses: { "200": binaryResponse("Datei"), ...errors("not_found") },
}),
},
// ---------- L13 Planung ----------
"/planning/board": {
get: op({
tag: "Planung",
operationId: "getPlanningBoard",
summary: "Plantafel: Aufträge je Team und Tag mit Kapazität, Auslastung und Konflikten",
description:
"Standard-Zeitraum: aktuelle Woche (Mo–So, Mandanten-Zeitzone), maximal 42 Tage. Teams sind Kolonnen: Kapazität = `dailyCapacityMinutes` je Arbeitstag (nicht je Person). `work_order:read_all` sieht alle Teams (Einplanen mit `work_order:assign` + `work_order:write`); Teamleiter (`work_order:read_team` + Leitung eines Teams) nur eigene Teams, lesend; sonst 403. Konflikte (`severity: conflict`): `overbooked`, `overlap`, `assignee_double_booked`, `outside_working_days`; Hinweis (`severity: hint`, nicht in `conflictCount`): `crew_incomplete` (< 2 aktive Personen). Liegt heute im Zeitraum, tragen Karten `delay` (≥ 80 % erfasste Kolonnenzeit) bzw. `atRisk` (Folgeauftrag gefährdet). Filter `orderTypeId`/`priority` wirken auf die angezeigten Karten, nicht auf die Auslastung.",
module: "work_orders",
permissions: [],
parameters: [
query("from", str({ format: "date" }), "Erster Tag (YYYY-MM-DD)"),
query("to", str({ format: "date" }), "Letzter Tag (YYYY-MM-DD)"),
{ name: "teamId", in: "query", required: false, schema: arr(str()), style: "form", explode: true, description: "mehrfach möglich" },
query("orderTypeId", str()),
query("priority", str({ enum: ["low", "normal", "high", "urgent"] })),
],
responses: {
"200": jsonResponse(
"Plantafel",
open(
{
from: str({ format: "date" }),
to: str({ format: "date" }),
timeZone: str(),
days: arr(obj({ key: str({ format: "date" }), weekday: int({ minimum: 0, maximum: 6 }) })),
teams: arr(open({ id: str(), name: str(), dailyCapacityMinutes: int({ description: "Kolonnen-Arbeitstag in Minuten" }), workingDays: int({ description: "Bitmaske Mo=1 … So=64" }), days: arr(open({ day: str(), memberCount: int(), capacityMinutes: int(), plannedMinutes: int(), utilization: { type: ["integer", "null"] }, conflicts: arr({ type: "object" }) })) })),
orders: { type: "object", additionalProperties: { type: "object" } },
unplanned: arr({ type: "object" }),
conflictCount: int(),
},
["from", "to", "teams", "orders", "unplanned"],
),
),
...errors("unprocessable"),
},
}),
},
"/planning/schedule": {
post: op({
tag: "Planung",
operationId: "scheduleWorkOrder",
summary: "Auftrag einplanen (Team + Termin) – atomar über Zuweisung und Auftragsänderung",
description:
"Nutzt `assignWorkOrder` (Event `work_order.assigned`) und `updateWorkOrder` in einer Transaktion; Audit. Nur Status draft/review_required/planned/assigned/accepted. Veraltete `baseVersion` → 409 („Auftrag wurde zwischenzeitlich geändert“). Antwort enthält die Konflikte des Zielteams am Zieltag.",
module: "work_orders",
permissions: ["work_order:assign", "work_order:write"],
requestBody: jsonBody(
obj(
{
workOrderId: str(),
teamId: str(),
plannedStart: dateTime(),
plannedEnd: nDateTime({ description: "weggelassen: bisherige Länge bleibt erhalten" }),
plannedDurationMinutes: { type: ["integer", "null"], minimum: 15, maximum: 20160 },
baseVersion: int({ minimum: 1 }),
},
["workOrderId", "teamId", "plannedStart", "baseVersion"],
),
),
responses: {
"200": jsonResponse(
"Eingeplant",
open({ id: str(), number: str(), version: int(), status: str({ enum: WORK_ORDER_STATUSES }), teamId: str(), plannedStart: dateTime(), plannedEnd: nDateTime(), plannedDurationMinutes: { type: ["integer", "null"] }, conflicts: arr({ type: "object" }) }, ["id", "version", "conflicts"]),
),
...errors("not_found", "conflict", "unprocessable"),
},
}),
},
"/planning/recommendations": {
get: op({
tag: "Planung",
operationId: "getPlanningRecommendations",
summary: "Einsatz-Empfehlungen (Luftlinie + Terminlage) und nahe ungeplante Aufträge",
description:
"Nur Vorschläge, nichts wird gespeichert. Ohne Koordinaten am Objekt: `status: no_coordinates` + Hinweis; Verortung wird im Hintergrund angestoßen. Top 5 nach Score (Distanz dominant, freie Kapazität, frühes Datum gewichtet nach Priorität).",
module: "work_orders",
permissions: ["work_order:assign"],
parameters: [
{ name: "workOrderId", in: "query", required: true, schema: str() },
query("days", int({ minimum: 1, maximum: 31, default: 10 })),
query("radiusKm", num({ minimum: 0.5, maximum: 200, default: 25 })),
query("nearbyRadiusKm", num({ minimum: 0.5, maximum: 100, default: 5 })),
query("locale", str({ enum: ["de", "en"], default: "de" })),
],
responses: {
"200": jsonResponse(
"Empfehlungen",
open(
{
status: str({ enum: ["ok", "no_coordinates", "not_schedulable"] }),
hint: nstr(),
requiredMinutes: int(),
recommendations: arr(open({ teamId: str(), teamName: str(), day: str({ format: "date" }), distanceKm: num(), nearOrder: obj({ id: str(), number: str() }), freeMinutes: int(), tight: bool(), score: num(), suggestedStart: dateTime(), text: str() })),
nearby: open({ status: str(), radiusKm: num(), items: arr({ type: "object" }) }),
},
["status", "recommendations", "nearby"],
),
),
...errors("not_found", "unprocessable"),
},
}),
},
"/planning/live": {
get: op({
tag: "Planung",
operationId: "getLiveSituation",
summary: "Live-Lage der Monteure (ohne GPS)",
description:
"Standort = Einsatzort (Objekt) des Auftrags der aktiven WorkSession; Status aus der Session (`en_route`, `working`, `paused`, sonst `free`). Es werden keine Geräte-Koordinaten gelesen oder ausgeliefert. `crews`: ein Eintrag je Team/Kolonne mit individuellem Status der Mitglieder und Verzugswarnung (`delay`); `freed`: Kolonnen, die heute früher fertig wurden, mit Vorzieh-/Umkreis-Vorschlägen (nur Vorschlag). Teamleiter: nur eigene Teams.",
module: "work_orders",
permissions: [],
parameters: [query("teamId", str()), query("status", str({ enum: ["en_route", "working", "paused", "free"] }))],
responses: {
"200": jsonResponse(
"Live-Lage",
open(
{
generatedAt: dateTime(),
crews: arr(open({ teamId: str(), teamName: str(), status: str(), current: { type: ["object", "null"] }, delay: { type: ["object", "null"] }, members: arr({ type: "object" }), freedMinutes: { type: ["integer", "null"] } })),
technicians: arr(open({ userId: str(), name: str(), crewId: nstr(), status: str(), since: nDateTime(), current: { type: ["object", "null"] }, delay: { type: ["object", "null"] }, next: arr({ type: "object" }) })),
freed: arr({ type: "object" }),
counts: { type: "object" },
attention: arr({ type: "object" }),
withoutLocation: int(),
},
["generatedAt", "technicians"],
),
),
...errors("unprocessable"),
},
}),
},
"/openapi.json": {
get: op({
tag: "Meta",
@@ -1137,6 +1270,7 @@ export const openApiDocument = {
{ name: "Import", description: "Dokumentenimport mit KI-Extraktion" },
{ name: "Berichte", description: "Tages-/Abschlussberichte" },
{ name: "Einsatz", description: "Mobile/Offline: Sync, Uploads, Bundle" },
{ name: "Planung", description: "Plantafel, Live-Lage, Einsatz-Empfehlungen" },
{ name: "Meta" },
],
paths,
+4
View File
@@ -23,6 +23,10 @@ export const EVENT_TYPES = [
"time.approval_requested",
"time.approved",
"time.rejected",
// L13 Planung (emitted only by the planning-watch job; in-app to back office + team leads)
"planning.capacity_freed",
"planning.overrun",
"planning.followup_at_risk",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
+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`;
}
+11
View File
@@ -15,6 +15,9 @@ import {
Siren,
Compass,
Timer,
CalendarRange,
LayoutGrid,
MapPinned,
type LucideIcon,
} from "lucide-react";
import type { ModuleKey } from "@/lib/modules";
@@ -38,10 +41,18 @@ export interface NavItem {
module?: ModuleKey;
permissions?: readonly Permission[];
section: "main" | "admin";
/** L13: indented sub entry of the preceding item */
sub?: boolean;
/** L13: active only on exactly this path (not on sub paths) */
exact?: boolean;
}
export const NAV_ITEMS: readonly NavItem[] = [
{ href: "/dashboard", label: "dashboard", icon: LayoutDashboard, section: "main" },
// L13 Planung: top-level entry with sub entries; backoffice (read_all) + team leads (report:approve_team, own crews read-only)
{ href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main" },
{ href: "/planning", label: "planningBoard", icon: LayoutGrid, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true, exact: true },
{ href: "/planning/live", label: "planningLive", icon: MapPinned, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true },
{
href: "/work-orders",
label: "workOrders",
+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. */
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. */
@@ -0,0 +1,27 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { moduleGuard } from "@/server/action-guard";
import { toActionError, type ActionState } from "@/server/api/action-state";
import { ctxFromGuard } from "@/server/services/context";
import { updateTeamPlanningSettings } from "@/server/services/planning/team-settings";
const guard = moduleGuard("work_orders");
/** L13 Planung: crew capacity (working day in minutes) + working days (popup on the planning board). */
export async function saveTeamCapacityAction(teamId: string, returnTo: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("team:manage"));
const workingDays = fd
.getAll("day")
.map(Number)
.filter((n) => [1, 2, 4, 8, 16, 32, 64].includes(n))
.reduce((mask, bit) => mask | bit, 0);
await updateTeamPlanningSettings(ctx, teamId, { dailyCapacityMinutes: String(fd.get("minutes") ?? ""), workingDays });
} catch (err) {
return toActionError(err);
}
revalidatePath("/planning");
redirect(returnTo.startsWith("/planning") && !returnTo.startsWith("//") ? returnTo : "/planning");
}
@@ -0,0 +1,12 @@
import { geocodeSite } from "@/server/services/geo/geocode-site";
import type { JobPayload } from "../queues";
/**
* Queue "geocode-site" (L13 Planung): address → coordinates for one site, cached on the row.
* The worker runs this queue with concurrency 1 + BullMQ limiter 1/s; the Nominatim provider
* additionally throttles process-wide. `failed` is rethrown so BullMQ retries with backoff.
*/
export async function process(payload: JobPayload): Promise<void> {
const outcome = await geocodeSite(payload.tenantId, payload.entityId);
if (outcome === "failed") throw new Error("geocoding failed (retry scheduled)");
}
+2
View File
@@ -13,6 +13,8 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
"report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle
"image-derivatives": () => import("./image-derivatives").then((m) => m.process),
"ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker)
"geocode-site": () => import("./geocode-site").then((m) => m.process), // L13: geocoding of sites (OSM Nominatim)
"planning-watch": () => import("./planning-watch").then((m) => m.process), // L13: delay/overrun/freed-capacity alerts (every 5 min)
};
/** Inline fallback when no Redis is available (dev/demo). */
@@ -0,0 +1,17 @@
import { runPlanningWatch, runPlanningWatchAllTenants } from "@/server/services/planning/watch";
import type { JobPayload } from "../queues";
/**
* Queue "planning-watch" (L13 Planung): every 5 min (job scheduler registered by the craftvia worker)
* evaluates running orders (delay ≥ 100 % → planning.overrun, follow-up order at risk →
* planning.followup_at_risk) and early completions (planning.capacity_freed). Deduplicated via the
* audit-log ledger; payload tenantId "*" = all tenants.
*/
export async function process(payload: JobPayload): Promise<void> {
if (!payload?.tenantId || payload.tenantId === "*") {
const r = await runPlanningWatchAllTenants();
if (r.overrun || r.followupAtRisk || r.capacityFreed || r.failed) console.info(`[planning-watch] ${JSON.stringify(r)}`);
return;
}
await runPlanningWatch(payload.tenantId);
}
+13
View File
@@ -13,6 +13,8 @@ export const JOB_QUEUES = {
reportPdf: "report-pdf",
imageDerivatives: "image-derivatives",
aiRetention: "ai-retention",
geocodeSite: "geocode-site", // L13 Planung: site address → coordinates (OSM Nominatim, 1 req/s)
planningWatch: "planning-watch", // L13 Planung: delay/overrun/freed-capacity alerts every 5 min
} as const;
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
@@ -97,6 +99,17 @@ export async function scheduleRecurringJobs(connection: Redis): Promise<void> {
} finally {
await q.close();
}
// L13 Planung: planning watch every 5 minutes (all tenants)
const watch = new Queue<JobPayload>(JOB_QUEUES.planningWatch, { connection });
try {
await watch.upsertJobScheduler(
"planning-watch-5min",
{ every: 5 * 60 * 1000 },
{ name: JOB_QUEUES.planningWatch, data: { tenantId: "*", entityId: "watch" }, opts: { removeOnComplete: { count: 50 }, removeOnFail: { count: 50 } } },
);
} finally {
await watch.close();
}
}
export async function closeJobQueues(): Promise<void> {
+42
View File
@@ -0,0 +1,42 @@
import packageJson from "../../../../package.json";
/**
* Geocoding + map configuration (L13 Planung). Defaults follow the OpenStreetMap usage policies:
* Nominatim only server side, max. 1 request/s, identifying User-Agent, results cached per site.
* For production with more than a handful of addresses a self-hosted / contracted geocoding and
* tile service is recommended (see docs/craftvia/lanes/planung.md).
*/
export const GEOCODING_PROVIDERS = ["nominatim", "none"] as const;
export type GeocodingProviderName = (typeof GEOCODING_PROVIDERS)[number];
export const DEFAULT_NOMINATIM_URL = "https://nominatim.openstreetmap.org";
export const DEFAULT_MAP_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
export const DEFAULT_MAP_ATTRIBUTION = "© OpenStreetMap-Mitwirkende";
type Env = Record<string, string | undefined>;
const val = (v: string | undefined) => (v && v.trim() ? v.trim() : undefined);
export function geocodingProviderName(env: Env = process.env): GeocodingProviderName {
const v = val(env.GEOCODING_PROVIDER)?.toLowerCase();
return v === "none" ? "none" : "nominatim";
}
export function geocodingConfig(env: Env = process.env) {
const baseUrl = val(env.APP_BASE_URL) ?? val(env.AUTH_URL) ?? "http://localhost:3000";
return {
provider: geocodingProviderName(env),
url: (val(env.GEOCODING_URL) ?? DEFAULT_NOMINATIM_URL).replace(/\/+$/, ""),
userAgent: val(env.GEOCODING_USER_AGENT) ?? `Craftvia/${packageJson.version} (+${baseUrl})`,
timeoutMs: 8_000,
minIntervalMs: 1_000,
};
}
/** Tile URL + attribution for the live map (passed from server pages to the client). */
export function mapConfig(env: Env = process.env) {
return {
tileUrl: val(env.MAP_TILE_URL) ?? DEFAULT_MAP_TILE_URL,
attribution: val(env.MAP_ATTRIBUTION) ?? DEFAULT_MAP_ATTRIBUTION,
};
}
+24
View File
@@ -0,0 +1,24 @@
import { enqueueJob } from "@/server/jobs/queues";
import { geocodingProviderName } from "@/server/services/geo/config";
/**
* Ask the worker to geocode a site (after create / address change / import confirmation).
* Never runs inline: no external request inside a user request (OSM policy: no bulk geocoding in
* requests). If the queue is not reachable yet (lazy Redis connection) one delayed retry is made;
* otherwise the site stays without coordinates ("ohne Ortsangabe") until the next change or
* `scripts/geocode-backfill.ts`. Never throws.
*/
export async function requestSiteGeocoding(tenantId: string, siteId: string): Promise<boolean> {
if (geocodingProviderName() === "none") return false;
const payload = { tenantId, entityId: siteId };
try {
if (await enqueueJob("geocode-site", payload)) return true;
} catch (err) {
console.error("[geo] enqueue geocode-site failed:", (err as Error).message);
}
const timer = setTimeout(() => {
enqueueJob("geocode-site", payload).catch(() => undefined);
}, 1_500);
timer.unref?.();
return false;
}
+92
View File
@@ -0,0 +1,92 @@
import { isValidLatLng } from "@/lib/geo/distance";
import { writeAuditLog } from "@/server/audit";
import { dbForTenant } from "@/server/db";
import { geocodeQueryFor } from "@/server/services/geo/normalize";
import { getGeocodingProvider, type GeocodingProvider } from "@/server/services/geo/provider";
/**
* Geocode one site and cache the result on the row (job `geocode-site`, backfill script).
*
* Rules:
* - Manually entered coordinates (coordinates without `geocodedAt`) are never overwritten.
* - Unchanged address (`geocodeQuery`) with status ok/not_found → no provider call ("cached").
* - No usable address or provider `none` → status `skipped` (retried once enabled/complete).
* - Transport/HTTP error → status `failed` (the job rethrows so BullMQ retries).
* - Address changed and not found → previous automatic coordinates are removed (they would be wrong).
*/
export type GeocodeOutcome = "ok" | "not_found" | "failed" | "skipped" | "cached" | "manual" | "missing";
const SITE_SELECT = {
id: true,
street: true,
houseNumber: true,
postalCode: true,
city: true,
country: true,
latitude: true,
longitude: true,
geocodedAt: true,
geocodeStatus: true,
geocodeQuery: true,
} as const;
export async function geocodeSite(
tenantId: string,
siteId: string,
opts: { provider?: GeocodingProvider | null; now?: Date } = {},
): Promise<GeocodeOutcome> {
const db = dbForTenant(tenantId);
const site = await db.site.findFirst({ where: { id: siteId, deletedAt: null }, select: SITE_SELECT });
if (!site) return "missing";
const now = opts.now ?? new Date();
const query = geocodeQueryFor(site);
if (isValidLatLng(site) && !site.geocodedAt) {
if (site.geocodeStatus !== "skipped" || site.geocodeQuery !== query) {
await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "skipped", geocodeQuery: query } });
}
return "manual";
}
if (!query) {
const clearAuto = site.geocodedAt !== null && (site.latitude !== null || site.longitude !== null);
await db.site.update({
where: { id: site.id },
data: { geocodeStatus: "skipped", geocodeQuery: null, ...(clearAuto ? { latitude: null, longitude: null } : {}) },
});
return "skipped";
}
if (site.geocodeQuery === query && (site.geocodeStatus === "ok" || site.geocodeStatus === "not_found")) return "cached";
const provider = opts.provider === undefined ? await getGeocodingProvider() : opts.provider;
if (!provider) {
if (site.geocodeStatus !== "skipped") await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "skipped" } });
return "skipped";
}
let result;
try {
result = await provider.geocode({ street: site.street, houseNumber: site.houseNumber, postalCode: site.postalCode, city: site.city, country: site.country });
} catch (err) {
console.error(`[geo] geocoding site ${site.id} failed:`, (err as Error).message);
await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "failed", geocodeQuery: query, geocodedAt: now } });
return "failed";
}
const before = { latitude: site.latitude, longitude: site.longitude, geocodeStatus: site.geocodeStatus };
const coords = result.status === "ok" ? { latitude: result.latitude, longitude: result.longitude } : { latitude: null, longitude: null };
await db.site.update({ where: { id: site.id }, data: { ...coords, geocodeStatus: result.status, geocodeQuery: query, geocodedAt: now } });
if (before.latitude !== coords.latitude || before.longitude !== coords.longitude) {
await writeAuditLog({
tenantId,
action: "update",
entity: "site",
entityId: site.id,
before,
after: { ...coords, geocodeStatus: result.status, source: "geocoding", provider: provider.name },
});
}
return result.status;
}
+58
View File
@@ -0,0 +1,58 @@
import { GeocodingError, type GeocodeAddress, type GeocodeResult, type GeocodingProvider } from "@/server/services/geo/provider";
import { nominatimLimiter } from "@/server/services/geo/rate-limit";
/**
* Nominatim (OpenStreetMap) provider. Usage policy: server side only, max. 1 request/s process-wide,
* identifying User-Agent, results are cached in Site.latitude/longitude by the caller, no bulk
* geocoding inside requests (only the geocode-site job and scripts/geocode-backfill.ts call this).
*/
type Config = { url: string; userAgent: string; timeoutMs: number };
type Deps = { fetch: typeof fetch; limiter: { acquire(): Promise<number> } };
/** Structured search URL (no free text → fewer ambiguous hits). */
export function buildNominatimUrl(baseUrl: string, address: GeocodeAddress): URL {
const url = new URL(`${baseUrl.replace(/\/+$/, "")}/search`);
url.searchParams.set("format", "jsonv2");
url.searchParams.set("limit", "1");
url.searchParams.set("addressdetails", "0");
const street = [address.houseNumber, address.street].map((s) => s?.trim()).filter(Boolean).join(" ");
if (street) url.searchParams.set("street", street);
if (address.postalCode?.trim()) url.searchParams.set("postalcode", address.postalCode.trim());
if (address.city?.trim()) url.searchParams.set("city", address.city.trim());
const country = (address.country ?? "DE").trim().toLowerCase();
if (/^[a-z]{2}$/.test(country)) url.searchParams.set("countrycodes", country);
return url;
}
export function createNominatimProvider(config: Config, deps: Deps = { fetch: globalThis.fetch, limiter: nominatimLimiter }): GeocodingProvider {
return {
name: "nominatim",
async geocode(address: GeocodeAddress): Promise<GeocodeResult> {
await deps.limiter.acquire();
let res: Response;
try {
res = await deps.fetch(buildNominatimUrl(config.url, address), {
headers: { "User-Agent": config.userAgent, "Accept-Language": "de", Accept: "application/json" },
signal: AbortSignal.timeout(config.timeoutMs),
});
} catch (err) {
throw new GeocodingError(`request failed: ${(err as Error).name}`);
}
if (!res.ok) throw new GeocodingError(`http ${res.status}`);
let body: unknown;
try {
body = await res.json();
} catch {
throw new GeocodingError("invalid response");
}
const hit = Array.isArray(body) ? (body[0] as { lat?: unknown; lon?: unknown } | undefined) : undefined;
const latitude = Number(hit?.lat);
const longitude = Number(hit?.lon);
if (!hit || !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) {
return { status: "not_found" };
}
return { status: "ok", latitude, longitude };
},
};
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Normalized address string of a site ("hafenstraße 12|20457|hamburg|de"). Stored in
* Site.geocodeQuery next to the coordinates: an unchanged address is never geocoded twice.
* Returns null when the address is too incomplete to geocode (neither postal code nor city).
*/
export type SiteAddress = {
street?: string | null;
houseNumber?: string | null;
postalCode?: string | null;
city?: string | null;
country?: string | null;
};
const clean = (v: string | null | undefined) => (v ?? "").normalize("NFC").trim().replace(/\s+/g, " ").toLowerCase();
export function geocodeQueryFor(site: SiteAddress): string | null {
const postal = clean(site.postalCode);
const city = clean(site.city);
if (!postal && !city) return null;
const street = [clean(site.street), clean(site.houseNumber)].filter(Boolean).join(" ");
return [street, postal, city, clean(site.country) || "de"].join("|");
}
/** True when the geocoding-relevant part of the address changed. */
export function addressChanged(before: SiteAddress, after: SiteAddress): boolean {
return geocodeQueryFor(before) !== geocodeQueryFor(after);
}
+41
View File
@@ -0,0 +1,41 @@
import { geocodingConfig, geocodingProviderName } from "@/server/services/geo/config";
/** Provider-neutral geocoding contract (L13 Planung) — swap Nominatim for another service via env. */
export type GeocodeAddress = {
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
country: string | null;
};
export type GeocodeResult = { status: "ok"; latitude: number; longitude: number } | { status: "not_found" };
export interface GeocodingProvider {
readonly name: string;
/** Resolves ok/not_found; throws GeocodingError for transport/HTTP failures (retryable). */
geocode(address: GeocodeAddress): Promise<GeocodeResult>;
}
export class GeocodingError extends Error {
constructor(message: string) {
super(message);
this.name = "GeocodingError";
}
}
let override: GeocodingProvider | null | undefined;
/** Tests inject a fake provider (never Nominatim); `undefined` restores the env-based provider. */
export function setGeocodingProvider(provider: GeocodingProvider | null | undefined): void {
override = provider;
}
/** Configured provider, or null when geocoding is disabled (`GEOCODING_PROVIDER=none`). */
export async function getGeocodingProvider(): Promise<GeocodingProvider | null> {
if (override !== undefined) return override;
if (geocodingProviderName() === "none") return null;
const { createNominatimProvider } = await import("@/server/services/geo/nominatim");
return createNominatimProvider(geocodingConfig());
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Minimum-interval limiter: callers are released one after another with at least `intervalMs`
* between two slots (Nominatim policy: max. 1 request per second). Clock is injectable for tests.
*/
export type Clock = { now: () => number; sleep: (ms: number) => Promise<void> };
const realClock: Clock = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) };
export function createIntervalLimiter(intervalMs: number, clock: Clock = realClock) {
let next = 0;
return {
/** Waits for the next free slot and returns its timestamp. */
async acquire(): Promise<number> {
const now = clock.now();
const slot = Math.max(now, next);
next = slot + intervalMs;
if (slot > now) await clock.sleep(slot - now);
return slot;
},
};
}
/** Process-wide limiter for all Nominatim requests of this process (worker, backfill script). */
export const nominatimLimiter = createIntervalLimiter(1_000);
+2
View File
@@ -8,6 +8,7 @@ import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imp
import { createWorkOrder } from "@/server/services/work-orders/create";
import { startExtraction, type Dispatch } from "./upload";
import { dispatchJob } from "@/server/jobs/dispatch";
import { requestSiteGeocoding } from "@/server/services/geo/dispatch";
export type ConfirmResult = { workOrderId: string; workOrderNumber: string; customerId: string; siteId: string | null; contactId: string | null };
@@ -168,6 +169,7 @@ export async function confirmImport(ctx: ServiceCtx, importId: string, rawForm:
if (result.createdCustomer) await writeAuditLog({ ...base, action: "create", entity: "customer", entityId: result.customerId, after: { source: "import", importId: job.id } });
if (result.contactId) await writeAuditLog({ ...base, action: "create", entity: "contact", entityId: result.contactId, after: { customerId: result.customerId, source: "import" } });
if (result.createdSite && result.siteId) await writeAuditLog({ ...base, action: "create", entity: "site", entityId: result.siteId, after: { customerId: result.customerId, source: "import" } });
if (result.createdSite && result.siteId) void requestSiteGeocoding(ctx.tenantId, result.siteId); // L13 Planung: queued geocoding of the new site
// work order creation is audited by services/work-orders/create (entity work_order)
await writeAuditLog({
...base,
@@ -136,6 +136,11 @@ function textVars(locale: Locale, f: EventFacts): Record<string, string | undefi
actor: f.actorName ?? fallbackText(locale, "system"),
fileName: f.fileName ?? fallbackText(locale, "document"),
reason: f.syncErrorCode ?? f.rejectionReason,
// L13 Planung
team: f.team,
minutes: f.planningMinutes,
percent: f.planningPercent,
blocker: f.planningBlocker,
};
}
@@ -49,6 +49,10 @@ export type EventFacts = {
emergencyEnd?: Date | null;
/** L12: reason of a rejected time entry */
rejectionReason?: string;
// L13 Planung
planningMinutes?: string;
planningPercent?: string;
planningBlocker?: string;
};
export type RecipientPlan = {
@@ -331,6 +335,20 @@ export async function resolveRecipients(
rule = { users: [backofficeWhere()], userIds: [op.userId] };
break;
}
case "planning.capacity_freed":
case "planning.overrun":
case "planning.followup_at_risk": {
// L13 Planung: in-app only — back office + team leads of the order's crew.
const wo = await loadWorkOrder(ctx, event.entityId);
if (!wo) return null;
Object.assign(facts, workOrderFacts(wo));
const d = event.data ?? {};
if (typeof d.minutes === "number" || typeof d.minutes === "string") facts.planningMinutes = String(d.minutes);
if (typeof d.percent === "number" || typeof d.percent === "string") facts.planningPercent = String(d.percent);
if (typeof d.blockerNumber === "string") facts.planningBlocker = d.blockerNumber;
rule = { users: [backofficeWhere()], userIds: teamLeadIds(wo), mailToUsers: false };
break;
}
default:
return null;
}
+38
View File
@@ -0,0 +1,38 @@
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* Who may use the planning views (L13):
* - `work_order:read_all` (backoffice, admin): all teams; scheduling needs `work_order:assign` + `work_order:write`.
* - `work_order:read_team` + leader of at least one active team (Teamleiter): read-only, own teams only.
* - everyone else (Monteur): forbidden.
*/
export type PlanningAccess = {
all: boolean;
/** null = all teams of the tenant */
teamIds: string[] | null;
canSchedule: boolean;
canManageTeams: boolean;
};
export async function planningAccess(ctx: ServiceCtx): Promise<PlanningAccess> {
if (can(ctx, "work_order:read_all")) {
return {
all: true,
teamIds: null,
canSchedule: can(ctx, "work_order:assign") && can(ctx, "work_order:write"),
canManageTeams: can(ctx, "team:manage"),
};
}
if (can(ctx, "work_order:read_team")) {
const led = await ctx.db.team.findMany({ where: { leaderUserId: ctx.userId, status: "active", deletedAt: null }, select: { id: true } });
if (led.length > 0) return { all: false, teamIds: led.map((t) => t.id), canSchedule: false, canManageTeams: false };
}
throw new ServiceError("forbidden", "planning not allowed");
}
/** Recommendations and scheduling are backoffice functions (all teams + assign right). */
export async function assertCanPlanAll(ctx: ServiceCtx): Promise<PlanningAccess> {
const access = await planningAccess(ctx);
if (!access.all || !can(ctx, "work_order:assign")) throw new ServiceError("forbidden", "missing permission work_order:assign");
return access;
}
+177
View File
@@ -0,0 +1,177 @@
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<typeof boardSchema>;
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<string, BoardOrder>;
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<PlanningBoard> {
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<string, string>();
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<string, Set<ConflictKind>>();
for (const c of conflicts.filter(isConflict)) {
for (const id of c.orderIds) {
const set = kindsByOrder.get(id) ?? new Set<ConflictKind>();
set.add(c.kind);
kindsByOrder.set(id, set);
}
}
const orders: Record<string, BoardOrder> = 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;
}
}
+184
View File
@@ -0,0 +1,184 @@
import type { Prisma } from "@prisma/client";
import { isValidLatLng } from "@/lib/geo/distance";
import { effectiveDurationMinutes, hasTimeOfDay, orderDays, type PlanningOrder, type PlanningTeam } from "@/lib/planning/capacity";
import { STATUS_GROUP, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
import type { ServiceCtx } from "@/server/services/context";
import { customerDisplayName } from "@/server/services/work-orders/options";
import { workOrderScope } from "@/server/services/work-orders/visibility";
/** Shared loaders + DTOs of the planning services (tenant via ctx.db, visibility via workOrderScope). */
/** Unplanned = open planning statuses without team or without planned start (+ assigned/accepted without date). */
export const UNPLANNED_STATUSES: WorkOrderStatus[] = ["draft", "review_required", "planned", "assigned", "accepted"];
const NEEDS_TEAM_STATUSES: WorkOrderStatus[] = ["draft", "review_required", "planned"];
export const ORDER_SELECT = {
id: true,
number: true,
title: true,
status: true,
priority: true,
version: true,
plannedStart: true,
plannedEnd: true,
plannedDurationMinutes: true,
assignedTeamId: true,
orderTypeId: true,
isEmergency: true,
orderType: { select: { name: true, defaultDurationMinutes: true } },
customer: { select: { companyName: true, firstName: true, lastName: true } },
site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, latitude: true, longitude: true, geocodeStatus: true } },
assignees: { select: { userId: true } },
} as const satisfies Prisma.WorkOrderSelect;
export type OrderRow = Prisma.WorkOrderGetPayload<{ select: typeof ORDER_SELECT }>;
export type BoardOrder = {
id: string;
number: string;
title: string;
status: WorkOrderStatus;
statusGroup: StatusGroup;
priority: "low" | "normal" | "high" | "urgent";
version: number;
isEmergency: boolean;
customerName: string;
siteId: string | null;
siteName: string | null;
siteAddress: string | null;
hasCoordinates: boolean;
orderTypeId: string | null;
orderTypeName: string | null;
teamId: string | null;
plannedStart: string | null;
plannedEnd: string | null;
plannedDurationMinutes: number | null;
durationMinutes: number;
allDay: boolean;
multiDay: boolean;
assigneeIds: string[];
conflictKinds: string[];
/** Running order: recorded crew work time vs. planned duration (≥ 80 % → risk, ≥ 100 % → overrun). */
delay: { level: "risk" | "overrun"; percent: number; workedMinutes: number } | null;
/** Planned follow-up order endangered by the delay of another order of the same crew. */
atRisk: { byOrderId: string; byNumber: string; reason: "start" | "capacity" } | null;
};
export function toPlanningOrder(r: OrderRow): PlanningOrder {
return {
id: r.id,
teamId: r.assignedTeamId,
plannedStart: r.plannedStart,
plannedEnd: r.plannedEnd,
plannedDurationMinutes: r.plannedDurationMinutes,
orderTypeDefaultMinutes: r.orderType?.defaultDurationMinutes ?? null,
assigneeIds: r.assignees.map((a) => a.userId),
};
}
export function siteAddress(s: { street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null } | null): string | null {
if (!s) return null;
const line = [[s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
return line || null;
}
export function toBoardOrder(r: OrderRow, timeZone: string, conflictKinds: string[] = []): BoardOrder {
const po = toPlanningOrder(r);
return {
id: r.id,
number: r.number,
title: r.title,
status: r.status as WorkOrderStatus,
statusGroup: STATUS_GROUP[r.status as WorkOrderStatus],
priority: r.priority,
version: r.version,
isEmergency: r.isEmergency,
customerName: customerDisplayName(r.customer),
siteId: r.site?.id ?? null,
siteName: r.site?.name ?? null,
siteAddress: siteAddress(r.site),
hasCoordinates: isValidLatLng(r.site),
orderTypeId: r.orderTypeId,
orderTypeName: r.orderType?.name ?? null,
teamId: r.assignedTeamId,
plannedStart: r.plannedStart?.toISOString() ?? null,
plannedEnd: r.plannedEnd?.toISOString() ?? null,
plannedDurationMinutes: r.plannedDurationMinutes,
durationMinutes: effectiveDurationMinutes(po, timeZone),
allDay: !r.plannedStart || !hasTimeOfDay(r.plannedStart, timeZone),
multiDay: orderDays(po, timeZone).length > 1,
assigneeIds: po.assigneeIds,
conflictKinds,
delay: null,
atRisk: null,
};
}
export async function loadTeams(ctx: ServiceCtx, teamIds: string[] | null) {
return ctx.db.team.findMany({
where: { deletedAt: null, status: "active", ...(teamIds ? { id: { in: teamIds } } : {}) },
select: {
id: true,
name: true,
leaderUserId: true,
dailyCapacityMinutes: true,
workingDays: true,
leader: { select: { id: true, name: true, status: true } },
members: { select: { userId: true, validFrom: true, validTo: true, user: { select: { name: true, status: true } } }, orderBy: { validFrom: "asc" } },
},
orderBy: { name: "asc" },
});
}
export type TeamRow = Awaited<ReturnType<typeof loadTeams>>[number];
export function toPlanningTeam(t: TeamRow): PlanningTeam {
return {
id: t.id,
dailyCapacityMinutes: t.dailyCapacityMinutes,
workingDays: t.workingDays,
members: t.members.map((m) => ({ userId: m.userId, validFrom: m.validFrom, validTo: m.validTo, active: m.user.status === "ACTIVE" })),
};
}
/** Orders of the teams planned in [start, end) incl. multi-day orders overlapping the range. */
export async function loadPlannedOrders(ctx: ServiceCtx, opts: { teamIds: string[]; start: Date; end: Date; excludeId?: string }): Promise<OrderRow[]> {
if (opts.teamIds.length === 0) return [];
return ctx.db.workOrder.findMany({
where: {
AND: [
await workOrderScope(ctx),
{
assignedTeamId: { in: opts.teamIds },
status: { not: "cancelled" },
plannedStart: { lt: opts.end, not: null },
OR: [{ plannedEnd: null, plannedStart: { gte: opts.start } }, { plannedEnd: { gt: opts.start } }],
},
opts.excludeId ? { id: { not: opts.excludeId } } : {},
],
},
select: ORDER_SELECT,
orderBy: [{ plannedStart: "asc" }, { number: "asc" }],
take: 3000,
});
}
export async function loadUnplannedOrders(ctx: ServiceCtx, teamIds: string[] | null, take = 200): Promise<OrderRow[]> {
return ctx.db.workOrder.findMany({
where: {
AND: [
await workOrderScope(ctx),
{ status: { in: UNPLANNED_STATUSES } },
{ OR: [{ plannedStart: null }, { assignedTeamId: null, status: { in: NEEDS_TEAM_STATUSES } }] },
teamIds ? { assignedTeamId: { in: teamIds } } : {},
],
},
select: ORDER_SELECT,
orderBy: [{ priority: "desc" }, { createdAt: "asc" }],
take,
});
}
export async function loadOrder(ctx: ServiceCtx, workOrderId: string): Promise<OrderRow | null> {
return ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, await workOrderScope(ctx)] }, select: ORDER_SELECT });
}
+271
View File
@@ -0,0 +1,271 @@
import { z } from "zod";
import { isValidLatLng } from "@/lib/geo/distance";
import { dayBounds, dayKeyOf } from "@/lib/planning/days";
import type { WorkOrderStatus } from "@/lib/work-orders/status";
import type { ServiceCtx } from "@/server/services/context";
import { planningAccess } from "@/server/services/planning/access";
import { loadTeams, siteAddress } from "@/server/services/planning/data";
import { trackedSessionWhere } from "@/server/services/planning/time-tracking";
import { evaluateDelays, getFreedCapacity, type FreedTeam } from "@/server/services/planning/watch";
import { customerDisplayName } from "@/server/services/work-orders/options";
import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared";
import { workOrderScope } from "@/server/services/work-orders/visibility";
/**
* Live situation (L13, Live-Lage) WITHOUT GPS: a technician is shown at the site of the order of
* his active WorkSession; the status comes from that session (en_route → Unterwegs, running → In
* Arbeit, paused → Pause, none → frei). Teams are crews ("Kolonnen"): ONE entry/marker per crew at
* the site of its running order with the individual status of each member; technicians without a
* team are shown individually. Device positions are never read: WorkSession.startLat/startLng,
* deviceInfo and photo coordinates are not selected and never part of the result (privacy decision).
*/
export const LIVE_STATUSES = ["en_route", "working", "paused", "free"] as const;
export type LiveStatus = (typeof LIVE_STATUSES)[number];
const SESSION_STATUS: Record<string, LiveStatus> = { en_route: "en_route", running: "working", paused: "paused" };
const RANK: Record<LiveStatus, number> = { working: 3, en_route: 2, paused: 1, free: 0 };
const liveSchema = z.object({
teamId: z.string().min(1).max(64).nullish(),
status: z.enum(LIVE_STATUSES).nullish(),
});
export type LiveOrderRef = {
id: string;
number: string;
title: string;
customerName: string;
siteName: string | null;
address: string | null;
latitude: number | null;
longitude: number | null;
};
export type LiveDelay = { level: "risk" | "overrun"; percent: number; workedMinutes: number; plannedMinutes: number };
export type LiveTechnician = {
userId: string;
name: string;
teams: { id: string; name: string }[];
/** Crew the person currently belongs to (team of the running session, else first team). */
crewId: string | null;
status: LiveStatus;
since: string | null;
/** null = no active session; `visible=false` = session on an order outside the caller's scope */
current: (LiveOrderRef & { visible: true }) | { visible: false } | null;
delay: LiveDelay | null;
next: { id: string; number: string; title: string; plannedStart: string; customerName: string; city: string | null }[];
};
export type LiveCrew = {
teamId: string;
teamName: string;
/** Highest member status: In Arbeit > Unterwegs > Pause > frei */
status: LiveStatus;
current: LiveOrderRef | null;
delay: LiveDelay | null;
members: { userId: string; name: string; status: LiveStatus; since: string | null; orderNumber: string | null }[];
freedMinutes: number | null;
};
export type LiveSituation = {
generatedAt: string;
timeZone: string;
teams: { id: string; name: string }[];
crews: LiveCrew[];
technicians: LiveTechnician[];
counts: Record<LiveStatus, number>;
attention: { id: string; number: string; title: string; plannedStart: string; teamName: string | null; customerName: string; reason: "not_accepted" | "overdue" }[];
freed: FreedTeam[];
withoutLocation: number;
};
export async function getLiveSituation(ctx: ServiceCtx, raw: z.input<typeof liveSchema> = {}, opts: { now?: Date } = {}): Promise<LiveSituation> {
const access = await planningAccess(ctx);
const input = parseInput(liveSchema, raw);
const timeZone = await tenantTimezone(ctx);
const now = opts.now ?? new Date();
const today = dayBounds(dayKeyOf(now, timeZone), timeZone);
const allTeams = await loadTeams(ctx, access.teamIds);
const teams = input.teamId ? allTeams.filter((tm) => tm.id === input.teamId) : allTeams;
// People: active members today + team leaders of the visible teams.
const people = new Map<string, { name: string; teams: { id: string; name: string }[] }>();
const addPerson = (userId: string, name: string, team: { id: string; name: string }) => {
const p = people.get(userId) ?? { name, teams: [] };
if (!p.teams.some((x) => x.id === team.id)) p.teams.push(team);
people.set(userId, p);
};
for (const team of teams) {
const ref = { id: team.id, name: team.name };
for (const m of team.members) {
if (m.user.status === "ACTIVE" && m.validFrom <= now && (!m.validTo || m.validTo > now)) addPerson(m.userId, m.user.name, ref);
}
if (team.leader && team.leader.status === "ACTIVE") addPerson(team.leader.id, team.leader.name, ref);
}
// Active clock sessions — explicit select: NEVER startLat/startLng/deviceInfo.
const sessionWhere = { AND: [trackedSessionWhere, { status: { in: ["en_route", "running", "paused"] as ("en_route" | "running" | "paused")[] } }] };
const sessions = await ctx.db.workSession.findMany({
where: access.all && !input.teamId ? sessionWhere : { AND: [sessionWhere, { userId: { in: [...people.keys()] } }] },
select: { id: true, userId: true, teamId: true, status: true, startedAt: true, workOrderId: true },
orderBy: { startedAt: "desc" },
take: 1000,
});
// Backoffice without team filter: also technicians with a session outside any team.
const unknown = [...new Set(sessions.map((s) => s.userId).filter((id) => !people.has(id)))];
if (unknown.length) {
const users = await ctx.db.user.findMany({ where: { id: { in: unknown }, status: "ACTIVE" }, select: { id: true, name: true } });
for (const u of users) people.set(u.id, { name: u.name, teams: [] });
}
const sessionByUser = new Map<string, (typeof sessions)[number]>();
for (const s of sessions) if (!sessionByUser.has(s.userId) && people.has(s.userId)) sessionByUser.set(s.userId, s);
const activeSessions = [...sessionByUser.values()];
const openSegments = activeSessions.length
? await ctx.db.timeEntry.findMany({
where: { workSessionId: { in: activeSessions.map((s) => s.id) }, endedAt: null },
select: { workSessionId: true, startedAt: true },
orderBy: { startedAt: "desc" },
})
: [];
const segmentStart = new Map<string, Date>();
for (const e of openSegments) if (!segmentStart.has(e.workSessionId)) segmentStart.set(e.workSessionId, e.startedAt);
const scope = await workOrderScope(ctx);
const teamIds = teams.map((tm) => tm.id);
const userIds = [...people.keys()];
const [currentOrders, todays, delayEval, freed] = await Promise.all([
activeSessions.length
? ctx.db.workOrder.findMany({
where: { AND: [scope, { id: { in: [...new Set(activeSessions.map((s) => s.workOrderId))] } }] },
select: {
id: true,
number: true,
title: true,
customer: { select: { companyName: true, firstName: true, lastName: true } },
site: { select: { name: true, street: true, houseNumber: true, postalCode: true, city: true, latitude: true, longitude: true } },
},
})
: Promise.resolve([]),
ctx.db.workOrder.findMany({
where: {
AND: [
scope,
{
plannedStart: { gte: today.start, lt: today.end },
status: { in: ["planned", "assigned", "accepted"] },
OR: [...(teamIds.length ? [{ assignedTeamId: { in: teamIds } }] : []), ...(userIds.length ? [{ assignees: { some: { userId: { in: userIds } } } }] : [])],
},
access.all && !input.teamId ? {} : { assignedTeamId: { in: teamIds } },
],
},
select: {
id: true,
number: true,
title: true,
status: true,
plannedStart: true,
assignedTeamId: true,
team: { select: { name: true } },
customer: { select: { companyName: true, firstName: true, lastName: true } },
site: { select: { city: true } },
assignees: { select: { userId: true } },
},
orderBy: { plannedStart: "asc" },
take: 500,
}),
evaluateDelays(ctx, { now, teamIds: access.all && !input.teamId ? null : teamIds }),
getFreedCapacity(ctx, { now }),
]);
const orderById = new Map(currentOrders.map((o) => [o.id, o]));
const delayById = new Map(
delayEval.delays
.filter((d) => d.level !== "ok")
.map((d) => [d.workOrderId, { level: d.level as "risk" | "overrun", percent: d.percent, workedMinutes: d.workedMinutes, plannedMinutes: d.plannedMinutes }]),
);
const technicians: LiveTechnician[] = [...people].map(([userId, p]) => {
const session = sessionByUser.get(userId);
const status: LiveStatus = session ? SESSION_STATUS[session.status] ?? "free" : "free";
const order = session ? orderById.get(session.workOrderId) : undefined;
let current: LiveTechnician["current"] = null;
if (session && order) {
const coords = isValidLatLng(order.site) ? { latitude: order.site!.latitude as number, longitude: order.site!.longitude as number } : { latitude: null, longitude: null };
current = {
visible: true,
id: order.id,
number: order.number,
title: order.title,
customerName: customerDisplayName(order.customer),
siteName: order.site?.name ?? null,
address: siteAddress(order.site),
...coords,
};
} else if (session) {
current = { visible: false };
}
const myTeams = new Set(p.teams.map((x) => x.id));
const crewId = session?.teamId && myTeams.has(session.teamId) ? session.teamId : p.teams[0]?.id ?? null;
const next = todays
.filter((o) => (o.assignees.length ? o.assignees.some((a) => a.userId === userId) : !!o.assignedTeamId && myTeams.has(o.assignedTeamId)))
.slice(0, 3)
.map((o) => ({ id: o.id, number: o.number, title: o.title, plannedStart: o.plannedStart!.toISOString(), customerName: customerDisplayName(o.customer), city: o.site?.city ?? null }));
const since = session ? (segmentStart.get(session.id) ?? session.startedAt).toISOString() : null;
const delay = current?.visible ? delayById.get(current.id) ?? null : null;
return { userId, name: p.name, teams: p.teams, crewId, status, since, current, delay, next };
});
const counts = Object.fromEntries(LIVE_STATUSES.map((s) => [s, technicians.filter((x) => x.status === s).length])) as Record<LiveStatus, number>;
const matches = (x: { status: LiveStatus }) => !input.status || x.status === input.status;
const freedByTeam = new Map(freed.teams.map((f) => [f.teamId, f.earlyMinutes]));
// One entry per crew (team): members with their own status, crew order = order of the most active member.
const crews: LiveCrew[] = teams
.map((team) => {
const members = technicians.filter((x) => x.crewId === team.id).sort((a, b) => RANK[b.status] - RANK[a.status] || a.name.localeCompare(b.name, "de"));
const lead = members.find((m) => m.current?.visible);
const current = lead?.current?.visible ? (({ visible: _v, ...rest }) => (void _v, rest))(lead.current) : null;
return {
teamId: team.id,
teamName: team.name,
status: members[0]?.status ?? ("free" as LiveStatus),
current,
delay: current ? delayById.get(current.id) ?? null : null,
members: members.map((m) => ({ userId: m.userId, name: m.name, status: m.status, since: m.since, orderNumber: m.current?.visible ? m.current.number : null })),
freedMinutes: freedByTeam.get(team.id) ?? null,
};
})
.filter((c) => c.members.length > 0 && matches(c));
const filtered = technicians
.filter(matches)
.sort((a, b) => RANK[b.status] - RANK[a.status] || a.name.localeCompare(b.name, "de"));
const attention = todays
.filter((o) => o.plannedStart! < now)
.map((o) => ({
id: o.id,
number: o.number,
title: o.title,
plannedStart: o.plannedStart!.toISOString(),
teamName: o.team?.name ?? null,
customerName: customerDisplayName(o.customer),
reason: ((o.status as WorkOrderStatus) === "accepted" ? "overdue" : "not_accepted") as "overdue" | "not_accepted",
}));
return {
generatedAt: now.toISOString(),
timeZone,
teams: allTeams.map((tm) => ({ id: tm.id, name: tm.name })),
crews,
technicians: filtered,
counts,
attention,
freed: input.teamId ? freed.teams.filter((f) => f.teamId === input.teamId) : freed.teams,
// running orders (distinct) whose site has no coordinates — shown in the list only
withoutLocation: new Set(filtered.flatMap((x) => (x.current?.visible && x.current.latitude === null ? [x.current.id] : []))).size,
};
}
+222
View File
@@ -0,0 +1,222 @@
import { z } from "zod";
import deMessages from "../../../../messages/de/planning.json";
import enMessages from "../../../../messages/en/planning.json";
import { formatDistance, haversineKm, isValidLatLng } from "@/lib/geo/distance";
import { computeTeamDay, effectiveDurationMinutes, minutesOnDay, timeWindow } from "@/lib/planning/capacity";
import { addDays, dayBounds, dayKeyOf, dayRange, isDayKey, minutesOfDay } from "@/lib/planning/days";
import { fillTemplate, formatDayShort, formatMinutes } from "@/lib/planning/text";
import { wallTimeToUtc } from "@/lib/work-orders/time";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { requestSiteGeocoding } from "@/server/services/geo/dispatch";
import { assertCanPlanAll } from "@/server/services/planning/access";
import {
loadOrder,
loadPlannedOrders,
loadTeams,
loadUnplannedOrders,
toBoardOrder,
toPlanningOrder,
toPlanningTeam,
type OrderRow,
} from "@/server/services/planning/data";
import { SCHEDULABLE_STATUSES } from "@/server/services/planning/schedule";
import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared";
/**
* Assignment suggestions (L13): straight-line distance to orders a team already has on a day +
* free capacity that day. Suggestions only — the human always schedules (no auto-save).
* Score: distance dominates (0.6), then free capacity (0.2), early date (weight by priority), tight −0.25.
*/
const MESSAGES = { de: deMessages, en: enMessages } as const;
type Locale = keyof typeof MESSAGES;
const PRIORITY_DATE_WEIGHT = { low: 0.05, normal: 0.15, high: 0.25, urgent: 0.35 } as const;
const DAY_START_MINUTES = 8 * 60;
const LATEST_START_MINUTES = 18 * 60;
const recommendSchema = z.object({
workOrderId: z.string().min(1).max(64),
days: z.coerce.number().int().min(1).max(31).default(10),
radiusKm: z.coerce.number().min(0.5).max(200).default(25),
from: z.string().refine(isDayKey).optional(),
locale: z.enum(["de", "en"]).default("de"),
});
export type RecommendInput = z.input<typeof recommendSchema>;
export type Recommendation = {
teamId: string;
teamName: string;
day: string;
distanceKm: number;
nearOrder: { id: string; number: string };
freeMinutes: number;
requiredMinutes: number;
tight: boolean;
score: number;
suggestedStart: string;
text: string;
};
export type RecommendationResult = {
workOrderId: string;
number: string;
status: "ok" | "no_coordinates" | "not_schedulable";
geocodeStatus: string | null;
requiredMinutes: number;
radiusKm: number;
recommendations: Recommendation[];
hint: string | null;
};
async function loadTarget(ctx: ServiceCtx, workOrderId: string): Promise<OrderRow> {
const row = await loadOrder(ctx, workOrderId);
if (!row) throw new ServiceError("not_found", "work_order_not_found");
return row;
}
/** Ask for geocoding when the target site has no coordinates (not for confirmed "not found"). */
function nudgeGeocoding(ctx: ServiceCtx, row: OrderRow) {
if (row.site && row.site.geocodeStatus !== "not_found") void requestSiteGeocoding(ctx.tenantId, row.site.id);
}
export async function recommendAssignments(ctx: ServiceCtx, raw: RecommendInput): Promise<RecommendationResult> {
await assertCanPlanAll(ctx);
const input = parseInput(recommendSchema, raw);
const timeZone = await tenantTimezone(ctx);
const t = MESSAGES[input.locale as Locale].recommend;
const row = await loadTarget(ctx, input.workOrderId);
const target = toPlanningOrder(row);
const requiredMinutes = effectiveDurationMinutes({ ...target, plannedEnd: null }, timeZone);
const base = {
workOrderId: row.id,
number: row.number,
geocodeStatus: row.site?.geocodeStatus ?? null,
requiredMinutes,
radiusKm: input.radiusKm,
recommendations: [] as Recommendation[],
};
if (!SCHEDULABLE_STATUSES.includes(row.status)) return { ...base, status: "not_schedulable", hint: t.notSchedulable };
if (!row.site || !isValidLatLng(row.site)) {
nudgeGeocoding(ctx, row);
return { ...base, status: "no_coordinates", hint: row.site?.geocodeStatus === "not_found" ? t.addressNotFound : t.noCoordinates };
}
const origin = { latitude: row.site.latitude as number, longitude: row.site.longitude as number };
const today = dayKeyOf(new Date(), timeZone);
const from = input.from && input.from > today ? input.from : today;
const days = dayRange(from, addDays(from, input.days - 1));
const teams = await loadTeams(ctx, null);
const rows = await loadPlannedOrders(ctx, {
teamIds: teams.map((tm) => tm.id),
start: dayBounds(days[0], timeZone).start,
end: dayBounds(days[days.length - 1], timeZone).end,
excludeId: row.id,
});
const byId = new Map(rows.map((r) => [r.id, r]));
const orders = rows.map(toPlanningOrder);
const candidates: Omit<Recommendation, "text">[] = [];
for (const team of teams) {
const pt = toPlanningTeam(team);
const teamOrders = orders.filter((o) => o.teamId === team.id);
days.forEach((day, idx) => {
const td = computeTeamDay(pt, day, teamOrders, timeZone);
if (!td.workingDay || td.capacityMinutes <= 0) return;
const freeMinutes = td.capacityMinutes - td.plannedMinutes;
if (freeMinutes <= 0) return;
let near: { id: string; number: string; km: number } | null = null;
for (const o of teamOrders) {
if (minutesOnDay(o, day, timeZone) <= 0) continue;
const site = byId.get(o.id)?.site;
if (!site || !isValidLatLng(site)) continue;
const km = haversineKm(origin, { latitude: site.latitude as number, longitude: site.longitude as number });
if (!near || km < near.km) near = { id: o.id, number: byId.get(o.id)!.number, km };
}
if (!near || near.km > input.radiusKm) return;
const tight = freeMinutes < requiredMinutes;
const score =
0.6 * (1 - near.km / input.radiusKm) +
0.2 * (Math.min(freeMinutes / requiredMinutes, 2) / 2) +
PRIORITY_DATE_WEIGHT[row.priority] * (1 - idx / days.length) -
(tight ? 0.25 : 0);
// Suggested start: 08:00 or right after the team's last timed order that day (15-min grid).
const lastEnd = teamOrders
.filter((o) => minutesOnDay(o, day, timeZone) > 0)
.map((o) => timeWindow(o, timeZone)?.end)
.filter((v): v is number => typeof v === "number")
.sort((a, b) => b - a)[0];
let startMinutes = DAY_START_MINUTES;
if (lastEnd !== undefined) {
const m = Math.ceil(minutesOfDay(new Date(lastEnd), timeZone) / 15) * 15;
if (m > startMinutes && m <= LATEST_START_MINUTES) startMinutes = m;
}
const hh = String(Math.floor(startMinutes / 60)).padStart(2, "0");
const mm = String(startMinutes % 60).padStart(2, "0");
const suggestedStart = wallTimeToUtc(`${day}T${hh}:${mm}`, timeZone) ?? dayBounds(day, timeZone).start;
candidates.push({
teamId: team.id,
teamName: team.name,
day,
distanceKm: Math.round(near.km * 10) / 10,
nearOrder: { id: near.id, number: near.number },
freeMinutes,
requiredMinutes,
tight,
score: Math.round(score * 1000) / 1000,
suggestedStart: suggestedStart.toISOString(),
});
});
}
const locale = input.locale;
const recommendations = candidates
.sort((a, b) => b.score - a.score || a.distanceKm - b.distanceKm || a.day.localeCompare(b.day))
.slice(0, 5)
.map((c) => ({
...c,
text: fillTemplate(c.tight ? t.reasonTight : t.reason, {
distance: formatDistance(c.distanceKm, locale),
number: c.nearOrder.number,
team: c.teamName,
day: formatDayShort(c.day, locale),
free: formatMinutes(c.freeMinutes, locale),
required: formatMinutes(c.requiredMinutes, locale),
}),
}));
return { ...base, status: "ok", recommendations, hint: recommendations.length ? null : fillTemplate(t.none, { radius: input.radiusKm, days: input.days }) };
}
const nearbySchema = z.object({
workOrderId: z.string().min(1).max(64),
radiusKm: z.coerce.number().min(0.5).max(100).default(5),
});
export type NearbyOrder = ReturnType<typeof toBoardOrder> & { distanceKm: number };
/** Other unplanned orders around the target ("zusammen einplanen?"). */
export async function findNearbyUnplanned(
ctx: ServiceCtx,
raw: z.input<typeof nearbySchema>,
): Promise<{ status: "ok" | "no_coordinates"; radiusKm: number; items: NearbyOrder[] }> {
await assertCanPlanAll(ctx);
const input = parseInput(nearbySchema, raw);
const timeZone = await tenantTimezone(ctx);
const row = await loadTarget(ctx, input.workOrderId);
if (!row.site || !isValidLatLng(row.site)) {
nudgeGeocoding(ctx, row);
return { status: "no_coordinates", radiusKm: input.radiusKm, items: [] };
}
const origin = { latitude: row.site.latitude as number, longitude: row.site.longitude as number };
const items = (await loadUnplannedOrders(ctx, null, 500))
.filter((r) => r.id !== row.id && r.site && isValidLatLng(r.site))
.map((r) => ({ ...toBoardOrder(r, timeZone), distanceKm: Math.round(haversineKm(origin, { latitude: r.site!.latitude as number, longitude: r.site!.longitude as number }) * 10) / 10 }))
.filter((r) => r.distanceKm <= input.radiusKm)
.sort((a, b) => a.distanceKm - b.distanceKm)
.slice(0, 10);
return { status: "ok", radiusKm: input.radiusKm, items };
}
+148
View File
@@ -0,0 +1,148 @@
import { z } from "zod";
import { orderDays, SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/planning/capacity";
import type { WorkOrderStatus } from "@/lib/work-orders/status";
import { writeAuditLog } from "@/server/audit";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { getPlanningBoard, MAX_BOARD_DAYS } from "@/server/services/planning/board";
import { assignWorkOrder } from "@/server/services/work-orders/assign";
import { parseInput, snapshot, tenantTimezone, writeWithVersion } from "@/server/services/work-orders/_shared";
import { updateWorkOrder } from "@/server/services/work-orders/update";
import { workOrderScope } from "@/server/services/work-orders/visibility";
/**
* Schedule a work order from the planning board: team + planned start/end (+ duration) in ONE
* transaction on top of the L2 services (assignWorkOrder → work_order.assigned, updateWorkOrder →
* work_order.changed). The human always confirms — nothing is scheduled automatically.
*/
export { SCHEDULABLE_STATUSES };
export const VERSION_CONFLICT_MESSAGE = "Auftrag wurde zwischenzeitlich geändert";
const scheduleSchema = z.object({
workOrderId: z.string().min(1).max(64),
teamId: z.string().min(1).max(64),
plannedStart: z.coerce.date(),
plannedEnd: z.coerce.date().nullish(),
plannedDurationMinutes: z.coerce.number().int().min(15).max(14 * 24 * 60).nullish(),
baseVersion: z.coerce.number().int().positive(),
});
export type ScheduleInput = z.input<typeof scheduleSchema>;
export type ScheduleResult = {
id: string;
number: string;
version: number;
status: WorkOrderStatus;
teamId: string;
plannedStart: string;
plannedEnd: string | null;
plannedDurationMinutes: number | null;
/** Conflicts of the target team on the target day(s), computed after the commit. */
conflicts: PlanningConflict[];
};
export async function scheduleWorkOrder(ctx: ServiceCtx, raw: ScheduleInput): Promise<ScheduleResult> {
assertCan(ctx, "work_order:assign");
assertCan(ctx, "work_order:write");
const input = parseInput(scheduleSchema, raw);
if (Number.isNaN(input.plannedStart.getTime())) throw new ServiceError("invalid", "validation_failed", [{ path: "plannedStart" }]);
const saved = await inTransaction(ctx, async (tx) => {
const wo = await tx.db.workOrder.findFirst({
where: { AND: [{ id: input.workOrderId }, await workOrderScope(tx)] },
select: {
id: true,
number: true,
status: true,
version: true,
assignedTeamId: true,
teamLeadUserId: true,
plannedStart: true,
plannedEnd: true,
plannedDurationMinutes: true,
assignees: { select: { userId: true } },
},
});
if (!wo) throw new ServiceError("not_found", "work_order_not_found");
if (wo.version !== input.baseVersion) {
throw new ServiceError("conflict", VERSION_CONFLICT_MESSAGE, { reason: "version_conflict", currentVersion: wo.version, baseVersion: input.baseVersion });
}
if (!SCHEDULABLE_STATUSES.includes(wo.status as WorkOrderStatus)) throw new ServiceError("invalid", "not_schedulable", { status: wo.status });
// Keep the order's length when only the start moves and no new end was given.
const plannedEnd =
input.plannedEnd !== undefined
? input.plannedEnd ?? null
: wo.plannedStart && wo.plannedEnd
? new Date(input.plannedStart.getTime() + (wo.plannedEnd.getTime() - wo.plannedStart.getTime()))
: null;
const duration = input.plannedDurationMinutes !== undefined ? input.plannedDurationMinutes ?? null : wo.plannedDurationMinutes;
let version = wo.version;
let status = wo.status as WorkOrderStatus;
const teamChanged = wo.assignedTeamId !== input.teamId;
if (teamChanged || ["draft", "review_required", "planned"].includes(wo.status)) {
let userIds = wo.assignees.map((a) => a.userId);
if (teamChanged && userIds.length) {
const now = new Date();
const stay = await tx.db.teamMember.findMany({
where: { teamId: input.teamId, userId: { in: userIds }, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] },
select: { userId: true },
});
userIds = [...new Set(stay.map((s) => s.userId))];
}
const res = await assignWorkOrder(tx, {
workOrderId: wo.id,
teamId: input.teamId,
userIds,
teamLeadUserId: teamChanged ? null : wo.teamLeadUserId,
baseVersion: version,
});
version = res.version;
status = res.status;
}
const datesChanged = wo.plannedStart?.getTime() !== input.plannedStart.getTime() || (wo.plannedEnd?.getTime() ?? null) !== (plannedEnd?.getTime() ?? null);
if (datesChanged) {
const res = await updateWorkOrder(tx, wo.id, { plannedStart: input.plannedStart, plannedEnd }, version);
version = res.version;
}
if (duration !== wo.plannedDurationMinutes) {
version = await writeWithVersion(tx, { id: wo.id, version }, { plannedDurationMinutes: duration });
}
if (version !== wo.version) {
await writeAuditLog({
tenantId: tx.tenantId,
actorId: tx.userId,
action: "update",
entity: "work_order",
entityId: wo.id,
before: snapshot({ assignedTeamId: wo.assignedTeamId, plannedStart: wo.plannedStart, plannedEnd: wo.plannedEnd, plannedDurationMinutes: wo.plannedDurationMinutes, status: wo.status }),
after: snapshot({ source: "planning", assignedTeamId: input.teamId, plannedStart: input.plannedStart, plannedEnd, plannedDurationMinutes: duration, status, version }),
});
}
return { id: wo.id, number: wo.number, version, status, plannedEnd, duration };
});
let conflicts: PlanningConflict[] = [];
try {
const days = orderDays({ plannedStart: input.plannedStart, plannedEnd: saved.plannedEnd }, await tenantTimezone(ctx)).slice(0, MAX_BOARD_DAYS);
const board = await getPlanningBoard(ctx, { from: days[0], to: days[days.length - 1], teamIds: [input.teamId] });
conflicts = board.conflicts.filter((c) => c.teamIds.includes(input.teamId));
} catch (err) {
if (!(err instanceof ServiceError)) throw err;
}
return {
id: saved.id,
number: saved.number,
version: saved.version,
status: saved.status,
teamId: input.teamId,
plannedStart: input.plannedStart.toISOString(),
plannedEnd: saved.plannedEnd?.toISOString() ?? null,
plannedDurationMinutes: saved.duration,
conflicts,
};
}
+23
View File
@@ -0,0 +1,23 @@
import { dayKeyOf } from "@/lib/planning/days";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { getPlanningBoard } from "@/server/services/planning/board";
import { getLiveSituation } from "@/server/services/planning/live";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
/** Dashboard tile "Planung heute" (L13): crews on the job, conflicts today, unplanned orders. Null without planning access. */
export async function getPlanningToday(ctx: ServiceCtx): Promise<{ day: string; teamsTotal: number; teamsWorking: number; conflicts: number; unplanned: number } | null> {
try {
const day = dayKeyOf(new Date(), await tenantTimezone(ctx));
const [board, live] = await Promise.all([getPlanningBoard(ctx, { from: day, to: day }), getLiveSituation(ctx)]);
return {
day,
teamsTotal: board.teams.length,
teamsWorking: live.crews.filter((c) => c.status !== "free").length,
conflicts: board.conflictCount,
unplanned: board.unplanned.length,
};
} catch (err) {
if (err instanceof ServiceError && err.code === "forbidden") return null;
throw err;
}
}
@@ -0,0 +1,29 @@
import { z } from "zod";
import { writeAuditLog } from "@/server/audit";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { parseInput } from "@/server/services/work-orders/_shared";
/** Planning settings of a team = crew (L13): crew working day in minutes + working days bit mask. */
const settingsSchema = z.object({
dailyCapacityMinutes: z.coerce.number().int().min(0).max(24 * 60),
workingDays: z.coerce.number().int().min(0).max(127),
});
export type TeamPlanningSettingsInput = z.input<typeof settingsSchema>;
export async function updateTeamPlanningSettings(ctx: ServiceCtx, teamId: string, raw: TeamPlanningSettingsInput) {
assertCan(ctx, "team:manage");
const input = parseInput(settingsSchema, raw);
const before = await ctx.db.team.findFirst({
where: { id: teamId, deletedAt: null },
select: { id: true, dailyCapacityMinutes: true, workingDays: true },
});
if (!before) throw new ServiceError("not_found", "team_not_found");
const after = await ctx.db.team.update({
where: { id: teamId },
data: input,
select: { id: true, dailyCapacityMinutes: true, workingDays: true },
});
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "team", entityId: teamId, before, after });
return after;
}
@@ -0,0 +1,30 @@
import { Prisma } from "@prisma/client";
/**
* Adapter to the time tracking model of lane L12 (read-only use in planning).
*
* L12 adds `TimeEntry.source` (tracked | manual), `TimeEntry.approvalStatus` (approved | pending |
* rejected) and `WorkSession.manual`. Planning rules:
* - live situation / delay evaluation: only `source = tracked` entries of non-manual sessions,
* - actual duration of completed orders: only `approvalStatus = approved` entries.
* The filters are enabled automatically as soon as the generated Prisma client knows the fields
* (after the L12 merge); before that they are no-ops, so this lane compiles against both schemas.
*/
const entryFields = new Set<string>(Object.values(Prisma.TimeEntryScalarFieldEnum));
const sessionFields = new Set<string>(Object.values(Prisma.WorkSessionScalarFieldEnum));
export const TIME_TRACKING_FIELDS = {
entrySource: entryFields.has("source"),
entryApproval: entryFields.has("approvalStatus"),
sessionManual: sessionFields.has("manual"),
} as const;
/** Time entries recorded by the running clock (no manual additions). */
export const trackedEntryWhere = (TIME_TRACKING_FIELDS.entrySource ? { source: "tracked" } : {}) as unknown as Prisma.TimeEntryWhereInput;
/** Approved time entries (actual duration of completed orders). */
export const approvedEntryWhere = (TIME_TRACKING_FIELDS.entryApproval ? { approvalStatus: "approved" } : {}) as unknown as Prisma.TimeEntryWhereInput;
/** Sessions started by the clock (manual after-the-fact sessions excluded). */
export const trackedSessionWhere = (TIME_TRACKING_FIELDS.sessionManual ? { manual: false } : {}) as unknown as Prisma.WorkSessionWhereInput;
+391
View File
@@ -0,0 +1,391 @@
import { haversineKm, isValidLatLng } from "@/lib/geo/distance";
import {
computeTeamDay,
effectiveDurationMinutes,
estimateTravelMinutes,
hasTimeOfDay,
overrunBucket,
unionMinutes,
} from "@/lib/planning/capacity";
import { addDays, dayBounds, dayKeyOf, isDayKey } from "@/lib/planning/days";
import type { WorkOrderStatus } from "@/lib/work-orders/status";
import { writeAuditLog } from "@/server/audit";
import { dbForTenant, prisma } from "@/server/db";
import { emitEvent } from "@/server/events";
import { PERMISSIONS } from "@/server/rbac";
import type { ServiceCtx } from "@/server/services/context";
import { planningAccess } from "@/server/services/planning/access";
import {
loadPlannedOrders,
loadTeams,
loadUnplannedOrders,
ORDER_SELECT,
toBoardOrder,
toPlanningOrder,
toPlanningTeam,
type BoardOrder,
type OrderRow,
} from "@/server/services/planning/data";
import { approvedEntryWhere, trackedEntryWhere, trackedSessionWhere } from "@/server/services/planning/time-tracking";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
import { workOrderScope } from "@/server/services/work-orders/visibility";
/**
* L13 Planung — watch functions:
* - evaluateDelays: running orders, recorded crew work time (union of tracked `work` segments of all
* sessions of the order — parallel crew members count once) vs. planned duration → ≥ 80 % "risk",
* ≥ 100 % "overrun"; next planned order of the same crew today → endangered when remaining time +
* estimated travel passes its start, or the overrun pushes the crew day over capacity.
* - getFreedCapacity: orders of the day completed before their planned end → free crew minutes +
* suggestions (pull forward later/next orders of the crew, nearby unplanned orders). Never changes data.
* - runPlanningWatch: the only place that emits planning.* events (job `planning-watch`, every 5 min);
* duplicates are prevented by an append-only ledger in the audit log (entity `planning_alert`).
*/
export const DELAY_RISK_RATIO = 0.8;
export const RUNNING_STATUSES: WorkOrderStatus[] = ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created"];
export const COMPLETED_STATUSES: WorkOrderStatus[] = ["technically_completed", "signature_pending", "in_review", "released_for_billing", "billed"];
const UPCOMING_STATUSES: WorkOrderStatus[] = ["planned", "assigned", "accepted"];
const ACTIVE_SESSION_STATUSES: ("en_route" | "running" | "paused")[] = ["en_route", "running", "paused"];
export const MIN_FREED_MINUTES = 15;
const PULL_FORWARD_DAYS = 5;
const MAX_SUGGESTIONS = 3;
const coords = (row: Pick<OrderRow, "site"> | undefined) =>
row?.site && isValidLatLng(row.site) ? { latitude: row.site.latitude as number, longitude: row.site.longitude as number } : null;
const roundUpQuarter = (ms: number) => Math.ceil(ms / (15 * 60_000)) * 15 * 60_000;
// ---------------------------------------------------------------------------------------------
// Delays
// ---------------------------------------------------------------------------------------------
export type OrderDelay = {
workOrderId: string;
number: string;
teamId: string | null;
plannedMinutes: number;
workedMinutes: number;
remainingMinutes: number;
ratio: number;
percent: number;
level: "ok" | "risk" | "overrun";
};
export type FollowupRisk = {
workOrderId: string;
number: string;
teamId: string;
day: string;
blockedById: string;
blockedByNumber: string;
reason: "start" | "capacity";
etaAt: string;
travelMinutes: number;
};
export async function evaluateDelays(ctx: ServiceCtx, opts: { now?: Date; teamIds?: string[] | null } = {}): Promise<{ delays: OrderDelay[]; followups: FollowupRisk[] }> {
const now = opts.now ?? new Date();
const timeZone = await tenantTimezone(ctx);
const scope = await workOrderScope(ctx);
const running = await ctx.db.workOrder.findMany({
where: {
AND: [
scope,
{ status: { in: RUNNING_STATUSES } },
{ workSessions: { some: { AND: [trackedSessionWhere, { status: { in: ACTIVE_SESSION_STATUSES } }] } } },
opts.teamIds ? { assignedTeamId: { in: opts.teamIds } } : {},
],
},
select: ORDER_SELECT,
take: 500,
});
if (running.length === 0) return { delays: [], followups: [] };
const entries = await ctx.db.timeEntry.findMany({
where: { AND: [trackedEntryWhere, { type: "work", startedAt: { lt: now }, workSession: { AND: [trackedSessionWhere, { workOrderId: { in: running.map((r) => r.id) } }] } }] },
select: { startedAt: true, endedAt: true, workSession: { select: { workOrderId: true } } },
});
const segments = new Map<string, { start: number; end: number }[]>();
for (const e of entries) {
const list = segments.get(e.workSession.workOrderId) ?? [];
list.push({ start: e.startedAt.getTime(), end: Math.min((e.endedAt ?? now).getTime(), now.getTime()) });
segments.set(e.workSession.workOrderId, list);
}
const delays: OrderDelay[] = running.map((r) => {
const plannedMinutes = effectiveDurationMinutes(toPlanningOrder(r), timeZone);
const workedMinutes = unionMinutes(segments.get(r.id) ?? []);
const ratio = plannedMinutes > 0 ? workedMinutes / plannedMinutes : 0;
return {
workOrderId: r.id,
number: r.number,
teamId: r.assignedTeamId,
plannedMinutes,
workedMinutes,
remainingMinutes: Math.max(0, plannedMinutes - workedMinutes),
ratio,
percent: Math.round(ratio * 100),
level: ratio >= 1 ? "overrun" : ratio >= DELAY_RISK_RATIO ? "risk" : "ok",
};
});
const today = dayKeyOf(now, timeZone);
const { start, end } = dayBounds(today, timeZone);
const teamIds = [...new Set(delays.map((d) => d.teamId).filter((t): t is string => !!t))];
const [teams, dayOrders] = await Promise.all([loadTeams(ctx, teamIds), loadPlannedOrders(ctx, { teamIds, start, end })]);
const runningById = new Map(running.map((r) => [r.id, r]));
const followups: FollowupRisk[] = [];
for (const d of delays) {
const team = d.teamId ? teams.find((t) => t.id === d.teamId) : undefined;
if (!team) continue;
const next = dayOrders
.filter((o) => o.assignedTeamId === team.id && o.id !== d.workOrderId && UPCOMING_STATUSES.includes(o.status as WorkOrderStatus) && o.plannedStart && o.plannedStart.getTime() >= start.getTime())
.sort((a, b) => a.plannedStart!.getTime() - b.plannedStart!.getTime())[0];
if (!next) continue;
const from = coords(runningById.get(d.workOrderId));
const to = coords(next);
const travelMinutes = estimateTravelMinutes(from && to ? haversineKm(from, to) : null);
const eta = now.getTime() + (d.remainingMinutes + travelMinutes) * 60_000;
const overrunExtra = Math.max(0, d.workedMinutes - d.plannedMinutes);
let reason: FollowupRisk["reason"] | null = null;
if (hasTimeOfDay(next.plannedStart!, timeZone) && eta > next.plannedStart!.getTime()) {
reason = "start";
} else if (overrunExtra > 0) {
const td = computeTeamDay(toPlanningTeam(team), today, dayOrders.filter((o) => o.assignedTeamId === team.id).map(toPlanningOrder), timeZone);
if (td.capacityMinutes > 0 && td.plannedMinutes + overrunExtra + travelMinutes > td.capacityMinutes) reason = "capacity";
}
if (reason) {
followups.push({
workOrderId: next.id,
number: next.number,
teamId: team.id,
day: today,
blockedById: d.workOrderId,
blockedByNumber: d.number,
reason,
etaAt: new Date(eta).toISOString(),
travelMinutes,
});
}
}
return { delays, followups };
}
// ---------------------------------------------------------------------------------------------
// Freed capacity ("früher fertig")
// ---------------------------------------------------------------------------------------------
export type FreedSuggestion = {
kind: "pull_forward" | "nearby_unplanned";
order: BoardOrder;
distanceKm: number | null;
day: string;
suggestedStart: string;
};
export type FreedTeam = {
teamId: string;
teamName: string;
day: string;
earlyMinutes: number;
availableMinutes: number;
availableFrom: string;
orders: { id: string; number: string; earlyMinutes: number; finishedAt: string; actualMinutes: number | null }[];
suggestions: FreedSuggestion[];
};
export async function getFreedCapacity(
ctx: ServiceCtx,
raw: { date?: string | null; now?: Date; radiusKm?: number; withSuggestions?: boolean } = {},
): Promise<{ date: string; teams: FreedTeam[] }> {
const access = await planningAccess(ctx);
const now = raw.now ?? new Date();
const timeZone = await tenantTimezone(ctx);
const today = dayKeyOf(now, timeZone);
const date = raw.date && isDayKey(raw.date) ? raw.date : today;
const radiusKm = raw.radiusKm ?? 15;
const { start, end } = dayBounds(date, timeZone);
const rows = await ctx.db.workOrder.findMany({
where: {
AND: [
await workOrderScope(ctx),
{ status: { in: COMPLETED_STATUSES }, plannedStart: { gte: start, lt: end } },
access.teamIds ? { assignedTeamId: { in: access.teamIds } } : { assignedTeamId: { not: null } },
],
},
select: ORDER_SELECT,
take: 500,
});
if (rows.length === 0) return { date, teams: [] };
const ids = rows.map((r) => r.id);
// Session end (clock sessions only) and approved work time (actual duration after L12 approval).
const [sessions, approved] = await Promise.all([
ctx.db.workSession.findMany({
where: { AND: [trackedSessionWhere, { workOrderId: { in: ids }, status: "ended", endedAt: { not: null } }] },
select: { workOrderId: true, endedAt: true },
}),
ctx.db.timeEntry.findMany({
where: { AND: [approvedEntryWhere, { type: "work", endedAt: { not: null }, workSession: { workOrderId: { in: ids } } }] },
select: { startedAt: true, endedAt: true, workSession: { select: { workOrderId: true } } },
}),
]);
const lastEnd = new Map<string, number>();
for (const s of sessions) lastEnd.set(s.workOrderId, Math.max(lastEnd.get(s.workOrderId) ?? 0, s.endedAt!.getTime()));
const approvedSegments = new Map<string, { start: number; end: number }[]>();
for (const e of approved) {
const list = approvedSegments.get(e.workSession.workOrderId) ?? [];
list.push({ start: e.startedAt.getTime(), end: e.endedAt!.getTime() });
approvedSegments.set(e.workSession.workOrderId, list);
}
type Early = { row: OrderRow; finishedAt: number; plannedEndAt: number; earlyMinutes: number };
const byTeam = new Map<string, Early[]>();
for (const row of rows) {
const finishedAt = lastEnd.get(row.id);
if (!finishedAt || !row.plannedStart || !row.assignedTeamId || !hasTimeOfDay(row.plannedStart, timeZone)) continue;
const plannedEndAt = row.plannedStart.getTime() + effectiveDurationMinutes(toPlanningOrder(row), timeZone) * 60_000;
const earlyMinutes = Math.floor((plannedEndAt - finishedAt) / 60_000);
if (earlyMinutes < MIN_FREED_MINUTES) continue;
const list = byTeam.get(row.assignedTeamId) ?? [];
list.push({ row, finishedAt, plannedEndAt, earlyMinutes });
byTeam.set(row.assignedTeamId, list);
}
if (byTeam.size === 0) return { date, teams: [] };
const teams = await loadTeams(ctx, [...byTeam.keys()]);
const unplanned = raw.withSuggestions === false || date !== today ? [] : await loadUnplannedOrders(ctx, access.teamIds, 300);
const result: FreedTeam[] = [];
for (const team of teams) {
const early = byTeam.get(team.id)!;
const availableFromMs = Math.max(now.getTime(), ...early.map((e) => e.finishedAt));
const availableMinutes = early.reduce((sum, e) => sum + Math.max(0, Math.floor((e.plannedEndAt - Math.max(e.finishedAt, now.getTime())) / 60_000)), 0);
const suggestions: FreedSuggestion[] = [];
if (raw.withSuggestions !== false && date === today && availableMinutes >= MIN_FREED_MINUTES) {
const suggestedStart = new Date(roundUpQuarter(availableFromMs)).toISOString();
const later = await loadPlannedOrders(ctx, { teamIds: [team.id], start: new Date(availableFromMs), end: dayBounds(addDays(date, PULL_FORWARD_DAYS), timeZone).end });
for (const o of later
.filter((o) => UPCOMING_STATUSES.includes(o.status as WorkOrderStatus) && o.plannedStart && o.plannedStart.getTime() > availableFromMs)
.filter((o) => effectiveDurationMinutes(toPlanningOrder(o), timeZone) <= availableMinutes)
.slice(0, MAX_SUGGESTIONS)) {
suggestions.push({ kind: "pull_forward", order: toBoardOrder(o, timeZone), distanceKm: null, day: date, suggestedStart });
}
const latest = [...early].sort((a, b) => b.finishedAt - a.finishedAt)[0];
const origin = coords(latest.row);
if (origin) {
unplanned
.map((o) => ({ o, c: coords(o) }))
.filter((x): x is { o: OrderRow; c: { latitude: number; longitude: number } } => !!x.c)
.map(({ o, c }) => ({ o, km: haversineKm(origin, c) }))
.filter(({ o, km }) => km <= radiusKm && effectiveDurationMinutes(toPlanningOrder(o), timeZone) <= availableMinutes)
.sort((a, b) => a.km - b.km)
.slice(0, MAX_SUGGESTIONS)
.forEach(({ o, km }) => suggestions.push({ kind: "nearby_unplanned", order: toBoardOrder(o, timeZone), distanceKm: Math.round(km * 10) / 10, day: date, suggestedStart }));
}
}
result.push({
teamId: team.id,
teamName: team.name,
day: date,
earlyMinutes: early.reduce((sum, e) => sum + e.earlyMinutes, 0),
availableMinutes,
availableFrom: new Date(availableFromMs).toISOString(),
orders: early.map((e) => ({
id: e.row.id,
number: e.row.number,
earlyMinutes: e.earlyMinutes,
finishedAt: new Date(e.finishedAt).toISOString(),
actualMinutes: approvedSegments.has(e.row.id) ? unionMinutes(approvedSegments.get(e.row.id)!) : null,
})),
suggestions,
});
}
return { date, teams: result };
}
// ---------------------------------------------------------------------------------------------
// Watch job (events)
// ---------------------------------------------------------------------------------------------
export const WATCH_ACTOR_ID = "system:planning-watch";
/** System context of the watch job: all permissions within ONE tenant (dbForTenant + RLS). */
export function watchContext(tenantId: string): ServiceCtx {
return { db: dbForTenant(tenantId), tenantId, userId: WATCH_ACTOR_ID, permissions: new Set<string>(PERMISSIONS) };
}
export type AlertLedger = { claim(ctx: ServiceCtx, key: string, facts: Record<string, unknown>): Promise<boolean> };
/** Dedupe ledger in the append-only audit log (entity `planning_alert`, entityId = dedupe key). */
export const auditAlertLedger: AlertLedger = {
async claim(ctx, key, facts) {
const seen = await ctx.db.auditLog.findFirst({ where: { entity: "planning_alert", entityId: key }, select: { id: true } });
if (seen) return false;
await writeAuditLog({ tenantId: ctx.tenantId, action: "create", entity: "planning_alert", entityId: key, after: facts });
return true;
},
};
export type WatchResult = { overrun: number; followupAtRisk: number; capacityFreed: number };
export async function runPlanningWatch(
tenantId: string,
opts: { now?: Date; emit?: typeof emitEvent; ledger?: AlertLedger } = {},
): Promise<WatchResult> {
const ctx = watchContext(tenantId);
const now = opts.now ?? new Date();
const emit = opts.emit ?? emitEvent;
const ledger = opts.ledger ?? auditAlertLedger;
const result: WatchResult = { overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
const { delays, followups } = await evaluateDelays(ctx, { now });
for (const d of delays) {
const bucket = overrunBucket(d.ratio);
if (bucket < 0) continue;
if (!(await ledger.claim(ctx, `planning.overrun:${d.workOrderId}:${bucket}`, { percent: d.percent, teamId: d.teamId }))) continue;
await emit(ctx, { type: "planning.overrun", entityType: "work_order", entityId: d.workOrderId, data: { percent: d.percent, teamId: d.teamId, occurrenceId: bucket } });
result.overrun++;
}
for (const f of followups) {
if (!(await ledger.claim(ctx, `planning.followup_at_risk:${f.workOrderId}:${f.day}`, { blockedBy: f.blockedByNumber, reason: f.reason }))) continue;
await emit(ctx, {
type: "planning.followup_at_risk",
entityType: "work_order",
entityId: f.workOrderId,
data: { blockerNumber: f.blockedByNumber, teamId: f.teamId, reason: f.reason, occurrenceId: f.day },
});
result.followupAtRisk++;
}
const freed = await getFreedCapacity(ctx, { now, withSuggestions: false });
for (const team of freed.teams) {
for (const o of team.orders) {
if (!(await ledger.claim(ctx, `planning.capacity_freed:${team.teamId}:${freed.date}:${o.id}`, { minutes: o.earlyMinutes }))) continue;
await emit(ctx, { type: "planning.capacity_freed", entityType: "work_order", entityId: o.id, data: { teamId: team.teamId, minutes: o.earlyMinutes, occurrenceId: freed.date } });
result.capacityFreed++;
}
}
return result;
}
/** All tenants (job payload tenantId "*"); a failing tenant never stops the others. */
export async function runPlanningWatchAllTenants(opts: { now?: Date } = {}): Promise<{ tenants: number; failed: number } & WatchResult> {
const tenants = await prisma.tenant.findMany({ select: { id: true } });
const total = { tenants: tenants.length, failed: 0, overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
for (const t of tenants) {
try {
const r = await runPlanningWatch(t.id, opts);
total.overrun += r.overrun;
total.followupAtRisk += r.followupAtRisk;
total.capacityFreed += r.capacityFreed;
} catch (err) {
total.failed++;
console.error(`[planning-watch] tenant ${t.id} failed:`, (err as Error).message);
}
}
return total;
}
+4
View File
@@ -4,6 +4,8 @@ import { writeAuditLog } from "@/server/audit";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { siteScope } from "@/server/services/work-orders/visibility";
import { optStr } from "@/server/services/customers/schemas";
import { requestSiteGeocoding } from "@/server/services/geo/dispatch";
import { addressChanged } from "@/server/services/geo/normalize";
export const SITE_STATUSES = ["active", "inactive", "provisional"] as const;
@@ -121,6 +123,7 @@ export async function createSite(ctx: ServiceCtx, input: SiteCreateInput) {
await assertCustomerAndContact(ctx, data.customerId, data.contactId);
const site = await ctx.db.site.create({ data: { ...data, tenantId: ctx.tenantId, country: data.country ?? "DE" } });
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "site", entityId: site.id, after: site });
void requestSiteGeocoding(ctx.tenantId, site.id); // L13 Planung: queued, never inline
return site;
}
@@ -134,6 +137,7 @@ export async function updateSite(ctx: ServiceCtx, id: string, patch: SitePatchIn
await assertCustomerAndContact(ctx, customerId, contactId);
const after = await ctx.db.site.update({ where: { id }, data: { ...data, contactId } });
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "site", entityId: id, before, after });
if (addressChanged(before, after)) void requestSiteGeocoding(ctx.tenantId, id); // L13 Planung
return after;
}