"use client";
import React, { useEffect, useMemo, useState } from "react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Label } from "@/components/ui/label";
import { motion } from "framer-motion";
const STORAGE_KEY = "schichtplan_dispo_board_v2";
function getCurrentWeekStart() {
const now = new Date();
const day = now.getDay();
const diff = day === 0 ? -6 : 1 - day;
const monday = new Date(now);
monday.setDate(now.getDate() + diff);
return monday.toISOString().slice(0, 10);
}
function formatDateLabel(dateString) {
const date = new Date(`${dateString}T12:00:00`);
return new Intl.DateTimeFormat("cs-CZ", {
day: "2-digit",
month: "2-digit",
year: "numeric",
}).format(date);
}
function getWeekDates(weekStart) {
const start = new Date(`${weekStart}T12:00:00`);
return days.map((day, index) => {
const current = new Date(start);
current.setDate(start.getDate() + index);
const iso = current.toISOString().slice(0, 10);
return {
day,
iso,
label: formatDateLabel(iso),
};
});
}
const days = ["Pondělí", "Úterý", "Středa", "Čtvrtek", "Pátek", "Sobota", "Neděle"];
const shifts = [
{ id: "rano", label: "8:00–16.30:00", short: "Ranní", icon: "🌅" },
{ id: "odpo", label: "16:30–22:30", short: "Odpolední", icon: "🌆" },
];
function IconText({ children, className = "" }) {
return {children};
}
function ProgressBar({ label, value, max, rightLabel, tone = "slate" }) {
const percent = max > 0 ? Math.min(100, Math.round((value / max) * 100)) : 0;
const toneClass = {
slate: "bg-slate-700",
emerald: "bg-emerald-600",
amber: "bg-amber-500",
sky: "bg-sky-600",
red: "bg-red-500",
}[tone] || "bg-slate-700";
return (
{label}
{rightLabel || `${value}/${max}`}
);
}
const initialDrivers = [
{ id: "d1", name: "Řidič 1", phone: "", note: "" },
{ id: "d2", name: "Řidič 2", phone: "", note: "" },
{ id: "d3", name: "Řidič 3", phone: "", note: "" },
{ id: "d4", name: "Řidič 4", phone: "", note: "" },
];
const initialVehicles = [
{ id: "v1", name: "Octavia", plate: "8AJ 1629", note: "Hlavní vozidlo" },
{ id: "v2", name: "Fabia", plate: "", note: "Rezerva / město" },
{ id: "v3", name: "Mercedes", plate: "", note: "Transfer / VIP" },
];
const createAssignment = (driverId = "", vehicleId = "", note = "") => ({
id: `a${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
driverId,
vehicleId,
note,
});
const makeEmptySchedule = () => {
const schedule = {};
days.forEach((day) => {
schedule[day] = {};
shifts.filter((shift) => isShiftAvailableOnDay(shift, day)).forEach((shift) => {
schedule[day][shift.id] = [];
});
});
return schedule;
};
const normalizeSchedule = (savedSchedule) => {
const next = makeEmptySchedule();
if (!savedSchedule || typeof savedSchedule !== "object") return next;
days.forEach((day) => {
const oldDay = savedSchedule[day] || {};
// Nový formát: všechny směny ve všech dnech
shifts.forEach((shift) => {
if (Array.isArray(oldDay[shift.id])) {
next[day][shift.id] = oldDay[shift.id];
}
});
if (Array.isArray(oldDay.den) || Array.isArray(oldDay.odpo) || Array.isArray(oldDay.noc)) {
return;
}
// Starý formát: ranní + odpolední. Aby aplikace nespadla,
// převezmeme ranní směnu jako denní a staré odpolední ignorujeme.
if (Array.isArray(oldDay.frueh)) {
next[day].den = oldDay.frueh.map((entry) => ({
...entry,
note: entry.note || "Převedeno ze staré ranní směny na 6:00–16:00",
}));
}
});
return next;
};
function getDriver(drivers, id) {
return drivers.find((d) => d.id === id);
}
function getVehicle(vehicles, id) {
return vehicles.find((v) => v.id === id);
}
function getDriverName(drivers, id) {
return getDriver(drivers, id)?.name || "Bez řidiče";
}
function getVehicleName(vehicles, id) {
const v = getVehicle(vehicles, id);
if (!v) return "Bez vozidla";
return v.plate ? `${v.name} (${v.plate})` : v.name;
}
function isShiftAvailableOnDay(shift, day) {
if (!shift.onlyDays) return true;
return shift.onlyDays.includes(day);
}
function isAssignmentConflict(schedule, day, shiftId, entry, drivers, vehicles) {
const assignmentsSameDay = shifts.flatMap((shift) => (schedule?.[day]?.[shift.id] || []).map((a) => ({ ...a, shiftId: shift.id })));
const driverConflict = entry.driverId
? assignmentsSameDay.some(
(a) => a.id !== entry.id && a.driverId === entry.driverId && a.shiftId !== shiftId
)
: false;
const vehicleConflict = entry.vehicleId
? assignmentsSameDay.some(
(a) => a.id !== entry.id && a.vehicleId === entry.vehicleId && a.shiftId !== shiftId
)
: false;
return {
driverConflict,
vehicleConflict,
hasConflict: driverConflict || vehicleConflict,
driverName: getDriverName(drivers, entry.driverId),
vehicleName: getVehicleName(vehicles, entry.vehicleId),
};
}
function PoolChip({ label, sublabel, onDragStart, onDelete }) {
return (
↕
{label}
{sublabel ?
{sublabel}
: null}
{onDelete && (
)}
);
}
export default function SchichtplanFahrerApp() {
const [drivers, setDrivers] = useState(initialDrivers);
const [vehicles, setVehicles] = useState(initialVehicles);
const [schedule, setSchedule] = useState(makeEmptySchedule());
const [newDriver, setNewDriver] = useState({ name: "", phone: "", note: "" });
const [newVehicle, setNewVehicle] = useState({ name: "", plate: "", note: "" });
const [dragItem, setDragItem] = useState(null);
const [copiedAssignment, setCopiedAssignment] = useState(null);
const [loaded, setLoaded] = useState(false);
const [showWeeklyOverview, setShowWeeklyOverview] = useState(false);
const [weekStart, setWeekStart] = useState(getCurrentWeekStart());
const [actionMessage, setActionMessage] = useState("");
useEffect(() => {
if (typeof window === "undefined") return;
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
setDrivers(parsed.drivers || initialDrivers);
setVehicles(parsed.vehicles || initialVehicles);
setSchedule(normalizeSchedule(parsed.schedule));
setWeekStart(parsed.weekStart || getCurrentWeekStart());
}
} catch (e) {
console.error("Chyba při načítání", e);
setActionMessage("Nepodařilo se načíst uložená data.");
} finally {
setLoaded(true);
}
}, []);
useEffect(() => {
if (!loaded || typeof window === "undefined") return;
try {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
drivers,
vehicles,
schedule,
weekStart,
})
);
} catch (e) {
console.error("Chyba při ukládání", e);
}
}, [drivers, vehicles, schedule, weekStart, loaded]);
const addDriver = () => {
if (!newDriver.name.trim()) return;
setDrivers((prev) => [
...prev,
{ id: `d${Date.now()}`, name: newDriver.name.trim(), phone: newDriver.phone.trim(), note: newDriver.note.trim() },
]);
setNewDriver({ name: "", phone: "", note: "" });
};
const addVehicle = () => {
if (!newVehicle.name.trim()) return;
setVehicles((prev) => [
...prev,
{ id: `v${Date.now()}`, name: newVehicle.name.trim(), plate: newVehicle.plate.trim(), note: newVehicle.note.trim() },
]);
setNewVehicle({ name: "", plate: "", note: "" });
};
const deleteDriver = (driverId) => {
setDrivers((prev) => prev.filter((d) => d.id !== driverId));
setSchedule((prev) => {
const next = structuredClone(prev);
days.forEach((day) => {
shifts.filter((shift) => isShiftAvailableOnDay(shift, day)).forEach((shift) => {
next[day][shift.id] = (next[day][shift.id] || []).map((a) =>
a.driverId === driverId ? { ...a, driverId: "" } : a
);
});
});
return next;
});
};
const deleteVehicle = (vehicleId) => {
setVehicles((prev) => prev.filter((v) => v.id !== vehicleId));
setSchedule((prev) => {
const next = structuredClone(prev);
days.forEach((day) => {
shifts.filter((shift) => isShiftAvailableOnDay(shift, day)).forEach((shift) => {
next[day][shift.id] = (next[day][shift.id] || []).map((a) =>
a.vehicleId === vehicleId ? { ...a, vehicleId: "" } : a
);
});
});
return next;
});
};
const addAssignment = (day, shiftId, preset = {}) => {
setSchedule((prev) => ({
...prev,
[day]: {
...prev[day],
[shiftId]: [...prev[day][shiftId], createAssignment(preset.driverId || "", preset.vehicleId || "", preset.note || "")],
},
}));
};
const updateAssignment = (day, shiftId, assignmentId, field, value) => {
setSchedule((prev) => ({
...prev,
[day]: {
...prev[day],
[shiftId]: prev[day][shiftId].map((a) =>
a.id === assignmentId ? { ...a, [field]: value } : a
),
},
}));
};
const deleteAssignment = (day, shiftId, assignmentId) => {
setSchedule((prev) => ({
...prev,
[day]: {
...prev[day],
[shiftId]: prev[day][shiftId].filter((a) => a.id !== assignmentId),
},
}));
};
const moveAssignment = (fromDay, fromShiftId, toDay, toShiftId, assignmentId) => {
setSchedule((prev) => {
const next = structuredClone(prev);
const sourceList = next[fromDay][fromShiftId];
const idx = sourceList.findIndex((a) => a.id === assignmentId);
if (idx === -1) return prev;
const [moved] = sourceList.splice(idx, 1);
next[toDay][toShiftId].push(moved);
return next;
});
};
const onDropToCell = (day, shiftId) => {
if (!dragItem) return;
if (dragItem.kind === "driver") {
addAssignment(day, shiftId, { driverId: dragItem.id });
}
if (dragItem.kind === "vehicle") {
addAssignment(day, shiftId, { vehicleId: dragItem.id });
}
if (dragItem.kind === "assignment") {
moveAssignment(dragItem.fromDay, dragItem.fromShiftId, day, shiftId, dragItem.assignment.id);
}
setDragItem(null);
};
const onDropToTrash = () => {
if (!dragItem) return;
if (dragItem.kind === "assignment") {
deleteAssignment(dragItem.fromDay, dragItem.fromShiftId, dragItem.assignment.id);
}
if (dragItem.kind === "driver") {
deleteDriver(dragItem.id);
}
if (dragItem.kind === "vehicle") {
deleteVehicle(dragItem.id);
}
setDragItem(null);
};
const weekDates = useMemo(() => getWeekDates(weekStart), [weekStart]);
const weekRangeLabel = useMemo(() => {
if (!weekDates.length) return "";
return `${weekDates[0].label} – ${weekDates[weekDates.length - 1].label}`;
}, [weekDates]);
const stats = useMemo(() => {
const driverCounts = {};
const vehicleCounts = {};
const conflicts = [];
days.forEach((day) => {
const availableShifts = shifts.filter((shift) => isShiftAvailableOnDay(shift, day));
const dayDriverUse = {};
const dayVehicleUse = {};
availableShifts.forEach((shift) => {
const entries = schedule?.[day]?.[shift.id] || [];
entries.forEach((entry) => {
if (entry.driverId) {
driverCounts[entry.driverId] = (driverCounts[entry.driverId] || 0) + 1;
dayDriverUse[entry.driverId] = (dayDriverUse[entry.driverId] || []).concat(shift.label);
}
if (entry.vehicleId) {
vehicleCounts[entry.vehicleId] = (vehicleCounts[entry.vehicleId] || 0) + 1;
dayVehicleUse[entry.vehicleId] = (dayVehicleUse[entry.vehicleId] || []).concat(shift.label);
}
});
});
Object.entries(dayDriverUse).forEach(([driverId, usedShifts]) => {
if (usedShifts.length > 1) {
conflicts.push(`Řidič ${getDriverName(drivers, driverId)} je v ${day} přiřazen vícekrát: ${usedShifts.join(", ")}`);
}
});
Object.entries(dayVehicleUse).forEach(([vehicleId, usedShifts]) => {
if (usedShifts.length > 1) {
conflicts.push(`Vozidlo ${getVehicleName(vehicles, vehicleId)} je v ${day} obsazeno vícekrát: ${usedShifts.join(", ")}`);
}
});
});
return { driverCounts, vehicleCounts, conflicts };
}, [schedule, drivers, vehicles, weekDates]);
const graphicsStats = useMemo(() => {
const shiftUsage = shifts.map((shift) => {
const count = days.reduce((sum, day) => sum + (schedule?.[day]?.[shift.id] || []).length, 0);
return { ...shift, count };
});
const dayUsage = days.map((day) => {
const count = shifts.reduce((sum, shift) => sum + (schedule?.[day]?.[shift.id] || []).length, 0);
const dateInfo = weekDates.find((item) => item.day === day);
return { day, date: dateInfo?.label || "", count };
});
const driverUsage = drivers.map((driver) => ({
id: driver.id,
name: driver.name,
count: stats.driverCounts[driver.id] || 0,
}));
const vehicleUsage = vehicles.map((vehicle) => ({
id: vehicle.id,
name: vehicle.plate ? `${vehicle.name} (${vehicle.plate})` : vehicle.name,
count: stats.vehicleCounts[vehicle.id] || 0,
}));
const plannedCells = days.reduce(
(sum, day) => sum + shifts.reduce((inner, shift) => inner + ((schedule?.[day]?.[shift.id] || []).length > 0 ? 1 : 0), 0),
0
);
const totalCells = days.length * shifts.length;
return { shiftUsage, dayUsage, driverUsage, vehicleUsage, plannedCells, totalCells };
}, [schedule, drivers, vehicles, stats, weekDates]);
const fillExample = () => {
const next = makeEmptySchedule();
days.forEach((day) => {
next[day].rano = [
createAssignment(drivers[0]?.id || "", vehicles[0]?.id || "", "Ranní směna 6:00–14:00"),
createAssignment(drivers[1]?.id || "", vehicles[1]?.id || "", "Centrum / hotely"),
];
next[day].odpo = [
createAssignment(drivers[2]?.id || "", vehicles[2]?.id || "", "Odpolední směna 14:30–22:30"),
];
});
setSchedule(next);
};
const clearAll = () => {
setSchedule(makeEmptySchedule());
setActionMessage("Plán byl vymazán.");
};
const handleSave = () => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(
STORAGE_KEY,
JSON.stringify({ drivers, vehicles, schedule, weekStart })
);
setActionMessage("Data byla uložena.");
} catch (e) {
console.error("Chyba při ručním ukládání", e);
setActionMessage("Uložení se nepodařilo.");
}
};
const handlePrint = () => {
if (typeof window === "undefined") return;
const printableContent = document.getElementById("print-area");
if (!printableContent) {
setActionMessage("Nejprve otevřete týdenní přehled a pak zkuste tisk znovu.");
return;
}
const printWindow = window.open("", "_blank", "width=1400,height=900");
if (!printWindow) {
setActionMessage("Prohlížeč zablokoval tiskové okno.");
return;
}
printWindow.document.write(`
Týdenní přehled směn
Celotýdenní přehled směn
Týden: ${weekRangeLabel}
${printableContent.innerHTML}
`);
printWindow.document.close();
printWindow.focus();
setTimeout(() => {
printWindow.print();
}, 250);
};
const resetEverything = () => {
if (typeof window !== "undefined") {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch (e) {
console.error("Chyba při mazání uložených dat", e);
}
}
setDrivers(initialDrivers);
setVehicles(initialVehicles);
setSchedule(makeEmptySchedule());
setWeekStart(getCurrentWeekStart());
setCopiedAssignment(null);
setShowWeeklyOverview(false);
setActionMessage("Všechna data byla obnovena do výchozího stavu.");
};
const totalAssignments = useMemo(() => {
return days.reduce((sum, day) => sum + shifts.reduce((inner, shift) => inner + (schedule?.[day]?.[shift.id] || []).length, 0), 0);
}, [schedule]);
const driverOutputs = useMemo(() => {
return drivers.map((driver) => {
const plan = [];
days.forEach((day) => {
shifts.filter((shift) => isShiftAvailableOnDay(shift, day)).forEach((shift) => {
(schedule?.[day]?.[shift.id] || []).forEach((entry) => {
if (entry.driverId === driver.id) {
const dateInfo = weekDates.find((item) => item.day === day);
plan.push({
day,
date: dateInfo?.label || "",
shift: shift.short,
time: shift.label,
note: entry.note || "",
});
}
});
});
});
return {
...driver,
plan,
};
});
}, [drivers, schedule, weekDates]);
const weeklyOverview = useMemo(() => {
return shifts.filter((shift) => shift.onlyDays ? true : true).map((shift) => ({
...shift,
days: days.map((day) => {
const dateInfo = weekDates.find((item) => item.day === day);
const entries = schedule?.[day]?.[shift.id] || [];
const assignedDriverIds = entries.map((entry) => entry.driverId).filter(Boolean);
const assignedVehicleIds = entries.map((entry) => entry.vehicleId).filter(Boolean);
const freeDrivers = drivers.filter((driver) => !assignedDriverIds.includes(driver.id));
const freeVehicles = vehicles.filter((vehicle) => !assignedVehicleIds.includes(vehicle.id));
return {
day,
date: dateInfo?.label || "",
entryCount: entries.length,
isFree: entries.length === 0,
assignedDrivers: entries
.map((entry) => getDriverName(drivers, entry.driverId))
.filter((name) => name !== "Bez řidiče"),
assignedVehicles: entries
.map((entry) => getVehicleName(vehicles, entry.vehicleId))
.filter((name) => name !== "Bez vozidla"),
freeDrivers: freeDrivers.map((driver) => driver.name),
freeVehicles: freeVehicles.map((vehicle) => (vehicle.plate ? `${vehicle.name} (${vehicle.plate})` : vehicle.name)),
};
}),
}));
}, [schedule, drivers, vehicles]);
return (
Dispečerská tabule řidičů
Týdenní plánování jako skutečná tabule s funkcí drag & drop, barevným označením konfliktů, košem a automatickým ukládáním.
{actionMessage ? (
{actionMessage}
) : null}
👤
🚕
Vozidla
{vehicles.length}
📅
📋
Přiřazení
{totalAssignments}
⚠️
Konflikty
{stats.conflicts.length}
📊Obsazenost týdne
Obsazené bloky směn
{graphicsStats.plannedCells}/{graphicsStats.totalCells}
{Math.round((graphicsStats.plannedCells / Math.max(1, graphicsStats.totalCells)) * 100)} %
{graphicsStats.dayUsage.map((item) => {
const height = Math.max(12, Math.min(100, item.count * 22));
return (
{item.day.slice(0, 2)}
{item.count}
);
})}
👤Graf řidičů
{graphicsStats.driverUsage.length === 0 ? (
Zatím nejsou vloženi řidiči.
) : (
graphicsStats.driverUsage.map((driver) => (
7 ? "amber" : "emerald"} />
))
)}
🚕Graf vozidel
{graphicsStats.vehicleUsage.length === 0 ? (
Zatím nejsou vložena vozidla.
) : (
graphicsStats.vehicleUsage.map((vehicle) => (
10 ? "sky" : "slate"} />
))
)}
🧭Rozložení podle typu směny
{graphicsStats.shiftUsage.map((shift) => (
{shift.icon}{shift.short}
{shift.count}
))}
{showWeeklyOverview ? (
Celotýdenní přehled směn
Týden: {weekRangeLabel}
Kompaktní výstup na jednu stránku podle směn.
Směna
{weekDates.map((item) => (
))}
{weeklyOverview.map((shift) => (
{shift.short}
{shift.label}
{shift.days.map((cell) => (
{cell.isFree ? "VOLNÝ TERMÍN" : `Obsazeno: ${cell.entryCount}`}
Řidiči:{" "}
{cell.assignedDrivers.length ? cell.assignedDrivers.join(", ") : "nikdo"}
Volná auta:{" "}
{cell.freeVehicles.length ? cell.freeVehicles.join(", ") : "žádná"}
))}
))}
) : null}
Dispečink
Upozornění
Řidiči
Vozidla
Výstup pro řidiče
Seznam řidičů
{drivers.map((driver) => (
setDragItem({ kind: "driver", id: driver.id })}
onDelete={() => deleteDriver(driver.id)}
/>
))}
Seznam vozidel
{vehicles.map((vehicle) => (
setDragItem({ kind: "vehicle", id: vehicle.id })}
onDelete={() => deleteVehicle(vehicle.id)}
/>
))}
e.preventDefault()}
onDrop={onDropToTrash}
>
🗑
Koš
Přetáhněte sem přiřazení, řidiče nebo vozidlo pro smazání.
Týdenní tabule
Týden: {weekRangeLabel}
{weekDates.map((item) => (
))}
{shifts.map((shift) => {
const Icon = shift.icon;
return (
{Icon}
{shift.short}
{shift.label}
{days.map((day) => {
if (!isShiftAvailableOnDay(shift, day)) {
return (
);
}
const entries = schedule?.[day]?.[shift.id] || [];
return (
e.preventDefault()}
onDrop={() => onDropToCell(day, shift.id)}
className="rounded-2xl border bg-slate-50 p-2 min-h-[280px]"
>
{entries.length}
{entries.length === 0 ? (
Přetáhněte sem řidiče, vozidlo nebo přiřazení.
) : (
entries.map((entry) => {
const conflictState = isAssignmentConflict(schedule, day, shift.id, entry, drivers, vehicles);
return (
setDragItem({ kind: "assignment", fromDay: day, fromShiftId: shift.id, assignment: entry })
}
className={`cursor-grab rounded-2xl border p-3 shadow-sm active:cursor-grabbing ${
conflictState.hasConflict
? "border-amber-300 bg-amber-50"
: "border-slate-200 bg-white"
}`}
>
↕
Jízda
updateAssignment(day, shift.id, entry.id, "note", e.target.value)}
placeholder="Nádraží, hotel, letiště"
/>
{entry.driverId ? {getDriverName(drivers, entry.driverId)} : null}
{entry.vehicleId ? {getVehicleName(vehicles, entry.vehicleId)} : null}
{conflictState.driverConflict ? Konflikt řidiče : null}
{conflictState.vehicleConflict ? Konflikt vozidla : null}
);
})
)}
{copiedAssignment ? (
) : null}
);
})}
);
})}
Vytížení řidičů
{drivers.map((driver) => (
{driver.name}
{stats.driverCounts[driver.id] || 0} přiřazení
))}
Upozornění / konflikty
{stats.conflicts.length === 0 ? (
Nebyly zjištěny žádné konflikty.
) : (
stats.conflicts.map((conflict, index) => (
{conflict}
))
)}
{driverOutputs.map((driver) => (
{driver.name}
{driver.plan.length === 0 ? (
Tento řidič zatím nemá naplánovanou žádnou směnu.
) : (
{driver.plan.map((item, index) => (
{item.day}
{item.date}
{item.shift} • {item.time}
{item.note ?
Poznámka: {item.note}
: null}
))}
)}
))}
);
}