diff --git a/web/dochazka_template.xlsx b/web/dochazka_template.xlsx
new file mode 100644
index 0000000..f875237
Binary files /dev/null and b/web/dochazka_template.xlsx differ
diff --git a/web/export_dochazka.py b/web/export_dochazka.py
new file mode 100644
index 0000000..6b9fbc9
--- /dev/null
+++ b/web/export_dochazka.py
@@ -0,0 +1,505 @@
+#!/usr/bin/env python3
+"""
+export_dochazka.py
+Reads JSON from stdin: {"schedule": {...}, "month": N, "year": N}
+Directly manipulates dochazka_template.xlsx XML — styles.xml is never touched,
+so Excel never shows a "repaired styles" warning.
+"""
+import sys, json, io, zipfile, re
+import xml.etree.ElementTree as ET
+from datetime import date as Date, timedelta
+
+TEMPLATE_PATH = __file__.replace('export_dochazka.py', 'dochazka_template.xlsx')
+
+MONTH_NAMES = {
+ 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',
+}
+OP_CODE = 'OP24146101755'
+NS = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'
+ET.register_namespace('', NS)
+
+# Financial constants (from sichtovnice.py)
+KC_KM = 4 # Kč per km
+STRAVNE_12H = 236 # meal allowance for a 12+h shift
+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.
+# Only these people are exported, in this order.
+TEMPLATE_NAMES = [
+ 'Dittrich Vladimír',
+ 'Teslík Hynek',
+ 'Kohl David',
+ 'Vörös Pavel',
+ 'Janouš Petr',
+ 'Dvořák Václav',
+ 'Toman Milan',
+ 'Hanzlík Marek',
+ 'Vondrák Pavel',
+ 'Čeleda Olda',
+ 'Žemlička Miroslav',
+ 'Franek Lukáš',
+]
+
+# Per-person static data from lidijson.py (auto type, km per shift)
+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},
+ '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},
+ 'Hanzlík Marek': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Pauzer Libor': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Chudoba Jan': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Jór Leoš': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Gschray Jiří': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Rozman František': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Vörös Pavel': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Janouš Petr': {'auto': 'AUS', 'km_tkb': 70, 'km_sat': 60},
+ 'Strach Jiří': {'auto': 'AUV', 'km_tkb': 30, 'km_sat': 0},
+ 'Dittrich Vladimír': {'auto': 'AUV', 'km_tkb': 30, 'km_sat': 0},
+ 'Schwarz Richard': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Glaser Ondřej': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Herbst David': {'auto': 'AUV', 'km_tkb': 76, 'km_sat': 66},
+ 'Ryba Ondřej': {'auto': 'AUV', 'km_tkb': 100, 'km_sat': 80},
+ 'Zábranský Petr': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Šůna Jiří': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Čeleda Olda': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Vondrák Pavel': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Teslík Hynek': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+ 'Štefan Robert': {'auto': 'AUS', 'km_tkb': 0, 'km_sat': 0},
+}
+
+# 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
+
+# ──────────────────────────────────────────────────────────────
+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))
+ fixed.add(easter + timedelta(days=1))
+ return fixed
+
+def split_day(hours):
+ r=o=0; z=hours
+ while z>0:
+ if z>=6: r+=6; z-=6
+ else: r+=z; z=0
+ if z>=6: o+=6; z-=6
+ else: o+=z; z=0
+ return r, o
+
+def split_night(hours):
+ r=o=n=0; z=hours
+ while z>0:
+ if z>=2: o+=2; z-=2
+ else: o+=z; z=0
+ if z>=8: n+=8; z-=8
+ else: n+=z; z=0
+ if z>=2: r+=2; z-=2
+ else: r+=z; z=0
+ return r, o, n
+
+# ──────────────────────────────────────────────────────────────
+def compute(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]
+ work_days = sum(1 for d in month_days
+ if not d['weekend']
+ and Date(d['year'],d['month'],d['day']) not in holidays)
+ fpd = work_days * 8
+
+ tkb_results = []
+ # Only include people that are in the template — in template order
+ 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]
+ for person in tkb_people:
+ 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', '')
+ 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
+
+ # PMS stored at negative dayIdx = -month
+ # PMS = práce mimo směnu: hours added to R/O but NOT counted as shifts
+ pms = (person['data'].get(str(-month)) or {}).get('v','')
+ if pms=='A': pr,po=split_day(12); r+=pr; o+=po
+ elif pms=='B': pr,po,pn=split_night(12); r+=pr; o+=po; n+=pn
+ elif pms:
+ try:
+ ph=float(pms)
+ if ph>0: pr,po=split_day(ph); r+=pr; o+=po
+ except (ValueError,TypeError): pass
+
+ total = r+o+n
+ o_navic = total - fpd + minus_fpd
+ r_fpd, o_fpd, n_fpd = r, o, n
+ if o_navic > 0:
+ pom = o_navic
+ if r_fpd >= pom: r_fpd -= pom; pom = 0
+ else: pom -= r_fpd; r_fpd = 0
+ o_fpd = max(0, o_fpd - pom)
+
+ absence = ' '.join(filter(None, [
+ f'{dov}D' if dov else '',
+ f'{nem}N' if nem else '',
+ f'{otc}O' if otc else '',
+ ])) + (' ' if (dov or nem or otc) else '')
+
+ # --- Financial calculations (sichtovnice.py) ---
+ # Per-person FPD days (adjusted for D/N/O on working days)
+ person_fpd_dny = work_days - (minus_fpd / 8)
+
+ # R (Jine1): meal allowance supplement
+ # 1.15 × (sichet_12h × 236 + sichet_<12h × 155 − fpd_per_person × 155)
+ stravne_doplatek = round(
+ (1 + DAN) * (
+ sichet_12h * STRAVNE_12H
+ + sichet_under_12h * STRAVNE_8H
+ - person_fpd_dny * STRAVNE_8H
+ ), 2)
+
+ # S (Jine2): transport money — only AUV drivers
+ pdata = PERSON_DATA.get(person['name'], {'auto':'AUS','km_tkb':0,'km_sat':0})
+ kc_doprava = 0
+ if pdata['auto'] == 'AUV':
+ sichet_tkb = sichet_12h + sichet_under_12h # no SAT tracking
+ kc_tkb = pdata['km_tkb'] * KC_KM * sichet_tkb
+ kc_uzavera = uzavery * pdata['km_tkb'] * KC_KM
+ kc_doprava = kc_tkb + kc_uzavera
+
+ # P (Indiv1): closure count text (+ Internet reimbursement for Janouš Petr)
+ # indiv1_type: 'str', 'num', or None (empty)
+ if person['name'] == 'Janouš Petr':
+ if uzavery:
+ indiv1_value = f' {uzavery}U+{INTERNET}'
+ indiv1_type = 'str'
+ else:
+ indiv1_value = INTERNET
+ indiv1_type = 'num'
+ elif uzavery:
+ indiv1_value = f' {uzavery}U'
+ indiv1_type = 'str'
+ else:
+ indiv1_value = None
+ indiv1_type = None
+
+ tkb_results.append({
+ 'name': person['name'],
+ 'r': r_fpd, 'o': o_fpd, 'n': n_fpd,
+ 'o_navic': o_navic,
+ 'absence': absence.strip(),
+ 'indiv1_value': indiv1_value,
+ 'indiv1_type': indiv1_type,
+ 'jine1': stravne_doplatek,
+ 'jine2': kc_doprava,
+ })
+
+ it_order = ['it-glaser','it-janous','it-stefan','it-voros']
+ it_map = {p['id']:p for p in people if p.get('group')=='IT'}
+ it_results = []
+ for pid in it_order:
+ person = it_map.get(pid)
+ if not person:
+ it_results.append({'name':pid.split('-')[1].capitalize(),'hours':0,'prace':0})
+ continue
+ hours = 0
+ for d in month_days:
+ v = (person['data'].get(str(d['idx'])) or {}).get('v','')
+ if v in ('A','B'): hours += 12
+ elif v:
+ try: h=float(v); hours += h if h>0 else 0
+ except (ValueError,TypeError): pass
+ pms = (person['data'].get(str(-month)) or {}).get('v','')
+ prace = 0
+ try: prace = float(pms) if pms else 0
+ except (ValueError,TypeError): pass
+ it_results.append({'name':person['name'],'hours':hours,'prace':prace})
+
+ return {'month':month,'year':year,'work_days':work_days,'fpd':fpd,
+ 'tkb':tkb_results,'it':it_results}
+
+# ──────────────────────────────────────────────────────────────
+# Shared strings helpers
+# ──────────────────────────────────────────────────────────────
+def parse_ss(xml_bytes):
+ root = ET.fromstring(xml_bytes)
+ strings = []
+ for si in root.findall(f'{{{NS}}}si'):
+ t = si.find(f'.//{{{NS}}}t')
+ strings.append(t.text or '' if t is not None else '')
+ return root, strings
+
+def get_or_add(root, strings, text):
+ if text in strings:
+ return strings.index(text)
+ strings.append(text)
+ si = ET.SubElement(root, f'{{{NS}}}si')
+ t = ET.SubElement(si, f'{{{NS}}}t')
+ t.text = text
+ root.set('count', str(len(strings)))
+ root.set('uniqueCount', str(len(strings)))
+ return len(strings) - 1
+
+def serialize_ss(root):
+ return b'\n' + ET.tostring(root, encoding='unicode').encode('utf-8')
+
+# ──────────────────────────────────────────────────────────────
+# Sheet XML helpers
+# ──────────────────────────────────────────────────────────────
+def col_letter(col_idx): # 1-based
+ letters = ''
+ while col_idx > 0:
+ col_idx, rem = divmod(col_idx - 1, 26)
+ letters = chr(65 + rem) + letters
+ return letters
+
+def cell_ref(col, row):
+ return f'{col_letter(col)}{row}'
+
+def set_cell_num(row_el, col, row, value):
+ """Set a numeric cell value; creates or updates the cell."""
+ ref = cell_ref(col, row)
+ for c in list(row_el):
+ if c.get('r') == ref:
+ c.set('t', 'n')
+ v = c.find(f'{{{NS}}}v')
+ if v is None: v = ET.SubElement(c, f'{{{NS}}}v')
+ v.text = str(value)
+ # Remove formula if any
+ f = c.find(f'{{{NS}}}f')
+ if f is not None: row_el.remove(f)
+ return
+ # Cell doesn't exist — shouldn't happen for template cells, but handle gracefully
+ c = ET.SubElement(row_el, f'{{{NS}}}c')
+ c.set('r', ref); c.set('t', 'n')
+ v = ET.SubElement(c, f'{{{NS}}}v'); v.text = str(value)
+
+def set_cell_ss(row_el, col, row, ss_idx):
+ """Set a shared-string cell."""
+ ref = cell_ref(col, row)
+ for c in list(row_el):
+ if c.get('r') == ref:
+ c.set('t', 's')
+ v = c.find(f'{{{NS}}}v')
+ if v is None: v = ET.SubElement(c, f'{{{NS}}}v')
+ v.text = str(ss_idx)
+ f = c.find(f'{{{NS}}}f')
+ if f is not None: row_el.remove(f)
+ return
+ c = ET.SubElement(row_el, f'{{{NS}}}c')
+ c.set('r', ref); c.set('t', 's')
+ v = ET.SubElement(c, f'{{{NS}}}v'); v.text = str(ss_idx)
+
+def set_cell_formula(row_el, col, row, formula):
+ ref = cell_ref(col, row)
+ for c in list(row_el):
+ if c.get('r') == ref:
+ c.attrib.pop('t', None)
+ # Remove existing v
+ v = c.find(f'{{{NS}}}v')
+ if v is not None: c.remove(v)
+ f = c.find(f'{{{NS}}}f')
+ if f is None: f = ET.SubElement(c, f'{{{NS}}}f')
+ f.text = formula
+ return
+ c = ET.SubElement(row_el, f'{{{NS}}}c')
+ c.set('r', ref)
+ f = ET.SubElement(c, f'{{{NS}}}f'); f.text = formula
+
+def clear_cell(row_el, col, row):
+ """Remove value from a cell (keep style/element, clear v and t)."""
+ ref = cell_ref(col, row)
+ for c in list(row_el):
+ if c.get('r') == ref:
+ c.attrib.pop('t', None)
+ v = c.find(f'{{{NS}}}v')
+ if v is not None: c.remove(v)
+ f = c.find(f'{{{NS}}}f')
+ if f is not None: c.remove(f)
+ return
+
+# ──────────────────────────────────────────────────────────────
+def generate_excel(data):
+ with open(TEMPLATE_PATH, 'rb') as f:
+ tpl_bytes = f.read()
+
+ tpl_zip = io.BytesIO(tpl_bytes)
+ with zipfile.ZipFile(tpl_zip) as zin:
+ filenames = zin.namelist()
+ ss_bytes = zin.read('xl/sharedStrings.xml') if 'xl/sharedStrings.xml' in filenames else None
+ sheet_bytes = zin.read('xl/worksheets/sheet1.xml')
+ wb_bytes = zin.read('xl/workbook.xml')
+ all_bytes = {f: zin.read(f) for f in filenames}
+
+ # ── Shared strings ──
+ ss_root, ss_list = parse_ss(ss_bytes) if ss_bytes else (None, [])
+
+ def ss(text):
+ return get_or_add(ss_root, ss_list, text)
+
+ month_name = MONTH_NAMES[data['month']]
+ month_idx = ss(month_name)
+ op_idx = ss(OP_CODE)
+
+ # ── Sheet XML ──
+ sheet_root = ET.fromstring(sheet_bytes)
+ sd = sheet_root.find(f'{{{NS}}}sheetData')
+
+ # Build row map
+ 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
+
+ # ── ROW 3: month metadata ──
+ r3 = rows[3]
+ set_cell_ss (r3, 4, 3, month_idx) # D3 = month name
+ set_cell_num(r3, 6, 3, 8) # F3 = direction (always 8)
+ set_cell_num(r3, 7, 3, data['work_days']) # G3
+ set_cell_num(r3, 8, 3, data['fpd']) # H3
+
+ # ── DATA ROWS 8–19 ──
+ for i in range(n_tmpl):
+ row_num = TPL_FIRST + i
+ r = rows[row_num]
+ if i < n_people:
+ p = data['tkb'][i]
+ # Clear all value-bearing cells first
+ for col in range(2, 26):
+ clear_cell(r, col, row_num)
+ # Write our values
+ set_cell_ss (r, 2, row_num, op_idx)
+ set_cell_ss (r, 3, row_num, ss(p['name']))
+ set_cell_num(r, 4, row_num, p['r'])
+ set_cell_num(r, 6, row_num, p['o'])
+ set_cell_num(r, 8, row_num, p['n'])
+ set_cell_num(r,10, row_num, p['o_navic'])
+ # P (indiv1): closure text/internet — string OR numeric OR blank
+ if p['indiv1_type'] == 'str':
+ set_cell_ss(r, 16, row_num, ss(p['indiv1_value']))
+ elif p['indiv1_type'] == 'num':
+ set_cell_num(r, 16, row_num, p['indiv1_value'])
+ set_cell_num(r,18, row_num, p['jine1']) # R: stravné doplatek
+ set_cell_num(r,19, row_num, p['jine2']) # S: transport (AUV)
+ set_cell_num(r,23, row_num, 0)
+ if p['absence']:
+ set_cell_ss(r, 24, row_num, ss(p['absence']))
+ else:
+ # Fewer people than template: clear the row
+ for col in range(2, 26):
+ clear_cell(r, col, row_num)
+ # Mark as hidden
+ r.set('hidden', '1')
+
+ # n_people ≤ n_tmpl always (filtered to TEMPLATE_NAMES in compute()).
+ # Unused template rows are cleared and hidden above. Celkem row stays at TPL_CELKEM.
+ celkem_row = TPL_CELKEM
+ it_first = TPL_IT1
+
+ # ── CELKEM row ──
+ ck = rows[celkem_row]
+ for col in range(4, 24):
+ clear_cell(ck, col, celkem_row)
+ for col in [4, 6, 8, 10]:
+ cl = col_letter(col)
+ set_cell_formula(ck, col, celkem_row,
+ f'SUBTOTAL(9,{cl}{TPL_FIRST}:{cl}{TPL_LAST})')
+ for col in [5, 7, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]:
+ set_cell_num(ck, col, celkem_row, 0)
+
+ # ── IT section (hidden rows already shifted) ──
+ # IT people (rows it_first to it_first+3)
+ it_names = ['Glaser', 'Janouš', 'Štefan', 'Vörös']
+ for j, it in enumerate(data['it']):
+ rn = it_first + j
+ r = rows.get(rn)
+ if r is None: continue
+ set_cell_ss (r, 2, rn, ss(it_names[j] if j < len(it_names) else it['name']))
+ set_cell_num(r, 3, rn, it['hours'])
+ if it.get('prace', 0):
+ set_cell_num(r, 4, rn, it['prace'])
+ else:
+ clear_cell(r, 4, rn)
+
+ # ── Rebuild sheetData in row order ──
+ for old_row in list(sd):
+ sd.remove(old_row)
+ for rn in sorted(rows):
+ sd.append(rows[rn])
+
+ # ── Update sheet name in workbook.xml ──
+ new_sheet_name = f"{data['month']:02d}_{data['year']}"
+ wb_text = wb_bytes.decode('utf-8')
+ wb_text = re.sub(r'name="[^"]*"', f'name="{new_sheet_name}"', wb_text, count=1)
+
+ # ── Serialize ──
+ sheet_out = (b'\n'
+ + ET.tostring(sheet_root, encoding='unicode').encode('utf-8'))
+ ss_out = serialize_ss(ss_root) if ss_root is not None else ss_bytes
+
+ # ── Repack zip ──
+ out_buf = io.BytesIO()
+ with zipfile.ZipFile(out_buf, 'w', zipfile.ZIP_DEFLATED) as zout:
+ for fname, fbytes in all_bytes.items():
+ if fname == 'xl/worksheets/sheet1.xml':
+ zout.writestr(fname, sheet_out)
+ elif fname == 'xl/sharedStrings.xml':
+ zout.writestr(fname, ss_out)
+ elif fname == 'xl/workbook.xml':
+ zout.writestr(fname, wb_text.encode('utf-8'))
+ else:
+ zout.writestr(fname, fbytes)
+
+ out_buf.seek(0)
+ return out_buf.read()
+
+# ──────────────────────────────────────────────────────────────
+if __name__ == '__main__':
+ inp = json.loads(sys.stdin.buffer.read())
+ result = compute(inp['schedule'], inp['month'], inp['year'])
+ sys.stdout.buffer.write(generate_excel(result))
diff --git a/web/index.html b/web/index.html
index 6f3e360..84d268c 100644
--- a/web/index.html
+++ b/web/index.html
@@ -4,7 +4,7 @@
-
TKB Plan sluzeb
+ Tunely-DEV
diff --git a/web/server.js b/web/server.js
index 26f101f..105b00b 100644
--- a/web/server.js
+++ b/web/server.js
@@ -9,7 +9,8 @@ const __filename = fileURLToPath(import.meta.url)
const __dirname = dirname(__filename)
const app = express()
-const PORT = 3080
+const PORT = process.env.PORT || 3090
+const SERVER_VERSION = Date.now().toString()
const SCHEDULES_DIR = join(__dirname, 'schedules')
const SAVED_SCHEDULE_PATH = join(__dirname, 'saved_schedule.json')
@@ -198,6 +199,55 @@ app.get('/api/files/:id/export-excel', (_req, res) => {
res.status(501).json({ error: 'Excel export not yet implemented for TKB format' })
})
+// Export docházka Excel for a given month
+app.get('/api/files/:id/export-dochazka', 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_dochazka.py')
+
+ try {
+ const { spawn } = await import('child_process')
+ await new Promise((resolve, reject) => {
+ const chunks = []
+ const errChunks = []
+ const proc = spawn('python3', [scriptPath])
+ 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_dochazka 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 = `OP2416101755_TKB_${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: 'Export failed', details: err.message })
+ }
+})
+
// Create new file
app.post('/api/files', (req, res) => {
try {
@@ -372,6 +422,11 @@ app.get('/api/export-excel', (_req, res) => {
res.status(501).json({ error: 'Excel export not yet implemented for TKB format' })
})
+// Version endpoint — clients poll this and reload when version changes
+app.get('/api/version', (_req, res) => {
+ res.json({ version: SERVER_VERSION })
+})
+
// Serve static files from dist/
app.use(express.static(join(__dirname, 'dist'), {
setHeaders: (res) => {
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 168c6d5..f7ac110 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -4,13 +4,26 @@ import type { SelectedCell } from './ScheduleTable'
import { Toolbar } from './Toolbar'
import { ContextMenu } from './ContextMenu'
import { ProposalModal } from './ProposalModal'
+import { FpdModal } from './FpdCheck'
import { useScheduleState } from './useScheduleState'
import { useDragCell } from './useDragCell'
-import { Login, isAuthenticated } from './Login'
+import { Login, isAuthenticated, getAuthRole, clearAuthentication } from './Login'
+import type { UserRole } from './Login'
import { FileManager } from './FileManager'
import fallbackData from './data.json'
import type { ScheduleData, ScheduleFileWithData, ContextMenuState } from './types'
+const FAVOURITE_FILE_KEY = 'tkb_favourite_file'
+
+export function getFavouriteFileId(): string | null {
+ return localStorage.getItem(FAVOURITE_FILE_KEY)
+}
+
+export function setFavouriteFileId(id: string | null): void {
+ if (id) localStorage.setItem(FAVOURITE_FILE_KEY, id)
+ else localStorage.removeItem(FAVOURITE_FILE_KEY)
+}
+
function normalizeDayIndex(data: ScheduleData): ScheduleData {
const targetStart = new Date(2026, 0, 1) // January 1
const targetEnd = new Date(2026, 11, 31) // December 31
@@ -82,22 +95,34 @@ function getISOWeek(date: Date): number {
type AppMode = 'login' | 'files' | 'editor'
function App() {
+ // Auto-reload when server restarts with a new version (new deploy)
+ useEffect(() => {
+ let serverVersion: string | null = null
+ const check = async () => {
+ try {
+ const res = await fetch('/api/version')
+ if (!res.ok) return
+ const { version } = await res.json()
+ if (serverVersion === null) { serverVersion = version; return }
+ if (version !== serverVersion) window.location.reload()
+ } catch {}
+ }
+ check()
+ const interval = setInterval(check, 60_000)
+ return () => clearInterval(interval)
+ }, [])
+
const [authed, setAuthed] = useState(isAuthenticated())
- const [mode, setMode] = useState(authed ? 'files' : 'login')
+ const [role, setRole] = useState(() => isAuthenticated() ? getAuthRole() : 'editor')
+ const isViewer = role === 'viewer'
+ const hasFavOnLoad = authed && !!getFavouriteFileId()
+ 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(false)
+ const [loading, setLoading] = useState(hasFavOnLoad)
const [compareFileId, setCompareFileId] = useState(null)
-
- useEffect(() => {
- if (authed && mode === 'login') setMode('files')
- }, [authed, mode])
-
- const handleLogin = useCallback(() => {
- setAuthed(true)
- setMode('files')
- }, [])
+ const autoOpenAttempted = useRef(false)
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
setLoading(true)
@@ -112,11 +137,38 @@ function App() {
setMode('editor')
} catch (err) {
alert(`Chyba pri otevirani souboru: ${err}`)
+ setMode('files')
} finally {
setLoading(false)
}
}, [])
+ // Auto-open favourite file when authenticated
+ useEffect(() => {
+ if (!authed || autoOpenAttempted.current) return
+ autoOpenAttempted.current = true
+ const fav = getFavouriteFileId()
+ if (fav) handleOpenFile(fav)
+ }, [authed, handleOpenFile])
+
+ const handleLogin = useCallback((newRole: UserRole) => {
+ autoOpenAttempted.current = false
+ setRole(newRole)
+ setAuthed(true)
+ }, [])
+
+ const handleLogout = useCallback(() => {
+ clearAuthentication()
+ setAuthed(false)
+ setRole('editor')
+ setMode('login')
+ setFileId(null)
+ setFileName('')
+ setFileData(null)
+ setCompareFileId(null)
+ autoOpenAttempted.current = false
+ }, [])
+
const handleCompare = useCallback((id1: string, id2: string) => {
handleOpenFile(id1, id2)
}, [handleOpenFile])
@@ -162,7 +214,17 @@ function App() {
}
if (mode === 'files') {
- return
+ if (isViewer) {
+ return (
+
+
+
Žádný aktivní harmonogram
+
Kontaktujte správce pro nastavení výchozího souboru
+
+
+ )
+ }
+ return
}
if (mode === 'editor' && fileData && fileId) {
@@ -172,9 +234,11 @@ function App() {
fileName={fileName}
data={fileData}
compareFileId={compareFileId}
- onBack={handleBackToFiles}
+ onBack={isViewer ? undefined : handleBackToFiles}
+ isReadOnly={isViewer}
onFileNameChange={setFileName}
onClearCompare={() => setCompareFileId(null)}
+ onLogout={handleLogout}
/>
)
}
@@ -223,12 +287,14 @@ interface ScheduleAppProps {
fileName: string
data: ScheduleData
compareFileId: string | null
- onBack: () => void
+ onBack?: () => void
onFileNameChange: (name: string) => void
onClearCompare: () => void
+ isReadOnly?: boolean
+ onLogout?: () => void
}
-function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare }: ScheduleAppProps) {
+function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, onLogout }: ScheduleAppProps) {
const {
people, tunnelClosures, tunnelColors,
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
@@ -406,9 +472,11 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
}, [getSchedulePayload, fileName, onFileNameChange])
const [showProposals, setShowProposals] = useState(false)
+ const [showFpd, setShowFpd] = useState(false)
const [showMetro, setShowMetro] = useState(true)
const [showD8, setShowD8] = useState(true)
const [showSazlt, setShowSazlt] = useState(true)
+ const [hide162, setHide162] = useState(false)
const [hiddenValues, setHiddenValues] = useState>(new Set())
const handleExportPdf = useCallback(async (month: number) => {
@@ -457,24 +525,42 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
return (
-
-
-
- {fileName || 'TKB Plan sluzeb'}
-
- Plan sluzeb a pohotovosti
-
-
- {compareFileName && (
-
- Porovnani s: {compareFileName}
-
+
+
+ {onBack && (
+
+ )}
+ {isReadOnly && (
+
+ Jen pro čtení
+
+ )}
+
+ {fileName || 'TKB Plán služeb'}
+
+ Plán služeb a pohotovostí
+
+
+ {compareFileName && (
+
+ Porovnani s: {compareFileName}
+
+ )}
+
+ {onLogout && (
+
)}
@@ -493,6 +579,8 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
onToggleD8={() => setShowD8(v => !v)}
showSazlt={showSazlt}
onToggleSazlt={() => setShowSazlt(v => !v)}
+ hide162={hide162}
+ onToggle162={() => setHide162(v => !v)}
hiddenValues={hiddenValues}
onToggleValue={(code: string) => setHiddenValues(prev => {
const next = new Set(prev)
@@ -502,7 +590,9 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
diffFileName={compareFileName}
onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }}
onExportPdf={handleExportPdf}
- onShowProposals={() => setShowProposals(true)}
+ onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)}
+ onCheckFpd={() => setShowFpd(true)}
+ isReadOnly={isReadOnly}
/>
{} : onCellPointerDown}
+ onSetCell={isReadOnly ? () => {} : setCell}
+ onSetTunnelClosure={isReadOnly ? () => {} : setTunnelClosure}
+ onSetMetroClosure={isReadOnly ? () => {} : setMetroClosure}
+ onSetD8Closure={isReadOnly ? () => {} : setD8Closure}
+ onSetSazltClosure={isReadOnly ? () => {} : setSazltClosure}
showMetro={showMetro}
showD8={showD8}
showSazlt={showSazlt}
+ hide162={hide162}
hiddenValues={hiddenValues}
scrollRef={scrollRef}
- onContextMenu={handleContextMenu}
- onTunnelContextMenu={handleTunnelContextMenu}
- onInfoRowContextMenu={handleInfoRowContextMenu}
+ onContextMenu={isReadOnly ? () => {} : handleContextMenu}
+ onTunnelContextMenu={isReadOnly ? () => {} : handleTunnelContextMenu}
+ onInfoRowContextMenu={isReadOnly ? () => {} : handleInfoRowContextMenu}
compareData={compareData}
/>
@@ -567,6 +658,20 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
{showProposals && (
setShowProposals(false)} />
)}
+
+ {showFpd && activeMonth && (
+ d.month === activeMonth)?.year ?? 2026}
+ dayIndex={data.dayIndex}
+ people={people}
+ onClose={() => setShowFpd(false)}
+ onExportDochazka={currentFileId ? () => {
+ const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026
+ window.location.href = `/api/files/${currentFileId}/export-dochazka?month=${activeMonth}&year=${year}`
+ } : undefined}
+ />
+ )}
)
}
diff --git a/web/src/ContextMenu.tsx b/web/src/ContextMenu.tsx
index c319171..9921b6c 100644
--- a/web/src/ContextMenu.tsx
+++ b/web/src/ContextMenu.tsx
@@ -192,7 +192,18 @@ export function ContextMenu({
return (
{
+ (menuRef as React.MutableRefObject
).current = el
+ if (el) {
+ const rect = el.getBoundingClientRect()
+ if (rect.bottom > window.innerHeight) {
+ el.style.top = `${state.y - rect.height}px`
+ }
+ if (rect.right > window.innerWidth) {
+ el.style.left = `${state.x - rect.width}px`
+ }
+ }
+ }}
className="fixed z-[200] bg-slate-800 border border-slate-600 rounded-lg shadow-2xl py-1 min-w-[220px]"
style={{ left: state.x, top: state.y }}
>
diff --git a/web/src/FileManager.tsx b/web/src/FileManager.tsx
index 982430d..6d8569f 100644
--- a/web/src/FileManager.tsx
+++ b/web/src/FileManager.tsx
@@ -1,21 +1,30 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import type { ScheduleFile } from './types'
+import { getFavouriteFileId, setFavouriteFileId } from './App'
interface FileManagerProps {
onOpenFile: (fileId: string) => void
onCompare: (fileId1: string, fileId2: string) => void
onCreateNew?: () => void
+ onLogout?: () => void
}
-export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerProps) {
+export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: FileManagerProps) {
const [files, setFiles] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [selectedIds, setSelectedIds] = useState>(new Set())
const [uploading, setUploading] = useState(false)
const [deleting, setDeleting] = useState(null)
+ const [favouriteId, setFavouriteId] = useState(getFavouriteFileId)
const fileInputRef = useRef(null)
+ const handleToggleFavourite = useCallback((id: string) => {
+ const newFav = favouriteId === id ? null : id
+ setFavouriteFileId(newFav)
+ setFavouriteId(newFav)
+ }, [favouriteId])
+
const loadFiles = useCallback(async () => {
try {
const res = await fetch('/api/files')
@@ -127,12 +136,23 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
return (
@@ -150,7 +170,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
className="px-4 py-2 rounded text-sm bg-blue-700/70 text-blue-100 border border-blue-600
hover:bg-blue-600/70 cursor-pointer transition-colors"
>
- Novy soubor
+ Nový soubor
)}
- Zadne soubory
+ Žádné soubory
{onCreateNew && (
)}
@@ -215,18 +235,22 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
- Nazev |
+ |
+ Název |
Upraveno |
- Vytvoreno |
+ Vytvořeno |
Akce |
- {files.map(file => (
+ {files.map(file => {
+ const isFav = favouriteId === file.id
+ return (
|
|
+
+
+ |
- {file.name}
+
+ {file.name}
+ {isFav && výchozí}
+
|
{formatDate(file.modifiedAt)}
@@ -253,7 +292,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
className="px-3 py-1 rounded text-xs bg-blue-700/70 text-blue-100 border border-blue-600
hover:bg-blue-600/70 cursor-pointer transition-colors"
>
- Otevrit
+ Otevřít
|
- ))}
+ )})}
diff --git a/web/src/FpdCheck.tsx b/web/src/FpdCheck.tsx
new file mode 100644
index 0000000..d784077
--- /dev/null
+++ b/web/src/FpdCheck.tsx
@@ -0,0 +1,166 @@
+import type { DayInfo, Person } from './types'
+import { getCzechHolidays } from './holidays'
+
+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',
+}
+
+interface FpdResult {
+ personId: string
+ name: string
+ note?: string
+ workedHours: number
+ fpd: number
+ diff: number // positive = over, negative = under
+}
+
+function getHoursForValue(v: string): number {
+ if (v === 'A' || v === 'B') return 12
+ const num = parseFloat(v)
+ if (!isNaN(num) && num > 0) return num
+ return 0
+}
+
+export function computeFpd(
+ month: number,
+ year: number,
+ dayIndex: DayInfo[],
+ people: Person[],
+): { fpd: number; workDays: number; results: FpdResult[] } {
+ const holidays = getCzechHolidays(year)
+ const holidaySet = new Set(holidays.filter(h => h.month === month).map(h => h.day))
+
+ // Count working days in month
+ const monthDays = dayIndex.filter(d => d.month === month && d.year === year)
+ let workDays = 0
+ for (const d of monthDays) {
+ if (!d.weekend && !holidaySet.has(d.day)) workDays++
+ }
+ const fpd = workDays * 8
+
+ // Check TKB people only
+ const tkbPeople = people.filter(p => p.group === 'TKB')
+ const results: FpdResult[] = []
+
+ for (const person of tkbPeople) {
+ let workedHours = 0
+ for (const d of monthDays) {
+ const cellData = person.data[String(d.idx)]
+ const value = cellData?.v
+ if (value) {
+ workedHours += getHoursForValue(value)
+ }
+ }
+ const diff = workedHours - fpd
+ results.push({
+ personId: person.id,
+ name: person.name,
+ note: person.note,
+ workedHours,
+ fpd,
+ diff,
+ })
+ }
+
+ return { fpd, workDays, results }
+}
+
+interface FpdModalProps {
+ month: number
+ year: number
+ dayIndex: DayInfo[]
+ people: Person[]
+ onClose: () => void
+ onExportDochazka?: () => void
+}
+
+export function FpdModal({ month, year, dayIndex, people, onClose, onExportDochazka }: 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)
+
+ return (
+
+
e.stopPropagation()}>
+
+
+ Kontrola FPD — {MONTH_NAMES[month]} {year}
+
+
+
+
+
+ Pracovních dnů: {workDays}
+ |
+ FPD: {fpd} h
+ |
+ Pohotovost TKB: {results.length} osob
+
+
+
+ {under.length === 0 ? (
+
+ ✓ Nikdo nemá nedostatek hodin
+
+ ) : (
+ <>
+
Chybí hodiny ({under.length})
+
+ {under.map(r => (
+
+
+ {r.name}
+ {r.note && ({r.note})}
+
+
+ {r.workedHours}h / {r.fpd}h
+ {r.diff}h
+
+
+ ))}
+
+ >
+ )}
+
+
Ostatní ({okOrOver.length})
+
+ {okOrOver.map(r => (
+
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.workedHours}h / {r.fpd}h
+ 0 ? 'text-slate-300' : 'text-green-400'}`}>
+ {r.diff > 0 ? `+${r.diff}h` : '✓'}
+
+
+
+ ))}
+
+
+
+ {onExportDochazka && (
+
+
+
+ )}
+
+
+ )
+}
diff --git a/web/src/Login.tsx b/web/src/Login.tsx
index e49171e..0d48e26 100644
--- a/web/src/Login.tsx
+++ b/web/src/Login.tsx
@@ -2,18 +2,33 @@ import { useState } from 'react'
const VALID_USER = 'tkb'
const VALID_PASS = 'sluzby'
+const VIEWER_USER = 'prohlizec'
+const VIEWER_PASS = 'pohled'
const AUTH_KEY = 'tkb_auth'
+const ROLE_KEY = 'tkb_role'
+
+export type UserRole = 'editor' | 'viewer'
export function isAuthenticated(): boolean {
- return sessionStorage.getItem(AUTH_KEY) === 'true'
+ return localStorage.getItem(AUTH_KEY) === 'true'
}
-export function setAuthenticated(): void {
- sessionStorage.setItem(AUTH_KEY, 'true')
+export function getAuthRole(): UserRole {
+ return localStorage.getItem(ROLE_KEY) === 'viewer' ? 'viewer' : 'editor'
+}
+
+function setAuthenticated(role: UserRole): void {
+ localStorage.setItem(AUTH_KEY, 'true')
+ localStorage.setItem(ROLE_KEY, role)
+}
+
+export function clearAuthentication(): void {
+ localStorage.removeItem(AUTH_KEY)
+ localStorage.removeItem(ROLE_KEY)
}
interface LoginProps {
- onLogin: () => void
+ onLogin: (role: UserRole) => void
}
export function Login({ onLogin }: LoginProps) {
@@ -24,8 +39,11 @@ export function Login({ onLogin }: LoginProps) {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (username === VALID_USER && password === VALID_PASS) {
- setAuthenticated()
- onLogin()
+ setAuthenticated('editor')
+ onLogin('editor')
+ } else if (username === VIEWER_USER && password === VIEWER_PASS) {
+ setAuthenticated('viewer')
+ onLogin('viewer')
} else {
setError(true)
setTimeout(() => setError(false), 3000)
@@ -39,13 +57,13 @@ export function Login({ onLogin }: LoginProps) {
className="bg-slate-800 border border-slate-700 rounded-lg p-8 w-80 shadow-xl"
>
- TKB Plan sluzeb
+ TKB Plán služeb
- Planovani smen a pohotovosti
+ Plánování směn a pohotovostí
-
+
- Nespravne prihlasovaci udaje
+ Nesprávné přihlašovací údaje
)}
@@ -75,7 +93,7 @@ export function Login({ onLogin }: LoginProps) {
className="w-full py-2 rounded bg-blue-600 hover:bg-blue-500 text-white text-sm
font-medium transition-colors cursor-pointer"
>
- Prihlasit
+ Přihlásit
diff --git a/web/src/ProposalModal.tsx b/web/src/ProposalModal.tsx
index d710f3c..825ccee 100644
--- a/web/src/ProposalModal.tsx
+++ b/web/src/ProposalModal.tsx
@@ -59,7 +59,7 @@ export function ProposalModal({ onClose }: ProposalModalProps) {
>
-
Navrhy na vylepseni
+
Návrhy na vylepšení