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:
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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")}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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));
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import { dayBounds, dayKeyOf, dayRange, isWorkingDay, minutesOfDay } from "@/lib/planning/days";
|
||||
import type { WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
|
||||
/**
|
||||
* Capacity, utilization and conflict rules of the planning board (client-safe, pure functions,
|
||||
* unit-tested in scripts/test-planung-core.ts).
|
||||
*
|
||||
* A team is a crew ("Kolonne", 2+ people travelling and working together). Capacity is the crew's
|
||||
* working day in minutes (`Team.dailyCapacityMinutes`, default 480) on its working days — NOT
|
||||
* person-hours. Order durations are crew time as well.
|
||||
*
|
||||
* Duration of an order (minutes): plannedDurationMinutes → plannedStart/End on the same day with a
|
||||
* time of day → OrderType.defaultDurationMinutes → 120.
|
||||
* Multi-day orders (plannedEnd on a later day) occupy every day of the range: an explicit duration is
|
||||
* split evenly, otherwise each day counts with the order type default (or a full day of 480 min).
|
||||
*/
|
||||
|
||||
/** Statuses the planning board may (re)schedule — running or finished orders are never moved. */
|
||||
export const SCHEDULABLE_STATUSES: readonly WorkOrderStatus[] = ["draft", "review_required", "planned", "assigned", "accepted"];
|
||||
|
||||
export const FALLBACK_DURATION_MINUTES = 120;
|
||||
export const FULL_DAY_MINUTES = 480;
|
||||
/** Below this number of active members a crew is flagged as incomplete (hint, not a conflict). */
|
||||
export const MIN_CREW_SIZE = 2;
|
||||
|
||||
/** Travel time estimate: straight line × 1.3 detour at 50 km/h, at least 10 min. */
|
||||
export const TRAVEL_DETOUR_FACTOR = 1.3;
|
||||
export const TRAVEL_SPEED_KMH = 50;
|
||||
export const MIN_TRAVEL_MINUTES = 10;
|
||||
|
||||
export type PlanningOrder = {
|
||||
id: string;
|
||||
teamId: string | null;
|
||||
plannedStart: Date | null;
|
||||
plannedEnd: Date | null;
|
||||
plannedDurationMinutes: number | null;
|
||||
orderTypeDefaultMinutes: number | null;
|
||||
assigneeIds: string[];
|
||||
};
|
||||
|
||||
export type PlanningTeam = {
|
||||
id: string;
|
||||
dailyCapacityMinutes: number;
|
||||
workingDays: number;
|
||||
members: { userId: string; validFrom: Date; validTo: Date | null; active: boolean }[];
|
||||
};
|
||||
|
||||
export const CONFLICT_KINDS = ["overbooked", "overlap", "assignee_double_booked", "outside_working_days", "crew_incomplete"] as const;
|
||||
export type ConflictKind = (typeof CONFLICT_KINDS)[number];
|
||||
|
||||
export type PlanningConflict = {
|
||||
kind: ConflictKind;
|
||||
/** "hint" (crew_incomplete) is shown but not counted as a conflict. */
|
||||
severity: "conflict" | "hint";
|
||||
day: string;
|
||||
teamIds: string[];
|
||||
orderIds: string[];
|
||||
userId?: string;
|
||||
minutesOver?: number;
|
||||
memberCount?: number;
|
||||
};
|
||||
|
||||
export const isConflict = (c: Pick<PlanningConflict, "severity">) => c.severity === "conflict";
|
||||
|
||||
/** An instant at exactly local midnight counts as "date only" (no time of day entered). */
|
||||
export function hasTimeOfDay(date: Date, timeZone: string): boolean {
|
||||
return minutesOfDay(date, timeZone) !== 0;
|
||||
}
|
||||
|
||||
/** Days (keys) the order occupies; an end at exactly local midnight belongs to the previous day. */
|
||||
export function orderDays(o: Pick<PlanningOrder, "plannedStart" | "plannedEnd">, timeZone: string): string[] {
|
||||
if (!o.plannedStart) return [];
|
||||
const first = dayKeyOf(o.plannedStart, timeZone);
|
||||
if (!o.plannedEnd || o.plannedEnd <= o.plannedStart) return [first];
|
||||
const endMinusOne = new Date(o.plannedEnd.getTime() - (hasTimeOfDay(o.plannedEnd, timeZone) ? 0 : 1));
|
||||
const last = dayKeyOf(endMinusOne, timeZone);
|
||||
return last < first ? [first] : dayRange(first, last);
|
||||
}
|
||||
|
||||
export function effectiveDurationMinutes(o: PlanningOrder, timeZone: string): number {
|
||||
if (o.plannedDurationMinutes && o.plannedDurationMinutes > 0) return o.plannedDurationMinutes;
|
||||
if (o.plannedStart && o.plannedEnd && o.plannedEnd > o.plannedStart && hasTimeOfDay(o.plannedStart, timeZone) && orderDays(o, timeZone).length === 1) {
|
||||
return Math.round((o.plannedEnd.getTime() - o.plannedStart.getTime()) / 60_000);
|
||||
}
|
||||
if (o.orderTypeDefaultMinutes && o.orderTypeDefaultMinutes > 0) return o.orderTypeDefaultMinutes;
|
||||
return FALLBACK_DURATION_MINUTES;
|
||||
}
|
||||
|
||||
/** Minutes the order occupies on `day` (0 if it is not planned on that day). */
|
||||
export function minutesOnDay(o: PlanningOrder, day: string, timeZone: string): number {
|
||||
const days = orderDays(o, timeZone);
|
||||
if (!days.includes(day)) return 0;
|
||||
if (days.length === 1) return effectiveDurationMinutes(o, timeZone);
|
||||
if (o.plannedDurationMinutes && o.plannedDurationMinutes > 0) return Math.ceil(o.plannedDurationMinutes / days.length);
|
||||
return o.orderTypeDefaultMinutes && o.orderTypeDefaultMinutes > 0 ? o.orderTypeDefaultMinutes : FULL_DAY_MINUTES;
|
||||
}
|
||||
|
||||
/** Time window in ms for single-day orders with a time of day, otherwise null (no overlap check). */
|
||||
export function timeWindow(o: PlanningOrder, timeZone: string): { start: number; end: number } | null {
|
||||
if (!o.plannedStart || !hasTimeOfDay(o.plannedStart, timeZone) || orderDays(o, timeZone).length !== 1) return null;
|
||||
const start = o.plannedStart.getTime();
|
||||
const end = o.plannedEnd && o.plannedEnd > o.plannedStart ? o.plannedEnd.getTime() : start + effectiveDurationMinutes(o, timeZone) * 60_000;
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
const overlaps = (a: { start: number; end: number }, b: { start: number; end: number }) => a.start < b.end && b.start < a.end;
|
||||
|
||||
/** Distinct active members (user active, membership valid on the day). */
|
||||
export function activeMemberCount(team: PlanningTeam, day: string, timeZone: string): number {
|
||||
const { start, end } = dayBounds(day, timeZone);
|
||||
return new Set(team.members.filter((m) => m.active && m.validFrom < end && (!m.validTo || m.validTo > start)).map((m) => m.userId)).size;
|
||||
}
|
||||
|
||||
/** Crew capacity: the crew working day on working days if anyone is active, otherwise 0. */
|
||||
export function teamCapacityMinutes(team: PlanningTeam, day: string, timeZone: string): number {
|
||||
if (!isWorkingDay(team.workingDays, day)) return 0;
|
||||
return activeMemberCount(team, day, timeZone) > 0 ? team.dailyCapacityMinutes : 0;
|
||||
}
|
||||
|
||||
export function utilizationPercent(plannedMinutes: number, capacityMinutes: number): number | null {
|
||||
return capacityMinutes > 0 ? Math.round((plannedMinutes / capacityMinutes) * 100) : null;
|
||||
}
|
||||
|
||||
export type TeamDay = {
|
||||
day: string;
|
||||
workingDay: boolean;
|
||||
memberCount: number;
|
||||
capacityMinutes: number;
|
||||
plannedMinutes: number;
|
||||
utilization: number | null;
|
||||
orderIds: string[];
|
||||
conflicts: PlanningConflict[];
|
||||
};
|
||||
|
||||
/** Load + team-local conflicts (overbooked, overlap, outside_working_days) + crew_incomplete hint of one team on one day. */
|
||||
export function computeTeamDay(team: PlanningTeam, day: string, teamOrders: PlanningOrder[], timeZone: string): TeamDay {
|
||||
const onDay = teamOrders.filter((o) => o.teamId === team.id && minutesOnDay(o, day, timeZone) > 0);
|
||||
const workingDay = isWorkingDay(team.workingDays, day);
|
||||
const memberCount = activeMemberCount(team, day, timeZone);
|
||||
const capacityMinutes = teamCapacityMinutes(team, day, timeZone);
|
||||
const plannedMinutes = onDay.reduce((sum, o) => sum + minutesOnDay(o, day, timeZone), 0);
|
||||
const ids = onDay.map((o) => o.id);
|
||||
const conflicts: PlanningConflict[] = [];
|
||||
if (!workingDay && onDay.length > 0) {
|
||||
conflicts.push({ kind: "outside_working_days", severity: "conflict", day, teamIds: [team.id], orderIds: ids });
|
||||
} else if (workingDay && plannedMinutes > capacityMinutes && onDay.length > 0) {
|
||||
conflicts.push({ kind: "overbooked", severity: "conflict", day, teamIds: [team.id], orderIds: ids, minutesOver: plannedMinutes - capacityMinutes });
|
||||
}
|
||||
const timed = onDay.map((o) => ({ o, w: timeWindow(o, timeZone) })).filter((x): x is { o: PlanningOrder; w: { start: number; end: number } } => !!x.w);
|
||||
for (let i = 0; i < timed.length; i++) {
|
||||
for (let j = i + 1; j < timed.length; j++) {
|
||||
if (overlaps(timed[i].w, timed[j].w)) conflicts.push({ kind: "overlap", severity: "conflict", day, teamIds: [team.id], orderIds: [timed[i].o.id, timed[j].o.id] });
|
||||
}
|
||||
}
|
||||
if (workingDay && memberCount < MIN_CREW_SIZE) {
|
||||
conflicts.push({ kind: "crew_incomplete", severity: "hint", day, teamIds: [team.id], orderIds: ids, memberCount });
|
||||
}
|
||||
return { day, workingDay, memberCount, capacityMinutes, plannedMinutes, utilization: utilizationPercent(plannedMinutes, capacityMinutes), orderIds: ids, conflicts };
|
||||
}
|
||||
|
||||
/** A technician individually assigned to two orders (any team) whose time windows overlap on `day`. */
|
||||
export function assigneeDoubleBookings(orders: PlanningOrder[], day: string, timeZone: string): PlanningConflict[] {
|
||||
const byUser = new Map<string, { o: PlanningOrder; w: { start: number; end: number } }[]>();
|
||||
for (const o of orders) {
|
||||
if (!orderDays(o, timeZone).includes(day)) continue;
|
||||
const w = timeWindow(o, timeZone);
|
||||
if (!w) continue;
|
||||
for (const userId of new Set(o.assigneeIds)) {
|
||||
const list = byUser.get(userId) ?? [];
|
||||
list.push({ o, w });
|
||||
byUser.set(userId, list);
|
||||
}
|
||||
}
|
||||
const out: PlanningConflict[] = [];
|
||||
for (const [userId, list] of byUser) {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
if (overlaps(list[i].w, list[j].w)) {
|
||||
const teamIds = [...new Set([list[i].o.teamId, list[j].o.teamId].filter((t): t is string => !!t))];
|
||||
out.push({ kind: "assignee_double_booked", severity: "conflict", day, teamIds, orderIds: [list[i].o.id, list[j].o.id], userId });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Length of the union of time intervals in minutes (crew time: parallel segments count once). */
|
||||
export function unionMinutes(intervals: { start: number; end: number }[]): number {
|
||||
const sorted = intervals.filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);
|
||||
let total = 0;
|
||||
let curStart = -Infinity;
|
||||
let curEnd = -Infinity;
|
||||
for (const i of sorted) {
|
||||
if (i.start > curEnd) {
|
||||
if (curEnd > curStart) total += curEnd - curStart;
|
||||
curStart = i.start;
|
||||
curEnd = i.end;
|
||||
} else if (i.end > curEnd) {
|
||||
curEnd = i.end;
|
||||
}
|
||||
}
|
||||
if (curEnd > curStart) total += curEnd - curStart;
|
||||
return Math.round(total / 60_000);
|
||||
}
|
||||
|
||||
/** Estimated travel minutes for a straight-line distance (null = unknown → minimum). */
|
||||
export function estimateTravelMinutes(km: number | null): number {
|
||||
if (km === null || !Number.isFinite(km)) return MIN_TRAVEL_MINUTES;
|
||||
return Math.max(MIN_TRAVEL_MINUTES, Math.round(((km * TRAVEL_DETOUR_FACTOR) / TRAVEL_SPEED_KMH) * 60));
|
||||
}
|
||||
|
||||
/** Overrun notification bucket: 0 at ≥ 100 %, 1 at ≥ 150 %, 2 at ≥ 200 % … (-1 below 100 %). */
|
||||
export function overrunBucket(ratio: number): number {
|
||||
return ratio < 1 ? -1 : Math.floor((ratio - 1) / 0.5 + 1e-9);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { safeTimeZone, wallTimeToUtc } from "@/lib/work-orders/time";
|
||||
|
||||
/**
|
||||
* Calendar-day helpers for the planning board (client-safe). A "day key" is `YYYY-MM-DD` in the
|
||||
* tenant timezone; weekday index 0 = Monday … 6 = Sunday. Team working days are a bit mask
|
||||
* (Mon = 1, Tue = 2, … Sun = 64; default 31 = Mon–Fri).
|
||||
*/
|
||||
|
||||
export const DEFAULT_WORKING_DAYS = 31;
|
||||
export const WEEKDAY_BITS = [1, 2, 4, 8, 16, 32, 64] as const;
|
||||
|
||||
const DAY_KEY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
|
||||
|
||||
export function isDayKey(value: unknown): value is string {
|
||||
if (typeof value !== "string") return false;
|
||||
const m = DAY_KEY_RE.exec(value);
|
||||
if (!m) return false;
|
||||
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
|
||||
return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3];
|
||||
}
|
||||
|
||||
/** Day key of an instant in the given timezone. */
|
||||
export function dayKeyOf(date: Date, timeZone: string): string {
|
||||
return new Intl.DateTimeFormat("en-CA", { timeZone: safeTimeZone(timeZone), year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
|
||||
}
|
||||
|
||||
/** Pure calendar arithmetic on day keys (no timezone involved). */
|
||||
export function addDays(dayKey: string, days: number): string {
|
||||
const [y, m, d] = dayKey.split("-").map(Number);
|
||||
return new Date(Date.UTC(y, m - 1, d + days)).toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/** 0 = Monday … 6 = Sunday. */
|
||||
export function weekdayIndex(dayKey: string): number {
|
||||
const [y, m, d] = dayKey.split("-").map(Number);
|
||||
return (new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7;
|
||||
}
|
||||
|
||||
export function startOfWeek(dayKey: string): string {
|
||||
return addDays(dayKey, -weekdayIndex(dayKey));
|
||||
}
|
||||
|
||||
/** Inclusive list of day keys. */
|
||||
export function dayRange(from: string, to: string): string[] {
|
||||
const out: string[] = [];
|
||||
for (let k = from; k <= to && out.length < 400; k = addDays(k, 1)) out.push(k);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** [start, end) instants of a calendar day in the timezone (DST-safe). */
|
||||
export function dayBounds(dayKey: string, timeZone: string): { start: Date; end: Date } {
|
||||
const start = wallTimeToUtc(dayKey, timeZone);
|
||||
const end = wallTimeToUtc(addDays(dayKey, 1), timeZone);
|
||||
if (!start || !end) throw new Error(`invalid day key ${dayKey}`);
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function isWorkingDay(mask: number, dayKey: string): boolean {
|
||||
return (mask & WEEKDAY_BITS[weekdayIndex(dayKey)]) !== 0;
|
||||
}
|
||||
|
||||
/** `start` (always, even on a weekend) followed by the next working days — `count` keys in total. */
|
||||
export function nextWorkingDays(start: string, count: number, mask = DEFAULT_WORKING_DAYS): string[] {
|
||||
const out = [start];
|
||||
for (let k = addDays(start, 1); out.length < count && out.length < 400; k = addDays(k, 1)) if (isWorkingDay(mask, k)) out.push(k);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Move by `n` working days (negative = backwards); non-working start days count from the next one. */
|
||||
export function addWorkingDays(day: string, n: number, mask = DEFAULT_WORKING_DAYS): string {
|
||||
let k = day;
|
||||
const step = n < 0 ? -1 : 1;
|
||||
for (let left = Math.abs(n); left > 0; ) {
|
||||
k = addDays(k, step);
|
||||
if (isWorkingDay(mask, k)) left--;
|
||||
}
|
||||
return k;
|
||||
}
|
||||
|
||||
/** Minutes since local midnight of an instant in the timezone. */
|
||||
export function minutesOfDay(date: Date, timeZone: string): number {
|
||||
const parts = new Intl.DateTimeFormat("en-GB", { timeZone: safeTimeZone(timeZone), hourCycle: "h23", hour: "2-digit", minute: "2-digit" }).formatToParts(date);
|
||||
const n = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? 0);
|
||||
return n("hour") * 60 + n("minute");
|
||||
}
|
||||
|
||||
/** Local wall time "HH:mm" of an instant. */
|
||||
export function wallClock(date: Date, timeZone: string): string {
|
||||
const m = minutesOfDay(date, timeZone);
|
||||
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/** Small client-safe text helpers of the planning module (no ICU runtime needed on the server). */
|
||||
|
||||
const tag = (locale: string) => (locale === "en" ? "en-GB" : "de-DE");
|
||||
|
||||
/** Replaces `{name}` placeholders (simple message templates from messages/<locale>/planning.json). */
|
||||
export function fillTemplate(template: string, vars: Record<string, string | number>): string {
|
||||
return template.replace(/\{(\w+)\}/g, (match, key: string) => (key in vars ? String(vars[key]) : match));
|
||||
}
|
||||
|
||||
/** "45 min", "4 h", "4,5 h". */
|
||||
export function formatMinutes(minutes: number, locale: string): string {
|
||||
const m = Math.round(minutes);
|
||||
if (Math.abs(m) < 60) return `${m} min`;
|
||||
return `${new Intl.NumberFormat(tag(locale), { maximumFractionDigits: 1 }).format(m / 60)} h`;
|
||||
}
|
||||
|
||||
/** "Di 16.09." (de) / "Tue 16 Sep" (en) for a day key. */
|
||||
export function formatDayShort(dayKey: string, locale: string): string {
|
||||
const [y, m, d] = dayKey.split("-").map(Number);
|
||||
const date = new Date(Date.UTC(y, m - 1, d, 12));
|
||||
const weekday = new Intl.DateTimeFormat(tag(locale), { weekday: "short", timeZone: "UTC" }).format(date).replace(/\.$/, "");
|
||||
if (locale === "en") return `${weekday} ${new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", timeZone: "UTC" }).format(date)}`;
|
||||
return `${weekday} ${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.`;
|
||||
}
|
||||
@@ -14,15 +14,16 @@ export const DEFAULT_NUMBER_PREFIX: Record<"customer" | "work_order" | "emergenc
|
||||
};
|
||||
|
||||
/** Spec §10.2 — created by ensureDefaultOrderTypes on first use per tenant. */
|
||||
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)");
|
||||
}
|
||||
@@ -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,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> {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user