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:
29
web/.gitignore
vendored
Normal file
29
web/.gitignore
vendored
Normal 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
73
web/README.md
Normal 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
27
web/deploy.sh
Executable 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"
|
||||
171
web/docs/plans/2026-03-27-file-manager.md
Normal file
171
web/docs/plans/2026-03-27-file-manager.md
Normal 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
23
web/eslint.config.js
Normal 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
108
web/export_excel.py
Normal 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
166
web/import_excel.py
Executable 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
13
web/index.html
Normal 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
5358
web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
38
web/package.json
Normal file
38
web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
web/public/DejaVuSans-Bold.ttf
Normal file
BIN
web/public/DejaVuSans-Bold.ttf
Normal file
Binary file not shown.
BIN
web/public/DejaVuSans.ttf
Normal file
BIN
web/public/DejaVuSans.ttf
Normal file
Binary file not shown.
1
web/public/favicon.svg
Normal file
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
24
web/public/icons.svg
Normal 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
BIN
web/public/template.xlsx
Normal file
Binary file not shown.
391
web/server.js
Normal file
391
web/server.js
Normal 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
554
web/src/App.tsx
Normal 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"
|
||||
>
|
||||
← 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
411
web/src/ContextMenu.tsx
Normal 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">×</span>}
|
||||
{isActive && color && (
|
||||
<span className="text-[10px]" style={{ color: color === '#FFFF00' || color === '#E0E0E0' || color === '#87CEEB' ? '#333' : '#fff' }}>✓</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">▶</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">▶</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">▶</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">▶</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
286
web/src/FileManager.tsx
Normal 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
83
web/src/Login.tsx
Normal 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
338
web/src/PdfExport.ts
Normal 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
111
web/src/ProposalModal.tsx
Normal 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"
|
||||
>
|
||||
×
|
||||
</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
921
web/src/ScheduleTable.tsx
Normal 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
215
web/src/Toolbar.tsx
Normal 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"
|
||||
>
|
||||
✕ 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
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
1
web/src/assets/react.svg
Normal 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
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
52
web/src/cellColors.ts
Normal 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
3084
web/src/data.json
Normal file
File diff suppressed because it is too large
Load Diff
4
web/src/excelIO.ts
Normal file
4
web/src/excelIO.ts
Normal 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
65
web/src/holidays.ts
Normal 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
27
web/src/index.css
Normal 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
10
web/src/main.tsx
Normal 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
88
web/src/types.ts
Normal 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
81
web/src/useDragCell.ts
Normal 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
325
web/src/useScheduleState.ts
Normal 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
28
web/tsconfig.app.json
Normal 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
7
web/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
web/tsconfig.node.json
Normal file
26
web/tsconfig.node.json
Normal 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
12
web/vite.config.ts
Normal 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
33
web/watch-deploy.js
Normal 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
26
web/watch-deploy.sh
Executable 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
|
||||
Reference in New Issue
Block a user