feat(web): dashboard ergonomic cu tab-uri, stepper import si microcopy uman (3.4)

Reorganizeaza interfata web pe trei principii, fara a atinge backend-ul de
trimitere (worker, mapping, idempotency, masina de stari neatinse):

- US-001 app/web/labels.py: modul pur stari tehnice -> text uman + clasa CSS
- US-002 bara status /_fragments/status: microcopy uman, defalcare blocate, scoped cont
- US-003 shell 6 tab-uri (Acasa/Import/Coada/Mapari/Cont/Nomenclator): deep-link
  ?tab=, panou activ randat server-side, fragmente inactive lazy, ARIA real
- US-004 stepper import 4 pasi (pur vizual; hx-target + csrf pastrate)
- US-005 Acasa onboarding checklist auto-bifat + colaps + empty states prietenoase

Reparat in cursul VERIFY/CLOSE: izolare teste (reset ratelimit._hits in fixturi),
regresie avertisment "cont in asteptare de activare" (re-introdus in bara status),
culori hardcodate -> variabile paleta. 434 teste pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-18 22:26:10 +00:00
parent ccd26115f8
commit 4a1d28749a
22 changed files with 1889 additions and 96 deletions

View File

@@ -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)