feat: Práce mimo směnu column + right-click menu on PMS cells

- Added PMS summary column after each month's last day
- Merged header cell with rotated "Práce mimo směnu" label spanning
  DEN through D8 rows (no internal borders, overflow hidden)
- PMS cells in person rows are editable (click/double-click)
- Right-click context menu works on PMS cells (value, color, comments)
- PMS values stored as negative month keys in person data
- Dynamic height adjustment when info rows toggled

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Docker Config Backup
2026-04-02 12:18:40 +02:00
parent 785966463c
commit 3cd05ce0e2
4 changed files with 235 additions and 26 deletions

View File

@@ -417,7 +417,16 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
await exportMonthPdf(month, year, data.dayIndex, people, tunnelClosures, tunnelColors)
}, [data.dayIndex, people, tunnelClosures, tunnelColors])
const contextDayInfo = contextMenu ? data.dayIndex.find(d => d.idx === contextMenu.dayIdx) : null
const MONTH_NAMES_FULL: Record<number, string> = {
1:'Leden',2:'Únor',3:'Březen',4:'Duben',5:'Květen',6:'Červen',
7:'Červenec',8:'Srpen',9:'Září',10:'Říjen',11:'Listopad',12:'Prosinec',
}
const contextDayInfo = contextMenu
? contextMenu.dayIdx < 0
// PMS cell — synthetic day info
? { idx: contextMenu.dayIdx, day: 0, month: -contextMenu.dayIdx, year: 2026, week: 0, weekend: false } as import('./types').DayInfo
: data.dayIndex.find(d => d.idx === contextMenu.dayIdx) ?? null
: null
const contextPerson = contextMenu?.personId ? people.find(p => p.id === contextMenu.personId) : null
const contextCellData = contextMenu?.personId
? people.find(p => p.id === contextMenu.personId)?.data[String(contextMenu.dayIdx)]

View File

