feat(5.12): modal editare + cont obligatoriu la import; design.md + PRD 5.13 revizuit (/autoplan)

5.12 (livrat): editare in modal a randurilor de preview, cont obligatoriu inainte de
import, formular editare extras (_form_editare, _editare_preview_modal), plus suita de
teste aferenta (preview edit/compact, mapare op, form editare, signup, admin panel).

Design + planificare:
- docs/design.md: sistem de design (tokeni, breakpoints, scara control, componente, a11y).
- docs/prd/prd-5.12-* si prd-5.13-* (5.13 cu raport /autoplan: CEO+Design+Eng, audit trail).

Curatare: sterse PNG-urile de test/mockup temporare din radacina.

Nota: implementarea CSS 5.13 (responsive compact + sistem butoane) NU e inca facuta —
planul revizuit cere refactorul testelor fragile din test_web_responsive.py INAINTE de CSS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-27 18:52:20 +00:00
parent 283299ff20
commit b26dbb79e1
44 changed files with 4852 additions and 305 deletions

View File

@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.templating import Jinja2Templates
from .. import __version__
from ..accounts import list_accounts, set_active, set_status, delete_account
from ..accounts import account_is_complete, list_accounts, set_active, set_status, delete_account
from ..config import get_settings
from ..db import get_connection
from ..web.csrf import get_csrf_token, verify_csrf
@@ -48,6 +48,8 @@ def _render_admin(request: Request, conn, *, error: str | None = None, status_co
accounts = list_accounts(conn)
emails = _emails_by_account(conn)
for acct in accounts:
# Computa is_complete INAINTE de a suprascrie accounts.email cu emailul de login al userului
acct["is_complete"] = account_is_complete(acct)
acct["email"] = emails.get(acct["id"])
# Grupare pe STARE, nu pe `active`: altfel conturile arhivate/blocate (active=0)
# ar cadea gresit sub "in asteptare". 'deleted' e deja exclus din list_accounts.
@@ -85,6 +87,12 @@ def _apply_lifecycle(conn, ids: list[int], action: str) -> None:
for aid in ids:
try:
if action == "activate":
# Gate US-002: nu activam conturi fara identitate completa (companie+email+CUI)
acct_row = conn.execute(
"SELECT id, name, cui, email FROM accounts WHERE id=?", (aid,)
).fetchone()
if acct_row and not account_is_complete(acct_row):
continue # sarim activarea — contul incomplet ramane pending
set_status(conn, aid, "active")
elif action == "block":
set_status(conn, aid, "blocked")

View File

@@ -69,6 +69,16 @@ async def signup_post(
name=name, cui=cui, email=email,
), status_code=422)
# CUI obligatoriu la signup (US-001, PRD 5.12)
cui_norm = cui.strip().upper() if cui else ""
if not cui_norm:
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error="CUI-ul firmei este obligatoriu.",
name=name, cui=cui, email=email,
), status_code=422)
# Bootstrap admin: count_admins se citeste INAUNTRUL tranzactiei BEGIN IMMEDIATE,
# astfel lock-ul RESERVED serializeaza scriitorii si al doilea signup vede count==1.
conn = get_connection()
@@ -76,10 +86,43 @@ async def signup_post(
conn.execute("BEGIN IMMEDIATE")
try:
is_first = count_admins(conn) == 0
account_id = create_account(conn, name, cui.strip() or None, active=False)
account_id = create_account(conn, name, cui=cui_norm, email=email, active=False)
user_id = create_user(conn, account_id, email, parola, is_admin=is_first)
api_key = create_api_key(conn, account_id)
conn.execute("COMMIT")
except ValueError as exc:
conn.execute("ROLLBACK")
exc_msg = str(exc)
# Ordinea conteaza: verifica EMAIL inainte de CUI (ambele contin 'deja folosit').
# create_user ridica exact "email deja folosit"; create_account ridica "CUI X e deja folosit".
if "email deja folosit" in exc_msg:
# Email duplicat -> mesaj specific emailului (T3, D#14-email)
error_msg = (
"Acest email este deja folosit. "
"Daca ai deja cont, autentifica-te."
)
elif "deja folosit" in exc_msg or "IntegrityError" in exc_msg:
# CUI duplicat -> mesaj prietenos, NU mesajul tehnic cu 'activate --account' (T3, D#14)
settings = get_settings()
if settings.support_email:
error_msg = (
f"Aceasta firma (CUI {cui_norm}) e deja inregistrata. "
f"Cere accesul de la administratorul contului sau contacteaza suportul: "
f"{settings.support_email}"
)
else:
error_msg = (
f"Aceasta firma (CUI {cui_norm}) e deja inregistrata. "
f"Cere accesul de la administratorul contului."
)
else:
error_msg = exc_msg
return _TMPL.TemplateResponse(request, "signup.html", _ctx(
request,
csrf_token=get_csrf_token(request),
error=error_msg,
name=name, cui=cui, email=email,
), status_code=422)
except Exception as exc:
conn.execute("ROLLBACK")
return _TMPL.TemplateResponse(request, "signup.html", _ctx(

View File

@@ -174,13 +174,16 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
Scoped pe contul sesiunii — identic cu pattern-ul din restul rutelor (NULL->1).
"""
from ..mapping import account_or_default
from ..accounts import account_is_complete as _acct_is_complete
acct = account_or_default(account_id)
# Pas 1: are credentiale RAR configurate?
# Pas 1: are credentiale RAR configurate? + metadate cont (pentru banner incomplet)
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
# Banner cont incomplet (US-002): contul nu are companie + email + CUI complete
cont_incomplet = not _acct_is_complete(row) if row else False
# Pas 3: are cel putin un submission (trimis sau in coada)?
row_sub = conn.execute(
@@ -214,6 +217,8 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
"versiune_trimiteri": _trimiteri_versiune(conn, account_id),
# Acasa include caseta de upload -> are nevoie de csrf_token
"csrf_token": get_csrf_token(request),
# Banner ne-blocant (US-002): contul nu are identitate completa (companie+email+CUI)
"cont_incomplet": cont_incomplet,
}
@@ -266,8 +271,11 @@ 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()
row = conn.execute(
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
account_meta = _fetch_account_meta(conn, acct)
return templates.get_template("_cont.html").render({
"request": request,
"csrf_token": get_csrf_token(request),
@@ -276,6 +284,9 @@ def _render_panel_cont(request: Request, conn, account_id: int) -> str:
"creds_mesaj": None,
"creds_eroare": None,
"rot_eroare": None,
"account_meta": account_meta,
"date_firma_mesaj": None,
"date_firma_eroare": None,
})
@@ -1865,7 +1876,8 @@ def _web_compute_preview(
return "Batch de import inexistent sau inaccesibil."
raw_rows_db = conn.execute(
"SELECT row_index, raw_json, override_json FROM import_rows WHERE batch_id=? ORDER BY row_index",
"SELECT row_index, raw_json, override_json, reviewed FROM import_rows "
"WHERE batch_id=? ORDER BY row_index",
(import_id,),
).fetchall()
if not raw_rows_db:
@@ -1874,6 +1886,7 @@ def _web_compute_preview(
# Decripteaza randurile + override-urile editate
rows: list[dict[str, Any]] = []
overrides: list[dict[str, Any]] = []
reviewed_flags: list[bool] = []
for r in raw_rows_db:
try:
row_data = decrypt_creds(r["raw_json"]) or {}
@@ -1885,6 +1898,7 @@ def _web_compute_preview(
except Exception:
ov = None
overrides.append(ov or {})
reviewed_flags.append(bool(r["reviewed"]))
col_names = list(rows[0].keys()) if rows else []
sig = _signature(col_names)
@@ -1952,6 +1966,7 @@ def _web_compute_preview(
override=overrides[i] or None,
valid_codes=valid_codes,
text_rules=text_rules,
reviewed=reviewed_flags[i],
)
key: str | None = None
@@ -2161,12 +2176,14 @@ async def web_upload_import(
if sugg:
fuzzy_suggestions[col] = sugg
_sample = parsed.rows[:3]
return templates.TemplateResponse("_mapcoloane.html", {
"request": request,
"import_id": batch_id_int,
"filename": filename,
"columns": parsed.columns,
"sample_rows": parsed.rows[:3],
"sample_rows": _sample,
"prima_inregistrare": _sample[0] if _sample else None,
"fuzzy_suggestions": fuzzy_suggestions,
"canonical_fields": _CANONICAL_FIELDS,
"format_data": None,
@@ -2379,23 +2396,63 @@ def _render_preview_rand(
})
@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse)
def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Intra in mod editare pe un rand de preview (randul devine FORM propriu)."""
@router.get("/_import/{import_id}/rand/{row_index}/editare-modal", response_class=HTMLResponse)
def web_rand_editare_modal(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Fragment editare rand preview in modalul global (#detaliu-modal-body).
US-006 (PRD 5.12): inlocuieste editarea inline (tr.preview-edit) care cauza
colapsare vizuala si eroare JS la Anuleaza (R5). Randeaza _editare_preview_modal.html.
Campurile vehicul/data/odometru sunt preluate din starea curenta (resolved + override).
"""
account_id = require_login(request)
conn = get_connection()
try:
result, row = _preview_one_row(conn, import_id, account_id, row_index)
if row is None or isinstance(result, str):
raise HTTPException(status_code=404, detail="rand de import inexistent")
return _render_preview_rand(
request, import_id=import_id, row=row, editing=True,
include_oob=False, summary=result["summary"],
)
res = row.get("resolved") or {}
err_map: dict[str, str] = {}
fix_map: dict[str, str] = {}
for e in (row.get("errors") or []):
if isinstance(e, dict) and e.get("field"):
err_map[e["field"]] = e.get("message") or e.get("msg") or ""
if e.get("fix"):
fix_map[e["field"]] = e["fix"]
return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request,
"import_id": import_id,
"row_index": row_index,
"csrf_token": get_csrf_token(request),
"vin": res.get("vin") or "",
"stare_css": row.get("stare_css") or "",
"stare_eticheta": row.get("stare_eticheta") or "",
"form_nr": res.get("nr_inmatriculare") or "",
"form_vin": res.get("vin") or "",
"form_data": res.get("data_prestatie") or "",
"form_odo_final": str(res.get("odometru_final") or ""),
"form_odo_initial": str(res.get("odometru_initial") or ""),
"err_map": err_map,
"fix_map": fix_map,
"vin_context": res.get("vin") or "",
"btn_label": "Salveaza",
"message": None,
# T2 (US-007): butonul 'Confirma valorile' apare DOAR pe randurile needs_review.
"is_needs_review": row.get("resolved_status") == "needs_review",
})
finally:
conn.close()
@router.get("/_import/{import_id}/rand/{row_index}/editare", response_class=HTMLResponse)
def web_rand_editare(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Fragment editare rand preview in modal — alias al /editare-modal.
US-006: editarea inline eliminata; ruta pastrata pentru compatibilitate cu
apeluri externe / teste existente. Delega la web_rand_editare_modal.
"""
return web_rand_editare_modal(request, import_id, row_index)
@router.get("/_import/{import_id}/rand/{row_index}", response_class=HTMLResponse)
def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Iese din mod editare (Anuleaza) — re-randeaza randul read-only + OOB contoare."""
@@ -2415,11 +2472,17 @@ def web_rand_display(request: Request, import_id: int, row_index: int) -> HTMLRe
@router.post("/_import/{import_id}/rand/{row_index}/editeaza", response_class=HTMLResponse)
async def web_editeaza_rand(request: Request, import_id: int, row_index: int) -> HTMLResponse:
"""Persista override (mutatie pura) + re-randeaza DOAR randul.
"""Persista override (mutatie pura) + raspunde cu OOB rand+contoare sau erori in modal.
Statusul e rederivat prin `_resolve_row_for_preview`. Swap pe rand + OOB contoare.
Daca raman erori de continut pe camp, randul ramane in editare cu valorile pastrate
si mesajul pe campul vinovat."""
US-006 (PRD 5.12):
- Succes: raspuns cu OOB pe rand (#preview-row-N) + OOB contoare (#preview-rezumat) +
header HX-Trigger-After-Settle:inchideModal (modalul se inchide, OOB se aplica).
- Eroare camp: re-randeaza _editare_preview_modal.html cu valorile introduse + erorile
per-camp; modalul RAMANE DESCHIS; NU se emite inchideModal.
INVARIANT CRITIC (R2): submissions NEATINS — override-only pe import_rows.override_json,
NU re-queue, NU insereaza in submissions.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
@@ -2430,7 +2493,7 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
conn = get_connection()
try:
# Mutatie pura de stocare (404/409/422 -> propaga; htmx hx-on::response-error
# pastreaza randul + valorile la 4xx/5xx).
# pastreaza formularul modal cu valorile la 4xx/5xx).
apply_row_override(
conn, import_id=import_id, account_id=account_id,
row_index=row_index, fields=fields,
@@ -2443,15 +2506,120 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
if isinstance(e, dict) and e.get("field")
]
if field_errors:
return _render_preview_rand(
request, import_id=import_id, row=row, editing=True,
include_oob=True, summary=result["summary"],
message="Mai sunt valori invalide — corecteaza campurile marcate.",
)
return _render_preview_rand(
request, import_id=import_id, row=row, editing=False,
include_oob=True, summary=result["summary"],
)
# Eroare de validare: re-randeaza formularul in modal cu valorile introduse.
# Modalul RAMANE DESCHIS (fara HX-Trigger-After-Settle:inchideModal).
res = row.get("resolved") or {}
err_map: dict[str, str] = {}
fix_map: dict[str, str] = {}
for e in field_errors:
if e.get("field"):
err_map[e["field"]] = e.get("message") or e.get("msg") or ""
if e.get("fix"):
fix_map[e["field"]] = e["fix"]
return templates.TemplateResponse("_editare_preview_modal.html", {
"request": request,
"import_id": import_id,
"row_index": row_index,
"csrf_token": get_csrf_token(request),
"vin": res.get("vin") or "",
"stare_css": row.get("stare_css") or "",
"stare_eticheta": row.get("stare_eticheta") or "",
# Valorile DIN FORM (pentru ca userul sa vada ce a introdus):
"form_nr": str(form.get("nr_inmatriculare") or res.get("nr_inmatriculare") or ""),
"form_vin": str(form.get("vin") or res.get("vin") or ""),
"form_data": str(form.get("data_prestatie") or res.get("data_prestatie") or ""),
"form_odo_final": str(form.get("odometru_final") or res.get("odometru_final") or ""),
"form_odo_initial": str(form.get("odometru_initial") or res.get("odometru_initial") or ""),
"err_map": err_map,
"fix_map": fix_map,
"vin_context": res.get("vin") or "",
"btn_label": "Salveaza",
"message": "Mai sunt valori invalide — corecteaza campurile marcate.",
})
# Succes: OOB swap rand + contoare + inchideModal.
# Continut primar (swap in #detaliu-modal-body): stub invizibil + script recalc.
# OOB: <tr> actualizat + rezumat + contor.
# HX-Trigger-After-Settle: inchideModal → base.html JS inchide modalul.
oob_content = templates.get_template("_preview_rand.html").render({
"request": request,
"import_id": import_id,
"row": row,
"editing": False,
"oob_tr": True,
"include_oob": True,
"summary": result["summary"],
"message": None,
"csrf_token": get_csrf_token(request),
})
html_body = '<div style="display:none;"></div>' + oob_content
resp = HTMLResponse(content=html_body)
resp.headers["HX-Trigger-After-Settle"] = "inchideModal"
return resp
finally:
conn.close()
@router.post("/_import/{import_id}/rand/{row_index}/confirma-review", response_class=HTMLResponse)
async def web_confirma_review(
request: Request,
import_id: int,
row_index: int,
) -> HTMLResponse:
"""Confirma explicit valorile unui rand needs_review → seteaza reviewed=1 in DB.
US-007 (PRD 5.12), T2: butonul 'Confirma valorile' din modal seteaza reviewed=1
pentru randul indicat. La recalcul (_web_compute_preview), randul cu reviewed=1
si fara erori de validare reale devine ok (nu mai e blocat de flaguri ambigue).
Guard: 404 cross-account (scoping JOIN), 409 batch committed.
Raspunde OOB (rand + contoare) + HX-Trigger-After-Settle:inchideModal,
identic cu web_editeaza_rand la succes.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
acct = account_or_default(account_id)
conn = get_connection()
try:
# Scoping: JOIN verifica ca batch-ul apartine contului si ca randul exista.
# Acelasi tipar ca apply_row_override (404 cross-account, 409 committed).
row_db = conn.execute(
"SELECT r.id AS rid, b.status AS bstatus "
"FROM import_rows r JOIN import_batches b ON b.id = r.batch_id "
"WHERE b.id=? AND b.account_id=? AND r.row_index=?",
(import_id, acct, row_index),
).fetchone()
if not row_db:
raise HTTPException(status_code=404, detail="rand de import inexistent")
if row_db["bstatus"] == "committed":
raise HTTPException(status_code=409, detail="batch deja comis; confirmarea nu mai are efect")
# Seteaza reviewed=1 — marcaj separat, NU camp de continut (NU intra in override_json/payload).
conn.execute("UPDATE import_rows SET reviewed=1 WHERE id=?", (row_db["rid"],))
# Recalculeaza preview: randul cu reviewed=1 + fara erori reale devine ok.
result, row = _preview_one_row(conn, import_id, account_id, row_index)
if row is None or isinstance(result, str):
raise HTTPException(status_code=404, detail="rand de import inexistent")
# OOB: rand actualizat + rezumat + contor ok + inchideModal (identic cu succes editeaza)
oob_content = templates.get_template("_preview_rand.html").render({
"request": request,
"import_id": import_id,
"row": row,
"editing": False,
"oob_tr": True,
"include_oob": True,
"summary": result["summary"],
"message": None,
"csrf_token": get_csrf_token(request),
})
html_body = '<div style="display:none;"></div>' + oob_content
resp = HTMLResponse(content=html_body)
resp.headers["HX-Trigger-After-Settle"] = "inchideModal"
return resp
finally:
conn.close()
@@ -2499,6 +2667,82 @@ async def web_mapare_operatie(
conn.close()
@router.post("/_import/{import_id}/mapare-operatii", response_class=HTMLResponse)
async def web_mapare_operatii(
request: Request,
import_id: int,
) -> HTMLResponse:
"""Un singur POST salveaza toate maparile de operatii (US-004).
Primeste perechi (cod_op_service, cod_prestatie) ca liste paralele din un singur
<form> cu un select per operatie. Apeleaza save_mapping pentru fiecare pereche cu
cod ales (reuse EXACT, fara logica noua). Perechile cu cod_prestatie gol sunt ignorate.
D#12: per-item — cod invalid -> skip + sumar, restul salvate. O singura recompute
_web_compute_preview + re-randare #import-section la final.
CSRF + scoped sesiune (404 cross-account via _web_compute_preview) + guard committed 409.
"""
account_id = require_login(request)
conn = get_connection()
try:
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
# Guard batch committed (409)
acct = account_or_default(account_id)
batch = conn.execute(
"SELECT id, status FROM import_batches WHERE id=? AND account_id=?",
(import_id, acct),
).fetchone()
if not batch:
raise HTTPException(status_code=404, detail="batch de import inexistent sau inaccesibil")
if batch["status"] == "committed":
raise HTTPException(status_code=409, detail="batch deja comis; maparea nu mai are efect")
# Extrage listele paralele din form (getlist pentru valori multiple cu acelasi name)
ops_list = form.getlist("cod_op_service")
codes_list = form.getlist("cod_prestatie")
salvate: list[str] = []
sarite_invalide: list[str] = []
for cod_op_service, cod_prestatie in zip(ops_list, codes_list):
cod_op_service = str(cod_op_service or "").strip()
cod_prestatie = str(cod_prestatie or "").strip().upper()
# Ignora perechile fara cod ales (selectul ramas pe "— alege cod RAR —")
if not cod_op_service or not cod_prestatie:
continue
# Validare per-item (D#12): cod invalid -> skip + sumar, nu all-or-nothing
exists = conn.execute(
"SELECT 1 FROM nomenclator_rar WHERE cod_prestatie=?", (cod_prestatie,)
).fetchone()
if not exists:
sarite_invalide.append(f"{cod_op_service} ({cod_prestatie} necunoscut)")
continue
save_mapping(conn, account_id, cod_op_service, cod_prestatie, auto_send=False)
salvate.append(f"{cod_op_service} -> {cod_prestatie}")
# Compune mesajul sumar
parts: list[str] = []
if salvate:
parts.append(f"Salvate: {', '.join(salvate)}.")
if sarite_invalide:
parts.append(f"Coduri necunoscute ignorate: {', '.join(sarite_invalide)}.")
message = " ".join(parts) if parts else None
error = bool(sarite_invalide) and not salvate
result = _web_compute_preview(conn, import_id, account_id)
if isinstance(result, str):
return templates.TemplateResponse("_upload.html", _ctx(request, error=result))
return templates.TemplateResponse("_preview_import.html", _ctx(
request, import_id=import_id, message=message, error=error, **result
))
finally:
conn.close()
@router.get("/_import/reset", response_class=HTMLResponse)
def web_import_reset(request: Request) -> HTMLResponse:
"""Reseteaza sectiunea de import la starea initiala (drop zone gol)."""
@@ -2532,14 +2776,11 @@ async def web_confirma_import(
except (ValueError, TypeError):
n_confirmat = 0
# Randuri needs_review bifate explicit
reviewed_rows: set[int] = set()
for v in form.getlist("reviewed_rows"):
if isinstance(v, str):
try:
reviewed_rows.add(int(v))
except (ValueError, TypeError):
pass
# US-007: reviewed_rows (checkboxe vechi) NU mai este sursa de adevar pentru gate-ul
# de commit pe canalul web. Gate-ul este derivat din DB import_rows.reviewed (D#8).
# Randurile needs_review confirmate de operator via /confirma-review au resolved_status='ok'
# in DB (recalculat de _web_compute_preview), asa ca interogarea de mai jos include corect
# TOATE randurile gata de trimis.
confirmed_by = str(form.get("confirmed_by") or "").strip() or None
@@ -2559,10 +2800,19 @@ async def web_confirma_import(
request, message="Acest batch a fost deja comis."
))
# Incarca randurile cu stare ok si needs_review
# Incarca DOAR randurile ok din DB.
# D#8 (PRD 5.12): gate derivat din DB reviewed — randurile needs_review confirmate
# de operator via /confirma-review au resolved_status='ok' (recalculat de
# _web_compute_preview in calea /confirma-review). Randurile needs_review
# neconfirmate sunt excluse (nu au reviewed=1 => raman needs_review in DB).
# Fallback defensiv: includes si needs_review cu reviewed=1 (daca DB a ramas
# neactualizat din vreun motiv — ex. restart intre confirma-review si preview refresh).
ok_rows_db = conn.execute(
"SELECT row_index, raw_json, override_json, resolved_status FROM import_rows "
"WHERE batch_id=? AND resolved_status IN ('ok', 'needs_review') ORDER BY row_index",
"SELECT row_index, raw_json, override_json, resolved_status, reviewed "
"FROM import_rows "
"WHERE batch_id=? AND (resolved_status='ok' OR "
"(resolved_status='needs_review' AND reviewed=1)) "
"ORDER BY row_index",
(import_id,),
).fetchall()
@@ -2584,28 +2834,18 @@ async def web_confirma_import(
# Decripteaza si construieste lista de randuri de trimis
to_enqueue: list[dict[str, Any]] = []
review_indices: set[int] = set()
for r in ok_rows_db:
try:
row_data = decrypt_creds(r["raw_json"]) or {}
except Exception:
continue
if r["resolved_status"] == "ok":
to_enqueue.append({"row_index": r["row_index"], "data": row_data,
"override": _override_of(r), "status": "ok"})
elif r["resolved_status"] == "needs_review":
review_indices.add(r["row_index"])
# Adauga randurile needs_review bifate explicit
for r in ok_rows_db:
if r["resolved_status"] == "needs_review" and r["row_index"] in reviewed_rows:
try:
row_data = decrypt_creds(r["raw_json"]) or {}
to_enqueue.append({"row_index": r["row_index"], "data": row_data,
"override": _override_of(r), "status": "needs_review"})
except Exception:
pass
to_enqueue.append({
"row_index": r["row_index"],
"data": row_data,
"override": _override_of(r),
"status": r["resolved_status"],
})
n_total_ok = len(to_enqueue)
@@ -2816,6 +3056,9 @@ def _render_cont(
creds_mesaj: str | None = None,
creds_eroare: str | None = None,
rot_eroare: str | None = None,
account_meta: dict | None = None,
date_firma_mesaj: str | None = None,
date_firma_eroare: str | None = None,
) -> HTMLResponse:
"""Randeaza cardul 'Contul meu'. Parola niciodata in value=."""
return templates.TemplateResponse(
@@ -2827,22 +3070,41 @@ def _render_cont(
creds_mesaj=creds_mesaj,
creds_eroare=creds_eroare,
rot_eroare=rot_eroare,
account_meta=account_meta or {},
date_firma_mesaj=date_firma_mesaj,
date_firma_eroare=date_firma_eroare,
),
)
def _fetch_account_meta(conn, acct: int) -> dict:
"""Intoarce metadatele contului (id, name, cui, email) pentru sectiunea 'Date firma'."""
row = conn.execute(
"SELECT id, name, cui, email FROM accounts WHERE id=?", (acct,)
).fetchone()
if not row:
return {"id": acct, "name": "", "cui": "", "email": ""}
return {
"id": row["id"],
"name": row["name"] or "",
"cui": row["cui"] or "",
"email": row["email"] or "",
}
@router.get("/_fragments/cont", response_class=HTMLResponse)
def fragment_cont(request: Request) -> HTMLResponse:
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR (fara a le expune)."""
"""Fragment HTMX card 'Contul meu': stare cheie + creds RAR + date firma."""
account_id = require_login(request)
acct = account_or_default(account_id)
conn = get_connection()
try:
row = conn.execute(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
"SELECT id, name, cui, email, rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
return _render_cont(request, are_creds=are_creds)
account_meta = _fetch_account_meta(conn, acct)
return _render_cont(request, are_creds=are_creds, account_meta=account_meta)
finally:
conn.close()
@@ -2863,7 +3125,149 @@ def cont_roteste_cheie(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
return _render_cont(request, api_key=new_key, are_creds=are_creds)
account_meta = _fetch_account_meta(conn, acct)
return _render_cont(request, api_key=new_key, are_creds=are_creds, account_meta=account_meta)
finally:
conn.close()
@router.post("/cont/date-firma", response_class=HTMLResponse)
async def cont_date_firma(request: Request) -> HTMLResponse:
"""Actualizeaza datele firmei (companie, email, CUI) pentru contul din sesiune.
Valideaza campurile (reuse _norm_cui / _norm_email din accounts.py), verifica
unicitatea CUI-ului, actualizeaza accounts.name/email/cui. CSRF enforce.
Scoped pe contul sesiunii (nu poate atinge alt cont).
"""
from ..accounts import _norm_cui, _norm_email
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
acct = account_or_default(account_id)
companie_raw = str(form.get("companie") or "").strip()
email_raw = str(form.get("email") or "")
cui_raw = str(form.get("cui") or "")
# Validare companie
if not companie_raw:
conn = get_connection()
try:
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
account_meta=account_meta,
date_firma_eroare="Compania (numele firmei) este obligatorie.",
)
# Normalizare si validare email
try:
email_norm = _norm_email(email_raw)
except ValueError as exc:
conn = get_connection()
try:
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
date_firma_eroare=f"Email invalid: {exc}",
)
if not email_norm:
conn = get_connection()
try:
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
account_meta={"name": companie_raw, "email": email_raw, "cui": cui_raw},
date_firma_eroare="Email-ul de contact este obligatoriu.",
)
# Normalizare si validare CUI
try:
cui_norm = _norm_cui(cui_raw)
except ValueError as exc:
conn = get_connection()
try:
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
date_firma_eroare=f"CUI invalid: {exc}",
)
if not cui_norm:
conn = get_connection()
try:
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_raw},
date_firma_eroare="CUI-ul firmei este obligatoriu.",
)
# Actualizare in DB
conn = get_connection()
try:
try:
conn.execute(
"UPDATE accounts SET name=?, email=?, cui=? WHERE id=?",
(companie_raw, email_norm, cui_norm, acct),
)
except sqlite3.IntegrityError:
# CUI duplicat (index partial unic ux_accounts_cui)
existing = conn.execute(
"SELECT id FROM accounts WHERE cui=? AND id!=?", (cui_norm, acct)
).fetchone()
owner = existing["id"] if existing else "?"
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
return _render_cont(
request,
are_creds=are_creds,
account_meta={"name": companie_raw, "email": email_norm, "cui": cui_norm},
date_firma_eroare=(
f"CUI-ul {cui_norm} este deja folosit de alt cont (id={owner}). "
"Foloseste un CUI diferit sau contacteaza administratorul."
),
)
account_meta = _fetch_account_meta(conn, acct)
row_cr = conn.execute("SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)).fetchone()
are_creds = bool(row_cr and row_cr["rar_creds_enc"])
return _render_cont(
request,
are_creds=are_creds,
account_meta=account_meta,
date_firma_mesaj="Datele firmei au fost salvate.",
)
finally:
conn.close()
@@ -2949,12 +3353,14 @@ def cont_rar_creds(
"SELECT rar_creds_enc FROM accounts WHERE id=?", (acct,)
).fetchone()
are_creds = bool(row and row["rar_creds_enc"])
account_meta = _fetch_account_meta(conn, acct)
finally:
conn.close()
return _render_cont(
request,
are_creds=are_creds,
creds_eroare="Email si parola sunt obligatorii.",
account_meta=account_meta,
)
enc = encrypt_creds({"email": email, "password": parola})
@@ -2964,10 +3370,12 @@ def cont_rar_creds(
"UPDATE accounts SET rar_creds_enc=? WHERE id=?",
(enc, acct),
)
account_meta = _fetch_account_meta(conn, acct)
return _render_cont(
request,
are_creds=True,
creds_mesaj="Credentialele RAR au fost salvate cu succes.",
account_meta=account_meta,
)
finally:
conn.close()

View File

@@ -1,5 +1,18 @@
<div id="acasa-section">
{# === Banner ne-blocant: cont incomplet (US-002) ===
Apare cand accounts.name / email / CUI sunt necompletate (conturi legacy sau create din CLI).
NU blocheaza importul sau uploadul — doar orienteaza operatorul sa completeze datele.
Dispare automat dupa ce contul devine complet (re-render la urmatoarea navigare/reload).
#}
{% if cont_incomplet %}
<div class="card banner" style="border-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); margin-bottom:14px; padding:10px 14px; font-size:13px;">
<strong>Completeaza datele firmei (email / CUI).</strong>
Contul tau nu are inca email de contact si CUI configurate.
<a href="/?tab=cont" style="margin-left:6px;">Completeaza acum &rarr;</a>
</div>
{% endif %}
{# === Container colapsabil: stepper + upload intr-un singur element <details> (US-006).
Serverul seteaza atributul `open` din are_trimiteri:
are_trimiteri=False (first-run) → open (importul e vizibil imediat, fara JS)

View File

@@ -1,6 +1,47 @@
<div class="card" id="card-cont">
<h2 style="font-size:15px; margin:0 0 16px;">Contul meu</h2>
<!-- Sectiunea: Date firma (US-002) -->
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Date firma</h3>
{% if date_firma_mesaj %}
<div class="flash" style="margin-bottom:12px;">{{ date_firma_mesaj }}</div>
{% endif %}
{% if date_firma_eroare %}
<div class="banner" style="margin-bottom:12px; padding:8px 12px;">{{ date_firma_eroare }}</div>
{% endif %}
<form hx-post="/cont/date-firma"
hx-target="#card-cont"
hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Companie</label><br>
<input type="text" name="companie" required
value="{{ account_meta.name or '' }}"
style="width:100%; max-width:340px;"
placeholder="Numele firmei (ex. Service Auto SRL)">
</p>
<p style="margin:0 0 8px;">
<label style="font-size:13px; color:var(--muted);">Email contact</label><br>
<input type="email" name="email" required
value="{{ account_meta.email or '' }}"
style="width:100%; max-width:340px;"
placeholder="contact@firma.ro">
</p>
<p style="margin:0 0 12px;">
<label style="font-size:13px; color:var(--muted);">CUI (cod unic de identificare)</label><br>
<input type="text" name="cui" required
value="{{ account_meta.cui or '' }}"
style="width:100%; max-width:340px;"
placeholder="RO12345678">
</p>
<button type="submit">Salveaza datele firmei</button>
</form>
</div>
<!-- Sectiunea: Cheia mea API -->
<div style="margin-bottom:20px; padding-bottom:20px; border-bottom:1px solid var(--line);">
<h3 style="font-size:13px; color:var(--muted); font-weight:500; margin:0 0 8px; text-transform:uppercase; letter-spacing:.04em;">Cheia mea API</h3>

View File

@@ -0,0 +1,78 @@
{# _editare_preview_modal.html — fragment de editare rand preview in modalul global.
US-006 (PRD 5.12): swap-uit in #detaliu-modal-body de butonul Editeaza din preview.
US-007 (PRD 5.12): butonul 'Confirma valorile' apare DOAR pe randurile needs_review
(T2): trimite CSRF POST la /confirma-review, inchide modalul via HX-Trigger-After-Settle.
Necesita din context:
import_id — id batch import
row_index — index rand (0-based)
csrf_token — token CSRF
vin — VIN pentru titlu
stare_css — clasa CSS pill (ex. "s-ok")
stare_eticheta — text pill (ex. "Gata de trimis")
message — mesaj de eroare general (None daca nu e)
is_needs_review — True daca randul e in starea needs_review (afiseaza butonul Confirma)
+ variabilele pentru _form_editare.html:
form_nr, form_vin, form_data, form_odo_final, form_odo_initial
err_map, fix_map, vin_context, btn_label
#}
<div class="card" style="border:none; padding:0; margin:0;">
{# Header cu heading accesibil (aria-labelledby al dialogului) #}
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 id="detaliu-modal-titlu" style="font-size:15px; margin:0;">
Editare rand {{ row_index + 1 }}
{% if vin %}<span class="muted" style="font-weight:400; font-size:13px;">· {{ vin }}</span>{% endif %}
</h2>
<span class="pill {{ stare_css }}" style="font-size:11px;">{{ stare_eticheta }}</span>
</div>
{% if message %}
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:12px;"
role="alert">{{ message }}</div>
{% endif %}
<form hx-post="/_import/{{ import_id }}/rand/{{ row_index }}/editeaza"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-disabled-elt="find button"
hx-on::response-error="this.querySelector && this.querySelector('.rand-eroare-banner') && (this.querySelector('.rand-eroare-banner').style.display='block');">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div class="rand-eroare-banner" role="alert"
style="display:none; margin-bottom:10px; padding:8px 12px; border:1px solid var(--err);
background:color-mix(in srgb, var(--err) 12%, var(--card)); border-radius:6px; font-size:13px;">
Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca.
</div>
{% include "_form_editare.html" %}
<div style="margin-top:10px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<button type="button"
style="min-height:44px; padding:8px 18px;
background:var(--card); color:var(--muted); border-color:var(--line);"
data-modal-close>Anuleaza</button>
</div>
</form>
{% if is_needs_review %}
{# T2 (US-007): Butonul 'Confirma valorile' apare DOAR pe randurile needs_review.
POST separat (form propriu) la /confirma-review cu CSRF. Raspunsul inchide
modalul via HX-Trigger-After-Settle: inchideModal + swap OOB randul si countorii. #}
<form hx-post="/_import/{{ import_id }}/rand/{{ row_index }}/confirma-review"
hx-target="#detaliu-modal-body"
hx-swap="innerHTML"
hx-disabled-elt="find button"
style="margin-top:12px; border-top:1px solid var(--line); padding-top:12px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<p class="muted" style="font-size:13px; margin:0 0 8px;">
Valorile sunt corecte si doriesti sa includi acest rand la trimitere la RAR?
</p>
<button type="submit"
style="min-height:44px; padding:8px 18px;
background:var(--ok, #2a7); color:#fff; border-color:transparent;">
Confirma valorile
</button>
</form>
{% endif %}
</div>

View File

@@ -0,0 +1,41 @@
{# _form_editare.html — partial partajat: campurile vehicul/data/odometru.
US-005 (PRD 5.12): extras DRY din _trimitere_detaliu.html; refolosit si de
_preview_rand.html (US-006) pentru editarea randurilor de import in modal.
Inclus cu {% include "_form_editare.html" %} INSIDE un <form> element al
template-ului parinte. Acel parinte pune form-ul, CSRF-ul si orice campuri
suplimentare (ex. select cod_prestatie din _trimitere_detaliu.html).
Necesita din context (setate de parinte inainte de include):
form_nr — valoare curenta nr_inmatriculare
form_vin — valoare curenta vin
form_data — valoare curenta data_prestatie (YYYY-MM-DD sau brut)
form_odo_final — valoare curenta odometru_final
form_odo_initial — valoare curenta odometru_initial
err_map — dict {field_name: mesaj_eroare} (poate fi {})
fix_map — dict {field_name: hint_fix} (poate fi {})
vin_context — string VIN pentru aria-label (poate fi '')
btn_label — eticheta butonului primar (ex. 'Salveaza si retrimite')
#}
{% from "_macros.html" import camp %}
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{{ camp('vin', 'VIN (serie sasiu)', form_vin,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{# Restul campurilor in grila responsiva existenta. #}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:0 16px;">
{{ camp('data_prestatie', 'Data prestatie', form_data, tip='date',
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{{ camp('odometru_final', 'Odometru final', form_odo_final,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial,
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
</div>
{# Buton primar parametrizat. #}
<div style="margin-top:14px;">
<button type="submit">{{ btn_label or 'Salveaza' }}</button>
</div>

View File

@@ -4,3 +4,49 @@
Simbolul pastrat (apelat in _mapari.html, _preview_import.html, _trimitere_detaliu.html)
dar intoarce string gol. Coloanele DB raman (default 1, ne-citite pentru hold). #}
{% macro autosend_toggle(form_id='', checked=True, label='') -%}{%- endmacro %}
{# US-005 (PRD 5.12): macro `camp` partajat — extras din _trimitere_detaliu.html si
_preview_rand.html. Suporta tip='date' (calendar nativ, D#10/R3) si tip='text' (default).
Parametri:
nome — name="" al input-ului (si cheie in err_map/fix_map)
eticheta — text pentru label
valoare — valoarea curenta (pre-fill)
tip — type="" al input-ului: 'text' (default) sau 'date' (calendar nativ)
err_map — dict {field_name: mesaj_eroare}; default {}
fix_map — dict {field_name: hint_fix}; default {}
vin_context — string VIN pentru aria-label cu context (default '')
id_prefix — prefix pentru id="" al input-ului (default 'c'; preview poate folosi 'e-N')
#}
{% macro camp(nome, eticheta, valoare, tip='text', err_map={}, fix_map={}, vin_context='', id_prefix='c') %}
<div style="margin-bottom:10px;">
<label for="{{ id_prefix }}-{{ nome }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
{% if tip == 'date' %}
{# D#10/R3: degradare grijulie pentru valori ne-YYYY-MM-DD.
Daca valoarea nu e in formatul corect, inputul ramane gol + hint + hidden cu valoarea bruta
(ca sa nu se piarda tacut la submit). #}
{%- set _dp_ok = (valoare and valoare|length == 10 and valoare[4:5] == '-' and valoare[7:8] == '-') -%}
<input id="{{ id_prefix }}-{{ nome }}" type="date" name="{{ nome }}"
value="{{ valoare if _dp_ok else '' }}"
style="width:100%; {% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
aria-label="{{ eticheta }}{% if vin_context %} (VIN: {{ vin_context }}){% endif %}"
{% if err_map.get(nome) %}aria-invalid="true"{% endif %}>
{% if not _dp_ok and valoare %}
<input type="hidden" name="data_prestatie_raw" value="{{ valoare }}">
<span class="camp-fix" style="font-size:12px;">Valoarea originala: {{ valoare }}</span>
{% endif %}
{% else %}
<input id="{{ id_prefix }}-{{ nome }}" type="{{ tip }}" name="{{ nome }}"
value="{{ valoare or '' }}"
style="width:100%; {% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
{% if vin_context %}aria-label="{{ eticheta }} (VIN: {{ vin_context }})"{% endif %}
{% if err_map.get(nome) %}aria-invalid="true"{% endif %}>
{% endif %}
{% if err_map.get(nome) %}
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nome) }}</div>
{% endif %}
{% if fix_map.get(nome) %}
<span class="camp-fix" style="font-size:12px;">{{ fix_map.get(nome) }}</span>
{% endif %}
</div>
{% endmacro %}

View File

@@ -1,6 +1,8 @@
<div id="import-section">
{% set pas = 2 %}{% include '_stepper.html' %}
{% from '_eroare.html' import card_erori %}
{# prima_inregistrare poate veni din context (web_upload_import) sau derivat din sample_rows #}
{%- set prima_inreg = prima_inregistrare if prima_inregistrare is defined else (sample_rows[0] if sample_rows else none) -%}
<div class="card">
<h2 style="font-size:15px; margin:0 0 12px;">
Mapare coloane —
@@ -23,6 +25,44 @@
Maparea se retine automat pentru fisiere cu acelasi antet.
</p>
{# Tabel orizontal preview: antet + prima inregistrare (US-003) #}
<div class="tablewrap" style="margin-bottom:16px;">
<table class="preview-antet" style="border-collapse:collapse; font-size:12px; width:100%; min-width:max-content;">
<thead>
<tr>
{% for col in columns %}
<th style="padding:4px 10px; text-align:left; background:var(--card); border:1px solid var(--line);
white-space:nowrap; font-weight:600; font-size:12px; color:var(--ink);">
{{ col }}
</th>
{% endfor %}
</tr>
</thead>
<tbody>
{% if prima_inreg %}
<tr>
{% for col in columns %}
{%- set val = prima_inreg.get(col, '') | string -%}
<td style="padding:4px 10px; border:1px solid var(--line); white-space:nowrap;
font-size:11px; color:var(--muted); max-width:160px; overflow:hidden; text-overflow:ellipsis;"
title="{{ val }}">
{{ val[:40] }}{% if val | length > 40 %}…{% endif %}
</td>
{% endfor %}
</tr>
{% else %}
<tr>
<td colspan="{{ columns | length }}"
style="padding:6px 10px; border:1px solid var(--line); font-size:12px;
color:var(--muted); font-style:italic; text-align:center;">
Antet fara randuri de date
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
<form hx-post="/_import/{{ import_id }}/mapare-coloane"
hx-target="#import-section"
hx-swap="outerHTML">
@@ -87,12 +127,19 @@
<div style="margin-top:16px; display:flex; align-items:center; gap:12px; flex-wrap:wrap;">
<button type="submit"
style="min-height:44px; padding:10px 24px; font-size:14px;">
{% if not prima_inreg %}disabled aria-disabled="true"{% endif %}
style="min-height:44px; padding:10px 24px; font-size:14px;{% if not prima_inreg %} opacity:0.5; cursor:not-allowed;{% endif %}">
Salveaza si continua la preview
</button>
{% if not prima_inreg %}
<span style="font-size:12px; color:var(--err);">
Fisierul nu contine randuri de date — incarca un fisier cu cel putin o inregistrare.
</span>
{% else %}
<span class="muted" style="font-size:12px;">
maparea se retine pentru fisiere cu acelasi antet
</span>
{% endif %}
</div>
</form>

View File

@@ -54,7 +54,8 @@
{% endfor %}
</div>
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload) -->
<!-- Panou inline: operatii fara cod RAR, mapabile in flux (fara re-upload).
US-004: un singur <form> cu un select per operatie + un singur buton Salveaza. -->
{% if unmapped_ops %}
<div class="card" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:14px;">
<h3 style="font-size:14px; margin:0 0 6px;">Operatii de mapat la cod RAR</h3>
@@ -63,51 +64,68 @@
preselectata) si salveaza — randurile blocate trec automat in
<span class="s-ok">ok</span> si maparea se retine pentru fisierele viitoare.
</p>
{% for e in unmapped_ops %}
{%- set top = e.suggestions[0] if e.suggestions else None -%}
{%- set preselect = top.cod_prestatie if (top and top.score >= 60) else '' -%}
<form class="maprow" hx-post="/_import/{{ import_id }}/mapare-operatie"
hx-target="#import-section" hx-swap="outerHTML"
style="align-items:flex-end;">
<form hx-post="/_import/{{ import_id }}/mapare-operatii"
hx-target="#import-section" hx-swap="outerHTML">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
<div class="mapcol grow">
<div><strong>{{ e.cod_op_service }}</strong>
<span class="pill" title="randuri blocate">{{ e.blocked }} randuri</span></div>
{% if e.denumire and e.denumire != e.cod_op_service %}
<div class="muted">{{ e.denumire }}</div>
{% endif %}
{% if e.suggestions %}
<div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
{% for e in unmapped_ops %}
{%- set top = e.suggestions[0] if e.suggestions else None -%}
{%- set preselect = top.cod_prestatie if (top and top.score >= 60) else '' -%}
<div class="maprow" style="align-items:flex-end; margin-bottom:10px;">
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
<div class="mapcol grow">
<div><strong>{{ e.cod_op_service }}</strong>
<span class="pill" title="randuri blocate">{{ e.blocked }} randuri</span></div>
{% if e.denumire and e.denumire != e.cod_op_service %}
<div class="muted">{{ e.denumire }}</div>
{% endif %}
{% if e.suggestions %}
<div class="muted" style="font-size:12px; margin-top:4px;">
sugestii:
{% for s in e.suggestions[:3] %}
<span class="sugg">{{ s.cod_prestatie }} ({{ s.score|round|int }}%)</span>{% if not loop.last %}, {% endif %}
{% endfor %}
</div>
{% endif %}
</div>
<div class="mapcol">
<select name="cod_prestatie" aria-label="Cod RAR pentru {{ e.cod_op_service }}">
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
{% endif %}
</div>
<div class="mapcol">
<select name="cod_prestatie" required aria-label="Cod RAR pentru {{ e.cod_op_service }}">
<option value="">— alege cod RAR —</option>
{% for n in nomenclator %}
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == preselect %}selected{% endif %}>
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
</option>
{% endfor %}
</select>
</div>
<div class="mapcol">
<button type="submit" style="min-height:44px;">Salveaza</button>
{% endfor %}
<div style="margin-top:12px;">
<button type="submit" style="min-height:44px;">Salveaza maparile</button>
</div>
</form>
{% endfor %}
</div>
{% endif %}
<!-- Banner discoverability (T1, US-007): vizibil cand exista randuri needs_review.
Explica operatorului ca randurile cu 'Verifica valori' nu pleaca la RAR
pana le deschide in modal si apasa 'Confirma valorile'. Dispare via OOB
cand summary.needs_review == 0. -->
<div id="preview-needs-review-banner">
{% if summary.get('needs_review', 0) %}
<div class="banner warn" role="note" aria-live="polite"
style="margin-bottom:12px; padding:8px 14px; border-radius:6px;
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
border:1px solid var(--warn, #e6b34a); font-size:13px;">
Randurile cu <span class="pill s-needs_review" style="font-size:11px;">Verifica valori</span>
nu pleaca la RAR pana le deschizi in modal si confirmi in modal
cu butonul <strong>Confirma valorile</strong>.
</div>
{% endif %}
</div>
<!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri).
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form,
altfel Enter intr-un camp ar declansa trimiterea ireversibila). Bifele
needs_review se asociaza la #confirm-form prin atributul form=. -->
US-007: 8 coloane (coloana de verificare eliminata).
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). -->
<div class="tablewrap tabel-trimiteri">
<table>
<thead>
@@ -119,7 +137,6 @@
<th class="col-data">Data</th>
<th class="col-km">KM final</th>
<th class="col-note">Note</th>
<th class="col-verificat">Verificat?</th>
<th class="col-actiuni">Actiuni</th>
</tr>
</thead>
@@ -164,10 +181,7 @@
style="max-width:80px;"
aria-describedby="n-hint">
<span id="n-hint" class="muted" style="font-size:12px; margin-left:6px;">
(<span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> ok
{% if summary.get('needs_review', 0) %}
+ pana la {{ summary.get('needs_review', 0) }} verificate manual
{% endif %})
(<span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> ok)
</span>
</div>
@@ -226,20 +240,19 @@
return el ? parseInt(el.dataset.ok || '0', 10) : 0;
}
/* Actualizeaza N si bannerul cand se bifeaza needs_review SAU cand se editeaza un rand. */
/* Actualizeaza N dupa editare/confirmare rand (OOB).
US-007: reviewed_rows (checkboxe) eliminate; N = randurile ok din DB,
actualizate via OOB (#preview-ok-count[data-ok]) dupa /confirma-review sau /editeaza. */
function updateN() {
var checked = document.querySelectorAll('input[name="reviewed_rows"]:checked').length;
var total = getOk() + checked;
var total = getOk();
var inp = document.getElementById('n-confirmat');
var disp = document.getElementById('n-display');
var btn = document.getElementById('confirm-btn');
/* Nu re-activa confirm cat un rand e in editare (mutual-exclusion). */
var editing = document.querySelector('tr[data-editing="1"]') !== null;
if (inp) inp.value = total;
if (disp) disp.textContent = total;
var hintOk = document.getElementById('n-hint-ok');
if (hintOk) hintOk.textContent = getOk();
if (btn) btn.disabled = (total === 0) || editing;
if (hintOk) hintOk.textContent = total;
if (btn) btn.disabled = (total === 0);
}
/* Filtrare randuri dupa stare.

View File

@@ -1,11 +1,13 @@
{#
_preview_rand.html — un singur rand de preview import.
Doua moduri:
- display (editing falsy): <tr> normal cu 9 coloane in format .tabel-trimiteri.
- edit (editing truthy): <tr class="preview-edit"> (display:block) cu un singur
<td> ce contine un FORM PROPRIU (NU #confirm-form). Escapa grila table-layout:fixed.
Swap pe RAND (hx-target pe #preview-row-N, outerHTML), NU pe #import-section.
La save, optional OOB pe rezumat + contor "gata de trimis" (include_oob).
US-006 (PRD 5.12): editarea inline (tr.preview-edit + mutual-exclusion script)
a fost eliminata. Butonul Editeaza deschide MODALUL global (#detaliu-modal-body).
Parametri:
editing — ELIMINAT (ignorat, pastrat pentru compatibilitate apeluri vechi)
include_oob — True: randeaza OOB rezumat + contor + script recalc (swap dupa save)
oob_tr — True: adauga hx-swap-oob pe <tr> insusi (pentru raspunsul POST succes)
summary — dict cu contoarele per status
Campuri pre-computate de _web_compute_preview (NOT din template raw):
row.prez — prezentare_din_payload(resolved): vehicul_nr, vin_scurt,
@@ -16,87 +18,10 @@
#}
{%- set res = row.resolved -%}
{%- set status = row.resolved_status -%}
{% if editing %}
{%- set err_map = {} -%}
{%- set fix_map = {} -%}
{%- for e in row.errors -%}{%- if e is mapping and e.get('field') -%}{%- set _ = err_map.update({e.field: (e.get('message') or e.get('msg'))}) -%}{%- if e.get('fix') -%}{%- set _ = fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endif -%}{%- endfor -%}
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}" data-editing="1"
class="preview-edit">
<td data-eticheta="" style="padding:0; border:none;">
<form class="rand-editare"
hx-post="/_import/{{ import_id }}/rand/{{ row.row_index }}/editeaza"
hx-target="#preview-row-{{ row.row_index }}"
hx-swap="outerHTML"
hx-indicator="#rand-spinner-{{ row.row_index }}"
hx-disabled-elt="find button"
hx-on::response-error="this.querySelector('.rand-eroare-banner').style.display='block';"
style="padding:12px; background:rgba(91,141,239,.06); border-radius:4px;">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<div style="display:flex; align-items:center; gap:8px; margin-bottom:8px;">
<strong style="font-size:13px;">Editare rand {{ row.row_index + 1 }}</strong>
<span class="pill {{ row.stare_css }}" style="font-size:11px;">{{ row.stare_eticheta }}</span>
</div>
{% if message %}
<div class="flash" style="border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card)); margin-bottom:10px;"
role="alert">{{ message }}</div>
{% endif %}
<div class="rand-eroare-banner" role="alert"
style="display:none; margin-bottom:10px; padding:8px 12px; border:1px solid var(--err);
background:color-mix(in srgb, var(--err) 12%, var(--card)); border-radius:6px; font-size:13px;">
Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca.
</div>
{% macro camp(nume, eticheta, valoare, tip='text') %}
<div>
<label for="e-{{ row.row_index }}-{{ nume }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
<input id="e-{{ row.row_index }}-{{ nume }}" type="{{ tip }}" name="{{ nume }}" value="{{ valoare or '' }}"
style="width:100%; {% if err_map.get(nume) %}border-color:var(--err);{% endif %}"
aria-label="{{ eticheta }} — randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin') or '' }})"
{% if err_map.get(nume) %}aria-invalid="true"{% endif %}>
{% if err_map.get(nume) %}
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nume) }}</div>
{% endif %}
{% if fix_map.get(nume) %}
<span class="camp-fix">{{ fix_map.get(nume) }}</span>
{% endif %}
</div>
{% endmacro %}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:10px 16px;">
{{ camp('nr_inmatriculare', 'Numar inmatriculare', res.get('nr_inmatriculare')) }}
{{ camp('vin', 'VIN (serie sasiu)', res.get('vin')) }}
{{ camp('data_prestatie', 'Data prestatie (YYYY-MM-DD)', res.get('data_prestatie')) }}
{{ camp('odometru_final', 'Odometru final', res.get('odometru_final')) }}
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', res.get('odometru_initial')) }}
</div>
<div style="margin-top:10px; display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<button type="submit" style="min-height:44px; padding:8px 18px;">Salveaza</button>
<button type="button" style="min-height:44px; padding:8px 18px;
background:var(--card); color:var(--muted); border-color:var(--line);"
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}"
hx-target="#preview-row-{{ row.row_index }}" hx-swap="outerHTML">Anuleaza</button>
<span id="rand-spinner-{{ row.row_index }}" class="htmx-indicator muted" style="font-size:13px;">
se salveaza…
</span>
</div>
</form>
</td>
</tr>
<script>
(function() {
/* Mutual-exclusion: cat un rand e in editare, dezactiveaza confirm + alte Editeaza. */
var btn = document.getElementById('confirm-btn');
if (btn) { btn.disabled = true; btn.title = 'Termina editarea randului inainte de a trimite.'; }
document.querySelectorAll('.btn-editeaza').forEach(function(b) { b.disabled = true; });
})();
</script>
{% else %}
{%- set disp_fix_map = {} -%}
{%- for e in row.errors -%}{%- if e is mapping and e.get('field') and e.get('fix') -%}{%- set _ = disp_fix_map.update({e.field: e.get('fix')}) -%}{%- endif -%}{%- endfor -%}
<tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}"
{% if oob_tr %}hx-swap-oob="outerHTML:#preview-row-{{ row.row_index }}"{% endif %}
style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif status in ('already_sent', 'duplicate_in_file') %}opacity:.65;{% endif %}">
<td class="col-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td>
<td class="col-stare" data-eticheta="Stare">
@@ -105,7 +30,7 @@
<td class="col-vehicul" data-eticheta="Vehicul">
{{ row.prez.vehicul_nr }}
{% if row.prez.vin_scurt and row.prez.vin_scurt != '—' %}
<div class="muted" style="font-size:12px;">{{ row.prez.vin_scurt }}</div>
<div class="muted" style="font-size:12px; white-space:nowrap;">{{ row.prez.vin_scurt }}</div>
{% endif %}
{# Fix-uri de validare pe vehicul #}
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %}
@@ -140,24 +65,13 @@
{{ row.nota_umana or '' }}
{% endif %}
</td>
<td class="col-verificat" data-eticheta="Verificat?" style="text-align:center;">
{% if status == 'needs_review' %}
<label class="chk" style="min-height:44px; justify-content:center; cursor:pointer;"
title="Bifat inseamna ca ai verificat valorile si le incluzi in trimitere">
<input type="checkbox" form="confirm-form" name="reviewed_rows" value="{{ row.row_index }}"
onchange="window.updateN && window.updateN()"
aria-label="Verificat — randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
verif.
</label>
{% endif %}
</td>
<td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;">
{% if status not in ('already_sent', 'duplicate_in_file') %}
<button type="button" class="btn-editeaza"
style="min-height:44px; padding:6px 14px; font-size:13px;
background:transparent; border-color:var(--line); color:var(--ink);"
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare"
hx-target="#preview-row-{{ row.row_index }}" hx-swap="outerHTML"
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare-modal"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
aria-label="Editeaza randul {{ row.row_index + 1 }} (VIN: {{ res.get('vin', '') }})">
Editeaza
</button>
@@ -165,7 +79,7 @@
</td>
</tr>
{% if include_oob %}
{# OOB: actualizeaza rezumatul si contorul "gata de trimis" dupa save, fara a re-randa sectiunea. #}
{# OOB: actualizeaza rezumatul, contorul, bannerul needs_review dupa save/confirma-review. #}
{% set status_labels = [
('ok','Gata de trimis'), ('needs_review','Verifica valori'), ('needs_mapping','Cod RAR lipsa'),
('needs_data','Date incomplete'), ('already_sent','Deja trimis'), ('duplicate_in_file','Duplicat in fisier')] %}
@@ -177,6 +91,19 @@
{% endfor %}
</div>
<span id="preview-ok-count" hx-swap-oob="true" data-ok="{{ summary.get('ok', 0) }}" hidden></span>
{# Banner discoverability: OOB swap dupa confirmare/editare → dispare cand needs_review==0. #}
<div id="preview-needs-review-banner" hx-swap-oob="true">
{% if summary.get('needs_review', 0) %}
<div class="banner warn" role="note" aria-live="polite"
style="margin-bottom:12px; padding:8px 14px; border-radius:6px;
background:color-mix(in srgb, var(--warn, #e6b34a) 12%, var(--card));
border:1px solid var(--warn, #e6b34a); font-size:13px;">
Randurile cu <span class="pill s-needs_review" style="font-size:11px;">Verifica valori</span>
nu pleaca la RAR pana le deschizi in modal si confirmi in modal
cu butonul <strong>Confirma valorile</strong>.
</div>
{% endif %}
</div>
<script>
(function() {
/* Editare incheiata: re-activeaza confirm + butoanele Editeaza, recalculeaza N.
@@ -192,4 +119,3 @@
})();
</script>
{% endif %}
{% endif %}

View File

@@ -82,6 +82,12 @@
{% if editabil %}
{% set err_map = {} %}
{% for e in corectie_errors %}{% if e.field %}{% set _ = err_map.update({e.field: e.message}) %}{% endif %}{% endfor %}
{# fix_map gol pentru Trimiteri (fix-hints vin din preview, nu din corectii de trimitere). #}
{% set fix_map = {} %}
{# vin_context pentru aria-label cu context VIN (D#6). #}
{%- set vin_context = form_vin -%}
{# btn_label pentru butonul primar al partial-ului. #}
{%- set btn_label = 'Salveaza si retrimite' -%}
{% if corectie_msg %}
<div class="flash" style="{% if corectie_error %}border-color:var(--err); background:color-mix(in srgb, var(--err) 12%, var(--card));{% endif %} margin:0 0 12px;"
@@ -90,30 +96,19 @@
{# 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. #}
Erori cu camp raman afisate per-camp de macro-ul `camp` din _form_editare.html. #}
{% 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>
<input id="c-{{ nume }}" type="{{ tip }}" name="{{ nume }}" value="{{ valoare }}"
style="width:100%; {% if err_map.get(nume) %}border-color:var(--err);{% endif %}"
{% if err_map.get(nume) %}aria-invalid="true"{% endif %}>
{% if err_map.get(nume) %}
<div class="s-error" style="font-size:12px; margin-top:2px;">{{ err_map.get(nume) }}</div>
{% endif %}
</div>
{% endmacro %}
<form hx-post="/trimitere/{{ id }}/corecteaza"
hx-target="#detaliu-modal-body" hx-swap="innerHTML"
hx-disabled-elt="find button">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
{# Select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else). #}
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else).
RAMANE in _trimitere_detaliu.html (D#5 — logica specifica acestui modal). #}
{% 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>
@@ -139,7 +134,8 @@
{% endif %}
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent. #}
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent.
RAMANE in _trimitere_detaliu.html (D#5). #}
{% if prez.op_service_cod %}
<div style="margin:0 0 12px;">
<div class="muted" style="font-size:12px;">Operatie service</div>
@@ -147,22 +143,8 @@
</div>
{% endif %}
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr) }}
{{ camp('vin', 'VIN (serie sasiu)', form_vin) }}
{# Restul campurilor in grila. #}
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:0 16px;">
{{ camp('data_prestatie', 'Data prestatie (YYYY-MM-DD)', form_data) }}
{{ camp('odometru_final', 'Odometru final', form_odo_final) }}
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial) }}
</div>
{# === Actiune primara conditionata de stare. needs_data/needs_mapping
-> „Salveaza si retrimite" pe /corecteaza. UN SINGUR buton primar per stare. === #}
<div style="margin-top:14px;">
<button type="submit">Salveaza si retrimite</button>
</div>
{# === Campurile vehicul/data/odo + erori/fix + buton — partial DRY (US-005). === #}
{% include "_form_editare.html" %}
</form>
{% else %}
{# Context read-only pentru randuri ne-editabile (sent/sending/queued/error). #}

View File

@@ -60,7 +60,13 @@
{% if v == 'delete' %}onsubmit="return confirm('Stergi acest cont? (stergere soft)');"{% endif %}>
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="account_id" value="{{ acct.id }}">
{% if v == 'activate' and not acct.is_complete %}
<button type="submit"{% if cls == 'danger' %} class="danger"{% endif %}
disabled
title="Completeaza datele firmei (companie + email + CUI) inainte de activare">{{ label }}</button>
{% else %}
<button type="submit"{% if cls == 'danger' %} class="danger"{% endif %}>{{ label }}</button>
{% endif %}
</form>
{% endfor %}
</div>

View File

@@ -345,15 +345,28 @@
@media (max-width:1024px) {
.tabel-trimiteri .col-actualizat { display:none; }
}
/* Tableta (7681024px): header compact fara suprapuneri.
Grila 3-coloane se pastreaza; logo si titlu mai mici; versiunea ascunsa
(informatia secundara elibereaza spatiu in celula dreapta: comutator tema +
hamburger raman vizibili). min-height:92px din regula de baza e resetat —
inaltimea header-ului e determinata de continut, nu de un prag fix. */
@media (min-width:768px) and (max-width:1024px) {
header { min-height:0; padding:10px 16px; gap:6px; }
.brand-logo { height:44px; }
header h1 { font-size:16px; }
/* Versiunea (ex. "v0.9.3") este informatie secundara pe tableta:
ascunsa pentru a elibera spatiu in celula dreapta. */
.header-right > .muted { display:none; }
}
/* === Preview import: coloane extra fata de tabelul Trimiteri.
SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata —
nu atinge coloanele existente (col-chk/id/stare/data/rar/actualizat).
US-007: 8 coloane (coloana de verificare manuala eliminata).
Suma latimi fixe: col-id(48) + col-stare(104) + col-data(104) +
col-km(76) + col-note(176) + col-verificat(80) + col-actiuni(92) = 680px.
Restul (~600px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
col-km(76) + col-note(176) + col-actiuni(92) = 600px.
Restul (~680px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
.tabel-trimiteri .col-km { width:76px; }
.tabel-trimiteri .col-note { width:176px; }
.tabel-trimiteri .col-verificat{ width:80px; }
.tabel-trimiteri .col-actiuni { width:92px; }
/* Randul de editare inline iese din grila table-layout:fixed (display:block),
astfel formularul nu e constrans de latimile coloanelor individuale.
@@ -814,9 +827,12 @@
// Deschidere la click pe rand (htmx:beforeRequest): arata modalul cu placeholder
// inainte ca raspunsul fragmentului sa fie swap-uit in corp.
// Trateaza atat .trimitere-row (Trimiteri) cat si .btn-editeaza (preview import)
// → open() instaleaza inert pe <main>, focus-trap si readuce focusul la inchidere (US-006).
document.body.addEventListener('htmx:beforeRequest', function(evt) {
var elt = evt.detail && evt.detail.elt;
if (elt && elt.classList && elt.classList.contains('trimitere-row')) open(elt);
if (!elt || !elt.classList) return;
if (elt.classList.contains('trimitere-row') || elt.classList.contains('btn-editeaza')) open(elt);
});
// Dupa swap-ul fragmentului (sau re-render corectie/mapare): muta focusul in modal.
body.addEventListener('htmx:afterSettle', function() {

View File

@@ -50,8 +50,8 @@
<input type="text" name="name" value="{{ name or '' }}" required style="width:100%;">
</p>
<p>
<label>CUI <span style="color:var(--muted);font-size:12px;">(optional)</span></label><br>
<input type="text" name="cui" value="{{ cui or '' }}" style="width:100%;">
<label>CUI <span style="color:var(--err)">*</span></label><br>
<input type="text" name="cui" value="{{ cui or '' }}" required style="width:100%;">
</p>
<p>
<label>Email <span style="color:var(--err)">*</span></label><br>