feat(web): uniformizare/standardizare UI/UX + lifecycle conturi (PRD 5.5)

Aduce toate suprafetele dashboard-ului la grila tabelului Trimiteri, muta
navigarea intr-un meniu de cont (hamburger) si da panoului admin actiuni
reale de ciclu de viata. 9 stories, 3 valuri. UI pur (reskin + reasezare)
cu O SINGURA exceptie backend: modelul de stare a contului.

- US-001 sectiunea "Ajutor" eliminata din Acasa (wayfinding redundant).
- US-002 Nomenclator la grila standard (_submissions.html ca referinta).
- US-003 macro autosend compact (Manual<->Auto). Semantica de PREZENTA
  `auto_send` (bifat->true, absent->false) NEALTERATA — compatibil cu ambele
  parsere (Form(bool) la /mapari, bool(form.get()) la import). Zero backend.
- US-004 accounts.status (pending/active/blocked/archived/deleted), migrare
  defensiva idempotenta derivata din `active`, gate worker claim_one pe
  status='active' (echivalenta active=1 <=> status='active' pastrata).
- US-005 tabel Mapari compact + panou Ajutor (<details>, proza o singura data),
  coloana "In coada".
- US-006 meniu hamburger dropdown (Cont/Integrare/Nomenclator/Admin/logout) +
  context is_authenticated/is_admin/csrf_token defensiv in base.html.
- US-007 tab-bar redus la Acasa+Mapari; rutele /_fragments/{cont,integrare,
  nomenclator} + deep-link ?tab= raman valide.
- US-008 rute admin block/archive/delete + bulk pe lista account_id,
  require_admin + CSRF + PRG, dev id=1 sarit in bulk.
- US-009 admin UI: selectie bife + master + bara bulk + kebab per-rand,
  grupare pe stare (bloc nou blocate/arhivate), nota "cont dev implicit" scoasa.

Stergere = SOFT: tombstone (status='deleted'), dar PII purjata IMEDIAT
(rar_creds_enc + chei API revocate + CUI eliberat pentru re-inregistrare),
GDPR/L.142.

VERIFY: 671 teste pass (+40). E2E browser (Playwright) a prins 2 bug-uri
invizibile la TestClient: bara bulk cu display:flex inline invingea [hidden]
(mutat in CSS .bulk-bar[hidden]); conturi arhivate cadeau sub "in asteptare"
(grupare pe status). /code-review high a prins 2 bug-uri reale: soft delete
pastra creds RAR + CUI la nesfarsit fara purjare accounts (GDPR neonorat);
apostrof in numele firmei rupea confirm() inline din kebab — ambele reparate,
plus cleanup boilerplate rute (_lifecycle_route).

Backend trimitere (worker masina stari/idempotenta/mapping) neatins, cu
exceptia gate-ului de cont. Design: docs/design/5.5-uniformizare-ui.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-23 11:56:05 +00:00
parent 14e1c463f0
commit 1fbd894329
27 changed files with 1700 additions and 201 deletions

View File

@@ -39,9 +39,10 @@ def create_account(
raise ValueError("name gol (un cont are nevoie de nume)") raise ValueError("name gol (un cont are nevoie de nume)")
cui = _norm_cui(cui) cui = _norm_cui(cui)
try: try:
# Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'.
cur = conn.execute( cur = conn.execute(
"INSERT INTO accounts (name, cui, active) VALUES (?, ?, ?)", "INSERT INTO accounts (name, cui, active, status) VALUES (?, ?, ?, ?)",
(name, cui, 1 if active else 0), (name, cui, 1 if active else 0, "active" if active else "pending"),
) )
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone() existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone()
@@ -55,16 +56,72 @@ def create_account(
def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None: def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None:
"""Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca). """Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca).
Cont inexistent -> ValueError.""" Cont inexistent -> ValueError.
Mentine invariantul 5.5 active=1 <=> status='active': activarea -> 'active',
dezactivarea -> 'pending' (legacy „in asteptare"). Pentru blocare/arhivare/stergere
foloseste `set_status`/`delete_account`.
"""
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone() row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not row: if not row:
raise ValueError(f"cont inexistent: {account_id}") raise ValueError(f"cont inexistent: {account_id}")
conn.execute("UPDATE accounts SET active=? WHERE id=?", (1 if active else 0, account_id)) conn.execute(
"UPDATE accounts SET active=?, status=? WHERE id=?",
(1 if active else 0, "active" if active else "pending", account_id),
)
# Stari de ciclu de viata gestionate explicit (5.5). 'deleted' = stergere soft (purjata de
# retentie); restul sunt reversibile.
VALID_STATUSES = ("pending", "active", "blocked", "archived", "deleted")
# Verbele care nu se pot aplica contului de sistem id=1 (protejat, ca la deactivate in 3.3b).
_PROTECTED_ACCOUNT_ID = 1
def set_status(conn: sqlite3.Connection, account_id: int, status: str) -> None:
"""Seteaza `accounts.status` la una din `VALID_STATUSES`, mentinand mirror-ul `active`
(active=1 doar pentru 'active', altfel 0).
Contul de sistem id=1 NU poate fi mutat din 'active' (cont default) -> ValueError.
Status invalid sau cont inexistent -> ValueError.
"""
if status not in VALID_STATUSES:
raise ValueError(f"status invalid: {status}")
row = conn.execute("SELECT 1 FROM accounts WHERE id=?", (account_id,)).fetchone()
if not row:
raise ValueError(f"cont inexistent: {account_id}")
if account_id == _PROTECTED_ACCOUNT_ID and status != "active":
raise ValueError("Contul default (id=1) nu poate fi blocat/arhivat/sters (cont de sistem).")
conn.execute(
"UPDATE accounts SET active=?, status=? WHERE id=?",
(1 if status == "active" else 0, status, account_id),
)
def delete_account(conn: sqlite3.Connection, account_id: int) -> None:
"""Stergere SOFT: randul ramane ca tombstone (status='deleted', scos din liste), DAR datele
sensibile se purjeaza IMEDIAT (GDPR/L.142): credentialele RAR criptate sterse, cheile API
revocate si CUI-ul eliberat (ca acelasi CUI sa se poata re-inregistra — altfel indexul unic
`ux_accounts_cui` l-ar tine blocat de un cont invizibil). Contul de sistem id=1 e protejat.
Nota: nu facem hard DELETE pe rand din cauza FK-urilor (submissions/api_keys/...); pastram
tombstone-ul pentru audit, dar fara PII. Jobul de retentie T16 purjeaza `submissions`/batches,
NU acest tombstone — de aceea purjam PII aici, la momentul stergerii."""
set_status(conn, account_id, "deleted") # valideaza existenta + protejeaza id=1; seteaza status+active=0
conn.execute(
"UPDATE accounts SET rar_creds_enc=NULL, cui=NULL WHERE id=?", (account_id,)
)
conn.execute(
"UPDATE api_keys SET active=0, revoked_at=datetime('now') WHERE account_id=? AND active=1",
(account_id,),
)
def list_accounts(conn: sqlite3.Connection) -> list[dict]: def list_accounts(conn: sqlite3.Connection) -> list[dict]:
"""Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id.""" """Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted'
(stergere soft -> invizibile in panou)."""
rows = conn.execute( rows = conn.execute(
"SELECT id, name, cui, active, created_at FROM accounts ORDER BY id" "SELECT id, name, cui, active, status, created_at FROM accounts "
"WHERE status != 'deleted' ORDER BY id"
).fetchall() ).fetchall()
return [dict(r) for r in rows] return [dict(r) for r in rows]

View File

@@ -63,6 +63,18 @@ def _migrate(conn: sqlite3.Connection) -> None:
if "active" not in acc_cols: if "active" not in acc_cols:
# Conturi existente raman active (default 1). Lifecycle consumat de 3.3. # Conturi existente raman active (default 1). Lifecycle consumat de 3.3.
conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1") conn.execute("ALTER TABLE accounts ADD COLUMN active INTEGER NOT NULL DEFAULT 1")
acc_cols.add("active")
if "status" not in acc_cols:
# Stare de ciclu de viata (5.5). Defensiv idempotent (ca is_admin in 3.3b).
# Default 'active' (trece CHECK pe randurile existente), apoi derivam din `active`:
# active=0 -> 'pending'. Invariant: active=1 <=> status='active'.
conn.execute(
"ALTER TABLE accounts ADD COLUMN status TEXT NOT NULL DEFAULT 'active' "
"CHECK (status IN ('pending','active','blocked','archived','deleted'))"
)
conn.execute(
"UPDATE accounts SET status='pending' WHERE active=0 AND status='active'"
)
# Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu. # Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu.
conn.execute( conn.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL" "CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL"

View File

@@ -11,6 +11,13 @@ CREATE TABLE IF NOT EXISTS accounts (
name TEXT NOT NULL, name TEXT NOT NULL,
cui TEXT, cui TEXT,
active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3 active INTEGER NOT NULL DEFAULT 1, -- lifecycle cont (3.1); gate „in asteptare" consumat de 3.3
-- Stare de ciclu de viata explicita (5.5). Superset al lui `active`: mentinem invariantul
-- active=1 <=> status='active' (vezi accounts.set_status / set_active). Worker gate-uieste pe status.
-- pending=neactivat · active=operational · blocked=suspendat reversibil · archived=scos din liste,
-- date read-only · deleted=stergere soft (tombstone; PII/creds + CUI purjate imediat la stergere,
-- vezi accounts.delete_account — randul ramane doar pentru audit).
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('pending','active','blocked','archived','deleted')),
rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1) rar_creds_enc TEXT, -- creds RAR criptate (Fernet) durabile per-cont (D4/Eng#1)
created_at TEXT NOT NULL DEFAULT (datetime('now')) created_at TEXT NOT NULL DEFAULT (datetime('now'))
); );

View File

@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
from .. import __version__ from .. import __version__
from ..accounts import list_accounts, set_active from ..accounts import list_accounts, set_active, set_status, delete_account
from ..config import get_settings from ..config import get_settings
from ..db import get_connection from ..db import get_connection
from ..web.csrf import get_csrf_token, verify_csrf from ..web.csrf import get_csrf_token, verify_csrf
@@ -49,16 +49,20 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co
emails = _emails_by_account(conn) emails = _emails_by_account(conn)
for acct in accounts: for acct in accounts:
acct["email"] = emails.get(acct["id"]) acct["email"] = emails.get(acct["id"])
pending = [a for a in accounts if not a["active"] and a["id"] != 1] # Grupare pe STARE (5.5), nu pe `active`: altfel conturile arhivate/blocate (active=0)
active = [a for a in accounts if a["active"] and a["id"] != 1] # ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
default = next((a for a in accounts if a["id"] == 1), None) pending = [a for a in accounts if a["status"] == "pending" and a["id"] != 1]
active = [a for a in accounts if a["status"] == "active" and a["id"] != 1]
suspended = [a for a in accounts if a["status"] in ("blocked", "archived") and a["id"] != 1]
return _TMPL.TemplateResponse(request, "admin.html", _ctx( return _TMPL.TemplateResponse(request, "admin.html", _ctx(
request, request,
csrf_token=get_csrf_token(request), csrf_token=get_csrf_token(request),
pending=pending, pending=pending,
active=active, active=active,
default_account=default, suspended=suspended,
error=error, error=error,
is_authenticated=True,
is_admin=True,
), status_code=status_code) ), status_code=status_code)
@@ -74,28 +78,66 @@ async def admin_get(request: Request):
conn.close() conn.close()
@router.post("/admin/activate", response_class=HTMLResponse) def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
async def admin_activate( """Aplica un verb de ciclu de viata (5.5) pe o lista de conturi. Conturile protejate
request: Request, (id=1) sau inexistente ridica ValueError din helperi -> sarite (nu opresc bulk-ul).
account_id: int = Form(...), `action`: activate | block | archive | delete."""
csrf_token: str = Form(default=""), for aid in ids:
): try:
"""Activeaza un cont. PRG: redirect 303 la /admin dupa succes.""" if action == "activate":
set_status(conn, aid, "active")
elif action == "block":
set_status(conn, aid, "blocked")
elif action == "archive":
set_status(conn, aid, "archived")
elif action == "delete":
delete_account(conn, aid)
except ValueError:
continue # cont de sistem / inexistent -> sarit
def _lifecycle_route(request: Request, account_id: list[int], csrf_token: str, action: str):
"""Corp comun pentru rutele de ciclu de viata (5.5): auth + CSRF + aplica verbul (bulk) + PRG.
Evita 4 handlere copy-paste care difera doar prin verb."""
require_admin(request) require_admin(request)
verify_csrf(request, csrf_token) verify_csrf(request, csrf_token)
conn = get_connection() conn = get_connection()
try: try:
try: _apply_lifecycle(conn, account_id, action)
set_active(conn, account_id, True) conn.commit()
except ValueError as exc:
return _render_admin(request, conn, error=str(exc), status_code=422)
finally: finally:
conn.close() conn.close()
return RedirectResponse("/admin", status_code=303) return RedirectResponse("/admin", status_code=303)
@router.post("/admin/activate", response_class=HTMLResponse)
async def admin_activate(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Activeaza unul sau mai multe conturi (bulk). PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "activate")
@router.post("/admin/block", response_class=HTMLResponse)
async def admin_block(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Blocheaza (suspendare reversibila) unul sau mai multe conturi. PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "block")
@router.post("/admin/archive", response_class=HTMLResponse)
async def admin_archive(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Arhiveaza (scos din listele active, date read-only) unul sau mai multe conturi. PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "archive")
@router.post("/admin/delete", response_class=HTMLResponse)
async def admin_delete(request: Request, account_id: list[int] = Form(...),
csrf_token: str = Form(default="")):
"""Stergere SOFT (tombstone + purjare PII imediata) a unuia sau mai multor conturi. PRG 303."""
return _lifecycle_route(request, account_id, csrf_token, "delete")
@router.post("/admin/deactivate", response_class=HTMLResponse) @router.post("/admin/deactivate", response_class=HTMLResponse)
async def admin_deactivate( async def admin_deactivate(
request: Request, request: Request,

View File

@@ -331,6 +331,7 @@ def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
"active_tab": active_tab, "active_tab": active_tab,
"panel_html": panel_html, "panel_html": panel_html,
"badges": badges, "badges": badges,
"is_authenticated": True,
"is_admin": is_account_admin(conn, account_id), "is_admin": is_account_admin(conn, account_id),
"csrf_token": get_csrf_token(request), "csrf_token": get_csrf_token(request),
} }

