feat: TKB shift scheduler — personnel shift planning web app
Full rewrite of METRO HMG for TKB tunnel department: - People-based grid (18 TKB + 5 IT), year-long calendar - Color-coded shift values (4/6/8/12/A/B/D/N/U/O) - Drag-and-drop cells, multi-cell selection (click/ctrl/shift/drag) - Right-click context menu with color palette - Tunnel closure + Metro + D8 info rows (toggleable) - Czech holidays highlighted with names - PDF export (2-page A4 landscape, DejaVu font for Czech chars) - Improvement proposals system - Sticky headers (vertical + horizontal scroll) - Cell value filter toggles in legend Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
921
web/src/ScheduleTable.tsx
Normal file
921
web/src/ScheduleTable.tsx
Normal file
@@ -0,0 +1,921 @@
|
||||
import { useMemo, useState, useCallback, useRef, memo, useEffect } from 'react'
|
||||
import type { DayInfo, Person, DragState, ScheduleData } from './types'
|
||||
import { getCellStyle, 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']
|
||||
|
||||
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>
|
||||
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
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
showMetro: boolean
|
||||
showD8: 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__'
|
||||
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,
|
||||
nameColW,
|
||||
}: {
|
||||
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
|
||||
nameColW: number
|
||||
}) {
|
||||
const isDragging = dragState?.personId === person.id
|
||||
|
||||
return (
|
||||
<div className="flex border-b border-slate-700" style={{ height: CELL_H }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<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>
|
||||
{dayIndex.map((d) => {
|
||||
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 = getCellStyle(value || undefined)
|
||||
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
|
||||
${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'
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
// ---------- Main ScheduleTable ----------
|
||||
|
||||
export function ScheduleTable(props: ScheduleTableProps) {
|
||||
const {
|
||||
dayIndex, people, tunnelClosures, tunnelColors,
|
||||
metroClosures, metroColors, d8Closures, d8Colors,
|
||||
dayComments, cellComments,
|
||||
dragState, onCellPointerDown, onSetCell, onSetTunnelClosure,
|
||||
onSetMetroClosure, onSetD8Closure,
|
||||
showMetro, showD8, 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 tkbPeople = useMemo(() => people.filter(p => p.group === 'TKB'), [people])
|
||||
const itPeople = useMemo(() => people.filter(p => p.group === 'IT'), [people])
|
||||
|
||||
// ---------- idxToPos for drag overlay ----------
|
||||
|
||||
const idxToPos = useMemo(() => {
|
||||
const map = new Map<number, number>()
|
||||
dayIndex.forEach((d, i) => map.set(d.idx, i))
|
||||
return map
|
||||
}, [dayIndex])
|
||||
|
||||
// ---------- 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 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 {
|
||||
onSetCell(editingCell.personId, editingCell.dayIdx, trimmed || null)
|
||||
}
|
||||
setEditingCell(null)
|
||||
}, [editingCell, onSetCell, onSetTunnelClosure, onSetMetroClosure, onSetD8Closure])
|
||||
|
||||
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 = 200 + pos * CELL_W // 200 = nameColW
|
||||
|
||||
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
|
||||
const nameColW = 200
|
||||
|
||||
// Helper: render an info row (TKB/Metro/D8 closures)
|
||||
const renderInfoRow = (
|
||||
rowId: '__tunnel__' | '__metro__' | '__d8__',
|
||||
label: string,
|
||||
closures: Map<number, string>,
|
||||
colors: Map<number, string>,
|
||||
defaultBgClass: string,
|
||||
borderClass: string,
|
||||
editBorderColor: string,
|
||||
contextHandler: (dayIdx: number, x: number, y: number) => void,
|
||||
onStartEditFn: (dayIdx: number, currentValue: string) => void,
|
||||
) => (
|
||||
<div className={`flex border-b ${borderClass}`} style={{ height: 28 }}>
|
||||
<div
|
||||
className={`sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b ${borderClass} flex items-center px-3 flex-shrink-0`}
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">{label}</span>
|
||||
</div>
|
||||
{dayIndex.map((d) => {
|
||||
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)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={d.idx}
|
||||
className={`flex items-center justify-center text-xs font-mono font-bold relative
|
||||
border-r border-slate-700
|
||||
${!closureColor && closureVal ? `${defaultBgClass}` : !closureColor ? (isOff ? 'bg-slate-700' : 'bg-slate-800') : ''}
|
||||
${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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className="rounded-lg border border-slate-700 bg-slate-800/50 overflow-auto" style={{ maxHeight: 'calc(100vh - 160px)' }}>
|
||||
<div style={{ width: nameColW + totalGridW, position: 'relative' }}>
|
||||
|
||||
{/* === STICKY HEADER ROWS === */}
|
||||
<div className="sticky top-0 z-30 bg-slate-800">
|
||||
|
||||
{/* Month row */}
|
||||
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Měsíc</span>
|
||||
</div>
|
||||
{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 * CELL_W }}
|
||||
>
|
||||
{MONTH_NAMES[m.month]} {m.year}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Week row */}
|
||||
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Týden</span>
|
||||
</div>
|
||||
{weekSpans.map((w, i) => (
|
||||
<div
|
||||
key={`w-${i}`}
|
||||
className="flex items-center justify-center text-[10px] text-slate-400 border-r border-slate-700 bg-slate-800"
|
||||
style={{ width: w.count * CELL_W }}
|
||||
>
|
||||
{w.week}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day number row */}
|
||||
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den</span>
|
||||
</div>
|
||||
{dayIndex.map((d) => {
|
||||
const comment = dayComments.get(d.idx)
|
||||
const holidayName = holidays.get(`${d.year}-${d.month}-${d.day}`)
|
||||
const isHoliday = !!holidayName
|
||||
const isOff = d.weekend || isHoliday
|
||||
return (
|
||||
<div
|
||||
key={d.idx}
|
||||
className={`flex items-center justify-center text-[10px] font-mono relative
|
||||
${isOff ? 'text-red-400/70 bg-slate-700' : 'text-slate-400 bg-slate-800'}
|
||||
${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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Day name row */}
|
||||
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den t.</span>
|
||||
</div>
|
||||
{dayIndex.map((d, i) => {
|
||||
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
|
||||
const isOff = d.weekend || isHoliday
|
||||
return (
|
||||
<div
|
||||
key={d.idx}
|
||||
className={`flex items-center justify-center text-[9px] font-mono
|
||||
${isOff ? 'text-red-400/70 bg-slate-700' : 'text-slate-500 bg-slate-800'}
|
||||
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
|
||||
`}
|
||||
style={{ width: CELL_W }}
|
||||
>
|
||||
{dayNames[i]}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* TKB closures row */}
|
||||
{renderInfoRow('__tunnel__', 'TKB', tunnelClosures, tunnelColors,
|
||||
'bg-orange-700/40 text-orange-200', 'border-slate-600', 'border-orange-400',
|
||||
onTunnelContextMenu, onStartEditTunnel)}
|
||||
|
||||
{/* Metro row */}
|
||||
{showMetro && renderInfoRow('__metro__', 'Metro', metroClosures, metroColors,
|
||||
'bg-blue-700/40 text-blue-200', 'border-slate-700', 'border-blue-400',
|
||||
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'metro', x, y), onStartEditMetro)}
|
||||
|
||||
{/* D8 row */}
|
||||
{showD8 && renderInfoRow('__d8__', 'D8', d8Closures, d8Colors,
|
||||
'bg-green-700/40 text-green-200', 'border-slate-600', 'border-green-400',
|
||||
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'd8', x, y), onStartEditD8)}
|
||||
|
||||
</div>{/* end sticky header rows */}
|
||||
|
||||
{/* Data rows container (with overlay) */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
|
||||
{/* TKB section header */}
|
||||
<div className="flex" style={{ height: CELL_H }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-indigo-700 border-r border-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-xs font-bold text-indigo-100 uppercase tracking-wider">Pohotovost TKB</span>
|
||||
</div>
|
||||
<div
|
||||
className="bg-indigo-700 flex items-center justify-center text-xs font-bold uppercase tracking-wider text-white"
|
||||
style={{ width: totalGridW }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
nameColW={nameColW}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* IT section header */}
|
||||
<div className="flex" style={{ height: CELL_H }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-teal-700 border-r border-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-xs font-bold text-teal-100 uppercase tracking-wider">Pohotovost IT</span>
|
||||
</div>
|
||||
<div
|
||||
className="bg-teal-700 flex items-center justify-center text-xs font-bold uppercase tracking-wider text-white"
|
||||
style={{ width: totalGridW }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 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}
|
||||
nameColW={nameColW}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Compare ghost blocks */}
|
||||
{ghostBlocks && ghostBlocks.map((gb, i) => {
|
||||
const left = nameColW + gb.startPos * CELL_W
|
||||
// Account for section headers in top position
|
||||
const tkbCount = tkbPeople.length
|
||||
let top: number
|
||||
if (gb.personIdx < tkbCount) {
|
||||
top = (1 + gb.personIdx) * CELL_H // +1 for TKB section header
|
||||
} else {
|
||||
top = (1 + tkbCount + 1 + (gb.personIdx - tkbCount)) * CELL_H // +1 TKB header +1 IT header
|
||||
}
|
||||
const width = gb.length * 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user