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>
This commit is contained in:
Docker Config Backup
2026-09-17 13:02:52 +02:00
parent 131b0d0df1
commit 3d306e641c
22 changed files with 2231 additions and 242 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
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.
TEMPLATE_NAMES = [
'Dittrich Vladimír',
@@ -35,6 +38,7 @@ TEMPLATE_NAMES = [
'Vörös Pavel',
'Janouš Petr',
'Dvořák Václav',
'Kropáček Ondřej',
'Toman Milan',
'Hanzlík Marek',
'Vondrák Pavel',
@@ -48,6 +52,7 @@ PERSON_DATA = {
'Žemlička Miroslav': {'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},
'Kropáč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},
'Kohl David': {'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)
TPL_FIRST = 8 # first data row
TPL_LAST = 19 # last data row (12 people)
TPL_CELKEM = 20
TPL_HIDDEN1 = 22
TPL_HIDDEN2 = 23
TPL_IT_HDR = 24
TPL_IT1 = 25 # first IT person
TPL_LAST = 20 # last data row (13 people)
TPL_CELKEM = 21
TPL_HIDDEN1 = 23
TPL_HIDDEN2 = 24
TPL_IT_HDR = 25
TPL_IT1 = 26 # first IT person
# ──────────────────────────────────────────────────────────────
def get_czech_holidays(year):
@@ -132,37 +137,54 @@ def compute(schedule, month, year):
fpd = work_days * 8
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_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:
daily_hours = 4 if person['name'] in HALF_TIME_NAMES else 8
r=o=n=0; dov=nem=otc=0; minus_fpd=0
sichet_12h = 0 # shifts with ≥12 h
sichet_under_12h = 0 # shifts with <12 h
uzavery = 0 # 'U' days (closures)
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
Date(d['year'],d['month'],d['day']) not in holidays)
if v=='A':
dr,do=split_day(12); r+=dr; o+=do
sichet_12h += 1
elif v=='B':
nr,no,nn=split_night(12); r+=nr; o+=no; n+=nn
sichet_12h += 1
elif v=='D': dov+=1; minus_fpd += 8 if is_w else 0
elif v=='N': nem+=1; minus_fpd += 8 if is_w else 0
elif v=='O': otc+=1; minus_fpd += 8 if is_w else 0
elif v=='U': uzavery += 1
elif v not in ('x','U','') and v:
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
# Split cells carry two half-day values (dayValue + nightValue);
# process both halves, otherwise the single 'v'.
is_split = bool(cell.get('dayColor') or cell.get('nightColor')
or cell.get('dayValue') or cell.get('nightValue'))
values = ([cell.get('dayValue',''), cell.get('nightValue','')]
if is_split else [cell.get('v','')])
for v in values:
if not v: continue
if v=='A':
dr,do=split_day(12); r+=dr; o+=do
sichet_12h += 1
elif v=='B':
nr,no,nn=split_night(12); r+=nr; o+=no; n+=nn
sichet_12h += 1
elif v=='D': dov+=1; minus_fpd += daily_hours if is_w else 0
elif v=='N': nem+=1; minus_fpd += daily_hours if is_w else 0
elif v=='O': otc+=1; minus_fpd += daily_hours if is_w else 0
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 = 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
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
if o_navic > 0:
pom = o_navic
@@ -192,7 +215,7 @@ def compute(schedule, month, year):
# --- Financial calculations (sichtovnice.py) ---
# 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
# 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')}
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 ──
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, 8, 3, data['fpd']) # H3
# ── DATA ROWS 8–19 ──
# ── DATA ROWS 8–20 ──
for i in range(n_tmpl):
row_num = TPL_FIRST + i
r = rows[row_num]

485
web/export_full_excel.py Normal file
View File

@@ -0,0 +1,485 @@
"""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': 'FFC000', 'x': 'E0E0E0',
}
# 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,
'Kropáček Ondřej': 13,
'Vondrák Pavel': 14, 'Čeleda Olda': 15, 'Hanzlík Marek': 16,
'Kohl David': 17, 'Dittrich Vladimír': 18, 'Toman Milan': 19,
'Zábranský Petr': 20, 'Žemlička Miroslav': 21, 'Teslík Hynek': 22,
}
IT_ROWS = {
'Vörös Pavel': 24, 'Janouš Petr': 25, 'Glaser Ondřej': 26,
'Robert Štefan': 27, 'Franek Lukáš': 28,
}
# Template layout below the person rows
IT_HEADER_ROW = 23 # 'Pohotovost IT' label row
IT_FIRST_ROW, IT_LAST_ROW = 24, 28
BELOW_IT_ROW = 29 # first row under the IT section
# Day-pair start columns: 2, 4, 6, ..., 68 (34 day-pairs)
DAY_COLS = list(range(2, 70, 2))
# 162 group — excluded from FPD calculation (matches FpdCheck.tsx)
FPD_EXCLUDED = {'Pauzer Libor', 'Vörös Pavel', 'Janouš Petr', 'Franek Lukáš', 'Svoboda Daniel'}
def _easter_monday(year):
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
sunday = date(year, month, day)
return sunday + timedelta(days=1)
def czech_holidays(year):
em = _easter_monday(year)
gf = em - timedelta(days=3)
fixed = [(1, 1), (5, 1), (5, 8), (7, 5), (7, 6), (9, 28),
(10, 28), (11, 17), (12, 24), (12, 25), (12, 26)]
out = {(em.month, em.day), (gf.month, gf.day)}
for m, d in fixed:
out.add((m, d))
return out
def hours_for(value):
"""Match webapp's getHoursForValue (FpdCheck.tsx):
A and B count as 12 hours; numeric values as themselves;
everything else (U, D, N, x, O) counts as 0."""
if value in ('A', 'B'):
return 12
try:
n = float(value)
return n if n > 0 else 0
except (ValueError, TypeError):
return 0
def main():
payload = json.loads(sys.stdin.read())
schedule = payload['schedule']
month = int(payload['month'])
year = int(payload['year'])
# Show 3 days before the 1st of the month, then 34 days total
start = date(year, month, 1) - timedelta(days=3)
days = [start + timedelta(days=i) for i in range(34)]
template_path = Path(__file__).parent / 'public' / 'full_template.xlsx'
wb = load_workbook(str(template_path))
ws = wb['Plán Služeb']
# ---- A1: replace template "01.03.2026 v.1" with the export's month ----
ws.cell(1, 1).value = f'01.{month:02d}.{year} v.1'
# ---- Step 0: Strip ALL leftover cell comments from template ----
for row in ws.iter_rows():
for cell in row:
if cell.comment:
cell.comment = None
# Snapshot original PMS column (BU) fills+borders BEFORE any modifications.
# These represent the row's "default" formatting for PMS, used later for FPD column.
pms_original_styles = {}
for r in range(7, BELOW_IT_ROW):
cell = ws.cell(r, 71)
pms_original_styles[r] = (copy(cell.fill), copy(cell.border))
# ---- Step 1: Unmerge ALL cells in R3:R5 across day-pair cols (clean slate) ----
for m in list(ws.merged_cells.ranges):
if m.min_row >= 3 and m.max_row <= 5 and 2 <= m.min_col < 70:
ws.unmerge_cells(str(m))
# Clear values + fills in R3:R5 cols 2-69. Restore consistent borders:
# - no border between day half and night half within a day-pair
# - month boundary marked with thicker (medium) left border on the day half
# We compute month-boundary based on the requested month dates set later.
# For now, just clear and apply baseline thin borders; medium override happens after dates are computed.
for r in (3, 4, 5):
for c in range(2, 70):
cell = ws.cell(r, c)
cell.value = None
cell.fill = PatternFill(fill_type=None)
# ---- Step 2: Clear values in person rows. Replace RGB fills (sample shifts) with the
# row's "default" fill (taken from the col-1 label cell) so empty cells inherit the
# row's background instead of going white. ----
person_rows = list(TKB_ROWS.values()) + list(IT_ROWS.values())
for r in person_rows:
# Default row fill: from col-1 label cell (typically theme-colored)
try:
row_default_fill = copy(ws.cell(r, 1).fill)
except Exception:
row_default_fill = None
for c in range(2, 72):
# Cols 71 (PMS) and 72 (FPD) keep their template fills — handled separately
if c >= 71:
continue
cell = ws.cell(r, c)
if cell.__class__.__name__ == 'MergedCell':
continue
cell.value = None
# Col 70 (BR) is the separator column before "Práce mimo směnu" — keep white
if c == 70:
cell.fill = PatternFill(fill_type=None)
continue
try:
fg = cell.fill.fgColor
# If cell has an RGB fill (sample shift highlight), replace with row default
if fg and getattr(fg, 'type', None) == 'rgb':
if row_default_fill is not None:
cell.fill = copy(row_default_fill)
else:
cell.fill = PatternFill(fill_type=None)
except Exception:
pass
# ---- Step 3: Clear row 1 closure labels in day-pair cols ----
for m in list(ws.merged_cells.ranges):
if m.min_row == 1 and m.max_row == 1 and 2 <= m.min_col < 70:
ws.unmerge_cells(str(m))
for c in range(2, 70):
cell = ws.cell(1, c)
cell.value = None
# ---- Step 4: Update headers (R2 weekday, R3 year, R4 month, R5 day) + borders + weekend fills ----
thin = Side(style='thin', color='000000')
medium = Side(style='medium', color='000000')
WEEKEND_FILL = PatternFill(start_color='FCD5B4', end_color='FCD5B4', fill_type='solid') # peach
NO_FILL = PatternFill(fill_type=None)
for i, day in enumerate(days):
col = DAY_COLS[i]
ws.cell(2, col).value = CZECH_DAYS[day.weekday()]
ws.cell(3, col).value = day.year
ws.cell(4, col).value = day.month
ws.cell(5, col).value = day.day
is_month_start = (day.day == 1)
is_weekend = day.weekday() >= 5 # Sat=5, Sun=6
for r in (3, 4, 5):
# Day half (left): left border (medium at month boundary, else thin), no right border
ws.cell(r, col).border = Border(
left=medium if is_month_start else thin,
right=None,
top=thin if r == 3 else None,
bottom=thin if r == 5 else None,
)
# Night half (right): no left border, thin right border
ws.cell(r, col + 1).border = Border(
left=None,
right=thin,
top=thin if r == 3 else None,
bottom=thin if r == 5 else None,
)
# Apply weekend fill (or clear for weekday)
ws.cell(r, col).fill = WEEKEND_FILL if is_weekend else NO_FILL
ws.cell(r, col + 1).fill = WEEKEND_FILL if is_weekend else NO_FILL
# R2 (weekday name) — same peach as R3-R5 for weekend
ws.cell(2, col).fill = WEEKEND_FILL if is_weekend else NO_FILL
ws.cell(2, col + 1).fill = WEEKEND_FILL if is_weekend else NO_FILL
# Build dayIndex lookup by date
by_date = {}
for d in schedule['dayIndex']:
by_date[(d['year'], d['month'], d['day'])] = d
# ---- Step 5: SAZLT + D8 closures into row 1 (comma-separated) ----
info_rows = schedule.get('infoRows') or {}
sazlt_by_idx = {x['dayIdx']: x['text'] for x in (info_rows.get('sazlt') or [])}
d8_by_idx = {x['dayIdx']: x['text'] for x in (info_rows.get('d8') or [])}
for i, day in enumerate(days):
di = by_date.get((day.year, day.month, day.day))
if not di:
continue
parts = []
s = sazlt_by_idx.get(di['idx'])
d = d8_by_idx.get(di['idx'])
if s: parts.append(s)
if d: parts.append(d)
if parts:
col = DAY_COLS[i]
ws.cell(1, col).value = ', '.join(parts)
# Merge across the day-pair so the label spans both day + night cols
ws.merge_cells(start_row=1, start_column=col, end_row=1, end_column=col + 1)
# ---- Step 6: TKB closures: merged R3:R5 of NIGHT half (odd col), rotated text + bg color. Date in DAY half stays. ----
tunnel_closures = {tc['dayIdx']: tc['text'] for tc in (schedule.get('tunnelClosures') or [])}
tunnel_colors = {tc['dayIdx']: tc.get('color') for tc in (schedule.get('tunnelClosures') or [])}
DEFAULT_TKB_BG = 'FFC000' # orange — matches predominant style in sample
for i, day in enumerate(days):
di = by_date.get((day.year, day.month, day.day))
if not di:
continue
tkb = tunnel_closures.get(di['idx'])
if tkb:
night_col = DAY_COLS[i] + 1
ws.merge_cells(start_row=3, start_column=night_col, end_row=5, end_column=night_col)
cell = ws.cell(3, night_col)
cell.value = tkb
cell.alignment = Alignment(text_rotation=90, horizontal='center', vertical='center')
color = tunnel_colors.get(di['idx']) or DEFAULT_TKB_BG
color = color.lstrip('#').upper()
cell.fill = PatternFill(start_color=color, end_color=color, fill_type='solid')
cell.border = Border(left=None, right=thin, top=thin, bottom=thin)
# ---- Step 7: Fill person values ----
# Translation rules per group:
# ALL groups: A → 12 (day half), B → 12 (night half)
# IT only: 16 → day=4, night=12 ; 24 → day=12, night=12
def translate(value, is_it):
"""Convert app shift code → (day_value, night_value) for excel cells.
Returns (day, night) where each is the value to write or None."""
if value is None or value == '':
return (None, None)
if value == 'A':
return ('12', None)
if value == 'B':
return (None, '12')
if value == 'U':
return (None, 'U') # uzávěra is always at night
if is_it:
if value == '16':
return ('4', '12')
if value == '24':
return ('12', '12')
return (value, None) # default: in day half
def write_cell(row, col, val, text_color=None, custom_bg=None):
if val in (None, '') and not custom_bg:
return
cell = ws.cell(row, col)
is_numeric = False
if val not in (None, ''):
try:
n = int(val)
cell.value = n
is_numeric = True
except (ValueError, TypeError):
cell.value = val
# Background priority: custom color from app > shift color (only for letter codes).
bg = None
if custom_bg:
bg = custom_bg.lstrip('#').upper()
elif val not in (None, '') and not is_numeric:
bg = CELL_COLORS.get(str(val))
if bg:
cell.fill = PatternFill(start_color=bg, end_color=bg, fill_type='solid')
# Apply text color (red for VN persons, blue for NN persons)
if text_color:
base = cell.font
cell.font = Font(
name=base.name, size=base.size, bold=base.bold, italic=base.italic,
color=text_color,
)
for person in schedule['people']:
is_tkb = person.get('group') == 'TKB'
is_it = not is_tkb
rows_map = TKB_ROWS if is_tkb else IT_ROWS
row = rows_map.get(person['name'])
if not row:
continue
# Text color based on qualification: VN→red, NN→blue
note = (person.get('note') or '').strip()
text_color = None
if note == 'VN':
text_color = 'FFD32F2F'
elif note == 'NN':
text_color = 'FF1565C0'
for i, day in enumerate(days):
di = by_date.get((day.year, day.month, day.day))
if not di:
continue
cd = person['data'].get(str(di['idx']))
if not cd:
continue
day_col = DAY_COLS[i]
night_col = day_col + 1
# Apply translations to whole-cell value, half values are explicit
day_v = cd.get('dayValue')
night_v = cd.get('nightValue')
if day_v is None and night_v is None and cd.get('v'):
# Legacy whole-cell value — translate
day_v, night_v = translate(cd.get('v'), is_it)
else:
# Explicit halves — translate each independently if needed
if day_v in ('A',): day_v = '12'
if night_v in ('B',): night_v = '12'
if is_it:
if day_v == '16': day_v, _ = ('4', None); night_v = night_v or '12'
if night_v == '16': night_v, _ = ('12', None); day_v = day_v or '4'
if day_v == '24': day_v, _ = ('12', None); night_v = night_v or '12'
if night_v == '24': night_v = '12'; day_v = day_v or '12'
# Custom background colors from webapp (per-half override > whole-cell)
day_bg = cd.get('dayColor') or cd.get('color')
night_bg = cd.get('nightColor') or cd.get('color')
write_cell(row, day_col, day_v, text_color, custom_bg=day_bg)
write_cell(row, night_col, night_v, text_color, custom_bg=night_bg)
# PMS: data is stored at person.data[String(-month)] for the requested month
pms = person['data'].get(str(-month))
if pms and pms.get('v'):
write_cell(row, 71, pms['v'], text_color)
# ---- Step 8: FPD column at C72 — header + per-person diff (workedHours - FPD) ----
holidays = czech_holidays(year)
work_days = sum(
1 for d in schedule['dayIndex']
if d['year'] == year and d['month'] == month
and not d.get('weekend')
and (d['month'], d['day']) not in holidays
)
month_fpd = work_days * 8
# Unmerge any leftover merges in col 72
for m in list(ws.merged_cells.ranges):
if m.min_col <= 72 <= m.max_col and m.max_row >= 7:
ws.unmerge_cells(str(m))
# Match col 72 width to col 71 (PMS)
pms_col_dim = ws.column_dimensions.get('BS')
if pms_col_dim and pms_col_dim.width:
ws.column_dimensions['BT'].width = pms_col_dim.width
# Strip formatting on the IT section of PMS/FPD cols and adjacent boundaries
NO_BORDER = Border()
for r in range(IT_FIRST_ROW, BELOW_IT_ROW):
for c in (70, 71, 72, 73):
cell = ws.cell(r, c)
if c in (71, 72):
cell.fill = PatternFill(fill_type=None)
cell.border = NO_BORDER
else:
# Adjacent cols: only clear the side facing PMS/FPD
b = cell.border
if c == 70:
cell.border = Border(left=b.left, right=Side(), top=b.top, bottom=b.bottom)
elif c == 73:
cell.border = Border(left=Side(), right=b.right, top=b.top, bottom=b.bottom)
# Clear bleed-in from rows 22 (above IT) and 28 (below IT) on BS/BT
for c in (71, 72):
b = ws.cell(IT_HEADER_ROW, c).border
ws.cell(IT_HEADER_ROW, c).border = Border(left=b.left, right=b.right, top=b.top, bottom=Side())
ws.cell(BELOW_IT_ROW, c).border = NO_BORDER
# Header at C72 (BT) — copy style from C73 (PMS), set text "FPD", merge R2:R6
src_header = ws.cell(2, 71)
dst_header = ws.cell(2, 72)
dst_header.value = 'FPD'
dst_header.alignment = copy(src_header.alignment)
dst_header.font = copy(src_header.font)
dst_header.fill = copy(src_header.fill)
dst_header.border = copy(src_header.border)
try:
ws.merge_cells(start_row=2, start_column=72, end_row=6, end_column=72)
except Exception:
pass
# Per-person FPD diff (calculate for ALL TKB persons, including 162 group)
for person in schedule['people']:
if person.get('group') != 'TKB':
continue
row = TKB_ROWS.get(person['name'])
if not row:
continue
worked = 0.0
vacation = 0.0 # D = 8h, D/2 = 4h — reduces person's FPD target
def collect(v):
nonlocal worked, vacation
if not v:
return
if v == 'D':
vacation += 8
elif v == 'D/2':
vacation += 4
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_split = bool(cd.get('dayColor') or cd.get('nightColor')
or cd.get('dayValue') or cd.get('nightValue'))
if is_split:
collect(cd.get('dayValue'))
collect(cd.get('nightValue'))
else:
collect(cd.get('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'),
# Kropáček Ondřej — part-time student, not in CIS (no personal number); add here
# if he joins full time: ('Kropáček Ondřej', 'TKB', <osobní číslo>, 'Kropáč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

@@ -194,9 +194,103 @@ app.get('/api/files/:id', (req, res) => {
}
})
// Export file as Excel (not yet implemented for TKB format)
app.get('/api/files/:id/export-excel', (_req, res) => {
res.status(501).json({ error: 'Excel export not yet implemented for TKB format' })
// Export full schedule Excel for a given month
app.get('/api/files/:id/export-excel', 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: fileData.data, 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: fileData.data, 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

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 type { SelectedCell } from './ScheduleTable'
import { Toolbar } from './Toolbar'
@@ -24,6 +24,14 @@ export function setFavouriteFileId(id: string | null): void {
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 {
const targetStart = new Date(2026, 0, 1) // January 1
const targetEnd = new Date(2026, 11, 31) // December 31
@@ -95,6 +103,12 @@ function getISOWeek(date: Date): number {
type AppMode = 'login' | 'files' | 'editor'
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)
useEffect(() => {
let serverVersion: string | null = null
@@ -115,12 +129,16 @@ function App() {
const [authed, setAuthed] = useState(isAuthenticated())
const [role, setRole] = useState<UserRole>(() => isAuthenticated() ? getAuthRole() : 'editor')
const isViewer = role === 'viewer'
const isCis = role === 'cis'
const isReadOnlyRole = isViewer || isCis // cis has same UI rights as viewer
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 [fileId, setFileId] = useState<string | null>(null)
const [fileName, setFileName] = useState<string>('')
const [fileData, setFileData] = useState<ScheduleData | null>(null)
const [loading, setLoading] = useState(hasFavOnLoad)
const [loading, setLoading] = useState(hasFavOnLoad || viewerAutoLoad)
const [compareFileId, setCompareFileId] = useState<string | null>(null)
const autoOpenAttempted = useRef(false)
@@ -143,18 +161,37 @@ function App() {
}
}, [])
// Auto-open favourite file when authenticated
// Auto-open favourite file when authenticated; for viewers with no favourite, auto-open most recent
useEffect(() => {
if (!authed || autoOpenAttempted.current) return
autoOpenAttempted.current = true
const fav = getFavouriteFileId()
if (fav) handleOpenFile(fav)
}, [authed, handleOpenFile])
if (fav) {
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) => {
autoOpenAttempted.current = false
setRole(newRole)
setAuthed(true)
setMode('files')
}, [])
const handleLogout = useCallback(() => {
@@ -214,7 +251,7 @@ function App() {
}
if (mode === 'files') {
if (isViewer) {
if (isReadOnlyRole) {
return (
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
<div className="text-center">
@@ -234,8 +271,9 @@ function App() {
fileName={fileName}
data={fileData}
compareFileId={compareFileId}
onBack={isViewer ? undefined : handleBackToFiles}
isReadOnly={isViewer}
onBack={isReadOnlyRole ? undefined : handleBackToFiles}
isReadOnly={isReadOnlyRole}
canExportHarmonogram={!isViewer}
onFileNameChange={setFileName}
onClearCompare={() => setCompareFileId(null)}
onLogout={handleLogout}
@@ -291,15 +329,16 @@ interface ScheduleAppProps {
onFileNameChange: (name: string) => void
onClearCompare: () => void
isReadOnly?: boolean
canExportHarmonogram?: boolean
onLogout?: () => void
}
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, onLogout }: ScheduleAppProps) {
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, canExportHarmonogram = true, onLogout }: ScheduleAppProps) {
const {
people, tunnelClosures, tunnelColors,
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
dayComments, cellComments,
setCell, setCellColor, moveCell, setTunnelClosure, setTunnelClosureColor,
setCell, setCellColor, setCellHalfColor, setCellHalfValue, pasteCellTo, moveCell, setTunnelClosure, setTunnelClosureColor,
setMetroClosure, setMetroClosureColor, setD8Closure, setD8ClosureColor,
setSazltClosure, setSazltClosureColor,
undo, canUndo,
@@ -393,12 +432,14 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
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 })
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 })
setSelectedCellsForMenu(selectedCells)
}, [])
const handleDenDaySelect = useCallback((dayIdx: number, shiftKey: boolean) => {
@@ -476,14 +517,115 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
const [showMetro, setShowMetro] = useState(true)
const [showD8, setShowD8] = useState(true)
const [showSazlt, setShowSazlt] = useState(true)
const [showIT, setShowIT] = useState(true)
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())
// 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 { exportMonthPdf } = await import('./PdfExport')
const year = data.dayIndex.find(d => d.month === month)?.year ?? new Date().getFullYear()
await exportMonthPdf(month, year, data.dayIndex, people, tunnelClosures, tunnelColors)
}, [data.dayIndex, people, tunnelClosures, tunnelColors])
await exportMonthPdf(month, year, data.dayIndex, visiblePeople, tunnelClosures, tunnelColors, {
hide162,
showIT,
hiddenTkbPeople,
hiddenValues,
})
}, [data.dayIndex, visiblePeople, tunnelClosures, tunnelColors, hide162, showIT, hiddenTkbPeople, hiddenValues])
const MONTH_NAMES_FULL: Record<number, string> = {
1:'Leden',2:'Únor',3:'Březen',4:'Duben',5:'Květen',6:'Červen',
@@ -501,6 +643,10 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
: undefined
const contextCellValue = contextCellData?.v
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
const contextInfoRowId = contextMenu?.infoRowId
@@ -522,6 +668,24 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
else if (contextInfoRowId === 'sazlt') setSazltClosureColor(dayIdx, color)
}, [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 (
<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">
@@ -579,6 +743,10 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
onToggleD8={() => setShowD8(v => !v)}
showSazlt={showSazlt}
onToggleSazlt={() => setShowSazlt(v => !v)}
showIT={showIT}
onToggleIT={() => setShowIT(v => !v)}
theme={theme}
onToggleTheme={() => setTheme(t => t === 'dark' ? 'light' : 'dark')}
hide162={hide162}
onToggle162={() => setHide162(v => !v)}
hiddenValues={hiddenValues}
@@ -590,13 +758,22 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
diffFileName={compareFileName}
onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }}
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}`
} : undefined}
onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)}
onCheckFpd={() => setShowFpd(true)}
isReadOnly={isReadOnly}
tkbPeopleList={tkbPeopleList}
hiddenTkbPeople={hiddenTkbPeople}
onToggleTkbPerson={handleToggleTkbPerson}
onSetAllTkbVisible={handleSetAllTkbVisible}
onSetOnlyTkbPerson={handleSetOnlyTkbPerson}
/>
<ScheduleTable
dayIndex={data.dayIndex}
people={people}
people={visiblePeople}
tunnelClosures={tunnelClosures}
tunnelColors={tunnelColors}
metroClosures={metroClosures}
@@ -610,6 +787,7 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
dragState={isReadOnly ? null : dragState}
onCellPointerDown={isReadOnly ? () => {} : onCellPointerDown}
onSetCell={isReadOnly ? () => {} : setCell}
onSetCellHalfValue={isReadOnly ? () => {} : setCellHalfValue}
onSetTunnelClosure={isReadOnly ? () => {} : setTunnelClosure}
onSetMetroClosure={isReadOnly ? () => {} : setMetroClosure}
onSetD8Closure={isReadOnly ? () => {} : setD8Closure}
@@ -617,13 +795,21 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
showMetro={showMetro}
showD8={showD8}
showSazlt={showSazlt}
showIT={showIT}
hide162={hide162}
hiddenTkbPeople={hiddenTkbPeople}
hiddenValues={hiddenValues}
scrollRef={scrollRef}
onContextMenu={isReadOnly ? () => {} : handleContextMenu}
onTunnelContextMenu={isReadOnly ? () => {} : handleTunnelContextMenu}
onInfoRowContextMenu={isReadOnly ? () => {} : handleInfoRowContextMenu}
compareData={compareData}
isReadOnly={isReadOnly}
clipboardSource={clipboardSource}
hasClipboard={!!clipboard}
onCopyCell={isReadOnly ? () => {} : handleCopyCell}
onPasteCells={isReadOnly ? () => {} : handlePasteCells}
onClearClipboard={handleClearClipboard}
/>
</main>
@@ -634,6 +820,10 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
personName={contextPerson?.name || null}
cellValue={contextCellValue}
cellColor={contextCellColor}
cellDayColor={contextCellDayColor}
cellNightColor={contextCellNightColor}
cellDayValue={contextCellDayValue}
cellNightValue={contextCellNightValue}
existingDayComment={dayComments.get(contextMenu.dayIdx)}
existingCellComment={contextMenu.personId ? cellComments.get(`${contextMenu.personId}-${contextMenu.dayIdx}`) : undefined}
existingTunnelClosure={tunnelClosures.get(contextMenu.dayIdx)}
@@ -649,8 +839,15 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
onRemoveCellComment={removeCellComment}
onSetTunnelClosure={setTunnelClosure}
onSetCellColor={setCellColor}
onSetCellHalfColor={setCellHalfColor}
onSetCellHalfValue={setCellHalfValue}
onSetTunnelClosureColor={setTunnelClosureColor}
onSetInfoRowColor={handleInfoRowSetColor}
onSetAnyColor={handleSetAnyColor}
onSetAnyValue={handleSetAnyValue}
hasClipboard={!!clipboard}
onCopyCell={handleCopyCell}
onPasteCells={handlePasteCells}
onClose={() => { setContextMenu(null); setSelectedDenDays([]) }}
/>
)}
@@ -664,12 +861,16 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
month={activeMonth}
year={data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026}
dayIndex={data.dayIndex}
people={people}
people={visiblePeople}
onClose={() => setShowFpd(false)}
onExportDochazka={currentFileId ? () => {
onExportDochazka={currentFileId && !isReadOnly ? () => {
const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026
window.location.href = `/api/files/${currentFileId}/export-dochazka?month=${activeMonth}&year=${year}`
} : 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}`
} : undefined}
/>
)}
</div>

View File

@@ -9,6 +9,10 @@ interface ContextMenuProps {
personName: string | null
cellValue: string | undefined
cellColor: string | undefined
cellDayColor: string | undefined
cellNightColor: string | undefined
cellDayValue: string | undefined
cellNightValue: string | undefined
existingDayComment: string | undefined
existingCellComment: string | undefined
existingTunnelClosure: string | undefined
@@ -24,8 +28,15 @@ interface ContextMenuProps {
onRemoveCellComment: (personId: string, dayIdx: number) => void
onSetTunnelClosure: (dayIdx: number, text: 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
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
}
@@ -35,6 +46,10 @@ export function ContextMenu({
personName,
cellValue,
cellColor,
cellDayColor,
cellNightColor,
cellDayValue,
cellNightValue,
existingDayComment,
existingCellComment,
existingTunnelClosure,
@@ -50,16 +65,40 @@ export function ContextMenu({
onRemoveCellComment,
onSetTunnelClosure,
onSetCellColor,
onSetCellHalfColor,
onSetCellHalfValue,
onSetTunnelClosureColor,
onSetInfoRowColor,
onSetAnyColor,
onSetAnyValue,
hasClipboard,
onCopyCell,
onPasteCells,
onClose,
}: ContextMenuProps) {
const [colorTarget, setColorTarget] = useState<'cell' | 'day' | 'night'>('cell')
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 [commentText, setCommentText] = useState('')
const [showCellCommentInput, setShowCellCommentInput] = useState(false)
const [cellCommentText, setCellCommentText] = useState('')
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 commentInputRef = useRef<HTMLInputElement>(null)
const cellValueInputRef = useRef<HTMLInputElement>(null)
@@ -108,26 +147,36 @@ export function ContextMenu({
onClose()
}
const handleSetCellValue = () => {
const val = cellValueInput.trim()
if (isMultiSelect) {
for (const cell of selectedCells) {
onSetCell(cell.personId, cell.dayIdx, val || null)
const INFO_KEYS = new Set(['__tunnel__', '__metro__', '__d8__', '__sazlt__'])
const applyValueToCells = (val: string | null) => {
const cells = isMultiSelect ? selectedCells : (state.personId ? [{ personId: state.personId, dayIdx: state.dayIdx }] : [])
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()
}
const handleClearCell = () => {
if (isMultiSelect) {
for (const cell of selectedCells) {
onSetCell(cell.personId, cell.dayIdx, null)
}
} else if (state.personId) {
onSetCell(state.personId, state.dayIdx, null)
}
applyValueToCells(null)
onClose()
}
@@ -137,20 +186,78 @@ export function ContextMenu({
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 isTunnelRow = !!state.isTunnelRow
const isInfoRow = !!state.infoRowId
const infoRowId = state.infoRowId
const infoRowLabel = infoRowId === 'metro' ? 'METRO' : infoRowId === 'd8' ? 'D8' : ''
const isDenRow = !state.personId && !isTunnelRow && !isInfoRow
const multiDayLabel = selectedDays.length > 1 ? ` (${selectedDays.length} dnu)` : ''
const 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 activeColor = isTunnelRow
? existingTunnelColor
: isInfoRow
? existingInfoRowColor
: colorTarget === 'day'
? cellDayColor
: colorTarget === 'night'
? cellNightColor
: 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 (
<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>
@@ -160,20 +267,7 @@ export function ContextMenu({
return (
<button
key={color || 'none'}
onClick={() => {
if (isTunnelRow) {
onSetTunnelClosureColor(state.dayIdx, color || null)
} else if (isInfoRow) {
onSetInfoRowColor(state.dayIdx, color || null)
} else if (isMultiSelect) {
for (const cell of selectedCells) {
onSetCellColor(cell.personId, cell.dayIdx, color || null)
}
} else if (state.personId) {
onSetCellColor(state.personId, state.dayIdx, color || null)
}
onClose()
}}
onClick={() => applyColor(color || null)}
className="w-6 h-6 rounded border border-slate-600 hover:scale-110 transition-transform cursor-pointer flex items-center justify-center"
style={{ backgroundColor: color || '#1e293b' }}
title={label}
@@ -223,27 +317,74 @@ export function ContextMenu({
{dateStr} (T{dayInfo.week})
</div>
{isInfoRow ? (
<>
{existingInfoRowClosure && (
<div className="px-3 py-2 text-xs text-blue-300 border-b border-slate-700">
<span className="text-blue-400 mr-1.5">&#9654;</span>
{existingInfoRowClosure}
{isInfoRow || isTunnelRow ? (() => {
const rowKey = isTunnelRow
? '__tunnel__'
: infoRowId === 'metro' ? '__metro__'
: infoRowId === 'd8' ? '__d8__'
: '__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>
)}
{renderColorPalette()}
</>
) : isTunnelRow ? (
<>
{existingTunnelClosure && (
<div className="px-3 py-2 text-xs text-orange-300 border-b border-slate-700">
<span className="text-orange-400 mr-1.5">&#9654;</span>
{existingTunnelClosure}
</div>
)}
{renderColorPalette()}
</>
) : isDenRow ? (
{renderColorPalette()}
</>
)
})() : isDenRow ? (
<>
{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 */}
<div className="px-3 py-2 border-b border-slate-700">
<div className="flex items-center gap-1.5">

View File

@@ -6,6 +6,13 @@ const MONTH_NAMES: Record<number, string> = {
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 {
personId: string
name: string
@@ -13,6 +20,7 @@ interface FpdResult {
workedHours: number
fpd: number
diff: number // positive = over, negative = under
infoOnly: boolean // contractor — never warn on shortfall
}
function getHoursForValue(v: string): number {
@@ -39,27 +47,52 @@ export function computeFpd(
}
const fpd = workDays * 8
// Check TKB people only
const tkbPeople = people.filter(p => p.group === 'TKB')
// Check TKB people only — exclude the "162" group (handled separately)
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[] = []
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 vacationHours = 0 // D vacation reduces the FPD target (full day = dailyHours)
for (const d of monthDays) {
const cellData = person.data[String(d.idx)]
const value = cellData?.v
if (value) {
workedHours += getHoursForValue(value)
if (!cellData) continue
const isWorkDay = !d.weekend && !holidaySet.has(d.day)
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 on a working day is credited as a normal worked day (daily hours),
// so sick leave neither helps nor hurts the FPD balance.
else if (v === 'N') { if (isWorkDay) workedHours += dailyHours }
else workedHours += getHoursForValue(v)
}
if (isSplit) {
collect(cellData.dayValue)
collect(cellData.nightValue)
} else {
collect(cellData.v)
}
}
const diff = workedHours - fpd
// PMS (práce mimo směnu) is stored at day-index -month; counts as worked hours.
const pms = person.data[String(-month)]?.v
if (pms) workedHours += getHoursForValue(pms)
const personFpd = workDays * dailyHours - vacationHours
const diff = workedHours - personFpd
results.push({
personId: person.id,
name: person.name,
note: person.note,
workedHours,
fpd,
fpd: personFpd,
diff,
infoOnly: INFO_ONLY_PEOPLE.has(person.name),
})
}
@@ -73,12 +106,14 @@ interface FpdModalProps {
people: Person[]
onClose: () => 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 under = results.filter(r => r.diff < 0)
const okOrOver = results.filter(r => r.diff >= 0)
// Info-only people (contractors) never count as a shortfall
const under = results.filter(r => r.diff < 0 && !r.infoOnly)
const okOrOver = results.filter(r => r.diff >= 0 || r.infoOnly)
return (
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50" onClick={onClose}>
@@ -132,32 +167,51 @@ export function FpdModal({ month, year, dayIndex, people, onClose, onExportDocha
<div
key={r.personId}
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">
{r.name}
{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 className="flex items-center gap-2">
<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.diff > 0 ? `+${r.diff}h` : '✓'}
</span>
{r.infoOnly ? (
<span className="font-bold text-slate-400">{r.diff > 0 ? `+${r.diff}` : r.diff}h</span>
) : (
<span className={`font-bold ${r.diff > 0 ? 'text-slate-300' : 'text-green-400'}`}>
{r.diff > 0 ? `+${r.diff}h` : '✓'}
</span>
)}
</span>
</div>
))}
</div>
</div>
{onExportDochazka && (
<div className="px-5 py-3 border-t border-slate-700 flex justify-end">
<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>
{(onExportDochazka || onExportHarmonogram) && (
<div className="px-5 py-3 border-t border-slate-700 flex justify-end gap-2">
{onExportHarmonogram && (
<button
onClick={onExportHarmonogram}
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"
title="Export pro import do interního docházkového systému CIS"
>
↓ 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>

View File

@@ -4,17 +4,22 @@ const VALID_USER = 'tkb'
const VALID_PASS = 'sluzby'
const VIEWER_USER = 'prohlizec'
const VIEWER_PASS = 'pohled'
const CIS_USER = 'cis'
const CIS_PASS = 'import'
const AUTH_KEY = 'tkb_auth'
const ROLE_KEY = 'tkb_role'
export type UserRole = 'editor' | 'viewer'
export type UserRole = 'editor' | 'viewer' | 'cis'
export function isAuthenticated(): boolean {
return localStorage.getItem(AUTH_KEY) === 'true'
}
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 {
@@ -44,6 +49,9 @@ export function Login({ onLogin }: LoginProps) {
} else if (username === VIEWER_USER && password === VIEWER_PASS) {
setAuthenticated('viewer')
onLogin('viewer')
} else if (username === CIS_USER && password === CIS_PASS) {
setAuthenticated('cis')
onLogin('cis')
} else {
setError(true)
setTimeout(() => setError(false), 3000)

View File

@@ -2,6 +2,15 @@ import jsPDF from 'jspdf'
import type { DayInfo, Person } from './types'
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> = {
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',
@@ -40,6 +49,7 @@ export async function exportMonthPdf(
people: Person[],
tunnelClosures: 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 hasFont = await loadFont(doc)
@@ -52,8 +62,10 @@ export async function exportMonthPdf(
const startIdx = Math.max(0, firstDayIdx - daysFromPrevMonth)
const days = dayIndex.slice(startIdx, startIdx + 34)
const tkbPeople = people.filter(p => p.group === 'TKB')
const itPeople = people.filter(p => p.group === 'IT')
let tkbPeople = people.filter(p => p.group === 'TKB')
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)
const pageW = 297
@@ -63,11 +75,14 @@ export async function exportMonthPdf(
const daysPerPage = 17
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 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 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
const nameFontSize = 9
@@ -189,19 +204,21 @@ export async function exportMonthPdf(
if (showNames) doc.text('POHOTOVOST TKB', margin + 2, curY + rowH * 0.7)
curY += rowH
renderPeople(doc, tkbPeople, pageDays, curY, margin, showNames ? nameColW : 0, gridStartX, cellW, rowH, showNames, nameFontSize, cellFontSize, month)
renderPeople(doc, tkbPeople, pageDays, curY, margin, showNames ? nameColW : 0, gridStartX, cellW, rowH, showNames, nameFontSize, cellFontSize, month, filters.hiddenValues)
curY += tkbPeople.length * rowH
// IT section header
doc.setFillColor(13, 148, 136)
doc.rect(margin, curY, totalW, rowH, 'F')
doc.setFontSize(sectionFontSize)
doc.setTextColor(255, 255, 255)
if (showNames) doc.text('POHOTOVOST IT', margin + 2, curY + rowH * 0.7)
curY += rowH
if (filters.showIT) {
// IT section header
doc.setFillColor(13, 148, 136)
doc.rect(margin, curY, totalW, rowH, 'F')
doc.setFontSize(sectionFontSize)
doc.setTextColor(255, 255, 255)
if (showNames) doc.text('POHOTOVOST IT', margin + 2, curY + rowH * 0.7)
curY += rowH
renderPeople(doc, itPeople, pageDays, curY, margin, showNames ? nameColW : 0, gridStartX, cellW, rowH, showNames, nameFontSize, cellFontSize, month)
curY += itPeople.length * rowH
renderPeople(doc, itPeople, pageDays, curY, margin, showNames ? nameColW : 0, gridStartX, cellW, rowH, showNames, nameFontSize, cellFontSize, month, filters.hiddenValues)
curY += itPeople.length * rowH
}
// Outer border
doc.setDrawColor(0, 0, 0)
@@ -218,10 +235,12 @@ function renderPeople(
startY: number, margin: number, nameColW: number, gridStartX: number,
cellW: number, rowH: number, showNames: boolean,
nameFontSize: number, cellFontSize: number, activeMonth: number,
hiddenValues: Set<string>,
) {
const totalW = nameColW + days.length * cellW
const sectionH = people.length * rowH
const textY = rowH * 0.65
const halfW = cellW / 2
// Pass 1: Fill backgrounds
people.forEach((person, pi) => {
@@ -230,22 +249,62 @@ function renderPeople(
const x = gridStartX + di * cellW
const isOtherMonth = d.month !== activeMonth
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 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)
if (isOtherMonth) {
// Light gray wash for other month cells
doc.setFillColor(245, 245, 245)
doc.rect(x, y, cellW, rowH, 'F')
// If there's a value, draw it faded
if (manualColor || style) {
// For other-month cells, fade any colored content
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 }))
const rgb = hexToRgb(manualColor ?? style!.bg)
doc.setFillColor(rgb.r, rgb.g, rgb.b)
doc.rect(x, y, cellW, rowH, 'F')
doc.setGState(doc.GState({ opacity: 1 }))
}
} else if (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) {
const rgb = hexToRgb(manualColor)
doc.setFillColor(rgb.r, rgb.g, rgb.b)
@@ -301,9 +360,43 @@ function renderPeople(
days.forEach((d, di) => {
const x = gridStartX + di * cellW
const cellData = person.data[String(d.idx)]
const value = cellData?.v ?? ''
if (value) {
const isOtherMonth = d.month !== activeMonth
const rawValue = cellData?.v ?? ''
const value = hiddenValues.has(rawValue) ? '' : rawValue
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 style = getCellStyle(value || undefined)
const textColor = manualColor

View File

@@ -39,6 +39,7 @@ interface ScheduleTableProps {
dragState: DragState | null
onCellPointerDown: (e: React.PointerEvent, personId: string, dayIdx: number, value: string) => 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
onSetMetroClosure: (dayIdx: number, text: string | null) => void
onSetD8Closure: (dayIdx: number, text: string | null) => void
@@ -47,18 +48,27 @@ interface ScheduleTableProps {
showMetro: boolean
showD8: boolean
showSazlt: boolean
showIT: boolean
hide162: boolean
hiddenTkbPeople: Set<string>
hiddenValues: Set<string>
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number, selectedCells: SelectedCell[]) => void
onTunnelContextMenu: (dayIdx: number, x: number, y: number) => void
onInfoRowContextMenu: (dayIdx: number, infoRowId: string, x: number, y: number) => void
onTunnelContextMenu: (dayIdx: number, x: number, y: number, selectedCells: SelectedCell[]) => void
onInfoRowContextMenu: (dayIdx: number, infoRowId: string, x: number, y: number, selectedCells: SelectedCell[]) => void
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 {
personId: string | '__tunnel__' | '__metro__' | '__d8__' | '__sazlt__'
dayIdx: number
value: string
target?: 'cell' | 'day' | 'night' // person-cell only; default 'cell'
}
// ---------- PersonRow (memoized) ----------
@@ -84,6 +94,9 @@ const PersonRow = memo(function PersonRow({
onCellDragSelectMove,
pmsAfterSet,
pmsMonthAtPos,
isReadOnly,
rowIndex,
clipboardSource,
}: {
person: Person
dayIndex: DayInfo[]
@@ -95,7 +108,7 @@ const PersonRow = memo(function PersonRow({
onCellPointerDown: (e: React.PointerEvent, personId: string, dayIdx: number, value: string) => void
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number) => void
editingCell: EditingCell | null
onStartEdit: (personId: string, dayIdx: number, currentValue: string) => void
onStartEdit: (personId: string, dayIdx: number, currentValue: string, target?: 'cell' | 'day' | 'night') => void
onEditChange: (value: string) => void
onEditConfirm: () => void
onEditCancel: () => void
@@ -105,17 +118,28 @@ const PersonRow = memo(function PersonRow({
onCellDragSelectMove: (personId: string, dayIdx: number) => void
pmsAfterSet: Set<number>
pmsMonthAtPos: Map<number, number>
isReadOnly: boolean
rowIndex: number
clipboardSource: { personId: string; dayIdx: number } | null
}) {
const isDragging = dragState?.personId === person.id
const zebra = rowIndex % 2 === 1 ? 'bg-slate-500/20' : ''
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) => {
const cellData = person.data[String(d.idx)]
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 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 isWeekend = d.weekend || isHoliday
const commentKey = `${person.id}-${d.idx}`
@@ -129,7 +153,7 @@ const PersonRow = memo(function PersonRow({
const bgStyle: React.CSSProperties = {}
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' : ''}`
if (isDragOriginal) {
@@ -140,8 +164,14 @@ const PersonRow = memo(function PersonRow({
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) {
// 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'
} else {
const manualColor = cellData?.color
@@ -161,11 +191,24 @@ const PersonRow = memo(function PersonRow({
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) {
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 = (
<div
key={d.idx}
@@ -173,6 +216,7 @@ const PersonRow = memo(function PersonRow({
style={{ width: CELL_W, height: CELL_H, ...bgStyle }}
onPointerDown={(e) => {
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 (hasCellData) {
onCellPointerDown(e, person.id, d.idx, value || '●')
@@ -182,18 +226,24 @@ const PersonRow = memo(function PersonRow({
}
}}
onPointerEnter={(e) => {
if (e.buttons === 1 && !hasCellData) {
if (!isReadOnly && e.buttons === 1 && !hasCellData) {
onCellDragSelectMove(person.id, d.idx)
}
}}
onClick={(e) => {
if (e.button === 2) return
if (dragState) return
if (isReadOnly || dragState) return
onCellClick(person.id, d.idx, e.ctrlKey || e.metaKey, e.shiftKey)
}}
onDoubleClick={() => {
if (dragState) return
onStartEdit(person.id, d.idx, value)
onDoubleClick={(e) => {
if (isReadOnly || dragState) return
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) => {
e.preventDefault()
@@ -205,10 +255,31 @@ const PersonRow = memo(function PersonRow({
(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 ? (
<input
autoFocus
className="w-full h-full bg-white text-slate-900 text-center text-sm font-mono font-bold outline-none border-2 border-blue-500"
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 }}
value={editingCell!.value}
onChange={(e) => onEditChange(e.target.value)}
@@ -223,9 +294,9 @@ const PersonRow = memo(function PersonRow({
) : (
<>
{isDragPreview ? (
<span className="truncate text-amber-300 font-semibold">{dragState!.value}</span>
) : value && !isValueHidden ? (
<span className="truncate font-medium">{value}</span>
<span className="truncate text-amber-300 font-semibold relative z-10">{dragState!.value}</span>
) : value && !isValueHidden && !renderAsSplit ? (
<span className="truncate font-medium relative z-10">{value}</span>
) : null}
</>
)}
@@ -259,12 +330,13 @@ const PersonRow = memo(function PersonRow({
<div
key={`pms-${pmsMonth}`}
className={`flex items-center justify-center text-sm font-mono font-bold relative
border-r border-slate-600 cursor-text hover:brightness-125
${!pmsColor ? 'bg-slate-700/30' : ''}`}
border-r border-slate-600
${isReadOnly ? 'cursor-default' : 'cursor-text hover:brightness-125'}
${!pmsColor ? 'pms-cell' : ''}`}
style={pmsStyle}
onDoubleClick={() => onStartEdit(person.id, pmsDayIdx, pmsValue)}
onDoubleClick={() => { if (!isReadOnly) onStartEdit(person.id, pmsDayIdx, pmsValue) }}
onClick={() => {
if (!isPmsEditing) onStartEdit(person.id, pmsDayIdx, pmsValue)
if (!isReadOnly && !isPmsEditing) onStartEdit(person.id, pmsDayIdx, pmsValue)
}}
onContextMenu={(e) => {
e.preventDefault()
@@ -275,7 +347,7 @@ const PersonRow = memo(function PersonRow({
{isPmsEditing ? (
<input
autoFocus
className="w-full h-full bg-white text-slate-900 text-center text-sm font-mono font-bold outline-none border-2 border-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 }}
value={editingCell!.value}
onChange={(e) => onEditChange(e.target.value)}
@@ -309,10 +381,12 @@ export function ScheduleTable(props: ScheduleTableProps) {
dayIndex, people, tunnelClosures, tunnelColors,
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
dayComments, cellComments,
dragState, onCellPointerDown, onSetCell, onSetTunnelClosure,
dragState, onCellPointerDown, onSetCell, onSetCellHalfValue, onSetTunnelClosure,
onSetMetroClosure, onSetD8Closure, onSetSazltClosure,
showMetro, showD8, showSazlt, hide162, hiddenValues,
showMetro, showD8, showSazlt, showIT, hide162, hiddenTkbPeople, hiddenValues,
scrollRef, onContextMenu, onTunnelContextMenu, onInfoRowContextMenu, compareData,
isReadOnly = false,
clipboardSource, hasClipboard, onCopyCell, onPasteCells, onClearClipboard,
} = props
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 tkbPeople = useMemo(() => {
const all = people.filter(p => p.group === 'TKB')
return hide162 ? all.filter(p => !HIDE_162_NAMES.includes(p.name)) : all
}, [people, hide162])
let all = people.filter(p => p.group === 'TKB')
if (hide162) all = all.filter(p => !HIDE_162_NAMES.includes(p.name))
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])
// ---------- PMS (Práce mimo směnu) month-end positions ----------
@@ -425,8 +501,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
// ---------- Editing handlers ----------
const onStartEdit = useCallback((personId: string, dayIdx: number, currentValue: string) => {
setEditingCell({ personId, dayIdx, value: currentValue })
const onStartEdit = useCallback((personId: string, dayIdx: number, currentValue: string, target: 'cell' | 'day' | 'night' = 'cell') => {
setEditingCell({ personId, dayIdx, value: currentValue, target })
}, [])
const onStartEditTunnel = useCallback((dayIdx: number, currentValue: string) => {
@@ -461,10 +537,15 @@ export function ScheduleTable(props: ScheduleTableProps) {
} else if (editingCell.personId === '__sazlt__') {
onSetSazltClosure(editingCell.dayIdx, trimmed || null)
} 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)
}, [editingCell, onSetCell, onSetTunnelClosure, onSetMetroClosure, onSetD8Closure, onSetSazltClosure])
}, [editingCell, onSetCell, onSetCellHalfValue, onSetTunnelClosure, onSetMetroClosure, onSetD8Closure, onSetSazltClosure])
const onEditCancel = useCallback(() => {
setEditingCell(null)
@@ -479,31 +560,41 @@ export function ScheduleTable(props: ScheduleTableProps) {
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((
anchor: { personId: string; dayIdx: number },
current: { personId: string; dayIdx: number },
): Set<string> => {
const anchorPersonIdx = people.findIndex(p => p.id === anchor.personId)
const currentPersonIdx = people.findIndex(p => p.id === current.personId)
if (anchorPersonIdx < 0 || currentPersonIdx < 0) return new Set()
const anchorIdx = allRowKeys.indexOf(anchor.personId)
const currentIdx = allRowKeys.indexOf(current.personId)
if (anchorIdx < 0 || currentIdx < 0) return new Set()
const anchorDayPos = dayIndex.findIndex(d => d.idx === anchor.dayIdx)
const currentDayPos = dayIndex.findIndex(d => d.idx === current.dayIdx)
if (anchorDayPos < 0 || currentDayPos < 0) return new Set()
const minP = Math.min(anchorPersonIdx, currentPersonIdx)
const maxP = Math.max(anchorPersonIdx, currentPersonIdx)
const minP = Math.min(anchorIdx, currentIdx)
const maxP = Math.max(anchorIdx, currentIdx)
const minD = Math.min(anchorDayPos, currentDayPos)
const maxD = Math.max(anchorDayPos, currentDayPos)
const sel = new Set<string>()
for (let pi = minP; pi <= maxP; pi++) {
for (let di = minD; di <= maxD; di++) {
sel.add(cellKey(people[pi].id, dayIndex[di].idx))
sel.add(cellKey(allRowKeys[pi], dayIndex[di].idx))
}
}
return sel
}, [people, dayIndex])
}, [allRowKeys, dayIndex])
const onCellClick = useCallback((personId: string, dayIdx: number, ctrlKey: boolean, shiftKey: boolean) => {
const key = cellKey(personId, dayIdx)
@@ -571,17 +662,76 @@ export function ScheduleTable(props: ScheduleTableProps) {
prevDragRef.current = dragState
}, [dragState])
// Clear selection on Escape
// Keyboard: Escape clears selection + clipboard, Ctrl+C/V copy/paste
useEffect(() => {
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') {
setSelectedCells(new Set())
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)
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
const handlePersonContextMenu = useCallback((dayIdx: number, personId: string | null, x: number, y: number) => {
@@ -608,6 +758,22 @@ export function ScheduleTable(props: ScheduleTableProps) {
}
}, [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 ----------
const dragOverlay = useMemo(() => {
@@ -727,7 +893,6 @@ export function ScheduleTable(props: ScheduleTableProps) {
rowId: '__tunnel__' | '__metro__' | '__d8__' | '__sazlt__',
closures: Map<number, string>,
colors: Map<number, string>,
defaultBgClass: string,
borderClass: string,
editBorderColor: string,
label: string,
@@ -745,21 +910,48 @@ export function ScheduleTable(props: ScheduleTableProps) {
if (closureColor) {
rowBg.backgroundColor = 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[] = []
cells.push(
<div
key={d.idx}
className={`flex items-center justify-center text-xs font-mono font-bold relative
border-r border-slate-700/20
${!closureColor && closureVal ? `${defaultBgClass}` : !closureColor ? (isOff ? 'bg-slate-700/30' : 'bg-slate-800/30') : ''}
border-r border-slate-500/40
${!closureColor && !getCellStyle(closureVal) ? (isOff ? 'bg-slate-700/30' : 'bg-slate-800/30') : ''}
${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 }}
onClick={() => {
if (!isEditingThis) onStartEditFn(d.idx, closureVal)
style={{ width: CELL_W, height: 28, ...rowBg, ...infoExtraStyle }}
onPointerDown={(e) => {
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) => {
e.preventDefault()
@@ -770,7 +962,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
{isEditingThis ? (
<input
autoFocus
className={`w-full h-full bg-white text-slate-900 text-center text-xs font-mono font-bold outline-none border-2 ${editBorderColor}`}
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}
onChange={(e) => onEditChange(e.target.value)}
onKeyDown={(e) => {
@@ -787,7 +979,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
)
if (pmsAfterSet.has(i)) {
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
@@ -808,7 +1000,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
)
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">
{/* === Fixed left name column === */}
@@ -843,8 +1035,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
{renderSectionHeaderLabel('Pohotovost TKB', 'bg-indigo-700/60', 'text-indigo-100')}
{/* TKB person names */}
{tkbPeople.map((person) => (
<div key={person.id} className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: CELL_H }}>
{tkbPeople.map((person, idx) => (
<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">
{person.name}
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
@@ -853,11 +1045,11 @@ export function ScheduleTable(props: ScheduleTableProps) {
))}
{/* 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 */}
{itPeople.map((person) => (
<div key={person.id} className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: CELL_H }}>
{showIT && itPeople.map((person, idx) => (
<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">
{person.name}
{person.note && <span className="text-slate-500 ml-1">({person.note})</span>}
@@ -867,7 +1059,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
</div>
{/* === 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' }}>
{/* Month header row */}
@@ -920,7 +1112,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
)
}
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
}
@@ -974,18 +1166,17 @@ export function ScheduleTable(props: ScheduleTableProps) {
>
{/* Merged cell overlay covering DEN + DEN T. + all info rows */}
<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={{
top: 0,
left: 0,
width: CELL_W,
height: 28 + 24 + 28 + (showSazlt ? 28 : 0) + (showMetro ? 28 : 0) + (showD8 ? 28 : 0),
backgroundColor: '#283040',
zIndex: 15,
}}
>
<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={{
writingMode: 'vertical-rl',
transform: 'rotate(180deg)',
@@ -1021,7 +1212,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
)
if (pmsAfterSet.has(i)) {
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
@@ -1030,23 +1221,23 @@ export function ScheduleTable(props: ScheduleTableProps) {
{/* TKB info row */}
{renderInfoRowCells('__tunnel__', tunnelClosures, tunnelColors,
'bg-orange-700/40 text-orange-200', 'border-slate-700', 'border-orange-400', 'TKB',
onTunnelContextMenu, onStartEditTunnel)}
'border-slate-700', 'border-orange-400', 'TKB',
(dayIdx, x, y) => handleInfoRowContext(null, dayIdx, x, y), onStartEditTunnel)}
{/* SAZLT info row */}
{showSazlt && renderInfoRowCells('__sazlt__', sazltClosures, sazltColors,
'bg-amber-700/40 text-amber-200', 'border-slate-700', 'border-amber-400', 'SAZLT',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'sazlt', x, y), onStartEditSazlt)}
'border-slate-700', 'border-amber-400', 'SAZLT',
(dayIdx, x, y) => handleInfoRowContext('sazlt', dayIdx, x, y), onStartEditSazlt)}
{/* Metro info row */}
{showMetro && renderInfoRowCells('__metro__', metroClosures, metroColors,
'bg-blue-700/40 text-blue-200', 'border-slate-700', 'border-blue-400', 'Metro',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'metro', x, y), onStartEditMetro)}
'border-slate-700', 'border-blue-400', 'Metro',
(dayIdx, x, y) => handleInfoRowContext('metro', dayIdx, x, y), onStartEditMetro)}
{/* D8 info row */}
{showD8 && renderInfoRowCells('__d8__', d8Closures, d8Colors,
'bg-green-700/40 text-green-200', 'border-slate-600', 'border-green-400', 'D8',
(dayIdx, x, y) => onInfoRowContextMenu(dayIdx, 'd8', x, y), onStartEditD8)}
'border-slate-600', 'border-green-400', 'D8',
(dayIdx, x, y) => handleInfoRowContext('d8', dayIdx, x, y), onStartEditD8)}
{/* Data rows container (with overlay) */}
<div style={{ position: 'relative' }}>
@@ -1055,9 +1246,10 @@ export function ScheduleTable(props: ScheduleTableProps) {
{renderSectionHeaderBar('bg-indigo-700/60')}
{/* TKB person rows */}
{tkbPeople.map((person) => (
{tkbPeople.map((person, idx) => (
<PersonRow
key={person.id}
rowIndex={idx}
person={person}
dayIndex={dayIndex}
dragState={dragState}
@@ -1078,16 +1270,19 @@ export function ScheduleTable(props: ScheduleTableProps) {
onCellDragSelectMove={onCellDragSelectMove}
pmsAfterSet={pmsAfterSet}
pmsMonthAtPos={pmsMonthAtPos}
isReadOnly={isReadOnly}
clipboardSource={clipboardSource}
/>
))}
{/* IT section header bar */}
{renderSectionHeaderBar('bg-teal-700/60')}
{showIT && renderSectionHeaderBar('bg-teal-700/60')}
{/* IT person rows */}
{itPeople.map((person) => (
{showIT && itPeople.map((person, idx) => (
<PersonRow
key={person.id}
rowIndex={idx}
person={person}
dayIndex={dayIndex}
dragState={dragState}
@@ -1108,6 +1303,8 @@ export function ScheduleTable(props: ScheduleTableProps) {
onCellDragSelectMove={onCellDragSelectMove}
pmsAfterSet={pmsAfterSet}
pmsMonthAtPos={pmsMonthAtPos}
isReadOnly={isReadOnly}
clipboardSource={clipboardSource}
/>
))}

View File

@@ -1,3 +1,4 @@
import { useState, useRef, useEffect } from 'react'
import { getAllCellStyles } from './cellColors'
const MONTH_BUTTONS = [
@@ -26,19 +27,30 @@ interface ToolbarProps {
showMetro: boolean
showD8: boolean
showSazlt: boolean
showIT: boolean
hide162: boolean
theme: 'dark' | 'light'
onToggleMetro: () => void
onToggleD8: () => void
onToggleSazlt: () => void
onToggleIT: () => void
onToggle162: () => void
onToggleTheme: () => void
hiddenValues: Set<string>
onToggleValue: (code: string) => void
diffFileName?: string | null
onCloseDiff?: () => void
onExportPdf?: (month: number) => void
onExportExcel?: (month: number) => void
onShowProposals?: () => void
onCheckFpd?: () => void
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({
@@ -52,21 +64,48 @@ export function Toolbar({
showMetro,
showD8,
showSazlt,
showIT,
hide162,
theme,
onToggleMetro,
onToggleD8,
onToggleSazlt,
onToggleIT,
onToggle162,
onToggleTheme,
hiddenValues,
onToggleValue,
diffFileName,
onCloseDiff,
onExportPdf,
onExportExcel,
onShowProposals,
onCheckFpd,
isReadOnly = false,
tkbPeopleList = [],
hiddenTkbPeople = new Set(),
onToggleTkbPerson,
onSetAllTkbVisible,
onSetOnlyTkbPerson,
}: ToolbarProps) {
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 (
<div className="flex flex-col gap-2 mb-3">
@@ -159,6 +198,16 @@ export function Toolbar({
</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 && (
<button
onClick={onShowProposals}
@@ -179,6 +228,15 @@ export function Toolbar({
</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" />
{/* Row toggles */}
@@ -213,6 +271,16 @@ export function Toolbar({
>
SAZLT
</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
onClick={onToggle162}
title="Skrýt prvních 5 osob TKB (Pauzer, Vörös, Janouš, Franek, Svoboda)"
@@ -226,6 +294,87 @@ export function Toolbar({
</button>
</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" />
{/* Color legend / filter */}

View File

@@ -2963,6 +2963,12 @@
"group": "TKB",
"data": {}
},
{
"id": "tkb-kropacek",
"name": "Kropáček Ondřej",
"group": "TKB",
"data": {}
},
{
"id": "tkb-vondrak",
"name": "Vondrák Pavel",
@@ -2996,6 +3002,7 @@
"name": "Dittrich Vladimír",
"note": "VN",
"group": "TKB",
"retired": true,
"data": {}
},
{

View File

@@ -3,8 +3,8 @@
body {
margin: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #0f172a;
color: #e2e8f0;
background: var(--color-slate-900);
color: var(--color-slate-200);
}
#root {
@@ -15,13 +15,44 @@ body {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1e293b;
::-webkit-scrollbar-track { background: var(--color-slate-800); }
::-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 {
background: #475569;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #64748b;
.pms-label { background-color: #283040; }
.pms-label-text { color: var(--color-slate-300); }
/* ============================================================
LIGHT THEME
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'
note?: string
data: Record<string, CellValue>
retired?: boolean // hidden from UI/FPD; exports keep past-month data but skip empty months
}
export interface CellValue {
v?: 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 {

View File

@@ -138,14 +138,20 @@ export function useScheduleState(
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)] ?? {}
// 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) {
const existing = person.data[String(dayIdx)]
person.data[String(dayIdx)] = { v: value, ...(existing?.color ? { color: existing.color } : {}) }
person.data[String(dayIdx)] = { v: value, ...preserve }
} else {
const existing = person.data[String(dayIdx)]
if (existing?.color) {
const { v: _, ...rest } = existing
person.data[String(dayIdx)] = rest as any
if (Object.keys(preserve).length > 0) {
person.data[String(dayIdx)] = preserve
} else {
delete person.data[String(dayIdx)]
}
@@ -154,6 +160,9 @@ export function useScheduleState(
})
}, [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) => {
pushHistory()
setPeople(prev => {
@@ -166,12 +175,81 @@ export function useScheduleState(
} else {
const { color: _, ...rest } = existing as any
person.data[String(dayIdx)] = rest
if (!rest.v) delete person.data[String(dayIdx)]
if (isCellEmpty(rest)) delete person.data[String(dayIdx)]
}
return next
})
}, [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) => {
pushHistory()
setTunnelColorsState(prev => {
@@ -249,10 +327,30 @@ export function useScheduleState(
const next = JSON.parse(JSON.stringify(prev)) as Person[]
const person = next.find(p => p.id === personId)
if (!person) return prev
const value = person.data[String(fromIdx)]
if (!value) return prev
delete person.data[String(fromIdx)]
person.data[String(toIdx)] = value
const source = person.data[String(fromIdx)]
if (!source?.v) return prev
// 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
})
}, [pushHistory])
@@ -357,6 +455,9 @@ export function useScheduleState(
cellComments,
setCell,
setCellColor,
setCellHalfColor,
setCellHalfValue,
pasteCellTo,
moveCell,
setTunnelClosure,
setTunnelClosureColor,