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:
Docker Config Backup
2026-04-02 09:48:38 +02:00
commit b4158d687f
47 changed files with 14185 additions and 0 deletions

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
dist/
.playwright-mcp/
web/schedules/
web/uploads/
web/saved_schedule.json
*.local
.DS_Store

109
CLAUDE.md Normal file
View File

@@ -0,0 +1,109 @@
# TKB HMG — Tunnel Maintenance Schedule Manager
This is a spinoff of the METRO HMG project (Prague metro station maintenance scheduling). Adapted for Prague car tunnels (TKB) maintenance department.
## Origin
Copied from `/home/klas/Prace/METRO/web/` — a fully working schedule management web app.
## Architecture
### Tech Stack
- **Frontend**: React 19 + TypeScript + Vite + Tailwind CSS
- **Backend**: Express (Node.js), file-based JSON storage
- **Excel**: openpyxl (Python, server-side export/import), xlsx (client-side import)
- **Port**: 3080 (change in `web/server.js`)
### Key Files
```
web/
├── server.js # Express API server (ES modules)
├── export_excel.py # Python: JSON → Excel using template
├── import_excel.py # Python: Excel → JSON
├── deploy.sh # Deploy script (rsync to remote)
├── watch-deploy.js # Auto-deploy on file changes
├── public/
│ └── template.xlsx # Excel template for export formatting
├── src/
│ ├── App.tsx # Main app: login → file manager → editor
│ ├── FileManager.tsx # File list, upload, compare, delete
│ ├── ScheduleTable.tsx # Main schedule grid (largest component)
│ ├── useScheduleState.ts # State management: stations, obstacles, comments
│ ├── useDragBlock.ts # Drag-and-drop logic for blocks and s/v cells
│ ├── blockParser.ts # Parse k+14+Z indivisible blocks
│ ├── constraints.ts # Validation: max k/night, max 5 evaluations
│ ├── ContextMenu.tsx # Right-click menu (obstacles, comments, s/v)
│ ├── Toolbar.tsx # Filter toggles, save, undo, compare banner
│ ├── Login.tsx # Simple client-side auth
│ ├── excelIO.ts # Client-side Excel import (xlsx library)
│ ├── types.ts # All TypeScript interfaces
│ └── data.json # Default/fallback schedule data
└── schedules/ # Server-stored JSON schedule files
```
### API Endpoints (server.js)
- `GET /api/files` — List all schedule files (metadata only)
- `GET /api/files/:id` — Load single file with full data
- `POST /api/files` — Create new file `{ name, data }`
- `PUT /api/files/:id` — Update file `{ name?, data }`
- `DELETE /api/files/:id` — Delete file
- `POST /api/files/import-excel` — Upload .xlsx, parse to JSON, create file
- `GET /api/files/:id/export-excel` — Download as Excel using template
- `GET /api/files/diff/:id1/:id2` — Cell-by-cell diff between two files
### Data Model
```typescript
ScheduleData {
dayIndex: DayInfo[] // Array of day metadata (idx, day, month, year, week, weekend)
stations: Station[] // 27 stations, each with data: Record<string, CellValue>
obstacles?: Obstacle[] // Per-cell blocking issues (hatched red overlay)
dayComments?: DayComment[] // DEN row comments (hatched blue column overlay)
cellComments?: CellComment[] // Per-cell comments (blue triangle indicator)
}
```
Cell values: `s` (physical servers), `v` (virtual servers), `k` (on-site work), `14` (evaluation days), `Z` (processing)
### Key Concepts
- **dayIndex.idx** values are NOT array indices — they're offsets from the Excel column mapping (col = idx + 7)
- **Indivisible block**: k + 14×"14" + 5×"Z" = 20 cells that move as one unit
- **s and v cells** are independent, can be dragged separately, can have multiple per station
- **Holidays** are hardcoded in `useScheduleState.ts` as `HOLIDAYS` Map
- **Constraints**: max 1 k/night, max 5 stations in "14"+"Z" simultaneously
- **Ghost blocks**: comparison overlay shows old positions as dashed semi-transparent blocks
### Running
```bash
cd web
npm install
npx vite build
node server.js # Production: serves dist/ on port 3080
# OR
npm run dev # Dev: Vite HMR on port 5173, proxies /api to 3080
```
### Auth
- Username: `metro`, Password: `colsys` (client-side only, in Login.tsx)
- Change these for TKB deployment
### Deploying to Remote
```bash
./deploy.sh # Builds, rsyncs to remote, restarts pm2
```
Edit `deploy.sh` to set the remote host. Uses pm2 for process management.
### Excel Template
- `public/template.xlsx` — the original Excel schedule format
- Export writes data into this template, preserving formatting
- Replace with TKB's own template
## What to Customize for TKB
1. **Station list** — Change station codes/names in `data.json` and `STATION_ROWS` in `export_excel.py` and `server.js`
2. **Excel template** — Replace `public/template.xlsx` with TKB's schedule format
3. **Holidays** — Update `HOLIDAYS` Map in `useScheduleState.ts`
4. **Constraints** — Adjust rules in `constraints.ts` (may differ for tunnels)
5. **Phase labels** — Update `PHASE_LABELS` in `Toolbar.tsx` and `getCellColor` in `ScheduleTable.tsx`
6. **Login credentials** — Change in `Login.tsx`
7. **App title** — Change in `index.html` and header in `App.tsx`
8. **Port** — Change `PORT` in `server.js`
9. **Deploy target** — Update `REMOTE` in `deploy.sh`

View 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

19
proposals.md Normal file
View File

@@ -0,0 +1,19 @@
# Navrhy na vylepseni TKB Planu sluzeb
---
**1. 4. 2026 20:55:53** (Test User)
Testovaci navrh - tlacitko pro smazani vsech bunek
---
**1. 4. 2026 21:00:42** (Ondra)
Toto je testovaci navrh
---
**1. 4. 2026 22:37:55** (Ondra)
Priidatt dalsi radek typu "uzavery" pro práce v metru - volitelně napojit na harmonogram metra nebo vyplňovat manuálně

Binary file not shown.

29
web/.gitignore vendored Normal file
View File

@@ -0,0 +1,29 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Server data
schedules/
uploads/
saved_schedule.json

73
web/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

27
web/deploy.sh Executable file
View File

@@ -0,0 +1,27 @@
#!/bin/bash
# Deploy METRO HMG app to copelk
set -e
REMOTE="copelk"
REMOTE_DIR="~/Prace/METRO/web"
echo "Building..."
cd /home/klas/Prace/METRO/web
npx vite build
echo "Syncing to $REMOTE..."
rsync -avz --delete \
--exclude='node_modules' \
--exclude='.git' \
--exclude='saved_schedule.json' \
--exclude='schedules' \
--exclude='uploads' \
/home/klas/Prace/METRO/web/ $REMOTE:$REMOTE_DIR/
echo "Installing deps on remote..."
ssh $REMOTE "cd $REMOTE_DIR && npm install --production --silent"
echo "Restarting service on remote..."
ssh $REMOTE "cd $REMOTE_DIR && pm2 restart metro-hmg"
echo "Done! App running at http://copelk:3080"

View File

@@ -0,0 +1,171 @@
# File Manager + Diff Comparison Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Transform the app from single-schedule editor into a multi-file schedule manager with Excel import/export and visual diff comparison.
**Architecture:** Server stores multiple JSON schedule files in a `schedules/` directory. Each file has metadata (name, created, modified). The UI gets a file list sidebar/header, open/save/save-as/delete operations, Excel import creates new files, Excel export from any file. Diff mode loads two files and renders an overlay showing changed cells.
**Tech Stack:** Express (file-based storage), React (existing), openpyxl (Excel), xlsx (client-side Excel read)
---
### Task 1: Server — File Storage API
**Files:**
- Modify: `server.js`
**Step 1: Create schedules directory and migrate existing data**
Add `schedules/` directory. Each file is `{id}.json` with structure:
```json
{
"id": "abc123",
"name": "HMG_Profylaxe_20260326_D6",
"createdAt": "2026-03-26T10:00:00Z",
"modifiedAt": "2026-03-27T14:00:00Z",
"data": { "dayIndex": [...], "stations": [...], "obstacles": [...], "dayComments": [...] }
}
```
**Step 2: New API endpoints**
Replace single GET/POST `/api/schedule` with:
- `GET /api/files` — List all files (id, name, createdAt, modifiedAt — no data)
- `GET /api/files/:id` — Load single file with full data
- `POST /api/files` — Create new file (from JSON body or Excel import)
- `PUT /api/files/:id` — Update existing file
- `DELETE /api/files/:id` — Delete file
- `POST /api/files/import-excel` — Upload Excel, parse to JSON, create file
- `GET /api/files/:id/export-excel` — Download file as Excel using template
- `GET /api/files/diff/:id1/:id2` — Compute diff between two files, return changed cells
Keep old endpoints as aliases for backward compat (GET/POST `/api/schedule` → operates on a "current" file or the first file).
**Step 3: Migration on first start**
If `saved_schedule.json` exists but `schedules/` is empty, migrate it as the first file.
**Step 4: Diff computation**
Diff endpoint compares two files cell by cell:
```json
{
"added": [{"stationCode": "DE", "dayIdx": 100, "newValue": "k"}],
"removed": [{"stationCode": "DE", "dayIdx": 96, "oldValue": "k"}],
"changed": [{"stationCode": "HR", "dayIdx": 105, "oldValue": 14, "newValue": "Z"}]
}
```
**Step 5: Commit**
---
### Task 2: UI — File List & Management
**Files:**
- Create: `src/FileManager.tsx`
- Modify: `src/App.tsx`
- Modify: `src/types.ts`
**Step 1: Add types**
```typescript
interface ScheduleFile {
id: string
name: string
createdAt: string
modifiedAt: string
}
interface ScheduleFileWithData extends ScheduleFile {
data: ScheduleData
}
```
**Step 2: FileManager component**
A top bar or panel showing:
- List of saved files (name, date modified)
- "Otevřít" (open) button per file
- "Smazat" (delete) button per file
- "Nahrát Excel" (upload Excel) button
- "Nový soubor" (new file) button
- Currently open file name shown prominently
- "Uložit" saves to current file
- "Uložit jako" (save as) creates new file
- Checkbox to select two files for comparison
**Step 3: App.tsx changes**
- Start with file list view (no schedule loaded)
- Opening a file loads data into the editor
- Save writes back to the same file ID
- "Save as" prompts for name, creates new file
- Back button returns to file list
**Step 4: Commit**
---
### Task 3: Excel Import → New File
**Files:**
- Modify: `server.js` (import endpoint)
- Modify: `src/FileManager.tsx`
- Modify: `src/excelIO.ts`
**Step 1: Upload flow**
FileManager has "Nahrát Excel" button → file picker → POST multipart to `/api/files/import-excel` → server parses with Python → creates new schedule file → returns file metadata → UI refreshes list.
Server-side parsing reuses the column mapping logic (col = idx + 7, rows 13-39 for stations). Also extract comments from DEN row.
**Step 2: Commit**
---
### Task 4: Diff Comparison View
**Files:**
- Create: `src/DiffOverlay.tsx`
- Modify: `src/ScheduleTable.tsx`
- Modify: `src/App.tsx`
**Step 1: DiffOverlay component**
When two files are selected for comparison, load both, compute diff, and render overlay on the table:
- **Green background** on cells that exist in current but not in comparison (added)
- **Red background** on cells that exist in comparison but not in current (removed)
- **Yellow background** on cells with different values (changed)
- Legend showing what colors mean
- Toggle to show/hide diff overlay
**Step 2: Diff in ScheduleTable**
Pass optional `diffData` prop to ScheduleTable. When present, each cell checks if it has a diff entry and renders the appropriate overlay color.
**Step 3: Commit**
---
### Task 5: Excel Export per File
**Files:**
- Modify: `server.js`
- Modify: `export_excel.py`
**Step 1: Per-file export**
`GET /api/files/:id/export-excel` loads the specific file's data and runs through the Python template exporter. File downloads with the schedule name in filename.
**Step 2: Commit**
---
### Task 6: Deploy & Test
**Step 1: Build and test locally**
**Step 2: Deploy to copelk**
**Step 3: Migrate existing data on copelk**

23
web/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

108
web/export_excel.py Normal file
View File

@@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Generate Excel export from saved schedule using template."""
import json
import sys
import openpyxl
from openpyxl.styles import PatternFill
from copy import copy
TEMPLATE = sys.argv[1]
SCHEDULE = sys.argv[2]
OUTPUT = sys.argv[3]
STATION_ROWS = {
'DE': 13, 'HR': 14, 'MA': 15, 'ST': 16, 'MSA': 17, 'MUA': 18,
'NM': 19, 'FL': 20, 'JP': 21, 'ZL': 22, 'SN': 23, 'AN': 24,
'KN': 25, 'NA': 26, 'MSB': 27, 'NR': 28, 'FRB': 29, 'KR': 30,
'KC': 31, 'VY': 32, 'IP': 33, 'MUC': 34, 'HN': 35, 'FRC': 36,
'VL': 37, 'NH': 38, 'KB': 39,
}
NO_FILL = PatternFill(fill_type=None)
with open(SCHEDULE) as f:
raw = json.load(f)
data = raw['data'] if 'data' in raw and 'stations' not in raw else raw
wb = openpyxl.load_workbook(TEMPLATE)
ws = wb.active
# Identify weekend columns: DEN row (row 11) has fill on weekends/holidays
# Capture the DATA ROW fill (row 13) for those columns — it has the correct theme=0 gray
weekend_cols = set()
for col in range(7, 281):
den_cell = ws.cell(row=11, column=col)
if den_cell.fill and den_cell.fill.fill_type == 'solid':
weekend_cols.add(col)
col_fills = {}
for col in weekend_cols:
data_cell = ws.cell(row=13, column=col)
if data_cell.fill and data_cell.fill.fill_type == 'solid':
theme = data_cell.fill.fgColor.theme if hasattr(data_cell.fill.fgColor, 'theme') and isinstance(data_cell.fill.fgColor.theme, int) else None
if theme == 0: # Gray weekend fill, not teal s/v fill
col_fills[col] = copy(data_cell.fill)
for station in data['stations']:
row = STATION_ROWS.get(station['code'])
if not row:
continue
# Step 1: Clear all values
for col in range(7, 281):
ws.cell(row=row, column=col).value = None
# Step 2: Restore original column fills (weekends etc)
for col in range(7, 281):
if col in col_fills:
ws.cell(row=row, column=col).fill = copy(col_fills[col])
else:
ws.cell(row=row, column=col).fill = NO_FILL
# Step 3: Write data values (no extra fills — only weekend column fills from step 2)
for idx_str, cell_data in station['data'].items():
idx = int(idx_str)
col = idx + 7
if col < 7 or col > 280:
continue
val = cell_data.get('v')
if val is not None:
ws.cell(row=row, column=col).value = val
# Add DEN row comments
if 'dayComments' in data:
comments = data['dayComments']
# Handle both list format [{dayIdx, text}] and dict format {"idx": "text"}
if isinstance(comments, list):
for c in comments:
idx = c.get('dayIdx', 0)
text = c.get('text', '')
if text:
col = idx + 7
if 7 <= col <= 280:
from openpyxl.comments import Comment
ws.cell(row=11, column=col).comment = Comment(text, 'Metro HMG')
elif isinstance(comments, dict):
for idx_str, text in comments.items():
if text:
col = int(idx_str) + 7
if 7 <= col <= 280:
from openpyxl.comments import Comment
ws.cell(row=11, column=col).comment = Comment(text, 'Metro HMG')
# Add cell comments (per station+day)
if 'cellComments' in data:
from openpyxl.comments import Comment as XlComment
for c in data.get('cellComments', []):
code = c.get('stationCode', '')
idx = c.get('dayIdx', 0)
text = c.get('text', '')
row = STATION_ROWS.get(code)
if row and text:
col = idx + 7
if 7 <= col <= 280:
ws.cell(row=row, column=col).comment = XlComment(text, 'Metro HMG')
wb.save(OUTPUT)
print(f'Exported to {OUTPUT}', file=sys.stderr)

166
web/import_excel.py Executable file
View File

@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""Parse Excel schedule file and output JSON to stdout."""
import json
import sys
from datetime import date
import openpyxl
filepath = sys.argv[1]
wb = openpyxl.load_workbook(filepath, data_only=True)
ws = wb.active
# Also load with styles for comments
wb2 = openpyxl.load_workbook(filepath)
ws2 = wb2.active
# --- Build dayIndex from row 9 (months) and row 11 (days) ---
month_starts = {}
for col in range(7, 300):
val = ws.cell(row=9, column=col).value
if val is not None and hasattr(val, 'month'):
month_starts[col] = (val.year, val.month)
if not month_starts:
print(json.dumps({"error": "No month data found in row 9"}), file=sys.stderr)
sys.exit(1)
day_index = []
for col in range(7, 300):
day_val = ws.cell(row=11, column=col).value
if day_val is None:
continue
day_num = int(day_val)
current_month = None
for mcol in sorted(month_starts.keys(), reverse=True):
if col >= mcol:
current_month = month_starts[mcol]
break
if current_month is None:
continue
year, month = current_month
idx = col - 7
try:
d = date(year, month, day_num)
is_weekend = d.weekday() >= 5
week = d.isocalendar()[1]
except ValueError:
is_weekend = False
week = 0
day_index.append({
"idx": idx,
"day": day_num,
"month": month,
"year": year,
"week": week,
"weekend": is_weekend,
})
# --- Extract station data ---
valid_idx = set(d["idx"] for d in day_index)
stations = []
for row in range(13, 40):
code = ws.cell(row=row, column=1).value
name = ws.cell(row=row, column=2).value or ""
server = ws.cell(row=row, column=3).value or ""
if not code:
continue
data = {}
for col in range(7, 300):
idx = col - 7
if idx not in valid_idx:
continue
val = ws.cell(row=row, column=col).value
if val is None:
continue
if isinstance(val, str) and val.strip() == "":
continue
entry = {}
if isinstance(val, (int, float)):
entry["v"] = int(val) if val == int(val) else val
else:
v = str(val).strip()
# Normalize: lowercase z -> uppercase Z
if v == 'z':
v = 'Z'
entry["v"] = v
data[str(idx)] = entry
stations.append({
"code": str(code).strip(),
"name": str(name).strip(),
"server": str(server).strip(),
"duration": None,
"data": data,
})
# --- Extract DEN row comments ---
day_comments = []
for col in range(7, 300):
cell = ws2.cell(row=11, column=col)
if cell.comment:
idx = col - 7
if idx not in valid_idx:
continue
text = cell.comment.text
# Extract actual comment from threaded format (Czech or English)
if "Komentář:\n" in text:
note = text.split("Komentář:\n")[-1].strip()
elif "Comment:\n" in text:
note = text.split("Comment:\n")[-1].strip()
else:
note = text.strip()
if note:
day_comments.append({"dayIdx": idx, "text": note})
# --- Extract cell comments from data rows ---
cell_comments = []
for row in range(13, 40):
code = ws2.cell(row=row, column=1).value
if not code:
continue
code = str(code).strip()
for col in range(7, 300):
cell = ws2.cell(row=row, column=col)
if cell.comment:
idx = col - 7
if idx not in valid_idx:
continue
text = cell.comment.text
if "Komentář:\n" in text:
note = text.split("Komentář:\n")[-1].strip()
elif "Comment:\n" in text:
note = text.split("Comment:\n")[-1].strip()
else:
note = text.strip()
if note:
cell_comments.append({"stationCode": code, "dayIdx": idx, "text": note})
# Deduplicate dayIndex — keep only first occurrence of each date
seen_dates = set()
deduped_day_index = []
for d in day_index:
date_key = (d["year"], d["month"], d["day"])
if date_key not in seen_dates:
seen_dates.add(date_key)
deduped_day_index.append(d)
day_index = deduped_day_index
result = {
"dayIndex": day_index,
"stations": stations,
"obstacles": [],
"dayComments": day_comments,
"cellComments": cell_comments,
}
print(json.dumps(result, ensure_ascii=False))

