diff --git a/docs/plans/2026-04-22-half-cell-marks-design.md b/docs/plans/2026-04-22-half-cell-marks-design.md new file mode 100644 index 0000000..d604983 --- /dev/null +++ b/docs/plans/2026-04-22-half-cell-marks-design.md @@ -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. diff --git a/samples/Pohotovost_TKB+IT_směny_sample.xlsx b/samples/Pohotovost_TKB+IT_směny_sample.xlsx new file mode 100644 index 0000000..88b0c34 Binary files /dev/null and b/samples/Pohotovost_TKB+IT_směny_sample.xlsx differ diff --git a/samples/import_harmonogram (2).xlsx b/samples/import_harmonogram (2).xlsx new file mode 100644 index 0000000..5aa303a Binary files /dev/null and b/samples/import_harmonogram (2).xlsx differ diff --git a/samples/import_harmonogram.xlsx b/samples/import_harmonogram.xlsx new file mode 100644 index 0000000..128a0e1 Binary files /dev/null and b/samples/import_harmonogram.xlsx differ diff --git a/samples/tkb_web_excel_export_01.png b/samples/tkb_web_excel_export_01.png new file mode 100644 index 0000000..5bf429f Binary files /dev/null and b/samples/tkb_web_excel_export_01.png differ diff --git a/web/dochazka_template.xlsx b/web/dochazka_template.xlsx index f875237..166b1c0 100644 Binary files a/web/dochazka_template.xlsx and b/web/dochazka_template.xlsx differ diff --git a/web/export_dochazka.py b/web/export_dochazka.py index 6b9fbc9..2ff5fcf 100644 --- a/web/export_dochazka.py +++ b/web/export_dochazka.py @@ -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] diff --git a/web/export_full_excel.py b/web/export_full_excel.py new file mode 100644 index 0000000..a790f89 --- /dev/null +++ b/web/export_full_excel.py @@ -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() diff --git a/web/export_harmonogram.py b/web/export_harmonogram.py new file mode 100644 index 0000000..0b6bc3e --- /dev/null +++ b/web/export_harmonogram.py @@ -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', , '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() diff --git a/web/public/full_template.xlsx b/web/public/full_template.xlsx new file mode 100644 index 0000000..ccea9d1 Binary files /dev/null and b/web/public/full_template.xlsx differ diff --git a/web/server.js b/web/server.js index 105b00b..19825c1 100644 --- a/web/server.js +++ b/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 diff --git a/web/src/App.tsx b/web/src/App.tsx index f7ac110..c6cb063 100644 --- a/web/src/App.tsx +++ b/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 = { + '__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(() => 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(authed ? (hasFavOnLoad ? 'editor' : 'files') : 'login') const [fileId, setFileId] = useState(null) const [fileName, setFileName] = useState('') const [fileData, setFileData] = useState(null) - const [loading, setLoading] = useState(hasFavOnLoad) + const [loading, setLoading] = useState(hasFavOnLoad || viewerAutoLoad) const [compareFileId, setCompareFileId] = useState(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 (
@@ -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>(new Set()) const [hiddenValues, setHiddenValues] = useState>(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 = { 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 (
@@ -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} /> {} : 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} /> @@ -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} /> )}
diff --git a/web/src/ContextMenu.tsx b/web/src/ContextMenu.tsx index 9921b6c..496482a 100644 --- a/web/src/ContextMenu.tsx +++ b/web/src/ContextMenu.tsx @@ -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(null) const commentInputRef = useRef(null) const cellValueInputRef = useRef(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) => ( + + ) + return ( +
+
Cíl
+
+ {toggleBtn('cell', 'Celá')} + {toggleBtn('day', 'Denní (←)')} + {toggleBtn('night', 'Noční (→)')} +
+
+ ) + } + 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 (
Barva pozadi
@@ -160,20 +267,7 @@ export function ContextMenu({ return (
- {isInfoRow ? ( - <> - {existingInfoRowClosure && ( -
- ▶ - {existingInfoRowClosure} + {isInfoRow || isTunnelRow ? (() => { + const rowKey = isTunnelRow + ? '__tunnel__' + : infoRowId === 'metro' ? '__metro__' + : infoRowId === 'd8' ? '__d8__' + : '__sazlt__' + return ( + <> + {isTunnelRow && existingTunnelClosure && ( +
+ ▶ + {existingTunnelClosure} +
+ )} + {isInfoRow && existingInfoRowClosure && ( +
+ ▶ + {existingInfoRowClosure} +
+ )} + + {hasClipboard && ( + + )} + {/* Text input — same as person row */} +
+
+ 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" + /> + +
- )} - {renderColorPalette()} - - ) : isTunnelRow ? ( - <> - {existingTunnelClosure && ( -
- ▶ - {existingTunnelClosure} -
- )} - {renderColorPalette()} - - ) : isDenRow ? ( + {renderColorPalette()} + + ) + })() : isDenRow ? ( <> {existingDayComment && !showCommentInput ? ( <> @@ -310,6 +451,35 @@ export function ContextMenu({ ) : ( <> + {/* Copy / Paste */} + {!isMultiSelect && state.personId && ( + + )} + {hasClipboard && state.personId && ( + + )} + + {renderTargetToggle()} {/* Cell value editing */}
diff --git a/web/src/FpdCheck.tsx b/web/src/FpdCheck.tsx index d784077..8e4e032 100644 --- a/web/src/FpdCheck.tsx +++ b/web/src/FpdCheck.tsx @@ -6,6 +6,13 @@ const MONTH_NAMES: Record = { 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 (
@@ -132,32 +167,51 @@ export function FpdModal({ month, year, dayIndex, people, onClose, onExportDocha
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'}`} > {r.name} {r.note && ({r.note})} + {r.infoOnly && (info)} {r.workedHours}h / {r.fpd}h - 0 ? 'text-slate-300' : 'text-green-400'}`}> - {r.diff > 0 ? `+${r.diff}h` : '✓'} - + {r.infoOnly ? ( + {r.diff > 0 ? `+${r.diff}` : r.diff}h + ) : ( + 0 ? 'text-slate-300' : 'text-green-400'}`}> + {r.diff > 0 ? `+${r.diff}h` : '✓'} + + )}
))}
- {onExportDochazka && ( -
- + {(onExportDochazka || onExportHarmonogram) && ( +
+ {onExportHarmonogram && ( + + )} + {onExportDochazka && ( + + )}
)}
diff --git a/web/src/Login.tsx b/web/src/Login.tsx index 0d48e26..e36273d 100644 --- a/web/src/Login.tsx +++ b/web/src/Login.tsx @@ -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) diff --git a/web/src/PdfExport.ts b/web/src/PdfExport.ts index 98d7253..2abe70e 100644 --- a/web/src/PdfExport.ts +++ b/web/src/PdfExport.ts @@ -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 + hiddenValues: Set +} + const MONTH_NAMES: Record = { 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, tunnelColors: Map, + 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, ) { 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 diff --git a/web/src/ScheduleTable.tsx b/web/src/ScheduleTable.tsx index 48cacfa..e3a4386 100644 --- a/web/src/ScheduleTable.tsx +++ b/web/src/ScheduleTable.tsx @@ -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 hiddenValues: Set 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 pmsMonthAtPos: Map + 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 ( -
+
{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 = (
{ 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 && ( + <> +
+ {dayValue && {dayValue}} +
+
+ {nightValue && {nightValue}} +
+ + )} {isEditing ? ( onEditChange(e.target.value)} @@ -223,9 +294,9 @@ const PersonRow = memo(function PersonRow({ ) : ( <> {isDragPreview ? ( - {dragState!.value} - ) : value && !isValueHidden ? ( - {value} + {dragState!.value} + ) : value && !isValueHidden && !renderAsSplit ? ( + {value} ) : null} )} @@ -259,12 +330,13 @@ const PersonRow = memo(function PersonRow({
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 ? ( 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(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 => { - 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() 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, colors: Map, - 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(
{ - 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 ? ( onEditChange(e.target.value)} onKeyDown={(e) => { @@ -787,7 +979,7 @@ export function ScheduleTable(props: ScheduleTableProps) { ) if (pmsAfterSet.has(i)) { cells.push( -
+
) } return cells @@ -808,7 +1000,7 @@ export function ScheduleTable(props: ScheduleTableProps) { ) return ( -
+
{/* === 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) => ( -
+ {tkbPeople.map((person, idx) => ( +
{person.name} {person.note && ({person.note})} @@ -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) => ( -
+ {showIT && itPeople.map((person, idx) => ( +
{person.name} {person.note && ({person.note})} @@ -867,7 +1059,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
{/* === Scrollable grid === */} -
+
{/* Month header row */} @@ -920,7 +1112,7 @@ export function ScheduleTable(props: ScheduleTableProps) { ) } fragments.push( -
+
) fragStart = pmsPos + 1 } @@ -974,18 +1166,17 @@ export function ScheduleTable(props: ScheduleTableProps) { > {/* Merged cell overlay covering DEN + DEN T. + all info rows */}
+
) } 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) */}
@@ -1055,9 +1246,10 @@ export function ScheduleTable(props: ScheduleTableProps) { {renderSectionHeaderBar('bg-indigo-700/60')} {/* TKB person rows */} - {tkbPeople.map((person) => ( + {tkbPeople.map((person, idx) => ( ))} {/* 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) => ( ))} diff --git a/web/src/Toolbar.tsx b/web/src/Toolbar.tsx index d6b8f95..8298700 100644 --- a/web/src/Toolbar.tsx +++ b/web/src/Toolbar.tsx @@ -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 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 + 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(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 (
@@ -159,6 +198,16 @@ export function Toolbar({ )} + {onExportExcel && activeMonth && ( + + )} + {onShowProposals && ( +
{/* Row toggles */} @@ -213,6 +271,16 @@ export function Toolbar({ > SAZLT +
+ {/* TKB people filter */} + {tkbPeopleList.length > 0 && onToggleTkbPerson && ( +
+ + + {showPeopleFilter && ( +
+ {/* Header */} +
+ Pohotovost TKB +
+ + +
+
+ + {/* Person list */} +
+ {tkbPeopleList.map(person => { + const isVisible = !hiddenTkbPeople.has(person.id) + return ( +
+ onToggleTkbPerson(person.id)} + className="cursor-pointer accent-indigo-500" + id={`tkb-person-${person.id}`} + /> + + +
+ ) + })} +
+
+ )} +
+ )} +
{/* Color legend / filter */} diff --git a/web/src/data.json b/web/src/data.json index ddca30a..1d4198e 100644 --- a/web/src/data.json +++ b/web/src/data.json @@ -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": {} }, { diff --git a/web/src/index.css b/web/src/index.css index 8bfc1de..8ab36f8 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; } diff --git a/web/src/types.ts b/web/src/types.ts index bd6534e..cb5e8fe 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -13,11 +13,16 @@ export interface Person { group: 'TKB' | 'IT' note?: string data: Record + 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 { diff --git a/web/src/useScheduleState.ts b/web/src/useScheduleState.ts index a31276d..2383be5 100644 --- a/web/src/useScheduleState.ts +++ b/web/src/useScheduleState.ts @@ -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,