"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 ${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.

setWeekStart(e.target.value)} className="w-[170px]" />
{actionMessage ? (
{actionMessage}
) : null}
👤
Řidiči
{drivers.length}
🚕
Vozidla
{vehicles.length}
📅
Směny
21
📋
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) => (
{item.day}
{item.label}
))} {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) => (
{item.day}
{item.label}
))} {shifts.map((shift) => { const Icon = shift.icon; return (
{Icon} {shift.short}
{shift.label}
{days.map((day) => { if (!isShiftAvailableOnDay(shift, day)) { return (
Směna není aktivní
); } 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}
)) )}
Seznam řidičů {drivers.map((driver) => (
{driver.name}
Telefon: {driver.phone || "—"}
Poznámka: {driver.note || "—"}
))}
Přidat nového řidiče
setNewDriver({ ...newDriver, name: e.target.value })} placeholder="např. Jan Novák" />
setNewDriver({ ...newDriver, phone: e.target.value })} placeholder="+420 ..." />
setNewDriver({ ...newDriver, note: e.target.value })} placeholder="např. pouze denní směny" />
Seznam vozidel {vehicles.map((vehicle) => (
🚗 {vehicle.name}
SPZ: {vehicle.plate || "—"}
Poznámka: {vehicle.note || "—"}
))}
Přidat nové vozidlo
setNewVehicle({ ...newVehicle, name: e.target.value })} placeholder="např. Octavia 1" />
setNewVehicle({ ...newVehicle, plate: e.target.value })} placeholder="např. 8AJ 1629" />
setNewVehicle({ ...newVehicle, note: e.target.value })} placeholder="např. Comfort / rezerva / brzy servis" />
{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}
))}
)}
))}
); }