feat(5.6): observabilitate + jurnal aplicatie + lifecycle trimiteri blocate

Implementeaza PRD 5.6 complet (14 stories, TDD). Doua axe:

Lifecycle trimiteri blocate (Val A):
- submissions_admin.py: sterge/repune scoped (404 cross-account inaintea lui 409 stare)
- reactivare dedup peste `error` cu CAS (WHERE id=? AND status='error'), creds noi in
  submissions + accounts.rar_creds_enc; worker invalideaza sesiunea RAR la creds proaspete
  (JWT 30h vechi nu mai trimite cu parola gresita); camp aditiv `reactivated:true`
- retentie randuri blocate 30z; purge_expired exclude queued/sending; purge_after curatat
  la reactivare/requeue
- API DELETE /v1/prezentari/{id} + /repune (200+JSON); UI butoane + bulk + banner actionabil

Observabilitate:
- app/observ.py log_event: dublu canal app_events (DB) + RotatingFileHandler per-proces,
  redactare creds/PII la scriere (redact_pii/vin_partial)
- request_id middleware + X-Request-ID pe toate raspunsurile
- handler global excepții -> 500 envelope 6-chei + request_id (traceback doar in jurnal)
- audit cerere API (api_prezentari/api_auth_esuat) + audit worker (rar_login/tranzitii)
- tab "Jurnal" filtrabil scoped (non-admin doar contul sau); retentie jurnal 90z
- rar_error expus in GET /v1/prezentari/{id} (recovery observabil)

pytest -q: 741 passed, 0 failed. Docs: PRD raport VERIFY, contract endpointuri noi, ROADMAP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-23 18:45:39 +00:00
parent f48346de5c
commit c842e3352a
40 changed files with 2851 additions and 64 deletions

33
app/web/middleware.py Normal file
View File

@@ -0,0 +1,33 @@
"""Middleware HTTP: request_id per cerere (PRD 5.6 US-002).
Fiecare raspuns primeste un header `X-Request-ID` (generat daca clientul nu trimite
unul). Pe durata cererii, id-ul e disponibil prin `observ.request_id_var` (contextvar)
in handlerul de erori (US-001) si in `log_event` (US-003) — fara a polua semnaturile.
Format opac, fara PII: `secrets.token_hex(8)` (16 hex). Daca clientul trimite un
`X-Request-ID`, il pastram (corelare end-to-end), dar il scurtam defensiv (max 64).
"""
from __future__ import annotations
import secrets
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from ..observ import request_id_var
class RequestIDMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
incoming = request.headers.get("X-Request-ID")
request_id = (incoming.strip()[:64] if incoming and incoming.strip() else secrets.token_hex(8))
token = request_id_var.set(request_id)
# Expune si pe request.state pentru handlerele care prefera accesul explicit.
request.state.request_id = request_id
try:
response = await call_next(request)
finally:
request_id_var.reset(token)
response.headers["X-Request-ID"] = request_id
return response

View File

