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

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