feat(ux): import compact + preview format Trimiteri + navigatie + scoatere auto_send (5.11)

8 stories TDD (echipa Sonnet, lead orchestreaza). US-001 scoate hold-ul auto_send din mapare
(has_no_auto_send->False, simbol pastrat; cod rezolvat->queued). US-002 scoate bifa auto_send
din UI. US-003 preview pas 3 in format .tabel-trimiteri (STARI_PREVIEW + nota_umana_preview,
fara repr Python; view-model prez). US-004 filtre layout/stil ca referinta + buton Custom.
US-005 navigatie Trimiteri/Mapari sub contoare pe toate paginile. US-006 import <details> nativ
colapsabil. US-007 post-commit reveal (OOB _coada/_status + HX-Trigger). US-008 auto-refresh
dupa actiuni (nudge eliminat).

VERIFY context curat PASS (8/8). /code-review high: 3 buguri reparate (tab nav la self-refresh,
pill Custom valori stale, nota_umana_preview precedenta needs_mapping). 934 passed, 1 skipped.
Backend trimitere + schema NEATINSE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-26 15:16:28 +00:00
parent 412102b9b1
commit 283299ff20
34 changed files with 3079 additions and 389 deletions

View File

@@ -327,6 +327,80 @@ def parse_erori(rar_error: object) -> list[dict]:
return [{"problema": str(data)[:200], "cauza": "", "fix": "", "field": None}]
# ---------------------------------------------------------------------------
# Etichete stari preview import (vocabular DIFERIT de starile de submission)
#
# Starile de preview (ok/needs_review/already_sent/duplicate_in_file) NU
# exista in STARI_SUBMISSION — reutilizarea directa a eticheta_stare/eticheta_scurta
# ridica KeyError. Acest map este sursa de adevar pentru stratul de adaptare din
# _web_compute_preview (routes.py) si pentru template (_preview_rand.html).
# ---------------------------------------------------------------------------
STARI_PREVIEW: dict[str, tuple[str, str]] = {
"ok": ("Gata de trimis", "s-ok"),
"needs_review": ("Verifica valori", "s-needs_review"),
"needs_mapping": ("Cod RAR lipsa", "s-needs_mapping"),
"needs_data": ("Date incomplete", "s-needs_data"),
"already_sent": ("Deja trimis", "s-already_sent"),
"duplicate_in_file": ("Duplicat in fisier", "s-duplicate_in_file"),
}
def nota_umana_preview(status: str, errors: list, flags: list) -> str:
"""Formateaza mesajul uman pentru coloana Note din tabelul de preview import.
Primeste ``errors`` ca lista Python (nu JSON string) — NU pasa la motiv_uman
sau parse_erori care asteapta un JSON string si ar produce repr Python brut
prin fallback ``raw[:160]`` (bug documentat in PRD 5.11 US-003).
Logica de prioritate:
- already_sent / duplicate_in_file -> "" (template le afiseaza separat)
- needs_mapping -> unmapped INAINTE de flags (codul lipsa e motivul real)
- flags non-goale -> primul flag (needs_review: data ambigua etc.)
- errors cu cheie "unmapped" -> "Cod RAR lipsa pentru: COD1, COD2"
- errors cu field+message (needs_data) -> primul mesaj de validare
- altceva -> ""
Fara exceptii. Trunchiat la 200 caractere.
"""
if status in ("already_sent", "duplicate_in_file"):
return ""
# needs_mapping: codul RAR lipseste — prioritizeaza 'unmapped' inaintea flags,
# altfel un rand cu si un flag (ex. VIN numeric) ar afisa textul flag-ului
# si ascunde motivul real (cod lipsa).
if status == "needs_mapping":
for e in errors:
if not isinstance(e, dict):
continue
if "unmapped" in e:
ops = e.get("unmapped") or []
coduri = ", ".join(
o.get("cod_op_service", "") for o in ops if isinstance(o, dict)
)
return ("Cod RAR lipsa pentru: " + coduri if coduri else "Cod RAR lipsa")
if flags:
return str(flags[0])[:200]
for e in errors:
if not isinstance(e, dict):
continue
if "unmapped" in e:
ops = e.get("unmapped") or []
coduri = ", ".join(
o.get("cod_op_service", "") for o in ops if isinstance(o, dict)
)
return (f"Cod RAR lipsa pentru: {coduri}" if coduri else "Cod RAR lipsa")
msg = (
e.get("message")
or e.get("msg")
or e.get("problema")
or e.get("cauza")
or ""
)
if msg:
return str(msg)[:200]
return ""
# ---------------------------------------------------------------------------
# Constante auxiliare (microcopy fix, fara logica)
# ---------------------------------------------------------------------------

View File

