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:
2026-09-15 10:13:50 +02:00
co-authored by Claude Opus 5
parent 5e10523df6
commit ee17134863
25 changed files with 2557 additions and 3 deletions
@@ -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>
);
}