feat: dochazka Excel export + auto-reload + 162 filter
- Add dochazka Excel export using direct ZIP/XML manipulation of template (preserves styles.xml byte-for-byte to avoid Excel "repaired styles" warning) - Calculate per-person stravné doplatek, transport (AUV), and indiv1 (closure count + Janouš internet) per sichtovnice.py logic - Filter exported people to TEMPLATE_NAMES (12 fixed template rows) - Add server version polling + auto-reload on deploy - Add FPD check modal for monthly hour validation - Add "162" filter button to hide first 5 TKB people from view Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BIN
web/dochazka_template.xlsx
Normal file
BIN
web/dochazka_template.xlsx
Normal file
Binary file not shown.
505
web/export_dochazka.py
Normal file
505
web/export_dochazka.py
Normal file
@@ -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'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\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'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\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))
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>TKB Plan sluzeb</title>
|
<title>Tunely-DEV</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const __filename = fileURLToPath(import.meta.url)
|
|||||||
const __dirname = dirname(__filename)
|
const __dirname = dirname(__filename)
|
||||||
|
|
||||||
const app = express()
|
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 SCHEDULES_DIR = join(__dirname, 'schedules')
|
||||||
const SAVED_SCHEDULE_PATH = join(__dirname, 'saved_schedule.json')
|
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' })
|
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
|
// Create new file
|
||||||
app.post('/api/files', (req, res) => {
|
app.post('/api/files', (req, res) => {
|
||||||
try {
|
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' })
|
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/
|
// Serve static files from dist/
|
||||||
app.use(express.static(join(__dirname, 'dist'), {
|
app.use(express.static(join(__dirname, 'dist'), {
|
||||||
setHeaders: (res) => {
|
setHeaders: (res) => {
|
||||||
|
|||||||
165
web/src/App.tsx
165
web/src/App.tsx
@@ -4,13 +4,26 @@ import type { SelectedCell } from './ScheduleTable'
|
|||||||
import { Toolbar } from './Toolbar'
|
import { Toolbar } from './Toolbar'
|
||||||
import { ContextMenu } from './ContextMenu'
|
import { ContextMenu } from './ContextMenu'
|
||||||
import { ProposalModal } from './ProposalModal'
|
import { ProposalModal } from './ProposalModal'
|
||||||
|
import { FpdModal } from './FpdCheck'
|
||||||
import { useScheduleState } from './useScheduleState'
|
import { useScheduleState } from './useScheduleState'
|
||||||
import { useDragCell } from './useDragCell'
|
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 { FileManager } from './FileManager'
|
||||||
import fallbackData from './data.json'
|
import fallbackData from './data.json'
|
||||||
import type { ScheduleData, ScheduleFileWithData, ContextMenuState } from './types'
|
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 {
|
function normalizeDayIndex(data: ScheduleData): ScheduleData {
|
||||||
const targetStart = new Date(2026, 0, 1) // January 1
|
const targetStart = new Date(2026, 0, 1) // January 1
|
||||||
const targetEnd = new Date(2026, 11, 31) // December 31
|
const targetEnd = new Date(2026, 11, 31) // December 31
|
||||||
@@ -82,22 +95,34 @@ function getISOWeek(date: Date): number {
|
|||||||
type AppMode = 'login' | 'files' | 'editor'
|
type AppMode = 'login' | 'files' | 'editor'
|
||||||
|
|
||||||
function App() {
|
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 [authed, setAuthed] = useState(isAuthenticated())
|
||||||
const [mode, setMode] = useState<AppMode>(authed ? 'files' : 'login')
|
const [role, setRole] = useState<UserRole>(() => isAuthenticated() ? getAuthRole() : 'editor')
|
||||||
|
const isViewer = role === 'viewer'
|
||||||
|
const hasFavOnLoad = authed && !!getFavouriteFileId()
|
||||||
|
const [mode, setMode] = useState<AppMode>(authed ? (hasFavOnLoad ? 'editor' : 'files') : 'login')
|
||||||
const [fileId, setFileId] = useState<string | null>(null)
|
const [fileId, setFileId] = useState<string | null>(null)
|
||||||
const [fileName, setFileName] = useState<string>('')
|
const [fileName, setFileName] = useState<string>('')
|
||||||
const [fileData, setFileData] = useState<ScheduleData | null>(null)
|
const [fileData, setFileData] = useState<ScheduleData | null>(null)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(hasFavOnLoad)
|
||||||
const [compareFileId, setCompareFileId] = useState<string | null>(null)
|
const [compareFileId, setCompareFileId] = useState<string | null>(null)
|
||||||
|
const autoOpenAttempted = useRef(false)
|
||||||
useEffect(() => {
|
|
||||||
if (authed && mode === 'login') setMode('files')
|
|
||||||
}, [authed, mode])
|
|
||||||
|
|
||||||
const handleLogin = useCallback(() => {
|
|
||||||
setAuthed(true)
|
|
||||||
setMode('files')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
|
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -112,11 +137,38 @@ function App() {
|
|||||||
setMode('editor')
|
setMode('editor')
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Chyba pri otevirani souboru: ${err}`)
|
alert(`Chyba pri otevirani souboru: ${err}`)
|
||||||
|
setMode('files')
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
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) => {
|
const handleCompare = useCallback((id1: string, id2: string) => {
|
||||||
handleOpenFile(id1, id2)
|
handleOpenFile(id1, id2)
|
||||||
}, [handleOpenFile])
|
}, [handleOpenFile])
|
||||||
@@ -162,7 +214,17 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'files') {
|
if (mode === 'files') {
|
||||||
return <FileManager onOpenFile={handleOpenFile} onCompare={handleCompare} onCreateNew={handleCreateNew} />
|
if (isViewer) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-900 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-slate-400 text-sm mb-2">Žádný aktivní harmonogram</div>
|
||||||
|
<div className="text-slate-600 text-xs">Kontaktujte správce pro nastavení výchozího souboru</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return <FileManager onOpenFile={handleOpenFile} onCompare={handleCompare} onCreateNew={handleCreateNew} onLogout={handleLogout} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mode === 'editor' && fileData && fileId) {
|
if (mode === 'editor' && fileData && fileId) {
|
||||||
@@ -172,9 +234,11 @@ function App() {
|
|||||||
fileName={fileName}
|
fileName={fileName}
|
||||||
data={fileData}
|
data={fileData}
|
||||||
compareFileId={compareFileId}
|
compareFileId={compareFileId}
|
||||||
onBack={handleBackToFiles}
|
onBack={isViewer ? undefined : handleBackToFiles}
|
||||||
|
isReadOnly={isViewer}
|
||||||
onFileNameChange={setFileName}
|
onFileNameChange={setFileName}
|
||||||
onClearCompare={() => setCompareFileId(null)}
|
onClearCompare={() => setCompareFileId(null)}
|
||||||
|
onLogout={handleLogout}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -223,12 +287,14 @@ interface ScheduleAppProps {
|
|||||||
fileName: string
|
fileName: string
|
||||||
data: ScheduleData
|
data: ScheduleData
|
||||||
compareFileId: string | null
|
compareFileId: string | null
|
||||||
onBack: () => void
|
onBack?: () => void
|
||||||
onFileNameChange: (name: string) => void
|
onFileNameChange: (name: string) => void
|
||||||
onClearCompare: () => 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 {
|
const {
|
||||||
people, tunnelClosures, tunnelColors,
|
people, tunnelClosures, tunnelColors,
|
||||||
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
|
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
|
||||||
@@ -406,9 +472,11 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
}, [getSchedulePayload, fileName, onFileNameChange])
|
}, [getSchedulePayload, fileName, onFileNameChange])
|
||||||
|
|
||||||
const [showProposals, setShowProposals] = useState(false)
|
const [showProposals, setShowProposals] = useState(false)
|
||||||
|
const [showFpd, setShowFpd] = useState(false)
|
||||||
const [showMetro, setShowMetro] = useState(true)
|
const [showMetro, setShowMetro] = useState(true)
|
||||||
const [showD8, setShowD8] = useState(true)
|
const [showD8, setShowD8] = useState(true)
|
||||||
const [showSazlt, setShowSazlt] = useState(true)
|
const [showSazlt, setShowSazlt] = useState(true)
|
||||||
|
const [hide162, setHide162] = useState(false)
|
||||||
const [hiddenValues, setHiddenValues] = useState<Set<string>>(new Set())
|
const [hiddenValues, setHiddenValues] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const handleExportPdf = useCallback(async (month: number) => {
|
const handleExportPdf = useCallback(async (month: number) => {
|
||||||
@@ -457,18 +525,26 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-900">
|
<div className="min-h-screen bg-slate-900">
|
||||||
<header className="border-b border-slate-700 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-50 px-6 py-4">
|
<header className="border-b border-slate-700 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-50 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{onBack && (
|
||||||
<button
|
<button
|
||||||
onClick={onBack}
|
onClick={onBack}
|
||||||
className="px-3 py-1.5 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
|
className="px-3 py-1.5 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
|
||||||
hover:bg-slate-700 cursor-pointer transition-colors"
|
hover:bg-slate-700 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
← Zpet
|
← Zpět
|
||||||
</button>
|
</button>
|
||||||
|
)}
|
||||||
|
{isReadOnly && (
|
||||||
|
<span className="px-2 py-1 rounded text-xs bg-slate-700/60 text-slate-400 border border-slate-600">
|
||||||
|
Jen pro čtení
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<h1 className="text-xl font-semibold text-slate-100 tracking-tight">
|
<h1 className="text-xl font-semibold text-slate-100 tracking-tight">
|
||||||
{fileName || 'TKB Plan sluzeb'}
|
{fileName || 'TKB Plán služeb'}
|
||||||
<span className="ml-3 text-sm font-normal text-slate-400">
|
<span className="ml-3 text-sm font-normal text-slate-400">
|
||||||
Plan sluzeb a pohotovosti
|
Plán služeb a pohotovostí
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
{compareFileName && (
|
{compareFileName && (
|
||||||
@@ -477,6 +553,16 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{onLogout && (
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="px-3 py-1.5 rounded text-xs bg-slate-800 text-slate-400 border border-slate-700
|
||||||
|
hover:bg-slate-700 hover:text-slate-200 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
Odhlásit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="p-4">
|
<main className="p-4">
|
||||||
<Toolbar
|
<Toolbar
|
||||||
@@ -493,6 +579,8 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
onToggleD8={() => setShowD8(v => !v)}
|
onToggleD8={() => setShowD8(v => !v)}
|
||||||
showSazlt={showSazlt}
|
showSazlt={showSazlt}
|
||||||
onToggleSazlt={() => setShowSazlt(v => !v)}
|
onToggleSazlt={() => setShowSazlt(v => !v)}
|
||||||
|
hide162={hide162}
|
||||||
|
onToggle162={() => setHide162(v => !v)}
|
||||||
hiddenValues={hiddenValues}
|
hiddenValues={hiddenValues}
|
||||||
onToggleValue={(code: string) => setHiddenValues(prev => {
|
onToggleValue={(code: string) => setHiddenValues(prev => {
|
||||||
const next = new Set(prev)
|
const next = new Set(prev)
|
||||||
@@ -502,7 +590,9 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
diffFileName={compareFileName}
|
diffFileName={compareFileName}
|
||||||
onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }}
|
onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }}
|
||||||
onExportPdf={handleExportPdf}
|
onExportPdf={handleExportPdf}
|
||||||
onShowProposals={() => setShowProposals(true)}
|
onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)}
|
||||||
|
onCheckFpd={() => setShowFpd(true)}
|
||||||
|
isReadOnly={isReadOnly}
|
||||||
/>
|
/>
|
||||||
<ScheduleTable
|
<ScheduleTable
|
||||||
dayIndex={data.dayIndex}
|
dayIndex={data.dayIndex}
|
||||||
@@ -517,21 +607,22 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
sazltColors={sazltColors}
|
sazltColors={sazltColors}
|
||||||
dayComments={dayComments}
|
dayComments={dayComments}
|
||||||
cellComments={cellComments}
|
cellComments={cellComments}
|
||||||
dragState={dragState}
|
dragState={isReadOnly ? null : dragState}
|
||||||
onCellPointerDown={onCellPointerDown}
|
onCellPointerDown={isReadOnly ? () => {} : onCellPointerDown}
|
||||||
onSetCell={setCell}
|
onSetCell={isReadOnly ? () => {} : setCell}
|
||||||
onSetTunnelClosure={setTunnelClosure}
|
onSetTunnelClosure={isReadOnly ? () => {} : setTunnelClosure}
|
||||||
onSetMetroClosure={setMetroClosure}
|
onSetMetroClosure={isReadOnly ? () => {} : setMetroClosure}
|
||||||
onSetD8Closure={setD8Closure}
|
onSetD8Closure={isReadOnly ? () => {} : setD8Closure}
|
||||||
onSetSazltClosure={setSazltClosure}
|
onSetSazltClosure={isReadOnly ? () => {} : setSazltClosure}
|
||||||
showMetro={showMetro}
|
showMetro={showMetro}
|
||||||
showD8={showD8}
|
showD8={showD8}
|
||||||
showSazlt={showSazlt}
|
showSazlt={showSazlt}
|
||||||
|
hide162={hide162}
|
||||||
hiddenValues={hiddenValues}
|
hiddenValues={hiddenValues}
|
||||||
scrollRef={scrollRef}
|
scrollRef={scrollRef}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={isReadOnly ? () => {} : handleContextMenu}
|
||||||
onTunnelContextMenu={handleTunnelContextMenu}
|
onTunnelContextMenu={isReadOnly ? () => {} : handleTunnelContextMenu}
|
||||||
onInfoRowContextMenu={handleInfoRowContextMenu}
|
onInfoRowContextMenu={isReadOnly ? () => {} : handleInfoRowContextMenu}
|
||||||
compareData={compareData}
|
compareData={compareData}
|
||||||
/>
|
/>
|
||||||
</main>
|
</main>
|
||||||
@@ -567,6 +658,20 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
|||||||
{showProposals && (
|
{showProposals && (
|
||||||
<ProposalModal onClose={() => setShowProposals(false)} />
|
<ProposalModal onClose={() => setShowProposals(false)} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showFpd && activeMonth && (
|
||||||
|
<FpdModal
|
||||||
|
month={activeMonth}
|
||||||
|
year={data.dayIndex.find(d => 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -192,7 +192,18 @@ export function ContextMenu({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={menuRef}
|
ref={(el) => {
|
||||||
|
(menuRef as React.MutableRefObject<HTMLDivElement | null>).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]"
|
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 }}
|
style={{ left: state.x, top: state.y }}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1,21 +1,30 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||||
import type { ScheduleFile } from './types'
|
import type { ScheduleFile } from './types'
|
||||||
|
import { getFavouriteFileId, setFavouriteFileId } from './App'
|
||||||
|
|
||||||
interface FileManagerProps {
|
interface FileManagerProps {
|
||||||
onOpenFile: (fileId: string) => void
|
onOpenFile: (fileId: string) => void
|
||||||
onCompare: (fileId1: string, fileId2: string) => void
|
onCompare: (fileId1: string, fileId2: string) => void
|
||||||
onCreateNew?: () => void
|
onCreateNew?: () => void
|
||||||
|
onLogout?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerProps) {
|
export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: FileManagerProps) {
|
||||||
const [files, setFiles] = useState<ScheduleFile[]>([])
|
const [files, setFiles] = useState<ScheduleFile[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||||
const [uploading, setUploading] = useState(false)
|
const [uploading, setUploading] = useState(false)
|
||||||
const [deleting, setDeleting] = useState<string | null>(null)
|
const [deleting, setDeleting] = useState<string | null>(null)
|
||||||
|
const [favouriteId, setFavouriteId] = useState<string | null>(getFavouriteFileId)
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const handleToggleFavourite = useCallback((id: string) => {
|
||||||
|
const newFav = favouriteId === id ? null : id
|
||||||
|
setFavouriteFileId(newFav)
|
||||||
|
setFavouriteId(newFav)
|
||||||
|
}, [favouriteId])
|
||||||
|
|
||||||
const loadFiles = useCallback(async () => {
|
const loadFiles = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/files')
|
const res = await fetch('/api/files')
|
||||||
@@ -127,12 +136,23 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
|||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-slate-900">
|
<div className="min-h-screen bg-slate-900">
|
||||||
<header className="border-b border-slate-700 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-50 px-6 py-4">
|
<header className="border-b border-slate-700 bg-slate-900/80 backdrop-blur-sm sticky top-0 z-50 px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-semibold text-slate-100 tracking-tight">
|
<h1 className="text-xl font-semibold text-slate-100 tracking-tight">
|
||||||
TKB Plan sluzeb
|
TKB Plán služeb
|
||||||
<span className="ml-3 text-sm font-normal text-slate-400">
|
<span className="ml-3 text-sm font-normal text-slate-400">
|
||||||
Sprava souboru planu sluzeb
|
Správa souborů plánu služeb
|
||||||
</span>
|
</span>
|
||||||
</h1>
|
</h1>
|
||||||
|
{onLogout && (
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="px-3 py-1.5 rounded text-xs bg-slate-800 text-slate-400 border border-slate-700
|
||||||
|
hover:bg-slate-700 hover:text-slate-200 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
Odhlásit
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="p-6 max-w-4xl mx-auto">
|
<main className="p-6 max-w-4xl mx-auto">
|
||||||
@@ -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
|
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"
|
hover:bg-blue-600/70 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
Novy soubor
|
Nový soubor
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -159,7 +179,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
|||||||
className="px-4 py-2 rounded text-sm bg-green-700/70 text-green-100 border border-green-600
|
className="px-4 py-2 rounded text-sm bg-green-700/70 text-green-100 border border-green-600
|
||||||
hover:bg-green-600/70 cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
hover:bg-green-600/70 cursor-pointer transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
{uploading ? 'Nahravani...' : 'Nahrat Excel'}
|
{uploading ? 'Nahrávání...' : 'Nahrát Excel'}
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
@@ -189,7 +209,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
|||||||
{/* Files table */}
|
{/* Files table */}
|
||||||
{files.length === 0 ? (
|
{files.length === 0 ? (
|
||||||
<div className="text-center py-16">
|
<div className="text-center py-16">
|
||||||
<div className="text-slate-500 text-sm mb-4">Zadne soubory</div>
|
<div className="text-slate-500 text-sm mb-4">Žádné soubory</div>
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center gap-3">
|
||||||
{onCreateNew && (
|
{onCreateNew && (
|
||||||
<button
|
<button
|
||||||
@@ -197,7 +217,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
|
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"
|
hover:bg-blue-600/70 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
Vytvorit novy soubor
|
Vytvořit nový soubor
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
@@ -205,7 +225,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
|||||||
className="px-4 py-2 rounded text-sm bg-green-700/70 text-green-100 border border-green-600
|
className="px-4 py-2 rounded text-sm bg-green-700/70 text-green-100 border border-green-600
|
||||||
hover:bg-green-600/70 cursor-pointer transition-colors"
|
hover:bg-green-600/70 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
Nahrat Excel
|
Nahrát Excel
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,18 +235,22 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-slate-700">
|
<tr className="border-b border-slate-700">
|
||||||
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider w-10"></th>
|
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider w-10"></th>
|
||||||
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Nazev</th>
|
<th className="px-4 py-3 w-8"></th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Název</th>
|
||||||
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Upraveno</th>
|
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Upraveno</th>
|
||||||
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Vytvoreno</th>
|
<th className="px-4 py-3 text-left text-xs text-slate-400 uppercase tracking-wider">Vytvořeno</th>
|
||||||
<th className="px-4 py-3 text-right text-xs text-slate-400 uppercase tracking-wider">Akce</th>
|
<th className="px-4 py-3 text-right text-xs text-slate-400 uppercase tracking-wider">Akce</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{files.map(file => (
|
{files.map(file => {
|
||||||
|
const isFav = favouriteId === file.id
|
||||||
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={file.id}
|
key={file.id}
|
||||||
className={`border-b border-slate-700/50 hover:bg-slate-750 transition-colors
|
className={`border-b border-slate-700/50 hover:bg-slate-750 transition-colors
|
||||||
${selectedIds.has(file.id) ? 'bg-slate-700/30' : ''}`}
|
${selectedIds.has(file.id) ? 'bg-slate-700/30' : ''}
|
||||||
|
${isFav ? 'bg-amber-900/10' : ''}`}
|
||||||
>
|
>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<input
|
<input
|
||||||
@@ -237,8 +261,23 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
|||||||
focus:ring-blue-500 focus:ring-offset-0 cursor-pointer"
|
focus:ring-blue-500 focus:ring-offset-0 cursor-pointer"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="px-2 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleToggleFavourite(file.id)}
|
||||||
|
title={isFav ? 'Zrušit jako výchozí' : 'Nastavit jako výchozí (otevře se automaticky)'}
|
||||||
|
className="text-slate-500 hover:text-amber-400 transition-colors cursor-pointer"
|
||||||
|
>
|
||||||
|
{isFav
|
||||||
|
? <svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" className="text-amber-400"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
||||||
|
: <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-slate-200 font-medium">{file.name}</span>
|
<span className="text-sm text-slate-200 font-medium">{file.name}</span>
|
||||||
|
{isFav && <span className="text-[10px] px-1.5 py-0.5 rounded bg-amber-700/40 text-amber-300 border border-amber-700/50">výchozí</span>}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<span className="text-sm text-slate-400">{formatDate(file.modifiedAt)}</span>
|
<span className="text-sm text-slate-400">{formatDate(file.modifiedAt)}</span>
|
||||||
@@ -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
|
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"
|
hover:bg-blue-600/70 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
Otevrit
|
Otevřít
|
||||||
</button>
|
</button>
|
||||||
<a
|
<a
|
||||||
href={`/api/files/${file.id}/export-excel`}
|
href={`/api/files/${file.id}/export-excel`}
|
||||||
@@ -275,7 +314,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
)})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
166
web/src/FpdCheck.tsx
Normal file
166
web/src/FpdCheck.tsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import type { DayInfo, Person } from './types'
|
||||||
|
import { getCzechHolidays } from './holidays'
|
||||||
|
|
||||||
|
const MONTH_NAMES: Record<number, string> = {
|
||||||
|
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 (
|
||||||
|
<div className="fixed inset-0 z-[300] flex items-center justify-center bg-black/50" onClick={onClose}>
|
||||||
|
<div className="bg-slate-800 border border-slate-600 rounded-xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col" onClick={e => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700">
|
||||||
|
<h2 className="text-sm font-semibold text-slate-200">
|
||||||
|
Kontrola FPD — {MONTH_NAMES[month]} {year}
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="text-slate-400 hover:text-slate-200 cursor-pointer text-lg">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 py-3 border-b border-slate-700 text-xs text-slate-400">
|
||||||
|
Pracovních dnů: <span className="text-slate-200 font-semibold">{workDays}</span>
|
||||||
|
<span className="mx-2">|</span>
|
||||||
|
FPD: <span className="text-slate-200 font-semibold">{fpd} h</span>
|
||||||
|
<span className="mx-2">|</span>
|
||||||
|
Pohotovost TKB: <span className="text-slate-200 font-semibold">{results.length} osob</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-y-auto flex-1 px-5 py-3">
|
||||||
|
{under.length === 0 ? (
|
||||||
|
<div className="text-center py-4 text-green-400 text-sm font-semibold">
|
||||||
|
✓ Nikdo nemá nedostatek hodin
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="text-xs text-red-400 uppercase tracking-wider mb-2 font-semibold">Chybí hodiny ({under.length})</div>
|
||||||
|
<div className="space-y-1 mb-4">
|
||||||
|
{under.map(r => (
|
||||||
|
<div
|
||||||
|
key={r.personId}
|
||||||
|
className="flex items-center justify-between px-3 py-2 rounded text-xs bg-red-900/30 border border-red-700/50"
|
||||||
|
>
|
||||||
|
<span className="text-slate-200">
|
||||||
|
{r.name}
|
||||||
|
{r.note && <span className="text-slate-500 ml-1">({r.note})</span>}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">{r.workedHours}h / {r.fpd}h</span>
|
||||||
|
<span className="font-bold text-red-400">{r.diff}h</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wider mb-2">Ostatní ({okOrOver.length})</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{okOrOver.map(r => (
|
||||||
|
<div
|
||||||
|
key={r.personId}
|
||||||
|
className={`flex items-center justify-between px-3 py-2 rounded text-xs
|
||||||
|
${r.diff > 0 ? 'bg-slate-700/30 border border-slate-600/50' : 'bg-green-900/20 border border-green-700/30'}`}
|
||||||
|
>
|
||||||
|
<span className="text-slate-200">
|
||||||
|
{r.name}
|
||||||
|
{r.note && <span className="text-slate-500 ml-1">({r.note})</span>}
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-slate-400">{r.workedHours}h / {r.fpd}h</span>
|
||||||
|
<span className={`font-bold ${r.diff > 0 ? 'text-slate-300' : 'text-green-400'}`}>
|
||||||
|
{r.diff > 0 ? `+${r.diff}h` : '✓'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{onExportDochazka && (
|
||||||
|
<div className="px-5 py-3 border-t border-slate-700 flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={onExportDochazka}
|
||||||
|
className="px-4 py-2 rounded text-xs bg-green-700/70 text-green-100 border border-green-600
|
||||||
|
hover:bg-green-600/70 cursor-pointer transition-colors font-semibold"
|
||||||
|
>
|
||||||
|
↓ Stáhnout docházku (Excel)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -2,18 +2,33 @@ import { useState } from 'react'
|
|||||||
|
|
||||||
const VALID_USER = 'tkb'
|
const VALID_USER = 'tkb'
|
||||||
const VALID_PASS = 'sluzby'
|
const VALID_PASS = 'sluzby'
|
||||||
|
const VIEWER_USER = 'prohlizec'
|
||||||
|
const VIEWER_PASS = 'pohled'
|
||||||
const AUTH_KEY = 'tkb_auth'
|
const AUTH_KEY = 'tkb_auth'
|
||||||
|
const ROLE_KEY = 'tkb_role'
|
||||||
|
|
||||||
|
export type UserRole = 'editor' | 'viewer'
|
||||||
|
|
||||||
export function isAuthenticated(): boolean {
|
export function isAuthenticated(): boolean {
|
||||||
return sessionStorage.getItem(AUTH_KEY) === 'true'
|
return localStorage.getItem(AUTH_KEY) === 'true'
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setAuthenticated(): void {
|
export function getAuthRole(): UserRole {
|
||||||
sessionStorage.setItem(AUTH_KEY, 'true')
|
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 {
|
interface LoginProps {
|
||||||
onLogin: () => void
|
onLogin: (role: UserRole) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Login({ onLogin }: LoginProps) {
|
export function Login({ onLogin }: LoginProps) {
|
||||||
@@ -24,8 +39,11 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (username === VALID_USER && password === VALID_PASS) {
|
if (username === VALID_USER && password === VALID_PASS) {
|
||||||
setAuthenticated()
|
setAuthenticated('editor')
|
||||||
onLogin()
|
onLogin('editor')
|
||||||
|
} else if (username === VIEWER_USER && password === VIEWER_PASS) {
|
||||||
|
setAuthenticated('viewer')
|
||||||
|
onLogin('viewer')
|
||||||
} else {
|
} else {
|
||||||
setError(true)
|
setError(true)
|
||||||
setTimeout(() => setError(false), 3000)
|
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"
|
className="bg-slate-800 border border-slate-700 rounded-lg p-8 w-80 shadow-xl"
|
||||||
>
|
>
|
||||||
<h1 className="text-lg font-semibold text-slate-100 mb-1 text-center">
|
<h1 className="text-lg font-semibold text-slate-100 mb-1 text-center">
|
||||||
TKB Plan sluzeb
|
TKB Plán služeb
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-slate-400 mb-6 text-center">
|
<p className="text-sm text-slate-400 mb-6 text-center">
|
||||||
Planovani smen a pohotovosti
|
Plánování směn a pohotovostí
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label className="block text-xs text-slate-400 mb-1">Uzivatel</label>
|
<label className="block text-xs text-slate-400 mb-1">Uživatel</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
@@ -66,7 +84,7 @@ export function Login({ onLogin }: LoginProps) {
|
|||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<div className="mb-4 text-xs text-red-400 text-center">
|
<div className="mb-4 text-xs text-red-400 text-center">
|
||||||
Nespravne prihlasovaci udaje
|
Nesprávné přihlašovací údaje
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -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
|
className="w-full py-2 rounded bg-blue-600 hover:bg-blue-500 text-white text-sm
|
||||||
font-medium transition-colors cursor-pointer"
|
font-medium transition-colors cursor-pointer"
|
||||||
>
|
>
|
||||||
Prihlasit
|
Přihlásit
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function ProposalModal({ onClose }: ProposalModalProps) {
|
|||||||
>
|
>
|
||||||
<div className="bg-slate-800 border border-slate-600 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col">
|
<div className="bg-slate-800 border border-slate-600 rounded-xl shadow-2xl w-full max-w-xl max-h-[80vh] flex flex-col">
|
||||||
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700">
|
<div className="flex items-center justify-between px-5 py-3 border-b border-slate-700">
|
||||||
<h2 className="text-sm font-semibold text-slate-200">Navrhy na vylepseni</h2>
|
<h2 className="text-sm font-semibold text-slate-200">Návrhy na vylepšení</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-slate-500 hover:text-slate-300 cursor-pointer text-lg leading-none"
|
className="text-slate-500 hover:text-slate-300 cursor-pointer text-lg leading-none"
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ interface ScheduleTableProps {
|
|||||||
showMetro: boolean
|
showMetro: boolean
|
||||||
showD8: boolean
|
showD8: boolean
|
||||||
showSazlt: boolean
|
showSazlt: boolean
|
||||||
|
hide162: boolean
|
||||||
hiddenValues: Set<string>
|
hiddenValues: Set<string>
|
||||||
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number, selectedCells: SelectedCell[]) => void
|
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number, selectedCells: SelectedCell[]) => void
|
||||||
onTunnelContextMenu: (dayIdx: number, x: number, y: number) => void
|
onTunnelContextMenu: (dayIdx: number, x: number, y: number) => void
|
||||||
@@ -243,15 +244,24 @@ const PersonRow = memo(function PersonRow({
|
|||||||
const pmsDayIdx = -pmsMonth // negative month as special key
|
const pmsDayIdx = -pmsMonth // negative month as special key
|
||||||
const pmsData = person.data[String(pmsDayIdx)]
|
const pmsData = person.data[String(pmsDayIdx)]
|
||||||
const pmsValue = pmsData?.v ?? ''
|
const pmsValue = pmsData?.v ?? ''
|
||||||
|
const pmsColor = pmsData?.color
|
||||||
const isPmsEditing = editingCell?.personId === person.id && editingCell?.dayIdx === pmsDayIdx
|
const isPmsEditing = editingCell?.personId === person.id && editingCell?.dayIdx === pmsDayIdx
|
||||||
|
const pmsCommentKey = `${person.id}-${pmsDayIdx}`
|
||||||
|
const hasPmsComment = cellComments.has(pmsCommentKey)
|
||||||
|
const pmsStyle: React.CSSProperties = { width: CELL_W, height: CELL_H }
|
||||||
|
if (pmsColor) {
|
||||||
|
pmsStyle.backgroundColor = pmsColor
|
||||||
|
pmsStyle.color = getContrastColor(pmsColor)
|
||||||
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
dayCell,
|
dayCell,
|
||||||
<div
|
<div
|
||||||
key={`pms-${pmsMonth}`}
|
key={`pms-${pmsMonth}`}
|
||||||
className="flex items-center justify-center text-xs font-mono font-bold
|
className={`flex items-center justify-center text-sm font-mono font-bold relative
|
||||||
border-r border-slate-600 bg-slate-700/30 cursor-text hover:bg-slate-600/40"
|
border-r border-slate-600 cursor-text hover:brightness-125
|
||||||
style={{ width: CELL_W, height: CELL_H }}
|
${!pmsColor ? 'bg-slate-700/30' : ''}`}
|
||||||
|
style={pmsStyle}
|
||||||
onDoubleClick={() => onStartEdit(person.id, pmsDayIdx, pmsValue)}
|
onDoubleClick={() => onStartEdit(person.id, pmsDayIdx, pmsValue)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (!isPmsEditing) onStartEdit(person.id, pmsDayIdx, pmsValue)
|
if (!isPmsEditing) onStartEdit(person.id, pmsDayIdx, pmsValue)
|
||||||
@@ -260,12 +270,12 @@ const PersonRow = memo(function PersonRow({
|
|||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
onContextMenu(pmsDayIdx, person.id, e.clientX, e.clientY)
|
onContextMenu(pmsDayIdx, person.id, e.clientX, e.clientY)
|
||||||
}}
|
}}
|
||||||
title={`PMS ${MONTH_NAMES_SHORT[pmsMonth] ?? pmsMonth}: ${pmsValue || '—'}`}
|
title={`PMS ${MONTH_NAMES_SHORT[pmsMonth] ?? pmsMonth}: ${pmsValue || '—'}${hasPmsComment ? `\n💬 ${cellComments.get(pmsCommentKey)}` : ''}`}
|
||||||
>
|
>
|
||||||
{isPmsEditing ? (
|
{isPmsEditing ? (
|
||||||
<input
|
<input
|
||||||
autoFocus
|
autoFocus
|
||||||
className="w-full h-full bg-white text-slate-900 text-center text-xs font-mono font-bold outline-none border-2 border-purple-500"
|
className="w-full h-full bg-white text-slate-900 text-center text-sm font-mono font-bold outline-none border-2 border-purple-500"
|
||||||
style={{ width: CELL_W, height: CELL_H }}
|
style={{ width: CELL_W, height: CELL_H }}
|
||||||
value={editingCell!.value}
|
value={editingCell!.value}
|
||||||
onChange={(e) => onEditChange(e.target.value)}
|
onChange={(e) => onEditChange(e.target.value)}
|
||||||
@@ -278,7 +288,12 @@ const PersonRow = memo(function PersonRow({
|
|||||||
onPointerDown={(e) => e.stopPropagation()}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="truncate text-slate-300">{pmsValue}</span>
|
<span className="truncate font-medium">{pmsValue}</span>
|
||||||
|
)}
|
||||||
|
{hasPmsComment && !isPmsEditing && (
|
||||||
|
<span className="absolute top-0 right-0 w-0 h-0 pointer-events-none"
|
||||||
|
style={{ borderLeft: '5px solid transparent', borderTop: '5px solid #3b82f6' }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>,
|
</div>,
|
||||||
]
|
]
|
||||||
@@ -296,7 +311,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
dayComments, cellComments,
|
dayComments, cellComments,
|
||||||
dragState, onCellPointerDown, onSetCell, onSetTunnelClosure,
|
dragState, onCellPointerDown, onSetCell, onSetTunnelClosure,
|
||||||
onSetMetroClosure, onSetD8Closure, onSetSazltClosure,
|
onSetMetroClosure, onSetD8Closure, onSetSazltClosure,
|
||||||
showMetro, showD8, showSazlt, hiddenValues,
|
showMetro, showD8, showSazlt, hide162, hiddenValues,
|
||||||
scrollRef, onContextMenu, onTunnelContextMenu, onInfoRowContextMenu, compareData,
|
scrollRef, onContextMenu, onTunnelContextMenu, onInfoRowContextMenu, compareData,
|
||||||
} = props
|
} = props
|
||||||
|
|
||||||
@@ -355,7 +370,11 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
return combined
|
return combined
|
||||||
}, [dayIndex])
|
}, [dayIndex])
|
||||||
|
|
||||||
const tkbPeople = useMemo(() => people.filter(p => p.group === 'TKB'), [people])
|
const HIDE_162_NAMES = ['Pauzer Libor', 'Vörös Pavel', 'Janouš Petr', 'Franek Lukáš', 'Svoboda Daniel']
|
||||||
|
const tkbPeople = useMemo(() => {
|
||||||
|
const all = people.filter(p => p.group === 'TKB')
|
||||||
|
return hide162 ? all.filter(p => !HIDE_162_NAMES.includes(p.name)) : all
|
||||||
|
}, [people, hide162])
|
||||||
const itPeople = useMemo(() => people.filter(p => p.group === 'IT'), [people])
|
const itPeople = useMemo(() => people.filter(p => p.group === 'IT'), [people])
|
||||||
|
|
||||||
// ---------- PMS (Práce mimo směnu) month-end positions ----------
|
// ---------- PMS (Práce mimo směnu) month-end positions ----------
|
||||||
@@ -796,10 +815,10 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
|||||||
<div className="flex-shrink-0 z-20 bg-slate-800 border-r border-slate-600" style={{ width: nameColW }}>
|
<div className="flex-shrink-0 z-20 bg-slate-800 border-r border-slate-600" style={{ width: nameColW }}>
|
||||||
{/* Header labels */}
|
{/* Header labels */}
|
||||||
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 28 }}>
|
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 28 }}>
|
||||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Mesic</span>
|
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Měsíc</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 24 }}>
|
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 24 }}>
|
||||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Tyden</span>
|
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Týden</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 28 }}>
|
<div className="bg-slate-800 border-b border-slate-700 flex items-center px-3" style={{ height: 28 }}>
|
||||||
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den</span>
|
<span className="text-[10px] uppercase tracking-wider text-slate-500 font-semibold">Den</span>
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { getAllCellStyles } from './cellColors'
|
|||||||
const MONTH_BUTTONS = [
|
const MONTH_BUTTONS = [
|
||||||
{ month: 1, label: 'Led' },
|
{ month: 1, label: 'Led' },
|
||||||
{ month: 2, label: 'Uno' },
|
{ month: 2, label: 'Uno' },
|
||||||
{ month: 3, label: 'Bre' },
|
{ month: 3, label: 'Bře' },
|
||||||
{ month: 4, label: 'Dub' },
|
{ month: 4, label: 'Dub' },
|
||||||
{ month: 5, label: 'Kve' },
|
{ month: 5, label: 'Kvě' },
|
||||||
{ month: 6, label: 'Cvn' },
|
{ month: 6, label: 'Čvn' },
|
||||||
{ month: 7, label: 'Cvc' },
|
{ month: 7, label: 'Cvc' },
|
||||||
{ month: 8, label: 'Srp' },
|
{ month: 8, label: 'Srp' },
|
||||||
{ month: 9, label: 'Zar' },
|
{ month: 9, label: 'Zář' },
|
||||||
{ month: 10, label: 'Rij' },
|
{ month: 10, label: 'Říj' },
|
||||||
{ month: 11, label: 'Lis' },
|
{ month: 11, label: 'Lis' },
|
||||||
{ month: 12, label: 'Pro' },
|
{ month: 12, label: 'Pro' },
|
||||||
]
|
]
|
||||||
@@ -26,15 +26,19 @@ interface ToolbarProps {
|
|||||||
showMetro: boolean
|
showMetro: boolean
|
||||||
showD8: boolean
|
showD8: boolean
|
||||||
showSazlt: boolean
|
showSazlt: boolean
|
||||||
|
hide162: boolean
|
||||||
onToggleMetro: () => void
|
onToggleMetro: () => void
|
||||||
onToggleD8: () => void
|
onToggleD8: () => void
|
||||||
onToggleSazlt: () => void
|
onToggleSazlt: () => void
|
||||||
|
onToggle162: () => void
|
||||||
hiddenValues: Set<string>
|
hiddenValues: Set<string>
|
||||||
onToggleValue: (code: string) => void
|
onToggleValue: (code: string) => void
|
||||||
diffFileName?: string | null
|
diffFileName?: string | null
|
||||||
onCloseDiff?: () => void
|
onCloseDiff?: () => void
|
||||||
onExportPdf?: (month: number) => void
|
onExportPdf?: (month: number) => void
|
||||||
onShowProposals?: () => void
|
onShowProposals?: () => void
|
||||||
|
onCheckFpd?: () => void
|
||||||
|
isReadOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Toolbar({
|
export function Toolbar({
|
||||||
@@ -48,15 +52,19 @@ export function Toolbar({
|
|||||||
showMetro,
|
showMetro,
|
||||||
showD8,
|
showD8,
|
||||||
showSazlt,
|
showSazlt,
|
||||||
|
hide162,
|
||||||
onToggleMetro,
|
onToggleMetro,
|
||||||
onToggleD8,
|
onToggleD8,
|
||||||
onToggleSazlt,
|
onToggleSazlt,
|
||||||
|
onToggle162,
|
||||||
hiddenValues,
|
hiddenValues,
|
||||||
onToggleValue,
|
onToggleValue,
|
||||||
diffFileName,
|
diffFileName,
|
||||||
onCloseDiff,
|
onCloseDiff,
|
||||||
onExportPdf,
|
onExportPdf,
|
||||||
onShowProposals,
|
onShowProposals,
|
||||||
|
onCheckFpd,
|
||||||
|
isReadOnly = false,
|
||||||
}: ToolbarProps) {
|
}: ToolbarProps) {
|
||||||
const cellStyles = getAllCellStyles()
|
const cellStyles = getAllCellStyles()
|
||||||
|
|
||||||
@@ -101,13 +109,15 @@ export function Toolbar({
|
|||||||
<div className="h-5 w-px bg-slate-700" />
|
<div className="h-5 w-px bg-slate-700" />
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
|
{!isReadOnly && (
|
||||||
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={onUndo}
|
onClick={onUndo}
|
||||||
disabled={!canUndo}
|
disabled={!canUndo}
|
||||||
className="px-3 py-1 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
|
className="px-3 py-1 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
|
||||||
hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
Zpet
|
Zpět
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -123,10 +133,10 @@ export function Toolbar({
|
|||||||
: 'bg-blue-700/70 text-blue-100 border-blue-600 hover:bg-blue-600/70'
|
: 'bg-blue-700/70 text-blue-100 border-blue-600 hover:bg-blue-600/70'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{saveStatus === 'saving' ? 'Ukladam...'
|
{saveStatus === 'saving' ? 'Ukládám...'
|
||||||
: saveStatus === 'saved' ? 'Ulozeno'
|
: saveStatus === 'saved' ? 'Uloženo'
|
||||||
: saveStatus === 'error' ? 'Chyba!'
|
: saveStatus === 'error' ? 'Chyba!'
|
||||||
: 'Ulozit'}
|
: 'Uložit'}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -134,8 +144,10 @@ export function Toolbar({
|
|||||||
className="px-3 py-1 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
|
className="px-3 py-1 rounded text-xs bg-slate-800 text-slate-300 border border-slate-700
|
||||||
hover:bg-slate-700 cursor-pointer transition-colors"
|
hover:bg-slate-700 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
Ulozit jako
|
Uložit jako
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{onExportPdf && activeMonth && (
|
{onExportPdf && activeMonth && (
|
||||||
<button
|
<button
|
||||||
@@ -153,7 +165,17 @@ export function Toolbar({
|
|||||||
className="px-3 py-1 rounded text-xs bg-amber-700/70 text-amber-100 border border-amber-600
|
className="px-3 py-1 rounded text-xs bg-amber-700/70 text-amber-100 border border-amber-600
|
||||||
hover:bg-amber-600/70 cursor-pointer transition-colors"
|
hover:bg-amber-600/70 cursor-pointer transition-colors"
|
||||||
>
|
>
|
||||||
Navrhy
|
Návrhy
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{onCheckFpd && (
|
||||||
|
<button
|
||||||
|
onClick={onCheckFpd}
|
||||||
|
className="px-3 py-1 rounded text-xs bg-cyan-700/70 text-cyan-100 border border-cyan-600
|
||||||
|
hover:bg-cyan-600/70 cursor-pointer transition-colors"
|
||||||
|
>
|
||||||
|
FPD
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -191,6 +213,17 @@ export function Toolbar({
|
|||||||
>
|
>
|
||||||
SAZLT
|
SAZLT
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onToggle162}
|
||||||
|
title="Skrýt prvních 5 osob TKB (Pauzer, Vörös, Janouš, Franek, Svoboda)"
|
||||||
|
className={`px-2 py-1 rounded text-[10px] border cursor-pointer transition-colors
|
||||||
|
${hide162
|
||||||
|
? 'bg-slate-800 text-slate-500 border-slate-700 opacity-50'
|
||||||
|
: 'bg-purple-800/60 text-purple-200 border-purple-600'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
162
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="h-5 w-px bg-slate-700" />
|
<div className="h-5 w-px bg-slate-700" />
|
||||||
|
|||||||
@@ -3005,27 +3005,6 @@
|
|||||||
"group": "TKB",
|
"group": "TKB",
|
||||||
"data": {}
|
"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",
|
"id": "tkb-zabransky",
|
||||||
"name": "Zábranský Petr",
|
"name": "Zábranský Petr",
|
||||||
@@ -3070,12 +3049,6 @@
|
|||||||
"name": "Robert Štefan",
|
"name": "Robert Štefan",
|
||||||
"group": "IT",
|
"group": "IT",
|
||||||
"data": {}
|
"data": {}
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "it-franek",
|
|
||||||
"name": "Franek Lukáš",
|
|
||||||
"group": "IT",
|
|
||||||
"data": {}
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"tunnelClosures": [],
|
"tunnelClosures": [],
|
||||||
|
|||||||
Reference in New Issue
Block a user