diff --git a/app/web/labels.py b/app/web/labels.py new file mode 100644 index 0000000..2ebfdf3 --- /dev/null +++ b/app/web/labels.py @@ -0,0 +1,126 @@ +""" +labels.py — traducere stari tehnice in text uman + clasa CSS (US-001, PRD 3.4). + +Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri. + +Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001. +""" + +from typing import Tuple + +# --------------------------------------------------------------------------- +# Tipul returnat: (text_principal, subtext_tooltip, css_class) +# --------------------------------------------------------------------------- + +Eticheta = Tuple[str, str, str] + +# --------------------------------------------------------------------------- +# Etichete stari submissions +# Clasele CSS corespund celor definite in base.html: +# s-queued (accent/albastru), s-sending (warn/galben), s-sent (ok/verde), +# s-error, s-needs_data, s-needs_mapping (err/rosu). +# --------------------------------------------------------------------------- + +STARI_SUBMISSION: dict[str, Eticheta] = { + "queued": ( + "In asteptare sa fie trimise", + "", + "s-queued", + ), + "sending": ( + "Se trimite acum", + "", + "s-sending", + ), + "sent": ( + "Declarate la RAR (finalizate)", + "Confirmate cu numar de prezentare; nu se mai pot modifica.", + "s-sent", + ), + "needs_mapping": ( + "Lipseste codul prestatiei", + "Alege codul RAR in tab-ul Mapari.", + "s-needs_mapping", + ), + "needs_data": ( + "Date incomplete (respinse de RAR)", + "Corecteaza randul si reimporta.", + "s-needs_data", + ), + "error": ( + "Eroare la trimitere", + "Vezi detaliul randului; se reincearca automat sau necesita corectie.", + "s-error", + ), +} + + +def eticheta_stare(status: str) -> Eticheta: + """ + Returneaza (text, subtext, css_class) pentru o stare de submission. + + Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda + stari noi adaugate in schema fara mapare corespunzatoare. + """ + try: + return STARI_SUBMISSION[status] + except KeyError: + raise KeyError( + f"Starea de submission {status!r} nu are eticheta umana in labels.py. " + "Adauga-o in STARI_SUBMISSION." + ) + + +# --------------------------------------------------------------------------- +# Etichete worker (viu / mort) +# --------------------------------------------------------------------------- + +def eticheta_worker(viu: bool) -> Eticheta: + """ + Returneaza (text, subtext, css_class) pentru starea worker-ului. + + viu=True => "Trimitere automata: activa" (clasa s-sent / verde) + viu=False => "Trimitere automata: oprita" (clasa s-error / rosu) + """ + if viu: + return ( + "Trimitere automata: activa", + "Sistemul verifica coada si trimite la RAR la fiecare cateva secunde.", + "s-sent", + ) + return ( + "Trimitere automata: oprita", + "Nimic nu pleaca spre RAR pana reporneste. Anunta administratorul.", + "s-error", + ) + + +# --------------------------------------------------------------------------- +# Etichete conexiune RAR (ok / indisponibil) +# --------------------------------------------------------------------------- + +def eticheta_rar(stare: str) -> Eticheta: + """ + Returneaza (text, subtext, css_class) pentru starea conexiunii cu RAR. + + stare="ok" => "Legatura cu RAR: functionala" (s-sent / verde) + stare="indisponibil" => "Legatura cu RAR: indisponibila" (s-error / rosu) + """ + if stare == "ok": + return ( + "Legatura cu RAR: functionala", + "Portalul AUTOPASS raspunde.", + "s-sent", + ) + return ( + "Legatura cu RAR: indisponibila", + "Portalul RAR nu raspunde acum; coada se reia automat cand revine.", + "s-error", + ) + + +# --------------------------------------------------------------------------- +# Constante auxiliare (microcopy fix, fara logica) +# --------------------------------------------------------------------------- + +ETICHETA_ULTIMA_AUTENTIFICARE_RAR = "Ultima autentificare la RAR" diff --git a/app/web/routes.py b/app/web/routes.py index c4740c6..a82a271 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -24,6 +24,12 @@ from fastapi.templating import Jinja2Templates from .. import __version__ from ..auth import rotate_api_key from ..web.csrf import get_csrf_token, verify_csrf +from .labels import ( + ETICHETA_ULTIMA_AUTENTIFICARE_RAR, + eticheta_rar, + eticheta_stare, + eticheta_worker, +) from ..web.session import require_login from ..api.v1.import_router import ( _already_sent_lookup, @@ -115,25 +121,142 @@ def _rar_state(hb, worker_alive: bool) -> str: return "indisponibil?" if age > 108000 else "ok" +_TABS_VALIDE = {"acasa", "import", "coada", "mapari", "cont", "nomenclator"} + + +def _get_acasa_context(request: Request, conn, account_id: int) -> dict: + """Calculeaza contextul pentru panoul Acasa (US-005). + + Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1). + """ + from ..mapping import account_or_default + acct = account_or_default(account_id) + + # Pas 1: are credentiale RAR configurate? + row = conn.execute( + "SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,) + ).fetchone() + are_creds = bool(row and row["rar_creds_enc"]) + + # Pas 3: are cel putin un submission (trimis sau in coada)? + row_sub = conn.execute( + "SELECT 1 FROM submissions " + "WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) LIMIT 1", + (acct, acct), + ).fetchone() + are_trimiteri = row_sub is not None + + # Pas 2 (optional): are cheie API activa? + row_key = conn.execute( + "SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1", + (acct,), + ).fetchone() + are_cheie_folosita = row_key is not None + + return { + "request": request, + "are_creds": are_creds, + "are_trimiteri": are_trimiteri, + "are_cheie_folosita": are_cheie_folosita, + } + + +def _render_panel_acasa(request: Request, conn=None, account_id: int = 1) -> str: + """Randeaza panoul Acasa ca string HTML.""" + if conn is None: + return templates.get_template("_acasa.html").render({"request": request}) + ctx = _get_acasa_context(request, conn, account_id) + return templates.get_template("_acasa.html").render(ctx) + + +def _render_panel_import(request: Request) -> str: + """Randeaza panoul Import ca string HTML (include _upload.html).""" + return templates.get_template("_upload.html").render({ + "request": request, + "csrf_token": get_csrf_token(request), + }) + + +def _render_panel_coada(request: Request) -> str: + """Randeaza panoul Coada ca string HTML.""" + return templates.get_template("_coada.html").render({"request": request}) + + +def _render_panel_mapari(request: Request, conn, account_id: int) -> str: + """Randeaza panoul Mapari ca string HTML.""" + return templates.get_template("_mapari.html").render({ + "request": request, + "pending": pending_unmapped(conn, account_id), + "nomenclator": load_nomenclator(conn), + "message": None, + "csrf_token": get_csrf_token(request), + }) + + +def _render_panel_cont(request: Request, conn, account_id: int) -> str: + """Randeaza panoul Cont ca string HTML.""" + from ..mapping import account_or_default + acct = account_or_default(account_id) + row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone() + are_creds = bool(row and row["rar_creds_enc"]) + return templates.get_template("_cont.html").render({ + "request": request, + "csrf_token": get_csrf_token(request), + "api_key": None, + "are_creds": are_creds, + "creds_mesaj": None, + "creds_eroare": None, + "rot_eroare": None, + }) + + +def _render_panel_nomenclator(request: Request, conn) -> str: + """Randeaza panoul Nomenclator ca string HTML.""" + rows = conn.execute( + "SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie" + ).fetchall() + return templates.get_template("_nomenclator.html").render({ + "request": request, + "rows": rows, + }) + + +def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> str: + """Randeaza panoul corespunzator unui tab ca string HTML.""" + if tab == "acasa": + return _render_panel_acasa(request, conn, account_id) + if tab == "import": + return _render_panel_import(request) + if tab == "coada": + return _render_panel_coada(request) + if tab == "mapari": + return _render_panel_mapari(request, conn, account_id) + if tab == "cont": + return _render_panel_cont(request, conn, account_id) + if tab == "nomenclator": + return _render_panel_nomenclator(request, conn) + return _render_panel_acasa(request) + + @router.get("/", response_class=HTMLResponse) -def dashboard(request: Request) -> HTMLResponse: +def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse: + """Dashboard principal cu tab-uri (US-003). + + Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat + server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS). + Tab invalid -> fallback la 'acasa'. + """ account_id = require_login(request) + active_tab = tab if tab in _TABS_VALIDE else "acasa" conn = get_connection() try: - counts = _status_counts(conn, account_id) - hb = read_heartbeat(conn) - blocked = sum(counts.get(s, 0) for s in _BLOCKED) - worker_alive = _worker_alive(hb) + panel_html = _render_panel_for_tab(request, conn, account_id, active_tab) ctx = { "request": request, "rar_env": get_settings().rar_env, "version": __version__, - "counts": counts, - "blocked": blocked, - "worker_alive": worker_alive, - "last_login": hb["last_rar_login_ok"] if hb else None, - "rar_state": _rar_state(hb, worker_alive), - "account_active": _account_active(conn, account_id), + "active_tab": active_tab, + "panel_html": panel_html, "is_admin": is_account_admin(conn, account_id), "csrf_token": get_csrf_token(request), } @@ -142,6 +265,32 @@ def dashboard(request: Request) -> HTMLResponse: conn.close() +@router.get("/_fragments/acasa", response_class=HTMLResponse) +def fragment_acasa(request: Request) -> HTMLResponse: + """Fragment HTMX pentru tab-ul Acasa (US-003, US-005).""" + account_id = require_login(request) + conn = get_connection() + try: + ctx = _get_acasa_context(request, conn, account_id) + return templates.TemplateResponse("_acasa.html", ctx) + finally: + conn.close() + + +@router.get("/_fragments/import", response_class=HTMLResponse) +def fragment_import(request: Request) -> HTMLResponse: + """Fragment HTMX pentru tab-ul Import — include zona de upload (US-003).""" + require_login(request) + return templates.TemplateResponse("_upload.html", _ctx(request)) + + +@router.get("/_fragments/coada", response_class=HTMLResponse) +def fragment_coada(request: Request) -> HTMLResponse: + """Fragment HTMX pentru tab-ul Coada — include coada submissions (US-003).""" + require_login(request) + return templates.TemplateResponse("_coada.html", {"request": request}) + + @router.get("/_fragments/nomenclator", response_class=HTMLResponse) def fragment_nomenclator(request: Request) -> HTMLResponse: """Browser nomenclator RAR (cache local upsert-at de worker la fiecare login).""" @@ -173,6 +322,56 @@ def fragment_banner(request: Request) -> HTMLResponse: conn.close() +def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]: + """Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0. + + Ordinea: needs_mapping, needs_data, error — aceeasi ca in PRD. + Returneaza lista goala daca nu exista nicio stare blocata. + """ + rezultat = [] + for status in ("needs_mapping", "needs_data", "error"): + n = counts.get(status, 0) + if n > 0: + rezultat.append((eticheta_stare(status), n)) + return rezultat + + +@router.get("/_fragments/status", response_class=HTMLResponse) +def fragment_status(request: Request) -> HTMLResponse: + """Bara de status persistenta cu etichete umane (US-002, PRD 3.4). + + Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima + autentificare, contorii de coada si defalcarea blocatelor pe motiv. + Logica in routes.py (nu in template) pentru testabilitate. + """ + account_id = require_login(request) + conn = get_connection() + try: + counts = _status_counts(conn, account_id) + hb = read_heartbeat(conn) + worker_alive = _worker_alive(hb) + rar_state = _rar_state(hb, worker_alive) + + # Etichete umane pre-calculate (nu logica in template) + worker_lbl = eticheta_worker(worker_alive) + # eticheta_rar accepta "ok" sau orice alt string -> indisponibil/necunoscut + rar_lbl = eticheta_rar("ok" if rar_state == "ok" else rar_state) + + return templates.TemplateResponse("_status.html", { + "request": request, + "worker_lbl": worker_lbl, + "rar_lbl": rar_lbl, + "eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR, + "last_login": hb["last_rar_login_ok"] if hb else None, + "counts_queued": counts.get("queued", 0), + "counts_sent": counts.get("sent", 0), + "blocate_defalcat": _blocate_defalcat(counts), + "account_active": _account_active(conn, account_id), + }) + finally: + conn.close() + + @router.get("/_fragments/submissions", response_class=HTMLResponse) def fragment_submissions(request: Request) -> HTMLResponse: account_id = require_login(request) diff --git a/app/web/templates/_acasa.html b/app/web/templates/_acasa.html new file mode 100644 index 0000000..edd094f --- /dev/null +++ b/app/web/templates/_acasa.html @@ -0,0 +1,81 @@ +
+ +{% set toti_esentiali = are_creds and are_trimiteri %} + +{% if toti_esentiali %} +{# Ghid colapsat/discret cand toti pasii esentiali sunt gata #} +
+ Totul e configurat — + vezi coada +
+{% else %} +{# Card ghid de pornire vizibil cand nu toti pasii sunt finalizati #} +
+

Primii pasi

+ +
+{% endif %} + +{# Rezumat si scurtaturi rapide (mereu vizibile) #} +
+

Bun venit la Gateway RAR AUTOPASS

+

+ Importa fisiere din tab-ul Import, + urmareste coada in tab-ul Coada + si rezolva mapari lipsa in tab-ul Mapari. +

+
+ Coada submissions + Import fisier nou + Mapari operatii +
+
+ +
diff --git a/app/web/templates/_coada.html b/app/web/templates/_coada.html new file mode 100644 index 0000000..b5bbd72 --- /dev/null +++ b/app/web/templates/_coada.html @@ -0,0 +1,14 @@ +
+
+
+

Coada submissions

+ + export CSV: trimise + toate + +
+
+
se incarca…
+
+
+
diff --git a/app/web/templates/_mapari.html b/app/web/templates/_mapari.html index dc8e137..a71eef0 100644 --- a/app/web/templates/_mapari.html +++ b/app/web/templates/_mapari.html @@ -6,7 +6,10 @@ {% endif %} {% if not pending %} -
Nicio operatie nemapata. Tot ce a venit s-a tradus in coduri RAR.
+
+ Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR. + Importa un fisier nou daca vrei sa adaugi prezentari. +
{% else %}

Operatii ROAAUTO necunoscute, blocate in needs_mapping. diff --git a/app/web/templates/_mapcoloane.html b/app/web/templates/_mapcoloane.html index a201355..d89408f 100644 --- a/app/web/templates/_mapcoloane.html +++ b/app/web/templates/_mapcoloane.html @@ -1,4 +1,5 @@

+ {% set pas = 2 %}{% include '_stepper.html' %}

Mapare coloane — diff --git a/app/web/templates/_preview_import.html b/app/web/templates/_preview_import.html index e50e39c..0d43d5e 100644 --- a/app/web/templates/_preview_import.html +++ b/app/web/templates/_preview_import.html @@ -1,4 +1,5 @@
+ {% set pas = 3 %}{% include '_stepper.html' %}

diff --git a/app/web/templates/_status.html b/app/web/templates/_status.html new file mode 100644 index 0000000..ae53bb4 --- /dev/null +++ b/app/web/templates/_status.html @@ -0,0 +1,80 @@ +
+ + + {% if not account_active %} +
+ Cont in asteptare de activare. + Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR + porneste automat dupa activare de catre administrator. +
+ {% endif %} + +
+ + +
+
{{ worker_lbl[0] }}
+
+ {{ worker_lbl[0].split(':')[1].strip() if ':' in worker_lbl[0] else worker_lbl[0] }} +
+ {% if worker_lbl[1] %} +
{{ worker_lbl[1] }}
+ {% endif %} +
+ + +
+
Legatura RAR
+
+ {{ rar_lbl[0].split(':')[1].strip() if ':' in rar_lbl[0] else rar_lbl[0] }} +
+ {% if rar_lbl[1] and rar_lbl[2] != 's-sent' %} +
{{ rar_lbl[1] }}
+ {% endif %} +
+ + +
+
{{ eticheta_ultima_auth }}
+
{{ last_login or '—' }}
+
+ + +
+
In asteptare
+
{{ counts_queued }}
+
+ + +
+
Declarate la RAR
+
{{ counts_sent }}
+
+ +
+ + + {% if blocate_defalcat %} +
+
Necesita atentia ta
+
+ {% for eticheta, n in blocate_defalcat %} + {% if n > 0 %} +
+ {{ eticheta[0] }} + ({{ n }}) + {% if eticheta[1] %} +
{{ eticheta[1] }}
+ {% endif %} +
+ {% endif %} + {% endfor %} +
+
+ {% endif %} + +
diff --git a/app/web/templates/_stepper.html b/app/web/templates/_stepper.html new file mode 100644 index 0000000..9b1a6ae --- /dev/null +++ b/app/web/templates/_stepper.html @@ -0,0 +1,96 @@ +{# + _stepper.html — Antet wizard import (PUR vizual, fara logica de rutare). + + Parametru: `pas` (integer 1-4) — pasul curent. + Utilizare in template-uri care mostenesc contextul Jinja2: + {% set pas = 1 %}{% include '_stepper.html' %} + sau cu `with`: + {% with pas=2 %}{% include '_stepper.html' %}{% endwith %} + + Cei 4 pasi ficsi: + 1. Incarca fisier + 2. Potriveste coloanele + 3. Verifica + 4. Confirma trimiterea + + Stari vizuale: + - index < pas → "facut" (bulina plina, text bifat) + - index == pas → "activ" (evidentiat, aria-current="step") + - index > pas → "viitor" (estompat) +#} +{%- set _pasi_import = [ + (1, "Incarca fisier", "Trage un fisier xlsx/csv aici sau foloseste butonul de alegere."), + (2, "Potriveste coloanele", "Spune-ne ce coloana din fisier corespunde cu ce camp RAR."), + (3, "Verifica", "Verifica randurile inainte sa le trimiti la RAR."), + (4, "Confirma trimiterea", "Confirma numarul de prezentari — actiunea e ireversibila."), +] -%} + diff --git a/app/web/templates/_submissions.html b/app/web/templates/_submissions.html index 582c932..4d94015 100644 --- a/app/web/templates/_submissions.html +++ b/app/web/templates/_submissions.html @@ -18,5 +18,9 @@

{% else %} -
Coada e goala. Trimite o prezentare prin POST /v1/prezentari.
+
+ Nicio trimitere inca — + incepe cu Import + sau trimite o prezentare prin API. +
{% endif %} diff --git a/app/web/templates/_upload.html b/app/web/templates/_upload.html index 8173444..6d13801 100644 --- a/app/web/templates/_upload.html +++ b/app/web/templates/_upload.html @@ -1,4 +1,5 @@
+ {% set pas = 1 %}{% include '_stepper.html' %}

Import fisier (xlsx / csv)

diff --git a/app/web/templates/base.html b/app/web/templates/base.html index a4d2d7a..3ae9c33 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -64,6 +64,20 @@ button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; } button:hover { filter:brightness(1.08); } .chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; } + /* Tab-bar (US-003) */ + .tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch; + border-bottom:1px solid var(--line); margin-bottom:16px; padding-bottom:0; + scrollbar-width:none; } + .tab-bar::-webkit-scrollbar { display:none; } + .tab-link { display:inline-flex; align-items:center; padding:8px 16px; font-size:14px; + font-weight:500; color:var(--muted); text-decoration:none; border-radius:6px 6px 0 0; + border:1px solid transparent; border-bottom:none; white-space:nowrap; + transition:color .12s, background .12s; margin-bottom:-1px; } + .tab-link:hover { color:var(--ink); background:var(--line); } + .tab-link.tab-activ { color:var(--ink); background:var(--card); + border-color:var(--line); border-bottom-color:var(--card); } + .tab-panel { min-height:120px; } + .status-bar { margin-bottom:12px; } diff --git a/app/web/templates/dashboard.html b/app/web/templates/dashboard.html index b1e319a..504ce0f 100644 --- a/app/web/templates/dashboard.html +++ b/app/web/templates/dashboard.html @@ -10,61 +10,83 @@
- -{% include '_upload.html' %} - -