MBO-Tech-IT-Webseite/modules/07-kpi-dashboard/files/app/admin/statistik/GanttKalender.tsx

367 lines
14 KiB
TypeScript
Raw Permalink 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 { 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>
);
}