L13 Planung: Menüpunkt Planung mit Plantafel und Live-Lage
Plantafel: Standard heute + nächste 4 Werktage (Kolonnen als Zeilen, heutige Spalte mit Live-Status), Heute mit Kolonnen als parallelen Spalten (6–20 Uhr), Woche/nächste Woche, Auslastung, Konflikte/Hinweise, Verzugs- und Gefährdungs-Badges, Drag & Drop (@dnd-kit/core) mit Bestätigungs-Popover und Tastatur-Alternative, ungeplante Aufträge mit Vorschlägen, früher fertig mit Vorziehen, Kolonnenkapazität-Popup. Live-Lage: Leaflet/OSM-Karte mit einem Marker je Kolonne, Liste, Polling 30 s, Hinweis keine GPS-Ortung; CSP img-src für Kacheln. Dashboard-Kacheln Planung heute und Konflikte diese Woche, Einsatz-Vorschläge im Auftragsdetail, Navigation mit Unterpunkten, Smoke-Prüfungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user