This commit is contained in:
Luca Banfi
2026-05-06 13:59:13 +02:00
commit ebafa611be
39 changed files with 2763 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# Environment / runtime config
.env
# Python bytecode
__pycache__/
*.pyc
*.pyo
# SQLite database files
*.db
*.db-journal
*.db-wal
*.db-shm
+495
View File
@@ -0,0 +1,495 @@
"""
src/services/Reports/Reports.py
Web server che crea reports HTML live del processo RPA:
"""
__version__ = '1.0.0'
import os
import sys
from collections import deque
from datetime import datetime
from pathlib import Path
from string import Template
from http.server import BaseHTTPRequestHandler, HTTPServer
# ==============================================================
current = Path(__file__).resolve().parent
while current != current.parent:
if (current / "src").exists():
src_dir = current / "src"
break
current = current.parent
else:
raise FileNotFoundError("'src' folder not found in parent directories")
if str(src_dir) not in sys.path:
sys.path.insert(0, str(src_dir))
# ==============================================================
REPORTS_DIR = Path(__file__).resolve().parent
TMPL_DIR = REPORTS_DIR / "templates"
CSS_PATH = REPORTS_DIR / "static" / "style.css"
PAGES_CSS_DIR = REPORTS_DIR / "static" / "pages"
JS_DIR = REPORTS_DIR / "static" / "js"
DB_DEFAULT_DIR = src_dir.parent / "data_rpa" / "db" # <root>/data_rpa/db
# buffer circolare degli accessi HTTP (max 200 righe)
_access_log: deque = deque(maxlen=200)
# stato condiviso (db_path aggiornabile via /change-db)
_state: dict = {'db_path': '', 'step': 'unknown'}
def _refresh_step(db_path: str) -> str:
"""Detect and cache the RPA step for the current DB."""
step = _detect_step(db_path)
_state['step'] = step
return step
import json
from urllib.parse import urlparse, parse_qs
from queries import processes as q_processes
from queries import pec as q_pec
from queries import logs as q_logs
from queries import report as q_report
from queries import documenti as q_documenti
from queries import iscrizioni as q_iscrizioni
from queries.db import e as _e, query as _query, detect_step as _detect_step
# ------------------------------------------------------------------ templates
def _css() -> str:
return CSS_PATH.read_text(encoding='utf-8')
def _page_css(name: str) -> str:
parts = []
shared = PAGES_CSS_DIR / 'filters.css'
if shared.exists():
parts.append(shared.read_text(encoding='utf-8'))
main = PAGES_CSS_DIR / f'{name}.css'
if main.exists():
parts.append(main.read_text(encoding='utf-8'))
for f in sorted(PAGES_CSS_DIR.glob(f'{name}_*.css')):
parts.append(f.read_text(encoding='utf-8'))
return '\n'.join(parts)
def _page_js(name: str) -> str:
parts = []
shared = JS_DIR / 'filters.js'
if shared.exists():
parts.append(shared.read_text(encoding='utf-8'))
main = JS_DIR / f'{name}.js'
if main.exists():
parts.append(main.read_text(encoding='utf-8'))
for f in sorted(JS_DIR.glob(f'{name}_*.js')):
parts.append(f.read_text(encoding='utf-8'))
return '\n'.join(parts)
def _tmpl(name: str) -> Template:
return Template((TMPL_DIR / name).read_text(encoding='utf-8'))
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': ''}
if active in nav:
nav[active] = 'active'
step = _state.get('step', 'unknown')
h1_title = 'RPA &mdash; Corsi Intraziendali' if step == 'step2' else 'RPA &mdash; Comunicazioni Regione Lombardia'
# dynamic nav items depend on step
if step == 'step2':
cls = 'active' if active == 'nav_iscrizioni' else ''
nav_step_link = f'<a href="/iscrizioni" class="{cls}">Corsi Intraziendali</a>'
nav_pec_link = ''
nav_report_link = ''
else:
cls = 'active' if active == 'nav_documenti' else ''
nav_step_link = f'<a href="/documenti" class="{cls}">Documenti</a>'
cls_pec = 'active' if active == 'nav_pec' else ''
nav_pec_link = f'<a href="/pec" class="{cls_pec}">PEC</a>'
cls_rep = nav.get('nav_report', '')
nav_report_link = f'<a href="/report" class="{cls_rep}">Report</a>'
return _tmpl('_base.html').substitute(
css=_css(), page_css=page_css, page_js=page_js, title=title, now=now,
db_name=_e(Path(db_path).name),
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,
)
# ------------------------------------------------------------------ DB picker
def _pick_db_file() -> str:
"""Apre una finestra di dialogo per scegliere il file .db."""
try:
import tkinter as tk
from tkinter import filedialog
root = tk.Tk()
root.withdraw()
root.attributes('-topmost', True)
path = filedialog.askopenfilename(
title="Seleziona il file database RPA",
filetypes=[("SQLite Database", "*.db"), ("Tutti i file", "*.*")]
)
root.destroy()
return path or ''
except Exception as e:
print(f"Impossibile aprire il dialogo file: {e}")
return ''
def _find_db(db_file: str) -> str:
"""Cerca il DB in DB_DEFAULT_DIR; se non trovato apre popup."""
candidate = DB_DEFAULT_DIR / db_file
if candidate.exists():
return str(candidate)
print(f"DB '{db_file}' non trovato in {DB_DEFAULT_DIR}. Apertura dialogo selezione file...")
chosen = _pick_db_file()
if chosen and os.path.exists(chosen):
return chosen
raise FileNotFoundError(
f"DB '{db_file}' non trovato in {DB_DEFAULT_DIR}. "
"Imposta RPA_DB_PATH con il percorso assoluto al file .db"
)
# ------------------------------------------------------------------ DB helpers
def _load_data(db_path: str) -> dict:
step = _state.get('step', 'unknown')
middle_stats = (
q_iscrizioni.get_stats(db_path) if step == 'step2'
else q_pec.get_stats(db_path)
)
stats = {
**q_processes.get_stats(db_path),
**middle_stats,
**q_logs.get_stats(db_path),
}
return {
'processes': q_processes.get_processes(db_path),
'pec': [] if step == 'step2' else q_pec.get_pec(db_path),
'logs': q_logs.get_logs(db_path),
'stats': stats,
}
def _card(label, value, color='#2563eb') -> str:
return f'<div class="card"><div class="cv" style="color:{color}">{_e(value)}</div><div class="cl">{_e(label)}</div></div>'
# ------------------------------------------------------------------ page builders
def _page_dashboard(db_path: str) -> str:
s = _load_data(db_path)['stats']
step = _state.get('step', 'unknown')
cards_runs = (
_card("Run totali", s['total_runs'])
+ _card("Completati", s['completed_runs'], '#16a34a')
+ _card("Incompleti", s['total_runs'] - s['completed_runs'], '#dc2626')
)
cards_logs = (
_card("Errori", s['log_errors'], '#dc2626')
+ _card("Warning", s['log_warnings'], '#d97706')
)
if step == 'step2':
cards_middle = (
_card("Corsi totali", s.get('isc_total', 0))
+ _card("Completati", s.get('isc_ok', 0), '#16a34a')
+ _card("Con errore", s.get('isc_errore', 0), '#dc2626')
+ _card("In corso", s.get('isc_pending', 0), '#d97706')
)
section_middle = (
'<section>'
'<h2><a href="/iscrizioni" class="sec-link">&#128203; Corsi Intraziendali</a></h2>'
f'<div class="grid">{cards_middle}</div>'
'</section>'
)
else:
cards_middle = (
_card("PEC totali", s.get('total_pec', 0))
+ _card("Inviate", s.get('pec_inviato', 0), '#16a34a')
+ _card("Pending", s.get('pec_pending', 0), '#d97706')
+ _card("Errori", s.get('pec_errore', 0), '#dc2626')
)
section_middle = (
'<section>'
'<h2><a href="/pec" class="sec-link">&#9993; Comunicazioni PEC</a></h2>'
f'<div class="grid">{cards_middle}</div>'
'</section>'
)
content = _tmpl('dashboard.html').substitute(
cards_runs=cards_runs, section_middle=section_middle, cards_logs=cards_logs
)
return _base('Dashboard', content, 'nav_dashboard', db_path, _page_css('dashboard'), _page_js('dashboard'))
def _page_runs(db_path: str) -> str:
content = _tmpl('runs.html').substitute(
tbl_processes=q_processes.render_table(q_processes.get_processes(db_path))
)
return _base('Processi', content, 'nav_runs', db_path, _page_css('runs'), _page_js('runs'))
def _page_pec(db_path: str) -> str:
content = _tmpl('pec.html').substitute(
tbl_pec=q_pec.render_table(q_pec.get_pec(db_path))
)
return _base('PEC', content, 'nav_pec', db_path, _page_css('pec'), _page_js('pec'))
def _page_logs(db_path: str, run_id: int = None) -> str:
run_ids = [r['rpa_process_id'] for r in q_logs.get_last_run_ids(db_path)]
if not run_ids:
content = _tmpl('logs.html').substitute(
run_info='nessun log', pagination='', tbl_logs='<p class="empty">Nessun log trovato.</p>'
)
return _base('Log DB', content, 'nav_logs', db_path, _page_css('logs'), _page_js('logs'))
if run_id is None or run_id not in run_ids:
run_id = run_ids[0]
idx = run_ids.index(run_id)
prev_id = run_ids[idx + 1] if idx + 1 < len(run_ids) else None
next_id = run_ids[idx - 1] if idx > 0 else None
prev_btn = (f'<a class="pg-btn" href="/logs?run={prev_id}">&#8592; Precedente</a>'
if prev_id else '<span class="pg-btn disabled">&#8592; Precedente</span>')
next_btn = (f'<a class="pg-btn" href="/logs?run={next_id}">Successivo &#8594;</a>'
if next_id else '<span class="pg-btn disabled">Successivo &#8594;</span>')
options = ''.join(
f'<option value="{rid}"{" selected" if rid == run_id else ""}>Run #{rid}</option>'
for rid in run_ids
)
pagination = (
f'<div class="log-pagination">'
f'{prev_btn}'
f'<select class="pg-select" onchange="location.href=\'/logs?run=\'+this.value">{options}</select>'
f'{next_btn}'
f'<span class="pg-info">Run {idx + 1} / {len(run_ids)}</span>'
f'</div>'
)
logs = q_logs.get_logs_by_run(db_path, run_id)
content = _tmpl('logs.html').substitute(
run_info=f'Run #{run_id}',
pagination=pagination,
tbl_logs=q_logs.render_table(logs),
)
return _base('Log DB', content, 'nav_logs', db_path, _page_css('logs'), _page_js('logs'))
def _page_report(db_path: str) -> str:
rows = q_report.get_report(db_path)
content = _tmpl('report.html').substitute(
tbl_report=q_report.render_table(rows)
)
return _base('Report', content, 'nav_report', db_path, _page_css('report'), _page_js('report'))
def _page_documenti(db_path: str) -> str:
rows = q_documenti.get_documenti(db_path)
content = _tmpl('documenti.html').substitute(
sessions_html=q_documenti.render_page(rows)
)
return _base('Documenti', content, 'nav_documenti', db_path, _page_css('documenti'), _page_js('documenti'))
def _page_iscrizioni(db_path: str) -> str:
rows = q_iscrizioni.get_corsi(db_path)
content = _tmpl('iscrizioni.html').substitute(
tbl_iscrizioni=q_iscrizioni.render_table(rows),
filter_stato_html=q_iscrizioni.render_stato_filters(),
)
return _base('Corsi Intraziendali', content, 'nav_iscrizioni', db_path, _page_css('iscrizioni'), _page_js('iscrizioni'))
def _page_server_logs(db_path: str) -> str:
rows = ''.join(
f'<tr>'
f'<td>{_e(e["ts"])}</td><td>{_e(e["method"])}</td><td>{_e(e["path"])}</td>'
f'<td style="color:{"#16a34a" if e["code"]<400 else "#d97706" if e["code"]<500 else "#dc2626"};font-weight:600">{e["code"]}</td>'
f'<td>{_e(e["client"])}</td>'
f'</tr>'
for e in reversed(_access_log)
)
table = (
'<table><thead><tr>'
'<th>Timestamp</th><th>Method</th><th>Path</th><th>Status</th><th>Client</th>'
'</tr></thead><tbody>'
+ (rows or '<tr><td colspan=5 class="empty">Nessuna richiesta ancora.</td></tr>')
+ '</tbody></table>'
)
content = f'<section><h2>Accessi HTTP (ultimi 200)</h2><div class="tw">{table}</div></section>'
return _base('Server Logs', content, '', db_path, _page_css('server-logs'))
def make_handler(db_path: str):
class ReportHandler(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
print(f"[{datetime.now().strftime('%H:%M:%S')}] {fmt % args}")
def _send(self, code: int, body: str, content_type: str = 'text/html; charset=utf-8'):
encoded = body.encode('utf-8')
self.send_response(code)
self.send_header('Content-Type', content_type)
self.send_header('Content-Length', str(len(encoded)))
self.end_headers()
self.wfile.write(encoded)
_access_log.append({
'ts': datetime.now().strftime('%d/%m/%Y %H:%M:%S'),
'method': self.command,
'path': self.path,
'code': code,
'client': self.client_address[0],
})
def do_GET(self):
if self.path == '/change-db':
chosen = _pick_db_file()
if chosen and os.path.exists(chosen):
_state['db_path'] = chosen
_refresh_step(chosen)
self.send_response(302)
self.send_header('Location', '/')
self.end_headers()
_access_log.append({
'ts': datetime.now().strftime('%d/%m/%Y %H:%M:%S'),
'method': self.command, 'path': self.path,
'code': 302, 'client': self.client_address[0],
})
return
db_path = _state['db_path']
step = _state.get('step', 'unknown')
# JSON API
if self.path == '/api/documenti' and step != 'step2':
try:
rows = q_documenti.get_documenti(db_path)
html = q_documenti.render_page(rows)
ts = datetime.now().strftime('%d/%m/%Y %H:%M:%S')
self._send(200, json.dumps({'html': html, 'ts': ts}), 'application/json; charset=utf-8')
except Exception as exc:
self._send(500, json.dumps({'error': str(exc)}), 'application/json; charset=utf-8')
return
if self.path.startswith('/api/docs') and step != 'step2':
qs = parse_qs(urlparse(self.path).query)
sid = qs.get('sessione_id', [None])[0]
if sid:
try:
rows = _query(db_path,
"SELECT subfolder, filename, doc_status, sp_remote_path, sp_web_url, uploaded_at "
"FROM sessione_documenti WHERE sessione_id=? ORDER BY subfolder, filename",
(int(sid),)
)
self._send(200, json.dumps(rows), 'application/json; charset=utf-8')
except Exception as exc:
self._send(500, json.dumps({'error': str(exc)}), 'application/json; charset=utf-8')
else:
self._send(400, json.dumps({'error': 'missing sessione_id'}), 'application/json; charset=utf-8')
return
if self.path == '/api/iscrizioni' and step == 'step2':
try:
rows = q_iscrizioni.get_iscrizioni(db_path)
ts = datetime.now().strftime('%d/%m/%Y %H:%M:%S')
self._send(200, json.dumps({'rows': rows, 'ts': ts}), 'application/json; charset=utf-8')
except Exception as exc:
self._send(500, json.dumps({'error': str(exc)}), 'application/json; charset=utf-8')
return
parsed = urlparse(self.path)
qs = parse_qs(parsed.query)
path = parsed.path
def _logs_page():
raw = qs.get('run', [None])[0]
run_id = int(raw) if raw and raw.isdigit() else None
return _page_logs(db_path, run_id=run_id)
routes = {
'/': lambda: _page_dashboard(db_path),
'/runs': lambda: _page_runs(db_path),
'/logs': _logs_page,
'/server-logs': lambda: _page_server_logs(db_path),
}
if step == 'step2':
routes['/iscrizioni'] = lambda: _page_iscrizioni(db_path)
else:
routes['/report'] = lambda: _page_report(db_path)
routes['/documenti'] = lambda: _page_documenti(db_path)
routes['/pec'] = lambda: _page_pec(db_path)
fn = routes.get(path)
if fn:
try:
self._send(200, fn())
except Exception as exc:
self._send(500, f"<pre>Errore: {_e(str(exc))}</pre>")
elif path == '/ping':
self._send(200, 'ok', 'text/plain; charset=utf-8')
else:
self._send(404, '<h1>404 Not Found</h1>')
return ReportHandler
def run_server(db_path: str, host: str = 'localhost', port: int = 8080):
_state['db_path'] = db_path
step = _refresh_step(db_path)
handler = make_handler(db_path)
server = HTTPServer((host, port), handler)
print(f"RPA Report server avviato [step: {step}]")
print(f" Dashboard : http://localhost:{port}/")
print(f" Processi : http://localhost:{port}/runs")
if step == 'step2':
print(f" Iscrizioni : http://localhost:{port}/iscrizioni")
else:
print(f" PEC : http://localhost:{port}/pec")
print(f" Documenti : http://localhost:{port}/documenti")
print(f" Log DB : http://localhost:{port}/logs")
print(f" Report : http://localhost:{port}/report")
print(f" Server log : http://localhost:{port}/server-logs")
print(f" DB : {db_path}")
print("Ctrl+C per fermare.\n")
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nServer fermato.")
server.server_close()
# ------------------------------------------------------------------ entrypoint
if __name__ == '__main__':
from dotenv import load_dotenv
load_dotenv()
db_path = os.environ.get('RPA_DB_PATH')
if not db_path:
db_file = os.environ.get('RPA_DB_FILE', 'rpa_FORMAZIONE.db')
db_path = _find_db(db_file)
port = int(os.environ.get('RPA_REPORT_PORT', 8080))
run_server(db_path, port=port)
+14
View File
@@ -0,0 +1,14 @@
Web server che serve un report HTML live del processo RPA:
- GET / -> pagina report (dati freschi dal DB ad ogni richiesta)
- GET /ping -> healthcheck
Avvio:
```bash
cd src
python rpa/Reports/Reports.py
```
Opzionalmente via env:
```bash
RPA_DB_FILE=rpa_FORMAZIONE.db RPA_REPORT_PORT=8080 python rpa/Reports/Reports.py
```
View File
+67
View File
@@ -0,0 +1,67 @@
"""queries/db.py — helper condiviso per query SQLite"""
import sqlite3
from datetime import datetime
def query(db_path: str, sql: str, params: tuple = ()) -> list:
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
try:
cur = conn.cursor()
cur.execute(sql, params)
return [dict(r) for r in cur.fetchall()]
finally:
conn.close()
def count(db_path: str, sql: str) -> int:
rows = query(db_path, sql)
return rows[0]['n'] if rows else 0
# ------------------------------------------------------------------ HTML utils
def e(v) -> str:
if v is None: return '-'
return str(v).replace('&','&amp;').replace('<','&lt;').replace('>','&gt;').replace('"','&quot;')
def dt(v) -> str:
if not v: return '-'
try:
return datetime.fromisoformat(str(v)).strftime('%d/%m/%Y %H:%M:%S')
except Exception:
return str(v)
def d(v) -> str:
if not v: return '-'
try:
return datetime.fromisoformat(str(v)).strftime('%d/%m/%Y')
except Exception:
return str(v)
def dur(start, finish) -> str:
if not start or not finish: return '-'
try:
s = int((datetime.fromisoformat(str(finish)) - datetime.fromisoformat(str(start))).total_seconds())
return f"{s//60}m {s%60}s" if s >= 60 else f"{s}s"
except Exception:
return '-'
def badge(cls: str, text: str) -> str:
return f'<span class="badge {cls}">{text}</span>'
def detect_step(db_path: str) -> str:
"""Returns 'step1', 'step2', or 'unknown' based on DB schema.
step1 (db_reg_lombardia) → has table 'sessione_documenti'
step2 (db_corsi_intraziendali) → has table 'rpa_intra_ftp_json'
"""
rows = query(db_path, "SELECT name FROM sqlite_master WHERE type='table'")
tables = {r['name'] for r in rows}
if 'sessione_documenti' in tables:
return 'step1'
if 'rpa_intra_api_iscrizione' in tables:
return 'step2'
return 'unknown'
+126
View File
@@ -0,0 +1,126 @@
"""queries/documenti.py — sessione_documenti con join sessione"""
from .db import query, e, dt
_STATUS_BADGE = {
'trovato': '<span class="badge doc-trovato">trovato</span>',
'caricato': '<span class="badge doc-caricato">caricato</span>',
'errore': '<span class="badge doc-errore">errore</span>',
'warning': '<span class="badge doc-warning">warning</span>',
}
def get_documenti(db_path: str) -> list:
sql = """
SELECT
sd.sessione_id,
s.corso,
s.azienda,
sd.subfolder,
sd.doc_type,
sd.doc_status,
sd.filename,
sd.note,
sd.sp_web_url,
sd.uploaded_at
FROM sessione_documenti sd
LEFT JOIN sessione s ON s.id = sd.sessione_id
ORDER BY sd.sessione_id, sd.subfolder, sd.filename
"""
return query(db_path, sql)
def get_stats(db_path: str) -> dict:
rows = get_documenti(db_path)
return {
'doc_trovato': sum(1 for r in rows if r['doc_status'] == 'trovato'),
'doc_caricato': sum(1 for r in rows if r['doc_status'] == 'caricato'),
'doc_errore': sum(1 for r in rows if r['doc_status'] == 'errore'),
'doc_sessioni': len({r['sessione_id'] for r in rows}),
}
def render_page(rows: list) -> str:
if not rows:
return '<p class="empty-msg">Nessun documento registrato.</p>'
# Group by sessione_id
sessions: dict = {}
order: list = []
for r in rows:
sid = r['sessione_id']
if sid not in sessions:
sessions[sid] = {'corso': r['corso'], 'azienda': r['azienda'], 'docs': []}
order.append(sid)
sessions[sid]['docs'].append(r)
html_parts = []
for sid in order:
s = sessions[sid]
docs = s['docs']
n_trovato = sum(1 for d in docs if d['doc_status'] == 'trovato')
n_caricato = sum(1 for d in docs if d['doc_status'] == 'caricato')
n_errore = sum(1 for d in docs if d['doc_status'] == 'errore')
n_warning = sum(1 for d in docs if d['doc_status'] == 'warning')
counts_html = (
(f'<span class="cnt cnt-caricato">{n_caricato} caricati</span>' if n_caricato else '')
+ (f'<span class="cnt cnt-trovato">{n_trovato} trovati</span>' if n_trovato else '')
+ (f'<span class="cnt cnt-warning">{n_warning} warning</span>' if n_warning else '')
+ (f'<span class="cnt cnt-errore">{n_errore} errori</span>' if n_errore else '')
)
tbody_rows = ''
for d in docs:
status = d['doc_status'] or ''
badge_html = _STATUS_BADGE.get(status, f'<span class="badge">{e(status)}</span>')
if d['sp_web_url']:
sp_cell = f'<a href="{e(d["sp_web_url"])}" target="_blank" rel="noopener" class="sp-link" title="Apri su SharePoint">🔗</a>'
else:
sp_cell = '<span class="sp-missing" title="Non caricato su SharePoint">❌</span>'
tbody_rows += (
f'<tr class="doc-row doc-{e(status)}"'
f' data-filename="{e(d["filename"] or "")}"'
f' data-doctype="{e(d["doc_type"] or "")}"'
f' data-subfolder="{e(d["subfolder"] or "")}"'
f' data-status="{e(status)}"'
f' data-note="{e(d["note"] or "")}"'
f' data-uploaded="{e(dt(d["uploaded_at"]) if status == "caricato" else "")}">'
f'<td class="col-filename" title="{e(d["filename"] or "")}">{"<span style=\'color:#f97316;\'>" + e(d["filename"]) + "</span>" if d["filename"] and d["filename"].startswith("[") and d["filename"].endswith("]") else e(d["filename"])}</td>'
f'<td class="col-doctype">{e(d["doc_type"])}</td>'
f'<td class="col-subfolder">{e(d["subfolder"])}</td>'
f'<td class="col-status">{badge_html}</td>'
f'<td class="col-sp">{sp_cell}</td>'
f'<td class="col-note">{e(d["note"])}</td>'
f'<td class="col-uploaded">{dt(d["uploaded_at"]) if status == "caricato" else "-"}</td>'
f'</tr>'
)
subtable = (
'<table class="doc-subtable">'
'<thead><tr>'
'<th>File</th><th>Tipo</th><th>Cartella</th>'
'<th>Stato</th><th>SP</th><th>Note</th><th>Caricato</th>'
'</tr></thead>'
f'<tbody>{tbody_rows}</tbody>'
'</table>'
)
open_attr = ' open' if (n_errore > 0 or n_warning > 0) else ''
html_parts.append(
f'<details class="sess-block"{open_attr}'
f' data-sessione="{e(sid)}"'
f' data-corso="{e(s["corso"])}"'
f' data-azienda="{e(s["azienda"])}">'
f'<summary class="sess-summary">'
f'<span class="sess-id">#{e(sid)}</span>'
f'<span class="sess-corso">{e(s["corso"])}</span>'
f'<span class="sess-azienda">{e(s["azienda"])}</span>'
f'<span class="sess-counts">{counts_html}</span>'
f'</summary>'
f'<div class="sess-docs">{subtable}</div>'
f'</details>'
)
return '\n'.join(html_parts)
+121
View File
@@ -0,0 +1,121 @@
"""queries/iscrizioni.py — corsi intraziendali (step2: rpa_intra_ftp_json + rpa_intra_steps_status)"""
from .db import query, e, dt, badge
from rpa_intraziendale.shared.SqlLite.tables import STEP_STATES, STEP_STATE_LABELS
_STEP_BADGE = {
'done': lambda: badge('ok', 'done'),
'processing': lambda: badge('proc', 'processing'),
'pending': lambda: badge('warn', 'pending'),
'error': lambda: badge('err', 'error'),
'skipped': lambda: badge('info', 'skipped'),
}
def _sb(v) -> str:
fn = _STEP_BADGE.get(v)
return fn() if fn else (badge('warn', 'pending') if not v else e(v))
def get_corsi(db_path: str) -> list:
return query(db_path, """
SELECT
f.id,
f.rpa_process_id,
f.company_code,
m.ragione_sociale AS sorgente,
json_extract(f.azienda, '$.ragione_sociale') AS azienda_nome,
json_extract(f.corso, '$.IDsessione') AS sessione_id,
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,
s.step_ftp,
s.step_bc,
s.step_iscrizione,
s.step_odv,
s.step_sharepoint,
s.step_report,
s.step_email,
s.error_note,
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_intra_steps_status s ON s.rpa_intra_ftp_json_id = f.id
ORDER BY f.id DESC
""")
_ALL_STEPS = ('step_ftp', 'step_bc', 'step_iscrizione', 'step_odv',
'step_sharepoint', 'step_report', 'step_email')
def get_stats(db_path: str) -> dict:
rows = get_corsi(db_path)
return {
'isc_total': len(rows),
'isc_ok': sum(1 for r in rows if all(r.get(s) in ('done', 'skipped') for s in _ALL_STEPS)),
'isc_errore': sum(1 for r in rows if any(r.get(s) == 'error' for s in _ALL_STEPS)),
'isc_pending': sum(1 for r in rows if _row_status(r) in ('pending', 'processing', 'skipped')),
}
def _row_status(r: dict) -> str:
if any(r.get(s) == 'error' for s in _ALL_STEPS):
return 'error'
if all(r.get(s) in ('done', 'skipped') for s in _ALL_STEPS):
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'
def render_stato_filters() -> str:
return ''.join(
f'<label><input type="checkbox" data-status="{s}" checked> {e(STEP_STATE_LABELS.get(s, s))}</label>'
for s in STEP_STATES
)
def render_table(rows: list) -> str:
if not rows:
return '<p class="empty">Nessun corso trovato.</p>'
trs = ''.join(
f'<tr'
f' data-status="{_row_status(r)}"'
f' data-sorgente="{e(r.get("sorgente"))}"'
f' data-sessione="{e(r.get("sessione_id"))}">'
f'<td>{e(r.get("id"))}</td>'
f'<td>{e(r.get("sorgente"))}</td>'
f'<td>{e(r.get("azienda_nome"))}</td>'
f'<td>{e(r.get("sessione_id"))}</td>'
f'<td class="nc">{e(r.get("corso_descrizione"))}</td>'
f'<td>{e(r.get("corso_data"))}</td>'
f'<td>{e(r.get("n_iscritti"))}</td>'
f'<td>{_sb(r.get("step_ftp"))}</td>'
f'<td>{_sb(r.get("step_bc"))}</td>'
f'<td>{_sb(r.get("step_iscrizione"))}</td>'
f'<td>{_sb(r.get("step_odv"))}</td>'
f'<td>{_sb(r.get("step_sharepoint"))}</td>'
f'<td>{_sb(r.get("step_report"))}</td>'
f'<td>{_sb(r.get("step_email"))}</td>'
f'<td class="nc">{e(r.get("error_note"))}</td>'
f'</tr>'
for r in rows
)
return (
'<table><thead><tr>'
'<th>#</th><th>Sorgente</th><th>Azienda</th><th>Sessione</th>'
'<th>Corso</th><th>Data</th><th>N.</th>'
'<th>FTP</th><th>BC</th><th>Iscrizione</th><th>ODV</th>'
'<th>SharePoint</th><th>Report</th><th>Email</th>'
'<th>Note</th>'
f'</tr></thead><tbody>{trs}</tbody></table>'
)
# backward-compat alias used by Reports.py
def get_iscrizioni(db_path: str) -> list:
return get_corsi(db_path)
+51
View File
@@ -0,0 +1,51 @@
"""queries/logs.py — rpa_logs"""
from .db import query, count, e, dt, badge
def get_logs(db_path: str) -> list:
return query(db_path, "SELECT * FROM rpa_logs ORDER BY date_log DESC LIMIT 200")
def get_last_run_ids(db_path: str, n: int = 10) -> list:
"""Returns the last N distinct rpa_process_id values that have logs, DESC."""
return query(db_path,
"SELECT rpa_process_id FROM rpa_logs WHERE rpa_process_id IS NOT NULL "
"GROUP BY rpa_process_id ORDER BY rpa_process_id DESC LIMIT ?", (n,))
def get_logs_by_run(db_path: str, run_id: int) -> list:
return query(db_path,
"SELECT * FROM rpa_logs WHERE rpa_process_id=? ORDER BY date_log DESC", (run_id,))
def get_stats(db_path: str) -> dict:
return {
'log_errors': count(db_path, "SELECT COUNT(*) as n FROM rpa_logs WHERE type='error'"),
'log_warnings': count(db_path, "SELECT COUNT(*) as n FROM rpa_logs WHERE type='warning'"),
}
def render_table(rows: list) -> str:
if not rows:
return '<p class="empty">Nessun log trovato.</p>'
def _badge(tipo):
m = {'log': ('log', 'Log'), 'warning': ('warn', 'Warning'), 'error': ('err', 'Error')}
cls, lbl = m.get(tipo, ('', ''))
return badge(cls, lbl) if cls else e(tipo)
trs = ''.join(
f'<tr data-type="{e(r.get("type", ""))}">'
f'<td>{e(r.get("rpa_process_id"))}</td>'
f'<td>{dt(r.get("date_log"))}</td>'
f'<td>{_badge(r.get("type"))}</td>'
f'<td class="nc">{e(r.get("log"))}</td>'
f'</tr>'
for r in rows
)
return (
'<table><thead><tr>'
'<th>Processo</th><th>Data</th><th>Tipo</th><th>Messaggio</th>'
f'</tr></thead><tbody>{trs}</tbody></table>'
)
+58
View File
@@ -0,0 +1,58 @@
"""queries/pec.py — pec_comunicazioni"""
from .db import query, count, e, dt, badge
_PEC_JOIN = """
SELECT
p.id, p.sessione_id, p.tipo_comunicazione,
p.email_ats, p.stato_invio, p.data_invio_pec, p.created_at,
p.note,
s.articolo_cod, s.corso, s.azienda,
sl.data AS data_sessione, sl.indirizzo, sl.docenti
FROM pec_comunicazioni p
JOIN sessione s ON s.id = p.sessione_id
LEFT JOIN sessione_lezione sl ON sl.sessione_id = p.sessione_id
"""
def get_pec(db_path: str) -> list:
return query(db_path, _PEC_JOIN + "ORDER BY p.created_at DESC LIMIT 200")
def get_stats(db_path: str) -> dict:
return {
'total_pec': count(db_path, "SELECT COUNT(*) as n FROM pec_comunicazioni"),
'pec_inviato': count(db_path, "SELECT COUNT(*) as n FROM pec_comunicazioni WHERE stato_invio='inviata'"),
'pec_errore': count(db_path, "SELECT COUNT(*) as n FROM pec_comunicazioni WHERE stato_invio='errore'"),
'pec_pending': count(db_path, "SELECT COUNT(*) as n FROM pec_comunicazioni WHERE stato_invio='pending'"),
}
def render_table(rows: list) -> str:
if not rows:
return '<p class="empty">Nessuna comunicazione PEC trovata.</p>'
def _badge(stato):
m = {'inviata': ('ok', 'Inviata'), 'pending': ('warn', 'Pending'), 'errore': ('err', 'Errore')}
cls, lbl = m.get(stato, ('', ''))
return badge(cls, lbl) if cls else e(stato)
trs = ''.join(
f'<tr data-stato="{e(r.get("stato_invio", ""))}">'
f'<td>{e(r.get("articolo_cod"))}</td>'
f'<td>{e(r.get("sessione_id"))}</td>'
f'<td>{dt(r.get("data_sessione"))}</td>'
f'<td>{e(r.get("tipo_comunicazione"))}</td>'
f'<td>{e(r.get("email_ats"))}</td>'
f'<td>{dt(r.get("data_invio_pec"))}</td>'
f'<td>{_badge(r.get("stato_invio"))}</td>'
f'<td class="nc">{e(r.get("note"))}</td>'
f'</tr>'
for r in rows
)
return (
'<table><thead><tr>'
'<th>Corso</th><th>Sessione</th><th>Data Sessione</th><th>Tipo</th>'
'<th>Email ATS</th><th>Data Invio</th><th>Stato</th><th>Note</th>'
f'</tr></thead><tbody>{trs}</tbody></table>'
)
+41
View File
@@ -0,0 +1,41 @@
"""queries/processes.py — rpa_process"""
from .db import query, count, e, dt, dur, badge
def get_processes(db_path: str) -> list:
return query(db_path, "SELECT * FROM rpa_process ORDER BY start_run DESC LIMIT 50")
def get_stats(db_path: str) -> dict:
total = count(db_path, "SELECT COUNT(*) as n FROM rpa_process")
completed = count(db_path, "SELECT COUNT(*) as n FROM rpa_process WHERE completed = 1")
return {
'total_runs': total,
'completed_runs': completed,
'incomplete_runs': total - completed,
}
def render_table(rows: list) -> str:
if not rows:
return '<p class="empty">Nessun processo trovato.</p>'
def _badge(completed):
return badge('ok', 'Completato') if completed in (1, True, '1') else badge('err', 'Incompleto')
trs = ''.join(
f'<tr data-completed="{1 if r.get("completed") in (1, True, "1") else 0}">'
f'<td>{dt(r.get("start_run"))}</td>'
f'<td>{dt(r.get("finish_run"))}</td>'
f'<td>{dur(r.get("start_run"), r.get("finish_run"))}</td>'
f'<td>{_badge(r.get("completed"))}</td>'
f'<td class="nc">{e(r.get("note"))}</td>'
f'</tr>'
for r in rows
)
return (
'<table><thead><tr>'
'<th>Inizio</th><th>Fine</th><th>Durata</th><th>Stato</th><th>Note</th>'
f'</tr></thead><tbody>{trs}</tbody></table>'
)
+101
View File
@@ -0,0 +1,101 @@
"""queries/report.py — sessione_changes con join sessione"""
from .db import query, e, dt, d, badge
def get_report(db_path: str, da_data: str = None, a_data: str = None) -> list:
"""
Restituisce sessione_changes JOIN sessione, opzionalmente filtrate per data.
Ordinate dalla più recente.
"""
params = []
where = []
if da_data:
where.append("sc.rilevato_at >= ?")
params.append(da_data)
if a_data:
where.append("sc.rilevato_at <= ?")
params.append(a_data + " 23:59:59")
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
sql = f"""
SELECT
sc.id AS change_id,
sc.change_type,
sc.campo,
sc.valore_prec,
sc.valore_nuovo,
sc.rilevato_at,
sc.rpa_process_id,
s.id AS sessione_id,
s.corso,
s.variante,
s.modalita_cod,
s.modalita_desc,
s.monte_ore,
s.soggetto_formatore,
s.azienda,
s.articolo_cod,
s.articolo_desc,
s.requisiti_formativi_id,
s.requisiti_formativi_desc,
(SELECT MIN(sl.data) FROM sessione_lezione sl WHERE sl.sessione_id = s.id) AS prima_lezione
FROM sessione_changes sc
LEFT JOIN sessione s ON s.id = sc.sessione_id
{where_sql}
ORDER BY sc.rilevato_at DESC
"""
return query(db_path, sql, tuple(params))
def get_stats(db_path: str, da_data: str = None, a_data: str = None) -> dict:
rows = get_report(db_path, da_data, a_data)
nuovi = sum(1 for r in rows if r['change_type'] == 'nuovo')
cambiati = sum(1 for r in rows if r['change_type'] == 'modificato')
return {'report_nuovi': nuovi, 'report_cambiati': cambiati, 'report_total': len(rows)}
def render_table(rows: list) -> str:
if not rows:
return '<table><thead><tr><th colspan="11">Nessun dato</th></tr></thead><tbody></tbody></table>'
def row_html(r):
tag = badge('ok', 'Nuovo') if r['change_type'] == 'nuovo' else badge('warn', 'Modificato')
campo = e(r['campo']) if r['campo'] else '-'
prec = e(r['valore_prec']) if r['valore_prec'] else '-'
nuovo = e(r['valore_nuovo']) if r['valore_nuovo'] else '-'
sid = e(r['sessione_id'])
return (
f'<tr data-tipo="{e(r["change_type"])}">'
f'<td>{tag}</td>'
f'<td>{sid}</td>'
f'<td>{e(r["corso"])}</td>'
f'<td>{e(r["azienda"])}</td>'
f'<td>{e(r["articolo_cod"])}</td>'
f'<td class="col-prima-lez">{d(r["prima_lezione"])}</td>'
f'<td>{campo}</td>'
f'<td class="val-prec">{prec}</td>'
f'<td class="val-nuovo">{nuovo}</td>'
f'<td>{dt(r["rilevato_at"])}</td>'
f'<td><button class="doc-btn" data-sid="{sid}" data-corso="{e(r["corso"])}" title="Documenti caricati">📄</button></td>'
f'</tr>'
)
thead = (
'<thead><tr>'
'<th>Tipo</th>'
'<th>ID Sessione</th>'
'<th>Corso</th>'
'<th>Azienda</th>'
'<th>Articolo</th>'
'<th>Prima Lezione</th>'
'<th>Campo</th>'
'<th>Valore Precedente</th>'
'<th>Valore Nuovo</th>'
'<th>Rilevato</th>'
'<th></th>'
'</tr></thead>'
)
tbody = '<tbody>' + ''.join(row_html(r) for r in rows) + '</tbody>'
return f'<table>{thead}{tbody}</table>'
+177
View File
@@ -0,0 +1,177 @@
// ── documenti.js — core namespace, refresh, pagination, settings ──
var _Doc = {};
_Doc.blocks = [];
_Doc.PAGE_SIZE = 10;
_Doc.currentPage = 1;
_Doc.autorefreshTimer = null;
_Doc.rebuildBlocks = function() {
_Doc.blocks = Array.prototype.slice.call(document.querySelectorAll('.sess-block'));
};
_Doc.resetPage = function() { _Doc.currentPage = 1; };
_Doc.saveFilterState = function() {
var tipo = {}, cartella = {};
document.querySelectorAll('.tipo-chk').forEach(function(c){ tipo[c.value] = c.checked; });
document.querySelectorAll('.cartella-chk').forEach(function(c){ cartella[c.value] = c.checked; });
return { tipo: tipo, cartella: cartella };
};
_Doc.doRefresh = function() {
var btn = document.getElementById('btn-refresh');
if (btn) { btn.disabled = true; btn.textContent = '\u27F3 Aggiornamento\u2026'; }
var state = _Doc.saveFilterState();
fetch('/api/documenti')
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
.then(function(data) {
document.getElementById('doc-content').innerHTML = data.html;
_Doc.rebuildBlocks();
_Doc.buildDynamicFilters(state.tipo, state.cartella);
_Doc.applyFilters();
var tsEl = document.getElementById('doc-refresh-ts');
if (tsEl) tsEl.textContent = data.ts;
})
.catch(function(err) { console.error('Refresh error:', err); })
.finally(function() {
if (btn) { btn.disabled = false; btn.textContent = '\u21BB Aggiorna dati'; }
});
};
// ── Pagination ─────────────────────────────────────────────────────────────
// Pagination uses .pag-hidden class (not inline style) so export still works on all filtered data.
_Doc.applyPagination = function() {
// Remove any previous pagination-hiding first
_Doc.blocks.forEach(function(b) { b.classList.remove('pag-hidden'); });
var visible = _Doc.blocks.filter(function(b) { return b.style.display !== 'none'; });
var total = Math.max(1, Math.ceil(visible.length / _Doc.PAGE_SIZE));
var page = _Doc.currentPage;
if (page > total) page = _Doc.currentPage = total;
if (page < 1) page = _Doc.currentPage = 1;
var countEl = document.getElementById('doc-visible-count');
if (total <= 1) {
var pag = document.getElementById('doc-pagination');
if (pag) pag.innerHTML = '';
if (countEl) countEl.textContent = visible.length + ' sessioni';
return;
}
var start = (page - 1) * _Doc.PAGE_SIZE;
visible.forEach(function(b, i) {
b.classList.toggle('pag-hidden', i < start || i >= start + _Doc.PAGE_SIZE);
});
if (countEl) countEl.textContent = visible.length + ' sessioni \u2014 pag. ' + page + '/' + total;
_Doc.renderPagination(page, total);
};
_Doc.renderPagination = function(page, total) {
var pag = document.getElementById('doc-pagination');
if (!pag) return;
var lo = Math.max(1, page - 3);
var hi = Math.min(total, lo + 6);
lo = Math.max(1, hi - 6);
var html = '<button class="doc-pag-btn" data-page="' + (page - 1) + '"' + (page === 1 ? ' disabled' : '') + '>&laquo;</button>';
if (lo > 1) {
html += '<button class="doc-pag-btn" data-page="1">1</button>';
if (lo > 2) html += '<span class="doc-pag-ellipsis">&hellip;</span>';
}
for (var i = lo; i <= hi; i++) {
html += '<button class="doc-pag-btn' + (i === page ? ' active' : '') + '" data-page="' + i + '">' + i + '</button>';
}
if (hi < total) {
if (hi < total - 1) html += '<span class="doc-pag-ellipsis">&hellip;</span>';
html += '<button class="doc-pag-btn" data-page="' + total + '">' + total + '</button>';
}
html += '<button class="doc-pag-btn" data-page="' + (page + 1) + '"' + (page === total ? ' disabled' : '') + '>&raquo;</button>';
pag.innerHTML = html;
pag.querySelectorAll('.doc-pag-btn:not([disabled])').forEach(function(btn) {
btn.addEventListener('click', function() {
_Doc.currentPage = parseInt(this.getAttribute('data-page'), 10);
_Doc.applyPagination();
document.getElementById('doc-content').scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
};
// ── Event listeners ────────────────────────────────────────────────────────
document.getElementById('btn-refresh').addEventListener('click', _Doc.doRefresh);
document.getElementById('btn-expand-all').addEventListener('click', function() {
_Doc.blocks.forEach(function(b) {
if (b.style.display !== 'none' && !b.classList.contains('pag-hidden')) b.open = true;
});
});
document.getElementById('btn-collapse-all').addEventListener('click', function() {
_Doc.blocks.forEach(function(b) {
if (!b.classList.contains('pag-hidden')) b.open = false;
});
});
// ── Settings modal ─────────────────────────────────────────────────────────
(function() {
var wrenchDrop = document.querySelector('.wrench-drop');
if (wrenchDrop) {
var link = document.createElement('a');
link.id = 'btn-page-settings';
link.href = '#';
link.innerHTML = '&#9881; Impostazioni&hellip;';
wrenchDrop.appendChild(link);
}
var modal = document.createElement('div');
modal.id = 'page-settings-modal';
modal.className = 'page-settings-overlay';
modal.innerHTML =
'<div class="page-settings-dialog">' +
'<div class="page-settings-hdr">' +
'<span>Impostazioni pagina</span>' +
'<button class="page-settings-close" id="btn-settings-close" title="Chiudi">&times;</button>' +
'</div>' +
'<div class="page-settings-body">' +
'<div class="page-settings-row"><label><input type="checkbox" id="autorefresh-toggle"> Auto-aggiornamento</label></div>' +
'<div class="page-settings-row"><span>Intervallo (sec)</span><input type="number" id="autorefresh-seconds" class="page-settings-input" value="60" min="5" max="3600"></div>' +
'</div>' +
'</div>';
document.body.appendChild(modal);
function openModal() { modal.classList.add('open'); }
function closeModal() { modal.classList.remove('open'); }
var settingsBtn = document.getElementById('btn-page-settings');
if (settingsBtn) {
settingsBtn.addEventListener('click', function(e) {
e.preventDefault(); e.stopPropagation();
var drop = document.querySelector('.wrench-drop');
if (drop) drop.classList.remove('open');
openModal();
});
}
document.getElementById('btn-settings-close').addEventListener('click', closeModal);
modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); });
document.addEventListener('keydown', function(e) { if (e.key === 'Escape') closeModal(); });
document.getElementById('autorefresh-toggle').addEventListener('change', function() {
if (this.checked) { startAutorefresh(); } else { stopAutorefresh(); }
});
document.getElementById('autorefresh-seconds').addEventListener('change', function() {
if (document.getElementById('autorefresh-toggle').checked) { startAutorefresh(); }
});
})();
function startAutorefresh() {
stopAutorefresh();
var secs = parseInt(document.getElementById('autorefresh-seconds').value, 10) || 60;
if (secs < 5) secs = 5;
_Doc.autorefreshTimer = setInterval(_Doc.doRefresh, secs * 1000);
}
function stopAutorefresh() {
if (_Doc.autorefreshTimer) { clearInterval(_Doc.autorefreshTimer); _Doc.autorefreshTimer = null; }
}
+65
View File
@@ -0,0 +1,65 @@
// ── documenti_export.js — Excel & PDF export ──
// Depends on _Doc namespace. Export uses ALL filter-visible blocks (not just current page).
document.getElementById('btn-excel').addEventListener('click', function() {
var csvRows = [['Sessione', 'Corso', 'Azienda', 'Cartella', 'Tipo', 'Stato', 'File', 'Note', 'Caricato']];
_Doc.blocks.forEach(function(block) {
if (block.style.display === 'none') return; // filter-hidden: skip
var sessione = block.dataset.sessione || '';
var corso = block.dataset.corso || '';
var azienda = block.dataset.azienda || '';
block.querySelectorAll('.doc-row').forEach(function(row) {
if (row.style.display === 'none') return;
csvRows.push([
sessione, corso, azienda,
row.dataset.subfolder || '',
row.dataset.doctype || '',
row.dataset.status || '',
row.dataset.filename || '',
row.dataset.note || '',
row.dataset.uploaded || '',
]);
});
});
var csv = csvRows.map(function(r) {
return r.map(function(v) {
var s = String(v).replace(/"/g, '""');
return (s.indexOf(',') !== -1 || s.indexOf('"') !== -1 || s.indexOf('\n') !== -1) ? '"' + s + '"' : s;
}).join(',');
}).join('\r\n');
var blob = new Blob(['\uFEFF' + csv], { type: 'text/csv;charset=utf-8;' });
var url = URL.createObjectURL(blob);
var a = document.createElement('a');
a.href = url;
a.download = 'documenti_' + new Date().toISOString().slice(0, 10) + '.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
document.getElementById('btn-pdf').addEventListener('click', function() {
// Temporarily show all filter-visible blocks (including pag-hidden) for print
var pagHidden = _Doc.blocks.filter(function(b) { return b.classList.contains('pag-hidden'); });
pagHidden.forEach(function(b) { b.classList.remove('pag-hidden'); });
var wasOpen = _Doc.blocks.map(function(b) { return b.open; });
_Doc.blocks.forEach(function(b) {
if (b.style.display === 'none') {
b.classList.add('print-hidden');
} else {
var visibleRows = Array.prototype.filter.call(b.querySelectorAll('.doc-row'), function(r) { return r.style.display !== 'none'; });
if (visibleRows.length === 0) { b.classList.add('print-hidden'); } else { b.open = true; }
}
});
document.querySelectorAll('.doc-row').forEach(function(r) {
if (r.style.display === 'none') r.classList.add('print-hidden');
});
window.print();
// Restore state
_Doc.blocks.forEach(function(b, i) { b.classList.remove('print-hidden'); b.open = wasOpen[i]; });
document.querySelectorAll('.doc-row.print-hidden').forEach(function(r) { r.classList.remove('print-hidden'); });
pagHidden.forEach(function(b) { b.classList.add('pag-hidden'); });
});
+80
View File
@@ -0,0 +1,80 @@
// ── documenti_filters.js — filter logic, event listeners, init ──
// Depends on _Doc namespace from documenti.js (loaded first).
function _buildCheckboxGroup(containerId, cssClass, values, prevState) {
var container = document.getElementById(containerId);
if (!container) return;
container.innerHTML = '';
values.forEach(function(val) {
var lbl = document.createElement('label');
lbl.className = 'chk-label';
var chk = document.createElement('input');
chk.type = 'checkbox';
chk.className = cssClass;
chk.value = val;
chk.checked = prevState ? (prevState[val] !== false) : true;
lbl.appendChild(chk);
lbl.appendChild(document.createTextNode(' ' + val));
container.appendChild(lbl);
});
}
function _uniqueValues(attr) {
var seen = {}, result = [];
document.querySelectorAll('.doc-row[data-' + attr + ']').forEach(function(r) {
var v = r.dataset[attr];
if (v && !seen[v]) { seen[v] = true; result.push(v); }
});
result.sort();
return result;
}
_Doc.buildDynamicFilters = function(tipoState, cartellaState) {
_buildCheckboxGroup('filter-tipo', 'tipo-chk', _uniqueValues('doctype'), tipoState);
_buildCheckboxGroup('filter-cartella', 'cartella-chk', _uniqueValues('subfolder'), cartellaState);
};
_Doc.applyFilters = function() {
var q = document.getElementById('doc-search').value.toLowerCase();
var checkedStatus = [];
document.querySelectorAll('.status-chk:checked').forEach(function(c){ checkedStatus.push(c.value); });
var checkedTipo = [];
document.querySelectorAll('.tipo-chk:checked').forEach(function(c){ checkedTipo.push(c.value); });
var checkedCartella = [];
document.querySelectorAll('.cartella-chk:checked').forEach(function(c){ checkedCartella.push(c.value); });
_Doc.blocks.forEach(function(block) {
var text = (block.dataset.sessione + ' ' + block.dataset.corso + ' ' + block.dataset.azienda).toLowerCase();
var matchQ = !q || text.indexOf(q) !== -1;
var rows = Array.prototype.slice.call(block.querySelectorAll('.doc-row'));
var hasVisible = rows.length === 0;
rows.forEach(function(row) {
var status = row.dataset.status || '';
var doctype = row.dataset.doctype || '';
var subfolder = row.dataset.subfolder || '';
var show = checkedStatus.indexOf(status) !== -1
&& (!doctype || checkedTipo.length === 0 || checkedTipo.indexOf(doctype) !== -1)
&& (!subfolder || checkedCartella.length === 0 || checkedCartella.indexOf(subfolder) !== -1);
row.style.display = show ? '' : 'none';
if (show) hasVisible = true;
});
block.style.display = (matchQ && hasVisible) ? '' : 'none';
});
_Doc.resetPage();
_Doc.applyPagination();
};
// ── Event listeners ────────────────────────────────────────────────────────
document.getElementById('doc-search').addEventListener('input', _Doc.applyFilters);
document.querySelectorAll('.status-chk').forEach(function(c) {
c.addEventListener('change', _Doc.applyFilters);
});
document.getElementById('filter-tipo').addEventListener('change', _Doc.applyFilters);
document.getElementById('filter-cartella').addEventListener('change', _Doc.applyFilters);
// ── Init ───────────────────────────────────────────────────────────────────
_Doc.rebuildBlocks();
_Doc.buildDynamicFilters(null, null);
_Doc.applyFilters();
+12
View File
@@ -0,0 +1,12 @@
// ── filters.js — shared filter toggle (all pages) ──
(function () {
var btn = document.getElementById('btn-toggle-filters');
var collapsible = document.getElementById('filters-collapsible');
var arrow = document.getElementById('filters-arrow');
if (!btn || !collapsible) return;
btn.addEventListener('click', function () {
var closing = !collapsible.classList.contains('hidden');
collapsible.classList.toggle('hidden', closing);
if (arrow) arrow.classList.toggle('collapsed', closing);
});
})();
+59
View File
@@ -0,0 +1,59 @@
(function () {
// Populate sorgente checkboxes dynamically from table data
var sorgSet = [];
var seen = {};
document.querySelectorAll('tbody tr[data-sorgente]').forEach(function (tr) {
var v = tr.getAttribute('data-sorgente');
if (v && v !== '-' && !seen[v]) { seen[v] = true; sorgSet.push(v); }
});
var sorgWrap = document.getElementById('filter-sorgente');
sorgSet.forEach(function (s) {
var lbl = document.createElement('label');
var chk = document.createElement('input');
chk.type = 'checkbox';
chk.setAttribute('data-sorgente', s);
chk.checked = true;
lbl.appendChild(chk);
lbl.appendChild(document.createTextNode(' ' + s));
sorgWrap.appendChild(lbl);
});
if (!sorgSet.length) {
var wrap = document.getElementById('filter-sorgente-wrap');
if (wrap) wrap.style.display = 'none';
}
function applyFilter() {
var search = (document.getElementById('isc-search').value || '').trim().toLowerCase();
var activeStatus = {};
document.querySelectorAll('#filter-stato input[type="checkbox"]').forEach(function (chk) {
activeStatus[chk.getAttribute('data-status')] = chk.checked;
});
var activeSorg = null;
if (sorgSet.length) {
activeSorg = {};
document.querySelectorAll('#filter-sorgente input[type="checkbox"]').forEach(function (chk) {
activeSorg[chk.getAttribute('data-sorgente')] = chk.checked;
});
}
document.querySelectorAll('tbody tr[data-status]').forEach(function (tr) {
var status = tr.getAttribute('data-status') || 'pending';
var sorg = tr.getAttribute('data-sorgente') || '-';
var show = activeStatus[status] !== false;
if (activeSorg) show = show && activeSorg[sorg] !== false;
if (show && search) show = tr.textContent.toLowerCase().indexOf(search) !== -1;
tr.style.display = show ? '' : 'none';
});
}
document.getElementById('isc-search').addEventListener('input', applyFilter);
document.querySelectorAll('#filter-stato input[type="checkbox"]').forEach(function (chk) {
chk.addEventListener('change', applyFilter);
});
if (sorgWrap) sorgWrap.addEventListener('change', applyFilter);
})();
+16
View File
@@ -0,0 +1,16 @@
(function(){
var search = document.getElementById('log-search');
var checks = document.querySelectorAll('.type-filter');
function filter(){
var q = search.value.toLowerCase();
var active = {};
checks.forEach(function(c){ if(c.checked) active[c.value] = 1; });
document.querySelectorAll('tbody tr').forEach(function(tr){
var type = tr.dataset.type || '';
var text = tr.textContent.toLowerCase();
tr.style.display = (active[type] && text.indexOf(q) >= 0) ? '' : 'none';
});
}
search.addEventListener('input', filter);
checks.forEach(function(c){ c.addEventListener('change', filter); });
})();
+16
View File
@@ -0,0 +1,16 @@
(function(){
function applyFilter() {
var active = {};
document.querySelectorAll('.pec-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('.pec-filter input[type="checkbox"]').forEach(function(chk) {
chk.addEventListener('change', applyFilter);
});
})();
+233
View File
@@ -0,0 +1,233 @@
/* ── Filter + grouped view ─────────────────────────────────────────────── */
(function(){
var today = new Date().toISOString().slice(0,10);
var sevenAgo = new Date(Date.now() - 7*86400000).toISOString().slice(0,10);
document.getElementById('f-da').value = sevenAgo;
document.getElementById('f-a').value = today;
var table = document.querySelector('.tw table');
var tbody = table.querySelector('tbody');
var thead = table.querySelector('thead');
var allFlatRows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
var flatTheadHTML = thead.innerHTML;
var groupedTheadHTML = '<tr>'
+ '<th>Tipo</th><th>ID Sessione</th><th>Corso</th>'
+ '<th>Azienda</th><th>Articolo</th><th>Prima Lezione</th><th>Modifiche</th><th>Rilevato</th><th></th>'
+ '</tr>';
var isGrouped = false;
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function badgeHtml(tipo) {
if (tipo === 'nuovo') return '<span class="badge ok">Nuovo</span>';
if (tipo === 'modificato') return '<span class="badge warn">Modificato</span>';
return esc(tipo);
}
function getRowData(row) {
var c = row.cells;
return {
tipo: row.getAttribute('data-tipo') || '',
sessione_id: c[1] ? c[1].textContent.trim() : '',
corso: c[2] ? c[2].textContent.trim() : '',
azienda: c[3] ? c[3].textContent.trim() : '',
articolo: c[4] ? c[4].textContent.trim() : '',
prima_lezione: c[5] ? c[5].textContent.trim() : '',
campo: c[6] ? c[6].textContent.trim() : '',
valore_prec: c[7] ? c[7].textContent.trim() : '',
valore_nuovo: c[8] ? c[8].textContent.trim() : '',
rilevato: c[9] ? c[9].textContent.trim() : '',
};
}
function toIso(rilevato) {
var m = rilevato.match(/(\d{2})\/(\d{2})\/(\d{4})/);
return m ? m[3] + '-' + m[2] + '-' + m[1] : '';
}
function getFilters() {
var tipi = [];
document.querySelectorAll('.tipo-chk:checked').forEach(function(c){ tipi.push(c.value); });
return {
da: document.getElementById('f-da').value,
a: document.getElementById('f-a').value,
sid: document.getElementById('f-sid').value.trim(),
tipi: tipi,
};
}
function passes(d, f) {
var pd = toIso(d.prima_lezione);
return (!f.da || pd >= f.da)
&& (!f.a || pd <= f.a)
&& (!f.sid || d.sessione_id === f.sid)
&& f.tipi.indexOf(d.tipo) !== -1;
}
function updateCount(n) {
document.getElementById('visible-count').textContent = n + ' righe';
}
function applyFlat(f) {
tbody.innerHTML = '';
allFlatRows.forEach(function(r) { tbody.appendChild(r); });
var n = 0;
allFlatRows.forEach(function(row) {
var ok = passes(getRowData(row), f);
row.style.display = ok ? '' : 'none';
if (ok) n++;
});
updateCount(n);
}
function applyGrouped(f) {
var visible = allFlatRows.filter(function(r) { return passes(getRowData(r), f); });
var groups = {}, order = [];
visible.forEach(function(row) {
var d = getRowData(row);
var key = d.sessione_id || '__?__';
if (!groups[key]) {
groups[key] = { base: d, tipi: [], changes: [], latestIso: '', latestRilevato: '' };
order.push(key);
}
var g = groups[key];
if (g.tipi.indexOf(d.tipo) === -1) g.tipi.push(d.tipo);
var iso = toIso(d.rilevato);
if (iso > g.latestIso) { g.latestIso = iso; g.latestRilevato = d.rilevato; }
if (d.campo && d.campo !== '-') {
g.changes.push({ campo: d.campo, prec: d.valore_prec, nuovo: d.valore_nuovo });
}
});
var html = '';
order.forEach(function(key) {
var g = groups[key];
var b = g.base;
var tipiHtml = g.tipi.map(badgeHtml).join(' ');
var modHtml = g.changes.length
? g.changes.map(function(c) {
return '<div class="mod-row">'
+ '<span class="mod-campo">' + esc(c.campo) + '</span>: '
+ '<span class="val-prec">' + esc(c.prec) + '</span>'
+ ' &rarr; '
+ '<span class="val-nuovo">' + esc(c.nuovo) + '</span>'
+ '</div>';
}).join('')
: '<span class="mod-none">\u2014</span>';
html += '<tr>'
+ '<td>' + tipiHtml + '</td>'
+ '<td>' + esc(b.sessione_id) + '</td>'
+ '<td>' + esc(b.corso) + '</td>'
+ '<td>' + esc(b.azienda) + '</td>'
+ '<td>' + esc(b.articolo) + '</td>'
+ '<td class="col-prima-lez">' + esc(b.prima_lezione) + '</td>'
+ '<td class="mod-cell">' + modHtml + '</td>'
+ '<td>' + esc(g.latestRilevato) + '</td>'
+ '<td><button class="doc-btn" data-sid="' + esc(b.sessione_id) + '" data-corso="' + esc(b.corso) + '" title="Documenti caricati">📄</button></td>'
+ '</tr>';
});
tbody.innerHTML = html || '<tr><td colspan="9" class="empty">Nessun dato</td></tr>';
updateCount(order.length);
}
function applyFilters() {
var f = getFilters();
if (isGrouped) applyGrouped(f); else applyFlat(f);
}
document.getElementById('btn-toggle-view').addEventListener('click', function() {
isGrouped = !isGrouped;
this.textContent = isGrouped ? '\u229e Tabella' : '\u2a27 Raggruppa corso';
this.classList.toggle('active', isGrouped);
thead.innerHTML = isGrouped ? groupedTheadHTML : flatTheadHTML;
applyFilters();
});
document.getElementById('btn-filter').addEventListener('click', applyFilters);
document.getElementById('btn-reset').addEventListener('click', function(){
document.getElementById('f-da').value = sevenAgo;
document.getElementById('f-a').value = today;
document.getElementById('f-sid').value = '';
document.querySelectorAll('.tipo-chk').forEach(function(c){ c.checked = true; });
applyFilters();
});
document.querySelectorAll('.tipo-chk').forEach(function(c){
c.addEventListener('change', applyFilters);
});
applyFilters();
})();
/* ── Docs modal ────────────────────────────────────────────────────────── */
(function(){
var modal = document.getElementById('docs-modal');
var modalBody = document.getElementById('docs-modal-body');
var modalSid = document.getElementById('docs-modal-sid');
var modalCorso = document.getElementById('docs-modal-corso');
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function openDocsModal(sid, corso) {
modalSid.textContent = sid;
modalCorso.textContent = corso || '';
modalBody.innerHTML = '<p class="docs-loading">Caricamento\u2026</p>';
modal.style.display = 'flex';
fetch('/api/docs?sessione_id=' + encodeURIComponent(sid))
.then(function(r){ return r.json(); })
.then(function(docs) {
if (!docs || docs.length === 0) {
modalBody.innerHTML = '<p class="docs-empty">Nessun documento caricato.</p>';
return;
}
var rows = docs.map(function(d) {
var st = d.doc_status || '';
var badge = '<span class="doc-status-badge doc-status-' + esc(st) + '">' + esc(st) + '</span>';
var fn = d.filename || '';
var fnHtml = esc(fn);
if (fn.charAt(0) === '[' && fn.charAt(fn.length - 1) === ']')
fnHtml = '<span style="color:#f97316;">' + fnHtml + '</span>';
var name = d.sp_web_url
? '<a href="' + d.sp_web_url + '" target="_blank" rel="noopener">' + fnHtml + '</a>'
: fnHtml;
var ts = d.uploaded_at ? d.uploaded_at.slice(0,16).replace('T',' ') : '-';
return '<tr>'
+ '<td class="doc-subfolder">' + esc(d.subfolder) + '</td>'
+ '<td class="doc-filename">' + name + '</td>'
+ '<td>' + badge + '</td>'
+ '<td class="doc-date">' + ts + '</td>'
+ '</tr>';
}).join('');
modalBody.innerHTML =
'<table class="docs-table">'
+ '<thead><tr><th>Cartella</th><th>File</th><th>Doc</th><th>Caricato SP</th></tr></thead>'
+ '<tbody>' + rows + '</tbody>'
+ '</table>';
})
.catch(function(err) {
modalBody.innerHTML = '<p class="docs-error">Errore: ' + esc(String(err)) + '</p>';
});
}
function closeDocsModal() { modal.style.display = 'none'; }
document.getElementById('docs-modal-close').addEventListener('click', closeDocsModal);
modal.addEventListener('click', function(ev) { if (ev.target === modal) closeDocsModal(); });
document.addEventListener('keydown', function(ev) { if (ev.key === 'Escape') closeDocsModal(); });
document.querySelector('.tw').addEventListener('click', function(ev) {
var btn = ev.target.closest('.doc-btn');
if (btn) openDocsModal(btn.getAttribute('data-sid'), btn.getAttribute('data-corso'));
});
})();
+14
View File
@@ -0,0 +1,14 @@
(function(){
var chkOk = document.getElementById('chk-completed');
var chkErr = document.getElementById('chk-incomplete');
function applyFilter() {
var showOk = chkOk.checked;
var showErr = chkErr.checked;
document.querySelectorAll('tbody tr[data-completed]').forEach(function(tr) {
var done = tr.getAttribute('data-completed') === '1';
tr.style.display = (done ? showOk : showErr) ? '' : 'none';
});
}
chkOk.addEventListener('change', applyFilter);
chkErr.addEventListener('change', applyFilter);
})();
+3
View File
@@ -0,0 +1,3 @@
/* dashboard.css — stili specifici per la pagina Dashboard */
.sec-link{color:inherit;text-decoration:none}
.sec-link:hover{text-decoration:underline;text-underline-offset:3px}
+125
View File
@@ -0,0 +1,125 @@
/* ── documenti.css — session blocks, table, badges, print ── */
/* ── Session blocks ────────────────────────────── */
.sess-block {
border: 1px solid #e2e8f0;
border-radius: 8px;
margin-bottom: .6rem;
background: #fff;
overflow: hidden;
}
.sess-summary {
display: flex;
align-items: center;
gap: .75rem;
padding: .65rem 1rem;
cursor: pointer;
list-style: none;
user-select: none;
background: #f8fafc;
}
.sess-summary:hover { background: #f1f5f9; }
.sess-block[open] > .sess-summary { border-bottom: 1px solid #e2e8f0; }
.sess-id {
font-weight: 700;
font-size: .8rem;
color: #2563eb;
min-width: 56px;
}
.sess-corso {
font-weight: 600;
font-size: .875rem;
color: #1e293b;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sess-azienda {
font-size: .8rem;
color: #64748b;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 220px;
}
.sess-counts { display: flex; gap: .35rem; flex-shrink: 0; }
/* ── Count badges ──────────────────────────────── */
.cnt { font-size: .75rem; font-weight: 600; padding: 2px 8px; border-radius: 99px; }
.cnt-caricato { background: #dcfce7; color: #15803d; }
.cnt-trovato { background: #dbeafe; color: #1d4ed8; }
.cnt-warning { background: #fef9c3; color: #a16207; }
.cnt-errore { background: #fee2e2; color: #b91c1c; }
/* ── Status badges ─────────────────────────────── */
.badge.doc-trovato { background: #dbeafe; color: #1d4ed8; }
.badge.doc-caricato { background: #dcfce7; color: #15803d; }
.badge.doc-warning { background: #fef9c3; color: #a16207; }
.badge.doc-errore { background: #fee2e2; color: #b91c1c; }
/* ── Subtable ──────────────────────────────────── */
.sess-docs { padding: .5rem 1rem 1rem; overflow-x: auto; }
.doc-subtable { width: 100%; table-layout: fixed; border-collapse: collapse; font-size: .85rem; }
.doc-subtable thead th {
text-align: left; padding: 6px 10px;
border-bottom: 2px solid #e2e8f0; color: #475569; font-weight: 600; white-space: nowrap;
}
.doc-subtable tbody td { padding: 5px 10px; border-bottom: 1px solid #f1f5f9; color: #1e293b; }
.doc-subtable tbody tr:last-child td { border-bottom: none; }
.doc-subtable tbody tr:hover td { background: #f8fafc; }
.doc-row.doc-warning td { background: #fefce8; }
.doc-row.doc-errore td { background: #fff5f5; }
.col-subfolder { color: #64748b; font-size: .78rem; white-space: nowrap; }
.col-doctype { color: #475569; font-size: .8rem; white-space: nowrap; }
.col-status { white-space: nowrap; }
.col-filename { color: #1e293b; width: 30%; max-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.col-note { color: #94a3b8; font-size: .8rem; max-height: 3.2em; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.col-sp { text-align: center; white-space: nowrap; }
.sp-link { font-size: 1.1rem; text-decoration: none; }
.sp-link:hover { opacity: .75; }
.sp-missing { font-size: 1rem; color: #cbd5e1; }
.col-uploaded { color: #94a3b8; font-size: .78rem; white-space: nowrap; }
.empty-msg { color: #64748b; padding: 2rem 0; text-align: center; }
/* pagination-hidden (by JS) */
.pag-hidden { display: none; }
/* ── Print / PDF export ────────────────────────────────── */
@media print {
.print-hidden { display: none !important; }
@page { size: A4 landscape; margin: 12mm 10mm; }
header, nav, footer, .doc-toolbar, .floating-export { display: none !important; }
main { padding: 0; margin: 0; }
details.sess-block { display: block !important; }
details.sess-block > .sess-docs { display: block !important; }
.sess-block {
border: 1px solid #cbd5e1; border-radius: 4px; margin-bottom: 0;
page-break-before: always; page-break-inside: avoid; break-before: page; break-inside: avoid;
}
.sess-block:first-child { page-break-before: auto; break-before: auto; }
.sess-summary { background: #f1f5f9 !important; border-bottom: 1px solid #cbd5e1; }
.sess-docs { padding: .3rem .6rem .6rem; }
.doc-subtable { width: 100%; font-size: .72rem; table-layout: fixed; }
.doc-subtable thead th { background: #f8fafc; padding: 4px 6px; }
.doc-subtable tbody td { padding: 3px 6px; }
.col-filename { width: 22%; word-break: break-word; }
.col-doctype { width: 11%; }
.col-subfolder { width: 11%; }
.col-status { width: 7%; }
.col-note { width: 38%; word-break: break-word; -webkit-line-clamp: unset; max-height: none; }
.col-uploaded { width: 9%; }
.doc-row.doc-warning td { background: #fefce8 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.doc-row.doc-errore td { background: #fff5f5 !important; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.badge { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.cnt { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
.col-sp { display: none; }
.doc-subtable thead th:nth-child(5) { display: none; }
.pag-hidden { display: block !important; }
}
+144
View File
@@ -0,0 +1,144 @@
/* ── documenti_filters.css — toolbar, filters, export, settings, pagination ── */
.doc-header {
margin-bottom: 1rem;
padding-top: 16px;
/* sticky */
position: sticky;
top: 0;
z-index: 50;
background: #fff;
border-bottom: 1px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0,0,0,.06);
padding-bottom: .6rem;
margin-bottom: 0;
}
#doc-content { padding-top: 1rem; }
.doc-toolbar { display: flex; flex-direction: column; gap: .4rem; }
.doc-toolbar-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: .6rem;
}
.doc-search {
padding: .4rem .75rem;
border: 1px solid #cbd5e1;
border-radius: 6px;
font-size: .875rem;
width: 260px;
outline: none;
}
.doc-search:focus { border-color: #2563eb; box-shadow: 0 0 0 2px #dbeafe; }
.doc-filter-group { display: flex; align-items: center; gap: .5rem; }
.doc-count { margin-left: 0; font-size: .8rem; color: #64748b; }
.filter-label { font-size: .8rem; font-weight: 600; color: #475569; white-space: nowrap; min-width: 55px; }
.btn-refresh { background: #f0fdf4; border: 1px solid #86efac; color: #15803d; font-weight: 600; }
.btn-refresh:hover { background: #dcfce7; }
.btn-refresh:disabled { opacity: .6; cursor: default; }
.doc-refresh-ts { font-size: .75rem; color: #94a3b8; white-space: nowrap; }
.doc-action-group {
display: flex; align-items: center; gap: .4rem;
margin-left: auto;
background: #f1f5f9; border: 1px solid #e2e8f0; border-radius: 7px; padding: 3px 6px;
}
/* ── Export buttons ──────────────────────────────────────── */
.btn-excel { display: flex; align-items: center; gap: .35rem; background: #16a34a; color: #fff; border-color: #16a34a; }
.btn-excel:hover { background: #15803d; }
.btn-pdf { display: flex; align-items: center; gap: .35rem; background: #2563eb; color: #fff; border-color: #2563eb; }
.btn-pdf:hover { background: #1d4ed8; }
.pdf-icon { width: 1em; height: 1em; flex-shrink: 0; }
/* ── Floating export ─────────────────────────────────────── */
.floating-export {
position: fixed;
bottom: 1.5rem;
right: 1.75rem;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: .5rem;
z-index: 150;
filter: drop-shadow(0 4px 12px rgba(0,0,0,.18));
}
.floating-export-btns { display: flex; gap: .4rem; }
/* ── Page settings modal ─────────────────────────────────── */
.page-settings-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,.35); z-index: 500;
align-items: center; justify-content: center;
}
.page-settings-overlay.open { display: flex; }
.page-settings-dialog {
background: #fff; border-radius: 10px;
box-shadow: 0 8px 32px rgba(0,0,0,.2); width: 300px; overflow: hidden;
}
.page-settings-hdr {
display: flex; align-items: center; justify-content: space-between;
padding: .75rem 1rem; background: #f8fafc; border-bottom: 1px solid #e2e8f0;
font-weight: 600; font-size: .9rem; color: #1e293b;
}
.page-settings-close {
background: none; border: none; font-size: 1.25rem; line-height: 1;
cursor: pointer; color: #94a3b8; padding: 0 2px;
}
.page-settings-close:hover { color: #1e293b; }
.page-settings-body { padding: .9rem 1rem; display: flex; flex-direction: column; gap: .6rem; }
.page-settings-row { display: flex; align-items: center; gap: .5rem; font-size: .875rem; color: #1e293b; }
.page-settings-row label { display: flex; align-items: center; gap: .45rem; cursor: pointer; }
.page-settings-row span { white-space: nowrap; flex: 1; color: #475569; }
.page-settings-input {
width: 72px; padding: .3rem .4rem;
border: 1px solid #cbd5e1; border-radius: 5px; font-size: .875rem; outline: none;
}
.page-settings-input:focus { border-color: #2563eb; box-shadow: 0 0 0 2px #dbeafe; }
/* ── Arrow direction override (filters above the action row) ── */
.filters-arrow { transform: rotate(270deg); } /* expanded = up */
.filters-arrow.collapsed { transform: rotate(90deg); } /* collapsed = down */
/* ── Pagination ──────────────────────────────────────────── */
#doc-pagination {
display: flex;
align-items: center;
justify-content: flex-end;
gap: .25rem;
flex-wrap: wrap;
background: rgba(255,255,255,.96);
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: .4rem .6rem;
max-width: calc(100vw - 3.5rem);
}
.doc-pag-btn {
min-width: 2rem;
height: 2rem;
padding: 0 .5rem;
border: 1px solid #e2e8f0;
border-radius: 5px;
background: #fff;
color: #374151;
font-size: .82rem;
cursor: pointer;
transition: background .1s, border-color .1s;
line-height: 1;
}
.doc-pag-btn:hover:not([disabled]) { background: #f1f5f9; border-color: #94a3b8; }
.doc-pag-btn.active { background: #2563eb; color: #fff; border-color: #1d4ed8; font-weight: 600; }
.doc-pag-btn[disabled] { opacity: .35; cursor: default; }
.doc-pag-ellipsis { color: #94a3b8; font-size: .85rem; padding: 0 .2rem; }
+50
View File
@@ -0,0 +1,50 @@
/* ── filters.css — shared filter toggle (all pages) ── */
/* ── Sticky page header (filter bar) — shared across all pages ── */
.page-header {
position: sticky;
top: 0;
z-index: 50;
background: var(--surf, #fff);
border-bottom: 1px solid var(--brd, #e2e8f0);
box-shadow: 0 2px 8px rgba(0,0,0,.06);
padding-bottom: .6rem;
margin-bottom: 0;
}
.page-header + section { padding-top: 1rem; }
/* Wrapper that keeps the header row and collapsible in the same container */
.page-filter-wrap { display: flex; flex-direction: column; }
.page-filter-header { display: flex; align-items: center; margin-bottom: .5rem; }
.btn-toggle-filters {
display: flex; flex-direction: column; align-items: center; gap: .15rem;
background: #2563eb; border: 1px solid #1d4ed8; border-radius: 6px;
padding: .4rem .35rem; cursor: pointer; color: #fff;
flex-shrink: 0; margin-left: auto;
transition: background .12s, border-color .12s;
}
.btn-toggle-filters:hover { background: #1d4ed8; border-color: #1e40af; }
.filters-btn-text {
writing-mode: vertical-rl; transform: rotate(180deg);
font-size: .72rem; font-weight: 600; letter-spacing: .04em; user-select: none;
}
.filters-arrow {
width: .9em; height: .9em; flex-shrink: 0;
transition: transform .22s ease; transform: rotate(180deg);
}
.filters-arrow.collapsed { transform: rotate(0deg); }
.filters-collapsible {
display: grid; grid-template-rows: 1fr;
clip-path: inset(0 0 0 0);
transition: grid-template-rows .32s ease, clip-path .28s ease;
}
.filters-collapsible.hidden {
grid-template-rows: 0fr;
clip-path: inset(0 0 0 100%);
}
.filters-collapsible-inner { overflow: hidden; }
+69
View File
@@ -0,0 +1,69 @@
/* iscrizioni.css — Corsi Intraziendali filter bar */
.isc-header {
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
margin-bottom: .5rem;
}
.isc-header h2 { margin: 0; }
.isc-filters {
display: flex;
gap: 1.5rem;
align-items: center;
flex-wrap: wrap;
padding: .4rem 0;
}
.isc-filter-group {
display: flex;
align-items: center;
gap: .5rem;
}
.filter-label {
font-size: .85rem;
font-weight: 600;
color: var(--muted, #64748b);
white-space: nowrap;
}
.isc-filter-checks {
display: flex;
gap: .75rem;
flex-wrap: wrap;
align-items: center;
}
.isc-filter-checks label {
display: flex;
align-items: center;
gap: .3rem;
font-size: .9rem;
cursor: pointer;
user-select: none;
}
.isc-filter-checks input[type="checkbox"] {
width: 1rem;
height: 1rem;
cursor: pointer;
accent-color: #2563eb;
}
.isc-search {
border: 1px solid var(--brd, #e2e8f0);
border-radius: 6px;
padding: .3rem .65rem;
font-size: .9rem;
outline: none;
min-width: 200px;
background: var(--bg, #fff);
color: inherit;
}
.isc-search:focus {
border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37,99,235,.15);
}
+28
View File
@@ -0,0 +1,28 @@
/* logs.css — stili specifici per la pagina Log DB */
.log-filters{display:flex;align-items:center;gap:14px;margin-bottom:12px;flex-wrap:wrap}
.log-filters input[type=search]{padding:6px 10px;border:1px solid var(--brd);border-radius:6px;font-size:.85rem;width:230px;background:var(--surf);color:var(--txt)}
.log-filters label{display:flex;align-items:center;gap:5px;font-size:.85rem;cursor:pointer;color:var(--mut);user-select:none}
.log-filters input[type=checkbox]{cursor:pointer;accent-color:var(--pri)}
/* pagination — floating center bottom */
.log-pagination {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 8px;
z-index: 150;
background: rgba(255,255,255,.96);
border: 1px solid var(--brd);
border-radius: 8px;
padding: .4rem .75rem;
box-shadow: 0 4px 12px rgba(0,0,0,.18);
flex-wrap: wrap;
}
.pg-btn{padding:5px 13px;border:1px solid var(--brd);border-radius:6px;font-size:.85rem;text-decoration:none;color:var(--txt);background:var(--surf);cursor:pointer}
.pg-btn:hover{background:var(--pri);color:#fff;border-color:var(--pri)}
.pg-btn.disabled{opacity:.35;pointer-events:none;cursor:default}
.pg-select{padding:5px 8px;border:1px solid var(--brd);border-radius:6px;font-size:.85rem;background:var(--surf);color:var(--txt);cursor:pointer}
.pg-info{font-size:.8rem;color:var(--mut);margin-left:4px}
+30
View File
@@ -0,0 +1,30 @@
/* pec.css — stili specifici per la pagina PEC */
.pec-header {
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
margin-bottom: .5rem;
}
.pec-header h2 { margin: 0; }
.pec-filter {
display: flex;
gap: 1rem;
align-items: center;
}
.pec-filter label {
display: flex;
align-items: center;
gap: .35rem;
font-size: .9rem;
cursor: pointer;
user-select: none;
}
.pec-filter input[type="checkbox"] {
width: 1rem;
height: 1rem;
cursor: pointer;
accent-color: #2563eb;
}
+177
View File
@@ -0,0 +1,177 @@
/* ── Toolbar ─────────────────────────────────────────────────────────────── */
.report-header {
position: sticky;
top: 0;
z-index: 50;
background: var(--surf, #fff);
border-bottom: 1px solid #e2e8f0;
box-shadow: 0 2px 8px rgba(0,0,0,.06);
padding-bottom: .6rem;
margin-bottom: 0;
}
.report-header + section { padding-top: 1rem; }
.report-toolbar {
display: flex;
flex-direction: column;
gap: .5rem;
background: #fff;
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: .6rem 1rem;
}
.toolbar-row1 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.floating-report-ctrl {
position: fixed;
bottom: 1.5rem;
right: 1.75rem;
z-index: 150;
filter: drop-shadow(0 4px 12px rgba(0,0,0,.18));
}
.toolbar-right-row1 {
display: flex;
align-items: center;
gap: .5rem;
flex-shrink: 0;
}
/* Left side: groups of filters */
.report-filters {
display: flex;
align-items: center;
gap: .75rem;
flex-wrap: wrap;
}
/* A visual group (date group, checkbox group) */
.filter-group {
display: flex;
align-items: center;
gap: .5rem;
background: #f8fafc;
border: 1px solid #e2e8f0;
border-radius: 6px;
padding: .3rem .6rem;
}
.filter-label {
display: flex;
align-items: center;
gap: .3rem;
font-size: .83rem;
color: #475569;
white-space: nowrap;
}
.filter-label input[type=date] {
padding: .2rem .4rem;
border: 1px solid #cbd5e1;
border-radius: 4px;
font-size: .83rem;
background: #fff;
color: #1e293b;
}
.chk-label {
display: flex;
align-items: center;
gap: .3rem;
font-size: .83rem;
cursor: pointer;
user-select: none;
}
/* Action buttons inside the left side */
.btn-action {
padding: .28rem .7rem;
border: 1px solid #cbd5e1;
border-radius: 5px;
background: #fff;
color: #374151;
font-size: .83rem;
cursor: pointer;
transition: background .12s, border-color .12s;
}
.btn-action:hover { background: #f1f5f9; border-color: #94a3b8; }
.btn-action.btn-primary {
background: #2563eb;
color: #fff;
border-color: #1d4ed8;
}
.btn-action.btn-primary:hover { background: #1d4ed8; }
/* Row count pill */
.report-count {
font-size: .78rem;
color: #64748b;
background: #f1f5f9;
border: 1px solid #e2e8f0;
border-radius: 999px;
padding: .15rem .6rem;
white-space: nowrap;
}
/* ── Toggle button ───────────────────────────────────────────────────────── */
.btn-toggle {
padding: .3rem .85rem;
border: 1px solid #1d4ed8;
border-radius: 6px;
background: #2563eb;
color: #fff;
font-size: .83rem;
cursor: pointer;
transition: background .12s, border-color .12s;
white-space: nowrap;
}
.btn-toggle:hover { background: #1d4ed8; }
.btn-toggle.active { background: #1e40af; border-color: #1e3a8a; }
/* ── Table cells ─────────────────────────────────────────────────────────── */
td.val-prec { color: #dc2626; }
td.val-nuovo { color: #16a34a; }
td.col-prima-lez { color: #64748b; font-size: .8rem; white-space: nowrap; }
.val-prec { color: #dc2626; }
.val-nuovo { color: #16a34a; }
/* ── Grouped view: mod-cell ──────────────────────────────────────────────── */
.mod-cell { min-width: 220px; }
.mod-row {
display: flex;
flex-wrap: wrap;
gap: .25rem;
align-items: baseline;
font-size: .81rem;
padding: .15rem 0;
border-bottom: 1px dotted #e2e8f0;
}
.mod-row:last-child { border-bottom: none; }
.mod-campo { font-weight: 600; color: #475569; }
.mod-none { color: #94a3b8; }
/* ── Docs modal ──────────────────────────────────────────────────────────── */
.doc-btn { background: none; border: none; cursor: pointer; font-size: 1rem; opacity: .7; padding: 2px 4px; border-radius: 4px; }
.doc-btn:hover { opacity: 1; background: #f1f5f9; }
.docs-table { width: 100%; border-collapse: collapse; font-size: .875rem; }
.docs-table th { text-align: left; padding: 6px 10px; border-bottom: 2px solid #e2e8f0; color: #475569; font-weight: 600; }
.docs-table td { padding: 6px 10px; border-bottom: 1px solid #f1f5f9; color: #1e293b; }
.docs-table tr:last-child td { border-bottom: none; }
.doc-status-badge { display: inline-block; font-size: .75rem; font-weight: 600; padding: 2px 7px; border-radius: 99px; white-space: nowrap; }
.doc-status-trovato { background: #dbeafe; color: #1d4ed8; }
.doc-status-caricato { background: #dcfce7; color: #15803d; }
.doc-status-warning { background: #fef9c3; color: #a16207; }
.doc-status-errore { background: #fee2e2; color: #b91c1c; }
.doc-subfolder { color: #64748b; font-size: .8rem; white-space: nowrap; }
.doc-filename a { color: #2563eb; text-decoration: none; }
.doc-filename a:hover { text-decoration: underline; }
.doc-date { color: #94a3b8; font-size: .8rem; white-space: nowrap; }
.docs-loading, .docs-empty, .docs-error { color: #64748b; font-size: .9rem; padding: 12px 0; }
.docs-error { color: #dc2626; }
+30
View File
@@ -0,0 +1,30 @@
/* runs.css — stili specifici per la pagina Processi */
.runs-header {
display: flex;
align-items: center;
gap: 1.5rem;
flex-wrap: wrap;
margin-bottom: .5rem;
}
.runs-header h2 { margin: 0; }
.runs-filter {
display: flex;
gap: 1rem;
align-items: center;
}
.runs-filter label {
display: flex;
align-items: center;
gap: .35rem;
font-size: .9rem;
cursor: pointer;
user-select: none;
}
.runs-filter input[type="checkbox"] {
width: 1rem;
height: 1rem;
cursor: pointer;
accent-color: #2563eb;
}
+1
View File
@@ -0,0 +1 @@
/* server-logs.css — stili specifici per la pagina Server Logs */
+62
View File
@@ -0,0 +1,62 @@
:root{--bg:#f1f5f9;--surf:#fff;--brd:#e2e8f0;--txt:#1e293b;--mut:#64748b;
--pri:#2563eb;--ok:#16a34a;--warn:#d97706;--err:#dc2626;--lbl:#0284c7;--nav:#1d4ed8}
*{box-sizing:border-box;margin:0;padding:0}
html,body{height:100%}
body{font-family:'Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--txt);font-size:14px;line-height:1.5;display:flex;flex-direction:column;overflow:hidden}
header{background:var(--pri);color:#fff;padding:18px 32px;display:flex;justify-content:space-between;align-items:center;flex-shrink:0}
header h1{font-size:1.3rem;font-weight:600}
header .ts{font-size:.82rem;opacity:.8}
section{margin-bottom:38px}
h2{font-size:1rem;font-weight:600;margin-bottom:12px;color:var(--pri);
border-left:3px solid var(--pri);padding-left:10px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(150px,1fr));gap:12px;margin-bottom:28px}
.card{background:var(--surf);border:1px solid var(--brd);border-radius:8px;padding:14px 18px;text-align:center}
.cv{font-size:1.9rem;font-weight:700}
.cl{font-size:.75rem;color:var(--mut);margin-top:4px}
.tw{overflow-x:auto;background:var(--surf);border:1px solid var(--brd);border-radius:8px}
table{width:100%;border-collapse:collapse}
thead tr{background:#f8fafc}
th{padding:9px 13px;text-align:left;font-size:.73rem;text-transform:uppercase;
letter-spacing:.05em;color:var(--mut);border-bottom:1px solid var(--brd);white-space:nowrap}
td{padding:8px 13px;border-bottom:1px solid var(--brd);vertical-align:top}
tr:last-child td{border-bottom:none}
tr:hover td{background:#f8fafc}
.nc{max-width:260px;word-break:break-word;color:var(--mut)}
.badge{display:inline-block;padding:2px 8px;border-radius:999px;font-size:.72rem;font-weight:600;white-space:nowrap}
.ok {background:#dcfce7;color:var(--ok)}
.warn{background:#fef9c3;color:var(--warn)}
.err {background:#fee2e2;color:var(--err)}
.info{background:#dbeafe;color:#1d4ed8}
.proc{background:#ede9fe;color:#6d28d9}
.log {background:#e0f2fe;color:var(--lbl)}
.empty{color:var(--mut);font-style:italic;padding:14px}
footer{text-align:center;color:var(--mut);font-size:.78rem;padding:16px 0 36px}
main{max-width:1400px;width:100%;margin:0 auto;padding:24px 32px;flex:1;overflow-y:auto}
/* header left nav (Dashboard / Processi RPA / Log DB) */
.hdr-left{display:flex;align-items:center;gap:4px;margin-right:24px}
.hdr-nav{color:rgba(255,255,255,.75);text-decoration:none;padding:6px 14px;font-size:.82rem;
font-weight:500;border-radius:4px;transition:background .15s,opacity .15s}
.hdr-nav:hover{color:#fff;background:rgba(255,255,255,.15)}
.hdr-nav.active{color:#fff;background:rgba(255,255,255,.22);font-weight:700}
/* content nav (Documenti / Iscrizioni / Report / PEC) */
nav{background:var(--nav);display:flex;gap:2px;padding:0 32px;flex-shrink:0}
nav a{color:#fff;text-decoration:none;padding:10px 18px;font-size:.85rem;font-weight:500;
border-radius:4px 4px 0 0;opacity:.75;transition:opacity .15s}
nav a:hover{opacity:1}
nav a.active{opacity:1;background:var(--bg);color:var(--pri)}
/* header right */
.hdr-right{display:flex;align-items:center;gap:16px}
/* refresh button */
.refresh-btn{background:none;border:none;color:#fff;font-size:1.25rem;cursor:pointer;padding:4px 8px;opacity:.8;transition:opacity .15s,transform .3s;line-height:1;border-radius:4px}
.refresh-btn:hover{opacity:1;background:rgba(255,255,255,.15);transform:rotate(180deg)}
/* wrench menu */
.wrench-menu{position:relative}
.wrench-btn{background:none;border:none;color:#fff;font-size:1.15rem;cursor:pointer;padding:4px 8px;opacity:.8;transition:opacity .15s;line-height:1;border-radius:4px}
.wrench-btn:hover{opacity:1;background:rgba(255,255,255,.15)}
.wrench-drop{display:none;position:absolute;right:0;top:calc(100% + 6px);background:#fff;border:1px solid var(--brd);border-radius:6px;min-width:175px;box-shadow:0 4px 14px rgba(0,0,0,.15);z-index:100}
.wrench-drop.open{display:block}
.wrench-drop a{display:block;padding:10px 16px;color:var(--txt);text-decoration:none;font-size:.85rem;white-space:nowrap}
.wrench-drop a:hover{background:var(--bg);border-radius:6px}
/* sort indicators */
th.sort-asc::after{content:' \25B2';font-size:.65rem;opacity:.7}
th.sort-desc::after{content:' \25BC';font-size:.65rem;opacity:.7}
+74
View File
@@ -0,0 +1,74 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>${title} — RPA Report</title>
<style>${css}</style>
<style>${page_css}</style>
</head>
<body>
<header>
<div class="hdr-left">
<a href="/" class="hdr-nav ${nav_dashboard}">Dashboard</a>
<a href="/runs" class="hdr-nav ${nav_runs}">Processi RPA</a>
<a href="/logs" class="hdr-nav ${nav_logs}">Log DB</a>
</div>
<h1>${h1_title}</h1>
<div class="hdr-right">
<span class="ts">${now} &nbsp;|&nbsp; ${db_name}</span>
<button class="refresh-btn" title="Aggiorna dati" onclick="location.reload()">&#8635;</button>
<div class="wrench-menu">
<button class="wrench-btn" title="Impostazioni">&#9881;</button>
<div class="wrench-drop">
<a href="/change-db">&#128194; Cambia DB&hellip;</a>
</div>
</div>
</div>
</header>
<nav>
${nav_step_link}
${nav_report_link}
${nav_pec_link}
</nav>
<main>
${content}
</main>
<footer>RPA Process Dashboard &mdash; ${now} &nbsp;&bull;&nbsp; ATG</footer>
<script>${page_js}</script>
<script>
(function(){
var btn = document.querySelector('.wrench-btn');
var drop = document.querySelector('.wrench-drop');
if(btn && drop){
btn.addEventListener('click', function(e){
e.stopPropagation();
drop.classList.toggle('open');
});
document.addEventListener('click', function(){
drop.classList.remove('open');
});
}
})();
document.querySelectorAll('th').forEach(function(th, i) {
th.style.cursor = 'pointer';
th.title = 'Clicca per ordinare';
var asc = true;
th.addEventListener('click', function() {
var tbody = th.closest('table').querySelector('tbody');
var rows = Array.prototype.slice.call(tbody.querySelectorAll('tr'));
rows.sort(function(a, b) {
var av = (a.cells[i] ? a.cells[i].textContent : '').trim();
var bv = (b.cells[i] ? b.cells[i].textContent : '').trim();
return asc ? av.localeCompare(bv, 'it', {numeric: true}) : bv.localeCompare(av, 'it', {numeric: true});
});
rows.forEach(function(r) { tbody.appendChild(r); });
th.closest('thead').querySelectorAll('th').forEach(function(h) { h.classList.remove('sort-asc','sort-desc'); });
th.classList.add(asc ? 'sort-asc' : 'sort-desc');
asc = !asc;
});
});
</script>
</body>
</html>
+11
View File
@@ -0,0 +1,11 @@
<section>
<h2><a href="/runs" class="sec-link">&#9654; Processi RPA</a></h2>
<div class="grid">${cards_runs}</div>
</section>
${section_middle}
<section>
<h2><a href="/logs" class="sec-link">&#128196; Log DB</a></h2>
<div class="grid">${cards_logs}</div>
</section>
+50
View File
@@ -0,0 +1,50 @@
<section class="doc-header">
<div class="doc-toolbar">
<div id="filters-collapsible" class="filters-collapsible">
<div class="filters-collapsible-inner">
<div class="doc-toolbar-row">
<input type="text" id="doc-search" placeholder="Cerca ID sessione, corso, azienda…" class="doc-search">
<div class="doc-filter-group">
<label class="chk-label"><input type="checkbox" class="status-chk" value="trovato" checked> <span class="badge doc-trovato">trovato</span></label>
<label class="chk-label"><input type="checkbox" class="status-chk" value="caricato" checked> <span class="badge doc-caricato">caricato</span></label>
<label class="chk-label"><input type="checkbox" class="status-chk" value="warning" checked> <span class="badge doc-warning">warning</span></label>
<label class="chk-label"><input type="checkbox" class="status-chk" value="errore" checked> <span class="badge doc-errore">errore</span></label>
</div>
</div>
<div class="doc-toolbar-row">
<span class="filter-label">Tipo:</span>
<div class="doc-filter-group" id="filter-tipo"></div>
</div>
<div class="doc-toolbar-row">
<span class="filter-label">Cartella:</span>
<div class="doc-filter-group" id="filter-cartella"></div>
</div>
</div>
</div>
<div class="doc-toolbar-row">
<div class="doc-action-group">
<button id="btn-refresh" class="btn-action btn-refresh">&#x21bb; Aggiorna dati</button>
<span id="doc-refresh-ts" class="doc-refresh-ts"></span>
<span class="doc-count" id="doc-visible-count"></span>
<button id="btn-expand-all" class="btn-action">Espandi tutti</button>
<button id="btn-collapse-all" class="btn-action">Comprimi tutti</button>
</div>
<button id="btn-toggle-filters" class="btn-toggle-filters" title="Mostra/nascondi filtri">
<svg id="filters-arrow" class="filters-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>
<span class="filters-btn-text">Filtri</span>
</button>
</div>
</div>
</section>
<section id="doc-content">
${sessions_html}
</section>
<div class="floating-export">
<div id="doc-pagination"></div>
<div class="floating-export-btns">
<button id="btn-excel" class="btn-action btn-excel"><svg class="pdf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M8 7l4 5 4-5M8 17l4-5 4 5"/></svg> Excel</button>
<button id="btn-pdf" class="btn-action btn-pdf"><svg class="pdf-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="13" y2="17"/></svg> PDF</button>
</div>
</div>
+32
View File
@@ -0,0 +1,32 @@
<section class="page-header">
<div class="page-filter-wrap">
<div class="isc-header">
<h2>&#128203; Corsi Intraziendali</h2>
<button id="btn-toggle-filters" class="btn-toggle-filters" title="Mostra/nascondi filtri">
<svg id="filters-arrow" class="filters-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>
<span class="filters-btn-text">Filtri</span>
</button>
</div>
<div id="filters-collapsible" class="filters-collapsible">
<div class="filters-collapsible-inner">
<div class="isc-filters">
<div class="isc-filter-group">
<span class="filter-label">Cerca:</span>
<input type="text" id="isc-search" class="isc-search" placeholder="Azienda, corso&hellip;">
</div>
<div class="isc-filter-group">
<span class="filter-label">Stato:</span>
<div class="isc-filter-checks" id="filter-stato">${filter_stato_html}</div>
</div>
<div class="isc-filter-group" id="filter-sorgente-wrap">
<span class="filter-label">Sorgente:</span>
<div class="isc-filter-checks" id="filter-sorgente"></div>
</div>
</div>
</div>
</div>
</div>
</section>
<section>
<div class="tw">${tbl_iscrizioni}</div>
</section>
+25
View File
@@ -0,0 +1,25 @@
<section class="page-header">
<div class="page-filter-wrap">
<div class="page-filter-header">
<h2>Log DB &mdash; ${run_info}</h2>
<button id="btn-toggle-filters" class="btn-toggle-filters" title="Mostra/nascondi filtri">
<svg id="filters-arrow" class="filters-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>
<span class="filters-btn-text">Filtri</span>
</button>
</div>
<div id="filters-collapsible" class="filters-collapsible">
<div class="filters-collapsible-inner">
<div class="log-filters">
<input type="search" id="log-search" placeholder="Cerca nei log&hellip;" />
<label><input type="checkbox" class="type-filter" value="log" checked> Log</label>
<label><input type="checkbox" class="type-filter" value="warning" checked> Warning</label>
<label><input type="checkbox" class="type-filter" value="error" checked> Error</label>
</div>
</div>
</div>
</div>
</section>
<section>
<div class="tw">${tbl_logs}</div>
</section>
${pagination}
+23
View File
@@ -0,0 +1,23 @@
<section class="page-header">
<div class="page-filter-wrap">
<div class="pec-header">
<h2>Comunicazioni PEC (ultime 200)</h2>
<button id="btn-toggle-filters" class="btn-toggle-filters" title="Mostra/nascondi filtri">
<svg id="filters-arrow" class="filters-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>
<span class="filters-btn-text">Filtri</span>
</button>
</div>
<div id="filters-collapsible" class="filters-collapsible">
<div class="filters-collapsible-inner">
<div class="pec-filter">
<label><input type="checkbox" data-stato="inviato" checked> Inviato</label>
<label><input type="checkbox" data-stato="pending" checked> Pending</label>
<label><input type="checkbox" data-stato="errore" checked> Errore</label>
</div>
</div>
</div>
</div>
</section>
<section>
<div class="tw">${tbl_pec}</div>
</section>
+48
View File
@@ -0,0 +1,48 @@
<section class="report-header">
<div class="report-toolbar">
<div class="toolbar-row1">
<div class="toolbar-right-row1">
<button id="btn-reset" class="btn-action">Reset</button>
<span class="report-count" id="visible-count"></span>
<button id="btn-toggle-filters" class="btn-toggle-filters" title="Mostra/nascondi filtri">
<svg id="filters-arrow" class="filters-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>
<span class="filters-btn-text">Filtri</span>
</button>
</div>
</div>
<div id="filters-collapsible" class="filters-collapsible">
<div class="filters-collapsible-inner">
<div class="report-filters">
<div class="filter-group">
<label class="filter-label">Dal <input type="date" id="f-da"></label>
<label class="filter-label">Al <input type="date" id="f-a"></label>
<label class="filter-label">ID Sessione <input type="text" id="f-sid" placeholder="es. 123" style="width:80px;padding:.3rem .5rem;border:1px solid #cbd5e1;border-radius:6px;font-size:.875rem;"></label>
<button id="btn-filter" class="btn-action btn-primary">Filtra</button>
</div>
<div class="filter-group">
<label class="chk-label"><input type="checkbox" class="tipo-chk" value="nuovo" checked> <span class="badge ok">Nuovo</span></label>
<label class="chk-label"><input type="checkbox" class="tipo-chk" value="modificato" checked> <span class="badge warn">Modificato</span></label>
</div>
</div>
</div>
</div>
</div>
</section>
<section>
<div class="tw">${tbl_report}</div>
</section>
<div class="floating-report-ctrl">
<button id="btn-toggle-view" class="btn-toggle">&#10727; Raggruppa corso</button>
</div>
<!-- ── Docs Modal ───────────────────────────────────────────────────────── -->
<div id="docs-modal" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.45);z-index:1000;align-items:center;justify-content:center;">
<div style="background:#fff;border-radius:8px;padding:24px;min-width:520px;max-width:80vw;max-height:80vh;display:flex;flex-direction:column;gap:12px;box-shadow:0 8px 32px rgba(0,0,0,.25);">
<div style="display:flex;align-items:center;justify-content:space-between;">
<h3 style="margin:0;font-size:1rem;color:#1e293b;">📄 Documenti — <span id="docs-modal-corso"></span><br><small style="font-weight:400;color:#64748b;">sessione <span id="docs-modal-sid" style="color:#2563eb;"></span></small></h3>
<button id="docs-modal-close" style="background:none;border:none;font-size:1.4rem;cursor:pointer;color:#64748b;line-height:1;" title="Chiudi">&times;</button>
</div>
<div id="docs-modal-body" style="overflow-y:auto;flex:1;"></div>
</div>
</div>
+22
View File
@@ -0,0 +1,22 @@
<section class="page-header">
<div class="page-filter-wrap">
<div class="runs-header">
<h2>Processi RPA (ultimi 50)</h2>
<button id="btn-toggle-filters" class="btn-toggle-filters" title="Mostra/nascondi filtri">
<svg id="filters-arrow" class="filters-arrow" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 6 15 12 9 18"/></svg>
<span class="filters-btn-text">Filtri</span>
</button>
</div>
<div id="filters-collapsible" class="filters-collapsible">
<div class="filters-collapsible-inner">
<div class="runs-filter" id="runs-filter">
<label><input type="checkbox" id="chk-completed" checked> Completati</label>
<label><input type="checkbox" id="chk-incomplete" checked> Incompleti</label>
</div>
</div>
</div>
</div>
</section>
<section>
<div class="tw">${tbl_processes}</div>
</section>