diff --git a/app/web/routes.py b/app/web/routes.py index d2a16c8..91dcb09 100644 --- a/app/web/routes.py +++ b/app/web/routes.py @@ -20,7 +20,7 @@ from pathlib import Path from typing import Any from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.templating import Jinja2Templates from .. import __version__ @@ -113,6 +113,18 @@ def _status_counts(conn, account_id: int) -> dict[str, int]: return {r["status"]: int(r["n"]) for r in rows} +def _trimiteri_versiune(conn, account_id: int) -> str: + """Semnatura ieftina a starii trimiterilor contului: numar randuri + cel mai recent + updated_at. Se schimba la orice insert/update/delete -> nudge-ul "Date noi" o compara + fara a re-randa tabelul.""" + row = conn.execute( + "SELECT COUNT(*) AS n, COALESCE(MAX(updated_at), '') AS m FROM submissions " + "WHERE (account_id = ? OR (? = 1 AND account_id IS NULL))", + (account_id, account_id), + ).fetchone() + return f"{row['n']}:{row['m']}" + + def _account_active(conn, account_id: int) -> bool: """True daca contul e activ (sau legacy cu NULL/absent active).""" row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone() @@ -196,6 +208,10 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict: "are_trimiteri": are_trimiteri, "are_cheie_folosita": are_cheie_folosita, "blocate_total": blocate_total, + # Pill-uri de filtrare a starii, randate in bara de filtre (nu in bara de status). + "pills_categorii": _pills_categorii(counts), + # Semnatura datelor: nudge-ul "Date noi" o compara la fiecare poll usor. + "versiune_trimiteri": _trimiteri_versiune(conn, account_id), # US-002: Acasa include caseta de upload -> are nevoie de csrf_token "csrf_token": get_csrf_token(request), } @@ -212,7 +228,9 @@ def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status {"request": request, "csrf_token": get_csrf_token(request)} ) ctx = _get_acasa_context(request, conn, account_id) - ctx["status_filtru"] = status + # `status or ""`: campul hidden de filtru ar randa literal "None" cu un None Python + # (Jinja `default('')` inlocuieste doar undefined), trimitand status=None la poll. + ctx["status_filtru"] = status or "" return templates.get_template("_acasa.html").render(ctx) @@ -621,6 +639,19 @@ def fragment_status(request: Request) -> HTMLResponse: conn.close() +@router.get("/_fragments/trimiteri-versiune", response_class=JSONResponse) +def fragment_trimiteri_versiune(request: Request) -> JSONResponse: + """Semnatura curenta a trimiterilor contului (JSON usor). Pollerul "Date noi" o + compara cu versiunea cu care s-a randat tabelul; daca difera, arata nudge-ul de + reincarcare — tabelul nu se mai schimba singur.""" + account_id = require_login(request) + conn = get_connection() + try: + return JSONResponse({"v": _trimiteri_versiune(conn, account_id)}) + finally: + conn.close() + + def _iso_date_prefix(value: object) -> str | None: """Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel None. @@ -795,6 +826,10 @@ def fragment_submissions( "f_vehicul": vehicul_q or "", "f_data_de": data_de or "", "f_data_pana": data_pana or "", + # Pill-uri (OOB) + stare activa + versiune pentru nudge-ul "Date noi". + "pills_categorii": _pills_categorii(_status_counts(conn, account_id)), + "status_filtru": status or "", + "versiune_trimiteri": _trimiteri_versiune(conn, account_id), }) finally: conn.close() @@ -823,6 +858,9 @@ def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse "rows": view, "filtru_activ": False, "csrf_token": get_csrf_token(request), + "pills_categorii": _pills_categorii(_status_counts(conn, account_id)), + "status_filtru": "", + "versiune_trimiteri": _trimiteri_versiune(conn, account_id), }) diff --git a/app/web/templates/_coada.html b/app/web/templates/_coada.html index 6cec9fa..5f97312 100644 --- a/app/web/templates/_coada.html +++ b/app/web/templates/_coada.html @@ -21,19 +21,16 @@ - +
- {# US-003 (PRD 5.10): dropdown status eliminat — inlocuit cu pill-uri in bara de status. - Filtrul de stare vine de la pill-uri (/_fragments/submissions?status=X direct). - Camp hidden permite reset stare la submit manual din form (Filtreaza). #} - - {# US-004 (PRD 5.10): pagina curenta — actualizata prin OOB swap din _submissions.html. - Poll-ul (hx-include="#filtre-trimiteri") include automat pagina curenta (L2 PRD). #} + style="display:flex; gap:10px 14px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;"> + + {# Pagina curenta — actualizata prin OOB swap din _submissions.html; inclusa la reincarcari. #}
@@ -48,12 +45,24 @@
+ {# Pill-uri de stare pe acelasi rand cu filtrele; re-randate prin OOB la reincarcarea tabelului. #} + + {% include '_pills.html' %} +
- + + + +
se incarca…
diff --git a/app/web/templates/_pills.html b/app/web/templates/_pills.html new file mode 100644 index 0000000..b9c0687 --- /dev/null +++ b/app/web/templates/_pills.html @@ -0,0 +1,15 @@ +{# Pill-uri de filtrare a starii, randate in bara de filtre (_coada.html) si re-randate + prin OOB la fiecare reincarcare a tabelului (_submissions.html). Stare activa = + status_filtru. "Toate" reseteaza filtrul; categoriile apar doar cand au n>0. #} + +{% for pill in pills_categorii %} + +{% endfor %} diff --git a/app/web/templates/_status.html b/app/web/templates/_status.html index 7249d09..f0ed3bc 100644 --- a/app/web/templates/_status.html +++ b/app/web/templates/_status.html @@ -1,15 +1,3 @@ -
- - {% if pills_categorii %} -
-
- Necesita atentie: - {% for pill in pills_categorii %} - - {% endfor %} - {# Buton "Toate" — reseteaza filtrul de categorie #} - -
-
- {% endif %} + {# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #} diff --git a/app/web/templates/_submissions.html b/app/web/templates/_submissions.html index 0744ac8..eee4f13 100644 --- a/app/web/templates/_submissions.html +++ b/app/web/templates/_submissions.html @@ -5,6 +5,13 @@ #} +{# OOB: re-randeaza pill-urile de stare (in bara de filtre, in afara #submissions-wrap) cu + contoarele si starea activa proaspete la fiecare reincarcare a tabelului. #} +{% include '_pills.html' %} + +{# Versiunea datelor cu care s-a randat tabelul; pollerul "Date noi" o compara. #} + + {% if rows %} {# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate (gestionabil); sent/sending/queued nu au checkbox (read-only). #} diff --git a/app/web/templates/base.html b/app/web/templates/base.html index 12c2386..007d858 100644 --- a/app/web/templates/base.html +++ b/app/web/templates/base.html @@ -123,7 +123,7 @@ background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; } /* US-012c (PRD 5.10): grila 3 coloane — stanga (logo ROMFAST) | centru (titlu+env) | dreapta (controale). */ header { padding:16px 24px; border-bottom:1px solid var(--line); - display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; } + display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; min-height:92px; } .header-left { display:flex; align-items:center; } .header-center { display:flex; flex-direction:column; align-items:center; text-align:center; } .header-right { display:flex; align-items:center; justify-content:flex-end; gap:8px; } @@ -131,7 +131,8 @@ 32px inaltime — usor mai mare decat in header-center (28px) pentru vizibilitate ca brand anchor. margin:0 — aliniat stanga, NU centrat (era `margin:3px auto 0` cand era sub titlu). Logo transparent: ok pe dark/light/petrol fara filtre de culoare. */ - .brand-logo { height:32px; width:auto; display:block; margin:0; } + /* Logo ROMFAST la dimensiunea de pe romfast.ro (~60px inaltime), aliniat stanga. */ + .brand-logo { height:60px; width:auto; display:block; margin:0; } /* Env badge mic sub titlu in header-center (US-012c): nu mai echilibreaza optic dreapta (logo-ul face asta), ci identifica mediul langa titlu. Pastrat mic, color:var(--muted). */ .header-center .env { font-size:11px; margin-top:2px; } @@ -149,6 +150,28 @@ th { color:var(--muted); font-weight:500; font-size:12px; text-transform:uppercase; letter-spacing:.04em; } .empty { color:var(--muted); padding:24px; text-align:center; } .pill { font-size:12px; padding:2px 8px; border-radius:99px; border:1px solid var(--line); } + /* Pill-uri de filtrare a starii (bara de filtre Trimiteri). Inactiv = contur+text pe + culoarea categoriei (injectata inline); activ = umplere pe acea culoare. */ + .pills-categorii { display:inline-flex; gap:8px; flex-wrap:wrap; align-items:center; } + .pill-cat { display:inline-flex; align-items:center; gap:5px; padding:4px 11px; border-radius:99px; + font-size:12px; font-weight:600; cursor:pointer; background:transparent; + border:1.5px solid var(--line); color:var(--muted); min-height:30px; + transition:background .15s, color .15s; } + .pill-cat:hover { filter:brightness(1.1); } + .pill-cat:focus-visible { outline:2px solid var(--accent); outline-offset:2px; } + .pill-cat-n { font-size:11px; font-weight:700; color:var(--card); padding:0 5px; + border-radius:99px; min-width:18px; text-align:center; } + .pill-cat[aria-pressed="true"] { background:currentColor; color:var(--card); border-color:currentColor; } + .pill-cat[aria-pressed="true"] .pill-cat-n { background:var(--card) !important; color:currentColor; } + .pill-cat-reset[aria-pressed="true"] { background:var(--accent); color:#fff; border-color:var(--accent); } + /* Nudge "Date noi": apare doar cand pollerul usor detecteaza schimbari; tabelul nu se + schimba singur niciodata, utilizatorul reincarca cand vrea. */ + #nudge-trimiteri { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:0 0 12px; + padding:8px 12px; border-radius:8px; font-size:13px; + border:1px solid var(--accent); + background:color-mix(in srgb, var(--accent) 12%, var(--card)); } + #nudge-trimiteri[hidden] { display:none; } + #nudge-trimiteri button { font-size:13px; padding:5px 12px; min-height:32px; } .s-queued{color:var(--accent);} .s-sending{color:var(--warn);} .s-sent{color:var(--ok);} .s-error,.s-needs_data,.s-needs_mapping{color:var(--err);} .s-ok{color:var(--ok);} @@ -349,7 +372,8 @@ /* Header + nav colapsate: pe mobil trece de la grid la flex wrap. Randul 1: [logo ROMFAST stanga] [controale dreapta] (margin-left:auto pe .header-right). Randul 2: [titlu + env mic centrat, full-width]. Fara scroll orizontal, tinte >=44px. */ - header { display:flex; flex-wrap:wrap; padding:12px 16px; gap:8px; align-items:center; } + header { display:flex; flex-wrap:wrap; padding:12px 16px; gap:8px; align-items:center; min-height:0; } + .brand-logo { height:44px; } .header-left { order:0; flex:0 0 auto; } .header-center { order:2; width:100%; text-align:center; } .header-right { order:1; margin-left:auto; flex:0 0 auto; } @@ -789,45 +813,49 @@ })(); diff --git a/tests/test_acasa_trimiteri.py b/tests/test_acasa_trimiteri.py index 00e75ff..b573b4e 100644 --- a/tests/test_acasa_trimiteri.py +++ b/tests/test_acasa_trimiteri.py @@ -128,10 +128,19 @@ def test_badge_trimiteri_scoped_pe_acasa(client): assert "2" in html[idx:idx + 400] -def test_trimiteri_poll_aliniat_15s(client): - """Poll-ul de trimiteri e aliniat la 15s (anti dublu-poll M5), nu 10s.""" +def test_trimiteri_fara_poll_periodic_pe_tabel(client): + """Tabelul de trimiteri NU se mai reimprospateaza periodic: #submissions-wrap se + incarca la load / actiunile utilizatorului / Reincarca (nudge), fara `every Ns`. + Reimprospatarea live se face prin nudge-ul "Date noi" + endpointul de versiune.""" _seed_submission("sent") r = client.get("/?tab=acasa") html = r.text - assert "every 15s" in html - assert "every 10s" not in html + # Trigger-ul tabelului nu contine poll periodic. + wrap = html[html.find('id="submissions-wrap"'):] + wrap = wrap[:wrap.find(">") + 1] + assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}" + assert "reincarcaTrimiteri" in wrap + assert "trimiteriChanged" in wrap + # Mecanismul de nudge exista (banner + endpoint versiune). + assert 'id="nudge-trimiteri"' in html + assert "/_fragments/trimiteri-versiune" in html diff --git a/tests/test_web_modal.py b/tests/test_web_modal.py index 9d4bdf0..805a35d 100644 --- a/tests/test_web_modal.py +++ b/tests/test_web_modal.py @@ -161,58 +161,48 @@ def test_modal_hookuri_js_prezente(client): assert "window.inchideDetaliu" in js -# --- PRD 5.9 US-005 (R6): poll-guard --------------------------------------- -# Modalul + selectia trebuie sa supravietuiasca poll-ului de 15s. Logica e JS in -# base.html: testam la nivel de markup/handler ca guard-ul exista si distinge corect -# sursa trigger-ului (periodic vs trimiteriChanged/filtru). Comportamentul runtime -# efectiv (anularea propriu-zisa) e validat E2E (requiresBrowserCheck) — aici asertam -# codul/atributele care il implementeaza. +# --- Tabelul nu se reincarca singur: modalul + selectia sunt sigure --------- +# Tabelul (#submissions-wrap) nu mai are poll periodic; se reincarca DOAR la load, +# la actiunile utilizatorului (trimiteriChanged) sau la apasarea pe Reincarca (nudge). +# Asa, modalul deschis si bifele de bulk nu pot fi sterse de un timer. -def test_poll_pauzat_cat_modal_deschis(client): - """Guard-ul de poll exista si, cat modalul de detaliu e deschis, anuleaza - reincarcarea periodica a listei (#submissions-wrap), nu pe restul.""" - _create_account_user("poll1@test.com") +def test_tabel_fara_poll_periodic(client): + """#submissions-wrap nu are trigger periodic (`every Ns`) — niciun timer nu poate + reseta modalul deschis sau selectia de bulk in timpul interactiunii.""" + acct = _create_account_user("poll1@test.com") _login(client, "poll1@test.com") - js = client.get("/?tab=acasa").text + _insert_submission(acct) + html = client.get("/?tab=acasa").text - # Guard scopat la poll-ul listei, declansat pe htmx:beforeRequest. - assert "htmx:beforeRequest" in js - assert "d.elt.id !== 'submissions-wrap'" in js, "guard-ul trebuie scopat la #submissions-wrap" - # Conditia (a): modal deschis -> pauza (preventDefault). - assert "modalDeschis" in js - assert "modal-detaliu" in js and "hidden" in js - assert "evt.preventDefault()" in js, "pauza scopata se face prin preventDefault" + assert 'id="submissions-wrap"' in html + wrap = html[html.find('id="submissions-wrap"'):] + wrap = wrap[:wrap.find(">") + 1] + assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}" -def test_poll_pauzat_cat_exista_bifa(client): - """Conditia (b): macar un checkbox de bulk bifat -> poll-ul periodic e pus pe - pauza. Resume pe checkbox `change` prin delegare pe body (prinde si bifele - randate dupa swap).""" - _create_account_user("poll2@test.com") +def test_nudge_date_noi_in_loc_de_poll(client): + """Reimprospatarea live se face prin nudge-ul 'Date noi' (poller usor de versiune) + care NU atinge tabelul; utilizatorul reincarca explicit cand vrea.""" + acct = _create_account_user("poll2@test.com") _login(client, "poll2@test.com") - js = client.get("/?tab=acasa").text + _insert_submission(acct) + html = client.get("/?tab=acasa").text - # Detecteaza bifa de bulk in interiorul #submissions-wrap. - assert "existaBifa" in js - assert 'input[name="submission_id"]:checked' in js - # Resume: delegare pe body pe evenimentul `change` al checkbox-ului de bulk. - assert "addEventListener('change'" in js - assert "t.name === 'submission_id'" in js + assert 'id="nudge-trimiteri"' in html, "bannerul nudge 'Date noi' trebuie sa existe" + assert "/_fragments/trimiteri-versiune" in html, "pollerul de versiune trebuie configurat" + assert "reincarcaTrimiteri" in html, "reincarcarea manuala (Reincarca) trebuie expusa" -def test_trimiteriChanged_inca_reincarca_cu_bifa(client): - """R6 (F5): guard-ul NU anuleaza request-urile cu `triggeringEvent` - (trimiteriChanged / submit filtru) — acelea TREC MEREU, ca pauza sa nu ramana - lipita permanent daca randul bifat paraseste filtrul.""" - _create_account_user("poll3@test.com") +def test_trimiteriChanged_inca_reincarca(client): + """Actiunile utilizatorului (corectie / stergere) reincarca tabelul prin canalul + `trimiteriChanged`, pastrand filtrul curent (hx-include #filtre-trimiteri).""" + acct = _create_account_user("poll3@test.com") _login(client, "poll3@test.com") - js = client.get("/?tab=acasa").text + _insert_submission(acct) + html = client.get("/?tab=acasa").text - # Numai trigger-ul periodic (fara triggeringEvent) e candidat la pauza; - # orice request cu triggeringEvent iese devreme din guard. - assert "triggeringEvent" in js - assert "rc.triggeringEvent) return" in js, \ - "request-urile cu triggeringEvent (trimiteriChanged/filtru) trebuie sa treaca mereu" - # Resume explicit reutilizeaza acelasi canal `trimiteriChanged` (pastreaza filtrul). - assert "trimiteriChanged" in js + wrap = html[html.find('id="submissions-wrap"'):] + wrap = wrap[:wrap.find(">") + 1] + assert "trimiteriChanged from:body" in wrap, "tabelul trebuie sa reincarce pe trimiteriChanged" + assert 'hx-include="#filtre-trimiteri"' in wrap, "reincarcarea trebuie sa pastreze filtrul" diff --git a/tests/test_web_pill_filtre.py b/tests/test_web_pill_filtre.py index 0f36b4c..cb9192a 100644 --- a/tests/test_web_pill_filtre.py +++ b/tests/test_web_pill_filtre.py @@ -84,25 +84,24 @@ def test_pill_per_categorie_cu_numar(client): _ins(acct, status="sent", vin="WVIN_SE1_001", nr="BSE1") _login(client, "pill1@test.com") - resp = client.get("/_fragments/status") + # Pill-urile traiesc in bara de filtre din sectiunea Trimiteri. + resp = client.get("/?tab=acasa") assert resp.status_code == 200 body = resp.text - # Pill-urile sunt elemente