feat(5.13): carduri compacte mobil/tableta + fix editare preview (OOB tr) + toast

Dogfood pe import + Trimiteri (mobil/tableta <1024px), pur CSS + markup, backend
trimitere neatins:

- Card compact real pentru .tabel-trimiteri (preview + Trimiteri): vehicul=titlu,
  stare=pill dreapta-sus, operatie+cod, meta data/km muted, nota mica. Inlocuieste
  stiva generica eticheta+valoare (carduri de ~450px -> ~135px). Anuleaza regula
  desktop tr.trimitere-row > td{padding:11px} in blocul compact.
- FIX editare preview: OOB swap pe <tr> esua tacit in htmx 1.9 (un <tr> brut se
  pierde la parsarea unui fragment fara context de tabel) -> randul ramanea cu
  starea veche dupa salvare. Inlocuit cu reload complet al preview-ului prin
  HX-Trigger:reincarcaPreview + detalii randSalvat. /editeaza si /confirma-review
  folosesc helper-ul _raspuns_rand_salvat.
- Feedback post-salvare: toast global "Randul N actualizat · <stare>" + scroll +
  flash pe randul actualizat (base.html window.arataToast + listener randSalvat).
- Modal editare: Salveaza + Anuleaza pe acelasi rand (sistem .act): desktop text,
  mobil doua iconite Lucide 44px alaturate (save/x). Macro icon('x') + .act-primary.
- Randuri deja-trimise/duplicate colapsate implicit in preview + toggle "Arata N".
- Select "Operatii de mapat" full-width pe mobil (nu mai iese din viewport).
- Bara de filtre Trimiteri adaptata mobil: pills pe banda cu scroll orizontal,
  cautare vehicul proeminenta (nu 8 butoane full-width stivuite).
- Nota preview = culoarea camp-fix (accent) ca sa atraga atentia; hint-urile
  camp-fix per-camp scoase (campul Note e self-explanatory).
- Confirmare trimitere: scos campul email (Declarant); text mai clar
  ("Confirma numarul din N gata de trimis"). Backend confirmed_by ramane optional.

Teste: contractul OOB (rupt in browser) inlocuit cu noul contract
(reincarcaPreview + randSalvat) in test_web_preview_edit / test_preview_edit_ui /
test_import_review. Suita: 992 passed (exclus live).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-06-27 23:34:33 +00:00
parent bafaf05e83
commit 8d4ff3400e
18 changed files with 633 additions and 303 deletions

View File

