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" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TKB Plan sluzeb</title>
|
||||
<title>Tunely-DEV</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -9,7 +9,8 @@ const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = dirname(__filename)
|
||||
|
||||
const app = express()
|
||||
const PORT = 3080
|
||||
const PORT = process.env.PORT || 3090
|
||||
const SERVER_VERSION = Date.now().toString()
|
||||
|
||||
const SCHEDULES_DIR = join(__dirname, 'schedules')
|
||||
const SAVED_SCHEDULE_PATH = join(__dirname, 'saved_schedule.json')
|
||||
@@ -198,6 +199,55 @@ app.get('/api/files/:id/export-excel', (_req, res) => {
|
||||
res.status(501).json({ error: 'Excel export not yet implemented for TKB format' })
|
||||
})
|
||||
|
||||
// Export docházka Excel for a given month
|
||||
app.get('/api/files/:id/export-dochazka', async (req, res) => {
|
||||
const { id } = req.params
|
||||
const month = parseInt(req.query.month) || (new Date().getMonth() + 1)
|
||||
const year = parseInt(req.query.year) || new Date().getFullYear()
|
||||
|
||||
const filePath = join(SCHEDULES_DIR, `${id}.json`)
|
||||
if (!existsSync(filePath)) return res.status(404).json({ error: 'File not found' })
|
||||
|
||||
let fileData
|
||||
try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) }
|
||||
catch { return res.status(500).json({ error: 'Failed to read file' }) }
|
||||
|
||||
const input = JSON.stringify({ schedule: fileData.data, month, year })
|
||||
const scriptPath = join(__dirname, 'export_dochazka.py')
|
||||
|
||||
try {
|
||||
const { spawn } = await import('child_process')
|
||||
await new Promise((resolve, reject) => {
|
||||
const chunks = []
|
||||
const errChunks = []
|
||||
const proc = spawn('python3', [scriptPath])
|
||||
proc.stdout.on('data', d => chunks.push(d))
|
||||
proc.stderr.on('data', d => errChunks.push(d))
|
||||
proc.on('close', code => {
|
||||
if (code !== 0) {
|
||||
const errMsg = Buffer.concat(errChunks).toString()
|
||||
console.error('export_dochazka error:', errMsg)
|
||||
reject(new Error(errMsg))
|
||||
} else {
|
||||
const xlsx = Buffer.concat(chunks)
|
||||
const MONTH_NAMES = ['','Leden','Únor','Březen','Duben','Květen','Červen','Červenec','Srpen','Září','Říjen','Listopad','Prosinec']
|
||||
const mm = String(month).padStart(2, '0')
|
||||
const fname = `OP2416101755_TKB_${year}_${mm}_${MONTH_NAMES[month]}.xlsx`
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
|
||||
res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fname)}`)
|
||||
res.send(xlsx)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
proc.on('error', reject)
|
||||
proc.stdin.write(input)
|
||||
proc.stdin.end()
|
||||
})
|
||||
} catch (err) {
|
||||
if (!res.headersSent) res.status(500).json({ error: 'Export failed', details: err.message })
|
||||
}
|
||||
})
|
||||
|
||||
// Create new file
|
||||
app.post('/api/files', (req, res) => {
|
||||
try {
|
||||
@@ -372,6 +422,11 @@ app.get('/api/export-excel', (_req, res) => {
|
||||
res.status(501).json({ error: 'Excel export not yet implemented for TKB format' })
|
||||
})
|
||||
|
||||
// Version endpoint — clients poll this and reload when version changes
|
||||
app.get('/api/version', (_req, res) => {
|
||||
res.json({ version: SERVER_VERSION })
|
||||
})
|
||||
|
||||
// Serve static files from dist/
|
||||
app.use(express.static(join(__dirname, 'dist'), {
|
||||
setHeaders: (res) => {
|
||||
|
||||
165
web/src/App.tsx
165
web/src/App.tsx
@@ -4,13 +4,26 @@ import type { SelectedCell } from './ScheduleTable'
|
||||
import { Toolbar } from './Toolbar'
|
||||
import { ContextMenu } from './ContextMenu'
|
||||
import { ProposalModal } from './ProposalModal'
|
||||
import { FpdModal } from './FpdCheck'
|
||||
import { useScheduleState } from './useScheduleState'
|
||||
import { useDragCell } from './useDragCell'
|
||||
import { Login, isAuthenticated } from './Login'
|
||||
import { Login, isAuthenticated, getAuthRole, clearAuthentication } from './Login'
|
||||
import type { UserRole } from './Login'
|
||||
import { FileManager } from './FileManager'
|
||||
import fallbackData from './data.json'
|
||||
import type { ScheduleData, ScheduleFileWithData, ContextMenuState } from './types'
|
||||
|
||||
const FAVOURITE_FILE_KEY = 'tkb_favourite_file'
|
||||
|
||||
export function getFavouriteFileId(): string | null {
|
||||
return localStorage.getItem(FAVOURITE_FILE_KEY)
|
||||
}
|
||||
|
||||
export function setFavouriteFileId(id: string | null): void {
|
||||
if (id) localStorage.setItem(FAVOURITE_FILE_KEY, id)
|
||||
else localStorage.removeItem(FAVOURITE_FILE_KEY)
|
||||
}
|
||||
|
||||
function normalizeDayIndex(data: ScheduleData): ScheduleData {
|
||||
const targetStart = new Date(2026, 0, 1) // January 1
|
||||
const targetEnd = new Date(2026, 11, 31) // December 31
|
||||
@@ -82,22 +95,34 @@ function getISOWeek(date: Date): number {
|
||||
type AppMode = 'login' | 'files' | 'editor'
|
||||
|
||||
function App() {
|
||||
// Auto-reload when server restarts with a new version (new deploy)
|
||||
useEffect(() => {
|
||||
let serverVersion: string | null = null
|
||||
const check = async () => {
|
||||
try {
|
||||
const res = await fetch('/api/version')
|
||||
if (!res.ok) return
|
||||
const { version } = await res.json()
|
||||
if (serverVersion === null) { serverVersion = version; return }
|
||||
if (version !== serverVersion) window.location.reload()
|
||||
} catch {}
|
||||
}
|
||||
check()
|
||||
const interval = setInterval(check, 60_000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const [authed, setAuthed] = useState(isAuthenticated())
|
||||
const [mode, setMode] = useState<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 [fileName, setFileName] = useState<string>('')
|
||||
const [fileData, setFileData] = useState<ScheduleData | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loading, setLoading] = useState(hasFavOnLoad)
|
||||
const [compareFileId, setCompareFileId] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (authed && mode === 'login') setMode('files')
|
||||
}, [authed, mode])
|
||||
|
||||
const handleLogin = useCallback(() => {
|
||||
setAuthed(true)
|
||||
setMode('files')
|
||||
}, [])
|
||||
const autoOpenAttempted = useRef(false)
|
||||
|
||||
const handleOpenFile = useCallback(async (id: string, compareId?: string) => {
|
||||
setLoading(true)
|
||||
@@ -112,11 +137,38 @@ function App() {
|
||||
setMode('editor')
|
||||
} catch (err) {
|
||||
alert(`Chyba pri otevirani souboru: ${err}`)
|
||||
setMode('files')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Auto-open favourite file when authenticated
|
||||
useEffect(() => {
|
||||
if (!authed || autoOpenAttempted.current) return
|
||||
autoOpenAttempted.current = true
|
||||
const fav = getFavouriteFileId()
|
||||
if (fav) handleOpenFile(fav)
|
||||
}, [authed, handleOpenFile])
|
||||
|
||||
const handleLogin = useCallback((newRole: UserRole) => {
|
||||
autoOpenAttempted.current = false
|
||||
setRole(newRole)
|
||||
setAuthed(true)
|
||||
}, [])
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
clearAuthentication()
|
||||
setAuthed(false)
|
||||
setRole('editor')
|
||||
setMode('login')
|
||||
setFileId(null)
|
||||
setFileName('')
|
||||
setFileData(null)
|
||||
setCompareFileId(null)
|
||||
autoOpenAttempted.current = false
|
||||
}, [])
|
||||
|
||||
const handleCompare = useCallback((id1: string, id2: string) => {
|
||||
handleOpenFile(id1, id2)
|
||||
}, [handleOpenFile])
|
||||
@@ -162,7 +214,17 @@ function App() {
|
||||
}
|
||||
|
||||
if (mode === 'files') {
|
||||
return <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) {
|
||||
@@ -172,9 +234,11 @@ function App() {
|
||||
fileName={fileName}
|
||||
data={fileData}
|
||||
compareFileId={compareFileId}
|
||||
onBack={handleBackToFiles}
|
||||
onBack={isViewer ? undefined : handleBackToFiles}
|
||||
isReadOnly={isViewer}
|
||||
onFileNameChange={setFileName}
|
||||
onClearCompare={() => setCompareFileId(null)}
|
||||
onLogout={handleLogout}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -223,12 +287,14 @@ interface ScheduleAppProps {
|
||||
fileName: string
|
||||
data: ScheduleData
|
||||
compareFileId: string | null
|
||||
onBack: () => void
|
||||
onBack?: () => void
|
||||
onFileNameChange: (name: string) => void
|
||||
onClearCompare: () => void
|
||||
isReadOnly?: boolean
|
||||
onLogout?: () => void
|
||||
}
|
||||
|
||||
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare }: ScheduleAppProps) {
|
||||
function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, onLogout }: ScheduleAppProps) {
|
||||
const {
|
||||
people, tunnelClosures, tunnelColors,
|
||||
metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors,
|
||||
@@ -406,9 +472,11 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
||||
}, [getSchedulePayload, fileName, onFileNameChange])
|
||||
|
||||
const [showProposals, setShowProposals] = useState(false)
|
||||
const [showFpd, setShowFpd] = useState(false)
|
||||
const [showMetro, setShowMetro] = useState(true)
|
||||
const [showD8, setShowD8] = useState(true)
|
||||
const [showSazlt, setShowSazlt] = useState(true)
|
||||
const [hide162, setHide162] = useState(false)
|
||||
const [hiddenValues, setHiddenValues] = useState<Set<string>>(new Set())
|
||||
|
||||
const handleExportPdf = useCallback(async (month: number) => {
|
||||
@@ -457,18 +525,26 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-4">
|
||||
{onBack && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
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"
|
||||
>
|
||||
← Zpet
|
||||
← Zpět
|
||||
</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">
|
||||
{fileName || 'TKB Plan sluzeb'}
|
||||
{fileName || 'TKB Plán služeb'}
|
||||
<span className="ml-3 text-sm font-normal text-slate-400">
|
||||
Plan sluzeb a pohotovosti
|
||||
Plán služeb a pohotovostí
|
||||
</span>
|
||||
</h1>
|
||||
{compareFileName && (
|
||||
@@ -477,6 +553,16 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
||||
</span>
|
||||
)}
|
||||
</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>
|
||||
<main className="p-4">
|
||||
<Toolbar
|
||||
@@ -493,6 +579,8 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
||||
onToggleD8={() => setShowD8(v => !v)}
|
||||
showSazlt={showSazlt}
|
||||
onToggleSazlt={() => setShowSazlt(v => !v)}
|
||||
hide162={hide162}
|
||||
onToggle162={() => setHide162(v => !v)}
|
||||
hiddenValues={hiddenValues}
|
||||
onToggleValue={(code: string) => setHiddenValues(prev => {
|
||||
const next = new Set(prev)
|
||||
@@ -502,7 +590,9 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
||||
diffFileName={compareFileName}
|
||||
onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }}
|
||||
onExportPdf={handleExportPdf}
|
||||
onShowProposals={() => setShowProposals(true)}
|
||||
onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)}
|
||||
onCheckFpd={() => setShowFpd(true)}
|
||||
isReadOnly={isReadOnly}
|
||||
/>
|
||||
<ScheduleTable
|
||||
dayIndex={data.dayIndex}
|
||||
@@ -517,21 +607,22 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
||||
sazltColors={sazltColors}
|
||||
dayComments={dayComments}
|
||||
cellComments={cellComments}
|
||||
dragState={dragState}
|
||||
onCellPointerDown={onCellPointerDown}
|
||||
onSetCell={setCell}
|
||||
onSetTunnelClosure={setTunnelClosure}
|
||||
onSetMetroClosure={setMetroClosure}
|
||||
onSetD8Closure={setD8Closure}
|
||||
onSetSazltClosure={setSazltClosure}
|
||||
dragState={isReadOnly ? null : dragState}
|
||||
onCellPointerDown={isReadOnly ? () => {} : onCellPointerDown}
|
||||
onSetCell={isReadOnly ? () => {} : setCell}
|
||||
onSetTunnelClosure={isReadOnly ? () => {} : setTunnelClosure}
|
||||
onSetMetroClosure={isReadOnly ? () => {} : setMetroClosure}
|
||||
onSetD8Closure={isReadOnly ? () => {} : setD8Closure}
|
||||
onSetSazltClosure={isReadOnly ? () => {} : setSazltClosure}
|
||||
showMetro={showMetro}
|
||||
showD8={showD8}
|
||||
showSazlt={showSazlt}
|
||||
hide162={hide162}
|
||||
hiddenValues={hiddenValues}
|
||||
scrollRef={scrollRef}
|
||||
onContextMenu={handleContextMenu}
|
||||
onTunnelContextMenu={handleTunnelContextMenu}
|
||||
onInfoRowContextMenu={handleInfoRowContextMenu}
|
||||
onContextMenu={isReadOnly ? () => {} : handleContextMenu}
|
||||
onTunnelContextMenu={isReadOnly ? () => {} : handleTunnelContextMenu}
|
||||
onInfoRowContextMenu={isReadOnly ? () => {} : handleInfoRowContextMenu}
|
||||
compareData={compareData}
|
||||
/>
|
||||
</main>
|
||||
@@ -567,6 +658,20 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName
|
||||
{showProposals && (
|
||||
<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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -192,7 +192,18 @@ export function ContextMenu({
|
||||
|
||||
return (
|
||||
<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]"
|
||||
style={{ left: state.x, top: state.y }}
|
||||
>
|
||||
|
||||
@@ -1,21 +1,30 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import type { ScheduleFile } from './types'
|
||||
import { getFavouriteFileId, setFavouriteFileId } from './App'
|
||||
|
||||
interface FileManagerProps {
|
||||
onOpenFile: (fileId: string) => void
|
||||
onCompare: (fileId1: string, fileId2: string) => void
|
||||
onCreateNew?: () => void
|
||||
onLogout?: () => void
|
||||
}
|
||||
|
||||
export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerProps) {
|
||||
export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: FileManagerProps) {
|
||||
const [files, setFiles] = useState<ScheduleFile[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [deleting, setDeleting] = useState<string | null>(null)
|
||||
const [favouriteId, setFavouriteId] = useState<string | null>(getFavouriteFileId)
|
||||
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 () => {
|
||||
try {
|
||||
const res = await fetch('/api/files')
|
||||
@@ -127,12 +136,23 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
||||
return (
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<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">
|
||||
Sprava souboru planu sluzeb
|
||||
Správa souborů plánu služeb
|
||||
</span>
|
||||
</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>
|
||||
|
||||
<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
|
||||
hover:bg-blue-600/70 cursor-pointer transition-colors"
|
||||
>
|
||||
Novy soubor
|
||||
Nový soubor
|
||||
</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
|
||||
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>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
@@ -189,7 +209,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
||||
{/* Files table */}
|
||||
{files.length === 0 ? (
|
||||
<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">
|
||||
{onCreateNew && (
|
||||
<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
|
||||
hover:bg-blue-600/70 cursor-pointer transition-colors"
|
||||
>
|
||||
Vytvorit novy soubor
|
||||
Vytvořit nový soubor
|
||||
</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
|
||||
hover:bg-green-600/70 cursor-pointer transition-colors"
|
||||
>
|
||||
Nahrat Excel
|
||||
Nahrát Excel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -215,18 +235,22 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
||||
<thead>
|
||||
<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">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">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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map(file => (
|
||||
{files.map(file => {
|
||||
const isFav = favouriteId === file.id
|
||||
return (
|
||||
<tr
|
||||
key={file.id}
|
||||
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">
|
||||
<input
|
||||
@@ -237,8 +261,23 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
||||
focus:ring-blue-500 focus:ring-offset-0 cursor-pointer"
|
||||
/>
|
||||
</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">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 className="px-4 py-3">
|
||||
<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
|
||||
hover:bg-blue-600/70 cursor-pointer transition-colors"
|
||||
>
|
||||
Otevrit
|
||||
Otevřít
|
||||
</button>
|
||||
<a
|
||||
href={`/api/files/${file.id}/export-excel`}
|
||||
@@ -275,7 +314,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
)})}
|
||||
</tbody>
|
||||
</table>
|
||||
</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_PASS = 'sluzby'
|
||||
const VIEWER_USER = 'prohlizec'
|
||||
const VIEWER_PASS = 'pohled'
|
||||
const AUTH_KEY = 'tkb_auth'
|
||||
const ROLE_KEY = 'tkb_role'
|
||||
|
||||
export type UserRole = 'editor' | 'viewer'
|
||||
|
||||
export function isAuthenticated(): boolean {
|
||||
return sessionStorage.getItem(AUTH_KEY) === 'true'
|
||||
return localStorage.getItem(AUTH_KEY) === 'true'
|
||||
}
|
||||
|
||||
export function setAuthenticated(): void {
|
||||
sessionStorage.setItem(AUTH_KEY, 'true')
|
||||
export function getAuthRole(): UserRole {
|
||||
return localStorage.getItem(ROLE_KEY) === 'viewer' ? 'viewer' : 'editor'
|
||||
}
|
||||
|
||||
function setAuthenticated(role: UserRole): void {
|
||||
localStorage.setItem(AUTH_KEY, 'true')
|
||||
localStorage.setItem(ROLE_KEY, role)
|
||||
}
|
||||
|
||||
export function clearAuthentication(): void {
|
||||
localStorage.removeItem(AUTH_KEY)
|
||||
localStorage.removeItem(ROLE_KEY)
|
||||
}
|
||||
|
||||
interface LoginProps {
|
||||
onLogin: () => void
|
||||
onLogin: (role: UserRole) => void
|
||||
}
|
||||
|
||||
export function Login({ onLogin }: LoginProps) {
|
||||
@@ -24,8 +39,11 @@ export function Login({ onLogin }: LoginProps) {
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (username === VALID_USER && password === VALID_PASS) {
|
||||
setAuthenticated()
|
||||
onLogin()
|
||||
setAuthenticated('editor')
|
||||
onLogin('editor')
|
||||
} else if (username === VIEWER_USER && password === VIEWER_PASS) {
|
||||
setAuthenticated('viewer')
|
||||
onLogin('viewer')
|
||||
} else {
|
||||
setError(true)
|
||||
setTimeout(() => setError(false), 3000)
|
||||
@@ -39,13 +57,13 @@ export function Login({ onLogin }: LoginProps) {
|
||||
className="bg-slate-800 border border-slate-700 rounded-lg p-8 w-80 shadow-xl"
|
||||
>
|
||||
<h1 className="text-lg font-semibold text-slate-100 mb-1 text-center">
|
||||
TKB Plan sluzeb
|
||||
TKB Plán služeb
|
||||
</h1>
|
||||
<p className="text-sm text-slate-400 mb-6 text-center">
|
||||
Planovani smen a pohotovosti
|
||||
Plánování směn a pohotovostí
|
||||
</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
|
||||
type="text"
|
||||
value={username}
|
||||
@@ -66,7 +84,7 @@ export function Login({ onLogin }: LoginProps) {
|
||||
|
||||
{error && (
|
||||
<div className="mb-4 text-xs text-red-400 text-center">
|
||||
Nespravne prihlasovaci udaje
|
||||
Nesprávné přihlašovací údaje
|
||||
</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
|
||||
font-medium transition-colors cursor-pointer"
|
||||
>
|
||||
Prihlasit
|
||||
Přihlásit
|
||||
</button>
|
||||
</form>
|
||||
</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="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
|
||||
onClick={onClose}
|
||||
className="text-slate-500 hover:text-slate-300 cursor-pointer text-lg leading-none"
|
||||
|
||||
@@ -47,6 +47,7 @@ interface ScheduleTableProps {
|
||||
showMetro: boolean
|
||||
showD8: boolean
|
||||
showSazlt: boolean
|
||||
hide162: boolean
|
||||
hiddenValues: Set<string>
|
||||
onContextMenu: (dayIdx: number, personId: string | null, x: number, y: number, selectedCells: SelectedCell[]) => 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 pmsData = person.data[String(pmsDayIdx)]
|
||||
const pmsValue = pmsData?.v ?? ''
|
||||
const pmsColor = pmsData?.color
|
||||
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 [
|
||||
dayCell,
|
||||
<div
|
||||
key={`pms-${pmsMonth}`}
|
||||
className="flex items-center justify-center text-xs font-mono font-bold
|
||||
border-r border-slate-600 bg-slate-700/30 cursor-text hover:bg-slate-600/40"
|
||||
style={{ width: CELL_W, height: CELL_H }}
|
||||
className={`flex items-center justify-center text-sm font-mono font-bold relative
|
||||
border-r border-slate-600 cursor-text hover:brightness-125
|
||||
${!pmsColor ? 'bg-slate-700/30' : ''}`}
|
||||
style={pmsStyle}
|
||||
onDoubleClick={() => onStartEdit(person.id, pmsDayIdx, pmsValue)}
|
||||
onClick={() => {
|
||||
if (!isPmsEditing) onStartEdit(person.id, pmsDayIdx, pmsValue)
|
||||
@@ -260,12 +270,12 @@ const PersonRow = memo(function PersonRow({
|
||||
e.preventDefault()
|
||||
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 ? (
|
||||
<input
|
||||
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 }}
|
||||
value={editingCell!.value}
|
||||
onChange={(e) => onEditChange(e.target.value)}
|
||||
@@ -278,7 +288,12 @@ const PersonRow = memo(function PersonRow({
|
||||
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>,
|
||||
]
|
||||
@@ -296,7 +311,7 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
||||
dayComments, cellComments,
|
||||
dragState, onCellPointerDown, onSetCell, onSetTunnelClosure,
|
||||
onSetMetroClosure, onSetD8Closure, onSetSazltClosure,
|
||||
showMetro, showD8, showSazlt, hiddenValues,
|
||||
showMetro, showD8, showSazlt, hide162, hiddenValues,
|
||||
scrollRef, onContextMenu, onTunnelContextMenu, onInfoRowContextMenu, compareData,
|
||||
} = props
|
||||
|
||||
@@ -355,7 +370,11 @@ export function ScheduleTable(props: ScheduleTableProps) {
|
||||
return combined
|
||||
}, [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])
|
||||
|
||||
// ---------- 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 }}>
|
||||
{/* Header labels */}
|
||||
<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 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 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>
|
||||
|
||||
@@ -3,14 +3,14 @@ import { getAllCellStyles } from './cellColors'
|
||||
const MONTH_BUTTONS = [
|
||||
{ month: 1, label: 'Led' },
|
||||
{ month: 2, label: 'Uno' },
|
||||
{ month: 3, label: 'Bre' },
|
||||
{ month: 3, label: 'Bře' },
|
||||
{ month: 4, label: 'Dub' },
|
||||
{ month: 5, label: 'Kve' },
|
||||
{ month: 6, label: 'Cvn' },
|
||||
{ month: 5, label: 'Kvě' },
|
||||
{ month: 6, label: 'Čvn' },
|
||||
{ month: 7, label: 'Cvc' },
|
||||
{ month: 8, label: 'Srp' },
|
||||
{ month: 9, label: 'Zar' },
|
||||
{ month: 10, label: 'Rij' },
|
||||
{ month: 9, label: 'Zář' },
|
||||
{ month: 10, label: 'Říj' },
|
||||
{ month: 11, label: 'Lis' },
|
||||
{ month: 12, label: 'Pro' },
|
||||
]
|
||||
@@ -26,15 +26,19 @@ interface ToolbarProps {
|
||||
showMetro: boolean
|
||||
showD8: boolean
|
||||
showSazlt: boolean
|
||||
hide162: boolean
|
||||
onToggleMetro: () => void
|
||||
onToggleD8: () => void
|
||||
onToggleSazlt: () => void
|
||||
onToggle162: () => void
|
||||
hiddenValues: Set<string>
|
||||
onToggleValue: (code: string) => void
|
||||
diffFileName?: string | null
|
||||
onCloseDiff?: () => void
|
||||
onExportPdf?: (month: number) => void
|
||||
onShowProposals?: () => void
|
||||
onCheckFpd?: () => void
|
||||
isReadOnly?: boolean
|
||||
}
|
||||
|
||||
export function Toolbar({
|
||||
@@ -48,15 +52,19 @@ export function Toolbar({
|
||||
showMetro,
|
||||
showD8,
|
||||
showSazlt,
|
||||
hide162,
|
||||
onToggleMetro,
|
||||
onToggleD8,
|
||||
onToggleSazlt,
|
||||
onToggle162,
|
||||
hiddenValues,
|
||||
onToggleValue,
|
||||
diffFileName,
|
||||
onCloseDiff,
|
||||
onExportPdf,
|
||||
onShowProposals,
|
||||
onCheckFpd,
|
||||
isReadOnly = false,
|
||||
}: ToolbarProps) {
|
||||
const cellStyles = getAllCellStyles()
|
||||
|
||||
@@ -101,13 +109,15 @@ export function Toolbar({
|
||||
<div className="h-5 w-px bg-slate-700" />
|
||||
|
||||
{/* Actions */}
|
||||
{!isReadOnly && (
|
||||
<>
|
||||
<button
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
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"
|
||||
>
|
||||
Zpet
|
||||
Zpět
|
||||
</button>
|
||||
|
||||
<button
|
||||
@@ -123,10 +133,10 @@ export function Toolbar({
|
||||
: 'bg-blue-700/70 text-blue-100 border-blue-600 hover:bg-blue-600/70'
|
||||
}`}
|
||||
>
|
||||
{saveStatus === 'saving' ? 'Ukladam...'
|
||||
: saveStatus === 'saved' ? 'Ulozeno'
|
||||
{saveStatus === 'saving' ? 'Ukládám...'
|
||||
: saveStatus === 'saved' ? 'Uloženo'
|
||||
: saveStatus === 'error' ? 'Chyba!'
|
||||
: 'Ulozit'}
|
||||
: 'Uložit'}
|
||||
</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
|
||||
hover:bg-slate-700 cursor-pointer transition-colors"
|
||||
>
|
||||
Ulozit jako
|
||||
Uložit jako
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{onExportPdf && activeMonth && (
|
||||
<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
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -191,6 +213,17 @@ export function Toolbar({
|
||||
>
|
||||
SAZLT
|
||||
</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 className="h-5 w-px bg-slate-700" />
|
||||
|
||||
@@ -3005,27 +3005,6 @@
|
||||
"group": "TKB",
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "tkb-glaser",
|
||||
"name": "Glaser Ondřej",
|
||||
"note": "NN",
|
||||
"group": "TKB",
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "tkb-herbst",
|
||||
"name": "Herbst David",
|
||||
"note": "NN",
|
||||
"group": "TKB",
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "tkb-ryba",
|
||||
"name": "Ryba Ondřej",
|
||||
"note": "NN",
|
||||
"group": "TKB",
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "tkb-zabransky",
|
||||
"name": "Zábranský Petr",
|
||||
@@ -3070,12 +3049,6 @@
|
||||
"name": "Robert Štefan",
|
||||
"group": "IT",
|
||||
"data": {}
|
||||
},
|
||||
{
|
||||
"id": "it-franek",
|
||||
"name": "Franek Lukáš",
|
||||
"group": "IT",
|
||||
"data": {}
|
||||
}
|
||||
],
|
||||
"tunnelClosures": [],
|
||||
|
||||
Reference in New Issue
Block a user