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