@@ -2380,6 +2380,33 @@ def _preview_one_row(conn, import_id: int, account_id: int, row_index: int):
return result, row return result, row
def _raspuns_rand_salvat(import_id: int, row: dict) -> HTMLResponse:
"""Raspuns dupa salvarea/confirmarea unui rand de preview.
NU mai face OOB swap pe `<tr>` (htmx 1.9 pierde un `<tr>` brut la parsarea unui
fragment care nu incepe cu context de tabel -> swap-ul esua tacit, randul ramanea
cu starea veche; cauza confirmata a bug-ului 'editez si ramane la fel'). In schimb:
- `reincarcaPreview` (HX-Trigger) -> #import-section isi reincarca preview-ul complet
(rand + contoare + colaps deja-trimise, toate corecte, fara fragilitatea OOB).
- `randSalvat` (HX-Trigger, cu detalii) -> base.html arata un toast cu numarul randului
si noua stare, apoi evidentiaza randul dupa reload (feedback vizual clar: ce s-a salvat).
- `inchideModal` (HX-Trigger-After-Settle) -> inchide modalul.
"""
payload = {
"reincarcaPreview": True,
"randSalvat": {
"nr": row["row_index"] + 1,
"rowIndex": row["row_index"],
"stare": row.get("stare_eticheta") or "",
"stareCss": row.get("stare_css") or "",
},
}
resp = HTMLResponse(content='<div style="display:none;"></div>')
resp.headers["HX-Trigger"] = json.dumps(payload)
resp.headers["HX-Trigger-After-Settle"] = "inchideModal"
return resp
def _render_preview_rand( def _render_preview_rand(
request: Request, *, import_id: int, row: dict, editing: bool, request: Request, *, import_id: int, row: dict, editing: bool,
include_oob: bool, summary: dict, message: str | None = None, include_oob: bool, summary: dict, message: str | None = None,
@@ -2537,25 +2564,8 @@ async def web_editeaza_rand(request: Request, import_id: int, row_index: int) ->
"message": "Mai sunt valori invalide — corecteaza campurile marcate.", "message": "Mai sunt valori invalide — corecteaza campurile marcate.",
}) })
# Succes: OOB swap rand + contoare + inchideModal. # Succes: reincarca preview-ul complet + toast + inchide modal (vezi helper).
# Continut primar (swap in #detaliu-modal-body): stub invizibil + script recalc. return _raspuns_rand_salvat(import_id, row)
# 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: finally:
conn.close() conn.close()
@@ -2604,22 +2614,8 @@ async def web_confirma_review(
if row is None or isinstance(result, str): if row is None or isinstance(result, str):
raise HTTPException(status_code=404, detail="rand de import inexistent") raise HTTPException(status_code=404, detail="rand de import inexistent")
# OOB: rand actualizat + rezumat + contor ok + inchideModal (identic cu succes editeaza) # Reincarca preview-ul complet + toast + inchide modal (identic cu succes editeaza).
oob_content = templates.get_template("_preview_rand.html").render({ return _raspuns_rand_salvat(import_id, row)
"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: finally:
conn.close() conn.close()

View File

@@ -45,14 +45,10 @@
Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca. Salvarea nu a reusit (retea / sesiune). Valorile introduse sunt pastrate — reincearca.
</div> </div>
{# with_cancel=True: _form_editare.html randeaza Salveaza + Anuleaza pe acelasi
rand (sistemul .act: desktop text, mobil iconite Lucide 44px alaturate). #}
{% set with_cancel = true %}
{% include "_form_editare.html" %} {% 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> </form>
{% if is_needs_review %} {% if is_needs_review %}

View File

@@ -17,7 +17,7 @@
vin_context — string VIN pentru aria-label (poate fi '') vin_context — string VIN pentru aria-label (poate fi '')
btn_label — eticheta butonului primar (ex. 'Salveaza si retrimite') btn_label — eticheta butonului primar (ex. 'Salveaza si retrimite')
#} #}
{% from "_macros.html" import camp %} {% from "_macros.html" import camp, icon %}
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #} {# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr, {{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr,
@@ -35,7 +35,19 @@
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }} err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
</div> </div>
{# Buton primar parametrizat. #} {# Buton primar parametrizat.
with_cancel=True (modal editare preview): Salveaza + Anuleaza pe ACELASI rand,
sistemul .act (desktop = text alaturat; mobil = doua iconite Lucide 44px alaturate).
Implicit (ex. _trimitere_detaliu): un singur buton text, neschimbat. #}
{% if with_cancel %}
<div class="act-group" style="margin-top:14px;">
<button type="submit" class="act act-primary" aria-label="{{ btn_label or 'Salveaza' }}">
<span class="act-tx">{{ btn_label or 'Salveaza' }}</span>{{ icon('save') }}</button>
<button type="button" class="act" aria-label="{{ cancel_label or 'Anuleaza' }}" data-modal-close>
<span class="act-tx">{{ cancel_label or 'Anuleaza' }}</span>{{ icon('x') }}</button>
</div>
{% else %}
<div style="margin-top:14px;"> <div style="margin-top:14px;">
<button type="submit">{{ btn_label or 'Salveaza' }}</button> <button type="submit">{{ btn_label or 'Salveaza' }}</button>
</div> </div>
{% endif %}

View File

@@ -50,3 +50,25 @@
{% endif %} {% endif %}
</div> </div>
{% endmacro %} {% endmacro %}
{# PRD 5.13 — sistem butoane de actiune responsive.
CSS-ul aferent (.act, .act-tx, .act-ic, .act-save, .act-del, .act-group)
este definit in base.html.
Desktop: se afiseaza textul (.act-tx); mobil: se afiseaza iconita (.act-ic). #}
{% macro icon(name) -%}
<svg class="act-ic" viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
{%- if name == 'save' -%}<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>
{%- elif name == 'trash' -%}<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>
{%- elif name == 'edit' -%}<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
{%- elif name == 'plus' -%}<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
{%- elif name == 'x' -%}<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
{%- endif -%}
</svg>
{%- endmacro %}
{% macro act_btn(label, ic, kind='', attrs='') -%}
<button class="act{% if kind %} act-{{ kind }}{% endif %}" aria-label="{{ label }}" {{ attrs | safe }}>
<span class="act-tx">{{ label }}</span>{{ icon(ic) }}</button>
{%- endmacro %}

View File

@@ -135,24 +135,11 @@
</select> </select>
</td> </td>
<td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni"> <td style="text-align:right; white-space:nowrap;" data-eticheta="Actiuni">
{# Butoane icon mereu vizibile (fara kebab). SVG aria-hidden; aria-label pe buton. {# Butoane act_btn (desktop: text; mobil: iconita 44px).
data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand, data-dirty-form e citit de JS din base.html: la schimbarea select-ului din acelasi rand,
JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #} JS adauga clasa "dirty" pe butonul de salvare (fundal --accent = modificari nesalvate). #}
<button type="submit" form="map-salv-{{ loop.index }}" {{ ui.act_btn('Salveaza', 'save', 'save', 'type="submit" form="map-salv-' ~ loop.index ~ '" data-dirty-form="map-salv-' ~ loop.index ~ '"') }}
class="icon-btn" {{ ui.act_btn('Sterge', 'trash', 'del', 'type="submit" form="map-del-' ~ loop.index ~ '"') }}
data-dirty-form="map-salv-{{ loop.index }}"
aria-label="Salveaza maparea pentru {{ m.cod_op_service }}">
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M2 2a1 1 0 011-1h7.5L13 3.5V14a1 1 0 01-1 1H3a1 1 0 01-1-1V2zm5 10a2 2 0 100-4 2 2 0 000 4zM3 3v3h6V3H3z"/>
</svg>
</button>
<button type="submit" form="map-del-{{ loop.index }}"
class="icon-btn danger"
aria-label="Sterge maparea pentru {{ m.cod_op_service }}">
<svg aria-hidden="true" viewBox="0 0 16 16" width="16" height="16" fill="currentColor">
<path d="M5.5 5.5A.5.5 0 016 6v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm2.5 0a.5.5 0 01.5.5v6a.5.5 0 01-1 0V6a.5.5 0 01.5-.5zm3 .5a.5.5 0 00-1 0v6a.5.5 0 001 0V6zM14 3a1 1 0 01-1 1H3a1 1 0 110-2h3.5l1-1h2l1 1H13a1 1 0 011 1zm-1 1H3v9a1 1 0 001 1h8a1 1 0 001-1V4z"/>
</svg>
</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -206,10 +193,7 @@
{{ r.cod_prestatie }} {{ r.cod_prestatie }}
</td> </td>
<td style="text-align:right; white-space:nowrap;"> <td style="text-align:right; white-space:nowrap;">
<button type="submit" form="rt-del-{{ loop.index }}" {{ ui.act_btn('Sterge', 'trash', 'del', 'type="submit" form="rt-del-' ~ loop.index ~ '"') }}
style="background:var(--card); color:var(--err); border-color:var(--err);">
Sterge
</button>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -308,9 +292,7 @@
hx-confirm="Stergi acest format de coloane?"> hx-confirm="Stergi acest format de coloane?">
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
<input type="hidden" name="format_id" value="{{ f.id }}"> <input type="hidden" name="format_id" value="{{ f.id }}">
<button type="submit" style="background:var(--card); color:var(--err); border-color:var(--err);"> {{ ui.act_btn('Sterge', 'trash', 'del', 'type="submit"') }}
Sterge
</button>
</form> </form>
</td> </td>
</tr> </tr>

View File

@@ -1,5 +1,12 @@
{% import '_macros.html' as ui %} {% import '_macros.html' as ui %}
<div id="import-section"> {# reincarcaPreview (emis de /editeaza si /confirma-review prin HX-Trigger): preview-ul
se reincarca COMPLET (rand + contoare + colaps deja-trimise corecte) in loc de OOB swap
pe <tr> (fragil in htmx 1.9). Evidentierea + toast-ul randului salvat: base.html. #}
<div id="import-section"
hx-get="/_import/{{ import_id }}/preview"
hx-trigger="reincarcaPreview from:body"
hx-target="#import-section"
hx-swap="outerHTML">
{% set pas = 3 %}{% include '_stepper.html' %} {% set pas = 3 %}{% include '_stepper.html' %}
<div class="card"> <div class="card">
<div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;"> <div style="display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-bottom:12px;">
@@ -123,10 +130,22 @@
{% endif %} {% endif %}
</div> </div>
<!-- Toggle randuri deja-trimise / duplicate: colapsate implicit (nu ocupa loc).
Click -> comuta clasa .preview-arata-trimise pe tabel (CSS in base.html). -->
{% set _n_trimise = summary.get('already_sent', 0) + summary.get('duplicate_in_file', 0) %}
{% if _n_trimise %}
<div style="margin-bottom:8px;">
<button type="button" class="btn-secondary btn-sm" aria-expanded="false"
onclick="var t=document.getElementById('preview-tabel'); var on=t.classList.toggle('preview-arata-trimise'); this.setAttribute('aria-expanded', on); this.querySelector('.tgl-tx').textContent = on ? 'Ascunde {{ _n_trimise }} deja trimise / duplicate' : 'Arata {{ _n_trimise }} deja trimise / duplicate';">
<span class="tgl-tx">Arata {{ _n_trimise }} deja trimise / duplicate</span>
</button>
</div>
{% endif %}
<!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri). <!-- Tabel preview in format identic cu tabelul Trimiteri (.tabel-trimiteri).
US-007: 8 coloane (coloana de verificare eliminata). US-007: 8 coloane (coloana de verificare eliminata).
Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). --> Randurile au FORM PROPRIU pentru editare (NU sunt in #confirm-form). -->
<div class="tablewrap tabel-trimiteri"> <div id="preview-tabel" class="tablewrap tabel-trimiteri">
<table> <table>
<thead> <thead>
<tr> <tr>
@@ -169,32 +188,20 @@
prezentari la RAR (ireversibil). prezentari la RAR (ireversibil).
</div> </div>
<div style="display:flex; gap:12px; align-items:center; flex-wrap:wrap;"> <div style="display:flex; gap:8px; align-items:center; flex-wrap:wrap;">
<div>
<label for="n-confirmat" <label for="n-confirmat"
style="font-size:13px; color:var(--muted); display:block; margin-bottom:2px;"> style="font-size:13px; color:var(--muted);">
Numar prezentari de confirmat Confirma numarul
</label> </label>
<input type="number" id="n-confirmat" name="n_confirmat" <input type="number" id="n-confirmat" name="n_confirmat"
value="{{ summary.get('ok', 0) }}" value="{{ summary.get('ok', 0) }}"
min="0" required min="0" required
style="max-width:80px;" style="max-width:80px;"
aria-describedby="n-hint"> aria-describedby="n-hint">
<span id="n-hint" class="muted" style="font-size:12px; margin-left:6px;"> <span id="n-hint" class="muted" style="font-size:12px;">
(<span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> ok) din <span id="n-hint-ok">{{ summary.get('ok', 0) }}</span> gata de trimis
</span> </span>
</div> </div>
<div>
<label for="confirmed-by"
style="font-size:13px; color:var(--muted); display:block; margin-bottom:2px;">
Declarant (optional)
</label>
<input type="text" id="confirmed-by" name="confirmed_by"
placeholder="email sau nume"
style="max-width:200px;">
</div>
</div>
</div> </div>
<div style="display:flex; flex-direction:column; gap:6px; align-self:flex-end;"> <div style="display:flex; flex-direction:column; gap:6px; align-self:flex-end;">
@@ -281,5 +288,18 @@
/* Filtru implicit "Toate" activ la incarcare */ /* Filtru implicit "Toate" activ la incarcare */
filterRows('all'); filterRows('all');
updateN(); updateN();
/* Evidentiere rand dupa reincarcarea preview-ului (window.__randSalvat setat de
listener-ul 'randSalvat' din base.html): scroll + flash, ca userul sa vada CARE
rand s-a schimbat si sa nu ramana cu impresia ca "nu s-a intamplat nimic". */
if (window.__randSalvat) {
var d = window.__randSalvat; window.__randSalvat = null;
var r = document.getElementById('preview-row-' + d.rowIndex);
if (r) {
r.scrollIntoView({block:'center', behavior:'smooth'});
void r.offsetWidth;
r.classList.add('rand-actualizat');
}
}
})(); })();
</script> </script>

View File

@@ -18,11 +18,11 @@
#} #}
{%- set res = row.resolved -%} {%- set res = row.resolved -%}
{%- set status = row.resolved_status -%} {%- set status = row.resolved_status -%}
{%- set disp_fix_map = {} -%} {%- set _sent_dup = status in ('already_sent', 'duplicate_in_file') -%}
{%- 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 }}" <tr id="preview-row-{{ row.row_index }}" data-status="{{ status }}"
{% if _sent_dup %}class="preview-sent-row"{% endif %}
{% if oob_tr %}hx-swap-oob="outerHTML:#preview-row-{{ row.row_index }}"{% endif %} {% 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 %}"> style="{% if status == 'needs_review' %}background:rgba(230,179,74,.04);{% elif _sent_dup %}opacity:.6;{% endif %}">
<td class="col-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td> <td class="col-id muted" data-eticheta="#">{{ row.row_index + 1 }}</td>
<td class="col-stare" data-eticheta="Stare"> <td class="col-stare" data-eticheta="Stare">
<span class="pill {{ row.stare_css }}">{{ row.stare_eticheta }}</span> <span class="pill {{ row.stare_css }}">{{ row.stare_eticheta }}</span>
@@ -32,9 +32,6 @@
{% if row.prez.vin_scurt and row.prez.vin_scurt != '—' %} {% if row.prez.vin_scurt and row.prez.vin_scurt != '—' %}
<div class="muted" style="font-size:12px; white-space:nowrap;">{{ row.prez.vin_scurt }}</div> <div class="muted" style="font-size:12px; white-space:nowrap;">{{ row.prez.vin_scurt }}</div>
{% endif %} {% endif %}
{# Fix-uri de validare pe vehicul #}
{% if disp_fix_map.get('vin') %}<span class="camp-fix">{{ disp_fix_map.get('vin') }}</span>{% endif %}
{% if disp_fix_map.get('nr_inmatriculare') %}<span class="camp-fix">{{ disp_fix_map.get('nr_inmatriculare') }}</span>{% endif %}
</td> </td>
<td class="col-operatie" data-eticheta="Operatie"> <td class="col-operatie" data-eticheta="Operatie">
<div>{{ row.prez.operatie }}</div> <div>{{ row.prez.operatie }}</div>
@@ -44,14 +41,8 @@
<div class="muted cod-rar-sub">nemapat</div> <div class="muted cod-rar-sub">nemapat</div>
{% endif %} {% endif %}
</td> </td>
<td class="col-data" data-eticheta="Data prestatie"> <td class="col-data" data-eticheta="Data prestatie">{{ row.prez.data_prestatie }}</td>
{{ row.prez.data_prestatie }} <td class="col-km" data-eticheta="KM final">{{ row.prez.odometru }}</td>
{% if disp_fix_map.get('data_prestatie') %}<span class="camp-fix">{{ disp_fix_map.get('data_prestatie') }}</span>{% endif %}
</td>
<td class="col-km" data-eticheta="KM final">
{{ row.prez.odometru }}
{% if disp_fix_map.get('odometru_final') %}<span class="camp-fix">{{ disp_fix_map.get('odometru_final') }}</span>{% endif %}
</td>
<td class="col-note" data-eticheta="Note" <td class="col-note" data-eticheta="Note"
style="font-size:12px; white-space:normal;"> style="font-size:12px; white-space:normal;">
{% if status == 'already_sent' and row.get('already_sent_info') %} {% if status == 'already_sent' and row.get('already_sent_info') %}
@@ -68,7 +59,7 @@
<td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;"> <td class="col-actiuni" data-eticheta="Actiuni" style="text-align:center;">
{% if status not in ('already_sent', 'duplicate_in_file') %} {% if status not in ('already_sent', 'duplicate_in_file') %}
<button type="button" class="btn-editeaza" <button type="button" class="btn-editeaza"
style="min-height:44px; padding:6px 14px; font-size:13px; style="min-height:36px; padding:6px 14px; font-size:13px;
background:transparent; border-color:var(--line); color:var(--ink);" background:transparent; border-color:var(--line); color:var(--ink);"
hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare-modal" hx-get="/_import/{{ import_id }}/rand/{{ row.row_index }}/editare-modal"
hx-target="#detaliu-modal-body" hx-swap="innerHTML" hx-target="#detaliu-modal-body" hx-swap="innerHTML"

View File

@@ -1,22 +1,9 @@
{# {#
_stepper.html — Antet wizard import (PUR vizual, fara logica de rutare). _stepper.html — Antet wizard import COMPACT (PUR vizual). PRD 5.13.
Parametru: `pas` (integer 1-4). Clasele .stepper-* sunt definite in base.html.
Parametru: `pas` (integer 1-4) — pasul curent. >=1024px: bara slim orizontala (.stepper-track). <1024px: forma colapsata
Utilizare in template-uri care mostenesc contextul Jinja2: "Pasul N din 4 - Titlu" + bara de progres (.stepper-collapsed).
{% set pas = 1 %}{% include '_stepper.html' %} Utilizare: {% set pas = 1 %}{% include '_stepper.html' %}
sau cu `with`:
{% with pas=2 %}{% include '_stepper.html' %}{% endwith %}
Cei 4 pasi ficsi:
1. Incarca fisier
2. Potriveste coloanele
3. Verifica
4. Confirma trimiterea
Stari vizuale:
- index < pas "facut" (bulina plina, text bifat)
- index == pas "activ" (evidentiat, aria-current="step")
- index > pas → "viitor" (estompat)
#} #}
{%- set _pasi_import = [ {%- set _pasi_import = [
(1, "Incarca fisier", "Trage un fisier xlsx/csv aici sau foloseste butonul de alegere."), (1, "Incarca fisier", "Trage un fisier xlsx/csv aici sau foloseste butonul de alegere."),
@@ -24,73 +11,26 @@
(3, "Verifica", "Verifica randurile inainte sa le trimiti la RAR."), (3, "Verifica", "Verifica randurile inainte sa le trimiti la RAR."),
(4, "Confirma trimiterea", "Confirma numarul de prezentari actiunea e ireversibila."), (4, "Confirma trimiterea", "Confirma numarul de prezentari actiunea e ireversibila."),
] -%} ] -%}
<nav class="stepper-import" aria-label="Pasii importului" style=" {%- set _activ = _pasi_import[pas - 1] -%}
display:flex; <div class="stepper">
gap:0; {# Desktop (>=1024px): bara slim orizontala. #}
align-items:stretch; <nav class="stepper-track" aria-label="Pasii importului">
margin-bottom:20px;
border:1px solid var(--line);
border-radius:8px;
overflow:hidden;
background:var(--card);
">
{% for nr, titlu, ajutor in _pasi_import %} {% for nr, titlu, ajutor in _pasi_import %}
{%- if nr < pas %} {%- if nr < pas %}{% set cls = "is-done" %}{% set aria = "" %}
{%- set cls = "facut" -%} {%- elif nr == pas %}{% set cls = "is-active" %}{% set aria = ' aria-current="step"' %}
{%- set aria = "" -%} {%- else %}{% set cls = "" %}{% set aria = "" %}{% endif %}
{%- elif nr == pas %} <div class="stepper-step {{ cls }}"{{ aria | safe }}>
{%- set cls = "activ" -%} <span class="stepper-nr">{% if nr < pas %}&#10003;{% else %}{{ nr }}{% endif %}</span>
{%- set aria = ' aria-current="step"' -%} <span class="stepper-tx">{{ titlu }}</span>
{%- else %}
{%- set cls = "viitor" -%}
{%- set aria = "" -%}
{%- endif %}
<div class="stepper-pas stepper-pas--{{ cls }}"{{ aria | safe }}
style="
flex:1;
padding:10px 14px;
border-right:{% if not loop.last %}1px solid var(--line){% else %}none{% endif %};
{% if cls == 'activ' %}
background:rgba(91,141,239,.10);
{% elif cls == 'facut' %}
opacity:1;
{% else %}
opacity:.4;
{% endif %}
">
<div style="display:flex; align-items:center; gap:6px; margin-bottom:2px;">
<span class="stepper-nr" style="
display:inline-flex;
align-items:center;
justify-content:center;
width:20px;
height:20px;
border-radius:50%;
font-size:11px;
font-weight:700;
flex-shrink:0;
{% if cls == 'activ' %}
background:var(--accent);
color:#fff;
{% elif cls == 'facut' %}
background:var(--ok);
color:#fff;
{% else %}
background:var(--line);
color:var(--muted);
{% endif %}
">
{% if cls == 'facut' %}&#10003;{% else %}{{ nr }}{% endif %}
</span>
<span style="
font-size:13px;
font-weight:{% if cls == 'activ' %}600{% else %}400{% endif %};
color:{% if cls == 'activ' %}var(--ink){% elif cls == 'facut' %}var(--ink){% else %}var(--muted){% endif %};
">{{ titlu }}</span>
</div>
{% if cls == 'activ' %}
<p class="muted" style="margin:0; font-size:12px; padding-left:26px;">{{ ajutor }}</p>
{% endif %}
</div> </div>
{% endfor %} {% endfor %}
</nav> </nav>
{# Tableta/mobil (<1024px): colapsat "Pasul N din 4 - Titlu" + progres. #}
<div class="stepper-collapsed">
<div class="stepper-current">Pasul {{ pas }} din 4 <span class="muted">&middot; {{ _activ[1] }}</span></div>
<div class="stepper-progress" role="progressbar" aria-valuenow="{{ pas }}" aria-valuemin="1" aria-valuemax="4"
aria-label="Pasul {{ pas }} din 4"><span style="width:{{ (pas / 4 * 100) | round | int }}%;"></span></div>
</div>
{# Ajutorul pasului activ — o singura linie, sub bara (valabil pe ambele forme). #}
<p class="stepper-help">{{ _activ[2] }}</p>
</div>

View File

@@ -67,7 +67,7 @@
{# Eticheta umana scurta sub pill — text mic, `s-error` pe error/needs_* {# Eticheta umana scurta sub pill — text mic, `s-error` pe error/needs_*
(singurele stari pe care `eticheta_problema` e ne-goala). (singurele stari pe care `eticheta_problema` e ne-goala).
Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #} Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #}
{% if r.eticheta_problema %} {% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %}
<div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div> <div class="eticheta-problema s-error">{{ r.eticheta_problema }}</div>
{% endif %} {% endif %}
</td> </td>

View File

@@ -177,7 +177,7 @@
.banner.warn { border-left-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); } .banner.warn { border-left-color:var(--warn); background:color-mix(in srgb, var(--warn) 12%, var(--card)); }
/* Bara confirmare sticky */ /* Bara confirmare sticky */
.sticky-bar { position:sticky; bottom:0; background:var(--card); border-top:1px solid var(--line); .sticky-bar { position:sticky; bottom:0; background:var(--card); border-top:1px solid var(--line);
padding:12px 16px; display:flex; align-items:flex-start; gap:16px; padding:10px 14px; display:flex; align-items:flex-start; gap:12px;
flex-wrap:wrap; z-index:10; } flex-wrap:wrap; z-index:10; }
/* Indicator HTMX — ascuns pana la request */ /* Indicator HTMX — ascuns pana la request */
.htmx-indicator { display:none; } .htmx-indicator { display:none; }
@@ -199,6 +199,83 @@
select { max-width:340px; } select { max-width:340px; }
button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; } button { background:var(--accent); border-color:var(--accent); color:#fff; cursor:pointer; }
button:hover { filter:brightness(1.08); } button:hover { filter:brightness(1.08); }
/* Sistem butoane unificat (design.md §5.1). Primarul = `button`/`.btn` (deja stilat). */
.btn-secondary { background:transparent; color:var(--ink); border:1px solid var(--line); border-radius:6px;
padding:8px 14px; font:inherit; font-weight:500; cursor:pointer; min-height:36px;
display:inline-flex; align-items:center; justify-content:center; gap:6px; }
.btn-secondary:hover { background:var(--line); filter:none; }
.btn-ghost { background:transparent; color:var(--accent); border:1px solid transparent; border-radius:6px;
padding:8px 14px; font:inherit; font-weight:500; cursor:pointer; min-height:36px;
display:inline-flex; align-items:center; justify-content:center; gap:6px; }
.btn-ghost:hover { background:var(--line); filter:none; }
.btn-danger { background:transparent; color:var(--err); border:1px solid var(--err); border-radius:6px;
padding:8px 14px; font:inherit; font-weight:500; cursor:pointer; min-height:36px;
display:inline-flex; align-items:center; justify-content:center; gap:6px; }
.btn-danger:hover, .btn-danger:focus-visible { background:var(--err); color:#fff; filter:none; }
.btn-sm { padding:5px 10px; min-height:32px; font-size:13px; }
button:focus-visible, .btn-secondary:focus-visible, .btn-ghost:focus-visible, .btn-danger:focus-visible {
outline:2px solid var(--accent); outline-offset:2px; }
/* Actiuni de rand (design.md §5.1): desktop = text, mobil = iconita patrata 44px.
act_btn randeaza si .act-tx (text) si .act-ic (svg); CSS ascunde unul per breakpoint. */
.act { display:inline-flex; align-items:center; justify-content:center; gap:6px; font:inherit; font-weight:500;
border-radius:7px; padding:6px 12px; min-height:36px; cursor:pointer; background:transparent;
border:1px solid var(--line); color:var(--ink); }
.act:hover { background:var(--line); filter:none; }
.act:focus-visible { outline:2px solid var(--accent); outline-offset:2px; }
.act .act-ic { width:18px; height:18px; display:none; }
.act .act-tx { display:inline; }
.act-save.dirty { background:var(--accent); color:#fff; border-color:var(--accent); }
.act-save.dirty:hover { filter:brightness(.92); }
/* Variant primar mereu-accent (ex. Salveaza in modalul de editare). */
.act-primary { background:var(--accent); color:#fff; border-color:var(--accent); }
.act-primary:hover { filter:brightness(.92); background:var(--accent); }
.act-del { color:var(--err); border-color:var(--err); }
.act-del:hover, .act-del:focus-visible { background:var(--err); color:#fff; }
.act-group { display:inline-flex; gap:8px; align-items:center; }
.btn-editeaza { white-space:nowrap; }
/* Toast global (feedback tranzitoriu post-actiune). */
#toast { position:fixed; left:50%; bottom:24px; transform:translateX(-50%) translateY(8px);
z-index:1300; max-width:90vw; padding:11px 18px; border-radius:10px;
background:var(--ink); color:var(--card); font-size:14px; font-weight:500;
box-shadow:0 8px 28px rgba(0,0,0,.28); display:flex; align-items:center; gap:9px;
opacity:0; pointer-events:none; transition:opacity .2s, transform .2s; }
#toast[hidden] { display:none; }
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
#toast::before { content:""; width:9px; height:9px; border-radius:50%; background:var(--ok); flex:0 0 auto; }
#toast.t-err::before, #toast.t-s-error::before, #toast.t-s-needs_data::before,
#toast.t-s-needs_mapping::before { background:var(--err); }
#toast.t-warn::before, #toast.t-s-needs_review::before { background:var(--warn); }
/* Rand de preview tocmai actualizat: flash scurt ca userul sa-l localizeze. */
@keyframes rand-flash { 0% { background:color-mix(in srgb, var(--accent) 26%, var(--card)); }
100% { background:transparent; } }
.tabel-trimiteri tr.rand-actualizat { animation:rand-flash 1.6s ease-out; }
/* Randuri deja-trimise / duplicate in preview: colapsate implicit (nu ocupa loc).
Reafisate cand userul apasa toggle-ul -> .preview-arata-trimise pe container.
!important fiindca regulile compacte mobil/tableta seteaza `tr{display:flex}`. */
.tabel-trimiteri tr.preview-sent-row { display:none !important; }
.tabel-trimiteri.preview-arata-trimise tr.preview-sent-row { display:table-row !important; }
/* Stepper import compact (design.md §5.4). >=1024px: bara slim. <1024px: "Pasul N din 4" + progres. */
.stepper { margin-bottom:16px; }
.stepper-track { display:flex; align-items:stretch; border:1px solid var(--line); border-radius:8px;
overflow:hidden; background:var(--card); }
.stepper-step { flex:1; display:flex; align-items:center; gap:8px; padding:10px 14px; min-height:44px;
border-right:1px solid var(--line); }
.stepper-step:last-child { border-right:none; }
.stepper-nr { display:inline-flex; align-items:center; justify-content:center; width:20px; height:20px;
border-radius:50%; font-size:11px; font-weight:700; flex-shrink:0;
background:var(--line); color:var(--muted); }
.stepper-tx { font-size:13px; color:var(--muted); white-space:nowrap; }
.stepper-step.is-active { background:color-mix(in srgb, var(--accent) 10%, transparent); }
.stepper-step.is-active .stepper-nr { background:var(--accent); color:#fff; }
.stepper-step.is-active .stepper-tx { color:var(--ink); font-weight:600; }
.stepper-step.is-done .stepper-nr { background:var(--ok); color:#fff; }
.stepper-step.is-done .stepper-tx { color:var(--ink); }
.stepper-help { margin:6px 2px 0; font-size:12px; color:var(--muted); }
.stepper-collapsed { display:none; }
.stepper-current { font-size:14px; font-weight:600; margin-bottom:6px; }
.stepper-current .muted { font-weight:400; }
.stepper-progress { height:5px; border-radius:99px; background:var(--line); overflow:hidden; }
.stepper-progress > span { display:block; height:100%; background:var(--accent); border-radius:99px; }
.chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; } .chk { font-size:13px; color:var(--muted); display:flex; align-items:center; gap:6px; }
/* Tab-bar */ /* Tab-bar */
.tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch; .tab-bar { display:flex; gap:2px; overflow-x:auto; -webkit-overflow-scrolling:touch;
@@ -341,9 +418,12 @@
.tabel-trimiteri tr.trimitere-row:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); } .tabel-trimiteri tr.trimitere-row:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
.tabel-trimiteri tr.trimitere-row:focus, .tabel-trimiteri tr.trimitere-row:focus,
.tabel-trimiteri tr.trimitere-row:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; } .tabel-trimiteri tr.trimitere-row:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
/* 768-1024px: ascunde Actualizat (e in detaliu) -> 7 coloane, fara scroll */ /* col-actualizat ca linie meta mica in carduri (decizie 5.13 #8). */
.tabel-trimiteri td.col-actualizat { font-size:12px; }
/* Stepper: sub 1024px ascunde track-ul slim, arata forma colapsata (decizie 5.13 #11). */
@media (max-width:1024px) { @media (max-width:1024px) {
.tabel-trimiteri .col-actualizat { display:none; } .stepper-track { display:none; }
.stepper-collapsed { display:block; }
} }
/* Tableta (7681024px): header compact fara suprapuneri. /* Tableta (7681024px): header compact fara suprapuneri.
Grila 3-coloane se pastreaza; logo si titlu mai mici; versiunea ascunsa Grila 3-coloane se pastreaza; logo si titlu mai mici; versiunea ascunsa
@@ -358,6 +438,43 @@
ascunsa pentru a elibera spatiu in celula dreapta. */ ascunsa pentru a elibera spatiu in celula dreapta. */
.header-right > .muted { display:none; } .header-right > .muted { display:none; }
} }
/* Tableta 768-1024px: listele actionabile raman O COLOANA, cardificate (nu tabel storcit,
nu 2/rand). Decizie 5.13 (premisa user). Tabelele dense read-only raman .tablewrap. */
@media (min-width:768px) and (max-width:1024px) {
.tabel-trimiteri table, .tabel-card table { table-layout:auto; }
.tabel-trimiteri thead, .tabel-card thead { display:none; }
.tabel-trimiteri table, .tabel-trimiteri tbody, .tabel-trimiteri tr, .tabel-trimiteri td,
.tabel-card table, .tabel-card tbody, .tabel-card tr, .tabel-card td { display:block; width:auto; }
.tabel-trimiteri tr, .tabel-card tr { border:1px solid var(--line); border-radius:8px;
padding:10px 12px; margin-bottom:10px; }
.tabel-trimiteri td, .tabel-card td { border-bottom:none; padding:3px 0; }
.tabel-trimiteri td::before, .tabel-card td::before { content:attr(data-eticheta); display:block;
color:var(--muted); font-size:12px; margin-bottom:2px; }
.tabel-trimiteri td.col-chk, .tabel-trimiteri td.col-id { display:none; }
.tabel-trimiteri td[data-eticheta=""]::before, .tabel-card td[data-eticheta=""]::before,
.tabel-card td:not([data-eticheta])::before { display:none; }
.tabel-card td select, .tabel-card td input[type=text], .tabel-card td input[type=search] {
width:100%; max-width:none; }
/* Card compact si pe tableta (acelasi layout ca pe mobil) pentru `.tabel-trimiteri`. */
.tabel-trimiteri tr { display:flex; flex-wrap:wrap; align-items:baseline; gap:1px 8px; }
.tabel-trimiteri td { padding:0; }
/* Regula desktop `tr.trimitere-row > td { padding:11px }` e mai specifica -> o anulam
in cardul compact, altfel randurile de Trimiteri raman inalte/aerisite. */
.tabel-trimiteri tr.trimitere-row > td { padding-top:0; padding-bottom:0; }
.tabel-trimiteri td::before { display:none; }
.tabel-trimiteri td.col-vehicul { order:1; flex:1 1 55%; min-width:0; font-weight:600; font-size:15px; line-height:1.25; }
.tabel-trimiteri td.col-vehicul .muted { font-weight:400; }
.tabel-trimiteri td.col-stare { order:2; flex:0 0 auto; margin-left:auto; align-self:flex-start; }
.tabel-trimiteri td.col-operatie { order:3; flex:1 1 100%; font-size:13px; line-height:1.3; margin-top:1px; }
.tabel-trimiteri td.col-data, .tabel-trimiteri td.col-km, .tabel-trimiteri td.col-rar { font-size:12px; color:var(--muted); }
.tabel-trimiteri td.col-data { order:4; } .tabel-trimiteri td.col-km { order:5; } .tabel-trimiteri td.col-rar { order:6; }
.tabel-trimiteri td.col-km::before { content:"· "; display:inline; color:var(--muted); }
.tabel-trimiteri td.col-actualizat { order:7; flex:1 1 100%; font-size:12px; color:var(--muted); }
.tabel-trimiteri td.col-note { order:8; flex:1 1 100%; font-size:12px; color:var(--accent); line-height:1.3; margin-top:1px; }
.tabel-trimiteri td.col-actiuni { order:9; flex:0 0 auto; margin-left:auto; margin-top:4px; text-align:right; }
.tabel-trimiteri td.col-actiuni button, .tabel-trimiteri td.col-actiuni .act { width:auto; min-height:32px; padding:5px 14px; }
.tabel-trimiteri.preview-arata-trimise tr.preview-sent-row { display:flex !important; }
}
/* === Preview import: coloane extra fata de tabelul Trimiteri. /* === Preview import: coloane extra fata de tabelul Trimiteri.
SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata — SCOPAT prin .tabel-trimiteri (clasa partajata). Regiune separata —
nu atinge coloanele existente (col-chk/id/stare/data/rar/actualizat). nu atinge coloanele existente (col-chk/id/stare/data/rar/actualizat).
@@ -367,7 +484,12 @@
Restul (~680px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */ Restul (~680px la 1280px) revine lui col-vehicul + col-operatie (fluid). === */
.tabel-trimiteri .col-km { width:76px; } .tabel-trimiteri .col-km { width:76px; }
.tabel-trimiteri .col-note { width:176px; } .tabel-trimiteri .col-note { width:176px; }
.tabel-trimiteri .col-actiuni { width:92px; } /* Nota preview = culoarea camp-fix (accent), ca sa atraga atentia (dogfood 5.13):
campul Note e self-explanatory, asa ca hint-urile per-camp au fost scoase. */
.tabel-trimiteri td.col-note { color:var(--accent); }
/* Pill-ul de stare nu se rupe pe doua randuri in cardul compact. */
.tabel-trimiteri td.col-stare .pill { white-space:nowrap; }
.tabel-trimiteri .col-actiuni { width:104px; }
/* Randul de editare inline iese din grila table-layout:fixed (display:block), /* Randul de editare inline iese din grila table-layout:fixed (display:block),
astfel formularul nu e constrans de latimile coloanelor individuale. astfel formularul nu e constrans de latimile coloanelor individuale.
Salveaza/Anuleaza sunt mereu vizibile (overflow:visible, nu clip). */ Salveaza/Anuleaza sunt mereu vizibile (overflow:visible, nu clip). */
@@ -401,16 +523,51 @@
Breakpoint unic 767px (vezi conventia de sus). Cuprinde: card per rand pe tabelul Breakpoint unic 767px (vezi conventia de sus). Cuprinde: card per rand pe tabelul
de trimiteri, modal full-screen, header/nav colapsat cu tinte touch de trimiteri, modal full-screen, header/nav colapsat cu tinte touch
>=44px. Desktop (>=1024px) ramane neschimbat — regulile de baza nu se modifica. */ >=44px. Desktop (>=1024px) ramane neschimbat — regulile de baza nu se modifica. */
/* SENTINEL-TESTE-MOBIL: blocul mobil principal incepe mai jos; testele ancoreaza pe acest marker si feliaza pana la sfarsitul stilului. NU muta/sterge. */
@media (max-width:767px) { @media (max-width:767px) {
/* Tabel trimiteri: card per rand (eticheta:valoare stivuit) -> fara scroll orizontal */ /* Tabel trimiteri: card per rand (eticheta:valoare stivuit) -> fara scroll orizontal */
.tabel-trimiteri table { table-layout:auto; } .tabel-trimiteri table { table-layout:auto; }
.tabel-trimiteri thead { display:none; } .tabel-trimiteri thead { display:none; }
.tabel-trimiteri table, .tabel-trimiteri tbody, .tabel-trimiteri tr, .tabel-trimiteri td { display:block; width:auto; } .tabel-trimiteri table, .tabel-trimiteri tbody, .tabel-trimiteri tr, .tabel-trimiteri td { display:block; width:auto; }
.tabel-trimiteri tr { border:1px solid var(--line); border-radius:8px; padding:8px 12px; margin-bottom:10px; } .tabel-trimiteri tr { border:1px solid var(--line); border-radius:8px; padding:8px 12px; margin-bottom:10px; }
.tabel-trimiteri td { border-bottom:none; padding:4px 0; display:flex; gap:10px; align-items:baseline; } .tabel-trimiteri td { border-bottom:none; padding:3px 0; display:block; }
.tabel-trimiteri td::before { content:attr(data-eticheta); color:var(--muted); font-size:12px; .tabel-trimiteri td::before { content:attr(data-eticheta); display:block; color:var(--muted);
flex:0 0 auto; min-width:120px; } font-size:12px; margin-bottom:2px; }
.tabel-trimiteri td.col-chk { display:none; } .tabel-trimiteri td.col-chk, .tabel-trimiteri td.col-id { display:none; }
/* === Card COMPACT (PRD 5.13, corectie dogfood) ===
Inlocuieste stiva generica eticheta+valoare (prea inalta) cu un card
scanabil la prima vedere: vehicul = titlu, stare = pill dreapta-sus,
operatie+cod pe rand, meta (data/km/rar) muted mic, nota mica. Fara
etichete-zgomot. Override DUPA regulile de baza (cascada: ultimul castiga). */
.tabel-trimiteri tr { display:flex; flex-wrap:wrap; align-items:baseline;
gap:1px 8px; padding:9px 12px; }
.tabel-trimiteri td { display:block; padding:0; }
.tabel-trimiteri tr.trimitere-row > td { padding-top:0; padding-bottom:0; }
.tabel-trimiteri td::before { display:none; } /* compact: fara etichete */
.tabel-trimiteri td.col-vehicul { order:1; flex:1 1 55%; min-width:0;
font-weight:600; font-size:15px; line-height:1.25; }
.tabel-trimiteri td.col-vehicul .muted { font-weight:400; }
.tabel-trimiteri td.col-stare { order:2; flex:0 0 auto; margin-left:auto;
align-self:flex-start; }
.tabel-trimiteri td.col-operatie { order:3; flex:1 1 100%; font-size:13px;
line-height:1.3; margin-top:1px; }
.tabel-trimiteri td.col-data,
.tabel-trimiteri td.col-km,
.tabel-trimiteri td.col-rar { font-size:12px; color:var(--muted); }
.tabel-trimiteri td.col-data { order:4; }
.tabel-trimiteri td.col-km { order:5; }
.tabel-trimiteri td.col-km::before { content:"· "; display:inline; color:var(--muted); }
.tabel-trimiteri td.col-rar { order:6; }
.tabel-trimiteri td.col-actualizat { order:7; flex:1 1 100%; font-size:12px;
color:var(--muted); }
.tabel-trimiteri td.col-note { order:8; flex:1 1 100%; font-size:12px;
color:var(--accent); line-height:1.3; margin-top:1px; }
.tabel-trimiteri td.col-actiuni { order:9; flex:0 0 auto; margin-left:auto;
margin-top:4px; text-align:right; }
.tabel-trimiteri td.col-actiuni button,
.tabel-trimiteri td.col-actiuni .act { width:auto; min-height:32px; padding:5px 14px; }
.tabel-trimiteri.preview-arata-trimise tr.preview-sent-row { display:flex !important; }
/* Modal full-screen: ocupa tot ecranul, fara backdrop lateral (overlay fara /* Modal full-screen: ocupa tot ecranul, fara backdrop lateral (overlay fara
padding, dialog la latime/inaltime pline, fara colturi/umbra). Scroll intern padding, dialog la latime/inaltime pline, fara colturi/umbra). Scroll intern
@@ -483,16 +640,40 @@
#import-section #upload-btn { width:100%; min-height:44px; } #import-section #upload-btn { width:100%; min-height:44px; }
/* Bara de status: contoarele/randurile raman aliniate la stanga, fara scroll orizontal. */ /* Bara de status: contoarele/randurile raman aliniate la stanga, fara scroll orizontal. */
#status-bar > div { gap:10px; } #status-bar > div { gap:10px; }
/* Bara de filtre trimiteri: o coloana, fiecare control full-width, buton >=44px. /* Bara de filtre trimiteri ADAPTATA pentru mobil (nu doar stivuita):
!important suprascrie latimile inline (ex. max-width:180px pe vehicul) DOAR pe mobil. */ - cautarea vehicul = rand propriu prioritar (input + buton pe acelasi rand);
#filtre-trimiteri { flex-direction:column; align-items:stretch; } - grupurile de pill-uri (data + stare) = scroll orizontal, compacte (nu 8 butoane
full-width unul sub altul). !important suprascrie latimile inline doar pe mobil. */
#filtre-trimiteri { flex-direction:column; align-items:stretch; gap:8px; }
#filtre-trimiteri > div { width:100%; } #filtre-trimiteri > div { width:100%; }
#filtre-trimiteri select, #filtre-trimiteri input[type=text], /* Cautarea vehicul: input creste, butonul Filtreaza compact langa el. */
#filtre-trimiteri input[type=date] { width:100% !important; max-width:none !important; } #filtre-trimiteri input[type=text] { flex:1 1 auto; width:auto !important; max-width:none !important; min-height:44px; }
#filtre-trimiteri button { width:100%; min-height:44px; } #filtre-trimiteri input[type=date] { width:100% !important; max-width:none !important; min-height:44px; }
#filtre-trimiteri button[type=submit] { flex:0 0 auto; width:auto; min-height:44px; }
/* Grupurile de pill-uri: o singura banda scrolabila orizontal, compacta. */
#filtre-trimiteri .pills-categorii { margin-left:0 !important; flex-wrap:nowrap;
overflow-x:auto; -webkit-overflow-scrolling:touch; padding-bottom:2px; }
#filtre-trimiteri .pill-cat { flex:0 0 auto; }
/* Operatii de mapat (preview import): randul de mapare stiva pe o coloana,
select-ul full-width (altfel max-width:340px global il scoate din viewport). */
.maprow { gap:6px 12px; padding:10px 0; }
.maprow .mapcol { flex:1 1 100%; min-width:0; }
.maprow select { width:100% !important; max-width:none !important; }
.maprow button { width:100%; min-height:44px; }
/* Card de autentificare (login/signup): centrat si nu depaseste viewport-ul pe mobil. */ /* Card de autentificare (login/signup): centrat si nu depaseste viewport-ul pe mobil. */
.auth-card { max-width:100%; margin:24px auto; } .auth-card { max-width:100%; margin:24px auto; }
/* Versiunea ascunsa pe mobil (la fel ca pe tableta). */
.header-right > .muted { display:none; }
/* Actiuni .act pe mobil: iconita patrata 44px, textul ascuns. */
.act { min-width:44px; min-height:44px; width:44px; padding:0; }
.act .act-tx { display:none; }
.act .act-ic { display:inline-block; }
.act-group { gap:10px; }
/* Bara confirmare compacta pe mobil. */
.sticky-bar { padding:10px 12px; gap:10px; }
.sticky-bar button { width:100%; min-height:44px; }
} }
</style> </style>
</head> </head>
@@ -551,6 +732,9 @@
{# aria-live pentru anuntarea schimbarilor de tema (accesibilitate) #} {# aria-live pentru anuntarea schimbarilor de tema (accesibilitate) #}
<span id="tema-live" role="status" aria-live="polite" <span id="tema-live" role="status" aria-live="polite"
style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;"></span> style="position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;"></span>
{# Toast global: feedback tranzitoriu (ex. dupa salvarea unui rand de import).
aria-live=polite -> citit de screen-reader. window.arataToast(text, stareCss). #}
<div id="toast" role="status" aria-live="polite" hidden></div>
<main>{% block content %}{% endblock %}</main> <main>{% block content %}{% endblock %}</main>
{# Modal detaliu trimitere: container global, SIBLING al <main> (nu descendent), {# Modal detaliu trimitere: container global, SIBLING al <main> (nu descendent),
ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el. Corpul ca `inert`+`aria-hidden` pe <main> sa nu-l prinda si pe el. Corpul
@@ -696,6 +880,39 @@
if (saveBtn) saveBtn.classList.add('dirty'); if (saveBtn) saveBtn.classList.add('dirty');
}); });
</script> </script>
<script>
// Toast global: feedback tranzitoriu vizibil + accesibil (aria-live).
// window.arataToast(text, stareCss) — stareCss (ex. "s-error"/"s-needs_review")
// coloreaza punctul indicator (rosu/galben/verde). Auto-dispare dupa ~3.2s.
(function() {
var t = document.getElementById('toast');
var timer = null;
window.arataToast = function(text, stareCss) {
if (!t) return;
t.className = '';
if (stareCss) t.classList.add('t-' + stareCss);
t.textContent = text;
t.hidden = false;
void t.offsetWidth; // reflow -> tranzitia porneste
t.classList.add('show');
if (timer) clearTimeout(timer);
timer = setTimeout(function() {
t.classList.remove('show');
setTimeout(function() { t.hidden = true; }, 220);
}, 3200);
};
})();
// Feedback dupa salvarea/confirmarea unui rand de import (HX-Trigger 'randSalvat').
// Toast imediat (ce rand + ce stare are acum); evidentierea randului se aplica dupa
// ce preview-ul se reincarca (reincarcaPreview), de scriptul din _preview_import.
document.body.addEventListener('randSalvat', function(e) {
var d = e.detail || {};
if (window.arataToast)
window.arataToast('Randul ' + d.nr + ' actualizat · ' + (d.stare || ''), d.stareCss || '');
window.__randSalvat = d;
});
</script>
<script> <script>
// Cautare + paginare client-side pentru tabele mari (data-dt="<page_size>"). Filtreaza si // Cautare + paginare client-side pentru tabele mari (data-dt="<page_size>"). Filtreaza si
// pagineaza DOM-ul deja randat (fara cereri server) — potrivit pentru maparile care pot creste // pagineaza DOM-ul deja randat (fara cereri server) — potrivit pentru maparile care pot creste

File diff suppressed because one or more lines are too long

View File

@@ -102,7 +102,7 @@ Un singur prag conceptual mobil la **768px**; un prag de densitate la **1024px**
| Interval | Numit | Regula | | Interval | Numit | Regula |
|----------|-------|--------| |----------|-------|--------|
| `>= 1024px` | desktop | layout complet; aplica si compactarile globale (wizard) | | `>= 1024px` | desktop | layout complet; aplica si compactarile globale (wizard) |
| `7681024px` | tableta | **card-uri** pentru tabelele actionabile (preview, mapari), 2 pe rand; tabelele dense read-only raman cu scroll contained | | `7681024px` | tableta | **card-uri** pentru tabelele actionabile (Trimiteri, Preview, Mapari), UN card pe rand (nu 2/rand); tabelele dense read-only raman cu scroll contained |
| `< 768px` | mobil | un card pe rand, o coloana, tinte touch 44px | | `< 768px` | mobil | un card pe rand, o coloana, tinte touch 44px |
CSS custom properties NU functioneaza in `@media`; pragul se scrie literal CSS custom properties NU functioneaza in `@media`; pragul se scrie literal
@@ -167,8 +167,9 @@ flex" — sparge valorile pe verticala. In schimb:
- **Card semantic**: linie titlu (identificator + stare), linii secundare mici. Preferat - **Card semantic**: linie titlu (identificator + stare), linii secundare mici. Preferat
pentru liste lungi (Trimiteri, Preview). pentru liste lungi (Trimiteri, Preview).
Sub 768px: un card pe rand. 7681024px: grid 2 carduri pe rand Listele actionabile (Trimiteri, Preview, Mapari) raman **O COLOANA (un card pe rand)** pe
(`grid-template-columns:repeat(2,1fr); gap:12px`). TOT intervalul sub 1024px — nu se foloseste grila 2/rand. Decizie confirmata cu userul
(gate 2026-06-27): simplitate si consecventa primeaza fata de densitate pe tableta.
Tabelele **dense read-only** (Jurnal, Nomenclator, Admin) raman tabel cu scroll orizontal Tabelele **dense read-only** (Jurnal, Nomenclator, Admin) raman tabel cu scroll orizontal
**contained in card** (`.tablewrap { overflow-x:auto }`), nu se cardifica. **contained in card** (`.tablewrap { overflow-x:auto }`), nu se cardifica.

View File

@@ -316,9 +316,10 @@ def test_confirmare_in_modal_seteaza_reviewed_si_devine_ok(client):
Verifica: Verifica:
- Raspuns 200 - Raspuns 200
- reviewed=1 in DB - reviewed=1 in DB
- Raspuns contine OOB cu pill 'Gata de trimis' (starea ok) - HX-Trigger: randSalvat cu noua stare 'Gata de trimis' (pentru toast)
- Header HX-Trigger-After-Settle: inchideModal - HX-Trigger: reincarcaPreview + HX-Trigger-After-Settle: inchideModal
""" """
import json as _json
_seed_op1() _seed_op1()
iid = _upload_and_preview_needs_review(client) iid = _upload_and_preview_needs_review(client)
@@ -334,14 +335,11 @@ def test_confirmare_in_modal_seteaza_reviewed_si_devine_ok(client):
assert _get_reviewed(iid, 0) == 1, \ assert _get_reviewed(iid, 0) == 1, \
"reviewed trebuie sa fie 1 in DB dupa confirmare" "reviewed trebuie sa fie 1 in DB dupa confirmare"
# Raspuns contine OOB cu randul actualizat # Contractul nou: reload preview + randSalvat cu noua stare (nu OOB pe <tr>).
html = r.text trig = _json.loads(r.headers.get("HX-Trigger", "{}"))
assert 'id="preview-row-0"' in html or "preview-row-0" in html, \ assert trig.get("reincarcaPreview") is True, "confirma-review trebuie sa ceara reincarcaPreview"
"Raspunsul trebuie sa contina randul actualizat (OOB)" assert trig.get("randSalvat", {}).get("stare") == "Gata de trimis", \
"Dupa confirmare, randSalvat.stare trebuie sa fie 'Gata de trimis' (pentru toast)"
# Starea a devenit ok
assert "Gata de trimis" in html or "s-ok" in html, \
"Dupa confirmare, randul trebuie sa fie ok (pill 'Gata de trimis')"
# Modal se inchide # Modal se inchide
trigger = r.headers.get("HX-Trigger-After-Settle", "") trigger = r.headers.get("HX-Trigger-After-Settle", "")
@@ -585,16 +583,17 @@ def test_confirma_review_form_nu_foloseste_hx_swap_none():
) )
def test_confirma_review_raspuns_contine_script_updateN(client): def test_confirma_review_cere_reincarcarea_preview(client):
"""Bug B1 (functional): raspunsul POST confirma-review contine scriptul """Contractul nou (dogfood 5.13): confirma-review NU mai depinde de scriptul updateN
updateN in payload-ul principal (nu doar OOB), astfel ca htmx il va executa din payload (care, cu OOB pe <tr> rupt, lasa randul stale). Acum cere reincarcaPreview,
cand face swap in #detaliu-modal-body. iar preview-ul reincarcat re-randeaza contorul si butonul de confirmare cu n_confirmat
corect server-side — deci problema B1 (n_confirmat stale -> 422) dispare structural.
Verifica: Verifica:
- Raspuns 200 - Raspuns 200
- Raspunsul contine 'window.updateN' (scriptul de recalcul contor) - HX-Trigger contine reincarcaPreview (reincarca contorul/confirmarea, fresh)
- Raspunsul contine 'updateN' inainte de ultimul OOB-element (@script tag nu e OOB)
""" """
import json as _json
_seed_op1() _seed_op1()
iid = _upload_and_preview_needs_review(client) iid = _upload_and_preview_needs_review(client)
@@ -602,17 +601,8 @@ def test_confirma_review_raspuns_contine_script_updateN(client):
r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf}) r = client.post(f"/_import/{iid}/rand/0/confirma-review", data={"csrf_token": csrf})
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
html = r.text trig = _json.loads(r.headers.get("HX-Trigger", "{}"))
# Scriptul trebuie sa fie in raspuns assert trig.get("reincarcaPreview") is True, (
assert "window.updateN" in html or "updateN" in html, ( "confirma-review trebuie sa ceara reincarcaPreview — preview-ul reincarcat aduce "
"Raspunsul confirma-review trebuie sa contina scriptul updateN " "n_confirmat corect server-side (fara dependenta de scriptul updateN din payload)."
"pentru ca htmx sa-l execute la swap in #detaliu-modal-body."
)
# Scriptul NU trebuie sa aiba hx-swap-oob (altfel nu ar fi executat nici asa)
script_idx = html.rfind("<script>")
assert script_idx >= 0, "Tag-ul <script> nu a fost gasit in raspuns"
script_content = html[script_idx:]
assert "hx-swap-oob" not in script_content, (
"Scriptul updateN NU trebuie sa aiba hx-swap-oob — trebuie sa fie in "
"continutul principal pentru executie."
) )

