From ebafa611be44c6650c5b5fda11d82286c040ed2f Mon Sep 17 00:00:00 2001 From: Luca Banfi Date: Wed, 6 May 2026 13:59:13 +0200 Subject: [PATCH] init --- .gitignore | 13 + Reports.py | 495 +++++++++++++++++++++++++++++ guida.md | 14 + queries/__init__.py | 0 queries/db.py | 67 ++++ queries/documenti.py | 126 ++++++++ queries/iscrizioni.py | 121 +++++++ queries/logs.py | 51 +++ queries/pec.py | 58 ++++ queries/processes.py | 41 +++ queries/report.py | 101 ++++++ static/js/documenti.js | 177 +++++++++++ static/js/documenti_export.js | 65 ++++ static/js/documenti_filters.js | 80 +++++ static/js/filters.js | 12 + static/js/iscrizioni.js | 59 ++++ static/js/logs.js | 16 + static/js/pec.js | 16 + static/js/report.js | 233 ++++++++++++++ static/js/runs.js | 14 + static/pages/dashboard.css | 3 + static/pages/documenti.css | 125 ++++++++ static/pages/documenti_filters.css | 144 +++++++++ static/pages/filters.css | 50 +++ static/pages/iscrizioni.css | 69 ++++ static/pages/logs.css | 28 ++ static/pages/pec.css | 30 ++ static/pages/report.css | 177 +++++++++++ static/pages/runs.css | 30 ++ static/pages/server-logs.css | 1 + static/style.css | 62 ++++ templates/_base.html | 74 +++++ templates/dashboard.html | 11 + templates/documenti.html | 50 +++ templates/iscrizioni.html | 32 ++ templates/logs.html | 25 ++ templates/pec.html | 23 ++ templates/report.html | 48 +++ templates/runs.html | 22 ++ 39 files changed, 2763 insertions(+) create mode 100644 .gitignore create mode 100644 Reports.py create mode 100644 guida.md create mode 100644 queries/__init__.py create mode 100644 queries/db.py create mode 100644 queries/documenti.py create mode 100644 queries/iscrizioni.py create mode 100644 queries/logs.py create mode 100644 queries/pec.py create mode 100644 queries/processes.py create mode 100644 queries/report.py create mode 100644 static/js/documenti.js create mode 100644 static/js/documenti_export.js create mode 100644 static/js/documenti_filters.js create mode 100644 static/js/filters.js create mode 100644 static/js/iscrizioni.js create mode 100644 static/js/logs.js create mode 100644 static/js/pec.js create mode 100644 static/js/report.js create mode 100644 static/js/runs.js create mode 100644 static/pages/dashboard.css create mode 100644 static/pages/documenti.css create mode 100644 static/pages/documenti_filters.css create mode 100644 static/pages/filters.css create mode 100644 static/pages/iscrizioni.css create mode 100644 static/pages/logs.css create mode 100644 static/pages/pec.css create mode 100644 static/pages/report.css create mode 100644 static/pages/runs.css create mode 100644 static/pages/server-logs.css create mode 100644 static/style.css create mode 100644 templates/_base.html create mode 100644 templates/dashboard.html create mode 100644 templates/documenti.html create mode 100644 templates/iscrizioni.html create mode 100644 templates/logs.html create mode 100644 templates/pec.html create mode 100644 templates/report.html create mode 100644 templates/runs.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6ede97c --- /dev/null +++ b/.gitignore @@ -0,0 +1,13 @@ +# Environment / runtime config +.env + +# Python bytecode +__pycache__/ +*.pyc +*.pyo + +# SQLite database files +*.db +*.db-journal +*.db-wal +*.db-shm diff --git a/Reports.py b/Reports.py new file mode 100644 index 0000000..1dda915 --- /dev/null +++ b/Reports.py @@ -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" # /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 — Corsi Intraziendali' if step == 'step2' else 'RPA — Comunicazioni Regione Lombardia' + + # dynamic nav items depend on step + if step == 'step2': + cls = 'active' if active == 'nav_iscrizioni' else '' + nav_step_link = f'Corsi Intraziendali' + nav_pec_link = '' + nav_report_link = '' + else: + cls = 'active' if active == 'nav_documenti' else '' + nav_step_link = f'Documenti' + cls_pec = 'active' if active == 'nav_pec' else '' + nav_pec_link = f'PEC' + cls_rep = nav.get('nav_report', '') + nav_report_link = f'Report' + + 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'
{_e(value)}
{_e(label)}
' + + +# ------------------------------------------------------------------ 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 = ( + '
' + '

