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:
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user