View File

@@ -95,17 +95,23 @@ def test_editeaza_intra_in_mod_editare_form_propriu(client):
assert 'name="data_prestatie"' in html and 'name="vin"' in html assert 'name="data_prestatie"' in html and 'name="vin"' in html
def test_salveaza_reda_doar_randul(client): def test_salveaza_cere_reincarcare_si_toast(client):
"""POST editeaza: raspuns = fragmentul randului + OOB contoare, NU tot #import-section (D-3.1).""" """POST editeaza: raspuns minimal + HX-Trigger(reincarcaPreview + randSalvat).
Contractul nou (dogfood 5.13): nu mai facem OOB swap pe <tr> (fragil in htmx 1.9 ->
randul ramanea cu starea veche). Raspunsul cere reincarcarea preview-ului si emite
detaliile randului salvat pentru toast/evidentiere."""
import json as _json
_seed_op1() _seed_op1()
iid = _upload_and_preview(client) iid = _upload_and_preview(client)
r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"data_prestatie": "2026-06-10"}) r = client.post(f"/_import/{iid}/rand/0/editeaza", data={"data_prestatie": "2026-06-10"})
assert r.status_code == 200 assert r.status_code == 200
html = r.text trig = _json.loads(r.headers.get("HX-Trigger", "{}"))
assert 'id="preview-row-0"' in html assert trig.get("reincarcaPreview") is True
# OOB pe rezumat (contoare), NU re-randarea sectiunii intregi. assert trig.get("randSalvat", {}).get("nr") == 1
assert 'id="preview-rezumat"' in html and 'hx-swap-oob="true"' in html # Raspunsul e doar un stub; randul real vine din reload-ul preview-ului.
assert 'id="import-section"' not in html assert 'id="preview-row-0"' not in r.text
assert 'id="import-section"' not in r.text
def test_enter_in_camp_editare_nu_declanseaza_confirm(client): def test_enter_in_camp_editare_nu_declanseaza_confirm(client):