📋 Corsi Intraziendali

' + f'
{cards_middle}
' + '
' + ) + 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 = ( + '
' + '

✉ Comunicazioni PEC

' + f'
{cards_middle}
' + '
' + ) + + 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='

Nessun log trovato.

' + ) + 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'← Precedente' + if prev_id else '← Precedente') + next_btn = (f'Successivo →' + if next_id else 'Successivo →') + options = ''.join( + f'' + for rid in run_ids + ) + pagination = ( + f'
' + f'{prev_btn}' + f'' + f'{next_btn}' + f'Run {idx + 1} / {len(run_ids)}' + f'
' + ) + + 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'' + f'{_e(e["ts"])}{_e(e["method"])}{_e(e["path"])}' + f'{e["code"]}' + f'{_e(e["client"])}' + f'' + for e in reversed(_access_log) + ) + table = ( + '' + '' + '' + + (rows or '') + + '
TimestampMethodPathStatusClient
Nessuna richiesta ancora.
' + ) + content = f'

Accessi HTTP (ultimi 200)

{table}
' + 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"
Errore: {_e(str(exc))}
") + elif path == '/ping': + self._send(200, 'ok', 'text/plain; charset=utf-8') + else: + self._send(404, '

404 Not Found

') + + 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) diff --git a/guida.md b/guida.md new file mode 100644 index 0000000..30aaa66 --- /dev/null +++ b/guida.md @@ -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 +``` \ No newline at end of file diff --git a/queries/__init__.py b/queries/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/queries/db.py b/queries/db.py new file mode 100644 index 0000000..343bc76 --- /dev/null +++ b/queries/db.py @@ -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('&','&').replace('<','<').replace('>','>').replace('"','"') + +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'{text}' + + +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' diff --git a/queries/documenti.py b/queries/documenti.py new file mode 100644 index 0000000..c483b0a --- /dev/null +++ b/queries/documenti.py @@ -0,0 +1,126 @@ +"""queries/documenti.py β€” sessione_documenti con join sessione""" + +from .db import query, e, dt + + +_STATUS_BADGE = { + 'trovato': 'trovato', + 'caricato': 'caricato', + 'errore': 'errore', + 'warning': 'warning', +} + + +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 '

Nessun documento registrato.

' + + # 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'{n_caricato} caricati' if n_caricato else '') + + (f'{n_trovato} trovati' if n_trovato else '') + + (f'{n_warning} warning' if n_warning else '') + + (f'{n_errore} errori' if n_errore else '') + ) + + tbody_rows = '' + for d in docs: + status = d['doc_status'] or '' + badge_html = _STATUS_BADGE.get(status, f'{e(status)}') + if d['sp_web_url']: + sp_cell = f'πŸ”—' + else: + sp_cell = '❌' + tbody_rows += ( + f'' + f'{"" + e(d["filename"]) + "" if d["filename"] and d["filename"].startswith("[") and d["filename"].endswith("]") else e(d["filename"])}' + f'{e(d["doc_type"])}' + f'{e(d["subfolder"])}' + f'{badge_html}' + f'{sp_cell}' + f'{e(d["note"])}' + f'{dt(d["uploaded_at"]) if status == "caricato" else "-"}' + f'' + ) + + subtable = ( + '' + '' + '' + '' + '' + f'{tbody_rows}' + '
FileTipoCartellaStatoSPNoteCaricato
' + ) + + open_attr = ' open' if (n_errore > 0 or n_warning > 0) else '' + html_parts.append( + f'
' + f'' + f'#{e(sid)}' + f'{e(s["corso"])}' + f'{e(s["azienda"])}' + f'{counts_html}' + f'' + f'
{subtable}
' + f'
' + ) + + return '\n'.join(html_parts) diff --git a/queries/iscrizioni.py b/queries/iscrizioni.py new file mode 100644 index 0000000..6aec801 --- /dev/null +++ b/queries/iscrizioni.py @@ -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'' + for s in STEP_STATES + ) + + +def render_table(rows: list) -> str: + if not rows: + return '

Nessun corso trovato.

