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:
@@ -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 (
|
||||
|
||||
Reference in New Issue
Block a user