View File

@@ -232,21 +232,21 @@ def test_stepper_pas3_la_preview_direct_mapare_retinuta(client):
def test_stepper_marcheaza_pasii_facuti(client): def test_stepper_marcheaza_pasii_facuti(client):
"""In preview (pas 3), pasii 1 si 2 sunt marcati ca facuti (clasa 'facut'). """In preview (pas 3), pasii 1 si 2 sunt marcati ca facuti (clasa is-done).
Verifica prin prezenta clasei CSS sau a marcajului vizual de 'facut'. Verifica prin prezenta clasei CSS is-done (doua aparitii: pasii 1 si 2).
""" """
_seed_op_mapping(client) _seed_op_mapping(client)
import_id = _upload_and_get_import_id(client) import_id = _upload_and_get_import_id(client)
text = _get_preview_via_mapare(client, import_id) text = _get_preview_via_mapare(client, import_id)
# Clasa "facut" trebuie sa apara pentru pasii 1 si 2 (index < pas curent) # Clasa "is-done" trebuie sa apara pentru pasii 1 si 2 (index < pas curent)
assert "facut" in text, \ assert "is-done" in text, \
"Clasa/marcajul 'facut' nu a fost gasit in preview (pasii 1 si 2 ar trebui marcati ca facuti)" "Clasa 'is-done' nu a fost gasita in preview (pasii 1 si 2 ar trebui marcati ca is-done)"
# Numarul de aparitii: cel putin 2 pasi marcati ca facuti # Numarul de aparitii: cel putin 2 pasi marcati ca is-done
count_facut = text.count("facut") count_done = text.count("is-done")
assert count_facut >= 2, \ assert count_done >= 2, \
f"Asteptat cel putin 2 pasi marcati ca 'facut' in preview, gasit {count_facut}" f"Asteptat cel putin 2 pasi marcati ca 'is-done' in preview, gasit {count_done}"
def test_import_hx_target_in_tab(client): def test_import_hx_target_in_tab(client):

