367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import { useRef } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { Lock } from "lucide-react";
|
||
|
||
interface PlanPosition {
|
||
id: string;
|
||
anfrage_id: string;
|
||
maschine_name: string;
|
||
maschine_kategorie: string;
|
||
mietbeginn: string;
|
||
mietende: string;
|
||
gesamt_tage: number;
|
||
tagessatz: number | null;
|
||
anfrage_status: string;
|
||
firma: string;
|
||
}
|
||
|
||
/** Minimaler Sperrung-Typ fuer den Gantt. maschine_name wird benoetigt,
|
||
* weil der Gantt Zeilen anhand des Namens gruppiert. */
|
||
export interface SperrungGantt {
|
||
sperr_id: string;
|
||
maschine_name: string;
|
||
von: string;
|
||
bis: string;
|
||
grund: string;
|
||
notiz?: string;
|
||
}
|
||
|
||
interface Props {
|
||
planDaten: PlanPosition[];
|
||
heuteISO: string;
|
||
startISO?: string; // Optional: Startdatum (default: heuteISO)
|
||
tageGesamt?: number; // Optional: Sichtbare Tage (default: 95)
|
||
sperrungen?: SperrungGantt[];
|
||
}
|
||
|
||
const STATUS_FARBE: Record<string, string> = {
|
||
bestaetigt: "bg-green-500 border-green-600",
|
||
offen: "bg-amber-400 border-amber-500",
|
||
abgeschlossen: "bg-slate-400 border-slate-500",
|
||
abgelehnt: "bg-red-300 border-red-400",
|
||
};
|
||
|
||
const STATUS_LABEL: Record<string, string> = {
|
||
bestaetigt: "Bestätigt",
|
||
offen: "Offen",
|
||
abgeschlossen: "Abgeschlossen",
|
||
abgelehnt: "Abgelehnt",
|
||
};
|
||
|
||
// Sperrungen werden in einem neutralen Dunkelgrau mit diagonaler Schraffur
|
||
// dargestellt, damit sie sich visuell klar von Anfrage-Balken abheben.
|
||
const SPERRUNG_STYLE =
|
||
"bg-slate-700 border-slate-800 text-white " +
|
||
"bg-[repeating-linear-gradient(45deg,rgba(255,255,255,0.08)_0_6px,transparent_6px_12px)]";
|
||
|
||
function addDays(iso: string, days: number): string {
|
||
const d = new Date(iso + "T00:00:00");
|
||
d.setDate(d.getDate() + days);
|
||
return d.toISOString().slice(0, 10);
|
||
}
|
||
|
||
function daysBetween(a: string, b: string): number {
|
||
const da = new Date(a + "T00:00:00");
|
||
const db = new Date(b + "T00:00:00");
|
||
return Math.round((db.getTime() - da.getTime()) / 86400000);
|
||
}
|
||
|
||
function formatDate(iso: string): string {
|
||
return new Date(iso + "T00:00:00").toLocaleDateString("de-DE", {
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
});
|
||
}
|
||
|
||
function fmt(n: number) {
|
||
return n.toLocaleString("de-DE", { minimumFractionDigits: 0, maximumFractionDigits: 0 });
|
||
}
|
||
|
||
const TAGE_GESAMT = 95; // ca. 3 Monate + etwas Puffer
|
||
const TAG_BREITE = 28; // px pro Tag
|
||
|
||
export function GanttKalender({
|
||
planDaten,
|
||
heuteISO,
|
||
startISO,
|
||
tageGesamt,
|
||
sperrungen = [],
|
||
}: Props) {
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
const router = useRouter();
|
||
|
||
// Defaults setzen
|
||
const kalenderStart = startISO ?? heuteISO;
|
||
const TAGE_GESAMT = tageGesamt ?? 95;
|
||
const kalenderEnde = addDays(kalenderStart, TAGE_GESAMT - 1);
|
||
|
||
// Nur relevante Status anzeigen (nicht abgelehnt)
|
||
const sichtbar = planDaten.filter((p) => p.anfrage_status !== "abgelehnt");
|
||
|
||
// Sperrungen auf den sichtbaren Zeitraum eingrenzen
|
||
const sperrungenSichtbar = sperrungen.filter(
|
||
(s) => s.bis >= kalenderStart && s.von <= kalenderEnde
|
||
);
|
||
|
||
// Eindeutige Maschinen: zusammenfuehren aus Anfragen + Sperrungen, damit
|
||
// Sperrungen fuer reine "Leer-Maschinen" ebenfalls sichtbar sind.
|
||
const maschinenSet = new Set<string>();
|
||
for (const p of sichtbar) maschinenSet.add(p.maschine_name);
|
||
for (const s of sperrungenSichtbar) maschinenSet.add(s.maschine_name);
|
||
const maschinenOhneDouble = Array.from(maschinenSet).sort();
|
||
|
||
// Kalender-Tage generieren (ab kalenderStart statt immer ab heute)
|
||
const tage: string[] = [];
|
||
for (let i = 0; i < TAGE_GESAMT; i++) {
|
||
tage.push(addDays(kalenderStart, i));
|
||
}
|
||
|
||
// Monats-Separatoren
|
||
const monate: { label: string; start: number; breite: number }[] = [];
|
||
let currentMonat = "";
|
||
let startIdx = 0;
|
||
tage.forEach((d, i) => {
|
||
const m = d.slice(0, 7);
|
||
if (m !== currentMonat) {
|
||
if (currentMonat) {
|
||
monate.push({
|
||
label: new Date(currentMonat + "-01").toLocaleDateString("de-DE", {
|
||
month: "long",
|
||
year: "numeric",
|
||
}),
|
||
start: startIdx * TAG_BREITE,
|
||
breite: (i - startIdx) * TAG_BREITE,
|
||
});
|
||
}
|
||
currentMonat = m;
|
||
startIdx = i;
|
||
}
|
||
});
|
||
if (currentMonat) {
|
||
monate.push({
|
||
label: new Date(currentMonat + "-01").toLocaleDateString("de-DE", {
|
||
month: "long",
|
||
year: "numeric",
|
||
}),
|
||
start: startIdx * TAG_BREITE,
|
||
breite: (tage.length - startIdx) * TAG_BREITE,
|
||
});
|
||
}
|
||
|
||
const gesamtBreite = TAGE_GESAMT * TAG_BREITE;
|
||
|
||
if (maschinenOhneDouble.length === 0) {
|
||
return (
|
||
<div className="text-center py-12 text-slate-400 text-sm">
|
||
Keine geplanten Vermietungen oder Sperrungen im gewählten Zeitraum.
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div>
|
||
{/* Legende */}
|
||
<div className="flex flex-wrap gap-4 mb-4 text-xs text-slate-600">
|
||
{Object.entries(STATUS_LABEL)
|
||
.filter(([k]) => k !== "abgelehnt")
|
||
.map(([k, v]) => (
|
||
<div key={k} className="flex items-center gap-1.5">
|
||
<span className={`inline-block w-3 h-3 rounded-sm border ${STATUS_FARBE[k]}`} />
|
||
{v}
|
||
</div>
|
||
))}
|
||
<div className="flex items-center gap-1.5">
|
||
<span className={`inline-block w-3 h-3 rounded-sm border ${SPERRUNG_STYLE}`} />
|
||
Gesperrt
|
||
</div>
|
||
</div>
|
||
|
||
{/* Scrollbarer Gantt */}
|
||
<div
|
||
ref={scrollRef}
|
||
className="overflow-x-auto border border-slate-200 bg-white"
|
||
style={{ maxHeight: "520px", overflowY: "auto" }}
|
||
>
|
||
<div style={{ minWidth: gesamtBreite + 160 }}>
|
||
{/* Monats-Header */}
|
||
<div className="flex sticky top-0 z-20 bg-[#1c1917] text-white">
|
||
<div
|
||
className="flex-shrink-0 text-xs font-medium px-3 py-2 border-r border-white/10"
|
||
style={{ width: 160 }}
|
||
>
|
||
Maschine
|
||
</div>
|
||
<div className="relative flex-1 overflow-hidden" style={{ width: gesamtBreite }}>
|
||
{monate.map((m) => (
|
||
<div
|
||
key={m.label}
|
||
className="absolute top-0 bottom-0 flex items-center px-2 text-xs font-semibold text-white/90 border-r border-white/10"
|
||
style={{ left: m.start, width: m.breite }}
|
||
>
|
||
{m.label}
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tages-Header */}
|
||
<div className="flex sticky top-8 z-10 bg-slate-50 border-b border-slate-200">
|
||
<div className="flex-shrink-0 border-r border-slate-200" style={{ width: 160 }} />
|
||
<div className="relative flex" style={{ width: gesamtBreite }}>
|
||
{tage.map((d, i) => {
|
||
const wochentag = new Date(d + "T00:00:00").getDay();
|
||
const istHeute = d === heuteISO;
|
||
const istSo = wochentag === 0;
|
||
const istMo = wochentag === 1;
|
||
const tag = d.slice(8);
|
||
return (
|
||
<div
|
||
key={d}
|
||
className={`flex-shrink-0 text-center text-[10px] py-1 border-r border-slate-100
|
||
${istHeute ? "bg-[#f7d334] text-[#1c1917] font-bold" : ""}
|
||
${istSo && !istHeute ? "bg-red-50 text-red-300" : ""}
|
||
${istMo && !istHeute ? "border-l border-l-slate-300" : ""}
|
||
${!istHeute && !istSo ? "text-slate-400" : ""}
|
||
`}
|
||
style={{ width: TAG_BREITE }}
|
||
title={new Date(d + "T00:00:00").toLocaleDateString("de-DE", {
|
||
weekday: "short",
|
||
day: "2-digit",
|
||
month: "2-digit",
|
||
})}
|
||
>
|
||
{tag}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Maschinen-Zeilen */}
|
||
{maschinenOhneDouble.map((maschine, rowIdx) => {
|
||
const positionen = sichtbar.filter((p) => p.maschine_name === maschine);
|
||
const rowBg = rowIdx % 2 === 0 ? "bg-white" : "bg-slate-50/60";
|
||
|
||
return (
|
||
<div key={maschine} className={`flex border-b border-slate-100 group ${rowBg}`} style={{ minHeight: 44 }}>
|
||
{/* Maschinenname */}
|
||
<div
|
||
className="flex-shrink-0 flex items-center px-3 border-r border-slate-200 text-xs font-medium text-slate-700 group-hover:bg-slate-100/60 transition-colors"
|
||
style={{ width: 160 }}
|
||
>
|
||
<span className="truncate" title={maschine}>{maschine}</span>
|
||
</div>
|
||
|
||
{/* Balkenfläche */}
|
||
<div className="relative flex-1" style={{ width: gesamtBreite, minHeight: 44 }}>
|
||
{/* Heute-Linie */}
|
||
<div
|
||
className="absolute top-0 bottom-0 w-px bg-[#f7d334]/40 z-10"
|
||
style={{ left: 0 }}
|
||
/>
|
||
|
||
{/* Wochenenden */}
|
||
{tage.map((d, i) => {
|
||
const wt = new Date(d + "T00:00:00").getDay();
|
||
if (wt !== 0) return null;
|
||
return (
|
||
<div
|
||
key={d}
|
||
className="absolute top-0 bottom-0 bg-red-50/50"
|
||
style={{ left: i * TAG_BREITE, width: TAG_BREITE }}
|
||
/>
|
||
);
|
||
})}
|
||
|
||
{/* Sperrungs-Balken (unter den Anfragen gerendert, damit
|
||
Anfragen bei einer unerwarteten Ueberlappung oben liegen.
|
||
Durch die Collision-Pruefung in /api/admin/sperrungen
|
||
sollte das in der Praxis aber nicht vorkommen.) */}
|
||
{sperrungenSichtbar
|
||
.filter((s) => s.maschine_name === maschine)
|
||
.map((s) => {
|
||
const startOff = daysBetween(kalenderStart, s.von);
|
||
const endOff = daysBetween(kalenderStart, s.bis);
|
||
const left = Math.max(0, startOff) * TAG_BREITE;
|
||
const right = Math.min(TAGE_GESAMT, endOff + 1) * TAG_BREITE;
|
||
const width = right - left;
|
||
if (width <= 0) return null;
|
||
|
||
return (
|
||
<div
|
||
key={`sperr-${s.sperr_id}`}
|
||
className={`absolute top-1.5 bottom-1.5 rounded-sm border text-[10px] flex items-center gap-1 px-1.5 overflow-hidden group/sperr z-0 ${SPERRUNG_STYLE}`}
|
||
style={{ left, width: Math.max(width - 2, 4) }}
|
||
title={`Gesperrt · ${s.grund} · ${formatDate(s.von)}–${formatDate(s.bis)}`}
|
||
>
|
||
<Lock className="w-2.5 h-2.5 flex-shrink-0" />
|
||
<span className="truncate font-medium leading-tight">
|
||
{s.grund}
|
||
</span>
|
||
{/* Tooltip bei Hover */}
|
||
<div className="absolute left-0 top-full mt-1 z-30 hidden group-hover/sperr:block bg-[#1c1917] text-white text-[11px] rounded px-2 py-1.5 shadow-lg whitespace-nowrap pointer-events-none min-w-[160px]">
|
||
<p className="font-semibold flex items-center gap-1">
|
||
<Lock className="w-3 h-3" /> Sperrung
|
||
</p>
|
||
<p className="text-white/70 mt-0.5">{s.grund}</p>
|
||
<p className="text-white/70">
|
||
{formatDate(s.von)} – {formatDate(s.bis)}
|
||
</p>
|
||
{s.notiz && (
|
||
<p className="text-white/50 text-[10px] mt-1 border-t border-white/10 pt-1 max-w-[220px] whitespace-normal">
|
||
{s.notiz}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
{/* Vermietungs-Balken */}
|
||
{positionen.map((p) => {
|
||
const startOff = daysBetween(kalenderStart, p.mietbeginn);
|
||
const endOff = daysBetween(kalenderStart, p.mietende);
|
||
const left = Math.max(0, startOff) * TAG_BREITE;
|
||
const right = Math.min(TAGE_GESAMT, endOff + 1) * TAG_BREITE;
|
||
const width = right - left;
|
||
if (width <= 0) return null;
|
||
|
||
const umsatz = p.tagessatz != null
|
||
? p.tagessatz * p.gesamt_tage
|
||
: null;
|
||
|
||
return (
|
||
<div
|
||
key={p.id}
|
||
className={`absolute top-1.5 bottom-1.5 rounded-sm border text-white text-[10px] flex items-center px-1.5 overflow-hidden cursor-pointer group/bar ${STATUS_FARBE[p.anfrage_status] ?? "bg-slate-400 border-slate-500"} hover:shadow-lg transition-shadow`}
|
||
style={{ left, width: Math.max(width - 2, 4) }}
|
||
title={`${p.firma} · ${formatDate(p.mietbeginn)}–${formatDate(p.mietende)} · ${p.gesamt_tage} Tage${umsatz != null ? ` · ${fmt(umsatz)} €` : ""}`}
|
||
onClick={() => router.push(`/admin/anfragen/${p.anfrage_id}`)}
|
||
>
|
||
<span className="truncate font-medium leading-tight">{p.firma}</span>
|
||
{/* Tooltip bei Hover */}
|
||
<div className="absolute left-0 top-full mt-1 z-30 hidden group-hover/bar:block bg-[#1c1917] text-white text-[11px] rounded px-2 py-1.5 shadow-lg whitespace-nowrap pointer-events-none min-w-[160px]">
|
||
<p className="font-semibold">{p.firma}</p>
|
||
<p className="text-white/70 mt-0.5">{STATUS_LABEL[p.anfrage_status]}</p>
|
||
<p className="text-white/70">{formatDate(p.mietbeginn)} – {formatDate(p.mietende)}</p>
|
||
<p className="text-white/70">{p.gesamt_tage} Miettage</p>
|
||
{umsatz != null && (
|
||
<p className="text-[#f7d334] font-semibold mt-0.5">{fmt(umsatz)} € netto</p>
|
||
)}
|
||
<p className="text-white/50 text-[10px] mt-1 border-t border-white/10 pt-1">Klicken zum Öffnen →</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|