Compare commits
5 Commits
light-them
...
707e18c625
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
707e18c625 | ||
|
|
09a07b510b | ||
|
|
3d306e641c | ||
|
|
131b0d0df1 | ||
|
|
c3eda37f65 |
132
docs/plans/2026-04-22-half-cell-marks-design.md
Normal file
132
docs/plans/2026-04-22-half-cell-marks-design.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# Half-cell day/night preference marks — Design
|
||||||
|
|
||||||
|
**Date:** 2026-04-22
|
||||||
|
**Status:** approved, ready to implement
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
In the original Excel schedule, each day cell was split horizontally
|
||||||
|
(left = night, right = day). Users marked preferences/obstacles by
|
||||||
|
filling the appropriate half with a color before shift planning began.
|
||||||
|
|
||||||
|
In the current app, each day = one cell. Users can only express
|
||||||
|
day-vs-night preferences via cell comments (small blue triangle), which
|
||||||
|
is not visual at a glance and therefore "annoying."
|
||||||
|
|
||||||
|
Multiple users have requested restoring the Excel behavior.
|
||||||
|
|
||||||
|
## Decisions
|
||||||
|
|
||||||
|
1. **Coexist with shifts** — preference marks stay visible after a shift
|
||||||
|
is assigned (so we can see whether we honored or violated the
|
||||||
|
request).
|
||||||
|
2. **Free color from palette** — same flexibility as the existing
|
||||||
|
whole-cell color picker; semantics are user-defined.
|
||||||
|
3. **Horizontal split: left = night, right = day** — matches the
|
||||||
|
original Excel.
|
||||||
|
4. **Visual when shift is assigned: corner triangles** — full shift
|
||||||
|
color fills the cell; small ~8×8 px triangles in the bottom corners
|
||||||
|
indicate the marks (bottom-left = night, bottom-right = day).
|
||||||
|
5. **Toggle in context menu** — segmented control `Celá | Noční | Denní`
|
||||||
|
above the color palette selects which field the next color click
|
||||||
|
updates.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
`CellValue` (in `types.ts`) gains two optional fields:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface CellValue {
|
||||||
|
v?: string
|
||||||
|
color?: string // existing — full-cell background
|
||||||
|
dayColor?: string // NEW — right half / day mark
|
||||||
|
nightColor?: string // NEW — left half / night mark
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The change is purely additive. Existing JSON files load unchanged.
|
||||||
|
|
||||||
|
## Rendering rules
|
||||||
|
|
||||||
|
| State | Look |
|
||||||
|
|-------|------|
|
||||||
|
| empty + no marks | empty (current) |
|
||||||
|
| `dayColor` and/or `nightColor` set, no shift | left half = `nightColor`, right half = `dayColor` (each independent; missing half = transparent or `color` fallback) |
|
||||||
|
| shift assigned, no marks | full shift color (current) |
|
||||||
|
| shift assigned + marks | full shift color + 8 px triangles: bottom-left = night, bottom-right = day |
|
||||||
|
|
||||||
|
Priority for picking renderer:
|
||||||
|
|
||||||
|
1. If `dayColor || nightColor` → split-half mode
|
||||||
|
2. Else if `color` → full-cell color (current behavior)
|
||||||
|
3. Else → empty
|
||||||
|
|
||||||
|
## Interaction
|
||||||
|
|
||||||
|
Right-click cell → existing context menu, with new segmented toggle
|
||||||
|
above the color palette:
|
||||||
|
|
||||||
|
```
|
||||||
|
Barva:
|
||||||
|
[ Celá ] [ Noční ] [ Denní ] ← default: Celá
|
||||||
|
● ● ● ● ● ● ● [Bez barvy]
|
||||||
|
```
|
||||||
|
|
||||||
|
- Toggle selects target field (`color`, `nightColor`, `dayColor`)
|
||||||
|
- Color click writes only to the selected target
|
||||||
|
- "Bez barvy" clears only the selected target
|
||||||
|
- Three fields are independent
|
||||||
|
|
||||||
|
## Drag-and-drop
|
||||||
|
|
||||||
|
Half marks are tied to the **day**, not the shift. When a user drags a
|
||||||
|
shift cell:
|
||||||
|
|
||||||
|
- Only `v` (and `color` of the shift) moves to the destination
|
||||||
|
- `dayColor` / `nightColor` stay on both source and destination days
|
||||||
|
|
||||||
|
## Compare / diff view
|
||||||
|
|
||||||
|
Differences in `dayColor` / `nightColor` count as cell changes — render
|
||||||
|
the same orange "modified" border as for value changes.
|
||||||
|
|
||||||
|
## Filters
|
||||||
|
|
||||||
|
- "Color only" filter (rainbow dot) — also hides cells whose only
|
||||||
|
content is `dayColor` / `nightColor` (no `v`)
|
||||||
|
- Hidden value filter (e.g. hide "A") — hides the cell entirely
|
||||||
|
including marks (marks are auxiliary)
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Half-mark editing is available on:
|
||||||
|
|
||||||
|
- ✅ Person cells (Pohotovost TKB / Pohotovost IT)
|
||||||
|
- ✅ Info rows (TKB / SAZLT / Metro / D8)
|
||||||
|
- ❌ PMS columns (month-end summary, doesn't make sense)
|
||||||
|
|
||||||
|
## Read-only
|
||||||
|
|
||||||
|
Existing `isReadOnly` guard handles viewer mode automatically. Viewers
|
||||||
|
see marks but cannot edit.
|
||||||
|
|
||||||
|
## Out of scope (phase 1)
|
||||||
|
|
||||||
|
- **Excel export/import** — original template has no clean
|
||||||
|
representation; round-trip would lose marks. Documented as a known
|
||||||
|
limitation; revisit if users request it.
|
||||||
|
- **PDF export** — will render half marks using same rules (no extra
|
||||||
|
work expected).
|
||||||
|
|
||||||
|
## Files to change
|
||||||
|
|
||||||
|
1. `web/src/types.ts` — extend `CellValue`
|
||||||
|
2. `web/src/useScheduleState.ts` — add `setCellHalfColor(personId,
|
||||||
|
dayIdx, target, color)` reducer + undo support
|
||||||
|
3. `web/src/ScheduleTable.tsx` — render split halves / corner triangles
|
||||||
|
in `PersonRow` and `renderInfoRowCells`
|
||||||
|
4. `web/src/ContextMenu.tsx` — add segmented toggle, route color clicks
|
||||||
|
to selected target
|
||||||
|
5. `web/src/cellColors.ts` — no change
|
||||||
|
|
||||||
|
Estimate: ~150 LoC across 4 files plus minimal CSS.
|
||||||
BIN
samples/Pohotovost_TKB+IT_směny_sample.xlsx
Normal file
BIN
samples/Pohotovost_TKB+IT_směny_sample.xlsx
Normal file
Binary file not shown.
BIN
samples/import_harmonogram (2).xlsx
Normal file
BIN
samples/import_harmonogram (2).xlsx
Normal file
Binary file not shown.
BIN
samples/import_harmonogram.xlsx
Normal file
BIN
samples/import_harmonogram.xlsx
Normal file
Binary file not shown.
BIN
samples/tkb_web_excel_export_01.png
Normal file
BIN
samples/tkb_web_excel_export_01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
27
web/deploy-tkb.sh
Executable file
27
web/deploy-tkb.sh
Executable file
@@ -0,0 +1,27 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy TKB Plán služeb to copelk
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REMOTE="copelk"
|
||||||
|
REMOTE_DIR="~/tkb-plan/web"
|
||||||
|
|
||||||
|
echo "Building..."
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
npx vite build
|
||||||
|
|
||||||
|
echo "Syncing to $REMOTE..."
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude='node_modules' \
|
||||||
|
--exclude='.git' \
|
||||||
|
--exclude='saved_schedule.json' \
|
||||||
|
--exclude='schedules' \
|
||||||
|
--exclude='uploads' \
|
||||||
|
./ "$REMOTE:$REMOTE_DIR/"
|
||||||
|
|
||||||
|
echo "Patching production title (Tunely-DEV → Tunely)..."
|
||||||
|
ssh $REMOTE "sed -i 's|<title>Tunely-DEV</title>|<title>Tunely</title>|' $REMOTE_DIR/dist/index.html"
|
||||||
|
|
||||||
|
echo "Restarting service on remote..."
|
||||||
|
ssh $REMOTE "pm2 restart tkb-plan"
|
||||||
|
|
||||||
|
echo "Done! App running on copelk:3090"
|
||||||
Binary file not shown.
@@ -26,7 +26,10 @@ STRAVNE_8H = 155 # meal allowance for an 8h workday
|
|||||||
DAN = 0.15 # tax factor → 1.15 multiplier
|
DAN = 0.15 # tax factor → 1.15 multiplier
|
||||||
INTERNET = 500 # Janouš Petr monthly internet reimbursement
|
INTERNET = 500 # Janouš Petr monthly internet reimbursement
|
||||||
|
|
||||||
# Template name list — rows 8-19 of dochazka_template.xlsx.
|
# Part-time people: 4h/day instead of 8h → half FPD target, D/N/O deduct 4h.
|
||||||
|
HALF_TIME_NAMES = {'Dittrich Vladimír'}
|
||||||
|
|
||||||
|
# Template name list — rows 8-20 of dochazka_template.xlsx.
|
||||||
# Only these people are exported, in this order.
|
# Only these people are exported, in this order.
|
||||||
TEMPLATE_NAMES = [
|
TEMPLATE_NAMES = [
|
||||||
'Dittrich Vladimír',
|
'Dittrich Vladimír',
|
||||||
@@ -35,6 +38,7 @@ TEMPLATE_NAMES = [
|
|||||||
'Vörös Pavel',
|
'Vörös Pavel',
|
||||||
'Janouš Petr',
|
'Janouš Petr',
|
||||||
'Dvořák Václav',
|
'Dvořák Václav',
|
||||||
|
'Kópáček Ondřej',
|
||||||
'Toman Milan',
|
'Toman Milan',
|
||||||
'Hanzlík Marek',
|
'Hanzlík Marek',
|
||||||
'Vondrák Pavel',
|
'Vondrák Pavel',
|
||||||
@@ -48,6 +52,7 @@ PERSON_DATA = {
|
|||||||
'Žemlička Miroslav': {'auto': 'AUS', 'km_tkb': 40, 'km_sat': 0},
|
'Žemlička Miroslav': {'auto': 'AUS', 'km_tkb': 40, 'km_sat': 0},
|
||||||
'Franek Lukáš': {'auto': 'AUS', 'km_tkb': 40, 'km_sat': 0},
|
'Franek Lukáš': {'auto': 'AUS', 'km_tkb': 40, 'km_sat': 0},
|
||||||
'Dvořák Václav': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
|
'Dvořák Václav': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
|
||||||
|
'Kópáček Ondřej': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0}, # TODO confirm auto/km
|
||||||
'Toman Milan': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
|
'Toman Milan': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
|
||||||
'Kohl David': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
|
'Kohl David': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
|
||||||
'Milisavljevič Jovica':{'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
|
'Milisavljevič Jovica':{'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
|
||||||
@@ -75,12 +80,12 @@ PERSON_DATA = {
|
|||||||
|
|
||||||
# Template layout (1-based row numbers)
|
# Template layout (1-based row numbers)
|
||||||
TPL_FIRST = 8 # first data row
|
TPL_FIRST = 8 # first data row
|
||||||
TPL_LAST = 19 # last data row (12 people)
|
TPL_LAST = 20 # last data row (13 people)
|
||||||
TPL_CELKEM = 20
|
TPL_CELKEM = 21
|
||||||
TPL_HIDDEN1 = 22
|
TPL_HIDDEN1 = 23
|
||||||
TPL_HIDDEN2 = 23
|
TPL_HIDDEN2 = 24
|
||||||
TPL_IT_HDR = 24
|
TPL_IT_HDR = 25
|
||||||
TPL_IT1 = 25 # first IT person
|
TPL_IT1 = 26 # first IT person
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
def get_czech_holidays(year):
|
def get_czech_holidays(year):
|
||||||
@@ -132,37 +137,54 @@ def compute(schedule, month, year):
|
|||||||
fpd = work_days * 8
|
fpd = work_days * 8
|
||||||
|
|
||||||
tkb_results = []
|
tkb_results = []
|
||||||
# Only include people that are in the template — in template order
|
# Only include people that are in the template — in template order.
|
||||||
|
# Retired people are skipped for months where they have no data.
|
||||||
tkb_by_name = {p['name']: p for p in people if p.get('group')=='TKB'}
|
tkb_by_name = {p['name']: p for p in people if p.get('group')=='TKB'}
|
||||||
tkb_people = [tkb_by_name[name] for name in TEMPLATE_NAMES if name in tkb_by_name]
|
def _has_month_data(p):
|
||||||
|
return any((p['data'].get(str(d['idx'])) or {}).get('v') for d in month_days)
|
||||||
|
tkb_people = []
|
||||||
|
for name in TEMPLATE_NAMES:
|
||||||
|
p = tkb_by_name.get(name)
|
||||||
|
if not p: continue
|
||||||
|
if p.get('retired') and not _has_month_data(p): continue
|
||||||
|
tkb_people.append(p)
|
||||||
for person in tkb_people:
|
for person in tkb_people:
|
||||||
|
daily_hours = 4 if person['name'] in HALF_TIME_NAMES else 8
|
||||||
r=o=n=0; dov=nem=otc=0; minus_fpd=0
|
r=o=n=0; dov=nem=otc=0; minus_fpd=0
|
||||||
sichet_12h = 0 # shifts with ≥12 h
|
sichet_12h = 0 # shifts with ≥12 h
|
||||||
sichet_under_12h = 0 # shifts with <12 h
|
sichet_under_12h = 0 # shifts with <12 h
|
||||||
uzavery = 0 # 'U' days (closures)
|
uzavery = 0 # 'U' days (closures)
|
||||||
|
|
||||||
for d in month_days:
|
for d in month_days:
|
||||||
v = (person['data'].get(str(d['idx'])) or {}).get('v', '')
|
cell = person['data'].get(str(d['idx'])) or {}
|
||||||
is_w = (not d['weekend'] and
|
is_w = (not d['weekend'] and
|
||||||
Date(d['year'],d['month'],d['day']) not in holidays)
|
Date(d['year'],d['month'],d['day']) not in holidays)
|
||||||
if v=='A':
|
# Split cells carry two half-day values (dayValue + nightValue);
|
||||||
dr,do=split_day(12); r+=dr; o+=do
|
# process both halves, otherwise the single 'v'.
|
||||||
sichet_12h += 1
|
is_split = bool(cell.get('dayColor') or cell.get('nightColor')
|
||||||
elif v=='B':
|
or cell.get('dayValue') or cell.get('nightValue'))
|
||||||
nr,no,nn=split_night(12); r+=nr; o+=no; n+=nn
|
values = ([cell.get('dayValue',''), cell.get('nightValue','')]
|
||||||
sichet_12h += 1
|
if is_split else [cell.get('v','')])
|
||||||
elif v=='D': dov+=1; minus_fpd += 8 if is_w else 0
|
for v in values:
|
||||||
elif v=='N': nem+=1; minus_fpd += 8 if is_w else 0
|
if not v: continue
|
||||||
elif v=='O': otc+=1; minus_fpd += 8 if is_w else 0
|
if v=='A':
|
||||||
elif v=='U': uzavery += 1
|
dr,do=split_day(12); r+=dr; o+=do
|
||||||
elif v not in ('x','U','') and v:
|
sichet_12h += 1
|
||||||
try:
|
elif v=='B':
|
||||||
h=float(v)
|
nr,no,nn=split_night(12); r+=nr; o+=no; n+=nn
|
||||||
if h>0:
|
sichet_12h += 1
|
||||||
dr,do=split_day(h); r+=dr; o+=do
|
elif v=='D': dov+=1; minus_fpd += daily_hours if is_w else 0
|
||||||
if h >= 12: sichet_12h += 1
|
elif v=='N': nem+=1; minus_fpd += daily_hours if is_w else 0
|
||||||
else: sichet_under_12h += 1
|
elif v=='O': otc+=1; minus_fpd += daily_hours if is_w else 0
|
||||||
except (ValueError,TypeError): pass
|
elif v=='U': uzavery += 1
|
||||||
|
elif v not in ('x','U',''):
|
||||||
|
try:
|
||||||
|
h=float(v)
|
||||||
|
if h>0:
|
||||||
|
dr,do=split_day(h); r+=dr; o+=do
|
||||||
|
if h >= 12: sichet_12h += 1
|
||||||
|
else: sichet_under_12h += 1
|
||||||
|
except (ValueError,TypeError): pass
|
||||||
|
|
||||||
# PMS stored at negative dayIdx = -month
|
# PMS stored at negative dayIdx = -month
|
||||||
# PMS = práce mimo směnu: hours added to R/O but NOT counted as shifts
|
# PMS = práce mimo směnu: hours added to R/O but NOT counted as shifts
|
||||||
@@ -176,7 +198,8 @@ def compute(schedule, month, year):
|
|||||||
except (ValueError,TypeError): pass
|
except (ValueError,TypeError): pass
|
||||||
|
|
||||||
total = r+o+n
|
total = r+o+n
|
||||||
o_navic = total - fpd + minus_fpd
|
person_fpd = work_days * daily_hours
|
||||||
|
o_navic = total - person_fpd + minus_fpd
|
||||||
r_fpd, o_fpd, n_fpd = r, o, n
|
r_fpd, o_fpd, n_fpd = r, o, n
|
||||||
if o_navic > 0:
|
if o_navic > 0:
|
||||||
pom = o_navic
|
pom = o_navic
|
||||||
@@ -192,7 +215,7 @@ def compute(schedule, month, year):
|
|||||||
|
|
||||||
# --- Financial calculations (sichtovnice.py) ---
|
# --- Financial calculations (sichtovnice.py) ---
|
||||||
# Per-person FPD days (adjusted for D/N/O on working days)
|
# Per-person FPD days (adjusted for D/N/O on working days)
|
||||||
person_fpd_dny = work_days - (minus_fpd / 8)
|
person_fpd_dny = work_days - (minus_fpd / daily_hours)
|
||||||
|
|
||||||
# R (Jine1): meal allowance supplement
|
# R (Jine1): meal allowance supplement
|
||||||
# 1.15 × (sichet_12h × 236 + sichet_<12h × 155 − fpd_per_person × 155)
|
# 1.15 × (sichet_12h × 236 + sichet_<12h × 155 − fpd_per_person × 155)
|
||||||
@@ -394,7 +417,7 @@ def generate_excel(data):
|
|||||||
rows = {int(r.get('r')): r for r in sd.findall(f'{{{NS}}}row')}
|
rows = {int(r.get('r')): r for r in sd.findall(f'{{{NS}}}row')}
|
||||||
|
|
||||||
n_people = len(data['tkb'])
|
n_people = len(data['tkb'])
|
||||||
n_tmpl = TPL_LAST - TPL_FIRST + 1 # 12
|
n_tmpl = TPL_LAST - TPL_FIRST + 1 # 13
|
||||||
|
|
||||||
# ── ROW 3: month metadata ──
|
# ── ROW 3: month metadata ──
|
||||||
r3 = rows[3]
|
r3 = rows[3]
|
||||||
@@ -403,7 +426,7 @@ def generate_excel(data):
|
|||||||
set_cell_num(r3, 7, 3, data['work_days']) # G3
|
set_cell_num(r3, 7, 3, data['work_days']) # G3
|
||||||
set_cell_num(r3, 8, 3, data['fpd']) # H3
|
set_cell_num(r3, 8, 3, data['fpd']) # H3
|
||||||
|
|
||||||
# ── DATA ROWS 8–19 ──
|
# ── DATA ROWS 8–20 ──
|
||||||
for i in range(n_tmpl):
|
for i in range(n_tmpl):
|
||||||
row_num = TPL_FIRST + i
|
row_num = TPL_FIRST + i
|
||||||
r = rows[row_num]
|
r = rows[row_num]
|
||||||
|
|||||||
491
web/export_full_excel.py
Normal file
491
web/export_full_excel.py
Normal file
@@ -0,0 +1,491 @@
|
|||||||
|
"""Monthly Excel export matching the TKB sample format.
|
||||||
|
|
||||||
|
Reads JSON {schedule, month, year} from stdin, writes xlsx bytes to stdout.
|
||||||
|
|
||||||
|
Layout (preserved from samples/Pohotovost_TKB+IT_směny_2026_03_Březen.xlsx):
|
||||||
|
- 34 day-pair columns (cols 2-69, even=day half, odd=night half)
|
||||||
|
- Each export starts 3 days before the 1st of the requested month
|
||||||
|
- Persons matched by name to fixed template rows
|
||||||
|
- TKB closures → merged R3:R5 cell across day-pair
|
||||||
|
- SAZLT + D8 closures → row 1, comma-separated
|
||||||
|
- METRO closures → ignored
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
from copy import copy
|
||||||
|
from io import BytesIO
|
||||||
|
from datetime import date, timedelta
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.styles import PatternFill, Alignment, Border, Side, Font
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
CELL_COLORS = {
|
||||||
|
'4': 'F1F8E9', '6': 'E8F5E9', '8': 'CCFFCC',
|
||||||
|
'12': 'A3D977', '16': '66BB6A', '24': '2E7D32',
|
||||||
|
'A': 'FFE082', 'B': 'B0BEC5',
|
||||||
|
'D': 'FFFF00', 'D/2': 'FFF9C4',
|
||||||
|
'N': 'FF4444', 'U': '92D050',
|
||||||
|
'O': 'FFFF00', 'x': 'E0E0E0', # O = otcovská: same look as D
|
||||||
|
}
|
||||||
|
|
||||||
|
# Czech weekday names indexed by Python's weekday() (Monday=0)
|
||||||
|
CZECH_DAYS = ['Pondělí', 'Úterý', 'Středa', 'Čtvrtek', 'Pátek', 'Sobota', 'Neděle']
|
||||||
|
|
||||||
|
# Person name → row in template (must match template content)
|
||||||
|
TKB_ROWS = {
|
||||||
|
'Pauzer Libor': 7, 'Vörös Pavel': 8, 'Janouš Petr': 9,
|
||||||
|
'Franek Lukáš': 10, 'Svoboda Daniel': 11, 'Dvořák Václav': 12,
|
||||||
|
'Kópáček Ondřej': 13,
|
||||||
|
'Vondrák Pavel': 14, 'Čeleda Olda': 15, 'Hanzlík Marek': 16,
|
||||||
|
'Kohl David': 17, 'Dittrich Vladimír': 18, 'Toman Milan': 19,
|
||||||
|
'Zábranský Petr': 20, 'Žemlička Miroslav': 21, 'Teslík Hynek': 22,
|
||||||
|
}
|
||||||
|
IT_ROWS = {
|
||||||
|
'Vörös Pavel': 24, 'Janouš Petr': 25, 'Glaser Ondřej': 26,
|
||||||
|
'Robert Štefan': 27, 'Franek Lukáš': 28,
|
||||||
|
}
|
||||||
|
# Template layout below the person rows
|
||||||
|
IT_HEADER_ROW = 23 # 'Pohotovost IT' label row
|
||||||
|
IT_FIRST_ROW, IT_LAST_ROW = 24, 28
|
||||||
|
BELOW_IT_ROW = 29 # first row under the IT section
|
||||||
|
|
||||||
|
# Day-pair start columns: 2, 4, 6, ..., 68 (34 day-pairs)
|
||||||
|
DAY_COLS = list(range(2, 70, 2))
|
||||||
|
|
||||||
|
# 162 group — excluded from FPD calculation (matches FpdCheck.tsx)
|
||||||
|
FPD_EXCLUDED = {'Pauzer Libor', 'Vörös Pavel', 'Janouš Petr', 'Franek Lukáš', 'Svoboda Daniel'}
|
||||||
|
|
||||||
|
|
||||||
|
def _easter_monday(year):
|
||||||
|
a = year % 19
|
||||||
|
b = year // 100
|
||||||
|
c = year % 100
|
||||||
|
d = b // 4
|
||||||
|
e = b % 4
|
||||||
|
f = (b + 8) // 25
|
||||||
|
g = (b - f + 1) // 3
|
||||||
|
h = (19 * a + b - d - g + 15) % 30
|
||||||
|
i = c // 4
|
||||||
|
k = c % 4
|
||||||
|
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||||
|
m = (a + 11 * h + 22 * l) // 451
|
||||||
|
month = (h + l - 7 * m + 114) // 31
|
||||||
|
day = ((h + l - 7 * m + 114) % 31) + 1
|
||||||
|
sunday = date(year, month, day)
|
||||||
|
return sunday + timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
def czech_holidays(year):
|
||||||
|
em = _easter_monday(year)
|
||||||
|
gf = em - timedelta(days=3)
|
||||||
|
fixed = [(1, 1), (5, 1), (5, 8), (7, 5), (7, 6), (9, 28),
|
||||||
|
(10, 28), (11, 17), (12, 24), (12, 25), (12, 26)]
|
||||||
|
out = {(em.month, em.day), (gf.month, gf.day)}
|
||||||
|
for m, d in fixed:
|
||||||
|
out.add((m, d))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def hours_for(value):
|
||||||
|
"""Match webapp's getHoursForValue (FpdCheck.tsx):
|
||||||
|
A and B count as 12 hours; numeric values as themselves;
|
||||||
|
everything else (U, D, N, x, O) counts as 0."""
|
||||||
|
if value in ('A', 'B'):
|
||||||
|
return 12
|
||||||
|
try:
|
||||||
|
n = float(value)
|
||||||
|
return n if n > 0 else 0
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
payload = json.loads(sys.stdin.read())
|
||||||
|
schedule = payload['schedule']
|
||||||
|
month = int(payload['month'])
|
||||||
|
year = int(payload['year'])
|
||||||
|
|
||||||
|
# Show 3 days before the 1st of the month, then 34 days total
|
||||||
|
start = date(year, month, 1) - timedelta(days=3)
|
||||||
|
days = [start + timedelta(days=i) for i in range(34)]
|
||||||
|
|
||||||
|
template_path = Path(__file__).parent / 'public' / 'full_template.xlsx'
|
||||||
|
wb = load_workbook(str(template_path))
|
||||||
|
ws = wb['Plán Služeb']
|
||||||
|
|
||||||
|
# ---- A1: replace template "01.03.2026 v.1" with the export's month ----
|
||||||
|
ws.cell(1, 1).value = f'01.{month:02d}.{year} v.1'
|
||||||
|
|
||||||
|
# ---- Step 0: Strip ALL leftover cell comments from template ----
|
||||||
|
for row in ws.iter_rows():
|
||||||
|
for cell in row:
|
||||||
|
if cell.comment:
|
||||||
|
cell.comment = None
|
||||||
|
|
||||||
|
# Snapshot original PMS column (BU) fills+borders BEFORE any modifications.
|
||||||
|
# These represent the row's "default" formatting for PMS, used later for FPD column.
|
||||||
|
pms_original_styles = {}
|
||||||
|
for r in range(7, BELOW_IT_ROW):
|
||||||
|
cell = ws.cell(r, 71)
|
||||||
|
pms_original_styles[r] = (copy(cell.fill), copy(cell.border))
|
||||||
|
|
||||||
|
# ---- Step 1: Unmerge ALL cells in R3:R5 across day-pair cols (clean slate) ----
|
||||||
|
for m in list(ws.merged_cells.ranges):
|
||||||
|
if m.min_row >= 3 and m.max_row <= 5 and 2 <= m.min_col < 70:
|
||||||
|
ws.unmerge_cells(str(m))
|
||||||
|
# Clear values + fills in R3:R5 cols 2-69. Restore consistent borders:
|
||||||
|
# - no border between day half and night half within a day-pair
|
||||||
|
# - month boundary marked with thicker (medium) left border on the day half
|
||||||
|
# We compute month-boundary based on the requested month dates set later.
|
||||||
|
# For now, just clear and apply baseline thin borders; medium override happens after dates are computed.
|
||||||
|
for r in (3, 4, 5):
|
||||||
|
for c in range(2, 70):
|
||||||
|
cell = ws.cell(r, c)
|
||||||
|
cell.value = None
|
||||||
|
cell.fill = PatternFill(fill_type=None)
|
||||||
|
|
||||||
|
# ---- Step 2: Clear values in person rows. Replace RGB fills (sample shifts) with the
|
||||||
|
# row's "default" fill (taken from the col-1 label cell) so empty cells inherit the
|
||||||
|
# row's background instead of going white. ----
|
||||||
|
person_rows = list(TKB_ROWS.values()) + list(IT_ROWS.values())
|
||||||
|
for r in person_rows:
|
||||||
|
# Default row fill: from col-1 label cell (typically theme-colored)
|
||||||
|
try:
|
||||||
|
row_default_fill = copy(ws.cell(r, 1).fill)
|
||||||
|
except Exception:
|
||||||
|
row_default_fill = None
|
||||||
|
for c in range(2, 72):
|
||||||
|
# Cols 71 (PMS) and 72 (FPD) keep their template fills — handled separately
|
||||||
|
if c >= 71:
|
||||||
|
continue
|
||||||
|
cell = ws.cell(r, c)
|
||||||
|
if cell.__class__.__name__ == 'MergedCell':
|
||||||
|
continue
|
||||||
|
cell.value = None
|
||||||
|
# Col 70 (BR) is the separator column before "Práce mimo směnu" — keep white
|
||||||
|
if c == 70:
|
||||||
|
cell.fill = PatternFill(fill_type=None)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
fg = cell.fill.fgColor
|
||||||
|
# If cell has an RGB fill (sample shift highlight), replace with row default
|
||||||
|
if fg and getattr(fg, 'type', None) == 'rgb':
|
||||||
|
if row_default_fill is not None:
|
||||||
|
cell.fill = copy(row_default_fill)
|
||||||
|
else:
|
||||||
|
cell.fill = PatternFill(fill_type=None)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ---- Step 3: Clear row 1 closure labels in day-pair cols ----
|
||||||
|
for m in list(ws.merged_cells.ranges):
|
||||||
|
if m.min_row == 1 and m.max_row == 1 and 2 <= m.min_col < 70:
|
||||||
|
ws.unmerge_cells(str(m))
|
||||||
|
for c in range(2, 70):
|
||||||
|
cell = ws.cell(1, c)
|
||||||
|
cell.value = None
|
||||||
|
|
||||||
|
# ---- Step 4: Update headers (R2 weekday, R3 year, R4 month, R5 day) + borders + weekend fills ----
|
||||||
|
thin = Side(style='thin', color='000000')
|
||||||
|
medium = Side(style='medium', color='000000')
|
||||||
|
WEEKEND_FILL = PatternFill(start_color='FCD5B4', end_color='FCD5B4', fill_type='solid') # peach
|
||||||
|
NO_FILL = PatternFill(fill_type=None)
|
||||||
|
for i, day in enumerate(days):
|
||||||
|
col = DAY_COLS[i]
|
||||||
|
ws.cell(2, col).value = CZECH_DAYS[day.weekday()]
|
||||||
|
ws.cell(3, col).value = day.year
|
||||||
|
ws.cell(4, col).value = day.month
|
||||||
|
ws.cell(5, col).value = day.day
|
||||||
|
|
||||||
|
is_month_start = (day.day == 1)
|
||||||
|
is_weekend = day.weekday() >= 5 # Sat=5, Sun=6
|
||||||
|
for r in (3, 4, 5):
|
||||||
|
# Day half (left): left border (medium at month boundary, else thin), no right border
|
||||||
|
ws.cell(r, col).border = Border(
|
||||||
|
left=medium if is_month_start else thin,
|
||||||
|
right=None,
|
||||||
|
top=thin if r == 3 else None,
|
||||||
|
bottom=thin if r == 5 else None,
|
||||||
|
)
|
||||||
|
# Night half (right): no left border, thin right border
|
||||||
|
ws.cell(r, col + 1).border = Border(
|
||||||
|
left=None,
|
||||||
|
right=thin,
|
||||||
|
top=thin if r == 3 else None,
|
||||||
|
bottom=thin if r == 5 else None,
|
||||||
|
)
|
||||||
|
# Apply weekend fill (or clear for weekday)
|
||||||
|
ws.cell(r, col).fill = WEEKEND_FILL if is_weekend else NO_FILL
|
||||||
|
ws.cell(r, col + 1).fill = WEEKEND_FILL if is_weekend else NO_FILL
|
||||||
|
# R2 (weekday name) — same peach as R3-R5 for weekend
|
||||||
|
ws.cell(2, col).fill = WEEKEND_FILL if is_weekend else NO_FILL
|
||||||
|
ws.cell(2, col + 1).fill = WEEKEND_FILL if is_weekend else NO_FILL
|
||||||
|
|
||||||
|
# Build dayIndex lookup by date
|
||||||
|
by_date = {}
|
||||||
|
for d in schedule['dayIndex']:
|
||||||
|
by_date[(d['year'], d['month'], d['day'])] = d
|
||||||
|
|
||||||
|
# ---- Step 5: SAZLT + D8 closures into row 1 (comma-separated) ----
|
||||||
|
info_rows = schedule.get('infoRows') or {}
|
||||||
|
sazlt_by_idx = {x['dayIdx']: x['text'] for x in (info_rows.get('sazlt') or [])}
|
||||||
|
d8_by_idx = {x['dayIdx']: x['text'] for x in (info_rows.get('d8') or [])}
|
||||||
|
|
||||||
|
for i, day in enumerate(days):
|
||||||
|
di = by_date.get((day.year, day.month, day.day))
|
||||||
|
if not di:
|
||||||
|
continue
|
||||||
|
parts = []
|
||||||
|
s = sazlt_by_idx.get(di['idx'])
|
||||||
|
d = d8_by_idx.get(di['idx'])
|
||||||
|
if s: parts.append(s)
|
||||||
|
if d: parts.append(d)
|
||||||
|
if parts:
|
||||||
|
col = DAY_COLS[i]
|
||||||
|
ws.cell(1, col).value = ', '.join(parts)
|
||||||
|
# Merge across the day-pair so the label spans both day + night cols
|
||||||
|
ws.merge_cells(start_row=1, start_column=col, end_row=1, end_column=col + 1)
|
||||||
|
|
||||||
|
# ---- Step 6: TKB closures: merged R3:R5 of NIGHT half (odd col), rotated text + bg color. Date in DAY half stays. ----
|
||||||
|
tunnel_closures = {tc['dayIdx']: tc['text'] for tc in (schedule.get('tunnelClosures') or [])}
|
||||||
|
tunnel_colors = {tc['dayIdx']: tc.get('color') for tc in (schedule.get('tunnelClosures') or [])}
|
||||||
|
DEFAULT_TKB_BG = 'FFC000' # orange — matches predominant style in sample
|
||||||
|
for i, day in enumerate(days):
|
||||||
|
di = by_date.get((day.year, day.month, day.day))
|
||||||
|
if not di:
|
||||||
|
continue
|
||||||
|
tkb = tunnel_closures.get(di['idx'])
|
||||||
|
if tkb:
|
||||||
|
night_col = DAY_COLS[i] + 1
|
||||||
|
ws.merge_cells(start_row=3, start_column=night_col, end_row=5, end_column=night_col)
|
||||||
|
cell = ws.cell(3, night_col)
|
||||||
|
cell.value = tkb
|
||||||
|
cell.alignment = Alignment(text_rotation=90, horizontal='center', vertical='center')
|
||||||
|
color = tunnel_colors.get(di['idx']) or DEFAULT_TKB_BG
|
||||||
|
color = color.lstrip('#').upper()
|
||||||
|
cell.fill = PatternFill(start_color=color, end_color=color, fill_type='solid')
|
||||||
|
cell.border = Border(left=None, right=thin, top=thin, bottom=thin)
|
||||||
|
|
||||||
|
# ---- Step 7: Fill person values ----
|
||||||
|
# Translation rules per group:
|
||||||
|
# ALL groups: A → 12 (day half), B → 12 (night half)
|
||||||
|
# IT only: 16 → day=4, night=12 ; 24 → day=12, night=12
|
||||||
|
def translate(value, is_it):
|
||||||
|
"""Convert app shift code → (day_value, night_value) for excel cells.
|
||||||
|
Returns (day, night) where each is the value to write or None."""
|
||||||
|
if value is None or value == '':
|
||||||
|
return (None, None)
|
||||||
|
if value == 'A':
|
||||||
|
return ('12', None)
|
||||||
|
if value == 'B':
|
||||||
|
return (None, '12')
|
||||||
|
if value == 'U':
|
||||||
|
return (None, 'U') # uzávěra is always at night
|
||||||
|
if is_it:
|
||||||
|
if value == '16':
|
||||||
|
return ('4', '12')
|
||||||
|
if value == '24':
|
||||||
|
return ('12', '12')
|
||||||
|
return (value, None) # default: in day half
|
||||||
|
|
||||||
|
def write_cell(row, col, val, text_color=None, custom_bg=None):
|
||||||
|
if val in (None, '') and not custom_bg:
|
||||||
|
return
|
||||||
|
cell = ws.cell(row, col)
|
||||||
|
is_numeric = False
|
||||||
|
if val not in (None, ''):
|
||||||
|
try:
|
||||||
|
n = int(val)
|
||||||
|
cell.value = n
|
||||||
|
is_numeric = True
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
cell.value = val
|
||||||
|
# Background priority: custom color from app > shift color (only for letter codes).
|
||||||
|
bg = None
|
||||||
|
if custom_bg:
|
||||||
|
bg = custom_bg.lstrip('#').upper()
|
||||||
|
elif val not in (None, '') and not is_numeric:
|
||||||
|
bg = CELL_COLORS.get(str(val))
|
||||||
|
if bg:
|
||||||
|
cell.fill = PatternFill(start_color=bg, end_color=bg, fill_type='solid')
|
||||||
|
# Apply text color (red for VN persons, blue for NN persons)
|
||||||
|
if text_color:
|
||||||
|
base = cell.font
|
||||||
|
cell.font = Font(
|
||||||
|
name=base.name, size=base.size, bold=base.bold, italic=base.italic,
|
||||||
|
color=text_color,
|
||||||
|
)
|
||||||
|
|
||||||
|
for person in schedule['people']:
|
||||||
|
is_tkb = person.get('group') == 'TKB'
|
||||||
|
is_it = not is_tkb
|
||||||
|
rows_map = TKB_ROWS if is_tkb else IT_ROWS
|
||||||
|
row = rows_map.get(person['name'])
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
# Text color based on qualification: VN→red, NN→blue
|
||||||
|
note = (person.get('note') or '').strip()
|
||||||
|
text_color = None
|
||||||
|
if note == 'VN':
|
||||||
|
text_color = 'FFD32F2F'
|
||||||
|
elif note == 'NN':
|
||||||
|
text_color = 'FF1565C0'
|
||||||
|
|
||||||
|
for i, day in enumerate(days):
|
||||||
|
di = by_date.get((day.year, day.month, day.day))
|
||||||
|
if not di:
|
||||||
|
continue
|
||||||
|
cd = person['data'].get(str(di['idx']))
|
||||||
|
if not cd:
|
||||||
|
continue
|
||||||
|
day_col = DAY_COLS[i]
|
||||||
|
night_col = day_col + 1
|
||||||
|
|
||||||
|
# Apply translations to whole-cell value, half values are explicit
|
||||||
|
day_v = cd.get('dayValue')
|
||||||
|
night_v = cd.get('nightValue')
|
||||||
|
|
||||||
|
if day_v is None and night_v is None and cd.get('v'):
|
||||||
|
# Legacy whole-cell value — translate
|
||||||
|
day_v, night_v = translate(cd.get('v'), is_it)
|
||||||
|
else:
|
||||||
|
# Explicit halves — translate each independently if needed
|
||||||
|
if day_v in ('A',): day_v = '12'
|
||||||
|
if night_v in ('B',): night_v = '12'
|
||||||
|
if is_it:
|
||||||
|
if day_v == '16': day_v, _ = ('4', None); night_v = night_v or '12'
|
||||||
|
if night_v == '16': night_v, _ = ('12', None); day_v = day_v or '4'
|
||||||
|
if day_v == '24': day_v, _ = ('12', None); night_v = night_v or '12'
|
||||||
|
if night_v == '24': night_v = '12'; day_v = day_v or '12'
|
||||||
|
|
||||||
|
# Custom background colors from webapp (per-half override > whole-cell)
|
||||||
|
day_bg = cd.get('dayColor') or cd.get('color')
|
||||||
|
night_bg = cd.get('nightColor') or cd.get('color')
|
||||||
|
|
||||||
|
write_cell(row, day_col, day_v, text_color, custom_bg=day_bg)
|
||||||
|
write_cell(row, night_col, night_v, text_color, custom_bg=night_bg)
|
||||||
|
|
||||||
|
# PMS: data is stored at person.data[String(-month)] for the requested month
|
||||||
|
pms = person['data'].get(str(-month))
|
||||||
|
if pms and pms.get('v'):
|
||||||
|
write_cell(row, 71, pms['v'], text_color)
|
||||||
|
|
||||||
|
# ---- Step 8: FPD column at C72 — header + per-person diff (workedHours - FPD) ----
|
||||||
|
holidays = czech_holidays(year)
|
||||||
|
work_days = sum(
|
||||||
|
1 for d in schedule['dayIndex']
|
||||||
|
if d['year'] == year and d['month'] == month
|
||||||
|
and not d.get('weekend')
|
||||||
|
and (d['month'], d['day']) not in holidays
|
||||||
|
)
|
||||||
|
month_fpd = work_days * 8
|
||||||
|
|
||||||
|
# Unmerge any leftover merges in col 72
|
||||||
|
for m in list(ws.merged_cells.ranges):
|
||||||
|
if m.min_col <= 72 <= m.max_col and m.max_row >= 7:
|
||||||
|
ws.unmerge_cells(str(m))
|
||||||
|
|
||||||
|
# Match col 72 width to col 71 (PMS)
|
||||||
|
pms_col_dim = ws.column_dimensions.get('BS')
|
||||||
|
if pms_col_dim and pms_col_dim.width:
|
||||||
|
ws.column_dimensions['BT'].width = pms_col_dim.width
|
||||||
|
|
||||||
|
# Strip formatting on the IT section of PMS/FPD cols and adjacent boundaries
|
||||||
|
NO_BORDER = Border()
|
||||||
|
for r in range(IT_FIRST_ROW, BELOW_IT_ROW):
|
||||||
|
for c in (70, 71, 72, 73):
|
||||||
|
cell = ws.cell(r, c)
|
||||||
|
if c in (71, 72):
|
||||||
|
cell.fill = PatternFill(fill_type=None)
|
||||||
|
cell.border = NO_BORDER
|
||||||
|
else:
|
||||||
|
# Adjacent cols: only clear the side facing PMS/FPD
|
||||||
|
b = cell.border
|
||||||
|
if c == 70:
|
||||||
|
cell.border = Border(left=b.left, right=Side(), top=b.top, bottom=b.bottom)
|
||||||
|
elif c == 73:
|
||||||
|
cell.border = Border(left=Side(), right=b.right, top=b.top, bottom=b.bottom)
|
||||||
|
# Clear bleed-in from rows 22 (above IT) and 28 (below IT) on BS/BT
|
||||||
|
for c in (71, 72):
|
||||||
|
b = ws.cell(IT_HEADER_ROW, c).border
|
||||||
|
ws.cell(IT_HEADER_ROW, c).border = Border(left=b.left, right=b.right, top=b.top, bottom=Side())
|
||||||
|
ws.cell(BELOW_IT_ROW, c).border = NO_BORDER
|
||||||
|
|
||||||
|
# Header at C72 (BT) — copy style from C73 (PMS), set text "FPD", merge R2:R6
|
||||||
|
src_header = ws.cell(2, 71)
|
||||||
|
dst_header = ws.cell(2, 72)
|
||||||
|
dst_header.value = 'FPD'
|
||||||
|
dst_header.alignment = copy(src_header.alignment)
|
||||||
|
dst_header.font = copy(src_header.font)
|
||||||
|
dst_header.fill = copy(src_header.fill)
|
||||||
|
dst_header.border = copy(src_header.border)
|
||||||
|
try:
|
||||||
|
ws.merge_cells(start_row=2, start_column=72, end_row=6, end_column=72)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Per-person FPD diff (calculate for ALL TKB persons, including 162 group)
|
||||||
|
for person in schedule['people']:
|
||||||
|
if person.get('group') != 'TKB':
|
||||||
|
continue
|
||||||
|
row = TKB_ROWS.get(person['name'])
|
||||||
|
if not row:
|
||||||
|
continue
|
||||||
|
worked = 0.0
|
||||||
|
vacation = 0.0 # D = 8h, D/2 = 4h — reduces person's FPD target
|
||||||
|
def collect(v, is_workday):
|
||||||
|
nonlocal worked, vacation
|
||||||
|
if not v:
|
||||||
|
return
|
||||||
|
if v == 'D':
|
||||||
|
vacation += 8
|
||||||
|
elif v == 'D/2':
|
||||||
|
vacation += 4
|
||||||
|
elif v in ('N', 'O'):
|
||||||
|
# sick / paternity leave on a working day is credited as a normal
|
||||||
|
# worked day, so it neither helps nor hurts the balance (as in FpdCheck.tsx)
|
||||||
|
if is_workday:
|
||||||
|
worked += 8
|
||||||
|
else:
|
||||||
|
worked += hours_for(v)
|
||||||
|
for d in schedule['dayIndex']:
|
||||||
|
if d['year'] != year or d['month'] != month:
|
||||||
|
continue
|
||||||
|
cd = person['data'].get(str(d['idx']))
|
||||||
|
if not cd:
|
||||||
|
continue
|
||||||
|
is_workday = not d.get('weekend') and (d['month'], d['day']) not in holidays
|
||||||
|
is_split = bool(cd.get('dayColor') or cd.get('nightColor')
|
||||||
|
or cd.get('dayValue') or cd.get('nightValue'))
|
||||||
|
if is_split:
|
||||||
|
collect(cd.get('dayValue'), is_workday)
|
||||||
|
collect(cd.get('nightValue'), is_workday)
|
||||||
|
else:
|
||||||
|
collect(cd.get('v'), is_workday)
|
||||||
|
person_fpd = month_fpd - vacation
|
||||||
|
diff = worked - person_fpd
|
||||||
|
dst = ws.cell(row, 72)
|
||||||
|
src_pms = ws.cell(row, 71)
|
||||||
|
dst.value = int(diff) if diff == int(diff) else round(diff, 1)
|
||||||
|
dst.alignment = Alignment(horizontal='center', vertical='center')
|
||||||
|
# Center PMS values too
|
||||||
|
src_pms.alignment = Alignment(horizontal='center', vertical='center')
|
||||||
|
# Copy ORIGINAL PMS fill + border (snapshot before U/PMS values were written)
|
||||||
|
orig_fill, orig_border = pms_original_styles.get(row, (None, None))
|
||||||
|
if orig_fill is not None:
|
||||||
|
dst.fill = orig_fill
|
||||||
|
if orig_border is not None:
|
||||||
|
dst.border = orig_border
|
||||||
|
# Force black text (no VN/NN red/blue in FPD column)
|
||||||
|
base = dst.font
|
||||||
|
dst.font = Font(name=base.name, size=base.size, bold=base.bold,
|
||||||
|
italic=base.italic, color='FF000000')
|
||||||
|
|
||||||
|
out = BytesIO()
|
||||||
|
wb.save(out)
|
||||||
|
sys.stdout.buffer.write(out.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
239
web/export_harmonogram.py
Normal file
239
web/export_harmonogram.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
export_harmonogram.py
|
||||||
|
Reads JSON from stdin: {"schedule": {...}, "month": N, "year": N}
|
||||||
|
Writes an XLSX (to stdout) in the import format expected by the internal
|
||||||
|
attendance system. Layout matches samples/import_harmonogram.xlsx:
|
||||||
|
|
||||||
|
- Sheet "Harmonogram":
|
||||||
|
Row 1: dates as 'YYYY-MM-DD' starting at column C
|
||||||
|
Col A: person name (with "(VN)"/"(NN)" suffix where applicable)
|
||||||
|
Col B: personal ID number
|
||||||
|
Cells: comma-separated shift codes from the Definice sheet
|
||||||
|
- Sheet "Definice": copy of the canonical key/time table.
|
||||||
|
|
||||||
|
App → attendance code mapping:
|
||||||
|
A → R12 (12h day shift)
|
||||||
|
B → N12 (12h night shift)
|
||||||
|
8 → R (8h day shift)
|
||||||
|
12 → R12
|
||||||
|
D → D12 (vacation, paid as 12h day shift in CIS)
|
||||||
|
U → skipped (closure work; handled elsewhere)
|
||||||
|
N/O / x → skipped (absences; handled elsewhere)
|
||||||
|
IT 16 → "POH_B1,R,POH_B2" (weekday on-call)
|
||||||
|
IT 24 → "POH_B3" (weekend full-day on-call)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import io
|
||||||
|
from datetime import date as Date, time as Time, timedelta
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
# ── Fixed roster — TKB shift workers only ──
|
||||||
|
# Output rows ALWAYS appear in this exact order, regardless of whether
|
||||||
|
# the person has any data this month. Format: (display_name, group, person_id,
|
||||||
|
# data_lookup_name) where data_lookup_name is how the person is named in the
|
||||||
|
# schedule data (may differ from display_name).
|
||||||
|
# Note: IT on-call people (Glaser, Janouš, Štefan, Vörös) are intentionally
|
||||||
|
# excluded — they're handled outside the CIS attendance import.
|
||||||
|
# Part-time people: half the monthly FPD (4h/day instead of 8h).
|
||||||
|
HALF_TIME_NAMES = {'Dittrich Vladimír'}
|
||||||
|
|
||||||
|
ROSTER = [
|
||||||
|
('Dvořák Václav (VN)', 'TKB', 1247, 'Dvořák Václav'),
|
||||||
|
# Kópáček Ondřej — part-time student, not in CIS (no personal number); add here
|
||||||
|
# if he joins full time: ('Kópáček Ondřej', 'TKB', <osobní číslo>, 'Kópáček Ondřej'),
|
||||||
|
('Vondrák Pavel (NN)', 'TKB', 454, 'Vondrák Pavel'),
|
||||||
|
('Čeleda Olda (NN)', 'TKB', 463, 'Čeleda Olda'),
|
||||||
|
('Hanzlík Marek (VN)', 'TKB', 1366, 'Hanzlík Marek'),
|
||||||
|
('Kohl David (VN)', 'TKB', 1007, 'Kohl David'),
|
||||||
|
('Dittrich Vladimír (VN)', 'TKB', 373, 'Dittrich Vladimír'),
|
||||||
|
('Toman Milan (VN)', 'TKB', 157, 'Toman Milan'),
|
||||||
|
('Žemlička Miroslav (NN)', 'TKB', 1827, 'Žemlička Miroslav'),
|
||||||
|
('Teslík Hynek (NN)', 'TKB', 738, 'Teslík Hynek'),
|
||||||
|
]
|
||||||
|
|
||||||
|
# ── Definice sheet (verbatim from sample) ──
|
||||||
|
# (key, key_k2, time_from, from_24plus, time_to, to_24plus, typ)
|
||||||
|
DEFINICE_ROWS = [
|
||||||
|
('R', 184730838379022, Time(7, 0), 0, Time(15, 30), 0, 0),
|
||||||
|
('R12', 184997126340609, Time(7, 30), 0, Time(20, 0), 0, 0),
|
||||||
|
('D12', 184997126340609, Time(7, 30), 0, Time(20, 0), 0, 0),
|
||||||
|
('N12', 184997126340610, Time(20, 0), 0, Time(7, 30), 1, 0),
|
||||||
|
('POH_B1', 184936996798976, Time(0, 0), 0, Time(7, 0), 0, 2),
|
||||||
|
('POH_B2', 184936996798976, Time(15, 30), 0, Time(0, 0), 1, 2),
|
||||||
|
('POH_B3', 184936996798976, Time(0, 0), 0, Time(0, 0), 1, 2),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_czech_holidays(year):
|
||||||
|
fixed = {
|
||||||
|
Date(year, 1, 1), Date(year, 5, 1), Date(year, 5, 8),
|
||||||
|
Date(year, 7, 5), Date(year, 7, 6), Date(year, 9, 28),
|
||||||
|
Date(year, 10, 28), Date(year, 11, 17),
|
||||||
|
Date(year, 12, 24), Date(year, 12, 25), Date(year, 12, 26),
|
||||||
|
}
|
||||||
|
a = year % 19; b = year // 100; c = year % 100
|
||||||
|
d = b // 4; e = b % 4
|
||||||
|
f = (b + 8) // 25; g = (b - f + 1) // 3
|
||||||
|
h = (19 * a + b - d - g + 15) % 30
|
||||||
|
i = c // 4; k = c % 4
|
||||||
|
l = (32 + 2 * e + 2 * i - h - k) % 7
|
||||||
|
m = (a + 11 * h + 22 * l) // 451
|
||||||
|
mo = (h + l - 7 * m + 114) // 31
|
||||||
|
dy = ((h + l - 7 * m + 114) % 31) + 1
|
||||||
|
easter = Date(year, mo, dy)
|
||||||
|
fixed.add(easter - timedelta(days=2)) # Good Friday
|
||||||
|
fixed.add(easter + timedelta(days=1)) # Easter Monday
|
||||||
|
return fixed
|
||||||
|
|
||||||
|
|
||||||
|
def map_tkb_code(value):
|
||||||
|
"""Return attendance code(s) for a TKB person's cell value, or None to skip."""
|
||||||
|
v = (value or '').strip()
|
||||||
|
if not v:
|
||||||
|
return None
|
||||||
|
if v == 'A':
|
||||||
|
return 'R12'
|
||||||
|
if v == 'B':
|
||||||
|
return 'N12'
|
||||||
|
if v == '8':
|
||||||
|
return 'R'
|
||||||
|
if v == '12':
|
||||||
|
return 'R12'
|
||||||
|
if v == 'D':
|
||||||
|
return 'D12' # vacation → 12h day-shift code in CIS
|
||||||
|
# U (closure), N/O (absences), x (no-experience), other letters → skip.
|
||||||
|
if v in ('U', 'N', 'O', 'x'):
|
||||||
|
return None
|
||||||
|
# Unknown numeric or letter — skip silently to avoid breaking import.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def map_it_code(value, is_weekend_or_holiday):
|
||||||
|
"""Return attendance code(s) for an IT person's cell value, or None to skip.
|
||||||
|
Only the explicit on-call indicators map; everything else (U, D, N, O, x,
|
||||||
|
A, B, …) is skipped — those are absences or shift markers handled outside
|
||||||
|
the attendance import."""
|
||||||
|
v = (value or '').strip()
|
||||||
|
if v == '24':
|
||||||
|
return 'POH_B3'
|
||||||
|
if v == '16':
|
||||||
|
return 'POH_B1,R,POH_B2'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compute_person_fpd(person, month_days, holidays, daily_hours):
|
||||||
|
"""Per-person FPD target — mirrors the web Kontrola FPD check:
|
||||||
|
workDays × daily_hours, minus D/D2 vacation that falls on a working day.
|
||||||
|
(N sick is credited as a worked day in the check, so it does NOT reduce
|
||||||
|
the target here.)"""
|
||||||
|
work_days = sum(1 for d in month_days
|
||||||
|
if not d.get('weekend')
|
||||||
|
and Date(d['year'], d['month'], d['day']) not in holidays)
|
||||||
|
vacation = 0.0
|
||||||
|
for d in month_days:
|
||||||
|
cell = person['data'].get(str(d['idx'])) or {}
|
||||||
|
is_work_day = (not d.get('weekend')
|
||||||
|
and Date(d['year'], d['month'], d['day']) not in holidays)
|
||||||
|
if not is_work_day:
|
||||||
|
continue
|
||||||
|
for v in (cell.get('dayValue'), cell.get('nightValue')) if (
|
||||||
|
cell.get('dayValue') or cell.get('nightValue')
|
||||||
|
or cell.get('dayColor') or cell.get('nightColor')) else (cell.get('v'),):
|
||||||
|
if v == 'D':
|
||||||
|
vacation += daily_hours
|
||||||
|
elif v == 'D/2':
|
||||||
|
vacation += daily_hours / 2
|
||||||
|
fpd = work_days * daily_hours - vacation
|
||||||
|
return int(fpd) if float(fpd).is_integer() else fpd
|
||||||
|
|
||||||
|
|
||||||
|
def build_workbook(schedule, month, year):
|
||||||
|
holidays = get_czech_holidays(year)
|
||||||
|
day_index = schedule['dayIndex']
|
||||||
|
people = schedule['people']
|
||||||
|
|
||||||
|
month_days = [d for d in day_index if d['month'] == month and d['year'] == year]
|
||||||
|
month_days.sort(key=lambda d: d['day'])
|
||||||
|
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = 'Harmonogram'
|
||||||
|
|
||||||
|
# ── Header row: dates in YYYY-MM-DD as strings (matches sample) ──
|
||||||
|
for col_offset, d in enumerate(month_days):
|
||||||
|
col = 3 + col_offset # C, D, E, …
|
||||||
|
ws.cell(row=1, column=col,
|
||||||
|
value=f"{d['year']:04d}-{d['month']:02d}-{d['day']:02d}")
|
||||||
|
|
||||||
|
# ── Trailing FPD column (right after the last day) ──
|
||||||
|
fpd_col = 3 + len(month_days)
|
||||||
|
ws.cell(row=1, column=fpd_col, value='FPD')
|
||||||
|
work_days = sum(1 for d in month_days
|
||||||
|
if not d.get('weekend')
|
||||||
|
and Date(d['year'], d['month'], d['day']) not in holidays)
|
||||||
|
month_fpd = work_days * 8 # flat monthly work-time fund, same for every row
|
||||||
|
|
||||||
|
# ── Build name → person lookup from schedule data ──
|
||||||
|
by_name = {p['name']: p for p in people}
|
||||||
|
|
||||||
|
def person_has_month_data(p):
|
||||||
|
if not p: return False
|
||||||
|
for d in month_days:
|
||||||
|
v = (p['data'].get(str(d['idx'])) or {}).get('v', '')
|
||||||
|
if v: return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
# ── Write roster rows in fixed sample order ──
|
||||||
|
# Retired people are skipped for months where they have no data (past-month
|
||||||
|
# exports still include them because their cells are preserved).
|
||||||
|
row = 2
|
||||||
|
for display, group, pid, lookup_name in ROSTER:
|
||||||
|
person = by_name.get(lookup_name)
|
||||||
|
if person and person.get('retired') and not person_has_month_data(person):
|
||||||
|
continue # retired + no data this month → omit row entirely
|
||||||
|
ws.cell(row=row, column=1, value=display)
|
||||||
|
ws.cell(row=row, column=2, value=str(pid)) # MUST be string — attendance import does getString()
|
||||||
|
ws.cell(row=row, column=fpd_col, value=month_fpd)
|
||||||
|
if not person:
|
||||||
|
row += 1
|
||||||
|
continue # roster name not found in this schedule — leave row empty
|
||||||
|
for col_offset, d in enumerate(month_days):
|
||||||
|
col = 3 + col_offset
|
||||||
|
raw = (person['data'].get(str(d['idx'])) or {}).get('v', '')
|
||||||
|
if group == 'TKB':
|
||||||
|
code = map_tkb_code(raw)
|
||||||
|
else: # IT
|
||||||
|
is_we = d.get('weekend') or Date(d['year'], d['month'], d['day']) in holidays
|
||||||
|
code = map_it_code(raw, is_we)
|
||||||
|
if code:
|
||||||
|
ws.cell(row=row, column=col, value=code)
|
||||||
|
row += 1
|
||||||
|
|
||||||
|
# Set column A wider for readability
|
||||||
|
ws.column_dimensions['A'].width = 28
|
||||||
|
ws.column_dimensions['B'].width = 8
|
||||||
|
|
||||||
|
# ── Definice sheet ──
|
||||||
|
df = wb.create_sheet('Definice')
|
||||||
|
df.append(['Klíč', 'Klíč K2', 'Od', '24+', 'Do', '24+', 'Typ'])
|
||||||
|
for r in DEFINICE_ROWS:
|
||||||
|
df.append(list(r))
|
||||||
|
df.column_dimensions['A'].width = 10
|
||||||
|
df.column_dimensions['B'].width = 18
|
||||||
|
|
||||||
|
return wb
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
inp = json.loads(sys.stdin.buffer.read())
|
||||||
|
wb = build_workbook(inp['schedule'], inp['month'], inp['year'])
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
sys.stdout.buffer.write(buf.getvalue())
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
BIN
web/public/full_template.xlsx
Normal file
BIN
web/public/full_template.xlsx
Normal file
Binary file not shown.
218
web/server.js
218
web/server.js
@@ -46,12 +46,7 @@ function listScheduleFiles() {
|
|||||||
for (const f of files) {
|
for (const f of files) {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(readFileSync(join(SCHEDULES_DIR, f), 'utf-8'))
|
const raw = JSON.parse(readFileSync(join(SCHEDULES_DIR, f), 'utf-8'))
|
||||||
result.push({
|
result.push(fileMeta(raw))
|
||||||
id: raw.id,
|
|
||||||
name: raw.name,
|
|
||||||
createdAt: raw.createdAt,
|
|
||||||
modifiedAt: raw.modifiedAt,
|
|
||||||
})
|
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
result.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt))
|
result.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt))
|
||||||
@@ -69,6 +64,54 @@ function saveScheduleFile(fileObj) {
|
|||||||
writeFileSync(join(SCHEDULES_DIR, `${fileObj.id}.json`), JSON.stringify(fileObj, null, 2), 'utf-8')
|
writeFileSync(join(SCHEDULES_DIR, `${fileObj.id}.json`), JSON.stringify(fileObj, null, 2), 'utf-8')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Draft vs. published ---
|
||||||
|
// file.data = working copy (editor's draft, auto-saved)
|
||||||
|
// file.published = snapshot viewers see; updated only by POST /api/files/:id/publish
|
||||||
|
|
||||||
|
function ensurePublished(file) {
|
||||||
|
// Files created before publishing existed: their current data becomes the published snapshot
|
||||||
|
if (!file.published) {
|
||||||
|
file.published = JSON.parse(JSON.stringify(file.data))
|
||||||
|
file.publishedAt = file.modifiedAt || new Date().toISOString()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasUnpublishedChanges(file) {
|
||||||
|
if (!file.published) return true
|
||||||
|
return JSON.stringify(file.data) !== JSON.stringify(file.published)
|
||||||
|
}
|
||||||
|
|
||||||
|
function fileMeta(file) {
|
||||||
|
return {
|
||||||
|
id: file.id,
|
||||||
|
name: file.name,
|
||||||
|
createdAt: file.createdAt,
|
||||||
|
modifiedAt: file.modifiedAt,
|
||||||
|
publishedAt: file.publishedAt ?? null,
|
||||||
|
hasUnpublishedChanges: hasUnpublishedChanges(file),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// People renamed after data already existed: fix the name wherever it is stored
|
||||||
|
// (files on disk at startup, and every incoming save from a client that still has the old name)
|
||||||
|
const PERSON_RENAMES = { 'Kropáček Ondřej': 'Kópáček Ondřej' }
|
||||||
|
|
||||||
|
function normalizePeople(data) {
|
||||||
|
let changed = false
|
||||||
|
for (const p of (data && data.people) || []) {
|
||||||
|
const fixed = PERSON_RENAMES[p.name]
|
||||||
|
if (fixed) { p.name = fixed; changed = true }
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick draft or published data for a request (?view=published)
|
||||||
|
function pickData(file, view) {
|
||||||
|
return view === 'published' && file.published ? file.published : file.data
|
||||||
|
}
|
||||||
|
|
||||||
function getMostRecentFile() {
|
function getMostRecentFile() {
|
||||||
const files = listScheduleFiles()
|
const files = listScheduleFiles()
|
||||||
return files.length > 0 ? files[0] : null
|
return files.length > 0 ? files[0] : null
|
||||||
@@ -97,6 +140,18 @@ function migrateIfNeeded() {
|
|||||||
|
|
||||||
migrateIfNeeded()
|
migrateIfNeeded()
|
||||||
|
|
||||||
|
// Give every existing file a published snapshot (one-time, idempotent)
|
||||||
|
for (const f of listScheduleFiles()) {
|
||||||
|
const file = loadScheduleFile(f.id)
|
||||||
|
if (!file) continue
|
||||||
|
let dirty = false
|
||||||
|
if (ensurePublished(file)) { dirty = true; console.log(`Published snapshot initialised for ${file.name} (${file.id})`) }
|
||||||
|
const renamedDraft = normalizePeople(file.data)
|
||||||
|
const renamedPublished = normalizePeople(file.published)
|
||||||
|
if (renamedDraft || renamedPublished) { dirty = true; console.log(`People renamed in ${file.name} (${file.id})`) }
|
||||||
|
if (dirty) saveScheduleFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
// --- Diff computation ---
|
// --- Diff computation ---
|
||||||
|
|
||||||
function computeDiff(file1, file2) {
|
function computeDiff(file1, file2) {
|
||||||
@@ -187,16 +242,111 @@ app.get('/api/files/:id', (req, res) => {
|
|||||||
try {
|
try {
|
||||||
const file = loadScheduleFile(req.params.id)
|
const file = loadScheduleFile(req.params.id)
|
||||||
if (!file) return res.status(404).json({ error: 'File not found' })
|
if (!file) return res.status(404).json({ error: 'File not found' })
|
||||||
res.json(file)
|
const view = req.query.view === 'published' ? 'published' : 'draft'
|
||||||
|
res.json({ ...fileMeta(file), view, data: pickData(file, view) })
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error loading file:', err)
|
console.error('Error loading file:', err)
|
||||||
res.status(500).json({ error: 'Failed to load file' })
|
res.status(500).json({ error: 'Failed to load file' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Export file as Excel (not yet implemented for TKB format)
|
// Export full schedule Excel for a given month
|
||||||
app.get('/api/files/:id/export-excel', (_req, res) => {
|
app.get('/api/files/:id/export-excel', async (req, res) => {
|
||||||
res.status(501).json({ error: 'Excel export not yet implemented for TKB format' })
|
const { id } = req.params
|
||||||
|
const month = parseInt(req.query.month) || (new Date().getMonth() + 1)
|
||||||
|
const year = parseInt(req.query.year) || new Date().getFullYear()
|
||||||
|
|
||||||
|
const filePath = join(SCHEDULES_DIR, `${id}.json`)
|
||||||
|
if (!existsSync(filePath)) return res.status(404).json({ error: 'File not found' })
|
||||||
|
|
||||||
|
let fileData
|
||||||
|
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
|
||||||
|
catch { return res.status(500).json({ error: 'Failed to read file' }) }
|
||||||
|
|
||||||
|
const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year })
|
||||||
|
const scriptPath = join(__dirname, 'export_full_excel.py')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { spawn } = await import('child_process')
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const chunks = []
|
||||||
|
const errChunks = []
|
||||||
|
const proc = spawn('python3', [scriptPath], { cwd: __dirname })
|
||||||
|
proc.stdout.on('data', d => chunks.push(d))
|
||||||
|
proc.stderr.on('data', d => errChunks.push(d))
|
||||||
|
proc.on('close', code => {
|
||||||
|
if (code !== 0) {
|
||||||
|
const errMsg = Buffer.concat(errChunks).toString()
|
||||||
|
console.error('export_full_excel error:', errMsg)
|
||||||
|
res.status(500).json({ error: errMsg })
|
||||||
|
reject(new Error(errMsg))
|
||||||
|
} else {
|
||||||
|
const xlsx = Buffer.concat(chunks)
|
||||||
|
const MONTH_NAMES = ['','Leden','Únor','Březen','Duben','Květen','Červen','Červenec','Srpen','Září','Říjen','Listopad','Prosinec']
|
||||||
|
const mm = String(month).padStart(2, '0')
|
||||||
|
const fname = `Pohotovost_TKB+IT_smeny_${year}_${mm}_${MONTH_NAMES[month]}.xlsx`
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fname)}`)
|
||||||
|
res.send(xlsx)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
proc.on('error', reject)
|
||||||
|
proc.stdin.write(input)
|
||||||
|
proc.stdin.end()
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: String(err) })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Export harmonogram Excel for a given month (import into attendance system)
|
||||||
|
app.get('/api/files/:id/export-harmonogram', async (req, res) => {
|
||||||
|
const { id } = req.params
|
||||||
|
const month = parseInt(req.query.month) || (new Date().getMonth() + 1)
|
||||||
|
const year = parseInt(req.query.year) || new Date().getFullYear()
|
||||||
|
|
||||||
|
const filePath = join(SCHEDULES_DIR, `${id}.json`)
|
||||||
|
if (!existsSync(filePath)) return res.status(404).json({ error: 'File not found' })
|
||||||
|
|
||||||
|
let fileData
|
||||||
|
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
|
||||||
|
catch { return res.status(500).json({ error: 'Failed to read file' }) }
|
||||||
|
|
||||||
|
const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year })
|
||||||
|
const scriptPath = join(__dirname, 'export_harmonogram.py')
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { spawn } = await import('child_process')
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const chunks = []
|
||||||
|
const errChunks = []
|
||||||
|
const proc = spawn('python3', [scriptPath], { cwd: __dirname })
|
||||||
|
proc.stdout.on('data', d => chunks.push(d))
|
||||||
|
proc.stderr.on('data', d => errChunks.push(d))
|
||||||
|
proc.on('close', code => {
|
||||||
|
if (code !== 0) {
|
||||||
|
const errMsg = Buffer.concat(errChunks).toString()
|
||||||
|
console.error('export_harmonogram error:', errMsg)
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: errMsg })
|
||||||
|
reject(new Error(errMsg))
|
||||||
|
} else {
|
||||||
|
const xlsx = Buffer.concat(chunks)
|
||||||
|
const mm = String(month).padStart(2, '0')
|
||||||
|
const fname = `import_harmonogram_${year}_${mm}.xlsx`
|
||||||
|
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||||
|
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fname)}`)
|
||||||
|
res.send(xlsx)
|
||||||
|
resolve()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
proc.on('error', reject)
|
||||||
|
proc.stdin.write(input)
|
||||||
|
proc.stdin.end()
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
if (!res.headersSent) res.status(500).json({ error: 'Export failed', details: err.message })
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Export docházka Excel for a given month
|
// Export docházka Excel for a given month
|
||||||
@@ -212,7 +362,7 @@ app.get('/api/files/:id/export-dochazka', async (req, res) => {
|
|||||||
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
|
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
|
||||||
catch { return res.status(500).json({ error: 'Failed to read file' }) }
|
catch { return res.status(500).json({ error: 'Failed to read file' }) }
|
||||||
|
|
||||||
const input = JSON.stringify({ schedule: fileData.data, month, year })
|
const input = JSON.stringify({ schedule: pickData(fileData, req.query.view), month, year })
|
||||||
const scriptPath = join(__dirname, 'export_dochazka.py')
|
const scriptPath = join(__dirname, 'export_dochazka.py')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -255,6 +405,7 @@ app.post('/api/files', (req, res) => {
|
|||||||
if (!data || (!data.people && !data.stations) || !data.dayIndex) {
|
if (!data || (!data.people && !data.stations) || !data.dayIndex) {
|
||||||
return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' })
|
return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' })
|
||||||
}
|
}
|
||||||
|
normalizePeople(data)
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const fileObj = {
|
const fileObj = {
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
@@ -262,14 +413,11 @@ app.post('/api/files', (req, res) => {
|
|||||||
createdAt: now,
|
createdAt: now,
|
||||||
modifiedAt: now,
|
modifiedAt: now,
|
||||||
data,
|
data,
|
||||||
|
published: JSON.parse(JSON.stringify(data)),
|
||||||
|
publishedAt: now,
|
||||||
}
|
}
|
||||||
saveScheduleFile(fileObj)
|
saveScheduleFile(fileObj)
|
||||||
res.json({
|
res.json(fileMeta(fileObj))
|
||||||
id: fileObj.id,
|
|
||||||
name: fileObj.name,
|
|
||||||
createdAt: fileObj.createdAt,
|
|
||||||
modifiedAt: fileObj.modifiedAt,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error creating file:', err)
|
console.error('Error creating file:', err)
|
||||||
res.status(500).json({ error: 'Failed to create file' })
|
res.status(500).json({ error: 'Failed to create file' })
|
||||||
@@ -288,23 +436,47 @@ app.put('/api/files/:id', (req, res) => {
|
|||||||
if ((!data.people && !data.stations) || !data.dayIndex) {
|
if ((!data.people && !data.stations) || !data.dayIndex) {
|
||||||
return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' })
|
return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' })
|
||||||
}
|
}
|
||||||
|
normalizePeople(data)
|
||||||
file.data = data
|
file.data = data
|
||||||
}
|
}
|
||||||
file.modifiedAt = new Date().toISOString()
|
file.modifiedAt = new Date().toISOString()
|
||||||
saveScheduleFile(file)
|
saveScheduleFile(file)
|
||||||
|
|
||||||
res.json({
|
res.json(fileMeta(file))
|
||||||
id: file.id,
|
|
||||||
name: file.name,
|
|
||||||
createdAt: file.createdAt,
|
|
||||||
modifiedAt: file.modifiedAt,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error updating file:', err)
|
console.error('Error updating file:', err)
|
||||||
res.status(500).json({ error: 'Failed to update file' })
|
res.status(500).json({ error: 'Failed to update file' })
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Publish: optionally save the given draft, then make it the version viewers see
|
||||||
|
app.post('/api/files/:id/publish', (req, res) => {
|
||||||
|
try {
|
||||||
|
const file = loadScheduleFile(req.params.id)
|
||||||
|
if (!file) return res.status(404).json({ error: 'File not found' })
|
||||||
|
|
||||||
|
const now = new Date().toISOString()
|
||||||
|
const { data } = req.body || {}
|
||||||
|
if (data) {
|
||||||
|
if ((!data.people && !data.stations) || !data.dayIndex) {
|
||||||
|
return res.status(400).json({ error: 'Invalid schedule data (need people/stations and dayIndex)' })
|
||||||
|
}
|
||||||
|
normalizePeople(data)
|
||||||
|
file.data = data
|
||||||
|
file.modifiedAt = now
|
||||||
|
}
|
||||||
|
file.published = JSON.parse(JSON.stringify(file.data))
|
||||||
|
file.publishedAt = now
|
||||||
|
saveScheduleFile(file)
|
||||||
|
console.log(`Published ${file.name} (${file.id}) at ${now}`)
|
||||||
|
|
||||||
|
res.json(fileMeta(file))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error publishing file:', err)
|
||||||
|
res.status(500).json({ error: 'Failed to publish file' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Delete file
|
// Delete file
|
||||||
app.delete('/api/files/:id', (req, res) => {
|
app.delete('/api/files/:id', (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
349
web/src/App.tsx
349
web/src/App.tsx
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
|
||||||
import { ScheduleTable } from './ScheduleTable'
|
import { ScheduleTable } from './ScheduleTable'
|
||||||
import type { SelectedCell } from './ScheduleTable'
|
import type { SelectedCell } from './ScheduleTable'
|
||||||
import { Toolbar } from './Toolbar'
|
import { Toolbar } from './Toolbar'
|
||||||
@@ -24,6 +24,14 @@ export function setFavouriteFileId(id: string | null): void {
|
|||||||
else localStorage.removeItem(FAVOURITE_FILE_KEY)
|
else localStorage.removeItem(FAVOURITE_FILE_KEY)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Characteristic colors for info rows — used when pasting from info row to person cell
|
||||||
|
const INFO_ROW_COLORS: Record<string, string> = {
|
||||||
|
'__tunnel__': '#c2410c',
|
||||||
|
'__metro__': '#1d4ed8',
|
||||||
|
'__sazlt__': '#b45309',
|
||||||
|
'__d8__': '#15803d',
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeDayIndex(data: ScheduleData): ScheduleData {
|
function normalizeDayIndex(data: ScheduleData): ScheduleData {
|
||||||
const targetStart = new Date(2026, 0, 1) // January 1
|
const targetStart = new Date(2026, 0, 1) // January 1
|
||||||
const targetEnd = new Date(2026, 11, 31) // December 31
|
const targetEnd = new Date(2026, 11, 31) // December 31
|
||||||
@@ -95,11 +103,10 @@ function getISOWeek(date: Date): number {
|
|||||||
type AppMode = 'login' | 'files' | 'editor'
|
type AppMode = 'login' | 'files' | 'editor'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
// Apply theme from localStorage to <body> on every render of the app.
|
// Apply persisted theme on initial mount (before first render of inner components)
|
||||||
// Theme state lives inside ScheduleApp; this just applies the persisted value.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const t = (localStorage.getItem('tkb-theme') as 'dark' | 'light') || 'dark'
|
const t = localStorage.getItem('tkb-theme')
|
||||||
document.body.classList.toggle('light', t === 'light')
|
if (t === 'light') document.body.classList.add('light')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// Auto-reload when server restarts with a new version (new deploy)
|
// Auto-reload when server restarts with a new version (new deploy)
|
||||||
@@ -122,24 +129,31 @@ function App() {
|
|||||||
const [authed, setAuthed] = useState(isAuthenticated())
|
const [authed, setAuthed] = useState(isAuthenticated())
|
||||||
const [role, setRole] = useState<UserRole>(() => isAuthenticated() ? getAuthRole() : 'editor')
|
const [role, setRole] = useState<UserRole>(() => isAuthenticated() ? getAuthRole() : 'editor')
|
||||||
const isViewer = role === 'viewer'
|
const isViewer = role === 'viewer'
|
||||||
|
const isCis = role === 'cis'
|
||||||
|
const isReadOnlyRole = isViewer || isCis // cis has same UI rights as viewer
|
||||||
const hasFavOnLoad = authed && !!getFavouriteFileId()
|
const hasFavOnLoad = authed && !!getFavouriteFileId()
|
||||||
|
// Viewer/cis with no favourite: show loading while we auto-fetch the active file
|
||||||
|
const viewerAutoLoad = authed && isReadOnlyRole && !hasFavOnLoad
|
||||||
const [mode, setMode] = useState<AppMode>(authed ? (hasFavOnLoad ? 'editor' : 'files') : 'login')
|
const [mode, setMode] = useState<AppMode>(authed ? (hasFavOnLoad ? 'editor' : 'files') : 'login')
|
||||||
const [fileId, setFileId] = useState<string | null>(null)
|
const [fileId, setFileId] = useState<string | null>(null)
|
||||||
const [fileName, setFileName] = useState<string>('')
|
const [fileName, setFileName] = useState<string>('')
|
||||||
const [fileData, setFileData] = useState<ScheduleData | null>(null)
|
const [fileData, setFileData] = useState<ScheduleData | null>(null)
|
||||||
const [loading, setLoading] = useState(hasFavOnLoad)
|
const [fileMeta, setFileMeta] = useState<{ publishedAt: string | null; hasUnpublishedChanges: boolean }>({ publishedAt: null, hasUnpublishedChanges: false })
|
||||||
|
const [loading, setLoading] = useState(hasFavOnLoad || viewerAutoLoad)
|
||||||
const [compareFileId, setCompareFileId] = useState<string | null>(null)
|
const [compareFileId, setCompareFileId] = useState<string | null>(null)
|
||||||
const autoOpenAttempted = useRef(false)
|
const autoOpenAttempted = useRef(false)
|
||||||
|
|
||||||
|
// Viewers/cis only ever see the published snapshot; editors work on the draft
|
||||||
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
|
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/files/${id}`)
|
const res = await fetch(`/api/files/${id}${isReadOnlyRole ? '?view=published' : ''}`)
|
||||||
if (!res.ok) throw new Error('Failed to load file')
|
if (!res.ok) throw new Error('Failed to load file')
|
||||||
const file: ScheduleFileWithData = await res.json()
|
const file: ScheduleFileWithData = await res.json()
|
||||||
setFileId(file.id)
|
setFileId(file.id)
|
||||||
setFileName(file.name)
|
setFileName(file.name)
|
||||||
setFileData(normalizeDayIndex(file.data))
|
setFileData(normalizeDayIndex(file.data))
|
||||||
|
setFileMeta({ publishedAt: file.publishedAt ?? null, hasUnpublishedChanges: !!file.hasUnpublishedChanges })
|
||||||
setCompareFileId(compareId ?? null)
|
setCompareFileId(compareId ?? null)
|
||||||
setMode('editor')
|
setMode('editor')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -148,20 +162,55 @@ function App() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [isReadOnlyRole])
|
||||||
|
|
||||||
// Auto-open favourite file when authenticated
|
// Viewers: pick up a new publish automatically (poll metadata, reload when publishedAt changes)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReadOnlyRole || mode !== 'editor' || !fileId) return
|
||||||
|
const check = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/files')
|
||||||
|
if (!res.ok) return
|
||||||
|
const files: { id: string; publishedAt?: string | null }[] = await res.json()
|
||||||
|
const me = files.find(f => f.id === fileId)
|
||||||
|
if (me && (me.publishedAt ?? null) !== fileMeta.publishedAt) handleOpenFile(fileId)
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
const interval = setInterval(check, 60_000)
|
||||||
|
return () => clearInterval(interval)
|
||||||
|
}, [isReadOnlyRole, mode, fileId, fileMeta.publishedAt, handleOpenFile])
|
||||||
|
|
||||||
|
// Auto-open favourite file when authenticated; for viewers with no favourite, auto-open most recent
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authed || autoOpenAttempted.current) return
|
if (!authed || autoOpenAttempted.current) return
|
||||||
autoOpenAttempted.current = true
|
autoOpenAttempted.current = true
|
||||||
const fav = getFavouriteFileId()
|
const fav = getFavouriteFileId()
|
||||||
if (fav) handleOpenFile(fav)
|
if (fav) {
|
||||||
}, [authed, handleOpenFile])
|
handleOpenFile(fav)
|
||||||
|
} else if (isReadOnlyRole) {
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/files')
|
||||||
|
if (!res.ok) throw new Error('no files')
|
||||||
|
const files: { id: string; name: string }[] = await res.json()
|
||||||
|
if (files.length > 0) {
|
||||||
|
setFavouriteFileId(files[0].id)
|
||||||
|
handleOpenFile(files[0].id)
|
||||||
|
} else {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
}
|
||||||
|
}, [authed, isReadOnlyRole, handleOpenFile])
|
||||||
|
|
||||||
const handleLogin = useCallback((newRole: UserRole) => {
|
const handleLogin = useCallback((newRole: UserRole) => {
|
||||||
autoOpenAttempted.current = false
|
autoOpenAttempted.current = false
|
||||||
setRole(newRole)
|
setRole(newRole)
|
||||||
setAuthed(true)
|
setAuthed(true)
|
||||||
|
setMode('files')
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleLogout = useCallback(() => {
|
const handleLogout = useCallback(() => {
|
||||||
@@ -221,7 +270,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'files') {
|
if (mode === 'files') {
|
||||||
if (isViewer) {
|
if (isReadOnlyRole) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
|
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
@@ -237,12 +286,18 @@ function App() {
|
|||||||
if (mode === 'editor' && fileData && fileId) {
|
if (mode === 'editor' && fileData && fileId) {
|
||||||
return (
|
return (
|
||||||
<ScheduleApp
|
<ScheduleApp
|
||||||
|
// Remount viewers on a new publish so the grid shows the fresh snapshot
|
||||||
|
key={isReadOnlyRole ? `${fileId}:${fileMeta.publishedAt ?? ''}` : fileId}
|
||||||
fileId={fileId}
|
fileId={fileId}
|
||||||
|
publishedAt={fileMeta.publishedAt}
|
||||||
|
hasUnpublishedChanges={fileMeta.hasUnpublishedChanges}
|
||||||
|
viewPublished={isReadOnlyRole}
|
||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
data={fileData}
|
data={fileData}
|
||||||
compareFileId={compareFileId}
|
compareFileId={compareFileId}
|
||||||
onBack={isViewer ? undefined : handleBackToFiles}
|
onBack={isReadOnlyRole ? undefined : handleBackToFiles}
|
||||||
isReadOnly={isViewer}
|
isReadOnly={isReadOnlyRole}
|
||||||
|
canExportHarmonogram={!isViewer}
|
||||||
onFileNameChange={setFileName}
|
onFileNameChange={setFileName}
|
||||||
onClearCompare={() => setCompareFileId(null)}
|
onClearCompare={() => setCompareFileId(null)}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
@@ -298,15 +353,22 @@ interface ScheduleAppProps {
|
|||||||
onFileNameChange: (name: string) => void
|
onFileNameChange: (name: string) => void
|
||||||
onClearCompare: () => void
|
onClearCompare: () => void
|
||||||
isReadOnly?: boolean
|
isReadOnly?: boolean
|
||||||
|
canExportHarmonogram?: boolean
|
||||||
onLogout?: () => void
|
onLogout?: () => void
|
||||||
|
publishedAt?: string | null
|
||||||
|
hasUnpublishedChanges?: boolean
|
||||||
|
viewPublished?: boolean // data shown is the published snapshot (viewer/cis)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, onLogout }: ScheduleAppProps) {
|
const AUTOSAVE_DELAY_MS = 5_000 // save this long after the last change
|
||||||
|
const AUTOSAVE_MAX_WAIT_MS = 30_000 // ...but never later than this after the first unsaved change
|
||||||
|
|
||||||
|
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, canExportHarmonogram = true, onLogout, publishedAt: initialPublishedAt = null, hasUnpublishedChanges: initialUnpublished = false, viewPublished = false }: ScheduleAppProps) {
|
||||||
const {
|
const {
|
||||||
people, tunnelClosures, tunnelColors,
|
people, tunnelClosures, tunnelColors,
|
||||||
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
|
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
|
||||||
dayComments, cellComments,
|
dayComments, cellComments,
|
||||||
setCell, setCellColor, moveCell, setTunnelClosure, setTunnelClosureColor,
|
setCell, setCellColor, setCellHalfColor, setCellHalfValue, pasteCellTo, moveCell, setTunnelClosure, setTunnelClosureColor,
|
||||||
setMetroClosure, setMetroClosureColor, setD8Closure, setD8ClosureColor,
|
setMetroClosure, setMetroClosureColor, setD8Closure, setD8ClosureColor,
|
||||||
setSazltClosure, setSazltClosureColor,
|
setSazltClosure, setSazltClosureColor,
|
||||||
undo, canUndo,
|
undo, canUndo,
|
||||||
@@ -317,6 +379,13 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
|
|
||||||
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null)
|
||||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||||
|
const [dirty, setDirty] = useState(false) // unsaved changes exist
|
||||||
|
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null)
|
||||||
|
const [publishedAt, setPublishedAt] = useState<string | null>(initialPublishedAt)
|
||||||
|
const [unpublished, setUnpublished] = useState(initialUnpublished)
|
||||||
|
const [publishStatus, setPublishStatus] = useState<'idle' | 'publishing' | 'published' | 'error'>('idle')
|
||||||
|
const dirtySinceRef = useRef<number | null>(null)
|
||||||
|
const savingRef = useRef(false)
|
||||||
const [selectedDenDays, setSelectedDenDays] = useState<number[]>([])
|
const [selectedDenDays, setSelectedDenDays] = useState<number[]>([])
|
||||||
const [currentFileId, setCurrentFileId] = useState<string | null>(fileId)
|
const [currentFileId, setCurrentFileId] = useState<string | null>(fileId)
|
||||||
const [compareData, setCompareData] = useState<ScheduleData | null>(null)
|
const [compareData, setCompareData] = useState<ScheduleData | null>(null)
|
||||||
@@ -400,12 +469,14 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
setSelectedCellsForMenu(selectedCells ?? [])
|
setSelectedCellsForMenu(selectedCells ?? [])
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleTunnelContextMenu = useCallback((dayIdx: number, x: number, y: number) => {
|
const handleTunnelContextMenu = useCallback((dayIdx: number, x: number, y: number, selectedCells: SelectedCell[] = []) => {
|
||||||
setContextMenu({ dayIdx, personId: null, x, y, isTunnelRow: true })
|
setContextMenu({ dayIdx, personId: null, x, y, isTunnelRow: true })
|
||||||
|
setSelectedCellsForMenu(selectedCells)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleInfoRowContextMenu = useCallback((dayIdx: number, infoRowId: string, x: number, y: number) => {
|
const handleInfoRowContextMenu = useCallback((dayIdx: number, infoRowId: string, x: number, y: number, selectedCells: SelectedCell[] = []) => {
|
||||||
setContextMenu({ dayIdx, personId: null, x, y, infoRowId })
|
setContextMenu({ dayIdx, personId: null, x, y, infoRowId })
|
||||||
|
setSelectedCellsForMenu(selectedCells)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleDenDaySelect = useCallback((dayIdx: number, shiftKey: boolean) => {
|
const handleDenDaySelect = useCallback((dayIdx: number, shiftKey: boolean) => {
|
||||||
@@ -428,7 +499,9 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
})
|
})
|
||||||
}, [data.dayIndex])
|
}, [data.dayIndex])
|
||||||
|
|
||||||
const handleSave = useCallback(async () => {
|
const handleSave = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (savingRef.current) return false
|
||||||
|
savingRef.current = true
|
||||||
setSaveStatus('saving')
|
setSaveStatus('saving')
|
||||||
try {
|
try {
|
||||||
const payload = getSchedulePayload()
|
const payload = getSchedulePayload()
|
||||||
@@ -447,14 +520,78 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
})
|
})
|
||||||
if (!res.ok) throw new Error('Save failed')
|
if (!res.ok) throw new Error('Save failed')
|
||||||
}
|
}
|
||||||
|
setDirty(false)
|
||||||
|
dirtySinceRef.current = null
|
||||||
|
setLastSavedAt(new Date())
|
||||||
setSaveStatus('saved')
|
setSaveStatus('saved')
|
||||||
setTimeout(() => setSaveStatus('idle'), 3000)
|
setTimeout(() => setSaveStatus('idle'), 3000)
|
||||||
|
return true
|
||||||
} catch {
|
} catch {
|
||||||
setSaveStatus('error')
|
setSaveStatus('error')
|
||||||
setTimeout(() => setSaveStatus('idle'), 4000)
|
setTimeout(() => setSaveStatus('idle'), 4000)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
savingRef.current = false
|
||||||
}
|
}
|
||||||
}, [getSchedulePayload, currentFileId])
|
}, [getSchedulePayload, currentFileId])
|
||||||
|
|
||||||
|
// Any state change (getSchedulePayload identity changes with every edit) marks the draft dirty
|
||||||
|
const firstPayloadRef = useRef(true)
|
||||||
|
useEffect(() => {
|
||||||
|
if (firstPayloadRef.current) { firstPayloadRef.current = false; return }
|
||||||
|
if (isReadOnly) return
|
||||||
|
if (dirtySinceRef.current === null) dirtySinceRef.current = Date.now()
|
||||||
|
setDirty(true)
|
||||||
|
setUnpublished(true)
|
||||||
|
}, [getSchedulePayload, isReadOnly])
|
||||||
|
|
||||||
|
// Auto-save: debounce after the last change, with an upper bound since the first unsaved change
|
||||||
|
const saveRef = useRef(handleSave)
|
||||||
|
useEffect(() => { saveRef.current = handleSave }, [handleSave])
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty || isReadOnly || !currentFileId) return
|
||||||
|
const since = dirtySinceRef.current ?? Date.now()
|
||||||
|
const wait = Math.max(0, Math.min(AUTOSAVE_DELAY_MS, since + AUTOSAVE_MAX_WAIT_MS - Date.now()))
|
||||||
|
const t = setTimeout(() => { saveRef.current() }, wait)
|
||||||
|
return () => clearTimeout(t)
|
||||||
|
}, [dirty, getSchedulePayload, isReadOnly, currentFileId])
|
||||||
|
|
||||||
|
// Retry a failed auto-save after a while; warn before leaving with unsaved changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!dirty || isReadOnly) return
|
||||||
|
const retry = setInterval(() => { if (!savingRef.current) saveRef.current() }, AUTOSAVE_MAX_WAIT_MS)
|
||||||
|
const onBeforeUnload = (e: BeforeUnloadEvent) => { e.preventDefault(); e.returnValue = '' }
|
||||||
|
window.addEventListener('beforeunload', onBeforeUnload)
|
||||||
|
return () => { clearInterval(retry); window.removeEventListener('beforeunload', onBeforeUnload) }
|
||||||
|
}, [dirty, isReadOnly])
|
||||||
|
|
||||||
|
// Publish: save the current draft and make it the version viewers see
|
||||||
|
const handlePublish = useCallback(async () => {
|
||||||
|
if (!currentFileId) return
|
||||||
|
if (!confirm('Publikovat aktuální stav plánu?\nUživatelé s náhledem (prohlížeč) uvidí tuto verzi.')) return
|
||||||
|
setPublishStatus('publishing')
|
||||||
|
try {
|
||||||
|
const payload = getSchedulePayload()
|
||||||
|
const res = await fetch(`/api/files/${currentFileId}/publish`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ data: payload }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error('Publish failed')
|
||||||
|
const meta = await res.json()
|
||||||
|
setPublishedAt(meta.publishedAt ?? new Date().toISOString())
|
||||||
|
setUnpublished(false)
|
||||||
|
setDirty(false)
|
||||||
|
dirtySinceRef.current = null
|
||||||
|
setLastSavedAt(new Date())
|
||||||
|
setPublishStatus('published')
|
||||||
|
setTimeout(() => setPublishStatus('idle'), 3000)
|
||||||
|
} catch {
|
||||||
|
setPublishStatus('error')
|
||||||
|
setTimeout(() => setPublishStatus('idle'), 4000)
|
||||||
|
}
|
||||||
|
}, [currentFileId, getSchedulePayload])
|
||||||
|
|
||||||
const handleSaveAs = useCallback(async () => {
|
const handleSaveAs = useCallback(async () => {
|
||||||
const name = prompt('Nazev noveho souboru:', fileName ? `${fileName} - kopie` : '')
|
const name = prompt('Nazev noveho souboru:', fileName ? `${fileName} - kopie` : '')
|
||||||
if (!name) return
|
if (!name) return
|
||||||
@@ -483,22 +620,115 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
const [showMetro, setShowMetro] = useState(true)
|
const [showMetro, setShowMetro] = useState(true)
|
||||||
const [showD8, setShowD8] = useState(true)
|
const [showD8, setShowD8] = useState(true)
|
||||||
const [showSazlt, setShowSazlt] = useState(true)
|
const [showSazlt, setShowSazlt] = useState(true)
|
||||||
|
const [showIT, setShowIT] = useState(true)
|
||||||
const [hide162, setHide162] = useState(false)
|
const [hide162, setHide162] = useState(false)
|
||||||
const [theme, setTheme] = useState<'dark' | 'light'>(() =>
|
const [theme, setTheme] = useState<'dark' | 'light'>(() =>
|
||||||
(localStorage.getItem('tkb-theme') as 'dark' | 'light') || 'dark'
|
(localStorage.getItem('tkb-theme') as 'dark' | 'light') || 'dark'
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.body.classList.toggle('light', theme === 'light')
|
document.body.classList.toggle('light', theme === 'light')
|
||||||
localStorage.setItem('tkb-theme', theme)
|
localStorage.setItem('tkb-theme', theme)
|
||||||
}, [theme])
|
}, [theme])
|
||||||
|
const [hiddenTkbPeople, setHiddenTkbPeople] = useState<Set<string>>(new Set())
|
||||||
const [hiddenValues, setHiddenValues] = useState<Set<string>>(new Set())
|
const [hiddenValues, setHiddenValues] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// Clipboard for cell copy/paste
|
||||||
|
const [clipboard, setClipboard] = useState<(import('./types').CellValue & { comment?: string }) | null>(null)
|
||||||
|
const [clipboardSource, setClipboardSource] = useState<{ personId: string; dayIdx: number } | null>(null)
|
||||||
|
|
||||||
|
// Info row helpers — get/set closure text + color by row id
|
||||||
|
const infoRowMaps = {
|
||||||
|
'__tunnel__': { closures: tunnelClosures, colors: tunnelColors, setText: setTunnelClosure, setColor: setTunnelClosureColor },
|
||||||
|
'__sazlt__': { closures: sazltClosures, colors: sazltColors, setText: setSazltClosure, setColor: setSazltClosureColor },
|
||||||
|
'__metro__': { closures: metroClosures, colors: metroColors, setText: setMetroClosure, setColor: setMetroClosureColor },
|
||||||
|
'__d8__': { closures: d8Closures, colors: d8Colors, setText: setD8Closure, setColor: setD8ClosureColor },
|
||||||
|
} as const
|
||||||
|
const isInfoRowKey = (k: string): k is keyof typeof infoRowMaps =>
|
||||||
|
k === '__tunnel__' || k === '__sazlt__' || k === '__metro__' || k === '__d8__'
|
||||||
|
|
||||||
|
const handleCopyCell = useCallback((key: string, dayIdx: number) => {
|
||||||
|
if (isInfoRowKey(key)) {
|
||||||
|
const m = infoRowMaps[key]
|
||||||
|
const text = m.closures.get(dayIdx)
|
||||||
|
const color = m.colors.get(dayIdx)
|
||||||
|
setClipboard({
|
||||||
|
...(text ? { v: text } : {}),
|
||||||
|
...(color ? { color } : {}),
|
||||||
|
})
|
||||||
|
setClipboardSource({ personId: key, dayIdx })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const person = people.find(p => p.id === key)
|
||||||
|
if (!person) return
|
||||||
|
const cellData = person.data[String(dayIdx)] ?? {}
|
||||||
|
const comment = cellComments.get(`${key}-${dayIdx}`)
|
||||||
|
setClipboard({ ...cellData, ...(comment ? { comment } : {}) })
|
||||||
|
setClipboardSource({ personId: key, dayIdx })
|
||||||
|
}, [people, cellComments, infoRowMaps])
|
||||||
|
|
||||||
|
const handlePasteCells = useCallback((targets: { personId: string; dayIdx: number }[]) => {
|
||||||
|
if (!clipboard || targets.length === 0) return
|
||||||
|
for (const t of targets) {
|
||||||
|
if (clipboardSource && clipboardSource.personId === t.personId && clipboardSource.dayIdx === t.dayIdx) continue
|
||||||
|
// When pasting from an info row and the clipboard has no explicit color,
|
||||||
|
// inject the source row's characteristic color so the destination keeps the visual identity.
|
||||||
|
const extraColor = (!clipboard.color && clipboardSource && isInfoRowKey(clipboardSource.personId))
|
||||||
|
? INFO_ROW_COLORS[clipboardSource.personId]
|
||||||
|
: undefined
|
||||||
|
if (isInfoRowKey(t.personId)) {
|
||||||
|
const m = infoRowMaps[t.personId]
|
||||||
|
m.setText(t.dayIdx, clipboard.v ?? null)
|
||||||
|
m.setColor(t.dayIdx, clipboard.color ?? extraColor ?? null)
|
||||||
|
} else {
|
||||||
|
pasteCellTo(t.personId, t.dayIdx, {
|
||||||
|
...clipboard,
|
||||||
|
...(extraColor ? { color: extraColor } : {}),
|
||||||
|
comment: clipboard.comment ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [clipboard, clipboardSource, pasteCellTo, infoRowMaps])
|
||||||
|
|
||||||
|
const handleClearClipboard = useCallback(() => {
|
||||||
|
setClipboard(null)
|
||||||
|
setClipboardSource(null)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Retired people are hidden from all UI/FPD surfaces but their data stays in
|
||||||
|
// the schedule file (past-month exports keep working).
|
||||||
|
const visiblePeople = useMemo(() => people.filter(p => !p.retired), [people])
|
||||||
|
|
||||||
|
const tkbPeopleList = useMemo(
|
||||||
|
() => visiblePeople.filter(p => p.group === 'TKB'),
|
||||||
|
[visiblePeople]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleToggleTkbPerson = useCallback((id: string) => {
|
||||||
|
setHiddenTkbPeople(prev => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(id)) next.delete(id); else next.add(id)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSetAllTkbVisible = useCallback(() => {
|
||||||
|
setHiddenTkbPeople(new Set())
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleSetOnlyTkbPerson = useCallback((id: string) => {
|
||||||
|
setHiddenTkbPeople(new Set(tkbPeopleList.filter(p => p.id !== id).map(p => p.id)))
|
||||||
|
}, [tkbPeopleList])
|
||||||
|
|
||||||
const handleExportPdf = useCallback(async (month: number) => {
|
const handleExportPdf = useCallback(async (month: number) => {
|
||||||
const { exportMonthPdf } = await import('./PdfExport')
|
const { exportMonthPdf } = await import('./PdfExport')
|
||||||
const year = data.dayIndex.find(d => d.month === month)?.year ?? new Date().getFullYear()
|
const year = data.dayIndex.find(d => d.month === month)?.year ?? new Date().getFullYear()
|
||||||
await exportMonthPdf(month, year, data.dayIndex, people, tunnelClosures, tunnelColors)
|
await exportMonthPdf(month, year, data.dayIndex, visiblePeople, tunnelClosures, tunnelColors, {
|
||||||
}, [data.dayIndex, people, tunnelClosures, tunnelColors])
|
hide162,
|
||||||
|
showIT,
|
||||||
|
hiddenTkbPeople,
|
||||||
|
hiddenValues,
|
||||||
|
})
|
||||||
|
}, [data.dayIndex, visiblePeople, tunnelClosures, tunnelColors, hide162, showIT, hiddenTkbPeople, hiddenValues])
|
||||||
|
|
||||||
const MONTH_NAMES_FULL: Record<number, string> = {
|
const MONTH_NAMES_FULL: Record<number, string> = {
|
||||||
1:'Leden',2:'Únor',3:'Březen',4:'Duben',5:'Květen',6:'Červen',
|
1:'Leden',2:'Únor',3:'Březen',4:'Duben',5:'Květen',6:'Červen',
|
||||||
@@ -516,6 +746,10 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
: undefined
|
: undefined
|
||||||
const contextCellValue = contextCellData?.v
|
const contextCellValue = contextCellData?.v
|
||||||
const contextCellColor = contextCellData?.color
|
const contextCellColor = contextCellData?.color
|
||||||
|
const contextCellDayColor = contextCellData?.dayColor
|
||||||
|
const contextCellNightColor = contextCellData?.nightColor
|
||||||
|
const contextCellDayValue = contextCellData?.dayValue
|
||||||
|
const contextCellNightValue = contextCellData?.nightValue
|
||||||
|
|
||||||
// Determine which info row closures/colors/handlers to pass to context menu
|
// Determine which info row closures/colors/handlers to pass to context menu
|
||||||
const contextInfoRowId = contextMenu?.infoRowId
|
const contextInfoRowId = contextMenu?.infoRowId
|
||||||
@@ -537,6 +771,24 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
else if (contextInfoRowId === 'sazlt') setSazltClosureColor(dayIdx, color)
|
else if (contextInfoRowId === 'sazlt') setSazltClosureColor(dayIdx, color)
|
||||||
}, [contextInfoRowId, setMetroClosureColor, setD8ClosureColor, setSazltClosureColor])
|
}, [contextInfoRowId, setMetroClosureColor, setD8ClosureColor, setSazltClosureColor])
|
||||||
|
|
||||||
|
// Unified color setter — routes to the correct setter based on row type (personId)
|
||||||
|
const handleSetAnyColor = useCallback((personId: string, dayIdx: number, color: string | null) => {
|
||||||
|
if (personId === '__tunnel__') setTunnelClosureColor(dayIdx, color)
|
||||||
|
else if (personId === '__metro__') setMetroClosureColor(dayIdx, color)
|
||||||
|
else if (personId === '__d8__') setD8ClosureColor(dayIdx, color)
|
||||||
|
else if (personId === '__sazlt__') setSazltClosureColor(dayIdx, color)
|
||||||
|
else setCellColor(personId, dayIdx, color)
|
||||||
|
}, [setTunnelClosureColor, setMetroClosureColor, setD8ClosureColor, setSazltClosureColor, setCellColor])
|
||||||
|
|
||||||
|
// Unified value setter — routes to the correct setter based on row type (personId)
|
||||||
|
const handleSetAnyValue = useCallback((personId: string, dayIdx: number, value: string | null) => {
|
||||||
|
if (personId === '__tunnel__') setTunnelClosure(dayIdx, value)
|
||||||
|
else if (personId === '__metro__') setMetroClosure(dayIdx, value)
|
||||||
|
else if (personId === '__d8__') setD8Closure(dayIdx, value)
|
||||||
|
else if (personId === '__sazlt__') setSazltClosure(dayIdx, value)
|
||||||
|
else setCell(personId, dayIdx, value)
|
||||||
|
}, [setTunnelClosure, setMetroClosure, setD8Closure, setSazltClosure, setCell])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-900">
|
<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">
|
<header className="border-b border-slate-700 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-50 px-6 py-4">
|
||||||
@@ -586,6 +838,12 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
onSave={handleSave}
|
onSave={handleSave}
|
||||||
onSaveAs={handleSaveAs}
|
onSaveAs={handleSaveAs}
|
||||||
saveStatus={saveStatus}
|
saveStatus={saveStatus}
|
||||||
|
dirty={dirty}
|
||||||
|
lastSavedAt={lastSavedAt}
|
||||||
|
onPublish={currentFileId && !isReadOnly ? handlePublish : undefined}
|
||||||
|
publishStatus={publishStatus}
|
||||||
|
publishedAt={publishedAt}
|
||||||
|
hasUnpublished={unpublished}
|
||||||
onScrollToMonth={handleScrollToMonth}
|
onScrollToMonth={handleScrollToMonth}
|
||||||
activeMonth={activeMonth}
|
activeMonth={activeMonth}
|
||||||
showMetro={showMetro}
|
showMetro={showMetro}
|
||||||
@@ -594,10 +852,12 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
onToggleD8={() => setShowD8(v => !v)}
|
onToggleD8={() => setShowD8(v => !v)}
|
||||||
showSazlt={showSazlt}
|
showSazlt={showSazlt}
|
||||||
onToggleSazlt={() => setShowSazlt(v => !v)}
|
onToggleSazlt={() => setShowSazlt(v => !v)}
|
||||||
hide162={hide162}
|
showIT={showIT}
|
||||||
onToggle162={() => setHide162(v => !v)}
|
onToggleIT={() => setShowIT(v => !v)}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
onToggleTheme={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
|
onToggleTheme={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
|
||||||
|
hide162={hide162}
|
||||||
|
onToggle162={() => setHide162(v => !v)}
|
||||||
hiddenValues={hiddenValues}
|
hiddenValues={hiddenValues}
|
||||||
onToggleValue={(code: string) => setHiddenValues(prev => {
|
onToggleValue={(code: string) => setHiddenValues(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -607,13 +867,22 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
diffFileName={compareFileName}
|
diffFileName={compareFileName}
|
||||||
onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }}
|
onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }}
|
||||||
onExportPdf={handleExportPdf}
|
onExportPdf={handleExportPdf}
|
||||||
|
onExportExcel={currentFileId ? (month: number) => {
|
||||||
|
const year = data.dayIndex.find(d => d.month === month)?.year ?? new Date().getFullYear()
|
||||||
|
window.location.href = `/api/files/${currentFileId}/export-excel?month=${month}&year=${year}${viewPublished ? '&view=published' : ''}`
|
||||||
|
} : undefined}
|
||||||
onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)}
|
onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)}
|
||||||
onCheckFpd={() => setShowFpd(true)}
|
onCheckFpd={() => setShowFpd(true)}
|
||||||
isReadOnly={isReadOnly}
|
isReadOnly={isReadOnly}
|
||||||
|
tkbPeopleList={tkbPeopleList}
|
||||||
|
hiddenTkbPeople={hiddenTkbPeople}
|
||||||
|
onToggleTkbPerson={handleToggleTkbPerson}
|
||||||
|
onSetAllTkbVisible={handleSetAllTkbVisible}
|
||||||
|
onSetOnlyTkbPerson={handleSetOnlyTkbPerson}
|
||||||
/>
|
/>
|
||||||
<ScheduleTable
|
<ScheduleTable
|
||||||
dayIndex={data.dayIndex}
|
dayIndex={data.dayIndex}
|
||||||
people={people}
|
people={visiblePeople}
|
||||||
tunnelClosures={tunnelClosures}
|
tunnelClosures={tunnelClosures}
|
||||||
tunnelColors={tunnelColors}
|
tunnelColors={tunnelColors}
|
||||||
metroClosures={metroClosures}
|
metroClosures={metroClosures}
|
||||||
@@ -627,6 +896,7 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
dragState={isReadOnly ? null : dragState}
|
dragState={isReadOnly ? null : dragState}
|
||||||
onCellPointerDown={isReadOnly ? () => {} : onCellPointerDown}
|
onCellPointerDown={isReadOnly ? () => {} : onCellPointerDown}
|
||||||
onSetCell={isReadOnly ? () => {} : setCell}
|
onSetCell={isReadOnly ? () => {} : setCell}
|
||||||
|
onSetCellHalfValue={isReadOnly ? () => {} : setCellHalfValue}
|
||||||
onSetTunnelClosure={isReadOnly ? () => {} : setTunnelClosure}
|
onSetTunnelClosure={isReadOnly ? () => {} : setTunnelClosure}
|
||||||
onSetMetroClosure={isReadOnly ? () => {} : setMetroClosure}
|
onSetMetroClosure={isReadOnly ? () => {} : setMetroClosure}
|
||||||
onSetD8Closure={isReadOnly ? () => {} : setD8Closure}
|
onSetD8Closure={isReadOnly ? () => {} : setD8Closure}
|
||||||
@@ -634,13 +904,21 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
showMetro={showMetro}
|
showMetro={showMetro}
|
||||||
showD8={showD8}
|
showD8={showD8}
|
||||||
showSazlt={showSazlt}
|
showSazlt={showSazlt}
|
||||||
|
showIT={showIT}
|
||||||
hide162={hide162}
|
hide162={hide162}
|
||||||
|
hiddenTkbPeople={hiddenTkbPeople}
|
||||||
hiddenValues={hiddenValues}
|
hiddenValues={hiddenValues}
|
||||||
scrollRef={scrollRef}
|
scrollRef={scrollRef}
|
||||||
onContextMenu={isReadOnly ? () => {} : handleContextMenu}
|
onContextMenu={isReadOnly ? () => {} : handleContextMenu}
|
||||||
onTunnelContextMenu={isReadOnly ? () => {} : handleTunnelContextMenu}
|
onTunnelContextMenu={isReadOnly ? () => {} : handleTunnelContextMenu}
|
||||||
onInfoRowContextMenu={isReadOnly ? () => {} : handleInfoRowContextMenu}
|
onInfoRowContextMenu={isReadOnly ? () => {} : handleInfoRowContextMenu}
|
||||||
compareData={compareData}
|
compareData={compareData}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
clipboardSource={clipboardSource}
|
||||||
|
hasClipboard={!!clipboard}
|
||||||
|
onCopyCell={isReadOnly ? () => {} : handleCopyCell}
|
||||||
|
onPasteCells={isReadOnly ? () => {} : handlePasteCells}
|
||||||
|
onClearClipboard={handleClearClipboard}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
@@ -651,6 +929,10 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
personName={contextPerson?.name || null}
|
personName={contextPerson?.name || null}
|
||||||
cellValue={contextCellValue}
|
cellValue={contextCellValue}
|
||||||
cellColor={contextCellColor}
|
cellColor={contextCellColor}
|
||||||
|
cellDayColor={contextCellDayColor}
|
||||||
|
cellNightColor={contextCellNightColor}
|
||||||
|
cellDayValue={contextCellDayValue}
|
||||||
|
cellNightValue={contextCellNightValue}
|
||||||
existingDayComment={dayComments.get(contextMenu.dayIdx)}
|
existingDayComment={dayComments.get(contextMenu.dayIdx)}
|
||||||
existingCellComment={contextMenu.personId ? cellComments.get(`${contextMenu.personId}-${contextMenu.dayIdx}`) : undefined}
|
existingCellComment={contextMenu.personId ? cellComments.get(`${contextMenu.personId}-${contextMenu.dayIdx}`) : undefined}
|
||||||
existingTunnelClosure={tunnelClosures.get(contextMenu.dayIdx)}
|
existingTunnelClosure={tunnelClosures.get(contextMenu.dayIdx)}
|
||||||
@@ -666,8 +948,15 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
onRemoveCellComment={removeCellComment}
|
onRemoveCellComment={removeCellComment}
|
||||||
onSetTunnelClosure={setTunnelClosure}
|
onSetTunnelClosure={setTunnelClosure}
|
||||||
onSetCellColor={setCellColor}
|
onSetCellColor={setCellColor}
|
||||||
|
onSetCellHalfColor={setCellHalfColor}
|
||||||
|
onSetCellHalfValue={setCellHalfValue}
|
||||||
onSetTunnelClosureColor={setTunnelClosureColor}
|
onSetTunnelClosureColor={setTunnelClosureColor}
|
||||||
onSetInfoRowColor={handleInfoRowSetColor}
|
onSetInfoRowColor={handleInfoRowSetColor}
|
||||||
|
onSetAnyColor={handleSetAnyColor}
|
||||||
|
onSetAnyValue={handleSetAnyValue}
|
||||||
|
hasClipboard={!!clipboard}
|
||||||
|
onCopyCell={handleCopyCell}
|
||||||
|
onPasteCells={handlePasteCells}
|
||||||
onClose={() => { setContextMenu(null); setSelectedDenDays([]) }}
|
onClose={() => { setContextMenu(null); setSelectedDenDays([]) }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -681,11 +970,15 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
month={activeMonth}
|
month={activeMonth}
|
||||||
year={data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026}
|
year={data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026}
|
||||||
dayIndex={data.dayIndex}
|
dayIndex={data.dayIndex}
|
||||||
people={people}
|
people={visiblePeople}
|
||||||
onClose={() => setShowFpd(false)}
|
onClose={() => setShowFpd(false)}
|
||||||
onExportDochazka={currentFileId ? () => {
|
onExportDochazka={currentFileId && !isReadOnly ? () => {
|
||||||
const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026
|
const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026
|
||||||
window.location.href = `/api/files/${currentFileId}/export-dochazka?month=${activeMonth}&year=${year}`
|
window.location.href = `/api/files/${currentFileId}/export-dochazka?month=${activeMonth}&year=${year}${viewPublished ? '&view=published' : ''}`
|
||||||
|
} : undefined}
|
||||||
|
onExportHarmonogram={currentFileId && canExportHarmonogram ? () => {
|
||||||
|
const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026
|
||||||
|
window.location.href = `/api/files/${currentFileId}/export-harmonogram?month=${activeMonth}&year=${year}${viewPublished ? '&view=published' : ''}`
|
||||||
} : undefined}
|
} : undefined}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ interface ContextMenuProps {
|
|||||||
personName: string | null
|
personName: string | null
|
||||||
cellValue: string | undefined
|
cellValue: string | undefined
|
||||||
cellColor: string | undefined
|
cellColor: string | undefined
|
||||||
|
cellDayColor: string | undefined
|
||||||
|
cellNightColor: string | undefined
|
||||||
|
cellDayValue: string | undefined
|
||||||
|
cellNightValue: string | undefined
|
||||||
existingDayComment: string | undefined
|
existingDayComment: string | undefined
|
||||||
existingCellComment: string | undefined
|
existingCellComment: string | undefined
|
||||||
existingTunnelClosure: string | undefined
|
existingTunnelClosure: string | undefined
|
||||||
@@ -24,8 +28,15 @@ interface ContextMenuProps {
|
|||||||
onRemoveCellComment: (personId: string, dayIdx: number) => void
|
onRemoveCellComment: (personId: string, dayIdx: number) => void
|
||||||
onSetTunnelClosure: (dayIdx: number, text: string | null) => void
|
onSetTunnelClosure: (dayIdx: number, text: string | null) => void
|
||||||
onSetCellColor: (personId: string, dayIdx: number, color: string | null) => void
|
onSetCellColor: (personId: string, dayIdx: number, color: string | null) => void
|
||||||
|
onSetCellHalfColor: (personId: string, dayIdx: number, target: 'day' | 'night', color: string | null) => void
|
||||||
|
onSetCellHalfValue: (personId: string, dayIdx: number, target: 'day' | 'night', value: string | null) => void
|
||||||
onSetTunnelClosureColor: (dayIdx: number, color: string | null) => void
|
onSetTunnelClosureColor: (dayIdx: number, color: string | null) => void
|
||||||
onSetInfoRowColor: (dayIdx: number, color: string | null) => void
|
onSetInfoRowColor: (dayIdx: number, color: string | null) => void
|
||||||
|
onSetAnyColor: (personId: string, dayIdx: number, color: string | null) => void
|
||||||
|
onSetAnyValue: (personId: string, dayIdx: number, value: string | null) => void
|
||||||
|
hasClipboard: boolean
|
||||||
|
onCopyCell: (personId: string, dayIdx: number) => void
|
||||||
|
onPasteCells: (targets: { personId: string; dayIdx: number }[]) => void
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,6 +46,10 @@ export function ContextMenu({
|
|||||||
personName,
|
personName,
|
||||||
cellValue,
|
cellValue,
|
||||||
cellColor,
|
cellColor,
|
||||||
|
cellDayColor,
|
||||||
|
cellNightColor,
|
||||||
|
cellDayValue,
|
||||||
|
cellNightValue,
|
||||||
existingDayComment,
|
existingDayComment,
|
||||||
existingCellComment,
|
existingCellComment,
|
||||||
existingTunnelClosure,
|
existingTunnelClosure,
|
||||||
@@ -50,16 +65,40 @@ export function ContextMenu({
|
|||||||
onRemoveCellComment,
|
onRemoveCellComment,
|
||||||
onSetTunnelClosure,
|
onSetTunnelClosure,
|
||||||
onSetCellColor,
|
onSetCellColor,
|
||||||
|
onSetCellHalfColor,
|
||||||
|
onSetCellHalfValue,
|
||||||
onSetTunnelClosureColor,
|
onSetTunnelClosureColor,
|
||||||
onSetInfoRowColor,
|
onSetInfoRowColor,
|
||||||
|
onSetAnyColor,
|
||||||
|
onSetAnyValue,
|
||||||
|
hasClipboard,
|
||||||
|
onCopyCell,
|
||||||
|
onPasteCells,
|
||||||
onClose,
|
onClose,
|
||||||
}: ContextMenuProps) {
|
}: ContextMenuProps) {
|
||||||
|
const [colorTarget, setColorTarget] = useState<'cell' | 'day' | 'night'>('cell')
|
||||||
const isMultiSelect = selectedCells.length > 1
|
const isMultiSelect = selectedCells.length > 1
|
||||||
|
const isTunnelRow = !!state.isTunnelRow
|
||||||
|
const isInfoRow = !!state.infoRowId
|
||||||
|
|
||||||
|
// Canonical key for the currently right-clicked info row (null for person/DEN rows)
|
||||||
|
const infoRowKey: string | null = isTunnelRow ? '__tunnel__'
|
||||||
|
: isInfoRow ? (state.infoRowId === 'metro' ? '__metro__' : state.infoRowId === 'd8' ? '__d8__' : '__sazlt__')
|
||||||
|
: null
|
||||||
|
|
||||||
const [showCommentInput, setShowCommentInput] = useState(false)
|
const [showCommentInput, setShowCommentInput] = useState(false)
|
||||||
const [commentText, setCommentText] = useState('')
|
const [commentText, setCommentText] = useState('')
|
||||||
const [showCellCommentInput, setShowCellCommentInput] = useState(false)
|
const [showCellCommentInput, setShowCellCommentInput] = useState(false)
|
||||||
const [cellCommentText, setCellCommentText] = useState('')
|
const [cellCommentText, setCellCommentText] = useState('')
|
||||||
const [cellValueInput, setCellValueInput] = useState(cellValue ?? '')
|
const [cellValueInput, setCellValueInput] = useState(cellValue ?? '')
|
||||||
|
const [infoValueInput, setInfoValueInput] = useState(existingTunnelClosure ?? existingInfoRowClosure ?? '')
|
||||||
|
|
||||||
|
// When toggle changes, refresh input to show the matching field's value
|
||||||
|
useEffect(() => {
|
||||||
|
if (colorTarget === 'cell') setCellValueInput(cellValue ?? '')
|
||||||
|
else if (colorTarget === 'day') setCellValueInput(cellDayValue ?? '')
|
||||||
|
else if (colorTarget === 'night') setCellValueInput(cellNightValue ?? '')
|
||||||
|
}, [colorTarget, cellValue, cellDayValue, cellNightValue])
|
||||||
const cellCommentInputRef = useRef<HTMLInputElement>(null)
|
const cellCommentInputRef = useRef<HTMLInputElement>(null)
|
||||||
const commentInputRef = useRef<HTMLInputElement>(null)
|
const commentInputRef = useRef<HTMLInputElement>(null)
|
||||||
const cellValueInputRef = useRef<HTMLInputElement>(null)
|
const cellValueInputRef = useRef<HTMLInputElement>(null)
|
||||||
@@ -108,26 +147,36 @@ export function ContextMenu({
|
|||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSetCellValue = () => {
|
const INFO_KEYS = new Set(['__tunnel__', '__metro__', '__d8__', '__sazlt__'])
|
||||||
const val = cellValueInput.trim()
|
|
||||||
if (isMultiSelect) {
|
const applyValueToCells = (val: string | null) => {
|
||||||
for (const cell of selectedCells) {
|
const cells = isMultiSelect ? selectedCells : (state.personId ? [{ personId: state.personId, dayIdx: state.dayIdx }] : [])
|
||||||
onSetCell(cell.personId, cell.dayIdx, val || null)
|
for (const cell of cells) {
|
||||||
|
if (colorTarget === 'cell') {
|
||||||
|
onSetAnyValue(cell.personId, cell.dayIdx, val)
|
||||||
|
} else if (!INFO_KEYS.has(cell.personId)) {
|
||||||
|
// half-value only for person cells
|
||||||
|
onSetCellHalfValue(cell.personId, cell.dayIdx, colorTarget, val)
|
||||||
}
|
}
|
||||||
} else if (state.personId) {
|
|
||||||
onSetCell(state.personId, state.dayIdx, val || null)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyInfoValue = (val: string | null) => {
|
||||||
|
const cells = isMultiSelect
|
||||||
|
? selectedCells
|
||||||
|
: infoRowKey ? [{ personId: infoRowKey, dayIdx: state.dayIdx }] : []
|
||||||
|
for (const cell of cells) {
|
||||||
|
onSetAnyValue(cell.personId, cell.dayIdx, val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSetCellValue = () => {
|
||||||
|
applyValueToCells(cellValueInput.trim() || null)
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleClearCell = () => {
|
const handleClearCell = () => {
|
||||||
if (isMultiSelect) {
|
applyValueToCells(null)
|
||||||
for (const cell of selectedCells) {
|
|
||||||
onSetCell(cell.personId, cell.dayIdx, null)
|
|
||||||
}
|
|
||||||
} else if (state.personId) {
|
|
||||||
onSetCell(state.personId, state.dayIdx, null)
|
|
||||||
}
|
|
||||||
onClose()
|
onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,20 +186,78 @@ export function ContextMenu({
|
|||||||
7:'Červenec',8:'Srpen',9:'Září',10:'Říjen',11:'Listopad',12:'Prosinec',
|
7:'Červenec',8:'Srpen',9:'Září',10:'Říjen',11:'Listopad',12:'Prosinec',
|
||||||
}
|
}
|
||||||
const dateStr = isPms ? `Práce mimo směnu — ${MONTH_NAMES_CTX[dayInfo.month] ?? dayInfo.month}` : `${dayInfo.day}.${dayInfo.month}.${dayInfo.year}`
|
const dateStr = isPms ? `Práce mimo směnu — ${MONTH_NAMES_CTX[dayInfo.month] ?? dayInfo.month}` : `${dayInfo.day}.${dayInfo.month}.${dayInfo.year}`
|
||||||
const isTunnelRow = !!state.isTunnelRow
|
|
||||||
const isInfoRow = !!state.infoRowId
|
|
||||||
const infoRowId = state.infoRowId
|
const infoRowId = state.infoRowId
|
||||||
const infoRowLabel = infoRowId === 'metro' ? 'METRO' : infoRowId === 'd8' ? 'D8' : ''
|
const infoRowLabel = infoRowId === 'metro' ? 'METRO' : infoRowId === 'd8' ? 'D8' : ''
|
||||||
const isDenRow = !state.personId && !isTunnelRow && !isInfoRow
|
const isDenRow = !state.personId && !isTunnelRow && !isInfoRow
|
||||||
const multiDayLabel = selectedDays.length > 1 ? ` (${selectedDays.length} dnu)` : ''
|
const multiDayLabel = selectedDays.length > 1 ? ` (${selectedDays.length} dnu)` : ''
|
||||||
|
|
||||||
|
const isPersonCell = !isTunnelRow && !isInfoRow && !isDenRow
|
||||||
|
|
||||||
|
const renderTargetToggle = () => {
|
||||||
|
if (!isPersonCell) return null
|
||||||
|
const toggleBtn = (target: 'cell' | 'day' | 'night', label: string) => (
|
||||||
|
<button
|
||||||
|
key={target}
|
||||||
|
onClick={(e) => { e.stopPropagation(); setColorTarget(target) }}
|
||||||
|
className={`px-2 py-1 rounded text-[10px] cursor-pointer border transition-colors flex-1
|
||||||
|
${colorTarget === target
|
||||||
|
? 'bg-blue-600 text-white border-blue-500 font-semibold'
|
||||||
|
: 'bg-slate-900 text-slate-400 border-slate-700 hover:bg-slate-700'}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
<div className="border-b border-slate-700 px-3 py-2">
|
||||||
|
<div className="text-[10px] text-slate-500 uppercase tracking-wider mb-1.5">Cíl</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{toggleBtn('cell', 'Celá')}
|
||||||
|
{toggleBtn('day', 'Denní (←)')}
|
||||||
|
{toggleBtn('night', 'Noční (→)')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const renderColorPalette = () => {
|
const renderColorPalette = () => {
|
||||||
const activeColor = isTunnelRow
|
const activeColor = isTunnelRow
|
||||||
? existingTunnelColor
|
? existingTunnelColor
|
||||||
: isInfoRow
|
: isInfoRow
|
||||||
? existingInfoRowColor
|
? existingInfoRowColor
|
||||||
|
: colorTarget === 'day'
|
||||||
|
? cellDayColor
|
||||||
|
: colorTarget === 'night'
|
||||||
|
? cellNightColor
|
||||||
: cellColor
|
: cellColor
|
||||||
|
|
||||||
|
const INFO_KEYS = new Set(['__tunnel__', '__metro__', '__d8__', '__sazlt__'])
|
||||||
|
|
||||||
|
const applyColor = (color: string | null) => {
|
||||||
|
if (isMultiSelect) {
|
||||||
|
// Apply to all selected cells — routes to correct setter per row type
|
||||||
|
for (const cell of selectedCells) {
|
||||||
|
if (colorTarget === 'cell') {
|
||||||
|
onSetAnyColor(cell.personId, cell.dayIdx, color)
|
||||||
|
} else if (!INFO_KEYS.has(cell.personId)) {
|
||||||
|
// half-color only makes sense for person cells
|
||||||
|
onSetCellHalfColor(cell.personId, cell.dayIdx, colorTarget, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single-cell mode
|
||||||
|
if (isTunnelRow) {
|
||||||
|
onSetTunnelClosureColor(state.dayIdx, color)
|
||||||
|
} else if (isInfoRow) {
|
||||||
|
onSetInfoRowColor(state.dayIdx, color)
|
||||||
|
} else if (colorTarget === 'cell') {
|
||||||
|
if (state.personId) onSetCellColor(state.personId, state.dayIdx, color)
|
||||||
|
} else {
|
||||||
|
if (state.personId) onSetCellHalfColor(state.personId, state.dayIdx, colorTarget, color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-t border-slate-700 px-3 py-2">
|
<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="text-[10px] text-slate-500 uppercase tracking-wider mb-1.5">Barva pozadi</div>
|
||||||
@@ -160,20 +267,7 @@ export function ContextMenu({
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={color || 'none'}
|
key={color || 'none'}
|
||||||
onClick={() => {
|
onClick={() => applyColor(color || null)}
|
||||||
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"
|
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' }}
|
style={{ backgroundColor: color || '#1e293b' }}
|
||||||
title={label}
|
title={label}
|
||||||
@@ -223,27 +317,74 @@ export function ContextMenu({
|
|||||||
{dateStr} (T{dayInfo.week})
|
{dateStr} (T{dayInfo.week})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isInfoRow ? (
|
{isInfoRow || isTunnelRow ? (() => {
|
||||||
<>
|
const rowKey = isTunnelRow
|
||||||
{existingInfoRowClosure && (
|
? '__tunnel__'
|
||||||
<div className="px-3 py-2 text-xs text-blue-300 border-b border-slate-700">
|
: infoRowId === 'metro' ? '__metro__'
|
||||||
<span className="text-blue-400 mr-1.5">▶</span>
|
: infoRowId === 'd8' ? '__d8__'
|
||||||
{existingInfoRowClosure}
|
: '__sazlt__'
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => { onCopyCell(rowKey, 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 border-b border-slate-700"
|
||||||
|
>
|
||||||
|
<span className="text-blue-400 mr-1.5">⧉</span>
|
||||||
|
Kopírovat buňku
|
||||||
|
<span className="float-right text-slate-500 text-[10px]">Ctrl+C</span>
|
||||||
|
</button>
|
||||||
|
{hasClipboard && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const targets = isMultiSelect
|
||||||
|
? selectedCells.map(c => ({ personId: c.personId, dayIdx: c.dayIdx }))
|
||||||
|
: [{ personId: rowKey, dayIdx: state.dayIdx }]
|
||||||
|
onPasteCells(targets)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors border-b border-slate-700"
|
||||||
|
>
|
||||||
|
<span className="text-blue-400 mr-1.5">⊕</span>
|
||||||
|
Vložit{isMultiSelect ? ` (${selectedCells.length} buněk)` : ''}
|
||||||
|
<span className="float-right text-slate-500 text-[10px]">Ctrl+V</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{/* Text input — same as person row */}
|
||||||
|
<div className="px-3 py-2 border-b border-slate-700">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={infoValueInput}
|
||||||
|
onChange={e => setInfoValueInput(e.target.value)}
|
||||||
|
onKeyDown={e => { if (e.key === 'Enter') { applyInfoValue(infoValueInput.trim() || null); onClose() } }}
|
||||||
|
placeholder="Text..."
|
||||||
|
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={() => { applyInfoValue(infoValueInput.trim() || null); onClose() }}
|
||||||
|
className="px-2.5 py-1 rounded text-[10px] bg-blue-600 text-white hover:bg-blue-500 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
Nastavit{isMultiSelect ? ` (${selectedCells.length})` : ''}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
{renderColorPalette()}
|
||||||
{renderColorPalette()}
|
</>
|
||||||
</>
|
)
|
||||||
) : isTunnelRow ? (
|
})() : isDenRow ? (
|
||||||
<>
|
|
||||||
{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 ? (
|
{existingDayComment && !showCommentInput ? (
|
||||||
<>
|
<>
|
||||||
@@ -310,6 +451,35 @@ export function ContextMenu({
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
|
{/* Copy / Paste */}
|
||||||
|
{!isMultiSelect && state.personId && (
|
||||||
|
<button
|
||||||
|
onClick={() => { onCopyCell(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 border-b border-slate-700"
|
||||||
|
>
|
||||||
|
<span className="text-blue-400 mr-1.5">⧉</span>
|
||||||
|
Kopírovat buňku
|
||||||
|
<span className="float-right text-slate-500 text-[10px]">Ctrl+C</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{hasClipboard && state.personId && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const targets = isMultiSelect
|
||||||
|
? selectedCells.map(c => ({ personId: c.personId, dayIdx: c.dayIdx }))
|
||||||
|
: [{ personId: state.personId!, dayIdx: state.dayIdx }]
|
||||||
|
onPasteCells(targets)
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors border-b border-slate-700"
|
||||||
|
>
|
||||||
|
<span className="text-blue-400 mr-1.5">⊕</span>
|
||||||
|
Vložit{isMultiSelect ? ` (${selectedCells.length} buněk)` : ''}
|
||||||
|
<span className="float-right text-slate-500 text-[10px]">Ctrl+V</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderTargetToggle()}
|
||||||
{/* Cell value editing */}
|
{/* Cell value editing */}
|
||||||
<div className="px-3 py-2 border-b border-slate-700">
|
<div className="px-3 py-2 border-b border-slate-700">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@@ -238,6 +238,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: Fi
|
|||||||
<th className="px-4 py-3 w-8"></th>
|
<th className="px-4 py-3 w-8"></th>
|
||||||
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Název</th>
|
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Název</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">Upraveno</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Publikováno</th>
|
||||||
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Vytvořeno</th>
|
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Vytvořeno</th>
|
||||||
<th className="px-4 py-3 text-right text-xs text-slate-400 uppercase tracking-wider">Akce</th>
|
<th className="px-4 py-3 text-right text-xs text-slate-400 uppercase tracking-wider">Akce</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -282,6 +283,19 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: Fi
|
|||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm text-slate-400">{formatDate(file.modifiedAt)}</span>
|
<span className="text-sm text-slate-400">{formatDate(file.modifiedAt)}</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-400">{file.publishedAt ? formatDate(file.publishedAt) : '—'}</span>
|
||||||
|
{file.hasUnpublishedChanges && (
|
||||||
|
<span
|
||||||
|
title="Rozpracované změny, které uživatelé s náhledem zatím nevidí"
|
||||||
|
className="text-[10px] px-1.5 py-0.5 rounded bg-amber-700/40 text-amber-300 border border-amber-700/50"
|
||||||
|
>
|
||||||
|
nepublikované změny
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm text-slate-400">{formatDate(file.createdAt)}</span>
|
<span className="text-sm text-slate-400">{formatDate(file.createdAt)}</span>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -6,6 +6,13 @@ const MONTH_NAMES: Record<number, string> = {
|
|||||||
7:'Červenec',8:'Srpen',9:'Září',10:'Říjen',11:'Listopad',12:'Prosinec',
|
7:'Červenec',8:'Srpen',9:'Září',10:'Říjen',11:'Listopad',12:'Prosinec',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Part-time people: half the daily hours (4h/day) and half the FPD target.
|
||||||
|
const HALF_TIME_PEOPLE = new Set(['Dittrich Vladimír'])
|
||||||
|
|
||||||
|
// Info-only people (e.g. contractors): shown in the FPD check for reference but
|
||||||
|
// never flagged as a shortfall, even when below quota.
|
||||||
|
const INFO_ONLY_PEOPLE = new Set(['Zábranský Petr'])
|
||||||
|
|
||||||
interface FpdResult {
|
interface FpdResult {
|
||||||
personId: string
|
personId: string
|
||||||
name: string
|
name: string
|
||||||
@@ -13,6 +20,7 @@ interface FpdResult {
|
|||||||
workedHours: number
|
workedHours: number
|
||||||
fpd: number
|
fpd: number
|
||||||
diff: number // positive = over, negative = under
|
diff: number // positive = over, negative = under
|
||||||
|
infoOnly: boolean // contractor — never warn on shortfall
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHoursForValue(v: string): number {
|
function getHoursForValue(v: string): number {
|
||||||
@@ -39,27 +47,52 @@ export function computeFpd(
|
|||||||
}
|
}
|
||||||
const fpd = workDays * 8
|
const fpd = workDays * 8
|
||||||
|
|
||||||
// Check TKB people only
|
// Check TKB people only — exclude the "162" group (handled separately)
|
||||||
const tkbPeople = people.filter(p => p.group === 'TKB')
|
const EXCLUDED = new Set(['Pauzer Libor', 'Vörös Pavel', 'Janouš Petr', 'Franek Lukáš', 'Svoboda Daniel'])
|
||||||
|
const tkbPeople = people.filter(p => p.group === 'TKB' && !EXCLUDED.has(p.name) && !p.retired)
|
||||||
const results: FpdResult[] = []
|
const results: FpdResult[] = []
|
||||||
|
|
||||||
for (const person of tkbPeople) {
|
for (const person of tkbPeople) {
|
||||||
|
// Half-time people work half the daily hours: their FPD target is halved
|
||||||
|
// and a vacation day ('D') deducts only their daily hours (4h, not 8h).
|
||||||
|
const dailyHours = HALF_TIME_PEOPLE.has(person.name) ? 4 : 8
|
||||||
let workedHours = 0
|
let workedHours = 0
|
||||||
|
let vacationHours = 0 // D vacation reduces the FPD target (full day = dailyHours)
|
||||||
for (const d of monthDays) {
|
for (const d of monthDays) {
|
||||||
const cellData = person.data[String(d.idx)]
|
const cellData = person.data[String(d.idx)]
|
||||||
const value = cellData?.v
|
if (!cellData) continue
|
||||||
if (value) {
|
const isWorkDay = !d.weekend && !holidaySet.has(d.day)
|
||||||
workedHours += getHoursForValue(value)
|
const isSplit = !!(cellData.dayColor || cellData.nightColor || cellData.dayValue || cellData.nightValue)
|
||||||
|
const collect = (v: string | undefined) => {
|
||||||
|
if (!v) return
|
||||||
|
// D vacation on a working day reduces the FPD target.
|
||||||
|
if (v === 'D') { if (isWorkDay) vacationHours += dailyHours }
|
||||||
|
else if (v === 'D/2') { if (isWorkDay) vacationHours += dailyHours / 2 }
|
||||||
|
// N (sick) and O (otcovská dovolená) on a working day are credited as a normal
|
||||||
|
// worked day (daily hours), so they neither help nor hurt the FPD balance.
|
||||||
|
else if (v === 'N' || v === 'O') { if (isWorkDay) workedHours += dailyHours }
|
||||||
|
else workedHours += getHoursForValue(v)
|
||||||
|
}
|
||||||
|
if (isSplit) {
|
||||||
|
collect(cellData.dayValue)
|
||||||
|
collect(cellData.nightValue)
|
||||||
|
} else {
|
||||||
|
collect(cellData.v)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const diff = workedHours - fpd
|
// PMS (práce mimo směnu) is stored at day-index -month; counts as worked hours.
|
||||||
|
const pms = person.data[String(-month)]?.v
|
||||||
|
if (pms) workedHours += getHoursForValue(pms)
|
||||||
|
const personFpd = workDays * dailyHours - vacationHours
|
||||||
|
const diff = workedHours - personFpd
|
||||||
results.push({
|
results.push({
|
||||||
personId: person.id,
|
personId: person.id,
|
||||||
name: person.name,
|
name: person.name,
|
||||||
note: person.note,
|
note: person.note,
|
||||||
workedHours,
|
workedHours,
|
||||||
fpd,
|
fpd: personFpd,
|
||||||
diff,
|
diff,
|
||||||
|
infoOnly: INFO_ONLY_PEOPLE.has(person.name),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +106,14 @@ interface FpdModalProps {
|
|||||||
people: Person[]
|
people: Person[]
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
onExportDochazka?: () => void
|
onExportDochazka?: () => void
|
||||||
|
onExportHarmonogram?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FpdModal({ month, year, dayIndex, people, onClose, onExportDochazka }: FpdModalProps) {
|
export function FpdModal({ month, year, dayIndex, people, onClose, onExportDochazka, onExportHarmonogram }: FpdModalProps) {
|
||||||
const { fpd, workDays, results } = computeFpd(month, year, dayIndex, people)
|
const { fpd, workDays, results } = computeFpd(month, year, dayIndex, people)
|
||||||
const under = results.filter(r => r.diff < 0)
|
// Info-only people (contractors) never count as a shortfall
|
||||||
const okOrOver = results.filter(r => r.diff >= 0)
|
const under = results.filter(r => r.diff < 0 && !r.infoOnly)
|
||||||
|
const okOrOver = results.filter(r => r.diff >= 0 || r.infoOnly)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50" onClick={onClose}>
|
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
@@ -132,32 +167,51 @@ export function FpdModal({ month, year, dayIndex, people, onClose, onExportDocha
|
|||||||
<div
|
<div
|
||||||
key={r.personId}
|
key={r.personId}
|
||||||
className={`flex items-center justify-between px-3 py-2 rounded text-xs
|
className={`flex items-center justify-between px-3 py-2 rounded text-xs
|
||||||
${r.diff > 0 ? 'bg-slate-700/30 border border-slate-600/50' : 'bg-green-900/20 border border-green-700/30'}`}
|
${r.infoOnly ? 'bg-slate-700/20 border border-slate-600/40'
|
||||||
|
: r.diff > 0 ? 'bg-slate-700/30 border border-slate-600/50'
|
||||||
|
: 'bg-green-900/20 border border-green-700/30'}`}
|
||||||
>
|
>
|
||||||
<span className="text-slate-200">
|
<span className="text-slate-200">
|
||||||
{r.name}
|
{r.name}
|
||||||
{r.note && <span className="text-slate-500 ml-1">({r.note})</span>}
|
{r.note && <span className="text-slate-500 ml-1">({r.note})</span>}
|
||||||
|
{r.infoOnly && <span className="text-slate-500 ml-1">(info)</span>}
|
||||||
</span>
|
</span>
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<span className="text-slate-400">{r.workedHours}h / {r.fpd}h</span>
|
<span className="text-slate-400">{r.workedHours}h / {r.fpd}h</span>
|
||||||
<span className={`font-bold ${r.diff > 0 ? 'text-slate-300' : 'text-green-400'}`}>
|
{r.infoOnly ? (
|
||||||
{r.diff > 0 ? `+${r.diff}h` : '✓'}
|
<span className="font-bold text-slate-400">{r.diff > 0 ? `+${r.diff}` : r.diff}h</span>
|
||||||
</span>
|
) : (
|
||||||
|
<span className={`font-bold ${r.diff > 0 ? 'text-slate-300' : 'text-green-400'}`}>
|
||||||
|
{r.diff > 0 ? `+${r.diff}h` : '✓'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{onExportDochazka && (
|
{(onExportDochazka || onExportHarmonogram) && (
|
||||||
<div className="px-5 py-3 border-t border-slate-700 flex justify-end">
|
<div className="px-5 py-3 border-t border-slate-700 flex justify-end gap-2">
|
||||||
<button
|
{onExportHarmonogram && (
|
||||||
onClick={onExportDochazka}
|
<button
|
||||||
className="px-4 py-2 rounded text-xs bg-green-700/70 text-green-100 border border-green-600
|
onClick={onExportHarmonogram}
|
||||||
hover:bg-green-600/70 cursor-pointer transition-colors font-semibold"
|
className="px-4 py-2 rounded text-xs bg-blue-700/70 text-blue-100 border border-blue-600
|
||||||
>
|
hover:bg-blue-600/70 cursor-pointer transition-colors font-semibold"
|
||||||
↓ Stáhnout docházku (Excel)
|
title="Export pro import do interního docházkového systému CIS"
|
||||||
</button>
|
>
|
||||||
|
↓ CIS (import)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onExportDochazka && (
|
||||||
|
<button
|
||||||
|
onClick={onExportDochazka}
|
||||||
|
className="px-4 py-2 rounded text-xs bg-green-700/70 text-green-100 border border-green-600
|
||||||
|
hover:bg-green-600/70 cursor-pointer transition-colors font-semibold"
|
||||||
|
>
|
||||||
|
↓ Stáhnout docházku (Excel)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,17 +4,22 @@ const VALID_USER = 'tkb'
|
|||||||
const VALID_PASS = 'sluzby'
|
const VALID_PASS = 'sluzby'
|
||||||
const VIEWER_USER = 'prohlizec'
|
const VIEWER_USER = 'prohlizec'
|
||||||
const VIEWER_PASS = 'pohled'
|
const VIEWER_PASS = 'pohled'
|
||||||
|
const CIS_USER = 'cis'
|
||||||
|
const CIS_PASS = 'import'
|
||||||
const AUTH_KEY = 'tkb_auth'
|
const AUTH_KEY = 'tkb_auth'
|
||||||
const ROLE_KEY = 'tkb_role'
|
const ROLE_KEY = 'tkb_role'
|
||||||
|
|
||||||
export type UserRole = 'editor' | 'viewer'
|
export type UserRole = 'editor' | 'viewer' | 'cis'
|
||||||
|
|
||||||
export function isAuthenticated(): boolean {
|
export function isAuthenticated(): boolean {
|
||||||
return localStorage.getItem(AUTH_KEY) === 'true'
|
return localStorage.getItem(AUTH_KEY) === 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAuthRole(): UserRole {
|
export function getAuthRole(): UserRole {
|
||||||
return localStorage.getItem(ROLE_KEY) === 'viewer' ? 'viewer' : 'editor'
|
const r = localStorage.getItem(ROLE_KEY)
|
||||||
|
if (r === 'viewer') return 'viewer'
|
||||||
|
if (r === 'cis') return 'cis'
|
||||||
|
return 'editor'
|
||||||
}
|
}
|
||||||
|
|
||||||
function setAuthenticated(role: UserRole): void {
|
function setAuthenticated(role: UserRole): void {
|
||||||
@@ -44,6 +49,9 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
} else if (username === VIEWER_USER && password === VIEWER_PASS) {
|
} else if (username === VIEWER_USER && password === VIEWER_PASS) {
|
||||||
setAuthenticated('viewer')
|
setAuthenticated('viewer')
|
||||||
onLogin('viewer')
|
onLogin('viewer')
|
||||||
|
} else if (username === CIS_USER && password === CIS_PASS) {
|
||||||
|
setAuthenticated('cis')
|
||||||
|
onLogin('cis')
|
||||||
} else {
|
} else {
|
||||||
setError(true)
|
setError(true)
|
||||||
setTimeout(() => setError(false), 3000)
|
setTimeout(() => setError(false), 3000)
|
||||||
|
|||||||
@@ -2,6 +2,15 @@ import jsPDF from 'jspdf'
|
|||||||
import type { DayInfo, Person } from './types'
|
import type { DayInfo, Person } from './types'
|
||||||
import { getCellStyle } from './cellColors'
|
import { getCellStyle } from './cellColors'
|
||||||
|
|
||||||
|
const HIDE_162_NAMES = ['Pauzer Libor', 'Vörös Pavel', 'Janouš Petr', 'Franek Lukáš', 'Svoboda Daniel']
|
||||||
|
|
||||||
|
interface PdfFilters {
|
||||||
|
hide162: boolean
|
||||||
|
showIT: boolean
|
||||||
|
hiddenTkbPeople: Set<string>
|
||||||
|
hiddenValues: Set<string>
|
||||||
|
}
|
||||||
|
|
||||||
const MONTH_NAMES: Record<number, string> = {
|
const MONTH_NAMES: Record<number, string> = {
|
||||||
1: 'Leden', 2: 'Únor', 3: 'Březen', 4: 'Duben', 5: 'Květen', 6: 'Červen',
|
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',
|
7: 'Červenec', 8: 'Srpen', 9: 'Září', 10: 'Říjen', 11: 'Listopad', 12: 'Prosinec',
|
||||||
@@ -40,6 +49,7 @@ export async function exportMonthPdf(
|
|||||||
people: Person[],
|
people: Person[],
|
||||||
tunnelClosures: Map<number, string>,
|
tunnelClosures: Map<number, string>,
|
||||||
tunnelColors: Map<number, string>,
|
tunnelColors: Map<number, string>,
|
||||||
|
filters: PdfFilters = { hide162: false, showIT: true, hiddenTkbPeople: new Set(), hiddenValues: new Set() },
|
||||||
) {
|
) {
|
||||||
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
const doc = new jsPDF({ orientation: 'landscape', unit: 'mm', format: 'a4' })
|
||||||
const hasFont = await loadFont(doc)
|
const hasFont = await loadFont(doc)
|
||||||
@@ -52,8 +62,10 @@ export async function exportMonthPdf(
|
|||||||
const startIdx = Math.max(0, firstDayIdx - daysFromPrevMonth)
|
const startIdx = Math.max(0, firstDayIdx - daysFromPrevMonth)
|
||||||
const days = dayIndex.slice(startIdx, startIdx + 34)
|
const days = dayIndex.slice(startIdx, startIdx + 34)
|
||||||
|
|
||||||
const tkbPeople = people.filter(p => p.group === 'TKB')
|
let tkbPeople = people.filter(p => p.group === 'TKB')
|
||||||
const itPeople = people.filter(p => p.group === 'IT')
|
if (filters.hide162) tkbPeople = tkbPeople.filter(p => !HIDE_162_NAMES.includes(p.name))
|
||||||
|
if (filters.hiddenTkbPeople.size > 0) tkbPeople = tkbPeople.filter(p => !filters.hiddenTkbPeople.has(p.id))
|
||||||
|
const itPeople = filters.showIT ? people.filter(p => p.group === 'IT') : []
|
||||||
|
|
||||||
// Layout: fill entire A4 landscape (297 x 210 mm)
|
// Layout: fill entire A4 landscape (297 x 210 mm)
|
||||||
const pageW = 297
|
const pageW = 297
|
||||||
@@ -63,11 +75,14 @@ export async function exportMonthPdf(
|
|||||||
const daysPerPage = 17
|
const daysPerPage = 17
|
||||||
const cellW = (pageW - margin * 2 - nameColW) / daysPerPage
|
const cellW = (pageW - margin * 2 - nameColW) / daysPerPage
|
||||||
|
|
||||||
// Row height to fill full page
|
// Row height: fits unfiltered set; caps when filters reduce rows
|
||||||
|
// (so a single-person filter doesn't stretch the row to fill the page)
|
||||||
const headerH = 24
|
const headerH = 24
|
||||||
const totalDataRows = tkbPeople.length + itPeople.length + 2
|
const sectionHeaderRows = (filters.showIT ? 2 : 1)
|
||||||
|
const totalDataRows = tkbPeople.length + itPeople.length + sectionHeaderRows
|
||||||
const availableH = pageH - margin * 2 - headerH
|
const availableH = pageH - margin * 2 - headerH
|
||||||
const rowH = availableH / totalDataRows
|
const MAX_ROW_H = 7 // mm — keeps cells reasonable size when filtered
|
||||||
|
const rowH = Math.min(MAX_ROW_H, availableH / totalDataRows)
|
||||||
|
|
||||||
// Font sizes — match day number size for readability
|
// Font sizes — match day number size for readability
|
||||||
const nameFontSize = 9
|
const nameFontSize = 9
|
||||||
@@ -189,19 +204,21 @@ export async function exportMonthPdf(
|
|||||||
if (showNames) doc.text('POHOTOVOST TKB', margin + 2, curY + rowH * 0.7)
|
if (showNames) doc.text('POHOTOVOST TKB', margin + 2, curY + rowH * 0.7)
|
||||||
curY += rowH
|
curY += rowH
|
||||||
|
|
||||||
renderPeople(doc, tkbPeople, pageDays, curY, margin, showNames ? nameColW : 0, gridStartX, cellW, rowH, showNames, nameFontSize, cellFontSize, month)
|
renderPeople(doc, tkbPeople, pageDays, curY, margin, showNames ? nameColW : 0, gridStartX, cellW, rowH, showNames, nameFontSize, cellFontSize, month, filters.hiddenValues)
|
||||||
curY += tkbPeople.length * rowH
|
curY += tkbPeople.length * rowH
|
||||||
|
|
||||||
// IT section header
|
if (filters.showIT) {
|
||||||
doc.setFillColor(13, 148, 136)
|
// IT section header
|
||||||
doc.rect(margin, curY, totalW, rowH, 'F')
|
doc.setFillColor(13, 148, 136)
|
||||||
doc.setFontSize(sectionFontSize)
|
doc.rect(margin, curY, totalW, rowH, 'F')
|
||||||
doc.setTextColor(255, 255, 255)
|
doc.setFontSize(sectionFontSize)
|
||||||
if (showNames) doc.text('POHOTOVOST IT', margin + 2, curY + rowH * 0.7)
|
doc.setTextColor(255, 255, 255)
|
||||||
curY += rowH
|
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)
|
renderPeople(doc, itPeople, pageDays, curY, margin, showNames ? nameColW : 0, gridStartX, cellW, rowH, showNames, nameFontSize, cellFontSize, month, filters.hiddenValues)
|
||||||
curY += itPeople.length * rowH
|
curY += itPeople.length * rowH
|
||||||
|
}
|
||||||
|
|
||||||
// Outer border
|
// Outer border
|
||||||
doc.setDrawColor(0, 0, 0)
|
doc.setDrawColor(0, 0, 0)
|
||||||
@@ -218,10 +235,12 @@ function renderPeople(
|
|||||||
startY: number, margin: number, nameColW: number, gridStartX: number,
|
startY: number, margin: number, nameColW: number, gridStartX: number,
|
||||||
cellW: number, rowH: number, showNames: boolean,
|
cellW: number, rowH: number, showNames: boolean,
|
||||||
nameFontSize: number, cellFontSize: number, activeMonth: number,
|
nameFontSize: number, cellFontSize: number, activeMonth: number,
|
||||||
|
hiddenValues: Set<string>,
|
||||||
) {
|
) {
|
||||||
const totalW = nameColW + days.length * cellW
|
const totalW = nameColW + days.length * cellW
|
||||||
const sectionH = people.length * rowH
|
const sectionH = people.length * rowH
|
||||||
const textY = rowH * 0.65
|
const textY = rowH * 0.65
|
||||||
|
const halfW = cellW / 2
|
||||||
|
|
||||||
// Pass 1: Fill backgrounds
|
// Pass 1: Fill backgrounds
|
||||||
people.forEach((person, pi) => {
|
people.forEach((person, pi) => {
|
||||||
@@ -230,22 +249,62 @@ function renderPeople(
|
|||||||
const x = gridStartX + di * cellW
|
const x = gridStartX + di * cellW
|
||||||
const isOtherMonth = d.month !== activeMonth
|
const isOtherMonth = d.month !== activeMonth
|
||||||
const cellData = person.data[String(d.idx)]
|
const cellData = person.data[String(d.idx)]
|
||||||
const value = cellData?.v ?? ''
|
const rawValue = cellData?.v ?? ''
|
||||||
|
const value = hiddenValues.has(rawValue) ? '' : rawValue
|
||||||
const manualColor = cellData?.color
|
const manualColor = cellData?.color
|
||||||
|
const dayColor = cellData?.dayColor
|
||||||
|
const nightColor = cellData?.nightColor
|
||||||
|
const dayValue = cellData?.dayValue && !hiddenValues.has(cellData.dayValue) ? cellData.dayValue : ''
|
||||||
|
const nightValue = cellData?.nightValue && !hiddenValues.has(cellData.nightValue) ? cellData.nightValue : ''
|
||||||
|
const renderAsSplit = !!(dayColor || nightColor || dayValue || nightValue)
|
||||||
const style = getCellStyle(value || undefined)
|
const style = getCellStyle(value || undefined)
|
||||||
|
|
||||||
if (isOtherMonth) {
|
if (isOtherMonth) {
|
||||||
// Light gray wash for other month cells
|
|
||||||
doc.setFillColor(245, 245, 245)
|
doc.setFillColor(245, 245, 245)
|
||||||
doc.rect(x, y, cellW, rowH, 'F')
|
doc.rect(x, y, cellW, rowH, 'F')
|
||||||
// If there's a value, draw it faded
|
// For other-month cells, fade any colored content
|
||||||
if (manualColor || style) {
|
if (renderAsSplit) {
|
||||||
|
doc.setGState(doc.GState({ opacity: 0.35 }))
|
||||||
|
const dayBg = dayColor ?? getCellStyle(dayValue || undefined)?.bg
|
||||||
|
const nightBg = nightColor ?? getCellStyle(nightValue || undefined)?.bg
|
||||||
|
if (dayBg) {
|
||||||
|
const rgb = hexToRgb(dayBg)
|
||||||
|
doc.setFillColor(rgb.r, rgb.g, rgb.b)
|
||||||
|
doc.rect(x, y, halfW, rowH, 'F')
|
||||||
|
}
|
||||||
|
if (nightBg) {
|
||||||
|
const rgb = hexToRgb(nightBg)
|
||||||
|
doc.setFillColor(rgb.r, rgb.g, rgb.b)
|
||||||
|
doc.rect(x + halfW, y, halfW, rowH, 'F')
|
||||||
|
}
|
||||||
|
doc.setGState(doc.GState({ opacity: 1 }))
|
||||||
|
} else if (manualColor || style) {
|
||||||
doc.setGState(doc.GState({ opacity: 0.35 }))
|
doc.setGState(doc.GState({ opacity: 0.35 }))
|
||||||
const rgb = hexToRgb(manualColor ?? style!.bg)
|
const rgb = hexToRgb(manualColor ?? style!.bg)
|
||||||
doc.setFillColor(rgb.r, rgb.g, rgb.b)
|
doc.setFillColor(rgb.r, rgb.g, rgb.b)
|
||||||
doc.rect(x, y, cellW, rowH, 'F')
|
doc.rect(x, y, cellW, rowH, 'F')
|
||||||
doc.setGState(doc.GState({ opacity: 1 }))
|
doc.setGState(doc.GState({ opacity: 1 }))
|
||||||
}
|
}
|
||||||
|
} else if (renderAsSplit) {
|
||||||
|
// Split mode: Day = LEFT, Night = RIGHT
|
||||||
|
const dayBg = dayColor ?? getCellStyle(dayValue || undefined)?.bg
|
||||||
|
const nightBg = nightColor ?? getCellStyle(nightValue || undefined)?.bg
|
||||||
|
if (dayBg) {
|
||||||
|
const rgb = hexToRgb(dayBg)
|
||||||
|
doc.setFillColor(rgb.r, rgb.g, rgb.b)
|
||||||
|
doc.rect(x, y, halfW, rowH, 'F')
|
||||||
|
} else if (d.weekend) {
|
||||||
|
doc.setFillColor(240, 240, 240)
|
||||||
|
doc.rect(x, y, halfW, rowH, 'F')
|
||||||
|
}
|
||||||
|
if (nightBg) {
|
||||||
|
const rgb = hexToRgb(nightBg)
|
||||||
|
doc.setFillColor(rgb.r, rgb.g, rgb.b)
|
||||||
|
doc.rect(x + halfW, y, halfW, rowH, 'F')
|
||||||
|
} else if (d.weekend) {
|
||||||
|
doc.setFillColor(240, 240, 240)
|
||||||
|
doc.rect(x + halfW, y, halfW, rowH, 'F')
|
||||||
|
}
|
||||||
} else if (manualColor) {
|
} else if (manualColor) {
|
||||||
const rgb = hexToRgb(manualColor)
|
const rgb = hexToRgb(manualColor)
|
||||||
doc.setFillColor(rgb.r, rgb.g, rgb.b)
|
doc.setFillColor(rgb.r, rgb.g, rgb.b)
|
||||||
@@ -301,9 +360,43 @@ function renderPeople(
|
|||||||
days.forEach((d, di) => {
|
days.forEach((d, di) => {
|
||||||
const x = gridStartX + di * cellW
|
const x = gridStartX + di * cellW
|
||||||
const cellData = person.data[String(d.idx)]
|
const cellData = person.data[String(d.idx)]
|
||||||
const value = cellData?.v ?? ''
|
const rawValue = cellData?.v ?? ''
|
||||||
if (value) {
|
const value = hiddenValues.has(rawValue) ? '' : rawValue
|
||||||
const isOtherMonth = d.month !== activeMonth
|
const dayValue = cellData?.dayValue && !hiddenValues.has(cellData.dayValue) ? cellData.dayValue : ''
|
||||||
|
const nightValue = cellData?.nightValue && !hiddenValues.has(cellData.nightValue) ? cellData.nightValue : ''
|
||||||
|
const dayColor = cellData?.dayColor
|
||||||
|
const nightColor = cellData?.nightColor
|
||||||
|
const renderAsSplit = !!(dayColor || nightColor || dayValue || nightValue)
|
||||||
|
const isOtherMonth = d.month !== activeMonth
|
||||||
|
|
||||||
|
if (renderAsSplit) {
|
||||||
|
// Render text in each half: Day = LEFT, Night = RIGHT
|
||||||
|
const halfFontSize = Math.max(6, cellFontSize - 1)
|
||||||
|
if (dayValue) {
|
||||||
|
const dayStyle = dayColor
|
||||||
|
? (isLightColor(dayColor) ? '#333333' : '#ffffff')
|
||||||
|
: (getCellStyle(dayValue)?.text ?? '#333333')
|
||||||
|
const rgb = hexToRgb(dayStyle)
|
||||||
|
if (isOtherMonth) doc.setTextColor(200, 200, 200)
|
||||||
|
else doc.setTextColor(rgb.r, rgb.g, rgb.b)
|
||||||
|
doc.setFontSize(halfFontSize)
|
||||||
|
doc.text(dayValue, x + halfW / 2, y + textY, { align: 'center' })
|
||||||
|
}
|
||||||
|
if (nightValue) {
|
||||||
|
const nightStyle = nightColor
|
||||||
|
? (isLightColor(nightColor) ? '#333333' : '#ffffff')
|
||||||
|
: (getCellStyle(nightValue)?.text ?? '#333333')
|
||||||
|
const rgb = hexToRgb(nightStyle)
|
||||||
|
if (isOtherMonth) doc.setTextColor(200, 200, 200)
|
||||||
|
else doc.setTextColor(rgb.r, rgb.g, rgb.b)
|
||||||
|
doc.setFontSize(halfFontSize)
|
||||||
|
doc.text(nightValue, x + halfW + halfW / 2, y + textY, { align: 'center' })
|
||||||
|
}
|
||||||
|
// Thin divider between halves
|
||||||
|
doc.setDrawColor(160, 160, 160)
|
||||||
|
doc.setLineWidth(0.1)
|
||||||
|
doc.line(x + halfW, y, x + halfW, y + rowH)
|
||||||
|
} else if (value) {
|
||||||
const manualColor = cellData?.color
|
const manualColor = cellData?.color
|
||||||
const style = getCellStyle(value || undefined)
|
const style = getCellStyle(value || undefined)
|
||||||
const textColor = manualColor
|
const textColor = manualColor
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface ScheduleTableProps {
|
|||||||
dragState: DragState | null
|
dragState: DragState | null
|
||||||
onCellPointerDown: (e: React.PointerEvent, personId: string, dayIdx: number, value: string) => void
|
onCellPointerDown: (e: React.PointerEvent, personId: string, dayIdx: number, value: string) => void
|
||||||
onSetCell: (personId: string, dayIdx: number, value: string | null) => void
|
onSetCell: (personId: string, dayIdx: number, value: string | null) => void
|
||||||
|
onSetCellHalfValue: (personId: string, dayIdx: number, target: 'day' | 'night', value: string | null) => void
|
||||||
onSetTunnelClosure: (dayIdx: number, text: string | null) => void
|
onSetTunnelClosure: (dayIdx: number, text: string | null) => void
|
||||||
onSetMetroClosure: (dayIdx: number, text: string | null) => void
|
onSetMetroClosure: (dayIdx: number, text: string | null) => void
|
||||||
onSetD8Closure: (dayIdx: number, text: string | null) => void
|
onSetD8Closure: (dayIdx: number, text: string | null) => void
|
||||||
@@ -47,18 +48,27 @@ interface ScheduleTableProps {
|
|||||||
showMetro: boolean
|
showMetro: boolean
|
||||||
showD8: boolean
|
showD8: boolean
|
||||||
showSazlt: boolean
|
showSazlt: boolean
|
||||||
|
showIT: boolean
|
||||||
hide162: boolean
|
hide162: boolean
|
||||||
|
hiddenTkbPeople: Set<string>
|
||||||
hiddenValues: Set<string>
|
hiddenValues: Set<string>
|
||||||
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number, selectedCells: SelectedCell[]) => void
|
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number, selectedCells: SelectedCell[]) => void
|
||||||
onTunnelContextMenu: (dayIdx: number, x: number, y: number) => void
|
onTunnelContextMenu: (dayIdx: number, x: number, y: number, selectedCells: SelectedCell[]) => void
|
||||||
onInfoRowContextMenu: (dayIdx: number, infoRowId: string, x: number, y: number) => void
|
onInfoRowContextMenu: (dayIdx: number, infoRowId: string, x: number, y: number, selectedCells: SelectedCell[]) => void
|
||||||
compareData?: ScheduleData | null
|
compareData?: ScheduleData | null
|
||||||
|
isReadOnly?: boolean
|
||||||
|
clipboardSource: { personId: string; dayIdx: number } | null
|
||||||
|
hasClipboard: boolean
|
||||||
|
onCopyCell: (personId: string, dayIdx: number) => void
|
||||||
|
onPasteCells: (targets: { personId: string; dayIdx: number }[]) => void
|
||||||
|
onClearClipboard: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
interface EditingCell {
|
interface EditingCell {
|
||||||
personId: string | '__tunnel__' | '__metro__' | '__d8__' | '__sazlt__'
|
personId: string | '__tunnel__' | '__metro__' | '__d8__' | '__sazlt__'
|
||||||
dayIdx: number
|
dayIdx: number
|
||||||
value: string
|
value: string
|
||||||
|
target?: 'cell' | 'day' | 'night' // person-cell only; default 'cell'
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- PersonRow (memoized) ----------
|
// ---------- PersonRow (memoized) ----------
|
||||||
@@ -84,6 +94,9 @@ const PersonRow = memo(function PersonRow({
|
|||||||
onCellDragSelectMove,
|
onCellDragSelectMove,
|
||||||
pmsAfterSet,
|
pmsAfterSet,
|
||||||
pmsMonthAtPos,
|
pmsMonthAtPos,
|
||||||
|
isReadOnly,
|
||||||
|
rowIndex,
|
||||||
|
clipboardSource,
|
||||||
}: {
|
}: {
|
||||||
person: Person
|
person: Person
|
||||||
dayIndex: DayInfo[]
|
dayIndex: DayInfo[]
|
||||||
@@ -95,7 +108,7 @@ const PersonRow = memo(function PersonRow({
|
|||||||
onCellPointerDown: (e: React.PointerEvent, personId: string, dayIdx: number, value: string) => void
|
onCellPointerDown: (e: React.PointerEvent, personId: string, dayIdx: number, value: string) => void
|
||||||
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number) => void
|
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number) => void
|
||||||
editingCell: EditingCell | null
|
editingCell: EditingCell | null
|
||||||
onStartEdit: (personId: string, dayIdx: number, currentValue: string) => void
|
onStartEdit: (personId: string, dayIdx: number, currentValue: string, target?: 'cell' | 'day' | 'night') => void
|
||||||
onEditChange: (value: string) => void
|
onEditChange: (value: string) => void
|
||||||
onEditConfirm: () => void
|
onEditConfirm: () => void
|
||||||
onEditCancel: () => void
|
onEditCancel: () => void
|
||||||
@@ -105,17 +118,28 @@ const PersonRow = memo(function PersonRow({
|
|||||||
onCellDragSelectMove: (personId: string, dayIdx: number) => void
|
onCellDragSelectMove: (personId: string, dayIdx: number) => void
|
||||||
pmsAfterSet: Set<number>
|
pmsAfterSet: Set<number>
|
||||||
pmsMonthAtPos: Map<number, number>
|
pmsMonthAtPos: Map<number, number>
|
||||||
|
isReadOnly: boolean
|
||||||
|
rowIndex: number
|
||||||
|
clipboardSource: { personId: string; dayIdx: number } | null
|
||||||
}) {
|
}) {
|
||||||
const isDragging = dragState?.personId === person.id
|
const isDragging = dragState?.personId === person.id
|
||||||
|
const zebra = rowIndex % 2 === 1 ? 'bg-slate-500/20' : ''
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex border-b border-slate-700/50" style={{ height: CELL_H }}>
|
<div className={`flex border-b border-slate-700/50 ${zebra}`} style={{ height: CELL_H }}>
|
||||||
{dayIndex.map((d, i) => {
|
{dayIndex.map((d, i) => {
|
||||||
const cellData = person.data[String(d.idx)]
|
const cellData = person.data[String(d.idx)]
|
||||||
const value = cellData?.v ?? ''
|
const value = cellData?.v ?? ''
|
||||||
const isColorOnly = !value && !!cellData?.color
|
const dayColor = cellData?.dayColor
|
||||||
|
const nightColor = cellData?.nightColor
|
||||||
|
const dayValue = cellData?.dayValue ?? ''
|
||||||
|
const nightValue = cellData?.nightValue ?? ''
|
||||||
|
const hasHalfMarks = !!(dayColor || nightColor || dayValue || nightValue)
|
||||||
|
const isColorOnly = !value && !dayValue && !nightValue && (!!cellData?.color || !!(dayColor || nightColor))
|
||||||
const isValueHidden = !!(value && hiddenValues.has(value)) || (isColorOnly && hiddenValues.has('__color_only__'))
|
const isValueHidden = !!(value && hiddenValues.has(value)) || (isColorOnly && hiddenValues.has('__color_only__'))
|
||||||
const style = getCellStyleForPerson(value || undefined, person.note)
|
const style = getCellStyleForPerson(value || undefined, person.note)
|
||||||
|
const dayStyleObj = getCellStyleForPerson(dayValue || undefined, person.note)
|
||||||
|
const nightStyleObj = getCellStyleForPerson(nightValue || undefined, person.note)
|
||||||
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
|
const isHoliday = holidays.has(`${d.year}-${d.month}-${d.day}`)
|
||||||
const isWeekend = d.weekend || isHoliday
|
const isWeekend = d.weekend || isHoliday
|
||||||
const commentKey = `${person.id}-${d.idx}`
|
const commentKey = `${person.id}-${d.idx}`
|
||||||
@@ -129,7 +153,7 @@ const PersonRow = memo(function PersonRow({
|
|||||||
|
|
||||||
const bgStyle: React.CSSProperties = {}
|
const bgStyle: React.CSSProperties = {}
|
||||||
let className = `flex items-center justify-center text-sm font-mono font-bold relative
|
let className = `flex items-center justify-center text-sm font-mono font-bold relative
|
||||||
border-r border-slate-700/20
|
border-r border-slate-500/40
|
||||||
${isMonthStart ? 'border-l-2 border-l-slate-500' : ''}`
|
${isMonthStart ? 'border-l-2 border-l-slate-500' : ''}`
|
||||||
|
|
||||||
if (isDragOriginal) {
|
if (isDragOriginal) {
|
||||||
@@ -140,8 +164,14 @@ const PersonRow = memo(function PersonRow({
|
|||||||
className += ' border-2 border-dashed border-amber-400/70'
|
className += ' border-2 border-dashed border-amber-400/70'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Split mode is active when any half data exists. In split mode, the
|
||||||
|
// legacy whole-cell value (v) and color are hidden; halves take over.
|
||||||
|
const renderAsSplit = hasHalfMarks && !isValueHidden
|
||||||
|
|
||||||
if (isValueHidden) {
|
if (isValueHidden) {
|
||||||
// Hidden by filter — show as empty
|
if (isWeekend) className += ' bg-slate-800/50'
|
||||||
|
} else if (renderAsSplit) {
|
||||||
|
// Halves render their own bg via overlays; keep weekend tint as fallback
|
||||||
if (isWeekend) className += ' bg-slate-800/50'
|
if (isWeekend) className += ' bg-slate-800/50'
|
||||||
} else {
|
} else {
|
||||||
const manualColor = cellData?.color
|
const manualColor = cellData?.color
|
||||||
@@ -161,11 +191,24 @@ const PersonRow = memo(function PersonRow({
|
|||||||
className += ' ring-2 ring-blue-500 ring-inset z-10'
|
className += ' ring-2 ring-blue-500 ring-inset z-10'
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasCellData = !!(value || cellData?.color)
|
const isClipboardSource = !!clipboardSource && clipboardSource.personId === person.id && clipboardSource.dayIdx === d.idx
|
||||||
|
if (isClipboardSource && !isSelected) {
|
||||||
|
className += ' z-10'
|
||||||
|
bgStyle.outline = '2px dashed #60a5fa'
|
||||||
|
bgStyle.outlineOffset = '-2px'
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasCellData = !!(value || cellData?.color || hasHalfMarks)
|
||||||
if (hasCellData && !isDragOriginal) {
|
if (hasCellData && !isDragOriginal) {
|
||||||
className += ' cursor-grab active:cursor-grabbing'
|
className += ' cursor-grab active:cursor-grabbing'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Compute half styles (bg + text color) for split mode
|
||||||
|
const nightBg = nightColor ?? nightStyleObj?.bg
|
||||||
|
const nightTextColor = nightColor ? getContrastColor(nightColor) : nightStyleObj?.text
|
||||||
|
const dayBg = dayColor ?? dayStyleObj?.bg
|
||||||
|
const dayTextColor = dayColor ? getContrastColor(dayColor) : dayStyleObj?.text
|
||||||
|
|
||||||
const dayCell = (
|
const dayCell = (
|
||||||
<div
|
<div
|
||||||
key={d.idx}
|
key={d.idx}
|
||||||
@@ -173,6 +216,7 @@ const PersonRow = memo(function PersonRow({
|
|||||||
style={{ width: CELL_W, height: CELL_H, ...bgStyle }}
|
style={{ width: CELL_W, height: CELL_H, ...bgStyle }}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
if (e.button === 2) return
|
if (e.button === 2) return
|
||||||
|
if (isReadOnly) return // pan handled by scroll container
|
||||||
if (e.ctrlKey || e.metaKey || e.shiftKey) return // let onClick handle modifier clicks
|
if (e.ctrlKey || e.metaKey || e.shiftKey) return // let onClick handle modifier clicks
|
||||||
if (hasCellData) {
|
if (hasCellData) {
|
||||||
onCellPointerDown(e, person.id, d.idx, value || '●')
|
onCellPointerDown(e, person.id, d.idx, value || '●')
|
||||||
@@ -182,18 +226,24 @@ const PersonRow = memo(function PersonRow({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onPointerEnter={(e) => {
|
onPointerEnter={(e) => {
|
||||||
if (e.buttons === 1 && !hasCellData) {
|
if (!isReadOnly && e.buttons === 1 && !hasCellData) {
|
||||||
onCellDragSelectMove(person.id, d.idx)
|
onCellDragSelectMove(person.id, d.idx)
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (e.button === 2) return
|
if (e.button === 2) return
|
||||||
if (dragState) return
|
if (isReadOnly || dragState) return
|
||||||
onCellClick(person.id, d.idx, e.ctrlKey || e.metaKey, e.shiftKey)
|
onCellClick(person.id, d.idx, e.ctrlKey || e.metaKey, e.shiftKey)
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => {
|
onDoubleClick={(e) => {
|
||||||
if (dragState) return
|
if (isReadOnly || dragState) return
|
||||||
onStartEdit(person.id, d.idx, value)
|
if (renderAsSplit) {
|
||||||
|
const rect = e.currentTarget.getBoundingClientRect()
|
||||||
|
const isLeft = e.clientX - rect.left < rect.width / 2
|
||||||
|
onStartEdit(person.id, d.idx, isLeft ? dayValue : nightValue, isLeft ? 'day' : 'night')
|
||||||
|
} else {
|
||||||
|
onStartEdit(person.id, d.idx, value, 'cell')
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -205,10 +255,31 @@ const PersonRow = memo(function PersonRow({
|
|||||||
(hasCellComment ? `\n💬 ${cellComments.get(commentKey)}` : '')
|
(hasCellComment ? `\n💬 ${cellComments.get(commentKey)}` : '')
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{/* Split mode: each half renders its own bg + text. Day = LEFT, Night = RIGHT. */}
|
||||||
|
{renderAsSplit && (
|
||||||
|
<>
|
||||||
|
<div className="absolute top-0 left-0 h-full flex items-center justify-center pointer-events-none border-r border-slate-500/40"
|
||||||
|
style={{
|
||||||
|
width: '50%',
|
||||||
|
backgroundColor: dayBg ?? 'transparent',
|
||||||
|
color: dayTextColor ?? undefined,
|
||||||
|
}}>
|
||||||
|
{dayValue && <span className="font-mono font-bold text-[11px]">{dayValue}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="absolute top-0 right-0 h-full flex items-center justify-center pointer-events-none"
|
||||||
|
style={{
|
||||||
|
width: '50%',
|
||||||
|
backgroundColor: nightBg ?? 'transparent',
|
||||||
|
color: nightTextColor ?? undefined,
|
||||||
|
}}>
|
||||||
|
{nightValue && <span className="font-mono font-bold text-[11px]">{nightValue}</span>}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{isEditing ? (
|
{isEditing ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
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"
|
className="w-full h-full bg-white text-[#0f172a] text-center text-sm font-mono font-bold outline-none border-2 border-blue-500 relative z-10"
|
||||||
style={{ width: CELL_W, height: CELL_H }}
|
style={{ width: CELL_W, height: CELL_H }}
|
||||||
value={editingCell!.value}
|
value={editingCell!.value}
|
||||||
onChange={(e) => onEditChange(e.target.value)}
|
onChange={(e) => onEditChange(e.target.value)}
|
||||||
@@ -223,9 +294,9 @@ const PersonRow = memo(function PersonRow({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{isDragPreview ? (
|
{isDragPreview ? (
|
||||||
<span className="truncate text-amber-300 font-semibold">{dragState!.value}</span>
|
<span className="truncate text-amber-300 font-semibold relative z-10">{dragState!.value}</span>
|
||||||
) : value && !isValueHidden ? (
|
) : value && !isValueHidden && !renderAsSplit ? (
|
||||||
<span className="truncate font-medium">{value}</span>
|
<span className="truncate font-medium relative z-10">{value}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@@ -259,12 +330,13 @@ const PersonRow = memo(function PersonRow({
|
|||||||
<div
|
<div
|
||||||
key={`pms-${pmsMonth}`}
|
key={`pms-${pmsMonth}`}
|
||||||
className={`flex items-center justify-center text-sm font-mono font-bold relative
|
className={`flex items-center justify-center text-sm font-mono font-bold relative
|
||||||
border-r border-slate-600 cursor-text hover:brightness-125
|
border-r border-slate-600
|
||||||
|
${isReadOnly ? 'cursor-default' : 'cursor-text hover:brightness-125'}
|
||||||
${!pmsColor ? 'pms-cell' : ''}`}
|
${!pmsColor ? 'pms-cell' : ''}`}
|
||||||
style={pmsStyle}
|
style={pmsStyle}
|
||||||
onDoubleClick={() => onStartEdit(person.id, pmsDayIdx, pmsValue)}
|
onDoubleClick={() => { if (!isReadOnly) onStartEdit(person.id, pmsDayIdx, pmsValue) }}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isPmsEditing) onStartEdit(person.id, pmsDayIdx, pmsValue)
|
if (!isReadOnly && !isPmsEditing) onStartEdit(person.id, pmsDayIdx, pmsValue)
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -275,7 +347,7 @@ const PersonRow = memo(function PersonRow({
|
|||||||
{isPmsEditing ? (
|
{isPmsEditing ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full h-full bg-white text-slate-900 text-center text-sm font-mono font-bold outline-none border-2 border-purple-500"
|
className="w-full h-full bg-white text-[#0f172a] text-center text-sm font-mono font-bold outline-none border-2 border-purple-500"
|
||||||
style={{ width: CELL_W, height: CELL_H }}
|
style={{ width: CELL_W, height: CELL_H }}
|
||||||
value={editingCell!.value}
|
value={editingCell!.value}
|
||||||
onChange={(e) => onEditChange(e.target.value)}
|
onChange={(e) => onEditChange(e.target.value)}
|
||||||
@@ -309,10 +381,12 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
dayIndex, people, tunnelClosures, tunnelColors,
|
dayIndex, people, tunnelClosures, tunnelColors,
|
||||||
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
|
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
|
||||||
dayComments, cellComments,
|
dayComments, cellComments,
|
||||||
dragState, onCellPointerDown, onSetCell, onSetTunnelClosure,
|
dragState, onCellPointerDown, onSetCell, onSetCellHalfValue, onSetTunnelClosure,
|
||||||
onSetMetroClosure, onSetD8Closure, onSetSazltClosure,
|
onSetMetroClosure, onSetD8Closure, onSetSazltClosure,
|
||||||
showMetro, showD8, showSazlt, hide162, hiddenValues,
|
showMetro, showD8, showSazlt, showIT, hide162, hiddenTkbPeople, hiddenValues,
|
||||||
scrollRef, onContextMenu, onTunnelContextMenu, onInfoRowContextMenu, compareData,
|
scrollRef, onContextMenu, onTunnelContextMenu, onInfoRowContextMenu, compareData,
|
||||||
|
isReadOnly = false,
|
||||||
|
clipboardSource, hasClipboard, onCopyCell, onPasteCells, onClearClipboard,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
const [editingCell, setEditingCell] = useState<EditingCell | null>(null)
|
const [editingCell, setEditingCell] = useState<EditingCell | null>(null)
|
||||||
@@ -372,9 +446,11 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
|
|
||||||
const HIDE_162_NAMES = ['Pauzer Libor', 'Vörös Pavel', 'Janouš Petr', 'Franek Lukáš', 'Svoboda Daniel']
|
const HIDE_162_NAMES = ['Pauzer Libor', 'Vörös Pavel', 'Janouš Petr', 'Franek Lukáš', 'Svoboda Daniel']
|
||||||
const tkbPeople = useMemo(() => {
|
const tkbPeople = useMemo(() => {
|
||||||
const all = people.filter(p => p.group === 'TKB')
|
let all = people.filter(p => p.group === 'TKB')
|
||||||
return hide162 ? all.filter(p => !HIDE_162_NAMES.includes(p.name)) : all
|
if (hide162) all = all.filter(p => !HIDE_162_NAMES.includes(p.name))
|
||||||
}, [people, hide162])
|
if (hiddenTkbPeople.size > 0) all = all.filter(p => !hiddenTkbPeople.has(p.id))
|
||||||
|
return all
|
||||||
|
}, [people, hide162, hiddenTkbPeople])
|
||||||
const itPeople = useMemo(() => people.filter(p => p.group === 'IT'), [people])
|
const itPeople = useMemo(() => people.filter(p => p.group === 'IT'), [people])
|
||||||
|
|
||||||
// ---------- PMS (Práce mimo směnu) month-end positions ----------
|
// ---------- PMS (Práce mimo směnu) month-end positions ----------
|
||||||
@@ -425,8 +501,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
|
|
||||||
// ---------- Editing handlers ----------
|
// ---------- Editing handlers ----------
|
||||||
|
|
||||||
const onStartEdit = useCallback((personId: string, dayIdx: number, currentValue: string) => {
|
const onStartEdit = useCallback((personId: string, dayIdx: number, currentValue: string, target: 'cell' | 'day' | 'night' = 'cell') => {
|
||||||
setEditingCell({ personId, dayIdx, value: currentValue })
|
setEditingCell({ personId, dayIdx, value: currentValue, target })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const onStartEditTunnel = useCallback((dayIdx: number, currentValue: string) => {
|
const onStartEditTunnel = useCallback((dayIdx: number, currentValue: string) => {
|
||||||
@@ -461,10 +537,15 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
} else if (editingCell.personId === '__sazlt__') {
|
} else if (editingCell.personId === '__sazlt__') {
|
||||||
onSetSazltClosure(editingCell.dayIdx, trimmed || null)
|
onSetSazltClosure(editingCell.dayIdx, trimmed || null)
|
||||||
} else {
|
} else {
|
||||||
onSetCell(editingCell.personId, editingCell.dayIdx, trimmed || null)
|
const target = editingCell.target ?? 'cell'
|
||||||
|
if (target === 'cell') {
|
||||||
|
onSetCell(editingCell.personId, editingCell.dayIdx, trimmed || null)
|
||||||
|
} else {
|
||||||
|
onSetCellHalfValue(editingCell.personId, editingCell.dayIdx, target, trimmed || null)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setEditingCell(null)
|
setEditingCell(null)
|
||||||
}, [editingCell, onSetCell, onSetTunnelClosure, onSetMetroClosure, onSetD8Closure, onSetSazltClosure])
|
}, [editingCell, onSetCell, onSetCellHalfValue, onSetTunnelClosure, onSetMetroClosure, onSetD8Closure, onSetSazltClosure])
|
||||||
|
|
||||||
const onEditCancel = useCallback(() => {
|
const onEditCancel = useCallback(() => {
|
||||||
setEditingCell(null)
|
setEditingCell(null)
|
||||||
@@ -479,31 +560,41 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
|
|
||||||
const cellKey = (personId: string, dayIdx: number) => `${personId}-${dayIdx}`
|
const cellKey = (personId: string, dayIdx: number) => `${personId}-${dayIdx}`
|
||||||
|
|
||||||
|
// Unified row-order that includes info rows + person rows (used for rectangle selection).
|
||||||
|
const allRowKeys = useMemo(() => {
|
||||||
|
const rows: string[] = ['__tunnel__']
|
||||||
|
if (showSazlt) rows.push('__sazlt__')
|
||||||
|
if (showMetro) rows.push('__metro__')
|
||||||
|
if (showD8) rows.push('__d8__')
|
||||||
|
rows.push(...tkbPeople.map(p => p.id), ...itPeople.map(p => p.id))
|
||||||
|
return rows
|
||||||
|
}, [showSazlt, showMetro, showD8, tkbPeople, itPeople])
|
||||||
|
|
||||||
const computeRectSelection = useCallback((
|
const computeRectSelection = useCallback((
|
||||||
anchor: { personId: string; dayIdx: number },
|
anchor: { personId: string; dayIdx: number },
|
||||||
current: { personId: string; dayIdx: number },
|
current: { personId: string; dayIdx: number },
|
||||||
): Set<string> => {
|
): Set<string> => {
|
||||||
const anchorPersonIdx = people.findIndex(p => p.id === anchor.personId)
|
const anchorIdx = allRowKeys.indexOf(anchor.personId)
|
||||||
const currentPersonIdx = people.findIndex(p => p.id === current.personId)
|
const currentIdx = allRowKeys.indexOf(current.personId)
|
||||||
if (anchorPersonIdx < 0 || currentPersonIdx < 0) return new Set()
|
if (anchorIdx < 0 || currentIdx < 0) return new Set()
|
||||||
|
|
||||||
const anchorDayPos = dayIndex.findIndex(d => d.idx === anchor.dayIdx)
|
const anchorDayPos = dayIndex.findIndex(d => d.idx === anchor.dayIdx)
|
||||||
const currentDayPos = dayIndex.findIndex(d => d.idx === current.dayIdx)
|
const currentDayPos = dayIndex.findIndex(d => d.idx === current.dayIdx)
|
||||||
if (anchorDayPos < 0 || currentDayPos < 0) return new Set()
|
if (anchorDayPos < 0 || currentDayPos < 0) return new Set()
|
||||||
|
|
||||||
const minP = Math.min(anchorPersonIdx, currentPersonIdx)
|
const minP = Math.min(anchorIdx, currentIdx)
|
||||||
const maxP = Math.max(anchorPersonIdx, currentPersonIdx)
|
const maxP = Math.max(anchorIdx, currentIdx)
|
||||||
const minD = Math.min(anchorDayPos, currentDayPos)
|
const minD = Math.min(anchorDayPos, currentDayPos)
|
||||||
const maxD = Math.max(anchorDayPos, currentDayPos)
|
const maxD = Math.max(anchorDayPos, currentDayPos)
|
||||||
|
|
||||||
const sel = new Set<string>()
|
const sel = new Set<string>()
|
||||||
for (let pi = minP; pi <= maxP; pi++) {
|
for (let pi = minP; pi <= maxP; pi++) {
|
||||||
for (let di = minD; di <= maxD; di++) {
|
for (let di = minD; di <= maxD; di++) {
|
||||||
sel.add(cellKey(people[pi].id, dayIndex[di].idx))
|
sel.add(cellKey(allRowKeys[pi], dayIndex[di].idx))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return sel
|
return sel
|
||||||
}, [people, dayIndex])
|
}, [allRowKeys, dayIndex])
|
||||||
|
|
||||||
const onCellClick = useCallback((personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => {
|
const onCellClick = useCallback((personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => {
|
||||||
const key = cellKey(personId, dayIdx)
|
const key = cellKey(personId, dayIdx)
|
||||||
@@ -571,17 +662,76 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
prevDragRef.current = dragState
|
prevDragRef.current = dragState
|
||||||
}, [dragState])
|
}, [dragState])
|
||||||
|
|
||||||
// Clear selection on Escape
|
// Keyboard: Escape clears selection + clipboard, Ctrl+C/V copy/paste
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKey = (e: KeyboardEvent) => {
|
const handleKey = (e: KeyboardEvent) => {
|
||||||
|
// Skip if focus is in an input/textarea
|
||||||
|
const tag = (e.target as HTMLElement)?.tagName
|
||||||
|
if (tag === 'INPUT' || tag === 'TEXTAREA') return
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setSelectedCells(new Set())
|
setSelectedCells(new Set())
|
||||||
selectionAnchorRef.current = null
|
selectionAnchorRef.current = null
|
||||||
|
onClearClipboard()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (isReadOnly) return
|
||||||
|
const isCtrl = e.ctrlKey || e.metaKey
|
||||||
|
if (isCtrl && (e.key === 'c' || e.key === 'C')) {
|
||||||
|
if (selectedCells.size === 1) {
|
||||||
|
const key = Array.from(selectedCells)[0]
|
||||||
|
const lastDash = key.lastIndexOf('-')
|
||||||
|
const personId = key.substring(0, lastDash)
|
||||||
|
const dayIdx = Number(key.substring(lastDash + 1))
|
||||||
|
onCopyCell(personId, dayIdx)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
|
} else if (isCtrl && (e.key === 'v' || e.key === 'V')) {
|
||||||
|
if (hasClipboard && selectedCells.size > 0) {
|
||||||
|
const targets: { personId: string; dayIdx: number }[] = []
|
||||||
|
selectedCells.forEach(key => {
|
||||||
|
const lastDash = key.lastIndexOf('-')
|
||||||
|
targets.push({ personId: key.substring(0, lastDash), dayIdx: Number(key.substring(lastDash + 1)) })
|
||||||
|
})
|
||||||
|
onPasteCells(targets)
|
||||||
|
e.preventDefault()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('keydown', handleKey)
|
document.addEventListener('keydown', handleKey)
|
||||||
return () => document.removeEventListener('keydown', handleKey)
|
return () => document.removeEventListener('keydown', handleKey)
|
||||||
}, [])
|
}, [selectedCells, hasClipboard, isReadOnly, onCopyCell, onPasteCells, onClearClipboard])
|
||||||
|
|
||||||
|
// Pan/grab behavior for read-only mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isReadOnly) return
|
||||||
|
const el = scrollRef.current
|
||||||
|
if (!el) return
|
||||||
|
let startX = 0, startScrollLeft = 0, isPanning = false
|
||||||
|
|
||||||
|
const onDown = (e: PointerEvent) => {
|
||||||
|
if (e.button !== 0) return
|
||||||
|
isPanning = true
|
||||||
|
startX = e.clientX
|
||||||
|
startScrollLeft = el.scrollLeft
|
||||||
|
}
|
||||||
|
const onMove = (e: PointerEvent) => {
|
||||||
|
if (!isPanning) return
|
||||||
|
el.scrollLeft = startScrollLeft - (e.clientX - startX)
|
||||||
|
}
|
||||||
|
const onUp = () => { isPanning = false }
|
||||||
|
|
||||||
|
el.addEventListener('pointerdown', onDown, { capture: true })
|
||||||
|
document.addEventListener('pointermove', onMove)
|
||||||
|
document.addEventListener('pointerup', onUp)
|
||||||
|
document.addEventListener('pointercancel', onUp)
|
||||||
|
return () => {
|
||||||
|
el.removeEventListener('pointerdown', onDown, { capture: true })
|
||||||
|
document.removeEventListener('pointermove', onMove)
|
||||||
|
document.removeEventListener('pointerup', onUp)
|
||||||
|
document.removeEventListener('pointercancel', onUp)
|
||||||
|
}
|
||||||
|
}, [isReadOnly, scrollRef])
|
||||||
|
|
||||||
// Context menu handler wrapping the prop to include selected cells
|
// Context menu handler wrapping the prop to include selected cells
|
||||||
const handlePersonContextMenu = useCallback((dayIdx: number, personId: string | null, x: number, y: number) => {
|
const handlePersonContextMenu = useCallback((dayIdx: number, personId: string | null, x: number, y: number) => {
|
||||||
@@ -608,6 +758,22 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
}
|
}
|
||||||
}, [selectedCells, onContextMenu])
|
}, [selectedCells, onContextMenu])
|
||||||
|
|
||||||
|
// Context menu handler for info rows — packages current selection like handlePersonContextMenu
|
||||||
|
const handleInfoRowContext = useCallback((rowId: string | null, dayIdx: number, x: number, y: number) => {
|
||||||
|
const key = `${rowId ?? '__tunnel__'}-${dayIdx}`
|
||||||
|
const cells: SelectedCell[] = []
|
||||||
|
selectedCells.forEach(k => {
|
||||||
|
const lastDash = k.lastIndexOf('-')
|
||||||
|
cells.push({ personId: k.substring(0, lastDash), dayIdx: Number(k.substring(lastDash + 1)) })
|
||||||
|
})
|
||||||
|
const toPass = selectedCells.has(key) && selectedCells.size > 1 ? cells : [{ personId: rowId ?? '__tunnel__', dayIdx }]
|
||||||
|
if (rowId === null) {
|
||||||
|
onTunnelContextMenu(dayIdx, x, y, toPass)
|
||||||
|
} else {
|
||||||
|
onInfoRowContextMenu(dayIdx, rowId, x, y, toPass)
|
||||||
|
}
|
||||||
|
}, [selectedCells, onTunnelContextMenu, onInfoRowContextMenu])
|
||||||
|
|
||||||
// ---------- Drag overlay ----------
|
// ---------- Drag overlay ----------
|
||||||
|
|
||||||
const dragOverlay = useMemo(() => {
|
const dragOverlay = useMemo(() => {
|
||||||
@@ -727,7 +893,6 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
rowId: '__tunnel__' | '__metro__' | '__d8__' | '__sazlt__',
|
rowId: '__tunnel__' | '__metro__' | '__d8__' | '__sazlt__',
|
||||||
closures: Map<number, string>,
|
closures: Map<number, string>,
|
||||||
colors: Map<number, string>,
|
colors: Map<number, string>,
|
||||||
defaultBgClass: string,
|
|
||||||
borderClass: string,
|
borderClass: string,
|
||||||
editBorderColor: string,
|
editBorderColor: string,
|
||||||
label: string,
|
label: string,
|
||||||
@@ -745,21 +910,48 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
if (closureColor) {
|
if (closureColor) {
|
||||||
rowBg.backgroundColor = closureColor
|
rowBg.backgroundColor = closureColor
|
||||||
rowBg.color = getContrastColor(closureColor)
|
rowBg.color = getContrastColor(closureColor)
|
||||||
|
} else if (closureVal) {
|
||||||
|
const valStyle = getCellStyle(closureVal)
|
||||||
|
if (valStyle) {
|
||||||
|
rowBg.backgroundColor = valStyle.bg
|
||||||
|
rowBg.color = valStyle.text
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cellSelKey = `${rowId}-${d.idx}`
|
||||||
|
const isInfoSelected = selectedCells.has(cellSelKey)
|
||||||
|
const isInfoClipboardSource = !!clipboardSource && clipboardSource.personId === rowId && clipboardSource.dayIdx === d.idx
|
||||||
|
const infoExtraStyle: React.CSSProperties = {}
|
||||||
|
if (isInfoClipboardSource && !isInfoSelected) {
|
||||||
|
infoExtraStyle.outline = '2px dashed #60a5fa'
|
||||||
|
infoExtraStyle.outlineOffset = '-2px'
|
||||||
|
}
|
||||||
const cells: React.ReactNode[] = []
|
const cells: React.ReactNode[] = []
|
||||||
cells.push(
|
cells.push(
|
||||||
<div
|
<div
|
||||||
key={d.idx}
|
key={d.idx}
|
||||||
className={`flex items-center justify-center text-xs font-mono font-bold relative
|
className={`flex items-center justify-center text-xs font-mono font-bold relative
|
||||||
border-r border-slate-700/20
|
border-r border-slate-500/40
|
||||||
${!closureColor && closureVal ? `${defaultBgClass}` : !closureColor ? (isOff ? 'bg-slate-700/30' : 'bg-slate-800/30') : ''}
|
${!closureColor && !getCellStyle(closureVal) ? (isOff ? 'bg-slate-700/30' : 'bg-slate-800/30') : ''}
|
||||||
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
|
${d.day === 1 ? 'border-l-2 border-l-slate-500' : ''}
|
||||||
cursor-pointer hover:brightness-125
|
${isReadOnly ? 'cursor-default' : 'cursor-pointer hover:brightness-125'}
|
||||||
|
${isInfoSelected ? 'ring-2 ring-blue-500 ring-inset z-10' : ''}
|
||||||
`}
|
`}
|
||||||
style={{ width: CELL_W, height: 28, ...rowBg }}
|
style={{ width: CELL_W, height: 28, ...rowBg, ...infoExtraStyle }}
|
||||||
onClick={() => {
|
onPointerDown={(e) => {
|
||||||
if (!isEditingThis) onStartEditFn(d.idx, closureVal)
|
if (e.button === 2 || isReadOnly) return
|
||||||
|
if (e.ctrlKey || e.metaKey || e.shiftKey) return
|
||||||
|
onCellDragSelectStart(rowId, d.idx)
|
||||||
|
}}
|
||||||
|
onPointerEnter={(e) => {
|
||||||
|
if (!isReadOnly && e.buttons === 1) onCellDragSelectMove(rowId, d.idx)
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
if (e.button === 2 || isReadOnly) return
|
||||||
|
onCellClick(rowId, d.idx, e.ctrlKey || e.metaKey, e.shiftKey)
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => {
|
||||||
|
if (!isReadOnly && !isEditingThis) onStartEditFn(d.idx, closureVal)
|
||||||
}}
|
}}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
@@ -770,7 +962,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
{isEditingThis ? (
|
{isEditingThis ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className={`w-full h-full bg-white text-slate-900 text-center text-xs font-mono font-bold outline-none border-2 ${editBorderColor}`}
|
className={`w-full h-full bg-white text-[#0f172a] text-center text-xs font-mono font-bold outline-none border-2 ${editBorderColor}`}
|
||||||
value={editingCell!.value}
|
value={editingCell!.value}
|
||||||
onChange={(e) => onEditChange(e.target.value)}
|
onChange={(e) => onEditChange(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@@ -843,8 +1035,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
{renderSectionHeaderLabel('Pohotovost TKB', 'bg-indigo-700/60', 'text-indigo-100')}
|
{renderSectionHeaderLabel('Pohotovost TKB', 'bg-indigo-700/60', 'text-indigo-100')}
|
||||||
|
|
||||||
{/* TKB person names */}
|
{/* TKB person names */}
|
||||||
{tkbPeople.map((person) => (
|
{tkbPeople.map((person, idx) => (
|
||||||
<div key={person.id} className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: CELL_H }}>
|
<div key={person.id} className={`border-b border-slate-700 flex items-center px-3 ${idx % 2 === 1 ? 'bg-slate-500/20' : 'bg-slate-800'}`} style={{ height: CELL_H }}>
|
||||||
<span className="text-xs text-slate-200 whitespace-nowrap truncate">
|
<span className="text-xs text-slate-200 whitespace-nowrap truncate">
|
||||||
{person.name}
|
{person.name}
|
||||||
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
|
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
|
||||||
@@ -853,11 +1045,11 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
))}
|
))}
|
||||||
|
|
||||||
{/* IT section header */}
|
{/* IT section header */}
|
||||||
{renderSectionHeaderLabel('Pohotovost IT', 'bg-teal-700/60', 'text-teal-100')}
|
{showIT && renderSectionHeaderLabel('Pohotovost IT', 'bg-teal-700/60', 'text-teal-100')}
|
||||||
|
|
||||||
{/* IT person names */}
|
{/* IT person names */}
|
||||||
{itPeople.map((person) => (
|
{showIT && itPeople.map((person, idx) => (
|
||||||
<div key={person.id} className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: CELL_H }}>
|
<div key={person.id} className={`border-b border-slate-700 flex items-center px-3 ${idx % 2 === 1 ? 'bg-slate-500/20' : 'bg-slate-800'}`} style={{ height: CELL_H }}>
|
||||||
<span className="text-xs text-slate-200 whitespace-nowrap truncate">
|
<span className="text-xs text-slate-200 whitespace-nowrap truncate">
|
||||||
{person.name}
|
{person.name}
|
||||||
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
|
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
|
||||||
@@ -867,7 +1059,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* === Scrollable grid === */}
|
{/* === Scrollable grid === */}
|
||||||
<div ref={scrollRef} className="overflow-x-auto flex-1">
|
<div ref={scrollRef} className={`overflow-x-auto flex-1 ${isReadOnly ? 'cursor-grab active:cursor-grabbing' : ''}`}>
|
||||||
<div style={{ width: totalGridW, position: 'relative' }}>
|
<div style={{ width: totalGridW, position: 'relative' }}>
|
||||||
|
|
||||||
{/* Month header row */}
|
{/* Month header row */}
|
||||||
@@ -1029,23 +1221,23 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
|
|
||||||
{/* TKB info row */}
|
{/* TKB info row */}
|
||||||
{renderInfoRowCells('__tunnel__', tunnelClosures, tunnelColors,
|
{renderInfoRowCells('__tunnel__', tunnelClosures, tunnelColors,
|
||||||
'bg-orange-700/40 text-orange-200', 'border-slate-700', 'border-orange-400', 'TKB',
|
'border-slate-700', 'border-orange-400', 'TKB',
|
||||||
onTunnelContextMenu, onStartEditTunnel)}
|
(dayIdx, x, y) => handleInfoRowContext(null, dayIdx, x, y), onStartEditTunnel)}
|
||||||
|
|
||||||
{/* SAZLT info row */}
|
{/* SAZLT info row */}
|
||||||
{showSazlt && renderInfoRowCells('__sazlt__', sazltClosures, sazltColors,
|
{showSazlt && renderInfoRowCells('__sazlt__', sazltClosures, sazltColors,
|
||||||
'bg-amber-700/40 text-amber-200', 'border-slate-700', 'border-amber-400', 'SAZLT',
|
'border-slate-700', 'border-amber-400', 'SAZLT',
|
||||||
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'sazlt', x, y), onStartEditSazlt)}
|
(dayIdx, x, y) => handleInfoRowContext('sazlt', dayIdx, x, y), onStartEditSazlt)}
|
||||||
|
|
||||||
{/* Metro info row */}
|
{/* Metro info row */}
|
||||||
{showMetro && renderInfoRowCells('__metro__', metroClosures, metroColors,
|
{showMetro && renderInfoRowCells('__metro__', metroClosures, metroColors,
|
||||||
'bg-blue-700/40 text-blue-200', 'border-slate-700', 'border-blue-400', 'Metro',
|
'border-slate-700', 'border-blue-400', 'Metro',
|
||||||
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'metro', x, y), onStartEditMetro)}
|
(dayIdx, x, y) => handleInfoRowContext('metro', dayIdx, x, y), onStartEditMetro)}
|
||||||
|
|
||||||
{/* D8 info row */}
|
{/* D8 info row */}
|
||||||
{showD8 && renderInfoRowCells('__d8__', d8Closures, d8Colors,
|
{showD8 && renderInfoRowCells('__d8__', d8Closures, d8Colors,
|
||||||
'bg-green-700/40 text-green-200', 'border-slate-600', 'border-green-400', 'D8',
|
'border-slate-600', 'border-green-400', 'D8',
|
||||||
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'd8', x, y), onStartEditD8)}
|
(dayIdx, x, y) => handleInfoRowContext('d8', dayIdx, x, y), onStartEditD8)}
|
||||||
|
|
||||||
{/* Data rows container (with overlay) */}
|
{/* Data rows container (with overlay) */}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
@@ -1054,9 +1246,10 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
{renderSectionHeaderBar('bg-indigo-700/60')}
|
{renderSectionHeaderBar('bg-indigo-700/60')}
|
||||||
|
|
||||||
{/* TKB person rows */}
|
{/* TKB person rows */}
|
||||||
{tkbPeople.map((person) => (
|
{tkbPeople.map((person, idx) => (
|
||||||
<PersonRow
|
<PersonRow
|
||||||
key={person.id}
|
key={person.id}
|
||||||
|
rowIndex={idx}
|
||||||
person={person}
|
person={person}
|
||||||
dayIndex={dayIndex}
|
dayIndex={dayIndex}
|
||||||
dragState={dragState}
|
dragState={dragState}
|
||||||
@@ -1077,16 +1270,19 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
onCellDragSelectMove={onCellDragSelectMove}
|
onCellDragSelectMove={onCellDragSelectMove}
|
||||||
pmsAfterSet={pmsAfterSet}
|
pmsAfterSet={pmsAfterSet}
|
||||||
pmsMonthAtPos={pmsMonthAtPos}
|
pmsMonthAtPos={pmsMonthAtPos}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
clipboardSource={clipboardSource}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* IT section header bar */}
|
{/* IT section header bar */}
|
||||||
{renderSectionHeaderBar('bg-teal-700/60')}
|
{showIT && renderSectionHeaderBar('bg-teal-700/60')}
|
||||||
|
|
||||||
{/* IT person rows */}
|
{/* IT person rows */}
|
||||||
{itPeople.map((person) => (
|
{showIT && itPeople.map((person, idx) => (
|
||||||
<PersonRow
|
<PersonRow
|
||||||
key={person.id}
|
key={person.id}
|
||||||
|
rowIndex={idx}
|
||||||
person={person}
|
person={person}
|
||||||
dayIndex={dayIndex}
|
dayIndex={dayIndex}
|
||||||
dragState={dragState}
|
dragState={dragState}
|
||||||
@@ -1107,6 +1303,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
onCellDragSelectMove={onCellDragSelectMove}
|
onCellDragSelectMove={onCellDragSelectMove}
|
||||||
pmsAfterSet={pmsAfterSet}
|
pmsAfterSet={pmsAfterSet}
|
||||||
pmsMonthAtPos={pmsMonthAtPos}
|
pmsMonthAtPos={pmsMonthAtPos}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
|
clipboardSource={clipboardSource}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
import { getAllCellStyles } from './cellColors'
|
import { getAllCellStyles } from './cellColors'
|
||||||
|
|
||||||
const MONTH_BUTTONS = [
|
const MONTH_BUTTONS = [
|
||||||
@@ -15,22 +16,41 @@ const MONTH_BUTTONS = [
|
|||||||
{ month: 12, label: 'Pro' },
|
{ month: 12, label: 'Pro' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
function formatStamp(d: Date | string | null | undefined): string {
|
||||||
|
if (!d) return ''
|
||||||
|
const dt = typeof d === 'string' ? new Date(d) : d
|
||||||
|
if (isNaN(dt.getTime())) return ''
|
||||||
|
const hh = String(dt.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(dt.getMinutes()).padStart(2, '0')
|
||||||
|
const today = new Date()
|
||||||
|
const sameDay = dt.getFullYear() === today.getFullYear() && dt.getMonth() === today.getMonth() && dt.getDate() === today.getDate()
|
||||||
|
return sameDay ? `${hh}:${mm}` : `${dt.getDate()}. ${dt.getMonth() + 1}. ${hh}:${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
interface ToolbarProps {
|
interface ToolbarProps {
|
||||||
onUndo: () => void
|
onUndo: () => void
|
||||||
canUndo: boolean
|
canUndo: boolean
|
||||||
onSave: () => void
|
onSave: () => void
|
||||||
onSaveAs: () => void
|
onSaveAs: () => void
|
||||||
saveStatus: 'idle' | 'saving' | 'saved' | 'error'
|
saveStatus: 'idle' | 'saving' | 'saved' | 'error'
|
||||||
|
dirty?: boolean // unsaved changes (auto-save pending)
|
||||||
|
lastSavedAt?: Date | null
|
||||||
|
onPublish?: () => void
|
||||||
|
publishStatus?: 'idle' | 'publishing' | 'published' | 'error'
|
||||||
|
publishedAt?: string | null
|
||||||
|
hasUnpublished?: boolean // draft differs from what viewers see
|
||||||
onScrollToMonth: (month: number) => void
|
onScrollToMonth: (month: number) => void
|
||||||
activeMonth?: number
|
activeMonth?: number
|
||||||
showMetro: boolean
|
showMetro: boolean
|
||||||
showD8: boolean
|
showD8: boolean
|
||||||
showSazlt: boolean
|
showSazlt: boolean
|
||||||
|
showIT: boolean
|
||||||
hide162: boolean
|
hide162: boolean
|
||||||
theme: 'dark' | 'light'
|
theme: 'dark' | 'light'
|
||||||
onToggleMetro: () => void
|
onToggleMetro: () => void
|
||||||
onToggleD8: () => void
|
onToggleD8: () => void
|
||||||
onToggleSazlt: () => void
|
onToggleSazlt: () => void
|
||||||
|
onToggleIT: () => void
|
||||||
onToggle162: () => void
|
onToggle162: () => void
|
||||||
onToggleTheme: () => void
|
onToggleTheme: () => void
|
||||||
hiddenValues: Set<string>
|
hiddenValues: Set<string>
|
||||||
@@ -38,9 +58,16 @@ interface ToolbarProps {
|
|||||||
diffFileName?: string | null
|
diffFileName?: string | null
|
||||||
onCloseDiff?: () => void
|
onCloseDiff?: () => void
|
||||||
onExportPdf?: (month: number) => void
|
onExportPdf?: (month: number) => void
|
||||||
|
onExportExcel?: (month: number) => void
|
||||||
onShowProposals?: () => void
|
onShowProposals?: () => void
|
||||||
onCheckFpd?: () => void
|
onCheckFpd?: () => void
|
||||||
isReadOnly?: boolean
|
isReadOnly?: boolean
|
||||||
|
// People filter
|
||||||
|
tkbPeopleList?: { id: string; name: string; note?: string }[]
|
||||||
|
hiddenTkbPeople?: Set<string>
|
||||||
|
onToggleTkbPerson?: (id: string) => void
|
||||||
|
onSetAllTkbVisible?: () => void
|
||||||
|
onSetOnlyTkbPerson?: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar({
|
export function Toolbar({
|
||||||
@@ -49,16 +76,24 @@ export function Toolbar({
|
|||||||
onSave,
|
onSave,
|
||||||
onSaveAs,
|
onSaveAs,
|
||||||
saveStatus,
|
saveStatus,
|
||||||
|
dirty = false,
|
||||||
|
lastSavedAt = null,
|
||||||
|
onPublish,
|
||||||
|
publishStatus = 'idle',
|
||||||
|
publishedAt = null,
|
||||||
|
hasUnpublished = false,
|
||||||
onScrollToMonth,
|
onScrollToMonth,
|
||||||
activeMonth,
|
activeMonth,
|
||||||
showMetro,
|
showMetro,
|
||||||
showD8,
|
showD8,
|
||||||
showSazlt,
|
showSazlt,
|
||||||
|
showIT,
|
||||||
hide162,
|
hide162,
|
||||||
theme,
|
theme,
|
||||||
onToggleMetro,
|
onToggleMetro,
|
||||||
onToggleD8,
|
onToggleD8,
|
||||||
onToggleSazlt,
|
onToggleSazlt,
|
||||||
|
onToggleIT,
|
||||||
onToggle162,
|
onToggle162,
|
||||||
onToggleTheme,
|
onToggleTheme,
|
||||||
hiddenValues,
|
hiddenValues,
|
||||||
@@ -66,11 +101,34 @@ export function Toolbar({
|
|||||||
diffFileName,
|
diffFileName,
|
||||||
onCloseDiff,
|
onCloseDiff,
|
||||||
onExportPdf,
|
onExportPdf,
|
||||||
|
onExportExcel,
|
||||||
onShowProposals,
|
onShowProposals,
|
||||||
onCheckFpd,
|
onCheckFpd,
|
||||||
isReadOnly = false,
|
isReadOnly = false,
|
||||||
|
tkbPeopleList = [],
|
||||||
|
hiddenTkbPeople = new Set(),
|
||||||
|
onToggleTkbPerson,
|
||||||
|
onSetAllTkbVisible,
|
||||||
|
onSetOnlyTkbPerson,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
const cellStyles = getAllCellStyles()
|
const cellStyles = getAllCellStyles()
|
||||||
|
const [showPeopleFilter, setShowPeopleFilter] = useState(false)
|
||||||
|
const peopleFilterRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Close dropdown on outside click
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPeopleFilter) return
|
||||||
|
const handleClick = (e: MouseEvent) => {
|
||||||
|
if (peopleFilterRef.current && !peopleFilterRef.current.contains(e.target as Node)) {
|
||||||
|
setShowPeopleFilter(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClick)
|
||||||
|
return () => document.removeEventListener('mousedown', handleClick)
|
||||||
|
}, [showPeopleFilter])
|
||||||
|
|
||||||
|
const activeFilterCount = tkbPeopleList.length - hiddenTkbPeople.size
|
||||||
|
const isFiltered = hiddenTkbPeople.size > 0
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2 mb-3">
|
<div className="flex flex-col gap-2 mb-3">
|
||||||
@@ -150,9 +208,67 @@ export function Toolbar({
|
|||||||
>
|
>
|
||||||
Uložit jako
|
Uložit jako
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Auto-save state */}
|
||||||
|
<span
|
||||||
|
className={`text-[10px] whitespace-nowrap ${
|
||||||
|
saveStatus === 'error' ? 'text-red-400'
|
||||||
|
: dirty ? 'text-amber-400'
|
||||||
|
: 'text-slate-500'
|
||||||
|
}`}
|
||||||
|
title="Změny se ukládají automaticky několik sekund po poslední úpravě"
|
||||||
|
>
|
||||||
|
{saveStatus === 'error' ? 'Uložení selhalo, zkusím znovu'
|
||||||
|
: dirty ? 'Neuložené změny…'
|
||||||
|
: lastSavedAt ? `Uloženo ${formatStamp(lastSavedAt)}`
|
||||||
|
: ''}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{onPublish && (
|
||||||
|
<>
|
||||||
|
<div className="h-5 w-px bg-slate-700" />
|
||||||
|
<button
|
||||||
|
onClick={onPublish}
|
||||||
|
disabled={publishStatus === 'publishing'}
|
||||||
|
title={hasUnpublished
|
||||||
|
? 'Uložit a zveřejnit aktuální stav pro uživatele s náhledem'
|
||||||
|
: 'Náhled odpovídá aktuálnímu stavu'}
|
||||||
|
className={`px-3 py-1 rounded text-xs border cursor-pointer transition-colors
|
||||||
|
${publishStatus === 'published'
|
||||||
|
? 'bg-green-700/70 text-green-100 border-green-600'
|
||||||
|
: publishStatus === 'error'
|
||||||
|
? 'bg-red-700/70 text-red-100 border-red-600'
|
||||||
|
: publishStatus === 'publishing'
|
||||||
|
? 'bg-slate-700 text-slate-400 border-slate-600'
|
||||||
|
: hasUnpublished
|
||||||
|
? 'bg-amber-700/70 text-amber-100 border-amber-600 hover:bg-amber-600/70'
|
||||||
|
: 'bg-slate-800 text-slate-300 border-slate-700 hover:bg-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{publishStatus === 'publishing' ? 'Publikuji...'
|
||||||
|
: publishStatus === 'published' ? 'Publikováno'
|
||||||
|
: publishStatus === 'error' ? 'Chyba!'
|
||||||
|
: 'Publikovat'}
|
||||||
|
</button>
|
||||||
|
<span
|
||||||
|
className={`text-[10px] whitespace-nowrap ${hasUnpublished ? 'text-amber-400' : 'text-slate-500'}`}
|
||||||
|
title={publishedAt ? `Naposledy publikováno ${formatStamp(publishedAt)}` : 'Zatím nepublikováno'}
|
||||||
|
>
|
||||||
|
{hasUnpublished
|
||||||
|
? '● Nepublikované změny'
|
||||||
|
: publishedAt ? `Publikováno ${formatStamp(publishedAt)}` : 'Nepublikováno'}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{isReadOnly && publishedAt && (
|
||||||
|
<span className="text-[10px] text-slate-500 whitespace-nowrap" title="Zobrazená verze plánu">
|
||||||
|
Publikováno {formatStamp(publishedAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
{onExportPdf && activeMonth && (
|
{onExportPdf && activeMonth && (
|
||||||
<button
|
<button
|
||||||
onClick={() => onExportPdf(activeMonth)}
|
onClick={() => onExportPdf(activeMonth)}
|
||||||
@@ -163,6 +279,16 @@ export function Toolbar({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{onExportExcel && activeMonth && (
|
||||||
|
<button
|
||||||
|
onClick={() => onExportExcel(activeMonth)}
|
||||||
|
className="px-3 py-1 rounded text-xs bg-emerald-700/70 text-emerald-100 border border-emerald-600
|
||||||
|
hover:bg-emerald-600/70 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
Excel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
{onShowProposals && (
|
{onShowProposals && (
|
||||||
<button
|
<button
|
||||||
onClick={onShowProposals}
|
onClick={onShowProposals}
|
||||||
@@ -226,6 +352,16 @@ export function Toolbar({
|
|||||||
>
|
>
|
||||||
SAZLT
|
SAZLT
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onToggleIT}
|
||||||
|
className={`px-2 py-1 rounded text-[10px] border cursor-pointer transition-colors
|
||||||
|
${showIT
|
||||||
|
? 'bg-teal-800/60 text-teal-200 border-teal-600'
|
||||||
|
: 'bg-slate-800 text-slate-500 border-slate-700 opacity-50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
IT
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onToggle162}
|
onClick={onToggle162}
|
||||||
title="Skrýt prvních 5 osob TKB (Pauzer, Vörös, Janouš, Franek, Svoboda)"
|
title="Skrýt prvních 5 osob TKB (Pauzer, Vörös, Janouš, Franek, Svoboda)"
|
||||||
@@ -239,6 +375,87 @@ export function Toolbar({
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* TKB people filter */}
|
||||||
|
{tkbPeopleList.length > 0 && onToggleTkbPerson && (
|
||||||
|
<div className="relative" ref={peopleFilterRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowPeopleFilter(v => !v)}
|
||||||
|
className={`px-2 py-1 rounded text-[10px] border cursor-pointer transition-colors flex items-center gap-1
|
||||||
|
${isFiltered
|
||||||
|
? 'bg-indigo-700/80 text-indigo-100 border-indigo-500'
|
||||||
|
: 'bg-slate-800 text-slate-400 border-slate-700 hover:bg-slate-700 hover:text-slate-200'
|
||||||
|
}`}
|
||||||
|
title="Filtrovat osoby Pohotovost TKB"
|
||||||
|
>
|
||||||
|
Osoby TKB
|
||||||
|
{isFiltered && (
|
||||||
|
<span className="bg-indigo-500 text-white rounded-full px-1 font-bold" style={{ fontSize: 9 }}>
|
||||||
|
{activeFilterCount}/{tkbPeopleList.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showPeopleFilter && (
|
||||||
|
<div className="absolute top-full left-0 mt-1 z-[200] bg-slate-800 border border-slate-600 rounded-lg shadow-xl min-w-[200px]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-slate-700">
|
||||||
|
<span className="text-[10px] uppercase tracking-wider text-slate-400 font-semibold">Pohotovost TKB</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => { onSetAllTkbVisible?.(); }}
|
||||||
|
className="px-1.5 py-0.5 rounded text-[9px] bg-slate-700 text-slate-300 hover:bg-slate-600 cursor-pointer border border-slate-600"
|
||||||
|
>
|
||||||
|
Vše
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
tkbPeopleList.forEach(p => {
|
||||||
|
if (!hiddenTkbPeople.has(p.id)) onToggleTkbPerson(p.id)
|
||||||
|
})
|
||||||
|
}}
|
||||||
|
className="px-1.5 py-0.5 rounded text-[9px] bg-slate-700 text-slate-300 hover:bg-slate-600 cursor-pointer border border-slate-600"
|
||||||
|
>
|
||||||
|
Žádný
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Person list */}
|
||||||
|
<div className="py-1 max-h-64 overflow-y-auto">
|
||||||
|
{tkbPeopleList.map(person => {
|
||||||
|
const isVisible = !hiddenTkbPeople.has(person.id)
|
||||||
|
return (
|
||||||
|
<div key={person.id} className="flex items-center gap-2 px-3 py-1.5 hover:bg-slate-700/50 group">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isVisible}
|
||||||
|
onChange={() => onToggleTkbPerson(person.id)}
|
||||||
|
className="cursor-pointer accent-indigo-500"
|
||||||
|
id={`tkb-person-${person.id}`}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`tkb-person-${person.id}`}
|
||||||
|
className="text-xs text-slate-200 cursor-pointer flex-1 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{person.name}
|
||||||
|
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
onClick={() => { onSetOnlyTkbPerson?.(person.id); setShowPeopleFilter(false) }}
|
||||||
|
className="text-[9px] text-slate-500 hover:text-indigo-300 cursor-pointer opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap"
|
||||||
|
title="Zobrazit jen tuto osobu"
|
||||||
|
>
|
||||||
|
jen tuto
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="h-5 w-px bg-slate-700" />
|
<div className="h-5 w-px bg-slate-700" />
|
||||||
|
|
||||||
{/* Color legend / filter */}
|
{/* Color legend / filter */}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const CELL_STYLES: Record<string, CellStyle> = {
|
|||||||
'D/2': { bg: '#FFF9C4', text: '#333', label: 'Půl den dovolená' },
|
'D/2': { bg: '#FFF9C4', text: '#333', label: 'Půl den dovolená' },
|
||||||
'N': { bg: '#FF4444', text: '#fff', label: 'Nemocenská' },
|
'N': { bg: '#FF4444', text: '#fff', label: 'Nemocenská' },
|
||||||
'U': { bg: '#92D050', text: '#333', label: 'Uzavěra' },
|
'U': { bg: '#92D050', text: '#333', label: 'Uzavěra' },
|
||||||
'O': { bg: '#FFC000', text: '#333', label: 'Otčovská' },
|
'O': { bg: '#FFFF00', text: '#333', label: 'Otcovská dovolená' }, // looks like D, counts like N
|
||||||
'x': { bg: '#E0E0E0', text: '#999', label: 'Bez zkušeností VN' },
|
'x': { bg: '#E0E0E0', text: '#999', label: 'Bez zkušeností VN' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2963,6 +2963,12 @@
|
|||||||
"group": "TKB",
|
"group": "TKB",
|
||||||
"data": {}
|
"data": {}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "tkb-kropacek",
|
||||||
|
"name": "Kópáček Ondřej",
|
||||||
|
"group": "TKB",
|
||||||
|
"data": {}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "tkb-vondrak",
|
"id": "tkb-vondrak",
|
||||||
"name": "Vondrák Pavel",
|
"name": "Vondrák Pavel",
|
||||||
@@ -2996,6 +3002,7 @@
|
|||||||
"name": "Dittrich Vladimír",
|
"name": "Dittrich Vladimír",
|
||||||
"note": "VN",
|
"note": "VN",
|
||||||
"group": "TKB",
|
"group": "TKB",
|
||||||
|
"retired": true,
|
||||||
"data": {}
|
"data": {}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,13 +3,8 @@
|
|||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||||
background: #0f172a;
|
background: var(--color-slate-900);
|
||||||
color: #e2e8f0;
|
color: var(--color-slate-200);
|
||||||
}
|
|
||||||
|
|
||||||
body.light {
|
|
||||||
background: #f8fafc;
|
|
||||||
color: #1e293b;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#root {
|
#root {
|
||||||
@@ -20,205 +15,44 @@ body.light {
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track { background: var(--color-slate-800); }
|
||||||
background: #1e293b;
|
::-webkit-scrollbar-thumb { background: var(--color-slate-600); border-radius: 4px; }
|
||||||
}
|
::-webkit-scrollbar-thumb:hover { background: var(--color-slate-500); }
|
||||||
::-webkit-scrollbar-thumb {
|
|
||||||
background: #475569;
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
body.light ::-webkit-scrollbar-track {
|
|
||||||
background: #e2e8f0;
|
|
||||||
}
|
|
||||||
body.light ::-webkit-scrollbar-thumb {
|
|
||||||
background: #94a3b8;
|
|
||||||
}
|
|
||||||
body.light ::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #64748b;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ============================================================
|
|
||||||
LIGHT THEME OVERRIDES
|
|
||||||
Targets Tailwind utility classes used throughout the app.
|
|
||||||
The .light class is toggled on <body> by App.tsx.
|
|
||||||
============================================================ */
|
|
||||||
|
|
||||||
/* Backgrounds: solid slate */
|
|
||||||
.light .bg-slate-900 { background-color: #f8fafc !important; }
|
|
||||||
.light .bg-slate-900\/80 { background-color: rgba(248, 250, 252, 0.85) !important; }
|
|
||||||
.light .bg-slate-900\/95 { background-color: rgba(248, 250, 252, 0.95) !important; }
|
|
||||||
.light .bg-slate-800 { background-color: #ffffff !important; }
|
|
||||||
.light .bg-slate-800\/80 { background-color: rgba(255, 255, 255, 0.85) !important; }
|
|
||||||
.light .bg-slate-800\/60 { background-color: rgba(241, 245, 249, 0.6) !important; }
|
|
||||||
/* Weekday empty cells in info rows — plain white */
|
|
||||||
.light .bg-slate-800\/30 { background-color: #ffffff !important; }
|
|
||||||
/* Weekend/holiday empty cells in person rows — distinct tint */
|
|
||||||
.light .bg-slate-800\/50 { background-color: #dbeafe !important; }
|
|
||||||
.light .bg-slate-750 { background-color: #f1f5f9 !important; }
|
|
||||||
.light .bg-slate-700 { background-color: #e2e8f0 !important; }
|
|
||||||
.light .bg-slate-700\/60 { background-color: rgba(226, 232, 240, 0.6) !important; }
|
|
||||||
.light .bg-slate-700\/50 { background-color: rgba(226, 232, 240, 0.5) !important; }
|
|
||||||
.light .bg-slate-700\/40 { background-color: rgba(226, 232, 240, 0.4) !important; }
|
|
||||||
/* Weekend/holiday cells in info rows — same blue tint as person-row weekends */
|
|
||||||
.light .bg-slate-700\/30 { background-color: #dbeafe !important; }
|
|
||||||
|
|
||||||
/* PMS column (Práce mimo směnu) — month-boundary marker.
|
/* PMS column (Práce mimo směnu) — month-boundary marker.
|
||||||
Dark mode: matches original bg-slate-700/30. Light mode: pale yellow. */
|
Dark mode: matches the original bg-slate-700/30 tint. */
|
||||||
.pms-cell { background-color: rgba(51, 65, 85, 0.3); }
|
.pms-cell,
|
||||||
.light .pms-cell { background-color: #fde68a !important; }
|
.pms-week {
|
||||||
|
background-color: color-mix(in oklab, var(--color-slate-700) 30%, transparent);
|
||||||
|
}
|
||||||
|
.pms-label { background-color: #283040; }
|
||||||
|
.pms-label-text { color: var(--color-slate-300); }
|
||||||
|
|
||||||
/* PMS vertical label (rotated "Práce mimo směnu" text overlay) */
|
/* ============================================================
|
||||||
.pms-label { background-color: #283040; }
|
LIGHT THEME
|
||||||
.light .pms-label { background-color: #fde68a !important; }
|
Implemented by inverting the slate color scale via CSS variables.
|
||||||
.pms-label-text { color: #cbd5e1; }
|
Browser only re-evaluates the variable, no rule matching, no !important.
|
||||||
.light .pms-label-text { color: #78350f !important; }
|
============================================================ */
|
||||||
|
body.light {
|
||||||
/* PMS cell in the WEEK header row.
|
--color-slate-900: #f8fafc;
|
||||||
Dark mode: subtle (matches original bg-slate-700/30). Light mode: pale yellow
|
--color-slate-800: #ffffff;
|
||||||
merged with the label below. */
|
--color-slate-700: #e2e8f0;
|
||||||
.pms-week { background-color: rgba(51, 65, 85, 0.3); }
|
--color-slate-600: #cbd5e1;
|
||||||
.light .pms-week {
|
--color-slate-500: #94a3b8;
|
||||||
background-color: #fde68a !important;
|
--color-slate-400: #64748b;
|
||||||
box-shadow: inset 1px 0 0 #94a3b8, inset -1px 0 0 #94a3b8;
|
--color-slate-300: #334155;
|
||||||
|
--color-slate-200: #1e293b;
|
||||||
|
--color-slate-100: #0f172a;
|
||||||
|
--color-slate-50: #020617;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Hover effect: brightness(1.25) washes out pale colors in light mode.
|
/* Light-mode specific tweaks where simple inversion isn't enough.
|
||||||
Use a subtle dim instead. */
|
These selectors win on specificity (.light + .class > Tailwind's single class). */
|
||||||
.light .hover\:brightness-125:hover { filter: brightness(0.94) !important; }
|
body.light .bg-slate-800\/50 { background-color: #dbeafe; } /* weekend cells in person rows */
|
||||||
|
body.light .bg-slate-700\/30 { background-color: #dbeafe; } /* weekend cells in info rows */
|
||||||
/* Schedule outer wrapper — keep dark mode identical, force white in light mode
|
body.light .schedule-wrapper { background-color: #ffffff; }
|
||||||
(separate from bg-slate-800/50 used as the weekend cell tint). */
|
body.light .pms-cell,
|
||||||
.light .schedule-wrapper { background-color: #ffffff !important; }
|
body.light .pms-week { background-color: #fde68a; }
|
||||||
.light .bg-slate-600 { background-color: #cbd5e1 !important; }
|
body.light .pms-week { box-shadow: inset 1px 0 0 #94a3b8, inset -1px 0 0 #94a3b8; }
|
||||||
|
body.light .pms-label { background-color: #fde68a; }
|
||||||
/* Hover backgrounds */
|
body.light .pms-label-text { color: #78350f; }
|
||||||
.light .hover\:bg-slate-700:hover { background-color: #e2e8f0 !important; }
|
|
||||||
.light .hover\:bg-slate-700\/70:hover { background-color: rgba(226, 232, 240, 0.7) !important; }
|
|
||||||
.light .hover\:bg-slate-750:hover { background-color: #f1f5f9 !important; }
|
|
||||||
.light .hover\:bg-slate-800:hover { background-color: #f1f5f9 !important; }
|
|
||||||
|
|
||||||
/* Text colors */
|
|
||||||
.light .text-slate-100 { color: #0f172a !important; }
|
|
||||||
.light .text-slate-200 { color: #1e293b !important; }
|
|
||||||
.light .text-slate-300 { color: #334155 !important; }
|
|
||||||
.light .text-slate-400 { color: #475569 !important; }
|
|
||||||
.light .text-slate-500 { color: #64748b !important; }
|
|
||||||
.light .text-slate-600 { color: #94a3b8 !important; }
|
|
||||||
|
|
||||||
/* Hover text */
|
|
||||||
.light .hover\:text-slate-100:hover { color: #0f172a !important; }
|
|
||||||
.light .hover\:text-slate-200:hover { color: #1e293b !important; }
|
|
||||||
.light .hover\:text-slate-300:hover { color: #334155 !important; }
|
|
||||||
|
|
||||||
/* Borders */
|
|
||||||
.light .border-slate-900 { border-color: #e2e8f0 !important; }
|
|
||||||
.light .border-slate-800 { border-color: #e2e8f0 !important; }
|
|
||||||
.light .border-slate-700 { border-color: #cbd5e1 !important; }
|
|
||||||
.light .border-slate-700\/50 { border-color: rgba(203, 213, 225, 0.7) !important; }
|
|
||||||
.light .border-slate-600 { border-color: #94a3b8 !important; }
|
|
||||||
.light .border-slate-600\/50 { border-color: rgba(148, 163, 184, 0.7) !important; }
|
|
||||||
.light .border-slate-500 { border-color: #64748b !important; }
|
|
||||||
.light .border-b-slate-700 { border-bottom-color: #cbd5e1 !important; }
|
|
||||||
.light .divide-slate-700 > * + * { border-color: #cbd5e1 !important; }
|
|
||||||
|
|
||||||
/* Tinted accent backgrounds — make them paper-like */
|
|
||||||
.light .bg-blue-900\/50 { background-color: #dbeafe !important; }
|
|
||||||
.light .bg-blue-900\/60 { background-color: #dbeafe !important; }
|
|
||||||
.light .bg-blue-800\/60 { background-color: #bfdbfe !important; }
|
|
||||||
.light .bg-blue-700\/70 { background-color: #93c5fd !important; }
|
|
||||||
.light .text-blue-100 { color: #1e3a8a !important; }
|
|
||||||
.light .text-blue-200 { color: #1e40af !important; }
|
|
||||||
.light .text-blue-300 { color: #1e40af !important; }
|
|
||||||
.light .text-blue-400 { color: #2563eb !important; }
|
|
||||||
.light .border-blue-800 { border-color: #93c5fd !important; }
|
|
||||||
.light .border-blue-700 { border-color: #93c5fd !important; }
|
|
||||||
.light .border-blue-600 { border-color: #60a5fa !important; }
|
|
||||||
.light .hover\:bg-blue-600\/70:hover { background-color: #60a5fa !important; }
|
|
||||||
.light .hover\:bg-blue-700:hover { background-color: #2563eb !important; color: #fff !important; }
|
|
||||||
|
|
||||||
.light .bg-green-900\/30 { background-color: #dcfce7 !important; }
|
|
||||||
.light .bg-green-900\/20 { background-color: #dcfce7 !important; }
|
|
||||||
.light .bg-green-900\/50 { background-color: #bbf7d0 !important; }
|
|
||||||
.light .bg-green-800\/60 { background-color: #bbf7d0 !important; }
|
|
||||||
.light .bg-green-800\/70 { background-color: #86efac !important; }
|
|
||||||
.light .bg-green-700\/70 { background-color: #4ade80 !important; }
|
|
||||||
.light .text-green-100 { color: #14532d !important; }
|
|
||||||
.light .text-green-200 { color: #166534 !important; }
|
|
||||||
.light .text-green-300 { color: #15803d !important; }
|
|
||||||
.light .text-green-400 { color: #16a34a !important; }
|
|
||||||
.light .border-green-700 { border-color: #4ade80 !important; }
|
|
||||||
.light .border-green-600 { border-color: #22c55e !important; }
|
|
||||||
.light .border-green-700\/30 { border-color: rgba(74, 222, 128, 0.5) !important; }
|
|
||||||
.light .border-green-700\/50 { border-color: rgba(74, 222, 128, 0.7) !important; }
|
|
||||||
.light .hover\:bg-green-600\/70:hover { background-color: #22c55e !important; color: #fff !important; }
|
|
||||||
.light .hover\:bg-green-700\/70:hover { background-color: #16a34a !important; color: #fff !important; }
|
|
||||||
|
|
||||||
.light .bg-orange-800\/60 { background-color: #fed7aa !important; }
|
|
||||||
.light .text-orange-200 { color: #9a3412 !important; }
|
|
||||||
.light .border-orange-600 { border-color: #fb923c !important; }
|
|
||||||
|
|
||||||
.light .bg-purple-800\/60 { background-color: #e9d5ff !important; }
|
|
||||||
.light .bg-purple-700\/70 { background-color: #c4b5fd !important; }
|
|
||||||
.light .text-purple-100 { color: #581c87 !important; }
|
|
||||||
.light .text-purple-200 { color: #6b21a8 !important; }
|
|
||||||
.light .border-purple-600 { border-color: #a855f7 !important; }
|
|
||||||
.light .hover\:bg-purple-600\/70:hover { background-color: #a855f7 !important; color: #fff !important; }
|
|
||||||
|
|
||||||
.light .bg-amber-900\/10 { background-color: #fef3c7 !important; }
|
|
||||||
.light .bg-amber-700\/40 { background-color: #fde68a !important; }
|
|
||||||
.light .bg-amber-700\/70 { background-color: #fbbf24 !important; }
|
|
||||||
.light .text-amber-100 { color: #78350f !important; }
|
|
||||||
.light .text-amber-300 { color: #b45309 !important; }
|
|
||||||
.light .text-amber-400 { color: #d97706 !important; }
|
|
||||||
.light .border-amber-600 { border-color: #f59e0b !important; }
|
|
||||||
.light .border-amber-700\/50 { border-color: rgba(217, 119, 6, 0.5) !important; }
|
|
||||||
.light .hover\:bg-amber-600\/70:hover { background-color: #f59e0b !important; color: #fff !important; }
|
|
||||||
.light .hover\:text-amber-400:hover { color: #d97706 !important; }
|
|
||||||
|
|
||||||
.light .bg-cyan-700\/70 { background-color: #67e8f9 !important; }
|
|
||||||
.light .text-cyan-100 { color: #164e63 !important; }
|
|
||||||
.light .border-cyan-600 { border-color: #06b6d4 !important; }
|
|
||||||
.light .hover\:bg-cyan-600\/70:hover { background-color: #06b6d4 !important; color: #fff !important; }
|
|
||||||
|
|
||||||
.light .bg-red-900\/30 { background-color: #fee2e2 !important; }
|
|
||||||
.light .bg-red-900\/50 { background-color: #fecaca !important; }
|
|
||||||
.light .bg-red-800\/50 { background-color: #fca5a5 !important; }
|
|
||||||
.light .text-red-100 { color: #7f1d1d !important; }
|
|
||||||
.light .text-red-300 { color: #b91c1c !important; }
|
|
||||||
.light .text-red-400 { color: #dc2626 !important; }
|
|
||||||
.light .border-red-700 { border-color: #f87171 !important; }
|
|
||||||
.light .border-red-700\/50 { border-color: rgba(248, 113, 113, 0.7) !important; }
|
|
||||||
.light .border-red-800 { border-color: #ef4444 !important; }
|
|
||||||
.light .hover\:bg-red-800\/50:hover { background-color: #fca5a5 !important; }
|
|
||||||
|
|
||||||
.light .bg-indigo-700\/60 { background-color: #c7d2fe !important; }
|
|
||||||
.light .text-indigo-100 { color: #312e81 !important; }
|
|
||||||
.light .text-indigo-200 { color: #3730a3 !important; }
|
|
||||||
|
|
||||||
.light .bg-rose-800\/60 { background-color: #fecdd3 !important; }
|
|
||||||
.light .text-rose-200 { color: #9f1239 !important; }
|
|
||||||
.light .border-rose-600 { border-color: #f43f5e !important; }
|
|
||||||
|
|
||||||
/* Modal black overlays — soften */
|
|
||||||
.light .bg-black\/50 { background-color: rgba(15, 23, 42, 0.35) !important; }
|
|
||||||
|
|
||||||
/* ------- Info row default backgrounds (TKB/SAZLT/Metro/D8 with closure value) ------- */
|
|
||||||
.light .bg-orange-700\/40 { background-color: #fed7aa !important; }
|
|
||||||
.light .bg-amber-700\/40 { background-color: #fde68a !important; }
|
|
||||||
.light .bg-blue-700\/40 { background-color: #bfdbfe !important; }
|
|
||||||
.light .bg-green-700\/40 { background-color: #bbf7d0 !important; }
|
|
||||||
.light .text-amber-200 { color: #92400e !important; }
|
|
||||||
|
|
||||||
/* ------- Section header bars (Pohotovost TKB / IT) ------- */
|
|
||||||
.light .bg-indigo-700\/60 { background-color: #c7d2fe !important; }
|
|
||||||
.light .bg-teal-700\/60 { background-color: #99f6e4 !important; }
|
|
||||||
.light .text-teal-100 { color: #134e4a !important; }
|
|
||||||
.light .text-teal-200 { color: #115e59 !important; }
|
|
||||||
|
|
||||||
/* ------- Borders inside the schedule grid (slate-700/20 etc.) ------- */
|
|
||||||
.light .border-slate-700\/20 { border-color: rgba(148, 163, 184, 0.4) !important; }
|
|
||||||
.light .border-l-slate-500 { border-left-color: #64748b !important; }
|
|
||||||
|
|||||||
@@ -13,11 +13,16 @@ export interface Person {
|
|||||||
group: 'TKB' | 'IT'
|
group: 'TKB' | 'IT'
|
||||||
note?: string
|
note?: string
|
||||||
data: Record<string, CellValue>
|
data: Record<string, CellValue>
|
||||||
|
retired?: boolean // hidden from UI/FPD; exports keep past-month data but skip empty months
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CellValue {
|
export interface CellValue {
|
||||||
v?: string
|
v?: string
|
||||||
color?: string
|
color?: string
|
||||||
|
dayColor?: string // right half — day shift preference mark
|
||||||
|
nightColor?: string // left half — night shift preference mark
|
||||||
|
dayValue?: string // right half — day shift text
|
||||||
|
nightValue?: string // left half — night shift text
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TunnelClosure {
|
export interface TunnelClosure {
|
||||||
@@ -81,6 +86,8 @@ export interface ScheduleFile {
|
|||||||
name: string
|
name: string
|
||||||
createdAt: string
|
createdAt: string
|
||||||
modifiedAt: string
|
modifiedAt: string
|
||||||
|
publishedAt?: string | null // when the viewer-visible snapshot was last published
|
||||||
|
hasUnpublishedChanges?: boolean // draft differs from the published snapshot
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScheduleFileWithData extends ScheduleFile {
|
export interface ScheduleFileWithData extends ScheduleFile {
|
||||||
|
|||||||
@@ -138,14 +138,20 @@ export function useScheduleState(
|
|||||||
const next = JSON.parse(JSON.stringify(prev)) as Person[]
|
const next = JSON.parse(JSON.stringify(prev)) as Person[]
|
||||||
const person = next.find(p => p.id === personId)
|
const person = next.find(p => p.id === personId)
|
||||||
if (!person) return prev
|
if (!person) return prev
|
||||||
|
const existing = person.data[String(dayIdx)] ?? {}
|
||||||
|
// Half-cell colors and values belong to the day; always preserve them
|
||||||
|
const preserve = {
|
||||||
|
...(existing.color ? { color: existing.color } : {}),
|
||||||
|
...(existing.dayColor ? { dayColor: existing.dayColor } : {}),
|
||||||
|
...(existing.nightColor ? { nightColor: existing.nightColor } : {}),
|
||||||
|
...(existing.dayValue ? { dayValue: existing.dayValue } : {}),
|
||||||
|
...(existing.nightValue ? { nightValue: existing.nightValue } : {}),
|
||||||
|
}
|
||||||
if (value) {
|
if (value) {
|
||||||
const existing = person.data[String(dayIdx)]
|
person.data[String(dayIdx)] = { v: value, ...preserve }
|
||||||
person.data[String(dayIdx)] = { v: value, ...(existing?.color ? { color: existing.color } : {}) }
|
|
||||||
} else {
|
} else {
|
||||||
const existing = person.data[String(dayIdx)]
|
if (Object.keys(preserve).length > 0) {
|
||||||
if (existing?.color) {
|
person.data[String(dayIdx)] = preserve
|
||||||
const { v: _, ...rest } = existing
|
|
||||||
person.data[String(dayIdx)] = rest as any
|
|
||||||
} else {
|
} else {
|
||||||
delete person.data[String(dayIdx)]
|
delete person.data[String(dayIdx)]
|
||||||
}
|
}
|
||||||
@@ -154,6 +160,9 @@ export function useScheduleState(
|
|||||||
})
|
})
|
||||||
}, [pushHistory])
|
}, [pushHistory])
|
||||||
|
|
||||||
|
const isCellEmpty = (c: any) =>
|
||||||
|
!c.v && !c.color && !c.dayColor && !c.nightColor && !c.dayValue && !c.nightValue
|
||||||
|
|
||||||
const setCellColor = useCallback((personId: string, dayIdx: number, color: string | null) => {
|
const setCellColor = useCallback((personId: string, dayIdx: number, color: string | null) => {
|
||||||
pushHistory()
|
pushHistory()
|
||||||
setPeople(prev => {
|
setPeople(prev => {
|
||||||
@@ -166,12 +175,81 @@ export function useScheduleState(
|
|||||||
} else {
|
} else {
|
||||||
const { color: _, ...rest } = existing as any
|
const { color: _, ...rest } = existing as any
|
||||||
person.data[String(dayIdx)] = rest
|
person.data[String(dayIdx)] = rest
|
||||||
if (!rest.v) delete person.data[String(dayIdx)]
|
if (isCellEmpty(rest)) delete person.data[String(dayIdx)]
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [pushHistory])
|
}, [pushHistory])
|
||||||
|
|
||||||
|
const setCellHalfColor = useCallback((personId: string, dayIdx: number, target: 'day' | 'night', 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)] ?? {}
|
||||||
|
const field = target === 'day' ? 'dayColor' : 'nightColor'
|
||||||
|
if (color) {
|
||||||
|
person.data[String(dayIdx)] = { ...existing, [field]: color }
|
||||||
|
} else {
|
||||||
|
const { [field]: _, ...rest } = existing as any
|
||||||
|
person.data[String(dayIdx)] = rest
|
||||||
|
if (isCellEmpty(rest)) delete person.data[String(dayIdx)]
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [pushHistory])
|
||||||
|
|
||||||
|
const setCellHalfValue = useCallback((personId: string, dayIdx: number, target: 'day' | 'night', 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
|
||||||
|
const existing = person.data[String(dayIdx)] ?? {}
|
||||||
|
const field = target === 'day' ? 'dayValue' : 'nightValue'
|
||||||
|
if (value) {
|
||||||
|
person.data[String(dayIdx)] = { ...existing, [field]: value }
|
||||||
|
} else {
|
||||||
|
const { [field]: _, ...rest } = existing as any
|
||||||
|
person.data[String(dayIdx)] = rest
|
||||||
|
if (isCellEmpty(rest)) delete person.data[String(dayIdx)]
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [pushHistory])
|
||||||
|
|
||||||
|
const pasteCellTo = useCallback((personId: string, dayIdx: number, payload: import('./types').CellValue & { comment?: 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 cell: import('./types').CellValue = {
|
||||||
|
...(payload.v ? { v: payload.v } : {}),
|
||||||
|
...(payload.color ? { color: payload.color } : {}),
|
||||||
|
...(payload.dayValue ? { dayValue: payload.dayValue } : {}),
|
||||||
|
...(payload.dayColor ? { dayColor: payload.dayColor } : {}),
|
||||||
|
...(payload.nightValue ? { nightValue: payload.nightValue } : {}),
|
||||||
|
...(payload.nightColor ? { nightColor: payload.nightColor } : {}),
|
||||||
|
}
|
||||||
|
if (Object.keys(cell).length > 0) {
|
||||||
|
person.data[String(dayIdx)] = cell
|
||||||
|
} else {
|
||||||
|
delete person.data[String(dayIdx)]
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
// Comment: replace (set or remove)
|
||||||
|
setCellCommentsState(prev => {
|
||||||
|
const next = new Map(prev)
|
||||||
|
const key = `${personId}-${dayIdx}`
|
||||||
|
if (payload.comment) next.set(key, payload.comment)
|
||||||
|
else next.delete(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}, [pushHistory])
|
||||||
|
|
||||||
const setTunnelClosureColor = useCallback((dayIdx: number, color: string | null) => {
|
const setTunnelClosureColor = useCallback((dayIdx: number, color: string | null) => {
|
||||||
pushHistory()
|
pushHistory()
|
||||||
setTunnelColorsState(prev => {
|
setTunnelColorsState(prev => {
|
||||||
@@ -249,10 +327,30 @@ export function useScheduleState(
|
|||||||
const next = JSON.parse(JSON.stringify(prev)) as Person[]
|
const next = JSON.parse(JSON.stringify(prev)) as Person[]
|
||||||
const person = next.find(p => p.id === personId)
|
const person = next.find(p => p.id === personId)
|
||||||
if (!person) return prev
|
if (!person) return prev
|
||||||
const value = person.data[String(fromIdx)]
|
const source = person.data[String(fromIdx)]
|
||||||
if (!value) return prev
|
if (!source?.v) return prev
|
||||||
delete person.data[String(fromIdx)]
|
|
||||||
person.data[String(toIdx)] = value
|
// Shift moves with v + color; half-marks (color & value) stay with the day
|
||||||
|
const moving = { v: source.v, ...(source.color ? { color: source.color } : {}) }
|
||||||
|
const halfMarks = (c: any) => ({
|
||||||
|
...(c.dayColor ? { dayColor: c.dayColor } : {}),
|
||||||
|
...(c.nightColor ? { nightColor: c.nightColor } : {}),
|
||||||
|
...(c.dayValue ? { dayValue: c.dayValue } : {}),
|
||||||
|
...(c.nightValue ? { nightValue: c.nightValue } : {}),
|
||||||
|
})
|
||||||
|
const stayingOnSource = halfMarks(source)
|
||||||
|
if (Object.keys(stayingOnSource).length > 0) {
|
||||||
|
person.data[String(fromIdx)] = stayingOnSource
|
||||||
|
} else {
|
||||||
|
delete person.data[String(fromIdx)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Destination keeps its existing half-marks; v + color overwritten
|
||||||
|
const destExisting = person.data[String(toIdx)] ?? {}
|
||||||
|
person.data[String(toIdx)] = {
|
||||||
|
...halfMarks(destExisting),
|
||||||
|
...moving,
|
||||||
|
}
|
||||||
return next
|
return next
|
||||||
})
|
})
|
||||||
}, [pushHistory])
|
}, [pushHistory])
|
||||||
@@ -357,6 +455,9 @@ export function useScheduleState(
|
|||||||
cellComments,
|
cellComments,
|
||||||
setCell,
|
setCell,
|
||||||
setCellColor,
|
setCellColor,
|
||||||
|
setCellHalfColor,
|
||||||
|
setCellHalfValue,
|
||||||
|
pasteCellTo,
|
||||||
moveCell,
|
moveCell,
|
||||||
setTunnelClosure,
|
setTunnelClosure,
|
||||||
setTunnelClosureColor,
|
setTunnelClosureColor,
|
||||||
|
|||||||
Reference in New Issue
Block a user