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:
Docker Config Backup
2026-04-13 14:39:04 +02:00
parent 3cd05ce0e2
commit db56403f7c
13 changed files with 1079 additions and 155 deletions

BIN
web/dochazka_template.xlsx Normal file

Binary file not shown.

505
web/export_dochazka.py Normal file
View 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 819 ──
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))

View File

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

View File

@@ -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) => {

View File

@@ -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"
> >
&larr; Zpet &larr; 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>
) )
} }

View File

@@ -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 }}
> >

View File

@@ -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
View 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">&times;</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>
)
}

View File

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

View File

@@ -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"

View File

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

View File

@@ -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" />

View File

@@ -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": [],