fix(5.11): tabel trimiteri stabil — bug status=None, pills in bara de filtre, nudge "Date noi" in loc de poll 15s, logo ROMFAST marit

- Fix bug: campul hidden de filtru randa literal "None" (status_filtru None +
  Jinja default('')) -> poll-ul trimitea status=None -> tabel gol. status or "".
- Pills de stare mutate din bara de status in bara de filtre (filtreazaStare scrie
  campul hidden + re-trimite form-ul; filtrul persista la reincarcari). Re-randate
  OOB cu contoare proaspete la fiecare reincarcare a tabelului.
- Polling redesign: tabelul nu se mai reincarca singur (fara every 15s). Poller usor
  JSON (/_fragments/trimiteri-versiune) detecteaza schimbari -> nudge "Date noi —
  Reincarca". Reincarcarea (nudge / actiune) pastreaza filtrul+pagina. Scroll/selectia
  nu se mai pierd. Poll-guard eliminat (nu mai exista poll periodic de pauzat).
- Logo ROMFAST 32px -> 60px (ca pe romfast.ro), header min-height 92px, 44px pe mobil.

Regresie: 896 passed, 1 deselected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-25 21:13:42 +00:00
parent 074b6e7c8a
commit f05fe5b221
10 changed files with 224 additions and 199 deletions

View File

