diff --git a/TODOS.md b/TODOS.md index de00797..c970fbb 100644 --- a/TODOS.md +++ b/TODOS.md @@ -2,6 +2,15 @@ Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand devin prioritare. +## Din PRD 5.12 (2026-06-26) + +- [ ] **Mai multi utilizatori per firma (flux de invitatie / alaturare la cont)** — azi CUI e unic, deci + al doilea email care vrea pe aceeasi firma e respins la signup (nu exista flux de „alatura-te firmei"). + `users` permite tehnic mai multe loginuri per `account_id`, dar nu exista UI. Daca apare nevoia reala + (mai multe persoane dintr-o firma), construieste: admin-ul firmei invita un email SAU al doilea cere + acces si admin-ul aproba; membership pe `account_id`. Decizie user (2026-06-26): in 5.12 ramane + **1 firma = 1 cont = 1 login** + mesaj prietenos la CUI duplicat (US-001); acest flux = livrabila separata. + ## Din /autoplan PRD 5.11 (2026-06-26) - [ ] **E2E smoke de first-run ca poarta de release** — codifica scriptul de dogfooding @@ -16,3 +25,16 @@ Elemente deferate din review-uri. Negrupte de un PRD curent; de promovat cand de remapare inline (fara gate de preview). Daca apar integratori reali, evalueaza un throttle „primele N auto-trimiteri pe o regula text noua cer confirmare" sau un kill-switch per cont. (CEO F5/F6, severitate critical ca risc, dar pre-launch exposure ~zero acum.) + +## Din /autoplan PRD 5.13 (2026-06-27) + +- [ ] **Filtre de data 2x2 pe mobil** — Azi/7zile/30zile/Custom stivuiesc full-width (4 randuri) + pe mobil; grid 2x2 ar fi mai compact. Imbunatatire viitoare. (Design, low.) +- [ ] **Sprite `` pentru iconitele Lucide** — `act_btn` randeaza SVG inline pe + fiecare rand (bloat DOM pe toate viewporturile, ascuns pe desktop). Optimizare deferata; inline + acum (P5 simplu > optim prematur). (Eng §1, medium.) +- [ ] **"Eroare/Eroare" la nivel routes.py/labels.py** — guard-ul de template (pill-only cand + eticheta==stare) acopera cazul vizibil; curatarea logicii de continut ramane debt. (Design §2.) +- [ ] **Validare premisa "utilizare mobil reala"** — inainte de orice extindere responsive viitoare, + confirma device-mix-ul (analytics/cerere user). Daca ~95% desktop, nu mai investi in cardificare + mobil. (CEO F1, high — premisa nedovedita acum.) diff --git a/app/accounts.py b/app/accounts.py index 36e0103..a12b68e 100644 --- a/app/accounts.py +++ b/app/accounts.py @@ -18,31 +18,49 @@ import sqlite3 def _norm_cui(cui: str | None) -> str | None: - """trim + upper; sir gol -> None (tratat ca „fara CUI").""" + """trim + upper; sir gol -> ValueError daca e string gol, None daca e None.""" if cui is None: return None cui = cui.strip().upper() - return cui or None + if cui == "": + raise ValueError("CUI gol (un CUI trebuie sa fie un sir nevid)") + return cui + + +def _norm_email(email: str | None) -> str | None: + """trim + lower; sir gol -> ValueError daca e string gol, None daca e None.""" + if email is None: + return None + email = email.strip().lower() + if email == "": + raise ValueError("email gol (un email trebuie sa fie un sir nevid)") + return email def create_account( - conn: sqlite3.Connection, name: str, cui: str | None = None, active: bool = True + conn: sqlite3.Connection, + name: str, + cui: str | None = None, + email: str | None = None, + active: bool = True, ) -> int: """Insereaza un cont si intoarce id-ul nou (AUTOINCREMENT, deci >=2 — nu atinge default id=1). - `name` gol/whitespace -> ValueError. `cui` se normalizeaza (trim+upper); un CUI - deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial + `name` gol/whitespace -> ValueError. `cui` se normalizeaza (trim+upper); sir gol -> ValueError. + `email` se normalizeaza (trim+lower); sir gol -> ValueError. + Un CUI deja folosit -> ValueError cu cauza+fix. Unicitatea e impusa de indexul partial `ux_accounts_cui` (nu de un check separat), deci e sigura la concurenta. """ name = (name or "").strip() if not name: raise ValueError("name gol (un cont are nevoie de nume)") cui = _norm_cui(cui) + email = _norm_email(email) try: # Invariant (5.5): active=1 <=> status='active'; cont creat inactiv = 'pending'. cur = conn.execute( - "INSERT INTO accounts (name, cui, active, status) VALUES (?, ?, ?, ?)", - (name, cui, 1 if active else 0, "active" if active else "pending"), + "INSERT INTO accounts (name, cui, email, active, status) VALUES (?, ?, ?, ?, ?)", + (name, cui, email, 1 if active else 0, "active" if active else "pending"), ) except sqlite3.IntegrityError: existing = conn.execute("SELECT id FROM accounts WHERE cui=?", (cui,)).fetchone() @@ -54,6 +72,21 @@ def create_account( return int(cur.lastrowid or 0) +def account_is_complete(row: sqlite3.Row | dict) -> bool: + """Returneaza True daca contul are companie (name), email si CUI ne-goale. + + Contul de sistem id=1 (default) este EXCEPTAT si returneaza intotdeauna True + (nu are sens sa-l marcam ca incomplet — nu e un cont de client). + """ + acct_id = row["id"] if "id" in row.keys() else None + if acct_id == 1: + return True + name = (row["name"] or "").strip() + cui = (row["cui"] or "").strip() + email_val = (row["email"] or "").strip() if "email" in row.keys() else "" + return bool(name and cui and email_val) + + def set_active(conn: sqlite3.Connection, account_id: int, active: bool) -> None: """Comuta `accounts.active`. Idempotent (set activ pe activ nu arunca). Cont inexistent -> ValueError. @@ -121,7 +154,7 @@ def list_accounts(conn: sqlite3.Connection) -> list[dict]: """Metadate conturi (FARA `rar_creds_enc`), ordonate dupa id. Exclude conturile 'deleted' (stergere soft -> invizibile in panou).""" rows = conn.execute( - "SELECT id, name, cui, active, status, created_at FROM accounts " + "SELECT id, name, cui, email, active, status, created_at FROM accounts " "WHERE status != 'deleted' ORDER BY id" ).fetchall() return [dict(r) for r in rows] diff --git a/app/api/v1/import_router.py b/app/api/v1/import_router.py index bd00161..0055502 100644 --- a/app/api/v1/import_router.py +++ b/app/api/v1/import_router.py @@ -130,6 +130,7 @@ def _resolve_row_for_preview( override: dict[str, Any] | None = None, valid_codes: set[str] | None = None, text_rules: list[dict] | None = None, + reviewed: bool = False, ) -> dict[str, Any]: """Rezolva un rand din import pentru preview: aplica mapare coloane + validare. @@ -220,8 +221,10 @@ def _resolve_row_for_preview( # Validare continut errors = validate_prezentare(mapped) - if all_flags: - # needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala + if all_flags and not reviewed: + # needs_review: validarea a trecut, dar flagurile (date ambigue, formule) cer confirmare manuala. + # Daca reviewed=True (operatorul a confirmat explicit valorile in modal), sarim + # acest return si continuam spre ok/needs_data (US-007, PRD 5.12). return { "resolved_status": "needs_review", "resolved": mapped, @@ -337,7 +340,10 @@ def apply_row_override( new_override = _merge_override(current, fields) enc = encrypt_creds(new_override) if new_override else None - conn.execute("UPDATE import_rows SET override_json=? WHERE id=?", (enc, row["rid"])) + # D#9 (PRD 5.12): resetam reviewed=0 la orice schimbare de valoare — operatorul + # trebuie sa reconfirme dupa editare. NU conditionam pe reviewed curent: orice override + # (chiar si revert la valoarea initiala) anuleaza confirmarea implicita. + conn.execute("UPDATE import_rows SET override_json=?, reviewed=0 WHERE id=?", (enc, row["rid"])) return new_override @@ -932,10 +938,30 @@ def commit_import( if batch["status"] == "committed": raise HTTPException(status_code=409, detail="batch deja comis") - # Incarca randurile cu stare ok sau needs_review + # D#8 (PRD 5.12): gate commit derivat din DB `reviewed` pe AMBELE canale. + # API: reviewed_rows pastrat (contract stabil) dar seteaza reviewed=1 in DB inainte + # de interogare. Randurile needs_review cu reviewed=1 sunt incluse in comit. + if req.reviewed_rows: + conn.execute("BEGIN IMMEDIATE") + try: + for idx in req.reviewed_rows: + conn.execute( + "UPDATE import_rows SET reviewed=1 " + "WHERE batch_id=? AND row_index=? AND resolved_status='needs_review'", + (import_id, idx), + ) + conn.execute("COMMIT") + except Exception: + conn.execute("ROLLBACK") + raise + + # Incarca randurile ok + needs_review confirmate (reviewed=1) ok_rows_db = conn.execute( - "SELECT row_index, raw_json, override_json, resolved_status FROM import_rows " - "WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index", + "SELECT row_index, raw_json, override_json, resolved_status, reviewed " + "FROM import_rows " + "WHERE batch_id=? AND (resolved_status='ok' OR " + "(resolved_status='needs_review' AND reviewed=1)) " + "ORDER BY row_index", (import_id,), ).fetchall() @@ -947,8 +973,6 @@ def commit_import( # Decripteaza randurile ok ok_rows: list[dict] = [] - ok_indices: list[int] = [] - review_indices: set[int] = set() for r in ok_rows_db: try: @@ -957,28 +981,12 @@ def commit_import( continue except Exception: continue - - if r["resolved_status"] == "ok": - ok_rows.append({"row_index": r["row_index"], "data": row_data, - "override": _override_of(r), "status": "ok"}) - ok_indices.append(r["row_index"]) - elif r["resolved_status"] == "needs_review": - review_indices.add(r["row_index"]) - - # needs_review bifate explicit (atestare pe valori) - confirmed_review = [idx for idx in req.reviewed_rows if idx in review_indices] - for idx in confirmed_review: - # Gaseste randul needs_review si il adauga la ok_rows - for r in ok_rows_db: - if r["row_index"] == idx and r["resolved_status"] == "needs_review": - try: - row_data = decrypt_creds(r["raw_json"]) - if row_data: - ok_rows.append({"row_index": idx, "data": row_data, - "override": _override_of(r), "status": "needs_review"}) - ok_indices.append(idx) - except Exception: - pass + ok_rows.append({ + "row_index": r["row_index"], + "data": row_data, + "override": _override_of(r), + "status": r["resolved_status"], + }) # Gate HARD: n_confirmat trebuie sa fie EXACT egal cu numarul de randuri ok n_total_ok = len(ok_rows) diff --git a/app/config.py b/app/config.py index dddf3ab..1da059d 100644 --- a/app/config.py +++ b/app/config.py @@ -61,6 +61,11 @@ class Settings(BaseSettings): # False (dev): cookie fara Secure, functioneaza pe HTTP. session_https_only: bool = False + # --- Contact suport (US-001, PRD 5.12) --- + # Email/canal de contact afisat in mesaje catre utilizatori (ex. CUI duplicat la signup). + # Nesetat -> fallback la formularea generica fara canal concret. + support_email: str | None = None + # --- Notificare email admin la signup --- # Nesetat (smtp_host None) -> notificarea e DEGRADATA (doar log SIGNUP). smtp_host: str | None = None diff --git a/app/db.py b/app/db.py index f4d41ff..52e6336 100644 --- a/app/db.py +++ b/app/db.py @@ -81,6 +81,9 @@ def _migrate(conn: sqlite3.Connection) -> None: "ALTER TABLE accounts ADD COLUMN on_unmapped_error_default INTEGER NOT NULL DEFAULT 0 " "CHECK (on_unmapped_error_default IN (0, 1))" ) + if "email" not in acc_cols: + # Email canonic de contact al firmei (US-001, PRD 5.12). Nullable pt. conturi legacy. + conn.execute("ALTER TABLE accounts ADD COLUMN email TEXT") # Unicitate CUI (un CUI = un cont); NULL distinct nativ -> conturi fara CUI multiplu. conn.execute( "CREATE UNIQUE INDEX IF NOT EXISTS ux_accounts_cui ON accounts(cui) WHERE cui IS NOT NULL" @@ -105,6 +108,12 @@ def _migrate(conn: sqlite3.Connection) -> None: irows_cols = {r["name"] for r in conn.execute("PRAGMA table_info(import_rows)").fetchall()} if "override_json" not in irows_cols: conn.execute("ALTER TABLE import_rows ADD COLUMN override_json TEXT") + if "reviewed" not in irows_cols: + # Marcaj confirmare umana (US-007, PRD 5.12). NU intra in payload/idempotenta. + # NOT NULL DEFAULT 0: valoare clara (0=neconfirmat), fara ambiguitate NULL vs 0. + conn.execute( + "ALTER TABLE import_rows ADD COLUMN reviewed INTEGER NOT NULL DEFAULT 0" + ) # Index batch_id pe submissions (poate lipsi pe DB veche) existing_idx = {r["name"] for r in conn.execute( diff --git a/app/schema.sql b/app/schema.sql index be9a0c4..2c9846e 100644 --- a/app/schema.sql +++ b/app/schema.sql @@ -10,6 +10,7 @@ CREATE TABLE IF NOT EXISTS accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, cui TEXT, + email TEXT, -- email canonic de contact al firmei (US-001, PRD 5.12); nullable pt. conturi legacy 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. @@ -125,6 +126,7 @@ CREATE TABLE IF NOT EXISTS import_rows ( row_index INTEGER NOT NULL, raw_json TEXT NOT NULL, -- PII criptat (Fernet, ca submissions) override_json TEXT, -- patch CANONIC editat in preview, criptat Fernet (3.6, Approach B); NULL = fara editare + reviewed INTEGER NOT NULL DEFAULT 0, -- US-007 (PRD 5.12): 0=neconfirmat, 1=confirmat de operator; NU intra in payload/idempotenta resolved_status TEXT NOT NULL DEFAULT 'pending' CHECK (resolved_status IN ( 'pending','ok','needs_mapping','needs_data', diff --git a/app/web/admin_routes.py b/app/web/admin_routes.py index cd70f13..c221812 100644 --- a/app/web/admin_routes.py +++ b/app/web/admin_routes.py @@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.templating import Jinja2Templates from .. import __version__ -from ..accounts import list_accounts, set_active, set_status, delete_account +from ..accounts import account_is_complete, list_accounts, set_active, set_status, delete_account from ..config import get_settings from ..db import get_connection from ..web.csrf import get_csrf_token, verify_csrf @@ -48,6 +48,8 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co accounts = list_accounts(conn) emails = _emails_by_account(conn) for acct in accounts: + # Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului + acct["is_complete"] = account_is_complete(acct) acct["email"] = emails.get(acct["id"]) # Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0) # ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts. @@ -85,6 +87,12 @@ def _apply_lifecycle(conn, ids: list[int], action: str) -> None: for aid in ids: try: if action == "activate": + # Gate US-002: nu activam conturi fara identitate completa (companie+email+CUI) + acct_row = conn.execute( + "SELECT id, name, cui, email FROM accounts WHERE id=?", (aid,) + ).fetchone() + if acct_row and not account_is_complete(acct_row): + continue # sarim activarea — contul incomplet ramane pending set_status(conn, aid, "active") elif action == "block": set_status(conn, aid, "blocked") diff --git a/app/web/auth_routes.py b/app/web/auth_routes.py index ade6f5c..283eb9c 100644 --- a/app/web/auth_routes.py +++ b/app/web/auth_routes.py @@ -69,6 +69,16 @@ async def signup_post( name=name, cui=cui, email=email, ), status_code=422) + # CUI obligatoriu la signup (US-001, PRD 5.12) + cui_norm = cui.strip().upper() if cui else "" + if not cui_norm: + return _TMPL.TemplateResponse(request, "signup.html", _ctx( + request, + csrf_token=get_csrf_token(request), + error="CUI-ul firmei este obligatoriu.", + name=name, cui=cui, email=email, + ), status_code=422) + # Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE, # astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1. conn = get_connection() @@ -76,10 +86,43 @@ async def signup_post( conn.execute("BEGIN IMMEDIATE") try: is_first = count_admins(conn) == 0 - account_id = create_account(conn, name, cui.strip() or None, active=False) + account_id = create_account(conn, name, cui=cui_norm, email=email, active=False) user_id = create_user(conn, account_id, email, parola, is_admin=is_first) api_key = create_api_key(conn, account_id) conn.execute("COMMIT") + except ValueError as exc: + conn.execute("ROLLBACK") + exc_msg = str(exc) + # Ordinea conteaza: verifica EMAIL inainte de CUI (ambele contin 'deja folosit'). + # create_user ridica exact "email deja folosit"; create_account ridica "CUI X e deja folosit". + if "email deja folosit" in exc_msg: + # Email duplicat -> mesaj specific emailului (T3, D#14-email) + error_msg = ( + "Acest email este deja folosit. " + "Daca ai deja cont, autentifica-te." + ) + elif "deja folosit" in exc_msg or "IntegrityError" in exc_msg: + # CUI duplicat -> mesaj prietenos, NU mesajul tehnic cu 'activate --account' (T3, D#14) + settings = get_settings() + if settings.support_email: + error_msg = ( + f"Aceasta firma (CUI {cui_norm}) e deja inregistrata. " + f"Cere accesul de la administratorul contului sau contacteaza suportul: " + f"{settings.support_email}" + ) + else: + error_msg = ( + f"Aceasta firma (CUI {cui_norm}) e deja inregistrata. " + f"Cere accesul de la administratorul contului." + ) + else: + error_msg = exc_msg + return _TMPL.TemplateResponse(request, "signup.html", _ctx( + request, + csrf_token=get_csrf_token(request), + error=error_msg, + name=name, cui=cui, email=email, + ), status_code=422) except Exception as exc: conn.execute("ROLLBACK") return _TMPL.TemplateResponse(request, "signup.html", _ctx( diff --git a/app/web/routes.py b/app/web/routes.py index 633cbbb..7f3749e 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -174,13 +174,16 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1). """ from ..mapping import account_or_default + from ..accounts import account_is_complete as _acct_is_complete acct = account_or_default(account_id) - # Pas 1: are credentiale RAR configurate? + # Pas 1: are credentiale RAR configurate? + metadate cont (pentru banner incomplet) row = conn.execute( - "SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,) + "SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,) ).fetchone() are_creds = bool(row and row["rar_creds_enc"]) + # Banner cont incomplet (US-002): contul nu are companie + email + CUI complete + cont_incomplet = not _acct_is_complete(row) if row else False # Pas 3: are cel putin un submission (trimis sau in coada)? row_sub = conn.execute( @@ -214,6 +217,8 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: "versiune_trimiteri": _trimiteri_versiune(conn, account_id), # Acasa include caseta de upload -> are nevoie de csrf_token "csrf_token": get_csrf_token(request), + # Banner ne-blocant (US-002): contul nu are identitate completa (companie+email+CUI) + "cont_incomplet": cont_incomplet, } @@ -266,8 +271,11 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str: """Randeaza panoul Cont ca string HTML.""" from ..mapping import account_or_default acct = account_or_default(account_id) - row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone() + row = conn.execute( + "SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,) + ).fetchone() are_creds = bool(row and row["rar_creds_enc"]) + account_meta = _fetch_account_meta(conn, acct) return templates.get_template("_cont.html").render({ "request": request, "csrf_token": get_csrf_token(request), @@ -276,6 +284,9 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str: "creds_mesaj": None, "creds_eroare": None, "rot_eroare": None, + "account_meta": account_meta, + "date_firma_mesaj": None, + "date_firma_eroare": None, }) @@ -1865,7 +1876,8 @@ def _web_compute_preview( return "Batch de import inexistent sau inaccesibil." raw_rows_db = conn.execute( - "SELECT row_index, raw_json, override_json FROM import_rows WHERE batch_id=? ORDER BY row_index", + "SELECT row_index, raw_json, override_json, reviewed FROM import_rows " + "WHERE batch_id=? ORDER BY row_index", (import_id,), ).fetchall() if not raw_rows_db: @@ -1874,6 +1886,7 @@ def _web_compute_preview( # Decripteaza randurile + override-urile editate rows: list[dict[str, Any]] = [] overrides: list[dict[str, Any]] = [] + reviewed_flags: list[bool] = [] for r in raw_rows_db: try: row_data = decrypt_creds(r["raw_json"]) or {} @@ -1885,6 +1898,7 @@ def _web_compute_preview( except Exception: ov = None overrides.append(ov or {}) + reviewed_flags.append(bool(r["reviewed"])) col_names = list(rows[0].keys()) if rows else [] sig = _signature(col_names) @@ -1952,6 +1966,7 @@ def _web_compute_preview( override=overrides[i] or None, valid_codes=valid_codes, text_rules=text_rules, + reviewed=reviewed_flags[i], ) key: str | None = None @@ -2161,12 +2176,14 @@ async def web_upload_import( if sugg: fuzzy_suggestions[col] = sugg + _sample = parsed.rows[:3] return templates.TemplateResponse("_mapcoloane.html", { "request": request, "import_id": batch_id_int, "filename": filename, "columns": parsed.columns, - "sample_rows": parsed.rows[:3], + "sample_rows": _sample, + "prima_inregistrare": _sample[0] if _sample else None, "fuzzy_suggestions": fuzzy_suggestions, "canonical_fields": _CANONICAL_FIELDS, "format_data": None, @@ -2379,23 +2396,63 @@ def _render_preview_rand( }) -@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse) -def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse: - """Intra in mod editare pe un rand de preview (randul devine FORM propriu).""" +@router.get("/_import/{import_id}/rand/{row_index}/editare-modal", response_class=HTMLResponse) +def web_rand_editare_modal(request: Request, import_id: int, row_index: int) -> HTMLResponse: + """Fragment editare rand preview in modalul global (#detaliu-modal-body). + + US-006 (PRD 5.12): inlocuieste editarea inline (tr.preview-edit) care cauza + colapsare vizuala si eroare JS la Anuleaza (R5). Randeaza _editare_preview_modal.html. + Campurile vehicul/data/odometru sunt preluate din starea curenta (resolved + override). + """ account_id = require_login(request) conn = get_connection() try: result, row = _preview_one_row(conn, import_id, account_id, row_index) if row is None or isinstance(result, str): raise HTTPException(status_code=404, detail="rand de import inexistent") - return _render_preview_rand( - request, import_id=import_id, row=row, editing=True, - include_oob=False, summary=result["summary"], - ) + res = row.get("resolved") or {} + err_map: dict[str, str] = {} + fix_map: dict[str, str] = {} + for e in (row.get("errors") or []): + if isinstance(e, dict) and e.get("field"): + err_map[e["field"]] = e.get("message") or e.get("msg") or "" + if e.get("fix"): + fix_map[e["field"]] = e["fix"] + return templates.TemplateResponse("_editare_preview_modal.html", { + "request": request, + "import_id": import_id, + "row_index": row_index, + "csrf_token": get_csrf_token(request), + "vin": res.get("vin") or "", + "stare_css": row.get("stare_css") or "", + "stare_eticheta": row.get("stare_eticheta") or "", + "form_nr": res.get("nr_inmatriculare") or "", + "form_vin": res.get("vin") or "", + "form_data": res.get("data_prestatie") or "", + "form_odo_final": str(res.get("odometru_final") or ""), + "form_odo_initial": str(res.get("odometru_initial") or ""), + "err_map": err_map, + "fix_map": fix_map, + "vin_context": res.get("vin") or "", + "btn_label": "Salveaza", + "message": None, + # T2 (US-007): butonul 'Confirma valorile' apare DOAR pe randurile needs_review. + "is_needs_review": row.get("resolved_status") == "needs_review", + }) finally: conn.close() +@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse) +def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse: + """Fragment editare rand preview in modal — alias al /editare-modal. + + US-006: editarea inline eliminata; ruta pastrata pentru compatibilitate cu + apeluri externe / teste existente. Delega la web_rand_editare_modal. + """ + return web_rand_editare_modal(request, import_id, row_index) + + @router.get("/_import/{import_id}/rand/{row_index}", response_class=HTMLResponse) def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLResponse: """Iese din mod editare (Anuleaza) — re-randeaza randul read-only + OOB contoare.""" @@ -2415,11 +2472,17 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe @router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse) async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse: - """Persista override (mutatie pura) + re-randeaza DOAR randul. + """Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal. - Statusul e rederivat prin `_resolve_row_for_preview`. Swap pe rand + OOB contoare. - Daca raman erori de continut pe camp, randul ramane in editare cu valorile pastrate - si mesajul pe campul vinovat.""" + US-006 (PRD 5.12): + - Succes: raspuns cu OOB pe rand (#preview-row-N) + OOB contoare (#preview-rezumat) + + header HX-Trigger-After-Settle:inchideModal (modalul se inchide, OOB se aplica). + - Eroare camp: re-randeaza _editare_preview_modal.html cu valorile introduse + erorile + per-camp; modalul RAMANE DESCHIS; NU se emite inchideModal. + + INVARIANT CRITIC (R2): submissions NEATINS — override-only pe import_rows.override_json, + NU re-queue, NU insereaza in submissions. + """ account_id = require_login(request) form = await request.form() verify_csrf(request, str(form.get("csrf_token") or "")) @@ -2430,7 +2493,7 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> conn = get_connection() try: # Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error - # pastreaza randul + valorile la 4xx/5xx). + # pastreaza formularul modal cu valorile la 4xx/5xx). apply_row_override( conn, import_id=import_id, account_id=account_id, row_index=row_index, fields=fields, @@ -2443,15 +2506,120 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> if isinstance(e, dict) and e.get("field") ] if field_errors: - return _render_preview_rand( - request, import_id=import_id, row=row, editing=True, - include_oob=True, summary=result["summary"], - message="Mai sunt valori invalide — corecteaza campurile marcate.", - ) - return _render_preview_rand( - request, import_id=import_id, row=row, editing=False, - include_oob=True, summary=result["summary"], - ) + # Eroare de validare: re-randeaza formularul in modal cu valorile introduse. + # Modalul RAMANE DESCHIS (fara HX-Trigger-After-Settle:inchideModal). + res = row.get("resolved") or {} + err_map: dict[str, str] = {} + fix_map: dict[str, str] = {} + for e in field_errors: + if e.get("field"): + err_map[e["field"]] = e.get("message") or e.get("msg") or "" + if e.get("fix"): + fix_map[e["field"]] = e["fix"] + return templates.TemplateResponse("_editare_preview_modal.html", { + "request": request, + "import_id": import_id, + "row_index": row_index, + "csrf_token": get_csrf_token(request), + "vin": res.get("vin") or "", + "stare_css": row.get("stare_css") or "", + "stare_eticheta": row.get("stare_eticheta") or "", + # Valorile DIN FORM (pentru ca userul sa vada ce a introdus): + "form_nr": str(form.get("nr_inmatriculare") or res.get("nr_inmatriculare") or ""), + "form_vin": str(form.get("vin") or res.get("vin") or ""), + "form_data": str(form.get("data_prestatie") or res.get("data_prestatie") or ""), + "form_odo_final": str(form.get("odometru_final") or res.get("odometru_final") or ""), + "form_odo_initial": str(form.get("odometru_initial") or res.get("odometru_initial") or ""), + "err_map": err_map, + "fix_map": fix_map, + "vin_context": res.get("vin") or "", + "btn_label": "Salveaza", + "message": "Mai sunt valori invalide — corecteaza campurile marcate.", + }) + + # Succes: OOB swap rand + contoare + inchideModal. + # Continut primar (swap in #detaliu-modal-body): stub invizibil + script recalc. + # OOB: actualizat + rezumat + contor. + # HX-Trigger-After-Settle: inchideModal → base.html JS inchide modalul. + oob_content = templates.get_template("_preview_rand.html").render({ + "request": request, + "import_id": import_id, + "row": row, + "editing": False, + "oob_tr": True, + "include_oob": True, + "summary": result["summary"], + "message": None, + "csrf_token": get_csrf_token(request), + }) + html_body = '
' + oob_content + resp = HTMLResponse(content=html_body) + resp.headers["HX-Trigger-After-Settle"] = "inchideModal" + return resp + finally: + conn.close() + + +@router.post("/_import/{import_id}/rand/{row_index}/confirma-review", response_class=HTMLResponse) +async def web_confirma_review( + request: Request, + import_id: int, + row_index: int, +) -> HTMLResponse: + """Confirma explicit valorile unui rand needs_review → seteaza reviewed=1 in DB. + + US-007 (PRD 5.12), T2: butonul 'Confirma valorile' din modal seteaza reviewed=1 + pentru randul indicat. La recalcul (_web_compute_preview), randul cu reviewed=1 + si fara erori de validare reale devine ok (nu mai e blocat de flaguri ambigue). + + Guard: 404 cross-account (scoping JOIN), 409 batch committed. + Raspunde OOB (rand + contoare) + HX-Trigger-After-Settle:inchideModal, + identic cu web_editeaza_rand la succes. + """ + account_id = require_login(request) + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + acct = account_or_default(account_id) + + conn = get_connection() + try: + # Scoping: JOIN verifica ca batch-ul apartine contului si ca randul exista. + # Acelasi tipar ca apply_row_override (404 cross-account, 409 committed). + row_db = conn.execute( + "SELECT r.id AS rid, b.status AS bstatus " + "FROM import_rows r JOIN import_batches b ON b.id = r.batch_id " + "WHERE b.id=? AND b.account_id=? AND r.row_index=?", + (import_id, acct, row_index), + ).fetchone() + if not row_db: + raise HTTPException(status_code=404, detail="rand de import inexistent") + if row_db["bstatus"] == "committed": + raise HTTPException(status_code=409, detail="batch deja comis; confirmarea nu mai are efect") + + # Seteaza reviewed=1 — marcaj separat, NU camp de continut (NU intra in override_json/payload). + conn.execute("UPDATE import_rows SET reviewed=1 WHERE id=?", (row_db["rid"],)) + + # Recalculeaza preview: randul cu reviewed=1 + fara erori reale devine ok. + result, row = _preview_one_row(conn, import_id, account_id, row_index) + if row is None or isinstance(result, str): + raise HTTPException(status_code=404, detail="rand de import inexistent") + + # OOB: rand actualizat + rezumat + contor ok + inchideModal (identic cu succes editeaza) + oob_content = templates.get_template("_preview_rand.html").render({ + "request": request, + "import_id": import_id, + "row": row, + "editing": False, + "oob_tr": True, + "include_oob": True, + "summary": result["summary"], + "message": None, + "csrf_token": get_csrf_token(request), + }) + html_body = '
' + oob_content + resp = HTMLResponse(content=html_body) + resp.headers["HX-Trigger-After-Settle"] = "inchideModal" + return resp finally: conn.close() @@ -2499,6 +2667,82 @@ async def web_mapare_operatie( conn.close() +@router.post("/_import/{import_id}/mapare-operatii", response_class=HTMLResponse) +async def web_mapare_operatii( + request: Request, + import_id: int, +) -> HTMLResponse: + """Un singur POST salveaza toate maparile de operatii (US-004). + + Primeste perechi (cod_op_service, cod_prestatie) ca liste paralele din un singur +
cu un select per operatie. Apeleaza save_mapping pentru fiecare pereche cu + cod ales (reuse EXACT, fara logica noua). Perechile cu cod_prestatie gol sunt ignorate. + D#12: per-item — cod invalid -> skip + sumar, restul salvate. O singura recompute + _web_compute_preview + re-randare #import-section la final. + CSRF + scoped sesiune (404 cross-account via _web_compute_preview) + guard committed 409. + """ + account_id = require_login(request) + conn = get_connection() + try: + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + + # Guard batch committed (409) + acct = account_or_default(account_id) + batch = conn.execute( + "SELECT id, status FROM import_batches WHERE id=? AND account_id=?", + (import_id, acct), + ).fetchone() + if not batch: + raise HTTPException(status_code=404, detail="batch de import inexistent sau inaccesibil") + if batch["status"] == "committed": + raise HTTPException(status_code=409, detail="batch deja comis; maparea nu mai are efect") + + # Extrage listele paralele din form (getlist pentru valori multiple cu acelasi name) + ops_list = form.getlist("cod_op_service") + codes_list = form.getlist("cod_prestatie") + + salvate: list[str] = [] + sarite_invalide: list[str] = [] + + for cod_op_service, cod_prestatie in zip(ops_list, codes_list): + cod_op_service = str(cod_op_service or "").strip() + cod_prestatie = str(cod_prestatie or "").strip().upper() + + # Ignora perechile fara cod ales (selectul ramas pe "— alege cod RAR —") + if not cod_op_service or not cod_prestatie: + continue + + # Validare per-item (D#12): cod invalid -> skip + sumar, nu all-or-nothing + exists = conn.execute( + "SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,) + ).fetchone() + if not exists: + sarite_invalide.append(f"{cod_op_service} ({cod_prestatie} necunoscut)") + continue + + save_mapping(conn, account_id, cod_op_service, cod_prestatie, auto_send=False) + salvate.append(f"{cod_op_service} -> {cod_prestatie}") + + # Compune mesajul sumar + parts: list[str] = [] + if salvate: + parts.append(f"Salvate: {', '.join(salvate)}.") + if sarite_invalide: + parts.append(f"Coduri necunoscute ignorate: {', '.join(sarite_invalide)}.") + message = " ".join(parts) if parts else None + error = bool(sarite_invalide) and not salvate + + result = _web_compute_preview(conn, import_id, account_id) + if isinstance(result, str): + return templates.TemplateResponse("_upload.html", _ctx(request, error=result)) + return templates.TemplateResponse("_preview_import.html", _ctx( + request, import_id=import_id, message=message, error=error, **result + )) + finally: + conn.close() + + @router.get("/_import/reset", response_class=HTMLResponse) def web_import_reset(request: Request) -> HTMLResponse: """Reseteaza sectiunea de import la starea initiala (drop zone gol).""" @@ -2532,14 +2776,11 @@ async def web_confirma_import( except (ValueError, TypeError): n_confirmat = 0 - # Randuri needs_review bifate explicit - reviewed_rows: set[int] = set() - for v in form.getlist("reviewed_rows"): - if isinstance(v, str): - try: - reviewed_rows.add(int(v)) - except (ValueError, TypeError): - pass + # US-007: reviewed_rows (checkboxe vechi) NU mai este sursa de adevar pentru gate-ul + # de commit pe canalul web. Gate-ul este derivat din DB import_rows.reviewed (D#8). + # Randurile needs_review confirmate de operator via /confirma-review au resolved_status='ok' + # in DB (recalculat de _web_compute_preview), asa ca interogarea de mai jos include corect + # TOATE randurile gata de trimis. confirmed_by = str(form.get("confirmed_by") or "").strip() or None @@ -2559,10 +2800,19 @@ async def web_confirma_import( request, message="Acest batch a fost deja comis." )) - # Incarca randurile cu stare ok si needs_review + # Incarca DOAR randurile ok din DB. + # D#8 (PRD 5.12): gate derivat din DB reviewed — randurile needs_review confirmate + # de operator via /confirma-review au resolved_status='ok' (recalculat de + # _web_compute_preview in calea /confirma-review). Randurile needs_review + # neconfirmate sunt excluse (nu au reviewed=1 => raman needs_review in DB). + # Fallback defensiv: includes si needs_review cu reviewed=1 (daca DB a ramas + # neactualizat din vreun motiv — ex. restart intre confirma-review si preview refresh). ok_rows_db = conn.execute( - "SELECT row_index, raw_json, override_json, resolved_status FROM import_rows " - "WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index", + "SELECT row_index, raw_json, override_json, resolved_status, reviewed " + "FROM import_rows " + "WHERE batch_id=? AND (resolved_status='ok' OR " + "(resolved_status='needs_review' AND reviewed=1)) " + "ORDER BY row_index", (import_id,), ).fetchall() @@ -2584,28 +2834,18 @@ async def web_confirma_import( # Decripteaza si construieste lista de randuri de trimis to_enqueue: list[dict[str, Any]] = [] - review_indices: set[int] = set() for r in ok_rows_db: try: row_data = decrypt_creds(r["raw_json"]) or {} except Exception: continue - if r["resolved_status"] == "ok": - to_enqueue.append({"row_index": r["row_index"], "data": row_data, - "override": _override_of(r), "status": "ok"}) - elif r["resolved_status"] == "needs_review": - review_indices.add(r["row_index"]) - - # Adauga randurile needs_review bifate explicit - for r in ok_rows_db: - if r["resolved_status"] == "needs_review" and r["row_index"] in reviewed_rows: - try: - row_data = decrypt_creds(r["raw_json"]) or {} - to_enqueue.append({"row_index": r["row_index"], "data": row_data, - "override": _override_of(r), "status": "needs_review"}) - except Exception: - pass + to_enqueue.append({ + "row_index": r["row_index"], + "data": row_data, + "override": _override_of(r), + "status": r["resolved_status"], + }) n_total_ok = len(to_enqueue) @@ -2816,6 +3056,9 @@ def _render_cont( creds_mesaj: str | None = None, creds_eroare: str | None = None, rot_eroare: str | None = None, + account_meta: dict | None = None, + date_firma_mesaj: str | None = None, + date_firma_eroare: str | None = None, ) -> HTMLResponse: """Randeaza cardul 'Contul meu'. Parola niciodata in value=.""" return templates.TemplateResponse( @@ -2827,22 +3070,41 @@ def _render_cont( creds_mesaj=creds_mesaj, creds_eroare=creds_eroare, rot_eroare=rot_eroare, + account_meta=account_meta or {}, + date_firma_mesaj=date_firma_mesaj, + date_firma_eroare=date_firma_eroare, ), ) +def _fetch_account_meta(conn, acct: int) -> dict: + """Intoarce metadatele contului (id, name, cui, email) pentru sectiunea 'Date firma'.""" + row = conn.execute( + "SELECT id, name, cui, email FROM accounts WHERE id=?", (acct,) + ).fetchone() + if not row: + return {"id": acct, "name": "", "cui": "", "email": ""} + return { + "id": row["id"], + "name": row["name"] or "", + "cui": row["cui"] or "", + "email": row["email"] or "", + } + + @router.get("/_fragments/cont", response_class=HTMLResponse) def fragment_cont(request: Request) -> HTMLResponse: - """Fragment HTMX card 'Contul meu': stare cheie + creds RAR (fara a le expune).""" + """Fragment HTMX card 'Contul meu': stare cheie + creds RAR + date firma.""" account_id = require_login(request) acct = account_or_default(account_id) conn = get_connection() try: row = conn.execute( - "SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,) + "SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,) ).fetchone() are_creds = bool(row and row["rar_creds_enc"]) - return _render_cont(request, are_creds=are_creds) + account_meta = _fetch_account_meta(conn, acct) + return _render_cont(request, are_creds=are_creds, account_meta=account_meta) finally: conn.close() @@ -2863,7 +3125,149 @@ def cont_roteste_cheie( "SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,) ).fetchone() are_creds = bool(row and row["rar_creds_enc"]) - return _render_cont(request, api_key=new_key, are_creds=are_creds) + account_meta = _fetch_account_meta(conn, acct) + return _render_cont(request, api_key=new_key, are_creds=are_creds, account_meta=account_meta) + finally: + conn.close() + + +@router.post("/cont/date-firma", response_class=HTMLResponse) +async def cont_date_firma(request: Request) -> HTMLResponse: + """Actualizeaza datele firmei (companie, email, CUI) pentru contul din sesiune. + + Valideaza campurile (reuse _norm_cui / _norm_email din accounts.py), verifica + unicitatea CUI-ului, actualizeaza accounts.name/email/cui. CSRF enforce. + Scoped pe contul sesiunii (nu poate atinge alt cont). + """ + from ..accounts import _norm_cui, _norm_email + + account_id = require_login(request) + form = await request.form() + verify_csrf(request, str(form.get("csrf_token") or "")) + acct = account_or_default(account_id) + + companie_raw = str(form.get("companie") or "").strip() + email_raw = str(form.get("email") or "") + cui_raw = str(form.get("cui") or "") + + # Validare companie + if not companie_raw: + conn = get_connection() + try: + account_meta = _fetch_account_meta(conn, acct) + row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone() + are_creds = bool(row_cr and row_cr["rar_creds_enc"]) + finally: + conn.close() + return _render_cont( + request, + are_creds=are_creds, + account_meta=account_meta, + date_firma_eroare="Compania (numele firmei) este obligatorie.", + ) + + # Normalizare si validare email + try: + email_norm = _norm_email(email_raw) + except ValueError as exc: + conn = get_connection() + try: + account_meta = _fetch_account_meta(conn, acct) + row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone() + are_creds = bool(row_cr and row_cr["rar_creds_enc"]) + finally: + conn.close() + return _render_cont( + request, + are_creds=are_creds, + account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw}, + date_firma_eroare=f"Email invalid: {exc}", + ) + + if not email_norm: + conn = get_connection() + try: + account_meta = _fetch_account_meta(conn, acct) + row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone() + are_creds = bool(row_cr and row_cr["rar_creds_enc"]) + finally: + conn.close() + return _render_cont( + request, + are_creds=are_creds, + account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw}, + date_firma_eroare="Email-ul de contact este obligatoriu.", + ) + + # Normalizare si validare CUI + try: + cui_norm = _norm_cui(cui_raw) + except ValueError as exc: + conn = get_connection() + try: + account_meta = _fetch_account_meta(conn, acct) + row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone() + are_creds = bool(row_cr and row_cr["rar_creds_enc"]) + finally: + conn.close() + return _render_cont( + request, + are_creds=are_creds, + account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw}, + date_firma_eroare=f"CUI invalid: {exc}", + ) + + if not cui_norm: + conn = get_connection() + try: + account_meta = _fetch_account_meta(conn, acct) + row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone() + are_creds = bool(row_cr and row_cr["rar_creds_enc"]) + finally: + conn.close() + return _render_cont( + request, + are_creds=are_creds, + account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw}, + date_firma_eroare="CUI-ul firmei este obligatoriu.", + ) + + # Actualizare in DB + conn = get_connection() + try: + try: + conn.execute( + "UPDATE accounts SET name=?, email=?, cui=? WHERE id=?", + (companie_raw, email_norm, cui_norm, acct), + ) + except sqlite3.IntegrityError: + # CUI duplicat (index partial unic ux_accounts_cui) + existing = conn.execute( + "SELECT id FROM accounts WHERE cui=? AND id!=?", (cui_norm, acct) + ).fetchone() + owner = existing["id"] if existing else "?" + account_meta = _fetch_account_meta(conn, acct) + row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone() + are_creds = bool(row_cr and row_cr["rar_creds_enc"]) + return _render_cont( + request, + are_creds=are_creds, + account_meta={"name": companie_raw, "email": email_norm, "cui": cui_norm}, + date_firma_eroare=( + f"CUI-ul {cui_norm} este deja folosit de alt cont (id={owner}). " + "Foloseste un CUI diferit sau contacteaza administratorul." + ), + ) + + account_meta = _fetch_account_meta(conn, acct) + row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone() + are_creds = bool(row_cr and row_cr["rar_creds_enc"]) + return _render_cont( + request, + are_creds=are_creds, + account_meta=account_meta, + date_firma_mesaj="Datele firmei au fost salvate.", + ) finally: conn.close() @@ -2949,12 +3353,14 @@ def cont_rar_creds( "SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,) ).fetchone() are_creds = bool(row and row["rar_creds_enc"]) + account_meta = _fetch_account_meta(conn, acct) finally: conn.close() return _render_cont( request, are_creds=are_creds, creds_eroare="Email si parola sunt obligatorii.", + account_meta=account_meta, ) enc = encrypt_creds({"email": email, "password": parola}) @@ -2964,10 +3370,12 @@ def cont_rar_creds( "UPDATE accounts SET rar_creds_enc=? WHERE id=?", (enc, acct), ) + account_meta = _fetch_account_meta(conn, acct) return _render_cont( request, are_creds=True, creds_mesaj="Credentialele RAR au fost salvate cu succes.", + account_meta=account_meta, ) finally: conn.close() diff --git a/app/web/templates/_acasa.html b/app/web/templates/_acasa.html index 302188c..5220a32 100644 --- a/app/web/templates/_acasa.html +++ b/app/web/templates/_acasa.html @@ -1,5 +1,18 @@
+ {# === Banner ne-blocant: cont incomplet (US-002) === + Apare cand accounts.name / email / CUI sunt necompletate (conturi legacy sau create din CLI). + NU blocheaza importul sau uploadul — doar orienteaza operatorul sa completeze datele. + Dispare automat dupa ce contul devine complet (re-render la urmatoarea navigare/reload). + #} + {% if cont_incomplet %} + + {% endif %} + {# === Container colapsabil: stepper + upload intr-un singur element
(US-006). Serverul seteaza atributul `open` din are_trimiteri: are_trimiteri=False (first-run) → open (importul e vizibil imediat, fara JS) diff --git a/app/web/templates/_cont.html b/app/web/templates/_cont.html index e581ff9..e3a8181 100644 --- a/app/web/templates/_cont.html +++ b/app/web/templates/_cont.html @@ -1,6 +1,47 @@

Contul meu

+ +
+

Date firma

+ + {% if date_firma_mesaj %} +
{{ date_firma_mesaj }}
+ {% endif %} + + {% if date_firma_eroare %} + + {% endif %} + + + +

+
+ +

+

+
+ +

+

+
+ +

+ + +
+

Cheia mea API

diff --git a/app/web/templates/_editare_preview_modal.html b/app/web/templates/_editare_preview_modal.html new file mode 100644 index 0000000..363edc9 --- /dev/null +++ b/app/web/templates/_editare_preview_modal.html @@ -0,0 +1,78 @@ +{# _editare_preview_modal.html — fragment de editare rand preview in modalul global. + US-006 (PRD 5.12): swap-uit in #detaliu-modal-body de butonul Editeaza din preview. + US-007 (PRD 5.12): butonul 'Confirma valorile' apare DOAR pe randurile needs_review + (T2): trimite CSRF POST la /confirma-review, inchide modalul via HX-Trigger-After-Settle. + + Necesita din context: + import_id — id batch import + row_index — index rand (0-based) + csrf_token — token CSRF + vin — VIN pentru titlu + stare_css — clasa CSS pill (ex. "s-ok") + stare_eticheta — text pill (ex. "Gata de trimis") + message — mesaj de eroare general (None daca nu e) + is_needs_review — True daca randul e in starea needs_review (afiseaza butonul Confirma) + + variabilele pentru _form_editare.html: + form_nr, form_vin, form_data, form_odo_final, form_odo_initial + err_map, fix_map, vin_context, btn_label +#} +
+ + {# Header cu heading accesibil (aria-labelledby al dialogului) #} +
+

+ Editare rand {{ row_index + 1 }} + {% if vin %}· {{ vin }}{% endif %} +

+ {{ stare_eticheta }} +
+ + {% if message %} + + {% endif %} + +
+ + + + + {% include "_form_editare.html" %} + +
+ +
+
+ + {% if is_needs_review %} + {# T2 (US-007): Butonul 'Confirma valorile' apare DOAR pe randurile needs_review. + POST separat (form propriu) la /confirma-review cu CSRF. Raspunsul inchide + modalul via HX-Trigger-After-Settle: inchideModal + swap OOB randul si countorii. #} +
+ +

+ Valorile sunt corecte si doriesti sa includi acest rand la trimitere la RAR? +

+ +
+ {% endif %} +
diff --git a/app/web/templates/_form_editare.html b/app/web/templates/_form_editare.html new file mode 100644 index 0000000..a7264f6 --- /dev/null +++ b/app/web/templates/_form_editare.html @@ -0,0 +1,41 @@ +{# _form_editare.html — partial partajat: campurile vehicul/data/odometru. + US-005 (PRD 5.12): extras DRY din _trimitere_detaliu.html; refolosit si de + _preview_rand.html (US-006) pentru editarea randurilor de import in modal. + + Inclus cu {% include "_form_editare.html" %} INSIDE un
element al + template-ului parinte. Acel parinte pune form-ul, CSRF-ul si orice campuri + suplimentare (ex. select cod_prestatie din _trimitere_detaliu.html). + + Necesita din context (setate de parinte inainte de include): + form_nr — valoare curenta nr_inmatriculare + form_vin — valoare curenta vin + form_data — valoare curenta data_prestatie (YYYY-MM-DD sau brut) + form_odo_final — valoare curenta odometru_final + form_odo_initial — valoare curenta odometru_initial + err_map — dict {field_name: mesaj_eroare} (poate fi {}) + fix_map — dict {field_name: hint_fix} (poate fi {}) + vin_context — string VIN pentru aria-label (poate fi '') + btn_label — eticheta butonului primar (ex. 'Salveaza si retrimite') +#} +{% from "_macros.html" import camp %} + +{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #} +{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr, + err_map=err_map, fix_map=fix_map, vin_context=vin_context) }} +{{ camp('vin', 'VIN (serie sasiu)', form_vin, + err_map=err_map, fix_map=fix_map, vin_context=vin_context) }} + +{# Restul campurilor in grila responsiva existenta. #} +
+ {{ camp('data_prestatie', 'Data prestatie', form_data, tip='date', + err_map=err_map, fix_map=fix_map, vin_context=vin_context) }} + {{ camp('odometru_final', 'Odometru final', form_odo_final, + err_map=err_map, fix_map=fix_map, vin_context=vin_context) }} + {{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial, + err_map=err_map, fix_map=fix_map, vin_context=vin_context) }} +
+ +{# Buton primar parametrizat. #} +
+ +
diff --git a/app/web/templates/_macros.html b/app/web/templates/_macros.html index 0e4ae2a..793e6d0 100644 --- a/app/web/templates/_macros.html +++ b/app/web/templates/_macros.html @@ -4,3 +4,49 @@ Simbolul pastrat (apelat in _mapari.html, _preview_import.html, _trimitere_detaliu.html) dar intoarce string gol. Coloanele DB raman (default 1, ne-citite pentru hold). #} {% macro autosend_toggle(form_id='', checked=True, label='') -%}{%- endmacro %} + +{# US-005 (PRD 5.12): macro `camp` partajat — extras din _trimitere_detaliu.html si + _preview_rand.html. Suporta tip='date' (calendar nativ, D#10/R3) si tip='text' (default). + + Parametri: + nome — name="" al input-ului (si cheie in err_map/fix_map) + eticheta — text pentru label + valoare — valoarea curenta (pre-fill) + tip — type="" al input-ului: 'text' (default) sau 'date' (calendar nativ) + err_map — dict {field_name: mesaj_eroare}; default {} + fix_map — dict {field_name: hint_fix}; default {} + vin_context — string VIN pentru aria-label cu context (default '') + id_prefix — prefix pentru id="" al input-ului (default 'c'; preview poate folosi 'e-N') +#} +{% macro camp(nome, eticheta, valoare, tip='text', err_map={}, fix_map={}, vin_context='', id_prefix='c') %} +
+ + {% if tip == 'date' %} + {# D#10/R3: degradare grijulie pentru valori ne-YYYY-MM-DD. + Daca valoarea nu e in formatul corect, inputul ramane gol + hint + hidden cu valoarea bruta + (ca sa nu se piarda tacut la submit). #} + {%- set _dp_ok = (valoare and valoare|length == 10 and valoare[4:5] == '-' and valoare[7:8] == '-') -%} + + {% if not _dp_ok and valoare %} + + Valoarea originala: {{ valoare }} + {% endif %} + {% else %} + + {% endif %} + {% if err_map.get(nome) %} +
{{ err_map.get(nome) }}
+ {% endif %} + {% if fix_map.get(nome) %} + {{ fix_map.get(nome) }} + {% endif %} +
+{% endmacro %} diff --git a/app/web/templates/_mapcoloane.html b/app/web/templates/_mapcoloane.html index 4b89465..940bf92 100644 --- a/app/web/templates/_mapcoloane.html +++ b/app/web/templates/_mapcoloane.html @@ -1,6 +1,8 @@
{% set pas = 2 %}{% include '_stepper.html' %} {% from '_eroare.html' import card_erori %} + {# prima_inregistrare poate veni din context (web_upload_import) sau derivat din sample_rows #} + {%- set prima_inreg = prima_inregistrare if prima_inregistrare is defined else (sample_rows[0] if sample_rows else none) -%}

Mapare coloane — @@ -23,6 +25,44 @@ Maparea se retine automat pentru fisiere cu acelasi antet.

+ {# Tabel orizontal preview: antet + prima inregistrare (US-003) #} +
+ + + + {% for col in columns %} + + {% endfor %} + + + + {% if prima_inreg %} + + {% for col in columns %} + {%- set val = prima_inreg.get(col, '') | string -%} + + {% endfor %} + + {% else %} + + + + {% endif %} + +
+ {{ col }} +
+ {{ val[:40] }}{% if val | length > 40 %}…{% endif %} +
+ Antet fara randuri de date +
+
+ @@ -87,12 +127,19 @@
+ {% if not prima_inreg %} + + Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare. + + {% else %} maparea se retine pentru fisiere cu acelasi antet + {% endif %}
diff --git a/app/web/templates/_preview_import.html b/app/web/templates/_preview_import.html index f35e70b..1d2875a 100644 --- a/app/web/templates/_preview_import.html +++ b/app/web/templates/_preview_import.html @@ -54,7 +54,8 @@ {% endfor %}

- + {% if unmapped_ops %}

Operatii de mapat la cod RAR

@@ -63,51 +64,68 @@ preselectata) si salveaza — randurile blocate trec automat in ok si maparea se retine pentru fisierele viitoare.

- {% for e in unmapped_ops %} - {%- set top = e.suggestions[0] if e.suggestions else None -%} - {%- set preselect = top.cod_prestatie if (top and top.score >= 60) else '' -%} -
+ - -
-
{{ e.cod_op_service }} - {{ e.blocked }} randuri
- {% if e.denumire and e.denumire != e.cod_op_service %} -
{{ e.denumire }}
- {% endif %} - {% if e.suggestions %} -
- sugestii: - {% for s in e.suggestions[:3] %} - {{ s.cod_prestatie }} ({{ s.score|round|int }}%){% if not loop.last %}, {% endif %} - {% endfor %} + {% for e in unmapped_ops %} + {%- set top = e.suggestions[0] if e.suggestions else None -%} + {%- set preselect = top.cod_prestatie if (top and top.score >= 60) else '' -%} +
+ +
+
{{ e.cod_op_service }} + {{ e.blocked }} randuri
+ {% if e.denumire and e.denumire != e.cod_op_service %} +
{{ e.denumire }}
+ {% endif %} + {% if e.suggestions %} +
+ sugestii: + {% for s in e.suggestions[:3] %} + {{ s.cod_prestatie }} ({{ s.score|round|int }}%){% if not loop.last %}, {% endif %} + {% endfor %} +
+ {% endif %} +
+
+
- {% endif %}
-
- -
-
- + {% endfor %} +
+
- {% endfor %}
{% endif %} + +
+ {% if summary.get('needs_review', 0) %} + + {% endif %} +
+ + US-007: 8 coloane (coloana de verificare eliminata). + Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). -->
@@ -119,7 +137,6 @@ - @@ -164,10 +181,7 @@ style="max-width:80px;" aria-describedby="n-hint"> -({{ summary.get('ok', 0) }} ok - {% if summary.get('needs_review', 0) %} - + pana la {{ summary.get('needs_review', 0) }} verificate manual - {% endif %}) +({{ summary.get('ok', 0) }} ok) @@ -226,20 +240,19 @@ return el ? parseInt(el.dataset.ok || '0', 10) : 0; } - /* Actualizeaza N si bannerul cand se bifeaza needs_review SAU cand se editeaza un rand. */ + /* Actualizeaza N dupa editare/confirmare rand (OOB). + US-007: reviewed_rows (checkboxe) eliminate; N = randurile ok din DB, + actualizate via OOB (#preview-ok-count[data-ok]) dupa /confirma-review sau /editeaza. */ function updateN() { - var checked = document.querySelectorAll('input[name="reviewed_rows"]:checked').length; - var total = getOk() + checked; + var total = getOk(); var inp = document.getElementById('n-confirmat'); var disp = document.getElementById('n-display'); var btn = document.getElementById('confirm-btn'); - /* Nu re-activa confirm cat un rand e in editare (mutual-exclusion). */ - var editing = document.querySelector('tr[data-editing="1"]') !== null; if (inp) inp.value = total; if (disp) disp.textContent = total; var hintOk = document.getElementById('n-hint-ok'); - if (hintOk) hintOk.textContent = getOk(); - if (btn) btn.disabled = (total === 0) || editing; + if (hintOk) hintOk.textContent = total; + if (btn) btn.disabled = (total === 0); } /* Filtrare randuri dupa stare. diff --git a/app/web/templates/_preview_rand.html b/app/web/templates/_preview_rand.html index 0688078..ce4e0c4 100644 --- a/app/web/templates/_preview_rand.html +++ b/app/web/templates/_preview_rand.html @@ -1,11 +1,13 @@ {# _preview_rand.html — un singur rand de preview import. - Doua moduri: - - display (editing falsy): normal cu 9 coloane in format .tabel-trimiteri. - - edit (editing truthy): (display:block) cu un singur - insusi (pentru raspunsul POST succes) + summary — dict cu contoarele per status Campuri pre-computate de _web_compute_preview (NOT din template raw): row.prez — prezentare_din_payload(resolved): vehicul_nr, vin_scurt, @@ -16,87 +18,10 @@ #} {%- set res = row.resolved -%} {%- set status = row.resolved_status -%} -{% if editing %} -{%- set err_map = {} -%} -{%- set fix_map = {} -%} -{%- for e in row.errors -%}{%- if e is mapping and e.get('field') -%}{%- set _ = err_map.update({e.field: (e.get('message') or e.get('msg'))}) -%}{%- if e.get('fix') -%}{%- set _ = fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endif -%}{%- endfor -%} - - - - -{% else %} {%- set disp_fix_map = {} -%} {%- for e in row.errors -%}{%- if e is mapping and e.get('field') and e.get('fix') -%}{%- set _ = disp_fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endfor -%} - {% if include_oob %} -{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea. #} +{# OOB: actualizeaza rezumatul, contorul, bannerul needs_review dupa save/confirma-review. #} {% set status_labels = [ ('ok','Gata de trimis'), ('needs_review','Verifica valori'), ('needs_mapping','Cod RAR lipsa'), ('needs_data','Date incomplete'), ('already_sent','Deja trimis'), ('duplicate_in_file','Duplicat in fisier')] %} @@ -177,6 +91,19 @@ {% endfor %} +{# Banner discoverability: OOB swap dupa confirmare/editare → dispare cand needs_review==0. #} +
+{% if summary.get('needs_review', 0) %} + +{% endif %} +
{% endif %} -{% endif %} diff --git a/app/web/templates/_trimitere_detaliu.html b/app/web/templates/_trimitere_detaliu.html index 71224d3..4a9f419 100644 --- a/app/web/templates/_trimitere_detaliu.html +++ b/app/web/templates/_trimitere_detaliu.html @@ -82,6 +82,12 @@ {% if editabil %} {% set err_map = {} %} {% for e in corectie_errors %}{% if e.field %}{% set _ = err_map.update({e.field: e.message}) %}{% endif %}{% endfor %} + {# fix_map gol pentru Trimiteri (fix-hints vin din preview, nu din corectii de trimitere). #} + {% set fix_map = {} %} + {# vin_context pentru aria-label cu context VIN (D#6). #} + {%- set vin_context = form_vin -%} + {# btn_label pentru butonul primar al partial-ului. #} + {%- set btn_label = 'Salveaza si retrimite' -%} {% if corectie_msg %} {% endfor %} - {% macro camp(nume, eticheta, valoare, tip='text') %} -
- - - {% if err_map.get(nume) %} -
{{ err_map.get(nume) }}
- {% endif %} -
- {% endmacro %} - {# Select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator. - Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). #} + Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). + RAMANE in _trimitere_detaliu.html (D#5 — logica specifica acestui modal). #} {% if nomenclator_rar %}
@@ -139,7 +134,8 @@ {% endif %} {# Operatie service (cod intern + denumire venita prin API/import), distinct de - operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. #} + operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. + RAMANE in _trimitere_detaliu.html (D#5). #} {% if prez.op_service_cod %}
Operatie service
@@ -147,22 +143,8 @@
{% endif %} - {# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #} - {{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }} - {{ camp('vin', 'VIN (serie sasiu)', form_vin) }} - - {# Restul campurilor in grila. #} -
- {{ camp('data_prestatie', 'Data prestatie (YYYY-MM-DD)', form_data) }} - {{ camp('odometru_final', 'Odometru final', form_odo_final) }} - {{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }} -
- - {# === Actiune primara conditionata de stare. needs_data/needs_mapping - -> „Salveaza si retrimite" pe /corecteaza. UN SINGUR buton primar per stare. === #} -
- -
+ {# === Campurile vehicul/data/odo + erori/fix + buton — partial DRY (US-005). === #} + {% include "_form_editare.html" %} {% else %} {# Context read-only pentru randuri ne-editabile (sent/sending/queued/error). #} diff --git a/app/web/templates/admin.html b/app/web/templates/admin.html index 703f1d4..a11d5c2 100644 --- a/app/web/templates/admin.html +++ b/app/web/templates/admin.html @@ -60,7 +60,13 @@ {% if v == 'delete' %}onsubmit="return confirm('Stergi acest cont? (stergere soft)');"{% endif %}> + {% if v == 'activate' and not acct.is_complete %} + + {% else %} + {% endif %} {% endfor %}
diff --git a/app/web/templates/base.html b/app/web/templates/base.html index f2baf62..2a556f7 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -345,15 +345,28 @@ @media (max-width:1024px) { .tabel-trimiteri .col-actualizat { display:none; } } + /* Tableta (768–1024px): header compact fara suprapuneri. + Grila 3-coloane se pastreaza; logo si titlu mai mici; versiunea ascunsa + (informatia secundara elibereaza spatiu in celula dreapta: comutator tema + + hamburger raman vizibili). min-height:92px din regula de baza e resetat — + inaltimea header-ului e determinata de continut, nu de un prag fix. */ + @media (min-width:768px) and (max-width:1024px) { + header { min-height:0; padding:10px 16px; gap:6px; } + .brand-logo { height:44px; } + header h1 { font-size:16px; } + /* Versiunea (ex. "v0.9.3") este informatie secundara pe tableta: + ascunsa pentru a elibera spatiu in celula dreapta. */ + .header-right > .muted { display:none; } + } /* === Preview import: coloane extra fata de tabelul Trimiteri. SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata — nu atinge coloanele existente (col-chk/id/stare/data/rar/actualizat). + US-007: 8 coloane (coloana de verificare manuala eliminata). Suma latimi fixe: col-id(48) + col-stare(104) + col-data(104) + - col-km(76) + col-note(176) + col-verificat(80) + col-actiuni(92) = 680px. - Restul (~600px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */ + col-km(76) + col-note(176) + col-actiuni(92) = 600px. + Restul (~680px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */ .tabel-trimiteri .col-km { width:76px; } .tabel-trimiteri .col-note { width:176px; } - .tabel-trimiteri .col-verificat{ width:80px; } .tabel-trimiteri .col-actiuni { width:92px; } /* Randul de editare inline iese din grila table-layout:fixed (display:block), astfel formularul nu e constrans de latimile coloanelor individuale. @@ -814,9 +827,12 @@ // Deschidere la click pe rand (htmx:beforeRequest): arata modalul cu placeholder // inainte ca raspunsul fragmentului sa fie swap-uit in corp. + // Trateaza atat .trimitere-row (Trimiteri) cat si .btn-editeaza (preview import) + // → open() instaleaza inert pe
, focus-trap si readuce focusul la inchidere (US-006). document.body.addEventListener('htmx:beforeRequest', function(evt) { var elt = evt.detail && evt.detail.elt; - if (elt && elt.classList && elt.classList.contains('trimitere-row')) open(elt); + if (!elt || !elt.classList) return; + if (elt.classList.contains('trimitere-row') || elt.classList.contains('btn-editeaza')) open(elt); }); // Dupa swap-ul fragmentului (sau re-render corectie/mapare): muta focusul in modal. body.addEventListener('htmx:afterSettle', function() { diff --git a/app/web/templates/signup.html b/app/web/templates/signup.html index 75ead6a..0b3d02a 100644 --- a/app/web/templates/signup.html +++ b/app/web/templates/signup.html @@ -50,8 +50,8 @@

-
- +
+


diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 956b661..9f2c533 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -48,7 +48,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi > PRD-uri (`docs/prd/prd-X.Y-*.md`), linkate in coloana Detalii. La fiecare livrabila terminata: > schimba statusul + data + linkul PRD si actualizeaza "Ultima actualizare". -**Ultima actualizare**: 2026-06-26 — 5.11 LIVRAT (import compact + preview in format Trimiteri + navigatie + simplificare auto_send; dogfooding baza goala). 8 stories TDD prin echipa de teammates Sonnet (lead orchestreaza, NU scrie cod; 6 runde pe valuri cu fisiere disjuncte; `base.html`/`routes.py`/`_coada.html`/`_status.html` serializate). US-001 scoate hold-ul auto_send din mapare (`has_no_auto_send`→`return False`, simbol pastrat; cod rezolvat→queued; R1 acceptat constient — rastoarna default-ul de siguranta). US-002 scoate bifa auto_send din UI. US-003 preview pas 3 = format Trimiteri (`STARI_PREVIEW`+`nota_umana_preview` in labels.py, fara repr Python/KeyError; view-model `prez`). US-004 filtre layout/stil ca referinta + buton Custom. US-005 nav Trimiteri+Mapari sub contoare pe toate paginile. US-006 import `

` nativ. US-007 post-commit reveal (OOB `_coada`+`_status` + `HX-Trigger: trimiteriChanged`). US-008 auto-refresh dupa actiuni (nudge eliminat). VERIFY context curat PASS (8/8 stories, dovezi cod+teste+randare runtime). `/code-review high` (8 unghiuri prin subagenti): 3 buguri reale reparate TDD (#status-bar pierdea tab-ul la self-refresh; pill Custom lasa valori stale; `nota_umana_preview` ascundea "Cod RAR lipsa" pe needs_mapping). Regresie **934 passed, 1 skipped, 0 failed**; smoke boot OK. E2E browser click-through + live RAR `FINALIZATA` neprobate (mediu fara browser/creds). Backend trimitere + schema NEATINSE. PRD: [prd-5.11](prd/prd-5.11-ux-import-compact-preview-navigatie.md). | 2026-06-25 — 5.10 LIVRAT (UX trimiteri: pill filtre + paginare + detaliu; Mapari in meniu; branding ROMFAST + teme). 14 stories TDD prin echipa de workeri (lead orchestreaza, 3 teammates Sonnet pe valuri cu fisiere disjuncte; routes.py si base.html serializate ca fisiere fierbinti). US-001 fix filtrare data (`_iso_date_prefix` pe garda+comparatie). US-002 op service in `payload_view` (chei distincte `op_service_cod/denumire`, conventie goala `""`). US-003 pill-uri categorii `
`+OOB contoare (nu pe sectiune), form propriu, mutual-exclusion, reuse grila `_trimitere_detaliu.html` (US-002); Mapari + formate de coloane ca tabele `.tablewrap`, H4 auto_send stocat (US-005/006); bifa "auto-send"→comutator etichetat pe COADA ("Pune automat in coada"/"Tine pentru verificare"), scoped pe operatie, `name=auto_send` pastrat (zero backend) (US-007). 523 teste pass. **VERIFY**: E2E browser pe `/` (Acasa unificata, upload slim, editare rand needs_data→ok cu swap pe rand + contoare OOB, Mapari tabelar + comutator) + LIVE pe RAR test — import fara coloana data → editarea completeaza data (override) → commit → worker login RAR test → `postPrezentare` → `sent` cu `idPrezentare=68696`, confirmat independent in lista finalizate RAR. 3 bug-uri JS (htmx 1.9.12) prinse DOAR la E2E in browser (invizibile la TestClient) si reparate: `useTemplateFragments=true` (raspunsul ``+OOB era parsat in context de tabel → `swapError` + contoare pierdute), re-activare `confirm-btn` deferita pe tick (race `editing=true` tranzitoriu), `n-hint` ok-count actualizat de `updateN`. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare) NEATINS — singura atingere de schema: 1 coloana nullable `override_json` cu migrare defensiva. PRD: [prd-3.6](prd/prd-3.6-editare-preview-acasa-unificata.md). | ISTORIC: 3.5 LIVRAT (dashboard compact). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). +**Ultima actualizare**: 2026-06-26 — 5.12 IMPLEMENTAT + VERIFY PASS (asteapta confirmare commit). Editare unificata in modal + cont cu companie/email/CUI obligatorii + rafinari import + responsive tableta/mobil. 8 stories TDD prin agent team (lead orchestreaza, NU scrie cod; teammates Sonnet pe valuri cu fisiere disjuncte; `routes.py` + `base.html` serializate ca fisiere fierbinti). Val 1: US-001 (`accounts.email` migrare defensiva + `create_account` valideaza companie/email/CUI + `account_is_complete` id=1 exceptat + signup CUI obligatoriu + mesaj prietenos CUI duplicat cu `support_email` T3 + CLI `--email`/`--cui`; Q3/D#14 factory in conftest, fara `--allow-incomplete`), US-003 (mapcoloane: cap-tabel coloane + valori prima inregistrare, degradare „antet fara randuri" D#11), US-004 (un singur „Salveaza maparile", ruta plurala `/mapare-operatii`, skip invalid+sumar D#12), US-005 (`_form_editare.html` NOU extras DRY + macro `camp` cu `tip='date'` calendar nativ + degradare ne-ISO gol+hidden raw D#10; `_trimitere_detaliu` consuma, select cod + nemapate raman D#5). Val 2: US-002 (Cont „Date firma" + `POST /cont/date-firma` scoped+CSRF + banner cont incomplet + gate Activeaza pe `account_is_complete`), US-006 (Editeaza preview -> MODAL global `#detaliu-modal-body`, ramura inline `tr.preview-edit` + script mutual-exclusion ELIMINATE, POST editeaza -> `inchideModal`+OOB; R2 submissions neatins). Val 3 (base.html serializat): US-007 (`import_rows.reviewed INTEGER DEFAULT 0` D#7 migrare defensiva; coloana „Verificat?" eliminata -> 8 coloane, VIN nowrap; gate `needs_review` mutat in modal, buton „Confirma valorile" T2 -> `reviewed=1`, banner discoverability T1, gate HARD pe ambele canale D#8, reset reviewed la editare D#9; `reviewed` marcaj separat NU in payload/override/idempotenta), US-008 (`@media` tableta 768-1024 header fara suprapuneri + modal full-screen mobil VERIFICAT D#13, tinte 44px, fara overflow). VERIFY context curat PASS (8/8 stories, dovezi cod+teste; E2E browser Playwright pe 9 scenarii inclusiv responsive 390/820/1280) + 1 FAIL prins si remediat TDD (signup.html eticheta CUI „(optional)" + input fara `required` contrazicea AC US-001 -> `*`+`required`, test lock). `/code-review high` (8 unghiuri subagenti + verificare cod): 3 buguri reale reparate TDD — (1) HIGH `confirma-review` cu `hx-swap="none"` suprima `updateN()` -> `n_confirmat` stale -> commit 422 la prima incercare (aliniat la `hx-swap=innerHTML` ca /editeaza); (2) MEDIUM email duplicat la signup arata mesaj CUI gresit (`"deja folosit"` prindea si eroarea `create_user` -> detectie email-dup intai); (3) MEDIUM a11y butonul Editeaza preview ocolea `open()` (fara inert/focus-trap -> handler global trateaza si `.btn-editeaza`). Debt notat (neblocant): API preview re-deriva needs_review cross-channel, mesaje dead-code camp gol, `zip()` truncheaza POST inegal, id cont in mesaj CUI-dup, duplicari cleanup. Regresie **987 passed, 1 skipped, 0 failed** (baseline 934 -> +53 teste). E2E live RAR `FINALIZATA` neprobat (opt-in indisponibil). Backend trimitere (worker/masina stari/idempotenta/`build_key`/contract RAR/canal API) + `mapping.resolve_prestatii`/`validation.py` NEATINSE (confirmat `git diff --stat`); atingeri schema doar aditive (2 coloane nullable/default, migrare defensiva). PRD: [prd-5.12](prd/prd-5.12-editare-modal-cont-obligatoriu-import.md). | 2026-06-26 — 5.11 LIVRAT (import compact + preview in format Trimiteri + navigatie + simplificare auto_send; dogfooding baza goala). 8 stories TDD prin echipa de teammates Sonnet (lead orchestreaza, NU scrie cod; 6 runde pe valuri cu fisiere disjuncte; `base.html`/`routes.py`/`_coada.html`/`_status.html` serializate). US-001 scoate hold-ul auto_send din mapare (`has_no_auto_send`→`return False`, simbol pastrat; cod rezolvat→queued; R1 acceptat constient — rastoarna default-ul de siguranta). US-002 scoate bifa auto_send din UI. US-003 preview pas 3 = format Trimiteri (`STARI_PREVIEW`+`nota_umana_preview` in labels.py, fara repr Python/KeyError; view-model `prez`). US-004 filtre layout/stil ca referinta + buton Custom. US-005 nav Trimiteri+Mapari sub contoare pe toate paginile. US-006 import `
` nativ. US-007 post-commit reveal (OOB `_coada`+`_status` + `HX-Trigger: trimiteriChanged`). US-008 auto-refresh dupa actiuni (nudge eliminat). VERIFY context curat PASS (8/8 stories, dovezi cod+teste+randare runtime). `/code-review high` (8 unghiuri prin subagenti): 3 buguri reale reparate TDD (#status-bar pierdea tab-ul la self-refresh; pill Custom lasa valori stale; `nota_umana_preview` ascundea "Cod RAR lipsa" pe needs_mapping). Regresie **934 passed, 1 skipped, 0 failed**; smoke boot OK. E2E browser click-through + live RAR `FINALIZATA` neprobate (mediu fara browser/creds). Backend trimitere + schema NEATINSE. PRD: [prd-5.11](prd/prd-5.11-ux-import-compact-preview-navigatie.md). | 2026-06-25 — 5.10 LIVRAT (UX trimiteri: pill filtre + paginare + detaliu; Mapari in meniu; branding ROMFAST + teme). 14 stories TDD prin echipa de workeri (lead orchestreaza, 3 teammates Sonnet pe valuri cu fisiere disjuncte; routes.py si base.html serializate ca fisiere fierbinti). US-001 fix filtrare data (`_iso_date_prefix` pe garda+comparatie). US-002 op service in `payload_view` (chei distincte `op_service_cod/denumire`, conventie goala `""`). US-003 pill-uri categorii `
`+OOB contoare (nu pe sectiune), form propriu, mutual-exclusion, reuse grila `_trimitere_detaliu.html` (US-002); Mapari + formate de coloane ca tabele `.tablewrap`, H4 auto_send stocat (US-005/006); bifa "auto-send"→comutator etichetat pe COADA ("Pune automat in coada"/"Tine pentru verificare"), scoped pe operatie, `name=auto_send` pastrat (zero backend) (US-007). 523 teste pass. **VERIFY**: E2E browser pe `/` (Acasa unificata, upload slim, editare rand needs_data→ok cu swap pe rand + contoare OOB, Mapari tabelar + comutator) + LIVE pe RAR test — import fara coloana data → editarea completeaza data (override) → commit → worker login RAR test → `postPrezentare` → `sent` cu `idPrezentare=68696`, confirmat independent in lista finalizate RAR. 3 bug-uri JS (htmx 1.9.12) prinse DOAR la E2E in browser (invizibile la TestClient) si reparate: `useTemplateFragments=true` (raspunsul ``+OOB era parsat in context de tabel → `swapError` + contoare pierdute), re-activare `confirm-btn` deferita pe tick (race `editing=true` tranzitoriu), `n-hint` ok-count actualizat de `updateN`. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare) NEATINS — singura atingere de schema: 1 coloana nullable `override_json` cu migrare defensiva. PRD: [prd-3.6](prd/prd-3.6-editare-preview-acasa-unificata.md). | ISTORIC: 3.5 LIVRAT (dashboard compact). 11 stories in 4 valuri, TDD. US-001 bara status compacta pe 2 randuri cu bife accesibile (glife ✓/✗ + text, nu doar culoare) + `format_data_rar` (dd.mm.yyyy hh24:mi:ss, helper pur). US-002 Acasa = ecranul de import (upload dominant inline, tab Import scos, `?tab=import`→Acasa fara 404). US-003 helper pur partajat `app/payload_view.py` (payload→campuri afisabile, defensiv, coercion Excel) refolosit si de `GET /v1/prezentari` (DRY). US-004 "Coada"→"Trimiteri": coloane RO + stare umana + detaliu complet la click in panou dedicat `#trimitere-detaliu` (nu inline — poll 10s), scoped 404 cross-account. US-005/006 CRUD mapari operatii + formate coloane salvate (scoped, re-rezolvare auto la edit cod). US-007 "Mapari" 3 sectiuni (de rezolvat / op salvate / formate coloane), "Cont" doar cheie+creds. US-008 motiv (mesaj validare) pe randuri needs_data in preview. US-009 filtre Trimiteri (stare SQL / vehicul+data Python) scoped + "sterge filtrele". US-010 corectie inline needs_data→queued cu payload+idempotency recalculate, sent read-only (403), coliziune idempotency prinsa pre-UPDATE. US-011 badge contoare pe tab-uri (Mapari/Trimiteri), scoped, aria-label. VERIFY context curat PASS (483 teste; E2E browser/RAR LIVE neprobat — recomandata probare manuala `--send`). `/code-review` high a prins 4 findings reale, toate reparate: corectie needs_mapping re-rezolva prestatiile (nu mai poate trimite cod nul la RAR), filtru fara LIMIT silentios, coliziune idempotency atomica (try/except IntegrityError), comparatie data doar ISO. Backend trimitere (worker, masina stari, idempotenta-logica, mapping-rezolvare, schema) NEATINS. PRD: [prd-3.5](prd/prd-3.5-dashboard-compact-trimiteri-mapari.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). > 2026-06-18 — 3.4 LIVRAT (interfata web ergonomica: tab-uri + wizard + microcopy). US-001 modul pur `app/web/labels.py` (stari tehnice→text uman + clasa CSS; test parametrizat din CHECK-ul `schema.sql` iese rosu la stare nemapata). US-002 bara status `/_fragments/status` + `_status.html` (etichete umane, defalcare blocate pe motiv, poll 15s, scoped pe cont). US-003 shell 6 tab-uri (Acasa·Import·Coada·Mapari·Cont·Nomenclator) cu deep-link `?tab=`, panou activ randat server-side, fragmente inactive lazy pe click, ARIA real (tablist/tab/tabpanel + aria-selected + navigare cu sageti). US-004 stepper import 4 pasi (PUR vizual, `hx-target="#import-section"` + csrf pastrate). US-005 Acasa onboarding checklist auto-bifat (are_creds/are_trimiteri) + colaps cand totul gata + empty states prietenoase Coada/Mapari. VERIFY lead-driven (TestClient ACs + 434 pytest pass; E2E browser/RAR LIVE neprobat in sesiune — recomandata probare manuala `--send`). Fix izolare teste (reset `ratelimit._hits` in fixturi, 429 la rulare subset). `/code-review` high: regasit avertisment "cont in asteptare de activare" (regresie din scoaterea `/_fragments/banner`) re-introdus in bara status + culori hardcodate→variabile paleta. 434 teste pass. Backend trimitere neatins. PRD: [prd-3.4](prd/prd-3.4-ux-dashboard-web.md). Urmeaza Etapa 4 (4.1 mapare AI/MCP). @@ -106,6 +106,7 @@ Reguli de contract (detalii in `docs/api-rar-contract.md`): `FINALIZATA` e termi | 5.7 | Raspuns API onest la blocaje (`erori`/`nemapate`/`motiv` pe orice status != `queued`) + mapare inline din panoul de detaliu trimitere | DONE | 2026-06-23 | Raportat din client VFP: `POST /v1/prezentari` raspundea `submission_id`+`status` fara motiv pe randuri blocate (`erori` doar pe `on_unmapped_error=True`) → `needs_data`/`needs_mapping` parea succes. 3 stories TDD. US-001 (backend API): `SubmissionResult` += `nemapate`+`motiv` (ADITIV), `create_prezentari` populeaza `erori`/`nemapate`/`motiv` pe enqueue + respins + reactivare via helperele `_rezultat_enqueue`/`_rezultat_respins`/`_motiv_clasificare`; `on_unmapped_error=True` pastreaza `erori`=COD_NEMAPAT (compat). US-002 (web): ruta `POST /trimitere/{id}/mapeaza` (reuse EXACT `save_mapping`+`reresolve_account`, scoped sesiune 404 + CSRF, re-rezolva pe `batch_id`-ul randului) + `_nemapate_pentru_submission` + context in `_detaliu_ctx`. US-003 (UI): sectiune "Mapeaza codul operatiei" in `_trimitere_detaliu.html` (selector cod RAR cu sugestie fuzzy preselectata >=60, `ui.autosend_toggle`), doar pe operatii nemapate reale. `/code-review high`: 2 buguri reale reparate (reactivarea omitea `erori`/`nemapate`/`motiv`; dublu `load_nomenclator`), restul infirmate. `pytest -q` **765 passed, 0 failed** (+1 skipped live). **Live RAR `--send` PROBAT (2026-06-23)**: mapare inline in browser → `queued` → worker → `sent idPrezentare=68827` (confirmat independent in finalizate RAR + jurnal `app_events`); automatizat ca test live opt-in `tests/test_live_rar.py` (skip implicit; `AUTOPASS_LIVE_RAR=1` + creds test → reproduce tot lantul, `idPrezentare=68828`). Backend trimitere (worker/masina stari/idempotenta) si schema NEATINSE. PRD: [prd-5.7](prd/prd-5.7-raspuns-onest-mapare-inline.md) | | 5.10 | UX trimiteri (pill filtre + paginare numerotata + VIN sub nr + editare op RAR + op service in detaliu + eroare simpla) + Mapari in meniu hamburger (pagina consolidata + butoane icon/dirty) + branding ROMFAST (header `by ROMFAST` + paleta azur + IBM Plex + selector tema ciclic Light/Dark/Petrol/Auto) | DONE | 2026-06-25 | 14 stories TDD prin echipa (lead orchestreaza, 3 teammates Sonnet pe valuri cu fisiere disjuncte; `routes.py` + `base.html` serializate). US-001 `_iso_date_prefix` (garda+comparatie, fix filtrare data cu ora). US-002 chei distincte `op_service_cod/denumire` in `payload_view` (conventie goala `""`). US-003 pill-uri `
Data KM final NoteVerificat? Actiuni
ce contine un FORM PROPRIU (NU #confirm-form). Escapa grila table-layout:fixed. - Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section. - La save, optional OOB pe rezumat + contor "gata de trimis" (include_oob). + US-006 (PRD 5.12): editarea inline (tr.preview-edit + mutual-exclusion script) + a fost eliminata. Butonul Editeaza deschide MODALUL global (#detaliu-modal-body). + + Parametri: + editing — ELIMINAT (ignorat, pastrat pentru compatibilitate apeluri vechi) + include_oob — True: randeaza OOB rezumat + contor + script recalc (swap dupa save) + oob_tr — True: adauga hx-swap-oob pe
-
- - -
- Editare rand {{ row.row_index + 1 }} - {{ row.stare_eticheta }} -
- - {% if message %} - - {% endif %} - - - {% macro camp(nume, eticheta, valoare, tip='text') %} -
- - - {% if err_map.get(nume) %} -
{{ err_map.get(nume) }}
- {% endif %} - {% if fix_map.get(nume) %} - {{ fix_map.get(nume) }} - {% endif %} -
- {% endmacro %} - -
- {{ camp('nr_inmatriculare', 'Numar inmatriculare', res.get('nr_inmatriculare')) }} - {{ camp('vin', 'VIN (serie sasiu)', res.get('vin')) }} - {{ camp('data_prestatie', 'Data prestatie (YYYY-MM-DD)', res.get('data_prestatie')) }} - {{ camp('odometru_final', 'Odometru final', res.get('odometru_final')) }} - {{ camp('odometru_initial', 'Odometru initial (daca e cerut)', res.get('odometru_initial')) }} -
- -
- - - - se salveaza… - -
-
-
{{ row.row_index + 1 }} @@ -105,7 +30,7 @@ {{ row.prez.vehicul_nr }} {% if row.prez.vin_scurt and row.prez.vin_scurt != '—' %} -
{{ row.prez.vin_scurt }}
+
{{ row.prez.vin_scurt }}
{% endif %} {# Fix-uri de validare pe vehicul #} {% if disp_fix_map.get('vin') %}{{ disp_fix_map.get('vin') }}{% endif %} @@ -140,24 +65,13 @@ {{ row.nota_umana or '' }} {% endif %}
- {% if status == 'needs_review' %} - - {% endif %} - {% if status not in ('already_sent', 'duplicate_in_file') %} @@ -165,7 +79,7 @@