@@ -49,11 +49,17 @@ from ..api.v1.import_router import (
)
from ..config import get_settings
from ..crypto import decrypt_creds, encrypt_creds
from ..db import get_connection, read_heartbeat
from ..db import get_connection, read_app_events, read_heartbeat
from ..idempotency import build_key, canonicalize_row
from ..validation import validate_prezentare
from ..import_parse import FileTooLarge, HeaderError, MultipleSheets, parse_date_value, parse_file
from ..users import is_account_admin
from ..submissions_admin import (
SubmissionNotFound,
SubmissionStateConflict,
delete_submission,
requeue_submission,
)
from ..mapping import (
DEFAULT_ACCOUNT_ID,
account_or_default,
@@ -138,7 +144,7 @@ def _rar_state(hb, worker_alive: bool) -> str:
# cade pe Acasa (tab invalid -> fallback "acasa" in dashboard()), fara 404.
# US-003 (3.6): "coada" (Trimiteri) nu mai e tab — Trimiterile sunt sectiune pe Acasa.
# ?tab=coada cade tot pe Acasa (fallback), fara 404, fara fragment orfan.
_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare"}
_TABS_VALIDE = {"acasa", "mapari", "cont", "nomenclator", "integrare", "jurnal"}
def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
@@ -186,13 +192,18 @@ def _get_acasa_context(request: Request, conn, account_id: int) -> dict:
}
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1) -> str:
"""Randeaza panoul Acasa ca string HTML."""
def _render_panel_acasa(request: Request, conn=None, account_id: int = 1, status: str | None = None) -> str:
"""Randeaza panoul Acasa ca string HTML.
`status` (US-014/T13): deep-link `?tab=acasa&status=error` pre-selecteaza filtrul de
stare in sectiunea Trimiteri, astfel ca lista se incarca direct filtrata (nu dead-end).
"""
if conn is None:
return templates.get_template("_acasa.html").render(
{"request": request, "csrf_token": get_csrf_token(request)}
)
ctx = _get_acasa_context(request, conn, account_id)
ctx["status_filtru"] = status
return templates.get_template("_acasa.html").render(ctx)
@@ -286,10 +297,88 @@ def _render_integrare(request: Request, conn, account_id: int) -> str:
})
def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) -> str:
_JURNAL_PAGE_SIZE = 50
def _jurnal_context(
request: Request, conn, account_id: int, *,
tip: str | None = None, nivel: str | None = None,
data_de: str | None = None, data_pana: str | None = None,
cont: str | None = None, page: int = 0,
) -> dict:
"""Context pentru tab-ul Jurnal (US-006): evenimente paginate + filtre + scope.
Admin -> vede TOT, cu filtru optional pe cont. Non-admin -> DOAR evenimentele
contului sau (regula NULL->cont 1, ca restul UI-ului). Decizie §5.
"""
admin = is_account_admin(conn, account_id)
tip = (tip or "").strip() or None
nivel = (nivel or "").strip() or None
data_de = (data_de or "").strip() or None
data_pana = (data_pana or "").strip() or None
page = max(0, page)
if admin:
cont_filtru = None
if cont and str(cont).strip():
try:
cont_filtru = int(cont)
except (ValueError, TypeError):
cont_filtru = None
scope_account = cont_filtru # None = toate conturile
else:
scope_account = account_or_default(account_id)
offset = page * _JURNAL_PAGE_SIZE
rows = read_app_events(
conn, account_id=scope_account, tip=tip, nivel=nivel,
date_from=data_de, date_to=data_pana,
limit=_JURNAL_PAGE_SIZE + 1, offset=offset,
)
has_more = len(rows) > _JURNAL_PAGE_SIZE
rows = rows[:_JURNAL_PAGE_SIZE]
evenimente = []
for r in rows:
evenimente.append({
"ts": format_data_rar(r["ts"]),
"sursa": r["sursa"],
"tip": r["tip"],
"nivel": r["nivel"],
"account_id": r["account_id"],
"cod": r["cod"],
"mesaj": r["mesaj"],
})
tipuri = [r["tip"] for r in conn.execute("SELECT DISTINCT tip FROM app_events ORDER BY tip").fetchall()]
return {
"request": request,
"evenimente": evenimente,
"tipuri": tipuri,
"is_admin": admin,
"f_tip": tip or "",
"f_nivel": nivel or "",
"f_data_de": data_de or "",
"f_data_pana": data_pana or "",
"f_cont": (cont or "") if admin else "",
"page": page,
"has_more": has_more,
"prev_page": page - 1 if page > 0 else None,
"next_page": page + 1 if has_more else None,
}
def _render_panel_jurnal(request: Request, conn, account_id: int) -> str:
"""Randeaza panoul Jurnal ca string HTML (US-006)."""
return templates.get_template("_jurnal.html").render(_jurnal_context(request, conn, account_id))
def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str, status: str | None = None) -> str:
"""Randeaza panoul corespunzator unui tab ca string HTML."""
if tab == "acasa":
return _render_panel_acasa(request, conn, account_id)
return _render_panel_acasa(request, conn, account_id, status=status)
if tab == "jurnal":
return _render_panel_jurnal(request, conn, account_id)
if tab == "import":
return _render_panel_import(request)
if tab == "coada":
@@ -306,18 +395,19 @@ def _render_panel_for_tab(request: Request, conn, account_id: int, tab: str) ->
@router.get("/", response_class=HTMLResponse)
def dashboard(request: Request, tab: str = "acasa") -> HTMLResponse:
def dashboard(request: Request, tab: str = "acasa", status: str | None = None) -> HTMLResponse:
"""Dashboard principal cu tab-uri (US-003).
Parametrul ?tab= permite deep-link pe orice sectiune; panoul activ e randat
server-side la full load (fara palpaiere la refresh, degradare gratiosa fara JS).
Tab invalid -> fallback la 'acasa'.
Tab invalid -> fallback la 'acasa'. `?status=` (US-014/T13) pre-filtreaza lista
Trimiteri de pe Acasa (deep-link din banner-ul "Necesita atentia ta").
"""
account_id = require_login(request)
active_tab = tab if tab in _TABS_VALIDE else "acasa"
conn = get_connection()
try:
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab)
panel_html = _render_panel_for_tab(request, conn, account_id, active_tab, status=status)
# Badge contoare pe tab-uri (US-011): needs_mapping -> Mapari. Blocatele
# (fost badge "coada") se reflecta acum in heading-ul sectiunii Trimiteri (US-003).
counts = _status_counts(conn, account_id)
@@ -400,6 +490,32 @@ def fragment_integrare(request: Request) -> HTMLResponse:
conn.close()
@router.get("/_fragments/jurnal", response_class=HTMLResponse)
def fragment_jurnal(
request: Request,
tip: str | None = None,
nivel: str | None = None,
data_de: str | None = None,
data_pana: str | None = None,
cont: str | None = None,
page: int = 0,
) -> HTMLResponse:
"""Tab Jurnal (US-006): evenimente app_events paginate + filtre, scoped pe cont.
Admin vede tot (filtru optional pe cont); non-admin doar evenimentele proprii.
"""
account_id = require_login(request)
conn = get_connection()
try:
ctx = _jurnal_context(
request, conn, account_id,
tip=tip, nivel=nivel, data_de=data_de, data_pana=data_pana, cont=cont, page=page,
)
return templates.TemplateResponse("_jurnal.html", ctx)
finally:
conn.close()
@router.get("/_fragments/banner", response_class=HTMLResponse)
def fragment_banner(request: Request) -> HTMLResponse:
account_id = require_login(request)
@@ -430,6 +546,45 @@ def _blocate_defalcat(counts: dict[str, int]) -> list[tuple]:
return rezultat
# Cate randuri blocate identificam nominal sub fiecare categorie din banner (US-014).
_BLOCATE_SAMPLE = 3
def _blocate_actionabil(conn, account_id: int) -> list[dict]:
"""Categorii blocate cu identificatorii primelor randuri + deep-link (US-014).
Pentru fiecare stare blocata cu n>0: eticheta umana, contorul, primii N identificatori
(VIN partial + nr inmatriculare + #id — PII doar partial, ca jurnalul) si cati raman.
Scoped pe cont (regula NULL->1). Lista goala -> banner-ul nu se randeaza (se stinge).
"""
from ..security import vin_partial
scope_sql, scope_params = account_scope_clause(account_id)
out: list[dict] = []
for status in ("needs_mapping", "needs_data", "error"):
rows = conn.execute(
f"SELECT id, payload_json FROM submissions WHERE {scope_sql} AND status=? ORDER BY id DESC",
scope_params + [status],
).fetchall()
if not rows:
continue
sample = []
for r in rows[:_BLOCATE_SAMPLE]:
prez = prezentare_din_payload(r["payload_json"])
sample.append({
"id": r["id"],
"vin": vin_partial(prez.get("vin") or ""),
"nr": prez.get("vehicul_nr") or "",
})
out.append({
"status": status,
"eticheta": eticheta_stare(status),
"n": len(rows),
"randuri": sample,
"rest": max(0, len(rows) - len(sample)),
})
return out
@router.get("/_fragments/status", response_class=HTMLResponse)
def fragment_status(request: Request) -> HTMLResponse:
"""Bara de status persistenta cu etichete umane (US-002, PRD 3.4).
@@ -466,6 +621,7 @@ def fragment_status(request: Request) -> HTMLResponse:
"counts_sent": counts.get("sent", 0),
"blocate_total": blocate_total,
"blocate_defalcat": _blocate_defalcat(counts),
"blocate_actionabil": _blocate_actionabil(conn, account_id),
"account_active": _account_active(conn, account_id),
})
finally:
@@ -496,6 +652,9 @@ def _submission_row_view(r) -> dict:
"id_prezentare": r["id_prezentare"],
"updated_at": format_data_rar(r["updated_at"]),
"motiv": motiv_uman(r["status"], r["rar_error"]),
# US-011: randurile blocate (error/needs_data/needs_mapping) sunt selectabile
# pentru stergere bulk; sent/sending/queued raman read-only (fara checkbox).
"gestionabil": r["status"] in _GESTIONABILE_WEB,
}
@@ -566,6 +725,7 @@ def fragment_submissions(
"request": request,
"rows": view,
"filtru_activ": filtru_activ,
"csrf_token": get_csrf_token(request),
})
finally:
conn.close()
@@ -573,6 +733,25 @@ def fragment_submissions(
# Stari ne-trimise blocate pe care le putem corecta inline (US-010).
_CORECTABILE = ("needs_data", "needs_mapping")
# Stari gestionabile prin lifecycle web (US-011): sterge / re-pune in coada.
_GESTIONABILE_WEB = ("error", "needs_data", "needs_mapping")
def _render_submissions(request: Request, conn, account_id: int) -> HTMLResponse:
"""Re-randeaza lista Trimiteri (fara filtre) — folosit dupa actiuni bulk (US-011)."""
scope_sql, scope_params = account_scope_clause(account_id)
rows = conn.execute(
"SELECT id, status, id_prezentare, rar_status_code, rar_error, retry_count, "
f"updated_at, payload_json FROM submissions WHERE {scope_sql} ORDER BY id DESC LIMIT 200",
scope_params,
).fetchall()
view = [_submission_row_view(r) for r in rows]
return templates.TemplateResponse("_submissions.html", {
"request": request,
"rows": view,
"filtru_activ": False,
"csrf_token": get_csrf_token(request),
})
def _payload_form_values(payload_json) -> dict:
@@ -616,6 +795,8 @@ def _detaliu_ctx(request: Request, row, *, message: str | None = None,
"next_attempt_at": format_data_rar(row["next_attempt_at"]),
# randuri ne-trimise blocate sunt corectabile (US-010); sent/sending nu
"editabil": row["status"] in _CORECTABILE,
# US-011: error/needs_data/needs_mapping pot fi sterse / re-puse in coada
"gestionabil": row["status"] in _GESTIONABILE_WEB,
"corectie_msg": message,
"corectie_error": error,
"corectie_errors": corectie_errors or [],
@@ -792,6 +973,92 @@ async def post_corectie_trimitere(request: Request, submission_id: int) -> HTMLR
conn.close()
# =========================================================================== #
# US-011 — Lifecycle trimiteri blocate din dashboard: sterge / re-pune in coada #
# Peste helper-ul US-009 (submissions_admin). CSRF enforce; scoped pe sesiune. #
# =========================================================================== #
@router.post("/trimitere/{submission_id}/repune", response_class=HTMLResponse)
async def post_repune_trimitere(request: Request, submission_id: int) -> HTMLResponse:
"""Re-pune in coada un rand blocat (error/needs_data/needs_mapping) din dashboard.
Scoped pe sesiune (404 cross-account/inexistent, 409 sent/sending). Re-randeaza
panoul de detaliu cu starea noua + nudge `trimiteriChanged` pentru lista.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
conn = get_connection()
try:
try:
requeue_submission(conn, account_id, submission_id)
except SubmissionNotFound:
raise HTTPException(status_code=404, detail="trimitere inexistenta")
except SubmissionStateConflict:
raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)")
row = _fetch_submission_scoped(conn, account_id, submission_id)
resp = templates.TemplateResponse(
"_trimitere_detaliu.html",
_detaliu_ctx(request, row, message="Re-pus in coada — pleaca la urmatoarea trimitere."),
)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
@router.post("/trimitere/{submission_id}/sterge", response_class=HTMLResponse)
async def post_sterge_trimitere(request: Request, submission_id: int) -> HTMLResponse:
"""Sterge un rand blocat din dashboard. Scoped pe sesiune; sent/sending interzis (409)."""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
conn = get_connection()
try:
try:
delete_submission(conn, account_id, submission_id)
except SubmissionNotFound:
raise HTTPException(status_code=404, detail="trimitere inexistenta")
except SubmissionStateConflict:
raise HTTPException(status_code=409, detail="trimitere read-only (deja procesata)")
resp = HTMLResponse(
'<div class="flash" style="margin:0;">Trimitere stearsa.</div>'
)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
@router.post("/trimiteri/sterge-bulk", response_class=HTMLResponse)
async def post_sterge_bulk(request: Request) -> HTMLResponse:
"""Sterge in bloc trimiterile selectate (doar blocate, scoped pe sesiune).
Sare peste randuri sent/sending (read-only) si cross-account (inexistente) fara a
opri operatia — pe modelul panoului admin (PRD 5.5). Re-randeaza lista Trimiteri.
"""
account_id = require_login(request)
form = await request.form()
verify_csrf(request, str(form.get("csrf_token") or ""))
ids = form.getlist("submission_id")
conn = get_connection()
try:
for raw in ids:
try:
sid = int(str(raw))
except (ValueError, TypeError):
continue
try:
delete_submission(conn, account_id, sid)
except (SubmissionNotFound, SubmissionStateConflict):
continue # doar blocate ale contului; restul sarite
resp = _render_submissions(request, conn, account_id)
resp.headers["HX-Trigger"] = "trimiteriChanged"
return resp
finally:
conn.close()
def _load_saved_op_mappings(conn, account_id: int) -> list[dict]:
"""Mapari operatie->cod salvate (operations_mapping) ale contului, cu numele
prestatiei jonctionat din nomenclator (US-005). Scoped pe cont (NOT NULL → simplu)."""