13
web/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>TKB Plan sluzeb</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5358
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
web/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "web",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"start": "npm run build && node server.js",
"serve": "node server.js",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"exceljs": "^4.4.0",
"express": "^5.2.1",
"jspdf": "^4.2.1",
"multer": "^2.1.1",
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}

Binary file not shown.

BIN
web/public/DejaVuSans.ttf Normal file

Binary file not shown.

1
web/public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
web/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

BIN
web/public/template.xlsx Normal file

Binary file not shown.

391
web/server.js Normal file
View File

@@ -0,0 +1,391 @@
import express from 'express'
import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync, readdirSync } from 'fs'
import { fileURLToPath } from 'url'
import { dirname, join } from 'path'
import crypto from 'crypto'
import multer from 'multer'
const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const app = express()
const PORT = 3080
const SCHEDULES_DIR = join(__dirname, 'schedules')
const SAVED_SCHEDULE_PATH = join(__dirname, 'saved_schedule.json')
const DEFAULT_DATA_PATH = join(__dirname, 'src', 'data.json')
// Multer config for Excel uploads
const upload = multer({
dest: join(__dirname, 'uploads'),
limits: { fileSize: 20 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' ||
file.originalname.endsWith('.xlsx')) {
cb(null, true)
} else {
cb(new Error('Only .xlsx files are accepted'))
}
}
})
app.use(express.json({ limit: '10mb' }))
// --- Helpers ---
function ensureSchedulesDir() {
if (!existsSync(SCHEDULES_DIR)) {
mkdirSync(SCHEDULES_DIR, { recursive: true })
}
}
function listScheduleFiles() {
ensureSchedulesDir()
const files = readdirSync(SCHEDULES_DIR).filter(f => f.endsWith('.json'))
const result = []
for (const f of files) {
try {
const raw = JSON.parse(readFileSync(join(SCHEDULES_DIR, f), 'utf-8'))
result.push({
id: raw.id,
name: raw.name,
createdAt: raw.createdAt,
modifiedAt: raw.modifiedAt,
})
} catch {}
}
result.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt))
return result
}
function loadScheduleFile(id) {
const filePath = join(SCHEDULES_DIR, `${id}.json`)
if (!existsSync(filePath)) return null
return JSON.parse(readFileSync(filePath, 'utf-8'))
}
function saveScheduleFile(fileObj) {
ensureSchedulesDir()
writeFileSync(join(SCHEDULES_DIR, `${fileObj.id}.json`), JSON.stringify(fileObj, null, 2), 'utf-8')
}
function getMostRecentFile() {
const files = listScheduleFiles()
return files.length > 0 ? files[0] : null
}
// --- Migration on startup ---
function migrateIfNeeded() {
ensureSchedulesDir()
const existing = readdirSync(SCHEDULES_DIR).filter(f => f.endsWith('.json'))
if (existing.length === 0 && existsSync(SAVED_SCHEDULE_PATH)) {
console.log('Migrating saved_schedule.json to schedules/ directory...')
const data = JSON.parse(readFileSync(SAVED_SCHEDULE_PATH, 'utf-8'))
const now = new Date().toISOString()
const fileObj = {
id: crypto.randomUUID(),
name: `Migrated_Schedule_${now.slice(0, 10)}`,
createdAt: now,
modifiedAt: now,
data,
}
saveScheduleFile(fileObj)
console.log(`Migrated as ${fileObj.id} (${fileObj.name})`)
}
}
migrateIfNeeded()
// --- Diff computation ---
function computeDiff(file1, file2) {
const changes = []
// Build lookup: personId -> { dayIdx -> value }
function buildMap(data) {
const map = {}
if (!data) return map
const people = data.people || data.stations
if (!people) return map
for (const person of people) {
const cells = {}
if (person.data) {
for (const [idx, cellData] of Object.entries(person.data)) {
if (cellData && cellData.v !== undefined && cellData.v !== null && cellData.v !== '') {
cells[idx] = cellData.v
}
}
}
map[person.id || person.code] = cells
}
return map
}
const map1 = buildMap(file1.data)
const map2 = buildMap(file2.data)
// All person IDs from both files
const allIds = new Set([...Object.keys(map1), ...Object.keys(map2)])
for (const id of allIds) {
const cells1 = map1[id] || {}
const cells2 = map2[id] || {}
const allIdx = new Set([...Object.keys(cells1), ...Object.keys(cells2)])
for (const idx of allIdx) {
const v1 = cells1[idx]
const v2 = cells2[idx]
const dayIdx = parseInt(idx)
if (v1 !== undefined && v2 === undefined) {
changes.push({ personId: id, dayIdx, type: 'removed', oldValue: v1 })
} else if (v1 === undefined && v2 !== undefined) {
changes.push({ personId: id, dayIdx, type: 'added', newValue: v2 })
} else if (v1 !== v2) {
changes.push({ personId: id, dayIdx, type: 'changed', oldValue: v1, newValue: v2 })
}
}
}
return { changes }
}
// --- API: File management ---
// List all files
app.get('/api/files', (_req, res) => {
try {
res.json(listScheduleFiles())
} catch (err) {
console.error('Error listing files:', err)
res.status(500).json({ error: 'Failed to list files' })
}
})
// Diff between two files
app.get('/api/files/diff/:id1/:id2', (req, res) => {
try {
const file1 = loadScheduleFile(req.params.id1)
const file2 = loadScheduleFile(req.params.id2)
if (!file1) return res.status(404).json({ error: `File ${req.params.id1} not found` })
if (!file2) return res.status(404).json({ error: `File ${req.params.id2} not found` })
res.json(computeDiff(file1, file2))
} catch (err) {
console.error('Error computing diff:', err)
res.status(500).json({ error: 'Failed to compute diff' })
}
})
// Import Excel (not yet implemented for TKB format)
app.post('/api/files/import-excel', upload.single('file'), (_req, res) => {
res.status(501).json({ error: 'Excel import not yet implemented for TKB format' })
})
// Get single file
app.get('/api/files/:id', (req, res) => {
try {
const file = loadScheduleFile(req.params.id)
if (!file) return res.status(404).json({ error: 'File not found' })
res.json(file)
} catch (err) {
console.error('Error loading file:', err)
res.status(500).json({ error: 'Failed to load file' })
}
})
// Export file as Excel (not yet implemented for TKB format)
app.get('/api/files/:id/export-excel', (_req, res) => {
res.status(501).json({ error: 'Excel export not yet implemented for TKB format' })
})
// Create new file
app.post('/api/files', (req, res) => {
try {
const { name, data } = req.body
if (!data || (!data.people && !data.stations) || !data.dayIndex) {
return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' })
}
const now = new Date().toISOString()
const fileObj = {
id: crypto.randomUUID(),
name: name || `Schedule_${now.slice(0, 10)}`,
createdAt: now,
modifiedAt: now,
data,
}
saveScheduleFile(fileObj)
res.json({
id: fileObj.id,
name: fileObj.name,
createdAt: fileObj.createdAt,
modifiedAt: fileObj.modifiedAt,
})
} catch (err) {
console.error('Error creating file:', err)
res.status(500).json({ error: 'Failed to create file' })
}
})
// Update existing file
app.put('/api/files/:id', (req, res) => {
try {
const file = loadScheduleFile(req.params.id)
if (!file) return res.status(404).json({ error: 'File not found' })
const { name, data } = req.body
if (name !== undefined) file.name = name
if (data) {
if ((!data.people && !data.stations) || !data.dayIndex) {
return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' })
}
file.data = data
}
file.modifiedAt = new Date().toISOString()
saveScheduleFile(file)
res.json({
id: file.id,
name: file.name,
createdAt: file.createdAt,
modifiedAt: file.modifiedAt,
})
} catch (err) {
console.error('Error updating file:', err)
res.status(500).json({ error: 'Failed to update file' })
}
})
// Delete file
app.delete('/api/files/:id', (req, res) => {
try {
const filePath = join(SCHEDULES_DIR, `${req.params.id}.json`)
if (!existsSync(filePath)) return res.status(404).json({ error: 'File not found' })
unlinkSync(filePath)
res.json({ ok: true })
} catch (err) {
console.error('Error deleting file:', err)
res.status(500).json({ error: 'Failed to delete file' })
}
})
// --- Backward compatible endpoints ---
// GET /api/schedule — Load most recently modified file (or fallback to src/data.json)
app.get('/api/schedule', (_req, res) => {
try {
const recent = getMostRecentFile()
if (recent) {
const file = loadScheduleFile(recent.id)
if (file) return res.json(file.data)
}
// Fallback to old paths
if (existsSync(SAVED_SCHEDULE_PATH)) {
return res.json(JSON.parse(readFileSync(SAVED_SCHEDULE_PATH, 'utf-8')))
}
res.json(JSON.parse(readFileSync(DEFAULT_DATA_PATH, 'utf-8')))
} catch (err) {
console.error('Error loading schedule:', err)
res.status(500).json({ error: 'Failed to load schedule' })
}
})
// POST /api/schedule — Save to most recently modified file (or create new one)
app.post('/api/schedule', (req, res) => {
try {
const data = req.body
if (!data || (!data.people && !data.stations) || !data.dayIndex) {
return res.status(400).json({ error: 'Invalid schedule data' })
}
const recent = getMostRecentFile()
const now = new Date().toISOString()
if (recent) {
const file = loadScheduleFile(recent.id)
if (file) {
file.data = data
file.modifiedAt = now
saveScheduleFile(file)
console.log(`Schedule saved to ${file.name} (${file.id}) at ${now}`)
return res.json({ ok: true, id: file.id })
}
}
// No existing file — create new
const fileObj = {
id: crypto.randomUUID(),
name: `Schedule_${now.slice(0, 10)}`,
createdAt: now,
modifiedAt: now,
data,
}
saveScheduleFile(fileObj)
console.log(`Schedule saved as new file ${fileObj.name} (${fileObj.id}) at ${now}`)
res.json({ ok: true, id: fileObj.id })
} catch (err) {
console.error('Error saving schedule:', err)
res.status(500).json({ error: 'Failed to save schedule' })
}
})
// --- API: Proposals ---
const PROPOSALS_PATH = join(__dirname, '..', 'proposals.md')
app.get('/api/proposals', (_req, res) => {
try {
if (existsSync(PROPOSALS_PATH)) {
res.json({ content: readFileSync(PROPOSALS_PATH, 'utf-8') })
} else {
res.json({ content: '' })
}
} catch (err) {
console.error('Error reading proposals:', err)
res.status(500).json({ error: 'Failed to read proposals' })
}
})
app.post('/api/proposals', (req, res) => {
try {
const { text, author } = req.body
if (!text?.trim()) return res.status(400).json({ error: 'Empty proposal' })
const now = new Date().toLocaleString('cs-CZ')
const entry = `\n---\n\n**${now}** ${author ? `(${author})` : ''}\n\n${text.trim()}\n`
let content = ''
if (existsSync(PROPOSALS_PATH)) {
content = readFileSync(PROPOSALS_PATH, 'utf-8')
} else {
content = '# Navrhy na vylepseni TKB Planu sluzeb\n'
}
content += entry
writeFileSync(PROPOSALS_PATH, content, 'utf-8')
res.json({ ok: true })
} catch (err) {
console.error('Error saving proposal:', err)
res.status(500).json({ error: 'Failed to save proposal' })
}
})
// API: export as Excel (legacy endpoint — not yet implemented for TKB format)
app.get('/api/export-excel', (_req, res) => {
res.status(501).json({ error: 'Excel export not yet implemented for TKB format' })
})
// Serve static files from dist/
app.use(express.static(join(__dirname, 'dist'), {
setHeaders: (res, path) => {
if (path.endsWith('.html')) {
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate')
}
}
}))
// SPA fallback
app.get('/{*splat}', (_req, res) => {
res.sendFile(join(__dirname, 'dist', 'index.html'))
})
app.listen(PORT, () => {
console.log(`TKB server running on http://localhost:${PORT}`)
})

554
web/src/App.tsx Normal file
View File

