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:
132
docs/plans/2026-04-22-half-cell-marks-design.md
Normal file
132
docs/plans/2026-04-22-half-cell-marks-design.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Half-cell day/night preference marks — Design
|
||||
|
||||
**Date:** 2026-04-22
|
||||
**Status:** approved, ready to implement
|
||||
|
||||
## Problem
|
||||
|
||||
In the original Excel schedule, each day cell was split horizontally
|
||||
(left = night, right = day). Users marked preferences/obstacles by
|
||||
filling the appropriate half with a color before shift planning began.
|
||||
|
||||
In the current app, each day = one cell. Users can only express
|
||||
day-vs-night preferences via cell comments (small blue triangle), which
|
||||
is not visual at a glance and therefore "annoying."
|
||||
|
||||
Multiple users have requested restoring the Excel behavior.
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Coexist with shifts** — preference marks stay visible after a shift
|
||||
is assigned (so we can see whether we honored or violated the
|
||||
request).
|
||||
2. **Free color from palette** — same flexibility as the existing
|
||||
whole-cell color picker; semantics are user-defined.
|
||||
3. **Horizontal split: left = night, right = day** — matches the
|
||||
original Excel.
|
||||
4. **Visual when shift is assigned: corner triangles** — full shift
|
||||
color fills the cell; small ~8×8 px triangles in the bottom corners
|
||||
indicate the marks (bottom-left = night, bottom-right = day).
|
||||
5. **Toggle in context menu** — segmented control `Celá | Noční | Denní`
|
||||
above the color palette selects which field the next color click
|
||||
updates.
|
||||
|
||||
## Data model
|
||||
|
||||
`CellValue` (in `types.ts`) gains two optional fields:
|
||||
|
||||
```ts
|
||||
interface CellValue {
|
||||
v?: string
|
||||
color?: string // existing — full-cell background
|
||||
dayColor?: string // NEW — right half / day mark
|
||||
nightColor?: string // NEW — left half / night mark
|
||||
}
|
||||
```
|
||||
|
||||
The change is purely additive. Existing JSON files load unchanged.
|
||||
|
||||
## Rendering rules
|
||||
|
||||
| State | Look |
|
||||
|-------|------|
|
||||
| empty + no marks | empty (current) |
|
||||
| `dayColor` and/or `nightColor` set, no shift | left half = `nightColor`, right half = `dayColor` (each independent; missing half = transparent or `color` fallback) |
|
||||
| shift assigned, no marks | full shift color (current) |
|
||||
| shift assigned + marks | full shift color + 8 px triangles: bottom-left = night, bottom-right = day |
|
||||
|
||||
Priority for picking renderer:
|
||||
|
||||
1. If `dayColor || nightColor` → split-half mode
|
||||
2. Else if `color` → full-cell color (current behavior)
|
||||
3. Else → empty
|
||||
|
||||
## Interaction
|
||||
|
||||
Right-click cell → existing context menu, with new segmented toggle
|
||||
above the color palette:
|
||||
|
||||
```
|
||||
Barva:
|
||||
[ Celá ] [ Noční ] [ Denní ] ← default: Celá
|
||||
● ● ● ● ● ● ● [Bez barvy]
|
||||
```
|
||||
|
||||
- Toggle selects target field (`color`, `nightColor`, `dayColor`)
|
||||
- Color click writes only to the selected target
|
||||
- "Bez barvy" clears only the selected target
|
||||
- Three fields are independent
|
||||
|
||||
## Drag-and-drop
|
||||
|
||||
Half marks are tied to the **day**, not the shift. When a user drags a
|
||||
shift cell:
|
||||
|
||||
- Only `v` (and `color` of the shift) moves to the destination
|
||||
- `dayColor` / `nightColor` stay on both source and destination days
|
||||
|
||||
## Compare / diff view
|
||||
|
||||
Differences in `dayColor` / `nightColor` count as cell changes — render
|
||||
the same orange "modified" border as for value changes.
|
||||
|
||||
## Filters
|
||||
|
||||
- "Color only" filter (rainbow dot) — also hides cells whose only
|
||||
content is `dayColor` / `nightColor` (no `v`)
|
||||
- Hidden value filter (e.g. hide "A") — hides the cell entirely
|
||||
including marks (marks are auxiliary)
|
||||
|
||||
## Scope
|
||||
|
||||
Half-mark editing is available on:
|
||||
|
||||
- ✅ Person cells (Pohotovost TKB / Pohotovost IT)
|
||||
- ✅ Info rows (TKB / SAZLT / Metro / D8)
|
||||
- ❌ PMS columns (month-end summary, doesn't make sense)
|
||||
|
||||
## Read-only
|
||||
|
||||
Existing `isReadOnly` guard handles viewer mode automatically. Viewers
|
||||
see marks but cannot edit.
|
||||
|
||||
## Out of scope (phase 1)
|
||||
|
||||
- **Excel export/import** — original template has no clean
|
||||
representation; round-trip would lose marks. Documented as a known
|
||||
limitation; revisit if users request it.
|
||||
- **PDF export** — will render half marks using same rules (no extra
|
||||
work expected).
|
||||
|
||||
## Files to change
|
||||
|
||||
1. `web/src/types.ts` — extend `CellValue`
|
||||
2. `web/src/useScheduleState.ts` — add `setCellHalfColor(personId,
|
||||
dayIdx, target, color)` reducer + undo support
|
||||
3. `web/src/ScheduleTable.tsx` — render split halves / corner triangles
|
||||
in `PersonRow` and `renderInfoRowCells`
|
||||
4. `web/src/ContextMenu.tsx` — add segmented toggle, route color clicks
|
||||
to selected target
|
||||
5. `web/src/cellColors.ts` — no change
|
||||
|
||||
Estimate: ~150 LoC across 4 files plus minimal CSS.
|
||||
BIN
samples/Pohotovost_TKB+IT_směny_sample.xlsx
Normal file
BIN
samples/Pohotovost_TKB+IT_směny_sample.xlsx
Normal file
Binary file not shown.
BIN
samples/import_harmonogram (2).xlsx
Normal file
BIN
samples/import_harmonogram (2).xlsx
Normal file
Binary file not shown.
BIN
samples/import_harmonogram.xlsx
Normal file
BIN
samples/import_harmonogram.xlsx
Normal file
Binary file not shown.
BIN
samples/tkb_web_excel_export_01.png
Normal file
BIN
samples/tkb_web_excel_export_01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 181 KiB |
Binary file not shown.
@@ -26,7 +26,10 @@ STRAVNE_8H = 155 # meal allowance for an 8h workday
|
||||
DAN = 0.15 # tax factor → 1.15 multiplier
|
||||
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
485
web/export_full_excel.py
Normal 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
239
web/export_harmonogram.py
Normal file
@@ -0,0 +1,239 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
export_harmonogram.py
|
||||
Reads JSON from stdin: {"schedule": {...}, "month": N, "year": N}
|
||||
Writes an XLSX (to stdout) in the import format expected by the internal
|
||||
attendance system. Layout matches samples/import_harmonogram.xlsx:
|
||||
|
||||
- Sheet "Harmonogram":
|
||||
Row 1: dates as 'YYYY-MM-DD' starting at column C
|
||||
Col A: person name (with "(VN)"/"(NN)" suffix where applicable)
|
||||
Col B: personal ID number
|
||||
Cells: comma-separated shift codes from the Definice sheet
|
||||
- Sheet "Definice": copy of the canonical key/time table.
|
||||
|
||||
App → attendance code mapping:
|
||||
A → R12 (12h day shift)
|
||||
B → N12 (12h night shift)
|
||||
8 → R (8h day shift)
|
||||
12 → R12
|
||||
D → D12 (vacation, paid as 12h day shift in CIS)
|
||||
U → skipped (closure work; handled elsewhere)
|
||||
N/O / x → skipped (absences; handled elsewhere)
|
||||
IT 16 → "POH_B1,R,POH_B2" (weekday on-call)
|
||||
IT 24 → "POH_B3" (weekend full-day on-call)
|
||||
"""
|
||||
import sys
|
||||
import json
|
||||
import io
|
||||
from datetime import date as Date, time as Time, timedelta
|
||||
from openpyxl import Workbook
|
||||
from openpyxl.utils import get_column_letter
|
||||
|
||||
# ── Fixed roster — TKB shift workers only ──
|
||||
# Output rows ALWAYS appear in this exact order, regardless of whether
|
||||
# the person has any data this month. Format: (display_name, group, person_id,
|
||||
# data_lookup_name) where data_lookup_name is how the person is named in the
|
||||
# schedule data (may differ from display_name).
|
||||
# Note: IT on-call people (Glaser, Janouš, Štefan, Vörös) are intentionally
|
||||
# excluded — they're handled outside the CIS attendance import.
|
||||
# Part-time people: half the monthly FPD (4h/day instead of 8h).
|
||||
HALF_TIME_NAMES = {'Dittrich Vladimír'}
|
||||
|
||||
ROSTER = [
|
||||
('Dvořák Václav (VN)', 'TKB', 1247, 'Dvořák Václav'),
|
||||
# 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()
|
||||
BIN
web/public/full_template.xlsx
Normal file
BIN
web/public/full_template.xlsx
Normal file
Binary file not shown.
100
web/server.js
100
web/server.js
@@ -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
|
||||
|
||||
235
web/src/App.tsx
235
web/src/App.tsx
@@ -1,4 +1,4 @@
|
||||
import { useState, useRef, useCallback, useEffect } from 'react'
|
||||
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
|
||||
import { ScheduleTable } from './ScheduleTable'
|
||||
import 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>
|
||||
|
||||
@@ -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">▶</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">▶</span>
|
||||
{existingTunnelClosure}
|
||||
</div>
|
||||
)}
|
||||
{isInfoRow && existingInfoRowClosure && (
|
||||
<div className="px-3 py-2 text-xs text-blue-300 border-b border-slate-700">
|
||||
<span className="text-blue-400 mr-1.5">▶</span>
|
||||
{existingInfoRowClosure}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => { onCopyCell(rowKey, state.dayIdx); onClose() }}
|
||||
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors border-b border-slate-700"
|
||||
>
|
||||
<span className="text-blue-400 mr-1.5">⧉</span>
|
||||
Kopírovat buňku
|
||||
<span className="float-right text-slate-500 text-[10px]">Ctrl+C</span>
|
||||
</button>
|
||||
{hasClipboard && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const targets = isMultiSelect
|
||||
? selectedCells.map(c => ({ personId: c.personId, dayIdx: c.dayIdx }))
|
||||
: [{ personId: rowKey, dayIdx: state.dayIdx }]
|
||||
onPasteCells(targets)
|
||||
onClose()
|
||||
}}
|
||||
className="w-full text-left px-3 py-2 text-xs text-slate-300 hover:bg-slate-700 cursor-pointer transition-colors border-b border-slate-700"
|
||||
>
|
||||
<span className="text-blue-400 mr-1.5">⊕</span>
|
||||
Vložit{isMultiSelect ? ` (${selectedCells.length} buněk)` : ''}
|
||||
<span className="float-right text-slate-500 text-[10px]">Ctrl+V</span>
|
||||
</button>
|
||||
)}
|
||||
{/* Text input — same as person row */}
|
||||
<div className="px-3 py-2 border-b border-slate-700">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={infoValueInput}
|
||||
onChange={e => setInfoValueInput(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') { applyInfoValue(infoValueInput.trim() || null); onClose() } }}
|
||||
placeholder="Text..."
|
||||
className="flex-1 bg-slate-900 border border-slate-600 rounded px-2 py-1.5 text-xs text-slate-200
|
||||
placeholder-slate-500 outline-none focus:border-blue-500 transition-colors"
|
||||
/>
|
||||
<button
|
||||
onClick={() => { applyInfoValue(infoValueInput.trim() || null); onClose() }}
|
||||
className="px-2.5 py-1 rounded text-[10px] bg-blue-600 text-white hover:bg-blue-500 cursor-pointer transition-colors"
|
||||
>
|
||||
Nastavit{isMultiSelect ? ` (${selectedCells.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{renderColorPalette()}
|
||||
</>
|
||||
) : isTunnelRow ? (
|
||||
<>
|
||||
{existingTunnelClosure && (
|
||||
<div className="px-3 py-2 text-xs text-orange-300 border-b border-slate-700">
|
||||
<span className="text-orange-400 mr-1.5">▶</span>
|
||||
{existingTunnelClosure}
|
||||
</div>
|
||||
)}
|
||||
{renderColorPalette()}
|
||||
</>
|
||||
) : isDenRow ? (
|
||||
{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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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": {}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user