feat(service-auto): multi-tenant + tier 3 lookups + D1 partener + AsyncAutoComplete

Refactor izolare multi-tenant:
- Schema Oracle rezolvată din id_firma via CONTAFIN_ORACLE.V_NOM_FIRME (cached 24h)
- server_id propagat din JWT (request.state.server_id) la oracle_pool.get_connection
- Elimină _SCHEMA='MARIUSM_AUTO' și literal 'mariusm_test' din toate query-urile
- Autorizare firmă la router (_company_id): 403 dacă id_firma nu e în JWT companies[]

Tier 3 — lookup endpoints cached 24h:
- GET /asiguratori (DEV_NOM_ASIGURATORI ← NOM_PARTENERI)
- GET /inspectori?id_asigurator=N (DEV_NOM_INSPECTORI per asig)
- GET /operatii (DEV_NOM_NORME)
- GET /parteneri?q=... (typeahead LIKE escape)
- GET /masini/{id}/detalii (VIN, cilindree, putere)
- POST /comenzi: PACK_SERII_NUMERE.aloca_numar + compensating dezaloca;
  pc_nr VFP-format prefix+seq/nrinmat; ORA-06512 stripped din detail

D1 PartnerCreateDialog (nou):
- POST /api/service-auto/parteneri → PartnerCreateRequest; 409 pe CUI
  duplicat (NOM_PARTENERI fără UNIQUE constraint — check manual);
  id_part = MAX+1 cu retry pe ORA-00001 (fără sequence în schema VFP legacy)
- Frontend PartnerCreateDialog.vue — PrimeVue, design tokens, dark-mode safe
- Integrat în ComandaNoua.vue via AutoComplete empty-action hook

Shared AsyncAutoComplete (nou):
- src/shared/components/AsyncAutoComplete.vue — typeahead async debounced
  cu emptyAction slot, force-selection, keyboard (Enter/Esc), design tokens
- ComandaNoua.vue refactorizat să folosească shared component
- SupplierDualField (data-entry) skipped — documentat în
  docs/service-auto/autocomplete-dual-decision.md (pattern diferit)

Mobile chrome (CLAUDE.md):
- ComandaNoua.vue + ComenziBrowseView.vue: MobileTopBar, BottomSheet
  filtre, MobileBottomNav, card list, isMobile resize listener

Migrații grant-uri idempotente:
- ff_2026_04_13_01_AUTO.sql — SELECT/EXECUTE pe tabele Tier 3 + index
  IX_NOM_PARTENERI_DEN_UPPER
- ff_2026_04_13_02_AUTO.sql — INSERT pe NOM_PARTENERI pentru D1

Live smoke pe MARIUSM_AUTO: /ping 1ms, /tip-deviz 7, /masini 261,
POST /parteneri id_part=70241, firma neautorizată → 403.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-13 20:09:42 +00:00
parent ee6d857e9d
commit 4397027f36
13 changed files with 2089 additions and 377 deletions

View File

