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>
99 lines
4.5 KiB
TypeScript
99 lines
4.5 KiB
TypeScript
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>
|
|
);
|
|
}
|