Files
TKB_plan/web/src/ContextMenu.tsx
Docker Config Backup b4158d687f 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>
2026-04-02 09:48:38 +02:00

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">&times;</span>}
{isActive && color && (
<span className="text-[10px]" style={{ color: color === '#FFFF00' || color === '#E0E0E0' || color === '#87CEEB' ? '#333' : '#fff' }}>&#10003;</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">&#9654;</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">&#9654;</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">&#9654;</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">&#9654;</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>
)
}