View File

@@ -1,9 +1,13 @@
"""Teste US-011 (PRD 5.10): butoane icon salvare/stergere + dirty state pe Mapari. """Teste US-011 (PRD 5.10): butoane salvare/stergere + dirty state pe Mapari.
Actualizat PRD 5.13: superseda sistemul .icon-btn din 5.10 -> sistem .act
(desktop text / mobil iconita 44px).
Cerinte: Cerinte:
- Butoane .icon-btn mereu vizibile pe rand (nu ascunse in kebab) - Butoane .act mereu vizibile pe rand (nu ascunse in kebab)
- Meniu kebab (<details class="kebab">) eliminat - Meniu kebab (<details class="kebab">) eliminat
- aria-label descriptiv pe fiecare buton icon - aria-label pe fiecare buton .act (label scurt: "Salveaza" / "Sterge")
- Iconita Lucide (.act-ic svg) prezenta in output
- data-dirty-form pe butonul de salvare (permite JS dirty-state) - data-dirty-form pe butonul de salvare (permite JS dirty-state)
""" """
@@ -75,7 +79,10 @@ def client(monkeypatch):
def test_butoane_icon_vizibile_pe_rand_salvate(client): def test_butoane_icon_vizibile_pe_rand_salvate(client):
"""Butoanele de salvare/stergere in 'Mapari salvate' au clasa icon-btn (mereu vizibile).""" """Butoanele de salvare/stergere in 'Mapari salvate' au clasele .act (mereu vizibile pe rand).
PRD 5.13: .icon-btn inlocuit cu sistemul .act (act-save / act-del).
"""
acct = _create_account_user("actiuni_icon@test.com") acct = _create_account_user("actiuni_icon@test.com")
_seed_saved_mapping(acct) _seed_saved_mapping(acct)
_login(client, "actiuni_icon@test.com") _login(client, "actiuni_icon@test.com")
@@ -84,9 +91,13 @@ def test_butoane_icon_vizibile_pe_rand_salvate(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
assert 'class="icon-btn' in html, ( assert 'class="act act-save"' in html, (
"Butoanele de actiune din 'Mapari salvate' trebuie sa aiba clasa 'icon-btn' " "Butonul de salvare din 'Mapari salvate' trebuie sa aiba clasa 'act act-save' "
"(mereu vizibile pe rand, nu ascunse in kebab)." "(mereu vizibil pe rand, nu ascuns in kebab)."
)
assert 'class="act act-del"' in html, (
"Butonul de stergere din 'Mapari salvate' trebuie sa aiba clasa 'act act-del' "
"(mereu vizibil pe rand, nu ascuns in kebab)."
) )
@@ -109,7 +120,11 @@ def test_fara_kebab_meniu(client):
def test_butoane_cu_aria_label(client): def test_butoane_cu_aria_label(client):
"""Butoanele icon-btn au aria-label descriptiv.""" """Butoanele .act din 'Mapari salvate' au aria-label si iconita Lucide (.act-ic).
PRD 5.13: aria-label scurt ("Salveaza" / "Sterge") pe butonul .act;
iconita SVG cu clasa .act-ic (Lucide stroke) prezenta pentru mobil.
"""
acct = _create_account_user("actiuni_aria@test.com") acct = _create_account_user("actiuni_aria@test.com")
_seed_saved_mapping(acct) _seed_saved_mapping(acct)
_login(client, "actiuni_aria@test.com") _login(client, "actiuni_aria@test.com")
@@ -118,10 +133,13 @@ def test_butoane_cu_aria_label(client):
assert resp.status_code == 200 assert resp.status_code == 200
html = resp.text html = resp.text
icon_btns = re.findall(r'<button[^>]+class="icon-btn[^"]*"[^>]*>', html) act_btns = re.findall(r'<button[^>]+class="act[^"]*"[^>]*>', html)
assert icon_btns, "Trebuie sa existe butoane cu clasa icon-btn in 'Mapari salvate'." assert act_btns, "Trebuie sa existe butoane cu clasa 'act' in 'Mapari salvate'."
assert any('aria-label' in btn for btn in icon_btns), ( assert any('aria-label' in btn for btn in act_btns), (
"Cel putin un buton icon-btn trebuie sa aiba atributul aria-label descriptiv." "Cel putin un buton .act trebuie sa aiba atributul aria-label."
)
assert 'class="act-ic"' in html, (
"Iconita Lucide (.act-ic svg) trebuie sa fie prezenta in output (afisata pe mobil)."
) )

View File

@@ -241,17 +241,23 @@ def test_editeaza_preview_serveste_fragment_modal(client):
"Fragmentul modal nu trebuie sa contina confirm-form" "Fragmentul modal nu trebuie sa contina confirm-form"
def test_salvare_preview_inchide_modal_si_oob_rand(client): def test_salvare_preview_inchide_modal_si_reincarca(client):
"""POST /_import/{id}/rand/0/editeaza cu date valide → HX-Trigger-After-Settle: inchideModal """POST /_import/{id}/rand/0/editeaza cu date valide → inchide modalul + reincarca preview-ul.
+ OOB pe rand (#preview-row-0) si contoare (#preview-rezumat).
Contractul a fost schimbat (dogfood 5.13): OOB swap pe <tr> esua tacit in htmx 1.9
(un <tr> brut se pierde la parsarea fragmentului) -> randul ramanea cu starea veche.
Acum raspunsul NU mai contine OOB pe rand; emite HX-Trigger:
- reincarcaPreview -> #import-section isi reincarca preview-ul complet;
- randSalvat (cu nr + stare) -> toast + evidentiere in base.html;
iar HX-Trigger-After-Settle: inchideModal inchide modalul.
Verifica: Verifica:
- Status 200 - Status 200
- Header HX-Trigger-After-Settle contine 'inchideModal' - HX-Trigger-After-Settle contine 'inchideModal'
- Raspuns contine OOB pentru randul actualizat (hx-swap-oob prezent) - HX-Trigger contine 'reincarcaPreview' si 'randSalvat' (cu numarul randului)
- Raspuns contine OOB pentru rezumat (#preview-rezumat) - Raspunsul NU re-randeaza inline randul/sectiunea (reload via GET)
- NU re-randeaza intreaga sectiune (#import-section absent)
""" """
import json as _json
_seed_op1() _seed_op1()
iid = _upload_and_preview(client) iid = _upload_and_preview(client)
@@ -259,26 +265,21 @@ def test_salvare_preview_inchide_modal_si_oob_rand(client):
"data_prestatie": "2026-06-15", "data_prestatie": "2026-06-15",
}) })
assert r.status_code == 200, r.text assert r.status_code == 200, r.text
html = r.text
# Header de inchidere modal trigger_settle = r.headers.get("HX-Trigger-After-Settle", "")
trigger = r.headers.get("HX-Trigger-After-Settle", "") assert "inchideModal" in trigger_settle, \
assert "inchideModal" in trigger, \ f"HX-Trigger-After-Settle trebuie sa contina 'inchideModal', gasit: '{trigger_settle}'"
f"Header HX-Trigger-After-Settle trebuie sa contina 'inchideModal', gasit: '{trigger}'"
# OOB pe randul actualizat trigger = _json.loads(r.headers.get("HX-Trigger", "{}"))
assert 'id="preview-row-0"' in html, \ assert trigger.get("reincarcaPreview") is True, \
"Raspunsul trebuie sa contina randul actualizat (#preview-row-0)" "HX-Trigger trebuie sa ceara reincarcarea preview-ului (reincarcaPreview)"
assert "hx-swap-oob" in html, \ assert "randSalvat" in trigger, "HX-Trigger trebuie sa contina detaliile randului salvat"
"Raspunsul trebuie sa contina OOB swap" assert trigger["randSalvat"]["nr"] == 1, "randSalvat.nr = numarul (1-based) al randului editat"
assert "stare" in trigger["randSalvat"], "randSalvat trebuie sa contina noua stare (pentru toast)"
# OOB pe rezumatul stari # Raspunsul e doar un stub invizibil — randul real vine din reload, nu din OOB.
assert 'id="preview-rezumat"' in html, \ assert 'id="preview-row-0"' not in r.text, \
"Raspunsul trebuie sa contina OOB pe #preview-rezumat" "Noul contract NU mai face OOB swap pe rand (reload complet via reincarcaPreview)"
# NU re-randeaza intreaga sectiune de import
assert 'id="import-section"' not in html, \
"Editarea randului NU trebuie sa re-randeze intreaga sectiune #import-section"
def test_anuleaza_nu_lasa_rand_orfan(client): def test_anuleaza_nu_lasa_rand_orfan(client):

