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:
195
web/src/App.tsx
195
web/src/App.tsx
@@ -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"
|
||||
>
|
||||
← 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"
|
||||
>
|
||||
← 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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user