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:
126
app/web/labels.py
Normal file
126
app/web/labels.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
labels.py — traducere stari tehnice in text uman + clasa CSS (US-001, PRD 3.4).
|
||||
|
||||
Functii pure: fara DB, fara request. Usor de testat unitar si de importat in template-uri.
|
||||
|
||||
Sursa de adevar pentru texte: tabelul din PRD 3.4 §3 US-001.
|
||||
"""
|
||||
|
||||
from typing import Tuple
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tipul returnat: (text_principal, subtext_tooltip, css_class)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Eticheta = Tuple[str, str, str]
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Etichete stari submissions
|
||||
# Clasele CSS corespund celor definite in base.html:
|
||||
# s-queued (accent/albastru), s-sending (warn/galben), s-sent (ok/verde),
|
||||
# s-error, s-needs_data, s-needs_mapping (err/rosu).
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
STARI_SUBMISSION: dict[str, Eticheta] = {
|
||||
"queued": (
|
||||
"In asteptare sa fie trimise",
|
||||
"",
|
||||
"s-queued",
|
||||
),
|
||||
"sending": (
|
||||
"Se trimite acum",
|
||||
"",
|
||||
"s-sending",
|
||||
),
|
||||
"sent": (
|
||||
"Declarate la RAR (finalizate)",
|
||||
"Confirmate cu numar de prezentare; nu se mai pot modifica.",
|
||||
"s-sent",
|
||||
),
|
||||
"needs_mapping": (
|
||||
"Lipseste codul prestatiei",
|
||||
"Alege codul RAR in tab-ul Mapari.",
|
||||
"s-needs_mapping",
|
||||
),
|
||||
"needs_data": (
|
||||
"Date incomplete (respinse de RAR)",
|
||||
"Corecteaza randul si reimporta.",
|
||||
"s-needs_data",
|
||||
),
|
||||
"error": (
|
||||
"Eroare la trimitere",
|
||||
"Vezi detaliul randului; se reincearca automat sau necesita corectie.",
|
||||
"s-error",
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def eticheta_stare(status: str) -> Eticheta:
|
||||
"""
|
||||
Returneaza (text, subtext, css_class) pentru o stare de submission.
|
||||
|
||||
Arunca KeyError daca starea nu este mapata — intentionat, ca sa prinda
|
||||
stari noi adaugate in schema fara mapare corespunzatoare.
|
||||
"""
|
||||
try:
|
||||
return STARI_SUBMISSION[status]
|
||||
except KeyError:
|
||||
raise KeyError(
|
||||
f"Starea de submission {status!r} nu are eticheta umana in labels.py. "
|
||||
"Adauga-o in STARI_SUBMISSION."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Etichete worker (viu / mort)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def eticheta_worker(viu: bool) -> Eticheta:
|
||||
"""
|
||||
Returneaza (text, subtext, css_class) pentru starea worker-ului.
|
||||
|
||||
viu=True => "Trimitere automata: activa" (clasa s-sent / verde)
|
||||
viu=False => "Trimitere automata: oprita" (clasa s-error / rosu)
|
||||
"""
|
||||
if viu:
|
||||
return (
|
||||
"Trimitere automata: activa",
|
||||
"Sistemul verifica coada si trimite la RAR la fiecare cateva secunde.",
|
||||
"s-sent",
|
||||
)
|
||||
return (
|
||||
"Trimitere automata: oprita",
|
||||
"Nimic nu pleaca spre RAR pana reporneste. Anunta administratorul.",
|
||||
"s-error",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Etichete conexiune RAR (ok / indisponibil)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def eticheta_rar(stare: str) -> Eticheta:
|
||||
"""
|
||||
Returneaza (text, subtext, css_class) pentru starea conexiunii cu RAR.
|
||||
|
||||
stare="ok" => "Legatura cu RAR: functionala" (s-sent / verde)
|
||||
stare="indisponibil" => "Legatura cu RAR: indisponibila" (s-error / rosu)
|
||||
"""
|
||||
if stare == "ok":
|
||||
return (
|
||||
"Legatura cu RAR: functionala",
|
||||
"Portalul AUTOPASS raspunde.",
|
||||
"s-sent",
|
||||
)
|
||||
return (
|
||||
"Legatura cu RAR: indisponibila",
|
||||
"Portalul RAR nu raspunde acum; coada se reia automat cand revine.",
|
||||
"s-error",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Constante auxiliare (microcopy fix, fara logica)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
ETICHETA_ULTIMA_AUTENTIFICARE_RAR = "Ultima autentificare la RAR"
|
||||
@@ -24,6 +24,12 @@ from fastapi.templating import Jinja2Templates
|
||||
from .. import __version__
|
||||
from ..auth import rotate_api_key
|
||||
from ..web.csrf import get_csrf_token, verify_csrf
|
||||
from .labels import (
|
||||
ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
||||
eticheta_rar,
|
||||
eticheta_stare,
|
||||
eticheta_worker,
|
||||
)
|
||||
from ..web.session import require_login
|
||||
from ..api.v1.import_router import (
|
||||
_already_sent_lookup,
|
||||
@@ -115,25 +121,142 @@ def _rar_state(hb, worker_alive: bool) -> str:
|
||||
return "indisponibil?" if age > 108000 else "ok"
|
||||
|
||||
|
||||
_TABS_VALIDE = {"acasa", "import", "coada", "mapari", "cont", "nomenclator"}
|
||||
|
||||
|
||||
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
|
||||
"""Calculeaza contextul pentru panoul Acasa (US-005).
|
||||
|
||||
Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1).
|
||||
"""
|
||||
from ..mapping import account_or_default
|
||||
acct = account_or_default(account_id)
|
||||
|
||||
# Pas 1: are credentiale RAR configurate?
|
||||
row = conn.execute(
|
||||
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
|
||||
).fetchone()
|
||||
are_creds = bool(row and row["rar_creds_enc"])
|
||||
|
||||
# Pas 3: are cel putin un submission (trimis sau in coada)?
|
||||
row_sub = conn.execute(
|
||||
"SELECT 1 FROM submissions "
|
||||
"WHERE (account_id = ? OR (? = 1 AND account_id IS NULL)) LIMIT 1",
|
||||
(acct, acct),
|
||||
).fetchone()
|
||||
are_trimiteri = row_sub is not None
|
||||
|
||||
# Pas 2 (optional): are cheie API activa?
|
||||
row_key = conn.execute(
|
||||
"SELECT 1 FROM api_keys WHERE account_id=? AND active=1 LIMIT 1",
|
||||
(acct,),
|
||||
).fetchone()
|
||||
are_cheie_folosita = row_key is not None
|
||||
|
||||
return {
|
||||
"request": request,
|
||||
"are_creds": are_creds,
|
||||
"are_trimiteri": are_trimiteri,
|
||||
"are_cheie_folosita": are_cheie_folosita,
|
||||
}
|
||||
|
||||
|
||||
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1) -> str:
|
||||
"""Randeaza panoul Acasa ca string HTML."""
|
||||
if conn is None:
|
||||
return templates.get_template("_acasa.html").render({"request": request})
|
||||
ctx = _get_acasa_context(request, conn, account_id)
|
||||
return templates.get_template("_acasa.html").render(ctx)
|
||||
|
||||
|
||||
def _render_panel_import(request: Request) -> str:
|
||||
"""Randeaza panoul Import ca string HTML (include _upload.html)."""
|
||||
return templates.get_template("_upload.html").render({
|
||||
"request": request,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
|
||||
|
||||
def _render_panel_coada(request: Request) -> str:
|
||||
"""Randeaza panoul Coada ca string HTML."""
|
||||
return templates.get_template("_coada.html").render({"request": request})
|
||||
|
||||
|
||||
def _render_panel_mapari(request: Request, conn, account_id: int) -> str:
|
||||
"""Randeaza panoul Mapari ca string HTML."""
|
||||
return templates.get_template("_mapari.html").render({
|
||||
"request": request,
|
||||
"pending": pending_unmapped(conn, account_id),
|
||||
"nomenclator": load_nomenclator(conn),
|
||||
"message": None,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
})
|
||||
|
||||
|
||||
def _render_panel_cont(request: Request, conn, account_id: int) -> str:
|
||||
"""Randeaza panoul Cont ca string HTML."""
|
||||
from ..mapping import account_or_default
|
||||
acct = account_or_default(account_id)
|
||||
row = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
|
||||
are_creds = bool(row and row["rar_creds_enc"])
|
||||
return templates.get_template("_cont.html").render({
|
||||
"request": request,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
"api_key": None,
|
||||
"are_creds": are_creds,
|
||||
"creds_mesaj": None,
|
||||
"creds_eroare": None,
|
||||
"rot_eroare": None,
|
||||
})
|
||||
|
||||
|
||||
def _render_panel_nomenclator(request: Request, conn) -> str:
|
||||
"""Randeaza panoul Nomenclator ca string HTML."""
|
||||
rows = conn.execute(
|
||||
"SELECT cod_prestatie, nume_prestatie, updated_at FROM nomenclator_rar ORDER BY cod_prestatie"
|
||||
).fetchall()
|
||||
return templates.get_template("_nomenclator.html").render({
|
||||
"request": request,
|
||||
"rows": rows,
|
||||
})
|
||||
|
||||
|
||||
def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> str:
|
||||
"""Randeaza panoul corespunzator unui tab ca string HTML."""
|
||||
if tab == "acasa":
|
||||
return _render_panel_acasa(request, conn, account_id)
|
||||
if tab == "import":
|
||||
return _render_panel_import(request)
|
||||
if tab == "coada":
|
||||
return _render_panel_coada(request)
|
||||
if tab == "mapari":
|
||||
return _render_panel_mapari(request, conn, account_id)
|
||||
if tab == "cont":
|
||||
return _render_panel_cont(request, conn, account_id)
|
||||
if tab == "nomenclator":
|
||||
return _render_panel_nomenclator(request, conn)
|
||||
return _render_panel_acasa(request)
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
def dashboard(request: Request) -> HTMLResponse:
|
||||
def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
|
||||
"""Dashboard principal cu tab-uri (US-003).
|
||||
|
||||
Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat
|
||||
server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS).
|
||||
Tab invalid -> fallback la 'acasa'.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
active_tab = tab if tab in _TABS_VALIDE else "acasa"
|
||||
conn = get_connection()
|
||||
try:
|
||||
counts = _status_counts(conn, account_id)
|
||||
hb = read_heartbeat(conn)
|
||||
blocked = sum(counts.get(s, 0) for s in _BLOCKED)
|
||||
worker_alive = _worker_alive(hb)
|
||||
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab)
|
||||
ctx = {
|
||||
"request": request,
|
||||
"rar_env": get_settings().rar_env,
|
||||
"version": __version__,
|
||||
"counts": counts,
|
||||
"blocked": blocked,
|
||||
"worker_alive": worker_alive,
|
||||
"last_login": hb["last_rar_login_ok"] if hb else None,
|
||||
"rar_state": _rar_state(hb, worker_alive),
|
||||
"account_active": _account_active(conn, account_id),
|
||||
"active_tab": active_tab,
|
||||
"panel_html": panel_html,
|
||||
"is_admin": is_account_admin(conn, account_id),
|
||||
"csrf_token": get_csrf_token(request),
|
||||
}
|
||||
@@ -142,6 +265,32 @@ def dashboard(request: Request) -> HTMLResponse:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/_fragments/acasa", response_class=HTMLResponse)
|
||||
def fragment_acasa(request: Request) -> HTMLResponse:
|
||||
"""Fragment HTMX pentru tab-ul Acasa (US-003, US-005)."""
|
||||
account_id = require_login(request)
|
||||
conn = get_connection()
|
||||
try:
|
||||
ctx = _get_acasa_context(request, conn, account_id)
|
||||
return templates.TemplateResponse("_acasa.html", ctx)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/_fragments/import", response_class=HTMLResponse)
|
||||
def fragment_import(request: Request) -> HTMLResponse:
|
||||
"""Fragment HTMX pentru tab-ul Import — include zona de upload (US-003)."""
|
||||
require_login(request)
|
||||
return templates.TemplateResponse("_upload.html", _ctx(request))
|
||||
|
||||
|
||||
@router.get("/_fragments/coada", response_class=HTMLResponse)
|
||||
def fragment_coada(request: Request) -> HTMLResponse:
|
||||
"""Fragment HTMX pentru tab-ul Coada — include coada submissions (US-003)."""
|
||||
require_login(request)
|
||||
return templates.TemplateResponse("_coada.html", {"request": request})
|
||||
|
||||
|
||||
@router.get("/_fragments/nomenclator", response_class=HTMLResponse)
|
||||
def fragment_nomenclator(request: Request) -> HTMLResponse:
|
||||
"""Browser nomenclator RAR (cache local upsert-at de worker la fiecare login)."""
|
||||
@@ -173,6 +322,56 @@ def fragment_banner(request: Request) -> HTMLResponse:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
|
||||
"""Construieste lista [(eticheta, n), ...] pentru starile blocate cu n > 0.
|
||||
|
||||
Ordinea: needs_mapping, needs_data, error — aceeasi ca in PRD.
|
||||
Returneaza lista goala daca nu exista nicio stare blocata.
|
||||
"""
|
||||
rezultat = []
|
||||
for status in ("needs_mapping", "needs_data", "error"):
|
||||
n = counts.get(status, 0)
|
||||
if n > 0:
|
||||
rezultat.append((eticheta_stare(status), n))
|
||||
return rezultat
|
||||
|
||||
|
||||
@router.get("/_fragments/status", response_class=HTMLResponse)
|
||||
def fragment_status(request: Request) -> HTMLResponse:
|
||||
"""Bara de status persistenta cu etichete umane (US-002, PRD 3.4).
|
||||
|
||||
Scoped pe contul sesiunii. Expune starea worker, legatura RAR, ultima
|
||||
autentificare, contorii de coada si defalcarea blocatelor pe motiv.
|
||||
Logica in routes.py (nu in template) pentru testabilitate.
|
||||
"""
|
||||
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_lbl = eticheta_rar("ok" if rar_state == "ok" else rar_state)
|
||||
|
||||
return templates.TemplateResponse("_status.html", {
|
||||
"request": request,
|
||||
"worker_lbl": worker_lbl,
|
||||
"rar_lbl": rar_lbl,
|
||||
"eticheta_ultima_auth": ETICHETA_ULTIMA_AUTENTIFICARE_RAR,
|
||||
"last_login": hb["last_rar_login_ok"] if hb else None,
|
||||
"counts_queued": counts.get("queued", 0),
|
||||
"counts_sent": counts.get("sent", 0),
|
||||
"blocate_defalcat": _blocate_defalcat(counts),
|
||||
"account_active": _account_active(conn, account_id),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/_fragments/submissions", response_class=HTMLResponse)
|
||||
def fragment_submissions(request: Request) -> HTMLResponse:
|
||||
account_id = require_login(request)
|
||||
|
||||
81
app/web/templates/_acasa.html
Normal file
81
app/web/templates/_acasa.html
Normal 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;">✓</span>
|
||||
{% else %}
|
||||
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">○</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;">✓</span>
|
||||
{% else %}
|
||||
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">○</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;">✓</span>
|
||||
{% else %}
|
||||
<span class="pas-nebifat" style="color:var(--muted); flex-shrink:0;">○</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>
|
||||
14
app/web/templates/_coada.html
Normal file
14
app/web/templates/_coada.html
Normal 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>
|
||||
@@ -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>.
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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;">
|
||||
|
||||
80
app/web/templates/_status.html
Normal file
80
app/web/templates/_status.html
Normal 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>
|
||||
96
app/web/templates/_stepper.html
Normal file
96
app/web/templates/_stepper.html
Normal 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' %}✓{% 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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 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 %}
|
||||
|
||||
Reference in New Issue
Block a user