Compare commits

..

7 Commits

Author SHA1 Message Date
Docker Config Backup
06a7e65340 fix: negative PMS (práce mimo směnu) hours count in FPD totals
Web FPD check: PMS hours keep their sign (getPmsHours) instead of being
dropped when negative. Month Excel FPD column: PMS was not counted at
all; it now adds the signed PMS hours like the web.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-30 08:52:13 +02:00
Docker Config Backup
c220956146 feat: month Excel FPD column matches the web FPD check for N and O
Restores the rule from 707e18c: a working-day N or O is credited as a
worked day (8 h) in the FPD column of the Excel exported by the
"Excel" button, the same as the web FPD check. Reverts e2da33b.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:42:16 +02:00
Docker Config Backup
e2da33bc44 revert: month Excel FPD column counts N/O as 0 h again
The Excel FPD column is meant to differ from the web FPD check; only
O should behave like N, which it already did. Keeps the D-like colour
for O.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:36:18 +02:00
Docker Config Backup
575164d8a1 fix: name is Kopáček Ondřej (second correction)
Rename Kópáček → Kopáček in default data, both Excel templates and the
export rosters; the server fix-up map now maps both earlier spellings
to the correct one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:34:41 +02:00
Docker Config Backup
707e18c625 fix: correct name to Kópáček Ondřej; O (otcovská) looks like D, counts like N
The new worker's surname was misheard: Kropáček → Kópáček everywhere
(default data, both Excel templates, export rosters). The person id
tkb-kropacek is kept. The server now normalises the name in stored
files at startup and in every incoming save, so clients that still
hold the old name cannot write it back.

"O" (otcovská dovolená) is now rendered in the same yellow as "D" in
the app and the month Excel, labelled "Otcovská dovolená", and for the
FPD balance a working-day O is credited like N (a normal worked day)
both in the web FPD check and in the month Excel's FPD column, which
previously counted N and O as zero and disagreed with the web.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-22 09:28:48 +02:00
Docker Config Backup
09a07b510b feat: auto-save drafts + publish step for viewers
Editors' changes are now auto-saved (5 s after the last edit, at most
30 s after the first unsaved one, retried on failure) with a status
indicator; leaving the page with unsaved changes prompts a warning.

