feat: TKB shift scheduler — personnel shift planning web app
Full rewrite of METRO HMG for TKB tunnel department: - People-based grid (18 TKB + 5 IT), year-long calendar - Color-coded shift values (4/6/8/12/A/B/D/N/U/O) - Drag-and-drop cells, multi-cell selection (click/ctrl/shift/drag) - Right-click context menu with color palette - Tunnel closure + Metro + D8 info rows (toggleable) - Czech holidays highlighted with names - PDF export (2-page A4 landscape, DejaVu font for Czech chars) - Improvement proposals system - Sticky headers (vertical + horizontal scroll) - Cell value filter toggles in legend Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
838
docs/plans/2026-04-01-shift-scheduler-rewrite.md
Normal file
838
docs/plans/2026-04-01-shift-scheduler-rewrite.md
Normal file
@@ -0,0 +1,838 @@
|
||||
# TKB Shift Scheduler Rewrite
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Rewrite the METRO HMG station maintenance scheduler into a TKB personnel shift planner where rows are people (grouped TKB + IT), columns are calendar days (year-long), cells contain shift codes that determine background color, with drag-and-drop and comments.
|
||||
|
||||
**Architecture:** React grid with people as rows and calendar days as columns. Cell text (8, 12, D, N, U, etc.) drives background color via a simple lookup map. Two groups (TKB, IT) shown on one view with a visual separator. Tunnel closures displayed as an editable header row. Server remains Express with JSON file storage — Excel import/export deferred to later phase.
|
||||
|
||||
**Tech Stack:** React 19 + TypeScript + Vite + Tailwind CSS (frontend), Express/Node.js (backend), same as existing
|
||||
|
||||
---
|
||||
|
||||
## Data Model Reference
|
||||
|
||||
### Cell Values and Colors (from Excel analysis + dochazka scripts)
|
||||
|
||||
| Value | Meaning | Background Color |
|
||||
|-------|---------|-----------------|
|
||||
| `8` | 8-hour day shift | `#CCFFCC` (light green) |
|
||||
| `12` | 12-hour shift | `#A3D977` (medium green) |
|
||||
| `6` | 6-hour shift | `#E8F5E9` (pale green) |
|
||||
| `4` | 4-hour shift | `#F1F8E9` (very pale green) |
|
||||
| `D` | Dovolená (vacation) | `#FFFF00` (yellow) |
|
||||
| `D/2` | Half-day vacation | `#FFF9C4` (light yellow) |
|
||||
| `N` | Nemocenská (sick) | `#FF0000` / white text (red) |
|
||||
| `U` | Uzavěra (closure) | `#92D050` (bright green) |
|
||||
| `O` | Otčovská (paternity) | `#FFC000` (golden) |
|
||||
| `x` | No VN experience marker | `#E0E0E0` (gray) |
|
||||
| empty | No shift | transparent |
|
||||
|
||||
### People (from Excel sample — March 2026)
|
||||
|
||||
**TKB Group (rows 8-25):**
|
||||
1. Pauzer Libor
|
||||
2. Vörös Pavel
|
||||
3. Janouš Petr
|
||||
4. Franek Lukáš
|
||||
5. Svoboda Daniel
|
||||
6. Dvořák Václav
|
||||
7. Vondrák Pavel
|
||||
8. Čeleda Olda
|
||||
9. Hanzlík Marek
|
||||
10. Kohl David
|
||||
11. Dittrich Vladimír
|
||||
12. Toman Milan
|
||||
13. Glaser Ondřej
|
||||
14. Herbst David
|
||||
15. Ryba Ondřej
|
||||
16. Zábranský Petr
|
||||
17. Žemlička Miroslav
|
||||
18. Teslík Hynek
|
||||
|
||||
**IT Group (rows 31-39):**
|
||||
1. Vörös Pavel (same person, IT duties)
|
||||
2. Janouš Petr
|
||||
3. Glaser Ondřej
|
||||
4. Robert Štefan
|
||||
5. Franek Lukáš
|
||||
|
||||
### Tunnel Closure Codes (row 1 of Excel)
|
||||
SAT, LAT, ZAT-ZTT, ZAT-VTT, ATM, TAT-V, TAT-M — free-text per day
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Rewrite Data Types
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/types.ts` (full rewrite)
|
||||
|
||||
**Step 1: Rewrite types.ts**
|
||||
|
||||
Replace the entire file with the new data model:
|
||||
|
||||
```typescript
|
||||
export interface DayInfo {
|
||||
idx: number
|
||||
day: number
|
||||
month: number
|
||||
year: number
|
||||
week: number
|
||||
weekend: boolean
|
||||
}
|
||||
|
||||
export interface Person {
|
||||
id: string // unique identifier
|
||||
name: string // "Vörös Pavel"
|
||||
group: 'TKB' | 'IT' // section
|
||||
note?: string // e.g. "(NN)", "(VN)", "(all in one)"
|
||||
data: Record<string, CellValue> // dayIdx -> cell value
|
||||
}
|
||||
|
||||
export interface CellValue {
|
||||
v?: string // cell text: "8", "12", "D", "N", "U", etc.
|
||||
}
|
||||
|
||||
export interface TunnelClosure {
|
||||
dayIdx: number
|
||||
text: string // e.g. "SAT", "LAT", "ZAT - ZTT"
|
||||
}
|
||||
|
||||
export interface CellComment {
|
||||
personId: string
|
||||
dayIdx: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface DayComment {
|
||||
dayIdx: number
|
||||
text: string
|
||||
}
|
||||
|
||||
export interface ScheduleData {
|
||||
dayIndex: DayInfo[]
|
||||
people: Person[]
|
||||
tunnelClosures?: TunnelClosure[]
|
||||
dayComments?: DayComment[]
|
||||
cellComments?: CellComment[]
|
||||
}
|
||||
|
||||
export interface DragState {
|
||||
personId: string
|
||||
originalIdx: number
|
||||
previewIdx: number
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface ContextMenuState {
|
||||
x: number
|
||||
y: number
|
||||
dayIdx: number
|
||||
personId: string | null
|
||||
}
|
||||
|
||||
export interface DiffChange {
|
||||
personId: string
|
||||
dayIdx: number
|
||||
type: 'added' | 'removed' | 'changed'
|
||||
oldValue?: string
|
||||
newValue?: string
|
||||
}
|
||||
|
||||
export interface ScheduleFile {
|
||||
id: string
|
||||
name: string
|
||||
createdAt: string
|
||||
modifiedAt: string
|
||||
}
|
||||
|
||||
export interface ScheduleFileWithData extends ScheduleFile {
|
||||
data: ScheduleData
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add web/src/types.ts
|
||||
git commit -m "refactor: rewrite types for TKB shift scheduler (people, groups, shift codes)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create Cell Color Map Utility
|
||||
|
||||
**Files:**
|
||||
- Create: `web/src/cellColors.ts`
|
||||
|
||||
**Step 1: Create cellColors.ts**
|
||||
|
||||
```typescript
|
||||
// Cell value -> background color + text color mapping
|
||||
export interface CellStyle {
|
||||
bg: string
|
||||
text: string
|
||||
label: string
|
||||
}
|
||||
|
||||
const CELL_STYLES: Record<string, CellStyle> = {
|
||||
'4': { bg: '#F1F8E9', text: '#333', label: '4h směna' },
|
||||
'6': { bg: '#E8F5E9', text: '#333', label: '6h směna' },
|
||||
'8': { bg: '#CCFFCC', text: '#333', label: '8h směna' },
|
||||
'12': { bg: '#A3D977', text: '#333', label: '12h směna' },
|
||||
'D': { bg: '#FFFF00', text: '#333', label: 'Dovolená' },
|
||||
'D/2': { bg: '#FFF9C4', text: '#333', label: 'Půl den dovolená' },
|
||||
'N': { bg: '#FF4444', text: '#fff', label: 'Nemocenská' },
|
||||
'U': { bg: '#92D050', text: '#333', label: 'Uzavěra' },
|
||||
'O': { bg: '#FFC000', text: '#333', label: 'Otčovská' },
|
||||
'x': { bg: '#E0E0E0', text: '#999', label: 'Bez zkušeností VN' },
|
||||
}
|
||||
|
||||
export function getCellStyle(value: string | undefined): CellStyle | null {
|
||||
if (!value) return null
|
||||
const v = value.trim()
|
||||
return CELL_STYLES[v] ?? null
|
||||
}
|
||||
|
||||
export function getAllCellStyles(): Record<string, CellStyle> {
|
||||
return { ...CELL_STYLES }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add web/src/cellColors.ts
|
||||
git commit -m "feat: add cell color mapping for shift codes (D, N, U, hours)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create Default Data
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/data.json` (full rewrite)
|
||||
|
||||
**Step 1: Write a script to generate data.json**
|
||||
|
||||
Create a temporary Node script `web/generate-data.js`:
|
||||
|
||||
```javascript
|
||||
// Generates default data.json with year-long calendar and people list
|
||||
const people = [
|
||||
// TKB group
|
||||
{ id: 'tkb-pauzer', name: 'Pauzer Libor', group: 'TKB', note: 'all in one' },
|
||||
{ id: 'tkb-voros', name: 'Vörös Pavel', group: 'TKB', note: 'NN' },
|
||||
{ id: 'tkb-janous', name: 'Janouš Petr', group: 'TKB', note: 'VN' },
|
||||
{ id: 'tkb-franek', name: 'Franek Lukáš', group: 'TKB', note: 'NN' },
|
||||
{ id: 'tkb-svoboda', name: 'Svoboda Daniel', group: 'TKB' },
|
||||
{ id: 'tkb-dvorak', name: 'Dvořák Václav', group: 'TKB', note: 'VN' },
|
||||
{ id: 'tkb-vondrak', name: 'Vondrák Pavel', group: 'TKB', note: 'NN' },
|
||||
{ id: 'tkb-celeda', name: 'Čeleda Olda', group: 'TKB', note: 'NN' },
|
||||
{ id: 'tkb-hanzlik', name: 'Hanzlík Marek', group: 'TKB', note: 'VN' },
|
||||
{ id: 'tkb-kohl', name: 'Kohl David', group: 'TKB', note: 'VN' },
|
||||
{ id: 'tkb-dittrich', name: 'Dittrich Vladimír', group: 'TKB', note: 'VN' },
|
||||
{ id: 'tkb-toman', name: 'Toman Milan', group: 'TKB', note: 'VN' },
|
||||
{ id: 'tkb-glaser', name: 'Glaser Ondřej', group: 'TKB', note: 'NN' },
|
||||
{ id: 'tkb-herbst', name: 'Herbst David', group: 'TKB', note: 'NN' },
|
||||
{ id: 'tkb-ryba', name: 'Ryba Ondřej', group: 'TKB', note: 'NN' },
|
||||
{ id: 'tkb-zabransky', name: 'Zábranský Petr', group: 'TKB', note: 'NN' },
|
||||
{ id: 'tkb-zemlicka', name: 'Žemlička Miroslav', group: 'TKB', note: 'NN' },
|
||||
{ id: 'tkb-teslik', name: 'Teslík Hynek', group: 'TKB', note: 'NN' },
|
||||
// IT group
|
||||
{ id: 'it-voros', name: 'Vörös Pavel', group: 'IT' },
|
||||
{ id: 'it-janous', name: 'Janouš Petr', group: 'IT' },
|
||||
{ id: 'it-glaser', name: 'Glaser Ondřej', group: 'IT' },
|
||||
{ id: 'it-stefan', name: 'Robert Štefan', group: 'IT' },
|
||||
{ id: 'it-franek', name: 'Franek Lukáš', group: 'IT' },
|
||||
]
|
||||
|
||||
function getISOWeek(date) {
|
||||
const d = new Date(date.getTime())
|
||||
d.setHours(0, 0, 0, 0)
|
||||
d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7))
|
||||
const week1 = new Date(d.getFullYear(), 0, 4)
|
||||
return 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7)
|
||||
}
|
||||
|
||||
// Generate Jan 1 2026 - Dec 31 2026
|
||||
const dayIndex = []
|
||||
const start = new Date(2026, 0, 1)
|
||||
const end = new Date(2026, 11, 31)
|
||||
let idx = 0
|
||||
const current = new Date(start)
|
||||
while (current <= end) {
|
||||
dayIndex.push({
|
||||
idx,
|
||||
day: current.getDate(),
|
||||
month: current.getMonth() + 1,
|
||||
year: current.getFullYear(),
|
||||
week: getISOWeek(current),
|
||||
weekend: current.getDay() === 0 || current.getDay() === 6,
|
||||
})
|
||||
idx++
|
||||
current.setDate(current.getDate() + 1)
|
||||
}
|
||||
|
||||
const data = {
|
||||
dayIndex,
|
||||
people: people.map(p => ({ ...p, data: {} })),
|
||||
tunnelClosures: [],
|
||||
dayComments: [],
|
||||
cellComments: [],
|
||||
}
|
||||
|
||||
const fs = require('fs')
|
||||
fs.writeFileSync('src/data.json', JSON.stringify(data, null, 2))
|
||||
console.log(`Generated ${dayIndex.length} days, ${people.length} people`)
|
||||
```
|
||||
|
||||
Run: `cd web && node generate-data.js`
|
||||
|
||||
Then delete the generator script.
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add web/src/data.json
|
||||
git commit -m "feat: default data with TKB+IT people list and year-long 2026 calendar"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Rewrite State Management
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/useScheduleState.ts` (full rewrite)
|
||||
- Delete: `web/src/blockParser.ts`
|
||||
- Delete: `web/src/constraints.ts`
|
||||
|
||||
**Step 1: Rewrite useScheduleState.ts**
|
||||
|
||||
Remove all block/constraint logic. The hook manages:
|
||||
- `people` array with per-cell data
|
||||
- `tunnelClosures` map
|
||||
- `dayComments` and `cellComments` maps
|
||||
- `setCell(personId, dayIdx, value)` — set or clear a cell
|
||||
- `moveCell(personId, fromIdx, toIdx)` — move a cell value (for drag)
|
||||
- `setTunnelClosure(dayIdx, text)` — set tunnel closure text
|
||||
- Undo history
|
||||
- Import/export for save/load
|
||||
|
||||
```typescript
|
||||
import { useState, useCallback, useRef } from 'react'
|
||||
import type { Person, DayInfo, TunnelClosure, DayComment, CellComment, ScheduleData } from './types'
|
||||
|
||||
interface ScheduleSnapshot {
|
||||
people: Person[]
|
||||
tunnelClosures: Map<number, string>
|
||||
dayComments: Map<number, string>
|
||||
cellComments: Map<string, string>
|
||||
}
|
||||
|
||||
export function useScheduleState(
|
||||
initialPeople: Person[],
|
||||
dayIndex: DayInfo[],
|
||||
initialTunnelClosures?: TunnelClosure[],
|
||||
initialDayComments?: DayComment[],
|
||||
initialCellComments?: CellComment[],
|
||||
) {
|
||||
const [people, setPeople] = useState<Person[]>(() => JSON.parse(JSON.stringify(initialPeople)))
|
||||
|
||||
const [tunnelClosures, setTunnelClosures] = useState<Map<number, string>>(() => {
|
||||
const m = new Map<number, string>()
|
||||
if (initialTunnelClosures) {
|
||||
for (const tc of initialTunnelClosures) m.set(tc.dayIdx, tc.text)
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
const [dayComments, setDayComments] = useState<Map<number, string>>(() => {
|
||||
const m = new Map<number, string>()
|
||||
if (initialDayComments) {
|
||||
for (const dc of initialDayComments) m.set(dc.dayIdx, dc.text)
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
const [cellComments, setCellComments] = useState<Map<string, string>>(() => {
|
||||
const m = new Map<string, string>()
|
||||
if (initialCellComments) {
|
||||
for (const cc of initialCellComments) m.set(`${cc.personId}-${cc.dayIdx}`, cc.text)
|
||||
}
|
||||
return m
|
||||
})
|
||||
|
||||
const historyRef = useRef<ScheduleSnapshot[]>([])
|
||||
|
||||
const pushHistory = useCallback(() => {
|
||||
historyRef.current.push({
|
||||
people: JSON.parse(JSON.stringify(people)),
|
||||
tunnelClosures: new Map(tunnelClosures),
|
||||
dayComments: new Map(dayComments),
|
||||
cellComments: new Map(cellComments),
|
||||
})
|
||||
if (historyRef.current.length > 50) historyRef.current.shift()
|
||||
}, [people, tunnelClosures, dayComments, cellComments])
|
||||
|
||||
const setCell = useCallback((personId: string, dayIdx: number, value: string | null) => {
|
||||
pushHistory()
|
||||
setPeople(prev => {
|
||||
const next = JSON.parse(JSON.stringify(prev)) as Person[]
|
||||
const person = next.find(p => p.id === personId)
|
||||
if (!person) return prev
|
||||
if (value) {
|
||||
person.data[String(dayIdx)] = { v: value }
|
||||
} else {
|
||||
delete person.data[String(dayIdx)]
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [pushHistory])
|
||||
|
||||
const moveCell = useCallback((personId: string, fromIdx: number, toIdx: number) => {
|
||||
if (fromIdx === toIdx) return
|
||||
pushHistory()
|
||||
setPeople(prev => {
|
||||
const next = JSON.parse(JSON.stringify(prev)) as Person[]
|
||||
const person = next.find(p => p.id === personId)
|
||||
if (!person) return prev
|
||||
const value = person.data[String(fromIdx)]
|
||||
if (!value) return prev
|
||||
delete person.data[String(fromIdx)]
|
||||
person.data[String(toIdx)] = value
|
||||
return next
|
||||
})
|
||||
}, [pushHistory])
|
||||
|
||||
const setTunnelClosure = useCallback((dayIdx: number, text: string | null) => {
|
||||
pushHistory()
|
||||
setTunnelClosures(prev => {
|
||||
const next = new Map(prev)
|
||||
if (text) next.set(dayIdx, text)
|
||||
else next.delete(dayIdx)
|
||||
return next
|
||||
})
|
||||
}, [pushHistory])
|
||||
|
||||
const addDayComment = useCallback((dayIdx: number, text: string) => {
|
||||
pushHistory()
|
||||
setDayComments(prev => new Map(prev).set(dayIdx, text))
|
||||
}, [pushHistory])
|
||||
|
||||
const removeDayComment = useCallback((dayIdx: number) => {
|
||||
pushHistory()
|
||||
setDayComments(prev => {
|
||||
const next = new Map(prev)
|
||||
next.delete(dayIdx)
|
||||
return next
|
||||
})
|
||||
}, [pushHistory])
|
||||
|
||||
const addCellComment = useCallback((personId: string, dayIdx: number, text: string) => {
|
||||
pushHistory()
|
||||
setCellComments(prev => new Map(prev).set(`${personId}-${dayIdx}`, text))
|
||||
}, [pushHistory])
|
||||
|
||||
const removeCellComment = useCallback((personId: string, dayIdx: number) => {
|
||||
pushHistory()
|
||||
setCellComments(prev => {
|
||||
const next = new Map(prev)
|
||||
next.delete(`${personId}-${dayIdx}`)
|
||||
return next
|
||||
})
|
||||
}, [pushHistory])
|
||||
|
||||
const undo = useCallback(() => {
|
||||
const snapshot = historyRef.current.pop()
|
||||
if (!snapshot) return
|
||||
setPeople(snapshot.people)
|
||||
setTunnelClosures(snapshot.tunnelClosures)
|
||||
setDayComments(snapshot.dayComments)
|
||||
setCellComments(snapshot.cellComments)
|
||||
}, [])
|
||||
|
||||
const canUndo = historyRef.current.length > 0
|
||||
|
||||
const getSchedulePayload = useCallback((): ScheduleData => ({
|
||||
dayIndex,
|
||||
people,
|
||||
tunnelClosures: Array.from(tunnelClosures.entries()).map(([dayIdx, text]) => ({ dayIdx, text })),
|
||||
dayComments: Array.from(dayComments.entries()).map(([dayIdx, text]) => ({ dayIdx, text })),
|
||||
cellComments: Array.from(cellComments.entries()).map(([key, text]) => {
|
||||
const [personId, dayIdxStr] = key.split('-')
|
||||
return { personId, dayIdx: parseInt(dayIdxStr), text }
|
||||
}),
|
||||
}), [dayIndex, people, tunnelClosures, dayComments, cellComments])
|
||||
|
||||
return {
|
||||
people,
|
||||
tunnelClosures,
|
||||
dayComments,
|
||||
cellComments,
|
||||
setCell,
|
||||
moveCell,
|
||||
setTunnelClosure,
|
||||
addDayComment,
|
||||
removeDayComment,
|
||||
addCellComment,
|
||||
removeCellComment,
|
||||
undo,
|
||||
canUndo,
|
||||
getSchedulePayload,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Delete obsolete files**
|
||||
```bash
|
||||
rm web/src/blockParser.ts web/src/constraints.ts
|
||||
```
|
||||
|
||||
**Step 3: Commit**
|
||||
```bash
|
||||
git add web/src/useScheduleState.ts
|
||||
git rm web/src/blockParser.ts web/src/constraints.ts
|
||||
git commit -m "refactor: rewrite state management for shift scheduler, remove blocks/constraints"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Rewrite Drag-and-Drop
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/useDragBlock.ts` → rename to `web/src/useDragCell.ts`
|
||||
|
||||
**Step 1: Rewrite as useDragCell.ts**
|
||||
|
||||
Simplified: drags a single cell value from one day column to another for the same person.
|
||||
|
||||
```typescript
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { DragState } from './types'
|
||||
|
||||
interface UseDragCellOptions {
|
||||
scrollRef: React.RefObject<HTMLDivElement | null>
|
||||
cellWidth: number
|
||||
minDayIdx: number
|
||||
maxDayIdx: number
|
||||
onMoveCell: (personId: string, fromIdx: number, toIdx: number) => void
|
||||
}
|
||||
|
||||
export function useDragCell({ scrollRef, cellWidth, minDayIdx, maxDayIdx, onMoveCell }: UseDragCellOptions) {
|
||||
const [dragState, setDragState] = useState<DragState | null>(null)
|
||||
const dragRef = useRef<{
|
||||
personId: string
|
||||
value: string
|
||||
originalIdx: number
|
||||
startX: number
|
||||
scrollLeft: number
|
||||
autoScrollRaf: number | null
|
||||
} | null>(null)
|
||||
|
||||
const onCellPointerDown = useCallback((
|
||||
e: React.PointerEvent,
|
||||
personId: string,
|
||||
dayIdx: number,
|
||||
value: string,
|
||||
) => {
|
||||
e.preventDefault()
|
||||
;(e.target as HTMLElement).setPointerCapture(e.pointerId)
|
||||
const scrollLeft = scrollRef.current?.scrollLeft ?? 0
|
||||
dragRef.current = {
|
||||
personId,
|
||||
value,
|
||||
originalIdx: dayIdx,
|
||||
startX: e.clientX,
|
||||
scrollLeft,
|
||||
autoScrollRaf: null,
|
||||
}
|
||||
setDragState({ personId, originalIdx: dayIdx, previewIdx: dayIdx, value })
|
||||
}, [scrollRef])
|
||||
|
||||
useEffect(() => {
|
||||
if (!dragState) return
|
||||
|
||||
const handleMove = (e: PointerEvent) => {
|
||||
const ref = dragRef.current
|
||||
if (!ref || !scrollRef.current) return
|
||||
const scrollDelta = scrollRef.current.scrollLeft - ref.scrollLeft
|
||||
const dx = e.clientX - ref.startX + scrollDelta
|
||||
const dayOffset = Math.round(dx / cellWidth)
|
||||
const newIdx = Math.max(minDayIdx, Math.min(maxDayIdx, ref.originalIdx + dayOffset))
|
||||
setDragState(prev => prev ? { ...prev, previewIdx: newIdx } : null)
|
||||
|
||||
// Auto-scroll near edges
|
||||
const rect = scrollRef.current.getBoundingClientRect()
|
||||
const margin = 60
|
||||
if (e.clientX < rect.left + margin) {
|
||||
scrollRef.current.scrollLeft -= 4
|
||||
} else if (e.clientX > rect.right - margin) {
|
||||
scrollRef.current.scrollLeft += 4
|
||||
}
|
||||
}
|
||||
|
||||
const handleUp = () => {
|
||||
const ref = dragRef.current
|
||||
const state = dragState
|
||||
if (ref && state && state.previewIdx !== state.originalIdx) {
|
||||
onMoveCell(ref.personId, state.originalIdx, state.previewIdx)
|
||||
}
|
||||
dragRef.current = null
|
||||
setDragState(null)
|
||||
}
|
||||
|
||||
document.addEventListener('pointermove', handleMove)
|
||||
document.addEventListener('pointerup', handleUp)
|
||||
return () => {
|
||||
document.removeEventListener('pointermove', handleMove)
|
||||
document.removeEventListener('pointerup', handleUp)
|
||||
}
|
||||
}, [dragState, cellWidth, minDayIdx, maxDayIdx, scrollRef, onMoveCell])
|
||||
|
||||
return { dragState, onCellPointerDown }
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Delete old file and commit**
|
||||
```bash
|
||||
git rm web/src/useDragBlock.ts
|
||||
git add web/src/useDragCell.ts
|
||||
git commit -m "refactor: replace block drag with single-cell drag for shifts"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Rewrite ScheduleTable
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/ScheduleTable.tsx` (full rewrite)
|
||||
|
||||
**Step 1: Rewrite ScheduleTable.tsx**
|
||||
|
||||
This is the largest component. Key changes:
|
||||
- Rows = people (grouped TKB then IT with separator)
|
||||
- Tunnel closure row at top (editable)
|
||||
- Cell background driven by `getCellStyle()`
|
||||
- Click to edit cell value (inline input)
|
||||
- Comment indicators preserved (blue triangle)
|
||||
- Day comment indicators (blue column header)
|
||||
- Weekend columns dimmed
|
||||
- Month/week headers preserved
|
||||
|
||||
The component should render:
|
||||
```
|
||||
[Fixed left column: person names] [Scrollable grid]
|
||||
Header rows:
|
||||
- Month spans
|
||||
- Week numbers
|
||||
- Day numbers
|
||||
- Day names (Po, Út, St, Čt, Pá, So, Ne)
|
||||
- Tunnel closures (editable)
|
||||
Body rows:
|
||||
- TKB section label
|
||||
- Person rows (TKB group)
|
||||
- IT section label (visual separator)
|
||||
- Person rows (IT group)
|
||||
```
|
||||
|
||||
Cell interactions:
|
||||
- **Click**: Opens inline text input to type value (8, 12, D, N, U, etc.)
|
||||
- **Double-click or Enter**: Confirms value, applies color
|
||||
- **Drag**: Moves cell value to another day
|
||||
- **Right-click**: Context menu for comments
|
||||
- **Escape**: Cancels edit
|
||||
|
||||
Cell width: 32px (same as original). Person name column: ~200px.
|
||||
|
||||
Full implementation is large — write the complete component with:
|
||||
1. Header rendering (months, weeks, days, day names, tunnel closures)
|
||||
2. Person row rendering with cell color coding
|
||||
3. Group separators between TKB and IT
|
||||
4. Inline cell editing
|
||||
5. Drag-and-drop support
|
||||
6. Comment indicators
|
||||
7. Weekend dimming
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add web/src/ScheduleTable.tsx
|
||||
git commit -m "feat: rewrite schedule grid for person-based shift planning"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Rewrite Context Menu
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/ContextMenu.tsx`
|
||||
|
||||
**Step 1: Rewrite ContextMenu**
|
||||
|
||||
Simplified menu:
|
||||
- For **day header**: add/edit/remove day comment
|
||||
- For **tunnel closure row**: edit tunnel closure text
|
||||
- For **person cell**:
|
||||
- Set cell value (text input for shift code)
|
||||
- Add/edit/remove cell comment
|
||||
- Clear cell
|
||||
|
||||
Remove all obstacle and s/v logic.
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add web/src/ContextMenu.tsx
|
||||
git commit -m "refactor: simplify context menu for shift editing and comments"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Rewrite Toolbar
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/Toolbar.tsx`
|
||||
|
||||
**Step 1: Rewrite Toolbar**
|
||||
|
||||
Remove: phase filters, compressed view toggle, constraint violation badges.
|
||||
Keep: Save, Save As, Undo, Export Excel buttons, save status indicator.
|
||||
Add: Color legend showing all shift codes and their colors (from `getAllCellStyles()`).
|
||||
Add: Month quick-jump buttons (Leden through Prosinec for year-long view).
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add web/src/Toolbar.tsx
|
||||
git commit -m "refactor: simplify toolbar — save/undo/legend/month navigation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Rewrite App.tsx
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/src/App.tsx`
|
||||
|
||||
**Step 1: Update App.tsx**
|
||||
|
||||
Changes:
|
||||
- Replace `Station` references with `Person`
|
||||
- Replace `useScheduleState` call parameters (people instead of stations, tunnelClosures)
|
||||
- Replace `useDragBlock` with `useDragCell`
|
||||
- Remove `blocks`, `violations`, `moveBlock`, `moveSV`, `activeFilters`, `compressedView`
|
||||
- Update `normalizeDayIndex` to cover full year (Jan 1 - Dec 31 2026)
|
||||
- Update header text: "TKB Plán služeb" instead of "HMG Profylaxe"
|
||||
- Remove `ALL_PHASES` constant
|
||||
- Pass `tunnelClosures` and `setTunnelClosure` to ScheduleTable
|
||||
- Update `ScheduleApp` props and wiring
|
||||
|
||||
**Step 2: Update Login.tsx credentials**
|
||||
- Change username to `tkb`, password to `sluzby` (or keep configurable)
|
||||
- Update title text
|
||||
|
||||
**Step 3: Update index.html title**
|
||||
```html
|
||||
<title>TKB Plán služeb</title>
|
||||
```
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add web/src/App.tsx web/src/Login.tsx web/index.html
|
||||
git commit -m "feat: wire up TKB shift scheduler app shell"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Update Server
|
||||
|
||||
**Files:**
|
||||
- Modify: `web/server.js`
|
||||
|
||||
**Step 1: Update server.js**
|
||||
|
||||
Changes:
|
||||
- Remove `STATION_ROWS` constant
|
||||
- Update validation: check for `people` instead of `stations` in data payload (support both for backward compat during transition)
|
||||
- Update console log message: "TKB server running on..."
|
||||
- Comment out or simplify Excel export/import (will need new Python scripts later)
|
||||
- Keep all file management endpoints unchanged
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add web/server.js
|
||||
git commit -m "refactor: update server for people-based data model"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 11: Remove Obsolete Excel Scripts
|
||||
|
||||
**Files:**
|
||||
- Delete or stub: `web/export_excel.py`, `web/import_excel.py`
|
||||
- Modify: `web/src/excelIO.ts`
|
||||
|
||||
**Step 1: Stub excelIO.ts**
|
||||
|
||||
Remove the old station-based import. Create a minimal placeholder that can be expanded later:
|
||||
|
||||
```typescript
|
||||
// Excel import/export — to be implemented for TKB shift format
|
||||
export function importFromExcel(_file: File): Promise<null> {
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Commit**
|
||||
```bash
|
||||
git add web/src/excelIO.ts
|
||||
git commit -m "chore: stub excel import/export for future TKB format"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 12: Build and Test
|
||||
|
||||
**Step 1: Install dependencies and build**
|
||||
```bash
|
||||
cd web && npm install && npx vite build
|
||||
```
|
||||
|
||||
**Step 2: Fix any TypeScript errors**
|
||||
|
||||
Iterate on compilation errors — the most likely issues:
|
||||
- Import paths (useDragBlock → useDragCell)
|
||||
- Property names (stationCode → personId, stations → people)
|
||||
- Removed types (StationBlock, ConstraintViolation)
|
||||
|
||||
**Step 3: Start dev server and verify**
|
||||
```bash
|
||||
npm run dev
|
||||
# In parallel: node server.js
|
||||
```
|
||||
|
||||
Test:
|
||||
1. Login works
|
||||
2. Can create new file
|
||||
3. Grid shows TKB people, then separator, then IT people
|
||||
4. Can click cell and type "8" → green background appears
|
||||
5. Can type "D" → yellow background
|
||||
6. Can type "N" → red background
|
||||
7. Can drag a filled cell to another day
|
||||
8. Right-click shows context menu with comment options
|
||||
9. Tunnel closure row is editable
|
||||
10. Can scroll through all 12 months
|
||||
11. Save and reload preserves data
|
||||
12. Month navigation buttons work
|
||||
|
||||
**Step 4: Commit**
|
||||
```bash
|
||||
git add -A
|
||||
git commit -m "feat: TKB shift scheduler MVP — people grid, color-coded shifts, drag-and-drop"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope (Future Tasks)
|
||||
- Excel import from TKB format (parse the Pohotovost Excel)
|
||||
- Excel export to TKB format
|
||||
- Adding/removing people from the UI
|
||||
- Holiday calendar (Czech holidays highlighting)
|
||||
- Overtime calculation
|
||||
- Print view
|
||||
- Multi-year support
|
||||
Reference in New Issue
Block a user