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 = { 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 = { 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 tunnelColors: Map metroClosures: Map metroColors: Map d8Closures: Map d8Colors: Map sazltClosures: Map sazltColors: Map dayComments: Map cellComments: Map 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 showMetro: boolean showD8: boolean showSazlt: boolean hide162: boolean hiddenValues: Set 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 dayComments: Map holidays: Map hiddenValues: Set 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 onCellClick: (personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => void onCellDragSelectStart: (personId: string, dayIdx: number) => void onCellDragSelectMove: (personId: string, dayIdx: number) => void pmsAfterSet: Set pmsMonthAtPos: Map }) { const isDragging = dragState?.personId === person.id return (
{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 = (
{ 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 ? ( 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 ? ( {dragState!.value} ) : value && !isValueHidden ? ( {value} ) : null} )} {hasCellComment && !isEditing && ( )}
) 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,
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 ? ( 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()} /> ) : ( {pmsValue} )} {hasPmsComment && !isPmsEditing && ( )}
, ] })}
) }) // ---------- 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(null) const editInputRef = useRef(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() 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() 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() 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>(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 => { 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() 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(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 (
{dragState.value}
) }, [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() for (const cd of compareData.dayIndex) { compareDayByDate.set(`${cd.year}-${cd.month}-${cd.day}`, cd.idx) } const comparePersonMap = new Map>() 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) => (
{label}
) // Helper: render info row cells (right column) const renderInfoRowCells = ( rowId: '__tunnel__' | '__metro__' | '__d8__' | '__sazlt__', closures: Map, colors: Map, defaultBgClass: string, borderClass: string, editBorderColor: string, label: string, contextHandler: (dayIdx: number, x: number, y: number) => void, onStartEditFn: (dayIdx: number, currentValue: string) => void, ) => (
{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(
{ 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 ? ( onEditChange(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') onEditConfirm() if (e.key === 'Escape') onEditCancel() }} onBlur={onEditConfirm} onClick={(e) => e.stopPropagation()} /> ) : ( {closureVal} )}
) if (pmsAfterSet.has(i)) { cells.push(
) } return cells })}
) // Helper: render section header (left column) const renderSectionHeaderLabel = (label: string, bgClass: string, textClass: string) => (
{label}
) // Helper: render section header bar (right column) const renderSectionHeaderBar = (bgClass: string) => (
) return (
{/* === Fixed left name column === */}
{/* Header labels */}
Měsíc
Týden
Den
Den t.
{/* 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) => (
{person.name} {person.note && ({person.note})}
))} {/* IT section header */} {renderSectionHeaderLabel('Pohotovost IT', 'bg-teal-700/60', 'text-teal-100')} {/* IT person names */} {itPeople.map((person) => (
{person.name} {person.note && ({person.note})}
))}
{/* === Scrollable grid === */}
{/* Month header row */}
{monthSpans.map(m => (
{MONTH_NAMES[m.month]} {m.year}
))}
{/* Week header row */}
{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 (
{w.week}
) } // 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(
{w.week}
) } fragments.push(
) fragStart = pmsPos + 1 } // Remaining fragment after last PMS const remaining = spanEnd - fragStart + 1 if (remaining > 0) { fragments.push(
{w.week}
) } return {fragments} })}
{/* Day number header row (DEN) — PMS rotated text appears here */}
{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(
{d.day} {(comment || holidayName) && ( )}
) if (pmsAfterSet.has(i)) { cells.push(
{/* Merged cell overlay covering DEN + DEN T. + all info rows */}
Práce mimo směnu
) } return cells })}
{/* Day name header row */}
{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(
{dayNames[i]}
) if (pmsAfterSet.has(i)) { cells.push(
) } return cells })}
{/* 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) */}
{/* TKB section header bar */} {renderSectionHeaderBar('bg-indigo-700/60')} {/* TKB person rows */} {tkbPeople.map((person) => ( ))} {/* IT section header bar */} {renderSectionHeaderBar('bg-teal-700/60')} {/* IT person rows */} {itPeople.map((person) => ( ))} {/* 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 (
{gb.value}
) })} {/* Drag overlay */} {dragOverlay}
) }