@@ -20,7 +20,7 @@ from pathlib import Path
from typing import Any from typing import Any
from fastapi import APIRouter, File, Form, HTTPException, Request, UploadFile 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 fastapi.templating import Jinja2Templates
from .. import __version__ 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} 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: def _account_active(conn, account_id: int) -> bool:
"""True daca contul e activ (sau legacy cu NULL/absent active).""" """True daca contul e activ (sau legacy cu NULL/absent active)."""
row = conn.execute("SELECT active FROM accounts WHERE id=?", (account_id,)).fetchone() 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_trimiteri": are_trimiteri,
"are_cheie_folosita": are_cheie_folosita, "are_cheie_folosita": are_cheie_folosita,
"blocate_total": blocate_total, "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 # US-002: Acasa include caseta de upload -> are nevoie de csrf_token
"csrf_token": get_csrf_token(request), "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)} {"request": request, "csrf_token": get_csrf_token(request)}
) )
ctx = _get_acasa_context(request, conn, account_id) 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) return templates.get_template("_acasa.html").render(ctx)
@@ -621,6 +639,19 @@ def fragment_status(request: Request) -> HTMLResponse:
conn.close() 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: def _iso_date_prefix(value: object) -> str | None:
"""Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel 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_vehicul": vehicul_q or "",
"f_data_de": data_de or "", "f_data_de": data_de or "",
"f_data_pana": data_pana 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: finally:
conn.close() conn.close()
@@ -823,6 +858,9 @@ def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse
"rows": view, "rows": view,
"filtru_activ": False, "filtru_activ": False,
"csrf_token": get_csrf_token(request), "csrf_token": get_csrf_token(request),
"pills_categorii": _pills_categorii(_status_counts(conn, account_id)),
"status_filtru": "",
"versiune_trimiteri": _trimiteri_versiune(conn, account_id),
}) })

View File

@@ -21,19 +21,16 @@
</span> </span>
</div> </div>
<!-- Filtre (US-009): reincarca tabelul; poll-ul re-trimite filtrul curent prin hx-include --> <!-- Bara de filtre: vehicul/data + pill-uri de stare pe acelasi rand. Pill-urile scriu
campul hidden status si re-trimit form-ul (filtreazaStare) -> filtrul persista la reincarcari. -->
<form id="filtre-trimiteri" <form id="filtre-trimiteri"
hx-get="/_fragments/submissions" hx-get="/_fragments/submissions"
hx-target="#submissions-wrap" hx-target="#submissions-wrap"
hx-swap="innerHTML" hx-swap="innerHTML"
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']" hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;"> style="display:flex; gap:10px 14px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
{# US-003 (PRD 5.10): dropdown status eliminat — inlocuit cu pill-uri in bara de status. <input type="hidden" id="f-status" name="status" value="{{ status_filtru | default('', true) }}">
Filtrul de stare vine de la pill-uri (/_fragments/submissions?status=X direct). {# Pagina curenta — actualizata prin OOB swap din _submissions.html; inclusa la reincarcari. #}
Camp hidden permite reset stare la submit manual din form (Filtreaza). #}
<input type="hidden" name="status" value="{{ status_filtru | default('') }}">
{# 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). #}
<input type="hidden" id="f-page" name="page" value="1"> <input type="hidden" id="f-page" name="page" value="1">
<div> <div>
<label for="f-vehicul" class="muted" style="display:block; font-size:12px;">Vehicul (nr/VIN)</label> <label for="f-vehicul" class="muted" style="display:block; font-size:12px;">Vehicul (nr/VIN)</label>
@@ -48,12 +45,24 @@
<input id="f-data-pana" type="date" name="data_pana"> <input id="f-data-pana" type="date" name="data_pana">
</div> </div>
<button type="submit">Filtreaza</button> <button type="submit">Filtreaza</button>
{# Pill-uri de stare pe acelasi rand cu filtrele; re-randate prin OOB la reincarcarea tabelului. #}
<span id="pills-categorii" class="pills-categorii" style="margin-left:auto;">
{% include '_pills.html' %}
</span>
</form> </form>
<!-- Poll aliniat la 15s ca status-ul (M5: nu doua timere perpetue pe pagina mereu deschisa) --> <!-- Nudge "Date noi": tabelul nu se reimprospateaza singur; bannerul apare doar cand
pollerul usor detecteaza schimbari, iar utilizatorul reincarca cand vrea. -->
<div id="nudge-trimiteri" hidden role="status" aria-live="polite">
<span>Sunt trimiteri actualizate.</span>
<button type="button" onclick="reincarcaTrimiteri()">Reincarca</button>
</div>
<!-- Tabelul se reincarca DOAR la: incarcarea paginii, actiunile tale (trimiteriChanged)
sau apasarea pe Reincarca (reincarcaTrimiteri). Fara poll periodic care sa-l reseteze. -->
<div id="submissions-wrap" <div id="submissions-wrap"
hx-get="/_fragments/submissions" hx-get="/_fragments/submissions"
hx-trigger="load, every 15s, trimiteriChanged from:body" hx-trigger="load, trimiteriChanged from:body, reincarcaTrimiteri"
hx-include="#filtre-trimiteri" hx-swap="innerHTML"> hx-include="#filtre-trimiteri" hx-swap="innerHTML">
<div class="empty">se incarca…</div> <div class="empty">se incarca…</div>
</div> </div>

View File

@@ -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. #}
<button type="button" class="pill-cat pill-cat-reset" data-status=""
aria-pressed="{{ 'true' if not status_filtru else 'false' }}"
onclick="filtreazaStare(this, '')">Toate</button>
{% for pill in pills_categorii %}
<button type="button" class="pill-cat" data-status="{{ pill.status }}"
aria-pressed="{{ 'true' if status_filtru == pill.status else 'false' }}"
style="color:var({{ pill.color_var }}); border-color:var({{ pill.color_var }});"
onclick="filtreazaStare(this, '{{ pill.status }}')">
{{ pill.label }}
<span class="pill-cat-n" style="background:var({{ pill.color_var }});">{{ pill.n }}</span>
</button>
{% endfor %}

View File

@@ -1,15 +1,3 @@
<style>
/* Pill-uri categorii blocate (US-003 PRD 5.10)
Culoarea e injectata inline (color_var: --warn / --err) dupa DESIGN.md:
Lipsa cod = --warn (chihlimbar), Date incomplete + Eroare = --err (rosu).
Activ = fundal pe culoarea categoriei (NU accent albastru — S1/A5). */
.pill-cat { transition: background 0.15s, color 0.15s; }
.pill-cat:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* Activ: background = culoarea categoriei (currentColor = var(--err/--warn) din inline style),
text = var(--card) (contrast AA). NU accent albastru (S1/A5 DESIGN.md). */
.pill-cat[aria-pressed="true"] { background: currentColor !important; color: var(--card) !important; border-color: currentColor !important; }
.pill-cat[aria-pressed="true"] span { background: var(--card) !important; color: currentColor; }
</style>
<div id="status-bar" class="status-bar card" <div id="status-bar" class="status-bar card"
hx-get="/_fragments/status" hx-get="/_fragments/status"
hx-trigger="every 15s" hx-trigger="every 15s"
@@ -59,51 +47,6 @@
</span> </span>
</div> </div>
<!-- Pill-uri categorii blocate (US-003 PRD 5.10): inlocuiesc lista de ID-uri. {# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #}
<button> reale cu aria-pressed, focalizabile, activare Enter/Space.
Inactiv = contur+text pe culoarea categoriei; activ = umplere pe culoarea categoriei.
Pill ascuns cand n=0 (lista pills_categorii filtreaza deja). -->
{% if pills_categorii %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
<span style="font-size:12px; color:var(--muted);">Necesita atentie:</span>
{% for pill in pills_categorii %}
<button type="button"
class="pill-cat"
aria-pressed="false"
hx-get="/_fragments/submissions?status={{ pill.status }}"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
onclick="(function(b){
var pressed=b.getAttribute('aria-pressed')==='true';
document.querySelectorAll('.pill-cat').forEach(function(x){x.setAttribute('aria-pressed','false');});
if(!pressed){b.setAttribute('aria-pressed','true');}
var s=document.getElementById('trimiteri-section');
if(s){s.scrollIntoView({behavior:'smooth'});}
})(this)"
style="display:inline-flex; align-items:center; gap:5px;
padding:3px 10px; border-radius:99px; font-size:12px; font-weight:600;
cursor:pointer; border:1.5px solid var({{ pill.color_var }}); color:var({{ pill.color_var }});
background:transparent; transition:background 0.15s, color 0.15s;">
{{ pill.label }}
<span style="font-size:11px; font-weight:700; background:var({{ pill.color_var }}); color:var(--card);
padding:0 5px; border-radius:99px; min-width:18px; text-align:center;">{{ pill.n }}</span>
</button>
{% endfor %}
{# Buton "Toate" — reseteaza filtrul de categorie #}
<button type="button"
class="pill-cat-reset"
aria-label="Arata toate trimiterile"
hx-get="/_fragments/submissions"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
onclick="document.querySelectorAll('.pill-cat').forEach(function(x){x.setAttribute('aria-pressed','false');})"
style="padding:3px 10px; border-radius:99px; font-size:12px; cursor:pointer;
border:1px solid var(--line); background:transparent; color:var(--muted);">
Toate
</button>
</div>
</div>
{% endif %}
</div> </div>

View File

@@ -5,6 +5,13 @@
#} #}
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true"> <input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
{# 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. #}
<span hx-swap-oob="innerHTML:#pills-categorii">{% include '_pills.html' %}</span>
{# Versiunea datelor cu care s-a randat tabelul; pollerul "Date noi" o compara. #}
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
{% if rows %} {% if rows %}
{# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate {# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
(gestionabil); sent/sending/queued nu au checkbox (read-only). #} (gestionabil); sent/sending/queued nu au checkbox (read-only). #}

View File

@@ -123,7 +123,7 @@
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; } 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). */ /* 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); 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-left { display:flex; align-items:center; }
.header-center { display:flex; flex-direction:column; align-items:center; text-align: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; } .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. 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). 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. */ 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 /* 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). */ (logo-ul face asta), ci identifica mediul langa titlu. Pastrat mic, color:var(--muted). */
.header-center .env { font-size:11px; margin-top:2px; } .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; } 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; } .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 { 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-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-error,.s-needs_data,.s-needs_mapping{color:var(--err);}
.s-ok{color:var(--ok);} .s-ok{color:var(--ok);}
@@ -349,7 +372,8 @@
/* Header + nav colapsate: pe mobil trece de la grid la flex wrap. /* 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 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. */ 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-left { order:0; flex:0 0 auto; }
.header-center { order:2; width:100%; text-align:center; } .header-center { order:2; width:100%; text-align:center; }
.header-right { order:1; margin-left:auto; flex:0 0 auto; } .header-right { order:1; margin-left:auto; flex:0 0 auto; }
@@ -789,45 +813,49 @@
})(); })();
</script> </script>
<script> <script>
// Poll-guard (PRD 5.9 US-005, R6). Inlocuieste vechea pauza pe „rand expandat" (5.8): // Filtrare stare prin pill-uri + reincarcare manuala a tabelului. Tabelul NU se mai
// randul-sibling de detaliu nu mai exista (US-003 l-a mutat in modalul global, care // schimba singur (fara poll periodic pe #submissions-wrap): un poller usor verifica
// traieste in afara #submissions-wrap -> un swap de poll nu-l atinge). Aici oprim // doar versiunea datelor si arata nudge-ul "Date noi" cand difera. Reincarcarea
// poll-ul de 15s de a REINCARCA lista cat timp (a) modalul e deschis SAU (b) exista // (pill, nudge sau actiune) trece prin form -> pastreaza filtrul/pagina curenta.
// cel putin un checkbox de bulk bifat — altfel modalul s-ar reseta / bifele s-ar sterge.
//
// CRITIC (F5): blocam DOAR trigger-ul periodic. In htmx `load`/`every 15s` declanseaza
// requestul FARA `triggeringEvent`; `trimiteriChanged` (HX-Trigger dupa corectie/stergere)
// si submit-ul/filtrul AU `triggeringEvent` -> TREC MEREU. Asa evitam blocajul permanent:
// daca randul bifat paraseste filtrul, pauza nu ramane lipita (pauza e legata strict de
// trigger-ul periodic, nu de o stare „sticky"). Anularea unui `htmx:beforeRequest` NU
// opreste timer-ul htmx (se reprogrameaza singur) -> poll-ul reia automat la urmatorul
// tic cand ambele conditii dispar; nu se pierde scroll, focus sau selectia de bife.
(function() { (function() {
function modalDeschis() { // Pill de stare: scrie campul hidden, reseteaza pagina la 1 si re-trimite filtrul.
var o = document.getElementById('modal-detaliu'); window.filtreazaStare = function(btn, status) {
return !!(o && !o.hidden); var form = document.getElementById('filtre-trimiteri');
if (!form) return;
var hs = document.getElementById('f-status'); if (hs) hs.value = status || '';
var hp = document.getElementById('f-page'); if (hp) hp.value = '1';
document.querySelectorAll('#pills-categorii .pill-cat').forEach(function(b) {
b.setAttribute('aria-pressed', 'false');
});
if (btn) btn.setAttribute('aria-pressed', 'true');
if (form.requestSubmit) form.requestSubmit(); else form.submit();
};
// Reincarca tabelul pastrand filtrul curent (hx-include #filtre-trimiteri) si ascunde nudge-ul.
window.reincarcaTrimiteri = function() {
var n = document.getElementById('nudge-trimiteri'); if (n) n.hidden = true;
if (window.htmx) htmx.trigger('#submissions-wrap', 'reincarcaTrimiteri');
};
// Poller "Date noi": compara versiunea datelor cu cea cu care s-a randat tabelul.
// Daca difera, arata nudge-ul; daca nu, nu atinge nimic. JSON usor, fara re-render.
var INTERVAL = 20000;
function versiuneCurenta() {
var e = document.getElementById('trimiteri-versiune');
return e ? e.getAttribute('data-v') : null;
} }
function existaBifa() { function verifica() {
return !!document.querySelector('#submissions-wrap input[name="submission_id"]:checked'); if (versiuneCurenta() === null) return; // tabelul nu e pe ecran (alt tab)
var nudge = document.getElementById('nudge-trimiteri');
if (!nudge || !nudge.hidden) return; // deja afisat -> nu re-cere
fetch('/_fragments/trimiteri-versiune', { headers: { 'X-Requested-With': 'fetch' } })
.then(function(r) { return r.ok ? r.json() : null; })
.then(function(d) {
if (!d) return;
if (d.v !== versiuneCurenta()) nudge.hidden = false;
})
.catch(function() {});
} }
document.body.addEventListener('htmx:beforeRequest', function(evt) { setInterval(verifica, INTERVAL);
var d = evt.detail || {};
if (!d.elt || d.elt.id !== 'submissions-wrap') return; // doar poll-ul listei
var rc = d.requestConfig || {};
if (rc.triggeringEvent) return; // trimiteriChanged / filtru: TREC MEREU
if (modalDeschis() || existaBifa()) evt.preventDefault(); // pauza scopata pe periodic
});
// Resume pe checkbox `change`->gol: delegare pe body ca sa prinda si checkbox-urile
// randate dupa swap. Cand modalul e inchis si nu mai exista nicio bifa, fortam un
// refresh imediat (nu mai asteptam ticul de 15s) prin `trimiteriChanged from:body`,
// care pastreaza filtrul curent (hx-include #filtre-trimiteri) si trece de guard.
document.body.addEventListener('change', function(evt) {
var t = evt.target;
if (!(t && t.name === 'submission_id')) return;
if (!modalDeschis() && !existaBifa() && window.htmx) {
htmx.trigger(document.body, 'trimiteriChanged');
}
});
})(); })();
</script> </script>
</body> </body>

View File

@@ -128,10 +128,19 @@ def test_badge_trimiteri_scoped_pe_acasa(client):
assert "2" in html[idx:idx + 400] assert "2" in html[idx:idx + 400]
def test_trimiteri_poll_aliniat_15s(client): def test_trimiteri_fara_poll_periodic_pe_tabel(client):
"""Poll-ul de trimiteri e aliniat la 15s (anti dublu-poll M5), nu 10s.""" """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") _seed_submission("sent")
r = client.get("/?tab=acasa") r = client.get("/?tab=acasa")
html = r.text html = r.text
assert "every 15s" in html # Trigger-ul tabelului nu contine poll periodic.
assert "every 10s" not 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}"
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

