import { useState, useRef, useCallback, useEffect, useMemo } from 'react' import { ScheduleTable } from './ScheduleTable' import type { SelectedCell } from './ScheduleTable' import { Toolbar } from './Toolbar' import { ContextMenu } from './ContextMenu' import { ProposalModal } from './ProposalModal' import { FpdModal } from './FpdCheck' import { useScheduleState } from './useScheduleState' import { useDragCell } from './useDragCell' import { Login, isAuthenticated, getAuthRole, clearAuthentication } from './Login' import type { UserRole } from './Login' import { FileManager } from './FileManager' import fallbackData from './data.json' import type { ScheduleData, ScheduleFileWithData, ContextMenuState } from './types' const FAVOURITE_FILE_KEY = 'tkb_favourite_file' export function getFavouriteFileId(): string | null { return localStorage.getItem(FAVOURITE_FILE_KEY) } export function setFavouriteFileId(id: string | null): void { if (id) localStorage.setItem(FAVOURITE_FILE_KEY, id) else localStorage.removeItem(FAVOURITE_FILE_KEY) } // Characteristic colors for info rows — used when pasting from info row to person cell const INFO_ROW_COLORS: Record = { '__tunnel__': '#c2410c', '__metro__': '#1d4ed8', '__sazlt__': '#b45309', '__d8__': '#15803d', } 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() 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() 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() { // Apply persisted theme on initial mount (before first render of inner components) useEffect(() => { const t = localStorage.getItem('tkb-theme') if (t === 'light') document.body.classList.add('light') }, []) // Auto-reload when server restarts with a new version (new deploy) useEffect(() => { let serverVersion: string | null = null const check = async () => { try { const res = await fetch('/api/version') if (!res.ok) return const { version } = await res.json() if (serverVersion === null) { serverVersion = version; return } if (version !== serverVersion) window.location.reload() } catch {} } check() const interval = setInterval(check, 60_000) return () => clearInterval(interval) }, []) const [authed, setAuthed] = useState(isAuthenticated()) const [role, setRole] = useState(() => isAuthenticated() ? getAuthRole() : 'editor') const isViewer = role === 'viewer' const isCis = role === 'cis' const isReadOnlyRole = isViewer || isCis // cis has same UI rights as viewer const hasFavOnLoad = authed && !!getFavouriteFileId() // Viewer/cis with no favourite: show loading while we auto-fetch the active file const viewerAutoLoad = authed && isReadOnlyRole && !hasFavOnLoad const [mode, setMode] = useState(authed ? (hasFavOnLoad ? 'editor' : 'files') : 'login') const [fileId, setFileId] = useState(null) const [fileName, setFileName] = useState('') const [fileData, setFileData] = useState(null) const [fileMeta, setFileMeta] = useState<{ publishedAt: string | null; hasUnpublishedChanges: boolean }>({ publishedAt: null, hasUnpublishedChanges: false }) const [loading, setLoading] = useState(hasFavOnLoad || viewerAutoLoad) const [compareFileId, setCompareFileId] = useState(null) const autoOpenAttempted = useRef(false) // Viewers/cis only ever see the published snapshot; editors work on the draft const handleOpenFile = useCallback(async (id: string, compareId?: string) => { setLoading(true) try { const res = await fetch(`/api/files/${id}${isReadOnlyRole ? '?view=published' : ''}`) 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)) setFileMeta({ publishedAt: file.publishedAt ?? null, hasUnpublishedChanges: !!file.hasUnpublishedChanges }) setCompareFileId(compareId ?? null) setMode('editor') } catch (err) { alert(`Chyba pri otevirani souboru: ${err}`) setMode('files') } finally { setLoading(false) } }, [isReadOnlyRole]) // Viewers: pick up a new publish automatically (poll metadata, reload when publishedAt changes) useEffect(() => { if (!isReadOnlyRole || mode !== 'editor' || !fileId) return const check = async () => { try { const res = await fetch('/api/files') if (!res.ok) return const files: { id: string; publishedAt?: string | null }[] = await res.json() const me = files.find(f => f.id === fileId) if (me && (me.publishedAt ?? null) !== fileMeta.publishedAt) handleOpenFile(fileId) } catch {} } const interval = setInterval(check, 60_000) return () => clearInterval(interval) }, [isReadOnlyRole, mode, fileId, fileMeta.publishedAt, handleOpenFile]) // Auto-open favourite file when authenticated; for viewers with no favourite, auto-open most recent useEffect(() => { if (!authed || autoOpenAttempted.current) return autoOpenAttempted.current = true const fav = getFavouriteFileId() if (fav) { handleOpenFile(fav) } else if (isReadOnlyRole) { ;(async () => { try { const res = await fetch('/api/files') if (!res.ok) throw new Error('no files') const files: { id: string; name: string }[] = await res.json() if (files.length > 0) { setFavouriteFileId(files[0].id) handleOpenFile(files[0].id) } else { setLoading(false) } } catch { setLoading(false) } })() } }, [authed, isReadOnlyRole, handleOpenFile]) const handleLogin = useCallback((newRole: UserRole) => { autoOpenAttempted.current = false setRole(newRole) setAuthed(true) setMode('files') }, []) const handleLogout = useCallback(() => { clearAuthentication() setAuthed(false) setRole('editor') setMode('login') setFileId(null) setFileName('') setFileData(null) setCompareFileId(null) autoOpenAttempted.current = 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 } if (loading) { return (
Nacitam harmonogram...
) } if (mode === 'files') { if (isReadOnlyRole) { return (
Žádný aktivní harmonogram
Kontaktujte správce pro nastavení výchozího souboru
) } return } if (mode === 'editor' && fileData && fileId) { return ( setCompareFileId(null)} onLogout={handleLogout} /> ) } return } function LegacyLoader({ onBack }: { onBack: () => void }) { const [data, setData] = useState(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 (
Nacitam harmonogram...
) } return ( {}} onClearCompare={() => {}} /> ) } interface ScheduleAppProps { fileId: string | null fileName: string data: ScheduleData compareFileId: string | null onBack?: () => void onFileNameChange: (name: string) => void onClearCompare: () => void isReadOnly?: boolean canExportHarmonogram?: boolean onLogout?: () => void publishedAt?: string | null hasUnpublishedChanges?: boolean viewPublished?: boolean // data shown is the published snapshot (viewer/cis) } const AUTOSAVE_DELAY_MS = 5_000 // save this long after the last change const AUTOSAVE_MAX_WAIT_MS = 30_000 // ...but never later than this after the first unsaved change function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, canExportHarmonogram = true, onLogout, publishedAt: initialPublishedAt = null, hasUnpublishedChanges: initialUnpublished = false, viewPublished = false }: ScheduleAppProps) { const { people, tunnelClosures, tunnelColors, metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors, dayComments, cellComments, setCell, setCellColor, setCellHalfColor, setCellHalfValue, pasteCellTo, moveCell, setTunnelClosure, setTunnelClosureColor, setMetroClosure, setMetroClosureColor, setD8Closure, setD8ClosureColor, setSazltClosure, setSazltClosureColor, undo, canUndo, addDayComment, removeDayComment, addCellComment, removeCellComment, getSchedulePayload, } = useScheduleState(data.people, data.dayIndex, data.tunnelClosures, data.dayComments, data.cellComments, data.infoRows) const [contextMenu, setContextMenu] = useState(null) const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [dirty, setDirty] = useState(false) // unsaved changes exist const [lastSavedAt, setLastSavedAt] = useState(null) const [publishedAt, setPublishedAt] = useState(initialPublishedAt) const [unpublished, setUnpublished] = useState(initialUnpublished) const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle') const dirtySinceRef = useRef(null) const savingRef = useRef(false) const [selectedDenDays, setSelectedDenDays] = useState([]) const [currentFileId, setCurrentFileId] = useState(fileId) const [compareData, setCompareData] = useState(null) const [compareFileName, setCompareFileName] = useState(null) const scrollRef = useRef(null) // Track active month from left edge of scroll const currentMonth = new Date().getMonth() + 1 const [activeMonth, setActiveMonth] = useState(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([]) 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, selectedCells: SelectedCell[] = []) => { setContextMenu({ dayIdx, personId: null, x, y, isTunnelRow: true }) setSelectedCellsForMenu(selectedCells) }, []) const handleInfoRowContextMenu = useCallback((dayIdx: number, infoRowId: string, x: number, y: number, selectedCells: SelectedCell[] = []) => { setContextMenu({ dayIdx, personId: null, x, y, infoRowId }) setSelectedCellsForMenu(selectedCells) }, []) 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 (): Promise => { if (savingRef.current) return false savingRef.current = true 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') } setDirty(false) dirtySinceRef.current = null setLastSavedAt(new Date()) setSaveStatus('saved') setTimeout(() => setSaveStatus('idle'), 3000) return true } catch { setSaveStatus('error') setTimeout(() => setSaveStatus('idle'), 4000) return false } finally { savingRef.current = false } }, [getSchedulePayload, currentFileId]) // Any state change (getSchedulePayload identity changes with every edit) marks the draft dirty const firstPayloadRef = useRef(true) useEffect(() => { if (firstPayloadRef.current) { firstPayloadRef.current = false; return } if (isReadOnly) return if (dirtySinceRef.current === null) dirtySinceRef.current = Date.now() setDirty(true) setUnpublished(true) }, [getSchedulePayload, isReadOnly]) // Auto-save: debounce after the last change, with an upper bound since the first unsaved change const saveRef = useRef(handleSave) useEffect(() => { saveRef.current = handleSave }, [handleSave]) useEffect(() => { if (!dirty || isReadOnly || !currentFileId) return const since = dirtySinceRef.current ?? Date.now() const wait = Math.max(0, Math.min(AUTOSAVE_DELAY_MS, since + AUTOSAVE_MAX_WAIT_MS - Date.now())) const t = setTimeout(() => { saveRef.current() }, wait) return () => clearTimeout(t) }, [dirty, getSchedulePayload, isReadOnly, currentFileId]) // Retry a failed auto-save after a while; warn before leaving with unsaved changes useEffect(() => { if (!dirty || isReadOnly) return const retry = setInterval(() => { if (!savingRef.current) saveRef.current() }, AUTOSAVE_MAX_WAIT_MS) const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = '' } window.addEventListener('beforeunload', onBeforeUnload) return () => { clearInterval(retry); window.removeEventListener('beforeunload', onBeforeUnload) } }, [dirty, isReadOnly]) // Publish: save the current draft and make it the version viewers see const handlePublish = useCallback(async () => { if (!currentFileId) return if (!confirm('Publikovat aktuální stav plánu?\nUživatelé s náhledem (prohlížeč) uvidí tuto verzi.')) return setPublishStatus('publishing') try { const payload = getSchedulePayload() const res = await fetch(`/api/files/${currentFileId}/publish`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ data: payload }), }) if (!res.ok) throw new Error('Publish failed') const meta = await res.json() setPublishedAt(meta.publishedAt ?? new Date().toISOString()) setUnpublished(false) setDirty(false) dirtySinceRef.current = null setLastSavedAt(new Date()) setPublishStatus('published') setTimeout(() => setPublishStatus('idle'), 3000) } catch { setPublishStatus('error') setTimeout(() => setPublishStatus('idle'), 4000) } }, [currentFileId, getSchedulePayload]) 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 [showFpd, setShowFpd] = useState(false) const [showMetro, setShowMetro] = useState(true) const [showD8, setShowD8] = useState(true) const [showSazlt, setShowSazlt] = useState(true) const [showIT, setShowIT] = useState(true) const [hide162, setHide162] = useState(false) const [theme, setTheme] = useState<'dark' | 'light'>(() => (localStorage.getItem('tkb-theme') as 'dark' | 'light') || 'dark' ) useEffect(() => { document.body.classList.toggle('light', theme === 'light') localStorage.setItem('tkb-theme', theme) }, [theme]) const [hiddenTkbPeople, setHiddenTkbPeople] = useState>(new Set()) const [hiddenValues, setHiddenValues] = useState>(new Set()) // Clipboard for cell copy/paste const [clipboard, setClipboard] = useState<(import('./types').CellValue & { comment?: string }) | null>(null) const [clipboardSource, setClipboardSource] = useState<{ personId: string; dayIdx: number } | null>(null) // Info row helpers — get/set closure text + color by row id const infoRowMaps = { '__tunnel__': { closures: tunnelClosures, colors: tunnelColors, setText: setTunnelClosure, setColor: setTunnelClosureColor }, '__sazlt__': { closures: sazltClosures, colors: sazltColors, setText: setSazltClosure, setColor: setSazltClosureColor }, '__metro__': { closures: metroClosures, colors: metroColors, setText: setMetroClosure, setColor: setMetroClosureColor }, '__d8__': { closures: d8Closures, colors: d8Colors, setText: setD8Closure, setColor: setD8ClosureColor }, } as const const isInfoRowKey = (k: string): k is keyof typeof infoRowMaps => k === '__tunnel__' || k === '__sazlt__' || k === '__metro__' || k === '__d8__' const handleCopyCell = useCallback((key: string, dayIdx: number) => { if (isInfoRowKey(key)) { const m = infoRowMaps[key] const text = m.closures.get(dayIdx) const color = m.colors.get(dayIdx) setClipboard({ ...(text ? { v: text } : {}), ...(color ? { color } : {}), }) setClipboardSource({ personId: key, dayIdx }) return } const person = people.find(p => p.id === key) if (!person) return const cellData = person.data[String(dayIdx)] ?? {} const comment = cellComments.get(`${key}-${dayIdx}`) setClipboard({ ...cellData, ...(comment ? { comment } : {}) }) setClipboardSource({ personId: key, dayIdx }) }, [people, cellComments, infoRowMaps]) const handlePasteCells = useCallback((targets: { personId: string; dayIdx: number }[]) => { if (!clipboard || targets.length === 0) return for (const t of targets) { if (clipboardSource && clipboardSource.personId === t.personId && clipboardSource.dayIdx === t.dayIdx) continue // When pasting from an info row and the clipboard has no explicit color, // inject the source row's characteristic color so the destination keeps the visual identity. const extraColor = (!clipboard.color && clipboardSource && isInfoRowKey(clipboardSource.personId)) ? INFO_ROW_COLORS[clipboardSource.personId] : undefined if (isInfoRowKey(t.personId)) { const m = infoRowMaps[t.personId] m.setText(t.dayIdx, clipboard.v ?? null) m.setColor(t.dayIdx, clipboard.color ?? extraColor ?? null) } else { pasteCellTo(t.personId, t.dayIdx, { ...clipboard, ...(extraColor ? { color: extraColor } : {}), comment: clipboard.comment ?? null, }) } } }, [clipboard, clipboardSource, pasteCellTo, infoRowMaps]) const handleClearClipboard = useCallback(() => { setClipboard(null) setClipboardSource(null) }, []) // Retired people are hidden from all UI/FPD surfaces but their data stays in // the schedule file (past-month exports keep working). const visiblePeople = useMemo(() => people.filter(p => !p.retired), [people]) const tkbPeopleList = useMemo( () => visiblePeople.filter(p => p.group === 'TKB'), [visiblePeople] ) const handleToggleTkbPerson = useCallback((id: string) => { setHiddenTkbPeople(prev => { const next = new Set(prev) if (next.has(id)) next.delete(id); else next.add(id) return next }) }, []) const handleSetAllTkbVisible = useCallback(() => { setHiddenTkbPeople(new Set()) }, []) const handleSetOnlyTkbPerson = useCallback((id: string) => { setHiddenTkbPeople(new Set(tkbPeopleList.filter(p => p.id !== id).map(p => p.id))) }, [tkbPeopleList]) 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, visiblePeople, tunnelClosures, tunnelColors, { hide162, showIT, hiddenTkbPeople, hiddenValues, }) }, [data.dayIndex, visiblePeople, tunnelClosures, tunnelColors, hide162, showIT, hiddenTkbPeople, hiddenValues]) const MONTH_NAMES_FULL: Record = { 1:'Leden',2:'Únor',3:'Březen',4:'Duben',5:'Květen',6:'Červen', 7:'Červenec',8:'Srpen',9:'Září',10:'Říjen',11:'Listopad',12:'Prosinec', } const contextDayInfo = contextMenu ? contextMenu.dayIdx < 0 // PMS cell — synthetic day info ? { idx: contextMenu.dayIdx, day: 0, month: -contextMenu.dayIdx, year: 2026, week: 0, weekend: false } as import('./types').DayInfo : data.dayIndex.find(d => d.idx === contextMenu.dayIdx) ?? null : 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 const contextCellDayColor = contextCellData?.dayColor const contextCellNightColor = contextCellData?.nightColor const contextCellDayValue = contextCellData?.dayValue const contextCellNightValue = contextCellData?.nightValue // 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) : contextInfoRowId === 'sazlt' ? sazltClosures.get(contextMenu.dayIdx) : undefined : undefined const contextInfoRowColor = contextMenu ? contextInfoRowId === 'metro' ? metroColors.get(contextMenu.dayIdx) : contextInfoRowId === 'd8' ? d8Colors.get(contextMenu.dayIdx) : contextInfoRowId === 'sazlt' ? sazltColors.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) else if (contextInfoRowId === 'sazlt') setSazltClosureColor(dayIdx, color) }, [contextInfoRowId, setMetroClosureColor, setD8ClosureColor, setSazltClosureColor]) // Unified color setter — routes to the correct setter based on row type (personId) const handleSetAnyColor = useCallback((personId: string, dayIdx: number, color: string | null) => { if (personId === '__tunnel__') setTunnelClosureColor(dayIdx, color) else if (personId === '__metro__') setMetroClosureColor(dayIdx, color) else if (personId === '__d8__') setD8ClosureColor(dayIdx, color) else if (personId === '__sazlt__') setSazltClosureColor(dayIdx, color) else setCellColor(personId, dayIdx, color) }, [setTunnelClosureColor, setMetroClosureColor, setD8ClosureColor, setSazltClosureColor, setCellColor]) // Unified value setter — routes to the correct setter based on row type (personId) const handleSetAnyValue = useCallback((personId: string, dayIdx: number, value: string | null) => { if (personId === '__tunnel__') setTunnelClosure(dayIdx, value) else if (personId === '__metro__') setMetroClosure(dayIdx, value) else if (personId === '__d8__') setD8Closure(dayIdx, value) else if (personId === '__sazlt__') setSazltClosure(dayIdx, value) else setCell(personId, dayIdx, value) }, [setTunnelClosure, setMetroClosure, setD8Closure, setSazltClosure, setCell]) return (
{onBack && ( )} {isReadOnly && ( Jen pro čtení )}

{fileName || 'TKB Plán služeb'} Plán služeb a pohotovostí

{compareFileName && ( Porovnani s: {compareFileName} )}
{onLogout && ( )}
setShowMetro(v => !v)} onToggleD8={() => setShowD8(v => !v)} showSazlt={showSazlt} onToggleSazlt={() => setShowSazlt(v => !v)} showIT={showIT} onToggleIT={() => setShowIT(v => !v)} theme={theme} onToggleTheme={() => setTheme(t => t === 'dark' ? 'light' : 'dark')} hide162={hide162} onToggle162={() => setHide162(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} onExportExcel={currentFileId ? (month: number) => { const year = data.dayIndex.find(d => d.month === month)?.year ?? new Date().getFullYear() window.location.href = `/api/files/${currentFileId}/export-excel?month=${month}&year=${year}${viewPublished ? '&view=published' : ''}` } : undefined} onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)} onCheckFpd={() => setShowFpd(true)} isReadOnly={isReadOnly} tkbPeopleList={tkbPeopleList} hiddenTkbPeople={hiddenTkbPeople} onToggleTkbPerson={handleToggleTkbPerson} onSetAllTkbVisible={handleSetAllTkbVisible} onSetOnlyTkbPerson={handleSetOnlyTkbPerson} /> {} : onCellPointerDown} onSetCell={isReadOnly ? () => {} : setCell} onSetCellHalfValue={isReadOnly ? () => {} : setCellHalfValue} onSetTunnelClosure={isReadOnly ? () => {} : setTunnelClosure} onSetMetroClosure={isReadOnly ? () => {} : setMetroClosure} onSetD8Closure={isReadOnly ? () => {} : setD8Closure} onSetSazltClosure={isReadOnly ? () => {} : setSazltClosure} showMetro={showMetro} showD8={showD8} showSazlt={showSazlt} showIT={showIT} hide162={hide162} hiddenTkbPeople={hiddenTkbPeople} hiddenValues={hiddenValues} scrollRef={scrollRef} onContextMenu={isReadOnly ? () => {} : handleContextMenu} onTunnelContextMenu={isReadOnly ? () => {} : handleTunnelContextMenu} onInfoRowContextMenu={isReadOnly ? () => {} : handleInfoRowContextMenu} compareData={compareData} isReadOnly={isReadOnly} clipboardSource={clipboardSource} hasClipboard={!!clipboard} onCopyCell={isReadOnly ? () => {} : handleCopyCell} onPasteCells={isReadOnly ? () => {} : handlePasteCells} onClearClipboard={handleClearClipboard} />
{contextMenu && contextDayInfo && ( { setContextMenu(null); setSelectedDenDays([]) }} /> )} {showProposals && ( setShowProposals(false)} /> )} {showFpd && activeMonth && ( d.month === activeMonth)?.year ?? 2026} dayIndex={data.dayIndex} people={visiblePeople} onClose={() => setShowFpd(false)} onExportDochazka={currentFileId && !isReadOnly ? () => { const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026 window.location.href = `/api/files/${currentFileId}/export-dochazka?month=${activeMonth}&year=${year}${viewPublished ? '&view=published' : ''}` } : undefined} onExportHarmonogram={currentFileId && canExportHarmonogram ? () => { const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026 window.location.href = `/api/files/${currentFileId}/export-harmonogram?month=${activeMonth}&year=${year}${viewPublished ? '&view=published' : ''}` } : undefined} /> )}
) } export default App