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

239
web/export_harmonogram.py Normal file
View File

@@ -0,0 +1,239 @@
#!/usr/bin/env python3
"""
export_harmonogram.py
Reads JSON from stdin: {"schedule": {...}, "month": N, "year": N}
Writes an XLSX (to stdout) in the import format expected by the internal
attendance system. Layout matches samples/import_harmonogram.xlsx:
- Sheet "Harmonogram":
Row 1: dates as 'YYYY-MM-DD' starting at column C
Col A: person name (with "(VN)"/"(NN)" suffix where applicable)
Col B: personal ID number
Cells: comma-separated shift codes from the Definice sheet
- Sheet "Definice": copy of the canonical key/time table.
App → attendance code mapping:
A → R12 (12h day shift)
B → N12 (12h night shift)
8 → R (8h day shift)
12 → R12
D → D12 (vacation, paid as 12h day shift in CIS)
U → skipped (closure work; handled elsewhere)
N/O / x → skipped (absences; handled elsewhere)
IT 16 → "POH_B1,R,POH_B2" (weekday on-call)
IT 24 → "POH_B3" (weekend full-day on-call)
"""
import sys
import json
import io
from datetime import date as Date, time as Time, timedelta
from openpyxl import Workbook
from openpyxl.utils import get_column_letter
# ── Fixed roster — TKB shift workers only ──
# Output rows ALWAYS appear in this exact order, regardless of whether
# the person has any data this month. Format: (display_name, group, person_id,
# data_lookup_name) where data_lookup_name is how the person is named in the
# schedule data (may differ from display_name).
# Note: IT on-call people (Glaser, Janouš, Štefan, Vörös) are intentionally
# excluded — they're handled outside the CIS attendance import.
# Part-time people: half the monthly FPD (4h/day instead of 8h).
HALF_TIME_NAMES = {'Dittrich Vladimír'}
ROSTER = [
('Dvořák Václav (VN)', 'TKB', 1247, 'Dvořák Václav'),
# Kropáček Ondřej — part-time student, not in CIS (no personal number); add here
# if he joins full time: ('Kropáček Ondřej', 'TKB', <osobní číslo>, 'Kropáček Ondřej'),
('Vondrák Pavel (NN)', 'TKB', 454, 'Vondrák Pavel'),
('Čeleda Olda (NN)', 'TKB', 463, 'Čeleda Olda'),
('Hanzlík Marek (VN)', 'TKB', 1366, 'Hanzlík Marek'),
('Kohl David (VN)', 'TKB', 1007, 'Kohl David'),
('Dittrich Vladimír (VN)', 'TKB', 373, 'Dittrich Vladimír'),
('Toman Milan (VN)', 'TKB', 157, 'Toman Milan'),
('Žemlička Miroslav (NN)', 'TKB', 1827, 'Žemlička Miroslav'),
('Teslík Hynek (NN)', 'TKB', 738, 'Teslík Hynek'),
]
# ── Definice sheet (verbatim from sample) ──
# (key, key_k2, time_from, from_24plus, time_to, to_24plus, typ)
DEFINICE_ROWS = [
('R', 184730838379022, Time(7, 0), 0, Time(15, 30), 0, 0),
('R12', 184997126340609, Time(7, 30), 0, Time(20, 0), 0, 0),
('D12', 184997126340609, Time(7, 30), 0, Time(20, 0), 0, 0),
('N12', 184997126340610, Time(20, 0), 0, Time(7, 30), 1, 0),
('POH_B1', 184936996798976, Time(0, 0), 0, Time(7, 0), 0, 2),
('POH_B2', 184936996798976, Time(15, 30), 0, Time(0, 0), 1, 2),
('POH_B3', 184936996798976, Time(0, 0), 0, Time(0, 0), 1, 2),
]
def get_czech_holidays(year):
fixed = {
Date(year, 1, 1), Date(year, 5, 1), Date(year, 5, 8),
Date(year, 7, 5), Date(year, 7, 6), Date(year, 9, 28),
Date(year, 10, 28), Date(year, 11, 17),
Date(year, 12, 24), Date(year, 12, 25), Date(year, 12, 26),
}
a = year % 19; b = year // 100; c = year % 100
d = b // 4; e = b % 4
f = (b + 8) // 25; g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4; k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
mo = (h + l - 7 * m + 114) // 31
dy = ((h + l - 7 * m + 114) % 31) + 1
easter = Date(year, mo, dy)
fixed.add(easter - timedelta(days=2)) # Good Friday
fixed.add(easter + timedelta(days=1)) # Easter Monday
return fixed
def map_tkb_code(value):
"""Return attendance code(s) for a TKB person's cell value, or None to skip."""
v = (value or '').strip()
if not v:
return None
if v == 'A':
return 'R12'
if v == 'B':
return 'N12'
if v == '8':
return 'R'
if v == '12':
return 'R12'
if v == 'D':
return 'D12' # vacation → 12h day-shift code in CIS
# U (closure), N/O (absences), x (no-experience), other letters → skip.
if v in ('U', 'N', 'O', 'x'):
return None
# Unknown numeric or letter — skip silently to avoid breaking import.
return None
def map_it_code(value, is_weekend_or_holiday):
"""Return attendance code(s) for an IT person's cell value, or None to skip.
Only the explicit on-call indicators map; everything else (U, D, N, O, x,
A, B, …) is skipped — those are absences or shift markers handled outside
the attendance import."""
v = (value or '').strip()
if v == '24':
return 'POH_B3'
if v == '16':
return 'POH_B1,R,POH_B2'
return None
def compute_person_fpd(person, month_days, holidays, daily_hours):
"""Per-person FPD target — mirrors the web Kontrola FPD check:
workDays × daily_hours, minus D/D2 vacation that falls on a working day.
(N sick is credited as a worked day in the check, so it does NOT reduce
the target here.)"""
work_days = sum(1 for d in month_days
if not d.get('weekend')
and Date(d['year'], d['month'], d['day']) not in holidays)
vacation = 0.0
for d in month_days:
cell = person['data'].get(str(d['idx'])) or {}
is_work_day = (not d.get('weekend')
and Date(d['year'], d['month'], d['day']) not in holidays)
if not is_work_day:
continue
for v in (cell.get('dayValue'), cell.get('nightValue')) if (
cell.get('dayValue') or cell.get('nightValue')
or cell.get('dayColor') or cell.get('nightColor')) else (cell.get('v'),):
if v == 'D':
vacation += daily_hours
elif v == 'D/2':
vacation += daily_hours / 2
fpd = work_days * daily_hours - vacation
return int(fpd) if float(fpd).is_integer() else fpd
def build_workbook(schedule, month, year):
holidays = get_czech_holidays(year)
day_index = schedule['dayIndex']
people = schedule['people']
month_days = [d for d in day_index if d['month'] == month and d['year'] == year]
month_days.sort(key=lambda d: d['day'])
wb = Workbook()
ws = wb.active
ws.title = 'Harmonogram'
# ── Header row: dates in YYYY-MM-DD as strings (matches sample) ──
for col_offset, d in enumerate(month_days):
col = 3 + col_offset # C, D, E, …
ws.cell(row=1, column=col,
value=f"{d['year']:04d}-{d['month']:02d}-{d['day']:02d}")
# ── Trailing FPD column (right after the last day) ──
fpd_col = 3 + len(month_days)
ws.cell(row=1, column=fpd_col, value='FPD')
work_days = sum(1 for d in month_days
if not d.get('weekend')
and Date(d['year'], d['month'], d['day']) not in holidays)
month_fpd = work_days * 8 # flat monthly work-time fund, same for every row
# ── Build name → person lookup from schedule data ──
by_name = {p['name']: p for p in people}
def person_has_month_data(p):
if not p: return False
for d in month_days:
v = (p['data'].get(str(d['idx'])) or {}).get('v', '')
if v: return True
return False
# ── Write roster rows in fixed sample order ──
# Retired people are skipped for months where they have no data (past-month
# exports still include them because their cells are preserved).
row = 2
for display, group, pid, lookup_name in ROSTER:
person = by_name.get(lookup_name)
if person and person.get('retired') and not person_has_month_data(person):
continue # retired + no data this month → omit row entirely
ws.cell(row=row, column=1, value=display)
ws.cell(row=row, column=2, value=str(pid)) # MUST be string — attendance import does getString()
ws.cell(row=row, column=fpd_col, value=month_fpd)
if not person:
row += 1
continue # roster name not found in this schedule — leave row empty
for col_offset, d in enumerate(month_days):
col = 3 + col_offset
raw = (person['data'].get(str(d['idx'])) or {}).get('v', '')
if group == 'TKB':
code = map_tkb_code(raw)
else: # IT
is_we = d.get('weekend') or Date(d['year'], d['month'], d['day']) in holidays
code = map_it_code(raw, is_we)
if code:
ws.cell(row=row, column=col, value=code)
row += 1
# Set column A wider for readability
ws.column_dimensions['A'].width = 28
ws.column_dimensions['B'].width = 8
# ── Definice sheet ──
df = wb.create_sheet('Definice')
df.append(['Klíč', 'Klíč K2', 'Od', '24+', 'Do', '24+', 'Typ'])
for r in DEFINICE_ROWS:
df.append(list(r))
df.column_dimensions['A'].width = 10
df.column_dimensions['B'].width = 18
return wb
def main():
inp = json.loads(sys.stdin.buffer.read())
wb = build_workbook(inp['schedule'], inp['month'], inp['year'])
buf = io.BytesIO()
wb.save(buf)
sys.stdout.buffer.write(buf.getvalue())
if __name__ == '__main__':
main()