feat(5.10): UX trimiteri (pill filtre, paginare, editare) + Mapari in meniu + branding ROMFAST
14 stories TDD prin echipa de workeri (lead orchestreaza, 3 teammates pe valuri cu fisiere disjuncte; routes.py + base.html serializate ca fisiere fierbinti). - US-001 fix filtrare data (_iso_date_prefix pe garda+comparatie, prinde timestamp cu ora) - US-002/007 operatie service distincta in payload_view + afisare in detaliu - US-003 pill-uri categorii (button/aria-pressed; needs_mapping --warn, needs_data/error --err); fara lista ID-uri/dropdown - US-004 paginare numerotata 25/pag (total ramificat SQL-COUNT vs fetch-all+slice, clamp page, poll pastreaza pagina) - US-005 VIN block-level sub nr - US-006/006b editare cod RAR + validare nomenclator + recalcul idempotency (needs_data/needs_mapping via /corecteaza, error via /repune) - US-008 card eroare 3-niveluri doar pe read-only + rezumat top-of-form - US-009 Mapari in meniu hamburger; scoatere tab-bar + role=tablist orfan - US-010/011 pagina Mapari consolidata + butoane icon SVG + dirty-state (fara kebab/emoji) - US-012/012b header centrat + logo ROMFAST (/static/romfast_logo.png) in header - US-013 paleta azur ROMFAST (#2E74D6/#1F66C9) + IBM Plex Sans/Mono self-host (woff2 reale) - US-014 selector tema ciclic Light/Dark/Petrol/Auto + anti-FOUC pe 4 stari Backend trimitere (worker/masina stari/idempotenta/mapping) + schema NEATINSE (UI/UX pur + 1 fix de filtrare). VERIFY context curat PASS; /code-review high: 1 finding material reparat (US-006b). Regresie 896 passed, 1 skipped, 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -115,6 +115,16 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
||||
# cod_rar: exclusiv din cod_prestatie (NU fallback la cod_op_service); uppercase + strip ".0"
|
||||
cod_rar = _clean_cod_rar(item.get("cod_prestatie"))
|
||||
|
||||
# US-002: operatia de service originala (codul intern + denumire venita prin API/import),
|
||||
# distincta de operatia RAR mapata (cod_rar).
|
||||
# Conventie goala: aceste campuri NOI intorc "" (string gol) cand lipsesc — NU EMPTY="—".
|
||||
# Motivul: US-007 decide sa nu afiseze randul deloc (vs afisaj gol), testând `!= ""`.
|
||||
# Campurile vechi (vehicul_nr, vin, operatie etc.) pastreaza conventia EMPTY="—".
|
||||
op_service_cod = _clean_str(item.get("cod_op_service"))
|
||||
# op_service_denumire e relevant doar cand exista un cod de operatie de service;
|
||||
# altfel ar expune denumirea RAR drept op. de service, ceea ce e semantic incorect.
|
||||
op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else ""
|
||||
|
||||
return {
|
||||
"vehicul_nr": nr or EMPTY,
|
||||
"vin": vin or EMPTY,
|
||||
@@ -124,4 +134,7 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
||||
"odometru": odo or EMPTY,
|
||||
"cod": cod or EMPTY,
|
||||
"cod_rar": cod_rar or EMPTY,
|
||||
# US-002: chei noi cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
|
||||
"op_service_cod": op_service_cod,
|
||||
"op_service_denumire": op_service_denumire,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import sqlite3
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
@@ -555,43 +556,26 @@ def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
|
||||
return rezultat
|
||||
|
||||
|
||||
# Cate randuri blocate identificam nominal sub fiecare categorie din banner (US-014).
|
||||
_BLOCATE_SAMPLE = 3
|
||||
def _pills_categorii(counts: dict[str, int]) -> list[dict]:
|
||||
"""Pill-uri pentru starile cu problema (US-003 PRD 5.10).
|
||||
|
||||
|
||||
def _blocate_actionabil(conn, account_id: int) -> list[dict]:
|
||||
"""Categorii blocate cu identificatorii primelor randuri + deep-link (US-014).
|
||||
|
||||
Pentru fiecare stare blocata cu n>0: eticheta umana, contorul, primii N identificatori
|
||||
(VIN partial + nr inmatriculare + #id — PII doar partial, ca jurnalul) si cati raman.
|
||||
Scoped pe cont (regula NULL->1). Lista goala -> banner-ul nu se randeaza (se stinge).
|
||||
Inlocuieste _blocate_actionabil (care incarca PII/VIN per rand).
|
||||
Reutilizeaza contoarele deja calculate din _status_counts.
|
||||
Returneza lista goala daca nu exista nicio stare blocata.
|
||||
"""
|
||||
from ..security import vin_partial
|
||||
scope_sql, scope_params = account_scope_clause(account_id)
|
||||
out: list[dict] = []
|
||||
for status in ("needs_mapping", "needs_data", "error"):
|
||||
rows = conn.execute(
|
||||
f"SELECT id, payload_json FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC",
|
||||
scope_params + [status],
|
||||
).fetchall()
|
||||
if not rows:
|
||||
continue
|
||||
sample = []
|
||||
for r in rows[:_BLOCATE_SAMPLE]:
|
||||
prez = prezentare_din_payload(r["payload_json"])
|
||||
sample.append({
|
||||
"id": r["id"],
|
||||
"vin": vin_partial(prez.get("vin") or ""),
|
||||
"nr": prez.get("vehicul_nr") or "",
|
||||
})
|
||||
out.append({
|
||||
"status": status,
|
||||
"eticheta": eticheta_stare(status),
|
||||
"n": len(rows),
|
||||
"randuri": sample,
|
||||
"rest": max(0, len(rows) - len(sample)),
|
||||
})
|
||||
return out
|
||||
# DESIGN.md §Componente: Lipsa cod = --warn (chihlimbar), celelalte categorii = --err (rosu).
|
||||
# Culoarea e CSS variable name (nu clasa), injectata direct in style tag al pill-ului,
|
||||
# pentru ca s-needs_mapping in base.html e tot --err (incorect pentru pill).
|
||||
PILL_DEFS = [
|
||||
("needs_mapping", "Lipsa cod", "--warn"),
|
||||
("needs_data", "Date incomplete", "--err"),
|
||||
("error", "Eroare", "--err"),
|
||||
]
|
||||
return [
|
||||
{"status": status, "label": label, "color_var": color_var, "n": counts.get(status, 0)}
|
||||
for status, label, color_var in PILL_DEFS
|
||||
if counts.get(status, 0) > 0
|
||||
]
|
||||
|
||||
|
||||
@router.get("/_fragments/status", response_class=HTMLResponse)
|
||||
@@ -630,23 +614,31 @@ def fragment_status(request: Request) -> HTMLResponse:
|
||||
"counts_sent": counts.get("sent", 0),
|
||||
"blocate_total": blocate_total,
|
||||
"blocate_defalcat": _blocate_defalcat(counts),
|
||||
"blocate_actionabil": _blocate_actionabil(conn, account_id),
|
||||
"pills_categorii": _pills_categorii(counts),
|
||||
"account_active": _account_active(conn, account_id),
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def _is_iso_date(value: object) -> bool:
|
||||
"""True daca `value` e o data ISO YYYY-MM-DD (comparabila lexicografic corect)."""
|
||||
def _iso_date_prefix(value: object) -> str | None:
|
||||
"""Intoarce primele 10 caractere (YYYY-MM-DD) daca incep cu o data ISO valida, altfel None.
|
||||
|
||||
Permite filtrarea dupa data_prestatie chiar daca valoarea contine ora/minut/secunda
|
||||
(ex. '2026-06-20 14:35:07' sau '2026-06-20T14:35:07') — extrage portiunea de data
|
||||
fara a exclude timestamp-urile (bug-ul fix US-001: _is_iso_date cerea len==10).
|
||||
Valori care nu incep cu o data ISO valida (ex. '05.12.2024') intorc None si
|
||||
sunt excluse din filtru — comportament actual pastrat.
|
||||
"""
|
||||
s = str(value or "").strip()
|
||||
if len(s) != 10:
|
||||
return False
|
||||
if len(s) < 10:
|
||||
return None
|
||||
prefix = s[:10]
|
||||
try:
|
||||
datetime.strptime(s, "%Y-%m-%d")
|
||||
return True
|
||||
datetime.strptime(prefix, "%Y-%m-%d")
|
||||
return prefix
|
||||
except (ValueError, TypeError):
|
||||
return False
|
||||
return None
|
||||
|
||||
|
||||
# Stari care semnaleaza o problema ce necesita atentia operatorului. Eticheta umana
|
||||
@@ -693,6 +685,9 @@ def _submission_row_view(r) -> dict:
|
||||
}
|
||||
|
||||
|
||||
_PAGE_SIZE = 25 # Marime pagina fixa (US-004 PRD 5.10)
|
||||
|
||||
|
||||
@router.get("/_fragments/submissions", response_class=HTMLResponse)
|
||||
def fragment_submissions(
|
||||
request: Request,
|
||||
@@ -700,12 +695,14 @@ def fragment_submissions(
|
||||
vehicul: str | None = None,
|
||||
data_de: str | None = None,
|
||||
data_pana: str | None = None,
|
||||
page: int = 1,
|
||||
) -> HTMLResponse:
|
||||
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale (US-009).
|
||||
"""Tabel Trimiteri, scoped pe cont, cu filtre optionale si paginare (US-009, US-004).
|
||||
|
||||
Filtrarea pe stare se face in SQL (foloseste idx_submissions_account_status);
|
||||
filtrarea pe vehicul (nr/VIN, case-insensitive) si pe interval data_prestatie
|
||||
se face dupa parsarea payload_json in Python (plafon perf notat — eng review).
|
||||
US-004 H1: totalul se calculeaza DIFERIT dupa tipul de filtru:
|
||||
- FARA filtru Python (status-only / niciun filtru): SQL COUNT(*) + LIMIT/OFFSET
|
||||
- CU filtru vehicul/data activ: fetch-all -> filtreaza Python -> total=len -> slice
|
||||
SQL COUNT/LIMIT pe calea cu filtru Python ar da total gresit (taie inainte de filtru).
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
status = (status or "").strip() or None
|
||||
@@ -713,6 +710,9 @@ def fragment_submissions(
|
||||
data_de = (data_de or "").strip() or None
|
||||
data_pana = (data_pana or "").strip() or None
|
||||
filtru_activ = bool(status or vehicul_q or data_de or data_pana)
|
||||
filtru_python = bool(vehicul_q or data_de or data_pana) # filtru care necesita Python
|
||||
|
||||
page = max(1, page) # pre-clamp >= 1
|
||||
|
||||
conn = get_connection()
|
||||
try:
|
||||
@@ -722,45 +722,79 @@ def fragment_submissions(
|
||||
if status:
|
||||
where.append("status=?")
|
||||
params.append(status)
|
||||
# Filtrarea pe vehicul/data se face in Python (dupa parsarea payload). Daca am
|
||||
# taia la LIMIT inainte de filtru, am rata silentios randuri mai vechi care
|
||||
# potrivesc. Cand un filtru text/data e activ, scoatem LIMIT-ul din SQL si plafonam
|
||||
# afisarea dupa filtrare (OK la scara actuala — plafon perf notat, eng review).
|
||||
limit_sql = "" if (vehicul_q or data_de or data_pana) else " LIMIT 200"
|
||||
rows = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
||||
"updated_at, payload_json FROM submissions "
|
||||
f"WHERE {' AND '.join(where)} ORDER BY id DESC{limit_sql}",
|
||||
params,
|
||||
).fetchall()
|
||||
where_sql = " AND ".join(where)
|
||||
|
||||
view = []
|
||||
for r in rows:
|
||||
v = _submission_row_view(r)
|
||||
prez = v["prez"]
|
||||
if vehicul_q:
|
||||
hay = f"{prez['vehicul_nr']} {prez['vin']}".upper()
|
||||
if vehicul_q not in hay:
|
||||
continue
|
||||
if data_de or data_pana:
|
||||
d = prez["data_prestatie"]
|
||||
# Comparam doar date in format ISO (YYYY-MM-DD); altfel comparatia de string
|
||||
# ar fi gresita (ex. "05.12.2024"). Valori ne-ISO sunt excluse din filtru.
|
||||
if not _is_iso_date(d):
|
||||
continue
|
||||
if data_de and d < data_de:
|
||||
continue
|
||||
if data_pana and d > data_pana:
|
||||
continue
|
||||
view.append(v)
|
||||
if len(view) >= 200:
|
||||
break
|
||||
if filtru_python:
|
||||
# Calea B: fetch-all, filtreaza in Python, slice (US-004 H1)
|
||||
# FARA LIMIT — altfel paginile >8 ar disparea silentios (bug PRD H1)
|
||||
rows_db = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
||||
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC",
|
||||
params,
|
||||
).fetchall()
|
||||
|
||||
view_all: list[dict] = []
|
||||
for r in rows_db:
|
||||
v = _submission_row_view(r)
|
||||
prez = v["prez"]
|
||||
if vehicul_q:
|
||||
hay = f"{prez['vehicul_nr']} {prez['vin']}".upper()
|
||||
if vehicul_q not in hay:
|
||||
continue
|
||||
if data_de or data_pana:
|
||||
# Extragem portiunea YYYY-MM-DD (US-001 fix).
|
||||
d_prefix = _iso_date_prefix(prez["data_prestatie"])
|
||||
if d_prefix is None:
|
||||
continue
|
||||
if data_de and d_prefix < data_de:
|
||||
continue
|
||||
if data_pana and d_prefix > data_pana:
|
||||
continue
|
||||
view_all.append(v)
|
||||
|
||||
total = len(view_all)
|
||||
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
|
||||
page = max(1, min(page, pages)) # clamp H2
|
||||
offset = (page - 1) * _PAGE_SIZE
|
||||
view = view_all[offset:offset + _PAGE_SIZE]
|
||||
|
||||
else:
|
||||
# Calea A: SQL COUNT(*) + LIMIT/OFFSET (eficient, fara filtru Python activ)
|
||||
total = conn.execute(
|
||||
f"SELECT COUNT(*) FROM submissions WHERE {where_sql}", params
|
||||
).fetchone()[0]
|
||||
|
||||
pages = max(1, math.ceil(total / _PAGE_SIZE)) if total > 0 else 1
|
||||
page = max(1, min(page, pages)) # clamp H2
|
||||
offset = (page - 1) * _PAGE_SIZE
|
||||
|
||||
rows_db = conn.execute(
|
||||
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
|
||||
f"updated_at, payload_json FROM submissions WHERE {where_sql} ORDER BY id DESC "
|
||||
"LIMIT ? OFFSET ?",
|
||||
params + [_PAGE_SIZE, offset],
|
||||
).fetchall()
|
||||
view = [_submission_row_view(r) for r in rows_db]
|
||||
|
||||
page_start = (page - 1) * _PAGE_SIZE + 1 if total > 0 else 0
|
||||
page_end = min(page * _PAGE_SIZE, total)
|
||||
|
||||
return templates.TemplateResponse("_submissions.html", {
|
||||
"request": request,
|
||||
"rows": view,
|
||||
"filtru_activ": filtru_activ,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
# Paginare (US-004)
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"page_start": page_start,
|
||||
"page_end": page_end,
|
||||
# Filtre curente pentru linkurile de paginare (pastreaza filtrele, H2)
|
||||
"f_status": status or "",
|
||||
"f_vehicul": vehicul_q or "",
|
||||
"f_data_de": data_de or "",
|
||||
"f_data_pana": data_pana or "",
|
||||
})
|
||||
finally:
|
||||
conn.close()
|
||||
@@ -768,6 +802,9 @@ def fragment_submissions(
|
||||
|
||||
# Stari ne-trimise blocate pe care le putem corecta inline (US-010).
|
||||
_CORECTABILE = ("needs_data", "needs_mapping")
|
||||
# US-006b: stari cu select editabil cod_prestatie (superset al _CORECTABILE: error
|
||||
# primeste select in formularul /repune, nu in /corecteaza — fara schimbare de vehicle fields).
|
||||
_EDITABILE_OP = ("needs_data", "needs_mapping", "error")
|
||||
# Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada.
|
||||
_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping")
|
||||
|
||||
@@ -851,12 +888,31 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
||||
eticheta = eticheta_stare(row["status"])
|
||||
nemapate_inline: list[dict] = []
|
||||
nomenclator: list[dict] = []
|
||||
# Variabila interna: nomenclatorul complet (incarcat pentru needs_mapping, refolosit pt US-006)
|
||||
_nomenclator_complet: list[dict] = []
|
||||
if conn is not None and row["status"] == "needs_mapping":
|
||||
# Un singur SELECT pe nomenclator: il refolosim si pentru sugestii si pentru dropdown.
|
||||
nomenclator = load_nomenclator(conn)
|
||||
nemapate_inline = _nemapate_pentru_submission(row, nomenclator)
|
||||
if not nemapate_inline:
|
||||
nomenclator = [] # nu expunem dropdown-ul cand nu exista operatii de mapat
|
||||
_nomenclator_complet = load_nomenclator(conn)
|
||||
nemapate_inline = _nemapate_pentru_submission(row, _nomenclator_complet)
|
||||
nomenclator = _nomenclator_complet if nemapate_inline else []
|
||||
|
||||
# US-006/US-006b: nomenclator pentru selectul cod_prestatie — needs_data/needs_mapping (in
|
||||
# formularul /corecteaza) + error (in formularul /repune). Refoloseste _nomenclator_complet
|
||||
# daca e deja incarcat (needs_mapping), altfel incarca fresh.
|
||||
nomenclator_rar: list[dict] = []
|
||||
if conn is not None and row["status"] in _EDITABILE_OP:
|
||||
nomenclator_rar = _nomenclator_complet if _nomenclator_complet else load_nomenclator(conn)
|
||||
|
||||
# US-006: cod_prestatie curent din prima prestatie (pentru pre-selectare in select)
|
||||
cod_prestatie_curent = ""
|
||||
try:
|
||||
_pd = json.loads(row["payload_json"] or "{}")
|
||||
_prestatii = (_pd.get("prestatii") or []) if isinstance(_pd, dict) else []
|
||||
if _prestatii and isinstance(_prestatii[0], dict):
|
||||
cod_prestatie_curent = (_prestatii[0].get("cod_prestatie") or "").strip().upper()
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
ctx = {
|
||||
"request": request,
|
||||
"csrf_token": get_csrf_token(request),
|
||||
@@ -882,6 +938,9 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
|
||||
# PRD 5.7: mapare inline (operatii nemapate ale acestui rand + nomenclator)
|
||||
"nemapate_inline": nemapate_inline,
|
||||
"nomenclator": nomenclator,
|
||||
# US-006: select cod_prestatie pentru stari editabile
|
||||
"nomenclator_rar": nomenclator_rar,
|
||||
"cod_prestatie_curent": cod_prestatie_curent,
|
||||
"corectie_msg": message,
|
||||
"corectie_error": error,
|
||||
"corectie_errors": corectie_errors or [],
|
||||
@@ -1011,6 +1070,31 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
||||
if isinstance(val, str) and val.strip() != "":
|
||||
content[camp] = val.strip()
|
||||
|
||||
# US-006: injectare cod_prestatie din form INAINTE de resolve_prestatii.
|
||||
# Oglindeste validarea din post_mapeaza_inline (nomenclator check). Codul nou
|
||||
# e injectat in prima prestatie (index 0); build_key il include in hash (CLAUDE.md
|
||||
# invariant "build_key hashuieste cod_prestatie, idempotency.py:34").
|
||||
_cod_raw = form.get("cod_prestatie")
|
||||
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
|
||||
if cod_prestatie_form:
|
||||
exists_nom = conn.execute(
|
||||
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,)
|
||||
).fetchone()
|
||||
if not exists_nom:
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(
|
||||
request, row, conn=conn, account_id=account_id, error=True,
|
||||
message=f"Cod RAR necunoscut in nomenclator: {cod_prestatie_form}. "
|
||||
"Alege un cod valid din lista.",
|
||||
),
|
||||
)
|
||||
prestatii_form = content.get("prestatii")
|
||||
if isinstance(prestatii_form, list) and prestatii_form:
|
||||
p0 = dict(prestatii_form[0])
|
||||
p0["cod_prestatie"] = cod_prestatie_form
|
||||
content["prestatii"] = [p0] + list(prestatii_form[1:])
|
||||
|
||||
# Re-rezolva prestatiile cu maparea curenta (ca reresolve_account): NU re-pune
|
||||
# niciodata in coada un cod nemapat (codPrestatie null) — FINALIZATA e ireversibil
|
||||
# la RAR. Corectia campurilor de continut nu poate deebloca o operatie nemapata.
|
||||
@@ -1133,14 +1217,114 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
|
||||
async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse:
|
||||
"""Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard.
|
||||
|
||||
Scoped pe sesiune (404 cross-account/inexistent, 409 sent/sending). Re-randeaza
|
||||
panoul de detaliu cu starea noua + nudge `trimiteriChanged` pentru lista.
|
||||
US-006b: daca randul e in starea `error` si formularul contine `cod_prestatie`,
|
||||
actualizeaza codul in payload, recalculeaza cheia de idempotency si re-pun in coada
|
||||
direct (fara `requeue_submission`, care nu actualizeaza cheia). Scoped pe sesiune
|
||||
(404 cross-account/inexistent, 409 sent/sending). Re-randeaza panoul de detaliu cu
|
||||
starea noua + nudge `trimiteriChanged` pentru lista.
|
||||
"""
|
||||
account_id = require_login(request)
|
||||
form = await request.form()
|
||||
verify_csrf(request, str(form.get("csrf_token") or ""))
|
||||
conn = get_connection()
|
||||
try:
|
||||
# US-006b: prelucrare cod_prestatie pentru starea error (inaintea requeue_submission
|
||||
# standard, care nu actualizeaza cheia de idempotency).
|
||||
_cod_raw = form.get("cod_prestatie")
|
||||
cod_prestatie_form = (_cod_raw.strip().upper() if isinstance(_cod_raw, str) else "")
|
||||
|
||||
if cod_prestatie_form:
|
||||
row = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="trimitere inexistenta")
|
||||
if row["status"] != "error":
|
||||
# cod_prestatie acceptat DOAR pentru starea error prin /repune
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail="modificarea cod_prestatie prin repune e valida doar pentru starea error",
|
||||
)
|
||||
# Valideaza cod-ul fata de nomenclator
|
||||
exists_nom = conn.execute(
|
||||
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie_form,)
|
||||
).fetchone()
|
||||
if not exists_nom:
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(
|
||||
request, row, conn=conn, account_id=account_id,
|
||||
error=True,
|
||||
message=f"Cod RAR necunoscut: {cod_prestatie_form}. Alege un cod valid.",
|
||||
),
|
||||
)
|
||||
# Parseaza payload si injecteaza cod_prestatie
|
||||
try:
|
||||
content = json.loads(row["payload_json"]) if row["payload_json"] else {}
|
||||
if not isinstance(content, dict):
|
||||
content = {}
|
||||
except (ValueError, TypeError):
|
||||
content = {}
|
||||
prestatii = content.get("prestatii") or []
|
||||
if isinstance(prestatii, list) and prestatii:
|
||||
p0 = dict(prestatii[0])
|
||||
p0["cod_prestatie"] = cod_prestatie_form
|
||||
# sterge cod_op_service/denumire daca exista (codul direct preia prioritate)
|
||||
p0.pop("cod_op_service", None)
|
||||
content["prestatii"] = [p0] + list(prestatii[1:])
|
||||
# Re-rezolva prestatii cu noul cod
|
||||
mapping_meta = load_mapping_meta(conn, account_id)
|
||||
mapping = {op: m["cod_prestatie"] for op, m in mapping_meta.items()}
|
||||
valid_codes = load_nomenclator_codes(conn) or None
|
||||
text_rules = load_text_rules(conn, account_id)
|
||||
resolved, _unmapped = resolve_prestatii(content.get("prestatii"), mapping, valid_codes, text_rules)
|
||||
content["prestatii"] = resolved
|
||||
# Canonicalize + rebuild idempotency key
|
||||
canon = canonicalize_row(content)
|
||||
payload_json = json.dumps(content, ensure_ascii=False)
|
||||
new_key = build_key(account_id, canon)
|
||||
# Verifica coliziune (numai daca cheia s-a schimbat)
|
||||
if new_key != row["idempotency_key"]:
|
||||
dup = conn.execute(
|
||||
"SELECT id FROM submissions WHERE idempotency_key=? AND id<>?",
|
||||
(new_key, row["id"]),
|
||||
).fetchone()
|
||||
if dup:
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(
|
||||
request, row, conn=conn, account_id=account_id,
|
||||
error=True,
|
||||
message=f"Exista deja o trimitere identica (rand #{dup['id']}).",
|
||||
),
|
||||
)
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE submissions SET idempotency_key=?, status='queued', payload_json=?, "
|
||||
"rar_error=NULL, retry_count=0, next_attempt_at=datetime('now'), "
|
||||
"updated_at=datetime('now') WHERE id=? AND account_id=?",
|
||||
(new_key, payload_json, row["id"], account_id),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
return templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(
|
||||
request, row, conn=conn, account_id=account_id,
|
||||
error=True,
|
||||
message="Exista deja o trimitere identica. Operatia a fost oprita.",
|
||||
),
|
||||
)
|
||||
row2 = _fetch_submission_scoped(conn, account_id, submission_id)
|
||||
resp = templates.TemplateResponse(
|
||||
"_trimitere_detaliu.html",
|
||||
_detaliu_ctx(
|
||||
request, row2, conn=conn, account_id=account_id,
|
||||
message="Cod actualizat — randul a fost re-pus in coada.",
|
||||
),
|
||||
)
|
||||
resp.headers["HX-Trigger"] = "trimiteriChanged"
|
||||
return resp
|
||||
|
||||
# Cale normala: fara cod_prestatie → delega la requeue_submission
|
||||
try:
|
||||
requeue_submission(conn, account_id, submission_id)
|
||||
except SubmissionNotFound:
|
||||
|
||||
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexMono-Regular-latin.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Bold-latin.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Medium-latin.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin-ext.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin-ext.woff2
Normal file
Binary file not shown.
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin.woff2
Normal file
BIN
app/web/static/fonts/IBMPlexSans-Regular-latin.woff2
Normal file
Binary file not shown.
BIN
app/web/static/romfast_logo.png
Normal file
BIN
app/web/static/romfast_logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -28,21 +28,13 @@
|
||||
hx-swap="innerHTML"
|
||||
hx-trigger="submit, change, keyup delay:400ms from:input[name='vehicul']"
|
||||
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
|
||||
<div>
|
||||
<label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label>
|
||||
{# US-014/T13: status_filtru (din deep-link ?tab=acasa&status=) pre-selecteaza
|
||||
starea, iar submissions-wrap (hx-include #filtre-trimiteri) o incarca filtrat. #}
|
||||
{% set sf = status_filtru | default('') %}
|
||||
<select id="f-status" name="status">
|
||||
<option value="" {% if not sf %}selected{% endif %}>toate</option>
|
||||
<option value="queued" {% if sf == 'queued' %}selected{% endif %}>in asteptare</option>
|
||||
<option value="sent" {% if sf == 'sent' %}selected{% endif %}>declarate la RAR</option>
|
||||
<option value="needs_mapping" {% if sf == 'needs_mapping' %}selected{% endif %}>lipsa cod</option>
|
||||
<option value="needs_data" {% if sf == 'needs_data' %}selected{% endif %}>date incomplete</option>
|
||||
<option value="error" {% if sf == 'error' %}selected{% endif %}>eroare</option>
|
||||
<option value="sending" {% if sf == 'sending' %}selected{% endif %}>se trimite</option>
|
||||
</select>
|
||||
</div>
|
||||
{# US-003 (PRD 5.10): dropdown status eliminat — inlocuit cu pill-uri in bara de status.
|
||||
Filtrul de stare vine de la pill-uri (/_fragments/submissions?status=X direct).
|
||||
Camp hidden permite reset stare la submit manual din form (Filtreaza). #}
|
||||
<input type="hidden" name="status" value="{{ status_filtru | default('') }}">
|
||||
{# US-004 (PRD 5.10): pagina curenta — actualizata prin OOB swap din _submissions.html.
|
||||
Poll-ul (hx-include="#filtre-trimiteri") include automat pagina curenta (L2 PRD). #}
|
||||
<input type="hidden" id="f-page" name="page" value="1">
|
||||
<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;">
|
||||
|
||||
@@ -21,26 +21,11 @@
|
||||
{# US-005 (5.5): antet standard + link Ajutor ca <details> nativ (fara JS). Toata proza
|
||||
care inainte se repeta inline (scopul maparilor, Auto/Manual) traieste acum AICI,
|
||||
o singura data, ascunsa implicit. #}
|
||||
{# US-010: sectiunea de ajutor (details.ajutor-mapari) eliminata.
|
||||
Empty-state „Nicio operatie nemapata" eliminat — sectiunea ramane goala (fara text). #}
|
||||
<h2 style="font-size:15px; margin:0 0 8px;">De rezolvat</h2>
|
||||
<details class="ajutor-mapari" style="margin:0 0 12px;">
|
||||
<summary class="cardlink" style="display:inline-flex; color:var(--accent); cursor:pointer; padding:4px 0;">Ajutor</summary>
|
||||
<div class="muted" style="font-size:13px; margin-top:8px; max-width:680px;">
|
||||
Maparile leaga o operatie din softul tau (cod intern ROAAUTO) de un cod RAR oficial.
|
||||
Operatiile necunoscute raman blocate in <span class="s-needs_mapping">needs_mapping</span>
|
||||
si NU pleaca la RAR pana le mapezi. Sugestiile (%) vin din potrivire fuzzy pe denumire —
|
||||
verifica-le inainte sa salvezi. <strong>In coada</strong>: <strong>Auto</strong> = la
|
||||
urmatoarele fisiere cu aceasta operatie randurile intra automat in coada;
|
||||
<strong>Manual</strong> = raman pentru verificare, nimic nu pleaca la RAR pana confirmi.
|
||||
La schimbarea unui cod, submission-urile blocate pe acea operatie se re-rezolva automat.
|
||||
</div>
|
||||
</details>
|
||||
|
||||
{% if not pending %}
|
||||
<div class="empty">
|
||||
Nicio operatie nemapata — tot ce a venit s-a tradus in coduri RAR.
|
||||
<a href="/?tab=acasa">Importa un fisier nou</a> daca vrei sa adaugi prezentari.
|
||||
</div>
|
||||
{% else %}
|
||||
{% if pending %}
|
||||
<div data-dt="10">
|
||||
<div class="dt-tools">
|
||||
<input type="search" data-dt-search class="dt-search"
|
||||
@@ -165,15 +150,24 @@
|
||||
{{ 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">
|
||||
{# Salveaza/Sterge in meniu contextual (kebab) — randul ramane ingust. Butoanele se
|
||||
leaga prin form= de cele doua form-uri hx-post definite in prima celula a randului. #}
|
||||
<details class="kebab">
|
||||
<summary aria-label="Actiuni pentru {{ m.cod_op_service }}">⋯</summary>
|
||||
<div class="kebab-menu">
|
||||
<button type="submit" form="map-salv-{{ loop.index }}">Salveaza</button>
|
||||
<button type="submit" form="map-del-{{ loop.index }}" class="danger">Sterge</button>
|
||||
</div>
|
||||
</details>
|
||||
{# US-011: 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,
|
||||
JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
|
||||
<button type="submit" form="map-salv-{{ loop.index }}"
|
||||
class="icon-btn"
|
||||
data-dirty-form="map-salv-{{ loop.index }}"
|
||||
aria-label="Salveaza maparea pentru {{ m.cod_op_service }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||
<path d="M2 2a1 1 0 011-1h7.5L13 3.5V14a1 1 0 01-1 1H3a1 1 0 01-1-1V2zm5 10a2 2 0 100-4 2 2 0 000 4zM3 3v3h6V3H3z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button type="submit" form="map-del-{{ loop.index }}"
|
||||
class="icon-btn danger"
|
||||
aria-label="Sterge maparea pentru {{ m.cod_op_service }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
|
||||
<path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6zM14 3a1 1 0 01-1 1H3a1 1 0 110-2h3.5l1-1h2l1 1H13a1 1 0 011 1zm-1 1H3v9a1 1 0 001 1h8a1 1 0 001-1V4z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
@@ -187,79 +181,8 @@
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Sectiunea 3: Formate de coloane salvate (column_mappings) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
|
||||
|
||||
{% if not column_formats %}
|
||||
<div class="empty">
|
||||
Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului
|
||||
se retine aici si se reaplica automat la fisierele cu acelasi antet.
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
|
||||
</p>
|
||||
|
||||
<div data-dt="10">
|
||||
<div class="dt-tools">
|
||||
<input type="search" data-dt-search class="dt-search"
|
||||
placeholder="Cauta coloana sau camp..." aria-label="Cauta in formatele de coloane">
|
||||
</div>
|
||||
<div class="tablewrap tabel-card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Coloane</th>
|
||||
<th>Mapari (coloana → camp)</th>
|
||||
<th>Format data</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for f in column_formats %}
|
||||
<tr>
|
||||
<td style="white-space:nowrap;" data-eticheta="Coloane">
|
||||
<strong>{{ f.columns | length }} coloane</strong>
|
||||
</td>
|
||||
<td class="muted" style="font-size:12px; white-space:normal; max-width:340px;" data-eticheta="Mapari (coloana → camp)">
|
||||
{% for col, camp in f.mappings.items() %}
|
||||
<span class="sugg">{{ col }}</span> → {{ camp }}{% if not loop.last %}; {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td data-eticheta="Format data">
|
||||
<form id="fmt-edit-{{ loop.index }}" hx-post="/formate-coloane/editeaza"
|
||||
hx-target="#mapari-section" hx-swap="outerHTML"
|
||||
style="display:flex; gap:6px; align-items:center;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="format_id" value="{{ f.id }}">
|
||||
<input type="text" name="format_data" value="{{ f.format_data or '' }}"
|
||||
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
|
||||
<button type="submit">Salveaza data</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
|
||||
hx-confirm="Stergi acest format de coloane?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="format_id" value="{{ f.id }}">
|
||||
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
|
||||
Sterge
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dt-empty" data-dt-empty style="display:none;">Niciun format nu se potriveste cautarii.</div>
|
||||
<div class="dt-pager" data-dt-pager></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Sectiunea 4: Reguli automate pe text (operation_text_rules) -->
|
||||
<!-- Sectiunea 3: Reguli automate pe text (operation_text_rules) -->
|
||||
<!-- US-010: mutata pe pozitia 3 (inainte de Formate de coloane) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 8px;">Reguli automate (text)</h2>
|
||||
@@ -354,4 +277,77 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================================ -->
|
||||
<!-- Sectiunea 4: Formate de coloane salvate (column_mappings) -->
|
||||
<!-- US-010: mutata pe pozitia 4 (dupa Reguli automate) -->
|
||||
<!-- ============================================================ -->
|
||||
<div class="card">
|
||||
<h2 style="font-size:15px; margin:0 0 12px;">Formate de coloane salvate</h2>
|
||||
|
||||
{% if not column_formats %}
|
||||
<div class="empty">
|
||||
Niciun format de coloane salvat inca. La primul import, maparea coloanelor fisierului
|
||||
se retine aici si se reaplica automat la fisierele cu acelasi antet.
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">
|
||||
Antetele de fisier recunoscute. Un fisier cu alte coloane = format nou separat.
|
||||
</p>
|
||||
|
||||
<div data-dt="10">
|
||||
<div class="dt-tools">
|
||||
<input type="search" data-dt-search class="dt-search"
|
||||
placeholder="Cauta coloana sau camp..." aria-label="Cauta in formatele de coloane">
|
||||
</div>
|
||||
<div class="tablewrap tabel-card">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th>Coloane</th>
|
||||
<th>Mapari (coloana → camp)</th>
|
||||
<th>Format data</th>
|
||||
<th></th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
{% for f in column_formats %}
|
||||
<tr>
|
||||
<td style="white-space:nowrap;" data-eticheta="Coloane">
|
||||
<strong>{{ f.columns | length }} coloane</strong>
|
||||
</td>
|
||||
<td class="muted" style="font-size:12px; white-space:normal; max-width:340px;" data-eticheta="Mapari (coloana → camp)">
|
||||
{% for col, camp in f.mappings.items() %}
|
||||
<span class="sugg">{{ col }}</span> → {{ camp }}{% if not loop.last %}; {% endif %}
|
||||
{% endfor %}
|
||||
</td>
|
||||
<td data-eticheta="Format data">
|
||||
<form id="fmt-edit-{{ loop.index }}" hx-post="/formate-coloane/editeaza"
|
||||
hx-target="#mapari-section" hx-swap="outerHTML"
|
||||
style="display:flex; gap:6px; align-items:center;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="format_id" value="{{ f.id }}">
|
||||
<input type="text" name="format_data" value="{{ f.format_data or '' }}"
|
||||
placeholder="ex. DD.MM.YYYY" aria-label="Format data" style="max-width:130px;">
|
||||
<button type="submit">Salveaza data</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
<form hx-post="/formate-coloane/sterge" hx-target="#mapari-section" hx-swap="outerHTML"
|
||||
hx-confirm="Stergi acest format de coloane?">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="format_id" value="{{ f.id }}">
|
||||
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
|
||||
Sterge
|
||||
</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="dt-empty" data-dt-empty style="display:none;">Niciun format nu se potriveste cautarii.</div>
|
||||
<div class="dt-pager" data-dt-pager></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
<style>
|
||||
/* Pill-uri categorii blocate (US-003 PRD 5.10)
|
||||
Culoarea e injectata inline (color_var: --warn / --err) dupa DESIGN.md:
|
||||
Lipsa cod = --warn (chihlimbar), Date incomplete + Eroare = --err (rosu).
|
||||
Activ = fundal pe culoarea categoriei (NU accent albastru — S1/A5). */
|
||||
.pill-cat { transition: background 0.15s, color 0.15s; }
|
||||
.pill-cat:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
/* Activ: background = culoarea categoriei (currentColor = var(--err/--warn) din inline style),
|
||||
text = var(--card) (contrast AA). NU accent albastru (S1/A5 DESIGN.md). */
|
||||
.pill-cat[aria-pressed="true"] { background: currentColor !important; color: var(--card) !important; border-color: currentColor !important; }
|
||||
.pill-cat[aria-pressed="true"] span { background: var(--card) !important; color: currentColor; }
|
||||
</style>
|
||||
<div id="status-bar" class="status-bar card"
|
||||
hx-get="/_fragments/status"
|
||||
hx-trigger="every 15s"
|
||||
@@ -47,36 +59,49 @@
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Necesita atentia ta (US-014): categorii actionabile — link la lista filtrata
|
||||
+ identificatorii primelor randuri blocate. Se randeaza DOAR daca exista randuri
|
||||
blocate; cand contorul ajunge 0 (sters/re-pus/purjat), sectiunea dispare. -->
|
||||
{% if blocate_actionabil %}
|
||||
<!-- Pill-uri categorii blocate (US-003 PRD 5.10): inlocuiesc lista de ID-uri.
|
||||
<button> reale cu aria-pressed, focalizabile, activare Enter/Space.
|
||||
Inactiv = contur+text pe culoarea categoriei; activ = umplere pe culoarea categoriei.
|
||||
Pill ascuns cand n=0 (lista pills_categorii filtreaza deja). -->
|
||||
{% if pills_categorii %}
|
||||
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
|
||||
<div style="font-size:13px; font-weight:600; margin-bottom:8px;">Necesita atentia ta</div>
|
||||
<div style="display:flex; gap:18px; flex-wrap:wrap;">
|
||||
{% for cat in blocate_actionabil %}
|
||||
<div style="min-width:200px;">
|
||||
{# Link: filtreaza lista Trimiteri pe aceasta stare (HTMX in-place) cu fallback
|
||||
deep-link server-side (?tab=acasa&status=...). #}
|
||||
<a class="{{ cat.eticheta[2] }}" style="font-size:13px; font-weight:600; text-decoration:none;"
|
||||
href="/?tab=acasa&status={{ cat.status }}"
|
||||
hx-get="/_fragments/submissions?status={{ cat.status }}"
|
||||
hx-target="#submissions-wrap" hx-swap="innerHTML"
|
||||
onclick="var s=document.getElementById('trimiteri-section'); if(s) s.scrollIntoView({behavior:'smooth'});">
|
||||
{{ cat.eticheta[0] }} ({{ cat.n }}) ›
|
||||
</a>
|
||||
<ul style="list-style:none; margin:6px 0 0; padding:0;">
|
||||
{% for r in cat.randuri %}
|
||||
<li class="muted" style="font-size:12px;">
|
||||
#{{ r.id }} {{ r.vin }}{% if r.nr %} / {{ r.nr }}{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if cat.rest %}
|
||||
<li class="muted" style="font-size:12px;">…si inca {{ cat.rest }}</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
<div style="display:flex; gap:8px; flex-wrap:wrap; align-items:center;">
|
||||
<span style="font-size:12px; color:var(--muted);">Necesita atentie:</span>
|
||||
{% for pill in pills_categorii %}
|
||||
<button type="button"
|
||||
class="pill-cat"
|
||||
aria-pressed="false"
|
||||
hx-get="/_fragments/submissions?status={{ pill.status }}"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
onclick="(function(b){
|
||||
var pressed=b.getAttribute('aria-pressed')==='true';
|
||||
document.querySelectorAll('.pill-cat').forEach(function(x){x.setAttribute('aria-pressed','false');});
|
||||
if(!pressed){b.setAttribute('aria-pressed','true');}
|
||||
var s=document.getElementById('trimiteri-section');
|
||||
if(s){s.scrollIntoView({behavior:'smooth'});}
|
||||
})(this)"
|
||||
style="display:inline-flex; align-items:center; gap:5px;
|
||||
padding:3px 10px; border-radius:99px; font-size:12px; font-weight:600;
|
||||
cursor:pointer; border:1.5px solid var({{ pill.color_var }}); color:var({{ pill.color_var }});
|
||||
background:transparent; transition:background 0.15s, color 0.15s;">
|
||||
{{ pill.label }}
|
||||
<span style="font-size:11px; font-weight:700; background:var({{ pill.color_var }}); color:var(--card);
|
||||
padding:0 5px; border-radius:99px; min-width:18px; text-align:center;">{{ pill.n }}</span>
|
||||
</button>
|
||||
{% endfor %}
|
||||
{# Buton "Toate" — reseteaza filtrul de categorie #}
|
||||
<button type="button"
|
||||
class="pill-cat-reset"
|
||||
aria-label="Arata toate trimiterile"
|
||||
hx-get="/_fragments/submissions"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
onclick="document.querySelectorAll('.pill-cat').forEach(function(x){x.setAttribute('aria-pressed','false');})"
|
||||
style="padding:3px 10px; border-radius:99px; font-size:12px; cursor:pointer;
|
||||
border:1px solid var(--line); background:transparent; color:var(--muted);">
|
||||
Toate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
{#
|
||||
OOB: actualizeaza inputul id="f-page" din #filtre-trimiteri (US-004 L2).
|
||||
Poll-ul de 15s (hx-include="#filtre-trimiteri") preia automat pagina curenta.
|
||||
Elementul OOB e extras din continutul normal de HTMX inainte de swap in #submissions-wrap.
|
||||
#}
|
||||
<input type="hidden" id="f-page" name="page" value="{{ page | default(1) }}" hx-swap-oob="true">
|
||||
|
||||
{% if rows %}
|
||||
{# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
|
||||
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
|
||||
@@ -61,7 +68,8 @@
|
||||
<td class="col-vehicul" data-eticheta="Vehicul">
|
||||
{{ r.prez.vehicul_nr }}
|
||||
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
||||
<span class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</span>
|
||||
{# US-005: VIN pe rand separat sub nr (element block, nu span inline) #}
|
||||
<div class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-operatie" data-eticheta="Operatie">
|
||||
@@ -83,6 +91,105 @@
|
||||
</table>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{#
|
||||
Paginare numerotata (US-004 PRD 5.10).
|
||||
Afisata doar cand exista mai mult de o pagina.
|
||||
Fiecare link pastreaza filtrele curente (status, vehicul, data_de, data_pana).
|
||||
Pagina curenta: aria-current="page" (semantic).
|
||||
#}
|
||||
{% if total is defined %}
|
||||
<div aria-live="polite"
|
||||
style="font-size:12px; color:var(--muted); text-align:right; margin-top:6px; margin-bottom:2px;">
|
||||
{% if total == 0 %}
|
||||
0 trimiteri
|
||||
{% else %}
|
||||
{{ page_start }}–{{ page_end }} din {{ total }}
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if pages is defined and pages > 1 %}
|
||||
{#
|
||||
Construim param-string pentru filtrele curente (fara page) — refolosit in fiecare link.
|
||||
Filtrul status vine din pill-uri (nu din form); il pastram in URL.
|
||||
#}
|
||||
{% set pq = "" %}
|
||||
{% if f_status %}{% set pq = pq + "&status=" + f_status %}{% endif %}
|
||||
{% if f_vehicul %}{% set pq = pq + "&vehicul=" + f_vehicul %}{% endif %}
|
||||
{% if f_data_de %}{% set pq = pq + "&data_de=" + f_data_de %}{% endif %}
|
||||
{% if f_data_pana %}{% set pq = pq + "&data_pana=" + f_data_pana %}{% endif %}
|
||||
|
||||
<nav aria-label="Paginare trimiteri"
|
||||
style="display:flex; justify-content:center; gap:4px; flex-wrap:wrap; margin-top:10px;">
|
||||
|
||||
{# Buton Anterior #}
|
||||
{% if page > 1 %}
|
||||
<button type="button"
|
||||
hx-get="/_fragments/submissions?page={{ page - 1 }}{{ pq }}"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
|
||||
border:1px solid var(--line); background:var(--card); color:var(--fg);"
|
||||
aria-label="Pagina anterioara">
|
||||
«
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" disabled
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px;
|
||||
border:1px solid var(--line); background:var(--card); color:var(--muted);
|
||||
opacity:0.4; cursor:default;"
|
||||
aria-label="Pagina anterioara (indisponibila)">
|
||||
«
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
{# Numerele de pagina #}
|
||||
{% for p in range(1, pages + 1) %}
|
||||
{% if p == page %}
|
||||
<button type="button"
|
||||
aria-current="page"
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:default;
|
||||
border:1px solid var(--accent); background:var(--accent); color:#fff;
|
||||
font-weight:700;">
|
||||
{{ p }}
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button"
|
||||
hx-get="/_fragments/submissions?page={{ p }}{{ pq }}"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
|
||||
border:1px solid var(--line); background:var(--card); color:var(--fg);">
|
||||
{{ p }}
|
||||
</button>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{# Buton Urmator #}
|
||||
{% if page < pages %}
|
||||
<button type="button"
|
||||
hx-get="/_fragments/submissions?page={{ page + 1 }}{{ pq }}"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px; cursor:pointer;
|
||||
border:1px solid var(--line); background:var(--card); color:var(--fg);"
|
||||
aria-label="Pagina urmatoare">
|
||||
»
|
||||
</button>
|
||||
{% else %}
|
||||
<button type="button" disabled
|
||||
style="padding:3px 10px; border-radius:6px; font-size:13px;
|
||||
border:1px solid var(--line); background:var(--card); color:var(--muted);
|
||||
opacity:0.4; cursor:default;"
|
||||
aria-label="Pagina urmatoare (indisponibila)">
|
||||
»
|
||||
</button>
|
||||
{% endif %}
|
||||
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% elif filtru_activ %}
|
||||
<div class="empty">
|
||||
Nimic pe filtrul curent.
|
||||
|
||||
@@ -19,8 +19,10 @@
|
||||
<p class="muted" style="margin:0 0 12px; font-size:13px;">{{ stare_subtext }}</p>
|
||||
{% endif %}
|
||||
|
||||
{# === R10 (2): bloc eroare blocanta cand exista === #}
|
||||
{% if erori_3n %}
|
||||
{# === R10 (2): bloc eroare blocanta — DOAR in read-only (US-008).
|
||||
In editare, cardul 3-niveluri e inlocuit cu: erori per-camp in macro `camp`
|
||||
(text simplu .s-error) + rezumat top-of-form pentru erori fara camp (mai jos). === #}
|
||||
{% if not editabil and erori_3n %}
|
||||
<div style="margin:0 0 14px;">
|
||||
{{ card_erori(erori_3n) }}
|
||||
</div>
|
||||
@@ -88,6 +90,13 @@
|
||||
{% if corectie_error %}role="alert"{% endif %}>{{ corectie_msg }}</div>
|
||||
{% endif %}
|
||||
|
||||
{# US-008 (M6): erori fara camp (field None) nu dispar silentios in editare —
|
||||
cardul 3n e ascuns, deci adaugam un rezumat simplu top-of-form.
|
||||
Erori cu camp raman afisate per-camp de macro-ul `camp` de mai jos. #}
|
||||
{% for e in erori_3n if not e.field %}
|
||||
<div class="s-error" style="font-size:13px; margin:0 0 10px;" role="alert">{{ e.problema }}</div>
|
||||
{% endfor %}
|
||||
|
||||
{% macro camp(nume, eticheta, valoare, tip='text') %}
|
||||
<div style="margin-bottom:10px;">
|
||||
<label for="c-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
|
||||
@@ -105,11 +114,40 @@
|
||||
hx-disabled-elt="find button">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
{# US-006: select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
|
||||
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). #}
|
||||
{% if nomenclator_rar %}
|
||||
<div style="margin:0 0 12px;">
|
||||
<label for="c-cod-prestatie" class="muted" style="font-size:12px; display:block;">Operatie RAR (cod prestatie)</label>
|
||||
{% if prez.operatie and prez.operatie != '—' %}
|
||||
<div class="muted" style="font-size:12px; margin-bottom:4px;">{{ prez.operatie }}</div>
|
||||
{% endif %}
|
||||
<select id="c-cod-prestatie" name="cod_prestatie" style="max-width:380px; width:100%;"
|
||||
aria-label="Alege operatia RAR din nomenclator">
|
||||
<option value="">— pastrat ({{ cod_afis }}) —</option>
|
||||
{% for n in nomenclator_rar %}
|
||||
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
|
||||
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Operatie + cod RAR read-only deasupra campurilor (R9, fara eticheta „Cod RAR"). #}
|
||||
<div style="margin:0 0 12px;">
|
||||
<div class="muted" style="font-size:12px;">Operatie</div>
|
||||
<div>{{ prez.operatie }} · {{ cod_afis }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# US-007: operatie service (cod intern + denumire venita prin API/import), distinct de
|
||||
operatia RAR mapata. Conventie US-002: op_service_cod="" cand lipseste → randul absent. #}
|
||||
{% if prez.op_service_cod %}
|
||||
<div style="margin:0 0 12px;">
|
||||
<div class="muted" style="font-size:12px;">Operatie service</div>
|
||||
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
|
||||
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }}
|
||||
@@ -139,6 +177,12 @@
|
||||
<div style="word-break:break-all;">{{ prez.vin }}</div>
|
||||
</div>
|
||||
<div><div class="muted" style="font-size:12px;">Operatie</div><div>{{ prez.operatie }} · {{ cod_afis }}</div></div>
|
||||
{# US-007: operatie service (cod intern + denumire), distinct de operatia RAR.
|
||||
Conventie US-002: op_service_cod="" cand lipseste → randul absent (fara "—"). #}
|
||||
{% if prez.op_service_cod %}
|
||||
<div><div class="muted" style="font-size:12px;">Operatie service</div>
|
||||
<div>{{ prez.op_service_cod }}{% if prez.op_service_denumire %} — {{ prez.op_service_denumire }}{% endif %}</div></div>
|
||||
{% endif %}
|
||||
<div><div class="muted" style="font-size:12px;">Data prestatie</div><div>{{ prez.data_prestatie }}</div></div>
|
||||
<div><div class="muted" style="font-size:12px;">Odometru final</div><div>{{ prez.odometru }}</div></div>
|
||||
</div>
|
||||
@@ -147,12 +191,30 @@
|
||||
{# === R10 (5): actiuni de jos — primar Re-pune (doar error) + Sterge pe RAND SEPARAT (R2/R11) === #}
|
||||
{% if status == 'error' or gestionabil %}
|
||||
<div class="detaliu-actiuni-jos" style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line);">
|
||||
{# R2: error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil). #}
|
||||
{# R2: error -> buton primar „Re-pune in coada" pe /repune (error nu e editabil pentru #}
|
||||
{# campuri vehicul, dar US-006b permite schimbarea cod_prestatie prin acelasi formular). #}
|
||||
{% if status == 'error' %}
|
||||
<form hx-post="/trimitere/{{ id }}/repune"
|
||||
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
|
||||
hx-disabled-elt="find button" style="margin:0 0 10px;">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
{# US-006b: select cod_prestatie optional in formularul /repune (doar pentru error). #}
|
||||
{% if nomenclator_rar %}
|
||||
<label for="cod-rar-error-{{ id }}" style="display:block; font-size:12px; color:var(--muted); margin-bottom:4px;">
|
||||
Operatie RAR (optional — schimba codul si re-pune)
|
||||
</label>
|
||||
<select id="cod-rar-error-{{ id }}" name="cod_prestatie"
|
||||
aria-label="Alege operatia RAR din nomenclator"
|
||||
style="width:100%; margin-bottom:8px; font-size:13px;">
|
||||
<option value="">— pastrat ({{ cod_prestatie_curent }}) —</option>
|
||||
{% for item in nomenclator_rar %}
|
||||
<option value="{{ item.cod_prestatie }}"
|
||||
{% if item.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
|
||||
{{ item.cod_prestatie }} — {{ item.nome_prestatie }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% endif %}
|
||||
<button type="submit">Re-pune in coada</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
@@ -14,12 +14,16 @@
|
||||
htmx.config.useTemplateFragments = true;
|
||||
</script>
|
||||
<script>
|
||||
// Anti-FOUC (US-001 PRD 5.3): citeste preferinta tema din localStorage inainte de
|
||||
// primul paint; seteaza data-theme pe <html> sincron, fara blink dark->light.
|
||||
// Anti-FOUC (US-001 PRD 5.3, extins US-014 PRD 5.10): citeste preferinta tema din
|
||||
// localStorage inainte de primul paint; seteaza data-theme pe <html> sincron, fara blink.
|
||||
// Cunoaste toate cele 4 teme: light/dark/petrol/auto. Valoare legacy/necunoscuta -> auto.
|
||||
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink).
|
||||
(function() {
|
||||
var VALID = {light:1, dark:1, petrol:1, auto:1};
|
||||
try {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (!t) {
|
||||
if (!t || !VALID[t]) t = 'auto'; // fallback: valoare lipsa sau legacy -> auto
|
||||
if (t === 'auto') {
|
||||
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
@@ -29,19 +33,103 @@
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
:root { --bg:#0f1115; --card:#181b22; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
|
||||
--ok:#3ecf8e; --warn:#e6b34a; --err:#e5605e; --accent:#5b8def; }
|
||||
[data-theme="light"] { --bg:#f6f7f9; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
|
||||
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#2563eb; }
|
||||
/* US-013 (PRD 5.10): IBM Plex Sans + Mono self-hosted (latin-ext pentru diacritice romanesti).
|
||||
font-display:swap permite text vizibil inainte de incarcare (FOUT system-ui->IBM Plex).
|
||||
FOUT pe tabular-nums: IBM Plex Sans are metrici apropiate de system-ui; reflow-ul vizibil
|
||||
pe VIN/coduri e acceptat explicit — fontul se incarca din /static/ (acelasi origin).
|
||||
IBM Plex Sans/Mono self-host, subset latin + latin-ext de pe fontsource
|
||||
(@fontsource/ibm-plex-sans + @fontsource/ibm-plex-mono, v5.0.8), woff2 valide. */
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Regular-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Regular-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Medium-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Medium-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Bold-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Sans";
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexSans-Bold-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexMono-Regular-latin-ext.woff2") format("woff2");
|
||||
unicode-range: U+0100-024F, U+0259, U+1E00-1EFF, U+2020, U+20A0-20AB, U+20AD-20CF;
|
||||
}
|
||||
@font-face {
|
||||
font-family: "IBM Plex Mono";
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
font-display: swap;
|
||||
src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* Paleta dark (default) — accent azur ROMFAST conform DESIGN.md */
|
||||
:root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
|
||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; }
|
||||
/* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */
|
||||
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
|
||||
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
|
||||
/* Paleta Petrol (US-014) — tema intunecata alternativa, accent teal #0E7C7B.
|
||||
Wordmark-ul FAST #2E74D6 coexista armonios: ambele sunt reci/saturate, contrast AA pe --card #161e20. */
|
||||
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e;
|
||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
|
||||
* { box-sizing:border-box; }
|
||||
/* PRD 5.9 US-006 — CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
|
||||
CSS custom properties NU functioneaza in `@media`, deci pragul nu poate fi o
|
||||
variabila; folosim consecvent `@media (max-width:767px)` peste tot (mobil) si
|
||||
`@media (max-width:1024px)` doar pentru densitatea tabelului. >=1024px = layout
|
||||
desktop neschimbat (fara regresie). Orice regula mobila noua reutilizeaza 767px. */
|
||||
body { margin:0; font:15px/1.5 ui-sans-serif,system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
body { margin:0; font:15px/1.5 "IBM Plex Sans",system-ui,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
|
||||
background:var(--bg); color:var(--ink); -webkit-font-smoothing:antialiased; }
|
||||
header { padding:16px 24px; border-bottom:1px solid var(--line); display:flex; align-items:center; gap:12px; }
|
||||
/* US-012 (PRD 5.10): grila 3 coloane — stanga (env badge echilibru) | centru (titlu+wordmark) | dreapta (controale). */
|
||||
header { padding:16px 24px; border-bottom:1px solid var(--line);
|
||||
display:grid; grid-template-columns:1fr auto 1fr; align-items:center; gap:8px; }
|
||||
.header-left { display:flex; align-items:center; }
|
||||
.header-center { display:flex; flex-direction:column; align-items:center; text-align:center; }
|
||||
.header-right { display:flex; align-items:center; justify-content:flex-end; gap:8px; }
|
||||
/* US-012b: logo PNG ROMFAST sub titlu — 28px inaltime, centrat, fara filtre de culoare.
|
||||
Logo are fundal transparent + culori proprii (ROM rosu + FAST albastru) -> ok pe toate temele. */
|
||||
.brand-logo { height:28px; width:auto; display:block; margin:3px auto 0; }
|
||||
header h1 { font-size:20px; margin:0; font-weight:700; letter-spacing:-.01em; }
|
||||
header .env { font-size:12px; color:var(--muted); border:1px solid var(--line); padding:2px 8px; border-radius:99px; }
|
||||
main { padding:24px; max-width:1100px; margin:0 auto; }
|
||||
@@ -115,7 +203,7 @@
|
||||
border-radius:0 6px 6px 0; }
|
||||
.eroare-3n-sep { margin-top:6px; }
|
||||
.eroare-3n-problema { font-weight:600; color:var(--err); font-size:13px; }
|
||||
.eroare-3n-camp { font-family:monospace; font-size:12px; opacity:.85; }
|
||||
.eroare-3n-camp { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:12px; opacity:.85; }
|
||||
.eroare-3n-cauza { color:var(--muted); font-size:12px; margin-top:3px; }
|
||||
.eroare-3n-fix { color:var(--accent); font-size:12px; margin-top:3px; }
|
||||
.eroare-3n-label { font-weight:500; }
|
||||
@@ -127,6 +215,11 @@
|
||||
border-radius:6px; min-height:36px; min-width:36px; font-size:16px; padding:4px 8px;
|
||||
line-height:1; display:inline-flex; align-items:center; justify-content:center; }
|
||||
.icon-btn:hover { background:var(--line); }
|
||||
/* US-011: variante icon-btn — dirty (modificari nesalvate) + danger (destructiv) */
|
||||
.icon-btn.dirty { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||||
.icon-btn.dirty:hover { filter:brightness(0.9); }
|
||||
.icon-btn.danger { color:var(--err); border-color:var(--err); }
|
||||
.icon-btn.danger:hover, .icon-btn.danger:focus-visible { background:var(--err); color:#fff; }
|
||||
.cont-menu { position:absolute; right:0; top:calc(100% + 8px); min-width:180px; z-index:50;
|
||||
background:var(--card); border:1px solid var(--line); border-radius:8px; padding:6px;
|
||||
box-shadow:0 8px 24px rgba(0,0,0,.18); display:flex; flex-direction:column; gap:2px; }
|
||||
@@ -182,7 +275,7 @@
|
||||
/* secundarul muted („cod RAR" / „nemapat") — >=12px, contrast pe var(--muted) >=4.5:1 */
|
||||
.tabel-trimiteri .cod-rar-sub { font-size:12px; margin-top:2px; }
|
||||
/* PRD 5.9 US-002: codul RAR pe linia 2 — chip discret, fara prefixul „cod RAR:". */
|
||||
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:ui-monospace,SFMono-Regular,Menlo,monospace;
|
||||
.tabel-trimiteri .cod-rar-cod { display:inline-block; font-family:"IBM Plex Mono",ui-monospace,monospace;
|
||||
font-size:12px; padding:1px 7px; border:1px solid var(--line);
|
||||
border-radius:99px; color:var(--muted); }
|
||||
/* PRD 5.9 US-002 (R1): eticheta umana scurta sub pill — text mic; clasa `s-error`
|
||||
@@ -248,9 +341,13 @@
|
||||
/* US-004 (R11): actiunile de jos din detaliu (Re-pune / Sterge) full-width stivuit pe mobil. */
|
||||
.detaliu-actiuni-jos button { width:100%; }
|
||||
|
||||
/* Header + nav colapsate: header se rupe pe linii, fara scroll orizontal de pagina;
|
||||
tintele touch (toggle tema/cont, taburi, itemi meniu cont) cresc la >=44px. */
|
||||
header { padding:12px 16px; flex-wrap:wrap; gap:8px; }
|
||||
/* Header + nav colapsate: pe mobil trece de la grid la flex wrap.
|
||||
Randul 1: [env badge stanga] [controale dreapta] (margin-left:auto pe .header-right).
|
||||
Randul 2: [titlu + wordmark centrat, full-width]. Fara scroll orizontal, tinte >=44px. */
|
||||
header { display:flex; flex-wrap:wrap; padding:12px 16px; gap:8px; align-items:center; }
|
||||
.header-left { order:0; flex:0 0 auto; }
|
||||
.header-center { order:2; width:100%; text-align:center; }
|
||||
.header-right { order:1; margin-left:auto; flex:0 0 auto; }
|
||||
header h1 { font-size:17px; }
|
||||
main { padding:16px; }
|
||||
.icon-btn { min-height:44px; min-width:44px; }
|
||||
@@ -315,10 +412,21 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{# US-012 (PRD 5.10): grila 3 coloane — stanga (env badge) | centru (titlu+wordmark) | dreapta (controale). #}
|
||||
<header>
|
||||
<h1>Gateway RAR AUTOPASS</h1>
|
||||
<span class="env">{{ rar_env }}</span>
|
||||
<div style="margin-left:auto; display:flex; align-items:center; gap:8px;">
|
||||
{# Celula stanga: badge env (test/prod) — echilibru optic fata de controalele din dreapta #}
|
||||
<div class="header-left">
|
||||
<span class="env">{{ rar_env }}</span>
|
||||
</div>
|
||||
{# Celula centru: titlu + wordmark 'by ROMFAST' #}
|
||||
<div class="header-center">
|
||||
<h1>Gateway RAR AUTOPASS</h1>
|
||||
{# US-012b (decizie user): logo PNG real in loc de wordmark text.
|
||||
288x175 RGBA fundal transparent — lizibil pe light/dark/petrol fara filtre. #}
|
||||
<img src="/static/romfast_logo.png" alt="ROMFAST" class="brand-logo">
|
||||
</div>
|
||||
{# Celula dreapta: comutator tema + versiune + meniu cont #}
|
||||
<div class="header-right">
|
||||
<button id="tema-toggle" class="icon-btn"
|
||||
aria-label="Comuta tema (luminos/intunecat)"
|
||||
title="Comuta tema">☀</button>
|
||||
@@ -331,6 +439,10 @@
|
||||
aria-haspopup="true" aria-expanded="false" aria-controls="cont-menu"
|
||||
aria-label="Meniu cont" title="Meniu cont">☰</button>
|
||||
<div id="cont-menu" class="cont-menu" role="menu" aria-labelledby="cont-menu-toggle" hidden>
|
||||
{# US-009 (PRD 5.10): Mapari mutat din tab-bar in meniu, 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>
|
||||
<hr>
|
||||
<a role="menuitem" href="/?tab=cont">Cont</a>
|
||||
<a role="menuitem" href="/?tab=integrare">Integrare</a>
|
||||
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
|
||||
@@ -346,6 +458,9 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</header>
|
||||
{# aria-live pentru anuntarea schimbarilor de tema (US-014, accesibilitate) #}
|
||||
<span id="tema-live" role="status" aria-live="polite"
|
||||
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;"></span>
|
||||
<main>{% block content %}{% endblock %}</main>
|
||||
{# Modal detaliu trimitere (PRD 5.9 US-003): container global, SIBLING al <main>
|
||||
(nu descendent), ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el (R7).
|
||||
@@ -360,36 +475,46 @@
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Handler comutator tema (US-002 PRD 5.3): click toggle light<->dark, persista in localStorage.
|
||||
// Separare init (doar sincronizare iconita) de persistenta (doar la click explicit).
|
||||
// Motivatie: scrierea in localStorage la init ar ingloba imediat preferinta OS-aware ca alegere
|
||||
// explicita, impiedicand urmarirea ulterioara a modificarilor de tema ale sistemului de operare.
|
||||
// Comutator tema ciclic (US-014 PRD 5.10): click cicleaza Light->Dark->Petrol->Auto.
|
||||
// Separare init (sincronizare iconita/label) de persistenta (doar la click explicit).
|
||||
// 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat.
|
||||
(function() {
|
||||
var btn = document.getElementById('tema-toggle');
|
||||
if (!btn) return;
|
||||
// Sincronizeaza iconita si aria-label dupa tema curenta -- FARA efecte secundare in localStorage.
|
||||
function _syncIcon(t) {
|
||||
if (t === 'light') {
|
||||
btn.innerHTML = '☾';
|
||||
btn.setAttribute('aria-label', 'Comuta tema (intunecat)');
|
||||
btn.title = 'Comuta tema (intunecat)';
|
||||
} else {
|
||||
btn.innerHTML = '☀';
|
||||
btn.setAttribute('aria-label', 'Comuta tema (luminos)');
|
||||
btn.title = 'Comuta tema (luminos)';
|
||||
}
|
||||
var CYCLE = ['light', 'dark', 'petrol', 'auto'];
|
||||
var VALID = {light:1, dark:1, petrol:1, auto:1};
|
||||
// Iconite per tema: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto
|
||||
var ICONS = {light:'☀', dark:'☾', petrol:'◐', auto:'◙'};
|
||||
var LABELS = {light:'Light', dark:'Dark', petrol:'Petrol', auto:'Auto'};
|
||||
var NEXT = {light:'Dark', dark:'Petrol', petrol:'Auto', auto:'Light'};
|
||||
var TOOLTIP_CICLU = 'Ciclu: Light → Dark → Petrol → Auto';
|
||||
|
||||
function _stored() {
|
||||
try { var v = localStorage.getItem('theme'); return (v && VALID[v]) ? v : 'auto'; } catch(e) { return 'auto'; }
|
||||
}
|
||||
function _resolved(stored) {
|
||||
if (stored !== 'auto') return stored;
|
||||
return window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
function _syncButton(stored) {
|
||||
var s = VALID[stored] ? stored : 'auto';
|
||||
btn.innerHTML = ICONS[s];
|
||||
btn.setAttribute('aria-label', 'Tema: ' + LABELS[s] + ', apasa pentru ' + NEXT[s]);
|
||||
btn.title = 'Tema: ' + LABELS[s] + '. ' + TOOLTIP_CICLU;
|
||||
}
|
||||
// Aplica o tema noua, seteaza data-theme si persista in localStorage -- apelat DOAR la click.
|
||||
function _setTheme(t) {
|
||||
document.documentElement.setAttribute('data-theme', t);
|
||||
document.documentElement.setAttribute('data-theme', _resolved(t));
|
||||
try { localStorage.setItem('theme', t); } catch(e) {}
|
||||
_syncIcon(t);
|
||||
_syncButton(t);
|
||||
var live = document.getElementById('tema-live');
|
||||
if (live) live.textContent = 'Tema: ' + LABELS[t] + (t === 'auto' ? ' (urmeaza sistemul)' : '');
|
||||
}
|
||||
// La init: sincronizeaza doar iconita din data-theme curent (setat deja de scriptul anti-FOUC).
|
||||
_syncIcon(document.documentElement.getAttribute('data-theme') || 'dark');
|
||||
// Init: sincronizeaza iconita din starea stocata (fara a scrie in localStorage).
|
||||
_syncButton(_stored());
|
||||
btn.addEventListener('click', function() {
|
||||
var cur = document.documentElement.getAttribute('data-theme') || 'dark';
|
||||
_setTheme(cur === 'dark' ? 'light' : 'dark');
|
||||
var cur = _stored();
|
||||
var idx = CYCLE.indexOf(cur);
|
||||
_setTheme(CYCLE[(idx + 1) % CYCLE.length]);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
@@ -470,6 +595,19 @@
|
||||
window.addEventListener('resize', function() { closeAll(null); });
|
||||
})();
|
||||
</script>
|
||||
<script>
|
||||
// US-011: dirty state pentru butoanele de salvare din tabelele de mapari.
|
||||
// Cand utilizatorul schimba un select dintr-un form de mapare, butonul de salvare
|
||||
// legat prin data-dirty-form devine evidentiat (clasa "dirty" → fundal --accent).
|
||||
// Starea "dirty" e efemera per-render: un swap outerHTML o reseteaza automat.
|
||||
// Delegare pe document → supravietuieste swap-urilor HTMX (#mapari-section).
|
||||
document.addEventListener('change', function(e) {
|
||||
var el = e.target;
|
||||
if (el.tagName !== 'SELECT' || !el.form || !el.form.id) return;
|
||||
var saveBtn = document.querySelector('button[data-dirty-form="' + el.form.id + '"]');
|
||||
if (saveBtn) saveBtn.classList.add('dirty');
|
||||
});
|
||||
</script>
|
||||
<script>
|
||||
// Cautare + paginare client-side pentru tabele mari (data-dt="<page_size>"). Filtreaza si
|
||||
// pagineaza DOM-ul deja randat (fara cereri server) — potrivit pentru maparile care pot creste
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block content %}
|
||||
|
||||
{# US-007 (5.5): nav-ul ad-hoc (Panou admin + logout) a fost mutat in meniul de cont (☰)
|
||||
din header (base.html). Aici raman doar bara de status + tab-bar-ul de lucru zilnic. #}
|
||||
{# US-009 (PRD 5.10): tab-bar-ul Acasa/Mapari a fost eliminat. Mapari s-a mutat in meniul
|
||||
hamburger (#cont-menu in base.html). Acasa e continutul principal direct — nicio schela ARIA
|
||||
role="tablist"/"tab"/"tabpanel" orfana. Rutele /_fragments/* si deep-link-urile ?tab=
|
||||
raman valide (navigare prin meniu → full page reload). #}
|
||||
|
||||
<!-- Bara de status (US-002): mereu vizibila, deasupra tab-bar-ului -->
|
||||
<!-- Bara de status: mereu vizibila -->
|
||||
<div id="status-bar" class="status-bar card"
|
||||
hx-get="/_fragments/status"
|
||||
hx-trigger="load, every 15s"
|
||||
@@ -12,80 +14,9 @@
|
||||
<div class="empty muted" style="padding:8px 0;">se incarca starea…</div>
|
||||
</div>
|
||||
|
||||
<!-- Tab-bar: navigare intre sectiuni -->
|
||||
<div role="tablist" class="tab-bar" aria-label="Sectiuni dashboard">
|
||||
{# US-007 (5.5): tab-bar redus la suprafetele de LUCRU ZILNIC (Acasa·Mapari).
|
||||
Cont/Integrare/Nomenclator s-au mutat in meniul de cont (☰) din header — rutele
|
||||
`/_fragments/{cont,integrare,nomenclator}` + deep-link `?tab=` raman valide. #}
|
||||
{% set tabs = [
|
||||
("acasa", "Acasa", "tab-acasa"),
|
||||
("mapari", "Mapari", "tab-mapari")
|
||||
] %}
|
||||
{% for tab_id, tab_label, tab_elem_id in tabs %}
|
||||
{% set badge = (badges.get(tab_id, 0) if badges else 0) %}
|
||||
<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"
|
||||
{% if badge %}aria-label="{{ tab_label }}, {{ badge }} necesita atentie"{% endif %}
|
||||
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 }}{% if 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;">{{ badge }}</span>{% endif %}</a>
|
||||
{% endfor %}
|
||||
</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">
|
||||
<!-- Panou activ: randat server-side la full load (Acasa implicit, sau ?tab= prin meniu) -->
|
||||
<div id="tab-panel" class="tab-panel">
|
||||
{{ panel_html | safe }}
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
/* Navigare cu sageti intre tab-uri (ARIA pattern) — scoped pe fiecare tablist.
|
||||
Folosim querySelectorAll pentru a suporta multiple tablist-uri pe pagina
|
||||
(tab-bar principal + tab-urile interne din panoul Integrare). */
|
||||
document.querySelectorAll('[role="tablist"]').forEach(function(tablist) {
|
||||
var tabs = Array.from(tablist.querySelectorAll('[role="tab"]'));
|
||||
if (!tabs.length) return;
|
||||
|
||||
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();
|
||||
}
|
||||
});
|
||||
|
||||
/* La click pe tab: actualizeaza aria-selected + tabindex (scoped pe tablist-ul curent) */
|
||||
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