Files
TKB_plan/web/src/ScheduleTable.tsx
Docker Config Backup db56403f7c 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>
2026-04-13 14:39:04 +02:00

1161 lines
46 KiB
TypeScript

import React, { useMemo, useState, useCallback, useRef, memo, useEffect } from 'react'
import type { DayInfo, Person, DragState, ScheduleData } from './types'
import { getCellStyle, getCellStyleForPerson, getContrastColor } from './cellColors'
import { getHolidayMap } from './holidays'
const CELL_W = 32
const CELL_H = 32
const MONTH_NAMES: 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 DAY_NAMES = ['Ne', 'Po', 'Út', 'St', 'Čt', 'Pá', 'So']
const MONTH_NAMES_SHORT: Record<number, string> = {
1: 'Led', 2: 'Úno', 3: 'Bře', 4: 'Dub', 5: 'Kvě', 6: 'Čvn',
7: 'Čvc', 8: 'Srp', 9: 'Zář', 10: 'Říj', 11: 'Lis', 12: 'Pro',
}
export interface SelectedCell {
personId: string
dayIdx: number
}
interface ScheduleTableProps {
dayIndex: DayInfo[]
people: Person[]
tunnelClosures: Map<number, string>
tunnelColors: Map<number, string>
metroClosures: Map<number, string>
metroColors: Map<number, string>
d8Closures: Map<number, string>
d8Colors: Map<number, string>
sazltClosures: Map<number, string>
sazltColors: Map<number, string>
dayComments: Map<number, string>
cellComments: Map<string, string>
dragState: DragState | null
onCellPointerDown: (e: React.PointerEvent, personId: string, dayIdx: number, value: string) => void
onSetCell: (personId: string, dayIdx: number, value: string | null) => void
onSetTunnelClosure: (dayIdx: number, text: string | null) => void
onSetMetroClosure: (dayIdx: number, text: string | null) => void
onSetD8Closure: (dayIdx: number, text: string | null) => void
onSetSazltClosure: (dayIdx: number, text: string | null) => void
scrollRef: React.RefObject<HTMLDivElement | null>
showMetro: boolean
showD8: boolean
showSazlt: boolean
hide162: boolean
hiddenValues: Set<string>
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number, selectedCells: SelectedCell[]) => void
onTunnelContextMenu: (dayIdx: number, x: number, y: number) => void
onInfoRowContextMenu: (dayIdx: number, infoRowId: string, x: number, y: number) => void
compareData?: ScheduleData | null
}
interface EditingCell {
personId: string | '__tunnel__' | '__metro__' | '__d8__' | '__sazlt__'
dayIdx: number
value: string
}
// ---------- PersonRow (memoized) ----------
const PersonRow = memo(function PersonRow({
person,
dayIndex,
dragState,
cellComments,
dayComments,
holidays,
hiddenValues,
onCellPointerDown,
onContextMenu,
editingCell,
onStartEdit,
onEditChange,
onEditConfirm,
onEditCancel,
selectedCells,
onCellClick,
onCellDragSelectStart,
onCellDragSelectMove,
pmsAfterSet,
pmsMonthAtPos,
}: {
person: Person
dayIndex: DayInfo[]
dragState: DragState | null
cellComments: Map<string, string>
dayComments: Map<number, string>
holidays: Map<string, string>
hiddenValues: Set<string>
onCellPointerDown: (e: React.PointerEvent, personId: string, dayIdx: number, value: string) => void
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number) => void
editingCell: EditingCell | null
onStartEdit: (personId: string, dayIdx: number, currentValue: string) => void
onEditChange: (value: string) => void
onEditConfirm: () => void
onEditCancel: () => void
selectedCells: Set<string>
onCellClick: (personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => void
onCellDragSelectStart: (personId: string, dayIdx: number) => void
onCellDragSelectMove: (personId: string, dayIdx: number) => void
pmsAfterSet: Set<number>
pmsMonthAtPos: Map<number, number>
}) {
const isDragging = dragState?.personId === person.id
return (
<div className="flex border-b border-slate-700/50" style={{ height: CELL_H }}>
{dayIndex.map((d, i) => {
const cellData = person.data[String(d.idx)]
const value = cellData?.v ?? ''
const isColorOnly = !value && !!cellData?.color
const isValueHidden = !!(value && hiddenValues.has(value)) || (isColorOnly && hiddenValues.has('__color_only__'))
const style = getCellStyleForPerson(value || undefined, person.note)
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
const isWeekend = d.weekend || isHoliday
const commentKey = `${person.id}-${d.idx}`
const hasCellComment = cellComments.has(commentKey)
const isMonthStart = d.day === 1
const isDragOriginal = isDragging && dragState!.originalIdx === d.idx
const isDragPreview = isDragging && dragState!.previewIdx === d.idx && dragState!.previewIdx !== dragState!.originalIdx
const isEditing = editingCell?.personId === person.id && editingCell?.dayIdx === d.idx
const bgStyle: React.CSSProperties = {}
let className = `flex items-center justify-center text-sm font-mono font-bold relative
border-r border-slate-700/20
${isMonthStart ? 'border-l-2 border-l-slate-500' : ''}`
if (isDragOriginal) {
className += ' opacity-30'
}
if (isDragPreview) {
className += ' border-2 border-dashed border-amber-400/70'
}
if (isValueHidden) {
// Hidden by filter — show as empty
if (isWeekend) className += ' bg-slate-800/50'
} else {
const manualColor = cellData?.color
if (manualColor) {
bgStyle.backgroundColor = manualColor
bgStyle.color = getContrastColor(manualColor)
} else if (style) {
bgStyle.backgroundColor = style.bg
bgStyle.color = style.text
} else if (isWeekend) {
className += ' bg-slate-800/50'
}
}
const isSelected = selectedCells.has(`${person.id}-${d.idx}`)
if (isSelected) {
className += ' ring-2 ring-blue-500 ring-inset z-10'
}
const hasCellData = !!(value || cellData?.color)
if (hasCellData && !isDragOriginal) {
className += ' cursor-grab active:cursor-grabbing'
}
const dayCell = (
<div
key={d.idx}
className={className}
style={{ width: CELL_W, height: CELL_H, ...bgStyle }}
onPointerDown={(e) => {
if (e.button === 2) return
if (e.ctrlKey || e.metaKey || e.shiftKey) return // let onClick handle modifier clicks
if (hasCellData) {
onCellPointerDown(e, person.id, d.idx, value || '●')
} else {
// Start drag-select on empty cells
onCellDragSelectStart(person.id, d.idx)
}
}}
onPointerEnter={(e) => {
if (e.buttons === 1 && !hasCellData) {
onCellDragSelectMove(person.id, d.idx)
}
}}
onClick={(e) => {
if (e.button === 2) return
if (dragState) return
onCellClick(person.id, d.idx, e.ctrlKey || e.metaKey, e.shiftKey)
}}
onDoubleClick={() => {
if (dragState) return
onStartEdit(person.id, d.idx, value)
}}
onContextMenu={(e) => {
e.preventDefault()
onContextMenu(d.idx, person.id, e.clientX, e.clientY)
}}
title={
`${person.name}${d.day}.${d.month}.${d.year}` +
(value ? `\n${value}` : '') +
(hasCellComment ? `\n💬 ${cellComments.get(commentKey)}` : '')
}
>
{isEditing ? (
<input
autoFocus
className="w-full h-full bg-white text-slate-900 text-center text-sm font-mono font-bold outline-none border-2 border-blue-500"
style={{ width: CELL_W, height: CELL_H }}
value={editingCell!.value}
onChange={(e) => onEditChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onEditConfirm()
if (e.key === 'Escape') onEditCancel()
}}
onBlur={onEditConfirm}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
/>
) : (
<>
{isDragPreview ? (
<span className="truncate text-amber-300 font-semibold">{dragState!.value}</span>
) : value && !isValueHidden ? (
<span className="truncate font-medium">{value}</span>
) : null}
</>
)}
{hasCellComment && !isEditing && (
<span className="absolute top-0 right-0 w-0 h-0 pointer-events-none"
style={{ borderLeft: '5px solid transparent', borderTop: '5px solid #3b82f6' }}
/>
)}
</div>
)
if (!pmsAfterSet.has(i)) return dayCell
// PMS cell after this day (month boundary)
const pmsMonth = pmsMonthAtPos.get(i)!
const pmsDayIdx = -pmsMonth // negative month as special key
const pmsData = person.data[String(pmsDayIdx)]
const pmsValue = pmsData?.v ?? ''
const pmsColor = pmsData?.color
const isPmsEditing = editingCell?.personId === person.id && editingCell?.dayIdx === pmsDayIdx
const pmsCommentKey = `${person.id}-${pmsDayIdx}`
const hasPmsComment = cellComments.has(pmsCommentKey)
const pmsStyle: React.CSSProperties = { width: CELL_W, height: CELL_H }
if (pmsColor) {
pmsStyle.backgroundColor = pmsColor
pmsStyle.color = getContrastColor(pmsColor)
}
return [
dayCell,
<div
key={`pms-${pmsMonth}`}
className={`flex items-center justify-center text-sm font-mono font-bold relative
border-r border-slate-600 cursor-text hover:brightness-125
${!pmsColor ? 'bg-slate-700/30' : ''}`}
style={pmsStyle}
onDoubleClick={() => onStartEdit(person.id, pmsDayIdx, pmsValue)}
onClick={() => {
if (!isPmsEditing) onStartEdit(person.id, pmsDayIdx, pmsValue)
}}
onContextMenu={(e) => {
e.preventDefault()
onContextMenu(pmsDayIdx, person.id, e.clientX, e.clientY)
}}
title={`PMS ${MONTH_NAMES_SHORT[pmsMonth] ?? pmsMonth}: ${pmsValue || '—'}${hasPmsComment ? `\n💬 ${cellComments.get(pmsCommentKey)}` : ''}`}
>
{isPmsEditing ? (
<input
autoFocus
className="w-full h-full bg-white text-slate-900 text-center text-sm font-mono font-bold outline-none border-2 border-purple-500"
style={{ width: CELL_W, height: CELL_H }}
value={editingCell!.value}
onChange={(e) => onEditChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onEditConfirm()
if (e.key === 'Escape') onEditCancel()
}}
onBlur={onEditConfirm}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
/>
) : (
<span className="truncate font-medium">{pmsValue}</span>
)}
{hasPmsComment && !isPmsEditing && (
<span className="absolute top-0 right-0 w-0 h-0 pointer-events-none"
style={{ borderLeft: '5px solid transparent', borderTop: '5px solid #3b82f6' }}
/>
)}
</div>,
]
})}
</div>
)
})
// ---------- Main ScheduleTable ----------
export function ScheduleTable(props: ScheduleTableProps) {
const {
dayIndex, people, tunnelClosures, tunnelColors,
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
dayComments, cellComments,
dragState, onCellPointerDown, onSetCell, onSetTunnelClosure,
onSetMetroClosure, onSetD8Closure, onSetSazltClosure,
showMetro, showD8, showSazlt, hide162, hiddenValues,
scrollRef, onContextMenu, onTunnelContextMenu, onInfoRowContextMenu, compareData,
} = props
const [editingCell, setEditingCell] = useState<EditingCell | null>(null)
const editInputRef = useRef<HTMLInputElement>(null)
// ---------- Computed spans ----------
const monthSpans = useMemo(() => {
const spans: { month: number; year: number; startIdx: number; count: number }[] = []
let current: (typeof spans)[0] | null = null
for (let i = 0; i < dayIndex.length; i++) {
const d = dayIndex[i]
if (!current || current.month !== d.month || current.year !== d.year) {
if (current) spans.push(current)
current = { month: d.month, year: d.year, startIdx: i, count: 1 }
} else {
current.count++
}
}
if (current) spans.push(current)
return spans
}, [dayIndex])
const weekSpans = useMemo(() => {
const spans: { week: number; startIdx: number; count: number }[] = []
let current: (typeof spans)[0] | null = null
for (let i = 0; i < dayIndex.length; i++) {
const d = dayIndex[i]
if (!current || current.week !== d.week) {
if (current) spans.push(current)
current = { week: d.week, startIdx: i, count: 1 }
} else {
current.count++
}
}
if (current) spans.push(current)
return spans
}, [dayIndex])
const dayNames = useMemo(() => {
return dayIndex.map(d => {
const dow = new Date(d.year, d.month - 1, d.day).getDay()
return DAY_NAMES[dow]
})
}, [dayIndex])
// Holiday map for all years in the data
const holidays = useMemo(() => {
const years = new Set(dayIndex.map(d => d.year))
const combined = new Map<string, string>()
for (const year of years) {
const yearMap = getHolidayMap(year)
yearMap.forEach((name, key) => combined.set(`${year}-${key}`, name))
}
return combined
}, [dayIndex])
const HIDE_162_NAMES = ['Pauzer Libor', 'Vörös Pavel', 'Janouš Petr', 'Franek Lukáš', 'Svoboda Daniel']
const tkbPeople = useMemo(() => {
const all = people.filter(p => p.group === 'TKB')
return hide162 ? all.filter(p => !HIDE_162_NAMES.includes(p.name)) : all
}, [people, hide162])
const itPeople = useMemo(() => people.filter(p => p.group === 'IT'), [people])
// ---------- PMS (Práce mimo směnu) month-end positions ----------
const monthEndPositions = useMemo(() => {
const positions: { afterDayPos: number; month: number; year: number }[] = []
for (let i = 0; i < dayIndex.length - 1; i++) {
if (dayIndex[i].month !== dayIndex[i + 1].month) {
positions.push({ afterDayPos: i, month: dayIndex[i].month, year: dayIndex[i].year })
}
}
const last = dayIndex[dayIndex.length - 1]
if (last) positions.push({ afterDayPos: dayIndex.length - 1, month: last.month, year: last.year })
return positions
}, [dayIndex])
// Set of dayIndex positions after which a PMS column appears
const pmsAfterSet = useMemo(() => new Set(monthEndPositions.map(p => p.afterDayPos)), [monthEndPositions])
// Map from dayIndex position to the month number for PMS
const pmsMonthAtPos = useMemo(() => {
const m = new Map<number, number>()
for (const p of monthEndPositions) m.set(p.afterDayPos, p.month)
return m
}, [monthEndPositions])
// ---------- idxToPos for drag overlay ----------
// Maps dayIndex array position to column position (accounting for PMS columns)
const dayPosToColPos = useMemo(() => {
const arr: number[] = []
let pmsCount = 0
for (let i = 0; i < dayIndex.length; i++) {
arr.push(i + pmsCount)
if (pmsAfterSet.has(i)) pmsCount++
}
return arr
}, [dayIndex, pmsAfterSet])
// Maps dayIdx to column position for drag overlay
const idxToPos = useMemo(() => {
const map = new Map<number, number>()
for (let i = 0; i < dayIndex.length; i++) {
map.set(dayIndex[i].idx, dayPosToColPos[i])
}
return map
}, [dayIndex, dayPosToColPos])
// ---------- Editing handlers ----------
const onStartEdit = useCallback((personId: string, dayIdx: number, currentValue: string) => {
setEditingCell({ personId, dayIdx, value: currentValue })
}, [])
const onStartEditTunnel = useCallback((dayIdx: number, currentValue: string) => {
setEditingCell({ personId: '__tunnel__', dayIdx, value: currentValue })
}, [])
const onStartEditMetro = useCallback((dayIdx: number, currentValue: string) => {
setEditingCell({ personId: '__metro__', dayIdx, value: currentValue })
}, [])
const onStartEditD8 = useCallback((dayIdx: number, currentValue: string) => {
setEditingCell({ personId: '__d8__', dayIdx, value: currentValue })
}, [])
const onStartEditSazlt = useCallback((dayIdx: number, currentValue: string) => {
setEditingCell({ personId: '__sazlt__', dayIdx, value: currentValue })
}, [])
const onEditChange = useCallback((value: string) => {
setEditingCell(prev => prev ? { ...prev, value } : null)
}, [])
const onEditConfirm = useCallback(() => {
if (!editingCell) return
const trimmed = editingCell.value.trim()
if (editingCell.personId === '__tunnel__') {
onSetTunnelClosure(editingCell.dayIdx, trimmed || null)
} else if (editingCell.personId === '__metro__') {
onSetMetroClosure(editingCell.dayIdx, trimmed || null)
} else if (editingCell.personId === '__d8__') {
onSetD8Closure(editingCell.dayIdx, trimmed || null)
} else if (editingCell.personId === '__sazlt__') {
onSetSazltClosure(editingCell.dayIdx, trimmed || null)
} else {
onSetCell(editingCell.personId, editingCell.dayIdx, trimmed || null)
}
setEditingCell(null)
}, [editingCell, onSetCell, onSetTunnelClosure, onSetMetroClosure, onSetD8Closure, onSetSazltClosure])
const onEditCancel = useCallback(() => {
setEditingCell(null)
}, [])
// ---------- Multi-cell selection ----------
const [selectedCells, setSelectedCells] = useState<Set<string>>(new Set())
const selectionAnchorRef = useRef<{ personId: string; dayIdx: number } | null>(null)
const isDragSelectingRef = useRef(false)
const dragSelectStartRef = useRef<{ personId: string; dayIdx: number } | null>(null)
const cellKey = (personId: string, dayIdx: number) => `${personId}-${dayIdx}`
const computeRectSelection = useCallback((
anchor: { personId: string; dayIdx: number },
current: { personId: string; dayIdx: number },
): Set<string> => {
const anchorPersonIdx = people.findIndex(p => p.id === anchor.personId)
const currentPersonIdx = people.findIndex(p => p.id === current.personId)
if (anchorPersonIdx < 0 || currentPersonIdx < 0) return new Set()
const anchorDayPos = dayIndex.findIndex(d => d.idx === anchor.dayIdx)
const currentDayPos = dayIndex.findIndex(d => d.idx === current.dayIdx)
if (anchorDayPos < 0 || currentDayPos < 0) return new Set()
const minP = Math.min(anchorPersonIdx, currentPersonIdx)
const maxP = Math.max(anchorPersonIdx, currentPersonIdx)
const minD = Math.min(anchorDayPos, currentDayPos)
const maxD = Math.max(anchorDayPos, currentDayPos)
const sel = new Set<string>()
for (let pi = minP; pi <= maxP; pi++) {
for (let di = minD; di <= maxD; di++) {
sel.add(cellKey(people[pi].id, dayIndex[di].idx))
}
}
return sel
}, [people, dayIndex])
const onCellClick = useCallback((personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => {
const key = cellKey(personId, dayIdx)
if (isDragSelectingRef.current) {
isDragSelectingRef.current = false
return
}
if (dragJustEndedRef.current) return
if (shiftKey && selectionAnchorRef.current) {
const rect = computeRectSelection(selectionAnchorRef.current, { personId, dayIdx })
setSelectedCells(rect)
} else if (ctrlKey) {
setSelectedCells(prev => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
selectionAnchorRef.current = { personId, dayIdx }
} else {
setSelectedCells(new Set([key]))
selectionAnchorRef.current = { personId, dayIdx }
}
}, [computeRectSelection])
const onCellDragSelectStart = useCallback((personId: string, dayIdx: number) => {
dragSelectStartRef.current = { personId, dayIdx }
isDragSelectingRef.current = false
selectionAnchorRef.current = { personId, dayIdx }
setSelectedCells(new Set([cellKey(personId, dayIdx)]))
}, [])
const onCellDragSelectMove = useCallback((personId: string, dayIdx: number) => {
const start = dragSelectStartRef.current
if (!start) return
isDragSelectingRef.current = true
const rect = computeRectSelection(start, { personId, dayIdx })
setSelectedCells(rect)
}, [computeRectSelection])
// Finalize drag-select on pointerup
useEffect(() => {
const handleUp = () => {
dragSelectStartRef.current = null
}
document.addEventListener('pointerup', handleUp)
return () => document.removeEventListener('pointerup', handleUp)
}, [])
// Track when drag-move just completed so we can suppress the subsequent click
const dragJustEndedRef = useRef(false)
const prevDragRef = useRef<DragState | null>(null)
useEffect(() => {
if (prevDragRef.current && !dragState) {
// Drag just ended — clear selection and set flag to suppress click
setSelectedCells(new Set())
selectionAnchorRef.current = null
dragJustEndedRef.current = true
setTimeout(() => { dragJustEndedRef.current = false }, 50)
}
prevDragRef.current = dragState
}, [dragState])
// Clear selection on Escape
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSelectedCells(new Set())
selectionAnchorRef.current = null
}
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [])
// Context menu handler wrapping the prop to include selected cells
const handlePersonContextMenu = useCallback((dayIdx: number, personId: string | null, x: number, y: number) => {
if (!personId) {
onContextMenu(dayIdx, personId, x, y, [])
return
}
const key = cellKey(personId, dayIdx)
if (selectedCells.has(key) && selectedCells.size > 1) {
// Right-clicked on a selected cell => pass all selected cells
const cells: SelectedCell[] = []
selectedCells.forEach(k => {
const lastDash = k.lastIndexOf('-')
const pId = k.substring(0, lastDash)
const dIdx = Number(k.substring(lastDash + 1))
cells.push({ personId: pId, dayIdx: dIdx })
})
onContextMenu(dayIdx, personId, x, y, cells)
} else {
// Right-clicked on unselected cell => select just this one
setSelectedCells(new Set([key]))
selectionAnchorRef.current = { personId, dayIdx }
onContextMenu(dayIdx, personId, x, y, [{ personId, dayIdx }])
}
}, [selectedCells, onContextMenu])
// ---------- Drag overlay ----------
const dragOverlay = useMemo(() => {
if (!dragState) return null
const personIdx = people.findIndex(p => p.id === dragState.personId)
if (personIdx < 0) return null
// Compute top accounting for section headers
const tkbCount = tkbPeople.length
let top: number
const tkbIdx = tkbPeople.findIndex(p => p.id === dragState.personId)
if (tkbIdx >= 0) {
// TKB section: +1 for TKB section header row
top = (1 + tkbIdx) * CELL_H
} else {
const itIdx = itPeople.findIndex(p => p.id === dragState.personId)
// IT section: +1 TKB header + TKB rows + +1 IT header
top = (1 + tkbCount + 1 + itIdx) * CELL_H
}
const pos = idxToPos.get(dragState.previewIdx) ?? 0
const left = pos * CELL_W
const style = getCellStyle(dragState.value)
return (
<div
className="absolute border-2 border-dashed rounded pointer-events-none
flex items-center justify-center text-[10px] font-mono font-semibold"
style={{
left, top, width: CELL_W, height: CELL_H,
borderColor: style?.bg ?? '#f59e0b',
backgroundColor: (style?.bg ?? '#f59e0b') + '40',
color: style?.text ?? '#fbbf24',
}}
>
{dragState.value}
</div>
)
}, [dragState, people, tkbPeople, itPeople, idxToPos])
// ---------- Compare ghost overlay ----------
const ghostBlocks = useMemo(() => {
if (!compareData) return null
const blocks: { personIdx: number; startPos: number; length: number; value: string }[] = []
const compareDayByDate = new Map<string, number>()
for (const cd of compareData.dayIndex) {
compareDayByDate.set(`${cd.year}-${cd.month}-${cd.day}`, cd.idx)
}
const comparePersonMap = new Map<string, Record<string, { v?: string }>>()
for (const cp of compareData.people) {
comparePersonMap.set(cp.id, cp.data)
}
for (let pi = 0; pi < people.length; pi++) {
const person = people[pi]
const compareData2 = comparePersonMap.get(person.id)
if (!compareData2) continue
let runStart = -1
let runValue = ''
let runLength = 0
let runHasDiff = false
const flush = () => {
if (runStart >= 0 && runHasDiff) {
blocks.push({ personIdx: pi, startPos: runStart, length: runLength, value: runValue })
}
runStart = -1; runValue = ''; runLength = 0; runHasDiff = false
}
for (let di = 0; di < dayIndex.length; di++) {
const d = dayIndex[di]
const compIdx = compareDayByDate.get(`${d.year}-${d.month}-${d.day}`)
const compCell = compIdx !== undefined ? compareData2[String(compIdx)] : undefined
const compVal = compCell?.v ?? ''
const curCell = person.data[String(d.idx)]
const curVal = curCell?.v ?? ''
const isDiff = compVal !== curVal
if (compVal && compVal === runValue) {
runLength++
if (isDiff) runHasDiff = true
} else {
flush()
if (compVal) {
runStart = di
runValue = compVal
runLength = 1
runHasDiff = isDiff
}
}
}
flush()
}
return blocks.length > 0 ? blocks : null
}, [compareData, people, dayIndex])
// ---------- Render ----------
const totalGridW = dayIndex.length * CELL_W + monthEndPositions.length * CELL_W
const nameColW = 200
// Helper: render info row label (left column)
const renderInfoRowLabel = (label: string, borderClass: string) => (
<div className={`bg-slate-800 border-r border-slate-600 border-b ${borderClass} flex items-center px-3`} style={{ height: 28 }}>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">{label}</span>
</div>
)
// Helper: render info row cells (right column)
const renderInfoRowCells = (
rowId: '__tunnel__' | '__metro__' | '__d8__' | '__sazlt__',
closures: Map<number, string>,
colors: Map<number, string>,
defaultBgClass: string,
borderClass: string,
editBorderColor: string,
label: string,
contextHandler: (dayIdx: number, x: number, y: number) => void,
onStartEditFn: (dayIdx: number, currentValue: string) => void,
) => (
<div className={`flex border-b ${borderClass}`} style={{ height: 28 }}>
{dayIndex.map((d, i) => {
const closureVal = closures.get(d.idx) ?? ''
const closureColor = colors.get(d.idx)
const isEditingThis = editingCell?.personId === rowId && editingCell?.dayIdx === d.idx
const isOff = d.weekend || holidays.has(`${d.year}-${d.month}-${d.day}`)
const rowBg: React.CSSProperties = {}
if (closureColor) {
rowBg.backgroundColor = closureColor
rowBg.color = getContrastColor(closureColor)
}
const cells: React.ReactNode[] = []
cells.push(
<div
key={d.idx}
className={`flex items-center justify-center text-xs font-mono font-bold relative
border-r border-slate-700/20
${!closureColor && closureVal ? `${defaultBgClass}` : !closureColor ? (isOff ? 'bg-slate-700/30' : 'bg-slate-800/30') : ''}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
cursor-pointer hover:brightness-125
`}
style={{ width: CELL_W, height: 28, ...rowBg }}
onClick={() => {
if (!isEditingThis) onStartEditFn(d.idx, closureVal)
}}
onContextMenu={(e) => {
e.preventDefault()
contextHandler(d.idx, e.clientX, e.clientY)
}}
title={closureVal ? `${label}: ${closureVal}` : `${d.day}.${d.month}.`}
>
{isEditingThis ? (
<input
autoFocus
className={`w-full h-full bg-white text-slate-900 text-center text-xs font-mono font-bold outline-none border-2 ${editBorderColor}`}
value={editingCell!.value}
onChange={(e) => onEditChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onEditConfirm()
if (e.key === 'Escape') onEditCancel()
}}
onBlur={onEditConfirm}
onClick={(e) => e.stopPropagation()}
/>
) : (
<span className="truncate">{closureVal}</span>
)}
</div>
)
if (pmsAfterSet.has(i)) {
cells.push(
<div key={`pms-info-${rowId}-${i}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 28 }} />
)
}
return cells
})}
</div>
)
// Helper: render section header (left column)
const renderSectionHeaderLabel = (label: string, bgClass: string, textClass: string) => (
<div className={`${bgClass} border-r border-slate-600 flex items-center px-3`} style={{ height: CELL_H }}>
<span className={`text-xs font-bold ${textClass} uppercase tracking-wider`}>{label}</span>
</div>
)
// Helper: render section header bar (right column)
const renderSectionHeaderBar = (bgClass: string) => (
<div className={`${bgClass}`} style={{ height: CELL_H, width: totalGridW }} />
)
return (
<div className="rounded-lg border border-slate-700 bg-slate-800/50 overflow-hidden">
<div className="flex">
{/* === Fixed left name column === */}
<div className="flex-shrink-0 z-20 bg-slate-800 border-r border-slate-600" style={{ width: nameColW }}>
{/* Header labels */}
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 28 }}>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Měsíc</span>
</div>
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 24 }}>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Týden</span>
</div>
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 28 }}>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den</span>
</div>
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 24 }}>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den t.</span>
</div>
{/* TKB info row label */}
{renderInfoRowLabel('TKB', 'border-slate-700')}
{/* SAZLT info row label */}
{showSazlt && renderInfoRowLabel('SAZLT', 'border-slate-700')}
{/* Metro info row label */}
{showMetro && renderInfoRowLabel('Metro', 'border-slate-700')}
{/* D8 info row label */}
{showD8 && renderInfoRowLabel('D8', 'border-slate-600')}
{/* TKB section header */}
{renderSectionHeaderLabel('Pohotovost TKB', 'bg-indigo-700/60', 'text-indigo-100')}
{/* TKB person names */}
{tkbPeople.map((person) => (
<div key={person.id} className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: CELL_H }}>
<span className="text-xs text-slate-200 whitespace-nowrap truncate">
{person.name}
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
</span>
</div>
))}
{/* IT section header */}
{renderSectionHeaderLabel('Pohotovost IT', 'bg-teal-700/60', 'text-teal-100')}
{/* IT person names */}
{itPeople.map((person) => (
<div key={person.id} className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: CELL_H }}>
<span className="text-xs text-slate-200 whitespace-nowrap truncate">
{person.name}
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
</span>
</div>
))}
</div>
{/* === Scrollable grid === */}
<div ref={scrollRef} className="overflow-x-auto flex-1">
<div style={{ width: totalGridW, position: 'relative' }}>
{/* Month header row */}
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
{monthSpans.map(m => (
<div
key={`${m.year}-${m.month}`}
className="flex items-center justify-center text-xs font-semibold text-slate-300 border-r border-slate-600 bg-slate-800"
style={{ width: (m.count + 1) * CELL_W }}
>
{MONTH_NAMES[m.month]} {m.year}
</div>
))}
</div>
{/* Week header row */}
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
{weekSpans.map((w, i) => {
// Check if any PMS column falls inside this week span
const spanEnd = w.startIdx + w.count - 1
const pmsCols: number[] = []
for (let di = w.startIdx; di <= spanEnd; di++) {
if (pmsAfterSet.has(di)) pmsCols.push(di)
}
if (pmsCols.length === 0) {
return (
<div
key={`w-${i}`}
className="flex items-center justify-center text-[10px] text-slate-400 border-r border-slate-700/50 bg-slate-800/80"
style={{ width: w.count * CELL_W }}
>
{w.week}
</div>
)
}
// Split span around PMS columns
const fragments: React.ReactNode[] = []
let fragStart = w.startIdx
for (const pmsPos of pmsCols) {
const fragCount = pmsPos - fragStart + 1
if (fragCount > 0) {
fragments.push(
<div
key={`w-${i}-f-${fragStart}`}
className="flex items-center justify-center text-[10px] text-slate-400 border-r border-slate-700/50 bg-slate-800/80"
style={{ width: fragCount * CELL_W }}
>
{w.week}
</div>
)
}
fragments.push(
<div key={`w-${i}-pms-${pmsPos}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 24 }} />
)
fragStart = pmsPos + 1
}
// Remaining fragment after last PMS
const remaining = spanEnd - fragStart + 1
if (remaining > 0) {
fragments.push(
<div
key={`w-${i}-f-${fragStart}`}
className="flex items-center justify-center text-[10px] text-slate-400 border-r border-slate-700/50 bg-slate-800/80"
style={{ width: remaining * CELL_W }}
>
{w.week}
</div>
)
}
return <React.Fragment key={`w-${i}`}>{fragments}</React.Fragment>
})}
</div>
{/* Day number header row (DEN) — PMS rotated text appears here */}
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
{dayIndex.map((d, i) => {
const comment = dayComments.get(d.idx)
const holidayName = holidays.get(`${d.year}-${d.month}-${d.day}`)
const isHoliday = !!holidayName
const isOff = d.weekend || isHoliday
const cells: React.ReactNode[] = []
cells.push(
<div
key={d.idx}
className={`flex items-center justify-center text-[10px] font-mono relative
${isOff ? 'text-red-400/70 bg-slate-700/30' : 'text-slate-400 bg-slate-800/50'}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
`}
style={{ width: CELL_W }}
title={`${d.day}.${d.month}.${d.year}${holidayName ? `\n🎉 ${holidayName}` : ''}${comment ? `\n${comment}` : ''}`}
>
{d.day}
{(comment || holidayName) && (
<span className={`absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full pointer-events-none ${holidayName ? 'bg-red-400' : 'bg-blue-500'}`} />
)}
</div>
)
if (pmsAfterSet.has(i)) {
cells.push(
<div
key={`pms-den-${i}`}
className="relative"
style={{ width: CELL_W, height: 28, zIndex: 15 }}
>
{/* Merged cell overlay covering DEN + DEN T. + all info rows */}
<div
className="absolute flex items-center justify-center border-l border-r border-slate-600 overflow-hidden"
style={{
top: 0,
left: 0,
width: CELL_W,
height: 28 + 24 + 28 + (showSazlt ? 28 : 0) + (showMetro ? 28 : 0) + (showD8 ? 28 : 0),
backgroundColor: '#283040',
zIndex: 15,
}}
>
<span
className="text-[10px] font-bold text-slate-300 pointer-events-none select-none"
style={{
writingMode: 'vertical-rl',
transform: 'rotate(180deg)',
}}
>
Práce mimo směnu
</span>
</div>
</div>
)
}
return cells
})}
</div>
{/* Day name header row */}
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
{dayIndex.map((d, i) => {
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
const isOff = d.weekend || isHoliday
const cells: React.ReactNode[] = []
cells.push(
<div
key={d.idx}
className={`flex items-center justify-center text-[9px] font-mono
${isOff ? 'text-red-400/70 bg-slate-700/30' : 'text-slate-500 bg-slate-800/50'}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
`}
style={{ width: CELL_W }}
>
{dayNames[i]}
</div>
)
if (pmsAfterSet.has(i)) {
cells.push(
<div key={`pms-dayname-${i}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 24 }} />
)
}
return cells
})}
</div>
{/* TKB info row */}
{renderInfoRowCells('__tunnel__', tunnelClosures, tunnelColors,
'bg-orange-700/40 text-orange-200', 'border-slate-700', 'border-orange-400', 'TKB',
onTunnelContextMenu, onStartEditTunnel)}
{/* SAZLT info row */}
{showSazlt && renderInfoRowCells('__sazlt__', sazltClosures, sazltColors,
'bg-amber-700/40 text-amber-200', 'border-slate-700', 'border-amber-400', 'SAZLT',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'sazlt', x, y), onStartEditSazlt)}
{/* Metro info row */}
{showMetro && renderInfoRowCells('__metro__', metroClosures, metroColors,
'bg-blue-700/40 text-blue-200', 'border-slate-700', 'border-blue-400', 'Metro',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'metro', x, y), onStartEditMetro)}
{/* D8 info row */}
{showD8 && renderInfoRowCells('__d8__', d8Closures, d8Colors,
'bg-green-700/40 text-green-200', 'border-slate-600', 'border-green-400', 'D8',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'd8', x, y), onStartEditD8)}
{/* Data rows container (with overlay) */}
<div style={{ position: 'relative' }}>
{/* TKB section header bar */}
{renderSectionHeaderBar('bg-indigo-700/60')}
{/* TKB person rows */}
{tkbPeople.map((person) => (
<PersonRow
key={person.id}
person={person}
dayIndex={dayIndex}
dragState={dragState}
cellComments={cellComments}
dayComments={dayComments}
holidays={holidays}
hiddenValues={hiddenValues}
onCellPointerDown={onCellPointerDown}
onContextMenu={handlePersonContextMenu}
editingCell={editingCell}
onStartEdit={onStartEdit}
onEditChange={onEditChange}
onEditConfirm={onEditConfirm}
onEditCancel={onEditCancel}
selectedCells={selectedCells}
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
pmsAfterSet={pmsAfterSet}
pmsMonthAtPos={pmsMonthAtPos}
/>
))}
{/* IT section header bar */}
{renderSectionHeaderBar('bg-teal-700/60')}
{/* IT person rows */}
{itPeople.map((person) => (
<PersonRow
key={person.id}
person={person}
dayIndex={dayIndex}
dragState={dragState}
cellComments={cellComments}
dayComments={dayComments}
holidays={holidays}
hiddenValues={hiddenValues}
onCellPointerDown={onCellPointerDown}
onContextMenu={handlePersonContextMenu}
editingCell={editingCell}
onStartEdit={onStartEdit}
onEditChange={onEditChange}
onEditConfirm={onEditConfirm}
onEditCancel={onEditCancel}
selectedCells={selectedCells}
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
pmsAfterSet={pmsAfterSet}
pmsMonthAtPos={pmsMonthAtPos}
/>
))}
{/* Compare ghost blocks */}
{ghostBlocks && ghostBlocks.map((gb, i) => {
const colPos = dayPosToColPos[gb.startPos] ?? gb.startPos
const left = colPos * CELL_W
const tkbCount = tkbPeople.length
let top: number
if (gb.personIdx < tkbCount) {
top = (1 + gb.personIdx) * CELL_H
} else {
top = (1 + tkbCount + 1 + (gb.personIdx - tkbCount)) * CELL_H
}
// Ghost block width: count columns between start and end, including any PMS columns in between
const endDayPos = gb.startPos + gb.length - 1
const endColPos = dayPosToColPos[endDayPos] ?? endDayPos
const width = (endColPos - colPos + 1) * CELL_W
const cellStyle = getCellStyle(gb.value)
return (
<div
key={`ghost-${i}`}
className="absolute pointer-events-none flex items-center justify-center text-[10px] font-mono font-semibold"
style={{
left, top, width, height: CELL_H,
borderWidth: 2,
borderStyle: 'dashed',
borderRadius: 2,
borderColor: (cellStyle?.bg ?? '#64748b') + 'b3',
backgroundColor: (cellStyle?.bg ?? '#64748b') + '33',
color: (cellStyle?.text ?? '#94a3b8') + 'cc',
}}
title={`Bylo: ${gb.value} (${gb.length} dní)`}
>
{gb.value}
</div>
)
})}
{/* Drag overlay */}
{dragOverlay}
</div>
</div>
</div>
</div>
</div>
)
}