View File

@@ -44,15 +44,8 @@
</div> </div>
{% endif %} {% endif %}
{# === Subordonat: ajutor rapid pe un rand discret === {# US-001 (5.5): randul "Ajutor" (wayfinding Mapari/Coduri RAR) eliminat — navigarea
US-003 (3.6): linkul redundant "Trimiteri" a fost scos (Trimiterile sunt mai jos traieste in tab-bar (Mapari) si in meniul de cont (Nomenclator etc.). #}
pe aceeasi pagina). Wayfinding "Mapari"/"Coduri RAR" pastrat pentru operatori. #}
<div style="margin-top:10px; font-size:13px; color:var(--muted);
display:flex; gap:16px; flex-wrap:wrap; align-items:center;">
<span>Ajutor:</span>
<a href="/?tab=mapari">Mapari</a>
<a href="/?tab=nomenclator">Coduri RAR</a>
</div>
{# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003). {# === Sectiunea Trimiteri ("Trimiterile tale"), permanenta sub upload (US-003).
Suprimata la first-run (zero trimiteri): bara de upload acopera deja CTA-ul, Suprimata la first-run (zero trimiteri): bara de upload acopera deja CTA-ul,

View File

@@ -1,26 +1,28 @@
{# Macro-uri partajate intre template-urile de import si mapari. #} {# Macro-uri partajate intre template-urile de import si mapari. #}
{# US-007: comutator pe COADA in loc de bifa "auto-send". {# US-003 (5.5): comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand, fara
Framing pe punerea in coada, NU pe trimitere (poarta autoplan UC-A): etichetele proza inline (explicatia traieste o singura data in panoul Ajutor al cardului Mapari).
poarta singure sensul de send-safety. `name="auto_send" value="true"` pastrat cu Inlocuieste varianta verbose din 3.6 care injecta 3 randuri de text pe FIECARE linie de
semantica de prezenta (bifat -> True, nebifat -> absent -> False) ca sa produca tabel (randuri inalte -> Salveaza/Sterge ieseau din viewport).
bool corect cu AMBELE parsere backend (Form(bool) la /mapari, bool(form.get())
la /_import/.../mapare-operatie). Zero atingere backend. INVARIANT BACKEND (nealterat din 3.6): control = checkbox cu `name="auto_send" value="true"`
si SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())`
la /_import/.../mapare-operatie. Radio Auto/Manual cu value="false" ar trimite campul prezent
pe "Manual" -> `bool("false")` = True la import (regresie tacuta). De aceea comutator vizual
Manual<->Auto peste checkbox, NU doua radio-uri. Zero atingere backend.
- form_id: leaga input-ul de un <form> extern (necesar in celulele de tabel). - form_id: leaga input-ul de un <form> extern (necesar in celulele de tabel).
- checked: starea initiala (H4 - reflecta valoarea STOCATA per mapare). #} - checked: starea STOCATA per mapare (H4) — bifat = Auto. #}
{% macro autosend_toggle(form_id='', checked=True) -%} {% macro autosend_toggle(form_id='', checked=True) -%}
<div class="autosend-toggle" style="display:flex; flex-direction:column; gap:4px;"> <label class="autosend-toggle"
<span class="muted" style="font-size:12px;">La fisierele viitoare cu aceasta operatie:</span> title="Auto = pune automat in coada la fisierele viitoare cu aceasta operatie. Manual = tine pentru verificare; nimic nu pleaca la RAR pana confirmi."
<label class="chk" style="display:inline-flex; align-items:center; gap:8px; min-height:44px;"> style="display:inline-flex; align-items:center; gap:6px; white-space:nowrap; min-height:36px; cursor:pointer; font-size:13px;">
<span class="muted">Manual</span>
<input type="checkbox" name="auto_send" value="true" <input type="checkbox" name="auto_send" value="true"
{%- if form_id %} form="{{ form_id }}"{% endif %} {%- if form_id %} form="{{ form_id }}"{% endif %}
{%- if checked %} checked{% endif %} {%- if checked %} checked{% endif %}
aria-label="Pune automat in coada la fisierele viitoare cu aceasta operatie"> aria-label="In coada: Auto (bifat) sau Manual (nebifat), pentru aceasta operatie"
<span><strong>Pune automat in coada</strong></span> style="width:32px; height:18px; cursor:pointer; accent-color:var(--accent);">
<span><strong>Auto</strong></span>
</label> </label>
<span class="muted" style="font-size:11px;">
Nebifat = "Tine pentru verificare". Doar pentru aceasta operatie;
nimic nu pleaca la RAR pana confirmi.
</span>
</div>
{%- endmacro %} {%- endmacro %}

View File

@@ -9,7 +9,22 @@
<!-- Sectiunea 1: De rezolvat (operatii needs_mapping) --> <!-- Sectiunea 1: De rezolvat (operatii needs_mapping) -->
<!-- ============================================================ --> <!-- ============================================================ -->
<div class="card"> <div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">De rezolvat</h2> {# US-005 (5.5): antet standard + link Ajutor ca <details> nativ (fara JS). Toata proza
care inainte se repeta inline (scopul maparilor, Auto/Manual) traieste acum AICI,
o singura data, ascunsa implicit. #}
<h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
<details class="ajutor-mapari" style="margin:0 0 12px;">
<summary class="cardlink" style="display:inline-flex; color:var(--accent); cursor:pointer; padding:4px 0;">Ajutor</summary>
<div class="muted" style="font-size:13px; margin-top:8px; max-width:680px;">
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
Operatiile necunoscute raman blocate in <span class="s-needs_mapping">needs_mapping</span>
si NU pleaca la RAR pana le mapezi. Sugestiile (%) vin din potrivire fuzzy pe denumire —
verifica-le inainte sa salvezi. <strong>In coada</strong>: <strong>Auto</strong> = la
urmatoarele fisiere cu aceasta operatie randurile intra automat in coada;
<strong>Manual</strong> = raman pentru verificare, nimic nu pleaca la RAR pana confirmi.
La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
</div>
</details>
{% if not pending %} {% if not pending %}
<div class="empty"> <div class="empty">
@@ -17,18 +32,13 @@
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari. <a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
</div> </div>
{% else %} {% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Operatii ROAAUTO necunoscute, blocate in <span class="s-needs_mapping">needs_mapping</span>.
Alege codul RAR (sugestia fuzzy e preselectata) si salveaza — submission-urile se deblocheaza automat.
</p>
<div class="tablewrap"> <div class="tablewrap">
<table> <table>
<thead><tr> <thead><tr>
<th>Operatie</th> <th>Operatie</th>
<th>Sugestii</th> <th>Sugestii</th>
<th>Cod RAR</th> <th>Cod RAR</th>
<th>Punere in coada</th> <th>In coada</th>
<th></th> <th></th>
</tr></thead> </tr></thead>
<tbody> <tbody>
@@ -88,17 +98,14 @@
Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand. Nicio mapare salvata inca. Pe masura ce mapezi operatii, ele apar aici si le poti edita oricand.
</div> </div>
{% else %} {% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;"> {# US-005 (5.5): proza explicativa mutata in panoul Ajutor de la "De rezolvat" (o singura data). #}
Maparile operatie -> cod RAR retinute pentru contul tau. Schimba codul sau punerea in coada si salveaza;
la schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
</p>
<div class="tablewrap"> <div class="tablewrap">
<table> <table>
<thead><tr> <thead><tr>
<th>Operatie</th> <th>Operatie</th>
<th>Cod RAR</th> <th>Cod RAR</th>
<th>Punere in coada</th> <th>In coada</th>
<th>Actiuni</th> <th>Actiuni</th>
</tr></thead> </tr></thead>
<tbody> <tbody>
@@ -160,7 +167,7 @@
</div> </div>
{% else %} {% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;"> <p class="muted" style="margin:0 0 12px; font-size:13px;">
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat (nu suprascrie). Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
</p> </p>
<div class="tablewrap"> <div class="tablewrap">

View File

@@ -1,12 +1,20 @@
{# US-002 (5.5): aceeasi grila standard ca tabelul Trimiteri (_submissions.html):
.tablewrap > table, antet th standard (mostenit din base.html), cod in .pill,
denumire ca text normal (singura coloana care se poate rupe pe randuri inguste),
empty-state in .empty. Zero stiluri inline noi — totul vine din base.html. #}
{% if rows %} {% if rows %}
<div class="tablewrap"> <div class="tablewrap">
<table> <table>
<thead><tr><th>Cod</th><th>Denumire</th><th>Actualizat</th></tr></thead> <thead><tr>
<th>Cod</th>
<th>Denumire</th>
<th>Actualizat</th>
</tr></thead>
<tbody> <tbody>
{% for r in rows %} {% for r in rows %}
<tr> <tr>
<td><span class="pill">{{ r.cod_prestatie }}</span></td> <td><span class="pill">{{ r.cod_prestatie }}</span></td>
<td>{{ r.nume_prestatie }}</td> <td style="white-space:normal;">{{ r.nume_prestatie }}</td>
<td class="muted">{{ r.updated_at }}</td> <td class="muted">{{ r.updated_at }}</td>
</tr> </tr>
{% endfor %} {% endfor %}

View File

@@ -1,6 +1,104 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Panou admin — Gateway RAR AUTOPASS{% endblock %} {% block title %}Panou admin — Gateway RAR AUTOPASS{% endblock %}
{% block content %} {% block content %}
{# US-009 (5.5): metadate verbe de ciclu de viata (eticheta, ruta, clasa). #}
{% set VERBS = {
'activate': ('Activeaza', '/admin/activate', ''),
'block': ('Blocheaza', '/admin/block', ''),
'archive': ('Arhiveaza', '/admin/archive', ''),
'delete': ('Sterge', '/admin/delete', 'danger')
} %}
{% macro lifecycle_block(title, rows, block_id, bulk_verbs, row_verbs) %}
<div class="card">
<h3 style="margin-top:0;">{{ title }} ({{ rows|length }})</h3>
{% if rows %}
{# Bara bulk: form propriu (id=bulk-<block>); checkbox-urile randurilor se leaga prin atributul
HTML5 form= (fara form-uri imbricate). Ascunsa pana exista o selectie (JS). #}
<form id="bulk-{{ block_id }}" method="post" class="bulk-form" data-block="{{ block_id }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="bulk-bar" hidden>
<span class="bulk-count muted" style="font-size:13px;">0 selectate</span>
{% for v in bulk_verbs %}
{% set label, action, cls = VERBS[v] %}
<button type="submit" formaction="{{ action }}"
{% if v == 'delete' %}onclick="return confirm('Stergi conturile selectate? (stergere soft, datele se purjeaza)');"{% endif %}
style="{% if cls == 'danger' %}background:var(--card); color:var(--err); border-color:var(--err);{% endif %}">{{ label }}</button>
{% endfor %}
</div>
</form>
<div class="tablewrap">
<table>
<thead><tr>
<th style="width:28px;"><input type="checkbox" class="master-check" data-block="{{ block_id }}"
aria-label="Selecteaza tot"></th>
<th>ID</th><th>Companie</th><th>CUI</th><th>Email</th><th>Stare</th><th>Inregistrat</th><th>Actiuni</th>
</tr></thead>
<tbody>
{% for acct in rows %}
<tr>
<td><input type="checkbox" name="account_id" value="{{ acct.id }}" form="bulk-{{ block_id }}"
class="row-check" data-block="{{ block_id }}"
aria-label="Selecteaza contul {{ acct.name }}"></td>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td><span class="pill">{{ acct.status }}</span></td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td style="white-space:nowrap;">
<details class="kebab">
<summary class="cardlink" style="list-style:none; cursor:pointer; display:inline-flex;
padding:4px 10px;" aria-label="Actiuni pentru {{ acct.name }}">&#8943;</summary>
<div class="kebab-menu">
{% for v in row_verbs %}
{% set label, action, cls = VERBS[v] %}
{# Confirm fara nume interpolat: un apostrof in numele firmei (free-form) ar rupe
string-ul JS din atributul inline (entitatea &#39; e decodata inainte de parse). #}
<form method="post" action="{{ action }}"
{% if v == 'delete' %}onsubmit="return confirm('Stergi acest cont? (stergere soft)');"{% endif %}>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit" {% if cls == 'danger' %}style="color:var(--err);"{% endif %}>{{ label }}</button>
</form>
{% endfor %}
</div>
</details>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont.</p>
{% endif %}
</div>
{% endmacro %}
<style>
/* Bara de actiuni bulk — ascunsa pana exista selectie. `[hidden]` trebuie sa invinga
display-ul, deci stilul sta in CSS (NU inline cu display:flex, care ar invinge [hidden]). */
.bulk-bar { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:10px;
padding:8px 10px; border:1px solid var(--line); border-radius:8px;
background:color-mix(in srgb, var(--accent) 8%, var(--card)); }
.bulk-bar[hidden] { display:none; }
/* Kebab per-rand (reuseaza estetica meniului de cont) */
.kebab { position:relative; display:inline-block; }
.kebab > summary::-webkit-details-marker { display:none; }
.kebab-menu { position:absolute; right:0; top:calc(100% + 4px); min-width:140px; z-index:40;
background:var(--card); border:1px solid var(--line); border-radius:8px; padding:6px;
box-shadow:0 8px 24px rgba(0,0,0,.18); display:flex; flex-direction:column; gap:2px; }
.kebab[open] > summary { background:var(--line); }
.kebab-menu form { margin:0; }
.kebab-menu button { display:block; width:100%; text-align:left; background:transparent; border:none;
color:var(--ink); font:inherit; padding:7px 10px; border-radius:6px; cursor:pointer;
min-height:36px; }
.kebab-menu button:hover { background:var(--line); }
</style>
<div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;"> <div style="display:flex;align-items:center;gap:16px;margin-bottom:20px;">
<h2 style="margin:0;">Panou admin</h2> <h2 style="margin:0;">Panou admin</h2>
<a href="/" class="cardlink muted">Inapoi la dashboard</a> <a href="/" class="cardlink muted">Inapoi la dashboard</a>
@@ -10,96 +108,45 @@
<div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div> <div class="banner" style="margin-bottom:16px;padding:10px 14px;">{{ error }}</div>
{% endif %} {% endif %}
<!-- Conturi in asteptare --> {{ lifecycle_block("Conturi in asteptare", pending, "pending",
<div class="card"> ['activate', 'block', 'archive', 'delete'],
<h3 style="margin-top:0;">Conturi in asteptare ({{ pending|length }})</h3> ['activate', 'block', 'archive', 'delete']) }}
{% if pending %}
<div class="tablewrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Companie</th>
<th>CUI</th>
<th>Email</th>
<th>Inregistrat</th>
<th>Actiune</th>
</tr>
</thead>
<tbody>
{% for acct in pending %}
<tr>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td>
<form method="post" action="/admin/activate" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit">Activeaza</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont in asteptare.</p>
{% endif %}
</div>
<!-- Conturi active --> {{ lifecycle_block("Conturi active", active, "active",
<div class="card"> ['block', 'archive', 'delete'],
<h3 style="margin-top:0;">Conturi active ({{ active|length }})</h3> ['block', 'archive', 'delete']) }}
{% if active %}
<div class="tablewrap">
<table>
<thead>
<tr>
<th>ID</th>
<th>Companie</th>
<th>CUI</th>
<th>Email</th>
<th>Inregistrat</th>
<th>Actiune</th>
</tr>
</thead>
<tbody>
{% for acct in active %}
<tr>
<td class="muted">{{ acct.id }}</td>
<td>{{ acct.name }}</td>
<td class="muted">{{ acct.cui or "—" }}</td>
<td>{{ acct.email or "—" }}</td>
<td class="muted">{{ acct.created_at or "—" }}</td>
<td>
<form method="post" action="/admin/deactivate" style="display:inline;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
<button type="submit" style="background:var(--err);border-color:var(--err);">Dezactiveaza</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="empty">Niciun cont activ (in afara de contul dev).</p>
{% endif %}
</div>
<!-- Contul dev default (id=1) --> {# Conturi suspendate (blocate/arhivate): reactivare sau stergere. Stare reala in pill. #}
{% if default_account %} {{ lifecycle_block("Conturi blocate / arhivate", suspended, "suspended",
<div class="card" style="border-color:var(--muted);"> ['activate', 'delete'],
<p class="muted" style="margin:0;font-size:13px;"> ['activate', 'delete']) }}
Cont dev implicit (id=1): <strong>{{ default_account.name }}</strong>
— activ={{ default_account.active }} — fara buton de activare/dezactivare (cont de sistem). <script>
</p> (function() {
</div> // Selectie + bara bulk, scoped pe fiecare bloc (pending/active) prin data-block.
{% endif %} document.querySelectorAll('.master-check').forEach(function(master) {
var block = master.getAttribute('data-block');
var rows = Array.prototype.slice.call(
document.querySelectorAll('.row-check[data-block="' + block + '"]'));
var form = document.getElementById('bulk-' + block);
var bar = form ? form.querySelector('.bulk-bar') : null;
var count = form ? form.querySelector('.bulk-count') : null;
function refresh() {
var n = rows.filter(function(r) { return r.checked; }).length;
if (bar) bar.hidden = (n === 0);
if (count) count.textContent = n + ' selectate';
master.checked = (n > 0 && n === rows.length);
master.indeterminate = (n > 0 && n < rows.length);
}
master.addEventListener('change', function() {
rows.forEach(function(r) { r.checked = master.checked; });
refresh();
});
rows.forEach(function(r) { r.addEventListener('change', refresh); });
refresh();
});
})();
</script>
{% endblock %} {% endblock %}

View File

@@ -116,6 +116,22 @@
.eroare-3n-label { font-weight:500; } .eroare-3n-label { font-weight:500; }
/* Inline fix per camp in preview */ /* Inline fix per camp in preview */
.camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; } .camp-fix { color:var(--accent); font-size:11px; margin-top:2px; display:block; }
/* Meniu hamburger cont (US-006 PRD 5.5) — dropdown ancorat dreapta-sus */
.cont-menu-wrap { position:relative; }
.icon-btn { background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer;
border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px;
line-height:1; display:inline-flex; align-items:center; justify-content:center; }
.icon-btn:hover { background:var(--line); }
.cont-menu { position:absolute; right:0; top:calc(100% + 8px); min-width:180px; z-index:50;
background:var(--card); border:1px solid var(--line); border-radius:8px; padding:6px;
box-shadow:0 8px 24px rgba(0,0,0,.18); display:flex; flex-direction:column; gap:2px; }
.cont-menu[hidden] { display:none; }
.cont-menu a, .cont-menu button { display:block; width:100%; text-align:left; background:transparent;
border:none; color:var(--ink); text-decoration:none; font:inherit; padding:8px 10px;
border-radius:6px; cursor:pointer; min-height:36px; }
.cont-menu a:hover, .cont-menu button:hover { background:var(--line); }
.cont-menu hr { border:none; border-top:1px solid var(--line); margin:4px 0; }
.cont-menu form { margin:0; }
</style> </style>
</head> </head>
<body> <body>
@@ -123,11 +139,30 @@
<h1>Gateway RAR AUTOPASS</h1> <h1>Gateway RAR AUTOPASS</h1>
<span class="env">{{ rar_env }}</span> <span class="env">{{ rar_env }}</span>
<div style="margin-left:auto; display:flex; align-items:center; gap:8px;"> <div style="margin-left:auto; display:flex; align-items:center; gap:8px;">
<button id="tema-toggle" <button id="tema-toggle" class="icon-btn"
aria-label="Comuta tema (luminos/intunecat)" aria-label="Comuta tema (luminos/intunecat)"
title="Comuta tema" title="Comuta tema">&#9728;</button>
style="background:transparent; border:1px solid var(--line); color:var(--ink); cursor:pointer; border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px; line-height:1; display:inline-flex; align-items:center; justify-content:center;">&#9728;</button>
<span class="muted" style="font-size:13px;">v{{ version }}</span> <span class="muted" style="font-size:13px;">v{{ version }}</span>
{% if is_authenticated|default(false) %}
{# Meniu cont (US-006 PRD 5.5): Cont/Integrare/Nomenclator + (admin) + logout.
Pe paginile neautentificate (login/signup) nu se randeaza deloc. #}
<div class="cont-menu-wrap">
<button id="cont-menu-toggle" class="icon-btn"
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
aria-label="Meniu cont" title="Meniu cont">&#9776;</button>
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
<a role="menuitem" href="/?tab=cont">Cont</a>
<a role="menuitem" href="/?tab=integrare">Integrare</a>
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Panou admin</a>{% endif %}
<hr>
<form method="post" action="/logout">
<input type="hidden" name="csrf_token" value="{{ csrf_token|default('') }}">
<button role="menuitem" type="submit">Iesi din cont</button>
</form>
</div>
</div>
{% endif %}
</div> </div>
</header> </header>
<main>{% block content %}{% endblock %}</main> <main>{% block content %}{% endblock %}</main>
@@ -165,5 +200,37 @@
}); });
})(); })();
</script> </script>
<script>
// Meniu cont (US-006 PRD 5.5): dropdown ancorat dreapta-sus. Deschide/inchide la click,
// inchide la Esc (focus readus pe buton) si la click in afara. Fara dependente.
(function() {
var toggle = document.getElementById('cont-menu-toggle');
var menu = document.getElementById('cont-menu');
if (!toggle || !menu) return;
function open() {
menu.hidden = false;
toggle.setAttribute('aria-expanded', 'true');
document.addEventListener('click', onDocClick, true);
document.addEventListener('keydown', onKey, true);
}
function close(refocus) {
menu.hidden = true;
toggle.setAttribute('aria-expanded', 'false');
document.removeEventListener('click', onDocClick, true);
document.removeEventListener('keydown', onKey, true);
if (refocus) toggle.focus();
}
function onDocClick(e) {
if (!menu.contains(e.target) && e.target !== toggle) close(false);
}
function onKey(e) {
if (e.key === 'Escape') { e.preventDefault(); close(true); }
}
toggle.addEventListener('click', function(e) {
e.stopPropagation();
if (menu.hidden) open(); else close(false);
});
})();
</script>
</body> </body>
</html> </html>

View File

@@ -1,14 +1,8 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block content %} {% block content %}
<!-- Nav cont: link admin (doar pentru admini) + logout --> {# US-007 (5.5): nav-ul ad-hoc (Panou admin + logout) a fost mutat in meniul de cont (☰)
<div style="display:flex; gap:8px; justify-content:flex-end; margin-bottom:12px; flex-wrap:wrap;"> din header (base.html). Aici raman doar bara de status + tab-bar-ul de lucru zilnic. #}
{% if is_admin %}<a class="cardlink" href="/admin">Panou admin</a>{% endif %}
<form method="post" action="/logout" style="display:inline; margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--muted); border-color:var(--line);">Iesi din cont</button>
</form>
</div>
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului --> <!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
<div id="status-bar" class="status-bar card" <div id="status-bar" class="status-bar card"
@@ -20,14 +14,12 @@
<!-- Tab-bar: navigare intre sectiuni --> <!-- Tab-bar: navigare intre sectiuni -->
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard"> <div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
{# US-003 (3.6): tab-ul "Trimiteri" (coada) a fost eliminat — Trimiterile traiesc {# US-007 (5.5): tab-bar redus la suprafetele de LUCRU ZILNIC (Acasa·Mapari).
ca sectiune permanenta pe Acasa. Raman: Acasa·Mapari·Cont·Nomenclator·Integrare. #} Cont/Integrare/Nomenclator s-au mutat in meniul de cont (☰) din header — rutele
`/_fragments/{cont,integrare,nomenclator}` + deep-link `?tab=` raman valide. #}
{% set tabs = [ {% set tabs = [
("acasa", "Acasa", "tab-acasa"), ("acasa", "Acasa", "tab-acasa"),
("mapari", "Mapari", "tab-mapari"), ("mapari", "Mapari", "tab-mapari")
("cont", "Cont", "tab-cont"),
("nomenclator", "Nomenclator", "tab-nomenclator"),
("integrare", "Integrare", "tab-integrare")
] %} ] %}
{% for tab_id, tab_label, tab_elem_id in tabs %} {% for tab_id, tab_label, tab_elem_id in tabs %}
{% set badge = (badges.get(tab_id, 0) if badges else 0) %} {% set badge = (badges.get(tab_id, 0) if badges else 0) %}

View File

@@ -145,7 +145,9 @@ def claim_one(conn) -> dict | None:
"FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id " "FROM submissions s LEFT JOIN accounts a ON a.id = s.account_id "
"WHERE s.status='queued' " "WHERE s.status='queued' "
"AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) " "AND (s.next_attempt_at IS NULL OR s.next_attempt_at <= ?) "
"AND COALESCE(a.active, 1) = 1 " # Gate pe stare de cont (5.5): doar 'active' trimite. Derivam defensiv din `active`
# cand `status` lipseste (DB veche pre-migrare), pastrand active=1 <=> 'active'.
"AND COALESCE(a.status, CASE WHEN COALESCE(a.active,1)=1 THEN 'active' ELSE 'pending' END) = 'active' "
"ORDER BY s.id LIMIT 1", "ORDER BY s.id LIMIT 1",
(_iso(_now()),), (_iso(_now()),),
).fetchone() ).fetchone()

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,277 @@
# Design 5.5 — Uniformizare & standardizare UI/UX
**Stare**: aprobat (decizii utilizator 2026-06-23, vezi §10)
**Context**: dashboard web HTMX (`app/web/templates/`), paleta dark/light deja livrata (5.3),
erori 3-niveluri (5.4). Acest document = sursa de adevar **vizuala** pentru PRD 5.5. Unde PRD-ul
descrie *ce* livram pe stories, aici descriem *cum arata* si *de ce*.
> Nu reinventam estetica. Paleta, tipografia si tokenii din `base.html` (5.3) raman **NESCHIMBATI
> la octet**. Standardizarea = aducem toate tabelele si paginile la acelasi vocabular de componente
> care exista deja in tabelul Trimiteri (`_submissions.html`), tabelul considerat corect de referinta.
---
## 1. Problema (audit pe codul real)
Inventar al neuniformitatii curente:
| Suprafata | Simptom | Referinta corecta |
|-----------|---------|-------------------|
| Tabel **Mapari** (`_mapari.html`) | Labartat: coloana "Punere in coada" injecteaza prin macro `autosend_toggle` 3 randuri de text explicativ pe **fiecare** linie → randuri inalte, butoanele **Salveaza/Sterge** ies din viewport, trebuie scroll orizontal | grila Trimiteri |
| Tabel **Nomenclator** (`_nomenclator.html`) | Functional dar minim; nu imparte exact acelasi aspect/hover/aliniere cu Trimiteri | grila Trimiteri |
| **Acasa** (`_acasa.html`) | Sectiune "Ajutor: Mapari / Coduri RAR" redundanta (wayfinding repetat) | — (se elimina) |
| **Navigare** | Cont, Integrare, Nomenclator stau ca tab-uri amestecate cu lucrul zilnic; logout + link admin sunt agatate ad-hoc in coltul dreapta-sus al dashboard-ului, absente pe alte pagini | meniu de cont dedicat |
| **Panou admin** (`admin.html`) | Conturile in asteptare au doar "Activeaza" per-rand; lipsesc selectie multipla si actiunile blocare/arhivare/stergere. Nota "Cont dev implicit" e jargon intern nederivabil | tabel cu selectie + bara bulk |
Principiu de standardizare: **un singur tabel, o singura componenta de antet de sectiune, un singur
loc pentru ajutor** (link/disclosure, nu text inline repetat pe randuri).
---
## 2. Design tokens (existenti — se reutilizeaza, nu se modifica)
Din `base.html` (`:root` dark + `[data-theme="light"]`). Citat aici doar ca referinta; **nicio
valoare noua de culoare**. Orice suprafata noua foloseste `color-mix(... var(--card))` pentru stari
(lectia 5.3: zero literali hardcodati, altfel se sparge light mode).
```
--bg --card --ink --muted --line
--ok (verde) --warn (chihlimbar) --err (rosu) --accent (albastru)
```
Spacing: cardurile 16-20px padding; celule tabel `8px 10px`; gap-uri 6/8/12/16px (scara existenta).
Radius: 6px controale, 10px carduri, 99px pill-uri. Tipografie: tabel 14px `tabular-nums`,
antet `th` 12px uppercase `--muted`. **Nu introducem fonturi sau marimi noi.**
---
## 3. Componenta canonica: Tabelul standard
Tabelul Trimiteri defineste contractul. Orice tabel din aplicatie il respecta:
```
.tablewrap > table
thead th -> 12px uppercase, color --muted, font-weight 500, white-space nowrap
tbody td -> 14px, padding 8px 10px, border-bottom 1px var(--line), nowrap implicit
stare -> <span class="pill {s-*}">{text uman}</span> (glifa+text, nu doar culoare)
coloana lunga (motiv) -> white-space:normal; max-width:280px (singura exceptie de la nowrap)
empty state -> .empty (centrat, --muted, cu CTA contextual)
```
Reguli care fac diferenta vizibila fata de "labartat":
1. **Coloanele de control sunt inguste si nowrap.** Niciun text explicativ in celule. Explicatiile
traiesc o singura data, in antetul cardului (link "Ajutor") sau intr-un `<details>`.
2. **Actiunile incap fara scroll orizontal.** Coloana "Actiuni" la dreapta, `white-space:nowrap`,
butoane scurte. Pe ecrane inguste scroll-ul ramane IN card (`.tablewrap`), nu in pagina.
3. **Densitate constanta.** Inaltimea randului = o linie de text + padding. Sub-text (ex. "2 blocate",
"acum: COD") merge in `<div class="muted" style="font-size:12px">` sub valoarea principala, nu
pe coloana separata.
### 3.1 Antet de sectiune standard (cu Ajutor)
```
+--------------------------------------------------------------+
| De rezolvat [ Ajutor ] | <- h2 15px la stanga, link la dreapta
+--------------------------------------------------------------+
```
`Ajutor` = link discret `.cardlink` care comuta un `<details>`/panou de text (vezi §5). Mutam acolo
toata proza care azi se repeta pe randuri. Un singur loc, citit la nevoie.
---
## 4. Tabelul Mapari — inainte / dupa
### Inainte (labartat)
Fiecare rand din "De rezolvat" si "Mapari salvate" poarta `autosend_toggle`, care randeaza:
- "La fisierele viitoare cu aceasta operatie:" (12px)
- checkbox + **"Pune automat in coada"**
- "Nebifat = «Tine pentru verificare». Doar pentru aceasta operatie; nimic nu pleaca la RAR..." (11px)
x N randuri. Coloana e mai lata decat selectul de cod; Salveaza/Sterge sunt impinse afara.
### Dupa (compact, ca Trimiteri)
```
De rezolvat [ Ajutor ]
-----------------------------------------------------------------
OPERATIE SUGESTII COD RAR IN COADA ACTIUNI
RevTehBP A012 (88%) [ A012 v ] (o) Auto [Salveaza]
2 blocate ( ) Manual
-----------------------------------------------------------------
```
- Coloana **IN COADA** = comutator scurt cu doua stari etichetate **Auto** / **Manual** (radio sau
switch), fara nicio propozitie. Tooltip pe control: "Auto = pune automat in coada la fisierele
viitoare cu aceasta operatie; Manual = tine pentru verificare."
- Explicatia completa (de ce exista maparile, ce inseamna Auto vs Manual, ce e blocat) → in panoul
**Ajutor** din antet, scris o singura data.
- **Invariant backend pastrat**: controlul emite tot `name="auto_send" value="true"` cu semantica de
prezenta (bifat→true, absent→false), exact ca azi. Zero atingere backend (lectia 5.3/3.6:
reskin la nivel de macro, parserele `/mapari` si `/_import/.../mapare-operatie` raman valide).
- "Mapari salvate" si "Formate de coloane" → aceeasi grila; sub-textul ("acum: COD — nume",
"N coloane", maparea coloana→camp) ramane `muted` 12px sub valoare, nu pe coloane separate verbose.
Macro-ul `autosend_toggle` se rescrie compact (acelasi `name`/`form`/`checked`), deci se schimba
intr-un singur loc si se propaga si in fluxul de import (mapcoloane) unde e refolosit.
---
## 5. Panoul Ajutor (mapari)
Un `<details>` nativ in antetul cardului "De rezolvat" (sau link care expandeaza acelasi `<details>`),
inchis implicit, fara JS:
```
Ajutor (v)
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
- Operatii necunoscute raman blocate in needs_mapping si NU pleaca la RAR pana le mapezi.
- Sugestiile (%) vin din potrivire fuzzy pe denumire — verifica-le inainte sa salvezi.
- In coada: Auto = la urmatoarele fisiere cu aceasta operatie, randurile intra automat in coada.
Manual = raman pentru verificare; nimic nu pleaca la RAR pana confirmi tu.
- La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
```
Avantaj: text scris o data, accesibil, fara cost de inaltime pe fiecare rand. `<details>` =
accesibil din tastatura nativ, fara dependente.
---
## 6. Navigare: meniu hamburger (decizie: dropdown ancorat dreapta-sus)
### 6.1 Header
```
[Gateway RAR AUTOPASS] [test] [☀] v1.0 [☰]
|
+------------------+
| Cont |
| Integrare |
| Nomenclator |
| Panou admin | <- doar admin
|------------------|
| Iesi din cont | <- form POST /logout
+------------------+
```
- Iconita `☰` (`min 36x36`, `aria-label="Meniu cont"`, `aria-expanded`, `aria-controls`) langa toggle-ul
de tema. Dropdown ancorat sub iconita, aliniat la dreapta. **Fara overlay** pe pagina.
- Inchidere: click in afara, `Esc`, sau selectarea unui element. Focus trap minimal: `Esc` readuce
focusul pe `☰`. Navigare cu sageti optionala (consistent cu pattern-ul tab existent), dar `Tab`
natural e suficient.
- Continut **dependent de autentificare** (vezi §6.3).
### 6.2 Tab-bar dupa mutare (decizie: doar Acasa · Mapari)
```
[ Acasa ] [ Mapari ]
```
Cont, Integrare, Nomenclator parasesc tab-bar-ul → meniul `☰`. Raman doar cele doua suprafete de
**lucru zilnic**. Badge-urile de contoare (Mapari) raman pe tab. Deep-link `?tab=` si rutele
`/_fragments/{cont,integrare,nomenclator}` raman valide (accesate acum din meniu, nu din tab-bar) —
deci zero rute moarte, doar punctul de intrare se muta.
### 6.3 Stare de autentificare in header
`base.html` e partajat de `login.html`, `signup.html`, `dashboard.html`, `admin.html`. Meniul trebuie
sa stie daca esti logat:
- **Autentificat**: arata Cont, Integrare, Nomenclator, (Panou admin daca `is_admin`), separator,
"Iesi din cont" (form `POST /logout` cu `csrf_token`).
- **Neautentificat** (login/signup): meniul arata doar "Autentificare" / "Inregistrare" (sau iconita
`☰` ascunsa pe aceste pagini — vezi PRD US). Niciun link de cont, niciun logout.
Necesita ca `base.html` sa primeasca `is_authenticated`, `is_admin`, `csrf_token` in context. Se
adauga ca un helper de context partajat (un singur loc), nu duplicat in fiecare render. Acesta e
singurul "backend touch" din zona de navigare si trebuie sa fie aditiv si defensiv (lipsa cheilor →
meniu in stare neautentificata, nu eroare).
---
## 7. Panou admin: selectie + actiuni bulk
### 7.1 Tabel conturi in asteptare (si analog conturi active)
```
[v] Selecteaza tot 2 selectate:
[Activeaza] [Blocheaza] [Arhiveaza] [Sterge] <- bara bulk, apare la selectie
-----------------------------------------------------------------------
[v] ID COMPANIE CUI EMAIL INREGISTRAT ACTIUNI
[v] 7 Auto SRL RO123 a@b.ro 12.06.2026 [ ... ]
[ ] 8 Moto SA RO456 c@d.ro 13.06.2026 [ ... ]
-----------------------------------------------------------------------
```
- Coloana de **checkbox** la stanga + un master "selecteaza tot" in antet.
- **Bara de actiuni bulk** ascunsa pana exista o selectie; afiseaza numarul selectat si butoanele
contextuale. Actioneaza pe toate randurile bifate (POST cu lista de `account_id`).
- **Actiuni per-rand** in meniul `[ ... ]` (kebab): aceleasi verbe, pentru o singura tinta.
- Verbele, ca stari de cont distincte (vezi §7.2): **Activeaza, Blocheaza, Arhiveaza, Sterge**.
- `Sterge` = actiune distructiva → `hx-confirm` / dialog de confirmare obligatoriu.
- `Blocheaza`/`Arhiveaza` reversibile → confirmare doar pe bulk (cantitate).
- Stari vizuale ale verbelor: distructiv (`Sterge`) cu `color:var(--err)`; restul neutre `.cardlink`.
### 7.2 Model de stare cont (impact backend — vezi PRD riscuri)
Azi: `accounts.active` (0/1); "pending" = inregistrat dar `active=0`. Cele 4 verbe cer stari
distincte care nu incap intr-un bool. Propunere (PRD o ratifica): coloana `accounts.status`
TEXT, migrare defensiva, derivata din `active` la prima rulare:
```
pending -> inregistrat, neactivat inca (active=0, status nesetat istoric)
active -> operational (active=1)
blocked -> suspendat reversibil (nu logheaza, worker nu trimite)
archived -> ascuns din liste, date pastrate (read-only)
deleted -> stergere (GDPR/L.142) (hard delete SAU soft cu purge)
```
- Worker `claim_one` gate-uieste pe **status='active'** (azi pe `COALESCE(active,1)=1`) — schimbare
semantica de pastrat compatibila: `active=1 ⇔ status='active'`.
- **Contul dev id=1 e protejat** de Blocheaza/Arhiveaza/Sterge (cont de sistem), exact ca azi la
activate/deactivate. Daca e selectat in bulk, e sarit, nu eroare.
- Nota "Cont dev implicit (id=1)" din pagina **se elimina** (jargon intern, nederivabil de operator).
Protectia ramane in cod, nu o explicam in UI.
> Aceasta e singura zona cu schema/backend real. Restul livrabilei e UI pur (reskin + reasezare).
---
## 8. Accesibilitate & paritate tema
- **AA pe light+dark** pentru orice text nou (lectia 5.3: verzi/rosii hardcodate cad sub AA). Stari
doar prin `color-mix(... var(--card))`, niciun literal.
- Stare = **glifa + text**, nu doar culoare (pill-urile existente respecta deja).
- Meniul `☰`: `aria-expanded`, `aria-controls`, inchidere pe `Esc`, focus readus pe trigger.
- `<details>` Ajutor: accesibil nativ din tastatura.
- Checkbox-uri admin: `aria-label` per rand ("Selecteaza contul {companie}"); master = "Selecteaza tot".
- Toate controalele >=36px zona de atins (consistent cu toggle tema / `.cardlink`).
## 9. Motiune
Minima, consistenta cu existentul: dropdown `☰` fade/translate scurt (~120ms, ca `.tab-link`
transition). `<details>` = comportament nativ. Fara animatii noi de amploare. Respecta
`prefers-reduced-motion` daca adaugam tranzitii (omitere la cerere).
## 10. Decizii utilizator (2026-06-23)
1. Meniu hamburger = **dropdown ancorat dreapta-sus** (nu drawer).
2. Tab-bar = **Acasa · Mapari**; Nomenclator + Cont + Integrare + Panou admin → meniul `☰`.
3. Mapari = **grila compacta ca Trimiteri**, toggle scurt **Auto/Manual**, link **Ajutor** in antet;
textul repetat de pe randuri se elimina.
4. Admin = **selectie cu bife + bara de actiuni bulk** (Activeaza/Blocheaza/Arhiveaza/Sterge) +
actiuni per-rand; nota "cont dev implicit" **eliminata**.
5. Sectiunea "Ajutor" de pe Acasa se **elimina**.
6. Nomenclator capata exact aspectul tabelului Trimiteri.
## 11. Componente atinse (harta pentru PRD)
| Componenta | Fisier | Tip schimbare |
|------------|--------|---------------|
| Header + meniu `☰` + context auth | `base.html` (+ context render rute) | reasezare + mic backend context |
| Tab-bar redus | `dashboard.html` | reasezare |
| Acasa fara Ajutor | `_acasa.html` | stergere |
| Mapari standard + toggle compact + Ajutor | `_mapari.html`, `_macros.html` | reskin UI (zero backend) |
| Nomenclator ca Trimiteri | `_nomenclator.html` | reskin UI |
| Admin selectie + bulk + verbe noi | `admin.html` + rute `/admin/*` | UI + **backend (status)** |
| Model stare cont | `schema.sql`, `users.py`, worker gate | **backend + migrare** |

View File

@@ -0,0 +1,229 @@
# PRD 5.5 — Uniformizare & standardizare UI/UX
**Stare**: aprobat (2026-06-23)
> Proces: `docs/ROADMAP.md` §5. Contract RAR (sursa de adevar): `docs/api-rar-contract.md`.
> **Design vizual (sursa de adevar pentru *cum arata*)**: `docs/design/5.5-uniformizare-ui.md`.
> Stare: `draft → aprobat → in-executie → verify-pass → inchis`.
## 1. Obiectiv
Aducem toate suprafetele dashboard-ului la acelasi vocabular de componente ca tabelul **Trimiteri**
(referinta corecta), reasezam navigarea intr-un **meniu de cont** (hamburger) si dam panoului admin
actiuni reale de ciclu de viata pe conturi. Tinta: aplicatia arata si se comporta uniform, fara
tabele labartate, fara wayfinding redundant, fara scroll orizontal pentru actiuni. Detaliile vizuale
si deciziile utilizatorului: `docs/design/5.5-uniformizare-ui.md` (§10).
## 2. Non-Goals (anti scope-creep)
- **Fara redesign de estetica**: paleta/tipografia/tokenii din `base.html` (5.3) raman NESCHIMBATI la octet.
- **Fara atingere a fluxului de trimitere**: worker (masina stari submissions, idempotenta, mapping-rezolvare)
NEATINS, cu o singura exceptie controlata — gate-ul `claim_one` pe noua stare de cont (US-004), pastrand
echivalenta `active=1 ⇔ status='active'`.
- **Fara schimbare a semanticii `auto_send`**: comutatorul Auto/Manual ramane reskin la nivel de macro
(`name="auto_send"`, semantica de prezenta). Zero atingere a parserelor `/mapari` si `/_import/...`.
- **Fara rute noi de date / fara HTTP nou pe chei API**: lifecycle-ul conturilor e admin-only, sub
`require_admin` + CSRF, exact ca rutele admin existente.
- **Fara responsive/mobile nou** dincolo de ce ofera deja `.tablewrap` (scroll in card).
- **Tabelul Trimiteri ramane neatins** — e referinta, nu tinta.
## 3. Stories atomice
### US-001: Elimina sectiunea "Ajutor" din Acasa
**Ca** operator **vreau** o pagina Acasa fara wayfinding redundant **pentru ca** linkurile Mapari/Coduri RAR
sunt deja in navigare.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_acasa.html`, `tests/test_web_acasa.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_acasa.py``test_acasa_fara_sectiune_ajutor` (randul "Ajutor:" + linkurile
inline lipsesc din HTML-ul Acasa)
- **Acceptance criteria**:
- [ ] Blocul `Ajutor: <a>Mapari</a> <a>Coduri RAR</a>` (liniile ~47-55) eliminat din `_acasa.html`.
- [ ] Restul Acasa (upload, "Primii pasi", sectiunea Trimiteri) neschimbat.
- [ ] `python3 -m pytest tests/test_web_acasa.py -q` verde.
- **Verificare E2E**: browser HTMX pe `/` — Acasa nu mai afiseaza randul Ajutor; upload + Trimiteri intacte.
### US-002: Tabel Nomenclator cu aspectul tabelului Trimiteri
**Ca** operator **vreau** ca nomenclatorul sa arate identic cu Trimiteri **pentru ca** consistenta reduce
sarcina cognitiva.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_nomenclator.html`, `tests/test_web_nomenclator.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_nomenclator.py``test_nomenclator_grila_standard` (`.tablewrap` + `table`
+ antet `th` standard + `.pill` pe cod; empty-state `.empty`)
- **Acceptance criteria**:
- [ ] Foloseste aceeasi structura `.tablewrap > table` cu antet `th` standard ca `_submissions.html`.
- [ ] Codul prestatie ramane in `.pill`; coloanele aliniate, hover/aspect identice cu Trimiteri.
- [ ] Empty-state pastrat (`Nomenclator gol...`), in `.empty`.
- [ ] `python3 -m pytest tests/test_web_nomenclator.py -q` verde; AA light+dark (zero literali de culoare).
- **Verificare E2E**: browser — Nomenclator si Trimiteri arata din aceeasi familie vizuala in dark si light.
### US-003: Macro `autosend_toggle` compact (Auto / Manual)
**Ca** operator **vreau** un comutator scurt In coada, fara text repetat pe randuri **pentru ca** proza
inline ingrasa randurile si impinge actiunile afara din ecran.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/_macros.html`, `tests/test_web_macros.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_macros.py``test_autosend_compact` (macro-ul randeaza control Auto/Manual,
pastreaza `name="auto_send" value="true"` + `form=` + starea `checked`, si NU mai contine propozitiile
explicative "La fisierele viitoare..."/"Nebifat = ...")
- **Acceptance criteria**:
- [ ] `autosend_toggle(form_id, checked)` randeaza un comutator compact etichetat **Auto** / **Manual**
(radio sau switch), nowrap, fara propozitii inline.
- [ ] Pastreaza EXACT `name="auto_send"`, `value="true"`, semantica de prezenta (bifat→true / absent→false),
`form="{{form_id}}"`, `checked` reflecta `checked`.
- [ ] Explicatia detaliata NU mai e in macro (se muta in panoul Ajutor, US-005). Tooltip scurt admis pe control.
- [ ] `python3 -m pytest tests/test_web_macros.py tests/test_import_e2e.py -q` verde (parserele backend nealterate).
- **Verificare E2E**: in fluxul import (mapcoloane) si in Mapari, comutatorul produce acelasi `auto_send`
bool ca azi (queued vs needs_review neschimbat).
### US-004: Model de stare a contului (`accounts.status`) + gate worker
**Ca** sistem **vreau** stari de cont distincte (pending/active/blocked/archived/deleted) **pentru ca**
adminul are nevoie de blocare/arhivare/stergere, nu doar activ/inactiv.
- **Depinde de**: —
- **Fisiere**: `app/schema.sql`, `app/db.py` (migrare defensiva), `app/users.py`, `app/worker/...` (gate `claim_one`),
`tests/test_account_status.py`, `tests/test_worker_*.py` (~5 fisiere)
- **Test intai (RED)**: `tests/test_account_status.py``test_migrare_deriva_status_din_active`,
`test_blocked_nu_e_claimuit`, `test_archived_nu_e_claimuit`, `test_dev_id1_protejat`
- **Acceptance criteria**:
- [ ] Coloana `accounts.status` TEXT cu CHECK pe `{pending,active,blocked,archived,deleted}` (stergere = soft,
`status='deleted'` + purjare de catre jobul de retentie T16); migrare **defensiva si idempotenta** (pattern
`_migrate` ca la `is_admin`), derivata din `active` la prima rulare: `active=1→active`, altfel `pending`.
- [ ] Helperi puri in `users.py`: `set_account_status(id, status)`, `delete_account(id)`, cu protectia
contului dev `id=1` (ridica/ignora, nu corupe).
- [ ] Worker `claim_one` gate-uieste pe `status='active'`, pastrand echivalenta cu `COALESCE(active,1)=1`
de azi (conturile blocked/archived NU sunt claimuite).
- [ ] `active` ramane consistent (`active=1 ⇔ status='active'`) cat timp coexista, fara regresie pe testele worker.
- [ ] `python3 -m pytest -q` verde (suita completa).
- **Verificare E2E**: marcheaza un cont `blocked` → submission-urile lui nu pleaca la RAR; `active` → pleaca.
### US-005: Tabel Mapari standardizat + panou Ajutor
**Ca** operator **vreau** tabelele Mapari compacte ca Trimiteri, cu actiunile vizibile fara scroll si ajutor
intr-un singur loc **pentru ca** acum sunt labartate si butoanele Salveaza/Sterge ies din ecran.
- **Depinde de**: US-003
- **Fisiere**: `app/web/templates/_mapari.html`, `tests/test_web_mapari.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_mapari.py``test_mapari_grila_compacta` (coloane inguste nowrap, actiuni
la dreapta), `test_mapari_ajutor_disclosure` (un singur `<details>`/link Ajutor in antet, fara proza pe randuri)
- **Acceptance criteria**:
- [ ] Cele 3 sectiuni (De rezolvat / Mapari salvate / Formate coloane) folosesc grila standard ca Trimiteri;
coloana "In coada" foloseste macro-ul compact din US-003.
- [ ] Butoanele **Salveaza**/**Sterge** vizibile fara scroll orizontal pe latime de dashboard normala
(coloana Actiuni la dreapta, nowrap); sub-text (`N blocate`, `acum: COD`) ca `muted` 12px sub valoare.
- [ ] Antetul "De rezolvat" contine un link/`<details>` **Ajutor** cu explicatia maparilor + Auto/Manual,
scrisa O SINGURA DATA; proza inline de pe randuri eliminata.
- [ ] CSRF, `hx-post`, `hx-target="#mapari-section"`, formularele si re-rezolvarea la edit cod — neschimbate.
- [ ] `python3 -m pytest tests/test_web_mapari.py -q` verde; AA light+dark.
- **Verificare E2E**: browser — mapezi o operatie (Salveaza vizibil fara scroll), comuti Auto/Manual, deschizi Ajutor;
submission blocat se deblocheaza la salvarea codului (comportament neschimbat).
### US-006: Meniu hamburger in header + context de autentificare
**Ca** utilizator **vreau** un meniu de cont in dreapta-sus cu Cont/Integrare/Nomenclator/Panou admin/logout
**pentru ca** acestea nu sunt lucru zilnic si aglomereaza tab-bar-ul.
- **Depinde de**: —
- **Fisiere**: `app/web/templates/base.html`, `app/web/routes.py` (helper context partajat),
`tests/test_web_header_menu.py` (~3 fisiere)
- **Test intai (RED)**: `tests/test_web_header_menu.py``test_meniu_autentificat_are_linkuri_cont`,
`test_meniu_admin_doar_pentru_admin`, `test_meniu_neautentificat_fara_logout` (login/signup → fara linkuri de cont)
- **Acceptance criteria**:
- [ ] Iconita `☰` in header (langa toggle tema), `aria-label`, `aria-expanded`, `aria-controls`; dropdown ancorat
dreapta-sus; inchidere la click-afara + `Esc` (focus readus pe `☰`). Fara overlay.
- [ ] Continut autentificat: Cont, Integrare, Nomenclator, **Panou admin** (doar `is_admin`), separator,
**Iesi din cont** (form `POST /logout` cu `csrf_token`).
- [ ] `base.html` primeste `is_authenticated`/`is_admin`/`csrf_token` printr-un helper de context partajat (un
singur loc); **defensiv**: lipsa cheilor → meniu in stare neautentificata, nu eroare.
- [ ] Pe login/signup meniul nu arata linkuri de cont/logout.
- [ ] `python3 -m pytest tests/test_web_header_menu.py -q` verde.
- **Verificare E2E**: browser — `☰` deschide/inchide (Esc + click-afara), linkurile navigheaza corect, logout iese;
pe login meniul nu expune cont.
### US-007: Tab-bar redus la Acasa · Mapari
**Ca** operator **vreau** un tab-bar doar cu suprafetele de lucru zilnic **pentru ca** Cont/Integrare/Nomenclator
traiesc acum in meniul de cont.
- **Depinde de**: US-006
- **Fisiere**: `app/web/templates/dashboard.html`, `tests/test_web_dashboard_tabs.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_dashboard_tabs.py``test_tabbar_doar_acasa_mapari`,
`test_fragmente_mutate_inca_accesibile` (`/_fragments/{cont,integrare,nomenclator}` raman 200 + deep-link `?tab=`)
- **Acceptance criteria**:
- [ ] `tabs` in `dashboard.html` = doar `acasa`, `mapari`; badge-urile de contoare raman pe Mapari.
- [ ] Logout + link admin ad-hoc din coltul dreapta-sus al dashboard-ului eliminate (mutate in meniul US-006).
- [ ] Rutele `/_fragments/cont|integrare|nomenclator` + `?tab=` raman valide (accesate din meniu); zero rute moarte,
zero 404 pe deep-link existent.
- [ ] Navigarea ARIA cu sageti pe tab-bar ramane corecta cu 2 tab-uri.
- [ ] `python3 -m pytest tests/test_web_dashboard_tabs.py -q` verde.
- **Verificare E2E**: browser — tab-bar arata doar Acasa/Mapari; deschizi Nomenclator/Cont/Integrare din `☰`,
deep-link `/?tab=integrare` inca functioneaza.
### US-008: Rute admin pentru ciclul de viata al conturilor (block/archive/delete + bulk)
**Ca** admin **vreau** endpointuri care blocheaza/arhiveaza/sterg conturi, individual si in bulk **pentru ca**
panoul are nevoie sa actioneze pe selectie.
- **Depinde de**: US-004
- **Fisiere**: `app/web/routes.py` (rute `/admin/*`), `tests/test_admin_lifecycle.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_admin_lifecycle.py``test_block_archive_delete_single`,
`test_bulk_pe_lista_account_id`, `test_bulk_sare_contul_dev`, `test_non_admin_403`, `test_csrf_obligatoriu`
- **Acceptance criteria**:
- [ ] Rute `POST /admin/block`, `/admin/archive`, `/admin/delete` (+ pastreaza `activate`) sub `require_admin` + CSRF,
cu PRG (redirect inapoi la `/admin`), folosind helperii din US-004.
- [ ] Accepta o LISTA de `account_id` (bulk) si o singura tinta (per-rand) prin acelasi handler.
- [ ] Contul dev `id=1` e sarit in bulk (nu eroare) si refuzat individual; `delete` cere confirmare la nivel UI
(US-009) si purjeaza datele conform retentiei (GDPR/L.142).
- [ ] Non-admin → 403; lipsa CSRF → respins.
- [ ] `python3 -m pytest tests/test_admin_lifecycle.py -q` verde.
- **Verificare E2E**: POST autentificat ca admin pe fiecare verb (single + bulk) muta starea corect; contul dev neatins.
### US-009: Panou admin — selectie cu bife + bara bulk + actiuni per-rand
**Ca** admin **vreau** sa selectez conturi si sa aplic actiuni pe selectie **pentru ca** activarea/blocarea una
cate una e lenta.
- **Depinde de**: US-008
- **Fisiere**: `app/web/templates/admin.html`, `tests/test_web_admin.py` (~2 fisiere)
- **Test intai (RED)**: `tests/test_web_admin.py``test_admin_coloana_selectie_si_master`,
`test_bara_bulk_cu_cele_4_verbe`, `test_actiuni_per_rand`, `test_fara_nota_cont_dev`
- **Acceptance criteria**:
- [ ] Tabel conturi in asteptare (si analog active): coloana checkbox + master "Selecteaza tot"
(`aria-label` per rand + master).
- [ ] Bara de actiuni bulk (ascunsa pana la selectie) cu **Activeaza / Blocheaza / Arhiveaza / Sterge**;
`Sterge` cu `hx-confirm`/dialog; trimite lista de `account_id` la rutele US-008.
- [ ] Actiuni per-rand (kebab `...`) cu aceleasi verbe; `Sterge` cu `color:var(--err)` + confirmare.
- [ ] Nota "Cont dev implicit (id=1)" **eliminata** din pagina (protectia ramane in cod, US-004/US-008).
- [ ] `python3 -m pytest tests/test_web_admin.py -q` verde; AA light+dark; tabel in grila standard.
- **Verificare E2E**: browser ca admin — bifezi 2 conturi, bara bulk apare cu numarul selectat, Arhiveaza muta randurile;
Sterge cere confirmare; contul dev nu poate fi selectat-distrus.
## 4. Riscuri
- **Schema `accounts.status` (US-004)** = singura schimbare de date. Mitigare: migrare defensiva idempotenta (pattern
`_migrate` deja folosit la `accounts.active`/`users.is_admin`), derivata din `active`, cu echivalenta `active=1 ⇔
status='active'` pana cand `active` poate fi retras intr-o livrabila viitoare. Testele worker existente sunt plasa.
- **`base.html` partajat (US-006)**: e folosit de login/signup/admin/dashboard. Risc de context lipsa → meniu rupt.
Mitigare: helper de context partajat + defaulturi defensive (lipsa → neautentificat), test pe toate cele 4 pagini.
- **Coliziune pe fisiere intre stories** (lectia 5.1 clobber): US-006 si US-007 ating ambele zona de navigare
(`base.html` vs `dashboard.html`, plus scoaterea logout-ului ad-hoc din `dashboard.html`). Mitigare: US-007 depinde
de US-006 si ruleaza secvential (acelasi teammate recomandat), nu in worktree-uri paralele.
- **`delete` cont (US-008)**: actiune distructiva ireversibila. Mitigare: confirmare UI obligatorie, contul dev protejat,
purjare aliniata la retentia existenta (T16), nu stergere ad-hoc de date conexe fara plan.
- **Macro autosend (US-003)**: orice schimbare de `name`/semantica ar rupe tacit clasificarea queued/needs_review.
Mitigare: test care asereaza `name="auto_send"` + prezenta, plus `test_import_e2e` ramane verde.
## 5. Intrebari deschise — REZOLVATE (aprobare utilizator 2026-06-23)
- **Stergere cont** → **soft delete**: `status='deleted'`, scos imediat din toate listele, date purjate de jobul
de retentie existent (T16, GDPR/L.142). NU hard DELETE imediat (auditabil + fereastra de revenire).
- **Blocheaza vs Arhiveaza** → `blocked` = suspendare **reversibila**, contul ramane in liste, marcat vizibil,
worker nu trimite; `archived` = **scos din listele active**, date pastrate read-only. Etichete confirmate.
- **Comutatorul In coada** → **radio etichetat Auto / Manual** (explicit), nu switch on/off.
## 6. Valuri de executie (graful de dependente)
```
Val 1: [US-001] [US-002] [US-003] [US-004] ← fara dependente, fisiere disjuncte → paralel
Val 2: [US-005] ← dep US-003 (macro)
[US-006] ← fara dep (navigare/header)
[US-008] ← dep US-004 (rute admin pe model stare)
Val 3: [US-007] ← dep US-006 (acelasi fisier de navigare → secvential, NU worktree paralel)
[US-009] ← dep US-008 (UI admin pe rutele de lifecycle)
```

View File

@@ -102,12 +102,13 @@ def test_acasa_fara_linkuri_ajutor(client):
assert 'href="/?tab=coada"' not in r.text assert 'href="/?tab=coada"' not in r.text
def test_acasa_pastreaza_wayfinding_mapari_coduri(client): def test_acasa_fara_wayfinding_ajutor(client):
"""Wayfinding-ul pastreaza 'Mapari' si 'Coduri RAR'.""" """US-001 (5.5): randul 'Ajutor' (wayfinding Mapari/Coduri RAR) eliminat din Acasa —
navigarea traieste in tab-bar si in meniul de cont."""
r = client.get("/?tab=acasa") r = client.get("/?tab=acasa")
html = r.text html = r.text
assert 'href="/?tab=mapari"' in html assert "Ajutor:" not in html
assert "Coduri RAR" in html assert "Coduri RAR" not in html
def test_badge_trimiteri_scoped_pe_acasa(client): def test_badge_trimiteri_scoped_pe_acasa(client):

View File

@@ -0,0 +1,160 @@
"""Teste US-004 (PRD 5.5): model de stare a contului `accounts.status` + helperi.
Acopera: derivarea status din active la migrare, invariantul active=1 <=> status='active',
helperii set_status/delete_account, protectia contului de sistem id=1, excluderea 'deleted'
din listare.
"""
from __future__ import annotations
import os
import tempfile
import pytest
@pytest.fixture()
def conn(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_status.db"))
from app.config import get_settings
get_settings.cache_clear()
from app.db import get_connection, init_db
init_db()
c = get_connection()
yield c
c.close()
get_settings.cache_clear()
def _status(conn, acct_id):
return conn.execute("SELECT status, active FROM accounts WHERE id=?", (acct_id,)).fetchone()
def test_create_account_activ_status_active(conn):
from app.accounts import create_account
acct_id = create_account(conn, "Service X", active=True)
row = _status(conn, acct_id)
assert row["status"] == "active" and row["active"] == 1
def test_create_account_inactiv_status_pending(conn):
from app.accounts import create_account
acct_id = create_account(conn, "Service Y", active=False)
row = _status(conn, acct_id)
assert row["status"] == "pending" and row["active"] == 0
def test_default_account_id1_active(conn):
row = _status(conn, 1)
assert row["status"] == "active" and row["active"] == 1
def test_set_status_mentine_invariant_active(conn):
from app.accounts import create_account, set_status
acct_id = create_account(conn, "Service Z", active=True)
for st, exp_active in [("blocked", 0), ("archived", 0), ("active", 1), ("pending", 0)]:
set_status(conn, acct_id, st)
row = _status(conn, acct_id)
assert row["status"] == st and row["active"] == exp_active
def test_set_status_invalid_ridica(conn):
from app.accounts import create_account, set_status
acct_id = create_account(conn, "Service W", active=True)
with pytest.raises(ValueError):
set_status(conn, acct_id, "inexistent")
def test_set_status_cont_inexistent_ridica(conn):
from app.accounts import set_status
with pytest.raises(ValueError):
set_status(conn, 9999, "blocked")
def test_set_active_mirror_status(conn):
from app.accounts import create_account, set_active
acct_id = create_account(conn, "Service M", active=True)
set_active(conn, acct_id, False)
assert _status(conn, acct_id)["status"] == "pending"
set_active(conn, acct_id, True)
assert _status(conn, acct_id)["status"] == "active"
def test_delete_account_soft(conn):
from app.accounts import create_account, delete_account, list_accounts
acct_id = create_account(conn, "Service D", active=True)
delete_account(conn, acct_id)
assert _status(conn, acct_id)["status"] == "deleted"
# exclus din listare
assert all(a["id"] != acct_id for a in list_accounts(conn))
def test_delete_purjeaza_pii_si_elibereaza_cui(conn):
"""Stergerea soft purjeaza creds RAR + revoca cheile API + elibereaza CUI (re-inregistrabil)."""
from app.accounts import create_account, delete_account, list_accounts
acct_id = create_account(conn, "Service GDPR", cui="RO12345", active=True)
conn.execute("UPDATE accounts SET rar_creds_enc='secret_enc' WHERE id=?", (acct_id,))
conn.execute("INSERT INTO api_keys (account_id, key_hash, active) VALUES (?, 'h', 1)", (acct_id,))
conn.commit()
delete_account(conn, acct_id)
row = conn.execute("SELECT status, rar_creds_enc, cui FROM accounts WHERE id=?",
(acct_id,)).fetchone()
assert row["status"] == "deleted"
assert row["rar_creds_enc"] is None, "creds RAR trebuie purjate la stergere"
assert row["cui"] is None, "CUI trebuie eliberat la stergere"
key_active = conn.execute("SELECT active FROM api_keys WHERE account_id=?", (acct_id,)).fetchone()
assert key_active["active"] == 0, "cheile API trebuie revocate"
# CUI eliberat -> se poate re-inregistra acelasi CUI
new_id = create_account(conn, "Service Nou", cui="RO12345", active=True)
assert new_id != acct_id
assert all(a["id"] != acct_id for a in list_accounts(conn))
def test_dev_id1_protejat_de_status_negativ(conn):
from app.accounts import set_status, delete_account
for verb in ("blocked", "archived", "deleted"):
with pytest.raises(ValueError):
set_status(conn, 1, verb)
with pytest.raises(ValueError):
delete_account(conn, 1)
# ramane activ
assert _status(conn, 1)["status"] == "active"
def test_migrare_deriva_status_din_active(conn):
"""DB veche fara coloana status -> _migrate o adauga si o deriva din active.
Pornim de la schema reala (fixtura `conn` a rulat init_db), reconstruim tabela accounts
FARA coloana status (simuleaza DB pre-5.5), apoi rulam _migrate.
"""
from app.db import _migrate
# Reconstruim accounts fara `status` (rebuild de tabela — singura cale in SQLite vechi).
conn.executescript(
"""
PRAGMA foreign_keys=OFF;
CREATE TABLE accounts_legacy (
id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, cui TEXT,
active INTEGER NOT NULL DEFAULT 1, rar_creds_enc TEXT, created_at TEXT
);
INSERT INTO accounts_legacy (id, name, cui, active, rar_creds_enc, created_at)
SELECT id, name, cui, active, rar_creds_enc, created_at FROM accounts;
DROP TABLE accounts;
ALTER TABLE accounts_legacy RENAME TO accounts;
"""
)
conn.execute("INSERT INTO accounts (name, active) VALUES ('Activ', 1)")
conn.execute("INSERT INTO accounts (name, active) VALUES ('Inactiv', 0)")
conn.commit()
assert "status" not in {r["name"] for r in conn.execute("PRAGMA table_info(accounts)")}
_migrate(conn)
conn.commit()
rows = {r["name"]: r["status"] for r in conn.execute("SELECT name, status FROM accounts")}
assert rows["default"] == "active" # id=1
assert rows["Activ"] == "active"
assert rows["Inactiv"] == "pending"

View File

@@ -112,4 +112,4 @@ def test_list_accounts_ordonat_fara_creds(conn):
assert ids == sorted(ids) assert ids == sorted(ids)
for r in rows: for r in rows:
assert "rar_creds_enc" not in r assert "rar_creds_enc" not in r
assert set(r.keys()) == {"id", "name", "cui", "active", "created_at"} assert set(r.keys()) == {"id", "name", "cui", "active", "status", "created_at"}

View File

@@ -0,0 +1,137 @@
"""Teste US-008 (PRD 5.5): rute admin pentru ciclul de viata al conturilor —
block/archive/delete + bulk pe lista account_id, require_admin + CSRF + PRG, dev id=1 protejat.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_admin_lifecycle.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
get_settings.cache_clear()
def _csrf(client, url="/admin"):
resp = client.get(url, follow_redirects=True)
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m, f"csrf negasit in {url}"
return m.group(1)
def _signup(client, name, email, password="parola_test_001"):
tok = _csrf(client, "/signup")
resp = client.post("/signup", data={"name": name, "email": email, "parola": password,
"csrf_token": tok}, follow_redirects=True)
assert resp.status_code == 200
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT account_id FROM users WHERE email=? COLLATE NOCASE",
(email,)).fetchone()
return int(row["account_id"])
finally:
conn.close()
def _make_admin(account_id):
from app.db import get_connection
from app.users import set_admin
conn = get_connection()
try:
set_admin(conn, account_id, is_admin=True)
conn.commit()
finally:
conn.close()
def _login(client, email, password="parola_test_001"):
tok = _csrf(client, "/login")
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": tok})
assert resp.status_code == 303
def _status(account_id):
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT status FROM accounts WHERE id=?", (account_id,)).fetchone()
return row["status"] if row else None
finally:
conn.close()
def _admin_login(client):
admin_id = _signup(client, "Admin SA", "admin@test.ro")
_make_admin(admin_id)
_login(client, "admin@test.ro")
return admin_id
@pytest.mark.parametrize("verb,expected", [
("block", "blocked"),
("archive", "archived"),
("delete", "deleted"),
])
def test_lifecycle_single(client, verb, expected):
target = _signup(client, "Tinta SRL", "tinta@test.ro")
_admin_login(client)
tok = _csrf(client)
resp = client.post(f"/admin/{verb}", data={"account_id": target, "csrf_token": tok})
assert resp.status_code == 303
assert _status(target) == expected
def test_bulk_pe_lista_account_id(client):
a = _signup(client, "A SRL", "a@test.ro")
b = _signup(client, "B SRL", "b@test.ro")
_admin_login(client)
tok = _csrf(client)
resp = client.post("/admin/block", data={"account_id": [a, b], "csrf_token": tok})
assert resp.status_code == 303
assert _status(a) == "blocked" and _status(b) == "blocked"
def test_bulk_sare_contul_dev(client):
target = _signup(client, "Tinta SRL", "t2@test.ro")
_admin_login(client)
tok = _csrf(client)
# include id=1 (cont de sistem) in selectie -> sarit, fara eroare; tinta procesata
resp = client.post("/admin/archive", data={"account_id": [1, target], "csrf_token": tok})
assert resp.status_code == 303
assert _status(1) == "active", "contul dev id=1 trebuie sa ramana neatins"
assert _status(target) == "archived"
def test_non_admin_403(client):
target = _signup(client, "Tinta SRL", "t3@test.ro")
_signup(client, "Neadmin SRL", "plain@test.ro")
_login(client, "plain@test.ro")
# csrf de pe o pagina accesibila non-admin
tok = _csrf(client, "/")
resp = client.post("/admin/block", data={"account_id": target, "csrf_token": tok})
assert resp.status_code == 403
def test_csrf_obligatoriu(client):
target = _signup(client, "Tinta SRL", "t4@test.ro")
_admin_login(client)
resp = client.post("/admin/delete", data={"account_id": target}) # fara csrf_token
assert resp.status_code != 303
assert _status(target) != "deleted"

View File

@@ -103,13 +103,13 @@ def _macro_html(checked: bool = True, form_id: str = "") -> str:
# --- markup / copy --- # --- markup / copy ---
def test_comutator_coada_prezent(): def test_comutator_coada_prezent():
"""Textul tinteste COADA ("in coada"/"verificare"), NU "trimite"/"Manual" gol.""" """5.5 (supersede framing 3.6): comutator etichetat Auto/Manual, compact.
Send-safety pastrata prin tooltip/Ajutor (Manual = tine pentru verificare; nimic nu
pleaca la RAR pana confirmi). Semantica de prezenta name=auto_send nealterata."""
html = _macro_html() html = _macro_html()
assert "in coada" in html, "comutatorul trebuie sa vorbeasca despre coada"
assert "verificare" in html, "optiunea de verificare manuala trebuie prezenta"
assert "name=\"auto_send\"" in html and 'value="true"' in html assert "name=\"auto_send\"" in html and 'value="true"' in html
# framing periculos interzis (citit global = send-safety): assert "Auto" in html and "Manual" in html, "ambele stari etichetate"
assert "Manual" not in html, "fara 'Manual' gol (sugereaza bypass al confirmarii RAR)" assert "verificare" in html, "sensul de verificare manuala trebuie pastrat (tooltip/ajutor)"
assert "trimite" not in html.lower(), "fara cuvantul 'trimite' izolat in eticheta" assert "trimite" not in html.lower(), "fara cuvantul 'trimite' izolat in eticheta"
assert "auto-send" not in html, "jargonul 'auto-send' trebuie inlocuit" assert "auto-send" not in html, "jargonul 'auto-send' trebuie inlocuit"
@@ -179,8 +179,9 @@ def test_comutator_in_tab_mapari(client):
_login(client, "tm@test.com") _login(client, "tm@test.com")
resp = client.get("/?tab=mapari") resp = client.get("/?tab=mapari")
assert resp.status_code == 200 assert resp.status_code == 200
assert "Pune automat in coada" in resp.text # 5.5: comutatorul compact Auto/Manual e prezent in tabul Mapari
assert "aceasta operatie" in resp.text assert 'name="auto_send"' in resp.text
assert "Manual" in resp.text and "Auto" in resp.text
def test_comutator_in_panou_preview(client): def test_comutator_in_panou_preview(client):
@@ -210,5 +211,6 @@ def test_comutator_in_panou_preview(client):
}) })
assert r.status_code == 200 assert r.status_code == 200
assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare" assert "OP-NEMAPAT" in r.text, "operatia nemapata trebuie sa apara in panoul de mapare"
assert "Pune automat in coada" in r.text # 5.5: comutatorul compact Auto/Manual e prezent si in panoul de mapare din preview
assert "aceasta operatie" in r.text assert 'name="auto_send"' in r.text
assert "Manual" in r.text and "Auto" in r.text

128
tests/test_web_admin.py Normal file
View File

@@ -0,0 +1,128 @@
"""Teste US-009 (PRD 5.5): panou admin UI — selectie cu bife + master, bara de actiuni bulk
(Activeaza/Blocheaza/Arhiveaza/Sterge), actiuni per-rand, fara nota 'cont dev implicit',
grila standard.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "test_web_admin.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
monkeypatch.setenv("AUTOPASS_SIGNUP_RATE_MAX", "100")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
get_settings.cache_clear()
def _csrf(client, url):
resp = client.get(url, follow_redirects=True)
m = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text)
assert m
return m.group(1)
def _signup(client, name, email, password="parola_test_001"):
tok = _csrf(client, "/signup")
client.post("/signup", data={"name": name, "email": email, "parola": password,
"csrf_token": tok}, follow_redirects=True)
from app.db import get_connection
conn = get_connection()
try:
row = conn.execute("SELECT account_id FROM users WHERE email=? COLLATE NOCASE",
(email,)).fetchone()
return int(row["account_id"])
finally:
conn.close()
def _admin_login(client):
target = _signup(client, "Pending SRL", "pending@test.ro") # cont in asteptare
admin_id = _signup(client, "Admin SA", "admin@test.ro")
from app.db import get_connection
from app.users import set_admin
conn = get_connection()
try:
set_admin(conn, admin_id, is_admin=True)
conn.commit()
finally:
conn.close()
tok = _csrf(client, "/login")
resp = client.post("/login", data={"email": "admin@test.ro", "parola": "parola_test_001",
"csrf_token": tok})
assert resp.status_code == 303
return target
def test_admin_coloana_selectie_si_master(client):
_admin_login(client)
html = client.get("/admin").text
# checkbox de selectie pe rand + master
assert 'name="account_id"' in html
assert 'type="checkbox"' in html
assert "Selecteaza tot" in html or 'data-master' in html
def test_bara_bulk_cu_cele_4_verbe(client):
_admin_login(client)
html = client.get("/admin").text
assert 'formaction="/admin/activate"' in html
assert 'formaction="/admin/block"' in html
assert 'formaction="/admin/archive"' in html
assert 'formaction="/admin/delete"' in html
# bara e ascunsa initial (hidden), fara display inline care ar invinge [hidden]
assert re.search(r'class="bulk-bar"\s+hidden', html) or re.search(r'hidden[^>]*class="bulk-bar"', html)
assert "bulk-bar" in html and ".bulk-bar[hidden]" in html # CSS care face hidden eficient
def test_actiuni_per_rand(client):
_admin_login(client)
html = client.get("/admin").text
# forme per-rand catre rutele de lifecycle (kebab)
assert 'action="/admin/block"' in html
assert 'action="/admin/archive"' in html
assert 'action="/admin/delete"' in html
def test_fara_nota_cont_dev(client):
_admin_login(client)
html = client.get("/admin").text
assert "cont dev implicit" not in html.lower()
assert "Cont dev implicit" not in html
def test_grila_standard(client):
_admin_login(client)
html = client.get("/admin").text
assert "tablewrap" in html
def test_cont_arhivat_in_blocul_suspendate(client):
"""Gruparea pe STARE: un cont arhivat apare in blocul blocate/arhivate, nu in 'in asteptare'."""
target = _admin_login(client) # cont pending seedat
from app.db import get_connection
from app.accounts import set_status
conn = get_connection()
try:
set_status(conn, target, "archived")
conn.commit()
finally:
conn.close()
html = client.get("/admin").text
# contul arhivat ajunge in blocul suspendate (1 cont), nu in "in asteptare"
assert re.search(r"Conturi blocate / arhivate \(1\)", html)
assert ">archived<" in html

View File

@@ -0,0 +1,86 @@
"""Teste US-006 (PRD 5.5): meniu hamburger in header (Cont/Integrare/Nomenclator/Admin/logout)
+ context de autentificare. base.html partajat: pe login/signup meniul nu expune cont/logout.
"""
from __future__ import annotations
import os
import re
import tempfile
import pytest
from starlette.testclient import TestClient
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "menu_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def _make_user(email="u@test.com", password="parolasecreta10", is_admin=False):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Test", active=True)
create_user(conn, acct_id, email, password, is_admin=is_admin)
return acct_id
finally:
conn.close()
def _login(client, email="u@test.com", password="parolasecreta10"):
resp = client.get("/login")
csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text).group(1)
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
assert resp.status_code == 303
def test_meniu_autentificat_are_linkuri_cont(client):
_make_user()
_login(client)
html = client.get("/").text
# butonul de meniu (hamburger) prezent
assert 'id="cont-menu-toggle"' in html
assert 'aria-controls="cont-menu"' in html
# linkurile mutate in meniu
assert 'href="/?tab=cont"' in html
assert 'href="/?tab=integrare"' in html
assert 'href="/?tab=nomenclator"' in html
# logout in meniu
assert 'action="/logout"' in html
assert "Iesi din cont" in html
def test_meniu_admin_link_doar_pentru_admin(client):
_make_user(email="admin@test.com", is_admin=True)
_login(client, email="admin@test.com")
html = client.get("/").text
assert 'href="/admin"' in html
def test_meniu_fara_admin_pentru_neadmin(client):
_make_user(email="plain@test.com", is_admin=False)
_login(client, email="plain@test.com")
html = client.get("/").text
assert 'href="/admin"' not in html
def test_meniu_neautentificat_fara_logout(client):
"""Pe /login (neautentificat) meniul nu expune cont/logout."""
html = client.get("/login").text
assert "Iesi din cont" not in html
assert 'action="/logout"' not in html
assert 'id="cont-menu-toggle"' not in html

View File

@@ -28,12 +28,17 @@ def _starile_din_schema() -> list[str]:
sql = schema_path.read_text(encoding="utf-8") sql = schema_path.read_text(encoding="utf-8")
# Cauta blocul CHECK aferent coloanei status din CREATE TABLE submissions. # Cauta blocul CHECK aferent coloanei status din CREATE TABLE submissions.
# Pattern: CHECK (status IN ('a','b',...)) pe una sau mai multe linii. # Nota (5.5): de cand exista si `accounts.status` cu propriul CHECK, ancoram pe blocul
# tabelei submissions (`CREATE TABLE ... submissions`) ca sa nu prindem starile de cont.
tbl = re.search(
r"CREATE TABLE[^;]*?submissions\b(.*?);", sql, re.IGNORECASE | re.DOTALL
)
assert tbl, "Nu am gasit CREATE TABLE submissions in schema.sql — schema s-a schimbat?"
match = re.search( match = re.search(
r"CHECK\s*\(\s*status\s+IN\s*\(([^)]+)\)\s*\)", r"CHECK\s*\(\s*status\s+IN\s*\(([^)]+)\)\s*\)",
sql, tbl.group(1),
) )
assert match, "Nu am gasit CHECK (status IN (...)) in schema.sql — schema s-a schimbat?" assert match, "Nu am gasit CHECK (status IN (...)) in submissions — schema s-a schimbat?"
raw = match.group(1) raw = match.group(1)
# Extrage valorile dintre ghilimele simple # Extrage valorile dintre ghilimele simple

View File

@@ -73,7 +73,7 @@ def client(monkeypatch):
# ============================================================ # ============================================================
def test_dashboard_are_tabbar(client): def test_dashboard_are_tabbar(client):
"""Dashboard-ul contine un tab-bar cu cele 6 tab-uri.""" """US-007 (5.5): tab-bar redus la Acasa + Mapari; Cont/Integrare/Nomenclator in meniul ☰."""
_create_account_user("tabbar@test.com", "parolasecreta10") _create_account_user("tabbar@test.com", "parolasecreta10")
_login(client, "tabbar@test.com", "parolasecreta10") _login(client, "tabbar@test.com", "parolasecreta10")
@@ -83,12 +83,15 @@ def test_dashboard_are_tabbar(client):
html = resp.text html = resp.text
assert 'role="tablist"' in html, "Lipseste role=tablist" assert 'role="tablist"' in html, "Lipseste role=tablist"
# Tab-urile trebuie sa fie prezente (Import a fuzionat in Acasa; "Coada"->"Trimiteri" — PRD 3.5) # Doar Acasa + Mapari sunt tab-uri (role="tab")
for label in ("Acasa", "Trimiteri", "Mapari", "Cont", "Nomenclator"): assert re.search(r'role="tab"[^>]*>\s*Acasa', html), "Lipseste tab-ul Acasa"
assert label in html, f"Lipseste tab-ul '{label}' din tab-bar" assert re.search(r'role="tab"[^>]*>\s*Mapari', html), "Lipseste tab-ul Mapari"
# "Import" nu mai e un tab separat in tab-bar (importul e direct pe Acasa) # Cont/Integrare/Nomenclator NU mai sunt tab-uri
assert not re.search(r'role="tab"[^>]*>\s*Import\s*<', html), \ for label in ("Cont", "Integrare", "Nomenclator", "Import"):
"Tab-ul 'Import' nu ar mai trebui sa existe ca tab separat (US-002)" assert not re.search(rf'role="tab"[^>]*>\s*{label}\s*<', html), \
f"'{label}' nu ar mai trebui sa fie un tab separat (mutat in meniu)"
# ...dar traiesc in meniul de cont
assert 'href="/?tab=cont"' in html and 'href="/?tab=nomenclator"' in html
# ============================================================ # ============================================================
@@ -215,3 +218,20 @@ def test_tabbar_aria(client):
assert 'role="tabpanel"' in html, "Lipseste role=tabpanel" assert 'role="tabpanel"' in html, "Lipseste role=tabpanel"
assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ" assert 'aria-selected="true"' in html, "Lipseste aria-selected=true pe tab-ul activ"
assert 'aria-selected="false"' in html, "Lipseste aria-selected=false pe tab-urile inactive" assert 'aria-selected="false"' in html, "Lipseste aria-selected=false pe tab-urile inactive"
# ============================================================
# test_fragmentele_mutate_raman_accesibile (US-007)
# ============================================================
def test_fragmentele_mutate_raman_accesibile(client):
"""US-007 (5.5): Cont/Integrare/Nomenclator s-au mutat in meniu, dar rutele de fragment
si deep-link-ul ?tab= raman valide (zero rute moarte / 404)."""
_create_account_user("frag@test.com", "parolasecreta10")
_login(client, "frag@test.com", "parolasecreta10")
for tab in ("cont", "integrare", "nomenclator"):
r_frag = client.get(f"/_fragments/{tab}")
assert r_frag.status_code == 200, f"/_fragments/{tab} a dat {r_frag.status_code}"
r_deep = client.get(f"/?tab={tab}")
assert r_deep.status_code == 200, f"/?tab={tab} a dat {r_deep.status_code}"

View File

@@ -0,0 +1,171 @@
"""Teste PRD 5.5 — uniformizare UI: US-001 (Acasa fara Ajutor), US-002 (Nomenclator grila
standard), US-003 (macro autosend compact). Stories de template/macro -> render direct Jinja
pentru US-002/003; US-001 prin TestClient pe fragmentul Acasa.
"""
from __future__ import annotations
import os
import re
import tempfile
from pathlib import Path
import pytest
from jinja2 import Environment, FileSystemLoader
from starlette.testclient import TestClient
_TEMPLATES = Path(__file__).resolve().parents[1] / "app" / "web" / "templates"
def _env():
return Environment(loader=FileSystemLoader(str(_TEMPLATES)), autoescape=True)
# ============================================================
# US-002: Nomenclator ca tabel standard (grila Trimiteri)
# ============================================================
def test_nomenclator_grila_standard_cu_randuri():
tmpl = _env().get_template("_nomenclator.html")
html = tmpl.render(rows=[
{"cod_prestatie": "A012", "nume_prestatie": "Revizie tehnica", "updated_at": "2026-06-20"},
])
assert "tablewrap" in html
assert "<table" in html
assert 'class="pill"' in html # codul in pill, ca la Trimiteri
assert "A012" in html and "Revizie tehnica" in html
def test_nomenclator_empty_state():
tmpl = _env().get_template("_nomenclator.html")
html = tmpl.render(rows=[])
assert 'class="empty"' in html
assert "Nomenclator gol" in html
# ============================================================
# US-003: macro autosend_toggle compact (Auto/Manual)
# ============================================================
def _render_macro(form_id="map-1", checked=True):
mod = _env().get_template("_macros.html").module
return str(mod.autosend_toggle(form_id=form_id, checked=checked))
def test_autosend_pastreaza_name_si_prezenta():
"""Invariant backend: checkbox name=auto_send value=true (semantica de prezenta)."""
html = _render_macro(checked=True)
assert 'type="checkbox"' in html
assert 'name="auto_send"' in html
assert 'value="true"' in html
assert 'form="map-1"' in html
assert "checked" in html
def test_autosend_nebifat_fara_checked():
html = _render_macro(checked=False)
assert 'name="auto_send"' in html
assert "checked" not in html
def test_autosend_compact_fara_proza_inline():
"""Proza explicativa de pe randuri (3.6) eliminata din CONTINUTUL vizibil — traieste in
panoul Ajutor (US-005). Tooltip-ul scurt (atribut title=) e acceptat, deci il scoatem
inainte de verificare."""
html = _render_macro()
vizibil = re.sub(r'title="[^"]*"', "", html) # scoate atributul title (tooltip)
assert "La fisierele viitoare" not in vizibil
assert "Tine pentru verificare" not in vizibil
assert "nimic nu pleaca la RAR" not in vizibil
# ambele etichete de stare vizibile, compact
assert "Auto" in html and "Manual" in html
# ============================================================
# US-001: Acasa fara sectiunea Ajutor
# ============================================================
@pytest.fixture()
def client(monkeypatch):
tmp = tempfile.mkdtemp()
monkeypatch.setenv("AUTOPASS_DB_PATH", os.path.join(tmp, "uniform_test.db"))
monkeypatch.setenv("AUTOPASS_WEB_AUTH_REQUIRED", "true")
from app.config import get_settings
get_settings.cache_clear()
from app.web import ratelimit
ratelimit._hits.clear()
from app.main import app
with TestClient(app, follow_redirects=False) as c:
yield c
ratelimit._hits.clear()
get_settings.cache_clear()
def _login(client, email="u@test.com", password="parolasecreta10"):
from app.accounts import create_account
from app.users import create_user
from app.db import get_connection
conn = get_connection()
try:
acct_id = create_account(conn, "Service Test", active=True)
create_user(conn, acct_id, email, password)
finally:
conn.close()
resp = client.get("/login")
csrf = re.search(r'name="csrf_token"\s+value="([^"]+)"', resp.text).group(1)
resp = client.post("/login", data={"email": email, "parola": password, "csrf_token": csrf})
assert resp.status_code == 303
return acct_id
def test_acasa_fara_sectiune_ajutor(client):
_login(client)
resp = client.get("/_fragments/acasa")
assert resp.status_code == 200
# randul "Ajutor:" cu wayfinding Mapari/Coduri RAR eliminat din Acasa
assert "Ajutor:" not in resp.text
assert "Coduri RAR" not in resp.text
# ============================================================
# US-005: Tabel Mapari standardizat + panou Ajutor
# ============================================================
def _seed_needs_mapping(acct_id, cod_op="OP-NM", denumire="Operatie test"):
import json
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO submissions (idempotency_key, account_id, status, payload_json) "
"VALUES (?, ?, 'needs_mapping', ?)",
(f"k-{cod_op}", acct_id,
json.dumps({"prestatii": [{"cod_op_service": cod_op, "denumire": denumire}]})),
)
conn.commit()
finally:
conn.close()
def test_mapari_ajutor_disclosure_si_fara_proza_inline(client):
acct = _login(client)
_seed_needs_mapping(acct)
resp = client.get("/_fragments/mapari")
assert resp.status_code == 200
html = resp.text
# panou Ajutor (<details>) prezent
assert "ajutor-mapari" in html
assert "<details" in html and ">Ajutor<" in html
# antet de coloana compact
assert ">In coada<" in html
# proza inline veche eliminata de pe sectiuni
assert "sugestia fuzzy e preselectata) si salveaza" not in html
assert "Maparile operatie -> cod RAR retinute pentru contul tau" not in html
def test_mapari_comutator_compact_in_tabel(client):
acct = _login(client)
_seed_needs_mapping(acct)
html = client.get("/_fragments/mapari").text
assert 'name="auto_send"' in html
assert "Manual" in html and "Auto" in html