@@ -29,12 +29,14 @@ from ..payload_view import prezentare_din_payload
from ..web.csrf import get_csrf_token, verify_csrf
from .labels import (
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
STARI_PREVIEW,
eticheta_rar,
eticheta_scurta,
eticheta_stare,
eticheta_worker,
format_data_rar,
motiv_uman,
nota_umana_preview,
parse_erori,
)
from ..web.session import require_login
@@ -593,6 +595,40 @@ def _pills_categorii(counts: dict[str, int]) -> list[dict]:
]
def _build_status_ctx(request: Request, conn, account_id: int, *, oob: bool = False, tab_activ: str = "acasa") -> dict:
"""Construieste dictionarul de context pentru _status.html.
Accepta o conexiune deja deschisa (nu deschide alta). Folosit de fragment_status
si de web_confirma_import (OOB swap dupa commit).
"""
counts = _status_counts(conn, account_id)
hb = read_heartbeat(conn)
worker_alive = _worker_alive(hb)
rar_state = _rar_state(hb, worker_alive)
worker_lbl = eticheta_worker(worker_alive)
rar_ok = rar_state == "ok"
rar_lbl = eticheta_rar("ok" if rar_ok else rar_state)
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
return {
"request": request,
"worker_lbl": worker_lbl,
"rar_lbl": rar_lbl,
"worker_ok": worker_alive,
"rar_ok": rar_ok,
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
"counts_queued": counts.get("queued", 0),
"counts_sent": counts.get("sent", 0),
"blocate_total": blocate_total,
"blocate_defalcat": _blocate_defalcat(counts),
"pills_categorii": _pills_categorii(counts),
"account_active": _account_active(conn, account_id),
"tab_activ": tab_activ,
"mapari_badge": counts.get("needs_mapping", 0),
"oob": oob,
}
@router.get("/_fragments/status", response_class=HTMLResponse)
def fragment_status(request: Request) -> HTMLResponse:
"""Bara de status persistenta cu etichete umane.
@@ -604,34 +640,9 @@ def fragment_status(request: Request) -> HTMLResponse:
account_id = require_login(request)
conn = get_connection()
try:
counts = _status_counts(conn, account_id)
hb = read_heartbeat(conn)
worker_alive = _worker_alive(hb)
rar_state = _rar_state(hb, worker_alive)
# Etichete umane pre-calculate (nu logica in template)
worker_lbl = eticheta_worker(worker_alive)
# eticheta_rar accepta "ok" sau orice alt string -> indisponibil/necunoscut
rar_ok = rar_state == "ok"
rar_lbl = eticheta_rar("ok" if rar_ok else rar_state)
blocate_total = sum(counts.get(s, 0) for s in _BLOCKED)
return templates.TemplateResponse("_status.html", {
"request": request,
"worker_lbl": worker_lbl,
"rar_lbl": rar_lbl,
# Stari binare pentru bife accesibile: glifa + culoare
"worker_ok": worker_alive,
"rar_ok": rar_ok,
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
"last_login": format_data_rar(hb["last_rar_login_ok"] if hb else None),
"counts_queued": counts.get("queued", 0),
"counts_sent": counts.get("sent", 0),
"blocate_total": blocate_total,
"blocate_defalcat": _blocate_defalcat(counts),
"pills_categorii": _pills_categorii(counts),
"account_active": _account_active(conn, account_id),
})
tab_activ = request.query_params.get("tab", "acasa")
ctx = _build_status_ctx(request, conn, account_id, tab_activ=tab_activ)
return templates.TemplateResponse("_status.html", ctx)
finally:
conn.close()
@@ -1163,21 +1174,7 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
message="Lipseste inca un cod RAR — alege-l mai jos sau in tab-ul Mapari."),
)
if has_no_auto_send(resolved, mapping_meta):
conn.execute(
"UPDATE submissions SET status='needs_mapping', payload_json=?, rar_error=?, "
"updated_at=datetime('now') WHERE id=?",
(payload_json,
json.dumps({"auto_send": "cod mapat cu auto_send=0; review manual inainte de trimitere"},
ensure_ascii=False),
row["id"]),
)
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
return templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(request, row2, error=True,
message="Cod cu auto-send oprit — confirma manual din tab-ul Mapari."),
)
# US-001 (PRD 5.11): ramura auto_send eliminata din corectie.
errors = validate_prezentare(content)
if errors:
@@ -2025,6 +2022,29 @@ def _web_compute_preview(
except Exception:
conn.execute("ROLLBACK")
# Enrichment UI: adauga campuri pre-computate necesare template-ului.
# Toate consumatorii (preview complet, rand single via _preview_one_row)
# obtin automat campurile adaugate aici.
for row in preview_rows:
# view-model prez (vehicul/operatie/cod RAR) — prezentare_din_payload
# accepta dict direct (nu e nevoie de serializare/deserializare JSON).
row["prez"] = prezentare_din_payload(row["resolved"])
# Eticheta umana + clasa CSS pentru pill — din STARI_PREVIEW, nu STARI_SUBMISSION
# (eticheta_stare ridica KeyError pe ok/already_sent/duplicate_in_file).
_etq, _css = STARI_PREVIEW.get(
row["resolved_status"],
(row["resolved_status"], f"s-{row['resolved_status']}"),
)
row["stare_eticheta"] = _etq
row["stare_css"] = _css
# Nota umana formatata — errors e lista Python, NU JSON string;
# nota_umana_preview o trateaza corect (fara repr Python brut in Note).
row["nota_umana"] = nota_umana_preview(
row["resolved_status"],
row.get("errors") or [],
row.get("flags") or [],
)
nomenclator = load_nomenclator(conn)
return {
"rows": preview_rows,
@@ -2750,18 +2770,33 @@ async def web_confirma_import(
(n_enqueued, import_id),
)
# Succes → bara de upload slim cu mesaj de confirmare. are_trimiteri=True:
# contul tocmai a pus randuri in coada -> bara ramane slim si dezvaluie
# sectiunea "Trimiterile tale" de pe Acasa.
# Succes → bara de upload slim cu mesaj de confirmare + OOB swap al
# #trimiteri-section (injecteaza _coada.html cu lista proaspata) +
# header HX-Trigger: trimiteriChanged (declanseza reincarcarea automata).
toctou_msg = f" ({len(toctou)} coliziuni TOCTOU excluse)" if toctou else ""
return templates.TemplateResponse("_upload.html", _ctx(
request,
are_trimiteri=True,
message=(
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
f"Procesarea incepe in cateva secunde — vezi mai jos, in Trimiterile tale."
),
))
succes_msg = (
f"S-au pus in coada {n_enqueued} prezentari{toctou_msg}. "
f"Procesarea incepe in cateva secunde — vezi mai jos, in Trimiterile tale."
)
# Calculeaza contextele (necesita conn deschis) inainte de finally.
acasa_ctx = _get_acasa_context(request, conn, account_id)
acasa_ctx["status_filtru"] = ""
acasa_ctx["oob"] = True # adauga hx-swap-oob="outerHTML" la <section>
status_ctx = _build_status_ctx(request, conn, account_id, oob=True)
# Randeaza imediat (conn inca deschis — query-urile s-au facut mai sus).
upload_html = templates.get_template("_upload.html").render(
_ctx(request, are_trimiteri=True, message=succes_msg)
)
coada_html = templates.get_template("_coada.html").render(acasa_ctx)
status_html = templates.get_template("_status.html").render(status_ctx)
return HTMLResponse(
content=upload_html + "\n" + coada_html + "\n" + status_html,
headers={"HX-Trigger": "trimiteriChanged"},
)
finally:
conn.close()

View File

@@ -1,7 +1,16 @@
<div id="acasa-section">
{# === Centru de greutate: bara de upload (importul e operatia principala) === #}
{% include '_upload.html' %}
{# === Container colapsabil: stepper + upload intr-un singur element <details> (US-006).
Serverul seteaza atributul `open` din are_trimiteri:
are_trimiteri=False (first-run) → open (importul e vizibil imediat, fara JS)
are_trimiteri=True (returning) → colapsat (nu ocupa ecranul, dar e accesibil la click)
Degradare fara JS: corecta pe ambele ramuri.
In timpul fluxului (mapcoloane/preview), HTMX face swap pe #import-section (descendentul
intern) → <details> ramane neatins → containerul ramane deschis intre pasi. === #}
<details id="import-details"{% if not are_trimiteri %} open{% endif %}>
<summary>Importa un fisier</summary>
{% include '_upload.html' %}
</details>
{# === Subordonat: primii pasi pe un singur rand compact === #}
{% set toti_esentiali = are_creds and are_trimiteri %}
@@ -44,10 +53,14 @@
</div>
{% endif %}
{# Sectiunea Trimiteri, permanenta sub upload. Suprimata la first-run (zero
trimiteri): bara de upload acopera deja CTA-ul, iar empty-state-ul ar fi redundant. #}
{# Sectiunea Trimiteri, permanenta sub upload.
La first-run (zero trimiteri), randam un placeholder <section> gol/ascuns — necesar
ca OOB swap-ul de la confirma sa gaseasca tinta valida in DOM si sa injecteze
_coada.html fara reload complet. Fara placeholder, HTMX ignora silentios OOB-ul. #}
{% if are_trimiteri %}
{% include '_coada.html' %}
{% else %}
<section id="trimiteri-section" hidden></section>
{% endif %}
</div>

View File

@@ -3,7 +3,8 @@
Filtre + tabel (_submissions.html); detaliul se deschide in modalul global (#modal-detaliu).
#}
<section id="trimiteri-section" aria-labelledby="trimiteri-heading"
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);">
style="margin-top:22px; padding-top:18px; border-top:2px solid var(--line);"
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 id="trimiteri-heading" style="font-size:15px; margin:0;">
@@ -19,45 +20,66 @@
</span>
</div>
<!-- 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. -->
<!-- Bara de filtre: [quick-pills data STANGA] [cautare vehicul MIJLOC] [pills stare DREAPTA].
Pill-urile de stare scriu campul hidden status si re-trimit form-ul (filtreazaStare).
Quick-pills de data apeleaza setDataRange -> seteaza data_de/data_pana + re-submit. -->
<form id="filtre-trimiteri"
hx-get="/_fragments/submissions"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
style="display:flex; gap:10px 14px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
style="display:flex; gap:8px 12px; flex-wrap:wrap; align-items:center; margin-bottom:12px;">
<input type="hidden" id="f-status" name="status" value="{{ status_filtru | default('', true) }}">
{# Pagina curenta — actualizata prin OOB swap din _submissions.html; inclusa la reincarcari. #}
<input type="hidden" id="f-page" name="page" value="1">
<div>
<label for="f-vehicul" class="muted" style="display:block; font-size:12px;">Vehicul (nr/VIN)</label>
<input id="f-vehicul" type="text" name="vehicul" placeholder="ex. B123 sau VIN" style="max-width:180px;">
{# === STANGA: Quick-pills de data (preset interval) + buton Custom ===
Azi / 7 zile / 30 zile → seteaza interval preset si submitr automat.
Custom → dezvaluie #custom-date-fields pentru introducere manuala (fara submit automat). #}
<div style="flex:0 0 auto; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<div class="pills-categorii" id="quick-date-pills">
<button type="button" class="pill-cat pill-data" data-range="azi"
aria-pressed="false"
onclick="setDataRange(this,'azi')">Azi</button>
<button type="button" class="pill-cat pill-data" data-range="7zile"
aria-pressed="false"
onclick="setDataRange(this,'7zile')">7 zile</button>
<button type="button" class="pill-cat pill-data" data-range="30zile"
aria-pressed="false"
onclick="setDataRange(this,'30zile')">30 zile</button>
<button type="button" class="pill-cat pill-data" data-range="custom"
aria-pressed="false"
onclick="setDataRange(this,'custom')">Custom</button>
</div>
{# Campuri de data pentru modul Custom: ascunse pana la click pe „Custom".
type="date" (nu hidden) permite interactiunea utilizatorului.
Campul change pe form re-incarca automat lista via hx-trigger="change". #}
<div id="custom-date-fields"
style="display:none; gap:4px; align-items:center; flex-wrap:wrap; font-size:13px;">
<label for="f-data-de" class="muted" style="font-size:12px; white-space:nowrap;">De:</label>
<input type="date" id="f-data-de" name="data_de" value=""
style="font-size:13px; max-width:140px;">
<label for="f-data-pana" class="muted" style="font-size:12px; white-space:nowrap;">Pana:</label>
<input type="date" id="f-data-pana" name="data_pana" value=""
style="font-size:13px; max-width:140px;">
</div>
</div>
<div>
<label for="f-data-de" class="muted" style="display:block; font-size:12px;">Data de la</label>
<input id="f-data-de" type="date" name="data_de">
{# === MIJLOC: cautare vehicul (nr/VIN) + buton Filtreaza === #}
<div style="display:flex; align-items:center; gap:8px; flex:1 1 auto; min-width:160px; flex-wrap:wrap;">
<input id="f-vehicul" type="text" name="vehicul" placeholder="Vehicul (nr/VIN)"
style="flex:1 1 auto; min-width:120px;">
<button type="submit" style="flex:0 0 auto;">Filtreaza</button>
</div>
<div>
<label for="f-data-pana" class="muted" style="display:block; font-size:12px;">pana la</label>
<input id="f-data-pana" type="date" name="data_pana">
</div>
<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;">
{# === DREAPTA: pill-uri de stare cu contoare; re-randate via OOB la reincarcarea tabelului === #}
<span id="pills-categorii" class="pills-categorii" style="margin-left:auto; flex:0 0 auto;">
{% include '_pills.html' %}
</span>
</form>
<!-- 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. -->
<!-- Tabelul se reincarca la: incarcarea paginii, actiunile tale (trimiteriChanged)
si auto-refresh periodic din poller (date noi externe). -->
<div id="submissions-wrap"
hx-get="/_fragments/submissions"
hx-trigger="load, trimiteriChanged from:body, reincarcaTrimiteri"

View File

@@ -1,23 +1,6 @@
{# Macro-uri partajate intre template-urile de import si mapari. #}
{# Comutator COMPACT "In coada" — Manual <-> Auto pe un singur rand.
INVARIANT BACKEND: control = checkbox cu `name="auto_send" value="true"` si
SEMANTICA DE PREZENTA (bifat -> trimite "true" -> True; nebifat -> camp absent -> False).
E singura forma compatibila cu AMBELE parsere: `Form(bool)` la /mapari SI `bool(form.get())`
la /_import/.../mapare-operatie. Radio Auto/Manual cu value="false" ar trimite campul prezent
pe "Manual" -> `bool("false")` = True la import (regresie tacuta). De aceea comutator vizual
Manual<->Auto peste checkbox, NU doua radio-uri.
- form_id: leaga input-ul de un <form> extern (necesar in celulele de tabel).
- checked: starea STOCATA per mapare — bifat = Auto. #}
{% macro autosend_toggle(form_id='', checked=True, label='') -%}
<label class="autosend-toggle"
title="Bifat = Auto: pune automat in coada la fisierele viitoare cu aceasta operatie. Nebifat = Manual: tine pentru verificare; nimic nu pleaca la RAR pana confirmi."
style="display:inline-flex; align-items:center; justify-content:center; gap:8px; min-height:36px; cursor:pointer;">
{%- if label %}<span class="muted" style="font-size:13px;">{{ label }}</span>{% endif %}
<input type="checkbox" name="auto_send" value="true"
{%- if form_id %} form="{{ form_id }}"{% endif %}
{%- if checked %} checked{% endif %}
aria-label="In coada automat (Auto) pentru aceasta operatie"
style="width:18px; height:18px; cursor:pointer; accent-color:var(--accent);">
</label>
{%- endmacro %}
{# US-002 (PRD 5.11): autosend_toggle neutralizat — auto_send nu mai tine randuri (US-001).
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 %}

View File

@@ -32,7 +32,6 @@
<th>Operatie</th>
<th>Sugestii</th>
<th>Cod RAR</th>
<th>In coada</th>
<th></th>
</tr></thead>
<tbody>
@@ -69,9 +68,6 @@
{% endfor %}
</select>
</td>
<td data-eticheta="In coada">
{{ ui.autosend_toggle(form_id="map-rez-" ~ loop.index, checked=True) }}
</td>
<td>
<button type="submit" form="map-rez-{{ loop.index }}">Salveaza</button>
</td>
@@ -107,7 +103,6 @@
<thead><tr>
<th>Operatie</th>
<th>Cod RAR</th>
<th>In coada</th>
<th>Actiuni</th>
</tr></thead>
<tbody>
@@ -139,9 +134,6 @@
{% endfor %}
</select>
</td>
<td data-eticheta="In coada">
{{ ui.autosend_toggle(form_id="map-salv-" ~ loop.index, checked=m.auto_send) }}
</td>
<td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
{# Butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton.
data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
@@ -182,8 +174,6 @@
O regula leaga orice operatie al carei text <strong>contine</strong> (nu egal, ci substring)
un cuvant de un cod RAR. Util pentru operatii fara cod intern: ex. orice operatie care
<em>contine</em> „verificare" primeste codul ales. Match insensibil la majuscule/diacritice.
<strong>In coada</strong>: implicit oprit — regula rezolva codul dar tine randul pentru
verificare umana pana activezi „In coada".
</p>
{% if not text_rules %}
@@ -198,7 +188,6 @@
<thead><tr>
<th>Daca operatia contine</th>
<th>Cod RAR</th>
<th>In coada</th>
<th>Actiuni</th>
</tr></thead>
<tbody>
@@ -216,9 +205,6 @@
<td class="muted" style="font-size:12px;" data-eticheta="Cod RAR">
{{ r.cod_prestatie }}
</td>
<td class="muted" style="font-size:12px;" data-eticheta="In coada">
{% if r.auto_send %}Auto (in coada){% else %}Manual (verificare){% endif %}
</td>
<td style="text-align:right; white-space:nowrap;">
<button type="submit" form="rt-del-{{ loop.index }}"
style="background:var(--card); color:var(--err); border-color:var(--err);">
@@ -251,16 +237,13 @@
{% endfor %}
</select>
</td>
<td data-eticheta="In coada">
{{ ui.autosend_toggle(form_id="rt-add", checked=False) }}
</td>
<td style="text-align:right; white-space:nowrap;">
<button type="submit" form="rt-add">Adauga</button>
</td>
</tr>
{# Preview pre-salvare: cate operatii nemapate potriveste pattern-ul. #}
<tr>
<td colspan="4" style="padding-top:0;">
<td colspan="3" style="padding-top:0;">
<div id="rt-preview" aria-live="polite"></div>
</td>
</tr>

View File

@@ -17,25 +17,25 @@
</div>
{% endif %}
<!-- Rezumat stari (id stabil pentru OOB swap dupa editarea unui rand) -->
<!-- Rezumat stari cu etichete umane cu majuscula (id stabil pentru OOB swap) -->
{% set status_labels = [
('ok', 'gata de trimis'),
('needs_review', 'verifica valori'),
('needs_mapping', 'fara cod RAR'),
('needs_data', 'date lipsa'),
('already_sent', 'deja trimis'),
('duplicate_in_file','dublicat in fisier'),
('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'),
] %}
<div id="preview-rezumat" style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
{% for status_key, label in status_labels %}
{%- set cnt = summary.get(status_key, 0) -%}
{% if cnt > 0 %}
<span class="pill s-{{ status_key }}">{{ cnt }} {{ label }}</span>
<span class="pill s-{{ status_key }}">{{ cnt }} {{ label | lower }}</span>
{% endif %}
{% endfor %}
</div>
<!-- Butoane filtrare stare -->
<!-- Butoane filtrare stare — text uman, data-filter pastreaza codul tehnic -->
<div style="display:flex; gap:6px; flex-wrap:wrap; margin-bottom:10px;" role="group"
aria-label="Filtrare dupa stare">
<button type="button" class="filter-btn" data-filter="all"
@@ -48,7 +48,7 @@
<button type="button" class="filter-btn" data-filter="{{ status_key }}"
style="min-height:36px; font-size:13px; padding:4px 12px;
background:transparent; border-color:var(--line); color:var(--ink);">
{{ status_key }} ({{ cnt }})
{{ label }} ({{ cnt }})
</button>
{% endif %}
{% endfor %}
@@ -96,9 +96,6 @@
{% endfor %}
</select>
</div>
<div class="mapcol">
{{ ui.autosend_toggle(checked=True, label="In coada automat") }}
</div>
<div class="mapcol">
<button type="submit" style="min-height:44px;">Salveaza</button>
</div>
@@ -107,23 +104,23 @@
</div>
{% endif %}
<!-- Tabel preview. Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form,
<!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri).
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form,
altfel Enter intr-un camp ar declansa trimiterea ireversibila). Bifele
needs_review se asociaza la #confirm-form prin atributul form=. -->
<div class="tablewrap">
<div class="tablewrap tabel-trimiteri">
<table>
<thead>
<tr>
<th>#</th>
<th>VIN</th>
<th>Nr. Inm.</th>
<th>Data</th>
<th>KM final</th>
<th>Operatie</th>
<th>Stare</th>
<th>Note</th>
<th>Verificat?</th>
<th>Actiuni</th>
<th class="col-id">#</th>
<th class="col-stare">Stare</th>
<th class="col-vehicul">Vehicul</th>
<th class="col-operatie">Operatie</th>
<th class="col-data">Data</th>
<th class="col-km">KM final</th>
<th class="col-note">Note</th>
<th class="col-verificat">Verificat?</th>
<th class="col-actiuni">Actiuni</th>
</tr>
</thead>
<tbody>
@@ -132,6 +129,11 @@
{% endfor %}
</tbody>
</table>
<!-- Mesaj "filtrat la zero": afisat de JS cand filtrul ascunde toate randurile -->
<p id="preview-zero-message" class="muted"
style="display:none; text-align:center; padding:24px 16px; font-size:14px;">
Niciun rand nu corespunde filtrului selectat.
</p>
</div>
<!-- Bara confirmare (sticky jos) — singurul formular care trimite la RAR -->
@@ -240,11 +242,17 @@
if (btn) btn.disabled = (total === 0) || editing;
}
/* Filtrare randuri dupa stare */
/* Filtrare randuri dupa stare.
Cand niciun rand nu e vizibil, afiseaza mesajul #preview-zero-message. */
function filterRows(status) {
var visible = 0;
document.querySelectorAll('tbody tr[data-status]').forEach(function(tr) {
tr.style.display = (status === 'all' || tr.dataset.status === status) ? '' : 'none';
var show = status === 'all' || tr.dataset.status === status;
tr.style.display = show ? '' : 'none';
if (show) visible++;
});
var zeroMsg = document.getElementById('preview-zero-message');
if (zeroMsg) zeroMsg.style.display = (visible === 0) ? '' : 'none';
document.querySelectorAll('.filter-btn').forEach(function(b) {
var active = b.dataset.filter === status;
b.style.background = active ? 'var(--accent)' : '';

View File

@@ -1,34 +1,41 @@
{#
_preview_rand.html — un singur rand de preview import.
Doua moduri:
- display (editing falsy): <tr> normal + buton "Editeaza" pe coloana de actiuni.
- edit (editing truthy): <tr> cu un singur <td colspan> ce contine un FORM PROPRIU
(NU #confirm-form) cu grila responsiva refolosita din _trimitere_detaliu.html.
- display (editing falsy): <tr> normal cu 9 coloane in format .tabel-trimiteri.
- edit (editing truthy): <tr class="preview-edit"> (display:block) cu un singur
<td> 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).
Campuri pre-computate de _web_compute_preview (NOT din template raw):
row.prez — prezentare_din_payload(resolved): vehicul_nr, vin_scurt,
operatie, cod_rar, data_prestatie, odometru
row.stare_eticheta — text uman (ex. "Gata de trimis"), din STARI_PREVIEW
row.stare_css — clasa CSS (ex. "s-ok"), din STARI_PREVIEW
row.nota_umana — mesaj uman formatat pentru coloana Note (fara repr Python)
#}
{%- set res = row.resolved -%}
{%- set status = row.resolved_status -%}
{%- set prestatii = res.get('prestatii') or [] -%}
{%- set op = (prestatii[0].get('cod_prestatie') or prestatii[0].get('cod_op_service', '')) if prestatii else '' -%}
{% 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 -%}
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}" data-editing="1">
<td colspan="10" style="background:rgba(91,141,239,.06);">
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}" data-editing="1"
class="preview-edit">
<td data-eticheta="" style="padding:0; border:none;">
<form class="rand-editare"
hx-post="/_import/{{ import_id }}/rand/{{ row.row_index }}/editeaza"
hx-target="#preview-row-{{ row.row_index }}"
hx-swap="outerHTML"
hx-indicator="#rand-spinner-{{ row.row_index }}"
hx-disabled-elt="find button"
hx-on::response-error="this.querySelector('.rand-eroare-banner').style.display='block';">
hx-on::response-error="this.querySelector('.rand-eroare-banner').style.display='block';"
style="padding:12px; background:rgba(91,141,239,.06); border-radius:4px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
<strong style="font-size:13px;">Editare rand {{ row.row_index + 1 }}</strong>
<span class="pill s-{{ status }}" style="font-size:11px;">{{ status }}</span>
<span class="pill {{ row.stare_css }}" style="font-size:11px;">{{ row.stare_eticheta }}</span>
</div>
{% if message %}
@@ -91,22 +98,37 @@
{%- 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 -%}
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}"
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif status in ('already_sent', 'duplicate_in_file') %}opacity:.65;{% endif %}">
<td class="muted">{{ row.row_index + 1 }}</td>
<td>{{ res.get('vin') or '<span class="muted"></span>' | safe }}
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %}
<td class="col-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td>
<td class="col-stare" data-eticheta="Stare">
<span class="pill {{ row.stare_css }}">{{ row.stare_eticheta }}</span>
</td>
<td>{{ res.get('nr_inmatriculare') or '' }}
<td class="col-vehicul" data-eticheta="Vehicul">
{{ row.prez.vehicul_nr }}
{% if row.prez.vin_scurt and row.prez.vin_scurt != '—' %}
<div class="muted" style="font-size:12px;">{{ row.prez.vin_scurt }}</div>
{% endif %}
{# Fix-uri de validare pe vehicul #}
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %}
{% if disp_fix_map.get('nr_inmatriculare') %}<span class="camp-fix">{{ disp_fix_map.get('nr_inmatriculare') }}</span>{% endif %}
</td>
<td>{{ res.get('data_prestatie') or '' }}
<td class="col-operatie" data-eticheta="Operatie">
<div>{{ row.prez.operatie }}</div>
{% if row.prez.cod_rar and row.prez.cod_rar != '—' %}
<div class="cod-rar-sub"><span class="cod-rar-cod">{{ row.prez.cod_rar }}</span></div>
{% else %}
<div class="muted cod-rar-sub">nemapat</div>
{% endif %}
</td>
<td class="col-data" data-eticheta="Data prestatie">
{{ row.prez.data_prestatie }}
{% if disp_fix_map.get('data_prestatie') %}<span class="camp-fix">{{ disp_fix_map.get('data_prestatie') }}</span>{% endif %}
</td>
<td>{{ res.get('odometru_final') or '' }}
<td class="col-km" data-eticheta="KM final">
{{ row.prez.odometru }}
{% if disp_fix_map.get('odometru_final') %}<span class="camp-fix">{{ disp_fix_map.get('odometru_final') }}</span>{% endif %}
</td>
<td>{{ op or '<span class="muted"></span>' | safe }}</td>
<td><span class="pill s-{{ status }}">{{ status }}</span></td>
<td class="muted" style="font-size:12px; white-space:normal; max-width:220px;">
<td class="col-note" data-eticheta="Note"
style="font-size:12px; white-space:normal;">
{% if status == 'already_sent' and row.get('already_sent_info') %}
{% set ai = row.already_sent_info %}
deja trimis {{ (ai.get('created_at') or '')[:10] }}
@@ -114,20 +136,11 @@
{% elif status == 'duplicate_in_file' and row.get('duplicate_with') %}
dubla cu randul
{% for idx in row.duplicate_with %}{{ idx + 1 }}{% if not loop.last %}, {% endif %}{% endfor %}
{% elif row.flags %}
{{ row.flags[0] }}
{% elif row.errors %}
{%- for e in row.errors -%}
{%- if e is mapping -%}
{{ e.get('message') or e.get('msg') or (e.values() | list | first) }}
{%- else -%}
{{ e }}
{%- endif -%}
{%- if not loop.last %}; {% endif -%}
{%- endfor -%}
{% else %}
{{ row.nota_umana or '' }}
{% endif %}
</td>
<td style="text-align:center;">
<td class="col-verificat" data-eticheta="Verificat?" style="text-align:center;">
{% if status == 'needs_review' %}
<label class="chk" style="min-height:44px; justify-content:center; cursor:pointer;"
title="Bifat inseamna ca ai verificat valorile si le incluzi in trimitere">
@@ -138,7 +151,7 @@
</label>
{% endif %}
</td>
<td style="text-align:center;">
<td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;">
{% if status not in ('already_sent', 'duplicate_in_file') %}
<button type="button" class="btn-editeaza"
style="min-height:44px; padding:6px 14px; font-size:13px;
@@ -154,13 +167,13 @@
{% if include_oob %}
{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea. #}
{% set status_labels = [
('ok','gata de trimis'), ('needs_review','verifica valori'), ('needs_mapping','fara cod RAR'),
('needs_data','date lipsa'), ('already_sent','deja trimis'), ('duplicate_in_file','dublicat in fisier')] %}
('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')] %}
<div id="preview-rezumat" hx-swap-oob="true"
style="display:flex; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
{% for status_key, label in status_labels %}
{%- set cnt = summary.get(status_key, 0) -%}
{% if cnt > 0 %}<span class="pill s-{{ status_key }}">{{ cnt }} {{ label }}</span>{% endif %}
{% if cnt > 0 %}<span class="pill s-{{ status_key }}">{{ cnt }} {{ label | lower }}</span>{% endif %}
{% endfor %}
</div>
<span id="preview-ok-count" hx-swap-oob="true" data-ok="{{ summary.get('ok', 0) }}" hidden></span>

View File

@@ -1,7 +1,8 @@
<div id="status-bar" class="status-bar card"
hx-get="/_fragments/status"
hx-trigger="every 15s"
hx-swap="outerHTML">
hx-get="/_fragments/status?tab={{ tab_activ | default('acasa') }}"
hx-trigger="every 15s, trimiteriChanged from:body"
hx-swap="outerHTML"
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) -->
{% if not account_active %}
@@ -49,4 +50,20 @@
{# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #}
{# === Rand 3: navigatie rapida sub contoare (US-005) ===
Linkurile Trimiteri + Mapari apar pe FIECARE pagina sub status-bar.
Marcajul activ vine din variabila de context tab_activ (transmisa de dashboard via ?tab=
sau default 'acasa'). Badge-ul Mapari = mapari_badge (aceeasi sursa: counts.needs_mapping).
#}
{% set _tab = tab_activ | default('acasa') %}
<nav class="status-nav" aria-label="Navigatie rapida"
style="margin-top:10px; display:flex; gap:8px 16px; flex-wrap:wrap; font-size:13px; border-top:1px solid var(--line); padding-top:8px;">
<a href="/"
{% if _tab == 'acasa' or _tab == '' %}aria-current="page"{% endif %}
class="status-nav-link{% if _tab == 'acasa' or _tab == '' %} status-nav-activ{% endif %}">Trimiteri</a>
<a href="/?tab=mapari"
{% if _tab == 'mapari' %}aria-current="page"{% endif %}
class="status-nav-link{% if _tab == 'mapari' %} status-nav-activ{% endif %}">Mapari{% if mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:16px; height:16px; margin-left:4px; padding:0 4px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ mapari_badge }}</span>{% endif %}</a>
</nav>
</div>

View File

@@ -69,7 +69,6 @@
</option>
{% endfor %}
</select>
{{ ui.autosend_toggle(checked=True) }}
<button type="submit">Salveaza maparea</button>
</div>
</form>

View File

@@ -148,21 +148,20 @@
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); }
/* Hover: color-mix pe culoarea curenta a pill-ului (categoria sa), nu filter:brightness
(care producea rosu plin ilizibil pe pill-uri colorate). Activ suprima hover-ul. */
.pill-cat:hover { background:color-mix(in srgb, currentColor 12%, transparent); }
.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; }
/* Activ categorie: umplere cu culoarea categoriei (currentColor = var injectat inline) */
.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; }
/* Activ suprima hover: pastram culoarea activa, nu o mixam din nou */
.pill-cat[aria-pressed="true"]:hover { background:currentColor; }
/* Reset "Toate" activ = --accent plin (nu culoarea categoriei) */
.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; }
.pill-cat-reset[aria-pressed="true"]:hover { background:var(--accent); }
.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);}
@@ -267,6 +266,41 @@
border-radius:6px; cursor:pointer; min-height:36px; white-space:nowrap; }
.kebab-menu button:hover, .kebab-menu a:hover { background:var(--line); }
.kebab-menu button.danger { color:var(--err); }
/* === Accordion import compact (US-006 — regiune CSS disjuncta) ===
<details id="import-details"> invelete stepper + upload pe Acasa.
Atribut `open` setat de server din `are_trimiteri`:
False (first-run) → open; True (returning) → colapsat.
Degradare fara JS: returning-user colapsat, first-run deschis — ambele corecte
fara toggle JS (un toggle JS pur ar lasa returning-user fara-JS cu ecranul deschis).
aria-expanded + focus: native <details> le gestioneaza automat. */
#import-details { margin-bottom:16px; border:1px solid var(--accent); border-radius:8px;
background:var(--card); overflow:hidden; }
#import-details > summary { display:flex; align-items:center; gap:8px; cursor:pointer;
user-select:none; list-style:none; padding:10px 16px;
font-weight:600; font-size:14px; color:var(--ink); }
#import-details > summary::-webkit-details-marker { display:none; }
#import-details > summary::marker { display:none; }
#import-details > summary::before { content:"▶"; font-size:10px; color:var(--accent);
flex-shrink:0; transition:transform .15s; }
#import-details[open] > summary::before { transform:rotate(90deg); }
#import-details[open] > summary { border-bottom:1px solid var(--line); }
#import-details > summary:hover { background:color-mix(in srgb, var(--accent) 8%, var(--card)); }
#import-details > summary:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
/* Continutul (stepper + card upload): padding si bordul cardului interior sunt suprastate
de bordul exterior al #import-details — scoatem border duplicat de pe .card intern. */
#import-details #import-section { padding:0; }
#import-details #import-section > .card { border-left:none; border-right:none;
border-bottom:none; border-radius:0;
margin-bottom:0; }
/* === Sfarsit regiune accordion import === */
/* === Inceput regiune nav links status-bar (US-005) ===
Linkuri Trimiteri + Mapari sub contoare; marcaj activ cu aria-current/status-nav-activ. */
.status-nav-link { color:var(--accent); text-decoration:none; padding:2px 6px; border-radius:4px;
transition:background .1s; }
.status-nav-link:hover { background:color-mix(in srgb, var(--accent) 10%, transparent); }
.status-nav-link.status-nav-activ { color:var(--ink); font-weight:600; }
.status-nav-link.status-nav-activ:hover { background:color-mix(in srgb, var(--ink) 8%, transparent); }
/* === Sfarsit regiune nav links status-bar === */
/* Tabel cu cautare + paginare client-side (data-dt). Maparile pot creste la sute de randuri;
filtram/paginez DOM-ul deja randat, fara cereri suplimentare. Vezi scriptul din base.html. */
input[type=search] { font:inherit; background:var(--bg); color:var(--ink); border:1px solid var(--line);
@@ -311,6 +345,25 @@
@media (max-width:1024px) {
.tabel-trimiteri .col-actualizat { 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).
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). === */
.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.
Salveaza/Anuleaza sunt mereu vizibile (overflow:visible, nu clip). */
.tabel-trimiteri tr.preview-edit { display:block; }
.tabel-trimiteri tr.preview-edit > td { display:block; width:100%; box-sizing:border-box; padding:0; border:none; }
/* Pe mobil (<768px): pseudo-eticheta goala (data-eticheta="") nu lasa spatiu gol. */
@media (max-width:767px) {
.tabel-trimiteri td[data-eticheta=""]::before { display:none; }
}
/* === Modal detaliu: fereastra modala globala, in afara zonei de poll
(#submissions-wrap). Backdrop + dialog centrat pe desktop; focus-trap +
scroll-lock + inert pe <main> sunt in JS. Varianta full-screen mobil: vezi blocul
@@ -435,12 +488,16 @@
<header>
{# Celula stanga: logo ROMFAST #}
<div class="header-left">
{# Logo PNG real, RGBA transparent — ok pe toate temele fara filtre. #}
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
{# Logo PNG real, RGBA transparent — ok pe toate temele fara filtre.
Invelit in <a href="/"> pentru a naviga la Trimiteri (Acasa) de pe orice pagina. #}
<a href="/" style="display:inline-flex; align-items:center; text-decoration:none;">
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
</a>
</div>
{# Celula centru: titlu + badge env mic #}
{# Celula centru: titlu + badge env mic.
Titlul linkeaza la / (Trimiteri) ca si logo-ul. #}
<div class="header-center">
<h1>Gateway RAR AUTOPASS</h1>
<a href="/" style="text-decoration:none; color:inherit;"><h1>Gateway RAR AUTOPASS</h1></a>
<span class="env">{{ rar_env }}</span>
</div>
{# Celula dreapta: comutator tema + versiune + meniu cont #}
@@ -457,6 +514,8 @@
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
aria-label="Meniu cont" title="Meniu cont">&#9776;</button>
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
{# Prima intrare: Trimiteri (Acasa) — pagina principala cu import + lista trimiterilor. #}
<a role="menuitem" href="/">Trimiteri</a>
{# Mapari, cu badge needs_mapping. #}
{% set _mapari_badge = (badges.mapari if (badges is defined and badges and badges.mapari) else 0) %}
<a role="menuitem" href="/?tab=mapari">Mapari{% if _mapari_badge %}<span class="tab-badge" aria-hidden="true" style="display:inline-flex; align-items:center; justify-content:center; min-width:18px; height:18px; margin-left:6px; padding:0 5px; border-radius:99px; background:var(--err); color:#fff; font-size:11px; font-weight:700;">{{ _mapari_badge }}</span>{% endif %}</a>
@@ -800,11 +859,51 @@
})();
</script>
<script>
// Filtrare stare prin pill-uri + reincarcare manuala a tabelului. Tabelul NU se mai
// schimba singur (fara poll periodic pe #submissions-wrap): un poller usor verifica
// doar versiunea datelor si arata nudge-ul "Date noi" cand difera. Reincarcarea
// (pill, nudge sau actiune) trece prin form -> pastreaza filtrul/pagina curenta.
// Filtrare stare prin pill-uri + reincarcare a tabelului (manuala sau auto din poller).
// Reincarcarea trece prin form -> pastreaza filtrul/pagina curenta (hx-include).
(function() {
// Quick-pills de data (Azi/7 zile/30 zile/Custom): seteaza interval sau dezvaluie campuri manuale.
// NU modifica f-status — pastreaza pill-ul de stare activ curent.
window.setDataRange = function(btn, range) {
var form = document.getElementById('filtre-trimiteri');
if (!form) return;
var de = document.getElementById('f-data-de');
var pana = document.getElementById('f-data-pana');
var hp = document.getElementById('f-page'); if (hp) hp.value = '1';
var customPanel = document.getElementById('custom-date-fields');
// Marcheaza pill-ul de data activ, reseteaza celelalte quick-pills
document.querySelectorAll('.pill-data').forEach(function(b) {
b.setAttribute('aria-pressed', 'false');
});
if (btn) btn.setAttribute('aria-pressed', 'true');
// Custom: dezvaluie campurile manuale, asteapta inputul utilizatorului.
// NU face submit automat; form-ul submite la change (hx-trigger="change").
if (range === 'custom') {
// Goleste valorile ramase de la ultimul preset — utilizatorul porneste de la curat.
if (de) de.value = '';
if (pana) pana.value = '';
if (customPanel) customPanel.style.display = 'flex';
if (de) de.focus();
return;
}
// Preset-uri: ascunde campurile manuale, seteaza intervalul si trimite imediat.
if (customPanel) customPanel.style.display = 'none';
var azi = new Date();
// Formateaza data ca YYYY-MM-DD in zona locala (nu UTC, ca sa nu cada cu -1 zi noaptea)
function fmt(d) {
return d.getFullYear() + '-' +
String(d.getMonth() + 1).padStart(2, '0') + '-' +
String(d.getDate()).padStart(2, '0');
}
var from, to;
if (range === 'azi') { from = fmt(azi); to = fmt(azi); }
else if (range === '7zile') { var d7 = new Date(azi); d7.setDate(d7.getDate() - 6); from = fmt(d7); to = fmt(azi); }
else if (range === '30zile') { var d30 = new Date(azi); d30.setDate(d30.getDate() - 29); from = fmt(d30); to = fmt(azi); }
else { from = ''; to = ''; }
if (de) de.value = from;
if (pana) pana.value = to;
if (form.requestSubmit) form.requestSubmit(); else form.submit();
};
// Pill de stare: scrie campul hidden, reseteaza pagina la 1 si re-trimite filtrul.
window.filtreazaStare = function(btn, status) {
var form = document.getElementById('filtre-trimiteri');
@@ -817,30 +916,33 @@
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.
// Reincarca tabelul pastrand filtrul curent (hx-include #filtre-trimiteri).
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.
// Poller auto-refresh: compara versiunea datelor cu cea cu care s-a randat tabelul.
// Daca difera (schimbari externe — ex. worker a procesat trimiteri), reincarca automat
// pastrand filtrul curent. Fara nudge "Date noi" — auto-refresh e mai consistent.
// Decizie: nudge eliminat; distinctia propriu vs extern e imposibila pe client
// fara sesiune dedicata — auto-refresh acoper ambele cazuri (US-008).
var INTERVAL = 20000;
function versiuneCurenta() {
var e = document.getElementById('trimiteri-versiune');
return e ? e.getAttribute('data-v') : null;
}
var _verifica_in_curs = false;
function verifica() {
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
if (versiuneCurenta() === null) return; // tabelul nu e pe ecran (alt tab)
if (_verifica_in_curs) return; // evita suprapuneri
_verifica_in_curs = true;
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;
if (d && d.v !== versiuneCurenta()) reincarcaTrimiteri();
})
.catch(function() {});
.catch(function() {})
.finally(function() { _verifica_in_curs = false; });
}
setInterval(verifica, INTERVAL);
})();

View File

@@ -3,8 +3,8 @@
<!-- Bara de status: mereu vizibila -->
<div id="status-bar" class="status-bar card"
hx-get="/_fragments/status"
hx-trigger="load, every 15s"
hx-get="/_fragments/status?tab={{ active_tab }}"
hx-trigger="load, every 15s, trimiteriChanged from:body"
hx-swap="outerHTML">
<div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
</div>