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>
990 lines
39 KiB
TypeScript
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"
|
|
>
|
|
← 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
|