From db56403f7c0bba17444b8cd89b43b695fbc8da24 Mon Sep 17 00:00:00 2001 From: Docker Config Backup Date: Mon, 13 Apr 2026 14:39:04 +0200 Subject: [PATCH] feat: dochazka Excel export + auto-reload + 162 filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- web/dochazka_template.xlsx | Bin 0 -> 9282 bytes web/export_dochazka.py | 505 +++++++++++++++++++++++++++++++++++++ web/index.html | 2 +- web/server.js | 57 ++++- web/src/App.tsx | 195 ++++++++++---- web/src/ContextMenu.tsx | 13 +- web/src/FileManager.tsx | 77 ++++-- web/src/FpdCheck.tsx | 166 ++++++++++++ web/src/Login.tsx | 40 ++- web/src/ProposalModal.tsx | 2 +- web/src/ScheduleTable.tsx | 39 ++- web/src/Toolbar.tsx | 111 +++++--- web/src/data.json | 27 -- 13 files changed, 1079 insertions(+), 155 deletions(-) create mode 100644 web/dochazka_template.xlsx create mode 100644 web/export_dochazka.py create mode 100644 web/src/FpdCheck.tsx diff --git a/web/dochazka_template.xlsx b/web/dochazka_template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..f875237798d1855eade73f9356682059531be91f GIT binary patch literal 9282 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 SdB}u*|482Z$90: + if z>=6: r+=6; z-=6 + else: r+=z; z=0 + if z>=6: o+=6; z-=6 + else: o+=z; z=0 + return r, o + +def split_night(hours): + r=o=n=0; z=hours + while z>0: + if z>=2: o+=2; z-=2 + else: o+=z; z=0 + if z>=8: n+=8; z-=8 + else: n+=z; z=0 + if z>=2: r+=2; z-=2 + else: r+=z; z=0 + return r, o, n + +# ────────────────────────────────────────────────────────────── +def compute(schedule, month, year): + holidays = get_czech_holidays(year) + day_index = schedule['dayIndex'] + people = schedule['people'] + + month_days = [d for d in day_index if d['month']==month and d['year']==year] + work_days = sum(1 for d in month_days + if not d['weekend'] + and Date(d['year'],d['month'],d['day']) not in holidays) + fpd = work_days * 8 + + tkb_results = [] + # Only include people that are in the template — in template order + tkb_by_name = {p['name']: p for p in people if p.get('group')=='TKB'} + tkb_people = [tkb_by_name[name] for name in TEMPLATE_NAMES if name in tkb_by_name] + for person in tkb_people: + r=o=n=0; dov=nem=otc=0; minus_fpd=0 + sichet_12h = 0 # shifts with ≥12 h + sichet_under_12h = 0 # shifts with <12 h + uzavery = 0 # 'U' days (closures) + + for d in month_days: + v = (person['data'].get(str(d['idx'])) or {}).get('v', '') + is_w = (not d['weekend'] and + Date(d['year'],d['month'],d['day']) not in holidays) + if v=='A': + dr,do=split_day(12); r+=dr; o+=do + sichet_12h += 1 + elif v=='B': + nr,no,nn=split_night(12); r+=nr; o+=no; n+=nn + sichet_12h += 1 + elif v=='D': dov+=1; minus_fpd += 8 if is_w else 0 + elif v=='N': nem+=1; minus_fpd += 8 if is_w else 0 + elif v=='O': otc+=1; minus_fpd += 8 if is_w else 0 + elif v=='U': uzavery += 1 + elif v not in ('x','U','') and v: + try: + h=float(v) + if h>0: + dr,do=split_day(h); r+=dr; o+=do + if h >= 12: sichet_12h += 1 + else: sichet_under_12h += 1 + except (ValueError,TypeError): pass + + # PMS stored at negative dayIdx = -month + # PMS = práce mimo směnu: hours added to R/O but NOT counted as shifts + pms = (person['data'].get(str(-month)) or {}).get('v','') + if pms=='A': pr,po=split_day(12); r+=pr; o+=po + elif pms=='B': pr,po,pn=split_night(12); r+=pr; o+=po; n+=pn + elif pms: + try: + ph=float(pms) + if ph>0: pr,po=split_day(ph); r+=pr; o+=po + except (ValueError,TypeError): pass + + total = r+o+n + o_navic = total - fpd + minus_fpd + r_fpd, o_fpd, n_fpd = r, o, n + if o_navic > 0: + pom = o_navic + if r_fpd >= pom: r_fpd -= pom; pom = 0 + else: pom -= r_fpd; r_fpd = 0 + o_fpd = max(0, o_fpd - pom) + + absence = ' '.join(filter(None, [ + f'{dov}D' if dov else '', + f'{nem}N' if nem else '', + f'{otc}O' if otc else '', + ])) + (' ' if (dov or nem or otc) else '') + + # --- Financial calculations (sichtovnice.py) --- + # Per-person FPD days (adjusted for D/N/O on working days) + person_fpd_dny = work_days - (minus_fpd / 8) + + # R (Jine1): meal allowance supplement + # 1.15 × (sichet_12h × 236 + sichet_<12h × 155 − fpd_per_person × 155) + stravne_doplatek = round( + (1 + DAN) * ( + sichet_12h * STRAVNE_12H + + sichet_under_12h * STRAVNE_8H + - person_fpd_dny * STRAVNE_8H + ), 2) + + # S (Jine2): transport money — only AUV drivers + pdata = PERSON_DATA.get(person['name'], {'auto':'AUS','km_tkb':0,'km_sat':0}) + kc_doprava = 0 + if pdata['auto'] == 'AUV': + sichet_tkb = sichet_12h + sichet_under_12h # no SAT tracking + kc_tkb = pdata['km_tkb'] * KC_KM * sichet_tkb + kc_uzavera = uzavery * pdata['km_tkb'] * KC_KM + kc_doprava = kc_tkb + kc_uzavera + + # P (Indiv1): closure count text (+ Internet reimbursement for Janouš Petr) + # indiv1_type: 'str', 'num', or None (empty) + if person['name'] == 'Janouš Petr': + if uzavery: + indiv1_value = f' {uzavery}U+{INTERNET}' + indiv1_type = 'str' + else: + indiv1_value = INTERNET + indiv1_type = 'num' + elif uzavery: + indiv1_value = f' {uzavery}U' + indiv1_type = 'str' + else: + indiv1_value = None + indiv1_type = None + + tkb_results.append({ + 'name': person['name'], + 'r': r_fpd, 'o': o_fpd, 'n': n_fpd, + 'o_navic': o_navic, + 'absence': absence.strip(), + 'indiv1_value': indiv1_value, + 'indiv1_type': indiv1_type, + 'jine1': stravne_doplatek, + 'jine2': kc_doprava, + }) + + it_order = ['it-glaser','it-janous','it-stefan','it-voros'] + it_map = {p['id']:p for p in people if p.get('group')=='IT'} + it_results = [] + for pid in it_order: + person = it_map.get(pid) + if not person: + it_results.append({'name':pid.split('-')[1].capitalize(),'hours':0,'prace':0}) + continue + hours = 0 + for d in month_days: + v = (person['data'].get(str(d['idx'])) or {}).get('v','') + if v in ('A','B'): hours += 12 + elif v: + try: h=float(v); hours += h if h>0 else 0 + except (ValueError,TypeError): pass + pms = (person['data'].get(str(-month)) or {}).get('v','') + prace = 0 + try: prace = float(pms) if pms else 0 + except (ValueError,TypeError): pass + it_results.append({'name':person['name'],'hours':hours,'prace':prace}) + + return {'month':month,'year':year,'work_days':work_days,'fpd':fpd, + 'tkb':tkb_results,'it':it_results} + +# ────────────────────────────────────────────────────────────── +# Shared strings helpers +# ────────────────────────────────────────────────────────────── +def parse_ss(xml_bytes): + root = ET.fromstring(xml_bytes) + strings = [] + for si in root.findall(f'{{{NS}}}si'): + t = si.find(f'.//{{{NS}}}t') + strings.append(t.text or '' if t is not None else '') + return root, strings + +def get_or_add(root, strings, text): + if text in strings: + return strings.index(text) + strings.append(text) + si = ET.SubElement(root, f'{{{NS}}}si') + t = ET.SubElement(si, f'{{{NS}}}t') + t.text = text + root.set('count', str(len(strings))) + root.set('uniqueCount', str(len(strings))) + return len(strings) - 1 + +def serialize_ss(root): + return b'\n' + ET.tostring(root, encoding='unicode').encode('utf-8') + +# ────────────────────────────────────────────────────────────── +# Sheet XML helpers +# ────────────────────────────────────────────────────────────── +def col_letter(col_idx): # 1-based + letters = '' + while col_idx > 0: + col_idx, rem = divmod(col_idx - 1, 26) + letters = chr(65 + rem) + letters + return letters + +def cell_ref(col, row): + return f'{col_letter(col)}{row}' + +def set_cell_num(row_el, col, row, value): + """Set a numeric cell value; creates or updates the cell.""" + ref = cell_ref(col, row) + for c in list(row_el): + if c.get('r') == ref: + c.set('t', 'n') + v = c.find(f'{{{NS}}}v') + if v is None: v = ET.SubElement(c, f'{{{NS}}}v') + v.text = str(value) + # Remove formula if any + f = c.find(f'{{{NS}}}f') + if f is not None: row_el.remove(f) + return + # Cell doesn't exist — shouldn't happen for template cells, but handle gracefully + c = ET.SubElement(row_el, f'{{{NS}}}c') + c.set('r', ref); c.set('t', 'n') + v = ET.SubElement(c, f'{{{NS}}}v'); v.text = str(value) + +def set_cell_ss(row_el, col, row, ss_idx): + """Set a shared-string cell.""" + ref = cell_ref(col, row) + for c in list(row_el): + if c.get('r') == ref: + c.set('t', 's') + v = c.find(f'{{{NS}}}v') + if v is None: v = ET.SubElement(c, f'{{{NS}}}v') + v.text = str(ss_idx) + f = c.find(f'{{{NS}}}f') + if f is not None: row_el.remove(f) + return + c = ET.SubElement(row_el, f'{{{NS}}}c') + c.set('r', ref); c.set('t', 's') + v = ET.SubElement(c, f'{{{NS}}}v'); v.text = str(ss_idx) + +def set_cell_formula(row_el, col, row, formula): + ref = cell_ref(col, row) + for c in list(row_el): + if c.get('r') == ref: + c.attrib.pop('t', None) + # Remove existing v + v = c.find(f'{{{NS}}}v') + if v is not None: c.remove(v) + f = c.find(f'{{{NS}}}f') + if f is None: f = ET.SubElement(c, f'{{{NS}}}f') + f.text = formula + return + c = ET.SubElement(row_el, f'{{{NS}}}c') + c.set('r', ref) + f = ET.SubElement(c, f'{{{NS}}}f'); f.text = formula + +def clear_cell(row_el, col, row): + """Remove value from a cell (keep style/element, clear v and t).""" + ref = cell_ref(col, row) + for c in list(row_el): + if c.get('r') == ref: + c.attrib.pop('t', None) + v = c.find(f'{{{NS}}}v') + if v is not None: c.remove(v) + f = c.find(f'{{{NS}}}f') + if f is not None: c.remove(f) + return + +# ────────────────────────────────────────────────────────────── +def generate_excel(data): + with open(TEMPLATE_PATH, 'rb') as f: + tpl_bytes = f.read() + + tpl_zip = io.BytesIO(tpl_bytes) + with zipfile.ZipFile(tpl_zip) as zin: + filenames = zin.namelist() + ss_bytes = zin.read('xl/sharedStrings.xml') if 'xl/sharedStrings.xml' in filenames else None + sheet_bytes = zin.read('xl/worksheets/sheet1.xml') + wb_bytes = zin.read('xl/workbook.xml') + all_bytes = {f: zin.read(f) for f in filenames} + + # ── Shared strings ── + ss_root, ss_list = parse_ss(ss_bytes) if ss_bytes else (None, []) + + def ss(text): + return get_or_add(ss_root, ss_list, text) + + month_name = MONTH_NAMES[data['month']] + month_idx = ss(month_name) + op_idx = ss(OP_CODE) + + # ── Sheet XML ── + sheet_root = ET.fromstring(sheet_bytes) + sd = sheet_root.find(f'{{{NS}}}sheetData') + + # Build row map + rows = {int(r.get('r')): r for r in sd.findall(f'{{{NS}}}row')} + + n_people = len(data['tkb']) + n_tmpl = TPL_LAST - TPL_FIRST + 1 # 12 + + # ── ROW 3: month metadata ── + r3 = rows[3] + set_cell_ss (r3, 4, 3, month_idx) # D3 = month name + set_cell_num(r3, 6, 3, 8) # F3 = direction (always 8) + set_cell_num(r3, 7, 3, data['work_days']) # G3 + set_cell_num(r3, 8, 3, data['fpd']) # H3 + + # ── DATA ROWS 8–19 ── + for i in range(n_tmpl): + row_num = TPL_FIRST + i + r = rows[row_num] + if i < n_people: + p = data['tkb'][i] + # Clear all value-bearing cells first + for col in range(2, 26): + clear_cell(r, col, row_num) + # Write our values + set_cell_ss (r, 2, row_num, op_idx) + set_cell_ss (r, 3, row_num, ss(p['name'])) + set_cell_num(r, 4, row_num, p['r']) + set_cell_num(r, 6, row_num, p['o']) + set_cell_num(r, 8, row_num, p['n']) + set_cell_num(r,10, row_num, p['o_navic']) + # P (indiv1): closure text/internet — string OR numeric OR blank + if p['indiv1_type'] == 'str': + set_cell_ss(r, 16, row_num, ss(p['indiv1_value'])) + elif p['indiv1_type'] == 'num': + set_cell_num(r, 16, row_num, p['indiv1_value']) + set_cell_num(r,18, row_num, p['jine1']) # R: stravné doplatek + set_cell_num(r,19, row_num, p['jine2']) # S: transport (AUV) + set_cell_num(r,23, row_num, 0) + if p['absence']: + set_cell_ss(r, 24, row_num, ss(p['absence'])) + else: + # Fewer people than template: clear the row + for col in range(2, 26): + clear_cell(r, col, row_num) + # Mark as hidden + r.set('hidden', '1') + + # n_people ≤ n_tmpl always (filtered to TEMPLATE_NAMES in compute()). + # Unused template rows are cleared and hidden above. Celkem row stays at TPL_CELKEM. + celkem_row = TPL_CELKEM + it_first = TPL_IT1 + + # ── CELKEM row ── + ck = rows[celkem_row] + for col in range(4, 24): + clear_cell(ck, col, celkem_row) + for col in [4, 6, 8, 10]: + cl = col_letter(col) + set_cell_formula(ck, col, celkem_row, + f'SUBTOTAL(9,{cl}{TPL_FIRST}:{cl}{TPL_LAST})') + for col in [5, 7, 9, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]: + set_cell_num(ck, col, celkem_row, 0) + + # ── IT section (hidden rows already shifted) ── + # IT people (rows it_first to it_first+3) + it_names = ['Glaser', 'Janouš', 'Štefan', 'Vörös'] + for j, it in enumerate(data['it']): + rn = it_first + j + r = rows.get(rn) + if r is None: continue + set_cell_ss (r, 2, rn, ss(it_names[j] if j < len(it_names) else it['name'])) + set_cell_num(r, 3, rn, it['hours']) + if it.get('prace', 0): + set_cell_num(r, 4, rn, it['prace']) + else: + clear_cell(r, 4, rn) + + # ── Rebuild sheetData in row order ── + for old_row in list(sd): + sd.remove(old_row) + for rn in sorted(rows): + sd.append(rows[rn]) + + # ── Update sheet name in workbook.xml ── + new_sheet_name = f"{data['month']:02d}_{data['year']}" + wb_text = wb_bytes.decode('utf-8') + wb_text = re.sub(r'name="[^"]*"', f'name="{new_sheet_name}"', wb_text, count=1) + + # ── Serialize ── + sheet_out = (b'\n' + + ET.tostring(sheet_root, encoding='unicode').encode('utf-8')) + ss_out = serialize_ss(ss_root) if ss_root is not None else ss_bytes + + # ── Repack zip ── + out_buf = io.BytesIO() + with zipfile.ZipFile(out_buf, 'w', zipfile.ZIP_DEFLATED) as zout: + for fname, fbytes in all_bytes.items(): + if fname == 'xl/worksheets/sheet1.xml': + zout.writestr(fname, sheet_out) + elif fname == 'xl/sharedStrings.xml': + zout.writestr(fname, ss_out) + elif fname == 'xl/workbook.xml': + zout.writestr(fname, wb_text.encode('utf-8')) + else: + zout.writestr(fname, fbytes) + + out_buf.seek(0) + return out_buf.read() + +# ────────────────────────────────────────────────────────────── +if __name__ == '__main__': + inp = json.loads(sys.stdin.buffer.read()) + result = compute(inp['schedule'], inp['month'], inp['year']) + sys.stdout.buffer.write(generate_excel(result)) diff --git a/web/index.html b/web/index.html index 6f3e360..84d268c 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - TKB Plan sluzeb + Tunely-DEV
diff --git a/web/server.js b/web/server.js index 26f101f..105b00b 100644 --- a/web/server.js +++ b/web/server.js @@ -9,7 +9,8 @@ const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const app = express() -const PORT = 3080 +const PORT = process.env.PORT || 3090 +const SERVER_VERSION = Date.now().toString() const SCHEDULES_DIR = join(__dirname, 'schedules') const SAVED_SCHEDULE_PATH = join(__dirname, 'saved_schedule.json') @@ -198,6 +199,55 @@ app.get('/api/files/:id/export-excel', (_req, res) => { res.status(501).json({ error: 'Excel export not yet implemented for TKB format' }) }) +// Export docházka Excel for a given month +app.get('/api/files/:id/export-dochazka', async (req, res) => { + const { id } = req.params + const month = parseInt(req.query.month) || (new Date().getMonth() + 1) + const year = parseInt(req.query.year) || new Date().getFullYear() + + const filePath = join(SCHEDULES_DIR, `${id}.json`) + if (!existsSync(filePath)) return res.status(404).json({ error: 'File not found' }) + + let fileData + try { fileData = JSON.parse(readFileSync(filePath, 'utf8')) } + catch { return res.status(500).json({ error: 'Failed to read file' }) } + + const input = JSON.stringify({ schedule: fileData.data, month, year }) + const scriptPath = join(__dirname, 'export_dochazka.py') + + try { + const { spawn } = await import('child_process') + await new Promise((resolve, reject) => { + const chunks = [] + const errChunks = [] + const proc = spawn('python3', [scriptPath]) + proc.stdout.on('data', d => chunks.push(d)) + proc.stderr.on('data', d => errChunks.push(d)) + proc.on('close', code => { + if (code !== 0) { + const errMsg = Buffer.concat(errChunks).toString() + console.error('export_dochazka error:', errMsg) + reject(new Error(errMsg)) + } else { + const xlsx = Buffer.concat(chunks) + const MONTH_NAMES = ['','Leden','Únor','Březen','Duben','Květen','Červen','Červenec','Srpen','Září','Říjen','Listopad','Prosinec'] + const mm = String(month).padStart(2, '0') + const fname = `OP2416101755_TKB_${year}_${mm}_${MONTH_NAMES[month]}.xlsx` + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet') + res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fname)}`) + res.send(xlsx) + resolve() + } + }) + proc.on('error', reject) + proc.stdin.write(input) + proc.stdin.end() + }) + } catch (err) { + if (!res.headersSent) res.status(500).json({ error: 'Export failed', details: err.message }) + } +}) + // Create new file app.post('/api/files', (req, res) => { try { @@ -372,6 +422,11 @@ app.get('/api/export-excel', (_req, res) => { res.status(501).json({ error: 'Excel export not yet implemented for TKB format' }) }) +// Version endpoint — clients poll this and reload when version changes +app.get('/api/version', (_req, res) => { + res.json({ version: SERVER_VERSION }) +}) + // Serve static files from dist/ app.use(express.static(join(__dirname, 'dist'), { setHeaders: (res) => { diff --git a/web/src/App.tsx b/web/src/App.tsx index 168c6d5..f7ac110 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -4,13 +4,26 @@ import type { SelectedCell } from './ScheduleTable' import { Toolbar } from './Toolbar' import { ContextMenu } from './ContextMenu' import { ProposalModal } from './ProposalModal' +import { FpdModal } from './FpdCheck' import { useScheduleState } from './useScheduleState' import { useDragCell } from './useDragCell' -import { Login, isAuthenticated } from './Login' +import { Login, isAuthenticated, getAuthRole, clearAuthentication } from './Login' +import type { UserRole } from './Login' import { FileManager } from './FileManager' import fallbackData from './data.json' import type { ScheduleData, ScheduleFileWithData, ContextMenuState } from './types' +const FAVOURITE_FILE_KEY = 'tkb_favourite_file' + +export function getFavouriteFileId(): string | null { + return localStorage.getItem(FAVOURITE_FILE_KEY) +} + +export function setFavouriteFileId(id: string | null): void { + if (id) localStorage.setItem(FAVOURITE_FILE_KEY, id) + else localStorage.removeItem(FAVOURITE_FILE_KEY) +} + function normalizeDayIndex(data: ScheduleData): ScheduleData { const targetStart = new Date(2026, 0, 1) // January 1 const targetEnd = new Date(2026, 11, 31) // December 31 @@ -82,22 +95,34 @@ function getISOWeek(date: Date): number { type AppMode = 'login' | 'files' | 'editor' function App() { + // Auto-reload when server restarts with a new version (new deploy) + useEffect(() => { + let serverVersion: string | null = null + const check = async () => { + try { + const res = await fetch('/api/version') + if (!res.ok) return + const { version } = await res.json() + if (serverVersion === null) { serverVersion = version; return } + if (version !== serverVersion) window.location.reload() + } catch {} + } + check() + const interval = setInterval(check, 60_000) + return () => clearInterval(interval) + }, []) + const [authed, setAuthed] = useState(isAuthenticated()) - const [mode, setMode] = useState(authed ? 'files' : 'login') + const [role, setRole] = useState(() => isAuthenticated() ? getAuthRole() : 'editor') + const isViewer = role === 'viewer' + const hasFavOnLoad = authed && !!getFavouriteFileId() + const [mode, setMode] = useState(authed ? (hasFavOnLoad ? 'editor' : 'files') : 'login') const [fileId, setFileId] = useState(null) const [fileName, setFileName] = useState('') const [fileData, setFileData] = useState(null) - const [loading, setLoading] = useState(false) + const [loading, setLoading] = useState(hasFavOnLoad) const [compareFileId, setCompareFileId] = useState(null) - - useEffect(() => { - if (authed && mode === 'login') setMode('files') - }, [authed, mode]) - - const handleLogin = useCallback(() => { - setAuthed(true) - setMode('files') - }, []) + const autoOpenAttempted = useRef(false) const handleOpenFile = useCallback(async (id: string, compareId?: string) => { setLoading(true) @@ -112,11 +137,38 @@ function App() { setMode('editor') } catch (err) { alert(`Chyba pri otevirani souboru: ${err}`) + setMode('files') } finally { setLoading(false) } }, []) + // Auto-open favourite file when authenticated + useEffect(() => { + if (!authed || autoOpenAttempted.current) return + autoOpenAttempted.current = true + const fav = getFavouriteFileId() + if (fav) handleOpenFile(fav) + }, [authed, handleOpenFile]) + + const handleLogin = useCallback((newRole: UserRole) => { + autoOpenAttempted.current = false + setRole(newRole) + setAuthed(true) + }, []) + + const handleLogout = useCallback(() => { + clearAuthentication() + setAuthed(false) + setRole('editor') + setMode('login') + setFileId(null) + setFileName('') + setFileData(null) + setCompareFileId(null) + autoOpenAttempted.current = false + }, []) + const handleCompare = useCallback((id1: string, id2: string) => { handleOpenFile(id1, id2) }, [handleOpenFile]) @@ -162,7 +214,17 @@ function App() { } if (mode === 'files') { - return + if (isViewer) { + return ( +
+
+
Žádný aktivní harmonogram
+
Kontaktujte správce pro nastavení výchozího souboru
+
+
+ ) + } + return } if (mode === 'editor' && fileData && fileId) { @@ -172,9 +234,11 @@ function App() { fileName={fileName} data={fileData} compareFileId={compareFileId} - onBack={handleBackToFiles} + onBack={isViewer ? undefined : handleBackToFiles} + isReadOnly={isViewer} onFileNameChange={setFileName} onClearCompare={() => setCompareFileId(null)} + onLogout={handleLogout} /> ) } @@ -223,12 +287,14 @@ interface ScheduleAppProps { fileName: string data: ScheduleData compareFileId: string | null - onBack: () => void + onBack?: () => void onFileNameChange: (name: string) => void onClearCompare: () => void + isReadOnly?: boolean + onLogout?: () => void } -function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare }: ScheduleAppProps) { +function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileNameChange, onClearCompare, isReadOnly = false, onLogout }: ScheduleAppProps) { const { people, tunnelClosures, tunnelColors, metroClosures, metroColors, d8Closures, d8Colors, sazltClosures, sazltColors, @@ -406,9 +472,11 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName }, [getSchedulePayload, fileName, onFileNameChange]) const [showProposals, setShowProposals] = useState(false) + const [showFpd, setShowFpd] = useState(false) const [showMetro, setShowMetro] = useState(true) const [showD8, setShowD8] = useState(true) const [showSazlt, setShowSazlt] = useState(true) + const [hide162, setHide162] = useState(false) const [hiddenValues, setHiddenValues] = useState>(new Set()) const handleExportPdf = useCallback(async (month: number) => { @@ -457,24 +525,42 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName return (
-
- -

- {fileName || 'TKB Plan sluzeb'} - - Plan sluzeb a pohotovosti - -

- {compareFileName && ( - - Porovnani s: {compareFileName} - +
+
+ {onBack && ( + + )} + {isReadOnly && ( + + Jen pro čtení + + )} +

+ {fileName || 'TKB Plán služeb'} + + Plán služeb a pohotovostí + +

+ {compareFileName && ( + + Porovnani s: {compareFileName} + + )} +
+ {onLogout && ( + )}
@@ -493,6 +579,8 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName onToggleD8={() => setShowD8(v => !v)} showSazlt={showSazlt} onToggleSazlt={() => setShowSazlt(v => !v)} + hide162={hide162} + onToggle162={() => setHide162(v => !v)} hiddenValues={hiddenValues} onToggleValue={(code: string) => setHiddenValues(prev => { const next = new Set(prev) @@ -502,7 +590,9 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName diffFileName={compareFileName} onCloseDiff={() => { setCompareData(null); setCompareFileName(null); onClearCompare() }} onExportPdf={handleExportPdf} - onShowProposals={() => setShowProposals(true)} + onShowProposals={isReadOnly ? undefined : () => setShowProposals(true)} + onCheckFpd={() => setShowFpd(true)} + isReadOnly={isReadOnly} /> {} : onCellPointerDown} + onSetCell={isReadOnly ? () => {} : setCell} + onSetTunnelClosure={isReadOnly ? () => {} : setTunnelClosure} + onSetMetroClosure={isReadOnly ? () => {} : setMetroClosure} + onSetD8Closure={isReadOnly ? () => {} : setD8Closure} + onSetSazltClosure={isReadOnly ? () => {} : setSazltClosure} showMetro={showMetro} showD8={showD8} showSazlt={showSazlt} + hide162={hide162} hiddenValues={hiddenValues} scrollRef={scrollRef} - onContextMenu={handleContextMenu} - onTunnelContextMenu={handleTunnelContextMenu} - onInfoRowContextMenu={handleInfoRowContextMenu} + onContextMenu={isReadOnly ? () => {} : handleContextMenu} + onTunnelContextMenu={isReadOnly ? () => {} : handleTunnelContextMenu} + onInfoRowContextMenu={isReadOnly ? () => {} : handleInfoRowContextMenu} compareData={compareData} /> @@ -567,6 +658,20 @@ function ScheduleApp({ fileId, fileName, data, compareFileId, onBack, onFileName {showProposals && ( setShowProposals(false)} /> )} + + {showFpd && activeMonth && ( + d.month === activeMonth)?.year ?? 2026} + dayIndex={data.dayIndex} + people={people} + onClose={() => setShowFpd(false)} + onExportDochazka={currentFileId ? () => { + const year = data.dayIndex.find(d => d.month === activeMonth)?.year ?? 2026 + window.location.href = `/api/files/${currentFileId}/export-dochazka?month=${activeMonth}&year=${year}` + } : undefined} + /> + )}
) } diff --git a/web/src/ContextMenu.tsx b/web/src/ContextMenu.tsx index c319171..9921b6c 100644 --- a/web/src/ContextMenu.tsx +++ b/web/src/ContextMenu.tsx @@ -192,7 +192,18 @@ export function ContextMenu({ return (
{ + (menuRef as React.MutableRefObject).current = el + if (el) { + const rect = el.getBoundingClientRect() + if (rect.bottom > window.innerHeight) { + el.style.top = `${state.y - rect.height}px` + } + if (rect.right > window.innerWidth) { + el.style.left = `${state.x - rect.width}px` + } + } + }} className="fixed z-[200] bg-slate-800 border border-slate-600 rounded-lg shadow-2xl py-1 min-w-[220px]" style={{ left: state.x, top: state.y }} > diff --git a/web/src/FileManager.tsx b/web/src/FileManager.tsx index 982430d..6d8569f 100644 --- a/web/src/FileManager.tsx +++ b/web/src/FileManager.tsx @@ -1,21 +1,30 @@ import { useState, useEffect, useCallback, useRef } from 'react' import type { ScheduleFile } from './types' +import { getFavouriteFileId, setFavouriteFileId } from './App' interface FileManagerProps { onOpenFile: (fileId: string) => void onCompare: (fileId1: string, fileId2: string) => void onCreateNew?: () => void + onLogout?: () => void } -export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerProps) { +export function FileManager({ onOpenFile, onCompare, onCreateNew, onLogout }: FileManagerProps) { const [files, setFiles] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [selectedIds, setSelectedIds] = useState>(new Set()) const [uploading, setUploading] = useState(false) const [deleting, setDeleting] = useState(null) + const [favouriteId, setFavouriteId] = useState(getFavouriteFileId) const fileInputRef = useRef(null) + const handleToggleFavourite = useCallback((id: string) => { + const newFav = favouriteId === id ? null : id + setFavouriteFileId(newFav) + setFavouriteId(newFav) + }, [favouriteId]) + const loadFiles = useCallback(async () => { try { const res = await fetch('/api/files') @@ -127,12 +136,23 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP return (
-

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

+
+

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

+ {onLogout && ( + + )} +
@@ -150,7 +170,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP className="px-4 py-2 rounded text-sm bg-blue-700/70 text-blue-100 border border-blue-600 hover:bg-blue-600/70 cursor-pointer transition-colors" > - Novy soubor + Nový soubor )} -
Zadne soubory
+
Žádné soubory
{onCreateNew && ( )}
@@ -215,18 +235,22 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP - Nazev + + Název Upraveno - Vytvoreno + Vytvořeno Akce - {files.map(file => ( + {files.map(file => { + const isFav = favouriteId === file.id + return ( + + + - {file.name} +
+ {file.name} + {isFav && výchozí} +
{formatDate(file.modifiedAt)} @@ -253,7 +292,7 @@ export function FileManager({ onOpenFile, onCompare, onCreateNew }: FileManagerP className="px-3 py-1 rounded text-xs bg-blue-700/70 text-blue-100 border border-blue-600 hover:bg-blue-600/70 cursor-pointer transition-colors" > - Otevrit + Otevřít - ))} + )})}
diff --git a/web/src/FpdCheck.tsx b/web/src/FpdCheck.tsx new file mode 100644 index 0000000..d784077 --- /dev/null +++ b/web/src/FpdCheck.tsx @@ -0,0 +1,166 @@ +import type { DayInfo, Person } from './types' +import { getCzechHolidays } from './holidays' + +const MONTH_NAMES: Record = { + 1:'Leden',2:'Únor',3:'Březen',4:'Duben',5:'Květen',6:'Červen', + 7:'Červenec',8:'Srpen',9:'Září',10:'Říjen',11:'Listopad',12:'Prosinec', +} + +interface FpdResult { + personId: string + name: string + note?: string + workedHours: number + fpd: number + diff: number // positive = over, negative = under +} + +function getHoursForValue(v: string): number { + if (v === 'A' || v === 'B') return 12 + const num = parseFloat(v) + if (!isNaN(num) && num > 0) return num + return 0 +} + +export function computeFpd( + month: number, + year: number, + dayIndex: DayInfo[], + people: Person[], +): { fpd: number; workDays: number; results: FpdResult[] } { + const holidays = getCzechHolidays(year) + const holidaySet = new Set(holidays.filter(h => h.month === month).map(h => h.day)) + + // Count working days in month + const monthDays = dayIndex.filter(d => d.month === month && d.year === year) + let workDays = 0 + for (const d of monthDays) { + if (!d.weekend && !holidaySet.has(d.day)) workDays++ + } + const fpd = workDays * 8 + + // Check TKB people only + const tkbPeople = people.filter(p => p.group === 'TKB') + const results: FpdResult[] = [] + + for (const person of tkbPeople) { + let workedHours = 0 + for (const d of monthDays) { + const cellData = person.data[String(d.idx)] + const value = cellData?.v + if (value) { + workedHours += getHoursForValue(value) + } + } + const diff = workedHours - fpd + results.push({ + personId: person.id, + name: person.name, + note: person.note, + workedHours, + fpd, + diff, + }) + } + + return { fpd, workDays, results } +} + +interface FpdModalProps { + month: number + year: number + dayIndex: DayInfo[] + people: Person[] + onClose: () => void + onExportDochazka?: () => void +} + +export function FpdModal({ month, year, dayIndex, people, onClose, onExportDochazka }: FpdModalProps) { + const { fpd, workDays, results } = computeFpd(month, year, dayIndex, people) + const under = results.filter(r => r.diff < 0) + const okOrOver = results.filter(r => r.diff >= 0) + + return ( +
+
e.stopPropagation()}> +
+

+ Kontrola FPD — {MONTH_NAMES[month]} {year} +

+ +
+ +
+ Pracovních dnů: {workDays} + | + FPD: {fpd} h + | + Pohotovost TKB: {results.length} osob +
+ +
+ {under.length === 0 ? ( +
+ ✓ Nikdo nemá nedostatek hodin +
+ ) : ( + <> +
Chybí hodiny ({under.length})
+
+ {under.map(r => ( +
+ + {r.name} + {r.note && ({r.note})} + + + {r.workedHours}h / {r.fpd}h + {r.diff}h + +
+ ))} +
+ + )} + +
Ostatní ({okOrOver.length})
+
+ {okOrOver.map(r => ( +
0 ? 'bg-slate-700/30 border border-slate-600/50' : 'bg-green-900/20 border border-green-700/30'}`} + > + + {r.name} + {r.note && ({r.note})} + + + {r.workedHours}h / {r.fpd}h + 0 ? 'text-slate-300' : 'text-green-400'}`}> + {r.diff > 0 ? `+${r.diff}h` : '✓'} + + +
+ ))} +
+
+ + {onExportDochazka && ( +
+ +
+ )} +
+
+ ) +} diff --git a/web/src/Login.tsx b/web/src/Login.tsx index e49171e..0d48e26 100644 --- a/web/src/Login.tsx +++ b/web/src/Login.tsx @@ -2,18 +2,33 @@ import { useState } from 'react' const VALID_USER = 'tkb' const VALID_PASS = 'sluzby' +const VIEWER_USER = 'prohlizec' +const VIEWER_PASS = 'pohled' const AUTH_KEY = 'tkb_auth' +const ROLE_KEY = 'tkb_role' + +export type UserRole = 'editor' | 'viewer' export function isAuthenticated(): boolean { - return sessionStorage.getItem(AUTH_KEY) === 'true' + return localStorage.getItem(AUTH_KEY) === 'true' } -export function setAuthenticated(): void { - sessionStorage.setItem(AUTH_KEY, 'true') +export function getAuthRole(): UserRole { + return localStorage.getItem(ROLE_KEY) === 'viewer' ? 'viewer' : 'editor' +} + +function setAuthenticated(role: UserRole): void { + localStorage.setItem(AUTH_KEY, 'true') + localStorage.setItem(ROLE_KEY, role) +} + +export function clearAuthentication(): void { + localStorage.removeItem(AUTH_KEY) + localStorage.removeItem(ROLE_KEY) } interface LoginProps { - onLogin: () => void + onLogin: (role: UserRole) => void } export function Login({ onLogin }: LoginProps) { @@ -24,8 +39,11 @@ export function Login({ onLogin }: LoginProps) { const handleSubmit = (e: React.FormEvent) => { e.preventDefault() if (username === VALID_USER && password === VALID_PASS) { - setAuthenticated() - onLogin() + setAuthenticated('editor') + onLogin('editor') + } else if (username === VIEWER_USER && password === VIEWER_PASS) { + setAuthenticated('viewer') + onLogin('viewer') } else { setError(true) setTimeout(() => setError(false), 3000) @@ -39,13 +57,13 @@ export function Login({ onLogin }: LoginProps) { className="bg-slate-800 border border-slate-700 rounded-lg p-8 w-80 shadow-xl" >

- TKB Plan sluzeb + TKB Plán služeb

- Planovani smen a pohotovosti + Plánování směn a pohotovostí

- + - Nespravne prihlasovaci udaje + Nesprávné přihlašovací údaje )} @@ -75,7 +93,7 @@ export function Login({ onLogin }: LoginProps) { className="w-full py-2 rounded bg-blue-600 hover:bg-blue-500 text-white text-sm font-medium transition-colors cursor-pointer" > - Prihlasit + Přihlásit diff --git a/web/src/ProposalModal.tsx b/web/src/ProposalModal.tsx index d710f3c..825ccee 100644 --- a/web/src/ProposalModal.tsx +++ b/web/src/ProposalModal.tsx @@ -59,7 +59,7 @@ export function ProposalModal({ onClose }: ProposalModalProps) { >
-

Navrhy na vylepseni

+

Návrhy na vylepšení

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