@@ -0,0 +1,554 @@
import { useState, useRef, useCallback, useEffect } from 'react'
import { ScheduleTable } from './ScheduleTable'
import type { SelectedCell } from './ScheduleTable'
import { Toolbar } from './Toolbar'
import { ContextMenu } from './ContextMenu'
import { ProposalModal } from './ProposalModal'
import { useScheduleState } from './useScheduleState'
import { useDragCell } from './useDragCell'
import { Login, isAuthenticated } from './Login'
import { FileManager } from './FileManager'
import fallbackData from './data.json'
import type { ScheduleData, ScheduleFileWithData, ContextMenuState } from './types'
function normalizeDayIndex(data: ScheduleData): ScheduleData {
const targetStart = new Date(2026, 0, 1) // January 1
const targetEnd = new Date(2026, 11, 31) // December 31
const IDX_OFFSET = 0
const existingByDate = new Map<string, typeof data.dayIndex[0]>()
for (const d of data.dayIndex) {
existingByDate.set(`${d.year}-${d.month}-${d.day}`, d)
}
const newDayIndex: typeof data.dayIndex = []
const current = new Date(targetStart)
let idx = IDX_OFFSET
while (current <= targetEnd) {
const day = current.getDate()
const month = current.getMonth() + 1
const year = current.getFullYear()
const key = `${year}-${month}-${day}`
const existing = existingByDate.get(key)
newDayIndex.push(existing ?? {
idx,
day,
month,
year,
week: getISOWeek(current),
weekend: current.getDay() === 0 || current.getDay() === 6,
})
idx++
current.setDate(current.getDate() + 1)
}
const idxRemap = new Map<number, number>()
for (const d of data.dayIndex) {
const key = `${d.year}-${d.month}-${d.day}`
const newEntry = newDayIndex.find(n => `${n.year}-${n.month}-${n.day}` === key)
if (newEntry && newEntry.idx !== d.idx) {
idxRemap.set(d.idx, newEntry.idx)
}
}
let people = data.people
if (idxRemap.size > 0) {
people = JSON.parse(JSON.stringify(data.people))
for (const person of people) {
const newData: typeof person.data = {}
for (const [k, v] of Object.entries(person.data)) {
const oldIdx = Number(k)
const newIdx = idxRemap.get(oldIdx) ?? oldIdx
newData[String(newIdx)] = v
}
person.data = newData
}
}
return { ...data, dayIndex: newDayIndex, people }
}
function getISOWeek(date: Date): number {
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)
}
type AppMode = 'login' | 'files' | 'editor'
function App() {
const [authed, setAuthed] = useState(isAuthenticated())
const [mode, setMode] = useState<AppMode>(authed ? 'files' : 'login')
const [fileId, setFileId] = useState<string | null>(null)
const [fileName, setFileName] = useState<string>('')
const [fileData, setFileData] = useState<ScheduleData | null>(null)
const [loading, setLoading] = useState(false)
const [compareFileId, setCompareFileId] = useState<string | null>(null)
useEffect(() => {
if (authed && mode === 'login') setMode('files')
}, [authed, mode])
const handleLogin = useCallback(() => {
setAuthed(true)
setMode('files')
}, [])
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
setLoading(true)
try {
const res = await fetch(`/api/files/${id}`)
if (!res.ok) throw new Error('Failed to load file')
const file: ScheduleFileWithData = await res.json()
setFileId(file.id)
setFileName(file.name)
setFileData(normalizeDayIndex(file.data))
setCompareFileId(compareId ?? null)
setMode('editor')
} catch (err) {
alert(`Chyba pri otevirani souboru: ${err}`)
} finally {
setLoading(false)
}
}, [])
const handleCompare = useCallback((id1: string, id2: string) => {
handleOpenFile(id1, id2)
}, [handleOpenFile])
const handleBackToFiles = useCallback(() => {
setMode('files')
setFileId(null)
setFileName('')
setFileData(null)
setCompareFileId(null)
}, [])
const handleCreateNew = useCallback(async () => {
setLoading(true)
try {
const name = prompt('Nazev noveho souboru:', `Plan ${new Date().getFullYear()}`)
if (!name) { setLoading(false); return }
const data = fallbackData as unknown as ScheduleData
const res = await fetch('/api/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, data }),
})
if (!res.ok) throw new Error('Failed to create file')
const result = await res.json()
handleOpenFile(result.id)
} catch (err) {
alert(`Chyba pri vytvareni souboru: ${err}`)
setLoading(false)
}
}, [handleOpenFile])
if (!authed) {
return <Login onLogin={handleLogin} />
}
if (loading) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="text-slate-400 text-sm">Nacitam harmonogram...</div>
</div>
)
}
if (mode === 'files') {
return <FileManager onOpenFile={handleOpenFile} onCompare={handleCompare} onCreateNew={handleCreateNew} />
}
if (mode === 'editor' && fileData && fileId) {
return (
<ScheduleApp
fileId={fileId}
fileName={fileName}
data={fileData}
compareFileId={compareFileId}
onBack={handleBackToFiles}
onFileNameChange={setFileName}
onClearCompare={() => setCompareFileId(null)}
/>
)
}
return <LegacyLoader onBack={handleBackToFiles} />
}
function LegacyLoader({ onBack }: { onBack: () => void }) {
const [data, setData] = useState<ScheduleData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/schedule')
.then(r => {
if (!r.ok) throw new Error('Failed to load')
return r.json()
})
.then(d => setData(d as ScheduleData))
.catch(() => setData(fallbackData as unknown as ScheduleData))
.finally(() => setLoading(false))
}, [])
if (loading || !data) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="text-slate-400 text-sm">Nacitam harmonogram...</div>
</div>
)
}
return (
<ScheduleApp
fileId={null}
fileName="Bez nazvu"
data={data}
compareFileId={null}
onBack={onBack}
onFileNameChange={() => {}}
onClearCompare={() => {}}
/>
)
}
interface ScheduleAppProps {
fileId: string | null
fileName: string
data: ScheduleData
compareFileId: string | null
onBack: () => void
onFileNameChange: (name: string) => void
onClearCompare: () => void
}
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare }: ScheduleAppProps) {
const {
people, tunnelClosures, tunnelColors,
metroClosures, metroColors, d8Closures, d8Colors,
dayComments, cellComments,
setCell, setCellColor, moveCell, setTunnelClosure, setTunnelClosureColor,
setMetroClosure, setMetroClosureColor, setD8Closure, setD8ClosureColor,
undo, canUndo,
addDayComment, removeDayComment,
addCellComment, removeCellComment,
getSchedulePayload,
} = useScheduleState(data.people, data.dayIndex, data.tunnelClosures, data.dayComments, data.cellComments, data.infoRows)
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [selectedDenDays, setSelectedDenDays] = useState<number[]>([])
const [currentFileId, setCurrentFileId] = useState<string | null>(fileId)
const [compareData, setCompareData] = useState<ScheduleData | null>(null)
const [compareFileName, setCompareFileName] = useState<string | null>(null)
const scrollRef = useRef<HTMLDivElement>(null)
// Track active month from left edge of scroll
const currentMonth = new Date().getMonth() + 1
const [activeMonth, setActiveMonth] = useState<number>(currentMonth)
// Scroll to current month on initial load
useEffect(() => {
if (!scrollRef.current) return
const idx = data.dayIndex.findIndex(d => d.month === currentMonth)
if (idx >= 0) {
scrollRef.current.scrollTo({ left: idx * 32 })
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
const el = scrollRef.current
if (!el) return
const handleScroll = () => {
const dayIdx = Math.floor(el.scrollLeft / 32)
const clamped = Math.max(0, Math.min(dayIdx, data.dayIndex.length - 1))
const month = data.dayIndex[clamped]?.month
if (month) setActiveMonth(month)
}
el.addEventListener('scroll', handleScroll, { passive: true })
handleScroll()
return () => el.removeEventListener('scroll', handleScroll)
}, [data.dayIndex])
const { dragState, onCellPointerDown } = useDragCell({
scrollRef,
cellWidth: 32,
minDayIdx: data.dayIndex[0].idx,
maxDayIdx: data.dayIndex[data.dayIndex.length - 1].idx,
onMoveCell: moveCell,
})
// Fetch full comparison file when compareFileId is set
useEffect(() => {
if (!compareFileId || !fileId) {
setCompareData(null)
setCompareFileName(null)
return
}
let cancelled = false
;(async () => {
try {
const fileRes = await fetch(`/api/files/${compareFileId}`)
if (cancelled) return
if (fileRes.ok) {
const fileData: ScheduleFileWithData = await fileRes.json()
setCompareData(fileData.data)
setCompareFileName(fileData.name)
}
} catch {
if (!cancelled) {
setCompareData(null)
setCompareFileName(null)
}
}
})()
return () => { cancelled = true }
}, [compareFileId, fileId])
const handleScrollToMonth = useCallback((month: number) => {
if (!scrollRef.current) return
const idx = data.dayIndex.findIndex(d => d.month === month)
if (idx >= 0) {
scrollRef.current.scrollTo({ left: idx * 32, behavior: 'smooth' })
}
}, [data.dayIndex])
const [selectedCellsForMenu, setSelectedCellsForMenu] = useState<SelectedCell[]>([])
const handleContextMenu = useCallback((dayIdx: number, personId: string | null, x: number, y: number, selectedCells: SelectedCell[] = []) => {
setContextMenu({ dayIdx, personId, x, y })
setSelectedCellsForMenu(selectedCells ?? [])
}, [])
const handleTunnelContextMenu = useCallback((dayIdx: number, x: number, y: number) => {
setContextMenu({ dayIdx, personId: null, x, y, isTunnelRow: true })
}, [])
const handleInfoRowContextMenu = useCallback((dayIdx: number, infoRowId: string, x: number, y: number) => {
setContextMenu({ dayIdx, personId: null, x, y, infoRowId })
}, [])
const handleDenDaySelect = useCallback((dayIdx: number, shiftKey: boolean) => {
setSelectedDenDays(prev => {
if (shiftKey && prev.length > 0) {
const last = prev[prev.length - 1]
const min = Math.min(last, dayIdx)
const max = Math.max(last, dayIdx)
const range: number[] = []
for (const d of data.dayIndex) {
if (d.idx >= min && d.idx <= max) range.push(d.idx)
}
const merged = new Set([...prev, ...range])
return Array.from(merged).sort((a, b) => a - b)
}
if (prev.includes(dayIdx)) {
return prev.filter(d => d !== dayIdx)
}
return [dayIdx]
})
}, [data.dayIndex])
const handleSave = useCallback(async () => {
setSaveStatus('saving')
try {
const payload = getSchedulePayload()
if (currentFileId) {
const res = await fetch(`/api/files/${currentFileId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: payload }),
})
if (!res.ok) throw new Error('Save failed')
} else {
const res = await fetch('/api/schedule', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
if (!res.ok) throw new Error('Save failed')
}
setSaveStatus('saved')
setTimeout(() => setSaveStatus('idle'), 3000)
} catch {
setSaveStatus('error')
setTimeout(() => setSaveStatus('idle'), 4000)
}
}, [getSchedulePayload, currentFileId])
const handleSaveAs = useCallback(async () => {
const name = prompt('Nazev noveho souboru:', fileName ? `${fileName} - kopie` : '')
if (!name) return
setSaveStatus('saving')
try {
const payload = getSchedulePayload()
const res = await fetch('/api/files', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, data: payload }),
})
if (!res.ok) throw new Error('Save as failed')
const result = await res.json()
setCurrentFileId(result.id)
onFileNameChange(result.name)
setSaveStatus('saved')
setTimeout(() => setSaveStatus('idle'), 3000)
} catch {
setSaveStatus('error')
setTimeout(() => setSaveStatus('idle'), 4000)
}
}, [getSchedulePayload, fileName, onFileNameChange])
const [showProposals, setShowProposals] = useState(false)
const [showMetro, setShowMetro] = useState(true)
const [showD8, setShowD8] = useState(true)
const [hiddenValues, setHiddenValues] = useState<Set<string>>(new Set())
const handleExportPdf = useCallback(async (month: number) => {
const { exportMonthPdf } = await import('./PdfExport')
const year = data.dayIndex.find(d => d.month === month)?.year ?? new Date().getFullYear()
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 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)]
: undefined
const contextCellValue = contextCellData?.v
const contextCellColor = contextCellData?.color
// Determine which info row closures/colors/handlers to pass to context menu
const contextInfoRowId = contextMenu?.infoRowId
const contextInfoRowClosure = contextMenu
? contextInfoRowId === 'metro' ? metroClosures.get(contextMenu.dayIdx)
: contextInfoRowId === 'd8' ? d8Closures.get(contextMenu.dayIdx)
: undefined
: undefined
const contextInfoRowColor = contextMenu
? contextInfoRowId === 'metro' ? metroColors.get(contextMenu.dayIdx)
: contextInfoRowId === 'd8' ? d8Colors.get(contextMenu.dayIdx)
: undefined
: undefined
const handleInfoRowSetColor = useCallback((dayIdx: number, color: string | null) => {
if (contextInfoRowId === 'metro') setMetroClosureColor(dayIdx, color)
else if (contextInfoRowId === 'd8') setD8ClosureColor(dayIdx, color)
}, [contextInfoRowId, setMetroClosureColor, setD8ClosureColor])
return (
<div className="min-h-screen bg-slate-900">
<header className="border-b border-slate-700 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-50 px-6 py-4">
<div className="flex items-center gap-4">
<button
onClick={onBack}
className="px-3 py-1.5 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
hover:bg-slate-700 cursor-pointer transition-colors"
>
&larr; Zpet
</button>
<h1 className="text-xl font-semibold text-slate-100 tracking-tight">
{fileName || 'TKB Plan sluzeb'}
<span className="ml-3 text-sm font-normal text-slate-400">
Plan sluzeb a pohotovosti
</span>
</h1>
{compareFileName && (
<span className="px-2 py-1 rounded text-xs bg-blue-900/50 text-blue-300 border border-blue-800">
Porovnani s: {compareFileName}
</span>
)}
</div>
</header>
<main className="p-4">
<Toolbar
onUndo={undo}
canUndo={canUndo}
onSave={handleSave}
onSaveAs={handleSaveAs}
saveStatus={saveStatus}
onScrollToMonth={handleScrollToMonth}
activeMonth={activeMonth}
showMetro={showMetro}
showD8={showD8}
onToggleMetro={() => setShowMetro(v => !v)}
onToggleD8={() => setShowD8(v => !v)}
hiddenValues={hiddenValues}
onToggleValue={(code: string) => setHiddenValues(prev => {
const next = new Set(prev)
if (next.has(code)) next.delete(code); else next.add(code)
return next
})}
diffFileName={compareFileName}
onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }}
onExportPdf={handleExportPdf}
onShowProposals={() => setShowProposals(true)}
/>
<ScheduleTable
dayIndex={data.dayIndex}
people={people}
tunnelClosures={tunnelClosures}
tunnelColors={tunnelColors}
metroClosures={metroClosures}
metroColors={metroColors}
d8Closures={d8Closures}
d8Colors={d8Colors}
dayComments={dayComments}
cellComments={cellComments}
dragState={dragState}
onCellPointerDown={onCellPointerDown}
onSetCell={setCell}
onSetTunnelClosure={setTunnelClosure}
onSetMetroClosure={setMetroClosure}
onSetD8Closure={setD8Closure}
showMetro={showMetro}
showD8={showD8}
hiddenValues={hiddenValues}
scrollRef={scrollRef}
onContextMenu={handleContextMenu}
onTunnelContextMenu={handleTunnelContextMenu}
onInfoRowContextMenu={handleInfoRowContextMenu}
compareData={compareData}
/>
</main>
{contextMenu && contextDayInfo && (
<ContextMenu
state={contextMenu}
dayInfo={contextDayInfo}
personName={contextPerson?.name || null}
cellValue={contextCellValue}
cellColor={contextCellColor}
existingDayComment={dayComments.get(contextMenu.dayIdx)}
existingCellComment={contextMenu.personId ? cellComments.get(`${contextMenu.personId}-${contextMenu.dayIdx}`) : undefined}
existingTunnelClosure={tunnelClosures.get(contextMenu.dayIdx)}
existingTunnelColor={tunnelColors.get(contextMenu.dayIdx)}
existingInfoRowClosure={contextInfoRowClosure}
existingInfoRowColor={contextInfoRowColor}
selectedDays={selectedDenDays}
selectedCells={selectedCellsForMenu}
onSetCell={setCell}
onAddDayComment={addDayComment}
onRemoveDayComment={removeDayComment}
onAddCellComment={addCellComment}
onRemoveCellComment={removeCellComment}
onSetTunnelClosure={setTunnelClosure}
onSetCellColor={setCellColor}
onSetTunnelClosureColor={setTunnelClosureColor}
onSetInfoRowColor={handleInfoRowSetColor}
onClose={() => { setContextMenu(null); setSelectedDenDays([]) }}
/>
)}
{showProposals && (
<ProposalModal onClose={() => setShowProposals(false)} />
)}
</div>
)
}
export default App

411
web/src/ContextMenu.tsx Normal file
View File

@@ -0,0 +1,411 @@
import { useState, useEffect, useRef } from 'react'
import type { ContextMenuState, DayInfo } from './types'
import type { SelectedCell } from './ScheduleTable'
import { COLOR_PALETTE } from './cellColors'
interface ContextMenuProps {
state: ContextMenuState
dayInfo: DayInfo
personName: string | null
cellValue: string | undefined
cellColor: string | undefined
existingDayComment: string | undefined
existingCellComment: string | undefined
existingTunnelClosure: string | undefined
existingTunnelColor: string | undefined
existingInfoRowClosure: string | undefined
existingInfoRowColor: string | undefined
selectedDays: number[]
selectedCells: SelectedCell[]
onSetCell: (personId: string, dayIdx: number, value: string | null) => void
onAddDayComment: (dayIdx: number, text: string) => void
onRemoveDayComment: (dayIdx: number) => void
onAddCellComment: (personId: string, dayIdx: number, text: string) => void
onRemoveCellComment: (personId: string, dayIdx: number) => void
onSetTunnelClosure: (dayIdx: number, text: string | null) => void
onSetCellColor: (personId: string, dayIdx: number, color: string | null) => void
onSetTunnelClosureColor: (dayIdx: number, color: string | null) => void
onSetInfoRowColor: (dayIdx: number, color: string | null) => void
onClose: () => void
}
export function ContextMenu({
state,
dayInfo,
personName,
cellValue,
cellColor,
existingDayComment,
existingCellComment,
existingTunnelClosure,
existingTunnelColor,
existingInfoRowClosure,
existingInfoRowColor,
selectedDays,
selectedCells,
onSetCell,
onAddDayComment,
onRemoveDayComment,
onAddCellComment,
onRemoveCellComment,
onSetTunnelClosure,
onSetCellColor,
onSetTunnelClosureColor,
onSetInfoRowColor,
onClose,
}: ContextMenuProps) {
const isMultiSelect = selectedCells.length > 1
const [showCommentInput, setShowCommentInput] = useState(false)
const [commentText, setCommentText] = useState('')
const [showCellCommentInput, setShowCellCommentInput] = useState(false)
const [cellCommentText, setCellCommentText] = useState('')
const [cellValueInput, setCellValueInput] = useState(cellValue ?? '')
const cellCommentInputRef = useRef<HTMLInputElement>(null)
const commentInputRef = useRef<HTMLInputElement>(null)
const cellValueInputRef = useRef<HTMLInputElement>(null)
const menuRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (showCommentInput) commentInputRef.current?.focus()
}, [showCommentInput])
useEffect(() => {
if (showCellCommentInput) cellCommentInputRef.current?.focus()
}, [showCellCommentInput])
useEffect(() => {
const handleClick = (e: MouseEvent) => {
if (menuRef.current && !menuRef.current.contains(e.target as Node)) {
onClose()
}
}
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('mousedown', handleClick)
document.addEventListener('keydown', handleKey)
return () => {
document.removeEventListener('mousedown', handleClick)
document.removeEventListener('keydown', handleKey)
}
}, [onClose])
const handleAddComment = () => {
if (commentText.trim()) {
const days = selectedDays.length > 0 ? selectedDays : [state.dayIdx]
for (const dayIdx of days) {
onAddDayComment(dayIdx, commentText.trim())
}
onClose()
}
}
const handleRemoveComments = () => {
const days = selectedDays.length > 0 ? selectedDays : [state.dayIdx]
for (const dayIdx of days) {
onRemoveDayComment(dayIdx)
}
onClose()
}
const handleSetCellValue = () => {
const val = cellValueInput.trim()
if (isMultiSelect) {
for (const cell of selectedCells) {
onSetCell(cell.personId, cell.dayIdx, val || null)
}
} else if (state.personId) {
onSetCell(state.personId, state.dayIdx, val || null)
}
onClose()
}
const handleClearCell = () => {
if (isMultiSelect) {
for (const cell of selectedCells) {
onSetCell(cell.personId, cell.dayIdx, null)
}
} else if (state.personId) {
onSetCell(state.personId, state.dayIdx, null)
}
onClose()
}
const dateStr = `${dayInfo.day}.${dayInfo.month}.${dayInfo.year}`
const isTunnelRow = !!state.isTunnelRow
const isInfoRow = !!state.infoRowId
const infoRowId = state.infoRowId
const infoRowLabel = infoRowId === 'metro' ? 'METRO' : infoRowId === 'd8' ? 'D8' : ''
const isDenRow = !state.personId && !isTunnelRow && !isInfoRow
const multiDayLabel = selectedDays.length > 1 ? ` (${selectedDays.length} dnu)` : ''
const renderColorPalette = () => {
const activeColor = isTunnelRow
? existingTunnelColor
: isInfoRow
? existingInfoRowColor
: cellColor
return (
<div className="border-t border-slate-700 px-3 py-2">
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1.5">Barva pozadi</div>
<div className="flex flex-wrap gap-1">
{COLOR_PALETTE.map(({ color, label }) => {
const isActive = activeColor === color
return (
<button
key={color || 'none'}
onClick={() => {
if (isTunnelRow) {
onSetTunnelClosureColor(state.dayIdx, color || null)
} else if (isInfoRow) {
onSetInfoRowColor(state.dayIdx, color || null)
} else if (isMultiSelect) {
for (const cell of selectedCells) {
onSetCellColor(cell.personId, cell.dayIdx, color || null)
}
} else if (state.personId) {
onSetCellColor(state.personId, state.dayIdx, color || null)
}
onClose()
}}
className="w-6 h-6 rounded border border-slate-600 hover:scale-110 transition-transform cursor-pointer flex items-center justify-center"
style={{ backgroundColor: color || '#1e293b' }}
title={label}
>
{!color && <span className="text-[8px] text-slate-400">&times;</span>}
{isActive && color && (
<span className="text-[10px]" style={{ color: color === '#FFFF00' || color === '#E0E0E0' || color === '#87CEEB' ? '#333' : '#fff' }}>&#10003;</span>
)}
</button>
)
})}
</div>
</div>
)
}
return (
<div
ref={menuRef}
className="fixed z-[200] bg-slate-800 border border-slate-600 rounded-lg shadow-2xl py-1 min-w-[220px]"
style={{ left: state.x, top: state.y }}
>
<div className="px-3 py-1.5 text-[10px] text-slate-500 border-b border-slate-700">
{isTunnelRow ? (
<span className="uppercase tracking-wider">UZAVERY</span>
) : isInfoRow ? (
<span className="uppercase tracking-wider">{infoRowLabel}</span>
) : isDenRow ? (
<span className="uppercase tracking-wider">DEN{multiDayLabel}</span>
) : isMultiSelect ? (
<span className="uppercase tracking-wider">Vybrano {selectedCells.length} bunek</span>
) : (
<span className="uppercase tracking-wider">{personName || state.personId}</span>
)}
<span className="mx-1">--</span>
{dateStr} (T{dayInfo.week})
</div>
{isInfoRow ? (
<>
{existingInfoRowClosure && (
<div className="px-3 py-2 text-xs text-blue-300 border-b border-slate-700">
<span className="text-blue-400 mr-1.5">&#9654;</span>
{existingInfoRowClosure}
</div>
)}
{renderColorPalette()}
</>
) : isTunnelRow ? (
<>
{existingTunnelClosure && (
<div className="px-3 py-2 text-xs text-orange-300 border-b border-slate-700">
<span className="text-orange-400 mr-1.5">&#9654;</span>
{existingTunnelClosure}
</div>
)}
{renderColorPalette()}
</>
) : isDenRow ? (
<>
{existingDayComment && !showCommentInput ? (
<>
<div className="px-3 py-2 text-xs text-blue-300 border-b border-slate-700">
<span className="text-blue-400 mr-1.5">&#9654;</span>
{existingDayComment}
</div>
<button
onClick={handleRemoveComments}
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
>
Odebrat komentar{multiDayLabel}
</button>
<button
onClick={() => { setShowCommentInput(true); setCommentText(existingDayComment) }}
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
>
Upravit komentar
</button>
</>
) : (
<>
{!showCommentInput ? (
<button
onClick={() => setShowCommentInput(true)}
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
>
<span className="text-blue-400 mr-1.5">+</span>
Pridat komentar{multiDayLabel}
</button>
) : (
<div className="px-3 py-2">
<input
ref={commentInputRef}
type="text"
value={commentText}
onChange={e => setCommentText(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleAddComment() }}
placeholder="Komentar ke dni..."
className="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-200
placeholder-slate-500 outline-none focus:border-blue-500 transition-colors"
/>
<div className="flex gap-1.5 mt-1.5">
<button
onClick={handleAddComment}
disabled={!commentText.trim()}
className="px-2.5 py-1 rounded text-[10px] bg-blue-600 text-white hover:bg-blue-500
disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
>
Pridat{multiDayLabel}
</button>
<button
onClick={onClose}
className="px-2.5 py-1 rounded text-[10px] bg-slate-700 text-slate-300
hover:bg-slate-600 cursor-pointer transition-colors"
>
Zrusit
</button>
</div>
</div>
)}
</>
)}
</>
) : (
<>
{/* Cell value editing */}
<div className="px-3 py-2 border-b border-slate-700">
<div className="flex items-center gap-1.5">
<input
ref={cellValueInputRef}
type="text"
value={cellValueInput}
onChange={e => setCellValueInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter') handleSetCellValue() }}
placeholder="8, 12, D, N, U, O, D/2, x"
className="flex-1 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-200
placeholder-slate-500 outline-none focus:border-blue-500 transition-colors"
/>
<button
onClick={handleSetCellValue}
className="px-2.5 py-1 rounded text-[10px] bg-blue-600 text-white hover:bg-blue-500
cursor-pointer transition-colors"
>
Nastavit
</button>
</div>
</div>
{/* Clear cell */}
{(cellValue || isMultiSelect) && (
<button
onClick={handleClearCell}
className="w-full text-left px-3 py-2 text-xs text-red-300 hover:bg-slate-700 cursor-pointer transition-colors"
>
{isMultiSelect ? `Smazat hodnoty (${selectedCells.length} bunek)` : `Smazat hodnotu "${cellValue}"`}
</button>
)}
{/* Cell comment section - hidden for multi-select */}
{!isMultiSelect && <div className="border-t border-slate-700">
{existingCellComment ? (
<>
<div className="px-3 py-2 text-xs text-blue-300 border-b border-slate-700">
<span className="text-blue-400 mr-1.5">&#9654;</span>
{existingCellComment}
</div>
<button
onClick={() => { onRemoveCellComment(state.personId!, state.dayIdx); onClose() }}
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
>
Odebrat komentar
</button>
<button
onClick={() => { setShowCellCommentInput(true); setCellCommentText(existingCellComment) }}
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
>
Upravit komentar
</button>
</>
) : (
<>
{!showCellCommentInput ? (
<button
onClick={() => setShowCellCommentInput(true)}
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors"
>
<span className="text-blue-400 mr-1.5">+</span>
Pridat komentar
</button>
) : (
<div className="px-3 py-2">
<input
ref={cellCommentInputRef}
type="text"
value={cellCommentText}
onChange={e => setCellCommentText(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' && cellCommentText.trim() && state.personId) {
onAddCellComment(state.personId, state.dayIdx, cellCommentText.trim())
onClose()
}
}}
placeholder="Komentar k bunce..."
className="w-full bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-200
placeholder-slate-500 outline-none focus:border-blue-500 transition-colors"
/>
<div className="flex gap-1.5 mt-1.5">
<button
onClick={() => {
if (cellCommentText.trim() && state.personId) {
onAddCellComment(state.personId, state.dayIdx, cellCommentText.trim())
onClose()
}
}}
disabled={!cellCommentText.trim()}
className="px-2.5 py-1 rounded text-[10px] bg-blue-600 text-white hover:bg-blue-500
disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
>
Pridat
</button>
<button
onClick={onClose}
className="px-2.5 py-1 rounded text-[10px] bg-slate-700 text-slate-300
hover:bg-slate-600 cursor-pointer transition-colors"
>
Zrusit
</button>
</div>
</div>
)}
</>
)}
</div>}
{renderColorPalette()}
</>
)}
</div>
)
}

286
web/src/FileManager.tsx Normal file
View File

@@ -0,0 +1,286 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import type { ScheduleFile } from './types'
interface FileManagerProps {
onOpenFile: (fileId: string) => void
onCompare: (fileId1: string, fileId2: string) => void
onCreateNew?: () => void
}
export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerProps) {
const [files, setFiles] = useState<ScheduleFile[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
const [uploading, setUploading] = useState(false)
const [deleting, setDeleting] = useState<string | null>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const loadFiles = useCallback(async () => {
try {
const res = await fetch('/api/files')
if (!res.ok) throw new Error('Failed to load files')
const data = await res.json()
setFiles(data)
setError(null)
} catch (err) {
setError(`Chyba pri nacitani souboru: ${err}`)
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadFiles()
}, [loadFiles])
const handleDelete = useCallback(async (id: string, name: string) => {
if (!confirm(`Opravdu smazat "${name}"?`)) return
setDeleting(id)
try {
const res = await fetch(`/api/files/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error('Delete failed')
setSelectedIds(prev => {
const next = new Set(prev)
next.delete(id)
return next
})
await loadFiles()
} catch (err) {
alert(`Chyba pri mazani: ${err}`)
} finally {
setDeleting(null)
}
}, [loadFiles])
const handleUploadExcel = useCallback(async (file: File) => {
setUploading(true)
try {
const formData = new FormData()
formData.append('file', file)
const res = await fetch('/api/files/import-excel', {
method: 'POST',
body: formData,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Upload failed' }))
throw new Error(err.error || err.details || 'Upload failed')
}
await loadFiles()
} catch (err) {
alert(`Chyba pri importu Excel: ${err}`)
} finally {
setUploading(false)
}
}, [loadFiles])
const handleFileInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (file) handleUploadExcel(file)
// Reset input so the same file can be selected again
e.target.value = ''
}, [handleUploadExcel])
const toggleSelect = useCallback((id: string) => {
setSelectedIds(prev => {
const next = new Set(prev)
if (next.has(id)) {
next.delete(id)
} else {
if (next.size >= 2) {
// Replace oldest selection
const first = next.values().next().value
if (first !== undefined) next.delete(first)
}
next.add(id)
}
return next
})
}, [])
const handleCompare = useCallback(() => {
const ids = Array.from(selectedIds)
if (ids.length === 2) {
onCompare(ids[0], ids[1])
}
}, [selectedIds, onCompare])
const formatDate = (iso: string) => {
const d = new Date(iso)
return d.toLocaleDateString('cs-CZ', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
}
if (loading) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="text-slate-400 text-sm">Nacitam soubory...</div>
</div>
)
}
return (
<div className="min-h-screen bg-slate-900">
<header className="border-b border-slate-700 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-50 px-6 py-4">
<h1 className="text-xl font-semibold text-slate-100 tracking-tight">
TKB Plan sluzeb
<span className="ml-3 text-sm font-normal text-slate-400">
Sprava souboru planu sluzeb
</span>
</h1>
</header>
<main className="p-6 max-w-4xl mx-auto">
{error && (
<div className="mb-4 px-4 py-3 rounded bg-red-900/50 border border-red-700 text-red-300 text-sm">
{error}
</div>
)}
{/* Actions bar */}
<div className="flex items-center gap-3 mb-6">
{onCreateNew && (
<button
onClick={onCreateNew}
className="px-4 py-2 rounded text-sm bg-blue-700/70 text-blue-100 border border-blue-600
hover:bg-blue-600/70 cursor-pointer transition-colors"
>
Novy soubor
</button>
)}
<button
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
className="px-4 py-2 rounded text-sm bg-green-700/70 text-green-100 border border-green-600
hover:bg-green-600/70 cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{uploading ? 'Nahravani...' : 'Nahrat Excel'}
</button>
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
onChange={handleFileInputChange}
className="hidden"
/>
{selectedIds.size === 2 && (
<button
onClick={handleCompare}
className="px-4 py-2 rounded text-sm bg-purple-700/70 text-purple-100 border border-purple-600
hover:bg-purple-600/70 cursor-pointer transition-colors"
>
Porovnat ({selectedIds.size})
</button>
)}
{selectedIds.size > 0 && selectedIds.size < 2 && (
<span className="text-xs text-slate-500">
Vyberte 2 soubory pro porovnani ({selectedIds.size}/2)
</span>
)}
</div>
{/* Files table */}
{files.length === 0 ? (
<div className="text-center py-16">
<div className="text-slate-500 text-sm mb-4">Zadne soubory</div>
<div className="flex items-center justify-center gap-3">
{onCreateNew && (
<button
onClick={onCreateNew}
className="px-4 py-2 rounded text-sm bg-blue-700/70 text-blue-100 border border-blue-600
hover:bg-blue-600/70 cursor-pointer transition-colors"
>
Vytvorit novy soubor
</button>
)}
<button
onClick={() => fileInputRef.current?.click()}
className="px-4 py-2 rounded text-sm bg-green-700/70 text-green-100 border border-green-600
hover:bg-green-600/70 cursor-pointer transition-colors"
>
Nahrat Excel
</button>
</div>
</div>
) : (
<div className="bg-slate-800 border border-slate-700 rounded-lg overflow-hidden">
<table className="w-full">
<thead>
<tr className="border-b border-slate-700">
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider w-10"></th>
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Nazev</th>
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Upraveno</th>
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Vytvoreno</th>
<th className="px-4 py-3 text-right text-xs text-slate-400 uppercase tracking-wider">Akce</th>
</tr>
</thead>
<tbody>
{files.map(file => (
<tr
key={file.id}
className={`border-b border-slate-700/50 hover:bg-slate-750 transition-colors
${selectedIds.has(file.id) ? 'bg-slate-700/30' : ''}`}
>
<td className="px-4 py-3">
<input
type="checkbox"
checked={selectedIds.has(file.id)}
onChange={() => toggleSelect(file.id)}
className="w-4 h-4 rounded border-slate-600 bg-slate-900 text-blue-600
focus:ring-blue-500 focus:ring-offset-0 cursor-pointer"
/>
</td>
<td className="px-4 py-3">
<span className="text-sm text-slate-200 font-medium">{file.name}</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-slate-400">{formatDate(file.modifiedAt)}</span>
</td>
<td className="px-4 py-3">
<span className="text-sm text-slate-400">{formatDate(file.createdAt)}</span>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => onOpenFile(file.id)}
className="px-3 py-1 rounded text-xs bg-blue-700/70 text-blue-100 border border-blue-600
hover:bg-blue-600/70 cursor-pointer transition-colors"
>
Otevrit
</button>
<a
href={`/api/files/${file.id}/export-excel`}
className="px-3 py-1 rounded text-xs bg-green-800/70 text-green-200 border border-green-700
hover:bg-green-700/70 cursor-pointer transition-colors inline-flex items-center gap-1"
>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>
Excel
</a>
<button
onClick={() => handleDelete(file.id, file.name)}
disabled={deleting === file.id}
className="px-3 py-1 rounded text-xs bg-red-900/50 text-red-300 border border-red-800
hover:bg-red-800/50 cursor-pointer transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
{deleting === file.id ? '...' : 'Smazat'}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</main>
</div>
)
}

83
web/src/Login.tsx Normal file
View File

@@ -0,0 +1,83 @@
import { useState } from 'react'
const VALID_USER = 'tkb'
const VALID_PASS = 'sluzby'
const AUTH_KEY = 'tkb_auth'
export function isAuthenticated(): boolean {
return sessionStorage.getItem(AUTH_KEY) === 'true'
}
export function setAuthenticated(): void {
sessionStorage.setItem(AUTH_KEY, 'true')
}
interface LoginProps {
onLogin: () => void
}
export function Login({ onLogin }: LoginProps) {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState(false)
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (username === VALID_USER && password === VALID_PASS) {
setAuthenticated()
onLogin()
} else {
setError(true)
setTimeout(() => setError(false), 3000)
}
}
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<form
onSubmit={handleSubmit}
className="bg-slate-800 border border-slate-700 rounded-lg p-8 w-80 shadow-xl"
>
<h1 className="text-lg font-semibold text-slate-100 mb-1 text-center">
TKB Plan sluzeb
</h1>
<p className="text-sm text-slate-400 mb-6 text-center">
Planovani smen a pohotovosti
</p>
<label className="block text-xs text-slate-400 mb-1">Uzivatel</label>
<input
type="text"
value={username}
onChange={e => setUsername(e.target.value)}
className="w-full mb-4 px-3 py-2 rounded bg-slate-900 border border-slate-600 text-slate-100
text-sm focus:outline-none focus:border-blue-500"
autoFocus
/>
<label className="block text-xs text-slate-400 mb-1">Heslo</label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
className="w-full mb-6 px-3 py-2 rounded bg-slate-900 border border-slate-600 text-slate-100
text-sm focus:outline-none focus:border-blue-500"
/>
{error && (
<div className="mb-4 text-xs text-red-400 text-center">
Nespravne prihlasovaci udaje
</div>
)}
<button
type="submit"
className="w-full py-2 rounded bg-blue-600 hover:bg-blue-500 text-white text-sm
font-medium transition-colors cursor-pointer"
>
Prihlasit
</button>
</form>
</div>
)
}

338
web/src/PdfExport.ts Normal file
View File

@@ -0,0 +1,338 @@
import jsPDF from 'jspdf'
import type { DayInfo, Person } from './types'
import { getCellStyle } from './cellColors'
const MONTH_NAMES: 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 DAY_NAMES = ['Ne', 'Po', 'Út', 'St', 'Čt', 'Pá', 'So']
function arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer)
const chunks: string[] = []
for (let i = 0; i < bytes.length; i += 8192) {
chunks.push(String.fromCharCode.apply(null, bytes.slice(i, i + 8192) as unknown as number[]))
}
return btoa(chunks.join(''))
}
async function loadFont(doc: jsPDF) {
try {
const res = await fetch('/DejaVuSans.ttf')
const buf = await res.arrayBuffer()
const base64 = arrayBufferToBase64(buf)
doc.addFileToVFS('DejaVuSans.ttf', base64)
doc.addFont('DejaVuSans.ttf', 'DejaVu', 'normal')
doc.setFont('DejaVu', 'normal')
return true
} catch (e) {
console.error('Font load failed:', e)
return false
}
}
export async function exportMonthPdf(
month: number,
year: number,
dayIndex: DayInfo[],
people: Person[],
tunnelClosures: Map<number, string>,
tunnelColors: Map<number, string>,
) {
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
const hasFont = await loadFont(doc)
const monthDays = dayIndex.filter(d => d.month === month && d.year === year)
if (monthDays.length === 0) return
const firstDayIdx = dayIndex.indexOf(monthDays[0])
const daysFromPrevMonth = Math.max(0, 34 - monthDays.length)
const startIdx = Math.max(0, firstDayIdx - daysFromPrevMonth)
const days = dayIndex.slice(startIdx, startIdx + 34)
const tkbPeople = people.filter(p => p.group === 'TKB')
const itPeople = people.filter(p => p.group === 'IT')
// Layout: fill entire A4 landscape (297 x 210 mm)
const pageW = 297
const pageH = 210
const margin = 3
const nameColW = 50
const daysPerPage = 17
const cellW = (pageW - margin * 2 - nameColW) / daysPerPage
// Row height to fill full page
const headerH = 24
const totalDataRows = tkbPeople.length + itPeople.length + 2
const availableH = pageH - margin * 2 - headerH
const rowH = availableH / totalDataRows
// Font sizes — match day number size for readability
const nameFontSize = 9
const cellFontSize = 9
const sectionFontSize = 10
const dayNumFontSize = 10
const dayNameFontSize = 7
const closureFontSize = 6
for (let pageNum = 0; pageNum < 2; pageNum++) {
if (pageNum > 0) doc.addPage()
const pageDays = days.slice(pageNum * daysPerPage, (pageNum + 1) * daysPerPage)
if (pageDays.length === 0) continue
if (hasFont) doc.setFont('DejaVu', 'normal')
const showNames = pageNum === 0
const gridStartX = showNames ? margin + nameColW : margin
const gridW = pageDays.length * cellW
const totalW = showNames ? nameColW + gridW : gridW
const startY = margin
// --- Header area ---
// Month/year in top-left cell (only page 1)
if (showNames) {
doc.setFillColor(255, 255, 255)
doc.rect(margin, startY, nameColW, headerH, 'F')
doc.setFontSize(11)
doc.setTextColor(0)
doc.text(`${MONTH_NAMES[month]}`, margin + 2, startY + 9)
doc.setFontSize(10)
doc.text(`${year}`, margin + 2, startY + 17)
}
// Day columns header
pageDays.forEach((d, i) => {
const x = gridStartX + i * cellW
const isOtherMonth = d.month !== month
// Background
if (isOtherMonth) {
doc.setFillColor(235, 235, 235)
doc.rect(x, startY, cellW, headerH, 'F')
} else if (d.weekend) {
doc.setFillColor(230, 230, 230)
doc.rect(x, startY, cellW, headerH, 'F')
}
// Day number
if (isOtherMonth) {
doc.setTextColor(180, 180, 180)
} else {
doc.setTextColor(d.weekend ? 180 : 0, 0, 0)
}
doc.setFontSize(dayNumFontSize)
doc.text(String(d.day), x + cellW / 2, startY + 7, { align: 'center' })
// Day name
const dow = new Date(d.year, d.month - 1, d.day).getDay()
doc.setFontSize(dayNameFontSize)
doc.text(DAY_NAMES[dow], x + cellW / 2, startY + 13, { align: 'center' })
// Tunnel closure
const closure = tunnelClosures.get(d.idx)
const closureColor = tunnelColors.get(d.idx)
if (closure || closureColor) {
if (closureColor) {
const rgb = hexToRgb(closureColor)
doc.setFillColor(rgb.r, rgb.g, rgb.b)
} else {
doc.setFillColor(255, 165, 0)
}
if (isOtherMonth) {
// Make closure color lighter for other months
doc.setGState(doc.GState({ opacity: 0.4 }))
}
doc.rect(x, startY + 15, cellW, headerH - 15, 'F')
if (isOtherMonth) {
doc.setGState(doc.GState({ opacity: 1 }))
}
if (closure) {
doc.setFontSize(closureFontSize)
doc.setTextColor(isOtherMonth ? 180 : 0)
doc.text(closure, x + cellW / 2, startY + 20, { align: 'center' })
}
}
})
// Header grid lines
doc.setDrawColor(0, 0, 0)
doc.setLineWidth(0.2)
pageDays.forEach((d, i) => {
const x = gridStartX + i * cellW
const isOtherMonth = d.month !== month
doc.setDrawColor(isOtherMonth ? 180 : 0, isOtherMonth ? 180 : 0, isOtherMonth ? 180 : 0)
doc.line(x, startY, x, startY + headerH)
})
// Right edge of last column
const lastDay = pageDays[pageDays.length - 1]
const lastIsOther = lastDay && lastDay.month !== month
doc.setDrawColor(lastIsOther ? 180 : 0, lastIsOther ? 180 : 0, lastIsOther ? 180 : 0)
doc.line(gridStartX + pageDays.length * cellW, startY, gridStartX + pageDays.length * cellW, startY + headerH)
doc.setDrawColor(0, 0, 0)
doc.setLineWidth(0.3)
doc.line(margin, startY, margin + totalW, startY)
doc.line(margin, startY + headerH, margin + totalW, startY + headerH)
if (showNames) doc.line(gridStartX, startY, gridStartX, startY + headerH)
// --- Data rows ---
let curY = startY + headerH
// TKB section header
doc.setFillColor(79, 70, 229)
doc.rect(margin, curY, totalW, rowH, 'F')
doc.setFontSize(sectionFontSize)
doc.setTextColor(255, 255, 255)
if (showNames) doc.text('POHOTOVOST TKB', margin + 2, curY + rowH * 0.7)
curY += rowH
renderPeople(doc, tkbPeople, pageDays, curY, margin, showNames ? nameColW : 0, gridStartX, cellW, rowH, showNames, nameFontSize, cellFontSize, month)
curY += tkbPeople.length * rowH
// IT section header
doc.setFillColor(13, 148, 136)
doc.rect(margin, curY, totalW, rowH, 'F')
doc.setFontSize(sectionFontSize)
doc.setTextColor(255, 255, 255)
if (showNames) doc.text('POHOTOVOST IT', margin + 2, curY + rowH * 0.7)
curY += rowH
renderPeople(doc, itPeople, pageDays, curY, margin, showNames ? nameColW : 0, gridStartX, cellW, rowH, showNames, nameFontSize, cellFontSize, month)
curY += itPeople.length * rowH
// Outer border
doc.setDrawColor(0, 0, 0)
doc.setLineWidth(0.3)
doc.rect(margin, startY, totalW, curY - startY)
if (showNames) doc.line(gridStartX, startY, gridStartX, curY)
}
doc.save(`TKB_Plan_${MONTH_NAMES[month]}_${year}.pdf`)
}
function renderPeople(
doc: jsPDF, people: Person[], days: DayInfo[],
startY: number, margin: number, nameColW: number, gridStartX: number,
cellW: number, rowH: number, showNames: boolean,
nameFontSize: number, cellFontSize: number, activeMonth: number,
) {
const totalW = nameColW + days.length * cellW
const sectionH = people.length * rowH
const textY = rowH * 0.65
// Pass 1: Fill backgrounds
people.forEach((person, pi) => {
const y = startY + pi * rowH
days.forEach((d, di) => {
const x = gridStartX + di * cellW
const isOtherMonth = d.month !== activeMonth
const cellData = person.data[String(d.idx)]
const value = cellData?.v ?? ''
const manualColor = cellData?.color
const style = getCellStyle(value || undefined)
if (isOtherMonth) {
// Light gray wash for other month cells
doc.setFillColor(245, 245, 245)
doc.rect(x, y, cellW, rowH, 'F')
// If there's a value, draw it faded
if (manualColor || style) {
doc.setGState(doc.GState({ opacity: 0.35 }))
const rgb = hexToRgb(manualColor ?? style!.bg)
doc.setFillColor(rgb.r, rgb.g, rgb.b)
doc.rect(x, y, cellW, rowH, 'F')
doc.setGState(doc.GState({ opacity: 1 }))
}
} else if (manualColor) {
const rgb = hexToRgb(manualColor)
doc.setFillColor(rgb.r, rgb.g, rgb.b)
doc.rect(x, y, cellW, rowH, 'F')
} else if (style) {
const rgb = hexToRgb(style.bg)
doc.setFillColor(rgb.r, rgb.g, rgb.b)
doc.rect(x, y, cellW, rowH, 'F')
} else if (d.weekend) {
doc.setFillColor(240, 240, 240)
doc.rect(x, y, cellW, rowH, 'F')
}
})
})
// Pass 2: Grid lines — gray for other month columns, black for current month
doc.setLineWidth(0.15)
// Horizontal lines (always black)
doc.setDrawColor(0, 0, 0)
for (let pi = 0; pi <= people.length; pi++) {
doc.line(margin, startY + pi * rowH, margin + totalW, startY + pi * rowH)
}
// Vertical lines — color depends on which month the column is in
days.forEach((d, i) => {
const isOtherMonth = d.month !== activeMonth
doc.setDrawColor(isOtherMonth ? 190 : 0, isOtherMonth ? 190 : 0, isOtherMonth ? 190 : 0)
doc.line(gridStartX + i * cellW, startY, gridStartX + i * cellW, startY + sectionH)
})
// Right edge
const lastDay = days[days.length - 1]
const lastOther = lastDay && lastDay.month !== activeMonth
doc.setDrawColor(lastOther ? 190 : 0, lastOther ? 190 : 0, lastOther ? 190 : 0)
doc.line(gridStartX + days.length * cellW, startY, gridStartX + days.length * cellW, startY + sectionH)
// Name column separator
if (showNames) {
doc.setDrawColor(0, 0, 0)
doc.setLineWidth(0.2)
doc.line(gridStartX, startY, gridStartX, startY + sectionH)
}
// Pass 3: Text
people.forEach((person, pi) => {
const y = startY + pi * rowH
if (showNames) {
doc.setFontSize(nameFontSize)
doc.setTextColor(0)
const displayName = person.name + (person.note ? ` (${person.note})` : '')
doc.text(displayName, margin + 1, y + textY, { maxWidth: nameColW - 2 })
}
days.forEach((d, di) => {
const x = gridStartX + di * cellW
const cellData = person.data[String(d.idx)]
const value = cellData?.v ?? ''
if (value) {
const isOtherMonth = d.month !== activeMonth
const manualColor = cellData?.color
const style = getCellStyle(value || undefined)
const textColor = manualColor
? (isLightColor(manualColor) ? '#333333' : '#ffffff')
: (style?.text ?? '#333333')
const rgb = hexToRgb(textColor)
if (isOtherMonth) {
doc.setTextColor(200, 200, 200)
} else {
doc.setTextColor(rgb.r, rgb.g, rgb.b)
}
doc.setFontSize(cellFontSize)
doc.text(value, x + cellW / 2, y + textY, { align: 'center' })
}
})
})
}
function hexToRgb(hex: string): { r: number; g: number; b: number } {
let h = hex.replace('#', '')
if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2]
return {
r: parseInt(h.substring(0, 2), 16) || 0,
g: parseInt(h.substring(2, 4), 16) || 0,
b: parseInt(h.substring(4, 6), 16) || 0,
}
}
function isLightColor(hex: string): boolean {
const { r, g, b } = hexToRgb(hex)
return (r * 299 + g * 587 + b * 114) / 1000 > 128
}

