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'N. '
- 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'N. iscritti '
f' {trs}
'
)
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 (
''
- 'Processo Inizio Fine Durata Stato Note '
+ 'Processo Inizio Fine Durata Stato Note '
f' {trs}
'
)
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 (
+ ''
+ '# JSON ID Sessione Azienda '
+ 'Tipo Remote Path Stato Note '
+ 'Creato Aggiornato '
+ f' {trs}
'
+ )
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 @@
${nav_step_link}
${nav_report_link}
+ ${nav_sharepoint_link}
${nav_pec_link}
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 @@