View File

@@ -65,6 +65,14 @@ def _login(client, email: str, password: str = "parolasecreta10") -> None:
assert resp.status_code == 303 assert resp.status_code == 303
def _bloc_mobil_principal(html: str) -> str:
"""Felie din CSS de la sentinel-ul blocului mobil principal pana la `</style>`."""
i = html.find("SENTINEL-TESTE-MOBIL")
assert i != -1, "Lipseste SENTINEL-TESTE-MOBIL in base.html (vezi PRD 5.13 Wave 0)"
j = html.find("</style>", i)
return html[i:(j if j != -1 else len(html))]
@pytest.fixture() @pytest.fixture()
def client(monkeypatch): def client(monkeypatch):
tmp = tempfile.mkdtemp() tmp = tempfile.mkdtemp()
@@ -332,10 +340,7 @@ def test_header_elemente_nu_au_min_height_fix_pe_mobil(client):
"Blocul tableta nu reseteaza min-height pentru header" "Blocul tableta nu reseteaza min-height pentru header"
# Blocul mobil (<768px) reseteaza si el min-height (regresie: nu a disparut). # Blocul mobil (<768px) reseteaza si el min-height (regresie: nu a disparut).
# Folosim `{` ca sa nu potrivim mentionarile din comentarii CSS. mobil = _bloc_mobil_principal(html)
mobil_idx = html.find("@media (max-width:767px) {")
assert mobil_idx != -1
mobil = html[mobil_idx:mobil_idx + 5000]
assert "min-height:0" in mobil, "Blocul mobil a pierdut resetarea min-height pe header" assert "min-height:0" in mobil, "Blocul mobil a pierdut resetarea min-height pe header"
@@ -348,10 +353,7 @@ def test_modal_full_screen_pe_mobil(client):
html = client.get("/?tab=acasa").text html = client.get("/?tab=acasa").text
# Regula CSS full-screen exista in blocul @media (max-width:767px) {. # Regula CSS full-screen exista in blocul @media (max-width:767px) {.
# Folosim varianta cu `{` ca sa NU potrivim mentionarile din comentarii CSS. mobil = _bloc_mobil_principal(html)
mobil_idx = html.find("@media (max-width:767px) {")
assert mobil_idx != -1, "Nu exista bloc @media (max-width:767px) { in CSS"
mobil = html[mobil_idx:mobil_idx + 5000]
assert "100vw" in mobil, "Dialogul nu are latime 100vw pe mobil" assert "100vw" in mobil, "Dialogul nu are latime 100vw pe mobil"
assert "100vh" in mobil, "Dialogul nu are inaltime 100vh pe mobil" assert "100vh" in mobil, "Dialogul nu are inaltime 100vh pe mobil"
# Butonul de inchidere >=44px (tinta touch) pe mobil. # Butonul de inchidere >=44px (tinta touch) pe mobil.
@@ -363,3 +365,138 @@ def test_modal_full_screen_pe_mobil(client):
# Target swap pentru editare preview (US-006) exista in DOM. # Target swap pentru editare preview (US-006) exista in DOM.
assert 'id="detaliu-modal-body"' in html, \ assert 'id="detaliu-modal-body"' in html, \
"Target #detaliu-modal-body lipseste din base.html" "Target #detaliu-modal-body lipseste din base.html"
# ============================================================
# PRD 5.13: guard-uri responsive card mobil + sistem actiuni + stepper compact
# ============================================================
def test_card_mobil_fara_break_vertical_120px(client):
"""P0: blocul mobil principal NU mai forteaza min-width:120px pe eticheta
(cauza break-ului vertical caracter-cu-caracter). Eticheta stivuita deasupra
valorii: td::before cu display:block. Checkbox + # ascunse pe card."""
_create_account_user("card120@test.com")
_login(client, "card120@test.com")
html = client.get("/?tab=acasa").text
bloc = _bloc_mobil_principal(html)
# min-width:120px nu mai exista in blocul mobil.
assert "min-width:120px" not in bloc, \
"min-width:120px inca prezent in blocul mobil — cauzeaza break vertical"
# td::before stivuieste eticheta deasupra valorii (display:block).
assert ".tabel-trimiteri td::before" in bloc, \
".tabel-trimiteri td::before lipseste din blocul mobil"
assert "display:block" in bloc, \
"display:block lipseste din blocul mobil (eticheta nu e stivuita)"
# col-chk si col-id ascunse pe card (nu ocupa spatiu).
assert ".tabel-trimiteri td.col-chk, .tabel-trimiteri td.col-id { display:none; }" in bloc, \
"col-chk/col-id nu sunt ascunse in blocul mobil"
def test_sistem_act_desktop_text_mobil_icon(client):
"""Sistemul .act: desktop = iconita ascunsa (text vizibil),
mobil = text ascuns, iconita vizibila 44px (tinta touch)."""
_create_account_user("act_sys@test.com")
_login(client, "act_sys@test.com")
html = client.get("/?tab=acasa").text
bloc = _bloc_mobil_principal(html)
# Desktop: act-ic ascunsa implicit (display:none in regula de baza).
assert ".act .act-ic { width:18px; height:18px; display:none; }" in html, \
"Regula desktop care ascunde .act-ic lipseste din CSS"
# Mobil: text ascuns, iconita vizibila.
assert ".act .act-tx { display:none; }" in bloc, \
".act .act-tx nu e ascuns in blocul mobil"
assert ".act .act-ic { display:inline-block; }" in bloc, \
".act .act-ic nu devine vizibila in blocul mobil"
# .act are tinta touch >=44px pe mobil.
assert ".act { min-width:44px" in bloc, \
".act nu are min-width:44px in blocul mobil"
def _seed_saved_mapping_responsive(acct_id: int) -> None:
"""Insereaza o mapare salvata in operations_mapping (pentru test act aria-label)."""
from app.db import get_connection
conn = get_connection()
try:
conn.execute(
"INSERT INTO operations_mapping (account_id, cod_op_service, cod_prestatie, auto_send) "
"VALUES (?, ?, ?, ?)",
(acct_id, "OP-RESP-99", "OE-1", 1),
)
conn.commit()
finally:
conn.close()
def test_act_btn_aria_label_invariant(client):
"""Invariant a11y: butoanele .act din Mapari au mereu aria-label
(accesibil in modul icon-only pe mobil) si iconita .act-ic."""
acct = _create_account_user("actaria@test.com")
_seed_saved_mapping_responsive(acct)
_login(client, "actaria@test.com")
html = client.get("/_fragments/mapari").text
# Butoane cu clasa act exista in fragmentul de mapari.
act_btns = re.findall(r'<button[^>]+class="act[^"]*"[^>]*>', html)
assert act_btns, "Trebuie sa existe butoane cu clasa 'act' in fragmentul Mapari"
# Fiecare buton .act are aria-label (accesibil cand textul e ascuns pe mobil).
assert any("aria-label" in btn for btn in act_btns), \
"Niciun buton .act nu are aria-label — inaccessibil in modul icon-only"
# Iconita .act-ic prezenta in markup (afisata pe mobil).
assert 'class="act-ic"' in html, \
"Iconita .act-ic lipseste din fragmentul Mapari"
def test_stepper_compact_clase(client):
"""Stepper compact (5.13): clasele stepper-track, stepper-collapsed,
stepper-progress prezente; textul 'Pasul N din 4' randat; vechile clase
stepper-pas-- absente; comutarea la <1024px declarata in CSS."""
_create_account_user("stepper@test.com")
_login(client, "stepper@test.com")
html = client.get("/?tab=acasa").text
# Elementele de structura ale stepper-ului compact.
assert "stepper-track" in html, "stepper-track lipseste din markup"
assert "stepper-collapsed" in html, "stepper-collapsed lipseste din markup"
assert "stepper-progress" in html, "stepper-progress lipseste din markup"
# Textul de progres in forma colapsata (<1024px).
assert "Pasul 1 din 4" in html, "Textul 'Pasul 1 din 4' lipseste din markup"
# Vechile clase anti-pattern cu stepper-pas-- nu mai exista.
assert "stepper-pas--" not in html, \
"Clasa veche stepper-pas-- inca prezenta — curata markup-ul"
# CSS declara comutarea la <1024px (track ascuns, collapsed afisat).
assert "@media (max-width:1024px)" in html, \
"Lipseste regula @media (max-width:1024px) pentru comutarea stepper-ului"
def test_liste_actionabile_o_coloana_pana_1024(client):
"""Guard scope (decizie 5.13): listele actionabile raman O COLOANA
pana la 1024px — fara grila 2/rand (repeat(2)). Blocul tableta 768-1024px
cardifica (thead ascuns, card per rand)."""
_create_account_user("ocoloana@test.com")
_login(client, "ocoloana@test.com")
html = client.get("/?tab=acasa").text
# Nicaieri in CSS nu apare grila 2/rand (repeat(2, ...)).
assert "repeat(2" not in html, \
"CSS contine repeat(2 — listele actionabile NU trebuie sa fie 2/rand pana la 1024px"
# Exista blocul tableta (768-1024px).
assert "@media (min-width:768px) and (max-width:1024px)" in html, \
"Lipseste blocul @media tableta (min-width:768px) and (max-width:1024px)"
# Blocul tableta cardifica listele (thead ascuns = card per rand, o coloana).
assert ".tabel-trimiteri thead, .tabel-card thead { display:none; }" in html, \
"Blocul tableta nu ascunde thead-ul pentru cardificare (o coloana)"