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

@@ -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>