milestone: MVP — stable layout, all core features working
Reverted sticky header attempt back to clean two-column layout. Server now sends no-cache headers for all static files. Working features: - People grid (18 TKB + 5 IT) with year-long calendar - Shift codes (4/6/8/12/A/B/D/N/U/O) with auto-colored backgrounds - Drag-and-drop cells (including color-only cells) - Multi-cell selection (click/ctrl/shift/drag) with bulk operations - Right-click context menu: set value, color palette, comments - Info rows: TKB, Metro, D8 (toggleable) - Czech holidays highlighted with names - Cell value filter toggles in legend - PDF export (2-page A4 landscape, Czech font support) - Improvement proposals system - Auto-contrast text color on manual backgrounds - Opens at current month on load TODO: sticky header (needs different approach) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -374,10 +374,8 @@ app.get('/api/export-excel', (_req, res) => {
|
||||
|
||||
// Serve static files from dist/
|
||||
app.use(express.static(join(__dirname, 'dist'), {
|
||||
setHeaders: (res, path) => {
|
||||
if (path.endsWith('.html')) {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
}
|
||||
setHeaders: (res) => {
|
||||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -72,7 +72,6 @@ const PersonRow = memo(function PersonRow({
|
||||
onCellClick,
|
||||
onCellDragSelectStart,
|
||||
onCellDragSelectMove,
|
||||
nameColW,
|
||||
}: {
|
||||
person: Person
|
||||
dayIndex: DayInfo[]
|
||||
@@ -92,21 +91,11 @@ const PersonRow = memo(function PersonRow({
|
||||
onCellClick: (personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => void
|
||||
onCellDragSelectStart: (personId: string, dayIdx: number) => void
|
||||
onCellDragSelectMove: (personId: string, dayIdx: number) => void
|
||||
nameColW: number
|
||||
}) {
|
||||
const isDragging = dragState?.personId === person.id
|
||||
|
||||
return (
|
||||
<div className="flex border-b border-slate-700" style={{ height: CELL_H }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<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 className="flex border-b border-slate-700/50" style={{ height: CELL_H }}>
|
||||
{dayIndex.map((d) => {
|
||||
const cellData = person.data[String(d.idx)]
|
||||
const value = cellData?.v ?? ''
|
||||
@@ -126,7 +115,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
|
||||
border-r border-slate-700/20
|
||||
${isMonthStart ? 'border-l-2 border-l-slate-500' : ''}`
|
||||
|
||||
if (isDragOriginal) {
|
||||
@@ -517,7 +506,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
||||
}
|
||||
|
||||
const pos = idxToPos.get(dragState.previewIdx) ?? 0
|
||||
const left = 200 + pos * CELL_W // 200 = nameColW
|
||||
const left = pos * CELL_W
|
||||
|
||||
const style = getCellStyle(dragState.value)
|
||||
|
||||
@@ -603,25 +592,26 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
||||
const totalGridW = dayIndex.length * CELL_W
|
||||
const nameColW = 200
|
||||
|
||||
// Helper: render an info row (TKB/Metro/D8 closures)
|
||||
const renderInfoRow = (
|
||||
// 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__',
|
||||
label: string,
|
||||
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 }}>
|
||||
<div
|
||||
className={`sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b ${borderClass} flex items-center px-3 flex-shrink-0`}
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">{label}</span>
|
||||
</div>
|
||||
{dayIndex.map((d) => {
|
||||
const closureVal = closures.get(d.idx) ?? ''
|
||||
const closureColor = colors.get(d.idx)
|
||||
@@ -638,8 +628,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
||||
<div
|
||||
key={d.idx}
|
||||
className={`flex items-center justify-center text-xs font-mono font-bold relative
|
||||
border-r border-slate-700
|
||||
${!closureColor && closureVal ? `${defaultBgClass}` : !closureColor ? (isOff ? 'bg-slate-700' : 'bg-slate-800') : ''}
|
||||
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
|
||||
`}
|
||||
@@ -675,246 +665,263 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
||||
</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 ref={scrollRef} className="rounded-lg border border-slate-700 bg-slate-800/50 overflow-auto" style={{ maxHeight: 'calc(100vh - 160px)' }}>
|
||||
<div style={{ width: nameColW + totalGridW, position: 'relative' }}>
|
||||
<div className="rounded-lg border border-slate-700 bg-slate-800/50 overflow-hidden">
|
||||
<div className="flex">
|
||||
|
||||
{/* === STICKY HEADER ROWS === */}
|
||||
<div className="sticky top-0 z-30 bg-slate-800">
|
||||
|
||||
{/* Month row */}
|
||||
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Měsíc</span>
|
||||
</div>
|
||||
{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 * CELL_W }}
|
||||
>
|
||||
{MONTH_NAMES[m.month]} {m.year}
|
||||
</div>
|
||||
))}
|
||||
{/* === 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">Mesic</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">Tyden</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>
|
||||
|
||||
{/* Week row */}
|
||||
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Týden</span>
|
||||
</div>
|
||||
{weekSpans.map((w, i) => (
|
||||
<div
|
||||
key={`w-${i}`}
|
||||
className="flex items-center justify-center text-[10px] text-slate-400 border-r border-slate-700 bg-slate-800"
|
||||
style={{ width: w.count * CELL_W }}
|
||||
>
|
||||
{w.week}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* TKB info row label */}
|
||||
{renderInfoRowLabel('TKB', 'border-slate-600')}
|
||||
|
||||
{/* Day number row */}
|
||||
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den</span>
|
||||
</div>
|
||||
{dayIndex.map((d) => {
|
||||
const comment = dayComments.get(d.idx)
|
||||
const holidayName = holidays.get(`${d.year}-${d.month}-${d.day}`)
|
||||
const isHoliday = !!holidayName
|
||||
const isOff = d.weekend || isHoliday
|
||||
return (
|
||||
<div
|
||||
key={d.idx}
|
||||
className={`flex items-center justify-center text-[10px] font-mono relative
|
||||
${isOff ? 'text-red-400/70 bg-slate-700' : 'text-slate-400 bg-slate-800'}
|
||||
${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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{/* Metro info row label */}
|
||||
{showMetro && renderInfoRowLabel('Metro', 'border-slate-700')}
|
||||
|
||||
{/* Day name row */}
|
||||
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den t.</span>
|
||||
</div>
|
||||
{dayIndex.map((d, i) => {
|
||||
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
|
||||
const isOff = d.weekend || isHoliday
|
||||
return (
|
||||
<div
|
||||
key={d.idx}
|
||||
className={`flex items-center justify-center text-[9px] font-mono
|
||||
${isOff ? 'text-red-400/70 bg-slate-700' : 'text-slate-500 bg-slate-800'}
|
||||
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
|
||||
`}
|
||||
style={{ width: CELL_W }}
|
||||
>
|
||||
{dayNames[i]}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* TKB closures row */}
|
||||
{renderInfoRow('__tunnel__', 'TKB', tunnelClosures, tunnelColors,
|
||||
'bg-orange-700/40 text-orange-200', 'border-slate-600', 'border-orange-400',
|
||||
onTunnelContextMenu, onStartEditTunnel)}
|
||||
|
||||
{/* Metro row */}
|
||||
{showMetro && renderInfoRow('__metro__', 'Metro', metroClosures, metroColors,
|
||||
'bg-blue-700/40 text-blue-200', 'border-slate-700', 'border-blue-400',
|
||||
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'metro', x, y), onStartEditMetro)}
|
||||
|
||||
{/* D8 row */}
|
||||
{showD8 && renderInfoRow('__d8__', 'D8', d8Closures, d8Colors,
|
||||
'bg-green-700/40 text-green-200', 'border-slate-600', 'border-green-400',
|
||||
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'd8', x, y), onStartEditD8)}
|
||||
|
||||
</div>{/* end sticky header rows */}
|
||||
|
||||
{/* Data rows container (with overlay) */}
|
||||
<div style={{ position: 'relative' }}>
|
||||
{/* D8 info row label */}
|
||||
{showD8 && renderInfoRowLabel('D8', 'border-slate-600')}
|
||||
|
||||
{/* TKB section header */}
|
||||
<div className="flex" style={{ height: CELL_H }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-indigo-700 border-r border-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-xs font-bold text-indigo-100 uppercase tracking-wider">Pohotovost TKB</span>
|
||||
</div>
|
||||
<div
|
||||
className="bg-indigo-700 flex items-center justify-center text-xs font-bold uppercase tracking-wider text-white"
|
||||
style={{ width: totalGridW }}
|
||||
/>
|
||||
</div>
|
||||
{renderSectionHeaderLabel('Pohotovost TKB', 'bg-indigo-700/60', 'text-indigo-100')}
|
||||
|
||||
{/* TKB person rows */}
|
||||
{/* TKB person names */}
|
||||
{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}
|
||||
nameColW={nameColW}
|
||||
/>
|
||||
<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 */}
|
||||
<div className="flex" style={{ height: CELL_H }}>
|
||||
<div
|
||||
className="sticky left-0 z-20 bg-teal-700 border-r border-slate-600 flex items-center px-3 flex-shrink-0"
|
||||
style={{ width: nameColW, minWidth: nameColW }}
|
||||
>
|
||||
<span className="text-xs font-bold text-teal-100 uppercase tracking-wider">Pohotovost IT</span>
|
||||
</div>
|
||||
<div
|
||||
className="bg-teal-700 flex items-center justify-center text-xs font-bold uppercase tracking-wider text-white"
|
||||
style={{ width: totalGridW }}
|
||||
/>
|
||||
</div>
|
||||
{renderSectionHeaderLabel('Pohotovost IT', 'bg-teal-700/60', 'text-teal-100')}
|
||||
|
||||
{/* IT person rows */}
|
||||
{/* IT person names */}
|
||||
{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}
|
||||
nameColW={nameColW}
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
|
||||
{/* Compare ghost blocks */}
|
||||
{ghostBlocks && ghostBlocks.map((gb, i) => {
|
||||
const left = nameColW + gb.startPos * CELL_W
|
||||
// Account for section headers in top position
|
||||
const tkbCount = tkbPeople.length
|
||||
let top: number
|
||||
if (gb.personIdx < tkbCount) {
|
||||
top = (1 + gb.personIdx) * CELL_H // +1 for TKB section header
|
||||
} else {
|
||||
top = (1 + tkbCount + 1 + (gb.personIdx - tkbCount)) * CELL_H // +1 TKB header +1 IT header
|
||||
}
|
||||
const width = gb.length * 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>
|
||||
|
||||
{/* === 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 * 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) => (
|
||||
<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>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Day number header row */}
|
||||
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
|
||||
{dayIndex.map((d) => {
|
||||
const comment = dayComments.get(d.idx)
|
||||
const holidayName = holidays.get(`${d.year}-${d.month}-${d.day}`)
|
||||
const isHoliday = !!holidayName
|
||||
const isOff = d.weekend || isHoliday
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</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
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* TKB info row */}
|
||||
{renderInfoRowCells('__tunnel__', tunnelClosures, tunnelColors,
|
||||
'bg-orange-700/40 text-orange-200', 'border-slate-600', 'border-orange-400', 'TKB',
|
||||
onTunnelContextMenu, onStartEditTunnel)}
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Compare ghost blocks */}
|
||||
{ghostBlocks && ghostBlocks.map((gb, i) => {
|
||||
const left = gb.startPos * 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
|
||||
}
|
||||
const width = gb.length * 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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user