diff --git a/screenshots/Mimo_smenu_1.png b/screenshots/Mimo_smenu_1.png new file mode 100644 index 0000000..6db420d Binary files /dev/null and b/screenshots/Mimo_smenu_1.png differ 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 (