feat: TKB shift scheduler — personnel shift planning web app

Full rewrite of METRO HMG for TKB tunnel department:
- People-based grid (18 TKB + 5 IT), year-long calendar
- Color-coded shift values (4/6/8/12/A/B/D/N/U/O)
- Drag-and-drop cells, multi-cell selection (click/ctrl/shift/drag)
- Right-click context menu with color palette
- Tunnel closure + Metro + D8 info rows (toggleable)
- Czech holidays highlighted with names
- PDF export (2-page A4 landscape, DejaVu font for Czech chars)
- Improvement proposals system
- Sticky headers (vertical + horizontal scroll)
- Cell value filter toggles in legend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Docker Config Backup
2026-04-02 09:48:38 +02:00
commit b4158d687f
47 changed files with 14185 additions and 0 deletions

554
web/src/App.tsx Normal file
View File

@@ -0,0 +1,554 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { ScheduleTable } from './ScheduleTable'
import type { SelectedCell } from './ScheduleTable'
import { Toolbar } from './Toolbar'
import { ContextMenu } from './ContextMenu'
import { ProposalModal } from './ProposalModal'
import { useScheduleState } from './useScheduleState'
import { useDragCell } from './useDragCell'
import { Login, isAuthenticated } from './Login'
import { FileManager } from './FileManager'
import fallbackData from './data.json'
import type { ScheduleData, ScheduleFileWithData, ContextMenuState } from './types'
function normalizeDayIndex(data: ScheduleData): ScheduleData {
const targetStart = new Date(2026, 0, 1) // January 1
const targetEnd = new Date(2026, 11, 31) // December 31
const IDX_OFFSET = 0
const existingByDate = new Map<string, typeof data.dayIndex[0]>()
for (const d of data.dayIndex) {
existingByDate.set(`${d.year}-${d.month}-${d.day}`, d)
}
const newDayIndex: typeof data.dayIndex = []
const current = new Date(targetStart)
let idx = IDX_OFFSET
while (current <= targetEnd) {
const day = current.getDate()
const month = current.getMonth() + 1
const year = current.getFullYear()
const key = `${year}-${month}-${day}`
const existing = existingByDate.get(key)
newDayIndex.push(existing ?? {
idx,
day,
month,
year,
week: getISOWeek(current),
weekend: current.getDay() === 0 || current.getDay() === 6,
})
idx++
current.setDate(current.getDate() + 1)
}
const idxRemap = new Map<number, number>()
for (const d of data.dayIndex) {
const key = `${d.year}-${d.month}-${d.day}`
const newEntry = newDayIndex.find(n => `${n.year}-${n.month}-${n.day}` === key)
if (newEntry && newEntry.idx !== d.idx) {
idxRemap.set(d.idx, newEntry.idx)
}
}
let people = data.people
if (idxRemap.size > 0) {
people = JSON.parse(JSON.stringify(data.people))
for (const person of people) {
const newData: typeof person.data = {}
for (const [k, v] of Object.entries(person.data)) {
const oldIdx = Number(k)
const newIdx = idxRemap.get(oldIdx) ?? oldIdx
newData[String(newIdx)] = v
}
person.data = newData
}
}
return { ...data, dayIndex: newDayIndex, people }
}
function getISOWeek(date: Date): number {
const d = new Date(date.getTime())
d.setHours(0, 0, 0, 0)
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7))
const week1 = new Date(d.getFullYear(), 0, 4)
return 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
}
type AppMode = 'login' | 'files' | 'editor'
function App() {
const [authed, setAuthed] = useState(isAuthenticated())
const [mode, setMode] = useState<AppMode>(authed ? 'files' : 'login')
const [fileId, setFileId] = useState<string | null>(null)
const [fileName, setFileName] = useState<string>('')
const [fileData, setFileData] = useState<ScheduleData | null>(null)
const [loading, setLoading] = useState(false)
const [compareFileId, setCompareFileId] = useState<string | null>(null)
useEffect(() => {
if (authed && mode === 'login') setMode('files')
}, [authed, mode])
const handleLogin = useCallback(() => {
setAuthed(true)
setMode('files')
}, [])
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
setLoading(true)
try {
const res = await fetch(`/api/files/${id}`)
if (!res.ok) throw new Error('Failed to load file')
const file: ScheduleFileWithData = await res.json()
setFileId(file.id)
setFileName(file.name)
setFileData(normalizeDayIndex(file.data))
setCompareFileId(compareId ?? null)
setMode('editor')
} catch (err) {
alert(`Chyba pri otevirani souboru: ${err}`)
} finally {
setLoading(false)
}
}, [])
const handleCompare = useCallback((id1: string, id2: string) => {
handleOpenFile(id1, id2)
}, [handleOpenFile])
const handleBackToFiles = useCallback(() => {
setMode('files')
setFileId(null)
setFileName('')
setFileData(null)
setCompareFileId(null)
}, [])
const handleCreateNew = useCallback(async () => {
setLoading(true)
try {
const name = prompt('Nazev noveho souboru:', `Plan ${new Date().getFullYear()}`)
if (!name) { setLoading(false); return }
const data = fallbackData as unknown as ScheduleData
const res = await fetch('/api/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, data }),
})
if (!res.ok) throw new Error('Failed to create file')
const result = await res.json()
handleOpenFile(result.id)
} catch (err) {
alert(`Chyba pri vytvareni souboru: ${err}`)
setLoading(false)
}
}, [handleOpenFile])
if (!authed) {
return <Login onLogin={handleLogin} />
}
if (loading) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="text-slate-400 text-sm">Nacitam harmonogram...</div>
</div>
)
}
if (mode === 'files') {
return <FileManager onOpenFile={handleOpenFile} onCompare={handleCompare} onCreateNew={handleCreateNew} />
}
if (mode === 'editor' && fileData && fileId) {
return (
<ScheduleApp
fileId={fileId}
fileName={fileName}
data={fileData}
compareFileId={compareFileId}
onBack={handleBackToFiles}
onFileNameChange={setFileName}
onClearCompare={() => setCompareFileId(null)}
/>
)
}
return <LegacyLoader onBack={handleBackToFiles} />
}
function LegacyLoader({ onBack }: { onBack: () => void }) {
const [data, setData] = useState<ScheduleData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/schedule')
.then(r => {
if (!r.ok) throw new Error('Failed to load')
return r.json()
})
.then(d => setData(d as ScheduleData))
.catch(() => setData(fallbackData as unknown as ScheduleData))
.finally(() => setLoading(false))
}, [])
if (loading || !data) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="text-slate-400 text-sm">Nacitam harmonogram...</div>
</div>
)
}
return (
<ScheduleApp
fileId={null}
fileName="Bez nazvu"
data={data}
compareFileId={null}
onBack={onBack}
onFileNameChange={() => {}}
onClearCompare={() => {}}
/>
)
}
interface ScheduleAppProps {
fileId: string | null
fileName: string
data: ScheduleData
compareFileId: string | null
onBack: () => void
onFileNameChange: (name: string) => void
onClearCompare: () => void
}
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare }: ScheduleAppProps) {
const {
people, tunnelClosures, tunnelColors,
metroClosures, metroColors, d8Closures, d8Colors,
dayComments, cellComments,
setCell, setCellColor, moveCell, setTunnelClosure, setTunnelClosureColor,
setMetroClosure, setMetroClosureColor, setD8Closure, setD8ClosureColor,
undo, canUndo,
addDayComment, removeDayComment,
addCellComment, removeCellComment,
getSchedulePayload,
} = useScheduleState(data.people, data.dayIndex, data.tunnelClosures, data.dayComments, data.cellComments, data.infoRows)
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [selectedDenDays, setSelectedDenDays] = useState<number[]>([])
const [currentFileId, setCurrentFileId] = useState<string | null>(fileId)
const [compareData, setCompareData] = useState<ScheduleData | null>(null)
const [compareFileName, setCompareFileName] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// Track active month from left edge of scroll
const currentMonth = new Date().getMonth() + 1
const [activeMonth, setActiveMonth] = useState<number>(currentMonth)
// Scroll to current month on initial load
useEffect(() => {
if (!scrollRef.current) return
const idx = data.dayIndex.findIndex(d => d.month === currentMonth)
if (idx >= 0) {
scrollRef.current.scrollTo({ left: idx * 32 })
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const el = scrollRef.current
if (!el) return
const handleScroll = () => {
const dayIdx = Math.floor(el.scrollLeft / 32)
const clamped = Math.max(0, Math.min(dayIdx, data.dayIndex.length - 1))
const month = data.dayIndex[clamped]?.month
if (month) setActiveMonth(month)
}
el.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => el.removeEventListener('scroll', handleScroll)
}, [data.dayIndex])
const { dragState, onCellPointerDown } = useDragCell({
scrollRef,
cellWidth: 32,
minDayIdx: data.dayIndex[0].idx,
maxDayIdx: data.dayIndex[data.dayIndex.length - 1].idx,
onMoveCell: moveCell,
})
// Fetch full comparison file when compareFileId is set
useEffect(() => {
if (!compareFileId || !fileId) {
setCompareData(null)
setCompareFileName(null)
return
}
let cancelled = false
;(async () => {
try {
const fileRes = await fetch(`/api/files/${compareFileId}`)
if (cancelled) return
if (fileRes.ok) {
const fileData: ScheduleFileWithData = await fileRes.json()
setCompareData(fileData.data)
setCompareFileName(fileData.name)
}
} catch {
if (!cancelled) {
setCompareData(null)
setCompareFileName(null)
}
}
})()
return () => { cancelled = true }
}, [compareFileId, fileId])
const handleScrollToMonth = useCallback((month: number) => {
if (!scrollRef.current) return
const idx = data.dayIndex.findIndex(d => d.month === month)
if (idx >= 0) {
scrollRef.current.scrollTo({ left: idx * 32, behavior: 'smooth' })
}
}, [data.dayIndex])
const [selectedCellsForMenu, setSelectedCellsForMenu] = useState<SelectedCell[]>([])
const handleContextMenu = useCallback((dayIdx: number, personId: string | null, x: number, y: number, selectedCells: SelectedCell[] = []) => {
setContextMenu({ dayIdx, personId, x, y })
setSelectedCellsForMenu(selectedCells ?? [])
}, [])
const handleTunnelContextMenu = useCallback((dayIdx: number, x: number, y: number) => {
setContextMenu({ dayIdx, personId: null, x, y, isTunnelRow: true })
}, [])
const handleInfoRowContextMenu = useCallback((dayIdx: number, infoRowId: string, x: number, y: number) => {
setContextMenu({ dayIdx, personId: null, x, y, infoRowId })
}, [])
const handleDenDaySelect = useCallback((dayIdx: number, shiftKey: boolean) => {
setSelectedDenDays(prev => {
if (shiftKey && prev.length > 0) {
const last = prev[prev.length - 1]
const min = Math.min(last, dayIdx)
const max = Math.max(last, dayIdx)
const range: number[] = []
for (const d of data.dayIndex) {
if (d.idx >= min && d.idx <= max) range.push(d.idx)
}
const merged = new Set([...prev, ...range])
return Array.from(merged).sort((a, b) => a - b)
}
if (prev.includes(dayIdx)) {
return prev.filter(d => d !== dayIdx)
}
return [dayIdx]
})
}, [data.dayIndex])
const handleSave = useCallback(async () => {
setSaveStatus('saving')
try {
const payload = getSchedulePayload()
if (currentFileId) {
const res = await fetch(`/api/files/${currentFileId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: payload }),
})
if (!res.ok) throw new Error('Save failed')
} else {
const res = await fetch('/api/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error('Save failed')
}
setSaveStatus('saved')
setTimeout(() => setSaveStatus('idle'), 3000)
} catch {
setSaveStatus('error')
setTimeout(() => setSaveStatus('idle'), 4000)
}
}, [getSchedulePayload, currentFileId])
const handleSaveAs = useCallback(async () => {
const name = prompt('Nazev noveho souboru:', fileName ? `${fileName} - kopie` : '')
if (!name) return
setSaveStatus('saving')
try {
const payload = getSchedulePayload()
const res = await fetch('/api/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, data: payload }),
})
if (!res.ok) throw new Error('Save as failed')
const result = await res.json()
setCurrentFileId(result.id)
onFileNameChange(result.name)
setSaveStatus('saved')
setTimeout(() => setSaveStatus('idle'), 3000)
} catch {
setSaveStatus('error')
setTimeout(() => setSaveStatus('idle'), 4000)
}
}, [getSchedulePayload, fileName, onFileNameChange])
const [showProposals, setShowProposals] = useState(false)
const [showMetro, setShowMetro] = useState(true)
const [showD8, setShowD8] = useState(true)
const [hiddenValues, setHiddenValues] = useState<Set<string>>(new Set())
const handleExportPdf = useCallback(async (month: number) => {
const { exportMonthPdf } = await import('./PdfExport')
const year = data.dayIndex.find(d => d.month === month)?.year ?? new Date().getFullYear()
await exportMonthPdf(month, year, data.dayIndex, people, tunnelClosures, tunnelColors)
}, [data.dayIndex, people, tunnelClosures, tunnelColors])
const contextDayInfo = contextMenu ? data.dayIndex.find(d => d.idx === contextMenu.dayIdx) : null
const contextPerson = contextMenu?.personId ? people.find(p => p.id === contextMenu.personId) : null
const contextCellData = contextMenu?.personId
? people.find(p => p.id === contextMenu.personId)?.data[String(contextMenu.dayIdx)]
: undefined
const contextCellValue = contextCellData?.v
const contextCellColor = contextCellData?.color
// Determine which info row closures/colors/handlers to pass to context menu
const contextInfoRowId = contextMenu?.infoRowId
const contextInfoRowClosure = contextMenu
? contextInfoRowId === 'metro' ? metroClosures.get(contextMenu.dayIdx)
: contextInfoRowId === 'd8' ? d8Closures.get(contextMenu.dayIdx)
: undefined
: undefined
const contextInfoRowColor = contextMenu
? contextInfoRowId === 'metro' ? metroColors.get(contextMenu.dayIdx)
: contextInfoRowId === 'd8' ? d8Colors.get(contextMenu.dayIdx)
: undefined
: undefined
const handleInfoRowSetColor = useCallback((dayIdx: number, color: string | null) => {
if (contextInfoRowId === 'metro') setMetroClosureColor(dayIdx, color)
else if (contextInfoRowId === 'd8') setD8ClosureColor(dayIdx, color)
}, [contextInfoRowId, setMetroClosureColor, setD8ClosureColor])
return (
<div className="min-h-screen bg-slate-900">
<header className="border-b border-slate-700 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-50 px-6 py-4">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="px-3 py-1.5 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
hover:bg-slate-700 cursor-pointer transition-colors"
>
&larr; Zpet
</button>
<h1 className="text-xl font-semibold text-slate-100 tracking-tight">
{fileName || 'TKB Plan sluzeb'}
<span className="ml-3 text-sm font-normal text-slate-400">
Plan sluzeb a pohotovosti
</span>
</h1>
{compareFileName && (
<span className="px-2 py-1 rounded text-xs bg-blue-900/50 text-blue-300 border border-blue-800">
Porovnani s: {compareFileName}
</span>
)}
</div>
</header>
<main className="p-4">
<Toolbar
onUndo={undo}
canUndo={canUndo}
onSave={handleSave}
onSaveAs={handleSaveAs}
saveStatus={saveStatus}
onScrollToMonth={handleScrollToMonth}
activeMonth={activeMonth}
showMetro={showMetro}
showD8={showD8}
onToggleMetro={() => setShowMetro(v => !v)}
onToggleD8={() => setShowD8(v => !v)}
hiddenValues={hiddenValues}
onToggleValue={(code: string) => setHiddenValues(prev => {
const next = new Set(prev)
if (next.has(code)) next.delete(code); else next.add(code)
return next
})}
diffFileName={compareFileName}
onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }}
onExportPdf={handleExportPdf}
onShowProposals={() => setShowProposals(true)}
/>
<ScheduleTable
dayIndex={data.dayIndex}
people={people}
tunnelClosures={tunnelClosures}
tunnelColors={tunnelColors}
metroClosures={metroClosures}
metroColors={metroColors}
d8Closures={d8Closures}
d8Colors={d8Colors}
dayComments={dayComments}
cellComments={cellComments}
dragState={dragState}
onCellPointerDown={onCellPointerDown}
onSetCell={setCell}
onSetTunnelClosure={setTunnelClosure}
onSetMetroClosure={setMetroClosure}
onSetD8Closure={setD8Closure}
showMetro={showMetro}
showD8={showD8}
hiddenValues={hiddenValues}
scrollRef={scrollRef}
onContextMenu={handleContextMenu}
onTunnelContextMenu={handleTunnelContextMenu}
onInfoRowContextMenu={handleInfoRowContextMenu}
compareData={compareData}
/>
</main>
{contextMenu && contextDayInfo && (
<ContextMenu
state={contextMenu}
dayInfo={contextDayInfo}
personName={contextPerson?.name || null}
cellValue={contextCellValue}
cellColor={contextCellColor}
existingDayComment={dayComments.get(contextMenu.dayIdx)}
existingCellComment={contextMenu.personId ? cellComments.get(`${contextMenu.personId}-${contextMenu.dayIdx}`) : undefined}
existingTunnelClosure={tunnelClosures.get(contextMenu.dayIdx)}
existingTunnelColor={tunnelColors.get(contextMenu.dayIdx)}
existingInfoRowClosure={contextInfoRowClosure}
existingInfoRowColor={contextInfoRowColor}
selectedDays={selectedDenDays}
selectedCells={selectedCellsForMenu}
onSetCell={setCell}
onAddDayComment={addDayComment}
onRemoveDayComment={removeDayComment}
onAddCellComment={addCellComment}
onRemoveCellComment={removeCellComment}
onSetTunnelClosure={setTunnelClosure}
onSetCellColor={setCellColor}
onSetTunnelClosureColor={setTunnelClosureColor}
onSetInfoRowColor={handleInfoRowSetColor}
onClose={() => { setContextMenu(null); setSelectedDenDays([]) }}
/>
)}
{showProposals && (
<ProposalModal onClose={() => setShowProposals(false)} />
)}
</div>
)
}
export default App