Files
TKB_plan/web/src/App.tsx
Docker Config Backup 09a07b510b feat: auto-save drafts + publish step for viewers
Editors' changes are now auto-saved (5 s after the last edit, at most
30 s after the first unsaved one, retried on failure) with a status
indicator; leaving the page with unsaved changes prompts a warning.

Each schedule file keeps a published snapshot next to the draft.
Viewer/cis roles load and export only the published snapshot; the
editor's draft becomes visible to them only after "Publikovat".
Toolbar shows the publish state ("Nepublikované změny" / "Publikováno
HH:MM"), the file manager shows the publish date, and viewers pick up a
new publish automatically within a minute.

Server: GET /api/files/:id?view=published, POST /api/files/:id/publish,
?view=published on all exports, one-time migration that seeds the
published snapshot from the current data on startup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 13:12:49 +02:00

990 lines
39 KiB
TypeScript

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<string, string> = {
'__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<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() {
// 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<UserRole>(() => 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<AppMode>(authed ? (hasFavOnLoad ? 'editor' : 'files') : 'login')
const [fileId, setFileId] = useState<string | null>(null)
const [fileName, setFileName] = useState<string>('')
const [fileData, setFileData] = useState<ScheduleData | null>(null)
const [fileMeta, setFileMeta] = useState<{ publishedAt: string | null; hasUnpublishedChanges: boolean }>({ publishedAt: null, hasUnpublishedChanges: false })
const [loading, setLoading] = useState(hasFavOnLoad || viewerAutoLoad)
const [compareFileId, setCompareFileId] = useState<string | null>(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 <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') {
if (isReadOnlyRole) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="text-center">
<div className="text-slate-400 text-sm mb-2">Žádný aktivní harmonogram</div>
<div className="text-slate-600 text-xs">Kontaktujte správce pro nastavení výchozího souboru</div>
</div>
</div>
)
}
return <FileManager onOpenFile={handleOpenFile} onCompare={handleCompare} onCreateNew={handleCreateNew} onLogout={handleLogout} />
}
if (mode === 'editor' && fileData && fileId) {
return (
<ScheduleApp
// Remount viewers on a new publish so the grid shows the fresh snapshot
key={isReadOnlyRole ? `${fileId}:${fileMeta.publishedAt ?? ''}` : fileId}
fileId={fileId}
publishedAt={fileMeta.publishedAt}
hasUnpublishedChanges={fileMeta.hasUnpublishedChanges}
viewPublished={isReadOnlyRole}
fileName={fileName}
data={fileData}
compareFileId={compareFileId}
onBack={isReadOnlyRole ? undefined : handleBackToFiles}
isReadOnly={isReadOnlyRole}
canExportHarmonogram={!isViewer}
onFileNameChange={setFileName}
onClearCompare={() => setCompareFileId(null)}
onLogout={handleLogout}
/>
)
}
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
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<ContextMenuState | null>(null)
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [dirty, setDirty] = useState(false) // unsaved changes exist
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
const [publishedAt, setPublishedAt] = useState<string | null>(initialPublishedAt)
const [unpublished, setUnpublished] = useState(initialUnpublished)
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle')
const dirtySinceRef = useRef<number | null>(null)
const savingRef = useRef(false)
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, 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<boolean> => {
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<Set<string>>(new Set())
const [hiddenValues, setHiddenValues] = useState<Set<string>>(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<number, string> = {
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 (
<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 justify-between gap-4">
<div className="flex items-center gap-4">
{onBack && (
<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; Zpět
</button>
)}
{isReadOnly && (
<span className="px-2 py-1 rounded text-xs bg-slate-700/60 text-slate-400 border border-slate-600">
Jen pro čtení
</span>
)}
<h1 className="text-xl font-semibold text-slate-100 tracking-tight">
{fileName || 'TKB Plán služeb'}
<span className="ml-3 text-sm font-normal text-slate-400">
Plán služeb a pohotovostí
</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>
{onLogout && (
<button
onClick={onLogout}
className="px-3 py-1.5 rounded text-xs bg-slate-800 text-slate-400 border border-slate-700
hover:bg-slate-700 hover:text-slate-200 cursor-pointer transition-colors"
>
Odhlásit
</button>
)}
</div>
</header>
<main className="p-4">
<Toolbar
onUndo={undo}
canUndo={canUndo}
onSave={handleSave}
onSaveAs={handleSaveAs}
saveStatus={saveStatus}
dirty={dirty}
lastSavedAt={lastSavedAt}
onPublish={currentFileId && !isReadOnly ? handlePublish : undefined}
publishStatus={publishStatus}
publishedAt={publishedAt}
hasUnpublished={unpublished}
onScrollToMonth={handleScrollToMonth}
activeMonth={activeMonth}
showMetro={showMetro}
showD8={showD8}
onToggleMetro={() => 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}
/>
<ScheduleTable
dayIndex={data.dayIndex}
people={visiblePeople}
tunnelClosures={tunnelClosures}
tunnelColors={tunnelColors}
metroClosures={metroClosures}
metroColors={metroColors}
d8Closures={d8Closures}
d8Colors={d8Colors}
sazltClosures={sazltClosures}
sazltColors={sazltColors}
dayComments={dayComments}
cellComments={cellComments}
dragState={isReadOnly ? null : dragState}
onCellPointerDown={isReadOnly ? () => {} : 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}
/>
</main>
{contextMenu && contextDayInfo && (
<ContextMenu
state={contextMenu}
dayInfo={contextDayInfo}
personName={contextPerson?.name || null}
cellValue={contextCellValue}
cellColor={contextCellColor}
cellDayColor={contextCellDayColor}
cellNightColor={contextCellNightColor}
cellDayValue={contextCellDayValue}
cellNightValue={contextCellNightValue}
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}
onSetCellHalfColor={setCellHalfColor}
onSetCellHalfValue={setCellHalfValue}
onSetTunnelClosureColor={setTunnelClosureColor}
onSetInfoRowColor={handleInfoRowSetColor}
onSetAnyColor={handleSetAnyColor}
onSetAnyValue={handleSetAnyValue}
hasClipboard={!!clipboard}
onCopyCell={handleCopyCell}
onPasteCells={handlePasteCells}
onClose={() => { setContextMenu(null); setSelectedDenDays([]) }}
/>
)}
{showProposals && (
<ProposalModal onClose={() => setShowProposals(false)} />
)}
{showFpd && activeMonth && (
<FpdModal
month={activeMonth}
year={data.dayIndex.find(d => 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}
/>
)}
</div>
)
}
export default App