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>
412 lines
16 KiB
TypeScript
412 lines
16 KiB
TypeScript
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>
|
|
)
|
|
}
|