' + + trs = ''.join( + f'' + f'{e(r.get("id"))}' + f'{e(r.get("sorgente"))}' + f'{e(r.get("azienda_nome"))}' + f'{e(r.get("sessione_id"))}' + f'{e(r.get("corso_descrizione"))}' + f'{e(r.get("corso_data"))}' + f'{e(r.get("n_iscritti"))}' + f'{_sb(r.get("step_ftp"))}' + f'{_sb(r.get("step_bc"))}' + f'{_sb(r.get("step_iscrizione"))}' + f'{_sb(r.get("step_odv"))}' + f'{_sb(r.get("step_sharepoint"))}' + f'{_sb(r.get("step_report"))}' + f'{_sb(r.get("step_email"))}' + f'{e(r.get("error_note"))}' + f'' + for r in rows + ) + return ( + '' + '' + '' + '' + '' + '' + f'{trs}
#SorgenteAziendaSessioneCorsoDataN.FTPBCIscrizioneODVSharePointReportEmailNote
' + ) + + +# backward-compat alias used by Reports.py +def get_iscrizioni(db_path: str) -> list: + return get_corsi(db_path) diff --git a/queries/logs.py b/queries/logs.py new file mode 100644 index 0000000..04d8a79 --- /dev/null +++ b/queries/logs.py @@ -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 '

Nessun log trovato.

' + + 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'' + f'{e(r.get("rpa_process_id"))}' + f'{dt(r.get("date_log"))}' + f'{_badge(r.get("type"))}' + f'{e(r.get("log"))}' + f'' + for r in rows + ) + return ( + '' + '' + f'{trs}
ProcessoDataTipoMessaggio
' + ) diff --git a/queries/pec.py b/queries/pec.py new file mode 100644 index 0000000..a8866b0 --- /dev/null +++ b/queries/pec.py @@ -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 '

Nessuna comunicazione PEC trovata.

' + + 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'' + f'{e(r.get("articolo_cod"))}' + f'{e(r.get("sessione_id"))}' + f'{dt(r.get("data_sessione"))}' + f'{e(r.get("tipo_comunicazione"))}' + f'{e(r.get("email_ats"))}' + f'{dt(r.get("data_invio_pec"))}' + f'{_badge(r.get("stato_invio"))}' + f'{e(r.get("note"))}' + f'' + for r in rows + ) + return ( + '' + '' + '' + f'{trs}
CorsoSessioneData SessioneTipoEmail ATSData InvioStatoNote
' + ) diff --git a/queries/processes.py b/queries/processes.py new file mode 100644 index 0000000..6b17618 --- /dev/null +++ b/queries/processes.py @@ -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 '

Nessun processo trovato.

