feat: TKB shift scheduler — personnel shift planning web app
Full rewrite of METRO HMG for TKB tunnel department: - People-based grid (18 TKB + 5 IT), year-long calendar - Color-coded shift values (4/6/8/12/A/B/D/N/U/O) - Drag-and-drop cells, multi-cell selection (click/ctrl/shift/drag) - Right-click context menu with color palette - Tunnel closure + Metro + D8 info rows (toggleable) - Czech holidays highlighted with names - PDF export (2-page A4 landscape, DejaVu font for Czech chars) - Improvement proposals system - Sticky headers (vertical + horizontal scroll) - Cell value filter toggles in legend Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
411
web/src/ContextMenu.tsx
Normal file
411
web/src/ContextMenu.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import type { ContextMenuState, DayInfo } from './types'
|
||||
import type { SelectedCell } from './ScheduleTable'
|
||||
import { COLOR_PALETTE } from './cellColors'
|
||||
|
||||
interface ContextMenuProps {
|
||||
state: ContextMenuState
|
||||
dayInfo: DayInfo
|
||||
personName: string | null
|
||||
cellValue: string | undefined
|
||||
cellColor: string | undefined
|
||||
existingDayComment: string | undefined
|
||||
existingCellComment: string | undefined
|
||||
existingTunnelClosure: string | undefined
|
||||
existingTunnelColor: string | undefined
|
||||
existingInfoRowClosure: string | undefined
|
||||
existingInfoRowColor: string | undefined
|
||||
selectedDays: number[]
|
||||
selectedCells: SelectedCell[]
|
||||
onSetCell: (personId: string, dayIdx: number, value: string | null) => void
|
||||
onAddDayComment: (dayIdx: number, text: string) => void
|
||||
onRemoveDayComment: (dayIdx: number) => void
|
||||
onAddCellComment: (personId: string, dayIdx: number, text: string) => void
|
||||
onRemoveCellComment: (personId: string, dayIdx: number) => void
|
||||
onSetTunnelClosure: (dayIdx: number, text: string | null) => void
|
||||
onSetCellColor: (personId: string, dayIdx: number, color: string | null) => void
|
||||
onSetTunnelClosureColor: (dayIdx: number, color: string | null) => void
|
||||
onSetInfoRowColor: (dayIdx: number, color: string | null) => void
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
export function ContextMenu({
|
||||
state,
|
||||
dayInfo,
|
||||
personName,
|
||||
cellValue,
|
||||
cellColor,
|
||||
existingDayComment,
|
||||
existingCellComment,
|
||||
existingTunnelClosure,
|
||||
existingTunnelColor,
|
||||
existingInfoRowClosure,
|
||||
existingInfoRowColor,
|
||||
selectedDays,
|
||||
selectedCells,
|
||||
onSetCell,
|
||||
onAddDayComment,
|
||||
onRemoveDayComment,
|
||||
onAddCellComment,
|
||||
onRemoveCellComment,
|
||||
onSetTunnelClosure,
|
||||
onSetCellColor,
|
||||
onSetTunnelClosureColor,
|
||||
onSetInfoRowColor,
|
||||
onClose,
|
||||
}: ContextMenuProps) {
|
||||
const isMultiSelect = selectedCells.length > 1
|
||||
const [showCommentInput, setShowCommentInput] = useState(false)
|
||||
const [commentText, setCommentText] = useState('')
|
||||
const [showCellCommentInput, setShowCellCommentInput] = useState(false)
|
||||
const [cellCommentText, setCellCommentText] = useState('')
|
||||
const [cellValueInput, setCellValueInput] = useState(cellValue ?? '')
|
||||
const cellCommentInputRef = useRef<HTMLInputElement>(null)
|
||||
const commentInputRef = useRef<HTMLInputElement>(null)
|
||||
const cellValueInputRef = useRef<HTMLInputElement>(null)
|
||||
const menuRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (showCommentInput) commentInputRef.current?.focus()
|
||||
}, [showCommentInput])
|
||||
|
||||
useEffect(() => {
|
||||
if (showCellCommentInput) cellCommentInputRef.current?.focus()
|
||||
}, [showCellCommentInput])
|
||||
|
||||
useEffect(() => {
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
const handleKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose()
|
||||
}
|
||||
document.addEventListener('mousedown', handleClick)
|
||||
document.addEventListener('keydown', handleKey)
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClick)
|
||||
document.removeEventListener('keydown', handleKey)
|
||||
}
|
||||
}, [onClose])
|
||||
|
||||
const handleAddComment = () => {
|
||||
if (commentText.trim()) {
|
||||
const days = selectedDays.length > 0 ? selectedDays : [state.dayIdx]
|
||||
for (const dayIdx of days) {
|
||||
onAddDayComment(dayIdx, commentText.trim())
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
const handleRemoveComments = () => {
|
||||
const days = selectedDays.length > 0 ? selectedDays : [state.dayIdx]
|
||||
for (const dayIdx of days) {
|
||||
onRemoveDayComment(dayIdx)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleSetCellValue = () => {
|
||||
const val = cellValueInput.trim()
|
||||
if (isMultiSelect) {
|
||||
for (const cell of selectedCells) {
|
||||
onSetCell(cell.personId, cell.dayIdx, val || null)
|
||||
}
|
||||
} else if (state.personId) {
|
||||
onSetCell(state.personId, state.dayIdx, val || null)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleClearCell = () => {
|
||||
if (isMultiSelect) {
|
||||
for (const cell of selectedCells) {
|
||||
onSetCell(cell.personId, cell.dayIdx, null)
|
||||
}
|
||||
} else if (state.personId) {
|
||||
onSetCell(state.personId, state.dayIdx, null)
|
||||
}
|
||||
onClose()
|
||||
}
|
||||
|
||||
const dateStr = `${dayInfo.day}.${dayInfo.month}.${dayInfo.year}`
|
||||
const isTunnelRow = !!state.isTunnelRow
|
||||
const isInfoRow = !!state.infoRowId
|
||||
const infoRowId = state.infoRowId
|
||||
const infoRowLabel = infoRowId === 'metro' ? 'METRO' : infoRowId === 'd8' ? 'D8' : ''
|
||||
const isDenRow = !state.personId && !isTunnelRow && !isInfoRow
|
||||
const multiDayLabel = selectedDays.length > 1 ? ` (${selectedDays.length} dnu)` : ''
|
||||
|
||||
const renderColorPalette = () => {
|
||||
const activeColor = isTunnelRow
|
||||
? existingTunnelColor
|
||||
: isInfoRow
|
||||
? existingInfoRowColor
|
||||
: cellColor
|
||||
|
||||
return (
|
||||
<div className="border-t border-slate-700 px-3 py-2">
|
||||
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1.5">Barva pozadi</div>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{COLOR_PALETTE.map(({ color, label }) => {
|
||||
const isActive = activeColor === color
|
||||
return (
|
||||
<button
|
||||
key={color || 'none'}
|
||||
onClick={() => {
|
||||
if (isTunnelRow) {
|
||||
onSetTunnelClosureColor(state.dayIdx, color || null)
|
||||
} else if (isInfoRow) {
|
||||
onSetInfoRowColor(state.dayIdx, color || null)
|
||||
} else if (isMultiSelect) {
|
||||
for (const cell of selectedCells) {
|
||||
onSetCellColor(cell.personId, cell.dayIdx, color || null)
|
||||
}
|
||||
} else if (state.personId) {
|
||||
onSetCellColor(state.personId, state.dayIdx, color || null)
|
||||
}
|
||||
onClose()
|
||||
}}
|
||||
className="w-6 h-6 rounded border border-slate-600 hover:scale-110 transition-transform cursor-pointer flex items-center justify-center"
|
||||
style={{ backgroundColor: color || '#1e293b' }}
|
||||
title={label}
|
||||
>
|
||||
{!color && <span className="text-[8px] text-slate-400">×</span>}
|
||||
{isActive && color && (
|
||||
<span className="text-[10px]" style={{ color: color === '#FFFF00' || color === '#E0E0E0' || color === '#87CEEB' ? '#333' : '#fff' }}>✓</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed z-[200] bg-slate-800 border border-slate-600 rounded-lg shadow-2xl py-1 min-w-[220px]"
|
||||
style={{ left: state.x, top: state.y }}
|
||||
>
|
||||
<div className="px-3 py-1.5 text-[10px] text-slate-500 border-b border-slate-700">
|
||||
{isTunnelRow ? (
|
||||
<span className="uppercase tracking-wider">UZAVERY</span>
|
||||
) : isInfoRow ? (
|
||||
<span className="uppercase tracking-wider">{infoRowLabel}</span>
|
||||
) : isDenRow ? (
|
||||
<span className="uppercase tracking-wider">DEN{multiDayLabel}</span>
|
||||
) : isMultiSelect ? (
|
||||
<span className="uppercase tracking-wider">Vybrano {selectedCells.length} bunek</span>
|
||||
) : (
|
||||
<span className="uppercase tracking-wider">{personName || state.personId}</span>
|
||||
)}
|
||||
<span className="mx-1">--</span>
|
||||
{dateStr} (T{dayInfo.week})
|
||||
</div>
|
||||
|
||||
{isInfoRow ? (
|
||||
<>
|
||||
{existingInfoRowClosure && (
|
||||
<div className="px-3 py-2 text-xs text-blue-300 border-b border-slate-700">
|
||||
<span className="text-blue-400 mr-1.5">▶</span>
|
||||
{existingInfoRowClosure}
|
||||
</div>
|
||||
)}
|
||||
{renderColorPalette()}
|
||||
</>
|
||||
) : isTunnelRow ? (
|
||||
<>
|
||||
{existingTunnelClosure && (
|
||||
<div className="px-3 py-2 text-xs text-orange-300 border-b border-slate-700">
|
||||
<span className="text-orange-400 mr-1.5">▶</span>
|
||||
{existingTunnelClosure}
|
||||
</div>
|
||||
)}
|
||||
{renderColorPalette()}
|
||||
</>
|
||||
) : isDenRow ? (
|
||||
<>
|
||||
{existingDayComment && !showCommentInput ? (
|
||||
<>
|
||||
<div className="px-3 py-2 text-xs text-blue-300 border-b border-slate-700">
|
||||
<span className="text-blue-400 mr-1.5">▶</span>
|
||||
{existingDayComment}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRemoveComments}
|
||||
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
|
||||
>
|
||||
Odebrat komentar{multiDayLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCommentInput(true); setCommentText(existingDayComment) }}
|
||||
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
|
||||
>
|
||||
Upravit komentar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!showCommentInput ? (
|
||||
<button
|
||||
onClick={() => setShowCommentInput(true)}
|
||||
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
|
||||
>
|
||||
<span className="text-blue-400 mr-1.5">+</span>
|
||||
Pridat komentar{multiDayLabel}
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-3 py-2">
|
||||
<input
|
||||
ref={commentInputRef}
|
||||
type="text"
|
||||
value={commentText}
|
||||
onChange={e => setCommentText(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleAddComment() }}
|
||||
placeholder="Komentar ke dni..."
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-200
|
||||
placeholder-slate-500 outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<button
|
||||
onClick={handleAddComment}
|
||||
disabled={!commentText.trim()}
|
||||
className="px-2.5 py-1 rounded text-[10px] bg-blue-600 text-white hover:bg-blue-500
|
||||
disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
||||
>
|
||||
Pridat{multiDayLabel}
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-2.5 py-1 rounded text-[10px] bg-slate-700 text-slate-300
|
||||
hover:bg-slate-600 cursor-pointer transition-colors"
|
||||
>
|
||||
Zrusit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Cell value editing */}
|
||||
<div className="px-3 py-2 border-b border-slate-700">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
ref={cellValueInputRef}
|
||||
type="text"
|
||||
value={cellValueInput}
|
||||
onChange={e => setCellValueInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') handleSetCellValue() }}
|
||||
placeholder="8, 12, D, N, U, O, D/2, x"
|
||||
className="flex-1 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-200
|
||||
placeholder-slate-500 outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSetCellValue}
|
||||
className="px-2.5 py-1 rounded text-[10px] bg-blue-600 text-white hover:bg-blue-500
|
||||
cursor-pointer transition-colors"
|
||||
>
|
||||
Nastavit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clear cell */}
|
||||
{(cellValue || isMultiSelect) && (
|
||||
<button
|
||||
onClick={handleClearCell}
|
||||
className="w-full text-left px-3 py-2 text-xs text-red-300 hover:bg-slate-700 cursor-pointer transition-colors"
|
||||
>
|
||||
{isMultiSelect ? `Smazat hodnoty (${selectedCells.length} bunek)` : `Smazat hodnotu "${cellValue}"`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cell comment section - hidden for multi-select */}
|
||||
{!isMultiSelect && <div className="border-t border-slate-700">
|
||||
{existingCellComment ? (
|
||||
<>
|
||||
<div className="px-3 py-2 text-xs text-blue-300 border-b border-slate-700">
|
||||
<span className="text-blue-400 mr-1.5">▶</span>
|
||||
{existingCellComment}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { onRemoveCellComment(state.personId!, state.dayIdx); onClose() }}
|
||||
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
|
||||
>
|
||||
Odebrat komentar
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setShowCellCommentInput(true); setCellCommentText(existingCellComment) }}
|
||||
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
|
||||
>
|
||||
Upravit komentar
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!showCellCommentInput ? (
|
||||
<button
|
||||
onClick={() => setShowCellCommentInput(true)}
|
||||
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
|
||||
>
|
||||
<span className="text-blue-400 mr-1.5">+</span>
|
||||
Pridat komentar
|
||||
</button>
|
||||
) : (
|
||||
<div className="px-3 py-2">
|
||||
<input
|
||||
ref={cellCommentInputRef}
|
||||
type="text"
|
||||
value={cellCommentText}
|
||||
onChange={e => setCellCommentText(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter' && cellCommentText.trim() && state.personId) {
|
||||
onAddCellComment(state.personId, state.dayIdx, cellCommentText.trim())
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
placeholder="Komentar k bunce..."
|
||||
className="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-200
|
||||
placeholder-slate-500 outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<div className="flex gap-1.5 mt-1.5">
|
||||
<button
|
||||
onClick={() => {
|
||||
if (cellCommentText.trim() && state.personId) {
|
||||
onAddCellComment(state.personId, state.dayIdx, cellCommentText.trim())
|
||||
onClose()
|
||||
}
|
||||
}}
|
||||
disabled={!cellCommentText.trim()}
|
||||
className="px-2.5 py-1 rounded text-[10px] bg-blue-600 text-white hover:bg-blue-500
|
||||
disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
||||
>
|
||||
Pridat
|
||||
</button>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-2.5 py-1 rounded text-[10px] bg-slate-700 text-slate-300
|
||||
hover:bg-slate-600 cursor-pointer transition-colors"
|
||||
>
|
||||
Zrusit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>}
|
||||
|
||||
{renderColorPalette()}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user