Each schedule file keeps a published snapshot next to the draft.
Viewer/cis roles load and export only the published snapshot; the
editor's draft becomes visible to them only after "Publikovat".
Toolbar shows the publish state ("Nepublikované změny" / "Publikováno
HH:MM"), the file manager shows the publish date, and viewers pick up a
new publish automatically within a minute.

Server: GET /api/files/:id?view=published, POST /api/files/:id/publish,
?view=published on all exports, one-time migration that seeds the
published snapshot from the current data on startup.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 13:12:49 +02:00
Docker Config Backup
3d306e641c feat: add Kropáček Ondřej + May–Jul feature work (half-cell marks, copy/paste, exports, roles)
New worker: Kropáček Ondřej (TKB, part-time student) inserted between
Dvořák Václav and Vondrák Pavel in data.json, the full-month Excel
template (new row 13, everything below shifted) and the docházka
template (13 person rows). Not added to the harmonogram/CIS roster —
he has no CIS personal number.

Also checkpoints the uncommitted work deployed to production between
May and July 2026:
- half-cell day/night preference marks (color + text per half)
- cell copy/paste (Ctrl+C/V, context menu), incl. from info rows
- light/dark theme toggle, TKB people filter, 162 filter
- full-month Excel export (template) and harmonogram export for CIS
- docházka export: half-time workers, retired flag, contractor handling
- third login role "cis" (viewer + export access)
- retired flag hides a person from UI/FPD while keeping past data

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-17 13:02:52 +02:00
24 changed files with 2568 additions and 267 deletions

View 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.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

View File

@@ -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',
'Kopáč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},
'Kopáč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]

506
web/export_full_excel.py Normal file
View File

@@ -0,0 +1,506 @@
"""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,
'Kopáč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 pms_hours(value):
"""PMS (práce mimo směnu) hours — like hours_for, but negative values keep
their sign (hours taken off). Matches getPmsHours in FpdCheck.tsx."""
if value in ('A', 'B'):
return 12
try:
return float(value)
except (ValueError, TypeError):
return 0
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)
# PMS (práce mimo směnu) counts as worked hours, positive or negative (as in FpdCheck.tsx)
pms_v = (person['data'].get(str(-month)) or {}).get('v')
if pms_v:
worked += pms_hours(pms_v)
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
View 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'),
# Kopáček Ondřej — part-time student, not in CIS (no personal number); add here
# if he joins full time: ('Kopáček Ondřej', 'TKB', <osobní číslo>, 'Kopáč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()

Binary file not shown.

View File

@@ -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': 'Kopáček Ondřej', 'Kópáček Ondřej': 'Kopáč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 {

View File

@@ -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,6 +103,12 @@ function getISOWeek(date: Date): number {
type AppMode = 'login' | 'files' | 'editor' type AppMode = 'login' | 'files' | 'editor'
function App() { function App() {
// Apply persisted theme on initial mount (before first render of inner components)
useEffect(() => {
const t = localStorage.getItem('tkb-theme')
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)
useEffect(() => { useEffect(() => {
let serverVersion: string | null = null let serverVersion: string | null = null
@@ -115,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) {
@@ -141,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(() => {
@@ -214,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">
@@ -230,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}
@@ -291,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,
@@ -310,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)
@@ -393,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) => {
@@ -421,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()
@@ -440,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
@@ -476,14 +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'>(() =>
(localStorage.getItem('tkb-theme') as 'dark' | 'light') || 'dark'
)
useEffect(() => {
document.body.classList.toggle('light', theme === 'light')
localStorage.setItem('tkb-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',
@@ -501,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
@@ -522,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">
@@ -571,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}
@@ -579,6 +852,10 @@ 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)}
showIT={showIT}
onToggleIT={() => setShowIT(v => !v)}
theme={theme}
onToggleTheme={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
hide162={hide162} hide162={hide162}
onToggle162={() => setHide162(v => !v)} onToggle162={() => setHide162(v => !v)}
hiddenValues={hiddenValues} hiddenValues={hiddenValues}
@@ -590,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}
@@ -610,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}
@@ -617,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>
@@ -634,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)}
@@ -649,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([]) }}
/> />
)} )}
@@ -664,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}
/> />
)} )}

View File

@@ -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">&#9654;</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">&#9654;</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">&#9654;</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">&#9654;</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">

View File

@@ -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>

View File

@@ -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,13 @@ 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 getPmsHours(v: string): number {
if (v === 'A' || v === 'B') return 12
const num = parseFloat(v)
return isNaN(num) ? 0 : num
} }
function getHoursForValue(v: string): number { function getHoursForValue(v: string): number {
@@ -39,27 +53,53 @@ 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.
// Unlike day cells it may be negative (hours taken off), so keep the sign.
const pms = person.data[String(-month)]?.v
if (pms) workedHours += getPmsHours(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 +113,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 +174,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>

View File

@@ -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)

View File

@@ -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

View File

@@ -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
${!pmsColor ? 'bg-slate-700/30' : ''}`} ${isReadOnly ? 'cursor-default' : 'cursor-text hover:brightness-125'}
${!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) => {
@@ -787,7 +979,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
) )
if (pmsAfterSet.has(i)) { if (pmsAfterSet.has(i)) {
cells.push( cells.push(
<div key={`pms-info-${rowId}-${i}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 28 }} /> <div key={`pms-info-${rowId}-${i}`} className="pms-cell" style={{ width: CELL_W, height: 28 }} />
) )
} }
return cells return cells
@@ -808,7 +1000,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
) )
return ( return (
<div className="rounded-lg border border-slate-700 bg-slate-800/50 overflow-hidden"> <div className="rounded-lg border border-slate-700 bg-slate-800/50 schedule-wrapper overflow-hidden">
<div className="flex"> <div className="flex">
{/* === Fixed left name column === */} {/* === Fixed left name column === */}
@@ -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 */}
@@ -920,7 +1112,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
) )
} }
fragments.push( fragments.push(
<div key={`w-${i}-pms-${pmsPos}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 24 }} /> <div key={`w-${i}-pms-${pmsPos}`} className="pms-week" style={{ width: CELL_W, height: 24 }} />
) )
fragStart = pmsPos + 1 fragStart = pmsPos + 1
} }
@@ -974,18 +1166,17 @@ export function ScheduleTable(props: ScheduleTableProps) {
> >
{/* Merged cell overlay covering DEN + DEN T. + all info rows */} {/* Merged cell overlay covering DEN + DEN T. + all info rows */}
<div <div
className="absolute flex items-center justify-center border-l border-r border-slate-600 overflow-hidden" className="absolute flex items-center justify-center border-l border-r border-slate-600 overflow-hidden pms-label"
style={{ style={{
top: 0, top: 0,
left: 0, left: 0,
width: CELL_W, width: CELL_W,
height: 28 + 24 + 28 + (showSazlt ? 28 : 0) + (showMetro ? 28 : 0) + (showD8 ? 28 : 0), height: 28 + 24 + 28 + (showSazlt ? 28 : 0) + (showMetro ? 28 : 0) + (showD8 ? 28 : 0),
backgroundColor: '#283040',
zIndex: 15, zIndex: 15,
}} }}
> >
<span <span
className="text-[10px] font-bold text-slate-300 pointer-events-none select-none" className="text-[10px] font-bold pms-label-text pointer-events-none select-none"
style={{ style={{
writingMode: 'vertical-rl', writingMode: 'vertical-rl',
transform: 'rotate(180deg)', transform: 'rotate(180deg)',
@@ -1021,7 +1212,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
) )
if (pmsAfterSet.has(i)) { if (pmsAfterSet.has(i)) {
cells.push( cells.push(
<div key={`pms-dayname-${i}`} className="bg-slate-700/30" style={{ width: CELL_W, height: 24 }} /> <div key={`pms-dayname-${i}`} className="pms-cell" style={{ width: CELL_W, height: 24 }} />
) )
} }
return cells return cells
@@ -1030,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' }}>
@@ -1055,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}
@@ -1078,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}
@@ -1108,6 +1303,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
onCellDragSelectMove={onCellDragSelectMove} onCellDragSelectMove={onCellDragSelectMove}
pmsAfterSet={pmsAfterSet} pmsAfterSet={pmsAfterSet}
pmsMonthAtPos={pmsMonthAtPos} pmsMonthAtPos={pmsMonthAtPos}
isReadOnly={isReadOnly}
clipboardSource={clipboardSource}
/> />
))} ))}

View File

@@ -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,30 +16,58 @@ 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'
onToggleMetro: () => void onToggleMetro: () => void
onToggleD8: () => void onToggleD8: () => void
onToggleSazlt: () => void onToggleSazlt: () => void
onToggleIT: () => void
onToggle162: () => void onToggle162: () => void
onToggleTheme: () => void
hiddenValues: Set<string> hiddenValues: Set<string>
onToggleValue: (code: string) => void onToggleValue: (code: string) => void
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({
@@ -47,26 +76,59 @@ 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,
onToggleMetro, onToggleMetro,
onToggleD8, onToggleD8,
onToggleSazlt, onToggleSazlt,
onToggleIT,
onToggle162, onToggle162,
onToggleTheme,
hiddenValues, hiddenValues,
onToggleValue, onToggleValue,
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">
@@ -146,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)}
@@ -159,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}
@@ -179,6 +309,15 @@ export function Toolbar({
</button> </button>
)} )}
<button
onClick={onToggleTheme}
title={theme === 'dark' ? 'Přepnout na světlý režim' : 'Přepnout na tmavý režim'}
className="px-3 py-1 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
hover:bg-slate-700 cursor-pointer transition-colors"
>
{theme === 'dark' ? '☀ Světlý' : '🌙 Tmavý'}
</button>
<div className="h-5 w-px bg-slate-700" /> <div className="h-5 w-px bg-slate-700" />
{/* Row toggles */} {/* Row toggles */}
@@ -213,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)"
@@ -226,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 */}

View File

@@ -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' },
} }

View File

@@ -2963,6 +2963,12 @@
"group": "TKB", "group": "TKB",
"data": {} "data": {}
}, },
{
"id": "tkb-kropacek",
"name": "Kopáč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": {}
}, },
{ {

View File

@@ -3,8 +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);
} }
#root { #root {
@@ -15,13 +15,44 @@ body {
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); }
/* PMS column (Práce mimo směnu) — month-boundary marker.
Dark mode: matches the original bg-slate-700/30 tint. */
.pms-cell,
.pms-week {
background-color: color-mix(in oklab, var(--color-slate-700) 30%, transparent);
} }
::-webkit-scrollbar-thumb { .pms-label { background-color: #283040; }
background: #475569; .pms-label-text { color: var(--color-slate-300); }
border-radius: 4px;
} /* ============================================================
::-webkit-scrollbar-thumb:hover { LIGHT THEME
background: #64748b; Implemented by inverting the slate color scale via CSS variables.
Browser only re-evaluates the variable, no rule matching, no !important.
============================================================ */
body.light {
--color-slate-900: #f8fafc;
--color-slate-800: #ffffff;
--color-slate-700: #e2e8f0;
--color-slate-600: #cbd5e1;
--color-slate-500: #94a3b8;
--color-slate-400: #64748b;
--color-slate-300: #334155;
--color-slate-200: #1e293b;
--color-slate-100: #0f172a;
--color-slate-50: #020617;
} }
/* Light-mode specific tweaks where simple inversion isn't enough.
These selectors win on specificity (.light + .class > Tailwind's single class). */
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 */
body.light .schedule-wrapper { background-color: #ffffff; }
body.light .pms-cell,
body.light .pms-week { background-color: #fde68a; }
body.light .pms-week { box-shadow: inset 1px 0 0 #94a3b8, inset -1px 0 0 #94a3b8; }
body.light .pms-label { background-color: #fde68a; }
body.light .pms-label-text { color: #78350f; }

View File

@@ -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 {

View File

@@ -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,