From 3cd05ce0e273928519e5ab2c02488335552a97b8 Mon Sep 17 00:00:00 2001 From: Docker Config Backup Date: Thu, 2 Apr 2026 12:18:40 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Pr=C3=A1ce=20mimo=20sm=C4=9Bnu=20column?= =?UTF-8?q?=20+=20right-click=20menu=20on=20PMS=20cells?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- screenshots/Mimo_smenu_1.png | Bin 0 -> 3771 bytes web/src/App.tsx | 11 +- web/src/ContextMenu.tsx | 7 +- web/src/ScheduleTable.tsx | 243 +++++++++++++++++++++++++++++++---- 4 files changed, 235 insertions(+), 26 deletions(-) create mode 100644 screenshots/Mimo_smenu_1.png diff --git a/screenshots/Mimo_smenu_1.png b/screenshots/Mimo_smenu_1.png new file mode 100644 index 0000000000000000000000000000000000000000..6db420d3dd72149992c09c18d6b5e065fea7e45f GIT binary patch literal 3771 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 kVET_k+W*`!Qn15!K`gh%4P0d--pLjV8( literal 0 HcmV?d00001 diff --git a/web/src/App.tsx b/web/src/App.tsx index 44ff6ab..168c6d5 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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 = { + 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)] diff --git a/web/src/ContextMenu.tsx b/web/src/ContextMenu.tsx index 726adcc..c319171 100644 --- a/web/src/ContextMenu.tsx +++ b/web/src/ContextMenu.tsx @@ -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 = { + 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 diff --git a/web/src/ScheduleTable.tsx b/web/src/ScheduleTable.tsx index 5a9372b..3ccd96d 100644 --- a/web/src/ScheduleTable.tsx +++ b/web/src/ScheduleTable.tsx @@ -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 = { const DAY_NAMES = ['Ne', 'Po', 'Út', 'St', 'Čt', 'Pá', 'So'] +const MONTH_NAMES_SHORT: Record = { + 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 + pmsMonthAtPos: Map }) { const isDragging = dragState?.personId === person.id return (
- {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 = (
) + + 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, +
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 ? ( + 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()} + /> + ) : ( + {pmsValue} + )} +
, + ] })}
) @@ -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() + 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() - 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, ) => (
- {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(
) + if (pmsAfterSet.has(i)) { + cells.push( +
+ ) + } + return cells })}
) @@ -756,7 +857,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
{MONTH_NAMES[m.month]} {m.year}
@@ -765,25 +866,71 @@ export function ScheduleTable(props: ScheduleTableProps) { {/* Week header row */}
- {weekSpans.map((w, i) => ( -
- {w.week} -
- ))} + {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 ( +
+ {w.week} +
+ ) + } + // 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( +
+ {w.week} +
+ ) + } + fragments.push( +
+ ) + fragStart = pmsPos + 1 + } + // Remaining fragment after last PMS + const remaining = spanEnd - fragStart + 1 + if (remaining > 0) { + fragments.push( +
+ {w.week} +
+ ) + } + return {fragments} + })}
- {/* Day number header row */} + {/* Day number header row (DEN) — PMS rotated text appears here */}
- {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(
) + if (pmsAfterSet.has(i)) { + cells.push( +
+ {/* Merged cell overlay covering DEN + DEN T. + all info rows */} +
+ + Práce mimo směnu + +
+
+ ) + } + return cells })}
@@ -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(
) + if (pmsAfterSet.has(i)) { + cells.push( +
+ ) + } + return cells })}
@@ -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 (