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:
@@ -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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 →</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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
78
app/web/templates/_editare_preview_modal.html
Normal file
78
app/web/templates/_editare_preview_modal.html
Normal 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>
|
||||
41
app/web/templates/_form_editare.html
Normal file
41
app/web/templates/_form_editare.html
Normal 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>
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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). #}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -345,15 +345,28 @@
|
||||
@media (max-width:1024px) {
|
||||
.tabel-trimiteri .col-actualizat { display:none; }
|
||||
}
|
||||
/* Tableta (768–1024px): 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() {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user