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>
178 lines
7.3 KiB
TypeScript
178 lines
7.3 KiB
TypeScript
"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>
|
||
);
|
||
}
|