111
web/src/ProposalModal.tsx Normal file
View File

@@ -0,0 +1,111 @@
import { useState, useEffect, useRef } from 'react'
interface ProposalModalProps {
onClose: () => void
}
export function ProposalModal({ onClose }: ProposalModalProps) {
const [text, setText] = useState('')
const [author, setAuthor] = useState('')
const [proposals, setProposals] = useState('')
const [submitting, setSubmitting] = useState(false)
const [submitted, setSubmitted] = useState(false)
const overlayRef = useRef<HTMLDivElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
fetch('/api/proposals')
.then(r => r.json())
.then(d => setProposals(d.content || ''))
.catch(() => {})
}, [submitted])
useEffect(() => {
textareaRef.current?.focus()
}, [])
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [onClose])
const handleSubmit = async () => {
if (!text.trim()) return
setSubmitting(true)
try {
const res = await fetch('/api/proposals', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text.trim(), author: author.trim() || undefined }),
})
if (res.ok) {
setText('')
setSubmitted(s => !s)
}
} catch {
} finally {
setSubmitting(false)
}
}
return (
<div
ref={overlayRef}
className="fixed inset-0 z-[300] bg-black/50 flex items-center justify-center"
onClick={(e) => { if (e.target === overlayRef.current) onClose() }}
>
<div className="bg-slate-800 border border-slate-600 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col">
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700">
<h2 className="text-sm font-semibold text-slate-200">Navrhy na vylepseni</h2>
<button
onClick={onClose}
className="text-slate-500 hover:text-slate-300 cursor-pointer text-lg leading-none"
>
&times;
</button>
</div>
<div className="px-5 py-3 border-b border-slate-700">
<input
type="text"
value={author}
onChange={e => setAuthor(e.target.value)}
placeholder="Vase jmeno (nepovinne)"
className="w-full bg-slate-900 border border-slate-600 rounded px-3 py-1.5 text-xs text-slate-200
placeholder-slate-500 outline-none focus:border-blue-500 transition-colors mb-2"
/>
<textarea
ref={textareaRef}
value={text}
onChange={e => setText(e.target.value)}
placeholder="Popiste svuj navrh nebo problem..."
rows={3}
className="w-full bg-slate-900 border border-slate-600 rounded px-3 py-2 text-xs text-slate-200
placeholder-slate-500 outline-none focus:border-blue-500 transition-colors resize-none"
/>
<div className="flex justify-end mt-2">
<button
onClick={handleSubmit}
disabled={!text.trim() || submitting}
className="px-4 py-1.5 rounded text-xs bg-blue-600 text-white hover:bg-blue-500
disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
>
{submitting ? 'Odesilam...' : 'Odeslat'}
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-5 py-3">
{proposals ? (
<pre className="text-xs text-slate-300 whitespace-pre-wrap font-sans">{proposals}</pre>
) : (
<p className="text-xs text-slate-500">Zatim zadne navrhy.</p>
)}
</div>
</div>
</div>
)
}