@@ -3,15 +3,16 @@ from datetime import date
from typing import List, Optional
import oracledb
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from shared.auth.dependencies import get_current_user
from shared.auth.models import CurrentUser
from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import (
ComandaListResponse, ComandaRequest, ComandaResponse,
FirmaItem, TipDevizItem, MasinaClientItem,
AsiguratorItem, ComandaListResponse, ComandaRequest, ComandaResponse,
FirmaItem, InspectorItem, MasinaClientItem, MasinaDetails,
OperatieItem, PartenerItem, PartnerCreateRequest, TipDevizItem,
)
from ..services.comanda_service import ComandaService
from ..services.lookup_service import LookupService
@@ -19,69 +20,192 @@ from ..services.lookup_service import LookupService
router = APIRouter()
def _server_id(request: Request) -> Optional[str]:
"""Extrage server_id injectat de AuthenticationMiddleware din JWT."""
return getattr(request.state, "server_id", None)
def _company_id(
current_user: CurrentUser,
explicit: Optional[int],
) -> int:
"""
Rezolvă id_firma: query/body param dacă e dat, altfel prima firmă din JWT.
Validează că firma e printre cele autorizate în JWT.
"""
if explicit is not None:
cid = explicit
else:
if not current_user.companies:
raise HTTPException(status_code=400, detail="Niciun id_firma disponibil în JWT.")
cid = int(current_user.companies[0])
allowed = {int(c) for c in current_user.companies}
if cid not in allowed:
raise HTTPException(status_code=403, detail="Firmă neautorizată pentru utilizator.")
return cid
@router.get("/ping")
async def ping(_: CurrentUser = Depends(get_current_user)):
"""Health check: verifies Oracle connectivity for mariusm_test server."""
async def ping(
request: Request,
_: CurrentUser = Depends(get_current_user),
):
"""Health check: verifies Oracle connectivity pe serverul curent."""
t0 = time.perf_counter()
server_id = _server_id(request)
try:
async with oracle_pool.get_connection('mariusm_test') as conn:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cursor:
cursor.execute('SELECT 1 FROM DUAL')
row = cursor.fetchone()
except oracledb.DatabaseError as e:
raise HTTPException(status_code=503, detail=f"Oracle error: {e}")
elapsed_ms = round((time.perf_counter() - t0) * 1000, 2)
return {"result": row[0], "server": "mariusm_test", "latency_ms": elapsed_ms}
return {"result": row[0], "server": server_id or "(default)", "latency_ms": elapsed_ms}
@router.get("/firme", response_model=List[FirmaItem])
async def get_firme(current_user: CurrentUser = Depends(get_current_user)):
async def get_firme(
request: Request,
current_user: CurrentUser = Depends(get_current_user),
):
"""Firmele accesibile utilizatorului curent (din JWT companies[])."""
return await LookupService.get_firme(current_user.companies)
return await LookupService.get_firme(current_user.companies, _server_id(request))
@router.get("/tip-deviz", response_model=List[TipDevizItem])
async def get_tip_deviz(_: CurrentUser = Depends(get_current_user)):
"""Tipuri de deviz din DEV_TIP_DEVIZ."""
return await LookupService.get_tip_deviz()
async def get_tip_deviz(
request: Request,
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Tipuri de deviz din DEV_TIP_DEVIZ (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_tip_deviz(cid, _server_id(request))
@router.get("/masini", response_model=List[MasinaClientItem])
async def get_masini(_: CurrentUser = Depends(get_current_user)):
"""Mașini active din AUTO_VMASINICLIENTI (toate firmele pe același server)."""
return await LookupService.get_masini()
async def get_masini(
request: Request,
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Mașini active din AUTO_VMASINICLIENTI (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_masini(cid, _server_id(request))
@router.get("/comenzi", response_model=ComandaListResponse)
async def list_comenzi(
request: Request,
page: int = Query(default=1, ge=1),
per_page: int = Query(default=20, ge=1, le=100),
validat: Optional[int] = Query(default=None, ge=0, le=1),
data_de_la: Optional[date] = Query(default=None),
data_pana_la: Optional[date] = Query(default=None),
_: CurrentUser = Depends(get_current_user),
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
# NOTE: DEV_ORDL has no id_firma column — firmă filter not available at DB level.
# All comenzi in MARIUSM_AUTO schema are visible (companies 110/167/169 share schema).
# DEV_ORDL n-are id_firma; toate firmele pe aceeași schemă împart comenzile.
cid = _company_id(current_user, id_firma)
return await ComandaService.get_comenzi(
company_id=cid,
page=page,
per_page=per_page,
validat=validat,
data_de_la=data_de_la,
data_pana_la=data_pana_la,
server_id=_server_id(request),
)
@router.get("/asiguratori", response_model=List[AsiguratorItem])
async def get_asiguratori(
request: Request,
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Asigurători din DEV_NOM_ASIGURATORI (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_asiguratori(cid, _server_id(request))
@router.get("/inspectori", response_model=List[InspectorItem])
async def get_inspectori(
request: Request,
id_asigurator: int = Query(..., ge=1),
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Inspectori filtrați pe asigurator (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_inspectori(id_asigurator, cid, _server_id(request))
@router.get("/operatii", response_model=List[OperatieItem])
async def get_operatii(
request: Request,
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Lista completă operații DEV_NOM_NORME (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_operatii(cid, _server_id(request))
@router.get("/parteneri", response_model=List[PartenerItem])
async def search_parteneri(
request: Request,
q: str = Query(..., min_length=2, max_length=100),
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Typeahead pe NOM_PARTENERI (scoped pe schema firmei)."""
cid = _company_id(current_user, id_firma)
return await LookupService.search_parteneri(q, cid, _server_id(request))
@router.post("/parteneri", response_model=PartenerItem, status_code=201)
async def create_partener(
data: PartnerCreateRequest,
request: Request,
current_user: CurrentUser = Depends(get_current_user),
):
"""Creează partener nou în NOM_PARTENERI (scoped pe schema firmei din JWT)."""
cid = _company_id(current_user, data.id_firma)
data.id_firma = cid
return await LookupService.create_partener(data, _server_id(request))
@router.get("/masini/{id_masiniclient}/detalii", response_model=Optional[MasinaDetails])
async def get_masina_detalii(
id_masiniclient: int,
request: Request,
id_firma: Optional[int] = Query(default=None, ge=1),
current_user: CurrentUser = Depends(get_current_user),
):
"""Detalii complete mașină pentru card readonly după selecție."""
cid = _company_id(current_user, id_firma)
return await LookupService.get_masina_details(id_masiniclient, cid, _server_id(request))
@router.post("/comenzi", response_model=ComandaResponse)
async def creeaza_comanda(
data: ComandaRequest,
request: Request,
current_user: CurrentUser = Depends(get_current_user),
):
# data.id_firma e obligatoriu în body — validat via _company_id
cid = _company_id(current_user, data.id_firma)
# asigură consistența (dacă body trimite id_firma diferit de fallback)
data.id_firma = cid
try:
return await ComandaService.creeaza_comanda(
data=data,
username=current_user.username,
user_id=current_user.user_id,
server_id=_server_id(request),
)
except NotImplementedError as e:
raise HTTPException(status_code=501, detail=str(e))

View File

@@ -1,7 +1,7 @@
from datetime import date
from typing import List, Optional
from pydantic import BaseModel
from pydantic import BaseModel, Field
class ComandaRequest(BaseModel):
@@ -9,6 +9,10 @@ class ComandaRequest(BaseModel):
id_masiniclient: int
id_firma: int
id_sucursala: Optional[int] = None
id_asigurator: Optional[int] = None
id_inspector: Optional[int] = None
id_part_ref: Optional[int] = None
sir_id_operatii: Optional[List[int]] = None
observatii: str = ""
defectiuni: Optional[str] = None
km_int: int = 0
@@ -23,6 +27,50 @@ class ComandaResponse(BaseModel):
mesaj: str
class AsiguratorItem(BaseModel):
id_asigurator: int
denumire: str
class InspectorItem(BaseModel):
id_inspector: int
denumire: str
id_asigurator: int
class OperatieItem(BaseModel):
id_norme: int
codop: str
denop: str
timpn: Optional[float] = None
class PartenerItem(BaseModel):
id_part: int
denumire: str
class PartnerCreateRequest(BaseModel):
"""Payload pentru POST /parteneri — creare partener nou în NOM_PARTENERI."""
denumire: str = Field(min_length=2, max_length=100)
cui: Optional[str] = Field(default=None, max_length=30)
adresa: Optional[str] = Field(default=None, max_length=150)
id_firma: int = Field(ge=1)
class MasinaDetails(BaseModel):
id_masiniclient: int
label: str
nr_inmatriculare: Optional[str] = None
marca: Optional[str] = None
model: Optional[str] = None
serie_sasiu: Optional[str] = None
cilindree: Optional[int] = None
putere_cp: Optional[int] = None
putere_kw: Optional[int] = None
client_nume: Optional[str] = None
class FirmaItem(BaseModel):
id_firma: int
firma: str

View File

@@ -0,0 +1,65 @@
"""
Multi-tenant context resolver for service_auto.
Pattern-ul urmează `modules/reports`:
- `server_id` vine din JWT (`request.state.server_id`), propagat la `oracle_pool.get_connection(server_id)`.
- `schema` se rezolvă din `CONTAFIN_ORACLE.V_NOM_FIRME` bazat pe `id_firma`, pe serverul utilizatorului.
- Rezultatul e cached in-process 24h per (company_id, server_id).
NU introduce hardcodări de schemă sau server_id în service_auto. Toate query-urile SQL trebuie să
folosească `f"{schema}.{TABLE}"`, iar toate `get_connection()` trebuie să primească `server_id`.
"""
import time
from typing import Optional, Tuple
import oracledb
from fastapi import HTTPException
from shared.database.oracle_pool import oracle_pool
from .. import logger
_SCHEMA_TTL = 86400 # 24h — schema / firma binding changes via DB migration, not runtime
_schema_cache: dict = {}
async def get_schema(company_id: int, server_id: Optional[str]) -> str:
"""
Rezolvă schema Oracle pentru o firmă pe serverul curent al utilizatorului.
Query pe `CONTAFIN_ORACLE.V_NOM_FIRME` (prezent pe fiecare server în arhitectura ROA2WEB).
Cached per (company_id, server_id) 24h.
Raises 422 dacă firma nu există pe serverul respectiv (misconfiguration).
"""
key = (company_id, server_id or "")
entry: Optional[Tuple[float, str]] = _schema_cache.get(key)
if entry and (time.monotonic() - entry[0]) < _SCHEMA_TTL:
return entry[1]
try:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT schema FROM CONTAFIN_ORACLE.V_NOM_FIRME WHERE id_firma = :id",
{"id": company_id},
)
row = cur.fetchone()
except oracledb.DatabaseError:
logger.error("service_auto._context.get_schema Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la rezolvarea schemei firmei")
if not row or not row[0]:
raise HTTPException(
status_code=422,
detail=f"Firma {company_id} nu are schemă configurată pe serverul curent.",
)
schema = row[0]
_schema_cache[key] = (time.monotonic(), schema)
return schema
def reset_schema_cache() -> None:
"""Test helper — clear the schema cache."""
_schema_cache.clear()

View File

@@ -1,6 +1,6 @@
import re
from datetime import date, datetime
from typing import List, NoReturn, Optional
from typing import List, NoReturn, Optional, Tuple
import oracledb
from fastapi import HTTPException
@@ -8,35 +8,102 @@ from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import (
ComandaListItem, ComandaListResponse, ComandaRequest, ComandaResponse,
)
from .lookup_service import LookupService
from ._context import get_schema
from .. import logger
_SCHEMA = "MARIUSM_AUTO"
_MAX_PER_PAGE = 100
_MAX_OPERATII_CSV = 4000 # Oracle VARCHAR2 limit; ~600 IDs at 6 chars each
# Source: DEV_TIP_DEVIZ (verified 2026-04-13):
# 1=POST GARANTIE, 2=GARANTIE, 3=REGIE, 4=PREGATIRE, 5=REGIE 2 (no VFP mapping → ""),
# 6=PRODUCTIE, 7=CONSTATARE
# VFP reference: oproceduri_devize.prg lines 108-120 (pntipcom switch)
_PREFIX_MAP = {1: "", 2: "G", 3: "R", 4: "P", 6: "PR", 7: "C"}
def _aloca_numar_devize(
cursor, schema: str, user_id: int, id_sucursala: int,
) -> Tuple[int, int]:
"""
Calls {schema}.PACK_SERII_NUMERE.aloca_numar(20, NULL, NULL, user_id, id_sucursala) — 7-param overload.
Returns (seq, id_numar):
seq — the allocated command number (used in pc_nr)
id_numar — SERII_NUMERE.ID_NUMAR row, used by dezaloca_id_numar compensating call
"""
out_numar = cursor.var(oracledb.NUMBER)
out_id_numar = cursor.var(oracledb.NUMBER)
cursor.callproc(
f"{schema}.PACK_SERII_NUMERE.aloca_numar",
[20, None, None, user_id, id_sucursala, out_numar, out_id_numar],
)
seq = int(out_numar.getvalue() or 0)
id_numar = int(out_id_numar.getvalue() or 0)
logger.info(
"service_auto.seq_allocated",
extra={"seq": seq, "id_numar": id_numar, "user_id": user_id, "id_sucursala": id_sucursala},
)
if seq <= 0:
raise HTTPException(
status_code=422,
detail="Nu aveți serie alocată pentru comenzi devize. Contactați administratorul.",
)
return seq, id_numar
def _dezaloca_numar_devize(
cursor, schema: str, seq: int, id_numar: int, reason: str,
) -> None:
"""Compensating transaction — releases allocated seq number on callproc failure."""
try:
cursor.callproc(f"{schema}.PACK_SERII_NUMERE.dezaloca_id_numar", [id_numar])
logger.info(
"service_auto.seq_released",
extra={"seq": seq, "id_numar": id_numar, "reason": reason},
)
except Exception:
logger.warning(
"SEQ_LEAK",
extra={"seq": seq, "id_numar": id_numar, "reason": reason},
exc_info=True,
)
def _build_pc_nr(tip_id: int, seq: int, nr_inmatriculare: str) -> str:
"""Format: <prefix><seq>/<nr_inmatriculare>"""
prefix = _PREFIX_MAP.get(tip_id)
if prefix is None:
logger.warning("service_auto.unknown_tip_id", extra={"tip_id": tip_id})
prefix = ""
return f"{prefix}{seq}/{nr_inmatriculare}"
def _build_sir_id_operatii(operatii: Optional[List[int]]) -> Optional[str]:
"""Serializes list of operation IDs to CSV string for pcSirIdOperatii param."""
if not operatii:
return None
csv = ",".join(str(i) for i in operatii)
if len(csv) > _MAX_OPERATII_CSV:
raise HTTPException(
status_code=422,
detail=f"Prea multe operații selectate (max ~{_MAX_OPERATII_CSV // 6}).",
)
return csv
def _handle_oracle_error(e: Exception) -> NoReturn:
"""
Map Oracle error codes to FastAPI HTTPExceptions. Always raises.
Code ranges:
20000-20999 → 422 Unprocessable (business rule errors from RAISE_APPLICATION_ERROR)
12541/12170/12154/12560 → 503 Service Unavailable (Oracle unreachable / network)
1017 → 500 + CRITICAL log (bad credentials — config error)
942 → 500 + CRITICAL log (missing object/grant — deployment error)
* → 500 + ERROR log (unexpected)
"""
"""Map Oracle error codes to FastAPI HTTPExceptions. Always raises."""
err = e.args[0]
code = getattr(err, "code", 0)
raw_message = getattr(err, "message", str(e))
if 20000 <= code <= 20999:
# Strip "ORA-2xxxx: " prefix injected by Oracle; expose the business message only
# Strip "ORA-2xxxx: " prefix; strip "\nORA-06512: at ..." stack frames.
clean = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip()
clean = clean.split("\n")[0].strip()
raise HTTPException(status_code=422, detail=clean)
if code == 1438:
# ORA-01438: value larger than specified precision — bad input ID (e.g. id_masiniclient out of range)
raise HTTPException(status_code=422, detail="Valoare invalidă pentru câmp (ID prea mare)")
if code in (12541, 12170, 12154, 12560):
@@ -69,66 +136,102 @@ class ComandaService:
data: ComandaRequest,
username: str,
user_id: Optional[int] = None,
server_id: Optional[str] = None,
) -> ComandaResponse:
now = datetime.now()
# pcNr serves as NOM_LUCRARI.NRORD — must be unique; timestamp gives collision-safe value.
pc_nr = f"W{now.strftime('%Y%m%d%H%M%S')}{now.microsecond // 1000:03d}"
schema = await get_schema(data.id_firma, server_id)
# Fetch vehicle details early: validates vehicle exists + gets nrinmat for pc_nr
masina = await LookupService.get_masina_details(
data.id_masiniclient, data.id_firma, server_id
)
if masina is None:
raise HTTPException(status_code=422, detail="Mașina selectată nu există.")
nr_inmatriculare = masina.nr_inmatriculare or "?"
pc_sir_id_operatii = _build_sir_id_operatii(data.sir_id_operatii)
id_sucursala = data.id_sucursala or data.id_firma
logger.info(
"service_auto.create_comanda START",
extra={
"user": username,
"schema": schema,
"server_id": server_id,
"tip": data.tip_id,
"client_id": data.id_masiniclient,
"id_firma": data.id_firma,
"pc_nr": pc_nr,
"km": data.km_int,
"ore": data.ore_functionare,
"id_asigurator": data.id_asigurator,
"id_inspector": data.id_inspector,
"nr_operatii": len(data.sir_id_operatii) if data.sir_id_operatii else 0,
},
)
async with oracle_pool.get_connection("mariusm_test") as connection:
try:
async with oracle_pool.get_connection(server_id) as connection:
with connection.cursor() as cursor:
# Step 1: allocate sequence number via pack_serii_numere
try:
seq, id_numar = _aloca_numar_devize(
cursor, schema, user_id or 0, id_sucursala
)
except oracledb.DatabaseError as e:
try:
connection.rollback()
except Exception:
pass
_handle_oracle_error(e)
pc_nr = _build_pc_nr(data.tip_id, seq, nr_inmatriculare)
# Step 2: create comanda; compensating dezaloca on DB failure.
# pnIdOrdl is IN OUT — setvalue(0, 0) sets the IN side to 0;
# Oracle overwrites it with the new DEV_ORDL.ID_ORDL.
out_id_ordl = cursor.var(oracledb.NUMBER)
out_id_ordl.setvalue(0, 0)
try:
cursor.callproc(
f"{_SCHEMA}.PACK_AUTO.dev_adauga_lucrare",
f"{schema}.PACK_AUTO.dev_adauga_lucrare",
[
_SCHEMA, # v_gcs IN VARCHAR2
schema, # v_gcs IN VARCHAR2
now.year, # tnan IN NUMBER
now.month, # tnluna IN NUMBER
user_id or 0, # tnIdUtil IN NUMBER (Oracle ID_UTIL)
user_id or 0, # tnIdUtil IN NUMBER
pc_nr, # pcNr IN VARCHAR2 (NOM_LUCRARI.NRORD)
None, # pnIdInsp IN NUMBER
None, # pnIdAsig IN NUMBER
data.id_inspector, # pnIdInsp IN NUMBER
data.id_asigurator, # pnIdAsig IN NUMBER
data.nr_dosar or "", # pcNrDosar IN VARCHAR2
data.id_masiniclient, # pnIdMC IN NUMBER
data.km_int, # pnKmInt IN NUMBER
data.ore_functionare, # pnOreFct IN NUMBER (≥0; NOT NULL in DEV_MASINICLIENTI)
data.ore_functionare, # pnOreFct IN NUMBER
data.termen, # pdTermen IN DATE
data.tip_id, # pnTipCom IN NUMBER
None, # pcSirIdOperatii IN VARCHAR2 — MUST be None, NOT ''
data.observatii or None, # pcObservatii IN VARCHAR2 DEFAULT NULL
data.defectiuni or None, # pcDefectiuni IN VARCHAR2 DEFAULT NULL
0, # pnIdPartRef IN NUMBER (decode(0)NULL inside SP)
pc_sir_id_operatii, # pcSirIdOperatii IN VARCHAR2 (None or CSV)
data.observatii or None, # pcObservatii IN VARCHAR2
data.defectiuni or None, # pcDefectiuni IN VARCHAR2
data.id_part_ref or 0, # pnIdPartRef IN NUMBER (decode(0)NULL in SP)
out_id_ordl, # pnIdOrdl IN OUT NUMBER
],
)
connection.commit()
id_ordl = int(out_id_ordl.getvalue())
except oracledb.DatabaseError as e:
try:
connection.rollback()
except Exception:
pass # connection may be dead on network errors; ignore
pass
_dezaloca_numar_devize(cursor, schema, seq, id_numar, "dev_adauga_lucrare_failed")
try:
# aloca uses AUTONOMOUS_TRANSACTION; survives our rollback. Commit dezaloca.
connection.commit()
except Exception:
pass
_handle_oracle_error(e)
id_ordl = int(out_id_ordl.getvalue())
logger.info(
"service_auto.create_comanda OK",
extra={"user": username, "id_ordl": id_ordl, "nrord": pc_nr},
@@ -137,20 +240,24 @@ class ComandaService:
return ComandaResponse(
id_ordl=id_ordl,
nrord=pc_nr,
mesaj=f"Comanda {pc_nr} creata cu succes.",
mesaj=f"Comanda {pc_nr} creată cu succes.",
)
@staticmethod
async def get_comenzi(
company_id: int,
page: int,
per_page: int,
validat: Optional[int],
data_de_la: Optional[date],
data_pana_la: Optional[date],
server_id: Optional[str] = None,
) -> ComandaListResponse:
per_page = min(per_page, _MAX_PER_PAGE)
offset = (page - 1) * per_page
schema = await get_schema(company_id, server_id)
where_parts = ["d.sters = 0"]
filter_params: dict = {}
@@ -170,11 +277,11 @@ class ComandaService:
where_clause = " AND ".join(where_parts)
base_from = f"""
FROM MARIUSM_AUTO.DEV_ORDL d
LEFT JOIN MARIUSM_AUTO.NOM_LUCRARI l ON d.id_lucrare = l.id_lucrare
LEFT JOIN MARIUSM_AUTO.AUTO_VMASINICLIENTI mc
FROM {schema}.DEV_ORDL d
LEFT JOIN {schema}.NOM_LUCRARI l ON d.id_lucrare = l.id_lucrare
LEFT JOIN {schema}.AUTO_VMASINICLIENTI mc
ON d.id_masiniclient = mc.id_masiniclient
LEFT JOIN MARIUSM_AUTO.DEV_TIP_DEVIZ t ON d.id_tip = t.id_tip
LEFT JOIN {schema}.DEV_TIP_DEVIZ t ON d.id_tip = t.id_tip
WHERE {where_clause}
"""
@@ -190,7 +297,7 @@ class ComandaService:
"""
try:
async with oracle_pool.get_connection("mariusm_test") as conn:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
cur.execute(count_query, filter_params)
total = cur.fetchone()[0]

View File

@@ -1,7 +1,8 @@
"""
Lookup data for service_auto forms — tip deviz, masini, firme.
Lookup data for service_auto forms — tip deviz, masini, firme, asiguratori, inspectori, operatii, parteneri.
All three endpoints are read-only and infrequently changing.
Multi-tenant safe: `schema` e rezolvat din `id_firma` via `_context.get_schema()`; nu există
schemă hardcodată. `server_id` propagat din JWT (`request.state.server_id`).
"""
import time
from typing import List, Optional, Tuple
@@ -10,14 +11,22 @@ import oracledb
from fastapi import HTTPException
from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import FirmaItem, MasinaClientItem, TipDevizItem
from ..schemas.comanda import (
AsiguratorItem, FirmaItem, InspectorItem, MasinaClientItem,
MasinaDetails, OperatieItem, PartenerItem, PartnerCreateRequest,
TipDevizItem,
)
from .. import logger
from ._context import get_schema
# In-memory TTL cache: key → (monotonic_timestamp, value)
_cache: dict = {}
_TTL_TIP_DEVIZ = 86400 # 24 h — tip deviz changes only via DB migration
_TTL_MASINI = 300 # 5 min — vehicle inventory changes regularly
_TTL_ASIGURATORI = 86400 # 24 h
_TTL_INSPECTORI = 86400 # 24 h per asigurator
_TTL_OPERATII = 86400 # 24 h — DEV_NOM_NORME changes only via DB
def _cache_get(key: str, ttl: float):
@@ -31,13 +40,21 @@ def _cache_set(key: str, value) -> None:
_cache[key] = (time.monotonic(), value)
def reset_cache() -> None:
"""Test helper."""
_cache.clear()
class LookupService:
@staticmethod
async def get_firme(company_ids: List[str]) -> List[FirmaItem]:
async def get_firme(
company_ids: List[str],
server_id: Optional[str] = None,
) -> List[FirmaItem]:
"""
Returns firma names for the company IDs in the user's JWT.
Uses 'central' pool (CONTAFIN_ORACLE) to query V_NOM_FIRME.
Query pe `CONTAFIN_ORACLE.V_NOM_FIRME` pe serverul utilizatorului.
"""
if not company_ids:
return []
@@ -52,7 +69,7 @@ class LookupService:
params = {f"id{i}": int(cid) for i, cid in enumerate(company_ids)}
try:
async with oracle_pool.get_connection("central") as conn:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
cur.execute(query, params)
rows = cur.fetchall()
@@ -66,23 +83,26 @@ class LookupService:
]
@staticmethod
async def get_tip_deviz() -> List[TipDevizItem]:
async def get_tip_deviz(
company_id: int,
server_id: Optional[str] = None,
) -> List[TipDevizItem]:
"""
Returns all active tip deviz from MARIUSM_AUTO.DEV_TIP_DEVIZ.
Cached in-process for 24 h (changes only via DB migration).
ROA_WEB has SELECT grant on this view.
Tip deviz din `{schema}.DEV_TIP_DEVIZ`. Cached 24 h per schema.
"""
cached = _cache_get("tip_deviz", _TTL_TIP_DEVIZ)
schema = await get_schema(company_id, server_id)
cache_key = f"tip_deviz:{schema}"
cached = _cache_get(cache_key, _TTL_TIP_DEVIZ)
if cached is not None:
return cached
query = """
query = f"""
SELECT id_tip, denumire, inch_validare
FROM MARIUSM_AUTO.DEV_TIP_DEVIZ
FROM {schema}.DEV_TIP_DEVIZ
ORDER BY id_tip
"""
try:
async with oracle_pool.get_connection("mariusm_test") as conn:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
cur.execute(query)
rows = cur.fetchall()
@@ -94,29 +114,31 @@ class LookupService:
TipDevizItem(id_tip=r[0], denumire=r[1], inch_validare=r[2] or 0)
for r in rows
]
_cache_set("tip_deviz", result)
_cache_set(cache_key, result)
return result
@staticmethod
async def get_masini() -> List[MasinaClientItem]:
async def get_masini(
company_id: int,
server_id: Optional[str] = None,
) -> List[MasinaClientItem]:
"""
Returns active masini from MARIUSM_AUTO.AUTO_VMASINICLIENTI.
Cached in-process for 5 min (vehicle inventory changes regularly).
ROA_WEB has SELECT grant on this view.
Label format: "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)"
Mașini active din `{schema}.AUTO_VMASINICLIENTI`. Cached 5 min per schema.
"""
cached = _cache_get("masini", _TTL_MASINI)
schema = await get_schema(company_id, server_id)
cache_key = f"masini:{schema}"
cached = _cache_get(cache_key, _TTL_MASINI)
if cached is not None:
return cached
query = """
query = f"""
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener
FROM MARIUSM_AUTO.AUTO_VMASINICLIENTI
FROM {schema}.AUTO_VMASINICLIENTI
WHERE inactiv = 0
ORDER BY partener, nrinmat
"""
try:
async with oracle_pool.get_connection("mariusm_test") as conn:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
cur.execute(query)
rows = cur.fetchall()
@@ -137,5 +159,302 @@ class LookupService:
label = f"{partener or '?'}{vehicul}, {nrinmat or '?'}{an_str}"
result.append(MasinaClientItem(id_masiniclient=int(id_mc), label=label))
_cache_set("masini", result)
_cache_set(cache_key, result)
return result
@staticmethod
async def get_masina_details(
id_masiniclient: int,
company_id: int,
server_id: Optional[str] = None,
) -> Optional[MasinaDetails]:
"""
Detalii complete vehicul din `{schema}.AUTO_VMASINICLIENTI`. Fără cache (per-record).
"""
schema = await get_schema(company_id, server_id)
query = f"""
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener,
series, cilindree, puterecp, puterekw
FROM {schema}.AUTO_VMASINICLIENTI
WHERE id_masiniclient = :id_mc
"""
try:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
cur.execute(query, {"id_mc": id_masiniclient})
row = cur.fetchone()
except oracledb.DatabaseError:
logger.error("get_masina_details Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la încărcarea detaliilor mașinii")
if not row:
return None
id_mc, nrinmat, marca, masina, an, partener, serie_sasiu, cilindree, putere_cp, putere_kw = row
parts = [p for p in [marca, masina] if p]
vehicul = " ".join(parts) if parts else "?"
an_str = f" ({int(an)})" if an else ""
label = f"{partener or '?'}{vehicul}, {nrinmat or '?'}{an_str}"
return MasinaDetails(
id_masiniclient=int(id_mc),
label=label,
nr_inmatriculare=nrinmat,
marca=marca,
model=masina,
serie_sasiu=serie_sasiu,
cilindree=int(cilindree) if cilindree else None,
putere_cp=int(putere_cp) if putere_cp else None,
putere_kw=int(putere_kw) if putere_kw else None,
client_nume=partener,
)
@staticmethod
async def get_asiguratori(
company_id: int,
server_id: Optional[str] = None,
) -> List[AsiguratorItem]:
"""
Asigurători activi din `{schema}.DEV_NOM_ASIGURATORI`. Cached 24h per schema.
Numele din NOM_PARTENERI via FK ID_PART (DEV_NOM_ASIGURATORI nu are coloană denumire).
"""
schema = await get_schema(company_id, server_id)
cache_key = f"asiguratori:{schema}"
cached = _cache_get(cache_key, _TTL_ASIGURATORI)
if cached is not None:
return cached
query = f"""
SELECT a.id_asigurator, NVL(p.denumire, a.asigurator_vechi) AS denumire
FROM {schema}.DEV_NOM_ASIGURATORI a
LEFT JOIN {schema}.NOM_PARTENERI p ON a.id_part = p.id_part
WHERE NVL(a.sters, 0) = 0
ORDER BY NVL(p.denumire, a.asigurator_vechi)
"""
try:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
cur.execute(query)
rows = cur.fetchall()
except oracledb.DatabaseError:
logger.error("get_asiguratori Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la încărcarea asigurătorilor")
result = [AsiguratorItem(id_asigurator=int(r[0]), denumire=r[1] or "") for r in rows]
_cache_set(cache_key, result)
return result
@staticmethod
async def get_inspectori(
id_asigurator: int,
company_id: int,
server_id: Optional[str] = None,
) -> List[InspectorItem]:
"""
Inspectori filtrați per asigurator din `{schema}.DEV_NOM_INSPECTORI`.
Cached 24h per (schema, id_asigurator).
"""
schema = await get_schema(company_id, server_id)
cache_key = f"inspectori:{schema}:{id_asigurator}"
cached = _cache_get(cache_key, _TTL_INSPECTORI)
if cached is not None:
return cached
query = f"""
SELECT id_inspector, inspector AS denumire, id_asigurator
FROM {schema}.DEV_NOM_INSPECTORI
WHERE id_asigurator = :id_asig
AND NVL(sters, 0) = 0
ORDER BY inspector
"""
try:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
cur.execute(query, {"id_asig": id_asigurator})
rows = cur.fetchall()
except oracledb.DatabaseError:
logger.error("get_inspectori Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la încărcarea inspectorilor")
result = [
InspectorItem(id_inspector=int(r[0]), denumire=r[1] or "", id_asigurator=int(r[2]))
for r in rows
]
_cache_set(cache_key, result)
return result
@staticmethod
async def get_operatii(
company_id: int,
server_id: Optional[str] = None,
) -> List[OperatieItem]:
"""
Operații din `{schema}.DEV_NOM_NORME`. Cached 24h per schema.
Full list; filter client-side.
"""
schema = await get_schema(company_id, server_id)
cache_key = f"operatii:{schema}"
cached = _cache_get(cache_key, _TTL_OPERATII)
if cached is not None:
return cached
query = f"""
SELECT id_norme, codop, denop, timpn
FROM {schema}.DEV_NOM_NORME
WHERE NVL(sters, 0) = 0
AND NVL(inactiv, 0) = 0
ORDER BY denop
"""
try:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
cur.execute(query)
rows = cur.fetchall()
except oracledb.DatabaseError:
logger.error("get_operatii Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la încărcarea operațiilor")
# Oracle treats '' as NULL, so NVL(col,'') can still yield None in Python.
result = [
OperatieItem(
id_norme=int(r[0]),
codop=r[1] or "",
denop=r[2] or "",
timpn=float(r[3]) if r[3] is not None else None,
)
for r in rows
]
_cache_set(cache_key, result)
return result
@staticmethod
async def search_parteneri(
q: str,
company_id: int,
server_id: Optional[str] = None,
) -> List[PartenerItem]:
"""
Typeahead pe `{schema}.NOM_PARTENERI`. Min 2 chars, limit 50. No cache.
Folosește IX_NOM_PARTENERI_DEN_UPPER; LIKE escape pentru %, _, \\.
"""
if len(q) < 2:
return []
def _escape_like(s: str) -> str:
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
schema = await get_schema(company_id, server_id)
query = f"""
SELECT id_part, denumire
FROM {schema}.NOM_PARTENERI
WHERE UPPER(denumire) LIKE UPPER(:q) ESCAPE '\\'
AND NVL(sters, 0) = 0
ORDER BY denumire
FETCH FIRST 50 ROWS ONLY
"""
try:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
cur.execute(query, {"q": _escape_like(q) + "%"})
rows = cur.fetchall()
except oracledb.DatabaseError:
logger.error("search_parteneri Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la căutarea partenerilor")
return [PartenerItem(id_part=int(r[0]), denumire=r[1] or "") for r in rows]
@staticmethod
async def create_partener(
data: PartnerCreateRequest,
server_id: Optional[str] = None,
) -> PartenerItem:
"""
Creează partener nou în `{schema}.NOM_PARTENERI`.
- id_part alocat manual cu `NVL(MAX(id_part),0)+1` (nu există secvență/identity).
- Pre-check unicitate CUI (NU există unique constraint pe COD_FISCAL) → 409.
- PK collision (race) → ORA-00001 → retry o singură dată cu MAX+1 reactualizat.
- ORA-00942/01031 (table missing / no privileges) → log.critical + 500 (lipsă GRANT).
"""
denumire = data.denumire.strip()
cui = (data.cui or "").strip() or None
adresa = (data.adresa or "").strip() or None
schema = await get_schema(data.id_firma, server_id)
try:
async with oracle_pool.get_connection(server_id) as conn:
with conn.cursor() as cur:
# Pre-check duplicat CUI (doar dacă CUI a fost furnizat).
if cui:
cur.execute(
f"""
SELECT 1 FROM {schema}.NOM_PARTENERI
WHERE cod_fiscal = :cui
AND NVL(sters, 0) = 0
AND ROWNUM = 1
""",
{"cui": cui},
)
if cur.fetchone():
raise HTTPException(status_code=409, detail="CUI duplicat")
insert_sql = f"""
INSERT INTO {schema}.NOM_PARTENERI
(id_part, denumire, cod_fiscal, adresa,
sters, inactiv, id_mod, tip_persoana)
VALUES
(:id_part, :denumire, :cui, :adresa,
0, 0, 0, 1)
"""
params_base = {
"denumire": denumire,
"cui": cui,
"adresa": adresa,
}
new_id: Optional[int] = None
last_err: Optional[oracledb.DatabaseError] = None
for _attempt in range(2):
cur.execute(
f"SELECT NVL(MAX(id_part), 0) + 1 FROM {schema}.NOM_PARTENERI"
)
candidate = int(cur.fetchone()[0])
try:
cur.execute(insert_sql, {"id_part": candidate, **params_base})
new_id = candidate
break
except oracledb.DatabaseError as e:
err_code = e.args[0].code if e.args else None
if err_code == 1: # ORA-00001 PK race
last_err = e
continue
raise
if new_id is None:
# Două încercări consecutive cu PK collision — escaladăm.
if last_err is not None:
raise last_err
raise HTTPException(
status_code=500,
detail="Nu s-a putut aloca id_part după 2 încercări",
)
conn.commit()
except HTTPException:
raise
except oracledb.DatabaseError as e:
err_code = e.args[0].code if e.args else None
if err_code in (942, 1031):
logger.critical(
"create_partener: lipsă GRANT INSERT pe NOM_PARTENERI (schema=%s, ORA-%05d)",
schema, err_code, exc_info=True,
)
raise HTTPException(
status_code=500,
detail="Lipsă privilegii pe tabela NOM_PARTENERI; contactați administratorul.",
)
logger.error("create_partener Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la crearea partenerului")
return PartenerItem(id_part=new_id, denumire=denumire)

View File

@@ -0,0 +1,38 @@
# SupplierDualField — refactor decision
**Decizie**: `SupplierDualField.vue` **NU** se refactorizează cu `AsyncAutoComplete`.
## Context
Task #3 a extras `AsyncAutoComplete` (shared) din pattern-ul typeahead async din
`ComandaNoua.vue`. Candidatul pentru refactor a fost
`src/modules/data-entry/components/receipts/SupplierDualField.vue`.
## Motive pentru skip
1. **Filtru client-side, nu async remote**
`SupplierDualField` filtrează o listă `partners` **preîncărcată în memorie** (`props.partners.filter(...)` pe nume + CUI).
`AsyncAutoComplete` e construit pe `searchFn: (q) => Promise<Item[]>` (remote).
Adaptarea ar cere un wrapper artificial `async (q) => partners.filter(...)` care nu aduce valoare.
2. **`force-selection: false` vs `force-selection: true`**
SupplierDualField permite intrare free-text (`forceSelection: false`) pentru că
users pot tasta manual CUI/nume furnizor. `AsyncAutoComplete` impune
`force-selection: true` ca invariant de securitate (evită ID-uri fantomă).
Schimbarea ar rupe flow-ul existent.
3. **Dropdown manual + câmp CUI separat**
Componenta e **composite**: AutoComplete (nume) + `InputText` (CUI) + toggle adresă
+ sync-button + status badges (oracle/local/warning), totul cu propriile `update:*`
emits. AutoComplete-ul e doar o parte — extragerea lui izolat ar lăsa componenta
într-o stare hibridă, mai complicată decât acum.
4. **Prop `dropdown`**
SupplierDualField folosește `dropdown` (buton chevron care arată toată lista).
`AsyncAutoComplete` nu expune acest mod (ar contrazice pattern-ul async „caută
minim N caractere").
## Concluzie
Pattern-urile diferă fundamental. Păstrăm `SupplierDualField` neschimbat.
`AsyncAutoComplete` rămâne dedicat pattern-urilor de typeahead async, cu sursa
de date remote (ex: `ComandaNoua.vue` → partener service-auto, și viitoare
formulare similare).

View File

@@ -0,0 +1,26 @@
-- grant-uri ROA_WEB pe tabele asiguratori, inspectori, norme, parteneri + index typeahead
-- Rulat conectat ca schema MARIUSM_AUTO (sau DBA cu privilegii de GRANT)
GRANT SELECT ON MARIUSM_AUTO.DEV_NOM_NORME TO ROA_WEB;
GRANT SELECT ON MARIUSM_AUTO.DEV_NOM_INSPECTORI TO ROA_WEB;
GRANT SELECT ON MARIUSM_AUTO.DEV_NOM_ASIGURATORI TO ROA_WEB;
GRANT SELECT ON MARIUSM_AUTO.NOM_PARTENERI TO ROA_WEB;
GRANT EXECUTE ON MARIUSM_AUTO.pack_serii_numere TO ROA_WEB;
-- SER_SERII / SER_PLAJE: adaugati manual DACA pack_serii_numere are AUTHID CURRENT_USER
-- GRANT SELECT, INSERT, UPDATE ON MARIUSM_AUTO.SER_SERII TO ROA_WEB;
-- GRANT SELECT, INSERT, UPDATE ON MARIUSM_AUTO.SER_PLAJE TO ROA_WEB;
-- Index functional pentru typeahead parteneri (UPPER pe denumire)
-- Rulat ca MARIUSM_AUTO owner; ONLINE pentru zero-downtime pe Enterprise Edition
BEGIN
IF PACK_MIGRARE.OBJECTEXIST('IX_NOM_PARTENERI_DEN_UPPER','INDEX') = 0 THEN
EXECUTE IMMEDIATE
'CREATE INDEX MARIUSM_AUTO.IX_NOM_PARTENERI_DEN_UPPER
ON MARIUSM_AUTO.NOM_PARTENERI (UPPER(DENUMIRE))';
END IF;
END;
/
exec pack_migrare.UpdateVersiune('ff_2026_04_13_01_AUTO'); commit;

View File

@@ -0,0 +1,8 @@
-- grant INSERT pe NOM_PARTENERI pentru creare partener nou din UI Service Auto
-- Rulat conectat ca schema MARIUSM_AUTO (sau DBA cu privilegii de GRANT)
-- GRANT este idempotent: re-rulare = no-op.
GRANT INSERT ON MARIUSM_AUTO.NOM_PARTENERI TO ROA_WEB;
exec pack_migrare.UpdateVersiune('ff_2026_04_13_02_AUTO'); commit;

View File

@@ -0,0 +1,236 @@
<template>
<Dialog
:visible="visible"
modal
:header="'Adaugă partener nou'"
:style="{ width: '420px', maxWidth: '95vw' }"
:closable="!isSaving"
:close-on-escape="!isSaving"
@update:visible="onVisibleChange"
@show="onShow"
>
<form class="partner-dialog-form" @submit.prevent="save">
<div class="field">
<label for="partener-denumire" class="field-label">
Denumire <span class="req">*</span>
</label>
<InputText
id="partener-denumire"
ref="denumireInputRef"
v-model="denumire"
autocomplete="off"
maxlength="100"
:disabled="isSaving"
:class="['w-full', { 'p-invalid': denumireError }]"
aria-required="true"
@input="denumireError = ''"
/>
<small v-if="denumireError" class="field-error">{{ denumireError }}</small>
</div>
<div class="field">
<label for="partener-cui" class="field-label">CUI / CIF</label>
<InputText
id="partener-cui"
v-model="cui"
autocomplete="off"
maxlength="30"
inputmode="text"
placeholder="Ex: RO12345678 (opțional)"
:disabled="isSaving"
class="w-full"
/>
</div>
<div class="field">
<label for="partener-adresa" class="field-label">Adresă</label>
<Textarea
id="partener-adresa"
v-model="adresa"
rows="2"
maxlength="150"
:disabled="isSaving"
style="width: 100%; resize: vertical;"
placeholder="Adresa partenerului (opțional)"
/>
</div>
</form>
<template #footer>
<Button
label="Anulează"
severity="secondary"
text
:disabled="isSaving"
@click="cancel"
/>
<Button
label="Salvează"
icon="pi pi-check"
:loading="isSaving"
:disabled="!canSave"
@click="save"
/>
</template>
</Dialog>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
import { useToast } from 'primevue/usetoast'
import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Textarea from 'primevue/textarea'
import Button from 'primevue/button'
import serviceAutoApi from '../services/api.js'
const props = defineProps({
visible: { type: Boolean, default: false },
idFirma: { type: Number, default: null },
initialDenumire: { type: String, default: '' },
})
const emit = defineEmits(['update:visible', 'created'])
const toast = useToast()
const denumire = ref('')
const cui = ref('')
const adresa = ref('')
const denumireError = ref('')
const isSaving = ref(false)
const denumireInputRef = ref(null)
const canSave = computed(() => denumire.value.trim().length >= 2 && !isSaving.value)
watch(() => props.visible, (open) => {
if (open) {
denumire.value = (props.initialDenumire || '').trim()
cui.value = ''
adresa.value = ''
denumireError.value = ''
}
})
function onShow() {
// Focus pe input după render-ul dialogului (PrimeVue mounts overlay async).
nextTick(() => {
const el = denumireInputRef.value?.$el ?? denumireInputRef.value
if (el && typeof el.focus === 'function') el.focus()
})
}
function onVisibleChange(val) {
if (!val && isSaving.value) return // ignoră Esc/X în timpul salvării
emit('update:visible', val)
}
function cancel() {
if (isSaving.value) return
emit('update:visible', false)
}
async function save() {
const trimmed = denumire.value.trim()
if (trimmed.length < 2) {
denumireError.value = 'Denumirea trebuie să aibă cel puțin 2 caractere.'
return
}
if (!props.idFirma) {
toast.add({
severity: 'error',
summary: 'Firmă lipsă',
detail: 'Selectează o firmă înainte de a adăuga un partener.',
life: 5000,
})
return
}
isSaving.value = true
try {
const payload = {
denumire: trimmed,
cui: cui.value.trim() || null,
adresa: adresa.value.trim() || null,
id_firma: props.idFirma,
}
const { data } = await serviceAutoApi.createPartener(payload)
toast.add({
severity: 'success',
summary: 'Partener creat',
detail: data.denumire,
life: 3000,
})
emit('created', data)
emit('update:visible', false)
} catch (err) {
const status = err.response?.status
if (status === 409) {
toast.add({
severity: 'warn',
summary: 'CUI duplicat',
detail: err.response?.data?.detail || 'Există deja un partener cu acest CUI.',
life: 5000,
})
} else if (status === 422) {
toast.add({
severity: 'error',
summary: 'Validare',
detail: err.response?.data?.detail || 'Date invalide.',
life: 5000,
})
} else if (status === 403) {
toast.add({
severity: 'error',
summary: 'Acces refuzat',
detail: 'Nu aveți permisiune să creați parteneri pentru această firmă.',
life: 5000,
})
} else {
toast.add({
severity: 'error',
summary: 'Eroare server',
detail: 'Partenerul nu a fost creat. Reîncercați.',
life: 5000,
})
}
} finally {
isSaving.value = false
}
}
</script>
<style scoped>
.partner-dialog-form {
display: grid;
gap: var(--space-md);
padding-top: var(--space-xs);
}
.field {
display: grid;
gap: var(--space-xs);
}
.field-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-color-secondary);
}
.req {
color: var(--red-500);
margin-left: 2px;
}
.field-error {
color: var(--red-600);
font-size: var(--text-xs);
}
[data-theme="dark"] .field-error {
color: var(--red-400);
}
</style>

View File

@@ -5,13 +5,50 @@ const api = axios.create({ baseURL: '/api/service-auto' })
api.interceptors.request.use(config => {
const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${token}`
// Auto-injectare id_firma din compania selectată (localStorage).
// Cheia e `selected_company_<username>_<server_id>` — vezi `shared/stores/companies.js`.
// Backend acceptă fallback JWT companies[0] dacă param-ul lipsește.
const id_firma = getSelectedCompanyId()
if (id_firma != null) {
if (config.method === 'get') {
config.params = { id_firma, ...(config.params || {}) }
} else if (config.method === 'post' && config.data && typeof config.data === 'object' && !Array.isArray(config.data)) {
if (config.data.id_firma == null) config.data.id_firma = id_firma
}
}
return config
})
function getSelectedCompanyId() {
try {
const user = JSON.parse(localStorage.getItem('user') || 'null')
const serverId = localStorage.getItem('last_server_id')
const username = user?.username
if (!username) return null
const key = serverId
? `selected_company_${username}_${serverId}`
: `selected_company_${username}`
const raw = localStorage.getItem(key)
if (!raw) return null
const company = JSON.parse(raw)
return company?.id_firma ?? null
} catch {
return null
}
}
export default {
getFirme: () => api.get('/firme'),
getTipDeviz: () => api.get('/tip-deviz'),
getMasini: () => api.get('/masini'),
getMasinaDetails:(id) => api.get(`/masini/${id}/detalii`),
getAsiguratori: () => api.get('/asiguratori'),
getInspectori: (id_asigurator) => api.get('/inspectori', { params: { id_asigurator } }),
getOperatii: () => api.get('/operatii'),
getParteneri: (q) => api.get('/parteneri', { params: { q } }),
createPartener: (data) => api.post('/parteneri', data),
getComenzi: (params) => api.get('/comenzi', { params }),
creeazaComanda: (data) => api.post('/comenzi', data),
}

View File

@@ -1,22 +1,32 @@
<template>
<div class="page-container">
<div :class="isMobile ? 'mobile-page' : 'page-container'">
<Toast />
<div class="card" style="max-width: 560px; margin: var(--space-xl) auto;">
<div class="card-header">
<!-- Mobile chrome (CLAUDE.md: toate paginile mobile MUST use MobileTopBar + MobileBottomNav) -->
<MobileTopBar
v-if="isMobile"
title="Comandă nouă"
show-back
:actions="[{ icon: 'pi pi-check', label: 'Salvează', disabled: !isFormValid || isSubmitting }]"
@back-click="$router.back()"
@action-click="submitComanda"
/>
<!-- Content area card pe desktop, padding simplu pe mobile -->
<div :class="isMobile ? 'mobile-content' : ''">
<div :class="isMobile ? 'form-mobile' : 'card form-card'">
<div v-if="!isMobile" class="card-header">
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
Comandă Nouă
</h2>
</div>
<div class="card-body">
<div :class="isMobile ? '' : 'card-body'">
<form class="form-stack" @submit.prevent="submitComanda">
<!-- Firmă -->
<!-- 1. Firmă -->
<div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
Firmă *
</label>
<label class="field-label">Firmă <span class="req">*</span></label>
<Dropdown
v-model="form.id_firma"
:options="firme"
@@ -26,14 +36,13 @@
:disabled="isSubmitting || loadingFirme"
:loading="loadingFirme"
class="w-full"
aria-required="true"
/>
</div>
<!-- Tip comandă -->
<!-- 2. Tip comandă -->
<div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
Tip comandă *
</label>
<label class="field-label">Tip comandă <span class="req">*</span></label>
<Dropdown
v-model="form.tip_id"
:options="tipuriComanda"
@@ -43,14 +52,13 @@
:disabled="isSubmitting || loadingTipuri"
:loading="loadingTipuri"
class="w-full"
aria-required="true"
/>
</div>
<!-- Client / Mașină -->
<!-- 3. Client / Mașină -->
<div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
Client / Mașină *
</label>
<label class="field-label">Client / Mașină <span class="req">*</span></label>
<Dropdown
ref="clientDropdownRef"
v-model="form.id_masiniclient"
@@ -62,43 +70,110 @@
:loading="loadingMasini"
filter
class="w-full"
aria-required="true"
/>
</div>
<!-- Observații client -->
<!-- 4. Card mașină readonly apare după selecție (D3: afișează "—" nu ascunde) -->
<div v-if="form.id_masiniclient" class="masina-card">
<div style="font-size: var(--text-xs); font-weight: var(--font-semibold); color: var(--text-color-secondary); margin-bottom: var(--space-sm); text-transform: uppercase; letter-spacing: 0.05em;">
Detalii vehicul
</div>
<div v-if="loadingMasinaDetails" class="masina-card-grid">
<Skeleton v-for="i in 6" :key="i" height="36px" />
</div>
<div v-else class="masina-card-grid">
<div>
<div class="masina-field-label">Client</div>
<div class="masina-field-value">{{ masinaDetails?.client_nume || '—' }}</div>
</div>
<div>
<div class="masina-field-label">Nr. înmatriculare</div>
<div class="masina-field-value">{{ masinaDetails?.nr_inmatriculare || '—' }}</div>
</div>
<div>
<div class="masina-field-label">Marcă</div>
<div class="masina-field-value">{{ masinaDetails?.marca || '—' }}</div>
</div>
<div>
<div class="masina-field-label">Model</div>
<div class="masina-field-value">{{ masinaDetails?.model || '—' }}</div>
</div>
<div>
<div class="masina-field-label">Serie șasiu</div>
<div class="masina-field-value">{{ masinaDetails?.serie_sasiu || '—' }}</div>
</div>
<div>
<div class="masina-field-label">Cilindree / Putere</div>
<div class="masina-field-value">
{{ masinaDetails?.cilindree ?? '—' }} cm³ /
{{ masinaDetails?.putere_cp ?? '—' }} CP
</div>
</div>
</div>
</div>
<!-- 5. Asigurator + Inspector (cascadă) -->
<div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
Observații client
</label>
<Textarea
v-model="form.observatii"
rows="3"
placeholder="Solicitări, observații client..."
:disabled="isSubmitting"
style="width: 100%; resize: vertical;"
<label class="field-label">Asigurător</label>
<Dropdown
v-model="form.id_asigurator"
:options="asiguratori"
option-label="denumire"
option-value="id_asigurator"
placeholder="Selectează asigurătorul (opțional)"
:disabled="isSubmitting || loadingAsiguratori"
:loading="loadingAsiguratori"
show-clear
class="w-full"
/>
</div>
<!-- Defecțiuni constatate la recepție -->
<div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
Defecțiuni constatate la recepție
</label>
<Textarea
v-model="form.defectiuni"
rows="3"
placeholder="Defecțiuni observate la preluarea vehiculului..."
:disabled="isSubmitting"
style="width: 100%; resize: vertical;"
<label class="field-label">Inspector</label>
<Dropdown
v-model="form.id_inspector"
:options="inspectori"
option-label="denumire"
option-value="id_inspector"
:placeholder="form.id_asigurator ? 'Selectează inspectorul' : 'Selectați asigurătorul întâi'"
:disabled="isSubmitting || !form.id_asigurator || loadingInspectori"
:loading="loadingInspectori"
show-clear
class="w-full"
/>
</div>
<!-- 6. Referință partener (AsyncAutoComplete shared) -->
<div class="field">
<label class="field-label">Referință (partener)</label>
<AsyncAutoComplete
v-model="form._referinta_obj"
:search-fn="searchParteneri"
option-label="denumire"
option-key="id_part"
placeholder="Caută partener... (min. 2 caractere)"
empty-action-label="+ Adaugă partener nou"
aria-label="Referință partener"
:disabled="isSubmitting"
@empty-action="onAddNewPartner"
/>
</div>
<!-- 7. Nr dosar, Km, Ore, Termen -->
<div class="field">
<label class="field-label">Nr. dosar asigurare</label>
<InputText
v-model="form.nr_dosar"
placeholder="Completați dacă e comandă de asigurare..."
:disabled="isSubmitting"
class="w-full"
/>
</div>
<!-- Km + Ore funcționare (same row) -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md);">
<div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
Kilometraj la recepție
</label>
<label class="field-label">Kilometraj la recepție</label>
<InputNumber
v-model="form.km_int"
:min="0"
@@ -110,9 +185,7 @@
/>
</div>
<div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
Ore funcționare motor
</label>
<label class="field-label">Ore funcționare motor</label>
<InputNumber
v-model="form.ore_functionare"
:min="0"
@@ -125,11 +198,8 @@
</div>
</div>
<!-- Termen estimat -->
<div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
Termen estimat finalizare
</label>
<label class="field-label">Termen estimat finalizare</label>
<Calendar
v-model="form.termen"
date-format="dd.mm.yy"
@@ -141,21 +211,59 @@
/>
</div>
<!-- Număr dosar asigurare -->
<!-- 8. MultiSelect Operații cerute -->
<div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
Nr. dosar asigurare
</label>
<InputText
v-model="form.nr_dosar"
placeholder="Completați dacă e comandă de asigurare..."
<label class="field-label">Operații cerute de client</label>
<div v-if="loadingOperatii" style="display: flex; flex-direction: column; gap: var(--space-xs);">
<Skeleton v-for="i in 3" :key="i" height="32px" />
</div>
<MultiSelect
v-else
v-model="form.sir_id_operatii"
:options="operatii"
option-label="denop"
option-value="id_norme"
display="chip"
filter
filter-placeholder="Caută operație..."
empty-filter-message="Nicio operație nu corespunde filtrului"
placeholder="Selectează operațiile (opțional)"
:disabled="isSubmitting"
class="w-full"
>
<template #emptyfilter>
<div style="padding: var(--space-sm); color: var(--text-color-secondary); font-size: var(--text-sm);">
Încearcă alt termen sau verifică ortografia.
</div>
</template>
</MultiSelect>
</div>
<!-- 9. Observații + Defecțiuni -->
<div class="field">
<label class="field-label">Observații client</label>
<Textarea
v-model="form.observatii"
rows="3"
placeholder="Solicitări, observații client..."
:disabled="isSubmitting"
style="width: 100%; resize: vertical;"
/>
</div>
<!-- Submit -->
<div style="display: flex; justify-content: flex-end; padding-top: var(--space-sm);">
<div class="field">
<label class="field-label">Defecțiuni constatate la recepție</label>
<Textarea
v-model="form.defectiuni"
rows="3"
placeholder="Defecțiuni observate la preluarea vehiculului..."
:disabled="isSubmitting"
style="width: 100%; resize: vertical;"
/>
</div>
<!-- 10. Submit (desktop only pe mobile e în MobileTopBar action) -->
<div v-if="!isMobile" style="display: flex; justify-content: flex-end; padding-top: var(--space-sm);">
<Button
type="submit"
label="Creează Comanda"
@@ -169,10 +277,21 @@
</div>
</div>
</div>
<MobileBottomNav v-if="isMobile" :items="mobileNavItems" />
<PartnerCreateDialog
v-model:visible="partnerDialogVisible"
:id-firma="form.id_firma"
:initial-denumire="partnerDialogQuery"
@created="onPartnerCreated"
/>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import Dropdown from 'primevue/dropdown'
import Textarea from 'primevue/textarea'
@@ -181,43 +300,73 @@ import Toast from 'primevue/toast'
import InputNumber from 'primevue/inputnumber'
import InputText from 'primevue/inputtext'
import Calendar from 'primevue/calendar'
import MultiSelect from 'primevue/multiselect'
import Skeleton from 'primevue/skeleton'
import AsyncAutoComplete from '@shared/components/AsyncAutoComplete.vue'
import MobileTopBar from '@shared/components/mobile/MobileTopBar.vue'
import MobileBottomNav from '@shared/components/mobile/MobileBottomNav.vue'
import PartnerCreateDialog from '../components/PartnerCreateDialog.vue'
import serviceAutoApi from '../services/api.js'
import { useCompanyStore } from '../stores/sharedStores.js'
const toast = useToast()
const router = useRouter()
const companyStore = useCompanyStore()
const clientDropdownRef = ref(null)
const isSubmitting = ref(false)
// ─── Lookup data (from Oracle via API) ────────────────────────────────────────
// ─── Mobile detection ──────────────────────────────────────────────────────
const isMobile = ref(window.innerWidth <= 900)
function onResize() { isMobile.value = window.innerWidth <= 900 }
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
const mobileNavItems = [
{ label: 'Comenzi', icon: 'pi pi-list', to: '/service-auto/comenzi' },
{ label: 'Comandă nouă', icon: 'pi pi-plus', to: '/service-auto/comanda-noua', active: true },
]
// ─── Lookup data ───────────────────────────────────────────────────────────
const firme = ref([])
const tipuriComanda = ref([])
const masini = ref([])
const asiguratori = ref([])
const inspectori = ref([])
const operatii = ref([])
const masinaDetails = ref(null)
const loadingFirme = ref(false)
const loadingTipuri = ref(false)
const loadingMasini = ref(false)
const loadingAsiguratori = ref(false)
const loadingInspectori = ref(false)
const loadingOperatii = ref(false)
const loadingMasinaDetails = ref(false)
async function loadLookups() {
loadingFirme.value = true
loadingTipuri.value = true
loadingMasini.value = true
loadingAsiguratori.value = true
loadingOperatii.value = true
const [firmeRes, tipuriRes, masiniRes] = await Promise.allSettled([
const [firmeRes, tipuriRes, masiniRes, asiguratoriRes, operatiiRes] = await Promise.allSettled([
serviceAutoApi.getFirme(),
serviceAutoApi.getTipDeviz(),
serviceAutoApi.getMasini(),
serviceAutoApi.getAsiguratori(),
serviceAutoApi.getOperatii(),
])
if (firmeRes.status === 'fulfilled') {
firme.value = firmeRes.value.data
// Default: selected company from AppHeader store, fallback to first
if (firme.value.length > 0 && form.value.id_firma === null) {
const selected = companyStore.selectedCompany
const defaultFirma = firme.value.find(f => f.id_firma === selected?.id_firma) || firme.value[0]
if (defaultFirma) {
form.value.id_firma = defaultFirma.id_firma
}
if (defaultFirma) form.value.id_firma = defaultFirma.id_firma
}
} else {
toast.add({ severity: 'warn', summary: 'Firme', detail: 'Nu s-au putut încărca firmele', life: 4000 })
@@ -235,9 +384,23 @@ async function loadLookups() {
toast.add({ severity: 'warn', summary: 'Mașini', detail: 'Nu s-au putut încărca mașinile', life: 4000 })
}
if (asiguratoriRes.status === 'fulfilled') {
asiguratori.value = asiguratoriRes.value.data
} else {
toast.add({ severity: 'warn', summary: 'Asigurători', detail: 'Nu s-au putut încărca asigurătorii', life: 4000 })
}
if (operatiiRes.status === 'fulfilled') {
operatii.value = operatiiRes.value.data
} else {
toast.add({ severity: 'warn', summary: 'Operații', detail: 'Nu s-au putut încărca operațiile', life: 4000 })
}
loadingFirme.value = false
loadingTipuri.value = false
loadingMasini.value = false
loadingAsiguratori.value = false
loadingOperatii.value = false
}
onMounted(loadLookups)
@@ -248,12 +411,17 @@ const emptyForm = () => ({
id_firma: null,
tip_id: null,
id_masiniclient: null,
id_asigurator: null,
id_inspector: null,
id_part_ref: null,
sir_id_operatii: [],
observatii: '',
defectiuni: '',
km_int: 0,
ore_functionare: 0,
nr_dosar: '',
termen: null,
_referinta_obj: null, // AutoComplete display model — not sent to API
})
const form = ref(emptyForm())
@@ -267,10 +435,83 @@ const isFormValid = computed(() =>
form.value.id_masiniclient !== null
)
// ─── Mașină selection → card details ──────────────────────────────────────
watch(() => form.value.id_masiniclient, async (id) => {
masinaDetails.value = null
if (!id) return
loadingMasinaDetails.value = true
try {
const { data } = await serviceAutoApi.getMasinaDetails(id)
masinaDetails.value = data
} catch {
// Card rămâne null — comanda poate continua oricum
} finally {
loadingMasinaDetails.value = false
}
})
// ─── Asigurator cascade → Inspector ────────────────────────────────────────
watch(() => form.value.id_asigurator, async (id) => {
form.value.id_inspector = null
inspectori.value = []
if (!id) return
loadingInspectori.value = true
try {
const { data } = await serviceAutoApi.getInspectori(id)
inspectori.value = data
} catch {
toast.add({ severity: 'warn', summary: 'Inspectori', detail: 'Nu s-au putut încărca inspectorii', life: 4000 })
} finally {
loadingInspectori.value = false
}
})
// ─── Partener AsyncAutoComplete ─────────────────────────────────────────────
async function searchParteneri(q) {
try {
const { data } = await serviceAutoApi.getParteneri(q)
return data
} catch {
toast.add({ severity: 'warn', summary: 'Parteneri', detail: 'Eroare căutare parteneri', life: 3000 })
return []
}
}
// PartnerCreateDialog wiring: open with last typed query as initial denumire,
// auto-select created partener via _referinta_obj (watcher syncs id_part_ref).
const partnerDialogVisible = ref(false)
const partnerDialogQuery = ref('')
function onAddNewPartner(query) {
if (!form.value.id_firma) {
toast.add({
severity: 'warn',
summary: 'Selectează firmă',
detail: 'Alege o firmă înainte de a adăuga un partener.',
life: 4000,
})
return
}
partnerDialogQuery.value = query || ''
partnerDialogVisible.value = true
}
function onPartnerCreated(partener) {
form.value._referinta_obj = partener
}
// Sync id_part_ref when autocomplete selection changes
watch(() => form.value._referinta_obj, (val) => {
form.value.id_part_ref = val?.id_part ?? null
})
// ─── Submit ────────────────────────────────────────────────────────────────
async function submitComanda() {
if (!isFormValid.value) return
if (!isFormValid.value || isSubmitting.value) return
isSubmitting.value = true
try {
@@ -283,6 +524,10 @@ async function submitComanda() {
id_masiniclient: form.value.id_masiniclient,
id_firma: form.value.id_firma,
id_sucursala: idSucursala.value,
id_asigurator: form.value.id_asigurator || null,
id_inspector: form.value.id_inspector || null,
id_part_ref: form.value.id_part_ref || null,
sir_id_operatii: form.value.sir_id_operatii?.length ? form.value.sir_id_operatii : null,
observatii: form.value.observatii.trim() || '',
defectiuni: form.value.defectiuni.trim() || null,
km_int: form.value.km_int ?? 0,
@@ -295,18 +540,11 @@ async function submitComanda() {
severity: 'success',
summary: 'Comandă creată',
detail: `Nr ${data.nrord}`,
life: 3000,
life: 4000,
})
// Reset — preserve firma + tip (user creează mai multe consecutive)
const savedFirma = form.value.id_firma
const savedTip = form.value.tip_id
form.value = emptyForm()
form.value.id_firma = savedFirma
form.value.tip_id = savedTip
await nextTick()
clientDropdownRef.value?.$el?.querySelector('input, [role="combobox"]')?.focus()
// Redirect la browse cu highlight pe comanda nouă (D4)
router.push({ path: '/service-auto/comenzi', query: { highlight: data.id_ordl } })
} catch (err) {
const status = err.response?.status
@@ -315,7 +553,7 @@ async function submitComanda() {
} else if (status === 503) {
toast.add({ severity: 'error', summary: 'Eroare conexiune', detail: 'Serviciul nu este disponibil. Verificați conexiunea Oracle.', life: 5000 })
} else {
toast.add({ severity: 'error', summary: 'Eroare internă', detail: 'A apărut o eroare pe server. Reîncercați sau contactați suportul.', life: 5000 })
toast.add({ severity: 'error', summary: 'Eroare server', detail: 'Comanda nu a fost salvată. Reîncercați sau contactați suportul.', life: 5000 })
}
} finally {
isSubmitting.value = false
@@ -328,4 +566,75 @@ async function submitComanda() {
display: grid;
gap: var(--space-md);
}
.form-card {
max-width: 560px;
margin: var(--space-xl) auto;
}
.form-mobile {
padding: var(--space-md);
}
/* MobileTopBar + MobileBottomNav height offsets */
.mobile-content {
padding-top: 56px;
padding-bottom: 72px;
}
/* Card mașină readonly */
.masina-card {
background: var(--surface-card);
border: 1px solid var(--surface-border);
border-radius: var(--radius-md);
padding: var(--space-md);
}
.masina-card-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm) var(--space-md);
}
@media (max-width: 600px) {
.masina-card-grid {
grid-template-columns: 1fr;
}
}
.masina-field-label {
font-size: var(--text-xs);
font-weight: var(--font-medium);
color: var(--text-color-secondary);
margin-bottom: 2px;
}
.masina-field-value {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-color);
}
.field-label {
display: block;
font-weight: var(--font-medium);
font-size: var(--text-sm);
color: var(--text-color-secondary);
}
.req {
color: var(--red-500);
margin-left: 2px;
}
/* MultiSelect chip tokens — dark mode safe */
:deep(.p-multiselect-token) {
background: var(--surface-hover);
color: var(--text-color);
border: 1px solid var(--surface-border);
}
[data-theme="dark"] :deep(.p-multiselect-token) {
background: var(--surface-100);
}
</style>

View File

@@ -1,10 +1,64 @@
<template>
<div class="page-container">
<div :class="isMobile ? 'mobile-page' : 'page-container'">
<Toast />
<!-- Mobile chrome (CLAUDE.md: toate paginile mobile MUST use MobileTopBar + MobileBottomNav) -->
<MobileTopBar
v-if="isMobile"
title="Comenzi Service"
show-menu
:actions="[
{ icon: 'pi pi-filter', label: 'Filtre', active: hasActiveFilters },
{ icon: 'pi pi-plus', label: 'Comandă nouă' },
]"
@action-click="onMobileAction"
/>
<!-- Mobile filters in BottomSheet, NEVER inline (CLAUDE.md) -->
<BottomSheet v-if="isMobile" v-model="isFilterOpen">
<div class="mobile-filter-content">
<h3 style="font-size: var(--text-base); font-weight: var(--font-semibold); margin: 0 0 var(--space-md) 0;">Filtre</h3>
<div class="field">
<label class="filter-label">Status</label>
<Dropdown
v-model="filters.validat"
:options="statusOptions"
option-label="label"
option-value="value"
placeholder="Toate"
class="w-full"
/>
</div>
<div class="field">
<label class="filter-label">De la</label>
<Calendar
v-model="filters.data_de_la"
date-format="dd.mm.yy"
placeholder="—"
class="w-full"
/>
</div>
<div class="field">
<label class="filter-label">Până la</label>
<Calendar
v-model="filters.data_pana_la"
date-format="dd.mm.yy"
placeholder="—"
class="w-full"
/>
</div>
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-md);">
<Button label="Resetează" severity="secondary" outlined style="flex: 1;" @click="clearFilters(); isFilterOpen = false" />
<Button label="Aplică" style="flex: 1;" @click="resetAndLoad(); isFilterOpen = false" />
</div>
</div>
</BottomSheet>
<!-- Main content -->
<div :class="isMobile ? 'mobile-content' : ''">
<div class="card">
<!-- Header -->
<div class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-sm);">
<!-- Desktop header with Comandă Nouă button -->
<div v-if="!isMobile" class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-sm);">
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
Comenzi Service
</h2>
@@ -13,8 +67,8 @@
</router-link>
</div>
<!-- Filters row -->
<div class="card-body" style="padding-bottom: 0;">
<!-- Desktop filters row -->
<div v-if="!isMobile" class="card-body" style="padding-bottom: 0;">
<div class="filters-row">
<div class="filter-group">
<label class="filter-label">Status</label>
@@ -64,9 +118,11 @@
</div>
</div>
<!-- Table -->
<!-- Table (desktop) / Card list (mobile) -->
<div class="card-body">
<!-- Desktop: PrimeVue DataTable -->
<DataTable
v-if="!isMobile"
:value="comenzi"
:lazy="true"
:paginator="true"
@@ -75,6 +131,7 @@
:loading="loading"
class="p-datatable-sm"
striped-rows
:row-class="(row) => row.id_ordl === highlightId ? 'row-highlight' : ''"
@page="onPage"
>
<template #empty>
@@ -82,11 +139,8 @@
Nicio comandă găsită
</div>
</template>
<Column field="nrord" header="Nr. Ord." style="min-width: 100px;" />
<Column field="datai" header="Data" style="min-width: 100px;" />
<Column header="Status" style="min-width: 110px;">
<template #body="{ data }">
<span :class="['status-badge', statusClass(data)]">
@@ -94,26 +148,81 @@
</span>
</template>
</Column>
<Column header="Tip" style="min-width: 130px;">
<template #body="{ data }">
{{ data.tip_denumire }}
</template>
</Column>
<Column header="Client / Vehicul" style="min-width: 240px;">
<template #body="{ data }">
<span style="color: var(--text-color);">{{ data.vehicul || '—' }}</span>
</template>
</Column>
</DataTable>
<!-- Mobile: card list -->
<div v-else>
<div v-if="loading" style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
Se încarcă...
</div>
<div v-else-if="comenzi.length === 0" style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
Nicio comandă găsită
</div>
<div v-else>
<div
v-for="comanda in comenzi"
:key="comanda.id_ordl"
class="comanda-card-mobile"
:class="{ 'row-highlight-mobile': comanda.id_ordl === highlightId }"
>
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-sm);">
<div>
<div class="comanda-nrord">{{ comanda.nrord || '—' }}</div>
<div class="comanda-vehicul">{{ comanda.vehicul || '—' }}</div>
</div>
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 4px;">
<span :class="['status-badge', statusClass(comanda)]">{{ statusLabel(comanda) }}</span>
<span style="font-size: var(--text-xs); color: var(--text-color-secondary);">{{ comanda.datai || '' }}</span>
</div>
</div>
<div v-if="comanda.tip_denumire" style="margin-top: var(--space-xs); font-size: var(--text-xs); color: var(--text-color-secondary);">
{{ comanda.tip_denumire }}
</div>
</div>
<!-- Mobile pagination -->
<div v-if="total > perPage" style="display: flex; justify-content: center; gap: var(--space-sm); padding: var(--space-md) 0;">
<Button
icon="pi pi-chevron-left"
text
rounded
:disabled="page === 1"
@click="page--; loadComenzi()"
/>
<span style="line-height: 2.5rem; font-size: var(--text-sm); color: var(--text-color-secondary);">
{{ page }} / {{ Math.ceil(total / perPage) }}
</span>
<Button
icon="pi pi-chevron-right"
text
rounded
:disabled="page >= Math.ceil(total / perPage)"
@click="page++; loadComenzi()"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<MobileBottomNav v-if="isMobile" :items="mobileNavItems" />
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useToast } from 'primevue/usetoast'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
@@ -121,14 +230,34 @@ import Dropdown from 'primevue/dropdown'
import Calendar from 'primevue/calendar'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import MobileTopBar from '@shared/components/mobile/MobileTopBar.vue'
import MobileBottomNav from '@shared/components/mobile/MobileBottomNav.vue'
import BottomSheet from '@shared/components/mobile/BottomSheet.vue'
import serviceAutoApi from '../services/api.js'
const toast = useToast()
const route = useRoute()
const router = useRouter()
// ─── Mobile detection ─────────────────────────────────────────────────────────
const isMobile = ref(window.innerWidth <= 900)
const isFilterOpen = ref(false)
function onResize() { isMobile.value = window.innerWidth <= 900 }
onMounted(() => window.addEventListener('resize', onResize))
onUnmounted(() => window.removeEventListener('resize', onResize))
const mobileNavItems = [
{ label: 'Comenzi', icon: 'pi pi-list', to: '/service-auto/comenzi', active: true },
{ label: 'Comandă nouă', icon: 'pi pi-plus', to: '/service-auto/comanda-noua' },
]
// ─── State ────────────────────────────────────────────────────────────────────
const comenzi = ref([])
const total = ref(0)
const highlightId = ref(null) // D4: id_ordl from ?highlight= query param
const loading = ref(false)
const page = ref(1)
const perPage = ref(20)
@@ -139,6 +268,20 @@ const filters = ref({
data_pana_la: null,
})
const hasActiveFilters = computed(() =>
filters.value.validat !== null ||
filters.value.data_de_la !== null ||
filters.value.data_pana_la !== null
)
function onMobileAction(action) {
if (action.label === 'Filtre') {
isFilterOpen.value = true
} else if (action.label === 'Comandă nouă') {
router.push('/service-auto/comanda-noua')
}
}
const statusOptions = [
{ label: 'Toate', value: null },
{ label: 'Deschisă', value: 0 },
@@ -204,10 +347,70 @@ function onPage(event) {
loadComenzi()
}
onMounted(loadComenzi)
onMounted(async () => {
// D4: read highlight param before loading so it's set when rows render
const hl = route.query.highlight
if (hl) highlightId.value = parseInt(hl)
await loadComenzi()
if (highlightId.value) {
await nextTick()
// Clear highlight after 2s flash animation
setTimeout(() => { highlightId.value = null }, 2000)
}
})
</script>
<style scoped>
/* MobileTopBar + MobileBottomNav height offsets */
.mobile-content {
padding-top: 56px;
padding-bottom: 72px;
}
.mobile-filter-content {
padding: var(--space-md);
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.mobile-filter-content .field {
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
/* Mobile comanda cards */
.comanda-card-mobile {
padding: var(--space-md);
border-bottom: 1px solid var(--surface-border);
}
.comanda-card-mobile:last-child {
border-bottom: none;
}
.comanda-nrord {
font-size: var(--text-base);
font-weight: var(--font-semibold);
color: var(--text-color);
}
.comanda-vehicul {
font-size: var(--text-sm);
color: var(--text-color-secondary);
margin-top: 2px;
}
/* reuses existing @keyframes row-flash / row-flash-dark defined below */
.row-highlight-mobile {
animation: row-flash 2s ease-out;
}
[data-theme="dark"] .row-highlight-mobile {
animation: row-flash-dark 2s ease-out;
}
.filters-row {
display: flex;
flex-wrap: wrap;
@@ -272,4 +475,25 @@ onMounted(loadComenzi)
background: var(--surface-100);
color: var(--text-color-secondary);
}
/* D4 — highlight row flash animation după creare comandă nouă */
:deep(.row-highlight) {
animation: row-flash 2s ease-out;
}
@keyframes row-flash {
0% { background-color: var(--green-50); }
60% { background-color: var(--green-50); }
100% { background-color: transparent; }
}
[data-theme="dark"] :deep(.row-highlight) {
animation: row-flash-dark 2s ease-out;
}
@keyframes row-flash-dark {
0% { background-color: var(--green-900); }
60% { background-color: var(--green-900); }
100% { background-color: transparent; }
}
</style>

View File

@@ -0,0 +1,171 @@
<template>
<AutoComplete
:model-value="modelValue"
:suggestions="suggestions"
:option-label="optionLabel"
:data-key="optionKey"
:loading="loading"
:min-length="minChars"
:delay="debounceMs"
:placeholder="placeholder"
:disabled="disabled"
:force-selection="true"
:aria-label="ariaLabel"
class="w-full async-autocomplete"
@complete="onComplete"
@update:model-value="onUpdate"
@clear="onClear"
@keydown="onKeydown"
>
<template #option="slotProps">
<!-- eslint-disable-next-line vue/no-v-html -->
<span class="async-ac-option" v-html="highlight(getLabel(slotProps.option))"></span>
</template>
<template #empty>
<div class="async-ac-empty">
<div class="async-ac-empty-text">Niciun rezultat găsit.</div>
<Button
v-if="emptyActionLabel"
:label="emptyActionLabel"
icon="pi pi-plus"
size="small"
text
class="async-ac-empty-action"
@click="$emit('emptyAction', lastQuery)"
/>
</div>
</template>
</AutoComplete>
</template>
<script setup>
import { ref } from 'vue'
import AutoComplete from 'primevue/autocomplete'
import Button from 'primevue/button'
const props = defineProps({
modelValue: { type: [Object, null], default: null },
searchFn: { type: Function, required: true },
optionLabel: { type: String, default: 'denumire' },
optionKey: { type: String, default: 'id' },
placeholder: { type: String, default: 'Caută...' },
minChars: { type: Number, default: 2 },
debounceMs: { type: Number, default: 300 },
disabled: { type: Boolean, default: false },
emptyActionLabel: { type: String, default: '' },
ariaLabel: { type: String, default: '' },
})
const emit = defineEmits(['update:modelValue', 'search', 'emptyAction'])
const suggestions = ref([])
const loading = ref(false)
const lastQuery = ref('')
function getLabel(item) {
if (item == null) return ''
return String(item[props.optionLabel] ?? '')
}
function escapeHtml(s) {
return s.replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
}[c]))
}
function escapeRegExp(s) {
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
function highlight(label) {
const safe = escapeHtml(label)
const q = lastQuery.value.trim()
if (!q) return safe
const re = new RegExp(`(${escapeRegExp(escapeHtml(q))})`, 'ig')
return safe.replace(re, '<strong>$1</strong>')
}
async function onComplete(event) {
const q = (event?.query ?? '').trim()
lastQuery.value = q
emit('search', q)
if (q.length < props.minChars) {
suggestions.value = []
return
}
loading.value = true
try {
const result = await props.searchFn(q)
suggestions.value = Array.isArray(result) ? result : []
} catch {
suggestions.value = []
} finally {
loading.value = false
}
}
function onUpdate(val) {
// PrimeVue emits object (after select) or string (during typing with force-selection false).
// With force-selection=true, committed value is always an object/null. Pass-through only
// objects or null to the parent v-model.
if (val && typeof val === 'object') {
emit('update:modelValue', val)
} else if (val == null || val === '') {
emit('update:modelValue', null)
}
// During typing (string), do not emit — AutoComplete manages the input text internally.
}
function onClear() {
suggestions.value = []
lastQuery.value = ''
emit('update:modelValue', null)
}
function onKeydown(event) {
if (event.key === 'Escape') {
onClear()
return
}
if (event.key === 'Enter' && suggestions.value.length > 0 && !props.modelValue) {
event.preventDefault()
const first = suggestions.value[0]
emit('update:modelValue', first)
}
}
</script>
<style scoped>
.async-autocomplete {
width: 100%;
}
.async-ac-option {
display: block;
font-size: var(--text-sm);
color: var(--text-color);
padding: var(--space-xs) 0;
}
.async-ac-option :deep(strong) {
font-weight: var(--font-semibold);
color: var(--text-color);
}
.async-ac-empty {
display: flex;
flex-direction: column;
gap: var(--space-sm);
padding: var(--space-sm) var(--space-md);
}
.async-ac-empty-text {
font-size: var(--text-sm);
color: var(--text-color-secondary);
}
.async-ac-empty-action {
align-self: flex-start;
}
</style>