View File

@@ -125,6 +125,51 @@ def test_claim_account_null_tratat_activ(env):
assert _row_status(conn, sid) == "sending" assert _row_status(conn, sid) == "sending"
def test_claim_sare_cont_blocat(env):
"""5.5: cont blocked -> claim_one nu ridica submission-ul."""
from app.accounts import create_account, set_status
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Blocat", active=True)
sid = _insert(conn, account_id=acct_id)
set_status(conn, acct_id, "blocked")
assert claim_one(conn) is None
assert _row_status(conn, sid) == "queued"
def test_claim_sare_cont_arhivat(env):
"""5.5: cont archived -> claim_one nu ridica submission-ul."""
from app.accounts import create_account, set_status
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Arhivat", active=True)
sid = _insert(conn, account_id=acct_id)
set_status(conn, acct_id, "archived")
assert claim_one(conn) is None
assert _row_status(conn, sid) == "queued"
def test_deblocare_reia_trimiterea(env):
"""5.5: blocked -> set_status('active') -> claim_one ridica din nou."""
from app.accounts import create_account, set_status
from app.worker.__main__ import claim_one
conn, _ = env
acct_id = create_account(conn, "Service Revenit", active=True)
sid = _insert(conn, account_id=acct_id)
set_status(conn, acct_id, "blocked")
assert claim_one(conn) is None
set_status(conn, acct_id, "active")
result = claim_one(conn)
assert result is not None and result["id"] == sid
assert _row_status(conn, sid) == "sending"
def test_claim_cont_legacy_fara_active(env): def test_claim_cont_legacy_fara_active(env):
"""Simuleaza cont legacy: LEFT JOIN nu gaseste randul in accounts (account_id nul dupa stergere cont) """Simuleaza cont legacy: LEFT JOIN nu gaseste randul in accounts (account_id nul dupa stergere cont)
-> a.active=NULL dupa JOIN -> COALESCE(NULL,1)=1 -> tratat ca activ. -> a.active=NULL dupa JOIN -> COALESCE(NULL,1)=1 -> tratat ca activ.