Merge lane/planung in feature/craftvia-mvp

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:15:47 +02:00
co-authored by Claude Opus 5
77 changed files with 6326 additions and 21 deletions
+4 -1
View File
@@ -7,15 +7,18 @@ import { cn } from "@/lib/utils";
export function NavLink({
href,
match,
exact,
children,
}: {
href: string;
match?: string[];
/** L13: only the exact path is active (sub entries such as /planning vs. /planning/live) */
exact?: boolean;
children: React.ReactNode;
}) {
const pathname = usePathname();
const active = (match ?? [href]).some(
(m) => pathname === m || pathname.startsWith(m + "/")
(m) => pathname === m || (!exact && pathname.startsWith(m + "/"))
);
return (
+52
View File
@@ -0,0 +1,52 @@
"use client";
import Link from "next/link";
import { useActionState } from "react";
import { useTranslations } from "next-intl";
import { AlertCircle } from "lucide-react";
import { buttonCls } from "@/components/work-orders/button-cls";
import { Field, inputCls } from "@/components/work-orders/ui";
import type { ActionState } from "@/server/api/action-state";
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
/** Team capacity popup form (L13): minutes per member per working day + working days. */
export function CapacityForm({ action, minutes, workingDays, closeHref }: { action: Action; minutes: number; workingDays: number; closeHref: string }) {
const t = useTranslations("planning");
const [state, formAction, pending] = useActionState(action, { status: "idle" } as ActionState);
const errorText =
state.status === "error" ? (t.has(`capacity.errors.${state.code}`) ? t(`capacity.errors.${state.code}`) : t("capacity.errors.generic")) : null;
return (
<form action={formAction} className="grid gap-4 p-5">
<Field label={t("capacity.minutes")} htmlFor="cap-minutes" hint={t("capacity.minutesHint")}>
<input id="cap-minutes" name="minutes" type="number" min={0} max={1440} step={15} required defaultValue={minutes} className={inputCls} autoFocus />
</Field>
<fieldset>
<legend className="mb-1 text-[13px] font-semibold">{t("capacity.days")}</legend>
<div className="flex flex-wrap gap-x-4">
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
<label key={i} className="flex min-h-11 items-center gap-2 text-sm">
<input type="checkbox" name="day" value={1 << i} defaultChecked={(workingDays & (1 << i)) !== 0} className="size-5 accent-[var(--ui-primary)]" />
{t(`weekday.${i}`)}
</label>
))}
</div>
</fieldset>
{errorText && (
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)]">
<AlertCircle className="size-4 shrink-0" aria-hidden />
{errorText}
</p>
)}
<div className="flex flex-wrap gap-2">
<button type="submit" disabled={pending} className={buttonCls("primary")}>
{pending ? t("capacity.saving") : t("capacity.save")}
</button>
<Link href={closeHref} className={buttonCls("ghost")}>
{t("dialog.cancel")}
</Link>
</div>
</form>
);
}
@@ -0,0 +1,47 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { CalendarClock, CalendarRange } from "lucide-react";
import type { ServiceCtx } from "@/server/services/context";
import { countWeekConflicts } from "@/server/services/planning/board";
import { getPlanningToday } from "@/server/services/planning/summary";
const tileCls =
"shadow-card flex h-full min-h-24 items-start gap-3 rounded-xl border border-l-4 bg-card p-4 transition-colors hover:bg-muted/40 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none";
/** Dashboard tiles of the planning lane (L13, additive): "Planung heute" + "Konflikte diese Woche"; hidden without access. */
export async function PlanningTiles({ ctx }: { ctx: ServiceCtx }) {
const [today, week] = await Promise.all([getPlanningToday(ctx).catch(() => null), countWeekConflicts(ctx).catch(() => null)]);
if (!today && !week) return null;
const t = await getTranslations("planning");
return (
<>
{today && (
<li>
<Link href="/planning" className={tileCls} style={{ borderLeftColor: today.conflicts > 0 ? "var(--risk)" : "var(--ui-primary)" }}>
<CalendarClock className="mt-0.5 size-5 shrink-0" style={{ color: "var(--ui-primary)" }} aria-hidden />
<div className="min-w-0">
<p className="text-[13px] font-semibold text-muted-foreground">{t("dashboardTile.todayTitle")}</p>
<p className="font-heading text-3xl leading-tight font-bold text-foreground">
{today.teamsWorking}
<span className="text-base font-semibold text-muted-foreground"> / {today.teamsTotal}</span>
</p>
<p className="text-xs text-muted-foreground">{t("dashboardTile.todayHint", { conflicts: today.conflicts, unplanned: today.unplanned })}</p>
</div>
</Link>
</li>
)}
{week && (
<li>
<Link href={`/planning?view=week&date=${week.from}`} className={tileCls} style={{ borderLeftColor: week.count > 0 ? "var(--risk)" : "var(--line)" }}>
<CalendarRange className="mt-0.5 size-5 shrink-0" style={{ color: week.count > 0 ? "var(--risk)" : "var(--txt-muted)" }} aria-hidden />
<div className="min-w-0">
<p className="text-[13px] font-semibold text-muted-foreground">{t("dashboardTile.title")}</p>
<p className="font-heading text-3xl leading-tight font-bold text-foreground">{week.count}</p>
<p className="text-xs text-muted-foreground">{t("dashboardTile.hint")}</p>
</div>
</Link>
</li>
)}
</>
);
}
+112
View File
@@ -0,0 +1,112 @@
"use client";
import "leaflet/dist/leaflet.css";
import { useEffect, useRef, useState } from "react";
import type { LayerGroup, Map as LeafletMap } from "leaflet";
import { STATUS_TONE } from "./live-tone";
/**
* Leaflet map of the live situation (L13). Tiles from MAP_TILE_URL (default OpenStreetMap, CSP img-src
* allows only that host), attribution always visible, marker icons as inline SVG (no CDN assets).
* One marker per site; several people at the same site are bundled with a count.
*/
export type LiveMarker = {
key: string;
latitude: number;
longitude: number;
status: "en_route" | "working" | "paused";
count: number;
label: string;
lines: string[];
/** delay likely / exceeded → red ring (text is part of label and popup) */
alert?: boolean;
};
export { STATUS_TONE };
const GLYPH: Record<LiveMarker["status"], string> = {
// truck
en_route: '<path d="M3 7h10v8H3zM13 10h4l3 3v2h-7z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/><circle cx="7" cy="16.5" r="1.6" fill="#fff"/><circle cx="16.5" cy="16.5" r="1.6" fill="#fff"/>',
// wrench
working: '<path d="M14.5 5.5a4 4 0 0 0-5 5L4.5 15.5l2 2 5-5a4 4 0 0 0 5-5l-2.3 2.3-2-2z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/>',
// pause
paused: '<rect x="7" y="6" width="3.2" height="12" rx="1" fill="#fff"/><rect x="13.8" y="6" width="3.2" height="12" rx="1" fill="#fff"/>',
};
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] as string);
function iconHtml(m: LiveMarker): string {
const tone = STATUS_TONE[m.status];
const badge =
m.count > 1
? `<span style="position:absolute;top:-6px;right:-8px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:var(--brand-graphit);color:#fff;font:700 11px/20px system-ui,sans-serif;text-align:center;border:2px solid #fff">${m.count}</span>`
: "";
return `<div role="img" aria-label="${escapeHtml(m.label)}" style="position:relative;width:34px;height:34px;color:${tone}"><svg width="34" height="34" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="currentColor" stroke="${m.alert ? "var(--risk)" : "#fff"}" stroke-width="${m.alert ? 3 : 2}"/><g transform="translate(1.2 0.6) scale(0.9)">${GLYPH[m.status]}</g></svg>${badge}</div>`;
}
export function LiveMap({ markers, tileUrl, attribution, label, loadingLabel }: { markers: LiveMarker[]; tileUrl: string; attribution: string; label: string; loadingLabel: string }) {
const container = useRef<HTMLDivElement>(null);
const mapRef = useRef<LeafletMap | null>(null);
const layerRef = useRef<LayerGroup | null>(null);
const leafletRef = useRef<typeof import("leaflet") | null>(null);
const fittedRef = useRef<string>("");
const [ready, setReady] = useState(false);
useEffect(() => {
let cancelled = false;
import("leaflet").then((mod) => {
const L = (mod as unknown as { default?: typeof import("leaflet") }).default ?? mod;
if (cancelled || !container.current || mapRef.current) return;
leafletRef.current = L;
const map = L.map(container.current, { center: [51.1657, 10.4515], zoom: 6, scrollWheelZoom: true });
L.tileLayer(tileUrl, {
maxZoom: 19,
attribution: `${escapeHtml(attribution)} (<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">ODbL</a>)`,
}).addTo(map);
layerRef.current = L.layerGroup().addTo(map);
mapRef.current = map;
setReady(true);
});
return () => {
cancelled = true;
mapRef.current?.remove();
mapRef.current = null;
layerRef.current = null;
};
}, [tileUrl, attribution]);
useEffect(() => {
const L = leafletRef.current;
const map = mapRef.current;
const layer = layerRef.current;
if (!ready || !L || !map || !layer) return;
layer.clearLayers();
for (const m of markers) {
const marker = L.marker([m.latitude, m.longitude], {
icon: L.divIcon({ html: iconHtml(m), className: "", iconSize: [34, 34], iconAnchor: [17, 17], popupAnchor: [0, -16] }),
title: m.label,
alt: m.label,
keyboard: true,
});
marker.bindPopup(`<div style="font:13px/1.4 system-ui,sans-serif">${m.lines.map((l) => `<div>${escapeHtml(l)}</div>`).join("")}</div>`);
marker.addTo(layer);
}
const signature = markers.map((m) => m.key).sort().join("|");
if (markers.length && signature !== fittedRef.current) {
fittedRef.current = signature;
map.fitBounds(L.latLngBounds(markers.map((m) => [m.latitude, m.longitude] as [number, number])), { padding: [40, 40], maxZoom: 14 });
}
}, [markers, ready]);
return (
<div className="relative">
<div ref={container} role="region" aria-label={label} className="h-[62vh] min-h-80 w-full overflow-hidden rounded-xl border bg-muted" />
{!ready && (
<p role="status" className="absolute inset-0 grid place-items-center text-sm text-muted-foreground">
{loadingLabel}
</p>
)}
</div>
);
}
+361
View File
@@ -0,0 +1,361 @@
"use client";
import Link from "next/link";
import dynamic from "next/dynamic";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { AlarmClock, CircleDashed, Coffee, EyeOff, Hourglass, MapPinOff, RefreshCw, Timer, Truck, UsersRound, Wrench, type LucideIcon } from "lucide-react";
import { formatDistance } from "@/lib/geo/distance";
import { wallClock } from "@/lib/planning/days";
import { formatMinutes } from "@/lib/planning/text";
import { cn } from "@/lib/utils";
import { buttonCls } from "@/components/work-orders/button-cls";
import { inputCls } from "@/components/work-orders/ui";
import type { LiveCrew, LiveDelay, LiveSituation, LiveStatus } from "@/server/services/planning/live";
import { STATUS_TONE, type LiveMarker } from "./live-map";
const LiveMap = dynamic(() => import("./live-map").then((m) => m.LiveMap), { ssr: false });
const STATUS_ICON: Record<LiveStatus, LucideIcon> = { en_route: Truck, working: Wrench, paused: Coffee, free: CircleDashed };
const STATUSES: LiveStatus[] = ["en_route", "working", "paused", "free"];
const POLL_MS = 30_000;
/**
* Live situation (L13): one marker/card per crew at the site of its running order (members with their
* own status), technicians without team individually; delay badges, crews that finished early with
* pull-forward links, filters, polling every 30 s, "zuletzt aktualisiert". No GPS.
*/
export function LiveSituationView({ initial, tileUrl, attribution, locale }: { initial: LiveSituation; tileUrl: string; attribution: string; locale: string }) {
const t = useTranslations("planning");
const [data, setData] = useState(initial);
const [teamId, setTeamId] = useState("");
const [status, setStatus] = useState<LiveStatus | "">("");
const [tab, setTab] = useState<"map" | "list">("map");
const [loading, setLoading] = useState(false);
const [failed, setFailed] = useState(false);
const tz = data.timeZone;
const fetchLive = useCallback(async (filters: { teamId: string; status: LiveStatus | "" }) => {
setLoading(true);
try {
const qs = new URLSearchParams();
if (filters.teamId) qs.set("teamId", filters.teamId);
if (filters.status) qs.set("status", filters.status);
const res = await fetch(`/api/v1/planning/live?${qs}`, { cache: "no-store" });
if (!res.ok) throw new Error(String(res.status));
setData((await res.json()) as LiveSituation);
setFailed(false);
} catch {
setFailed(true);
} finally {
setLoading(false);
}
}, []);
const load = useCallback(() => fetchLive({ teamId, status }), [fetchLive, teamId, status]);
// Polling every 30 s (skipped while the tab is hidden); setState happens in the fetch callback.
useEffect(() => {
const timer = window.setInterval(() => {
if (!document.hidden) void load();
}, POLL_MS);
return () => window.clearInterval(timer);
}, [load]);
const changeFilter = (next: { teamId?: string; status?: LiveStatus | "" }) => {
const filters = { teamId: next.teamId ?? teamId, status: next.status ?? status };
setTeamId(filters.teamId);
setStatus(filters.status);
void fetchLive(filters);
};
const delayText = useCallback(
(d: LiveDelay) => (d.level === "overrun" ? t("live.delayOverrun", { percent: d.percent }) : t("live.delayRisk", { percent: d.percent })),
[t],
);
const solo = data.technicians.filter((x) => !x.crewId);
const markers = useMemo<LiveMarker[]>(() => {
const out: LiveMarker[] = [];
for (const crew of data.crews) {
if (!crew.current || crew.current.latitude === null || crew.current.longitude === null || crew.status === "free") continue;
const active = crew.members.filter((m) => m.status !== "free").length;
const head = `${crew.teamName} · ${crew.current.number} · ${t(`live.status.${crew.status}`)}${crew.delay ? ` · ${delayText(crew.delay)}` : ""}`;
out.push({
key: `crew-${crew.teamId}`,
latitude: crew.current.latitude,
longitude: crew.current.longitude,
status: crew.status as LiveMarker["status"],
count: Math.max(1, active),
alert: !!crew.delay,
label: `${head}: ${t("live.markerCount", { count: active })}`,
lines: [head, crew.current.siteName ?? "", ...crew.members.map((m) => `${m.name} – ${t(`live.status.${m.status}`)}${m.orderNumber && m.orderNumber !== crew.current?.number ? ` (${m.orderNumber})` : ""}`)].filter(Boolean),
});
}
for (const tech of solo) {
if (!tech.current?.visible || tech.current.latitude === null || tech.current.longitude === null || tech.status === "free") continue;
const line = `${tech.name} – ${t(`live.status.${tech.status}`)} – ${tech.current.number}${tech.delay ? ` · ${delayText(tech.delay)}` : ""}`;
out.push({ key: `solo-${tech.userId}`, latitude: tech.current.latitude, longitude: tech.current.longitude, status: tech.status as LiveMarker["status"], count: 1, alert: !!tech.delay, label: line, lines: [tech.current.siteName ?? tech.current.number, line] });
}
return out;
}, [data.crews, solo, t, delayText]);
const statusBadge = (s: LiveStatus) => {
const Icon = STATUS_ICON[s];
return (
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold" style={{ color: STATUS_TONE[s], borderColor: STATUS_TONE[s] }}>
<Icon className="size-3.5" aria-hidden />
{t(`live.status.${s}`)}
</span>
);
};
const delayBadge = (d: LiveDelay | null) =>
d && (
<span className={cn("inline-flex items-center gap-1 text-xs font-semibold", d.level === "overrun" ? "text-[var(--risk)]" : "text-[var(--warn)]")} title={t("live.worked", { worked: formatMinutes(d.workedMinutes, locale), planned: formatMinutes(d.plannedMinutes, locale) })}>
<Hourglass className="size-3.5" aria-hidden />
{delayText(d)}
</span>
);
const crewCard = (crew: LiveCrew) => (
<li key={crew.teamId} className="shadow-card rounded-xl border border-l-4 bg-card p-3 text-sm" style={{ borderLeftColor: crew.delay ? (crew.delay.level === "overrun" ? "var(--risk)" : "var(--warn)") : STATUS_TONE[crew.status] }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="flex items-center gap-1.5 font-semibold">
<UsersRound className="size-4" aria-hidden />
{crew.teamName}
</p>
{statusBadge(crew.status)}
</div>
{delayBadge(crew.delay)}
{crew.freedMinutes !== null && (
<p className="flex items-center gap-1 text-xs font-semibold text-[var(--ok)]">
<Timer className="size-3.5" aria-hidden />
{t("freed.badge", { team: crew.teamName, minutes: formatMinutes(crew.freedMinutes, locale) })}
</p>
)}
{crew.current ? (
<div className="mt-1">
<Link href={`/work-orders/${crew.current.id}`} className="font-mono text-xs font-semibold underline-offset-2 hover:underline">
{crew.current.number}
</Link>{" "}
<span className="text-xs">{crew.current.title}</span>
<p className="text-xs">{crew.current.customerName}</p>
<p className="flex items-center gap-1 text-xs text-muted-foreground">
{crew.current.latitude === null && <MapPinOff className="size-3.5" aria-hidden />}
{crew.current.address ?? crew.current.siteName ?? ""}
{crew.current.latitude === null && ` · ${t("live.noLocation")}`}
</p>
</div>
) : (
<p className="mt-1 text-xs text-muted-foreground">{t("live.freeHint")}</p>
)}
<p className="mt-1.5 border-t pt-1.5 text-[11px] font-semibold text-muted-foreground">{t("live.crewMembers")}</p>
<ul className="space-y-0.5">
{crew.members.map((m) => (
<li key={m.userId} className="flex flex-wrap items-center gap-x-2 text-xs">
<span className="font-semibold">{m.name}</span>
<span style={{ color: STATUS_TONE[m.status] }}>{t(`live.status.${m.status}`)}</span>
{m.since && <span className="text-muted-foreground">{t("live.since", { time: wallClock(new Date(m.since), tz) })}</span>}
{m.orderNumber && m.orderNumber !== crew.current?.number && <span className="text-muted-foreground">{t("live.memberAt", { number: m.orderNumber })}</span>}
</li>
))}
</ul>
</li>
);
return (
<div className="space-y-3">
<div className="flex flex-wrap items-end gap-3">
<label className="grid gap-1 text-[13px] font-semibold">
{t("live.filterTeam")}
<select value={teamId} onChange={(e) => changeFilter({ teamId: e.target.value })} className={`${inputCls} min-w-44`}>
<option value="">{t("live.all")}</option>
{data.teams.map((tm) => (
<option key={tm.id} value={tm.id}>
{tm.name}
</option>
))}
</select>
</label>
<label className="grid gap-1 text-[13px] font-semibold">
{t("live.filterStatus")}
<select value={status} onChange={(e) => changeFilter({ status: e.target.value as LiveStatus | "" })} className={`${inputCls} min-w-40`}>
<option value="">{t("live.all")}</option>
{STATUSES.map((s) => (
<option key={s} value={s}>
{t(`live.status.${s}`)}
</option>
))}
</select>
</label>
<button type="button" onClick={() => void load()} disabled={loading} className={buttonCls("outline")}>
<RefreshCw className={cn("size-4", loading && "animate-spin")} aria-hidden />
{loading ? t("live.refreshing") : t("live.refresh")}
</button>
<p role="status" aria-live="polite" className="min-h-11 content-center text-xs text-muted-foreground">
{t("live.updated", { time: wallClock(new Date(data.generatedAt), tz) })}
{failed && <span className="ml-2 font-semibold text-[var(--risk)]">{t("live.updateFailed")}</span>}
</p>
</div>
<ul className="flex flex-wrap gap-2" aria-label={t("live.filterStatus")}>
{STATUSES.map((s) => {
const Icon = STATUS_ICON[s];
return (
<li key={s} className="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold" style={{ color: STATUS_TONE[s], borderColor: STATUS_TONE[s] }}>
<Icon className="size-3.5" aria-hidden />
{t(`live.status.${s}`)}
<span className="text-foreground tabular-nums">{data.counts[s]}</span>
</li>
);
})}
</ul>
{data.freed.length > 0 && (
<section aria-labelledby="live-freed" className="shadow-card rounded-xl border border-l-4 border-l-[var(--ok)] bg-card p-3">
<h2 id="live-freed" className="flex items-center gap-1.5 font-heading text-[15px] font-semibold">
<Timer className="size-4 text-[var(--ok)]" aria-hidden />
{t("freed.title")}
</h2>
<p className="text-xs text-muted-foreground">{t("freed.hint")}</p>
<ul className="mt-1 space-y-2">
{data.freed.map((f) => (
<li key={f.teamId} className="text-sm">
<p className="font-semibold text-[var(--ok)]">{t("freed.badge", { team: f.teamName, minutes: formatMinutes(f.earlyMinutes, locale) })}</p>
{f.availableMinutes > 0 && <p className="text-xs">{t("freed.available", { minutes: formatMinutes(f.availableMinutes, locale), time: wallClock(new Date(f.availableFrom), tz) })}</p>}
{f.suggestions.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("freed.noSuggestions")}</p>
) : (
<ul className="mt-0.5 space-y-0.5">
{f.suggestions.map((s) => (
<li key={`${s.kind}-${s.order.id}`} className="flex flex-wrap items-center gap-2 text-xs">
<span className="font-mono font-semibold">{s.order.number}</span>
<span>{s.order.customerName}</span>
<span className="text-muted-foreground">
{s.kind === "pull_forward" ? t("freed.kindPullForward", { team: f.teamName }) : t("freed.kindNearby", { distance: formatDistance(s.distanceKm ?? 0, locale) })}
</span>
<Link
href={`/planning?view=day&schedule=${s.order.id}&scheduleTeam=${f.teamId}&day=${s.day}&time=${wallClock(new Date(s.suggestedStart), tz)}`}
className="ml-auto inline-flex min-h-11 items-center font-semibold underline-offset-2 hover:underline"
>
{t("freed.pullForward")}
</Link>
</li>
))}
</ul>
)}
</li>
))}
</ul>
</section>
)}
<div role="tablist" aria-label={t("live.tabsLabel")} className="flex gap-1 border-b lg:hidden">
{(["map", "list"] as const).map((k) => (
<button
key={k}
type="button"
role="tab"
aria-selected={tab === k}
onClick={() => setTab(k)}
className={cn("-mb-px min-h-12 border-b-2 px-4 text-sm font-semibold", tab === k ? "border-[var(--ui-accent)]" : "border-transparent text-muted-foreground")}
>
{t(`live.tabs.${k}`)}
</button>
))}
</div>
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
<div className={cn(tab !== "map" && "hidden lg:block")}>
<LiveMap markers={markers} tileUrl={tileUrl} attribution={attribution} label={t("live.mapLabel")} loadingLabel={t("live.mapLoading")} />
<p className="mt-1 text-[11px] text-muted-foreground">
{attribution} ·{" "}
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="underline">
openstreetmap.org/copyright
</a>
</p>
{data.withoutLocation > 0 && (
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<MapPinOff className="size-3.5" aria-hidden />
{t("live.withoutLocation", { count: data.withoutLocation })}
</p>
)}
</div>
<div className={cn("space-y-4", tab !== "list" && "hidden lg:block")}>
{data.crews.length === 0 && solo.length === 0 ? (
<p className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">{t("live.empty")}</p>
) : (
<>
{data.crews.length > 0 && (
<section aria-labelledby="live-crews">
<h2 id="live-crews" className="mb-1 font-heading text-[15px] font-semibold">
{t("live.crewsTitle")}
</h2>
<ul className="space-y-2">{data.crews.map(crewCard)}</ul>
</section>
)}
{solo.length > 0 && (
<section aria-labelledby="live-solo">
<h2 id="live-solo" className="mb-1 font-heading text-[15px] font-semibold">
{t("live.soloTitle")}
</h2>
<ul className="space-y-2">
{solo.map((tech) => (
<li key={tech.userId} className="shadow-card rounded-xl border border-l-4 bg-card p-3 text-sm" style={{ borderLeftColor: STATUS_TONE[tech.status] }}>
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="font-semibold">{tech.name}</p>
{statusBadge(tech.status)}
</div>
{tech.since && <p className="text-xs text-muted-foreground">{t("live.since", { time: wallClock(new Date(tech.since), tz) })}</p>}
{delayBadge(tech.delay)}
{tech.current === null && <p className="mt-1 text-xs text-muted-foreground">{t("live.freeHint")}</p>}
{tech.current && !tech.current.visible && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<EyeOff className="size-3.5" aria-hidden />
{t("live.hiddenOrder")}
</p>
)}
{tech.current?.visible && (
<p className="mt-1 text-xs">
<Link href={`/work-orders/${tech.current.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
{tech.current.number}
</Link>{" "}
{tech.current.customerName} · {tech.current.address ?? t("live.noLocation")}
</p>
)}
</li>
))}
</ul>
</section>
)}
</>
)}
<section aria-labelledby="live-attention" className="shadow-card rounded-xl border bg-card p-3">
<h2 id="live-attention" className="flex items-center gap-1.5 font-heading text-[15px] font-semibold">
<AlarmClock className="size-4 text-[var(--warn)]" aria-hidden />
{t("live.attentionTitle")}
</h2>
{data.attention.length === 0 ? (
<p className="mt-1 text-xs text-muted-foreground">{t("live.attentionEmpty")}</p>
) : (
<ul className="mt-1 space-y-1.5">
{data.attention.map((a) => (
<li key={a.id} className="text-xs">
<span className="font-semibold text-[var(--warn)]">{t(`live.reason.${a.reason}`)}</span> ·{" "}
<span className="tabular-nums">{wallClock(new Date(a.plannedStart), tz)}</span>{" "}
<Link href={`/work-orders/${a.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
{a.number}
</Link>{" "}
{a.customerName}
{a.teamName && ` · ${a.teamName}`}
</li>
))}
</ul>
)}
</section>
</div>
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import { CircleDashed, Coffee, Truck, Wrench, type LucideIcon } from "lucide-react";
/** Live status tones + icons (L13), shared by the live map, the live list and the planning board. Always combined with text. */
export type LiveStatusKey = "en_route" | "working" | "paused" | "free";
export const STATUS_TONE: Record<LiveStatusKey, string> = {
en_route: "var(--info)",
working: "var(--ui-accent)",
paused: "var(--warn)",
free: "var(--txt-muted)",
};
export const STATUS_ICON: Record<LiveStatusKey, LucideIcon> = { en_route: Truck, working: Wrench, paused: Coffee, free: CircleDashed };
+689
View File
@@ -0,0 +1,689 @@
"use client";
import Link from "next/link";
import { useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl";
import { DndContext, KeyboardSensor, PointerSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
import { CalendarPlus, CheckCircle2, ChevronDown, ChevronRight, GripVertical, Hourglass, Info, Lightbulb, MapPinOff, Settings2, Siren, Timer, TriangleAlert } from "lucide-react";
import { formatDistance } from "@/lib/geo/distance";
import { SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/planning/capacity";
import { dayKeyOf, minutesOfDay, wallClock } from "@/lib/planning/days";
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
import { STATUS_GROUP_TONE } from "@/lib/work-orders/status";
import { cn } from "@/lib/utils";
import { buttonCls } from "@/components/work-orders/button-cls";
import { GroupIcon, TONE_VAR } from "@/components/work-orders/ui";
import type { PlanningBoard } from "@/server/services/planning/board";
import type { BoardOrder } from "@/server/services/planning/data";
import type { LiveDelay, LiveStatus } from "@/server/services/planning/live";
import type { FreedTeam } from "@/server/services/planning/watch";
import { STATUS_ICON, STATUS_TONE } from "./live-tone";
import { RecommendationsPanel, type Recommendation } from "./recommendations-panel";
import { ScheduleDialog, type DialogState, type ScheduleResponse } from "./schedule-dialog";
/**
* Planning board (L13, desktop ≥ 1024 px).
* - "days" (default: today + next 4 working days) / "week": rows = crews (teams) with member short list,
* columns = days (today highlighted, with the live status of each crew), cells with order cards,
* crew utilization bar, conflicts (red edge + icon + text), hints (amber), delay / at-risk badges.
* - "today": crews as PARALLEL COLUMNS, time running vertically (6–20 h), overlapping orders side by side.
* Unplanned orders in the sidebar; crews that finished early with pull-forward suggestions. Drag & drop
* (dnd-kit, mouse + keyboard sensor on the grip) or "Einplanen …" open the confirmation popover — nothing
* is ever saved or moved automatically.
*/
const HOUR_PX = 72;
const START_H = 6;
const END_H = 20;
const HOURS = Array.from({ length: END_H - START_H }, (_, i) => START_H + i);
export type TeamLive = { status: LiveStatus; delay: LiveDelay | null; freedMinutes: number | null; active: number };
type Prefill = { order: BoardOrder; teamId: string | null; day: string | null; time: string | null } | null;
type T = ReturnType<typeof useTranslations<"planning">>;
type TeamDayT = PlanningBoard["teams"][number]["days"][number];
function layoutLanes(orders: BoardOrder[], tz: string) {
const lo = START_H * 60;
const hi = END_H * 60;
const sorted = orders
.map((o) => {
const s = minutesOfDay(new Date(o.plannedStart!), tz);
return { o, s, e: s + o.durationMinutes };
})
.sort((a, b) => a.s - b.s);
const laneEnds: number[] = [];
const items = sorted.map((it) => {
let lane = laneEnds.findIndex((end) => end <= it.s);
if (lane < 0) {
lane = laneEnds.length;
laneEnds.push(it.e);
} else {
laneEnds[lane] = it.e;
}
const s = Math.min(Math.max(it.s, lo), hi - 60);
const e = Math.max(Math.min(it.e, hi), s + 60);
return { o: it.o, lane, top: ((s - lo) / 60) * HOUR_PX, height: ((e - s) / 60) * HOUR_PX };
});
return { items, lanes: Math.max(1, laneEnds.length) };
}
export function PlanningBoardView({
board,
view,
visibleDays,
locale,
prefill,
capacityHref,
freed,
teamLive,
}: {
board: PlanningBoard;
view: "today" | "days" | "week";
visibleDays: string[];
locale: string;
prefill: Prefill;
capacityHref: string;
freed: FreedTeam[] | null;
teamLive: Record<string, TeamLive> | null;
}) {
const t = useTranslations("planning");
const tw = useTranslations("workOrders");
const router = useRouter();
const tz = board.timeZone;
const canSchedule = board.access.canSchedule;
const initialDialog = (order: BoardOrder, teamId: string | null, day: string | null, time: string | null): DialogState => {
const start = order.plannedStart ? new Date(order.plannedStart) : null;
return {
order,
teamId: teamId ?? order.teamId ?? board.teams[0]?.id ?? "",
day: day ?? (start ? dayKeyOf(start, tz) : board.from),
time: time ?? (start && !order.allDay ? wallClock(start, tz) : "08:00"),
duration: order.plannedDurationMinutes ?? order.durationMinutes,
};
};
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
const [dialog, setDialog] = useState<DialogState | null>(() => (prefill && canSchedule ? initialDialog(prefill.order, prefill.teamId, prefill.day, prefill.time) : null));
const [panelFor, setPanelFor] = useState<string | null>(null);
const [notice, setNotice] = useState<{ tone: "ok" | "warn" | "error"; text: string } | null>(null);
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor));
const allOrders = useMemo(() => {
const m = new Map<string, BoardOrder>(Object.entries(board.orders));
for (const o of board.unplanned) m.set(o.id, o);
for (const f of freed ?? []) for (const s of f.suggestions) if (!m.has(s.order.id)) m.set(s.order.id, s.order);
if (prefill) m.set(prefill.order.id, prefill.order);
return m;
}, [board, prefill, freed]);
const userNames = useMemo(() => new Map(board.teams.flatMap((tm) => tm.members.map((m) => [m.userId, m.name] as const))), [board.teams]);
const teamOptions = board.teams.map((tm) => ({ id: tm.id, name: tm.name }));
const dayCols = board.days.filter((d) => visibleDays.includes(d.key));
const open = (order: BoardOrder, teamId: string | null, day: string | null, time: string | null) => {
if (!canSchedule || !SCHEDULABLE_STATUSES.includes(order.status)) return;
setNotice(null);
setDialog(initialDialog(order, teamId, day, time));
};
const onDragEnd = (e: DragEndEvent) => {
const order = allOrders.get(String(e.active.id).replace(/^order:/, ""));
if (!order || !e.over) return;
const [kind, teamId, day, hour] = String(e.over.id).split(":");
if (kind === "cell") open(order, teamId, day, null);
if (kind === "slot") open(order, teamId, day, `${hour.padStart(2, "0")}:00`);
};
const num = (id?: string) => (id && allOrders.get(id)?.number) || "…";
const conflictText = (c: PlanningConflict) => {
switch (c.kind) {
case "overbooked":
return t("conflicts.overbooked", { over: formatMinutes(c.minutesOver ?? 0, locale) });
case "overlap":
return t("conflicts.overlap", { a: num(c.orderIds[0]), b: num(c.orderIds[1]) });
case "assignee_double_booked":
return t("conflicts.assignee_double_booked", { user: userNames.get(c.userId ?? "") ?? "—", a: num(c.orderIds[0]), b: num(c.orderIds[1]) });
case "crew_incomplete":
return t("conflicts.crew_incomplete", { count: c.memberCount ?? 0 });
default:
return t("conflicts.outside_working_days");
}
};
const onSaved = (res: ScheduleResponse) => {
setDialog(null);
const vars = { number: res.number, team: board.teams.find((tm) => tm.id === res.teamId)?.name ?? "", day: formatDayShort(dayKeyOf(new Date(res.plannedStart), tz), locale) };
const count = res.conflicts.filter((c) => c.severity === "conflict").length;
setNotice(count ? { tone: "warn", text: t("board.savedWithConflicts", { ...vars, count }) } : { tone: "ok", text: t("board.saved", vars) });
router.refresh();
};
const onConflict = (text: string) => {
setDialog(null);
setNotice({ tone: "error", text });
router.refresh();
};
const toggle = (teamId: string) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(teamId)) next.delete(teamId);
else next.add(teamId);
return next;
});
const cardProps = { canSchedule, locale, tz, onOpen: open, t, tw };
const loadLine = (td: TeamDayT) => {
if (!td.workingDay && td.plannedMinutes === 0) return <p className="text-[11px] text-muted-foreground">{t("board.notWorkingDay")}</p>;
const tone = td.utilization === null || td.utilization > 100 ? "var(--risk)" : td.utilization >= 85 ? "var(--warn)" : "var(--ok)";
const label =
td.utilization === null
? t("board.loadNoCapacity", { planned: formatMinutes(td.plannedMinutes, locale) })
: t("board.load", { planned: formatMinutes(td.plannedMinutes, locale), capacity: formatMinutes(td.capacityMinutes, locale), percent: td.utilization });
return (
<div>
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
<div className="h-full rounded-full" style={{ width: `${Math.min(100, td.utilization ?? 100)}%`, background: tone }} />
</div>
<p className="mt-0.5 text-[11px] font-semibold tabular-nums">{label}</p>
</div>
);
};
const conflictList = (conflicts: PlanningConflict[]) =>
conflicts.length > 0 && (
<ul className="space-y-0.5">
{conflicts.map((c, i) => {
const hint = c.severity === "hint";
const Icon = hint ? Info : TriangleAlert;
return (
<li key={i} className={cn("flex items-start gap-1 text-[11px] font-semibold", hint ? "text-[var(--warn)]" : "text-[var(--risk)]")} title={conflictText(c)}>
<Icon className="mt-px size-3.5 shrink-0" aria-hidden />
<span>
<span className="sr-only">{hint ? t("conflicts.hintLabel") : t("conflicts.label")}: </span>
{conflictText(c)}
</span>
</li>
);
})}
</ul>
);
const hasConflict = (conflicts: PlanningConflict[]) => conflicts.some((c) => c.severity === "conflict");
const liveLine = (teamId: string, teamName: string) => {
const live = teamLive?.[teamId];
if (!live) return null;
const Icon = STATUS_ICON[live.status];
return (
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 rounded-md bg-muted/60 px-1.5 py-0.5 text-[11px] font-semibold">
<span className="inline-flex items-center gap-1" style={{ color: STATUS_TONE[live.status] }}>
<Icon className="size-3.5" aria-hidden />
{t(`live.status.${live.status}`)}
{live.active > 0 && <span className="text-foreground">· {live.active}</span>}
</span>
{live.delay && (
<span className={cn("inline-flex items-center gap-1", live.delay.level === "overrun" ? "text-[var(--risk)]" : "text-[var(--warn)]")}>
<Hourglass className="size-3.5" aria-hidden />
{live.delay.level === "overrun" ? t("board.delayOverrun", { percent: live.delay.percent }) : t("board.delayRisk", { percent: live.delay.percent })}
</span>
)}
{live.freedMinutes !== null && (
<span className="inline-flex items-center gap-1 text-[var(--ok)]">
<Timer className="size-3.5" aria-hidden />
{t("freed.badge", { team: teamName, minutes: formatMinutes(live.freedMinutes, locale) })}
</span>
)}
</div>
);
};
const memberOrders = (userId: string, day: string) =>
Object.values(board.orders).filter((o) => o.assigneeIds.includes(userId) && o.plannedStart && dayKeyOf(new Date(o.plannedStart), tz) === day);
const showFreed = freed && freed.length > 0 && (view === "today" || visibleDays.includes(board.today));
return (
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
<div data-planning-view={view} data-day-count={view === "today" ? 1 : dayCols.length}>
<p role="status" aria-live="polite" className={cn("min-h-6 text-sm font-semibold", notice?.tone === "ok" && "text-[var(--ok)]", notice?.tone === "warn" && "text-[var(--warn)]", notice?.tone === "error" && "text-[var(--risk)]")}>
{notice && (
<span className="inline-flex items-center gap-1.5">
{notice.tone === "ok" ? <CheckCircle2 className="size-4" aria-hidden /> : <TriangleAlert className="size-4" aria-hidden />}
{notice.text}
</span>
)}
</p>
{showFreed && (
<section aria-labelledby="freed-title" className="shadow-card mb-3 rounded-xl border border-l-4 border-l-[var(--ok)] bg-card p-3">
<h2 id="freed-title" className="flex items-center gap-1.5 font-heading text-[15px] font-semibold">
<Timer className="size-4 text-[var(--ok)]" aria-hidden />
{t("freed.title")}
</h2>
<p className="text-xs text-muted-foreground">{t("freed.hint")}</p>
<ul className="mt-2 grid gap-3 xl:grid-cols-2">
{freed!.map((f) => (
<li key={f.teamId} className="rounded-lg border p-2 text-sm">
<p className="font-semibold text-[var(--ok)]">{t("freed.badge", { team: f.teamName, minutes: formatMinutes(f.earlyMinutes, locale) })}</p>
{f.availableMinutes > 0 && <p className="text-xs">{t("freed.available", { minutes: formatMinutes(f.availableMinutes, locale), time: wallClock(new Date(f.availableFrom), tz) })}</p>}
{f.suggestions.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("freed.noSuggestions")}</p>
) : (
<ul className="mt-1 space-y-1">
{f.suggestions.map((s) => (
<li key={`${s.kind}-${s.order.id}`} className="flex flex-wrap items-center gap-2 text-xs">
<Link href={`/work-orders/${s.order.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
{s.order.number}
</Link>
<span>{s.order.customerName}</span>
<span className="text-muted-foreground">
· {formatMinutes(s.order.durationMinutes, locale)} ·{" "}
{s.kind === "pull_forward" ? t("freed.kindPullForward", { team: f.teamName }) : t("freed.kindNearby", { distance: formatDistance(s.distanceKm ?? 0, locale) })}
</span>
{canSchedule && SCHEDULABLE_STATUSES.includes(s.order.status) && (
<button type="button" onClick={() => open(s.order, f.teamId, s.day, wallClock(new Date(s.suggestedStart), tz))} className={`${buttonCls("outline")} ml-auto`}>
{t("freed.pullForward")}
</button>
)}
</li>
))}
</ul>
)}
</li>
))}
</ul>
</section>
)}
<div className="flex items-start gap-4">
<div className="shadow-card min-w-0 flex-1 overflow-x-auto rounded-xl border bg-card">
{board.teams.length === 0 ? (
<p className="p-6 text-sm text-muted-foreground">{t("board.noTeams")}</p>
) : view !== "today" ? (
<div role="table" aria-label={t("board.title")} className="grid min-w-[980px]" style={{ gridTemplateColumns: `220px repeat(${dayCols.length}, minmax(150px, 1fr))` }}>
<div role="row" className="contents">
<div role="columnheader" className="sticky left-0 z-10 border-b bg-card p-2 text-xs font-semibold text-muted-foreground">
{t("board.team")}
</div>
{dayCols.map((d) => (
<div role="columnheader" key={d.key} className={cn("border-b border-l p-2 text-xs font-semibold", d.key === board.today && "border-t-4 border-t-[var(--ui-accent)] bg-[var(--ui-primary-soft)]")}>
{d.key === board.today && <span className="mr-1 text-[var(--ui-primary)]">{t("board.today")} ·</span>}
{formatDayShort(d.key, locale)}
</div>
))}
</div>
{board.teams.map((team) => (
<TeamRows key={team.id}>
<div role="row" className="contents">
<TeamHeader team={team} expanded={expanded.has(team.id)} onToggle={() => toggle(team.id)} canManage={board.access.canManageTeams} capacityHref={capacityHref} t={t} locale={locale} />
{team.days
.filter((td) => visibleDays.includes(td.day))
.map((td) => (
<DropCell
key={td.day}
id={`cell:${team.id}:${td.day}`}
disabled={!canSchedule}
className={cn(
"flex min-h-32 flex-col gap-1.5 border-b border-l p-1.5",
!td.workingDay && "bg-muted/50",
td.day === board.today && "bg-[var(--ui-primary-soft)]",
hasConflict(td.conflicts) && "border-2 border-[var(--risk)]",
)}
>
{td.day === board.today && liveLine(team.id, team.name)}
{loadLine(td)}
{conflictList(td.conflicts)}
{td.orderIds.map((id) => board.orders[id] && <OrderCard key={id} order={board.orders[id]} {...cardProps} />)}
</DropCell>
))}
</div>
{expanded.has(team.id) &&
team.members.map((m) => (
<div role="row" key={m.userId} className="contents">
<div role="rowheader" className="sticky left-0 z-10 border-b bg-card py-1.5 pr-2 pl-6 text-xs">
<p className="font-semibold">{m.name}</p>
<p className="text-muted-foreground">{t("board.memberHint")}</p>
</div>
{dayCols.map((d) => (
<div role="cell" key={d.key} className="border-b border-l p-1.5 text-[11px]">
{memberOrders(m.userId, d.key).map((o) => (
<Link key={o.id} href={`/work-orders/${o.id}`} className="flex min-h-11 items-center gap-1 rounded px-1 hover:bg-muted">
<span className="font-mono font-semibold">{o.number}</span>
{!o.allDay && o.plannedStart && <span>{wallClock(new Date(o.plannedStart), tz)}</span>}
</Link>
))}
</div>
))}
</div>
))}
</TeamRows>
))}
</div>
) : (
<div role="table" aria-label={t("board.title")} className="grid min-w-max" style={{ gridTemplateColumns: `56px repeat(${board.teams.length}, minmax(240px, 1fr))` }}>
<div role="row" className="contents">
<div role="columnheader" className="sticky left-0 z-20 border-b bg-card p-2 text-[11px] font-semibold text-muted-foreground">
{t("board.time")}
</div>
{board.teams.map((team) => {
const td = team.days[0];
return (
<div role="columnheader" key={team.id} className={cn("space-y-1 border-b border-l p-2", hasConflict(td.conflicts) && "border-t-4 border-t-[var(--risk)]")}>
<TeamHeader team={team} expanded={false} onToggle={null} canManage={board.access.canManageTeams} capacityHref={capacityHref} t={t} locale={locale} bare />
{td.day === board.today && liveLine(team.id, team.name)}
{loadLine(td)}
{conflictList(td.conflicts)}
</div>
);
})}
</div>
<div role="row" className="contents">
<div role="rowheader" className="sticky left-0 z-10 border-b bg-card p-1 text-[11px] font-semibold text-muted-foreground">
{t("board.allDay")}
</div>
{board.teams.map((team) => {
const td = team.days[0];
const untimed = td.orderIds.map((id) => board.orders[id]).filter((o) => o && (o.allDay || o.multiDay));
return (
<DropCell key={team.id} id={`cell:${team.id}:${td.day}`} disabled={!canSchedule} className="flex min-h-14 flex-col gap-1 border-b border-l p-1">
{untimed.map((o) => (
<OrderCard key={o.id} order={o} {...cardProps} />
))}
</DropCell>
);
})}
</div>
<div role="row" className="contents">
<div role="rowheader" className="sticky left-0 z-10 bg-card" style={{ height: HOURS.length * HOUR_PX, position: "sticky" }}>
{HOURS.map((h, i) => (
<span key={h} className="absolute right-1 text-[11px] text-muted-foreground tabular-nums" style={{ top: i * HOUR_PX + 2 }}>
{h}:00
</span>
))}
</div>
{board.teams.map((team) => {
const td = team.days[0];
const timed = td.orderIds.map((id) => board.orders[id]).filter((o) => o && !o.allDay && !o.multiDay && o.plannedStart);
const { items, lanes } = layoutLanes(timed, tz);
return (
<div role="cell" key={team.id} className="relative border-l" style={{ height: HOURS.length * HOUR_PX }}>
{HOURS.map((h, i) => (
<DropCell key={h} plain id={`slot:${team.id}:${td.day}:${h}`} disabled={!canSchedule} label={`${team.name} ${h}:00`} className="absolute inset-x-0 border-t" style={{ top: i * HOUR_PX, height: HOUR_PX }} />
))}
{items.map(({ o, lane, top, height }) => (
<div key={o.id} className="absolute z-10 p-0.5" style={{ top, height, left: `${(lane / lanes) * 100}%`, width: `${100 / lanes}%` }}>
<OrderCard order={o} {...cardProps} compact />
</div>
))}
</div>
);
})}
</div>
</div>
)}
</div>
<aside aria-labelledby="unplanned-title" className="w-80 shrink-0 space-y-2">
<h2 id="unplanned-title" className="font-heading text-[15px] font-semibold">
{t("unplanned.title")} <span className="text-muted-foreground">({board.unplanned.length})</span>
</h2>
{board.unplanned.length === 0 && <p className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">{t("unplanned.empty")}</p>}
<ul className="space-y-3">
{board.unplanned.map((o) => (
<li key={o.id} className="space-y-1.5">
<OrderCard order={o} {...cardProps} unplanned />
{canSchedule && (
<button type="button" aria-expanded={panelFor === o.id} onClick={() => setPanelFor(panelFor === o.id ? null : o.id)} className={`${buttonCls("ghost")} w-full`}>
<Lightbulb className="size-4" aria-hidden />
{t("unplanned.suggestions")}
</button>
)}
{panelFor === o.id && (
<RecommendationsPanel
workOrderId={o.id}
locale={locale}
onClose={() => setPanelFor(null)}
onTake={(r: Recommendation) => open(o, r.teamId, r.day, wallClock(new Date(r.suggestedStart), tz))}
onPlanTogether={(nearby, r, required) =>
open(nearby, r?.teamId ?? null, r?.day ?? null, r ? wallClock(new Date(new Date(r.suggestedStart).getTime() + required * 60_000), tz) : null)
}
/>
)}
</li>
))}
</ul>
</aside>
</div>
</div>
{dialog && (
<ScheduleDialog key={`${dialog.order.id}-${dialog.teamId}-${dialog.day}-${dialog.time}`} state={dialog} teams={teamOptions} timeZone={tz} locale={locale} onClose={() => setDialog(null)} onSaved={onSaved} onConflict={onConflict} />
)}
</DndContext>
);
}
function TeamRows({ children }: { children: React.ReactNode }) {
return <>{children}</>;
}
function TeamHeader({
team,
expanded,
onToggle,
canManage,
capacityHref,
t,
locale,
bare,
}: {
team: PlanningBoard["teams"][number];
expanded: boolean;
onToggle: (() => void) | null;
canManage: boolean;
capacityHref: string;
t: T;
locale: string;
bare?: boolean;
}) {
const names = team.members.map((m) => m.name).join(", ");
const shortNames = team.members.map((m) => m.name.split(" ")[0]).join(", ");
const body = (
<>
<div className="flex items-start justify-between gap-1">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{team.name}</p>
{shortNames && (
<p className="truncate text-[11px]" title={names}>
{shortNames}
</p>
)}
<p className="text-[11px] text-muted-foreground">{t("board.crewCapacity", { capacity: formatMinutes(team.dailyCapacityMinutes, locale), count: team.members.length })}</p>
</div>
{canManage && (
<Link href={`${capacityHref}${capacityHref.includes("?") ? "&" : "?"}capacity=${team.id}`} scroll={false} aria-label={t("board.capacityOf", { team: team.name })} title={t("board.capacity")} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
<Settings2 className="size-4" aria-hidden />
</Link>
)}
</div>
{onToggle && team.members.length > 0 && (
<button type="button" aria-expanded={expanded} onClick={onToggle} className="mt-1 inline-flex min-h-11 items-center gap-1 rounded-md px-1 text-xs font-semibold text-muted-foreground hover:bg-muted hover:text-foreground">
{expanded ? <ChevronDown className="size-3.5" aria-hidden /> : <ChevronRight className="size-3.5" aria-hidden />}
{expanded ? t("board.hideMembers", { team: team.name }) : t("board.showMembers", { team: team.name })}
</button>
)}
</>
);
if (bare) return body;
return (
<div role="rowheader" className="sticky left-0 z-10 border-b bg-card p-2">
{body}
</div>
);
}
function DropCell({
id,
disabled,
className,
style,
label,
plain,
children,
}: {
id: string;
disabled: boolean;
className?: string;
style?: React.CSSProperties;
label?: string;
plain?: boolean;
children?: React.ReactNode;
}) {
const { setNodeRef, isOver } = useDroppable({ id, disabled });
return (
<div ref={setNodeRef} role={plain ? undefined : "cell"} aria-label={label} className={cn(className, isOver && "bg-[var(--ui-primary-soft)] outline-2 outline-[var(--ui-accent)]")} style={style}>
{children}
</div>
);
}
function OrderCard({
order,
canSchedule,
locale,
tz,
onOpen,
t,
tw,
unplanned,
compact,
}: {
order: BoardOrder;
canSchedule: boolean;
locale: string;
tz: string;
onOpen: (order: BoardOrder, teamId: string | null, day: string | null, time: string | null) => void;
t: T;
tw: ReturnType<typeof useTranslations<"workOrders">>;
unplanned?: boolean;
compact?: boolean;
}) {
const movable = canSchedule && SCHEDULABLE_STATUSES.includes(order.status);
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `order:${order.id}`, disabled: !movable });
const conflict = order.conflictKinds.length > 0;
const alert = conflict || !!order.atRisk || order.delay?.level === "overrun";
const tone = TONE_VAR[STATUS_GROUP_TONE[order.statusGroup]];
const style: React.CSSProperties = {
borderLeftColor: alert ? "var(--risk)" : order.delay ? "var(--warn)" : tone,
...(transform ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, zIndex: 60, position: "relative" } : {}),
};
const time = order.plannedStart && !order.allDay ? wallClock(new Date(order.plannedStart), tz) : null;
const conflictLabel = order.conflictKinds.map((k) => t(`conflicts.short.${k}` as "conflicts.short.overbooked")).join(", ");
const grip = movable && (
<button type="button" {...listeners} {...attributes} aria-label={`${order.number} – ${t("board.move")}`} className="-m-0.5 grid size-8 shrink-0 cursor-grab touch-none place-items-center rounded text-muted-foreground hover:bg-muted active:cursor-grabbing">
<GripVertical className="size-4" aria-hidden />
</button>
);
const delayLine = order.delay && (
<p className={cn("inline-flex items-center gap-1 font-semibold", order.delay.level === "overrun" ? "text-[var(--risk)]" : "text-[var(--warn)]")}>
<Hourglass className="size-3.5 shrink-0" aria-hidden />
{order.delay.level === "overrun" ? t("board.delayOverrun", { percent: order.delay.percent }) : t("board.delayRisk", { percent: order.delay.percent })}
</p>
);
const atRiskLine = order.atRisk && (
<p className="flex items-center gap-1 font-semibold text-[var(--risk)]">
<TriangleAlert className="size-3.5 shrink-0" aria-hidden />
{t("board.atRisk", { number: order.atRisk.byNumber })}
</p>
);
if (compact) {
return (
<article ref={setNodeRef} style={style} className={cn("flex h-full flex-col overflow-hidden rounded-md border border-l-4 bg-card px-1 text-[11px] shadow-sm", conflict && "border-[var(--risk)]", isDragging && "opacity-80 shadow-lg")}>
<div className="flex items-center gap-0.5">
{grip}
<Link href={`/work-orders/${order.id}`} className="font-mono font-semibold underline-offset-2 hover:underline" title={order.title}>
{order.number}
</Link>
{conflict && (
<span className="text-[var(--risk)]" title={conflictLabel}>
<TriangleAlert className="size-3.5" aria-hidden />
<span className="sr-only">{conflictLabel}</span>
</span>
)}
{movable && (
<button type="button" onClick={() => onOpen(order, null, null, null)} aria-label={`${order.number} – ${t("board.move")}`} title={t("board.move")} className="ml-auto grid size-11 shrink-0 place-items-center rounded hover:bg-muted">
<CalendarPlus className="size-4" aria-hidden />
</button>
)}
</div>
<p className="truncate font-semibold">{order.customerName}</p>
<p className="truncate text-muted-foreground">
{time} · {formatMinutes(order.durationMinutes, locale)} · <span style={{ color: tone }}>{tw(`statusGroup.${order.statusGroup}`)}</span>
</p>
{delayLine}
{atRiskLine}
</article>
);
}
return (
<article ref={setNodeRef} style={style} className={cn("rounded-lg border border-l-4 bg-card p-1.5 text-xs shadow-sm", conflict && "border-[var(--risk)]", isDragging && "opacity-80 shadow-lg")}>
<div className="flex items-start gap-1">
{grip}
<div className="min-w-0 flex-1">
<p className="flex items-center gap-1">
<Link href={`/work-orders/${order.id}`} className="font-mono font-semibold underline-offset-2 hover:underline" title={t("board.open")}>
{order.number}
</Link>
{order.isEmergency && <Siren className="size-3.5 text-[var(--risk)]" aria-label={t("board.emergency")} />}
{conflict && (
<span className="ml-auto inline-flex items-center gap-0.5 font-semibold text-[var(--risk)]" title={conflictLabel}>
<TriangleAlert className="size-3.5" aria-hidden />
<span className="sr-only">{conflictLabel}</span>
</span>
)}
</p>
<p className="truncate font-semibold" title={order.title}>
{order.customerName}
</p>
<p className="truncate text-muted-foreground" title={order.siteAddress ?? order.siteName ?? ""}>
{order.siteAddress ?? order.siteName ?? "—"}
</p>
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-muted-foreground">
{time && <span className="tabular-nums">{time}</span>}
<span>{formatMinutes(order.durationMinutes, locale)}</span>
{order.multiDay && <span>· {t("board.multiDay")}</span>}
<span className="inline-flex items-center gap-0.5 font-semibold" style={{ color: tone }}>
<GroupIcon group={order.statusGroup} className="size-3" />
{tw(`statusGroup.${order.statusGroup}`)}
</span>
{(order.priority === "high" || order.priority === "urgent") && <span className="font-semibold text-[var(--warn)]">{tw(`priority.${order.priority}`)}</span>}
</p>
{delayLine}
{atRiskLine}
{unplanned && (
<p className="mt-0.5 flex flex-wrap gap-x-2 text-[11px] text-muted-foreground">
{!order.teamId && <span>{t("unplanned.noTeam")}</span>}
{!order.plannedStart && <span>{t("unplanned.noDate")}</span>}
{!order.hasCoordinates && (
<span className="inline-flex items-center gap-0.5">
<MapPinOff className="size-3" aria-hidden />
{t("unplanned.noLocation")}
</span>
)}
</p>
)}
</div>
</div>
{movable && (
<button type="button" onClick={() => onOpen(order, null, null, null)} className="mt-1 inline-flex min-h-11 w-full items-center justify-center gap-1 rounded-md border text-[11px] font-semibold hover:bg-muted">
<CalendarPlus className="size-3.5" aria-hidden />
{order.plannedStart && order.teamId ? t("board.move") : t("board.schedule")}
</button>
)}
</article>
);
}
@@ -0,0 +1,148 @@
"use client";
import Link from "next/link";
import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Info, Lightbulb, MapPin, X } from "lucide-react";
import { formatDistance } from "@/lib/geo/distance";
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
import { buttonCls } from "@/components/work-orders/button-cls";
import type { BoardOrder } from "@/server/services/planning/data";
export type Recommendation = {
teamId: string;
teamName: string;
day: string;
distanceKm: number;
nearOrder: { id: string; number: string };
freeMinutes: number;
requiredMinutes: number;
tight: boolean;
suggestedStart: string;
text: string;
};
type Response = {
status: "ok" | "no_coordinates" | "not_schedulable";
hint: string | null;
requiredMinutes: number;
recommendations: Recommendation[];
nearby: { status: string; radiusKm: number; items: (BoardOrder & { distanceKm: number })[] };
};
/** Suggestions for one unplanned order (L13). "Übernehmen" only prefills the schedule popover. */
export function RecommendationsPanel({
workOrderId,
locale,
onTake,
onPlanTogether,
onClose,
}: {
workOrderId: string;
locale: string;
onTake: (rec: Recommendation) => void;
onPlanTogether: (order: BoardOrder, rec: Recommendation | null, requiredMinutes: number) => void;
onClose: () => void;
}) {
const t = useTranslations("planning");
const [data, setData] = useState<Response | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
const ctrl = new AbortController();
fetch(`/api/v1/planning/recommendations?workOrderId=${encodeURIComponent(workOrderId)}&locale=${locale === "en" ? "en" : "de"}`, { signal: ctrl.signal })
.then((r) => (r.ok ? (r.json() as Promise<Response>) : Promise.reject(new Error(String(r.status)))))
.then(setData)
.catch(() => {
if (!ctrl.signal.aborted) setFailed(true);
});
return () => ctrl.abort();
}, [workOrderId, locale]);
const headingId = `rec-${workOrderId}`;
return (
<section aria-labelledby={headingId} className="rounded-lg border border-[var(--info)] bg-background p-3 text-sm">
<div className="flex items-start justify-between gap-2">
<div>
<h3 id={headingId} className="flex items-center gap-1.5 font-heading font-semibold">
<Lightbulb className="size-4 text-[var(--info)]" aria-hidden />
{t("recommend.title")}
</h3>
<p className="text-xs text-muted-foreground">{t("recommend.sub")}</p>
</div>
<button type="button" onClick={onClose} aria-label={t("recommend.close")} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted">
<X className="size-4" aria-hidden />
</button>
</div>
{!data && !failed && (
<p role="status" className="mt-2 text-xs text-muted-foreground">
{t("recommend.loading")}
</p>
)}
{failed && (
<p role="alert" className="mt-2 text-xs text-[var(--risk)]">
{t("recommend.failed")}
</p>
)}
{data?.hint && (
<p className="mt-2 flex items-start gap-1.5 text-xs">
<Info className="mt-0.5 size-3.5 shrink-0 text-[var(--info)]" aria-hidden />
{data.hint}
</p>
)}
{data && data.recommendations.length > 0 && (
<ul className="mt-2 space-y-2">
{data.recommendations.map((r) => (
<li key={`${r.teamId}-${r.day}`} className="rounded-md border p-2" title={r.text}>
<p className="text-[13px] font-semibold">
{t("recommend.card", {
day: formatDayShort(r.day, locale),
team: r.teamName,
distance: formatDistance(r.distanceKm, locale),
number: r.nearOrder.number,
free: formatMinutes(r.freeMinutes, locale),
})}
</p>
{r.tight && <p className="text-xs font-semibold text-[var(--warn)]">{t("recommend.tight")}</p>}
<button type="button" onClick={() => onTake(r)} className={`${buttonCls("outline")} mt-1.5 w-full`}>
{t("recommend.take")}
</button>
</li>
))}
</ul>
)}
{data && data.nearby.status === "ok" && (
<div className="mt-3 border-t pt-2">
<h4 className="text-xs font-semibold">{t("recommend.nearbyTitle")}</h4>
{data.nearby.items.length === 0 ? (
<p className="text-xs text-muted-foreground">{t("recommend.nearbyEmpty")}</p>
) : (
<>
<p className="text-xs text-muted-foreground">{t("recommend.nearbyQuestion", { radius: data.nearby.radiusKm })}</p>
<ul className="mt-1 space-y-1.5">
{data.nearby.items.map((o) => (
<li key={o.id} className="rounded-md border p-2">
<p className="flex items-center gap-1 text-xs">
<MapPin className="size-3.5 shrink-0" aria-hidden />
<Link href={`/work-orders/${o.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
{o.number}
</Link>
· {formatDistance(o.distanceKm, locale)}
</p>
<p className="truncate text-xs text-muted-foreground">{o.customerName}</p>
<button type="button" onClick={() => onPlanTogether(o, data.recommendations[0] ?? null, data.requiredMinutes)} className={`${buttonCls("ghost")} mt-1 w-full`}>
{t("recommend.planTogether")}
</button>
</li>
))}
</ul>
</>
)}
</div>
)}
</section>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { AlertCircle, X } from "lucide-react";
import type { PlanningConflict } from "@/lib/planning/capacity";
import { formatMinutes } from "@/lib/planning/text";
import { wallTimeToUtc } from "@/lib/work-orders/time";
import { buttonCls } from "@/components/work-orders/button-cls";
import { Field, inputCls } from "@/components/work-orders/ui";
import type { BoardOrder } from "@/server/services/planning/data";
export type DialogState = { order: BoardOrder; teamId: string; day: string; time: string; duration: number };
export type ScheduleResponse = {
id: string;
number: string;
version: number;
status: string;
teamId: string;
plannedStart: string;
plannedEnd: string | null;
plannedDurationMinutes: number | null;
conflicts: PlanningConflict[];
};
/**
* Confirmation popover for drag & drop and the keyboard path ("Einplanen …"): team, date, start and
* duration are prefilled; nothing is saved without "Speichern". Escape / backdrop / × close it and focus
* returns to the element that opened it.
*/
export function ScheduleDialog({
state,
teams,
timeZone,
locale,
onClose,
onSaved,
onConflict,
}: {
state: DialogState;
teams: { id: string; name: string }[];
timeZone: string;
locale: string;
onClose: () => void;
onSaved: (res: ScheduleResponse) => void;
onConflict: (message: string) => void;
}) {
const t = useTranslations("planning");
const [teamId, setTeamId] = useState(state.teamId);
const [day, setDay] = useState(state.day);
const [time, setTime] = useState(state.time || "08:00");
const [allDay, setAllDay] = useState(state.time === "");
const [duration, setDuration] = useState(state.duration);
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const closeRef = useRef(onClose);
useEffect(() => {
closeRef.current = onClose;
}, [onClose]);
useEffect(() => {
const opener = document.activeElement as HTMLElement | null;
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") closeRef.current();
};
window.addEventListener("keydown", onKey);
return () => {
window.removeEventListener("keydown", onKey);
opener?.focus?.();
};
}, []);
const order = state.order;
async function submit(e: React.FormEvent) {
e.preventDefault();
setError(null);
const start = wallTimeToUtc(allDay ? day : `${day}T${time}`, timeZone);
if (!start || !teamId || !Number.isFinite(duration) || duration < 15) {
setError(t("errors.invalid"));
return;
}
const body: Record<string, unknown> = { workOrderId: order.id, teamId, plannedStart: start.toISOString(), baseVersion: order.version };
if (!order.multiDay) {
body.plannedDurationMinutes = Math.round(duration);
body.plannedEnd = allDay ? null : new Date(start.getTime() + Math.round(duration) * 60_000).toISOString();
}
setPending(true);
try {
const res = await fetch("/api/v1/planning/schedule", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
const json = (await res.json().catch(() => null)) as (ScheduleResponse & { error?: { code?: string; message?: string } }) | null;
if (res.ok && json) {
onSaved(json);
return;
}
if (res.status === 409) {
onConflict(t("errors.conflict"));
return;
}
const message = json?.error?.message ?? "";
const code = json?.error?.code ?? "";
setError(t.has(`errors.${message}`) ? t(`errors.${message}`) : t.has(`errors.${code}`) ? t(`errors.${code}`) : t("errors.generic"));
} catch {
setError(t("errors.network"));
} finally {
setPending(false);
}
}
return (
<div
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-6 backdrop-blur-[2px]"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div role="dialog" aria-modal="true" aria-labelledby="schedule-title" className="shadow-card my-auto w-full max-w-lg rounded-2xl border bg-card">
<div className="flex items-start justify-between gap-3 border-b p-5">
<div className="min-w-0">
<h2 id="schedule-title" className="font-heading text-lg font-semibold">
{t("dialog.title")}
</h2>
<p className="text-sm">
<span className="font-mono">{order.number}</span> · {order.customerName}
</p>
{(order.siteAddress || order.siteName) && <p className="truncate text-xs text-muted-foreground">{order.siteAddress ?? order.siteName}</p>}
</div>
<button type="button" onClick={onClose} aria-label={t("dialog.close")} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
<X className="size-4.5" aria-hidden />
</button>
</div>
<form onSubmit={submit} className="grid gap-4 p-5 sm:grid-cols-2">
<Field label={t("dialog.team")} htmlFor="sd-team" className="sm:col-span-2">
<select id="sd-team" value={teamId} onChange={(e) => setTeamId(e.target.value)} required className={inputCls} autoFocus>
{teams.map((tm) => (
<option key={tm.id} value={tm.id}>
{tm.name}
</option>
))}
</select>
</Field>
<Field label={t("dialog.date")} htmlFor="sd-day">
<input id="sd-day" type="date" value={day} onChange={(e) => setDay(e.target.value)} required className={inputCls} />
</Field>
<Field label={t("dialog.time")} htmlFor="sd-time">
<input id="sd-time" type="time" step={900} value={time} onChange={(e) => setTime(e.target.value)} disabled={allDay} required={!allDay} className={inputCls} />
</Field>
<label className="flex min-h-11 items-center gap-2.5 text-sm sm:col-span-2">
<input type="checkbox" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} className="size-5 accent-[var(--ui-primary)]" />
{t("dialog.noTime")}
</label>
{!order.multiDay && (
<Field label={t("dialog.duration")} htmlFor="sd-duration" hint={t("dialog.durationHint", { duration: formatMinutes(order.durationMinutes, locale) })} className="sm:col-span-2">
<input id="sd-duration" type="number" min={15} max={20160} step={15} value={duration} onChange={(e) => setDuration(Number(e.target.value))} required className={inputCls} />
</Field>
)}
{error && (
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)] sm:col-span-2">
<AlertCircle className="size-4 shrink-0" aria-hidden />
{error}
</p>
)}
<div className="flex flex-wrap gap-2 sm:col-span-2">
<button type="submit" disabled={pending} className={buttonCls("primary")}>
{pending ? t("dialog.saving") : t("dialog.save")}
</button>
<button type="button" onClick={onClose} className={buttonCls("ghost")}>
{t("dialog.cancel")}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,98 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Info, Lightbulb, MapPin } from "lucide-react";
import { formatDistance } from "@/lib/geo/distance";
import { wallClock } from "@/lib/planning/days";
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
import { buttonCls } from "@/components/work-orders/button-cls";
import { Section } from "@/components/work-orders/ui";
import type { ServiceCtx } from "@/server/services/context";
import { findNearbyUnplanned, recommendAssignments } from "@/server/services/planning/recommend";
/**
* "Einsatz-Vorschläge" in the backoffice work order detail (L13, additive). Never breaks the page:
* any error hides the section. "Übernehmen" opens the planning board with a prefilled popover.
*/
export async function PlanningSuggestions({ ctx, workOrderId, locale, tz }: { ctx: ServiceCtx; workOrderId: string; locale: string; tz: string }) {
let rec: Awaited<ReturnType<typeof recommendAssignments>>;
let nearby: Awaited<ReturnType<typeof findNearbyUnplanned>>;
try {
[rec, nearby] = await Promise.all([recommendAssignments(ctx, { workOrderId, locale: locale === "en" ? "en" : "de" }), findNearbyUnplanned(ctx, { workOrderId })]);
} catch {
return null;
}
if (rec.status === "not_schedulable") return null;
const t = await getTranslations("planning");
const boardLink = (day: string | null, teamId: string | null, time: string | null, id = workOrderId) => {
const qs = new URLSearchParams({ schedule: id });
if (day) {
qs.set("date", day);
qs.set("day", day);
}
if (teamId) qs.set("scheduleTeam", teamId);
if (time) qs.set("time", time);
return `/planning?${qs}`;
};
return (
<Section
title={t("recommend.sectionTitle")}
className="mt-4"
actions={
<Link href="/planning" className={buttonCls("ghost")}>
{t("recommend.openBoard")}
</Link>
}
>
<p className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<Lightbulb className="size-3.5 text-[var(--info)]" aria-hidden />
{t("recommend.sub")}
</p>
{rec.hint && (
<p className="mb-2 flex items-start gap-1.5 text-sm">
<Info className="mt-0.5 size-4 shrink-0 text-[var(--info)]" aria-hidden />
{rec.hint}
</p>
)}
{rec.recommendations.length > 0 && (
<ul className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
{rec.recommendations.map((r) => (
<li key={`${r.teamId}-${r.day}`} className="rounded-lg border p-3 text-sm" title={r.text}>
<p className="font-semibold">
{t("recommend.card", { day: formatDayShort(r.day, locale), team: r.teamName, distance: formatDistance(r.distanceKm, locale), number: r.nearOrder.number, free: formatMinutes(r.freeMinutes, locale) })}
</p>
{r.tight && <p className="text-xs font-semibold text-[var(--warn)]">{t("recommend.tight")}</p>}
<Link href={boardLink(r.day, r.teamId, wallClock(new Date(r.suggestedStart), tz))} className={`${buttonCls("outline")} mt-2 w-full`}>
{t("recommend.take")}
</Link>
</li>
))}
</ul>
)}
{nearby.status === "ok" && nearby.items.length > 0 && (
<div className="mt-3 border-t pt-3">
<h3 className="text-sm font-semibold">{t("recommend.nearbyTitle")}</h3>
<p className="text-xs text-muted-foreground">{t("recommend.nearbyQuestion", { radius: nearby.radiusKm })}</p>
<ul className="mt-1.5 space-y-1">
{nearby.items.map((o) => {
const first = rec.recommendations[0];
return (
<li key={o.id} className="flex flex-wrap items-center gap-2 text-sm">
<MapPin className="size-3.5" aria-hidden />
<Link href={`/work-orders/${o.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
{o.number}
</Link>
<span>{o.customerName}</span>
<span className="text-muted-foreground">· {formatDistance(o.distanceKm, locale)}</span>
<Link href={boardLink(first?.day ?? null, first?.teamId ?? null, null, o.id)} className="ml-auto inline-flex min-h-11 items-center text-xs font-semibold underline-offset-2 hover:underline">
{t("recommend.planTogether")}
</Link>
</li>
);
})}
</ul>
</div>
)}
</Section>
);
}