Files
craftvia/src/components/planning/schedule-dialog.tsx
T
msolarczekandClaude Opus 5 ee17134863 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>
2026-09-15 10:13:50 +02:00

178 lines
7.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}