feat: dochazka Excel export + auto-reload + 162 filter

- Add dochazka Excel export using direct ZIP/XML manipulation of template
  (preserves styles.xml byte-for-byte to avoid Excel "repaired styles" warning)
- Calculate per-person stravné doplatek, transport (AUV), and indiv1
  (closure count + Janouš internet) per sichtovnice.py logic
- Filter exported people to TEMPLATE_NAMES (12 fixed template rows)
- Add server version polling + auto-reload on deploy
- Add FPD check modal for monthly hour validation
- Add "162" filter button to hide first 5 TKB people from view

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Docker Config Backup
2026-04-13 14:39:04 +02:00
parent 3cd05ce0e2
commit db56403f7c
13 changed files with 1079 additions and 155 deletions

View File

@@ -4,13 +4,26 @@ 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 } from './Login'
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)
}
function normalizeDayIndex(data: ScheduleData): ScheduleData {
const targetStart = new Date(2026, 0, 1) // January 1
const targetEnd = new Date(2026, 11, 31) // December 31
@@ -82,22 +95,34 @@ function getISOWeek(date: Date): number {
type AppMode = 'login' | 'files' | 'editor'
function App() {
// 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 [mode, setMode] = useState<AppMode>(authed ? 'files' : 'login')
const [role, setRole] = useState<UserRole>(() => isAuthenticated() ? getAuthRole() : 'editor')
const isViewer = role === 'viewer'
const hasFavOnLoad = authed && !!getFavouriteFileId()
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 [loading, setLoading] = useState(false)
const [loading, setLoading] = useState(hasFavOnLoad)
const [compareFileId, setCompareFileId] = useState<string | null>(null)
useEffect(() => {
if (authed && mode === 'login') setMode('files')
}, [authed, mode])
const handleLogin = useCallback(() => {
setAuthed(true)
setMode('files')
}, [])
const autoOpenAttempted = useRef(false)
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
setLoading(true)
@@ -112,11 +137,38 @@ function App() {
setMode('editor')
} catch (err) {
alert(`Chyba pri otevirani souboru: ${err}`)
setMode('files')
} finally {
setLoading(false)
}
}, [])
// Auto-open favourite file when authenticated
useEffect(() => {
if (!authed || autoOpenAttempted.current) return
autoOpenAttempted.current = true
const fav = getFavouriteFileId()
if (fav) handleOpenFile(fav)
}, [authed, handleOpenFile])
const handleLogin = useCallback((newRole: UserRole) => {
autoOpenAttempted.current = false
setRole(newRole)
setAuthed(true)
}, [])
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])
@@ -162,7 +214,17 @@ function App() {
}
if (mode === 'files') {
return <FileManager onOpenFile={handleOpenFile} onCompare={handleCompare} onCreateNew={handleCreateNew} />
if (isViewer) {
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) {
@@ -172,9 +234,11 @@ function App() {
fileName={fileName}
data={fileData}
compareFileId={compareFileId}
onBack={handleBackToFiles}
onBack={isViewer ? undefined : handleBackToFiles}
isReadOnly={isViewer}
onFileNameChange={setFileName}
onClearCompare={() => setCompareFileId(null)}
onLogout={handleLogout}
/>
)
}
@@ -223,12 +287,14 @@ interface ScheduleAppProps {
fileName: string
data: ScheduleData
compareFileId: string | null
onBack: () => void
onBack?: () => void
onFileNameChange: (name: string) => void
onClearCompare: () => void
isReadOnly?: boolean
onLogout?: () => void
}
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare }: ScheduleAppProps) {
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, onLogout }: ScheduleAppProps) {
const {
people, tunnelClosures, tunnelColors,
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
@@ -406,9 +472,11 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
}, [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 [hide162, setHide162] = useState(false)
const [hiddenValues, setHiddenValues] = useState<Set<string>>(new Set())
const handleExportPdf = useCallback(async (month: number) => {
@@ -457,24 +525,42 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
return (
<div className="min-h-screen bg-slate-900">
<header className="border-b border-slate-700 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-50 px-6 py-4">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="px-3 py-1.5 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
hover:bg-slate-700 cursor-pointer transition-colors"
>
&larr; Zpet
</button>
<h1 className="text-xl font-semibold text-slate-100 tracking-tight">
{fileName || 'TKB Plan sluzeb'}
<span className="ml-3 text-sm font-normal text-slate-400">
Plan sluzeb a pohotovosti
</span>
</h1>
{compareFileName && (
<span className="px-2 py-1 rounded text-xs bg-blue-900/50 text-blue-300 border border-blue-800">
Porovnani s: {compareFileName}
</span>
<div 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>
@@ -493,6 +579,8 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
onToggleD8={() => setShowD8(v => !v)}
showSazlt={showSazlt}
onToggleSazlt={() => setShowSazlt(v => !v)}
hide162={hide162}
onToggle162={() => setHide162(v => !v)}
hiddenValues={hiddenValues}
onToggleValue={(code: string) => setHiddenValues(prev => {
const next = new Set(prev)
@@ -502,7 +590,9 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
diffFileName={compareFileName}
onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }}
onExportPdf={handleExportPdf}
onShowProposals={() => setShowProposals(true)}
onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)}
onCheckFpd={() => setShowFpd(true)}
isReadOnly={isReadOnly}
/>
<ScheduleTable
dayIndex={data.dayIndex}
@@ -517,21 +607,22 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
sazltColors={sazltColors}
dayComments={dayComments}
cellComments={cellComments}
dragState={dragState}
onCellPointerDown={onCellPointerDown}
onSetCell={setCell}
onSetTunnelClosure={setTunnelClosure}
onSetMetroClosure={setMetroClosure}
onSetD8Closure={setD8Closure}
onSetSazltClosure={setSazltClosure}
dragState={isReadOnly ? null : dragState}
onCellPointerDown={isReadOnly ? () => {} : onCellPointerDown}
onSetCell={isReadOnly ? () => {} : setCell}
onSetTunnelClosure={isReadOnly ? () => {} : setTunnelClosure}
onSetMetroClosure={isReadOnly ? () => {} : setMetroClosure}
onSetD8Closure={isReadOnly ? () => {} : setD8Closure}
onSetSazltClosure={isReadOnly ? () => {} : setSazltClosure}
showMetro={showMetro}
showD8={showD8}
showSazlt={showSazlt}
hide162={hide162}
hiddenValues={hiddenValues}
scrollRef={scrollRef}
onContextMenu={handleContextMenu}
onTunnelContextMenu={handleTunnelContextMenu}
onInfoRowContextMenu={handleInfoRowContextMenu}
onContextMenu={isReadOnly ? () => {} : handleContextMenu}
onTunnelContextMenu={isReadOnly ? () => {} : handleTunnelContextMenu}
onInfoRowContextMenu={isReadOnly ? () => {} : handleInfoRowContextMenu}
compareData={compareData}
/>
</main>
@@ -567,6 +658,20 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
{showProposals && (
<ProposalModal onClose={() => setShowProposals(false)} />
)}
{showFpd && activeMonth && (
<FpdModal
month={activeMonth}
year={data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026}
dayIndex={data.dayIndex}
people={people}
onClose={() => setShowFpd(false)}
onExportDochazka={currentFileId ? () => {
const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026
window.location.href = `/api/files/${currentFileId}/export-dochazka?month=${activeMonth}&year=${year}`
} : undefined}
/>
)}
</div>
)
}