diff --git a/Tool/pages.py b/Tool/pages.py index c5e72ef..b2c0f6e 100644 --- a/Tool/pages.py +++ b/Tool/pages.py @@ -1,3 +1,4 @@ +import json as _json from .config import _state, _access_log, DbType from .renderer import _base, _card, _page_css, _page_js, _tmpl from .queries.db import e as _e, query as _query @@ -9,6 +10,7 @@ from .queries import documenti as q_documenti from .queries import iscrizioni as q_iscrizioni from .queries import api_iscrizioni as q_api_iscrizioni from .queries import email as q_email +from .queries import sharepoint as q_sharepoint def _load_data(db_path: str) -> dict: @@ -187,6 +189,14 @@ def _page_api_iscrizioni(db_path: str) -> str: return _base('Iscrizioni', content, 'nav_api_iscrizioni', db_path, _page_css('api_iscrizioni'), _page_js('api_iscrizioni')) +def _page_sharepoint(db_path: str) -> str: + rows = q_sharepoint.get_sharepoint(db_path) + content = _tmpl('sharepoint.html').substitute( + tbl_sharepoint=q_sharepoint.render_table(rows) + ) + return _base('SharePoint', content, 'nav_sharepoint', db_path, _page_css('sharepoint'), _page_js('sharepoint')) + + def _page_email(db_path: str) -> str: rows = q_email.get_email(db_path) content = _tmpl('email.html').substitute( @@ -195,6 +205,19 @@ def _page_email(db_path: str) -> str: return _base('Email', content, 'nav_email', db_path, _page_css('email'), _page_js('email')) +def _page_schema(db_path: str) -> str: + rows = q_processes.get_schema_stats(db_path) + labels = [r.get('process_name') or 'unknown' for r in rows] + counts = [r.get('run_count', 0) for r in rows] + minutes = [max(1, (r.get('total_seconds') or 0) // 60) for r in rows] + content = _tmpl('schema.html').substitute( + labels_json=_json.dumps(labels), + counts_json=_json.dumps(counts), + minutes_json=_json.dumps(minutes), + ) + return _base('Schema', content, 'nav_schema', db_path, _page_css('schema'), _page_js('schema')) + + def _page_server_logs(db_path: str) -> str: rows = ''.join( f'' diff --git a/Tool/queries/iscrizioni.py b/Tool/queries/iscrizioni.py index 202c47c..4803cf2 100644 --- a/Tool/queries/iscrizioni.py +++ b/Tool/queries/iscrizioni.py @@ -29,6 +29,7 @@ def get_corsi(db_path: str) -> list: json_extract(f.corso, '$.descrizione') AS corso_descrizione, json_extract(f.corso, '$.quando_start') AS corso_data, json_extract(f.corso, '$.n_iscritti') AS n_iscritti, + bc.odv_number, s.step_ftp, s.err_ftp, s.step_bc, s.err_bc, s.step_iscrizione, s.err_iscrizione, @@ -38,8 +39,9 @@ def get_corsi(db_path: str) -> list: s.step_email, s.err_email, f.created_at FROM rpa_intra_ftp_json f - LEFT JOIN rpa_mapping_aziende m ON m.company_code = f.company_code + LEFT JOIN rpa_mapping_aziende m ON m.company_code = f.company_code LEFT JOIN rpa_intra_steps_status s ON s.rpa_intra_ftp_json_id = f.id + LEFT JOIN rpa_intra_bc_check bc ON bc.rpa_intra_ftp_json_id = f.id ORDER BY f.id DESC """) @@ -82,15 +84,16 @@ def _row_status(r: dict) -> str: return 'done' if any(r.get(s) == 'processing' for s in _ALL_STEPS): return 'processing' - if any(r.get(s) == 'skipped' for s in _ALL_STEPS): - return 'skipped' - return 'pending' + if any(r.get(s) == 'pending' for s in _ALL_STEPS): + return 'pending' + return 'skipped' -def _fth(label: str, col: str) -> str: - return f'{label}' +def _fth(label: str, col: str, step: bool = False, data: bool = False) -> str: + cls = ' class="step-th"' if step else (' class="data-th"' if data else '') + return f'{label}' def _clip(text: str, n: int = 20) -> str: @@ -116,6 +119,7 @@ def render_table(rows: list) -> str: f' data-azienda="{e(r.get("azienda_nome") or "")}"' f' data-corso="{e(r.get("corso_descrizione") or "")}"' f' data-data="{e(r.get("corso_data") or "")}"' + f' data-odv_number="{e(r.get("odv_number") or "")}"' f' data-ftp="{e(r.get("step_ftp") or "pending")}"' f' data-bc="{e(r.get("step_bc") or "pending")}"' f' data-iscrizione="{e(r.get("step_iscrizione") or "pending")}"' @@ -124,39 +128,44 @@ def render_table(rows: list) -> str: f' data-report="{e(r.get("step_report") or "pending")}"' f' data-email="{e(r.get("step_email") or "pending")}"' f'>' - f'{e(r.get("id"))}' - f'{e(r.get("sorgente"))}' - f'{e(r.get("azienda_nome"))}' - f'{e(r.get("sessione_id"))}' - f'{_clip(r.get("corso_descrizione") or "")}' - f'{e(r.get("corso_data"))}' - f'{e(r.get("n_iscritti"))}' + f'{e(r.get("id"))}' + + (f'' + if r.get('rpa_process_id') else '') + + f'' f'{_step_cell(r.get("step_ftp"), r.get("err_ftp"))}' f'{_step_cell(r.get("step_bc"), r.get("err_bc"))}' f'{_step_cell(r.get("step_iscrizione"), r.get("err_iscrizione"))}' + f'{e(r.get("odv_number") or "-")}' f'{_step_cell(r.get("step_odv"), r.get("err_odv"))}' f'{_step_cell(r.get("step_sharepoint"), r.get("err_sharepoint"))}' f'{_step_cell(r.get("step_report"), r.get("err_report"))}' f'{_step_cell(r.get("step_email"), r.get("err_email"))}' + f'{e(r.get("sorgente"))}' + f'{e(r.get("azienda_nome"))}' + f'{e(r.get("sessione_id"))}' + f'{_clip(r.get("corso_descrizione") or "")}' + f'{e(r.get("corso_data"))}' + f'{e(r.get("n_iscritti"))}' f'' for r in rows ) return ( '' f'' - f'{_fth("Sorgente", "sorgente")}' - f'{_fth("Azienda", "azienda")}' - f'{_fth("Sessione", "sessione")}' - f'{_fth("Corso", "corso")}' - f'{_fth("Data", "data")}' - f'' - f'{_fth("FTP", "ftp")}' - f'{_fth("BC", "bc")}' - f'{_fth("Iscrizione", "iscrizione")}' - f'{_fth("ODV", "odv")}' - f'{_fth("SharePoint", "sharepoint")}' - f'{_fth("Report", "report")}' - f'{_fth("Email", "email")}' + f'{_fth("FTP", "ftp", step=True)}' + f'{_fth("BC", "bc", step=True)}' + f'{_fth("Iscrizione", "iscrizione", step=True)}' + f'{_fth("ODV Number", "odv_number", step=True)}' + f'{_fth("ODV Step", "odv", step=True)}' + f'{_fth("SharePoint", "sharepoint", step=True)}' + f'{_fth("Report", "report", step=True)}' + f'{_fth("Email", "email", step=True)}' + f'{_fth("Sorgente", "sorgente", data=True)}' + f'{_fth("Azienda", "azienda", data=True)}' + f'{_fth("Sessione", "sessione", data=True)}' + f'{_fth("Corso", "corso", data=True)}' + f'{_fth("Data", "data", data=True)}' + f'' f'{trs}
#N.N. iscritti
' ) diff --git a/Tool/queries/processes.py b/Tool/queries/processes.py index 080c88a..37c1884 100644 --- a/Tool/queries/processes.py +++ b/Tool/queries/processes.py @@ -4,7 +4,7 @@ from .db import query, count, e, dt, dur, badge def get_processes(db_path: str) -> list: - return query(db_path, "SELECT id, process_name, note, completed, start_run, finish_run FROM rpa_process ORDER BY start_run DESC LIMIT 50") + return query(db_path, "SELECT id, process_name, note, completed, start_run, finish_run FROM rpa_process ORDER BY id DESC LIMIT 50") def get_stats(db_path: str) -> dict: @@ -17,6 +17,19 @@ def get_stats(db_path: str) -> dict: } +def get_schema_stats(db_path: str) -> list: + return query(db_path, """ + SELECT process_name, + COUNT(*) as run_count, + SUM(CASE WHEN start_run IS NOT NULL AND finish_run IS NOT NULL + THEN CAST((julianday(finish_run) - julianday(start_run)) * 86400 AS INTEGER) + ELSE 0 END) as total_seconds + FROM rpa_process + GROUP BY process_name + ORDER BY run_count DESC + """) + + def render_table(rows: list) -> str: if not rows: return '

Nessun processo trovato.

' @@ -33,11 +46,12 @@ def render_table(rows: list) -> str: f'{dur(r.get("start_run"), r.get("finish_run"))}' f'{_badge(r.get("completed"))}' f'{e(r.get("note"))}' + f'→ Log' f'' for r in rows ) return ( '' - '' + '' f'{trs}
ProcessoInizioFineDurataStatoNoteProcessoInizioFineDurataStatoNote
' ) diff --git a/Tool/queries/sharepoint.py b/Tool/queries/sharepoint.py new file mode 100644 index 0000000..f9daccf --- /dev/null +++ b/Tool/queries/sharepoint.py @@ -0,0 +1,61 @@ +"""queries/sharepoint.py — rpa_intra_sharepoint (Intraz)""" + +from .db import query, count, e, dt, badge + + +def get_sharepoint(db_path: str) -> list: + return query(db_path, """ + SELECT + sp.id, sp.rpa_intra_ftp_json_id, sp.rpa_process_id, + sp.file_type, sp.remote_path, sp.stato, sp.note, + sp.created_at, sp.updated_at, + json_extract(f.corso, '$.IDsessione') AS sessione_id, + json_extract(f.corso, '$.descrizione') AS corso_descrizione, + json_extract(f.azienda, '$.ragione_sociale') AS azienda_nome + FROM rpa_intra_sharepoint sp + LEFT JOIN rpa_intra_ftp_json f ON f.id = sp.rpa_intra_ftp_json_id + ORDER BY sp.id DESC + LIMIT 500 + """) + + +def get_stats(db_path: str) -> dict: + return { + 'sp_total': count(db_path, "SELECT COUNT(*) as n FROM rpa_intra_sharepoint"), + 'sp_uploaded': count(db_path, "SELECT COUNT(*) as n FROM rpa_intra_sharepoint WHERE stato='uploaded'"), + 'sp_errore': count(db_path, "SELECT COUNT(*) as n FROM rpa_intra_sharepoint WHERE stato='errore'"), + 'sp_pending': count(db_path, "SELECT COUNT(*) as n FROM rpa_intra_sharepoint WHERE stato='pending'"), + } + + +def render_table(rows: list) -> str: + if not rows: + return '

Nessun file SharePoint trovato.

' + + def _badge(stato): + if stato == 'uploaded': return badge('ok', 'Uploaded') + if stato == 'errore': return badge('err', 'Errore') + return badge('warn', 'Pending') + + trs = ''.join( + f'' + f'{e(r.get("id"))}' + f'{e(r.get("rpa_intra_ftp_json_id"))}' + f'{e(r.get("sessione_id"))}' + f'{e(r.get("azienda_nome"))}' + f'{e(r.get("file_type"))}' + f'{e(r.get("remote_path"))}' + f'{_badge(r.get("stato"))}' + f'{e(r.get("note"))}' + f'{dt(r.get("created_at"))}' + f'{dt(r.get("updated_at"))}' + f'' + for r in rows + ) + return ( + '' + '' + '' + '' + f'{trs}
#JSON IDSessioneAziendaTipoRemote PathStatoNoteCreatoAggiornato
' + ) diff --git a/Tool/renderer.py b/Tool/renderer.py index e2a9579..08bab03 100644 --- a/Tool/renderer.py +++ b/Tool/renderer.py @@ -46,7 +46,7 @@ def _card(label, value, color='#2563eb') -> str: def _base(title: str, content: str, active: str, db_path: str, page_css: str = '', page_js: str = '') -> str: now = datetime.now().strftime('%d/%m/%Y %H:%M:%S') - nav = {'nav_dashboard': '', 'nav_runs': '', 'nav_logs': '', 'nav_report': ''} + nav = {'nav_dashboard': '', 'nav_runs': '', 'nav_logs': '', 'nav_report': '', 'nav_schema': ''} if active in nav: nav[active] = 'active' @@ -58,6 +58,8 @@ def _base(title: str, content: str, active: str, db_path: str, page_css: str = ' nav_step_link = f'RPA Steps' cls_api_isc = 'active' if active == 'nav_api_iscrizioni' else '' nav_report_link = f'Iscrizioni' + cls_sp = 'active' if active == 'nav_sharepoint' else '' + nav_sharepoint_link = f'SharePoint' cls_email = 'active' if active == 'nav_email' else '' nav_pec_link = f'Email' else: @@ -67,6 +69,7 @@ def _base(title: str, content: str, active: str, db_path: str, page_css: str = ' nav_pec_link = f'PEC' cls_rep = nav.get('nav_report', '') nav_report_link = f'Report' + nav_sharepoint_link = '' return _tmpl('_base.html').substitute( css=_css(), page_css=page_css, page_js=page_js, title=title, now=now, @@ -74,7 +77,8 @@ def _base(title: str, content: str, active: str, db_path: str, page_css: str = ' h1_title=h1_title, content=content, nav_step_link=nav_step_link, - nav_pec_link=nav_pec_link, nav_report_link=nav_report_link, + nav_sharepoint_link=nav_sharepoint_link, + nav_pec_link=nav_pec_link, **nav, ) diff --git a/Tool/server.py b/Tool/server.py index ab7b9b0..96fcc9d 100644 --- a/Tool/server.py +++ b/Tool/server.py @@ -8,7 +8,8 @@ from .config import _state, _access_log, DbType from .db_finder import _find_db, _pick_db_file from .pages import ( _page_dashboard, _page_runs, _page_pec, _page_logs, - _page_report, _page_documenti, _page_iscrizioni, _page_api_iscrizioni, _page_email, _page_server_logs, + _page_report, _page_documenti, _page_iscrizioni, _page_api_iscrizioni, _page_sharepoint, _page_email, _page_server_logs, + _page_schema, ) from .queries.db import e as _e, query as _query, detect_db as _detect_db from .queries import documenti as q_documenti @@ -110,11 +111,13 @@ def make_handler(db_path: str): '/': lambda: _page_dashboard(db_path), '/runs': lambda: _page_runs(db_path), '/logs': _logs_page, + '/schema': lambda: _page_schema(db_path), '/server-logs': lambda: _page_server_logs(db_path), } if db_type == DbType.INTRAZ: routes['/steps'] = lambda: _page_iscrizioni(db_path) routes['/iscrizioni-api'] = lambda: _page_api_iscrizioni(db_path) + routes['/sharepoint'] = lambda: _page_sharepoint(db_path) routes['/email'] = lambda: _page_email(db_path) else: routes['/report'] = lambda: _page_report(db_path) @@ -146,11 +149,13 @@ def run_server(db_path: str, host: str = 'localhost', port: int = 8080): if db_type == DbType.INTRAZ: print(f" RPA Steps : http://localhost:{port}/steps") print(f" Iscr. API : http://localhost:{port}/iscrizioni-api") + print(f" SharePoint : http://localhost:{port}/sharepoint") print(f" Email : http://localhost:{port}/email") else: print(f" PEC : http://localhost:{port}/pec") print(f" Documenti : http://localhost:{port}/documenti") print(f" Report : http://localhost:{port}/report") + print(f" Schema : http://localhost:{port}/schema") print(f" Log DB : http://localhost:{port}/logs") print(f" Server log : http://localhost:{port}/server-logs") print(f" DB : {db_path}") diff --git a/static/js/iscrizioni.js b/static/js/iscrizioni.js index 0aa9613..794e772 100644 --- a/static/js/iscrizioni.js +++ b/static/js/iscrizioni.js @@ -1,5 +1,17 @@ (function () { + // ── Details toggle (show/hide data columns) ────────────────────────────── + var btnDetails = document.getElementById('btn-toggle-details'); + var twMain = document.getElementById('tw-main'); + if (btnDetails && twMain) { + btnDetails.classList.add('active'); + btnDetails.addEventListener('click', function () { + var hidden = twMain.classList.toggle('data-hidden'); + btnDetails.classList.toggle('active', !hidden); + requestAnimationFrame(syncMirrorWidth); + }); + } + var COLS = ['sorgente', 'azienda', 'sessione', 'corso', 'data', 'ftp', 'bc', 'iscrizione', 'odv', 'sharepoint', 'report', 'email']; var STEP_COLS = ['ftp', 'bc', 'iscrizione', 'odv', 'sharepoint', 'report', 'email']; @@ -205,32 +217,6 @@ }); }); - // ── Sticky mirror scrollbar ─────────────────────────────────────────────── - (function () { - var tw = document.getElementById('tw-main'); - var mirror = document.getElementById('tw-mirror'); - var inner = document.getElementById('tw-inner'); - if (!tw || !mirror || !inner) return; - - function syncInnerWidth() { - inner.style.width = tw.scrollWidth + 'px'; - } - syncInnerWidth(); - new ResizeObserver(syncInnerWidth).observe(tw); - - var syncing = false; - mirror.addEventListener('scroll', function () { - if (syncing) return; - syncing = true; - tw.scrollLeft = mirror.scrollLeft; - syncing = false; - }); - tw.addEventListener('scroll', function () { - if (syncing) return; - syncing = true; - mirror.scrollLeft = tw.scrollLeft; - syncing = false; - }); - })(); + function syncMirrorWidth() {} // no-op, mirror replaced by native scrollbar })(); diff --git a/static/js/schema.js b/static/js/schema.js new file mode 100644 index 0000000..e78afb5 --- /dev/null +++ b/static/js/schema.js @@ -0,0 +1,74 @@ +(function () { + if (typeof Chart === 'undefined' || !_schemaLabels || !_schemaLabels.length) return; + + var COLORS = [ + '#2563eb','#16a34a','#d97706','#dc2626','#0284c7', + '#6d28d9','#db2777','#059669','#ea580c','#0891b2', + '#0f766e','#b45309','#7c3aed','#be185d','#0369a1' + ]; + + function palette(n) { + var out = []; + for (var i = 0; i < n; i++) out.push(COLORS[i % COLORS.length]); + return out; + } + + // --- bar chart: most used processes --- + var ctxBar = document.getElementById('chartBar'); + if (ctxBar) { + new Chart(ctxBar, { + type: 'bar', + data: { + labels: _schemaLabels, + datasets: [{ + label: 'Esecuzioni', + data: _schemaCounts, + backgroundColor: palette(_schemaLabels.length), + borderRadius: 4, + borderSkipped: false + }] + }, + options: { + responsive: true, + plugins: { + legend: { display: false } + }, + scales: { + y: { beginAtZero: true, ticks: { precision: 0 } } + } + } + }); + } + + // --- pie chart: time per process --- + var ctxPie = document.getElementById('chartPie'); + if (ctxPie) { + new Chart(ctxPie, { + type: 'pie', + data: { + labels: _schemaLabels, + datasets: [{ + data: _schemaMinutes, + backgroundColor: palette(_schemaLabels.length) + }] + }, + options: { + responsive: true, + plugins: { + legend: { position: 'right', labels: { font: { size: 12 } } }, + tooltip: { + callbacks: { + label: function (ctx) { + var min = ctx.parsed; + var h = Math.floor(min / 60); + var m = min % 60; + var dur = h > 0 ? h + 'h ' + m + 'min' : m + 'min'; + return ' ' + ctx.label + ': ' + dur; + } + } + } + } + } + }); + } +}()); diff --git a/static/js/sharepoint.js b/static/js/sharepoint.js new file mode 100644 index 0000000..9a2a4bf --- /dev/null +++ b/static/js/sharepoint.js @@ -0,0 +1,16 @@ +(function(){ + function applyFilter() { + var active = {}; + document.querySelectorAll('.email-filter input[type="checkbox"]').forEach(function(chk) { + active[chk.getAttribute('data-stato')] = chk.checked; + }); + document.querySelectorAll('tbody tr[data-stato]').forEach(function(tr) { + var stato = tr.getAttribute('data-stato'); + var show = (stato in active) ? active[stato] : true; + tr.style.display = show ? '' : 'none'; + }); + } + document.querySelectorAll('.email-filter input[type="checkbox"]').forEach(function(chk) { + chk.addEventListener('change', applyFilter); + }); +})(); diff --git a/static/pages/iscrizioni.css b/static/pages/iscrizioni.css index 3706bf9..8677a3b 100644 --- a/static/pages/iscrizioni.css +++ b/static/pages/iscrizioni.css @@ -145,28 +145,16 @@ .cfp-all { font-weight: 600 !important; } .cfp-sep { height: 1px; background: var(--brd, #e2e8f0); margin: 3px 0; } -/* Horizontal scroll — let table grow beyond container */ -.tw { overflow-x: hidden; } +/* Horizontal scroll with blue native scrollbar */ +.tw { overflow-x: auto; scrollbar-width: thin; scrollbar-color: #2563eb #dbeafe; } +.tw::-webkit-scrollbar { height: 14px; } +.tw::-webkit-scrollbar-track { background: #dbeafe; border-radius: 99px; } +.tw::-webkit-scrollbar-thumb { background: #2563eb; border-radius: 99px; border: 2px solid #dbeafe; } +.tw::-webkit-scrollbar-thumb:hover { background: #1d4ed8; } .tw table { width: max-content; min-width: 100%; } -/* Sticky mirror scrollbar — always visible at viewport bottom */ -.tw-scroll-mirror { - position: sticky; - bottom: 0; - overflow-x: scroll; - overflow-y: hidden; - height: 14px; - background: var(--surf); - border-top: 1px solid var(--brd); - z-index: 40; -} -.tw-scroll-inner { height: 1px; } - -.tw-scroll-mirror { scrollbar-color: #2563eb #dbeafe; scrollbar-width: thin; } -.tw-scroll-mirror::-webkit-scrollbar { height: 14px; } -.tw-scroll-mirror::-webkit-scrollbar-track { background: #dbeafe; border-radius: 99px; } -.tw-scroll-mirror::-webkit-scrollbar-thumb { background: #2563eb; border-radius: 99px; border: 2px solid #dbeafe; } -.tw-scroll-mirror::-webkit-scrollbar-thumb:hover { background: #1d4ed8; } +/* Mirror kept for layout, hidden */ +.tw-scroll-mirror { display: none; } /* Step cells with inline error */ .step-td { vertical-align: top; min-width: 80px; } @@ -201,3 +189,30 @@ background: #eff6ff; border-color: var(--pri, #2563eb); } + +/* Details toggle button */ +.btn-toggle-details { + background: none; + border: 1px solid var(--brd, #e2e8f0); + border-radius: 5px; + padding: .28rem .75rem; + font-size: .82rem; + cursor: pointer; + color: var(--mut, #64748b); + font-family: inherit; + transition: background .12s, border-color .12s, color .12s; +} +.btn-toggle-details.active { + background: #eff6ff; + border-color: var(--pri, #2563eb); + color: var(--pri, #2563eb); +} +.btn-toggle-details:hover { border-color: var(--pri, #2563eb); color: var(--pri, #2563eb); } + +/* Hide data columns when Details is off */ +.data-hidden .data-th, +.data-hidden .data-td { display: none; } + +/* Log link button inside # column */ +.td-id { white-space: nowrap; } +.td-id .btn-log { margin-left: 5px; vertical-align: middle; } diff --git a/static/pages/schema.css b/static/pages/schema.css new file mode 100644 index 0000000..addba00 --- /dev/null +++ b/static/pages/schema.css @@ -0,0 +1,5 @@ +.schema-charts{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:8px} +.chart-box{background:var(--surf);border:1px solid var(--brd);border-radius:8px;padding:20px 24px} +.chart-box h3{font-size:.75rem;font-weight:600;color:var(--mut);margin-bottom:16px; + text-transform:uppercase;letter-spacing:.05em} +@media(max-width:800px){.schema-charts{grid-template-columns:1fr}} diff --git a/static/style.css b/static/style.css index 8328285..7d560e7 100644 --- a/static/style.css +++ b/static/style.css @@ -73,3 +73,6 @@ th.sort-desc::after{content:' \25BC';font-size:.65rem;opacity:.7} .modal-box li{background:var(--bg);border:1px solid var(--brd);border-radius:6px;padding:10px 14px;font-size:.875rem;font-weight:500} .modal-close{position:absolute;top:12px;right:14px;background:none;border:none;font-size:1.2rem;cursor:pointer;color:var(--mut);line-height:1;padding:2px 6px;border-radius:4px} .modal-close:hover{color:var(--txt);background:var(--bg)} +/* Log link button (shared across pages) */ +.btn-log{display:inline-block;padding:1px 7px;border:1px solid var(--brd,#e2e8f0);border-radius:4px;font-size:.78rem;line-height:1.6;color:var(--pri,#2563eb);text-decoration:none;white-space:nowrap;transition:background .12s,border-color .12s} +.btn-log:hover{background:#eff6ff;border-color:var(--pri,#2563eb)} diff --git a/templates/_base.html b/templates/_base.html index 7f624e9..f2c8bd0 100644 --- a/templates/_base.html +++ b/templates/_base.html @@ -10,9 +10,10 @@

${h1_title}

@@ -30,6 +31,7 @@
diff --git a/templates/iscrizioni.html b/templates/iscrizioni.html index f947177..15674c7 100644 --- a/templates/iscrizioni.html +++ b/templates/iscrizioni.html @@ -2,6 +2,7 @@

📋 Corsi Intraziendali

+ +
+
+
+ +
+
+
+ +
+
${tbl_sharepoint}
+