View File

@@ -30,14 +30,17 @@
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
<div>
<label for="f-status" class="muted" style="display:block; font-size:12px;">Stare</label>
{# US-014/T13: status_filtru (din deep-link ?tab=acasa&status=) pre-selecteaza
starea, iar submissions-wrap (hx-include #filtre-trimiteri) o incarca filtrat. #}
{% set sf = status_filtru | default('') %}
<select id="f-status" name="status">
<option value="">toate</option>
<option value="queued">in asteptare</option>
<option value="sent">declarate la RAR</option>
<option value="needs_mapping">lipsa cod</option>
<option value="needs_data">date incomplete</option>
<option value="error">eroare</option>
<option value="sending">se trimite</option>
<option value="" {% if not sf %}selected{% endif %}>toate</option>
<option value="queued" {% if sf == 'queued' %}selected{% endif %}>in asteptare</option>
<option value="sent" {% if sf == 'sent' %}selected{% endif %}>declarate la RAR</option>
<option value="needs_mapping" {% if sf == 'needs_mapping' %}selected{% endif %}>lipsa cod</option>
<option value="needs_data" {% if sf == 'needs_data' %}selected{% endif %}>date incomplete</option>
<option value="error" {% if sf == 'error' %}selected{% endif %}>eroare</option>
<option value="sending" {% if sf == 'sending' %}selected{% endif %}>se trimite</option>
</select>
</div>
<div>
@@ -57,7 +60,8 @@
<!-- Poll aliniat la 15s ca status-ul (M5: nu doua timere perpetue pe pagina mereu deschisa) -->
<div id="submissions-wrap"
hx-get="/_fragments/submissions" hx-trigger="load, every 15s"
hx-get="/_fragments/submissions"
hx-trigger="load, every 15s, trimiteriChanged from:body"
hx-include="#filtre-trimiteri" hx-swap="innerHTML">
<div class="empty">se incarca…</div>
</div>

View File

@@ -0,0 +1,106 @@
{# _jurnal.html — tab Jurnal de aplicatie (US-006, PRD 5.6).
Lista paginata de evenimente (app_events), redactate la scriere. Filtre tip/nivel/
data + (admin) cont. Stil consistent cu tabelele PRD 5.5 (.tablewrap). #}
<section id="jurnal-section" aria-labelledby="jurnal-heading">
<div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:0 0 12px;">
<h2 id="jurnal-heading" style="font-size:15px; margin:0;">Jurnal de aplicatie</h2>
{% if is_admin %}
<span class="pill s-sent" style="font-size:11px;">admin: toate conturile</span>
{% else %}
<span class="muted" style="font-size:12px;">doar evenimentele contului tau</span>
{% endif %}
</div>
<form id="filtre-jurnal"
hx-get="/_fragments/jurnal"
hx-target="#jurnal-wrap"
hx-swap="innerHTML"
hx-trigger="submit, change"
style="display:flex; gap:10px; flex-wrap:wrap; align-items:flex-end; margin-bottom:12px;">
<div>
<label for="j-tip" class="muted" style="display:block; font-size:12px;">Tip eveniment</label>
<select id="j-tip" name="tip">
<option value="">toate</option>
{% for t in tipuri %}
<option value="{{ t }}" {% if f_tip == t %}selected{% endif %}>{{ t }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="j-nivel" class="muted" style="display:block; font-size:12px;">Nivel</label>
<select id="j-nivel" name="nivel">
<option value="">toate</option>
{% for nv in ("INFO", "WARNING", "ERROR", "CRITICAL", "DEBUG") %}
<option value="{{ nv }}" {% if f_nivel == nv %}selected{% endif %}>{{ nv }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="j-data-de" class="muted" style="display:block; font-size:12px;">Data de la</label>
<input id="j-data-de" type="date" name="data_de" value="{{ f_data_de }}">
</div>
<div>
<label for="j-data-pana" class="muted" style="display:block; font-size:12px;">pana la</label>
<input id="j-data-pana" type="date" name="data_pana" value="{{ f_data_pana }}">
</div>
{% if is_admin %}
<div>
<label for="j-cont" class="muted" style="display:block; font-size:12px;">Cont (id)</label>
<input id="j-cont" type="number" name="cont" value="{{ f_cont }}" placeholder="toate" style="max-width:100px;">
</div>
{% endif %}
<button type="submit">Filtreaza</button>
</form>
<div id="jurnal-wrap">
{% if evenimente %}
<div class="tablewrap">
<table>
<thead><tr>
<th>Cand</th>
<th>Sursa</th>
<th>Tip</th>
<th>Nivel</th>
{% if is_admin %}<th>Cont</th>{% endif %}
<th>Cod</th>
<th>Mesaj</th>
</tr></thead>
<tbody>
{% for e in evenimente %}
<tr>
<td class="muted" style="white-space:nowrap;">{{ e.ts }}</td>
<td>{{ e.sursa }}</td>
<td>{{ e.tip }}</td>
<td>
<span class="{% if e.nivel in ('ERROR','CRITICAL') %}s-error{% elif e.nivel == 'WARNING' %}s-needs_data{% else %}muted{% endif %}">{{ e.nivel }}</span>
</td>
{% if is_admin %}<td class="muted">{{ e.account_id if e.account_id is not none else '—' }}</td>{% endif %}
<td class="muted">{{ e.cod or '—' }}</td>
<td style="white-space:normal; max-width:360px;">{{ e.mesaj or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{# Paginare: prev/next pe acelasi set de filtre #}
{% if prev_page is not none or next_page is not none %}
<div style="display:flex; gap:10px; margin-top:12px; align-items:center;">
{% if prev_page is not none %}
<a href="#" hx-get="/_fragments/jurnal?page={{ prev_page }}&tip={{ f_tip }}&nivel={{ f_nivel }}&data_de={{ f_data_de }}&data_pana={{ f_data_pana }}&cont={{ f_cont }}"
hx-target="#jurnal-wrap" hx-swap="innerHTML">&lsaquo; mai noi</a>
{% endif %}
<span class="muted" style="font-size:12px;">pagina {{ page + 1 }}</span>
{% if next_page is not none %}
<a href="#" hx-get="/_fragments/jurnal?page={{ next_page }}&tip={{ f_tip }}&nivel={{ f_nivel }}&data_de={{ f_data_de }}&data_pana={{ f_data_pana }}&cont={{ f_cont }}"
hx-target="#jurnal-wrap" hx-swap="innerHTML">mai vechi &rsaquo;</a>
{% endif %}
</div>
{% endif %}
{% else %}
<div class="empty">Niciun eveniment pe filtrul curent.</div>
{% endif %}
</div>
</div>
</section>

View File

@@ -47,21 +47,35 @@
</span>
</div>
<!-- Defalcare blocate pe motiv (doar daca exista) -->
{% if blocate_defalcat %}
<!-- Necesita atentia ta (US-014): categorii actionabile — link la lista filtrata
+ identificatorii primelor randuri blocate. Se randeaza DOAR daca exista randuri
blocate; cand contorul ajunge 0 (sters/re-pus/purjat), sectiunea dispare. -->
{% if blocate_actionabil %}
<div style="margin-top:12px; padding-top:10px; border-top:1px solid var(--line);">
<div style="font-size:13px; font-weight:600; margin-bottom:6px;">Necesita atentia ta</div>
<div style="display:flex; gap:16px; flex-wrap:wrap;">
{% for eticheta, n in blocate_defalcat %}
{% if n > 0 %}
<div>
<span class="{{ eticheta[2] }}" style="font-size:13px;">{{ eticheta[0] }}</span>
<span class="muted" style="font-size:13px; margin-left:4px;">({{ n }})</span>
{% if eticheta[1] %}
<div class="muted" style="font-size:13px; max-width:240px;">{{ eticheta[1] }}</div>
{% endif %}
<div style="font-size:13px; font-weight:600; margin-bottom:8px;">Necesita atentia ta</div>
<div style="display:flex; gap:18px; flex-wrap:wrap;">
{% for cat in blocate_actionabil %}
<div style="min-width:200px;">
{# Link: filtreaza lista Trimiteri pe aceasta stare (HTMX in-place) cu fallback
deep-link server-side (?tab=acasa&status=...). #}
<a class="{{ cat.eticheta[2] }}" style="font-size:13px; font-weight:600; text-decoration:none;"
href="/?tab=acasa&status={{ cat.status }}"
hx-get="/_fragments/submissions?status={{ cat.status }}"
hx-target="#submissions-wrap" hx-swap="innerHTML"
onclick="var s=document.getElementById('trimiteri-section'); if(s) s.scrollIntoView({behavior:'smooth'});">
{{ cat.eticheta[0] }} ({{ cat.n }}) &rsaquo;
</a>
<ul style="list-style:none; margin:6px 0 0; padding:0;">
{% for r in cat.randuri %}
<li class="muted" style="font-size:12px;">
#{{ r.id }} {{ r.vin }}{% if r.nr %} / {{ r.nr }}{% endif %}
</li>
{% endfor %}
{% if cat.rest %}
<li class="muted" style="font-size:12px;">…si inca {{ cat.rest }}</li>
{% endif %}
</ul>
</div>
{% endif %}
{% endfor %}
</div>
</div>

View File

@@ -1,7 +1,24 @@
{% if rows %}
{# US-011: form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
<form id="bulk-trimiteri"
hx-post="/trimiteri/sterge-bulk"
hx-target="#submissions-wrap"
hx-swap="innerHTML"
hx-confirm="Stergi definitiv trimiterile selectate?"
hx-disinherit="hx-confirm"
style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div style="display:flex; justify-content:flex-end; margin-bottom:8px;">
<button type="submit" id="bulk-sterge-btn"
style="background:var(--card); color:var(--err); border-color:var(--err); font-size:13px; padding:4px 10px;">
Sterge selectate
</button>
</div>
<div class="tablewrap">
<table>
<thead><tr>
<th style="width:28px;"><span class="muted" title="Selecteaza randuri blocate">&#10003;</span></th>
<th>#</th>
<th>Stare</th>
<th>Vehicul</th>
@@ -19,6 +36,12 @@
hx-swap="innerHTML"
style="cursor:pointer;"
title="Click pentru detaliul complet">
<td onclick="event.stopPropagation();">
{% if r.gestionabil %}
<input type="checkbox" name="submission_id" value="{{ r.id }}"
aria-label="Selecteaza trimiterea #{{ r.id }} pentru stergere">
{% endif %}
</td>
<td class="muted">{{ r.id }}</td>
<td><span class="pill {{ r.stare_css }}">{{ r.stare_text }}</span></td>
<td>
@@ -37,6 +60,7 @@
</tbody>
</table>
</div>
</form>
{% elif filtru_activ %}
<div class="empty">
Nimic pe filtrul curent.

View File

@@ -46,6 +46,25 @@
</details>
{% endif %}
{# === Lifecycle (US-011): sterge / re-pune in coada — doar randuri blocate === #}
{% if gestionabil %}
<div style="margin-top:14px; padding-top:12px; border-top:1px solid var(--line); display:flex; gap:10px; flex-wrap:wrap;">
<form hx-post="/trimitere/{{ id }}/repune"
hx-target="#trimitere-detaliu" hx-swap="innerHTML" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit">Re-pune in coada</button>
</form>
<form hx-post="/trimitere/{{ id }}/sterge"
hx-target="#trimitere-detaliu" hx-swap="innerHTML"
hx-confirm="Stergi definitiv aceasta trimitere din coada?" style="margin:0;">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</form>
</div>
{% endif %}
{# === Corectie inline (US-010): doar randuri ne-trimise blocate === #}
{% if editabil %}
{% set err_map = {} %}

View File

@@ -184,6 +184,7 @@
<a role="menuitem" href="/?tab=cont">Cont</a>
<a role="menuitem" href="/?tab=integrare">Integrare</a>
<a role="menuitem" href="/?tab=nomenclator">Nomenclator</a>
<a role="menuitem" href="/?tab=jurnal">Jurnal</a>
{% if is_admin|default(false) %}<a role="menuitem" href="/admin">Conturi clienti</a>{% endif %}
<hr>
<form method="post" action="/logout">