921
web/src/ScheduleTable.tsx Normal file
View File

@@ -0,0 +1,921 @@
import { useMemo, useState, useCallback, useRef, memo, useEffect } from 'react'
import type { DayInfo, Person, DragState, ScheduleData } from './types'
import { getCellStyle, getContrastColor } from './cellColors'
import { getHolidayMap } from './holidays'
const CELL_W = 32
const CELL_H = 32
const MONTH_NAMES: 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 DAY_NAMES = ['Ne', 'Po', 'Út', 'St', 'Čt', 'Pá', 'So']
export interface SelectedCell {
personId: string
dayIdx: number
}
interface ScheduleTableProps {
dayIndex: DayInfo[]
people: Person[]
tunnelClosures: Map<number, string>
tunnelColors: Map<number, string>
metroClosures: Map<number, string>
metroColors: Map<number, string>
d8Closures: Map<number, string>
d8Colors: Map<number, string>
dayComments: Map<number, string>
cellComments: Map<string, string>
dragState: DragState | null
onCellPointerDown: (e: React.PointerEvent, personId: string, dayIdx: number, value: string) => void
onSetCell: (personId: string, dayIdx: number, value: string | null) => void
onSetTunnelClosure: (dayIdx: number, text: string | null) => void
onSetMetroClosure: (dayIdx: number, text: string | null) => void
onSetD8Closure: (dayIdx: number, text: string | null) => void
scrollRef: React.RefObject<HTMLDivElement | null>
showMetro: boolean
showD8: boolean
hiddenValues: Set<string>
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number, selectedCells: SelectedCell[]) => void
onTunnelContextMenu: (dayIdx: number, x: number, y: number) => void
onInfoRowContextMenu: (dayIdx: number, infoRowId: string, x: number, y: number) => void
compareData?: ScheduleData | null
}
interface EditingCell {
personId: string | '__tunnel__' | '__metro__' | '__d8__'
dayIdx: number
value: string
}
// ---------- PersonRow (memoized) ----------
const PersonRow = memo(function PersonRow({
person,
dayIndex,
dragState,
cellComments,
dayComments,
holidays,
hiddenValues,
onCellPointerDown,
onContextMenu,
editingCell,
onStartEdit,
onEditChange,
onEditConfirm,
onEditCancel,
selectedCells,
onCellClick,
onCellDragSelectStart,
onCellDragSelectMove,
nameColW,
}: {
person: Person
dayIndex: DayInfo[]
dragState: DragState | null
cellComments: Map<string, string>
dayComments: Map<number, string>
holidays: Map<string, string>
hiddenValues: Set<string>
onCellPointerDown: (e: React.PointerEvent, personId: string, dayIdx: number, value: string) => void
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number) => void
editingCell: EditingCell | null
onStartEdit: (personId: string, dayIdx: number, currentValue: string) => void
onEditChange: (value: string) => void
onEditConfirm: () => void
onEditCancel: () => void
selectedCells: Set<string>
onCellClick: (personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => void
onCellDragSelectStart: (personId: string, dayIdx: number) => void
onCellDragSelectMove: (personId: string, dayIdx: number) => void
nameColW: number
}) {
const isDragging = dragState?.personId === person.id
return (
<div className="flex border-b border-slate-700" style={{ height: CELL_H }}>
<div
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-xs text-slate-200 whitespace-nowrap truncate">
{person.name}
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
</span>
</div>
{dayIndex.map((d) => {
const cellData = person.data[String(d.idx)]
const value = cellData?.v ?? ''
const isColorOnly = !value && !!cellData?.color
const isValueHidden = !!(value && hiddenValues.has(value)) || (isColorOnly && hiddenValues.has('__color_only__'))
const style = getCellStyle(value || undefined)
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
const isWeekend = d.weekend || isHoliday
const commentKey = `${person.id}-${d.idx}`
const hasCellComment = cellComments.has(commentKey)
const isMonthStart = d.day === 1
const isDragOriginal = isDragging && dragState!.originalIdx === d.idx
const isDragPreview = isDragging && dragState!.previewIdx === d.idx && dragState!.previewIdx !== dragState!.originalIdx
const isEditing = editingCell?.personId === person.id && editingCell?.dayIdx === d.idx
const bgStyle: React.CSSProperties = {}
let className = `flex items-center justify-center text-sm font-mono font-bold relative
border-r border-slate-700
${isMonthStart ? 'border-l-2 border-l-slate-500' : ''}`
if (isDragOriginal) {
className += ' opacity-30'
}
if (isDragPreview) {
className += ' border-2 border-dashed border-amber-400/70'
}
if (isValueHidden) {
// Hidden by filter — show as empty
if (isWeekend) className += ' bg-slate-800/50'
} else {
const manualColor = cellData?.color
if (manualColor) {
bgStyle.backgroundColor = manualColor
bgStyle.color = getContrastColor(manualColor)
} else if (style) {
bgStyle.backgroundColor = style.bg
bgStyle.color = style.text
} else if (isWeekend) {
className += ' bg-slate-800/50'
}
}
const isSelected = selectedCells.has(`${person.id}-${d.idx}`)
if (isSelected) {
className += ' ring-2 ring-blue-500 ring-inset z-10'
}
const hasCellData = !!(value || cellData?.color)
if (hasCellData && !isDragOriginal) {
className += ' cursor-grab active:cursor-grabbing'
}
return (
<div
key={d.idx}
className={className}
style={{ width: CELL_W, height: CELL_H, ...bgStyle }}
onPointerDown={(e) => {
if (e.button === 2) return
if (e.ctrlKey || e.metaKey || e.shiftKey) return // let onClick handle modifier clicks
if (hasCellData) {
onCellPointerDown(e, person.id, d.idx, value || '●')
} else {
// Start drag-select on empty cells
onCellDragSelectStart(person.id, d.idx)
}
}}
onPointerEnter={(e) => {
if (e.buttons === 1 && !hasCellData) {
onCellDragSelectMove(person.id, d.idx)
}
}}
onClick={(e) => {
if (e.button === 2) return
if (dragState) return
onCellClick(person.id, d.idx, e.ctrlKey || e.metaKey, e.shiftKey)
}}
onDoubleClick={() => {
if (dragState) return
onStartEdit(person.id, d.idx, value)
}}
onContextMenu={(e) => {
e.preventDefault()
onContextMenu(d.idx, person.id, e.clientX, e.clientY)
}}
title={
`${person.name}${d.day}.${d.month}.${d.year}` +
(value ? `\n${value}` : '') +
(hasCellComment ? `\n💬 ${cellComments.get(commentKey)}` : '')
}
>
{isEditing ? (
<input
autoFocus
className="w-full h-full bg-white text-slate-900 text-center text-sm font-mono font-bold outline-none border-2 border-blue-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()}
/>
) : (
<>
{isDragPreview ? (
<span className="truncate text-amber-300 font-semibold">{dragState!.value}</span>
) : value && !isValueHidden ? (
<span className="truncate font-medium">{value}</span>
) : null}
</>
)}
{hasCellComment && !isEditing && (
<span className="absolute top-0 right-0 w-0 h-0 pointer-events-none"
style={{ borderLeft: '5px solid transparent', borderTop: '5px solid #3b82f6' }}
/>
)}
</div>
)
})}
</div>
)
})
// ---------- Main ScheduleTable ----------
export function ScheduleTable(props: ScheduleTableProps) {
const {
dayIndex, people, tunnelClosures, tunnelColors,
metroClosures, metroColors, d8Closures, d8Colors,
dayComments, cellComments,
dragState, onCellPointerDown, onSetCell, onSetTunnelClosure,
onSetMetroClosure, onSetD8Closure,
showMetro, showD8, hiddenValues,
scrollRef, onContextMenu, onTunnelContextMenu, onInfoRowContextMenu, compareData,
} = props
const [editingCell, setEditingCell] = useState<EditingCell | null>(null)
const editInputRef = useRef<HTMLInputElement>(null)
// ---------- Computed spans ----------
const monthSpans = useMemo(() => {
const spans: { month: number; year: number; startIdx: number; count: number }[] = []
let current: (typeof spans)[0] | null = null
for (let i = 0; i < dayIndex.length; i++) {
const d = dayIndex[i]
if (!current || current.month !== d.month || current.year !== d.year) {
if (current) spans.push(current)
current = { month: d.month, year: d.year, startIdx: i, count: 1 }
} else {
current.count++
}
}
if (current) spans.push(current)
return spans
}, [dayIndex])
const weekSpans = useMemo(() => {
const spans: { week: number; startIdx: number; count: number }[] = []
let current: (typeof spans)[0] | null = null
for (let i = 0; i < dayIndex.length; i++) {
const d = dayIndex[i]
if (!current || current.week !== d.week) {
if (current) spans.push(current)
current = { week: d.week, startIdx: i, count: 1 }
} else {
current.count++
}
}
if (current) spans.push(current)
return spans
}, [dayIndex])
const dayNames = useMemo(() => {
return dayIndex.map(d => {
const dow = new Date(d.year, d.month - 1, d.day).getDay()
return DAY_NAMES[dow]
})
}, [dayIndex])
// Holiday map for all years in the data
const holidays = useMemo(() => {
const years = new Set(dayIndex.map(d => d.year))
const combined = new Map<string, string>()
for (const year of years) {
const yearMap = getHolidayMap(year)
yearMap.forEach((name, key) => combined.set(`${year}-${key}`, name))
}
return combined
}, [dayIndex])
const tkbPeople = useMemo(() => people.filter(p => p.group === 'TKB'), [people])
const itPeople = useMemo(() => people.filter(p => p.group === 'IT'), [people])
// ---------- idxToPos for drag overlay ----------
const idxToPos = useMemo(() => {
const map = new Map<number, number>()
dayIndex.forEach((d, i) => map.set(d.idx, i))
return map
}, [dayIndex])
// ---------- Editing handlers ----------
const onStartEdit = useCallback((personId: string, dayIdx: number, currentValue: string) => {
setEditingCell({ personId, dayIdx, value: currentValue })
}, [])
const onStartEditTunnel = useCallback((dayIdx: number, currentValue: string) => {
setEditingCell({ personId: '__tunnel__', dayIdx, value: currentValue })
}, [])
const onStartEditMetro = useCallback((dayIdx: number, currentValue: string) => {
setEditingCell({ personId: '__metro__', dayIdx, value: currentValue })
}, [])
const onStartEditD8 = useCallback((dayIdx: number, currentValue: string) => {
setEditingCell({ personId: '__d8__', dayIdx, value: currentValue })
}, [])
const onEditChange = useCallback((value: string) => {
setEditingCell(prev => prev ? { ...prev, value } : null)
}, [])
const onEditConfirm = useCallback(() => {
if (!editingCell) return
const trimmed = editingCell.value.trim()
if (editingCell.personId === '__tunnel__') {
onSetTunnelClosure(editingCell.dayIdx, trimmed || null)
} else if (editingCell.personId === '__metro__') {
onSetMetroClosure(editingCell.dayIdx, trimmed || null)
} else if (editingCell.personId === '__d8__') {
onSetD8Closure(editingCell.dayIdx, trimmed || null)
} else {
onSetCell(editingCell.personId, editingCell.dayIdx, trimmed || null)
}
setEditingCell(null)
}, [editingCell, onSetCell, onSetTunnelClosure, onSetMetroClosure, onSetD8Closure])
const onEditCancel = useCallback(() => {
setEditingCell(null)
}, [])
// ---------- Multi-cell selection ----------
const [selectedCells, setSelectedCells] = useState<Set<string>>(new Set())
const selectionAnchorRef = useRef<{ personId: string; dayIdx: number } | null>(null)
const isDragSelectingRef = useRef(false)
const dragSelectStartRef = useRef<{ personId: string; dayIdx: number } | null>(null)
const cellKey = (personId: string, dayIdx: number) => `${personId}-${dayIdx}`
const computeRectSelection = useCallback((
anchor: { personId: string; dayIdx: number },
current: { personId: string; dayIdx: number },
): Set<string> => {
const anchorPersonIdx = people.findIndex(p => p.id === anchor.personId)
const currentPersonIdx = people.findIndex(p => p.id === current.personId)
if (anchorPersonIdx < 0 || currentPersonIdx < 0) return new Set()
const anchorDayPos = dayIndex.findIndex(d => d.idx === anchor.dayIdx)
const currentDayPos = dayIndex.findIndex(d => d.idx === current.dayIdx)
if (anchorDayPos < 0 || currentDayPos < 0) return new Set()
const minP = Math.min(anchorPersonIdx, currentPersonIdx)
const maxP = Math.max(anchorPersonIdx, currentPersonIdx)
const minD = Math.min(anchorDayPos, currentDayPos)
const maxD = Math.max(anchorDayPos, currentDayPos)
const sel = new Set<string>()
for (let pi = minP; pi <= maxP; pi++) {
for (let di = minD; di <= maxD; di++) {
sel.add(cellKey(people[pi].id, dayIndex[di].idx))
}
}
return sel
}, [people, dayIndex])
const onCellClick = useCallback((personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => {
const key = cellKey(personId, dayIdx)
if (isDragSelectingRef.current) {
isDragSelectingRef.current = false
return
}
if (dragJustEndedRef.current) return
if (shiftKey && selectionAnchorRef.current) {
const rect = computeRectSelection(selectionAnchorRef.current, { personId, dayIdx })
setSelectedCells(rect)
} else if (ctrlKey) {
setSelectedCells(prev => {
const next = new Set(prev)
if (next.has(key)) {
next.delete(key)
} else {
next.add(key)
}
return next
})
selectionAnchorRef.current = { personId, dayIdx }
} else {
setSelectedCells(new Set([key]))
selectionAnchorRef.current = { personId, dayIdx }
}
}, [computeRectSelection])
const onCellDragSelectStart = useCallback((personId: string, dayIdx: number) => {
dragSelectStartRef.current = { personId, dayIdx }
isDragSelectingRef.current = false
selectionAnchorRef.current = { personId, dayIdx }
setSelectedCells(new Set([cellKey(personId, dayIdx)]))
}, [])
const onCellDragSelectMove = useCallback((personId: string, dayIdx: number) => {
const start = dragSelectStartRef.current
if (!start) return
isDragSelectingRef.current = true
const rect = computeRectSelection(start, { personId, dayIdx })
setSelectedCells(rect)
}, [computeRectSelection])
// Finalize drag-select on pointerup
useEffect(() => {
const handleUp = () => {
dragSelectStartRef.current = null
}
document.addEventListener('pointerup', handleUp)
return () => document.removeEventListener('pointerup', handleUp)
}, [])
// Track when drag-move just completed so we can suppress the subsequent click
const dragJustEndedRef = useRef(false)
const prevDragRef = useRef<DragState | null>(null)
useEffect(() => {
if (prevDragRef.current && !dragState) {
// Drag just ended — clear selection and set flag to suppress click
setSelectedCells(new Set())
selectionAnchorRef.current = null
dragJustEndedRef.current = true
setTimeout(() => { dragJustEndedRef.current = false }, 50)
}
prevDragRef.current = dragState
}, [dragState])
// Clear selection on Escape
useEffect(() => {
const handleKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
setSelectedCells(new Set())
selectionAnchorRef.current = null
}
}
document.addEventListener('keydown', handleKey)
return () => document.removeEventListener('keydown', handleKey)
}, [])
// Context menu handler wrapping the prop to include selected cells
const handlePersonContextMenu = useCallback((dayIdx: number, personId: string | null, x: number, y: number) => {
if (!personId) {
onContextMenu(dayIdx, personId, x, y, [])
return
}
const key = cellKey(personId, dayIdx)
if (selectedCells.has(key) && selectedCells.size > 1) {
// Right-clicked on a selected cell => pass all selected cells
const cells: SelectedCell[] = []
selectedCells.forEach(k => {
const lastDash = k.lastIndexOf('-')
const pId = k.substring(0, lastDash)
const dIdx = Number(k.substring(lastDash + 1))
cells.push({ personId: pId, dayIdx: dIdx })
})
onContextMenu(dayIdx, personId, x, y, cells)
} else {
// Right-clicked on unselected cell => select just this one
setSelectedCells(new Set([key]))
selectionAnchorRef.current = { personId, dayIdx }
onContextMenu(dayIdx, personId, x, y, [{ personId, dayIdx }])
}
}, [selectedCells, onContextMenu])
// ---------- Drag overlay ----------
const dragOverlay = useMemo(() => {
if (!dragState) return null
const personIdx = people.findIndex(p => p.id === dragState.personId)
if (personIdx < 0) return null
// Compute top accounting for section headers
const tkbCount = tkbPeople.length
let top: number
const tkbIdx = tkbPeople.findIndex(p => p.id === dragState.personId)
if (tkbIdx >= 0) {
// TKB section: +1 for TKB section header row
top = (1 + tkbIdx) * CELL_H
} else {
const itIdx = itPeople.findIndex(p => p.id === dragState.personId)
// IT section: +1 TKB header + TKB rows + +1 IT header
top = (1 + tkbCount + 1 + itIdx) * CELL_H
}
const pos = idxToPos.get(dragState.previewIdx) ?? 0
const left = 200 + pos * CELL_W // 200 = nameColW
const style = getCellStyle(dragState.value)
return (
<div
className="absolute border-2 border-dashed rounded pointer-events-none
flex items-center justify-center text-[10px] font-mono font-semibold"
style={{
left, top, width: CELL_W, height: CELL_H,
borderColor: style?.bg ?? '#f59e0b',
backgroundColor: (style?.bg ?? '#f59e0b') + '40',
color: style?.text ?? '#fbbf24',
}}
>
{dragState.value}
</div>
)
}, [dragState, people, tkbPeople, itPeople, idxToPos])
// ---------- Compare ghost overlay ----------
const ghostBlocks = useMemo(() => {
if (!compareData) return null
const blocks: { personIdx: number; startPos: number; length: number; value: string }[] = []
const compareDayByDate = new Map<string, number>()
for (const cd of compareData.dayIndex) {
compareDayByDate.set(`${cd.year}-${cd.month}-${cd.day}`, cd.idx)
}
const comparePersonMap = new Map<string, Record<string, { v?: string }>>()
for (const cp of compareData.people) {
comparePersonMap.set(cp.id, cp.data)
}
for (let pi = 0; pi < people.length; pi++) {
const person = people[pi]
const compareData2 = comparePersonMap.get(person.id)
if (!compareData2) continue
let runStart = -1
let runValue = ''
let runLength = 0
let runHasDiff = false
const flush = () => {
if (runStart >= 0 && runHasDiff) {
blocks.push({ personIdx: pi, startPos: runStart, length: runLength, value: runValue })
}
runStart = -1; runValue = ''; runLength = 0; runHasDiff = false
}
for (let di = 0; di < dayIndex.length; di++) {
const d = dayIndex[di]
const compIdx = compareDayByDate.get(`${d.year}-${d.month}-${d.day}`)
const compCell = compIdx !== undefined ? compareData2[String(compIdx)] : undefined
const compVal = compCell?.v ?? ''
const curCell = person.data[String(d.idx)]
const curVal = curCell?.v ?? ''
const isDiff = compVal !== curVal
if (compVal && compVal === runValue) {
runLength++
if (isDiff) runHasDiff = true
} else {
flush()
if (compVal) {
runStart = di
runValue = compVal
runLength = 1
runHasDiff = isDiff
}
}
}
flush()
}
return blocks.length > 0 ? blocks : null
}, [compareData, people, dayIndex])
// ---------- Render ----------
const totalGridW = dayIndex.length * CELL_W
const nameColW = 200
// Helper: render an info row (TKB/Metro/D8 closures)
const renderInfoRow = (
rowId: '__tunnel__' | '__metro__' | '__d8__',
label: string,
closures: Map<number, string>,
colors: Map<number, string>,
defaultBgClass: string,
borderClass: string,
editBorderColor: string,
contextHandler: (dayIdx: number, x: number, y: number) => void,
onStartEditFn: (dayIdx: number, currentValue: string) => void,
) => (
<div className={`flex border-b ${borderClass}`} style={{ height: 28 }}>
<div
className={`sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b ${borderClass} flex items-center px-3 flex-shrink-0`}
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">{label}</span>
</div>
{dayIndex.map((d) => {
const closureVal = closures.get(d.idx) ?? ''
const closureColor = colors.get(d.idx)
const isEditingThis = editingCell?.personId === rowId && editingCell?.dayIdx === d.idx
const isOff = d.weekend || holidays.has(`${d.year}-${d.month}-${d.day}`)
const rowBg: React.CSSProperties = {}
if (closureColor) {
rowBg.backgroundColor = closureColor
rowBg.color = getContrastColor(closureColor)
}
return (
<div
key={d.idx}
className={`flex items-center justify-center text-xs font-mono font-bold relative
border-r border-slate-700
${!closureColor && closureVal ? `${defaultBgClass}` : !closureColor ? (isOff ? 'bg-slate-700' : 'bg-slate-800') : ''}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
cursor-pointer hover:brightness-125
`}
style={{ width: CELL_W, height: 28, ...rowBg }}
onClick={() => {
if (!isEditingThis) onStartEditFn(d.idx, closureVal)
}}
onContextMenu={(e) => {
e.preventDefault()
contextHandler(d.idx, e.clientX, e.clientY)
}}
title={closureVal ? `${label}: ${closureVal}` : `${d.day}.${d.month}.`}
>
{isEditingThis ? (
<input
autoFocus
className={`w-full h-full bg-white text-slate-900 text-center text-xs font-mono font-bold outline-none border-2 ${editBorderColor}`}
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()}
/>
) : (
<span className="truncate">{closureVal}</span>
)}
</div>
)
})}
</div>
)
return (
<div ref={scrollRef} className="rounded-lg border border-slate-700 bg-slate-800/50 overflow-auto" style={{ maxHeight: 'calc(100vh - 160px)' }}>
<div style={{ width: nameColW + totalGridW, position: 'relative' }}>
{/* === STICKY HEADER ROWS === */}
<div className="sticky top-0 z-30 bg-slate-800">
{/* Month row */}
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
<div
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Měsíc</span>
</div>
{monthSpans.map(m => (
<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 }}
>
{MONTH_NAMES[m.month]} {m.year}
</div>
))}
</div>
{/* Week row */}
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
<div
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Týden</span>
</div>
{weekSpans.map((w, i) => (
<div
key={`w-${i}`}
className="flex items-center justify-center text-[10px] text-slate-400 border-r border-slate-700 bg-slate-800"
style={{ width: w.count * CELL_W }}
>
{w.week}
</div>
))}
</div>
{/* Day number row */}
<div className="flex border-b border-slate-700" style={{ height: 28 }}>
<div
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den</span>
</div>
{dayIndex.map((d) => {
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 (
<div
key={d.idx}
className={`flex items-center justify-center text-[10px] font-mono relative
${isOff ? 'text-red-400/70 bg-slate-700' : 'text-slate-400 bg-slate-800'}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
`}
style={{ width: CELL_W }}
title={`${d.day}.${d.month}.${d.year}${holidayName ? `\n🎉 ${holidayName}` : ''}${comment ? `\n${comment}` : ''}`}
>
{d.day}
{(comment || holidayName) && (
<span className={`absolute bottom-0.5 left-1/2 -translate-x-1/2 w-1.5 h-1.5 rounded-full pointer-events-none ${holidayName ? 'bg-red-400' : 'bg-blue-500'}`} />
)}
</div>
)
})}
</div>
{/* Day name row */}
<div className="flex border-b border-slate-700" style={{ height: 24 }}>
<div
className="sticky left-0 z-20 bg-slate-800 border-r border-slate-600 border-b border-b-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den t.</span>
</div>
{dayIndex.map((d, i) => {
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
const isOff = d.weekend || isHoliday
return (
<div
key={d.idx}
className={`flex items-center justify-center text-[9px] font-mono
${isOff ? 'text-red-400/70 bg-slate-700' : 'text-slate-500 bg-slate-800'}
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
`}
style={{ width: CELL_W }}
>
{dayNames[i]}
</div>
)
})}
</div>
{/* TKB closures row */}
{renderInfoRow('__tunnel__', 'TKB', tunnelClosures, tunnelColors,
'bg-orange-700/40 text-orange-200', 'border-slate-600', 'border-orange-400',
onTunnelContextMenu, onStartEditTunnel)}
{/* Metro row */}
{showMetro && renderInfoRow('__metro__', 'Metro', metroClosures, metroColors,
'bg-blue-700/40 text-blue-200', 'border-slate-700', 'border-blue-400',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'metro', x, y), onStartEditMetro)}
{/* D8 row */}
{showD8 && renderInfoRow('__d8__', 'D8', d8Closures, d8Colors,
'bg-green-700/40 text-green-200', 'border-slate-600', 'border-green-400',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'd8', x, y), onStartEditD8)}
</div>{/* end sticky header rows */}
{/* Data rows container (with overlay) */}
<div style={{ position: 'relative' }}>
{/* TKB section header */}
<div className="flex" style={{ height: CELL_H }}>
<div
className="sticky left-0 z-20 bg-indigo-700 border-r border-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-xs font-bold text-indigo-100 uppercase tracking-wider">Pohotovost TKB</span>
</div>
<div
className="bg-indigo-700 flex items-center justify-center text-xs font-bold uppercase tracking-wider text-white"
style={{ width: totalGridW }}
/>
</div>
{/* TKB person rows */}
{tkbPeople.map((person) => (
<PersonRow
key={person.id}
person={person}
dayIndex={dayIndex}
dragState={dragState}
cellComments={cellComments}
dayComments={dayComments}
holidays={holidays}
hiddenValues={hiddenValues}
onCellPointerDown={onCellPointerDown}
onContextMenu={handlePersonContextMenu}
editingCell={editingCell}
onStartEdit={onStartEdit}
onEditChange={onEditChange}
onEditConfirm={onEditConfirm}
onEditCancel={onEditCancel}
selectedCells={selectedCells}
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
nameColW={nameColW}
/>
))}
{/* IT section header */}
<div className="flex" style={{ height: CELL_H }}>
<div
className="sticky left-0 z-20 bg-teal-700 border-r border-slate-600 flex items-center px-3 flex-shrink-0"
style={{ width: nameColW, minWidth: nameColW }}
>
<span className="text-xs font-bold text-teal-100 uppercase tracking-wider">Pohotovost IT</span>
</div>
<div
className="bg-teal-700 flex items-center justify-center text-xs font-bold uppercase tracking-wider text-white"
style={{ width: totalGridW }}
/>
</div>
{/* IT person rows */}
{itPeople.map((person) => (
<PersonRow
key={person.id}
person={person}
dayIndex={dayIndex}
dragState={dragState}
cellComments={cellComments}
dayComments={dayComments}
holidays={holidays}
hiddenValues={hiddenValues}
onCellPointerDown={onCellPointerDown}
onContextMenu={handlePersonContextMenu}
editingCell={editingCell}
onStartEdit={onStartEdit}
onEditChange={onEditChange}
onEditConfirm={onEditConfirm}
onEditCancel={onEditCancel}
selectedCells={selectedCells}
onCellClick={onCellClick}
onCellDragSelectStart={onCellDragSelectStart}
onCellDragSelectMove={onCellDragSelectMove}
nameColW={nameColW}
/>
))}
{/* Compare ghost blocks */}
{ghostBlocks && ghostBlocks.map((gb, i) => {
const left = nameColW + gb.startPos * CELL_W
// Account for section headers in top position
const tkbCount = tkbPeople.length
let top: number
if (gb.personIdx < tkbCount) {
top = (1 + gb.personIdx) * CELL_H // +1 for TKB section header
} else {
top = (1 + tkbCount + 1 + (gb.personIdx - tkbCount)) * CELL_H // +1 TKB header +1 IT header
}
const width = gb.length * CELL_W
const cellStyle = getCellStyle(gb.value)
return (
<div
key={`ghost-${i}`}
className="absolute pointer-events-none flex items-center justify-center text-[10px] font-mono font-semibold"
style={{
left, top, width, height: CELL_H,
borderWidth: 2,
borderStyle: 'dashed',
borderRadius: 2,
borderColor: (cellStyle?.bg ?? '#64748b') + 'b3',
backgroundColor: (cellStyle?.bg ?? '#64748b') + '33',
color: (cellStyle?.text ?? '#94a3b8') + 'cc',
}}
title={`Bylo: ${gb.value} (${gb.length} dní)`}
>
{gb.value}
</div>
)
})}
{/* Drag overlay */}
{dragOverlay}
</div>
</div>
</div>
)
}

