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

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

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

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

View File

@@ -39,6 +39,7 @@ interface ScheduleTableProps {
dragState: DragState | null
onCellPointerDown: (e: React.PointerEvent, personId: string, dayIdx: number, value: string) => void
onSetCell: (personId: string, dayIdx: number, value: string | null) => void
onSetCellHalfValue: (personId: string, dayIdx: number, target: 'day' | 'night', 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
@@ -47,18 +48,27 @@ interface ScheduleTableProps {
showMetro: boolean
showD8: boolean
showSazlt: boolean
showIT: boolean
hide162: boolean
hiddenTkbPeople: Set<string>
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
onTunnelContextMenu: (dayIdx: number, x: number, y: number, selectedCells: SelectedCell[]) => void
onInfoRowContextMenu: (dayIdx: number, infoRowId: string, x: number, y: number, selectedCells: SelectedCell[]) => void
compareData?: ScheduleData | null
isReadOnly?: boolean
clipboardSource: { personId: string; dayIdx: number } | null
hasClipboard: boolean
onCopyCell: (personId: string, dayIdx: number) => void
onPasteCells: (targets: { personId: string; dayIdx: number }[]) => void
onClearClipboard: () => void
}
interface EditingCell {
personId: string | '__tunnel__' | '__metro__' | '__d8__' | '__sazlt__'
dayIdx: number
value: string
target?: 'cell' | 'day' | 'night' // person-cell only; default 'cell'
}
// ---------- PersonRow (memoized) ----------
@@ -84,6 +94,9 @@ const PersonRow = memo(function PersonRow({
onCellDragSelectMove,
pmsAfterSet,
pmsMonthAtPos,
isReadOnly,
rowIndex,
clipboardSource,
}: {
person: Person
dayIndex: DayInfo[]
@@ -95,7 +108,7 @@ const PersonRow = memo(function PersonRow({
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
onStartEdit: (personId: string, dayIdx: number, currentValue: string, target?: 'cell' | 'day' | 'night') => void
onEditChange: (value: string) => void
onEditConfirm: () => void
onEditCancel: () => void
@@ -105,17 +118,28 @@ const PersonRow = memo(function PersonRow({
onCellDragSelectMove: (personId: string, dayIdx: number) => void
pmsAfterSet: Set<number>
pmsMonthAtPos: Map<number, number>
isReadOnly: boolean
rowIndex: number
clipboardSource: { personId: string; dayIdx: number } | null
}) {
const isDragging = dragState?.personId === person.id
const zebra = rowIndex % 2 === 1 ? 'bg-slate-500/20' : ''
return (
<div className="flex border-b border-slate-700/50" style={{ height: CELL_H }}>
<div className={`flex border-b border-slate-700/50 ${zebra}`} 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 dayColor = cellData?.dayColor
const nightColor = cellData?.nightColor
const dayValue = cellData?.dayValue ?? ''
const nightValue = cellData?.nightValue ?? ''
const hasHalfMarks = !!(dayColor || nightColor || dayValue || nightValue)
const isColorOnly = !value && !dayValue && !nightValue && (!!cellData?.color || !!(dayColor || nightColor))
const isValueHidden = !!(value && hiddenValues.has(value)) || (isColorOnly && hiddenValues.has('__color_only__'))
const style = getCellStyleForPerson(value || undefined, person.note)
const dayStyleObj = getCellStyleForPerson(dayValue || undefined, person.note)
const nightStyleObj = getCellStyleForPerson(nightValue || undefined, person.note)
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
const isWeekend = d.weekend || isHoliday
const commentKey = `${person.id}-${d.idx}`
@@ -129,7 +153,7 @@ const PersonRow = memo(function PersonRow({
const bgStyle: React.CSSProperties = {}
let className = `flex items-center justify-center text-sm font-mono font-bold relative
border-r border-slate-700/20
border-r border-slate-500/40
${isMonthStart ? 'border-l-2 border-l-slate-500' : ''}`
if (isDragOriginal) {
@@ -140,8 +164,14 @@ const PersonRow = memo(function PersonRow({
className += ' border-2 border-dashed border-amber-400/70'
}
// Split mode is active when any half data exists. In split mode, the
// legacy whole-cell value (v) and color are hidden; halves take over.
const renderAsSplit = hasHalfMarks && !isValueHidden
if (isValueHidden) {
// Hidden by filter — show as empty
if (isWeekend) className += ' bg-slate-800/50'
} else if (renderAsSplit) {
// Halves render their own bg via overlays; keep weekend tint as fallback
if (isWeekend) className += ' bg-slate-800/50'
} else {
const manualColor = cellData?.color
@@ -161,11 +191,24 @@ const PersonRow = memo(function PersonRow({
className += ' ring-2 ring-blue-500 ring-inset z-10'
}
const hasCellData = !!(value || cellData?.color)
const isClipboardSource = !!clipboardSource && clipboardSource.personId === person.id && clipboardSource.dayIdx === d.idx
if (isClipboardSource && !isSelected) {
className += ' z-10'
bgStyle.outline = '2px dashed #60a5fa'
bgStyle.outlineOffset = '-2px'
}
const hasCellData = !!(value || cellData?.color || hasHalfMarks)
if (hasCellData && !isDragOriginal) {
className += ' cursor-grab active:cursor-grabbing'
}
// Compute half styles (bg + text color) for split mode
const nightBg = nightColor ?? nightStyleObj?.bg
const nightTextColor = nightColor ? getContrastColor(nightColor) : nightStyleObj?.text
const dayBg = dayColor ?? dayStyleObj?.bg
const dayTextColor = dayColor ? getContrastColor(dayColor) : dayStyleObj?.text
const dayCell = (
<div
key={d.idx}
@@ -173,6 +216,7 @@ const PersonRow = memo(function PersonRow({
style={{ width: CELL_W, height: CELL_H, ...bgStyle }}
onPointerDown={(e) => {
if (e.button === 2) return
if (isReadOnly) return // pan handled by scroll container
if (e.ctrlKey || e.metaKey || e.shiftKey) return // let onClick handle modifier clicks
if (hasCellData) {
onCellPointerDown(e, person.id, d.idx, value || '●')
@@ -182,18 +226,24 @@ const PersonRow = memo(function PersonRow({
}
}}
onPointerEnter={(e) => {
if (e.buttons === 1 && !hasCellData) {
if (!isReadOnly && e.buttons === 1 && !hasCellData) {
onCellDragSelectMove(person.id, d.idx)
}
}}
onClick={(e) => {
if (e.button === 2) return
if (dragState) return
if (isReadOnly || dragState) return
onCellClick(person.id, d.idx, e.ctrlKey || e.metaKey, e.shiftKey)
}}
onDoubleClick={() => {
if (dragState) return
onStartEdit(person.id, d.idx, value)
onDoubleClick={(e) => {
if (isReadOnly || dragState) return
if (renderAsSplit) {
const rect = e.currentTarget.getBoundingClientRect()
const isLeft = e.clientX - rect.left < rect.width / 2
onStartEdit(person.id, d.idx, isLeft ? dayValue : nightValue, isLeft ? 'day' : 'night')
} else {
onStartEdit(person.id, d.idx, value, 'cell')
}
}}
onContextMenu={(e) => {
e.preventDefault()
@@ -205,10 +255,31 @@ const PersonRow = memo(function PersonRow({
(hasCellComment ? `\n💬 ${cellComments.get(commentKey)}` : '')
}
>
{/* Split mode: each half renders its own bg + text. Day = LEFT, Night = RIGHT. */}
{renderAsSplit && (
<>
<div className="absolute top-0 left-0 h-full flex items-center justify-center pointer-events-none border-r border-slate-500/40"
style={{
width: '50%',
backgroundColor: dayBg ?? 'transparent',
color: dayTextColor ?? undefined,
}}>
{dayValue && <span className="font-mono font-bold text-[11px]">{dayValue}</span>}
</div>
<div className="absolute top-0 right-0 h-full flex items-center justify-center pointer-events-none"
style={{
width: '50%',
backgroundColor: nightBg ?? 'transparent',
color: nightTextColor ?? undefined,
}}>
{nightValue && <span className="font-mono font-bold text-[11px]">{nightValue}</span>}
</div>
</>
)}
{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"
className="w-full h-full bg-white text-[#0f172a] text-center text-sm font-mono font-bold outline-none border-2 border-blue-500 relative z-10"
style={{ width: CELL_W, height: CELL_H }}
value={editingCell!.value}
onChange={(e) => onEditChange(e.target.value)}
@@ -223,9 +294,9 @@ const PersonRow = memo(function PersonRow({
) : (
<>
{isDragPreview ? (
<span className="truncate text-amber-300 font-semibold">{dragState!.value}</span>
) : value && !isValueHidden ? (
<span className="truncate font-medium">{value}</span>
<span className="truncate text-amber-300 font-semibold relative z-10">{dragState!.value}</span>
) : value && !isValueHidden && !renderAsSplit ? (
<span className="truncate font-medium relative z-10">{value}</span>
) : null}
</>
)}
@@ -259,12 +330,13 @@ const PersonRow = memo(function PersonRow({
<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' : ''}`}
border-r border-slate-600
${isReadOnly ? 'cursor-default' : 'cursor-text hover:brightness-125'}
${!pmsColor ? 'pms-cell' : ''}`}
style={pmsStyle}
onDoubleClick={() => onStartEdit(person.id, pmsDayIdx, pmsValue)}
onDoubleClick={() => { if (!isReadOnly) onStartEdit(person.id, pmsDayIdx, pmsValue) }}
onClick={() => {
if (!isPmsEditing) onStartEdit(person.id, pmsDayIdx, pmsValue)
if (!isReadOnly && !isPmsEditing) onStartEdit(person.id, pmsDayIdx, pmsValue)
}}
onContextMenu={(e) => {
e.preventDefault()
@@ -275,7 +347,7 @@ const PersonRow = memo(function PersonRow({
{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"
className="w-full h-full bg-white text-[#0f172a] 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)}
@@ -309,10 +381,12 @@ export function ScheduleTable(props: ScheduleTableProps) {
dayIndex, people, tunnelClosures, tunnelColors,
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
dayComments, cellComments,
dragState, onCellPointerDown, onSetCell, onSetTunnelClosure,
dragState, onCellPointerDown, onSetCell, onSetCellHalfValue, onSetTunnelClosure,
onSetMetroClosure, onSetD8Closure, onSetSazltClosure,
showMetro, showD8, showSazlt, hide162, hiddenValues,
showMetro, showD8, showSazlt, showIT, hide162, hiddenTkbPeople, hiddenValues,
scrollRef, onContextMenu, onTunnelContextMenu, onInfoRowContextMenu, compareData,
isReadOnly = false,
clipboardSource, hasClipboard, onCopyCell, onPasteCells, onClearClipboard,
} = props
const [editingCell, setEditingCell] = useState<EditingCell | null>(null)
@@ -372,9 +446,11 @@ export function ScheduleTable(props: ScheduleTableProps) {
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])
let all = people.filter(p => p.group === 'TKB')
if (hide162) all = all.filter(p => !HIDE_162_NAMES.includes(p.name))
if (hiddenTkbPeople.size > 0) all = all.filter(p => !hiddenTkbPeople.has(p.id))
return all
}, [people, hide162, hiddenTkbPeople])
const itPeople = useMemo(() => people.filter(p => p.group === 'IT'), [people])
// ---------- PMS (Práce mimo směnu) month-end positions ----------
@@ -425,8 +501,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
// ---------- Editing handlers ----------
const onStartEdit = useCallback((personId: string, dayIdx: number, currentValue: string) => {
setEditingCell({ personId, dayIdx, value: currentValue })
const onStartEdit = useCallback((personId: string, dayIdx: number, currentValue: string, target: 'cell' | 'day' | 'night' = 'cell') => {
setEditingCell({ personId, dayIdx, value: currentValue, target })
}, [])
const onStartEditTunnel = useCallback((dayIdx: number, currentValue: string) => {
@@ -461,10 +537,15 @@ export function ScheduleTable(props: ScheduleTableProps) {
} else if (editingCell.personId === '__sazlt__') {
onSetSazltClosure(editingCell.dayIdx, trimmed || null)
} else {
onSetCell(editingCell.personId, editingCell.dayIdx, trimmed || null)
const target = editingCell.target ?? 'cell'
if (target === 'cell') {
onSetCell(editingCell.personId, editingCell.dayIdx, trimmed || null)
} else {
onSetCellHalfValue(editingCell.personId, editingCell.dayIdx, target, trimmed || null)
}
}
setEditingCell(null)
}, [editingCell, onSetCell, onSetTunnelClosure, onSetMetroClosure, onSetD8Closure, onSetSazltClosure])
}, [editingCell, onSetCell, onSetCellHalfValue, onSetTunnelClosure, onSetMetroClosure, onSetD8Closure, onSetSazltClosure])
const onEditCancel = useCallback(() => {
setEditingCell(null)
@@ -479,31 +560,41 @@ export function ScheduleTable(props: ScheduleTableProps) {
const cellKey = (personId: string, dayIdx: number) => `${personId}-${dayIdx}`
// Unified row-order that includes info rows + person rows (used for rectangle selection).
const allRowKeys = useMemo(() => {
const rows: string[] = ['__tunnel__']
if (showSazlt) rows.push('__sazlt__')
if (showMetro) rows.push('__metro__')
if (showD8) rows.push('__d8__')
rows.push(...tkbPeople.map(p => p.id), ...itPeople.map(p => p.id))
return rows
}, [showSazlt, showMetro, showD8, tkbPeople, itPeople])
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 anchorIdx = allRowKeys.indexOf(anchor.personId)
const currentIdx = allRowKeys.indexOf(current.personId)
if (anchorIdx < 0 || currentIdx < 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 minP = Math.min(anchorIdx, currentIdx)
const maxP = Math.max(anchorIdx, currentIdx)
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))
sel.add(cellKey(allRowKeys[pi], dayIndex[di].idx))
}
}
return sel
}, [people, dayIndex])
}, [allRowKeys, dayIndex])
const onCellClick = useCallback((personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => {
const key = cellKey(personId, dayIdx)
@@ -571,17 +662,76 @@ export function ScheduleTable(props: ScheduleTableProps) {
prevDragRef.current = dragState
}, [dragState])
// Clear selection on Escape
// Keyboard: Escape clears selection + clipboard, Ctrl+C/V copy/paste
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
// Skip if focus is in an input/textarea
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if (e.key === 'Escape') {
setSelectedCells(new Set())
selectionAnchorRef.current = null
onClearClipboard()
return
}
if (isReadOnly) return
const isCtrl = e.ctrlKey || e.metaKey
if (isCtrl && (e.key === 'c' || e.key === 'C')) {
if (selectedCells.size === 1) {
const key = Array.from(selectedCells)[0]
const lastDash = key.lastIndexOf('-')
const personId = key.substring(0, lastDash)
const dayIdx = Number(key.substring(lastDash + 1))
onCopyCell(personId, dayIdx)
e.preventDefault()
}
} else if (isCtrl && (e.key === 'v' || e.key === 'V')) {
if (hasClipboard && selectedCells.size > 0) {
const targets: { personId: string; dayIdx: number }[] = []
selectedCells.forEach(key => {
const lastDash = key.lastIndexOf('-')
targets.push({ personId: key.substring(0, lastDash), dayIdx: Number(key.substring(lastDash + 1)) })
})
onPasteCells(targets)
e.preventDefault()
}
}
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [])
}, [selectedCells, hasClipboard, isReadOnly, onCopyCell, onPasteCells, onClearClipboard])
// Pan/grab behavior for read-only mode
useEffect(() => {
if (!isReadOnly) return
const el = scrollRef.current
if (!el) return
let startX = 0, startScrollLeft = 0, isPanning = false
const onDown = (e: PointerEvent) => {
if (e.button !== 0) return
isPanning = true
startX = e.clientX
startScrollLeft = el.scrollLeft
}
const onMove = (e: PointerEvent) => {
if (!isPanning) return
el.scrollLeft = startScrollLeft - (e.clientX - startX)
}
const onUp = () => { isPanning = false }
el.addEventListener('pointerdown', onDown, { capture: true })
document.addEventListener('pointermove', onMove)
document.addEventListener('pointerup', onUp)
document.addEventListener('pointercancel', onUp)
return () => {
el.removeEventListener('pointerdown', onDown, { capture: true })
document.removeEventListener('pointermove', onMove)
document.removeEventListener('pointerup', onUp)
document.removeEventListener('pointercancel', onUp)
}
}, [isReadOnly, scrollRef])
// Context menu handler wrapping the prop to include selected cells
const handlePersonContextMenu = useCallback((dayIdx: number, personId: string | null, x: number, y: number) => {
@@ -608,6 +758,22 @@ export function ScheduleTable(props: ScheduleTableProps) {
}
}, [selectedCells, onContextMenu])
// Context menu handler for info rows — packages current selection like handlePersonContextMenu
const handleInfoRowContext = useCallback((rowId: string | null, dayIdx: number, x: number, y: number) => {
const key = `${rowId ?? '__tunnel__'}-${dayIdx}`
const cells: SelectedCell[] = []
selectedCells.forEach(k => {
const lastDash = k.lastIndexOf('-')
cells.push({ personId: k.substring(0, lastDash), dayIdx: Number(k.substring(lastDash + 1)) })
})
const toPass = selectedCells.has(key) && selectedCells.size > 1 ? cells : [{ personId: rowId ?? '__tunnel__', dayIdx }]
if (rowId === null) {
onTunnelContextMenu(dayIdx, x, y, toPass)
} else {
onInfoRowContextMenu(dayIdx, rowId, x, y, toPass)
}
}, [selectedCells, onTunnelContextMenu, onInfoRowContextMenu])
// ---------- Drag overlay ----------
const dragOverlay = useMemo(() => {
@@ -727,7 +893,6 @@ export function ScheduleTable(props: ScheduleTableProps) {
rowId: '__tunnel__' | '__metro__' | '__d8__' | '__sazlt__',
closures: Map<number, string>,
colors: Map<number, string>,
defaultBgClass: string,
borderClass: string,
editBorderColor: string,
label: string,
@@ -745,21 +910,48 @@ export function ScheduleTable(props: ScheduleTableProps) {
if (closureColor) {
rowBg.backgroundColor = closureColor
rowBg.color = getContrastColor(closureColor)
} else if (closureVal) {
const valStyle = getCellStyle(closureVal)
if (valStyle) {
rowBg.backgroundColor = valStyle.bg
rowBg.color = valStyle.text
}
}
const cellSelKey = `${rowId}-${d.idx}`
const isInfoSelected = selectedCells.has(cellSelKey)
const isInfoClipboardSource = !!clipboardSource && clipboardSource.personId === rowId && clipboardSource.dayIdx === d.idx
const infoExtraStyle: React.CSSProperties = {}
if (isInfoClipboardSource && !isInfoSelected) {
infoExtraStyle.outline = '2px dashed #60a5fa'
infoExtraStyle.outlineOffset = '-2px'
}
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') : ''}
border-r border-slate-500/40
${!closureColor && !getCellStyle(closureVal) ? (isOff ? 'bg-slate-700/30' : 'bg-slate-800/30') : ''}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
cursor-pointer hover:brightness-125
${isReadOnly ? 'cursor-default' : 'cursor-pointer hover:brightness-125'}
${isInfoSelected ? 'ring-2 ring-blue-500 ring-inset z-10' : ''}
`}
style={{ width: CELL_W, height: 28, ...rowBg }}
onClick={() => {
if (!isEditingThis) onStartEditFn(d.idx, closureVal)
style={{ width: CELL_W, height: 28, ...rowBg, ...infoExtraStyle }}
onPointerDown={(e) => {
if (e.button === 2 || isReadOnly) return
if (e.ctrlKey || e.metaKey || e.shiftKey) return
onCellDragSelectStart(rowId, d.idx)
}}
onPointerEnter={(e) => {
if (!isReadOnly && e.buttons === 1) onCellDragSelectMove(rowId, d.idx)
}}
onClick={(e) => {
if (e.button === 2 || isReadOnly) return
onCellClick(rowId, d.idx, e.ctrlKey || e.metaKey, e.shiftKey)
}}
onDoubleClick={() => {
if (!isReadOnly && !isEditingThis) onStartEditFn(d.idx, closureVal)
}}
onContextMenu={(e) => {
e.preventDefault()
@@ -770,7 +962,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
{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}`}
className={`w-full h-full bg-white text-[#0f172a] text-center text-xs font-mono font-bold outline-none border-2 ${editBorderColor}`}
value={editingCell!.value}
onChange={(e) => onEditChange(e.target.value)}
onKeyDown={(e) => {
@@ -787,7 +979,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
)
if (pmsAfterSet.has(i)) {
cells.push(
<div key={`pms-info-${rowId}-${i}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 28 }} />
<div key={`pms-info-${rowId}-${i}`} className="pms-cell" style={{ width: CELL_W, height: 28 }} />
)
}
return cells
@@ -808,7 +1000,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
)
return (
<div className="rounded-lg border border-slate-700 bg-slate-800/50 overflow-hidden">
<div className="rounded-lg border border-slate-700 bg-slate-800/50 schedule-wrapper overflow-hidden">
<div className="flex">
{/* === Fixed left name column === */}
@@ -843,8 +1035,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
{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 }}>
{tkbPeople.map((person, idx) => (
<div key={person.id} className={`border-b border-slate-700 flex items-center px-3 ${idx % 2 === 1 ? 'bg-slate-500/20' : 'bg-slate-800'}`} 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>}
@@ -853,11 +1045,11 @@ export function ScheduleTable(props: ScheduleTableProps) {
))}
{/* IT section header */}
{renderSectionHeaderLabel('Pohotovost IT', 'bg-teal-700/60', 'text-teal-100')}
{showIT && 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 }}>
{showIT && itPeople.map((person, idx) => (
<div key={person.id} className={`border-b border-slate-700 flex items-center px-3 ${idx % 2 === 1 ? 'bg-slate-500/20' : 'bg-slate-800'}`} 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>}
@@ -867,7 +1059,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
</div>
{/* === Scrollable grid === */}
<div ref={scrollRef} className="overflow-x-auto flex-1">
<div ref={scrollRef} className={`overflow-x-auto flex-1 ${isReadOnly ? 'cursor-grab active:cursor-grabbing' : ''}`}>
<div style={{ width: totalGridW, position: 'relative' }}>
{/* Month header row */}
@@ -920,7 +1112,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
)
}
fragments.push(
<div key={`w-${i}-pms-${pmsPos}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 24 }} />
<div key={`w-${i}-pms-${pmsPos}`} className="pms-week" style={{ width: CELL_W, height: 24 }} />
)
fragStart = pmsPos + 1
}
@@ -974,18 +1166,17 @@ export function ScheduleTable(props: ScheduleTableProps) {
>
{/* 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"
className="absolute flex items-center justify-center border-l border-r border-slate-600 overflow-hidden pms-label"
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"
className="text-[10px] font-bold pms-label-text pointer-events-none select-none"
style={{
writingMode: 'vertical-rl',
transform: 'rotate(180deg)',
@@ -1021,7 +1212,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
)
if (pmsAfterSet.has(i)) {
cells.push(
<div key={`pms-dayname-${i}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 24 }} />
<div key={`pms-dayname-${i}`} className="pms-cell" style={{ width: CELL_W, height: 24 }} />
)
}
return cells
@@ -1030,23 +1221,23 @@ export function ScheduleTable(props: ScheduleTableProps) {
{/* TKB info row */}
{renderInfoRowCells('__tunnel__', tunnelClosures, tunnelColors,
'bg-orange-700/40 text-orange-200', 'border-slate-700', 'border-orange-400', 'TKB',
onTunnelContextMenu, onStartEditTunnel)}
'border-slate-700', 'border-orange-400', 'TKB',
(dayIdx, x, y) => handleInfoRowContext(null, dayIdx, x, y), 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)}
'border-slate-700', 'border-amber-400', 'SAZLT',
(dayIdx, x, y) => handleInfoRowContext('sazlt', dayIdx, 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)}
'border-slate-700', 'border-blue-400', 'Metro',
(dayIdx, x, y) => handleInfoRowContext('metro', dayIdx, 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)}
'border-slate-600', 'border-green-400', 'D8',
(dayIdx, x, y) => handleInfoRowContext('d8', dayIdx, x, y), onStartEditD8)}
{/* Data rows container (with overlay) */}
<div style={{ position: 'relative' }}>
@@ -1055,9 +1246,10 @@ export function ScheduleTable(props: ScheduleTableProps) {
{renderSectionHeaderBar('bg-indigo-700/60')}
{/* TKB person rows */}
{tkbPeople.map((person) => (
{tkbPeople.map((person, idx) => (
<PersonRow
key={person.id}
rowIndex={idx}
person={person}
dayIndex={dayIndex}
dragState={dragState}
@@ -1078,16 +1270,19 @@ export function ScheduleTable(props: ScheduleTableProps) {
onCellDragSelectMove={onCellDragSelectMove}
pmsAfterSet={pmsAfterSet}
pmsMonthAtPos={pmsMonthAtPos}
isReadOnly={isReadOnly}
clipboardSource={clipboardSource}
/>
))}
{/* IT section header bar */}
{renderSectionHeaderBar('bg-teal-700/60')}
{showIT && renderSectionHeaderBar('bg-teal-700/60')}
{/* IT person rows */}
{itPeople.map((person) => (
{showIT && itPeople.map((person, idx) => (
<PersonRow
key={person.id}
rowIndex={idx}
person={person}
dayIndex={dayIndex}
dragState={dragState}
@@ -1108,6 +1303,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
onCellDragSelectMove={onCellDragSelectMove}
pmsAfterSet={pmsAfterSet}
pmsMonthAtPos={pmsMonthAtPos}
isReadOnly={isReadOnly}
clipboardSource={clipboardSource}
/>
))}