' + + def _badge(completed): + return badge('ok', 'Completato') if completed in (1, True, '1') else badge('err', 'Incompleto') + + trs = ''.join( + f'' + f'{dt(r.get("start_run"))}' + f'{dt(r.get("finish_run"))}' + f'{dur(r.get("start_run"), r.get("finish_run"))}' + f'{_badge(r.get("completed"))}' + f'{e(r.get("note"))}' + f'' + for r in rows + ) + return ( + '' + '' + f'{trs}
InizioFineDurataStatoNote
' + ) diff --git a/queries/report.py b/queries/report.py new file mode 100644 index 0000000..ab4a3e0 --- /dev/null +++ b/queries/report.py @@ -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 '
Nessun dato
' + + 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'' + f'{tag}' + f'{sid}' + f'{e(r["corso"])}' + f'{e(r["azienda"])}' + f'{e(r["articolo_cod"])}' + f'{d(r["prima_lezione"])}' + f'{campo}' + f'{prec}' + f'{nuovo}' + f'{dt(r["rilevato_at"])}' + f'' + f'' + ) + + thead = ( + '' + 'Tipo' + 'ID Sessione' + 'Corso' + 'Azienda' + 'Articolo' + 'Prima Lezione' + 'Campo' + 'Valore Precedente' + 'Valore Nuovo' + 'Rilevato' + '' + '' + ) + tbody = '' + ''.join(row_html(r) for r in rows) + '' + return f'{thead}{tbody}
' diff --git a/static/js/documenti.js b/static/js/documenti.js new file mode 100644 index 0000000..cf45d37 --- /dev/null +++ b/static/js/documenti.js @@ -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 = ''; + if (lo > 1) { + html += ''; + if (lo > 2) html += ''; + } + for (var i = lo; i <= hi; i++) { + html += ''; + } + if (hi < total) { + if (hi < total - 1) html += ''; + html += ''; + } + html += ''; + + 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 = '⚙ Impostazioni…'; + wrenchDrop.appendChild(link); + } + + var modal = document.createElement('div'); + modal.id = 'page-settings-modal'; + modal.className = 'page-settings-overlay'; + modal.innerHTML = + '
' + + '
' + + 'Impostazioni pagina' + + '' + + '
' + + '
' + + '
' + + '
Intervallo (sec)
' + + '
' + + '
'; + 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; } +} diff --git a/static/js/documenti_export.js b/static/js/documenti_export.js new file mode 100644 index 0000000..a6b3260 --- /dev/null +++ b/static/js/documenti_export.js @@ -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'); }); +}); diff --git a/static/js/documenti_filters.js b/static/js/documenti_filters.js new file mode 100644 index 0000000..a51debc --- /dev/null +++ b/static/js/documenti_filters.js @@ -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(); diff --git a/static/js/filters.js b/static/js/filters.js new file mode 100644 index 0000000..a776ed5 --- /dev/null +++ b/static/js/filters.js @@ -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); + }); +})(); diff --git a/static/js/iscrizioni.js b/static/js/iscrizioni.js new file mode 100644 index 0000000..a56a4d9 --- /dev/null +++ b/static/js/iscrizioni.js @@ -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); +})(); diff --git a/static/js/logs.js b/static/js/logs.js new file mode 100644 index 0000000..75d47d7 --- /dev/null +++ b/static/js/logs.js @@ -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); }); +})(); diff --git a/static/js/pec.js b/static/js/pec.js new file mode 100644 index 0000000..f658c4b --- /dev/null +++ b/static/js/pec.js @@ -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); + }); +})(); diff --git a/static/js/report.js b/static/js/report.js new file mode 100644 index 0000000..33b1517 --- /dev/null +++ b/static/js/report.js @@ -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 = '' + + 'TipoID SessioneCorso' + + 'AziendaArticoloPrima LezioneModificheRilevato' + + ''; + + var isGrouped = false; + + function esc(s) { + return String(s == null ? '' : s) + .replace(/&/g,'&').replace(//g,'>'); + } + + function badgeHtml(tipo) { + if (tipo === 'nuovo') return 'Nuovo'; + if (tipo === 'modificato') return 'Modificato'; + 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 '
' + + '' + esc(c.campo) + ': ' + + '' + esc(c.prec) + '' + + ' → ' + + '' + esc(c.nuovo) + '' + + '
'; + }).join('') + : '\u2014'; + + html += '' + + '' + tipiHtml + '' + + '' + esc(b.sessione_id) + '' + + '' + esc(b.corso) + '' + + '' + esc(b.azienda) + '' + + '' + esc(b.articolo) + '' + + '' + esc(b.prima_lezione) + '' + + '' + modHtml + '' + + '' + esc(g.latestRilevato) + '' + + '' + + ''; + }); + + tbody.innerHTML = html || 'Nessun dato'; + 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,'&').replace(//g,'>'); + } + + function openDocsModal(sid, corso) { + modalSid.textContent = sid; + modalCorso.textContent = corso || ''; + modalBody.innerHTML = '

Caricamento\u2026

'; + 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 = '

Nessun documento caricato.

'; + return; + } + var rows = docs.map(function(d) { + var st = d.doc_status || ''; + var badge = '' + esc(st) + ''; + var fn = d.filename || ''; + var fnHtml = esc(fn); + if (fn.charAt(0) === '[' && fn.charAt(fn.length - 1) === ']') + fnHtml = '' + fnHtml + ''; + var name = d.sp_web_url + ? '' + fnHtml + '' + : fnHtml; + var ts = d.uploaded_at ? d.uploaded_at.slice(0,16).replace('T',' ') : '-'; + return '' + + '' + esc(d.subfolder) + '' + + '' + name + '' + + '' + badge + '' + + '' + ts + '' + + ''; + }).join(''); + modalBody.innerHTML = + '' + + '' + + '' + rows + '' + + '
CartellaFileDocCaricato SP
'; + }) + .catch(function(err) { + modalBody.innerHTML = '

Errore: ' + esc(String(err)) + '

'; + }); + } + + 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')); + }); +})(); diff --git a/static/js/runs.js b/static/js/runs.js new file mode 100644 index 0000000..9f4a665 --- /dev/null +++ b/static/js/runs.js @@ -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); +})(); diff --git a/static/pages/dashboard.css b/static/pages/dashboard.css new file mode 100644 index 0000000..6397d51 --- /dev/null +++ b/static/pages/dashboard.css @@ -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} diff --git a/static/pages/documenti.css b/static/pages/documenti.css new file mode 100644 index 0000000..3187b38 --- /dev/null +++ b/static/pages/documenti.css @@ -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; } +} diff --git a/static/pages/documenti_filters.css b/static/pages/documenti_filters.css new file mode 100644 index 0000000..7e608c6 --- /dev/null +++ b/static/pages/documenti_filters.css @@ -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; } diff --git a/static/pages/filters.css b/static/pages/filters.css new file mode 100644 index 0000000..14475fe --- /dev/null +++ b/static/pages/filters.css @@ -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; } diff --git a/static/pages/iscrizioni.css b/static/pages/iscrizioni.css new file mode 100644 index 0000000..17eaade --- /dev/null +++ b/static/pages/iscrizioni.css @@ -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); +} diff --git a/static/pages/logs.css b/static/pages/logs.css new file mode 100644 index 0000000..99f4ba0 --- /dev/null +++ b/static/pages/logs.css @@ -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} diff --git a/static/pages/pec.css b/static/pages/pec.css new file mode 100644 index 0000000..d650d50 --- /dev/null +++ b/static/pages/pec.css @@ -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; +} diff --git a/static/pages/report.css b/static/pages/report.css new file mode 100644 index 0000000..d4c6805 --- /dev/null +++ b/static/pages/report.css @@ -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; } diff --git a/static/pages/runs.css b/static/pages/runs.css new file mode 100644 index 0000000..b4a957d --- /dev/null +++ b/static/pages/runs.css @@ -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; +} diff --git a/static/pages/server-logs.css b/static/pages/server-logs.css new file mode 100644 index 0000000..587304e --- /dev/null +++ b/static/pages/server-logs.css @@ -0,0 +1 @@ +/* server-logs.css β€” stili specifici per la pagina Server Logs */ diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..124d12a --- /dev/null +++ b/static/style.css @@ -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} diff --git a/templates/_base.html b/templates/_base.html new file mode 100644 index 0000000..0321749 --- /dev/null +++ b/templates/_base.html @@ -0,0 +1,74 @@ + + + + + +${title} β€” RPA Report + + + + +
+ +