215
web/src/Toolbar.tsx Normal file
View File

@@ -0,0 +1,215 @@
import { getAllCellStyles } from './cellColors'
const MONTH_BUTTONS = [
{ month: 1, label: 'Led' },
{ month: 2, label: 'Uno' },
{ month: 3, label: 'Bre' },
{ month: 4, label: 'Dub' },
{ month: 5, label: 'Kve' },
{ month: 6, label: 'Cvn' },
{ month: 7, label: 'Cvc' },
{ month: 8, label: 'Srp' },
{ month: 9, label: 'Zar' },
{ month: 10, label: 'Rij' },
{ month: 11, label: 'Lis' },
{ month: 12, label: 'Pro' },
]
interface ToolbarProps {
onUndo: () => void
canUndo: boolean
onSave: () => void
onSaveAs: () => void
saveStatus: 'idle' | 'saving' | 'saved' | 'error'
onScrollToMonth: (month: number) => void
activeMonth?: number
showMetro: boolean
showD8: boolean
onToggleMetro: () => void
onToggleD8: () => void
hiddenValues: Set<string>
onToggleValue: (code: string) => void
diffFileName?: string | null
onCloseDiff?: () => void
onExportPdf?: (month: number) => void
onShowProposals?: () => void
}
export function Toolbar({
onUndo,
canUndo,
onSave,
onSaveAs,
saveStatus,
onScrollToMonth,
activeMonth,
showMetro,
showD8,
onToggleMetro,
onToggleD8,
hiddenValues,
onToggleValue,
diffFileName,
onCloseDiff,
onExportPdf,
onShowProposals,
}: ToolbarProps) {
const cellStyles = getAllCellStyles()
return (
<div className="flex flex-col gap-2 mb-3">
{diffFileName && (
<div className="flex items-center gap-3 px-4 py-2 rounded-lg bg-blue-900/60 border border-blue-700 text-blue-200">
<span className="text-blue-400 text-sm font-semibold">Porovnani:</span>
<span className="text-sm">{diffFileName}</span>
<span className="text-blue-400/60 text-xs ml-1">
(zelena = pridano, cervena = odebrano, oranzova = zmeneno)
</span>
{onCloseDiff && (
<button
onClick={onCloseDiff}
className="ml-auto px-2 py-0.5 rounded text-xs bg-blue-800 text-blue-300 border border-blue-600
hover:bg-blue-700 cursor-pointer transition-colors"
>
&#10005; Zavrit porovnani
</button>
)}
</div>
)}
<div className="flex flex-wrap items-center gap-3">
{/* Month navigation */}
<div className="flex items-center gap-0.5">
{MONTH_BUTTONS.map(({ month, label }) => (
<button
key={month}
onClick={() => onScrollToMonth(month)}
className={`px-1.5 py-1 rounded text-[10px] border cursor-pointer transition-colors
${activeMonth === month
? 'bg-blue-600 text-white border-blue-500 font-bold'
: 'bg-slate-800 text-slate-400 border-slate-700 hover:bg-slate-700 hover:text-slate-200'
}`}
>
{label}
</button>
))}
</div>
<div className="h-5 w-px bg-slate-700" />
{/* Actions */}
<button
onClick={onUndo}
disabled={!canUndo}
className="px-3 py-1 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
>
Zpet
</button>
<button
onClick={onSave}
disabled={saveStatus === 'saving'}
className={`px-3 py-1 rounded text-xs border cursor-pointer transition-colors
${saveStatus === 'saved'
? 'bg-green-700/70 text-green-100 border-green-600'
: saveStatus === 'error'
? 'bg-red-700/70 text-red-100 border-red-600'
: saveStatus === 'saving'
? 'bg-slate-700 text-slate-400 border-slate-600'
: 'bg-blue-700/70 text-blue-100 border-blue-600 hover:bg-blue-600/70'
}`}
>
{saveStatus === 'saving' ? 'Ukladam...'
: saveStatus === 'saved' ? 'Ulozeno'
: saveStatus === 'error' ? 'Chyba!'
: 'Ulozit'}
</button>
<button
onClick={onSaveAs}
className="px-3 py-1 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
hover:bg-slate-700 cursor-pointer transition-colors"
>
Ulozit jako
</button>
{onExportPdf && activeMonth && (
<button
onClick={() => onExportPdf(activeMonth)}
className="px-3 py-1 rounded text-xs bg-purple-700/70 text-purple-100 border border-purple-600
hover:bg-purple-600/70 cursor-pointer transition-colors"
>
PDF
</button>
)}
{onShowProposals && (
<button
onClick={onShowProposals}
className="px-3 py-1 rounded text-xs bg-amber-700/70 text-amber-100 border border-amber-600
hover:bg-amber-600/70 cursor-pointer transition-colors"
>
Navrhy
</button>
)}
<div className="h-5 w-px bg-slate-700" />
{/* Row toggles */}
<div className="flex items-center gap-1">
<button
onClick={onToggleMetro}
className={`px-2 py-1 rounded text-[10px] border cursor-pointer transition-colors
${showMetro
? 'bg-blue-800/60 text-blue-200 border-blue-600'
: 'bg-slate-800 text-slate-500 border-slate-700 opacity-50'
}`}
>
Metro
</button>
<button
onClick={onToggleD8}
className={`px-2 py-1 rounded text-[10px] border cursor-pointer transition-colors
${showD8
? 'bg-green-800/60 text-green-200 border-green-600'
: 'bg-slate-800 text-slate-500 border-slate-700 opacity-50'
}`}
>
D8
</button>
</div>
<div className="h-5 w-px bg-slate-700" />
{/* Color legend / filter */}
<div className="flex items-center gap-1">
{Object.entries(cellStyles).map(([code, style]) => {
const isHidden = hiddenValues.has(code)
return (
<button
key={code}
onClick={() => onToggleValue(code)}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border cursor-pointer transition-all
${isHidden ? 'opacity-25 border-slate-700' : 'border-slate-600'}`}
style={{ backgroundColor: style.bg, color: style.text }}
title={`${style.label}${isHidden ? ' (skryto)' : ' (klikni pro skrytí)'}`}
>
{code}
</button>
)
})}
<button
onClick={() => onToggleValue('__color_only__')}
className={`px-1.5 py-0.5 rounded text-[10px] font-medium border cursor-pointer transition-all
${hiddenValues.has('__color_only__') ? 'opacity-25 border-slate-700' : 'border-slate-600'}
bg-gradient-to-r from-yellow-300 via-green-300 to-blue-300`}
style={{ color: '#333' }}
title={`Pouze barva${hiddenValues.has('__color_only__') ? ' (skryto)' : ' (klikni pro skrytí)'}`}
>
</button>
</div>
</div>
</div>
)
}

BIN
web/src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

1
web/src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
web/src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

52
web/src/cellColors.ts Normal file
View File

@@ -0,0 +1,52 @@
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' },
'A': { bg: '#FFE082', text: '#333', label: 'Ranní (denní směna)' },
'B': { bg: '#5C6BC0', text: '#fff', label: 'Noční 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 }
}
// Returns black or white text color based on background luminance
export function getContrastColor(bgHex: string): string {
const h = bgHex.replace('#', '')
const full = h.length === 3 ? h[0]+h[0]+h[1]+h[1]+h[2]+h[2] : h
const r = parseInt(full.substring(0, 2), 16) || 0
const g = parseInt(full.substring(2, 4), 16) || 0
const b = parseInt(full.substring(4, 6), 16) || 0
const luminance = (r * 299 + g * 587 + b * 114) / 1000
return luminance > 140 ? '#333' : '#fff'
}
export const COLOR_PALETTE = [
{ color: '#FFFF00', label: 'Žlutá' },
{ color: '#FF4444', label: 'Červená' },
{ color: '#92D050', label: 'Zelená' },
{ color: '#FFC000', label: 'Oranžová' },
{ color: '#87CEEB', label: 'Modrá' },
{ color: '#DDA0DD', label: 'Fialová' },
{ color: '#E0E0E0', label: 'Šedá' },
{ color: '', label: 'Bez barvy' },
]

3084
web/src/data.json Normal file

File diff suppressed because it is too large Load Diff

4
web/src/excelIO.ts Normal file
View File

@@ -0,0 +1,4 @@
// Excel import/export — to be implemented for TKB shift format
export function importFromExcel(_file: File): Promise<null> {
return Promise.resolve(null)
}

65
web/src/holidays.ts Normal file
View File

@@ -0,0 +1,65 @@
// Czech public holidays
// Fixed holidays + Easter (computed)
function getEasterMonday(year: number): { month: number; day: number } {
// Anonymous Gregorian algorithm
const a = year % 19
const b = Math.floor(year / 100)
const c = year % 100
const d = Math.floor(b / 4)
const e = b % 4
const f = Math.floor((b + 8) / 25)
const g = Math.floor((b - f + 1) / 3)
const h = (19 * a + b - d - g + 15) % 30
const i = Math.floor(c / 4)
const k = c % 4
const l = (32 + 2 * e + 2 * i - h - k) % 7
const m = Math.floor((a + 11 * h + 22 * l) / 451)
const month = Math.floor((h + l - 7 * m + 114) / 31)
const day = ((h + l - 7 * m + 114) % 31) + 1
// Easter Sunday is month/day, Easter Monday is +1
const date = new Date(year, month - 1, day + 1)
return { month: date.getMonth() + 1, day: date.getDate() }
}
function getGoodFriday(year: number): { month: number; day: number } {
const em = getEasterMonday(year)
const date = new Date(year, em.month - 1, em.day - 3)
return { month: date.getMonth() + 1, day: date.getDate() }
}
export interface Holiday {
month: number
day: number
name: string
}
export function getCzechHolidays(year: number): Holiday[] {
const gf = getGoodFriday(year)
const em = getEasterMonday(year)
return [
{ month: 1, day: 1, name: 'Nový rok' },
{ month: gf.month, day: gf.day, name: 'Velký pátek' },
{ month: em.month, day: em.day, name: 'Velikonoční pondělí' },
{ month: 5, day: 1, name: 'Svátek práce' },
{ month: 5, day: 8, name: 'Den vítězství' },
{ month: 7, day: 5, name: 'Den slovanských věrozvěstů' },
{ month: 7, day: 6, name: 'Den upálení mistra Jana Husa' },
{ month: 9, day: 28, name: 'Den české státnosti' },
{ month: 10, day: 28, name: 'Den vzniku samostatného československého státu' },
{ month: 11, day: 17, name: 'Den boje za svobodu a demokracii' },
{ month: 12, day: 24, name: 'Štědrý den' },
{ month: 12, day: 25, name: '1. svátek vánoční' },
{ month: 12, day: 26, name: '2. svátek vánoční' },
]
}
// Returns a Map<string, string> keyed by "month-day" for quick lookup
export function getHolidayMap(year: number): Map<string, string> {
const m = new Map<string, string>()
for (const h of getCzechHolidays(year)) {
m.set(`${h.month}-${h.day}`, h.name)
}
return m
}

27
web/src/index.css Normal file
View File

@@ -0,0 +1,27 @@
@import "tailwindcss";
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #e2e8f0;
}
#root {
min-height: 100svh;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b;
}
::-webkit-scrollbar-thumb {
background: #475569;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748b;
}

10
web/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

88
web/src/types.ts Normal file
View File

@@ -0,0 +1,88 @@
export interface DayInfo {
idx: number
day: number
month: number
year: number
week: number
weekend: boolean
}
export interface Person {
id: string
name: string
group: 'TKB' | 'IT'
note?: string
data: Record<string, CellValue>
}
export interface CellValue {
v?: string
color?: string
}
export interface TunnelClosure {
dayIdx: number
text: string
color?: string
}
export interface InfoRowData {
dayIdx: number
text: string
color?: string
}
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[]
infoRows?: Record<string, InfoRowData[]>
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
isTunnelRow?: boolean
infoRowId?: string
}
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
}

81
web/src/useDragCell.ts Normal file
View File

@@ -0,0 +1,81 @@
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
} | 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,
}
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)
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 }
}

325
web/src/useScheduleState.ts Normal file
View File

@@ -0,0 +1,325 @@
import { useState, useCallback, useRef } from 'react'
import type { Person, DayInfo, TunnelClosure, InfoRowData, DayComment, CellComment, ScheduleData } from './types'
interface ScheduleSnapshot {
people: Person[]
tunnelClosures: Map<number, string>
tunnelColors: Map<number, string>
metroClosures: Map<number, string>
metroColors: Map<number, string>
d8Closures: Map<number, string>
d8Colors: Map<number, string>
dayComments: Map<number, string>
cellComments: Map<string, string>
}
export function useScheduleState(
initialPeople: Person[],
dayIndex: DayInfo[],
initialTunnelClosures?: TunnelClosure[],
initialDayComments?: DayComment[],
initialCellComments?: CellComment[],
initialInfoRows?: Record<string, InfoRowData[]>,
) {
const [people, setPeople] = useState<Person[]>(() => JSON.parse(JSON.stringify(initialPeople)))
const [tunnelClosures, setTunnelClosuresState] = 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 [tunnelColors, setTunnelColorsState] = useState<Map<number, string>>(() => {
const m = new Map<number, string>()
if (initialTunnelClosures) {
for (const tc of initialTunnelClosures) {
if (tc.color) m.set(tc.dayIdx, tc.color)
}
}
return m
})
const [metroClosures, setMetroClosuresState] = useState<Map<number, string>>(() => {
const m = new Map<number, string>()
if (initialInfoRows?.metro) {
for (const entry of initialInfoRows.metro) m.set(entry.dayIdx, entry.text)
}
return m
})
const [metroColors, setMetroColorsState] = useState<Map<number, string>>(() => {
const m = new Map<number, string>()
if (initialInfoRows?.metro) {
for (const entry of initialInfoRows.metro) {
if (entry.color) m.set(entry.dayIdx, entry.color)
}
}
return m
})
const [d8Closures, setD8ClosuresState] = useState<Map<number, string>>(() => {
const m = new Map<number, string>()
if (initialInfoRows?.d8) {
for (const entry of initialInfoRows.d8) m.set(entry.dayIdx, entry.text)
}
return m
})
const [d8Colors, setD8ColorsState] = useState<Map<number, string>>(() => {
const m = new Map<number, string>()
if (initialInfoRows?.d8) {
for (const entry of initialInfoRows.d8) {
if (entry.color) m.set(entry.dayIdx, entry.color)
}
}
return m
})
const [dayComments, setDayCommentsState] = 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, setCellCommentsState] = 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),
tunnelColors: new Map(tunnelColors),
metroClosures: new Map(metroClosures),
metroColors: new Map(metroColors),
d8Closures: new Map(d8Closures),
d8Colors: new Map(d8Colors),
dayComments: new Map(dayComments),
cellComments: new Map(cellComments),
})
if (historyRef.current.length > 50) historyRef.current.shift()
}, [people, tunnelClosures, tunnelColors, metroClosures, metroColors, d8Closures, d8Colors, 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) {
const existing = person.data[String(dayIdx)]
person.data[String(dayIdx)] = { v: value, ...(existing?.color ? { color: existing.color } : {}) }
} else {
const existing = person.data[String(dayIdx)]
if (existing?.color) {
const { v: _, ...rest } = existing
person.data[String(dayIdx)] = rest as any
} else {
delete person.data[String(dayIdx)]
}
}
return next
})
}, [pushHistory])
const setCellColor = useCallback((personId: string, dayIdx: number, color: 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
const existing = person.data[String(dayIdx)] ?? {}
if (color) {
person.data[String(dayIdx)] = { ...existing, color }
} else {
const { color: _, ...rest } = existing as any
person.data[String(dayIdx)] = rest
if (!rest.v) delete person.data[String(dayIdx)]
}
return next
})
}, [pushHistory])
const setTunnelClosureColor = useCallback((dayIdx: number, color: string | null) => {
pushHistory()
setTunnelColorsState(prev => {
const next = new Map(prev)
if (color) next.set(dayIdx, color)
else next.delete(dayIdx)
return next
})
}, [pushHistory])
const setMetroClosure = useCallback((dayIdx: number, text: string | null) => {
pushHistory()
setMetroClosuresState(prev => {
const next = new Map(prev)
if (text) next.set(dayIdx, text)
else next.delete(dayIdx)
return next
})
}, [pushHistory])
const setMetroClosureColor = useCallback((dayIdx: number, color: string | null) => {
pushHistory()
setMetroColorsState(prev => {
const next = new Map(prev)
if (color) next.set(dayIdx, color)
else next.delete(dayIdx)
return next
})
}, [pushHistory])
const setD8Closure = useCallback((dayIdx: number, text: string | null) => {
pushHistory()
setD8ClosuresState(prev => {
const next = new Map(prev)
if (text) next.set(dayIdx, text)
else next.delete(dayIdx)
return next
})
}, [pushHistory])
const setD8ClosureColor = useCallback((dayIdx: number, color: string | null) => {
pushHistory()
setD8ColorsState(prev => {
const next = new Map(prev)
if (color) next.set(dayIdx, color)
else next.delete(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()
setTunnelClosuresState(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()
setDayCommentsState(prev => new Map(prev).set(dayIdx, text))
}, [pushHistory])
const removeDayComment = useCallback((dayIdx: number) => {
pushHistory()
setDayCommentsState(prev => {
const next = new Map(prev)
next.delete(dayIdx)
return next
})
}, [pushHistory])
const addCellComment = useCallback((personId: string, dayIdx: number, text: string) => {
pushHistory()
setCellCommentsState(prev => new Map(prev).set(`${personId}-${dayIdx}`, text))
}, [pushHistory])
const removeCellComment = useCallback((personId: string, dayIdx: number) => {
pushHistory()
setCellCommentsState(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)
setTunnelClosuresState(snapshot.tunnelClosures)
setTunnelColorsState(snapshot.tunnelColors)
setMetroClosuresState(snapshot.metroClosures)
setMetroColorsState(snapshot.metroColors)
setD8ClosuresState(snapshot.d8Closures)
setD8ColorsState(snapshot.d8Colors)
setDayCommentsState(snapshot.dayComments)
setCellCommentsState(snapshot.cellComments)
}, [])
const canUndo = historyRef.current.length > 0
const getSchedulePayload = useCallback((): ScheduleData => ({
dayIndex,
people,
tunnelClosures: Array.from(tunnelClosures.entries()).map(([dayIdx, text]) => ({
dayIdx, text,
...(tunnelColors.get(dayIdx) ? { color: tunnelColors.get(dayIdx) } : {}),
})),
infoRows: {
metro: Array.from(metroClosures.entries()).map(([dayIdx, text]) => ({
dayIdx, text,
...(metroColors.get(dayIdx) ? { color: metroColors.get(dayIdx) } : {}),
})),
d8: Array.from(d8Closures.entries()).map(([dayIdx, text]) => ({
dayIdx, text,
...(d8Colors.get(dayIdx) ? { color: d8Colors.get(dayIdx) } : {}),
})),
},
dayComments: Array.from(dayComments.entries()).map(([dayIdx, text]) => ({ dayIdx, text })),
cellComments: Array.from(cellComments.entries()).map(([key, text]) => {
const dashIdx = key.lastIndexOf('-')
const personId = key.substring(0, dashIdx)
const dayIdx = parseInt(key.substring(dashIdx + 1))
return { personId, dayIdx, text }
}),
}), [dayIndex, people, tunnelClosures, tunnelColors, metroClosures, metroColors, d8Closures, d8Colors, dayComments, cellComments])
return {
people,
tunnelClosures,
tunnelColors,
metroClosures,
metroColors,
d8Closures,
d8Colors,
dayComments,
cellComments,
setCell,
setCellColor,
moveCell,
setTunnelClosure,
setTunnelClosureColor,
setMetroClosure,
setMetroClosureColor,
setD8Closure,
setD8ClosureColor,
addDayComment,
removeDayComment,
addCellComment,
removeCellComment,
undo,
canUndo,
getSchedulePayload,
}
}

28
web/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
web/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
web/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

12
web/vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:3080',
},
},
})

33
web/watch-deploy.js Normal file
View File

@@ -0,0 +1,33 @@
import { watch } from 'fs'
import { execSync } from 'child_process'
import { join } from 'path'
import { fileURLToPath } from 'url'
import { dirname } from 'path'
const __dirname = dirname(fileURLToPath(import.meta.url))
const SRC_DIR = join(__dirname, 'src')
const DEPLOY_SCRIPT = join(__dirname, 'deploy.sh')
const COOLDOWN_MS = 5000
let timer = null
console.log(`Watching ${SRC_DIR} for changes...`)
console.log('Auto-deploying to copelk on save')
console.log('Press Ctrl+C to stop\n')
watch(SRC_DIR, { recursive: true }, (event, filename) => {
if (!filename) return
console.log(`[${new Date().toLocaleTimeString()}] ${event}: ${filename}`)
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
console.log('\nDeploying...\n')
try {
execSync(`bash ${DEPLOY_SCRIPT}`, { stdio: 'inherit' })
console.log('\nWatching for next change...\n')
} catch (e) {
console.error('Deploy failed:', e.message)
}
}, COOLDOWN_MS)
})

26
web/watch-deploy.sh Executable file
View File

@@ -0,0 +1,26 @@
#!/bin/bash
# Watch for changes in src/ and auto-deploy to copelk
# Run: ./watch-deploy.sh
# Stop: Ctrl+C
WATCH_DIR="/home/klas/Prace/METRO/web/src"
DEPLOY_SCRIPT="/home/klas/Prace/METRO/web/deploy.sh"
COOLDOWN=10
echo "Watching $WATCH_DIR for changes..."
echo "Auto-deploying to copelk on save"
echo "Press Ctrl+C to stop"
inotifywait -m -r -e modify,create,delete "$WATCH_DIR" |
while read -r dir event file; do
echo ""
echo "[$(date +%H:%M:%S)] Change detected: $file ($event)"
echo "Deploying in ${COOLDOWN}s (waiting for more changes)..."
sleep $COOLDOWN
# Drain any queued events
while read -t 0.1 -r _; do :; done
echo "Deploying..."
bash "$DEPLOY_SCRIPT"
echo ""
echo "Watching for next change..."
done