feat: add Kropáček Ondřej + May–Jul feature work (half-cell marks, copy/paste, exports, roles)

New worker: Kropáček Ondřej (TKB, part-time student) inserted between
Dvořák Václav and Vondrák Pavel in data.json, the full-month Excel
template (new row 13, everything below shifted) and the docházka
template (13 person rows). Not added to the harmonogram/CIS roster —
he has no CIS personal number.

Also checkpoints the uncommitted work deployed to production between
May and July 2026:
- half-cell day/night preference marks (color + text per half)
- cell copy/paste (Ctrl+C/V, context menu), incl. from info rows
- light/dark theme toggle, TKB people filter, 162 filter
- full-month Excel export (template) and harmonogram export for CIS
- docházka export: half-time workers, retired flag, contractor handling
- third login role "cis" (viewer + export access)
- retired flag hides a person from UI/FPD while keeping past data

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Docker Config Backup
2026-09-17 13:02:52 +02:00
parent 131b0d0df1
commit 3d306e641c
22 changed files with 2231 additions and 242 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
import { ScheduleTable } from './ScheduleTable'
import type { SelectedCell } from './ScheduleTable'
import { Toolbar } from './Toolbar'
@@ -24,6 +24,14 @@ export function setFavouriteFileId(id: string | null): void {
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
@@ -95,6 +103,12 @@ function getISOWeek(date: Date): number {
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
@@ -115,12 +129,16 @@ function App() {
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 [loading, setLoading] = useState(hasFavOnLoad)
const [loading, setLoading] = useState(hasFavOnLoad || viewerAutoLoad)
const [compareFileId, setCompareFileId] = useState<string | null>(null)
const autoOpenAttempted = useRef(false)
@@ -143,18 +161,37 @@ function App() {
}
}, [])
// Auto-open favourite file when authenticated
// 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)
}, [authed, handleOpenFile])
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(() => {
@@ -214,7 +251,7 @@ function App() {
}
if (mode === 'files') {
if (isViewer) {
if (isReadOnlyRole) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="text-center">
@@ -234,8 +271,9 @@ function App() {
fileName={fileName}
data={fileData}
compareFileId={compareFileId}
onBack={isViewer ? undefined : handleBackToFiles}
isReadOnly={isViewer}
onBack={isReadOnlyRole ? undefined : handleBackToFiles}
isReadOnly={isReadOnlyRole}
canExportHarmonogram={!isViewer}
onFileNameChange={setFileName}
onClearCompare={() => setCompareFileId(null)}
onLogout={handleLogout}
@@ -291,15 +329,16 @@ interface ScheduleAppProps {
onFileNameChange: (name: string) => void
onClearCompare: () => void
isReadOnly?: boolean
canExportHarmonogram?: boolean
onLogout?: () => void
}
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, onLogout }: ScheduleAppProps) {
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, canExportHarmonogram = true, onLogout }: ScheduleAppProps) {
const {
people, tunnelClosures, tunnelColors,
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
dayComments, cellComments,
setCell, setCellColor, moveCell, setTunnelClosure, setTunnelClosureColor,
setCell, setCellColor, setCellHalfColor, setCellHalfValue, pasteCellTo, moveCell, setTunnelClosure, setTunnelClosureColor,
setMetroClosure, setMetroClosureColor, setD8Closure, setD8ClosureColor,
setSazltClosure, setSazltClosureColor,
undo, canUndo,
@@ -393,12 +432,14 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
setSelectedCellsForMenu(selectedCells ?? [])
}, [])
const handleTunnelContextMenu = useCallback((dayIdx: number, x: number, y: number) => {
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) => {
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) => {
@@ -476,14 +517,115 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
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, people, tunnelClosures, tunnelColors)
}, [data.dayIndex, people, tunnelClosures, tunnelColors])
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',
@@ -501,6 +643,10 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
: 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
@@ -522,6 +668,24 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
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">
@@ -579,6 +743,10 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
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}
@@ -590,13 +758,22 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
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}`
} : 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={people}
people={visiblePeople}
tunnelClosures={tunnelClosures}
tunnelColors={tunnelColors}
metroClosures={metroClosures}
@@ -610,6 +787,7 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
dragState={isReadOnly ? null : dragState}
onCellPointerDown={isReadOnly ? () => {} : onCellPointerDown}
onSetCell={isReadOnly ? () => {} : setCell}
onSetCellHalfValue={isReadOnly ? () => {} : setCellHalfValue}
onSetTunnelClosure={isReadOnly ? () => {} : setTunnelClosure}
onSetMetroClosure={isReadOnly ? () => {} : setMetroClosure}
onSetD8Closure={isReadOnly ? () => {} : setD8Closure}
@@ -617,13 +795,21 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
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>
@@ -634,6 +820,10 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
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)}
@@ -649,8 +839,15 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
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([]) }}
/>
)}
@@ -664,12 +861,16 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
month={activeMonth}
year={data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026}
dayIndex={data.dayIndex}
people={people}
people={visiblePeople}
onClose={() => setShowFpd(false)}
onExportDochazka={currentFileId ? () => {
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}`
} : 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}`
} : undefined}
/>
)}
</div>