${h1_title}

+
+ ${now}  |  ${db_name} + +
+ + +
+
+
+ +
+${content} +
+
RPA Process Dashboard — ${now}  •  ATG
+ + + + diff --git a/templates/dashboard.html b/templates/dashboard.html new file mode 100644 index 0000000..42ee7f0 --- /dev/null +++ b/templates/dashboard.html @@ -0,0 +1,11 @@ +
+

▶ Processi RPA

+
${cards_runs}
+
+ +${section_middle} + +
+

📄 Log DB

+
${cards_logs}
+
diff --git a/templates/documenti.html b/templates/documenti.html new file mode 100644 index 0000000..863ad81 --- /dev/null +++ b/templates/documenti.html @@ -0,0 +1,50 @@ +
+
+
+
+
+ +
+ + + + +
+
+
+ Tipo: +
+
+
+ Cartella: +
+
+
+
+
+
+ + + + + +
+ +
+
+
+ +
+${sessions_html} +
+ +
+
+
+ + +
+
diff --git a/templates/iscrizioni.html b/templates/iscrizioni.html new file mode 100644 index 0000000..0483458 --- /dev/null +++ b/templates/iscrizioni.html @@ -0,0 +1,32 @@ + +
+
${tbl_iscrizioni}
+
diff --git a/templates/logs.html b/templates/logs.html new file mode 100644 index 0000000..6c7f4b4 --- /dev/null +++ b/templates/logs.html @@ -0,0 +1,25 @@ + +
+
${tbl_logs}
+
+${pagination} diff --git a/templates/pec.html b/templates/pec.html new file mode 100644 index 0000000..55ae53b --- /dev/null +++ b/templates/pec.html @@ -0,0 +1,23 @@ + +
+
${tbl_pec}
+
diff --git a/templates/report.html b/templates/report.html new file mode 100644 index 0000000..38e0605 --- /dev/null +++ b/templates/report.html @@ -0,0 +1,48 @@ +
+
+
+
+ + + +
+
+
+
+
+
+ + + + +
+
+ + +
+
+
+
+
+
+
+
${tbl_report}
+
+ +
+ +
+ + + diff --git a/templates/runs.html b/templates/runs.html new file mode 100644 index 0000000..d294266 --- /dev/null +++ b/templates/runs.html @@ -0,0 +1,22 @@ + +
+
${tbl_processes}
+