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 (
-

- TKB Plan sluzeb - - Sprava souboru planu sluzeb - -

+
+

+ TKB Plán služeb + + Správa souborů plánu služeb + +

+ {onLogout && ( + + )} +
@@ -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í

+ {!isReadOnly && ( + <> + - + - + + + )} {onExportPdf && activeMonth && ( + )} + + {onCheckFpd && ( + )} @@ -191,6 +213,17 @@ export function Toolbar({ > SAZLT +
diff --git a/web/src/data.json b/web/src/data.json index c30966a..ddca30a 100644 --- a/web/src/data.json +++ b/web/src/data.json @@ -3005,27 +3005,6 @@ "group": "TKB", "data": {} }, - { - "id": "tkb-glaser", - "name": "Glaser Ondřej", - "note": "NN", - "group": "TKB", - "data": {} - }, - { - "id": "tkb-herbst", - "name": "Herbst David", - "note": "NN", - "group": "TKB", - "data": {} - }, - { - "id": "tkb-ryba", - "name": "Ryba Ondřej", - "note": "NN", - "group": "TKB", - "data": {} - }, { "id": "tkb-zabransky", "name": "Zábranský Petr", @@ -3070,12 +3049,6 @@ "name": "Robert Štefan", "group": "IT", "data": {} - }, - { - "id": "it-franek", - "name": "Franek Lukáš", - "group": "IT", - "data": {} } ], "tunnelClosures": [],