feat: add Kropáček Ondřej + May–Jul feature work (half-cell marks, copy/paste, exports, roles)

New worker: Kropáček Ondřej (TKB, part-time student) inserted between
Dvořák Václav and Vondrák Pavel in data.json, the full-month Excel
template (new row 13, everything below shifted) and the docházka
template (13 person rows). Not added to the harmonogram/CIS roster —
he has no CIS personal number.

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

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Docker Config Backup
2026-09-17 13:02:52 +02:00
parent 131b0d0df1
commit 3d306e641c
22 changed files with 2231 additions and 242 deletions

485
web/export_full_excel.py Normal file
View File

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