View File

@@ -161,58 +161,48 @@ def test_modal_hookuri_js_prezente(client):
assert "window.inchideDetaliu" in js assert "window.inchideDetaliu" in js
# --- PRD 5.9 US-005 (R6): poll-guard --------------------------------------- # --- Tabelul nu se reincarca singur: modalul + selectia sunt sigure ---------
# Modalul + selectia trebuie sa supravietuiasca poll-ului de 15s. Logica e JS in # Tabelul (#submissions-wrap) nu mai are poll periodic; se reincarca DOAR la load,
# base.html: testam la nivel de markup/handler ca guard-ul exista si distinge corect # la actiunile utilizatorului (trimiteriChanged) sau la apasarea pe Reincarca (nudge).
# sursa trigger-ului (periodic vs trimiteriChanged/filtru). Comportamentul runtime # Asa, modalul deschis si bifele de bulk nu pot fi sterse de un timer.
# efectiv (anularea propriu-zisa) e validat E2E (requiresBrowserCheck) — aici asertam
# codul/atributele care il implementeaza.
def test_poll_pauzat_cat_modal_deschis(client): def test_tabel_fara_poll_periodic(client):
"""Guard-ul de poll exista si, cat modalul de detaliu e deschis, anuleaza """#submissions-wrap nu are trigger periodic (`every Ns`) — niciun timer nu poate
reincarcarea periodica a listei (#submissions-wrap), nu pe restul.""" reseta modalul deschis sau selectia de bulk in timpul interactiunii."""
_create_account_user("poll1@test.com") acct = _create_account_user("poll1@test.com")
_login(client, "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 'id="submissions-wrap"' in html
assert "htmx:beforeRequest" in js wrap = html[html.find('id="submissions-wrap"'):]
assert "d.elt.id !== 'submissions-wrap'" in js, "guard-ul trebuie scopat la #submissions-wrap" wrap = wrap[:wrap.find(">") + 1]
# Conditia (a): modal deschis -> pauza (preventDefault). assert "every" not in wrap, f"tabelul nu trebuie sa aiba poll periodic: {wrap}"
assert "modalDeschis" in js
assert "modal-detaliu" in js and "hidden" in js
assert "evt.preventDefault()" in js, "pauza scopata se face prin preventDefault"
def test_poll_pauzat_cat_exista_bifa(client): def test_nudge_date_noi_in_loc_de_poll(client):
"""Conditia (b): macar un checkbox de bulk bifat -> poll-ul periodic e pus pe """Reimprospatarea live se face prin nudge-ul 'Date noi' (poller usor de versiune)
pauza. Resume pe checkbox `change` prin delegare pe body (prinde si bifele care NU atinge tabelul; utilizatorul reincarca explicit cand vrea."""
randate dupa swap).""" acct = _create_account_user("poll2@test.com")
_create_account_user("poll2@test.com")
_login(client, "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 'id="nudge-trimiteri"' in html, "bannerul nudge 'Date noi' trebuie sa existe"
assert "existaBifa" in js assert "/_fragments/trimiteri-versiune" in html, "pollerul de versiune trebuie configurat"
assert 'input[name="submission_id"]:checked' in js assert "reincarcaTrimiteri" in html, "reincarcarea manuala (Reincarca) trebuie expusa"
# Resume: delegare pe body pe evenimentul `change` al checkbox-ului de bulk.
assert "addEventListener('change'" in js
assert "t.name === 'submission_id'" in js
def test_trimiteriChanged_inca_reincarca_cu_bifa(client): def test_trimiteriChanged_inca_reincarca(client):
"""R6 (F5): guard-ul NU anuleaza request-urile cu `triggeringEvent` """Actiunile utilizatorului (corectie / stergere) reincarca tabelul prin canalul
(trimiteriChanged / submit filtru) — acelea TREC MEREU, ca pauza sa nu ramana `trimiteriChanged`, pastrand filtrul curent (hx-include #filtre-trimiteri)."""
lipita permanent daca randul bifat paraseste filtrul.""" acct = _create_account_user("poll3@test.com")
_create_account_user("poll3@test.com")
_login(client, "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; wrap = html[html.find('id="submissions-wrap"'):]
# orice request cu triggeringEvent iese devreme din guard. wrap = wrap[:wrap.find(">") + 1]
assert "triggeringEvent" in js assert "trimiteriChanged from:body" in wrap, "tabelul trebuie sa reincarce pe trimiteriChanged"
assert "rc.triggeringEvent) return" in js, \ assert 'hx-include="#filtre-trimiteri"' in wrap, "reincarcarea trebuie sa pastreze filtrul"
"request-urile cu triggeringEvent (trimiteriChanged/filtru) trebuie sa treaca mereu"
# Resume explicit reutilizeaza acelasi canal `trimiteriChanged` (pastreaza filtrul).
assert "trimiteriChanged" in js

View File

@@ -84,25 +84,24 @@ def test_pill_per_categorie_cu_numar(client):
_ins(acct, status="sent", vin="WVIN_SE1_001", nr="BSE1") _ins(acct, status="sent", vin="WVIN_SE1_001", nr="BSE1")
_login(client, "pill1@test.com") _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 assert resp.status_code == 200
body = resp.text body = resp.text
# Pill-urile sunt elemente <button> (nu <span onclick>) # Pill-urile sunt elemente <button>
assert "<button" in body, "Pill-urile trebuie sa fie elemente <button>" assert "<button" in body, "Pill-urile trebuie sa fie elemente <button>"
# Fiecare categorie problemativa apare ca pill # Fiecare categorie problemativa apare ca pill
assert "needs_data" in body, "Pill needs_data trebuie sa apara" assert 'data-status="needs_data"' in body, "Pill needs_data trebuie sa apara"
assert "needs_mapping" in body, "Pill needs_mapping trebuie sa apara" assert 'data-status="needs_mapping"' in body, "Pill needs_mapping trebuie sa apara"
assert "error" in body, "Pill error trebuie sa apara (hx-get sau text)" assert 'data-status="error"' in body, "Pill error trebuie sa apara"
# Contoarele sunt afisate in pill-uri # Contoarele sunt afisate in pill-uri
assert ">2<" in body or "2<" in body, "Contorul 2 pt needs_data trebuie vizibil in pill" assert ">2<" in body or "2<" in body, "Contorul 2 pt needs_data trebuie vizibil in pill"
# Starea 'sent' nu produce pill (nu e categorie de problema) # Starea 'sent' nu produce pill (nu e categorie de problema)
# (nu exista un pill cu status=sent in bara de status) assert 'data-status="sent"' not in body, "Nu trebuie pill pentru sent"
pill_sent_count = body.count("status=sent")
assert pill_sent_count == 0, "Nu trebuie pill pentru sent in bara de status"
def test_pill_click_seteaza_status(client): def test_pill_click_seteaza_status(client):
@@ -113,21 +112,21 @@ def test_pill_click_seteaza_status(client):
_ins(acct, status="error", vin="WVIN_ER2_001", nr="BER_P2a") _ins(acct, status="error", vin="WVIN_ER2_001", nr="BER_P2a")
_login(client, "pill2@test.com") _login(client, "pill2@test.com")
resp = client.get("/_fragments/status") resp = client.get("/?tab=acasa")
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.text body = resp.text
# Fiecare pill are atribut hx-get cu parametrul status corespunzator # Fiecare pill scrie campul de filtru prin filtreazaStare(this, 'X')
assert "status=needs_data" in body, "Pill needs_data trebuie sa aiba ?status=needs_data in hx-get" assert "filtreazaStare(this, 'needs_data')" in body, "Pill needs_data trebuie sa apeleze filtreazaStare cu needs_data"
assert "status=needs_mapping" in body, "Pill needs_mapping trebuie sa aiba ?status=needs_mapping in hx-get" assert "filtreazaStare(this, 'needs_mapping')" in body, "Pill needs_mapping trebuie sa apeleze filtreazaStare cu needs_mapping"
assert "status=error" in body, "Pill error trebuie sa aiba ?status=error in hx-get" assert "filtreazaStare(this, 'error')" in body, "Pill error trebuie sa apeleze filtreazaStare cu error"
# Pill-urile au aria-pressed pentru accesibilitate (WCAG) # Pill-urile au aria-pressed pentru accesibilitate (WCAG)
assert "aria-pressed" in body, "Pill-urile trebuie sa aiba atribut aria-pressed" assert "aria-pressed" in body, "Pill-urile trebuie sa aiba atribut aria-pressed"
# Target-ul este tabelul de trimiteri # Filtrarea trece prin form-ul care targeteaza tabelul de trimiteri
assert "submissions-wrap" in body or "_fragments/submissions" in body, ( assert "submissions-wrap" in body and "_fragments/submissions" in body, (
"Pill-urile trebuie sa targeteze #submissions-wrap sau sa apeleze /_fragments/submissions" "Form-ul de filtre trebuie sa targeteze #submissions-wrap prin /_fragments/submissions"
) )
@@ -161,7 +160,7 @@ def test_pill_needs_mapping_culoare_warn(client):
_ins(acct, status="needs_mapping", vin="WVIN_NM_WARN0001", nr="BNMW1") _ins(acct, status="needs_mapping", vin="WVIN_NM_WARN0001", nr="BNMW1")
_login(client, "warn@test.com") _login(client, "warn@test.com")
resp = client.get("/_fragments/status") resp = client.get("/?tab=acasa")
assert resp.status_code == 200 assert resp.status_code == 200
body = resp.text body = resp.text
@@ -169,4 +168,4 @@ def test_pill_needs_mapping_culoare_warn(client):
assert "var(--warn)" in body, ( assert "var(--warn)" in body, (
"Pill needs_mapping trebuie sa foloseasca var(--warn) conform DESIGN.md §Componente" "Pill needs_mapping trebuie sa foloseasca var(--warn) conform DESIGN.md §Componente"
) )
assert "status=needs_mapping" in body, "Pill needs_mapping trebuie sa fie prezent in bara" assert 'data-status="needs_mapping"' in body, "Pill needs_mapping trebuie sa fie prezent in bara de filtre"

View File

@@ -133,26 +133,16 @@ def test_status_blocate_defalcare(client):
_insert_submission("needs_data", acct_id) _insert_submission("needs_data", acct_id)
_insert_submission("error", acct_id) _insert_submission("error", acct_id)
resp = client.get("/_fragments/status") # Pill-urile s-au mutat in bara de filtre din sectiunea Trimiteri (nu in bara de status).
resp = client.get("/?tab=acasa")
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
# US-003 (PRD 5.10): Blocatele apar ca pill-uri (nu ca lista cu ID-uri)
assert "Necesita atentie" in html, (
f"Fragmentul nu contine sectiunea 'Necesita atentie'. HTML: {html[:800]}"
)
# Pill-urile au etichetele scurte per categorie (nu etichetele lungi din eticheta_stare) # Pill-urile au etichetele scurte per categorie (nu etichetele lungi din eticheta_stare)
assert "Lipsa cod" in html, ( assert "Lipsa cod" in html, "Acasa nu arata pill-ul pentru needs_mapping"
"Fragmentul nu arata pill-ul pentru needs_mapping" assert "Date incomplete" in html, "Acasa nu arata pill-ul pentru needs_data"
) assert "Eroare" in html, "Acasa nu arata pill-ul pentru error"
assert "Date incomplete" in html, (
"Fragmentul nu arata pill-ul pentru needs_data"
)
assert "Eroare" in html, (
"Fragmentul nu arata pill-ul pentru error"
)
# Pill-urile arata numarul total per categorie (2 needs_mapping, 1 needs_data, 1 error) # Pill-urile arata numarul total per categorie (2 needs_mapping, 1 needs_data, 1 error)
assert "2" in html, "Pill-ul needs_mapping trebuie sa arate numarul 2" assert 'class="pill-cat"' in html, "Pill-urile trebuie sa fie elemente cu clasa pill-cat"
assert "<button" in html, "Pill-urile trebuie sa fie elemente <button>" assert "<button" in html, "Pill-urile trebuie sa fie elemente <button>"
@@ -207,16 +197,15 @@ def _insert_submission_vehicul(status, account_id, vin, nr):
def test_categorie_blocata_linkeaza_la_trimiteri_filtrate(client): def test_categorie_blocata_linkeaza_la_trimiteri_filtrate(client):
"""US-003 (PRD 5.10): pill-ul error are hx-get cu ?status=error. """Pill-ul error filtreaza tabelul prin filtreazaStare(this, 'error') in bara de filtre."""
Deep-link-ul tab=acasa&status=error a fost eliminat (pill inlocuieste link-ul vechi)."""
acct_id, _ = _create_account_user("link@test.com", "parolasecreta10") acct_id, _ = _create_account_user("link@test.com", "parolasecreta10")
_login(client, "link@test.com", "parolasecreta10") _login(client, "link@test.com", "parolasecreta10")
_insert_submission("error", acct_id) _insert_submission("error", acct_id)
html = client.get("/_fragments/status").text html = client.get("/?tab=acasa").text
# Pill-ul are hx-get cu status=error (filtrare directa submissions) # Pill-ul scrie campul de filtru si re-trimite form-ul (nu mai navigheaza prin deep-link)
assert "/_fragments/submissions?status=error" in html assert "filtreazaStare(this, 'error')" in html
# Deep-link-ul tab=acasa&status=error nu mai exista — pill-uri inlocuiesc link-urile assert 'data-status="error"' in html
assert "tab=acasa&status=error" not in html assert "tab=acasa&status=error" not in html
@@ -228,12 +217,10 @@ def test_status_nu_arata_identificator_rand_blocat(client):
_insert_submission_vehicul("error", acct_id, "WVWZZZ1KZAW000123", "B123ABC") _insert_submission_vehicul("error", acct_id, "WVWZZZ1KZAW000123", "B123ABC")
html = client.get("/_fragments/status").text html = client.get("/_fragments/status").text
# Bara de status arata pill cu count, nu lista cu VIN/nr per rand # Bara de status arata doar contoare, nu lista cu VIN/nr per rand (fara PII nominal)
assert "B123ABC" not in html, "Nr inmatriculare nu trebuie sa mai apara in bara de status" assert "B123ABC" not in html, "Nr inmatriculare nu trebuie sa mai apara in bara de status"
assert "WVWZZZ1KZAW000123" not in html, "VIN integral nu trebuie expus" assert "WVWZZZ1KZAW000123" not in html, "VIN integral nu trebuie expus"
assert "0123" not in html, "VIN partial nu trebuie sa mai apara in bara de status" assert "0123" not in html, "VIN partial nu trebuie sa mai apara in bara de status"
# Pill-ul cu count 1 apare in locul listei
assert "status=error" in html, "Pill error trebuie sa aiba hx-get cu status=error"
def test_scoped_pe_cont(client): def test_scoped_pe_cont(client):