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 from typing import List, Optional
import oracledb 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.dependencies import get_current_user
from shared.auth.models import CurrentUser from shared.auth.models import CurrentUser
from shared.database.oracle_pool import oracle_pool from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import ( from ..schemas.comanda import (
ComandaListResponse, ComandaRequest, ComandaResponse, AsiguratorItem, ComandaListResponse, ComandaRequest, ComandaResponse,
FirmaItem, TipDevizItem, MasinaClientItem, FirmaItem, InspectorItem, MasinaClientItem, MasinaDetails,
OperatieItem, PartenerItem, PartnerCreateRequest, TipDevizItem,
) )
from ..services.comanda_service import ComandaService from ..services.comanda_service import ComandaService
from ..services.lookup_service import LookupService from ..services.lookup_service import LookupService
@@ -19,69 +20,192 @@ from ..services.lookup_service import LookupService
router = APIRouter() 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") @router.get("/ping")
async def ping(_: CurrentUser = Depends(get_current_user)): async def ping(
"""Health check: verifies Oracle connectivity for mariusm_test server.""" request: Request,
_: CurrentUser = Depends(get_current_user),
):
"""Health check: verifies Oracle connectivity pe serverul curent."""
t0 = time.perf_counter() t0 = time.perf_counter()
server_id = _server_id(request)
try: 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: with conn.cursor() as cursor:
cursor.execute('SELECT 1 FROM DUAL') cursor.execute('SELECT 1 FROM DUAL')
row = cursor.fetchone() row = cursor.fetchone()
except oracledb.DatabaseError as e: except oracledb.DatabaseError as e:
raise HTTPException(status_code=503, detail=f"Oracle error: {e}") raise HTTPException(status_code=503, detail=f"Oracle error: {e}")
elapsed_ms = round((time.perf_counter() - t0) * 1000, 2) 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]) @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[]).""" """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]) @router.get("/tip-deviz", response_model=List[TipDevizItem])
async def get_tip_deviz(_: CurrentUser = Depends(get_current_user)): async def get_tip_deviz(
"""Tipuri de deviz din DEV_TIP_DEVIZ.""" request: Request,
return await LookupService.get_tip_deviz() 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]) @router.get("/masini", response_model=List[MasinaClientItem])
async def get_masini(_: CurrentUser = Depends(get_current_user)): async def get_masini(
"""Mașini active din AUTO_VMASINICLIENTI (toate firmele pe același server).""" request: Request,
return await LookupService.get_masini() 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) @router.get("/comenzi", response_model=ComandaListResponse)
async def list_comenzi( async def list_comenzi(
request: Request,
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
per_page: int = Query(default=20, ge=1, le=100), per_page: int = Query(default=20, ge=1, le=100),
validat: Optional[int] = Query(default=None, ge=0, le=1), validat: Optional[int] = Query(default=None, ge=0, le=1),
data_de_la: Optional[date] = Query(default=None), data_de_la: Optional[date] = Query(default=None),
data_pana_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. # DEV_ORDL n-are id_firma; toate firmele pe aceeași schemă împart comenzile.
# All comenzi in MARIUSM_AUTO schema are visible (companies 110/167/169 share schema). cid = _company_id(current_user, id_firma)
return await ComandaService.get_comenzi( return await ComandaService.get_comenzi(
company_id=cid,
page=page, page=page,
per_page=per_page, per_page=per_page,
validat=validat, validat=validat,
data_de_la=data_de_la, data_de_la=data_de_la,
data_pana_la=data_pana_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) @router.post("/comenzi", response_model=ComandaResponse)
async def creeaza_comanda( async def creeaza_comanda(
data: ComandaRequest, data: ComandaRequest,
request: Request,
current_user: CurrentUser = Depends(get_current_user), 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: try:
return await ComandaService.creeaza_comanda( return await ComandaService.creeaza_comanda(
data=data, data=data,
username=current_user.username, username=current_user.username,
user_id=current_user.user_id, user_id=current_user.user_id,
server_id=_server_id(request),
) )
except NotImplementedError as e: except NotImplementedError as e:
raise HTTPException(status_code=501, detail=str(e)) raise HTTPException(status_code=501, detail=str(e))

View File

@@ -1,7 +1,7 @@
from datetime import date from datetime import date
from typing import List, Optional from typing import List, Optional
from pydantic import BaseModel from pydantic import BaseModel, Field
class ComandaRequest(BaseModel): class ComandaRequest(BaseModel):
@@ -9,6 +9,10 @@ class ComandaRequest(BaseModel):
id_masiniclient: int id_masiniclient: int
id_firma: int id_firma: int
id_sucursala: Optional[int] = None 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 = "" observatii: str = ""
defectiuni: Optional[str] = None defectiuni: Optional[str] = None
km_int: int = 0 km_int: int = 0
@@ -23,6 +27,50 @@ class ComandaResponse(BaseModel):
mesaj: str 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): class FirmaItem(BaseModel):
id_firma: int id_firma: int
firma: str 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 import re
from datetime import date, datetime from datetime import date, datetime
from typing import List, NoReturn, Optional from typing import List, NoReturn, Optional, Tuple
import oracledb import oracledb
from fastapi import HTTPException from fastapi import HTTPException
@@ -8,35 +8,102 @@ from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import ( from ..schemas.comanda import (
ComandaListItem, ComandaListResponse, ComandaRequest, ComandaResponse, ComandaListItem, ComandaListResponse, ComandaRequest, ComandaResponse,
) )
from .lookup_service import LookupService
from ._context import get_schema
from .. import logger from .. import logger
_SCHEMA = "MARIUSM_AUTO"
_MAX_PER_PAGE = 100 _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: def _handle_oracle_error(e: Exception) -> NoReturn:
""" """Map Oracle error codes to FastAPI HTTPExceptions. Always raises."""
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)
"""
err = e.args[0] err = e.args[0]
code = getattr(err, "code", 0) code = getattr(err, "code", 0)
raw_message = getattr(err, "message", str(e)) raw_message = getattr(err, "message", str(e))
if 20000 <= code <= 20999: 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 = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip()
clean = clean.split("\n")[0].strip()
raise HTTPException(status_code=422, detail=clean) raise HTTPException(status_code=422, detail=clean)
if code == 1438: 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)") raise HTTPException(status_code=422, detail="Valoare invalidă pentru câmp (ID prea mare)")
if code in (12541, 12170, 12154, 12560): if code in (12541, 12170, 12154, 12560):
@@ -69,65 +136,101 @@ class ComandaService:
data: ComandaRequest, data: ComandaRequest,
username: str, username: str,
user_id: Optional[int] = None, user_id: Optional[int] = None,
server_id: Optional[str] = None,
) -> ComandaResponse: ) -> ComandaResponse:
now = datetime.now() 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( logger.info(
"service_auto.create_comanda START", "service_auto.create_comanda START",
extra={ extra={
"user": username, "user": username,
"schema": schema,
"server_id": server_id,
"tip": data.tip_id, "tip": data.tip_id,
"client_id": data.id_masiniclient, "client_id": data.id_masiniclient,
"id_firma": data.id_firma, "id_firma": data.id_firma,
"pc_nr": pc_nr,
"km": data.km_int, "km": data.km_int,
"ore": data.ore_functionare, "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: async with oracle_pool.get_connection(server_id) as connection:
try: with connection.cursor() as cursor:
with connection.cursor() as cursor: # Step 1: allocate sequence number via pack_serii_numere
# pnIdOrdl is IN OUT — setvalue(0, 0) sets the IN side to 0; try:
# Oracle overwrites it with the new DEV_ORDL.ID_ORDL. seq, id_numar = _aloca_numar_devize(
out_id_ordl = cursor.var(oracledb.NUMBER) cursor, schema, user_id or 0, id_sucursala
out_id_ordl.setvalue(0, 0) )
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( 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.year, # tnan IN NUMBER
now.month, # tnluna 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) pc_nr, # pcNr IN VARCHAR2 (NOM_LUCRARI.NRORD)
None, # pnIdInsp IN NUMBER data.id_inspector, # pnIdInsp IN NUMBER
None, # pnIdAsig IN NUMBER data.id_asigurator, # pnIdAsig IN NUMBER
data.nr_dosar or "", # pcNrDosar IN VARCHAR2 data.nr_dosar or "", # pcNrDosar IN VARCHAR2
data.id_masiniclient, # pnIdMC IN NUMBER data.id_masiniclient, # pnIdMC IN NUMBER
data.km_int, # pnKmInt 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.termen, # pdTermen IN DATE
data.tip_id, # pnTipCom IN NUMBER data.tip_id, # pnTipCom IN NUMBER
None, # pcSirIdOperatii IN VARCHAR2 — MUST be None, NOT '' pc_sir_id_operatii, # pcSirIdOperatii IN VARCHAR2 (None or CSV)
data.observatii or None, # pcObservatii IN VARCHAR2 DEFAULT NULL data.observatii or None, # pcObservatii IN VARCHAR2
data.defectiuni or None, # pcDefectiuni IN VARCHAR2 DEFAULT NULL data.defectiuni or None, # pcDefectiuni IN VARCHAR2
0, # pnIdPartRef IN NUMBER (decode(0)NULL inside SP) data.id_part_ref or 0, # pnIdPartRef IN NUMBER (decode(0)NULL in SP)
out_id_ordl, # pnIdOrdl IN OUT NUMBER out_id_ordl, # pnIdOrdl IN OUT NUMBER
], ],
) )
connection.commit() connection.commit()
except oracledb.DatabaseError as e:
try:
connection.rollback()
except Exception:
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()) 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
_handle_oracle_error(e)
logger.info( logger.info(
"service_auto.create_comanda OK", "service_auto.create_comanda OK",
@@ -137,20 +240,24 @@ class ComandaService:
return ComandaResponse( return ComandaResponse(
id_ordl=id_ordl, id_ordl=id_ordl,
nrord=pc_nr, nrord=pc_nr,
mesaj=f"Comanda {pc_nr} creata cu succes.", mesaj=f"Comanda {pc_nr} creată cu succes.",
) )
@staticmethod @staticmethod
async def get_comenzi( async def get_comenzi(
company_id: int,
page: int, page: int,
per_page: int, per_page: int,
validat: Optional[int], validat: Optional[int],
data_de_la: Optional[date], data_de_la: Optional[date],
data_pana_la: Optional[date], data_pana_la: Optional[date],
server_id: Optional[str] = None,
) -> ComandaListResponse: ) -> ComandaListResponse:
per_page = min(per_page, _MAX_PER_PAGE) per_page = min(per_page, _MAX_PER_PAGE)
offset = (page - 1) * per_page offset = (page - 1) * per_page
schema = await get_schema(company_id, server_id)
where_parts = ["d.sters = 0"] where_parts = ["d.sters = 0"]
filter_params: dict = {} filter_params: dict = {}
@@ -170,11 +277,11 @@ class ComandaService:
where_clause = " AND ".join(where_parts) where_clause = " AND ".join(where_parts)
base_from = f""" base_from = f"""
FROM MARIUSM_AUTO.DEV_ORDL d FROM {schema}.DEV_ORDL d
LEFT JOIN MARIUSM_AUTO.NOM_LUCRARI l ON d.id_lucrare = l.id_lucrare LEFT JOIN {schema}.NOM_LUCRARI l ON d.id_lucrare = l.id_lucrare
LEFT JOIN MARIUSM_AUTO.AUTO_VMASINICLIENTI mc LEFT JOIN {schema}.AUTO_VMASINICLIENTI mc
ON d.id_masiniclient = mc.id_masiniclient 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} WHERE {where_clause}
""" """
@@ -190,7 +297,7 @@ class ComandaService:
""" """
try: 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: with conn.cursor() as cur:
cur.execute(count_query, filter_params) cur.execute(count_query, filter_params)
total = cur.fetchone()[0] 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 import time
from typing import List, Optional, Tuple from typing import List, Optional, Tuple
@@ -10,14 +11,22 @@ import oracledb
from fastapi import HTTPException from fastapi import HTTPException
from shared.database.oracle_pool import oracle_pool 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 .. import logger
from ._context import get_schema
# In-memory TTL cache: key → (monotonic_timestamp, value) # In-memory TTL cache: key → (monotonic_timestamp, value)
_cache: dict = {} _cache: dict = {}
_TTL_TIP_DEVIZ = 86400 # 24 h — tip deviz changes only via DB migration _TTL_TIP_DEVIZ = 86400 # 24 h — tip deviz changes only via DB migration
_TTL_MASINI = 300 # 5 min — vehicle inventory changes regularly _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): def _cache_get(key: str, ttl: float):
@@ -31,13 +40,21 @@ def _cache_set(key: str, value) -> None:
_cache[key] = (time.monotonic(), value) _cache[key] = (time.monotonic(), value)
def reset_cache() -> None:
"""Test helper."""
_cache.clear()
class LookupService: class LookupService:
@staticmethod @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. 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: if not company_ids:
return [] return []
@@ -52,7 +69,7 @@ class LookupService:
params = {f"id{i}": int(cid) for i, cid in enumerate(company_ids)} params = {f"id{i}": int(cid) for i, cid in enumerate(company_ids)}
try: 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: with conn.cursor() as cur:
cur.execute(query, params) cur.execute(query, params)
rows = cur.fetchall() rows = cur.fetchall()
@@ -66,23 +83,26 @@ class LookupService:
] ]
@staticmethod @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. Tip deviz din `{schema}.DEV_TIP_DEVIZ`. Cached 24 h per schema.
Cached in-process for 24 h (changes only via DB migration).
ROA_WEB has SELECT grant on this view.
""" """
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: if cached is not None:
return cached return cached
query = """ query = f"""
SELECT id_tip, denumire, inch_validare SELECT id_tip, denumire, inch_validare
FROM MARIUSM_AUTO.DEV_TIP_DEVIZ FROM {schema}.DEV_TIP_DEVIZ
ORDER BY id_tip ORDER BY id_tip
""" """
try: 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: with conn.cursor() as cur:
cur.execute(query) cur.execute(query)
rows = cur.fetchall() rows = cur.fetchall()
@@ -94,29 +114,31 @@ class LookupService:
TipDevizItem(id_tip=r[0], denumire=r[1], inch_validare=r[2] or 0) TipDevizItem(id_tip=r[0], denumire=r[1], inch_validare=r[2] or 0)
for r in rows for r in rows
] ]
_cache_set("tip_deviz", result) _cache_set(cache_key, result)
return result return result
@staticmethod @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. Mașini active din `{schema}.AUTO_VMASINICLIENTI`. Cached 5 min per schema.
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)"
""" """
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: if cached is not None:
return cached return cached
query = """ query = f"""
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener
FROM MARIUSM_AUTO.AUTO_VMASINICLIENTI FROM {schema}.AUTO_VMASINICLIENTI
WHERE inactiv = 0 WHERE inactiv = 0
ORDER BY partener, nrinmat ORDER BY partener, nrinmat
""" """
try: 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: with conn.cursor() as cur:
cur.execute(query) cur.execute(query)
rows = cur.fetchall() rows = cur.fetchall()
@@ -137,5 +159,302 @@ class LookupService:
label = f"{partener or '?'}{vehicul}, {nrinmat or '?'}{an_str}" label = f"{partener or '?'}{vehicul}, {nrinmat or '?'}{an_str}"
result.append(MasinaClientItem(id_masiniclient=int(id_mc), label=label)) result.append(MasinaClientItem(id_masiniclient=int(id_mc), label=label))
_cache_set("masini", result) _cache_set(cache_key, result)
return 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 => { api.interceptors.request.use(config => {
const token = localStorage.getItem('access_token') const token = localStorage.getItem('access_token')
if (token) config.headers.Authorization = `Bearer ${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 return config
}) })
export default { function getSelectedCompanyId() {
getFirme: () => api.get('/firme'), try {
getTipDeviz: () => api.get('/tip-deviz'), const user = JSON.parse(localStorage.getItem('user') || 'null')
getMasini: () => api.get('/masini'), const serverId = localStorage.getItem('last_server_id')
getComenzi: (params) => api.get('/comenzi', { params }), const username = user?.username
creeazaComanda: (data) => api.post('/comenzi', data), 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,178 +1,297 @@
<template> <template>
<div class="page-container"> <div :class="isMobile ? 'mobile-page' : 'page-container'">
<Toast /> <Toast />
<div class="card" style="max-width: 560px; margin: var(--space-xl) auto;"> <!-- Mobile chrome (CLAUDE.md: toate paginile mobile MUST use MobileTopBar + MobileBottomNav) -->
<div class="card-header"> <MobileTopBar
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;"> v-if="isMobile"
Comandă Nouă title="Comandă nouă"
</h2> show-back
</div> :actions="[{ icon: 'pi pi-check', label: 'Salvează', disabled: !isFormValid || isSubmitting }]"
@back-click="$router.back()"
@action-click="submitComanda"
/>
<div class="card-body"> <!-- Content area card pe desktop, padding simplu pe mobile -->
<form class="form-stack" @submit.prevent="submitComanda"> <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>
<!-- Firmă --> <div :class="isMobile ? '' : 'card-body'">
<div class="field"> <form class="form-stack" @submit.prevent="submitComanda">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
Firmă *
</label>
<Dropdown
v-model="form.id_firma"
:options="firme"
option-label="firma"
option-value="id_firma"
placeholder="Selectează firma"
:disabled="isSubmitting || loadingFirme"
:loading="loadingFirme"
class="w-full"
/>
</div>
<!-- Tip comandă --> <!-- 1. Firmă -->
<div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
Tip comandă *
</label>
<Dropdown
v-model="form.tip_id"
:options="tipuriComanda"
option-label="denumire"
option-value="id_tip"
placeholder="Selectează tipul comenzii"
:disabled="isSubmitting || loadingTipuri"
:loading="loadingTipuri"
class="w-full"
/>
</div>
<!-- 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>
<Dropdown
ref="clientDropdownRef"
v-model="form.id_masiniclient"
:options="masini"
option-label="label"
option-value="id_masiniclient"
placeholder="Selectează client / mașină"
:disabled="isSubmitting || loadingMasini"
:loading="loadingMasini"
filter
class="w-full"
/>
</div>
<!-- Observații client -->
<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;"
/>
</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;"
/>
</div>
<!-- Km + Ore funcționare (same row) -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md);">
<div class="field"> <div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);"> <label class="field-label">Firmă <span class="req">*</span></label>
Kilometraj la recepție <Dropdown
</label> v-model="form.id_firma"
<InputNumber :options="firme"
v-model="form.km_int" option-label="firma"
:min="0" option-value="id_firma"
:max="9999999" placeholder="Selectează firma"
:use-grouping="true" :disabled="isSubmitting || loadingFirme"
suffix=" km" :loading="loadingFirme"
class="w-full"
aria-required="true"
/>
</div>
<!-- 2. Tip comandă -->
<div class="field">
<label class="field-label">Tip comandă <span class="req">*</span></label>
<Dropdown
v-model="form.tip_id"
:options="tipuriComanda"
option-label="denumire"
option-value="id_tip"
placeholder="Selectează tipul comenzii"
:disabled="isSubmitting || loadingTipuri"
:loading="loadingTipuri"
class="w-full"
aria-required="true"
/>
</div>
<!-- 3. Client / Mașină -->
<div class="field">
<label class="field-label">Client / Mașină <span class="req">*</span></label>
<Dropdown
ref="clientDropdownRef"
v-model="form.id_masiniclient"
:options="masini"
option-label="label"
option-value="id_masiniclient"
placeholder="Selectează client / mașină"
:disabled="isSubmitting || loadingMasini"
:loading="loadingMasini"
filter
class="w-full"
aria-required="true"
/>
</div>
<!-- 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 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>
<div class="field">
<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" :disabled="isSubmitting"
class="w-full" class="w-full"
/> />
</div> </div>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-md);">
<div class="field">
<label class="field-label">Kilometraj la recepție</label>
<InputNumber
v-model="form.km_int"
:min="0"
:max="9999999"
:use-grouping="true"
suffix=" km"
:disabled="isSubmitting"
class="w-full"
/>
</div>
<div class="field">
<label class="field-label">Ore funcționare motor</label>
<InputNumber
v-model="form.ore_functionare"
:min="0"
:max="999999"
:use-grouping="true"
suffix=" ore"
:disabled="isSubmitting"
class="w-full"
/>
</div>
</div>
<div class="field"> <div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);"> <label class="field-label">Termen estimat finalizare</label>
Ore funcționare motor <Calendar
</label> v-model="form.termen"
<InputNumber date-format="dd.mm.yy"
v-model="form.ore_functionare" placeholder="Selectează data..."
:min="0"
:max="999999"
:use-grouping="true"
suffix=" ore"
:disabled="isSubmitting" :disabled="isSubmitting"
:min-date="new Date()"
show-icon
class="w-full" class="w-full"
/> />
</div> </div>
</div>
<!-- Termen estimat --> <!-- 8. MultiSelect Operații cerute -->
<div class="field"> <div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);"> <label class="field-label">Operații cerute de client</label>
Termen estimat finalizare <div v-if="loadingOperatii" style="display: flex; flex-direction: column; gap: var(--space-xs);">
</label> <Skeleton v-for="i in 3" :key="i" height="32px" />
<Calendar </div>
v-model="form.termen" <MultiSelect
date-format="dd.mm.yy" v-else
placeholder="Selectează data..." v-model="form.sir_id_operatii"
:disabled="isSubmitting" :options="operatii"
:min-date="new Date()" option-label="denop"
show-icon option-value="id_norme"
class="w-full" display="chip"
/> filter
</div> 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>
<!-- Număr dosar asigurare --> <!-- 9. Observații + Defecțiuni -->
<div class="field"> <div class="field">
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);"> <label class="field-label">Observații client</label>
Nr. dosar asigurare <Textarea
</label> v-model="form.observatii"
<InputText rows="3"
v-model="form.nr_dosar" placeholder="Solicitări, observații client..."
placeholder="Completați dacă e comandă de asigurare..." :disabled="isSubmitting"
:disabled="isSubmitting" style="width: 100%; resize: vertical;"
class="w-full" />
/> </div>
</div>
<!-- Submit --> <div class="field">
<div style="display: flex; justify-content: flex-end; padding-top: var(--space-sm);"> <label class="field-label">Defecțiuni constatate la recepție</label>
<Button <Textarea
type="submit" v-model="form.defectiuni"
label="Creează Comanda" rows="3"
icon="pi pi-check" placeholder="Defecțiuni observate la preluarea vehiculului..."
:disabled="!isFormValid || isSubmitting" :disabled="isSubmitting"
:loading="isSubmitting" style="width: 100%; resize: vertical;"
/> />
</div> </div>
</form> <!-- 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"
icon="pi pi-check"
:disabled="!isFormValid || isSubmitting"
:loading="isSubmitting"
/>
</div>
</form>
</div>
</div> </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> </div>
</template> </template>
<script setup> <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 { useToast } from 'primevue/usetoast'
import Dropdown from 'primevue/dropdown' import Dropdown from 'primevue/dropdown'
import Textarea from 'primevue/textarea' import Textarea from 'primevue/textarea'
@@ -181,43 +300,73 @@ import Toast from 'primevue/toast'
import InputNumber from 'primevue/inputnumber' import InputNumber from 'primevue/inputnumber'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Calendar from 'primevue/calendar' 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 serviceAutoApi from '../services/api.js'
import { useCompanyStore } from '../stores/sharedStores.js' import { useCompanyStore } from '../stores/sharedStores.js'
const toast = useToast() const toast = useToast()
const router = useRouter()
const companyStore = useCompanyStore() const companyStore = useCompanyStore()
const clientDropdownRef = ref(null) const clientDropdownRef = ref(null)
const isSubmitting = ref(false) 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 firme = ref([])
const tipuriComanda = ref([]) const tipuriComanda = ref([])
const masini = ref([]) const masini = ref([])
const asiguratori = ref([])
const inspectori = ref([])
const operatii = ref([])
const masinaDetails = ref(null)
const loadingFirme = ref(false) const loadingFirme = ref(false)
const loadingTipuri = ref(false) const loadingTipuri = ref(false)
const loadingMasini = 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() { async function loadLookups() {
loadingFirme.value = true loadingFirme.value = true
loadingTipuri.value = true loadingTipuri.value = true
loadingMasini.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.getFirme(),
serviceAutoApi.getTipDeviz(), serviceAutoApi.getTipDeviz(),
serviceAutoApi.getMasini(), serviceAutoApi.getMasini(),
serviceAutoApi.getAsiguratori(),
serviceAutoApi.getOperatii(),
]) ])
if (firmeRes.status === 'fulfilled') { if (firmeRes.status === 'fulfilled') {
firme.value = firmeRes.value.data firme.value = firmeRes.value.data
// Default: selected company from AppHeader store, fallback to first
if (firme.value.length > 0 && form.value.id_firma === null) { if (firme.value.length > 0 && form.value.id_firma === null) {
const selected = companyStore.selectedCompany const selected = companyStore.selectedCompany
const defaultFirma = firme.value.find(f => f.id_firma === selected?.id_firma) || firme.value[0] const defaultFirma = firme.value.find(f => f.id_firma === selected?.id_firma) || firme.value[0]
if (defaultFirma) { if (defaultFirma) form.value.id_firma = defaultFirma.id_firma
form.value.id_firma = defaultFirma.id_firma
}
} }
} else { } else {
toast.add({ severity: 'warn', summary: 'Firme', detail: 'Nu s-au putut încărca firmele', life: 4000 }) 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 }) 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 loadingFirme.value = false
loadingTipuri.value = false loadingTipuri.value = false
loadingMasini.value = false loadingMasini.value = false
loadingAsiguratori.value = false
loadingOperatii.value = false
} }
onMounted(loadLookups) onMounted(loadLookups)
@@ -248,12 +411,17 @@ const emptyForm = () => ({
id_firma: null, id_firma: null,
tip_id: null, tip_id: null,
id_masiniclient: null, id_masiniclient: null,
id_asigurator: null,
id_inspector: null,
id_part_ref: null,
sir_id_operatii: [],
observatii: '', observatii: '',
defectiuni: '', defectiuni: '',
km_int: 0, km_int: 0,
ore_functionare: 0, ore_functionare: 0,
nr_dosar: '', nr_dosar: '',
termen: null, termen: null,
_referinta_obj: null, // AutoComplete display model — not sent to API
}) })
const form = ref(emptyForm()) const form = ref(emptyForm())
@@ -267,10 +435,83 @@ const isFormValid = computed(() =>
form.value.id_masiniclient !== null 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 ──────────────────────────────────────────────────────────────── // ─── Submit ────────────────────────────────────────────────────────────────
async function submitComanda() { async function submitComanda() {
if (!isFormValid.value) return if (!isFormValid.value || isSubmitting.value) return
isSubmitting.value = true isSubmitting.value = true
try { try {
@@ -283,6 +524,10 @@ async function submitComanda() {
id_masiniclient: form.value.id_masiniclient, id_masiniclient: form.value.id_masiniclient,
id_firma: form.value.id_firma, id_firma: form.value.id_firma,
id_sucursala: idSucursala.value, 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() || '', observatii: form.value.observatii.trim() || '',
defectiuni: form.value.defectiuni.trim() || null, defectiuni: form.value.defectiuni.trim() || null,
km_int: form.value.km_int ?? 0, km_int: form.value.km_int ?? 0,
@@ -295,18 +540,11 @@ async function submitComanda() {
severity: 'success', severity: 'success',
summary: 'Comandă creată', summary: 'Comandă creată',
detail: `Nr ${data.nrord}`, detail: `Nr ${data.nrord}`,
life: 3000, life: 4000,
}) })
// Reset — preserve firma + tip (user creează mai multe consecutive) // Redirect la browse cu highlight pe comanda nouă (D4)
const savedFirma = form.value.id_firma router.push({ path: '/service-auto/comenzi', query: { highlight: data.id_ordl } })
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()
} catch (err) { } catch (err) {
const status = err.response?.status const status = err.response?.status
@@ -315,7 +553,7 @@ async function submitComanda() {
} else if (status === 503) { } else if (status === 503) {
toast.add({ severity: 'error', summary: 'Eroare conexiune', detail: 'Serviciul nu este disponibil. Verificați conexiunea Oracle.', life: 5000 }) toast.add({ severity: 'error', summary: 'Eroare conexiune', detail: 'Serviciul nu este disponibil. Verificați conexiunea Oracle.', life: 5000 })
} else { } 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 { } finally {
isSubmitting.value = false isSubmitting.value = false
@@ -328,4 +566,75 @@ async function submitComanda() {
display: grid; display: grid;
gap: var(--space-md); 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> </style>

View File

@@ -1,119 +1,228 @@
<template> <template>
<div class="page-container"> <div :class="isMobile ? 'mobile-page' : 'page-container'">
<Toast /> <Toast />
<div class="card"> <!-- Mobile chrome (CLAUDE.md: toate paginile mobile MUST use MobileTopBar + MobileBottomNav) -->
<!-- Header --> <MobileTopBar
<div class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-sm);"> v-if="isMobile"
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;"> title="Comenzi Service"
Comenzi Service show-menu
</h2> :actions="[
<router-link to="/service-auto/comanda-noua"> { icon: 'pi pi-filter', label: 'Filtre', active: hasActiveFilters },
<Button label="Comandă Nouă" icon="pi pi-plus" size="small" /> { icon: 'pi pi-plus', label: 'Comandă nouă' },
</router-link> ]"
@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> </div>
</BottomSheet>
<!-- Filters row --> <!-- Main content -->
<div class="card-body" style="padding-bottom: 0;"> <div :class="isMobile ? 'mobile-content' : ''">
<div class="filters-row"> <div class="card">
<div class="filter-group"> <!-- Desktop header with Comandă Nouă button -->
<label class="filter-label">Status</label> <div v-if="!isMobile" class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-sm);">
<Dropdown <h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
v-model="filters.validat" Comenzi Service
:options="statusOptions" </h2>
option-label="label" <router-link to="/service-auto/comanda-noua">
option-value="value" <Button label="Comandă Nouă" icon="pi pi-plus" size="small" />
placeholder="Toate" </router-link>
class="w-full" </div>
@change="resetAndLoad"
/> <!-- 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>
<Dropdown
v-model="filters.validat"
:options="statusOptions"
option-label="label"
option-value="value"
placeholder="Toate"
class="w-full"
@change="resetAndLoad"
/>
</div>
<div class="filter-group">
<label class="filter-label">De la</label>
<Calendar
v-model="filters.data_de_la"
date-format="dd.mm.yy"
placeholder="—"
class="w-full"
@date-select="resetAndLoad"
/>
</div>
<div class="filter-group">
<label class="filter-label">Până la</label>
<Calendar
v-model="filters.data_pana_la"
date-format="dd.mm.yy"
placeholder="—"
class="w-full"
@date-select="resetAndLoad"
/>
</div>
<div class="filter-group filter-actions">
<Button
label="Resetează"
icon="pi pi-filter-slash"
severity="secondary"
outlined
size="small"
@click="clearFilters"
/>
</div>
</div> </div>
</div>
<div class="filter-group"> <!-- Table (desktop) / Card list (mobile) -->
<label class="filter-label">De la</label> <div class="card-body">
<Calendar <!-- Desktop: PrimeVue DataTable -->
v-model="filters.data_de_la" <DataTable
date-format="dd.mm.yy" v-if="!isMobile"
placeholder="—" :value="comenzi"
class="w-full" :lazy="true"
@date-select="resetAndLoad" :paginator="true"
/> :rows="perPage"
</div> :total-records="total"
:loading="loading"
class="p-datatable-sm"
striped-rows
:row-class="(row) => row.id_ordl === highlightId ? 'row-highlight' : ''"
@page="onPage"
>
<template #empty>
<div style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
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)]">
{{ statusLabel(data) }}
</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>
<div class="filter-group"> <!-- Mobile: card list -->
<label class="filter-label">Până la</label> <div v-else>
<Calendar <div v-if="loading" style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
v-model="filters.data_pana_la" Se încarcă...
date-format="dd.mm.yy" </div>
placeholder="—" <div v-else-if="comenzi.length === 0" style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
class="w-full" Nicio comandă găsită
@date-select="resetAndLoad" </div>
/> <div v-else>
</div> <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>
<div class="filter-group filter-actions"> <!-- Mobile pagination -->
<Button <div v-if="total > perPage" style="display: flex; justify-content: center; gap: var(--space-sm); padding: var(--space-md) 0;">
label="Resetează" <Button
icon="pi pi-filter-slash" icon="pi pi-chevron-left"
severity="secondary" text
outlined rounded
size="small" :disabled="page === 1"
@click="clearFilters" @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>
</div> </div>
<!-- Table -->
<div class="card-body">
<DataTable
:value="comenzi"
:lazy="true"
:paginator="true"
:rows="perPage"
:total-records="total"
:loading="loading"
class="p-datatable-sm"
striped-rows
@page="onPage"
>
<template #empty>
<div style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
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)]">
{{ statusLabel(data) }}
</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>
</div>
</div> </div>
<MobileBottomNav v-if="isMobile" :items="mobileNavItems" />
</div> </div>
</template> </template>
<script setup> <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 { useToast } from 'primevue/usetoast'
import DataTable from 'primevue/datatable' import DataTable from 'primevue/datatable'
import Column from 'primevue/column' import Column from 'primevue/column'
@@ -121,14 +230,34 @@ import Dropdown from 'primevue/dropdown'
import Calendar from 'primevue/calendar' import Calendar from 'primevue/calendar'
import Button from 'primevue/button' import Button from 'primevue/button'
import Toast from 'primevue/toast' 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' import serviceAutoApi from '../services/api.js'
const toast = useToast() 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 ──────────────────────────────────────────────────────────────────── // ─── State ────────────────────────────────────────────────────────────────────
const comenzi = ref([]) const comenzi = ref([])
const total = ref(0) const total = ref(0)
const highlightId = ref(null) // D4: id_ordl from ?highlight= query param
const loading = ref(false) const loading = ref(false)
const page = ref(1) const page = ref(1)
const perPage = ref(20) const perPage = ref(20)
@@ -139,6 +268,20 @@ const filters = ref({
data_pana_la: null, 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 = [ const statusOptions = [
{ label: 'Toate', value: null }, { label: 'Toate', value: null },
{ label: 'Deschisă', value: 0 }, { label: 'Deschisă', value: 0 },
@@ -204,10 +347,70 @@ function onPage(event) {
loadComenzi() 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> </script>
<style scoped> <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 { .filters-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -272,4 +475,25 @@ onMounted(loadComenzi)
background: var(--surface-100); background: var(--surface-100);
color: var(--text-color-secondary); 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> </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>