feat(5.15+5.14): CLOSE — fix-uri code-review + embeddings functional
5.15 (propagare design + dashboard editare) si 5.14 (mapare LLM distilata) inchise dupa /code-review high. 8 buguri reparate TDD: - HIGH modal nu se deschidea pe randul slim (base.html: trimitere-slim) - HIGH /repune trunchia prestatii (declaratie incompleta la RAR) -> iterare peste existing, codes pozitional - HIGH embeddings incarca model ~230MB degeaba pe corpus gol -> poarta has_corpus() - HIGH picker chips gol pe re-render eroare -> conn/account_id pe toate ramurile - MED obs re-derivat dupa stergere explicita -> _merge_override pastreaza obs='' - MED mapare salvata fara denumire poluă GOLD -> _record_gold_validation guard - MED typo nome_prestatie -> nume_prestatie in select /repune - MED bucketare timp +3h gresita iarna -> SQLite localtime + TZ=Europe/Bucharest Embeddings WIRE-uit functional (PRD #15, decizie user): ensure_embeddings_corpus construieste corpus din nomenclator, gated pe AUTOPASS_EMBEDDINGS_ENABLED (default off). Marime model corectata ~50MB->~230MB (estimare PRD gresita). Cleanup: hoist load_* din bucla bulk-fix; import re la top. Regresie: 1256 passed, 1 deselected (live), 0 failed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -186,6 +186,14 @@ def _resolve_row_for_preview(
|
||||
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
||||
|
||||
# obs derive-on-empty (D7/E3 PRD 5.15): daca obs e gol si avem operatie,
|
||||
# copiem denumirea operatiei in obs (nu o mutam — op_service ramane neatins).
|
||||
# DERIVE-ON-EMPTY: doar cand obs e gol, ca sa fie idempotent la re-preview/re-editare.
|
||||
obs_curent = str(mapped.get("obs") or "").strip()
|
||||
if not obs_curent and operatie_val:
|
||||
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||
mapped["obs"] = obs_denumire
|
||||
|
||||
# Canonicalizare: normalizeaza VIN/nr/odometru
|
||||
canon = canonicalize_row(mapped)
|
||||
mapped.update({
|
||||
@@ -257,8 +265,9 @@ def _build_idempotency_key(account_id: int | None, resolved: dict[str, Any]) ->
|
||||
|
||||
|
||||
# Campuri de continut editabile in preview. Operatia/codul RAR NU se editeaza
|
||||
# aici (raman in panoul de mapare).
|
||||
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final")
|
||||
# aici (raman in panoul de mapare). obs = text liber, se trateaza ca non-canonic
|
||||
# (doar .strip(), fara canonicalize_row) — urmeaza ramura `else` din _merge_override.
|
||||
EDIT_FIELDS = ("vin", "nr_inmatriculare", "data_prestatie", "odometru_initial", "odometru_final", "obs")
|
||||
|
||||
|
||||
def _merge_override(current: dict[str, Any], fields: dict[str, str | None]) -> dict[str, Any]:
|
||||
@@ -279,7 +288,15 @@ def _merge_override(current: dict[str, Any], fields: dict[str, str | None]) -> d
|
||||
continue
|
||||
s = str(val).strip()
|
||||
if s == "":
|
||||
out.pop(camp, None) # empty = clear
|
||||
if camp == "obs":
|
||||
# obs e camp DERIVAT (copiaza denumirea operatiei cand e gol). Empty =
|
||||
# STERGERE EXPLICITA a userului -> pastram obs='' in override ca
|
||||
# derive-on-empty sa NU il re-deriveze (override aplicat ULTIMUL
|
||||
# suprascrie derivarea, in preview si la commit). Un pop ar fi pierdut
|
||||
# semnalul "sters explicit" -> obs re-derivat silentios din denumire.
|
||||
out["obs"] = ""
|
||||
else:
|
||||
out.pop(camp, None) # empty = clear (revine la valoarea din fisier)
|
||||
else:
|
||||
raw[camp] = s
|
||||
if raw:
|
||||
@@ -1078,6 +1095,13 @@ def commit_import(
|
||||
denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||
mapped["prestatii"] = [{"cod_op_service": str(operatie_val), "denumire": denumire}]
|
||||
|
||||
# obs derive-on-empty (D7/E3 PRD 5.15): copiere denumire in obs daca obs e gol.
|
||||
# Identic cu logica din _resolve_row_for_preview (override aplicat tot ultimul).
|
||||
obs_curent = str(mapped.get("obs") or "").strip()
|
||||
if not obs_curent and operatie_val:
|
||||
obs_denumire = str(denumire_val).strip() if denumire_val not in (None, "") else str(operatie_val)
|
||||
mapped["obs"] = obs_denumire
|
||||
|
||||
# Rezolva prestatii INAINTE de canonicalizare (altfel cheia difera de cea din preview)
|
||||
prestatii = mapped.get("prestatii") or []
|
||||
resolved, _ = resolve_prestatii(prestatii, mapping, valid_codes, text_rules)
|
||||
@@ -1180,12 +1204,14 @@ def commit_import(
|
||||
|
||||
class RandEditIn(BaseModel):
|
||||
"""Campuri de continut editabile in preview. None = ne-trimis (neschimbat);
|
||||
"" = sterge override-ul (revine la valoarea din fisier)."""
|
||||
"" = sterge override-ul (revine la valoarea din fisier).
|
||||
obs = text liber fara validare de continut (US-005 PRD 5.15)."""
|
||||
vin: str | None = None
|
||||
nr_inmatriculare: str | None = None
|
||||
data_prestatie: str | None = None
|
||||
odometru_initial: str | None = None
|
||||
odometru_final: str | None = None
|
||||
obs: str | None = None
|
||||
|
||||
|
||||
@router.post("/{import_id}/rand/{row_index}/editeaza")
|
||||
|
||||
@@ -104,6 +104,13 @@ class Settings(BaseSettings):
|
||||
worker_retry_max_s: int = 300
|
||||
worker_max_retries: int = 8 # peste atat -> error + banner
|
||||
|
||||
# --- Embeddings (sugestie mapare, Stratul 2 PRD 5.14) ---
|
||||
# DEZACTIVAT implicit: prima folosire lazy-load-eaza modelul fastembed/ONNX
|
||||
# (~230MB pe disc) sincron in thread-ul de cerere -> hang la prima cerere /mapari.
|
||||
# Activeaza explicit in productie (start.sh/Docker/.env) cand vrei sugestii semantice.
|
||||
# OFF pastreaza suita de teste rapida si /mapari instant (cade pe GOLD/SILVER+fuzzy).
|
||||
embeddings_enabled: bool = False
|
||||
|
||||
@property
|
||||
def rar_base_url(self) -> str:
|
||||
return self.rar_base_url_prod if self.rar_env == "prod" else self.rar_base_url_test
|
||||
|
||||
246
app/embeddings.py
Normal file
246
app/embeddings.py
Normal file
@@ -0,0 +1,246 @@
|
||||
"""Modul embedding in-proces pentru sugestie cod RAR -- L14-S4.
|
||||
|
||||
Design (PRD 5.14, Decision #16/#16b):
|
||||
- Model multilingv via fastembed/ONNX (~230MB pe disc, quantizat, fara torch)
|
||||
- Lazy load la prima folosire, NU la import si NU pe /healthz
|
||||
- Worker NU incarca modelul (API-only)
|
||||
- Degradare gratioasa: daca modelul nu se incarca -> is_available()=False,
|
||||
suggest_nearest() -> [] fara exceptie, ingestia NU e blocata
|
||||
- Embeddings = DOAR sugestie (nu intra in lantul de enqueue/resolve_prestatii)
|
||||
- NU apelat din resolve_prestatii/load_mapping (wiring vine in L14-S6 DUPA 5.15)
|
||||
|
||||
API public (nivel modul):
|
||||
index_corpus(items) -> None
|
||||
suggest_nearest(text, top_k) -> [{cod, similaritate}]
|
||||
is_available() -> bool
|
||||
|
||||
Clase (pentru teste / injectare backend):
|
||||
EmbeddingEngine(backend) -- motor testabil cu backend injectabil
|
||||
FastEmbedBackend() -- backend real fastembed/ONNX
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Modelul ales: paraphrase-multilingual-MiniLM-L12-v2
|
||||
# ~230MB pe disc (ONNX quantizat), 384 dim, multilingv (ro/en/etc.), suportat de
|
||||
# fastembed, fara torch. (Estimarea initiala din PRD de ~50MB a fost gresita.)
|
||||
FASTEMBED_MODEL = "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Protocol backend (mockabil in teste) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
@runtime_checkable
|
||||
class EmbeddingBackend(Protocol):
|
||||
"""Interfata minimala pentru un backend de embedding."""
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Intoarce o lista de vectori (cate unul per text)."""
|
||||
...
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Backend real: fastembed/ONNX #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
class FastEmbedBackend:
|
||||
"""Backend fastembed/ONNX. Lazy-load la constructie.
|
||||
|
||||
Arunca ImportError daca fastembed nu e instalat, sau orice exceptie
|
||||
de la TextEmbedding (download esuat, ONNX incompatibil etc.).
|
||||
Apelantul (_load_engine) prinde aceste exceptii.
|
||||
"""
|
||||
|
||||
def __init__(self, model_name: str = FASTEMBED_MODEL):
|
||||
from fastembed import TextEmbedding # import tardiv -- nu blocheaza la import modul
|
||||
self._model = TextEmbedding(model_name=model_name)
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
# fastembed.embed() intoarce un generator de numpy arrays
|
||||
return [vec.tolist() for vec in self._model.embed(texts)]
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Motor de embedding (testabil, backend injectabil) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def _cosine_similarity(a: list[float], b: list[float]) -> float:
|
||||
"""Similaritate cosine intre doi vectori. Returneaza 0.0 pe vectori nuli."""
|
||||
dot = sum(x * y for x, y in zip(a, b))
|
||||
na = math.sqrt(sum(x * x for x in a))
|
||||
nb = math.sqrt(sum(x * x for x in b))
|
||||
if na == 0.0 or nb == 0.0:
|
||||
return 0.0
|
||||
return dot / (na * nb)
|
||||
|
||||
|
||||
class EmbeddingEngine:
|
||||
"""Motor de embedding cu corpus indexat si cautare NN cosine.
|
||||
|
||||
Parametri:
|
||||
backend: instanta EmbeddingBackend (real sau mock).
|
||||
None => degradare gratioasa (is_available=False).
|
||||
"""
|
||||
|
||||
def __init__(self, backend: EmbeddingBackend | None = None):
|
||||
self._backend = backend
|
||||
self._corpus_vecs: list[list[float]] = []
|
||||
self._corpus_items: list[dict] = []
|
||||
self._corpus_sig: str | None = None
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""True daca backend-ul e disponibil si gata de folosire."""
|
||||
return self._backend is not None
|
||||
|
||||
def has_corpus(self) -> bool:
|
||||
"""True daca un corpus a fost indexat (suggest_nearest poate produce ceva)."""
|
||||
return bool(self._corpus_items)
|
||||
|
||||
def corpus_signature(self) -> str | None:
|
||||
"""Semnatura corpusului indexat (None daca gol). Apelantul re-indexeaza
|
||||
doar cand semnatura nomenclatorului s-a schimbat (evita re-embed inutil)."""
|
||||
return self._corpus_sig
|
||||
|
||||
def index_corpus(self, items: list[dict], signature: str | None = None) -> None:
|
||||
"""Vectorizeaza corpus [{denumire, cod}] si il pastreaza in memorie.
|
||||
|
||||
Ignora silentios daca backend-ul lipseste, corpus-ul e gol sau apare
|
||||
orice exceptie la vectorizare (degradare gratioasa).
|
||||
"""
|
||||
self._corpus_vecs = []
|
||||
self._corpus_items = []
|
||||
self._corpus_sig = None
|
||||
|
||||
if not items or not self.is_available():
|
||||
return
|
||||
|
||||
try:
|
||||
texts = [str(item["denumire"]) for item in items]
|
||||
vecs = self._backend.embed(texts)
|
||||
self._corpus_vecs = vecs
|
||||
self._corpus_items = list(items)
|
||||
self._corpus_sig = signature
|
||||
except Exception as exc:
|
||||
log.warning("embeddings: index_corpus esuat: %s", exc)
|
||||
# corpus ramane gol -- suggest_nearest va returna []
|
||||
|
||||
def suggest_nearest(
|
||||
self,
|
||||
denumire: str,
|
||||
top_k: int = 3,
|
||||
) -> list[dict]:
|
||||
"""Returneaza top_k vecini cosine [{cod, similaritate}].
|
||||
|
||||
Returneaza [] daca backend-ul lipseste, corpus-ul e gol sau apare
|
||||
orice exceptie (degradare gratioasa -- nu blocheaza ingestia).
|
||||
"""
|
||||
if not self.is_available() or not self._corpus_items:
|
||||
return []
|
||||
|
||||
try:
|
||||
query_vecs = self._backend.embed([str(denumire)])
|
||||
query_vec = query_vecs[0]
|
||||
scored = [
|
||||
{
|
||||
"cod": item["cod"],
|
||||
"similaritate": _cosine_similarity(query_vec, vec),
|
||||
}
|
||||
for item, vec in zip(self._corpus_items, self._corpus_vecs)
|
||||
]
|
||||
scored.sort(key=lambda r: r["similaritate"], reverse=True)
|
||||
return scored[:top_k]
|
||||
except Exception as exc:
|
||||
log.warning("embeddings: suggest_nearest esuat: %s", exc)
|
||||
return []
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# Singleton global cu lazy load (API-only, NU worker) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
_engine: EmbeddingEngine | None = None
|
||||
|
||||
|
||||
def _load_engine() -> EmbeddingEngine:
|
||||
"""Lazy load: construieste engine-ul la prima folosire.
|
||||
|
||||
Captureaza ORICE exceptie la incarcare (import, download, ONNX init)
|
||||
si returneaza un engine degradat (backend=None) -- ingestia continua
|
||||
pe exact+fuzzy, embedding = sugestie dezactivata.
|
||||
"""
|
||||
try:
|
||||
backend = FastEmbedBackend()
|
||||
log.info("embeddings: backend fastembed incarcat (%s)", FASTEMBED_MODEL)
|
||||
return EmbeddingEngine(backend=backend)
|
||||
except ImportError:
|
||||
log.warning(
|
||||
"embeddings: fastembed nu e instalat -- sugestii NN dezactivate"
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"embeddings: incarcare backend esuata (%s) -- sugestii NN dezactivate",
|
||||
exc,
|
||||
)
|
||||
return EmbeddingEngine(backend=None)
|
||||
|
||||
|
||||
def _get_engine() -> EmbeddingEngine:
|
||||
"""Returneaza engine-ul global (lazy-init)."""
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = _load_engine()
|
||||
return _engine
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# API public la nivel de modul (wiring L14-S6) #
|
||||
# --------------------------------------------------------------------------- #
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True daca modelul e incarcat si gata de folosire."""
|
||||
return _get_engine().is_available()
|
||||
|
||||
|
||||
def has_corpus() -> bool:
|
||||
"""True daca un corpus a fost indexat in motorul global.
|
||||
|
||||
NU forteaza incarcarea modelului: daca engine-ul nu a fost initializat inca
|
||||
(`_engine is None`), corpus-ul e gol prin definitie -> False, fara cost.
|
||||
Apelantii (ex. enrich_suggestions) folosesc asta ca poarta ieftina inainte de
|
||||
a atinge calea scumpa (is_available/suggest_nearest, care lazy-load ~230MB).
|
||||
"""
|
||||
if _engine is None:
|
||||
return False
|
||||
return _engine.has_corpus()
|
||||
|
||||
|
||||
def corpus_signature() -> str | None:
|
||||
"""Semnatura corpusului global indexat (None daca engine ne-initializat/gol).
|
||||
|
||||
NU forteaza incarcarea modelului: `_engine is None` -> None fara cost.
|
||||
"""
|
||||
if _engine is None:
|
||||
return None
|
||||
return _engine.corpus_signature()
|
||||
|
||||
|
||||
def index_corpus(items: list[dict], signature: str | None = None) -> None:
|
||||
"""Vectorizeaza corpus [{denumire, cod}] in motorul global.
|
||||
|
||||
Silentios pe eroare (degradare gratioasa).
|
||||
"""
|
||||
_get_engine().index_corpus(items, signature=signature)
|
||||
|
||||
|
||||
def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]:
|
||||
"""Returneaza top_k sugestii [{cod, similaritate}] sau [] la eroare.
|
||||
|
||||
Sigur de apelat indiferent de starea backend-ului.
|
||||
"""
|
||||
return _get_engine().suggest_nearest(denumire, top_k=top_k)
|
||||
151
app/mapping.py
151
app/mapping.py
@@ -14,6 +14,7 @@ unit-testabile direct. Cele cu `conn` sunt helpere de persistenta.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
@@ -483,10 +484,18 @@ def pending_unmapped(conn, account_id=None) -> list[dict]:
|
||||
entry["denumire"] = item.get("denumire")
|
||||
entry["_ids"].add(r["id"])
|
||||
|
||||
# Indexeaza corpusul embeddings o data inainte de bucla (no-op cand flagul e off).
|
||||
ensure_embeddings_corpus(conn, nomenclator)
|
||||
|
||||
out: list[dict] = []
|
||||
for entry in agg.values():
|
||||
entry["blocked"] = len(entry.pop("_ids"))
|
||||
entry["suggestions"] = suggest_codes(entry["denumire"], nomenclator, limit=5)
|
||||
# L14-S6: imbogatire sugestii cu GOLD partajat > SILVER > embeddings (Eng-F2).
|
||||
# SUGGESTION-ONLY: nu intra in resolve_prestatii/load_mapping (#13).
|
||||
enriched = enrich_suggestions(conn, entry["denumire"])
|
||||
entry["sugestie_principala"] = enriched["sugestie_principala"]
|
||||
entry["surse_sugestie"] = enriched["surse"]
|
||||
out.append(entry)
|
||||
out.sort(key=lambda e: (-e["blocked"], e["cod_op_service"]))
|
||||
return out
|
||||
@@ -561,6 +570,148 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
# Prag minim de similaritate cosine pentru sugestia din embeddings NN.
|
||||
# Sub acest scor, sugestia NN e prea incerta si nu o afisam (previne recomandari
|
||||
# irelevante cand corpus-ul e mic sau neindexat corect).
|
||||
EMB_MIN_SIMILARITATE = 0.5
|
||||
|
||||
|
||||
def _corpus_signature(nomenclator: list[dict]) -> str:
|
||||
"""Semnatura stabila a nomenclatorului pentru cache-ul corpusului embeddings.
|
||||
|
||||
Hash pe perechile (cod, denumire) sortate dupa cod -> se schimba la orice
|
||||
add/remove/redenumire de cod, ramane stabila altfel (evita re-embed inutil).
|
||||
"""
|
||||
pairs = sorted(
|
||||
(str(n.get("cod_prestatie") or ""), str(n.get("nume_prestatie") or ""))
|
||||
for n in nomenclator
|
||||
)
|
||||
blob = "".join(f"{c}{d}" for c, d in pairs)
|
||||
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None:
|
||||
"""Construieste/actualizeaza corpusul embeddings din nomenclator (Stratul 2 PRD 5.14).
|
||||
|
||||
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default OFF): cand e dezactivat, e un
|
||||
no-op total (nu atinge modelul, nu interogheaza nomenclatorul) -> /mapari instant
|
||||
+ suita de teste rapida; sugestiile cad pe GOLD/SILVER + fuzzy.
|
||||
|
||||
Cand e activat: indexeaza corpusul {denumire=nume_prestatie, cod=cod_prestatie}
|
||||
o singura data (lazy-load modelul ~230MB la prima chemare), re-indexeaza doar
|
||||
cand semnatura nomenclatorului s-a schimbat. Degradare gratioasa: orice eroare
|
||||
(model absent, embed esuat) lasa corpusul gol -> enrich_suggestions cade pe restul.
|
||||
|
||||
Apelat de apelantii care imbogatesc sugestii (pending_unmapped,
|
||||
_nemapate_pentru_submission) INAINTE de bucla de enrich_suggestions, NU din
|
||||
enrich_suggestions (care ramane o interogare ieftina cu garda has_corpus()).
|
||||
"""
|
||||
from .config import get_settings
|
||||
if not get_settings().embeddings_enabled:
|
||||
return
|
||||
try:
|
||||
from . import embeddings as _emb
|
||||
nomen = nomenclator if nomenclator is not None else load_nomenclator(conn)
|
||||
if not nomen:
|
||||
return
|
||||
sig = _corpus_signature(nomen)
|
||||
if _emb.corpus_signature() == sig and _emb.has_corpus():
|
||||
return # deja indexat pe acelasi nomenclator -> nimic de facut
|
||||
items = [
|
||||
{"denumire": str(n["nume_prestatie"]), "cod": str(n["cod_prestatie"])}
|
||||
for n in nomen
|
||||
if n.get("nume_prestatie") and n.get("cod_prestatie")
|
||||
]
|
||||
_emb.index_corpus(items, signature=sig)
|
||||
except Exception:
|
||||
pass # degradare gratioasa (#16b): esecul indexarii nu blocheaza editorul
|
||||
|
||||
|
||||
def enrich_suggestions(
|
||||
conn,
|
||||
denumire: str | None,
|
||||
*,
|
||||
include_embeddings: bool = True,
|
||||
) -> dict:
|
||||
"""Imbogateste sugestiile cu GOLD partajat, SILVER LLM si embeddings NN.
|
||||
|
||||
Precedenta Eng-F2 (pentru sugestie-only, nu auto-send):
|
||||
shared GOLD > SILVER > embeddings
|
||||
|
||||
(Account GOLD = operations_mapping propriu = deja rezolvat inainte de needs_mapping;
|
||||
nu apare in needs_mapping, deci nu e in precedenta de sugestie.)
|
||||
|
||||
Returneaza:
|
||||
{
|
||||
'sugestie_principala': {'cod_prestatie': str, 'sursa': str} | None,
|
||||
'surse': {'gold_partajat': str|None, 'silver': str|None, 'embedding': str|None}
|
||||
}
|
||||
|
||||
INVARIANTE:
|
||||
- Toate sursele = SUGGESTION-ONLY. NU intra in resolve_prestatii/load_mapping (#13).
|
||||
- SILVER cu is_nul=1 (non-operatie/gunoi) NU produce sugestie (#4).
|
||||
- Degradare gratioasa pe embeddings (#16b): daca motorul nu e disponibil sau arunca,
|
||||
returneaza sugestia disponibila din celelalte surse, fara exceptie.
|
||||
- Import local shared_store/embeddings: evita ciclu la import-time (shared_store
|
||||
importa normalize_for_match din mapping).
|
||||
"""
|
||||
sugestie_principala: dict | None = None
|
||||
surse: dict = {"gold_partajat": None, "silver": None, "embedding": None}
|
||||
|
||||
if not denumire:
|
||||
return {"sugestie_principala": sugestie_principala, "surse": surse}
|
||||
|
||||
# Colecteaza TOATE sursele (fara short-circuit) in `surse`: editorul le poate afisa
|
||||
# toate, independent de care castiga ca sugestie principala.
|
||||
# Precedenta Eng-F2 se aplica DOAR la alegerea sugestiei_principale.
|
||||
|
||||
# 1. GOLD partajat cross-account (validat de om, cel mai de incredere)
|
||||
try:
|
||||
from .shared_store import lookup_shared_gold
|
||||
row_gold = lookup_shared_gold(conn, denumire)
|
||||
if row_gold and row_gold["cod_prestatie"]:
|
||||
surse["gold_partajat"] = str(row_gold["cod_prestatie"])
|
||||
except Exception:
|
||||
pass # degradare gratioasa
|
||||
|
||||
# 2. SILVER LLM (bootstrap, nevalidat de om; is_nul = supresie)
|
||||
try:
|
||||
from .shared_store import lookup_suggestion
|
||||
row_silver = lookup_suggestion(conn, denumire)
|
||||
if row_silver and not row_silver["is_nul"] and row_silver["cod_prestatie"]:
|
||||
surse["silver"] = str(row_silver["cod_prestatie"])
|
||||
except Exception:
|
||||
pass # degradare gratioasa
|
||||
|
||||
# 3. Embeddings NN (similaritate semantica, degradare gratioasa #16b)
|
||||
if include_embeddings:
|
||||
try:
|
||||
from . import embeddings as _emb
|
||||
# Poarta IEFTINA: nu atinge is_available()/suggest_nearest cand corpus-ul
|
||||
# e gol — `is_available()` lazy-load-eaza modelul de ~230MB (30-120s in
|
||||
# thread-ul de cerere). Corpusul se construieste de apelant prin
|
||||
# ensure_embeddings_corpus (gated pe AUTOPASS_EMBEDDINGS_ENABLED); cand
|
||||
# flagul e off, has_corpus() ramane False si calea e un no-op real.
|
||||
if _emb.has_corpus():
|
||||
nn = _emb.suggest_nearest(str(denumire), top_k=1)
|
||||
# Prag minim: similaritate prea mica = sugestie inutila.
|
||||
# Evita recomandari irelevante cand corpus-ul e mic/partial.
|
||||
if nn and nn[0].get("similaritate", 0) >= EMB_MIN_SIMILARITATE:
|
||||
surse["embedding"] = str(nn[0]["cod"])
|
||||
except Exception:
|
||||
pass # degradare gratioasa (#16b): motorul absent nu blocheaza
|
||||
|
||||
# Alege sugestia principala in ordinea de precedenta: GOLD > SILVER > embeddings
|
||||
if surse["gold_partajat"]:
|
||||
sugestie_principala = {"cod_prestatie": surse["gold_partajat"], "sursa": "gold_partajat"}
|
||||
elif surse["silver"]:
|
||||
sugestie_principala = {"cod_prestatie": surse["silver"], "sursa": "silver"}
|
||||
elif surse["embedding"]:
|
||||
sugestie_principala = {"cod_prestatie": surse["embedding"], "sursa": "embedding"}
|
||||
|
||||
return {"sugestie_principala": sugestie_principala, "surse": surse}
|
||||
|
||||
|
||||
def _emite_text_rule_hits(conn, account_id: int, submission_id: int, resolved: list[dict] | None) -> None:
|
||||
"""Emite `text_rule_hit` in app_events pentru fiecare item rezolvat prin regula text.
|
||||
|
||||
|
||||
@@ -125,6 +125,10 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
||||
# altfel ar expune denumirea RAR drept op. de service, ceea ce e semantic incorect.
|
||||
op_service_denumire = _clean_str(item.get("denumire")) if op_service_cod else ""
|
||||
|
||||
# obs: text liber observatii (camp RAR, optional). Conventie goala "" (nu EMPTY).
|
||||
# US-005 PRD 5.15: obs traieste in payload_json (nu coloana separata).
|
||||
obs = _clean_str(data.get("obs"))
|
||||
|
||||
return {
|
||||
"vehicul_nr": nr or EMPTY,
|
||||
"vin": vin or EMPTY,
|
||||
@@ -137,4 +141,5 @@ def prezentare_din_payload(payload: str | dict | None) -> dict[str, str]:
|
||||
# Chei cu conventie goala "" (nu EMPTY) — vezi comentariu de mai sus
|
||||
"op_service_cod": op_service_cod,
|
||||
"op_service_denumire": op_service_denumire,
|
||||
"obs": obs,
|
||||
}
|
||||
|
||||
@@ -200,6 +200,42 @@ CREATE TABLE IF NOT EXISTS operation_text_rules (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_text_rules_account ON operation_text_rules(account_id);
|
||||
|
||||
-- Sugestii de mapare (strat SILVER, L14-S3 PRD 5.14).
|
||||
-- Etichete LLM/embedding — bootstrap; citita DOAR de suggest_codes/pending_unmapped,
|
||||
-- NICIODATA de load_mapping/resolve_prestatii (separare structurala #13).
|
||||
-- Cheia = denumire normalizata (fara diacritice, uppercase, spatii colapsate).
|
||||
-- is_nul=1: non-operatie (ITP, discount, nr. inmatriculare) -> suprima (#4), cod NULL.
|
||||
-- INSERT OR IGNORE la re-seed: nu suprascrie randuri existente (#2).
|
||||
CREATE TABLE IF NOT EXISTS mapping_suggestions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
denumire_normalizata TEXT NOT NULL UNIQUE,
|
||||
cod_prestatie TEXT, -- NULL cand is_nul=1 (supresie)
|
||||
is_nul INTEGER NOT NULL DEFAULT 0 CHECK (is_nul IN (0, 1)),
|
||||
source TEXT NOT NULL, -- 'llm', 'embedding', etc. (#5)
|
||||
confidence REAL NOT NULL DEFAULT 0.0 CHECK (confidence >= 0.0 AND confidence <= 1.0),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_mapping_suggestions_cod
|
||||
ON mapping_suggestions(cod_prestatie) WHERE cod_prestatie IS NOT NULL;
|
||||
|
||||
-- Mapari validate de oameni (strat GOLD partajat cross-account, L14-S3 PRD 5.14).
|
||||
-- Confirmarile umane din ORICE cont contribuie la acest store (#8).
|
||||
-- cross-account = suggestion-only (pre-completeaza editorul, F-A/#11), NU auto-send.
|
||||
-- Auto-send DOAR din operations_mapping (GOLD propriu per-cont, Eng-F2).
|
||||
-- Cheia = denumire_normalizata (NU cod_op_service: spatii de chei diferite, #14).
|
||||
CREATE TABLE IF NOT EXISTS shared_mappings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
denumire_normalizata TEXT NOT NULL UNIQUE,
|
||||
cod_prestatie TEXT NOT NULL, -- cod RAR valid (GOLD = validat de om)
|
||||
source TEXT NOT NULL DEFAULT 'human', -- 'human', 'human_import' (#5)
|
||||
provenance TEXT, -- detalii: cont, email, batch (#5)
|
||||
confidence REAL NOT NULL DEFAULT 1.0,
|
||||
confirmations INTEGER NOT NULL DEFAULT 1, -- contor confirmari din orice cont
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
-- Heartbeat worker (un singur rand, id=1). /healthz citeste de aici.
|
||||
CREATE TABLE IF NOT EXISTS worker_heartbeat (
|
||||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||||
|
||||
139
app/shared_store.py
Normal file
139
app/shared_store.py
Normal file
@@ -0,0 +1,139 @@
|
||||
"""Store partajat pentru sugestii (SILVER) si mapari validate de oameni (GOLD cross-account).
|
||||
|
||||
Straturi (L14-S3, PRD 5.14):
|
||||
- mapping_suggestions (SILVER): sugestii LLM/embedding, citite DOAR de suggest/pending_unmapped,
|
||||
NICIODATA de load_mapping/resolve_prestatii (separare structurala #13).
|
||||
- shared_mappings (GOLD partajat): mapari validate de om din orice cont; pot pre-completa
|
||||
editorul (suggestion-only cross-account, F-A/#11); auto-send DOAR GOLD propriu
|
||||
(operations_mapping per-cont, Eng-F2).
|
||||
|
||||
Invariante:
|
||||
- INSERT OR IGNORE la seed: nu suprascrie randuri existente (#2).
|
||||
- NUL = is_nul=1, cod_prestatie NULL; NU se promoveaza la cod RAR (#4).
|
||||
- source/confidence pe fiecare rand (provenienta + rollback batch model prost, #5).
|
||||
- Wiring in resolve_prestatii/load_mapping vine in L14-S6; modulul de fata e API pur.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from typing import Any
|
||||
|
||||
from .mapping import normalize_for_match
|
||||
|
||||
|
||||
def seed_suggestions(
|
||||
conn: sqlite3.Connection,
|
||||
items: list[dict[str, Any]],
|
||||
) -> int:
|
||||
"""Insereaza sugestii in mapping_suggestions (SILVER). INSERT OR IGNORE.
|
||||
|
||||
Nu suprascrie randuri deja existente (#2): re-rularea seeder-ului e sigura.
|
||||
Fiecare item trebuie sa contina:
|
||||
- 'denumire': str — text brut (se normalizeaza intern cu normalize_for_match)
|
||||
- 'source': str — 'llm', 'embedding', etc.
|
||||
Optional:
|
||||
- 'cod_prestatie': str | None — ignorat cand is_nul=True
|
||||
- 'is_nul': bool — True pt non-operatii (supresie, #4); cod_prestatie stocat NULL
|
||||
- 'confidence': float — 0..1 (default 0.0)
|
||||
|
||||
Returneaza numarul de randuri inserate efectiv (0 daca toate existau deja).
|
||||
"""
|
||||
inserted = 0
|
||||
for item in items:
|
||||
den_norm = normalize_for_match(item.get("denumire") or "")
|
||||
if not den_norm:
|
||||
continue
|
||||
is_nul = 1 if item.get("is_nul") else 0
|
||||
# NUL -> cod NULL obligatoriu (supresie stricta, #4)
|
||||
cod = None if is_nul else ((item.get("cod_prestatie") or "") or None)
|
||||
if cod:
|
||||
cod = cod.strip().upper()
|
||||
source = str(item.get("source") or "llm")
|
||||
confidence = float(item.get("confidence") or 0.0)
|
||||
cur = conn.execute(
|
||||
"""
|
||||
INSERT OR IGNORE INTO mapping_suggestions
|
||||
(denumire_normalizata, cod_prestatie, is_nul, source, confidence)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(den_norm, cod, is_nul, source, confidence),
|
||||
)
|
||||
inserted += cur.rowcount
|
||||
return inserted
|
||||
|
||||
|
||||
def lookup_suggestion(
|
||||
conn: sqlite3.Connection,
|
||||
denumire: str,
|
||||
) -> sqlite3.Row | None:
|
||||
"""Cauta sugestie SILVER dupa denumire normalizata.
|
||||
|
||||
Returneaza randul din mapping_suggestions sau None daca nu exista.
|
||||
NOTA: apelantul trebuie sa verifice is_nul inainte de a folosi cod_prestatie.
|
||||
"""
|
||||
den_norm = normalize_for_match(denumire)
|
||||
if not den_norm:
|
||||
return None
|
||||
return conn.execute(
|
||||
"SELECT * FROM mapping_suggestions WHERE denumire_normalizata = ?",
|
||||
(den_norm,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def lookup_shared_gold(
|
||||
conn: sqlite3.Connection,
|
||||
denumire: str,
|
||||
) -> sqlite3.Row | None:
|
||||
"""Cauta mapare GOLD partajata dupa denumire normalizata.
|
||||
|
||||
Returneaza randul din shared_mappings sau None daca nu exista.
|
||||
NOTA (F-A/#11): acest GOLD partajat e suggestion-only cross-account;
|
||||
auto-send vine DOAR din operations_mapping (GOLD propriu per-cont).
|
||||
"""
|
||||
den_norm = normalize_for_match(denumire)
|
||||
if not den_norm:
|
||||
return None
|
||||
return conn.execute(
|
||||
"SELECT * FROM shared_mappings WHERE denumire_normalizata = ?",
|
||||
(den_norm,),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def record_human_validation(
|
||||
conn: sqlite3.Connection,
|
||||
denumire: str,
|
||||
cod_prestatie: str,
|
||||
*,
|
||||
source: str = "human",
|
||||
provenance: str | None = None,
|
||||
confidence: float = 1.0,
|
||||
) -> None:
|
||||
"""Inregistreaza o mapare validata de om in GOLD partajat (shared_mappings).
|
||||
|
||||
Daca denumirea exista deja: incrementeaza confirmations + actualizeaza updated_at.
|
||||
Daca nu exista: insert nou cu confirmations=1.
|
||||
|
||||
Apelat la confirmarea umana a unei mapari (din editorul needs_mapping).
|
||||
Wiring efectiv vine in L14-S6 (dupa 5.15); aceasta functie e API-ul store.
|
||||
|
||||
NOTA: NU intra in operations_mapping (GOLD per-cont) — acela e gestionat
|
||||
separat de editorul existent. Ambele pot coexista.
|
||||
"""
|
||||
den_norm = normalize_for_match(denumire)
|
||||
if not den_norm:
|
||||
return
|
||||
cod = (cod_prestatie or "").strip().upper()
|
||||
if not cod:
|
||||
return
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO shared_mappings
|
||||
(denumire_normalizata, cod_prestatie, source, provenance, confidence, confirmations)
|
||||
VALUES (?, ?, ?, ?, ?, 1)
|
||||
ON CONFLICT(denumire_normalizata) DO UPDATE SET
|
||||
confirmations = confirmations + 1,
|
||||
updated_at = datetime('now')
|
||||
""",
|
||||
(den_norm, cod, source, provenance, confidence),
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
172
app/web/templates/_chips_prestatii.html
Normal file
172
app/web/templates/_chips_prestatii.html
Normal file
@@ -0,0 +1,172 @@
|
||||
{#
|
||||
_chips_prestatii.html — sectiunea de prestatii chips (E4, server-driven via /form-chips).
|
||||
|
||||
Re-randata de endpoint-ul /form-chips la fiecare add/remove de chip.
|
||||
Inclusa si din _form_editare.html pentru randarea initiala.
|
||||
|
||||
Starea chip-urilor traieste in input-uri hidden din form (NU in DB mid-edit).
|
||||
Fiecare operatie are un picker propriu cand e nemapata (E4 binding op<->cod).
|
||||
Reveal odometru initial semnalat prin data-has-r-odo="true" si chip-warn pe R-ODO/I-ODO.
|
||||
|
||||
Context vars (toate cu defaults):
|
||||
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire}
|
||||
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru picker
|
||||
has_r_odo — True daca orice chip e R-ODO sau I-ODO (server-computed)
|
||||
form_chips_url — URL pentru HTMX; default '/form-chips'
|
||||
chips_section_id — ID div (default 'chips-section')
|
||||
csrf_token — CSRF (trecut prin hx-include din form parinte)
|
||||
#}
|
||||
{% set _chips_url = form_chips_url or '/form-chips' %}
|
||||
{% set _sec_id = chips_section_id or 'chips-section' %}
|
||||
{% set _chips = prestatii_chips or [] %}
|
||||
{% set _has_ops = _chips | selectattr('cod_op_service') | list | length > 0 %}
|
||||
{# US-009: chips_submission_id e setat din _detaliu_ctx cand chips sunt randate in modalul de detaliu.
|
||||
Lipseste cand _chips_prestatii.html e rerandat via /form-chips (stateless, fara submission). #}
|
||||
{% set _sub_id = chips_submission_id if chips_submission_id is defined else none %}
|
||||
|
||||
<div id="{{ _sec_id }}" data-has-r-odo="{{ 'true' if has_r_odo else 'false' }}"
|
||||
aria-live="polite" aria-label="Prestatii cod RAR">
|
||||
|
||||
{# ===== Input-uri hidden pentru starea curenta a chip-urilor =====
|
||||
TOATE itemele emit 3 hidden inputs (cod poate fi "" pentru unmapped).
|
||||
Paralele index-by-index: cod_prestatie[i], chip_op_service[i], chip_denumire[i].
|
||||
Filtrate la submit de post_corectie_trimitere (coduri goale = neschimbate). #}
|
||||
{% for chip in _chips %}
|
||||
<input type="hidden" name="cod_prestatie" value="{{ chip.cod_prestatie or '' }}">
|
||||
<input type="hidden" name="chip_op_service" value="{{ chip.cod_op_service or '' }}">
|
||||
<input type="hidden" name="chip_denumire" value="{{ chip.denumire or '' }}">
|
||||
{% endfor %}
|
||||
|
||||
<div class="camp-slim" style="margin-bottom:8px;">
|
||||
<label>Prestatii — cod RAR pe fiecare operatie</label>
|
||||
|
||||
{% if _has_ops %}
|
||||
{# ===== Mod operatii: UN picker PE operatie (E4 binding) ===== #}
|
||||
{% for chip in _chips %}
|
||||
{% if chip.cod_op_service %}
|
||||
{% set _is_warn = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
||||
{% set _nemapat = not chip.cod_prestatie %}
|
||||
<div class="op-row {% if _nemapat %}op-row-warn{% endif %}" style="margin-bottom:6px;">
|
||||
<span class="op-row-name">
|
||||
{{ chip.cod_op_service }}
|
||||
{% if chip.denumire and chip.denumire != chip.cod_op_service %}
|
||||
<span class="muted" style="font-weight:400;font-size:11px;"> — {{ chip.denumire }}</span>
|
||||
{% endif %}
|
||||
{% if _nemapat %}
|
||||
<span style="color:var(--warn);font-size:10px;font-weight:400;"> · lipsa cod</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
<span style="display:flex;align-items:center;gap:8px;">
|
||||
{% if chip.cod_prestatie %}
|
||||
{# ===== Operatie mapata: chip cu × ===== #}
|
||||
<span class="chip {% if _is_warn %}chip-warn{% endif %}"
|
||||
aria-label="Prestatie {{ chip.cod_prestatie }} adaugata pentru {{ chip.cod_op_service }}">
|
||||
{{ chip.cod_prestatie }}
|
||||
<button type="button" class="chip-del"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"remove","chips_remove_index":{{ loop.index0 }}}'
|
||||
aria-label="Sterge codul {{ chip.cod_prestatie }} pentru {{ chip.cod_op_service }}">
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
{# US-009: "salveaza ca regula op->cod" — apare doar cand submission_id e cunoscut
|
||||
(in modalul de detaliu, nu la re-randarea stateless via /form-chips).
|
||||
Reuse EXACT save_mapping + reresolve_account via endpoint dedicat.
|
||||
hx-include="closest form" propaga csrf_token din form-ul parinte. #}
|
||||
<span id="save-rule-slot-{{ loop.index0 }}" class="save-rule-slot">
|
||||
{% if _sub_id and chip.cod_op_service and chip.cod_prestatie %}
|
||||
<button type="button"
|
||||
style="font-size:10px;color:var(--muted);background:none;border:none;cursor:pointer;text-decoration:underline;padding:0;margin-left:4px;line-height:1;"
|
||||
hx-post="/trimitere/{{ _sub_id }}/salveaza-regula-chip"
|
||||
hx-include="closest form"
|
||||
hx-target="#detaliu-modal-body"
|
||||
hx-swap="innerHTML"
|
||||
hx-vals='{"salveaza_op":{{ chip.cod_op_service | tojson }},"salveaza_cod":{{ chip.cod_prestatie | tojson }}}'
|
||||
aria-label="Salveaza regula {{ chip.cod_op_service }} -> {{ chip.cod_prestatie }}">
|
||||
salveaza ca regula
|
||||
</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% else %}
|
||||
{# ===== Operatie nemapata: picker galben cu "alege cod RAR" ===== #}
|
||||
<select name="chips_add_cod_{{ loop.index0 }}"
|
||||
id="picker-op-{{ loop.index0 }}"
|
||||
aria-label="Alege cod RAR pentru {{ chip.cod_op_service }}"
|
||||
style="min-width:160px;font-size:11px;height:26px;">
|
||||
<option value="">— alege cod RAR —</option>
|
||||
{% for n in (nomenclator_rar or []) %}
|
||||
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }} — {{ n.nume_prestatie }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button"
|
||||
class="add-code"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"add","chips_add_op_index":{{ loop.index0 }}}'
|
||||
aria-label="Adauga cod RAR pentru {{ chip.cod_op_service }}">
|
||||
+ Adauga
|
||||
</button>
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% else %}
|
||||
{# ===== Mod plat: lista de coduri libere (corectie pura, fara op_service) ===== #}
|
||||
<div class="chips" role="group" aria-label="Coduri RAR selectate">
|
||||
{% for chip in _chips %}
|
||||
{% if chip.cod_prestatie %}
|
||||
{% set _is_warn_flat = chip.cod_prestatie in ('R-ODO', 'I-ODO') %}
|
||||
<span class="chip {% if _is_warn_flat %}chip-warn{% endif %}"
|
||||
aria-label="Prestatie {{ chip.cod_prestatie }}">
|
||||
{{ chip.cod_prestatie }}
|
||||
<button type="button" class="chip-del"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"remove_flat","chips_remove_cod":"{{ chip.cod_prestatie }}"}'
|
||||
aria-label="Sterge codul {{ chip.cod_prestatie }}">×</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{# Picker adaugare cod nou in mod plat #}
|
||||
{% if nomenclator_rar %}
|
||||
<span style="display:inline-flex;align-items:center;gap:4px;">
|
||||
<select name="chips_add_cod_flat"
|
||||
aria-label="Adauga cod RAR nou"
|
||||
style="font-size:11px;height:22px;border:1px dashed color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:5px;background:transparent;color:var(--accent);">
|
||||
<option value="">+ cod</option>
|
||||
{% for n in nomenclator_rar %}
|
||||
<option value="{{ n.cod_prestatie }}">{{ n.cod_prestatie }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<button type="button"
|
||||
class="add-code"
|
||||
hx-post="{{ _chips_url }}"
|
||||
hx-include="closest form"
|
||||
hx-target="#{{ _sec_id }}"
|
||||
hx-swap="outerHTML"
|
||||
hx-vals='{"chips_action":"add_flat"}'
|
||||
aria-label="Adauga cod RAR selectat in lista">
|
||||
+
|
||||
</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Hint discret fara chips (debut) #}
|
||||
{% if not _chips %}
|
||||
<div style="font-size:10px;color:var(--muted);padding:4px 0;">
|
||||
Niciun cod RAR inca — alege din picker (sus) sau adauga prin mapare.
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,17 +1,22 @@
|
||||
{# _form_editare.html — partial partajat: campurile vehicul/data/odometru.
|
||||
US-005 (PRD 5.12): extras DRY din _trimitere_detaliu.html; refolosit si de
|
||||
_preview_rand.html (US-006) pentru editarea randurilor de import in modal.
|
||||
{# _form_editare.html — partial partajat slim: campurile vehicul/data/odo + obs + chips prestatii.
|
||||
US-007 (PRD 5.15): redesign slim cu VIN unic, Observatii textarea, chips prestatii (E4),
|
||||
si reveal dinamic odometru initial cand chips contin R-ODO/I-ODO (D10c, E6 server-driven).
|
||||
|
||||
Inclus cu {% include "_form_editare.html" %} INSIDE un <form> element al
|
||||
template-ului parinte. Acel parinte pune form-ul, CSRF-ul si orice campuri
|
||||
suplimentare (ex. select cod_prestatie din _trimitere_detaliu.html).
|
||||
suplimentare.
|
||||
|
||||
Necesita din context (setate de parinte inainte de include):
|
||||
Variabile necesare din context (setate de parinte inainte de include):
|
||||
form_nr — valoare curenta nr_inmatriculare
|
||||
form_vin — valoare curenta vin
|
||||
form_data — valoare curenta data_prestatie (YYYY-MM-DD sau brut)
|
||||
form_odo_final — valoare curenta odometru_final
|
||||
form_odo_initial — valoare curenta odometru_initial
|
||||
obs_val — valoare curenta obs (Observatii), text liber (default '')
|
||||
prestatii_chips — list of {cod_prestatie, cod_op_service, denumire} (default [])
|
||||
nomenclator_rar — list of {cod_prestatie, nume_prestatie} pentru picker (default [])
|
||||
has_r_odo — True daca chips contin R-ODO/I-ODO (server-computed, default False)
|
||||
form_chips_url — URL pentru HTMX chip endpoint (default '/form-chips')
|
||||
err_map — dict {field_name: mesaj_eroare} (poate fi {})
|
||||
fix_map — dict {field_name: hint_fix} (poate fi {})
|
||||
vin_context — string VIN pentru aria-label (poate fi '')
|
||||
@@ -19,23 +24,78 @@
|
||||
#}
|
||||
{% from "_macros.html" import camp, icon %}
|
||||
|
||||
{# Nr. inmatriculare pe rand propriu, VIN dedesubt — ambele latime plina. #}
|
||||
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr,
|
||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||
{{ camp('vin', 'VIN (serie sasiu)', form_vin,
|
||||
{# === 1. VIN — camp unic (fara "Confirma VIN"; contractul RAR cere un singur VIN) === #}
|
||||
{{ camp('vin', 'VIN (serie sasiu)', form_vin, slim=True, mono=True,
|
||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||
|
||||
{# Restul campurilor in grila responsiva existenta. #}
|
||||
<div style="display:grid; grid-template-columns:repeat(auto-fit, minmax(200px, 1fr)); gap:0 16px;">
|
||||
{{ camp('data_prestatie', 'Data prestatie', form_data, tip='date',
|
||||
{# === 2. Data prestatie + Nr. inmatriculare — grila 2 coloane === #}
|
||||
<div style="display:grid; grid-template-columns:1fr 1fr; gap:0 12px;">
|
||||
{{ camp('data_prestatie', 'Data prestatiei', form_data, tip='date', slim=True,
|
||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||
{{ camp('odometru_final', 'Odometru final', form_odo_final,
|
||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||
{{ camp('odometru_initial', 'Odometru initial (daca e cerut)', form_odo_initial,
|
||||
{{ camp('nr_inmatriculare', 'Numar inmatriculare', form_nr, slim=True, mono=True,
|
||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||
</div>
|
||||
|
||||
{# Buton primar parametrizat.
|
||||
{# === 3. Observatii (obs) — textarea liber, US-005 === #}
|
||||
<div class="camp-slim">
|
||||
<label for="c-obs">Observatii (operatiile efectuate)</label>
|
||||
<textarea id="c-obs" name="obs" rows="2"
|
||||
aria-label="Observatii (operatiile efectuate){% if vin_context %} (VIN: {{ vin_context }}){% endif %}"
|
||||
placeholder="ex: Revizie; schimbare placute frana">{{ obs_val or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
{# === 4. Prestatii chips (E4 server-driven, US-007) === #}
|
||||
{% set form_chips_url = form_chips_url or '/form-chips' %}
|
||||
{% set chips_section_id = 'chips-section' %}
|
||||
{% include "_chips_prestatii.html" %}
|
||||
|
||||
{# === 5. Odometru final — intotdeauna vizibil === #}
|
||||
{{ camp('odometru_final', 'Odometru final (km)', form_odo_final, slim=True, mono=True,
|
||||
err_map=err_map, fix_map=fix_map, vin_context=vin_context) }}
|
||||
|
||||
{# === 6. Odometru initial — reveal dinamic server cand chips contin R-ODO/I-ODO (D10c) ===
|
||||
has_r_odo=True (server-computed din lista de chips): sectiune vizibila cu marcaj warn.
|
||||
has_r_odo=False: hint discret, campul optional si vizual neutru. #}
|
||||
{% if has_r_odo %}
|
||||
<div class="odo-initial-warn"
|
||||
style="border-left:2px solid var(--warn);padding-left:10px;margin-left:-2px;">
|
||||
<div class="camp-slim">
|
||||
<label for="c-odometru_initial" style="color:var(--warn);">
|
||||
Odometru initial (km) · necesar pentru R-ODO
|
||||
</label>
|
||||
<input id="c-odometru_initial" type="text" name="odometru_initial"
|
||||
value="{{ form_odo_initial or '' }}"
|
||||
class="camp-mono"
|
||||
required
|
||||
aria-required="true"
|
||||
style="border-color:color-mix(in srgb,var(--warn) 50%,var(--line));{% if err_map.get('odometru_initial') %}border-color:var(--err);{% endif %}"
|
||||
aria-label="Odometru initial (VIN: {{ vin_context or '' }}) — necesar pentru R-ODO"
|
||||
{% if err_map.get('odometru_initial') %}aria-invalid="true"{% endif %}>
|
||||
{% if err_map.get('odometru_initial') %}
|
||||
<div class="s-error" style="font-size:12px;margin-top:2px;">{{ err_map.get('odometru_initial') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Hint discret cand nu e necesar #}
|
||||
<div class="camp-slim">
|
||||
<label for="c-odometru_initial" style="color:var(--muted);">Odometru initial (km)</label>
|
||||
<input id="c-odometru_initial" type="text" name="odometru_initial"
|
||||
value="{{ form_odo_initial or '' }}"
|
||||
class="camp-mono"
|
||||
style="{% if err_map.get('odometru_initial') %}border-color:var(--err);{% endif %}"
|
||||
aria-label="Odometru initial (optional){% if vin_context %} (VIN: {{ vin_context }}){% endif %}"
|
||||
{% if err_map.get('odometru_initial') %}aria-invalid="true"{% endif %}>
|
||||
{% if err_map.get('odometru_initial') %}
|
||||
<div class="s-error" style="font-size:12px;margin-top:2px;">{{ err_map.get('odometru_initial') }}</div>
|
||||
{% endif %}
|
||||
<span style="font-size:10px;color:var(--muted);font-style:italic;">
|
||||
Odometru initial se cere doar pentru coduri R-ODO / I-ODO.
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# === 7. 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. #}
|
||||
|
||||
@@ -18,9 +18,13 @@
|
||||
vin_context — string VIN pentru aria-label cu context (default '')
|
||||
id_prefix — prefix pentru id="" al input-ului (default 'c'; preview poate folosi 'e-N')
|
||||
#}
|
||||
{% macro camp(nome, eticheta, valoare, tip='text', err_map={}, fix_map={}, vin_context='', id_prefix='c') %}
|
||||
<div style="margin-bottom:10px;">
|
||||
<label for="{{ id_prefix }}-{{ nome }}" class="muted" style="font-size:12px; display:block;">{{ eticheta }}</label>
|
||||
{% macro camp(nome, eticheta, valoare, tip='text', err_map={}, fix_map={}, vin_context='', id_prefix='c', slim=False, mono=False) %}
|
||||
{# slim=False: randare clasica (neschimbata). slim=True: varianta compacta (.camp-slim) din US-002 PRD 5.15:
|
||||
label 11px muted deasupra, input ~30px, fundal --card2.
|
||||
mono=True (valid numai cu slim=True): adauga clasa 'camp-mono' pe input pentru campuri
|
||||
VIN/odometru/nr (IBM Plex Mono, prin .camp-slim .camp-mono din base.html). #}
|
||||
<div {% if slim %}class="camp-slim"{% else %}style="margin-bottom:10px;"{% endif %}>
|
||||
<label for="{{ id_prefix }}-{{ nome }}"{% if not slim %} class="muted" style="font-size:12px; display:block;"{% endif %}>{{ eticheta }}</label>
|
||||
{% if tip == 'date' %}
|
||||
{# D#10/R3: degradare grijulie pentru valori ne-YYYY-MM-DD.
|
||||
Daca valoarea nu e in formatul corect, inputul ramane gol + hint + hidden cu valoarea bruta
|
||||
@@ -28,7 +32,8 @@
|
||||
{%- set _dp_ok = (valoare and valoare|length == 10 and valoare[4:5] == '-' and valoare[7:8] == '-') -%}
|
||||
<input id="{{ id_prefix }}-{{ nome }}" type="date" name="{{ nome }}"
|
||||
value="{{ valoare if _dp_ok else '' }}"
|
||||
style="width:100%; {% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
|
||||
{% if slim and mono %}class="camp-mono"{% endif %}
|
||||
style="{% if not slim %}width:100%; {% endif %}{% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
|
||||
aria-label="{{ eticheta }}{% if vin_context %} (VIN: {{ vin_context }}){% endif %}"
|
||||
{% if err_map.get(nome) %}aria-invalid="true"{% endif %}>
|
||||
{% if not _dp_ok and valoare %}
|
||||
@@ -38,7 +43,8 @@
|
||||
{% else %}
|
||||
<input id="{{ id_prefix }}-{{ nome }}" type="{{ tip }}" name="{{ nome }}"
|
||||
value="{{ valoare or '' }}"
|
||||
style="width:100%; {% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
|
||||
{% if slim and mono %}class="camp-mono"{% endif %}
|
||||
style="{% if not slim %}width:100%; {% endif %}{% if err_map.get(nome) %}border-color:var(--err);{% endif %}"
|
||||
{% if vin_context %}aria-label="{{ eticheta }} (VIN: {{ vin_context }})"{% endif %}
|
||||
{% if err_map.get(nome) %}aria-invalid="true"{% endif %}>
|
||||
{% endif %}
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
<tbody>
|
||||
{% for e in pending %}
|
||||
{% set top = e.suggestions[0] if e.suggestions else None %}
|
||||
{% set preselect = top.cod_prestatie if (top and top.score >= 60) else '' %}
|
||||
{# L14-S6: pre-selectare din sugestie_principala (GOLD/SILVER/embedding) > fuzzy #}
|
||||
{% set preselect = e.sugestie_principala.cod_prestatie if e.sugestie_principala else (top.cod_prestatie if (top and top.score >= 60) else '') %}
|
||||
{# data-dt-row = haystack de cautare (randul contine un <select> cu tot nomenclatorul). #}
|
||||
<tr data-dt-row="{{ e.cod_op_service }} {{ e.denumire or '' }}
|
||||
{%- for s in e.suggestions[:3] %} {{ s.cod_prestatie }}{% endfor %}">
|
||||
@@ -45,6 +46,8 @@
|
||||
<form id="map-rez-{{ loop.index }}" hx-post="/mapari" hx-target="#mapari-section" hx-swap="outerHTML">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token or '' }}">
|
||||
<input type="hidden" name="cod_op_service" value="{{ e.cod_op_service }}">
|
||||
{# L14-S6: denumire pt record_human_validation in GOLD partajat #}
|
||||
<input type="hidden" name="denumire" value="{{ e.denumire or '' }}">
|
||||
</form>
|
||||
<div><strong>{{ e.cod_op_service }}</strong>
|
||||
<span class="pill" title="submission-uri blocate">{{ e.blocked }} blocate</span></div>
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
hx-swap="outerHTML"
|
||||
{% if oob %}hx-swap-oob="outerHTML"{% endif %}>
|
||||
|
||||
<!-- Cont in asteptare de activare (regasit din vechiul _banner; mereu vizibil) -->
|
||||
{# Banner cont in asteptare de activare (mereu vizibil cand contul e inactiv) #}
|
||||
{% if not account_active %}
|
||||
<div style="margin-bottom:12px; padding:8px 10px; border-left:3px solid var(--warn);
|
||||
background:color-mix(in srgb, var(--warn) 12%, var(--card)); border-radius:6px; font-size:13px;">
|
||||
@@ -14,50 +14,68 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Rand 1: doua bife binare + ultima autentificare -->
|
||||
<div style="display:flex; gap:28px; flex-wrap:wrap; align-items:center; font-size:14px;">
|
||||
|
||||
{# Bifa: glifa (✓/✗) + culoare + text — accesibil (nu doar culoare, design review) #}
|
||||
{% macro bifa(ok, text, tip) %}
|
||||
<span title="{{ tip }}" style="display:inline-flex; align-items:center; gap:7px;">
|
||||
{% if ok %}
|
||||
<span class="s-sent" aria-hidden="true" style="font-weight:bold;">✓</span>
|
||||
<span class="s-sent">{{ text }}</span>
|
||||
{% else %}
|
||||
<span class="s-error" aria-hidden="true" style="font-weight:bold;">✗</span>
|
||||
<span class="s-error">{{ text }}</span>
|
||||
{% endif %}
|
||||
</span>
|
||||
{% endmacro %}
|
||||
|
||||
{{ bifa(worker_ok, worker_lbl[0], worker_lbl[1]) }}
|
||||
{{ bifa(rar_ok, rar_lbl[0], rar_lbl[1]) }}
|
||||
|
||||
<span style="display:inline-flex; align-items:center; gap:6px;">
|
||||
<span class="muted">{{ eticheta_ultima_auth }}:</span>
|
||||
<span>{{ last_login }}</span>
|
||||
{# === D6: Strip sanatate mereu-vizibil DEASUPRA contoarelor ===
|
||||
Verde: worker viu + RAR ok → "Declaratiile curg normal"
|
||||
Rosu: worker oprit SAU RAR inaccesibil → "Blocat: ... — declaratiile NU pleaca"
|
||||
Glife accesibile ✓/✗ (nu doar culoare). Layout: glifa+text stanga, ultima auth dreapta.
|
||||
#}
|
||||
<div id="strip-sanatate"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style="display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap;
|
||||
padding:10px 14px; border-radius:8px; margin-bottom:14px;
|
||||
{% if sanatate_ok %}background:color-mix(in srgb, var(--ok) 13%, transparent); border:1px solid color-mix(in srgb, var(--ok) 30%, transparent);
|
||||
{% else %}background:color-mix(in srgb, var(--err) 16%, var(--card)); border:1px solid color-mix(in srgb, var(--err) 40%, transparent);
|
||||
{% endif %}">
|
||||
<div style="display:flex; align-items:center; gap:9px;">
|
||||
{% if sanatate_ok %}
|
||||
<span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--ok);">✓</span>
|
||||
{% else %}
|
||||
<span aria-hidden="true" style="font-weight:700; font-size:15px; color:var(--err);">✗</span>
|
||||
{% endif %}
|
||||
<span style="font-weight:700; font-size:13px;">{{ sanatate_text }}</span>
|
||||
</div>
|
||||
<span style="font:400 11px/1.4 'IBM Plex Mono',ui-monospace,monospace; color:var(--muted); white-space:nowrap;">
|
||||
{{ eticheta_ultima_auth }}: {{ last_login }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Rand 2: contoare coada -->
|
||||
<div style="margin-top:10px; display:flex; gap:20px; flex-wrap:wrap; font-size:14px;">
|
||||
<span><span class="muted">In asteptare:</span> <span class="s-queued">{{ counts_queued }}</span></span>
|
||||
<span><span class="muted">Declarate la RAR:</span> <span class="s-sent">{{ counts_sent }}</span></span>
|
||||
<span><span class="muted">Blocate:</span>
|
||||
<span class="{{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</span>
|
||||
</span>
|
||||
{# === D4: 3 carduri-contor (mockup exact: Trimise / In coada / De corectat) ===
|
||||
Responsive: flex-wrap => 3 pe rand desktop, 2/stivuite pe mobil (min-width:120px).
|
||||
Trimise: all-time (cifra mare) + sub-linie "luna N · azi N" (D4 + E7).
|
||||
De corectat: rosu cand >0 (s-error), muted cand 0.
|
||||
#}
|
||||
<div style="display:flex; gap:12px; flex-wrap:wrap; margin-bottom:14px;">
|
||||
|
||||
{# Trimise (all-time principal, luna/azi secundar) #}
|
||||
<div class="contor-card" style="flex:1; min-width:120px;">
|
||||
<div class="contor-cifra">{{ counts_sent }}</div>
|
||||
<div class="contor-label">Trimise (total)</div>
|
||||
<div class="contor-sub">luna {{ sent_month }} · azi {{ sent_today }}</div>
|
||||
</div>
|
||||
|
||||
{# In coada (accent/albastru) #}
|
||||
<div class="contor-card" style="flex:1; min-width:120px;">
|
||||
<div class="contor-cifra s-queued">{{ counts_queued }}</div>
|
||||
<div class="contor-label">In coada</div>
|
||||
</div>
|
||||
|
||||
{# De corectat (rosu daca >0, muted la 0; link catre lista) #}
|
||||
<a href="/" class="contor-card"
|
||||
style="flex:1; min-width:120px; text-decoration:none; display:block; cursor:pointer;"
|
||||
aria-label="De corectat: {{ blocate_total }} — click pentru lista de trimiteri">
|
||||
<div class="contor-cifra {{ 's-error' if blocate_total else 'muted' }}">{{ blocate_total }}</div>
|
||||
<div class="contor-label">De corectat</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
{# Pill-urile de stare s-au mutat in bara de filtre din sectiunea Trimiteri (_coada.html). #}
|
||||
|
||||
{# === Rand 3: navigatie rapida sub contoare (US-005) ===
|
||||
Linkurile Trimiteri + Mapari apar pe FIECARE pagina sub status-bar.
|
||||
Marcajul activ vine din variabila de context tab_activ (transmisa de dashboard via ?tab=
|
||||
sau default 'acasa'). Badge-ul Mapari = mapari_badge (aceeasi sursa: counts.needs_mapping).
|
||||
{# === Navigatie rapida: Trimiteri + Mapari cu badge needs_mapping ===
|
||||
Pastrata exact ca inainte (US-005): tab_activ determina marcajul activ.
|
||||
#}
|
||||
{% set _tab = tab_activ | default('acasa') %}
|
||||
<nav class="status-nav" aria-label="Navigatie rapida"
|
||||
style="margin-top:10px; display:flex; gap:8px 16px; flex-wrap:wrap; font-size:13px; border-top:1px solid var(--line); padding-top:8px;">
|
||||
style="display:flex; gap:8px 16px; flex-wrap:wrap; font-size:13px; border-top:1px solid var(--line); padding-top:8px;">
|
||||
<a href="/"
|
||||
{% if _tab == 'acasa' or _tab == '' %}aria-current="page"{% endif %}
|
||||
class="status-nav-link{% if _tab == 'acasa' or _tab == '' %} status-nav-activ{% endif %}">Trimiteri</a>
|
||||
|
||||
@@ -12,9 +12,22 @@
|
||||
{# Versiunea datelor cu care s-a randat tabelul; pollerul "Date noi" o compara. #}
|
||||
<span id="trimiteri-versiune" data-v="{{ versiune_trimiteri | default('') }}" hidden></span>
|
||||
|
||||
{% if bulk_message %}
|
||||
{# Sumar actiune bulk (US-010 PRD 5.15): afisat dupa bulk-fix, disparut la urmatoarea reincarcare. #}
|
||||
<div class="bulk-message" role="status" aria-live="polite"
|
||||
style="font-size:13px; color:var(--ink); background:var(--card2);
|
||||
border:1px solid var(--line); border-radius:6px;
|
||||
padding:6px 10px; margin-bottom:8px;">
|
||||
{{ bulk_message }}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if rows %}
|
||||
{# Form de stergere bulk. Selectia opereaza DOAR pe randuri blocate
|
||||
(gestionabil); sent/sending/queued nu au checkbox (read-only). #}
|
||||
{# Form bulk cu DOUA actiuni: (1) aplica cod RAR la selectate (bulk-fix, US-010),
|
||||
(2) sterge selectate (sterge-bulk). Selectia opereaza DOAR pe randuri blocate
|
||||
(gestionabil); sent/sending/queued nu au checkbox (read-only).
|
||||
Butonul "Aplica cod" foloseste hx-post propriu (override form action).
|
||||
hx-disinherit="hx-confirm" pe form => butonul aplica-cod NU mosteneste confirmare. #}
|
||||
<form id="bulk-trimiteri"
|
||||
hx-post="/trimiteri/sterge-bulk"
|
||||
hx-target="#submissions-wrap"
|
||||
@@ -23,30 +36,47 @@
|
||||
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;">
|
||||
<div style="display:flex; justify-content:flex-end; align-items:center;
|
||||
gap:6px; margin-bottom:8px; flex-wrap:wrap;">
|
||||
{# Bulk-fix: input cod + buton aplica (US-010 PRD 5.15) #}
|
||||
<input type="text" name="cod_prestatie" id="bulk-fix-cod"
|
||||
placeholder="Cod RAR (ex: OE-1)"
|
||||
autocomplete="off" autocapitalize="characters"
|
||||
style="width:120px; font-size:12px; padding:3px 7px;
|
||||
border:1px solid var(--line); border-radius:5px;
|
||||
background:var(--card2); color:var(--ink);"
|
||||
aria-label="Cod RAR de aplicat la randurile selectate">
|
||||
<button type="button"
|
||||
hx-post="/trimiteri/bulk-fix"
|
||||
hx-target="#submissions-wrap"
|
||||
hx-swap="innerHTML"
|
||||
style="background:var(--card); color:var(--accent); border-color:var(--accent);
|
||||
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;"
|
||||
aria-label="Aplica codul RAR la randurile blocate selectate">
|
||||
Aplica cod
|
||||
</button>
|
||||
{# Separator vizual #}
|
||||
<span style="color:var(--muted); font-size:11px; padding:0 2px;" aria-hidden="true">|</span>
|
||||
{# Bulk-delete: pastreaza exact comportamentul existent #}
|
||||
<button type="submit" id="bulk-sterge-btn"
|
||||
style="background:var(--card); color:var(--err); border-color:var(--err); font-size:13px; padding:4px 10px;">
|
||||
style="background:var(--card); color:var(--err); border-color:var(--err);
|
||||
font-size:13px; padding:4px 10px; border-radius:5px; cursor:pointer;">
|
||||
Sterge selectate
|
||||
</button>
|
||||
</div>
|
||||
<div class="tablewrap tabel-trimiteri">
|
||||
<table>
|
||||
<thead><tr>
|
||||
<th class="col-chk"><span class="muted" title="Selecteaza randuri blocate">✓</span></th>
|
||||
<th class="col-id">#</th>
|
||||
<th class="col-stare">Stare</th>
|
||||
<th class="col-vehicul">Vehicul</th>
|
||||
<th class="col-operatie">Operatie</th>
|
||||
<th class="col-data">Data prestatie</th>
|
||||
<th class="col-rar">Nr. prezentare RAR</th>
|
||||
<th class="col-actualizat">Actualizat</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
|
||||
{# Lista slim trimiteri (US-004, PRD 5.15).
|
||||
Inlocuieste tabelul cu randuri compacte: VIN mono + operatie·ora + pill.
|
||||
Nr. inmatriculare, data prestatie si nr. prezentare RAR raman accesibile
|
||||
pe linia meta discreta (linia 3) si in modalul de detaliu. #}
|
||||
<ul class="lista-trimiteri-slim" role="list"
|
||||
aria-label="Lista trimiteri">
|
||||
{% for r in rows %}
|
||||
{# Randul declanseaza deschiderea MODALULUI global (#detaliu-modal-body).
|
||||
{# Randul slim: stanga = VIN mono scurt (L1) + operatie·ora muted (L2) + meta (L3);
|
||||
dreapta = pill de stare. Click deschide modalul global (#detaliu-modal-body).
|
||||
Clickabil/focusabil (role=button); Enter/Space deschid modalul (JS in base.html). #}
|
||||
<tr id="trimitere-row-{{ r.id }}"
|
||||
class="trimitere-row"
|
||||
<li id="trimitere-row-{{ r.id }}"
|
||||
class="trimitere-slim"
|
||||
data-detaliu-id="{{ r.id }}"
|
||||
hx-get="/_fragments/trimitere/{{ r.id }}"
|
||||
hx-target="#detaliu-modal-body"
|
||||
@@ -55,47 +85,61 @@
|
||||
aria-haspopup="dialog"
|
||||
style="cursor:pointer;"
|
||||
title="Click pentru detaliul complet">
|
||||
<td class="col-chk" onclick="event.stopPropagation();">
|
||||
|
||||
{# Zona checkbox — nu declanseaza modalul (stopPropagation).
|
||||
Vizibila DOAR pe randurile gestionabile (error/needs_data/needs_mapping).
|
||||
Latimea fixa previne reflow la prezenta/absenta checkbox-ului. #}
|
||||
<div style="flex:0 0 22px; display:flex; align-items:center;" 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="col-id muted" data-eticheta="#">{{ r.id }}</td>
|
||||
<td class="col-stare" data-eticheta="Stare">
|
||||
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}">{{ r.stare_scurt }}</span>
|
||||
{# Eticheta umana scurta sub pill — text mic, `s-error` pe error/needs_*
|
||||
(singurele stari pe care `eticheta_problema` e ne-goala).
|
||||
Stare transmisa prin TEXT, nu doar culoare. Codul brut ramane in modal. #}
|
||||
{% 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>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-vehicul" data-eticheta="Vehicul">
|
||||
{{ r.prez.vehicul_nr }}
|
||||
</div>
|
||||
|
||||
{# Bloc text principal — stanga, ocupa spatiul ramas #}
|
||||
<div style="flex:1 1 auto; min-width:0;">
|
||||
|
||||
{# Linia 1: VIN mono scurt (slim-vin).
|
||||
Guard: vin_scurt='—' inseamna VIN lipsa; fallback la vehicul_nr. #}
|
||||
{% if r.prez.vin_scurt and r.prez.vin_scurt != '—' %}
|
||||
{# VIN pe rand separat sub nr (element block, nu span inline) #}
|
||||
<div class="muted" style="font-size:12px;">{{ r.prez.vin_scurt }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-operatie" data-eticheta="Operatie">
|
||||
<div>{{ r.prez.operatie }}</div>
|
||||
{# Doar codul RAR (ex. OE-2), FARA prefixul "cod RAR:" — chip muted discret;
|
||||
cand nemapat afiseaza "nemapat" muted. #}
|
||||
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
|
||||
<div class="cod-rar-sub"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div>
|
||||
<div class="slim-vin">{{ r.prez.vin_scurt }}</div>
|
||||
{% else %}
|
||||
<div class="muted cod-rar-sub">nemapat</div>
|
||||
<div class="slim-vin muted">{{ r.prez.vehicul_nr }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col-data" data-eticheta="Data prestatie">{{ r.prez.data_prestatie }}</td>
|
||||
<td class="col-rar" data-eticheta="Nr. prezentare RAR">{{ r.id_prezentare or '—' }}</td>
|
||||
<td class="col-actualizat muted" data-eticheta="Actualizat">{{ r.updated_at }}</td>
|
||||
</tr>
|
||||
|
||||
{# Linia 2: Operatie · ora/data (slim-meta muted) #}
|
||||
<div class="slim-meta">{{ r.prez.operatie }} · {{ r.updated_at }}</div>
|
||||
|
||||
{# Cod RAR sau indicatorul 'nemapat': discret sub operatie.
|
||||
Mentine compatibilitatea cu testele cod_rar: OE-2 vizibil, fara prefix 'cod RAR:'. #}
|
||||
{% if r.prez.cod_rar and r.prez.cod_rar != '—' %}
|
||||
<div class="slim-meta"><span class="cod-rar-cod">{{ r.prez.cod_rar }}</span></div>
|
||||
{% else %}
|
||||
<div class="slim-meta muted cod-rar-sub">nemapat</div>
|
||||
{% endif %}
|
||||
|
||||
{# Linia meta discreta: nr inmatriculare · data prestatie · nr prezentare RAR.
|
||||
Accesibila pe rand; informatia completa e in modalul de detaliu. #}
|
||||
<div class="slim-meta" style="opacity:0.7;">
|
||||
{{ r.prez.vehicul_nr -}}
|
||||
{%- if r.prez.data_prestatie and r.prez.data_prestatie != '—' %} · {{ r.prez.data_prestatie }}{% endif -%}
|
||||
{%- if r.id_prezentare %} · #{{ r.id_prezentare }}{% endif %}
|
||||
</div>
|
||||
|
||||
{# Eticheta umana scurta sub pill — text mic, s-error pe error/needs_*.
|
||||
Afisata DOAR pe randuri cu problema (eticheta_problema ne-goala).
|
||||
Starea transmisa prin TEXT, nu doar culoare. #}
|
||||
{% if r.eticheta_problema and r.eticheta_problema != r.stare_scurt and r.eticheta_problema != r.stare_text %}
|
||||
<div class="eticheta-problema s-error" style="font-size:10px; margin-top:2px;">{{ r.eticheta_problema }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{# Pill de stare — dreapta, flex:none #}
|
||||
<span class="pill {{ r.stare_css }}" title="{{ r.stare_text }}"
|
||||
style="flex:0 0 auto; white-space:nowrap;">{{ r.stare_scurt }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</ul>
|
||||
</form>
|
||||
|
||||
{#
|
||||
|
||||
@@ -106,32 +106,10 @@
|
||||
hx-disabled-elt="find button">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
|
||||
|
||||
{# Select cod RAR pe stari editabile (needs_data/needs_mapping), cu nomenclator.
|
||||
Read-only pe sent/sending/queued/error (nomenclator_rar gol → ramura else).
|
||||
RAMANE in _trimitere_detaliu.html (D#5 — logica specifica acestui modal). #}
|
||||
{% if nomenclator_rar %}
|
||||
<div style="margin:0 0 12px;">
|
||||
<label for="c-cod-prestatie" class="muted" style="font-size:12px; display:block;">Operatie RAR (cod prestatie)</label>
|
||||
{% if prez.operatie and prez.operatie != '—' %}
|
||||
<div class="muted" style="font-size:12px; margin-bottom:4px;">{{ prez.operatie }}</div>
|
||||
{% endif %}
|
||||
<select id="c-cod-prestatie" name="cod_prestatie" style="max-width:380px; width:100%;"
|
||||
aria-label="Alege operatia RAR din nomenclator">
|
||||
<option value="">— pastrat ({{ cod_afis }}) —</option>
|
||||
{% for n in nomenclator_rar %}
|
||||
<option value="{{ n.cod_prestatie }}" {% if n.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
|
||||
{{ n.cod_prestatie }} — {{ n.nume_prestatie }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
{% else %}
|
||||
{# Operatie + cod RAR read-only deasupra campurilor (fara eticheta „Cod RAR"). #}
|
||||
<div style="margin:0 0 12px;">
|
||||
<div class="muted" style="font-size:12px;">Operatie</div>
|
||||
<div>{{ prez.operatie }} · {{ cod_afis }}</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{# Cleanup B (US-009 PRD 5.15): vechiul <select name="cod_prestatie"> eliminat.
|
||||
Chips din _form_editare.html (via _chips_prestatii.html) il inlocuiesc complet:
|
||||
emit hidden inputs name="cod_prestatie" + picker per-operatie (E4, US-007).
|
||||
post_corectie_trimitere foloseste form.getlist("cod_prestatie") → compatibil. #}
|
||||
|
||||
{# Operatie service (cod intern + denumire venita prin API/import), distinct de
|
||||
operatia RAR mapata. op_service_cod="" cand lipseste → randul absent.
|
||||
@@ -190,7 +168,7 @@
|
||||
{% for item in nomenclator_rar %}
|
||||
<option value="{{ item.cod_prestatie }}"
|
||||
{% if item.cod_prestatie == cod_prestatie_curent %}selected{% endif %}>
|
||||
{{ item.cod_prestatie }} — {{ item.nome_prestatie }}
|
||||
{{ item.cod_prestatie }} — {{ item.nume_prestatie }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
|
||||
@@ -16,13 +16,16 @@
|
||||
<script>
|
||||
// Anti-FOUC: citeste preferinta tema din localStorage inainte de primul
|
||||
// paint; seteaza data-theme pe <html> sincron, fara blink.
|
||||
// Cunoaste toate cele 4 teme: light/dark/petrol/auto. Valoare legacy/necunoscuta -> auto.
|
||||
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink).
|
||||
// Cunoaste TOATE cele 7+1 teme: light/dark/petrol/grafit/cobalt/cupru/hartie + auto.
|
||||
// Valori legacy (light/dark/petrol) raman valide — fara migrare fortata.
|
||||
// Valoare lipsa/necunoscuta -> auto (fallback sigur, fara blink).
|
||||
// 'auto' se rezolva la 'light' sau 'dark' dupa prefers-color-scheme (fara blink):
|
||||
// auto + dark OS -> 'dark' | auto + light OS -> 'light' (comportament existent pastrat).
|
||||
(function() {
|
||||
var VALID = {light:1, dark:1, petrol:1, auto:1};
|
||||
var VALID = {light:1, dark:1, petrol:1, grafit:1, cobalt:1, cupru:1, hartie:1, auto:1};
|
||||
try {
|
||||
var t = localStorage.getItem('theme');
|
||||
if (!t || !VALID[t]) t = 'auto'; // fallback: valoare lipsa sau legacy -> auto
|
||||
if (!t || !VALID[t]) t = 'auto'; // fallback: valoare lipsa sau necunoscuta -> auto
|
||||
if (t === 'auto') {
|
||||
t = window.matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark';
|
||||
}
|
||||
@@ -100,16 +103,32 @@
|
||||
src: url("/static/fonts/IBMPlexMono-Regular-latin.woff2") format("woff2");
|
||||
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+20AC, U+2122, U+FEFF, U+FFFD;
|
||||
}
|
||||
/* Paleta dark (default) — accent azur ROMFAST */
|
||||
:root { --bg:#0f1218; --card:#181c24; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36;
|
||||
/* Paleta dark (default) — accent azur ROMFAST.
|
||||
--card2: fundal input/contor (= --bg, nivelul cel mai adanc).
|
||||
--line2: separator subtire (intre --bg si --line). */
|
||||
:root { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
|
||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#2E74D6; }
|
||||
/* Paleta light — accent azur inchis pentru contrast AA pe alb (#1F66C9: 5.51:1 pe alb) */
|
||||
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea;
|
||||
[data-theme="light"] { --bg:#f5f7fa; --card:#ffffff; --card2:#f5f7fa; --ink:#1a1d24; --muted:#5c6473; --line:#e2e5ea; --line2:#eaedf2;
|
||||
--ok:#15803d; --warn:#b45309; --err:#dc2626; --accent:#1F66C9; }
|
||||
/* Paleta Petrol — tema intunecata alternativa, accent teal #0E7C7B.
|
||||
Wordmark-ul FAST #2E74D6 coexista armonios: ambele sunt reci/saturate, contrast AA pe --card #161e20. */
|
||||
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e;
|
||||
[data-theme="petrol"] { --bg:#0e1416; --card:#161e20; --card2:#0e1416; --ink:#e6e9ef; --muted:#8b93a7; --line:#232c2e; --line2:#1c2426;
|
||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#0E7C7B; }
|
||||
/* Paleta Grafit — similara cu dark, accent azur deschis (#6ea2ec = landing --infot).
|
||||
Distinta de dark la cererea userului (D2 PRD 5.15). */
|
||||
[data-theme="grafit"] { --bg:#0f1218; --card:#181c24; --card2:#0f1218; --ink:#e6e9ef; --muted:#8b93a7; --line:#262b36; --line2:#1f2530;
|
||||
--ok:#2FBF8F; --warn:#E0A93B; --err:#E05D5D; --accent:#6ea2ec; }
|
||||
/* Paleta Cobalt — fundal bleumarin adanc, accent albastru viu (#8aa0ff = landing --infot). */
|
||||
[data-theme="cobalt"] { --bg:#080d1c; --card:#111a33; --card2:#0b1226; --ink:#e9ecfb; --muted:#8a93b8; --line:#1d2747; --line2:#161f3a;
|
||||
--ok:#2fd0a6; --warn:#E0A93B; --err:#f06a7a; --accent:#8aa0ff; }
|
||||
/* Paleta Cupru — fundal cald ciocolata, accent chihlimbar (#dfa45c = landing --infot). */
|
||||
[data-theme="cupru"] { --bg:#15110b; --card:#211a12; --card2:#15110b; --ink:#efe6d6; --muted:#a89a85; --line:#36291c; --line2:#281e14;
|
||||
--ok:#67b98c; --warn:#c97d2e; --err:#e2685a; --accent:#dfa45c; }
|
||||
/* Paleta Hartie — fundal crem cald, accent albastru clasic (#1F5FBF = landing --infot = --accent).
|
||||
Similara cu light, distinta la cererea userului (D2 PRD 5.15). */
|
||||
[data-theme="hartie"] { --bg:#f3efe6; --card:#fffdf7; --card2:#f3efe6; --ink:#1e1a13; --muted:#6a6052; --line:#e2dccc; --line2:#ece6d9;
|
||||
--ok:#1c7d5d; --warn:#b45309; --err:#bd463c; --accent:#1F5FBF; }
|
||||
* { box-sizing:border-box; }
|
||||
/* CONVENTIE BREAKPOINT: un singur prag mobil la 768px.
|
||||
CSS custom properties NU functioneaza in `@media`, deci pragul nu poate fi o
|
||||
@@ -675,6 +694,62 @@
|
||||
.sticky-bar { padding:10px 12px; gap:10px; }
|
||||
.sticky-bar button { width:100%; min-height:44px; }
|
||||
}
|
||||
/* === SENTINEL-COMPONENTE-SLIM: inceput componente slim US-002 (PRD 5.15).
|
||||
Testele ancoreaza pe acest marker. Nu muta/sterge. === */
|
||||
/* .contor-card — card cifra contor: fundal --card2, bordura --line, radius 8px, padding 10-12px.
|
||||
Variante de culoare a cifrei prin clasele .s-* existente (verde/accent/rosu). */
|
||||
.contor-card { background:var(--card2); border:1px solid var(--line); border-radius:8px; padding:10px 12px; }
|
||||
.contor-cifra { font-size:22px; font-weight:700; line-height:1; }
|
||||
.contor-label { font-size:11px; color:var(--muted); margin-top:5px; }
|
||||
.contor-sub { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:10px; color:var(--muted); margin-top:3px; }
|
||||
/* .lista-trimiteri-slim + .trimitere-slim — lista compacta cu separator --line2.
|
||||
Randul e clickabil (rol button), tinta min-height:44px pe mobil. */
|
||||
.lista-trimiteri-slim { list-style:none; margin:0; padding:0; }
|
||||
.trimitere-slim { display:flex; align-items:center; justify-content:space-between; gap:12px;
|
||||
padding:11px 14px; border-bottom:1px solid var(--line2); min-height:44px; cursor:pointer; }
|
||||
.trimitere-slim:last-child { border-bottom:none; }
|
||||
.trimitere-slim:hover { background:color-mix(in srgb, var(--accent) 6%, transparent); }
|
||||
.trimitere-slim:focus, .trimitere-slim:focus-visible { outline:2px solid var(--accent); outline-offset:-2px; }
|
||||
.slim-vin { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:13px; font-weight:500; color:var(--ink); }
|
||||
.slim-meta { font-size:11px; color:var(--muted); margin-top:3px; }
|
||||
/* .camp-slim — varianta compacta camp formular: label 11px muted deasupra, input ~30px, fundal --card2.
|
||||
Mono pentru campuri VIN/odometru/nr: adauga clasa .camp-mono pe input. */
|
||||
.camp-slim { margin-bottom:8px; }
|
||||
.camp-slim label { font-size:11px; color:var(--muted); display:block; margin-bottom:4px; }
|
||||
.camp-slim input, .camp-slim textarea, .camp-slim select { background:var(--card2); height:30px; width:100%;
|
||||
padding:0 10px; border:1px solid var(--line); border-radius:6px; font:inherit; color:var(--ink); }
|
||||
.camp-slim textarea { height:auto; min-height:48px; padding:8px 10px; resize:vertical; }
|
||||
.camp-slim .camp-mono { font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:12px; }
|
||||
/* .chips + .chip — prestatii multi-select cu buton de stergere accesibil (.chip-del).
|
||||
Fundal accent 18%, font IBM Plex Mono 11px. */
|
||||
.chips { min-height:30px; display:flex; align-items:center; gap:6px; flex-wrap:wrap;
|
||||
padding:4px 8px; border:1px solid var(--line); border-radius:6px; background:var(--card2); }
|
||||
.chip { display:inline-flex; align-items:center; gap:5px; padding:3px 8px; border-radius:5px;
|
||||
background:color-mix(in srgb, var(--accent) 18%, transparent); color:var(--accent);
|
||||
font-family:"IBM Plex Mono",ui-monospace,monospace; font-size:11px; font-weight:600; }
|
||||
.chip .chip-del { background:transparent; border:none; color:inherit; opacity:.7; cursor:pointer;
|
||||
padding:0; font-size:13px; line-height:1; display:inline-flex;
|
||||
align-items:center; justify-content:center; min-width:16px; min-height:16px; }
|
||||
.chip .chip-del:hover, .chip .chip-del:focus-visible { opacity:1; }
|
||||
.chip .chip-del:focus-visible { outline:2px solid var(--accent); outline-offset:1px; }
|
||||
/* Varianta chip warn (ex. R-ODO necesita odometruInitial) */
|
||||
.chip-warn { background:color-mix(in srgb, var(--warn) 22%, transparent); color:var(--warn); }
|
||||
/* .add-code — buton dashed pentru adaugare cod in chipbox */
|
||||
.add-code { display:inline-flex; align-items:center; height:22px; padding:0 7px; background:transparent;
|
||||
border:1px dashed color-mix(in srgb, var(--accent) 55%, var(--line));
|
||||
border-radius:5px; color:var(--accent); font:500 10px inherit; cursor:pointer; }
|
||||
.add-code:hover, .add-code:focus-visible { border-style:solid; }
|
||||
/* .op-row — rand operatie cu picker op<->cod (E4): operatie + chip cod + picker */
|
||||
.op-row { display:flex; align-items:center; justify-content:space-between; gap:10px;
|
||||
padding:8px 10px; border:1px solid var(--line); border-radius:6px;
|
||||
background:var(--card2); margin-bottom:8px; }
|
||||
.op-row-name { font-size:12px; font-weight:500; color:var(--ink); }
|
||||
.op-row-warn { border-color:color-mix(in srgb, var(--warn) 45%, var(--line)); }
|
||||
/* Mobil: tinta touch pentru trimitere-slim (deja garantata prin min-height:44px in regula de baza) */
|
||||
@media (max-width:767px) {
|
||||
.trimitere-slim { padding:12px 14px; }
|
||||
}
|
||||
/* === SENTINEL-COMPONENTE-SLIM: sfarsit componente slim US-002 === */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -749,18 +824,36 @@
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
// Comutator tema ciclic: click cicleaza Light->Dark->Petrol->Auto.
|
||||
// Separare init (sincronizare iconita/label) de persistenta (doar la click explicit).
|
||||
// 'auto' se rezolva la paint prin anti-FOUC; aici setam data-theme rezolvat.
|
||||
// Comutator tema ciclic (DRY E2 — PRD 5.15): config traieste intr-o singura structura
|
||||
// sursa-de-adevar THEMES din care se DERIVA CYCLE/VALID/ICONS/LABELS/NEXT.
|
||||
// Adaugarea unei teme noi = O singura intrare in THEMES.
|
||||
// Ciclu: Light->Dark->Petrol->Grafit->Cobalt->Cupru->Hartie->Auto->(inapoi la Light).
|
||||
// 'auto' se rezolva la paint prin anti-FOUC (dark OS -> 'dark', light OS -> 'light').
|
||||
(function() {
|
||||
var btn = document.getElementById('tema-toggle');
|
||||
if (!btn) return;
|
||||
var CYCLE = ['light', 'dark', 'petrol', 'auto'];
|
||||
var VALID = {light:1, dark:1, petrol:1, auto:1};
|
||||
// Iconite per tema: ☀ Light, ☾ Dark, ◐ Petrol, ◉ Auto
|
||||
var ICONS = {light:'☀', dark:'☾', petrol:'◐', auto:'◙'};
|
||||
var LABELS = {light:'Light', dark:'Dark', petrol:'Petrol', auto:'Auto'};
|
||||
var NEXT = {light:'Dark', dark:'Petrol', petrol:'Auto', auto:'Light'};
|
||||
// SURSA DE ADEVAR UNICA: adaugarea unei teme = o singura intrare aici.
|
||||
// Iconite: ☀ Light | ☾ Dark | ◐ Petrol | ◑ Grafit | ◆ Cobalt | ◇ Cupru | ○ Hartie | ◉ Auto
|
||||
var THEMES = [
|
||||
{id:'light', label:'Light', icon:'☀'},
|
||||
{id:'dark', label:'Dark', icon:'☾'},
|
||||
{id:'petrol', label:'Petrol', icon:'◐'},
|
||||
{id:'grafit', label:'Grafit', icon:'◑'},
|
||||
{id:'cobalt', label:'Cobalt', icon:'◆'},
|
||||
{id:'cupru', label:'Cupru', icon:'◇'},
|
||||
{id:'hartie', label:'Hartie', icon:'○'},
|
||||
{id:'auto', label:'Auto', icon:'◙'},
|
||||
];
|
||||
// Derivate din THEMES (nu literali separati — DRY E2):
|
||||
var CYCLE = THEMES.map(function(t) { return t.id; });
|
||||
var VALID = THEMES.reduce(function(a, t) { a[t.id] = 1; return a; }, {});
|
||||
var ICONS = THEMES.reduce(function(a, t) { a[t.id] = t.icon; return a; }, {});
|
||||
var LABELS = THEMES.reduce(function(a, t) { a[t.id] = t.label; return a; }, {});
|
||||
var NEXT = (function() {
|
||||
var n = {};
|
||||
THEMES.forEach(function(t, i) { n[t.id] = THEMES[(i + 1) % THEMES.length].label; });
|
||||
return n;
|
||||
})();
|
||||
function _stored() {
|
||||
try { var v = localStorage.getItem('theme'); return (v && VALID[v]) ? v : 'auto'; } catch(e) { return 'auto'; }
|
||||
}
|
||||
@@ -1049,7 +1142,7 @@
|
||||
document.body.addEventListener('htmx:beforeRequest', function(evt) {
|
||||
var elt = evt.detail && evt.detail.elt;
|
||||
if (!elt || !elt.classList) return;
|
||||
if (elt.classList.contains('trimitere-row') || elt.classList.contains('btn-editeaza')) open(elt);
|
||||
if (elt.classList.contains('trimitere-row') || elt.classList.contains('trimitere-slim') || elt.classList.contains('btn-editeaza')) open(elt);
|
||||
});
|
||||
// Dupa swap-ul fragmentului (sau re-render corectie/mapare): muta focusul in modal.
|
||||
body.addEventListener('htmx:afterSettle', function() {
|
||||
@@ -1083,7 +1176,7 @@
|
||||
// Tastatura pe rand (role=button): Enter/Space deschid modalul.
|
||||
document.body.addEventListener('keydown', function(evt) {
|
||||
var t = evt.target;
|
||||
if (!(t && t.classList && t.classList.contains('trimitere-row'))) return;
|
||||
if (!(t && t.classList && (t.classList.contains('trimitere-row') || t.classList.contains('trimitere-slim')))) return;
|
||||
if (evt.key === 'Enter' || evt.key === ' ' || evt.key === 'Spacebar') {
|
||||
evt.preventDefault();
|
||||
t.click();
|
||||
|
||||
Reference in New Issue
Block a user