feat(web): dashboard ergonomic cu tab-uri, stepper import si microcopy uman (3.4)

Reorganizeaza interfata web pe trei principii, fara a atinge backend-ul de
trimitere (worker, mapping, idempotency, masina de stari neatinse):

- US-001 app/web/labels.py: modul pur stari tehnice -> text uman + clasa CSS
- US-002 bara status /_fragments/status: microcopy uman, defalcare blocate, scoped cont
- US-003 shell 6 tab-uri (Acasa/Import/Coada/Mapari/Cont/Nomenclator): deep-link
  ?tab=, panou activ randat server-side, fragmente inactive lazy, ARIA real
- US-004 stepper import 4 pasi (pur vizual; hx-target + csrf pastrate)
- US-005 Acasa onboarding checklist auto-bifat + colaps + empty states prietenoase

Reparat in cursul VERIFY/CLOSE: izolare teste (reset ratelimit._hits in fixturi),
regresie avertisment "cont in asteptare de activare" (re-introdus in bara status),
culori hardcodate -> variabile paleta. 434 teste pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-18 22:26:10 +00:00
parent ccd26115f8
commit 4a1d28749a
22 changed files with 1889 additions and 96 deletions

View File

@@ -0,0 +1,81 @@
<div id="acasa-section">
{% set toti_esentiali = are_creds and are_trimiteri %}
{% if toti_esentiali %}
{# Ghid colapsat/discret cand toti pasii esentiali sunt gata #}
<div class="ghid-complet" style="margin-bottom:12px; font-size:13px; color:var(--muted);">
Totul e configurat —
<a href="/?tab=coada">vezi coada</a>
</div>
{% else %}
{# Card ghid de pornire vizibil cand nu toti pasii sunt finalizati #}
<div class="card" style="margin-bottom:16px;">
<h2 style="font-size:15px; margin:0 0 12px;">Primii pasi</h2>
<ul style="list-style:none; padding:0; margin:0; display:flex; flex-direction:column; gap:8px;">
{# Pas 1: Conecteaza contul RAR (esential) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
{% if are_creds %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
{% endif %}
<span>
<a href="/?tab=cont">Conecteaza-ti contul RAR</a>
<span class="muted" style="font-size:12px; display:block;">
Email + parola portal AUTOPASS RAR
</span>
</span>
</li>
{# Pas 2: Cheie API (optional) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
{% if are_cheie_folosita %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
{% endif %}
<span>
<a href="/?tab=cont">Ia-ti cheia API</a>
<span class="muted" style="font-size:12px; display:block;">
<em>Optional</em> — doar daca trimiti din soft propriu prin API
</span>
</span>
</li>
{# Pas 3: Import primul fisier (esential) #}
<li style="display:flex; align-items:flex-start; gap:10px;">
{% if are_trimiteri %}
<span class="pas-bifat" style="color:var(--ok); font-weight:bold; flex-shrink:0;">&#10003;</span>
{% else %}
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">&#9675;</span>
{% endif %}
<span>
<a href="/?tab=import">Importa primul fisier</a>
<span class="muted" style="font-size:12px; display:block;">
Incarca un fisier xlsx/csv cu prezentarile de declarat la RAR
</span>
</span>
</li>
</ul>
</div>
{% endif %}
{# Rezumat si scurtaturi rapide (mereu vizibile) #}
<div class="card">
<h2 style="font-size:15px; margin:0 0 8px;">Bun venit la Gateway RAR AUTOPASS</h2>
<p class="muted" style="margin:0 0 10px; font-size:13px;">
Importa fisiere din tab-ul <strong><a href="/?tab=import">Import</a></strong>,
urmareste coada in tab-ul <strong><a href="/?tab=coada">Coada</a></strong>
si rezolva mapari lipsa in tab-ul <strong><a href="/?tab=mapari">Mapari</a></strong>.
</p>
<div style="display:flex; gap:12px; flex-wrap:wrap; font-size:13px;">
<a href="/?tab=coada" class="cardlink">Coada submissions</a>
<a href="/?tab=import" class="cardlink">Import fisier nou</a>
<a href="/?tab=mapari" class="cardlink">Mapari operatii</a>
</div>
</div>
</div>

View File

@@ -0,0 +1,14 @@
<div id="coada-section">
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 style="font-size:15px; margin:0;">Coada submissions</h2>
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
</span>
</div>
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
<div class="empty">se incarca…</div>
</div>
</div>
</div>

View File

@@ -6,7 +6,10 @@
{% endif %}
{% if not pending %}
<div class="empty">Nicio operatie nemapata. Tot ce a venit s-a tradus in coduri RAR.</div>
<div class="empty">
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR.
<a href="/?tab=import">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
</div>
{% else %}
<p class="muted" style="margin:0 0 12px; font-size:13px;">
Operatii ROAAUTO necunoscute, blocate in <span class="s-needs_mapping">needs_mapping</span>.

View File

@@ -1,4 +1,5 @@
<div id="import-section">
{% set pas = 2 %}{% include '_stepper.html' %}
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">
Mapare coloane —

View File

@@ -1,4 +1,5 @@
<div id="import-section">
{% set pas = 3 %}{% include '_stepper.html' %}
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
<h2 style="font-size:15px; margin:0;">

View File

@@ -0,0 +1,80 @@
<div id="status-bar" class="status-bar card"
hx-get="/_fragments/status"
hx-trigger="every 15s"
hx-swap="outerHTML">
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) -->
{% if not account_active %}
<div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn);
background:#201c0f; border-radius:6px; font-size:13px;">
<strong>Cont in asteptare de activare.</strong>
Configureaza credentialele RAR si pregateste importul acum; trimiterea catre RAR
porneste automat dupa activare de catre administrator.
</div>
{% endif %}
<div style="display:flex; gap:24px; flex-wrap:wrap; align-items:flex-start;">
<!-- Starea worker (Trimitere automata) -->
<div>
<div class="muted" style="font-size:12px;">{{ worker_lbl[0] }}</div>
<div class="{{ worker_lbl[2] }}" title="{{ worker_lbl[1] }}">
{{ worker_lbl[0].split(':')[1].strip() if ':' in worker_lbl[0] else worker_lbl[0] }}
</div>
{% if worker_lbl[1] %}
<div class="muted" style="font-size:11px; max-width:220px;">{{ worker_lbl[1] }}</div>
{% endif %}
</div>
<!-- Legatura RAR -->
<div>
<div class="muted" style="font-size:12px;">Legatura RAR</div>
<div class="{{ rar_lbl[2] }}" title="{{ rar_lbl[1] }}">
{{ rar_lbl[0].split(':')[1].strip() if ':' in rar_lbl[0] else rar_lbl[0] }}
</div>
{% if rar_lbl[1] and rar_lbl[2] != 's-sent' %}
<div class="muted" style="font-size:11px; max-width:220px;">{{ rar_lbl[1] }}</div>
{% endif %}
</div>
<!-- Ultima autentificare RAR -->
<div>
<div class="muted" style="font-size:12px;">{{ eticheta_ultima_auth }}</div>
<div>{{ last_login or '—' }}</div>
</div>
<!-- In asteptare (queued) -->
<div>
<div class="muted" style="font-size:12px;">In asteptare</div>
<div class="s-queued">{{ counts_queued }}</div>
</div>
<!-- Declarate la RAR (sent) -->
<div>
<div class="muted" style="font-size:12px;">Declarate la RAR</div>
<div class="s-sent">{{ counts_sent }}</div>
</div>
</div>
<!-- Defalcare blocate pe motiv (doar daca exista) -->
{% if blocate_defalcat %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div style="font-size:12px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div>
<div style="display:flex; gap:16px; flex-wrap:wrap;">
{% for eticheta, n in blocate_defalcat %}
{% if n > 0 %}
<div>
<span class="{{ eticheta[2] }}" style="font-size:13px;">{{ eticheta[0] }}</span>
<span class="muted" style="font-size:12px; margin-left:4px;">({{ n }})</span>
{% if eticheta[1] %}
<div class="muted" style="font-size:11px; max-width:200px;">{{ eticheta[1] }}</div>
{% endif %}
</div>
{% endif %}
{% endfor %}
</div>
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,96 @@
{#
_stepper.html — Antet wizard import (PUR vizual, fara logica de rutare).
Parametru: `pas` (integer 1-4) — pasul curent.
Utilizare in template-uri care mostenesc contextul Jinja2:
{% set pas = 1 %}{% include '_stepper.html' %}
sau cu `with`:
{% with pas=2 %}{% include '_stepper.html' %}{% endwith %}
Cei 4 pasi ficsi:
1. Incarca fisier
2. Potriveste coloanele
3. Verifica
4. Confirma trimiterea
Stari vizuale:
- index < pas "facut" (bulina plina, text bifat)
- index == pas "activ" (evidentiat, aria-current="step")
- index > pas → "viitor" (estompat)
#}
{%- set _pasi_import = [
(1, "Incarca fisier", "Trage un fisier xlsx/csv aici sau foloseste butonul de alegere."),
(2, "Potriveste coloanele", "Spune-ne ce coloana din fisier corespunde cu ce camp RAR."),
(3, "Verifica", "Verifica randurile inainte sa le trimiti la RAR."),
(4, "Confirma trimiterea", "Confirma numarul de prezentari — actiunea e ireversibila."),
] -%}
<nav class="stepper-import" aria-label="Pasii importului" style="
display:flex;
gap:0;
align-items:stretch;
margin-bottom:20px;
border:1px solid var(--line);
border-radius:8px;
overflow:hidden;
background:var(--card);
">
{% for nr, titlu, ajutor in _pasi_import %}
{%- if nr < pas %}
{%- set cls = "facut" -%}
{%- set aria = "" -%}
{%- elif nr == pas %}
{%- set cls = "activ" -%}
{%- set aria = ' aria-current="step"' -%}
{%- else %}
{%- set cls = "viitor" -%}
{%- set aria = "" -%}
{%- endif %}
<div class="stepper-pas stepper-pas--{{ cls }}"{{ aria | safe }}
style="
flex:1;
padding:10px 14px;
border-right:{% if not loop.last %}1px solid var(--line){% else %}none{% endif %};
{% if cls == 'activ' %}
background:rgba(91,141,239,.10);
{% elif cls == 'facut' %}
opacity:1;
{% else %}
opacity:.4;
{% endif %}
">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:2px;">
<span class="stepper-nr" style="
display:inline-flex;
align-items:center;
justify-content:center;
width:20px;
height:20px;
border-radius:50%;
font-size:11px;
font-weight:700;
flex-shrink:0;
{% if cls == 'activ' %}
background:var(--accent);
color:#fff;
{% elif cls == 'facut' %}
background:var(--ok);
color:#fff;
{% else %}
background:var(--line);
color:var(--muted);
{% endif %}
">
{% if cls == 'facut' %}&#10003;{% else %}{{ nr }}{% endif %}
</span>
<span style="
font-size:13px;
font-weight:{% if cls == 'activ' %}600{% else %}400{% endif %};
color:{% if cls == 'activ' %}var(--ink){% elif cls == 'facut' %}var(--ink){% else %}var(--muted){% endif %};
">{{ titlu }}</span>
</div>
{% if cls == 'activ' %}
<p class="muted" style="margin:0; font-size:12px; padding-left:26px;">{{ ajutor }}</p>
{% endif %}
</div>
{% endfor %}
</nav>

View File

@@ -18,5 +18,9 @@
</table>
</div>
{% else %}
<div class="empty">Coada e goala. Trimite o prezentare prin <code>POST /v1/prezentari</code>.</div>
<div class="empty">
Nicio trimitere inca —
<a href="/?tab=import">incepe cu Import</a>
sau trimite o prezentare prin API.
</div>
{% endif %}

View File

@@ -1,4 +1,5 @@
<div id="import-section">
{% set pas = 1 %}{% include '_stepper.html' %}
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">Import fisier (xlsx / csv)</h2>

View File

@@ -64,6 +64,20 @@
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
button:hover { filter:brightness(1.08); }
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
/* Tab-bar (US-003) */
.tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch;
border-bottom:1px solid var(--line); margin-bottom:16px; padding-bottom:0;
scrollbar-width:none; }
.tab-bar::-webkit-scrollbar { display:none; }
.tab-link { display:inline-flex; align-items:center; padding:8px 16px; font-size:14px;
font-weight:500; color:var(--muted); text-decoration:none; border-radius:6px 6px 0 0;
border:1px solid transparent; border-bottom:none; white-space:nowrap;
transition:color .12s, background .12s; margin-bottom:-1px; }
.tab-link:hover { color:var(--ink); background:var(--line); }
.tab-link.tab-activ { color:var(--ink); background:var(--card);
border-color:var(--line); border-bottom-color:var(--card); }
.tab-panel { min-height:120px; }
.status-bar { margin-bottom:12px; }
</style>
</head>
<body>

View File

@@ -10,61 +10,83 @@
</form>
</div>
<!-- Sectiunea de import fisier: stare initiala = drop zone; HTMX swapeaza in flow -->
{% include '_upload.html' %}
<div class="card banner {% if not blocked %}hidden{% endif %}"
hx-get="/_fragments/banner" hx-trigger="every 15s" hx-swap="outerHTML">
<strong>Atentie:</strong> {{ blocked }} submission-uri blocate (error / needs_data / needs_mapping).
Plasa de siguranta pe pene RAR &gt; 30h. Verifica coada mai jos.
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
<div id="status-bar" class="status-bar card"
hx-get="/_fragments/status"
hx-trigger="load, every 15s"
hx-swap="outerHTML">
<div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
</div>
<div class="card">
<div style="display:flex; gap:24px; flex-wrap:wrap;">
<div><div class="muted">Worker</div><div class="{{ 's-sent' if worker_alive else 's-error' }}">
{{ 'viu' if worker_alive else 'mort' }}</div></div>
<div><div class="muted">RAR</div><div class="{{ 's-sent' if rar_state == 'ok' else 's-error' if 'indisponibil' in rar_state else 'muted' }}">{{ rar_state }}</div></div>
<div><div class="muted">Ultimul login RAR</div><div>{{ last_login or '—' }}</div></div>
<div><div class="muted">In coada</div><div>{{ counts.get('queued', 0) }}</div></div>
<div><div class="muted">Trimise</div><div class="s-sent">{{ counts.get('sent', 0) }}</div></div>
<div><div class="muted">Blocate</div><div class="{{ 's-error' if blocked else '' }}">{{ blocked }}</div></div>
</div>
{% if rar_state != 'ok' %}
<p class="muted" style="margin:12px 0 0; font-size:12px;">
RAR posibil indisponibil — coada de mai jos arata ultima stare cunoscuta (local), nu live din RAR.
</p>
{% endif %}
<!-- Tab-bar: navigare intre sectiuni -->
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
{% set tabs = [
("acasa", "Acasa", "tab-acasa"),
("import", "Import", "tab-import"),
("coada", "Coada", "tab-coada"),
("mapari", "Mapari", "tab-mapari"),
("cont", "Cont", "tab-cont"),
("nomenclator", "Nomenclator", "tab-nomenclator")
] %}
{% for tab_id, tab_label, tab_elem_id in tabs %}
<a id="{{ tab_elem_id }}"
role="tab"
href="/?tab={{ tab_id }}"
aria-selected="{{ 'true' if active_tab == tab_id else 'false' }}"
aria-controls="tab-panel"
class="tab-link{% if active_tab == tab_id %} tab-activ{% endif %}"
tabindex="{{ '0' if active_tab == tab_id else '-1' }}"
hx-get="/_fragments/{{ tab_id }}"
hx-target="#tab-panel"
hx-swap="innerHTML"
hx-push-url="/?tab={{ tab_id }}">{{ tab_label }}</a>
{% endfor %}
</div>
<!-- incarcat o data; NU poll (sa nu stergem o selectie in curs). Se re-randeaza la salvare. -->
<div hx-get="/_fragments/mapari" hx-trigger="load" hx-swap="outerHTML">
<div class="card"><div class="empty">se incarca mapari…</div></div>
<!-- Panou activ: randat server-side la full load; HTMX inlocuieste continutul la click pe tab -->
<div id="tab-panel" role="tabpanel" aria-labelledby="tab-{{ active_tab }}" class="tab-panel">
{{ panel_html | safe }}
</div>
<div hx-get="/_fragments/cont" hx-trigger="load" hx-swap="outerHTML">
<div class="card"><div class="empty">se incarca cont…</div></div>
</div>
<script>
(function() {
/* Navigare cu sageti intre tab-uri (ARIA pattern) */
var tablist = document.querySelector('[role="tablist"]');
if (!tablist) return;
var tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
tablist.addEventListener('keydown', function(e) {
var idx = tabs.indexOf(document.activeElement);
if (idx === -1) return;
var next = -1;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
next = (idx + 1) % tabs.length;
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
next = (idx - 1 + tabs.length) % tabs.length;
} else if (e.key === 'Home') {
next = 0;
} else if (e.key === 'End') {
next = tabs.length - 1;
}
if (next !== -1) {
e.preventDefault();
tabs[next].focus();
}
});
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 style="font-size:15px; margin:0;">Coada submissions</h2>
<span style="margin-left:auto; display:flex; gap:8px; flex-wrap:wrap;">
<a class="cardlink" href="/v1/audit/export?status=sent" download>export CSV: trimise</a>
<a class="cardlink" href="/v1/audit/export?status=all" download>toate</a>
</span>
</div>
<div hx-get="/_fragments/submissions" hx-trigger="load, every 10s" hx-swap="innerHTML">
<div class="empty">se incarca…</div>
</div>
</div>
<div class="card">
<details>
<summary style="cursor:pointer; font-size:15px; font-weight:600;">Nomenclator RAR (coduri prestatii)</summary>
<div style="margin-top:12px;" hx-get="/_fragments/nomenclator" hx-trigger="load" hx-swap="innerHTML">
<div class="empty">se incarca…</div>
</div>
</details>
</div>
/* La click pe tab: actualizeaza aria-selected + tabindex */
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) {
t.setAttribute('aria-selected', 'false');
t.setAttribute('tabindex', '-1');
t.classList.remove('tab-activ');
});
tab.setAttribute('aria-selected', 'true');
tab.setAttribute('tabindex', '0');
tab.classList.add('tab-activ');
});
});
})();
</script>
{% endblock %}