@@ -131,7 +131,12 @@ export function ContextMenu({
onClose()
}
const dateStr = `${dayInfo.day}.${dayInfo.month}.${dayInfo.year}`
const isPms = dayInfo.idx < 0
const MONTH_NAMES_CTX: Record<number, string> = {
1:'Leden',2:'Únor',3:'Březen',4:'Duben',5:'Květen',6:'Červen',
7:'Červenec',8:'Srpen',9:'Září',10:'Říjen',11:'Listopad',12:'Prosinec',
}
const dateStr = isPms ? `Práce mimo směnu — ${MONTH_NAMES_CTX[dayInfo.month] ?? dayInfo.month}` : `${dayInfo.day}.${dayInfo.month}.${dayInfo.year}`
const isTunnelRow = !!state.isTunnelRow
const isInfoRow = !!state.infoRowId
const infoRowId = state.infoRowId

View File

@@ -1,4 +1,4 @@
import { useMemo, useState, useCallback, useRef, memo, useEffect } from 'react'
import React, { useMemo, useState, useCallback, useRef, memo, useEffect } from 'react'
import type { DayInfo, Person, DragState, ScheduleData } from './types'
import { getCellStyle, getCellStyleForPerson, getContrastColor } from './cellColors'
import { getHolidayMap } from './holidays'
@@ -13,6 +13,11 @@ const MONTH_NAMES: Record<number, string> = {
const DAY_NAMES = ['Ne', 'Po', 'Út', 'St', 'Čt', 'Pá', 'So']
const MONTH_NAMES_SHORT: Record<number, string> = {
1: 'Led', 2: 'Úno', 3: 'Bře', 4: 'Dub', 5: 'Kvě', 6: 'Čvn',
7: 'Čvc', 8: 'Srp', 9: 'Zář', 10: 'Říj', 11: 'Lis', 12: 'Pro',
}
export interface SelectedCell {
personId: string
dayIdx: number
@@ -76,6 +81,8 @@ const PersonRow = memo(function PersonRow({
onCellClick,
onCellDragSelectStart,
onCellDragSelectMove,
pmsAfterSet,
pmsMonthAtPos,
}: {
person: Person
dayIndex: DayInfo[]
@@ -95,12 +102,14 @@ 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
pmsAfterSet: Set<number>
pmsMonthAtPos: Map<number, number>
}) {
const isDragging = dragState?.personId === person.id
return (
<div className="flex border-b border-slate-700/50" style={{ height: CELL_H }}>
{dayIndex.map((d) => {
{dayIndex.map((d, i) => {
const cellData = person.data[String(d.idx)]
const value = cellData?.v ?? ''
const isColorOnly = !value && !!cellData?.color
@@ -156,7 +165,7 @@ const PersonRow = memo(function PersonRow({
className += ' cursor-grab active:cursor-grabbing'
}
return (
const dayCell = (
<div
key={d.idx}
className={className}
@@ -226,6 +235,53 @@ const PersonRow = memo(function PersonRow({
)}
</div>
)
if (!pmsAfterSet.has(i)) return dayCell
// PMS cell after this day (month boundary)
const pmsMonth = pmsMonthAtPos.get(i)!
const pmsDayIdx = -pmsMonth // negative month as special key
const pmsData = person.data[String(pmsDayIdx)]
const pmsValue = pmsData?.v ?? ''
const isPmsEditing = editingCell?.personId === person.id && editingCell?.dayIdx === pmsDayIdx
return [
dayCell,
<div
key={`pms-${pmsMonth}`}
className="flex items-center justify-center text-xs font-mono font-bold
border-r border-slate-600 bg-slate-700/30 cursor-text hover:bg-slate-600/40"
style={{ width: CELL_W, height: CELL_H }}
onDoubleClick={() => onStartEdit(person.id, pmsDayIdx, pmsValue)}
onClick={() => {
if (!isPmsEditing) onStartEdit(person.id, pmsDayIdx, pmsValue)
}}
onContextMenu={(e) => {
e.preventDefault()
onContextMenu(pmsDayIdx, person.id, e.clientX, e.clientY)
}}
title={`PMS ${MONTH_NAMES_SHORT[pmsMonth] ?? pmsMonth}: ${pmsValue || '—'}`}
>
{isPmsEditing ? (
<input
autoFocus
className="w-full h-full bg-white text-slate-900 text-center text-xs 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)}
onKeyDown={(e) => {
if (e.key === 'Enter') onEditConfirm()
if (e.key === 'Escape') onEditCancel()
}}
onBlur={onEditConfirm}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
/>
) : (
<span className="truncate text-slate-300">{pmsValue}</span>
)}
</div>,
]
})}
</div>
)
@@ -302,13 +358,51 @@ export function ScheduleTable(props: ScheduleTableProps) {
const tkbPeople = useMemo(() => people.filter(p => p.group === 'TKB'), [people])
const itPeople = useMemo(() => people.filter(p => p.group === 'IT'), [people])
// ---------- PMS (Práce mimo směnu) month-end positions ----------
const monthEndPositions = useMemo(() => {
const positions: { afterDayPos: number; month: number; year: number }[] = []
for (let i = 0; i < dayIndex.length - 1; i++) {
if (dayIndex[i].month !== dayIndex[i + 1].month) {
positions.push({ afterDayPos: i, month: dayIndex[i].month, year: dayIndex[i].year })
}
}
const last = dayIndex[dayIndex.length - 1]
if (last) positions.push({ afterDayPos: dayIndex.length - 1, month: last.month, year: last.year })
return positions
}, [dayIndex])
// Set of dayIndex positions after which a PMS column appears
const pmsAfterSet = useMemo(() => new Set(monthEndPositions.map(p => p.afterDayPos)), [monthEndPositions])
// Map from dayIndex position to the month number for PMS
const pmsMonthAtPos = useMemo(() => {
const m = new Map<number, number>()
for (const p of monthEndPositions) m.set(p.afterDayPos, p.month)
return m
}, [monthEndPositions])
// ---------- idxToPos for drag overlay ----------
// Maps dayIndex array position to column position (accounting for PMS columns)
const dayPosToColPos = useMemo(() => {
const arr: number[] = []
let pmsCount = 0
for (let i = 0; i < dayIndex.length; i++) {
arr.push(i + pmsCount)
if (pmsAfterSet.has(i)) pmsCount++
}
return arr
}, [dayIndex, pmsAfterSet])
// Maps dayIdx to column position for drag overlay
const idxToPos = useMemo(() => {
const map = new Map<number, number>()
dayIndex.forEach((d, i) => map.set(d.idx, i))
for (let i = 0; i < dayIndex.length; i++) {
map.set(dayIndex[i].idx, dayPosToColPos[i])
}
return map
}, [dayIndex])
}, [dayIndex, dayPosToColPos])
// ---------- Editing handlers ----------
@@ -599,7 +693,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
// ---------- Render ----------
const totalGridW = dayIndex.length * CELL_W
const totalGridW = dayIndex.length * CELL_W + monthEndPositions.length * CELL_W
const nameColW = 200
// Helper: render info row label (left column)
@@ -622,7 +716,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
onStartEditFn: (dayIdx: number, currentValue: string) => void,
) => (
<div className={`flex border-b ${borderClass}`} style={{ height: 28 }}>
{dayIndex.map((d) => {
{dayIndex.map((d, i) => {
const closureVal = closures.get(d.idx) ?? ''
const closureColor = colors.get(d.idx)
const isEditingThis = editingCell?.personId === rowId && editingCell?.dayIdx === d.idx
@@ -634,7 +728,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
rowBg.color = getContrastColor(closureColor)
}
return (
const cells: React.ReactNode[] = []
cells.push(
<div
key={d.idx}
className={`flex items-center justify-center text-xs font-mono font-bold relative
@@ -671,6 +766,12 @@ export function ScheduleTable(props: ScheduleTableProps) {
)}
</div>
)
if (pmsAfterSet.has(i)) {
cells.push(
<div key={`pms-info-${rowId}-${i}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 28 }} />
)
}
return cells
})}
</div>
)
@@ -756,7 +857,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
<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 }}
style={{ width: (m.count + 1) * CELL_W }}
>
{MONTH_NAMES[m.month]} {m.year}
</div>
@@ -765,25 +866,71 @@ export function ScheduleTable(props: ScheduleTableProps) {
{/* 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>
))}
{weekSpans.map((w, i) => {
// Check if any PMS column falls inside this week span
const spanEnd = w.startIdx + w.count - 1
const pmsCols: number[] = []
for (let di = w.startIdx; di <= spanEnd; di++) {
if (pmsAfterSet.has(di)) pmsCols.push(di)
}
if (pmsCols.length === 0) {
return (
<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>
)
}
// Split span around PMS columns
const fragments: React.ReactNode[] = []
let fragStart = w.startIdx
for (const pmsPos of pmsCols) {
const fragCount = pmsPos - fragStart + 1
if (fragCount > 0) {
fragments.push(
<div
key={`w-${i}-f-${fragStart}`}
className="flex items-center justify-center text-[10px] text-slate-400 border-r border-slate-700/50 bg-slate-800/80"
style={{ width: fragCount * CELL_W }}
>
{w.week}
</div>
)
}
fragments.push(
<div key={`w-${i}-pms-${pmsPos}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 24 }} />
)
fragStart = pmsPos + 1
}
// Remaining fragment after last PMS
const remaining = spanEnd - fragStart + 1
if (remaining > 0) {
fragments.push(
<div
key={`w-${i}-f-${fragStart}`}
className="flex items-center justify-center text-[10px] text-slate-400 border-r border-slate-700/50 bg-slate-800/80"
style={{ width: remaining * CELL_W }}
>
{w.week}
</div>
)
}
return <React.Fragment key={`w-${i}`}>{fragments}</React.Fragment>
})}
</div>
{/* Day number header row */}
{/* Day number header row (DEN) — PMS rotated text appears here */}
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
{dayIndex.map((d) => {
{dayIndex.map((d, i) => {
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 (
const cells: React.ReactNode[] = []
cells.push(
<div
key={d.idx}
className={`flex items-center justify-center text-[10px] font-mono relative
@@ -799,6 +946,39 @@ export function ScheduleTable(props: ScheduleTableProps) {
)}
</div>
)
if (pmsAfterSet.has(i)) {
cells.push(
<div
key={`pms-den-${i}`}
className="relative"
style={{ width: CELL_W, height: 28, zIndex: 15 }}
>
{/* 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"
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"
style={{
writingMode: 'vertical-rl',
transform: 'rotate(180deg)',
}}
>
Práce mimo směnu
</span>
</div>
</div>
)
}
return cells
})}
</div>
@@ -807,7 +987,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
{dayIndex.map((d, i) => {
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
const isOff = d.weekend || isHoliday
return (
const cells: React.ReactNode[] = []
cells.push(
<div
key={d.idx}
className={`flex items-center justify-center text-[9px] font-mono
@@ -819,6 +1000,12 @@ export function ScheduleTable(props: ScheduleTableProps) {
{dayNames[i]}
</div>
)
if (pmsAfterSet.has(i)) {
cells.push(
<div key={`pms-dayname-${i}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 24 }} />
)
}
return cells
})}
</div>
@@ -870,6 +1057,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
pmsAfterSet={pmsAfterSet}
pmsMonthAtPos={pmsMonthAtPos}
/>
))}
@@ -898,12 +1087,15 @@ export function ScheduleTable(props: ScheduleTableProps) {
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
pmsAfterSet={pmsAfterSet}
pmsMonthAtPos={pmsMonthAtPos}
/>
))}
{/* Compare ghost blocks */}
{ghostBlocks && ghostBlocks.map((gb, i) => {
const left = gb.startPos * CELL_W
const colPos = dayPosToColPos[gb.startPos] ?? gb.startPos
const left = colPos * CELL_W
const tkbCount = tkbPeople.length
let top: number
if (gb.personIdx < tkbCount) {
@@ -911,7 +1103,10 @@ export function ScheduleTable(props: ScheduleTableProps) {
} else {
top = (1 + tkbCount + 1 + (gb.personIdx - tkbCount)) * CELL_H
}
const width = gb.length * CELL_W
// Ghost block width: count columns between start and end, including any PMS columns in between
const endDayPos = gb.startPos + gb.length - 1
const endColPos = dayPosToColPos[endDayPos] ?? endDayPos
const width = (endColPos - colPos + 1) * CELL_W
const cellStyle = getCellStyle(gb.value)
return (