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:
@@ -3,15 +3,16 @@ from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
import oracledb
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
|
||||
from shared.auth.dependencies import get_current_user
|
||||
from shared.auth.models import CurrentUser
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
|
||||
from ..schemas.comanda import (
|
||||
ComandaListResponse, ComandaRequest, ComandaResponse,
|
||||
FirmaItem, TipDevizItem, MasinaClientItem,
|
||||
AsiguratorItem, ComandaListResponse, ComandaRequest, ComandaResponse,
|
||||
FirmaItem, InspectorItem, MasinaClientItem, MasinaDetails,
|
||||
OperatieItem, PartenerItem, PartnerCreateRequest, TipDevizItem,
|
||||
)
|
||||
from ..services.comanda_service import ComandaService
|
||||
from ..services.lookup_service import LookupService
|
||||
@@ -19,69 +20,192 @@ from ..services.lookup_service import LookupService
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _server_id(request: Request) -> Optional[str]:
|
||||
"""Extrage server_id injectat de AuthenticationMiddleware din JWT."""
|
||||
return getattr(request.state, "server_id", None)
|
||||
|
||||
|
||||
def _company_id(
|
||||
current_user: CurrentUser,
|
||||
explicit: Optional[int],
|
||||
) -> int:
|
||||
"""
|
||||
Rezolvă id_firma: query/body param dacă e dat, altfel prima firmă din JWT.
|
||||
Validează că firma e printre cele autorizate în JWT.
|
||||
"""
|
||||
if explicit is not None:
|
||||
cid = explicit
|
||||
else:
|
||||
if not current_user.companies:
|
||||
raise HTTPException(status_code=400, detail="Niciun id_firma disponibil în JWT.")
|
||||
cid = int(current_user.companies[0])
|
||||
|
||||
allowed = {int(c) for c in current_user.companies}
|
||||
if cid not in allowed:
|
||||
raise HTTPException(status_code=403, detail="Firmă neautorizată pentru utilizator.")
|
||||
return cid
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
async def ping(_: CurrentUser = Depends(get_current_user)):
|
||||
"""Health check: verifies Oracle connectivity for mariusm_test server."""
|
||||
async def ping(
|
||||
request: Request,
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Health check: verifies Oracle connectivity pe serverul curent."""
|
||||
t0 = time.perf_counter()
|
||||
server_id = _server_id(request)
|
||||
try:
|
||||
async with oracle_pool.get_connection('mariusm_test') as conn:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute('SELECT 1 FROM DUAL')
|
||||
row = cursor.fetchone()
|
||||
except oracledb.DatabaseError as e:
|
||||
raise HTTPException(status_code=503, detail=f"Oracle error: {e}")
|
||||
elapsed_ms = round((time.perf_counter() - t0) * 1000, 2)
|
||||
return {"result": row[0], "server": "mariusm_test", "latency_ms": elapsed_ms}
|
||||
return {"result": row[0], "server": server_id or "(default)", "latency_ms": elapsed_ms}
|
||||
|
||||
|
||||
@router.get("/firme", response_model=List[FirmaItem])
|
||||
async def get_firme(current_user: CurrentUser = Depends(get_current_user)):
|
||||
async def get_firme(
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Firmele accesibile utilizatorului curent (din JWT companies[])."""
|
||||
return await LookupService.get_firme(current_user.companies)
|
||||
return await LookupService.get_firme(current_user.companies, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/tip-deviz", response_model=List[TipDevizItem])
|
||||
async def get_tip_deviz(_: CurrentUser = Depends(get_current_user)):
|
||||
"""Tipuri de deviz din DEV_TIP_DEVIZ."""
|
||||
return await LookupService.get_tip_deviz()
|
||||
async def get_tip_deviz(
|
||||
request: Request,
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Tipuri de deviz din DEV_TIP_DEVIZ (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_tip_deviz(cid, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/masini", response_model=List[MasinaClientItem])
|
||||
async def get_masini(_: CurrentUser = Depends(get_current_user)):
|
||||
"""Mașini active din AUTO_VMASINICLIENTI (toate firmele pe același server)."""
|
||||
return await LookupService.get_masini()
|
||||
async def get_masini(
|
||||
request: Request,
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Mașini active din AUTO_VMASINICLIENTI (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_masini(cid, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/comenzi", response_model=ComandaListResponse)
|
||||
async def list_comenzi(
|
||||
request: Request,
|
||||
page: int = Query(default=1, ge=1),
|
||||
per_page: int = Query(default=20, ge=1, le=100),
|
||||
validat: Optional[int] = Query(default=None, ge=0, le=1),
|
||||
data_de_la: Optional[date] = Query(default=None),
|
||||
data_pana_la: Optional[date] = Query(default=None),
|
||||
_: CurrentUser = Depends(get_current_user),
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
# NOTE: DEV_ORDL has no id_firma column — firmă filter not available at DB level.
|
||||
# All comenzi in MARIUSM_AUTO schema are visible (companies 110/167/169 share schema).
|
||||
# DEV_ORDL n-are id_firma; toate firmele pe aceeași schemă împart comenzile.
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await ComandaService.get_comenzi(
|
||||
company_id=cid,
|
||||
page=page,
|
||||
per_page=per_page,
|
||||
validat=validat,
|
||||
data_de_la=data_de_la,
|
||||
data_pana_la=data_pana_la,
|
||||
server_id=_server_id(request),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/asiguratori", response_model=List[AsiguratorItem])
|
||||
async def get_asiguratori(
|
||||
request: Request,
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Asigurători din DEV_NOM_ASIGURATORI (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_asiguratori(cid, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/inspectori", response_model=List[InspectorItem])
|
||||
async def get_inspectori(
|
||||
request: Request,
|
||||
id_asigurator: int = Query(..., ge=1),
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Inspectori filtrați pe asigurator (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_inspectori(id_asigurator, cid, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/operatii", response_model=List[OperatieItem])
|
||||
async def get_operatii(
|
||||
request: Request,
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Lista completă operații DEV_NOM_NORME (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_operatii(cid, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/parteneri", response_model=List[PartenerItem])
|
||||
async def search_parteneri(
|
||||
request: Request,
|
||||
q: str = Query(..., min_length=2, max_length=100),
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Typeahead pe NOM_PARTENERI (scoped pe schema firmei)."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.search_parteneri(q, cid, _server_id(request))
|
||||
|
||||
|
||||
@router.post("/parteneri", response_model=PartenerItem, status_code=201)
|
||||
async def create_partener(
|
||||
data: PartnerCreateRequest,
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Creează partener nou în NOM_PARTENERI (scoped pe schema firmei din JWT)."""
|
||||
cid = _company_id(current_user, data.id_firma)
|
||||
data.id_firma = cid
|
||||
return await LookupService.create_partener(data, _server_id(request))
|
||||
|
||||
|
||||
@router.get("/masini/{id_masiniclient}/detalii", response_model=Optional[MasinaDetails])
|
||||
async def get_masina_detalii(
|
||||
id_masiniclient: int,
|
||||
request: Request,
|
||||
id_firma: Optional[int] = Query(default=None, ge=1),
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
"""Detalii complete mașină pentru card readonly după selecție."""
|
||||
cid = _company_id(current_user, id_firma)
|
||||
return await LookupService.get_masina_details(id_masiniclient, cid, _server_id(request))
|
||||
|
||||
|
||||
@router.post("/comenzi", response_model=ComandaResponse)
|
||||
async def creeaza_comanda(
|
||||
data: ComandaRequest,
|
||||
request: Request,
|
||||
current_user: CurrentUser = Depends(get_current_user),
|
||||
):
|
||||
# data.id_firma e obligatoriu în body — validat via _company_id
|
||||
cid = _company_id(current_user, data.id_firma)
|
||||
# asigură consistența (dacă body trimite id_firma diferit de fallback)
|
||||
data.id_firma = cid
|
||||
try:
|
||||
return await ComandaService.creeaza_comanda(
|
||||
data=data,
|
||||
username=current_user.username,
|
||||
user_id=current_user.user_id,
|
||||
server_id=_server_id(request),
|
||||
)
|
||||
except NotImplementedError as e:
|
||||
raise HTTPException(status_code=501, detail=str(e))
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from datetime import date
|
||||
from typing import List, Optional
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class ComandaRequest(BaseModel):
|
||||
@@ -9,6 +9,10 @@ class ComandaRequest(BaseModel):
|
||||
id_masiniclient: int
|
||||
id_firma: int
|
||||
id_sucursala: Optional[int] = None
|
||||
id_asigurator: Optional[int] = None
|
||||
id_inspector: Optional[int] = None
|
||||
id_part_ref: Optional[int] = None
|
||||
sir_id_operatii: Optional[List[int]] = None
|
||||
observatii: str = ""
|
||||
defectiuni: Optional[str] = None
|
||||
km_int: int = 0
|
||||
@@ -23,6 +27,50 @@ class ComandaResponse(BaseModel):
|
||||
mesaj: str
|
||||
|
||||
|
||||
class AsiguratorItem(BaseModel):
|
||||
id_asigurator: int
|
||||
denumire: str
|
||||
|
||||
|
||||
class InspectorItem(BaseModel):
|
||||
id_inspector: int
|
||||
denumire: str
|
||||
id_asigurator: int
|
||||
|
||||
|
||||
class OperatieItem(BaseModel):
|
||||
id_norme: int
|
||||
codop: str
|
||||
denop: str
|
||||
timpn: Optional[float] = None
|
||||
|
||||
|
||||
class PartenerItem(BaseModel):
|
||||
id_part: int
|
||||
denumire: str
|
||||
|
||||
|
||||
class PartnerCreateRequest(BaseModel):
|
||||
"""Payload pentru POST /parteneri — creare partener nou în NOM_PARTENERI."""
|
||||
denumire: str = Field(min_length=2, max_length=100)
|
||||
cui: Optional[str] = Field(default=None, max_length=30)
|
||||
adresa: Optional[str] = Field(default=None, max_length=150)
|
||||
id_firma: int = Field(ge=1)
|
||||
|
||||
|
||||
class MasinaDetails(BaseModel):
|
||||
id_masiniclient: int
|
||||
label: str
|
||||
nr_inmatriculare: Optional[str] = None
|
||||
marca: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
serie_sasiu: Optional[str] = None
|
||||
cilindree: Optional[int] = None
|
||||
putere_cp: Optional[int] = None
|
||||
putere_kw: Optional[int] = None
|
||||
client_nume: Optional[str] = None
|
||||
|
||||
|
||||
class FirmaItem(BaseModel):
|
||||
id_firma: int
|
||||
firma: str
|
||||
|
||||
65
backend/modules/service_auto/services/_context.py
Normal file
65
backend/modules/service_auto/services/_context.py
Normal 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()
|
||||
@@ -1,6 +1,6 @@
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
from typing import List, NoReturn, Optional
|
||||
from typing import List, NoReturn, Optional, Tuple
|
||||
|
||||
import oracledb
|
||||
from fastapi import HTTPException
|
||||
@@ -8,35 +8,102 @@ from shared.database.oracle_pool import oracle_pool
|
||||
from ..schemas.comanda import (
|
||||
ComandaListItem, ComandaListResponse, ComandaRequest, ComandaResponse,
|
||||
)
|
||||
from .lookup_service import LookupService
|
||||
from ._context import get_schema
|
||||
from .. import logger
|
||||
|
||||
_SCHEMA = "MARIUSM_AUTO"
|
||||
|
||||
_MAX_PER_PAGE = 100
|
||||
_MAX_OPERATII_CSV = 4000 # Oracle VARCHAR2 limit; ~600 IDs at 6 chars each
|
||||
|
||||
# Source: DEV_TIP_DEVIZ (verified 2026-04-13):
|
||||
# 1=POST GARANTIE, 2=GARANTIE, 3=REGIE, 4=PREGATIRE, 5=REGIE 2 (no VFP mapping → ""),
|
||||
# 6=PRODUCTIE, 7=CONSTATARE
|
||||
# VFP reference: oproceduri_devize.prg lines 108-120 (pntipcom switch)
|
||||
_PREFIX_MAP = {1: "", 2: "G", 3: "R", 4: "P", 6: "PR", 7: "C"}
|
||||
|
||||
|
||||
def _aloca_numar_devize(
|
||||
cursor, schema: str, user_id: int, id_sucursala: int,
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
Calls {schema}.PACK_SERII_NUMERE.aloca_numar(20, NULL, NULL, user_id, id_sucursala) — 7-param overload.
|
||||
Returns (seq, id_numar):
|
||||
seq — the allocated command number (used in pc_nr)
|
||||
id_numar — SERII_NUMERE.ID_NUMAR row, used by dezaloca_id_numar compensating call
|
||||
"""
|
||||
out_numar = cursor.var(oracledb.NUMBER)
|
||||
out_id_numar = cursor.var(oracledb.NUMBER)
|
||||
cursor.callproc(
|
||||
f"{schema}.PACK_SERII_NUMERE.aloca_numar",
|
||||
[20, None, None, user_id, id_sucursala, out_numar, out_id_numar],
|
||||
)
|
||||
seq = int(out_numar.getvalue() or 0)
|
||||
id_numar = int(out_id_numar.getvalue() or 0)
|
||||
logger.info(
|
||||
"service_auto.seq_allocated",
|
||||
extra={"seq": seq, "id_numar": id_numar, "user_id": user_id, "id_sucursala": id_sucursala},
|
||||
)
|
||||
if seq <= 0:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Nu aveți serie alocată pentru comenzi devize. Contactați administratorul.",
|
||||
)
|
||||
return seq, id_numar
|
||||
|
||||
|
||||
def _dezaloca_numar_devize(
|
||||
cursor, schema: str, seq: int, id_numar: int, reason: str,
|
||||
) -> None:
|
||||
"""Compensating transaction — releases allocated seq number on callproc failure."""
|
||||
try:
|
||||
cursor.callproc(f"{schema}.PACK_SERII_NUMERE.dezaloca_id_numar", [id_numar])
|
||||
logger.info(
|
||||
"service_auto.seq_released",
|
||||
extra={"seq": seq, "id_numar": id_numar, "reason": reason},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"SEQ_LEAK",
|
||||
extra={"seq": seq, "id_numar": id_numar, "reason": reason},
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def _build_pc_nr(tip_id: int, seq: int, nr_inmatriculare: str) -> str:
|
||||
"""Format: <prefix><seq>/<nr_inmatriculare>"""
|
||||
prefix = _PREFIX_MAP.get(tip_id)
|
||||
if prefix is None:
|
||||
logger.warning("service_auto.unknown_tip_id", extra={"tip_id": tip_id})
|
||||
prefix = ""
|
||||
return f"{prefix}{seq}/{nr_inmatriculare}"
|
||||
|
||||
|
||||
def _build_sir_id_operatii(operatii: Optional[List[int]]) -> Optional[str]:
|
||||
"""Serializes list of operation IDs to CSV string for pcSirIdOperatii param."""
|
||||
if not operatii:
|
||||
return None
|
||||
csv = ",".join(str(i) for i in operatii)
|
||||
if len(csv) > _MAX_OPERATII_CSV:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Prea multe operații selectate (max ~{_MAX_OPERATII_CSV // 6}).",
|
||||
)
|
||||
return csv
|
||||
|
||||
|
||||
def _handle_oracle_error(e: Exception) -> NoReturn:
|
||||
"""
|
||||
Map Oracle error codes to FastAPI HTTPExceptions. Always raises.
|
||||
|
||||
Code ranges:
|
||||
20000-20999 → 422 Unprocessable (business rule errors from RAISE_APPLICATION_ERROR)
|
||||
12541/12170/12154/12560 → 503 Service Unavailable (Oracle unreachable / network)
|
||||
1017 → 500 + CRITICAL log (bad credentials — config error)
|
||||
942 → 500 + CRITICAL log (missing object/grant — deployment error)
|
||||
* → 500 + ERROR log (unexpected)
|
||||
"""
|
||||
"""Map Oracle error codes to FastAPI HTTPExceptions. Always raises."""
|
||||
err = e.args[0]
|
||||
code = getattr(err, "code", 0)
|
||||
raw_message = getattr(err, "message", str(e))
|
||||
|
||||
if 20000 <= code <= 20999:
|
||||
# Strip "ORA-2xxxx: " prefix injected by Oracle; expose the business message only
|
||||
# Strip "ORA-2xxxx: " prefix; strip "\nORA-06512: at ..." stack frames.
|
||||
clean = re.sub(r"^ORA-\d+:\s*", "", raw_message).strip()
|
||||
clean = clean.split("\n")[0].strip()
|
||||
raise HTTPException(status_code=422, detail=clean)
|
||||
|
||||
if code == 1438:
|
||||
# ORA-01438: value larger than specified precision — bad input ID (e.g. id_masiniclient out of range)
|
||||
raise HTTPException(status_code=422, detail="Valoare invalidă pentru câmp (ID prea mare)")
|
||||
|
||||
if code in (12541, 12170, 12154, 12560):
|
||||
@@ -69,65 +136,101 @@ class ComandaService:
|
||||
data: ComandaRequest,
|
||||
username: str,
|
||||
user_id: Optional[int] = None,
|
||||
server_id: Optional[str] = None,
|
||||
) -> ComandaResponse:
|
||||
now = datetime.now()
|
||||
# pcNr serves as NOM_LUCRARI.NRORD — must be unique; timestamp gives collision-safe value.
|
||||
pc_nr = f"W{now.strftime('%Y%m%d%H%M%S')}{now.microsecond // 1000:03d}"
|
||||
|
||||
schema = await get_schema(data.id_firma, server_id)
|
||||
|
||||
# Fetch vehicle details early: validates vehicle exists + gets nrinmat for pc_nr
|
||||
masina = await LookupService.get_masina_details(
|
||||
data.id_masiniclient, data.id_firma, server_id
|
||||
)
|
||||
if masina is None:
|
||||
raise HTTPException(status_code=422, detail="Mașina selectată nu există.")
|
||||
nr_inmatriculare = masina.nr_inmatriculare or "?"
|
||||
|
||||
pc_sir_id_operatii = _build_sir_id_operatii(data.sir_id_operatii)
|
||||
id_sucursala = data.id_sucursala or data.id_firma
|
||||
|
||||
logger.info(
|
||||
"service_auto.create_comanda START",
|
||||
extra={
|
||||
"user": username,
|
||||
"schema": schema,
|
||||
"server_id": server_id,
|
||||
"tip": data.tip_id,
|
||||
"client_id": data.id_masiniclient,
|
||||
"id_firma": data.id_firma,
|
||||
"pc_nr": pc_nr,
|
||||
"km": data.km_int,
|
||||
"ore": data.ore_functionare,
|
||||
"id_asigurator": data.id_asigurator,
|
||||
"id_inspector": data.id_inspector,
|
||||
"nr_operatii": len(data.sir_id_operatii) if data.sir_id_operatii else 0,
|
||||
},
|
||||
)
|
||||
|
||||
async with oracle_pool.get_connection("mariusm_test") as connection:
|
||||
try:
|
||||
with connection.cursor() as cursor:
|
||||
# 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)
|
||||
async with oracle_pool.get_connection(server_id) as connection:
|
||||
with connection.cursor() as cursor:
|
||||
# Step 1: allocate sequence number via pack_serii_numere
|
||||
try:
|
||||
seq, id_numar = _aloca_numar_devize(
|
||||
cursor, schema, user_id or 0, id_sucursala
|
||||
)
|
||||
except oracledb.DatabaseError as e:
|
||||
try:
|
||||
connection.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
_handle_oracle_error(e)
|
||||
|
||||
pc_nr = _build_pc_nr(data.tip_id, seq, nr_inmatriculare)
|
||||
|
||||
# Step 2: create comanda; compensating dezaloca on DB failure.
|
||||
# pnIdOrdl is IN OUT — setvalue(0, 0) sets the IN side to 0;
|
||||
# Oracle overwrites it with the new DEV_ORDL.ID_ORDL.
|
||||
out_id_ordl = cursor.var(oracledb.NUMBER)
|
||||
out_id_ordl.setvalue(0, 0)
|
||||
|
||||
try:
|
||||
cursor.callproc(
|
||||
f"{_SCHEMA}.PACK_AUTO.dev_adauga_lucrare",
|
||||
f"{schema}.PACK_AUTO.dev_adauga_lucrare",
|
||||
[
|
||||
_SCHEMA, # v_gcs IN VARCHAR2
|
||||
now.year, # tnan IN NUMBER
|
||||
now.month, # tnluna IN NUMBER
|
||||
user_id or 0, # tnIdUtil IN NUMBER (Oracle ID_UTIL)
|
||||
pc_nr, # pcNr IN VARCHAR2 (NOM_LUCRARI.NRORD)
|
||||
None, # pnIdInsp IN NUMBER
|
||||
None, # pnIdAsig IN NUMBER
|
||||
data.nr_dosar or "", # pcNrDosar IN VARCHAR2
|
||||
data.id_masiniclient, # pnIdMC IN NUMBER
|
||||
data.km_int, # pnKmInt IN NUMBER
|
||||
data.ore_functionare, # pnOreFct IN NUMBER (≥0; NOT NULL in DEV_MASINICLIENTI)
|
||||
data.termen, # pdTermen IN DATE
|
||||
data.tip_id, # pnTipCom IN NUMBER
|
||||
None, # pcSirIdOperatii IN VARCHAR2 — MUST be None, NOT ''
|
||||
data.observatii or None, # pcObservatii IN VARCHAR2 DEFAULT NULL
|
||||
data.defectiuni or None, # pcDefectiuni IN VARCHAR2 DEFAULT NULL
|
||||
0, # pnIdPartRef IN NUMBER (decode(0) → NULL inside SP)
|
||||
out_id_ordl, # pnIdOrdl IN OUT NUMBER
|
||||
schema, # v_gcs IN VARCHAR2
|
||||
now.year, # tnan IN NUMBER
|
||||
now.month, # tnluna IN NUMBER
|
||||
user_id or 0, # tnIdUtil IN NUMBER
|
||||
pc_nr, # pcNr IN VARCHAR2 (NOM_LUCRARI.NRORD)
|
||||
data.id_inspector, # pnIdInsp IN NUMBER
|
||||
data.id_asigurator, # pnIdAsig IN NUMBER
|
||||
data.nr_dosar or "", # pcNrDosar IN VARCHAR2
|
||||
data.id_masiniclient, # pnIdMC IN NUMBER
|
||||
data.km_int, # pnKmInt IN NUMBER
|
||||
data.ore_functionare, # pnOreFct IN NUMBER
|
||||
data.termen, # pdTermen IN DATE
|
||||
data.tip_id, # pnTipCom IN NUMBER
|
||||
pc_sir_id_operatii, # pcSirIdOperatii IN VARCHAR2 (None or CSV)
|
||||
data.observatii or None, # pcObservatii IN VARCHAR2
|
||||
data.defectiuni or None, # pcDefectiuni IN VARCHAR2
|
||||
data.id_part_ref or 0, # pnIdPartRef IN NUMBER (decode(0)→NULL in SP)
|
||||
out_id_ordl, # pnIdOrdl IN OUT NUMBER
|
||||
],
|
||||
)
|
||||
|
||||
connection.commit()
|
||||
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())
|
||||
except oracledb.DatabaseError as e:
|
||||
try:
|
||||
connection.rollback()
|
||||
except Exception:
|
||||
pass # connection may be dead on network errors; ignore
|
||||
_handle_oracle_error(e)
|
||||
id_ordl = int(out_id_ordl.getvalue())
|
||||
|
||||
logger.info(
|
||||
"service_auto.create_comanda OK",
|
||||
@@ -137,20 +240,24 @@ class ComandaService:
|
||||
return ComandaResponse(
|
||||
id_ordl=id_ordl,
|
||||
nrord=pc_nr,
|
||||
mesaj=f"Comanda {pc_nr} creata cu succes.",
|
||||
mesaj=f"Comanda {pc_nr} creată cu succes.",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_comenzi(
|
||||
company_id: int,
|
||||
page: int,
|
||||
per_page: int,
|
||||
validat: Optional[int],
|
||||
data_de_la: Optional[date],
|
||||
data_pana_la: Optional[date],
|
||||
server_id: Optional[str] = None,
|
||||
) -> ComandaListResponse:
|
||||
per_page = min(per_page, _MAX_PER_PAGE)
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
schema = await get_schema(company_id, server_id)
|
||||
|
||||
where_parts = ["d.sters = 0"]
|
||||
filter_params: dict = {}
|
||||
|
||||
@@ -170,11 +277,11 @@ class ComandaService:
|
||||
where_clause = " AND ".join(where_parts)
|
||||
|
||||
base_from = f"""
|
||||
FROM MARIUSM_AUTO.DEV_ORDL d
|
||||
LEFT JOIN MARIUSM_AUTO.NOM_LUCRARI l ON d.id_lucrare = l.id_lucrare
|
||||
LEFT JOIN MARIUSM_AUTO.AUTO_VMASINICLIENTI mc
|
||||
FROM {schema}.DEV_ORDL d
|
||||
LEFT JOIN {schema}.NOM_LUCRARI l ON d.id_lucrare = l.id_lucrare
|
||||
LEFT JOIN {schema}.AUTO_VMASINICLIENTI mc
|
||||
ON d.id_masiniclient = mc.id_masiniclient
|
||||
LEFT JOIN MARIUSM_AUTO.DEV_TIP_DEVIZ t ON d.id_tip = t.id_tip
|
||||
LEFT JOIN {schema}.DEV_TIP_DEVIZ t ON d.id_tip = t.id_tip
|
||||
WHERE {where_clause}
|
||||
"""
|
||||
|
||||
@@ -190,7 +297,7 @@ class ComandaService:
|
||||
"""
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection("mariusm_test") as conn:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(count_query, filter_params)
|
||||
total = cur.fetchone()[0]
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""
|
||||
Lookup data for service_auto forms — tip deviz, masini, firme.
|
||||
Lookup data for service_auto forms — tip deviz, masini, firme, asiguratori, inspectori, operatii, parteneri.
|
||||
|
||||
All three endpoints are read-only and infrequently changing.
|
||||
Multi-tenant safe: `schema` e rezolvat din `id_firma` via `_context.get_schema()`; nu există
|
||||
schemă hardcodată. `server_id` propagat din JWT (`request.state.server_id`).
|
||||
"""
|
||||
import time
|
||||
from typing import List, Optional, Tuple
|
||||
@@ -10,14 +11,22 @@ import oracledb
|
||||
from fastapi import HTTPException
|
||||
from shared.database.oracle_pool import oracle_pool
|
||||
|
||||
from ..schemas.comanda import FirmaItem, MasinaClientItem, TipDevizItem
|
||||
from ..schemas.comanda import (
|
||||
AsiguratorItem, FirmaItem, InspectorItem, MasinaClientItem,
|
||||
MasinaDetails, OperatieItem, PartenerItem, PartnerCreateRequest,
|
||||
TipDevizItem,
|
||||
)
|
||||
from .. import logger
|
||||
from ._context import get_schema
|
||||
|
||||
# In-memory TTL cache: key → (monotonic_timestamp, value)
|
||||
_cache: dict = {}
|
||||
|
||||
_TTL_TIP_DEVIZ = 86400 # 24 h — tip deviz changes only via DB migration
|
||||
_TTL_MASINI = 300 # 5 min — vehicle inventory changes regularly
|
||||
_TTL_TIP_DEVIZ = 86400 # 24 h — tip deviz changes only via DB migration
|
||||
_TTL_MASINI = 300 # 5 min — vehicle inventory changes regularly
|
||||
_TTL_ASIGURATORI = 86400 # 24 h
|
||||
_TTL_INSPECTORI = 86400 # 24 h per asigurator
|
||||
_TTL_OPERATII = 86400 # 24 h — DEV_NOM_NORME changes only via DB
|
||||
|
||||
|
||||
def _cache_get(key: str, ttl: float):
|
||||
@@ -31,13 +40,21 @@ def _cache_set(key: str, value) -> None:
|
||||
_cache[key] = (time.monotonic(), value)
|
||||
|
||||
|
||||
def reset_cache() -> None:
|
||||
"""Test helper."""
|
||||
_cache.clear()
|
||||
|
||||
|
||||
class LookupService:
|
||||
|
||||
@staticmethod
|
||||
async def get_firme(company_ids: List[str]) -> List[FirmaItem]:
|
||||
async def get_firme(
|
||||
company_ids: List[str],
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[FirmaItem]:
|
||||
"""
|
||||
Returns firma names for the company IDs in the user's JWT.
|
||||
Uses 'central' pool (CONTAFIN_ORACLE) to query V_NOM_FIRME.
|
||||
Query pe `CONTAFIN_ORACLE.V_NOM_FIRME` pe serverul utilizatorului.
|
||||
"""
|
||||
if not company_ids:
|
||||
return []
|
||||
@@ -52,7 +69,7 @@ class LookupService:
|
||||
params = {f"id{i}": int(cid) for i, cid in enumerate(company_ids)}
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection("central") as conn:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
@@ -66,23 +83,26 @@ class LookupService:
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
async def get_tip_deviz() -> List[TipDevizItem]:
|
||||
async def get_tip_deviz(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[TipDevizItem]:
|
||||
"""
|
||||
Returns all active tip deviz from MARIUSM_AUTO.DEV_TIP_DEVIZ.
|
||||
Cached in-process for 24 h (changes only via DB migration).
|
||||
ROA_WEB has SELECT grant on this view.
|
||||
Tip deviz din `{schema}.DEV_TIP_DEVIZ`. Cached 24 h per schema.
|
||||
"""
|
||||
cached = _cache_get("tip_deviz", _TTL_TIP_DEVIZ)
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"tip_deviz:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_TIP_DEVIZ)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = """
|
||||
query = f"""
|
||||
SELECT id_tip, denumire, inch_validare
|
||||
FROM MARIUSM_AUTO.DEV_TIP_DEVIZ
|
||||
FROM {schema}.DEV_TIP_DEVIZ
|
||||
ORDER BY id_tip
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection("mariusm_test") as conn:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
@@ -94,29 +114,31 @@ class LookupService:
|
||||
TipDevizItem(id_tip=r[0], denumire=r[1], inch_validare=r[2] or 0)
|
||||
for r in rows
|
||||
]
|
||||
_cache_set("tip_deviz", result)
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_masini() -> List[MasinaClientItem]:
|
||||
async def get_masini(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[MasinaClientItem]:
|
||||
"""
|
||||
Returns active masini from MARIUSM_AUTO.AUTO_VMASINICLIENTI.
|
||||
Cached in-process for 5 min (vehicle inventory changes regularly).
|
||||
ROA_WEB has SELECT grant on this view.
|
||||
Label format: "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)"
|
||||
Mașini active din `{schema}.AUTO_VMASINICLIENTI`. Cached 5 min per schema.
|
||||
"""
|
||||
cached = _cache_get("masini", _TTL_MASINI)
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"masini:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_MASINI)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = """
|
||||
query = f"""
|
||||
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener
|
||||
FROM MARIUSM_AUTO.AUTO_VMASINICLIENTI
|
||||
FROM {schema}.AUTO_VMASINICLIENTI
|
||||
WHERE inactiv = 0
|
||||
ORDER BY partener, nrinmat
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection("mariusm_test") as conn:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
@@ -137,5 +159,302 @@ class LookupService:
|
||||
label = f"{partener or '?'} — {vehicul}, {nrinmat or '?'}{an_str}"
|
||||
result.append(MasinaClientItem(id_masiniclient=int(id_mc), label=label))
|
||||
|
||||
_cache_set("masini", result)
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_masina_details(
|
||||
id_masiniclient: int,
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> Optional[MasinaDetails]:
|
||||
"""
|
||||
Detalii complete vehicul din `{schema}.AUTO_VMASINICLIENTI`. Fără cache (per-record).
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
query = f"""
|
||||
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener,
|
||||
series, cilindree, puterecp, puterekw
|
||||
FROM {schema}.AUTO_VMASINICLIENTI
|
||||
WHERE id_masiniclient = :id_mc
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, {"id_mc": id_masiniclient})
|
||||
row = cur.fetchone()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_masina_details Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea detaliilor mașinii")
|
||||
|
||||
if not row:
|
||||
return None
|
||||
|
||||
id_mc, nrinmat, marca, masina, an, partener, serie_sasiu, cilindree, putere_cp, putere_kw = row
|
||||
parts = [p for p in [marca, masina] if p]
|
||||
vehicul = " ".join(parts) if parts else "?"
|
||||
an_str = f" ({int(an)})" if an else ""
|
||||
label = f"{partener or '?'} — {vehicul}, {nrinmat or '?'}{an_str}"
|
||||
|
||||
return MasinaDetails(
|
||||
id_masiniclient=int(id_mc),
|
||||
label=label,
|
||||
nr_inmatriculare=nrinmat,
|
||||
marca=marca,
|
||||
model=masina,
|
||||
serie_sasiu=serie_sasiu,
|
||||
cilindree=int(cilindree) if cilindree else None,
|
||||
putere_cp=int(putere_cp) if putere_cp else None,
|
||||
putere_kw=int(putere_kw) if putere_kw else None,
|
||||
client_nume=partener,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
async def get_asiguratori(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[AsiguratorItem]:
|
||||
"""
|
||||
Asigurători activi din `{schema}.DEV_NOM_ASIGURATORI`. Cached 24h per schema.
|
||||
Numele din NOM_PARTENERI via FK ID_PART (DEV_NOM_ASIGURATORI nu are coloană denumire).
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"asiguratori:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_ASIGURATORI)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = f"""
|
||||
SELECT a.id_asigurator, NVL(p.denumire, a.asigurator_vechi) AS denumire
|
||||
FROM {schema}.DEV_NOM_ASIGURATORI a
|
||||
LEFT JOIN {schema}.NOM_PARTENERI p ON a.id_part = p.id_part
|
||||
WHERE NVL(a.sters, 0) = 0
|
||||
ORDER BY NVL(p.denumire, a.asigurator_vechi)
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_asiguratori Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea asigurătorilor")
|
||||
|
||||
result = [AsiguratorItem(id_asigurator=int(r[0]), denumire=r[1] or "") for r in rows]
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_inspectori(
|
||||
id_asigurator: int,
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[InspectorItem]:
|
||||
"""
|
||||
Inspectori filtrați per asigurator din `{schema}.DEV_NOM_INSPECTORI`.
|
||||
Cached 24h per (schema, id_asigurator).
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"inspectori:{schema}:{id_asigurator}"
|
||||
cached = _cache_get(cache_key, _TTL_INSPECTORI)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = f"""
|
||||
SELECT id_inspector, inspector AS denumire, id_asigurator
|
||||
FROM {schema}.DEV_NOM_INSPECTORI
|
||||
WHERE id_asigurator = :id_asig
|
||||
AND NVL(sters, 0) = 0
|
||||
ORDER BY inspector
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, {"id_asig": id_asigurator})
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_inspectori Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea inspectorilor")
|
||||
|
||||
result = [
|
||||
InspectorItem(id_inspector=int(r[0]), denumire=r[1] or "", id_asigurator=int(r[2]))
|
||||
for r in rows
|
||||
]
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def get_operatii(
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[OperatieItem]:
|
||||
"""
|
||||
Operații din `{schema}.DEV_NOM_NORME`. Cached 24h per schema.
|
||||
Full list; filter client-side.
|
||||
"""
|
||||
schema = await get_schema(company_id, server_id)
|
||||
cache_key = f"operatii:{schema}"
|
||||
cached = _cache_get(cache_key, _TTL_OPERATII)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
query = f"""
|
||||
SELECT id_norme, codop, denop, timpn
|
||||
FROM {schema}.DEV_NOM_NORME
|
||||
WHERE NVL(sters, 0) = 0
|
||||
AND NVL(inactiv, 0) = 0
|
||||
ORDER BY denop
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query)
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("get_operatii Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la încărcarea operațiilor")
|
||||
|
||||
# Oracle treats '' as NULL, so NVL(col,'') can still yield None in Python.
|
||||
result = [
|
||||
OperatieItem(
|
||||
id_norme=int(r[0]),
|
||||
codop=r[1] or "",
|
||||
denop=r[2] or "",
|
||||
timpn=float(r[3]) if r[3] is not None else None,
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
_cache_set(cache_key, result)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
async def search_parteneri(
|
||||
q: str,
|
||||
company_id: int,
|
||||
server_id: Optional[str] = None,
|
||||
) -> List[PartenerItem]:
|
||||
"""
|
||||
Typeahead pe `{schema}.NOM_PARTENERI`. Min 2 chars, limit 50. No cache.
|
||||
Folosește IX_NOM_PARTENERI_DEN_UPPER; LIKE escape pentru %, _, \\.
|
||||
"""
|
||||
if len(q) < 2:
|
||||
return []
|
||||
|
||||
def _escape_like(s: str) -> str:
|
||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
|
||||
schema = await get_schema(company_id, server_id)
|
||||
query = f"""
|
||||
SELECT id_part, denumire
|
||||
FROM {schema}.NOM_PARTENERI
|
||||
WHERE UPPER(denumire) LIKE UPPER(:q) ESCAPE '\\'
|
||||
AND NVL(sters, 0) = 0
|
||||
ORDER BY denumire
|
||||
FETCH FIRST 50 ROWS ONLY
|
||||
"""
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
cur.execute(query, {"q": _escape_like(q) + "%"})
|
||||
rows = cur.fetchall()
|
||||
except oracledb.DatabaseError:
|
||||
logger.error("search_parteneri Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la căutarea partenerilor")
|
||||
|
||||
return [PartenerItem(id_part=int(r[0]), denumire=r[1] or "") for r in rows]
|
||||
|
||||
@staticmethod
|
||||
async def create_partener(
|
||||
data: PartnerCreateRequest,
|
||||
server_id: Optional[str] = None,
|
||||
) -> PartenerItem:
|
||||
"""
|
||||
Creează partener nou în `{schema}.NOM_PARTENERI`.
|
||||
|
||||
- id_part alocat manual cu `NVL(MAX(id_part),0)+1` (nu există secvență/identity).
|
||||
- Pre-check unicitate CUI (NU există unique constraint pe COD_FISCAL) → 409.
|
||||
- PK collision (race) → ORA-00001 → retry o singură dată cu MAX+1 reactualizat.
|
||||
- ORA-00942/01031 (table missing / no privileges) → log.critical + 500 (lipsă GRANT).
|
||||
"""
|
||||
denumire = data.denumire.strip()
|
||||
cui = (data.cui or "").strip() or None
|
||||
adresa = (data.adresa or "").strip() or None
|
||||
|
||||
schema = await get_schema(data.id_firma, server_id)
|
||||
|
||||
try:
|
||||
async with oracle_pool.get_connection(server_id) as conn:
|
||||
with conn.cursor() as cur:
|
||||
# Pre-check duplicat CUI (doar dacă CUI a fost furnizat).
|
||||
if cui:
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT 1 FROM {schema}.NOM_PARTENERI
|
||||
WHERE cod_fiscal = :cui
|
||||
AND NVL(sters, 0) = 0
|
||||
AND ROWNUM = 1
|
||||
""",
|
||||
{"cui": cui},
|
||||
)
|
||||
if cur.fetchone():
|
||||
raise HTTPException(status_code=409, detail="CUI duplicat")
|
||||
|
||||
insert_sql = f"""
|
||||
INSERT INTO {schema}.NOM_PARTENERI
|
||||
(id_part, denumire, cod_fiscal, adresa,
|
||||
sters, inactiv, id_mod, tip_persoana)
|
||||
VALUES
|
||||
(:id_part, :denumire, :cui, :adresa,
|
||||
0, 0, 0, 1)
|
||||
"""
|
||||
params_base = {
|
||||
"denumire": denumire,
|
||||
"cui": cui,
|
||||
"adresa": adresa,
|
||||
}
|
||||
|
||||
new_id: Optional[int] = None
|
||||
last_err: Optional[oracledb.DatabaseError] = None
|
||||
for _attempt in range(2):
|
||||
cur.execute(
|
||||
f"SELECT NVL(MAX(id_part), 0) + 1 FROM {schema}.NOM_PARTENERI"
|
||||
)
|
||||
candidate = int(cur.fetchone()[0])
|
||||
try:
|
||||
cur.execute(insert_sql, {"id_part": candidate, **params_base})
|
||||
new_id = candidate
|
||||
break
|
||||
except oracledb.DatabaseError as e:
|
||||
err_code = e.args[0].code if e.args else None
|
||||
if err_code == 1: # ORA-00001 PK race
|
||||
last_err = e
|
||||
continue
|
||||
raise
|
||||
|
||||
if new_id is None:
|
||||
# Două încercări consecutive cu PK collision — escaladăm.
|
||||
if last_err is not None:
|
||||
raise last_err
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Nu s-a putut aloca id_part după 2 încercări",
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
except HTTPException:
|
||||
raise
|
||||
except oracledb.DatabaseError as e:
|
||||
err_code = e.args[0].code if e.args else None
|
||||
if err_code in (942, 1031):
|
||||
logger.critical(
|
||||
"create_partener: lipsă GRANT INSERT pe NOM_PARTENERI (schema=%s, ORA-%05d)",
|
||||
schema, err_code, exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail="Lipsă privilegii pe tabela NOM_PARTENERI; contactați administratorul.",
|
||||
)
|
||||
logger.error("create_partener Oracle error", exc_info=True)
|
||||
raise HTTPException(status_code=503, detail="Eroare la crearea partenerului")
|
||||
|
||||
return PartenerItem(id_part=new_id, denumire=denumire)
|
||||
|
||||
38
docs/service-auto/autocomplete-dual-decision.md
Normal file
38
docs/service-auto/autocomplete-dual-decision.md
Normal 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).
|
||||
26
docs/service-auto/migrations/ff_2026_04_13_01_AUTO.sql
Normal file
26
docs/service-auto/migrations/ff_2026_04_13_01_AUTO.sql
Normal 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;
|
||||
8
docs/service-auto/migrations/ff_2026_04_13_02_AUTO.sql
Normal file
8
docs/service-auto/migrations/ff_2026_04_13_02_AUTO.sql
Normal 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;
|
||||
236
src/modules/service-auto/components/PartnerCreateDialog.vue
Normal file
236
src/modules/service-auto/components/PartnerCreateDialog.vue
Normal 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>
|
||||
@@ -5,13 +5,50 @@ const api = axios.create({ baseURL: '/api/service-auto' })
|
||||
api.interceptors.request.use(config => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||
|
||||
// Auto-injectare id_firma din compania selectată (localStorage).
|
||||
// Cheia e `selected_company_<username>_<server_id>` — vezi `shared/stores/companies.js`.
|
||||
// Backend acceptă fallback JWT companies[0] dacă param-ul lipsește.
|
||||
const id_firma = getSelectedCompanyId()
|
||||
if (id_firma != null) {
|
||||
if (config.method === 'get') {
|
||||
config.params = { id_firma, ...(config.params || {}) }
|
||||
} else if (config.method === 'post' && config.data && typeof config.data === 'object' && !Array.isArray(config.data)) {
|
||||
if (config.data.id_firma == null) config.data.id_firma = id_firma
|
||||
}
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
export default {
|
||||
getFirme: () => api.get('/firme'),
|
||||
getTipDeviz: () => api.get('/tip-deviz'),
|
||||
getMasini: () => api.get('/masini'),
|
||||
getComenzi: (params) => api.get('/comenzi', { params }),
|
||||
creeazaComanda: (data) => api.post('/comenzi', data),
|
||||
function getSelectedCompanyId() {
|
||||
try {
|
||||
const user = JSON.parse(localStorage.getItem('user') || 'null')
|
||||
const serverId = localStorage.getItem('last_server_id')
|
||||
const username = user?.username
|
||||
if (!username) return null
|
||||
|
||||
const key = serverId
|
||||
? `selected_company_${username}_${serverId}`
|
||||
: `selected_company_${username}`
|
||||
const raw = localStorage.getItem(key)
|
||||
if (!raw) return null
|
||||
const company = JSON.parse(raw)
|
||||
return company?.id_firma ?? null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getFirme: () => api.get('/firme'),
|
||||
getTipDeviz: () => api.get('/tip-deviz'),
|
||||
getMasini: () => api.get('/masini'),
|
||||
getMasinaDetails:(id) => api.get(`/masini/${id}/detalii`),
|
||||
getAsiguratori: () => api.get('/asiguratori'),
|
||||
getInspectori: (id_asigurator) => api.get('/inspectori', { params: { id_asigurator } }),
|
||||
getOperatii: () => api.get('/operatii'),
|
||||
getParteneri: (q) => api.get('/parteneri', { params: { q } }),
|
||||
createPartener: (data) => api.post('/parteneri', data),
|
||||
getComenzi: (params) => api.get('/comenzi', { params }),
|
||||
creeazaComanda: (data) => api.post('/comenzi', data),
|
||||
}
|
||||
|
||||
@@ -1,178 +1,297 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div :class="isMobile ? 'mobile-page' : 'page-container'">
|
||||
<Toast />
|
||||
|
||||
<div class="card" style="max-width: 560px; margin: var(--space-xl) auto;">
|
||||
<div class="card-header">
|
||||
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
|
||||
Comandă Nouă
|
||||
</h2>
|
||||
</div>
|
||||
<!-- Mobile chrome (CLAUDE.md: toate paginile mobile MUST use MobileTopBar + MobileBottomNav) -->
|
||||
<MobileTopBar
|
||||
v-if="isMobile"
|
||||
title="Comandă nouă"
|
||||
show-back
|
||||
:actions="[{ icon: 'pi pi-check', label: 'Salvează', disabled: !isFormValid || isSubmitting }]"
|
||||
@back-click="$router.back()"
|
||||
@action-click="submitComanda"
|
||||
/>
|
||||
|
||||
<div class="card-body">
|
||||
<form class="form-stack" @submit.prevent="submitComanda">
|
||||
<!-- Content area — card pe desktop, padding simplu pe mobile -->
|
||||
<div :class="isMobile ? 'mobile-content' : ''">
|
||||
<div :class="isMobile ? 'form-mobile' : 'card form-card'">
|
||||
<div v-if="!isMobile" class="card-header">
|
||||
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
|
||||
Comandă Nouă
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<!-- Firmă -->
|
||||
<div class="field">
|
||||
<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>
|
||||
<div :class="isMobile ? '' : 'card-body'">
|
||||
<form class="form-stack" @submit.prevent="submitComanda">
|
||||
|
||||
<!-- Tip comandă -->
|
||||
<div class="field">
|
||||
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
|
||||
Tip comandă *
|
||||
</label>
|
||||
<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);">
|
||||
<!-- 1. Firmă -->
|
||||
<div class="field">
|
||||
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
|
||||
Kilometraj la recepție
|
||||
</label>
|
||||
<InputNumber
|
||||
v-model="form.km_int"
|
||||
:min="0"
|
||||
:max="9999999"
|
||||
:use-grouping="true"
|
||||
suffix=" km"
|
||||
<label class="field-label">Firmă <span class="req">*</span></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"
|
||||
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"
|
||||
class="w-full"
|
||||
/>
|
||||
</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">
|
||||
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
|
||||
Ore funcționare motor
|
||||
</label>
|
||||
<InputNumber
|
||||
v-model="form.ore_functionare"
|
||||
:min="0"
|
||||
:max="999999"
|
||||
:use-grouping="true"
|
||||
suffix=" ore"
|
||||
<label class="field-label">Termen estimat finalizare</label>
|
||||
<Calendar
|
||||
v-model="form.termen"
|
||||
date-format="dd.mm.yy"
|
||||
placeholder="Selectează data..."
|
||||
:disabled="isSubmitting"
|
||||
:min-date="new Date()"
|
||||
show-icon
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Termen estimat -->
|
||||
<div class="field">
|
||||
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
|
||||
Termen estimat finalizare
|
||||
</label>
|
||||
<Calendar
|
||||
v-model="form.termen"
|
||||
date-format="dd.mm.yy"
|
||||
placeholder="Selectează data..."
|
||||
:disabled="isSubmitting"
|
||||
:min-date="new Date()"
|
||||
show-icon
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<!-- 8. MultiSelect Operații cerute -->
|
||||
<div class="field">
|
||||
<label class="field-label">Operații cerute de client</label>
|
||||
<div v-if="loadingOperatii" style="display: flex; flex-direction: column; gap: var(--space-xs);">
|
||||
<Skeleton v-for="i in 3" :key="i" height="32px" />
|
||||
</div>
|
||||
<MultiSelect
|
||||
v-else
|
||||
v-model="form.sir_id_operatii"
|
||||
:options="operatii"
|
||||
option-label="denop"
|
||||
option-value="id_norme"
|
||||
display="chip"
|
||||
filter
|
||||
filter-placeholder="Caută operație..."
|
||||
empty-filter-message="Nicio operație nu corespunde filtrului"
|
||||
placeholder="Selectează operațiile (opțional)"
|
||||
:disabled="isSubmitting"
|
||||
class="w-full"
|
||||
>
|
||||
<template #emptyfilter>
|
||||
<div style="padding: var(--space-sm); color: var(--text-color-secondary); font-size: var(--text-sm);">
|
||||
Încearcă alt termen sau verifică ortografia.
|
||||
</div>
|
||||
</template>
|
||||
</MultiSelect>
|
||||
</div>
|
||||
|
||||
<!-- Număr dosar asigurare -->
|
||||
<div class="field">
|
||||
<label style="font-weight: var(--font-medium); font-size: var(--text-sm); color: var(--text-color-secondary);">
|
||||
Nr. dosar asigurare
|
||||
</label>
|
||||
<InputText
|
||||
v-model="form.nr_dosar"
|
||||
placeholder="Completați dacă e comandă de asigurare..."
|
||||
:disabled="isSubmitting"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<!-- 9. Observații + Defecțiuni -->
|
||||
<div class="field">
|
||||
<label class="field-label">Observații client</label>
|
||||
<Textarea
|
||||
v-model="form.observatii"
|
||||
rows="3"
|
||||
placeholder="Solicitări, observații client..."
|
||||
:disabled="isSubmitting"
|
||||
style="width: 100%; resize: vertical;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div style="display: flex; justify-content: flex-end; padding-top: var(--space-sm);">
|
||||
<Button
|
||||
type="submit"
|
||||
label="Creează Comanda"
|
||||
icon="pi pi-check"
|
||||
:disabled="!isFormValid || isSubmitting"
|
||||
:loading="isSubmitting"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Defecțiuni constatate la recepție</label>
|
||||
<Textarea
|
||||
v-model="form.defectiuni"
|
||||
rows="3"
|
||||
placeholder="Defecțiuni observate la preluarea vehiculului..."
|
||||
:disabled="isSubmitting"
|
||||
style="width: 100%; resize: vertical;"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</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>
|
||||
|
||||
<MobileBottomNav v-if="isMobile" :items="mobileNavItems" />
|
||||
|
||||
<PartnerCreateDialog
|
||||
v-model:visible="partnerDialogVisible"
|
||||
:id-firma="form.id_firma"
|
||||
:initial-denumire="partnerDialogQuery"
|
||||
@created="onPartnerCreated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import Textarea from 'primevue/textarea'
|
||||
@@ -181,43 +300,73 @@ import Toast from 'primevue/toast'
|
||||
import InputNumber from 'primevue/inputnumber'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Calendar from 'primevue/calendar'
|
||||
import MultiSelect from 'primevue/multiselect'
|
||||
import Skeleton from 'primevue/skeleton'
|
||||
import AsyncAutoComplete from '@shared/components/AsyncAutoComplete.vue'
|
||||
import MobileTopBar from '@shared/components/mobile/MobileTopBar.vue'
|
||||
import MobileBottomNav from '@shared/components/mobile/MobileBottomNav.vue'
|
||||
import PartnerCreateDialog from '../components/PartnerCreateDialog.vue'
|
||||
import serviceAutoApi from '../services/api.js'
|
||||
import { useCompanyStore } from '../stores/sharedStores.js'
|
||||
|
||||
const toast = useToast()
|
||||
const router = useRouter()
|
||||
const companyStore = useCompanyStore()
|
||||
const clientDropdownRef = ref(null)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
// ─── Lookup data (from Oracle via API) ────────────────────────────────────────
|
||||
// ─── Mobile detection ──────────────────────────────────────────────────────
|
||||
|
||||
const isMobile = ref(window.innerWidth <= 900)
|
||||
|
||||
function onResize() { isMobile.value = window.innerWidth <= 900 }
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
const mobileNavItems = [
|
||||
{ label: 'Comenzi', icon: 'pi pi-list', to: '/service-auto/comenzi' },
|
||||
{ label: 'Comandă nouă', icon: 'pi pi-plus', to: '/service-auto/comanda-noua', active: true },
|
||||
]
|
||||
|
||||
// ─── Lookup data ───────────────────────────────────────────────────────────
|
||||
|
||||
const firme = ref([])
|
||||
const tipuriComanda = ref([])
|
||||
const masini = ref([])
|
||||
const asiguratori = ref([])
|
||||
const inspectori = ref([])
|
||||
const operatii = ref([])
|
||||
const masinaDetails = ref(null)
|
||||
|
||||
const loadingFirme = ref(false)
|
||||
const loadingTipuri = ref(false)
|
||||
const loadingMasini = ref(false)
|
||||
const loadingAsiguratori = ref(false)
|
||||
const loadingInspectori = ref(false)
|
||||
const loadingOperatii = ref(false)
|
||||
const loadingMasinaDetails = ref(false)
|
||||
|
||||
async function loadLookups() {
|
||||
loadingFirme.value = true
|
||||
loadingTipuri.value = true
|
||||
loadingMasini.value = true
|
||||
loadingAsiguratori.value = true
|
||||
loadingOperatii.value = true
|
||||
|
||||
const [firmeRes, tipuriRes, masiniRes] = await Promise.allSettled([
|
||||
const [firmeRes, tipuriRes, masiniRes, asiguratoriRes, operatiiRes] = await Promise.allSettled([
|
||||
serviceAutoApi.getFirme(),
|
||||
serviceAutoApi.getTipDeviz(),
|
||||
serviceAutoApi.getMasini(),
|
||||
serviceAutoApi.getAsiguratori(),
|
||||
serviceAutoApi.getOperatii(),
|
||||
])
|
||||
|
||||
if (firmeRes.status === 'fulfilled') {
|
||||
firme.value = firmeRes.value.data
|
||||
// Default: selected company from AppHeader store, fallback to first
|
||||
if (firme.value.length > 0 && form.value.id_firma === null) {
|
||||
const selected = companyStore.selectedCompany
|
||||
const defaultFirma = firme.value.find(f => f.id_firma === selected?.id_firma) || firme.value[0]
|
||||
if (defaultFirma) {
|
||||
form.value.id_firma = defaultFirma.id_firma
|
||||
}
|
||||
if (defaultFirma) form.value.id_firma = defaultFirma.id_firma
|
||||
}
|
||||
} else {
|
||||
toast.add({ severity: 'warn', summary: 'Firme', detail: 'Nu s-au putut încărca firmele', life: 4000 })
|
||||
@@ -235,9 +384,23 @@ async function loadLookups() {
|
||||
toast.add({ severity: 'warn', summary: 'Mașini', detail: 'Nu s-au putut încărca mașinile', life: 4000 })
|
||||
}
|
||||
|
||||
if (asiguratoriRes.status === 'fulfilled') {
|
||||
asiguratori.value = asiguratoriRes.value.data
|
||||
} else {
|
||||
toast.add({ severity: 'warn', summary: 'Asigurători', detail: 'Nu s-au putut încărca asigurătorii', life: 4000 })
|
||||
}
|
||||
|
||||
if (operatiiRes.status === 'fulfilled') {
|
||||
operatii.value = operatiiRes.value.data
|
||||
} else {
|
||||
toast.add({ severity: 'warn', summary: 'Operații', detail: 'Nu s-au putut încărca operațiile', life: 4000 })
|
||||
}
|
||||
|
||||
loadingFirme.value = false
|
||||
loadingTipuri.value = false
|
||||
loadingMasini.value = false
|
||||
loadingAsiguratori.value = false
|
||||
loadingOperatii.value = false
|
||||
}
|
||||
|
||||
onMounted(loadLookups)
|
||||
@@ -248,12 +411,17 @@ const emptyForm = () => ({
|
||||
id_firma: null,
|
||||
tip_id: null,
|
||||
id_masiniclient: null,
|
||||
id_asigurator: null,
|
||||
id_inspector: null,
|
||||
id_part_ref: null,
|
||||
sir_id_operatii: [],
|
||||
observatii: '',
|
||||
defectiuni: '',
|
||||
km_int: 0,
|
||||
ore_functionare: 0,
|
||||
nr_dosar: '',
|
||||
termen: null,
|
||||
_referinta_obj: null, // AutoComplete display model — not sent to API
|
||||
})
|
||||
|
||||
const form = ref(emptyForm())
|
||||
@@ -267,10 +435,83 @@ const isFormValid = computed(() =>
|
||||
form.value.id_masiniclient !== null
|
||||
)
|
||||
|
||||
// ─── Mașină selection → card details ──────────────────────────────────────
|
||||
|
||||
watch(() => form.value.id_masiniclient, async (id) => {
|
||||
masinaDetails.value = null
|
||||
if (!id) return
|
||||
loadingMasinaDetails.value = true
|
||||
try {
|
||||
const { data } = await serviceAutoApi.getMasinaDetails(id)
|
||||
masinaDetails.value = data
|
||||
} catch {
|
||||
// Card rămâne null — comanda poate continua oricum
|
||||
} finally {
|
||||
loadingMasinaDetails.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Asigurator cascade → Inspector ────────────────────────────────────────
|
||||
|
||||
watch(() => form.value.id_asigurator, async (id) => {
|
||||
form.value.id_inspector = null
|
||||
inspectori.value = []
|
||||
if (!id) return
|
||||
loadingInspectori.value = true
|
||||
try {
|
||||
const { data } = await serviceAutoApi.getInspectori(id)
|
||||
inspectori.value = data
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Inspectori', detail: 'Nu s-au putut încărca inspectorii', life: 4000 })
|
||||
} finally {
|
||||
loadingInspectori.value = false
|
||||
}
|
||||
})
|
||||
|
||||
// ─── Partener AsyncAutoComplete ─────────────────────────────────────────────
|
||||
|
||||
async function searchParteneri(q) {
|
||||
try {
|
||||
const { data } = await serviceAutoApi.getParteneri(q)
|
||||
return data
|
||||
} catch {
|
||||
toast.add({ severity: 'warn', summary: 'Parteneri', detail: 'Eroare căutare parteneri', life: 3000 })
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// PartnerCreateDialog wiring: open with last typed query as initial denumire,
|
||||
// auto-select created partener via _referinta_obj (watcher syncs id_part_ref).
|
||||
const partnerDialogVisible = ref(false)
|
||||
const partnerDialogQuery = ref('')
|
||||
|
||||
function onAddNewPartner(query) {
|
||||
if (!form.value.id_firma) {
|
||||
toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Selectează firmă',
|
||||
detail: 'Alege o firmă înainte de a adăuga un partener.',
|
||||
life: 4000,
|
||||
})
|
||||
return
|
||||
}
|
||||
partnerDialogQuery.value = query || ''
|
||||
partnerDialogVisible.value = true
|
||||
}
|
||||
|
||||
function onPartnerCreated(partener) {
|
||||
form.value._referinta_obj = partener
|
||||
}
|
||||
|
||||
// Sync id_part_ref when autocomplete selection changes
|
||||
watch(() => form.value._referinta_obj, (val) => {
|
||||
form.value.id_part_ref = val?.id_part ?? null
|
||||
})
|
||||
|
||||
// ─── Submit ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function submitComanda() {
|
||||
if (!isFormValid.value) return
|
||||
if (!isFormValid.value || isSubmitting.value) return
|
||||
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
@@ -283,6 +524,10 @@ async function submitComanda() {
|
||||
id_masiniclient: form.value.id_masiniclient,
|
||||
id_firma: form.value.id_firma,
|
||||
id_sucursala: idSucursala.value,
|
||||
id_asigurator: form.value.id_asigurator || null,
|
||||
id_inspector: form.value.id_inspector || null,
|
||||
id_part_ref: form.value.id_part_ref || null,
|
||||
sir_id_operatii: form.value.sir_id_operatii?.length ? form.value.sir_id_operatii : null,
|
||||
observatii: form.value.observatii.trim() || '',
|
||||
defectiuni: form.value.defectiuni.trim() || null,
|
||||
km_int: form.value.km_int ?? 0,
|
||||
@@ -295,18 +540,11 @@ async function submitComanda() {
|
||||
severity: 'success',
|
||||
summary: 'Comandă creată',
|
||||
detail: `Nr ${data.nrord}`,
|
||||
life: 3000,
|
||||
life: 4000,
|
||||
})
|
||||
|
||||
// Reset — preserve firma + tip (user creează mai multe consecutive)
|
||||
const savedFirma = form.value.id_firma
|
||||
const savedTip = form.value.tip_id
|
||||
form.value = emptyForm()
|
||||
form.value.id_firma = savedFirma
|
||||
form.value.tip_id = savedTip
|
||||
|
||||
await nextTick()
|
||||
clientDropdownRef.value?.$el?.querySelector('input, [role="combobox"]')?.focus()
|
||||
// Redirect la browse cu highlight pe comanda nouă (D4)
|
||||
router.push({ path: '/service-auto/comenzi', query: { highlight: data.id_ordl } })
|
||||
|
||||
} catch (err) {
|
||||
const status = err.response?.status
|
||||
@@ -315,7 +553,7 @@ async function submitComanda() {
|
||||
} else if (status === 503) {
|
||||
toast.add({ severity: 'error', summary: 'Eroare conexiune', detail: 'Serviciul nu este disponibil. Verificați conexiunea Oracle.', life: 5000 })
|
||||
} else {
|
||||
toast.add({ severity: 'error', summary: 'Eroare internă', detail: 'A apărut o eroare pe server. Reîncercați sau contactați suportul.', life: 5000 })
|
||||
toast.add({ severity: 'error', summary: 'Eroare server', detail: 'Comanda nu a fost salvată. Reîncercați sau contactați suportul.', life: 5000 })
|
||||
}
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
@@ -328,4 +566,75 @@ async function submitComanda() {
|
||||
display: grid;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.form-card {
|
||||
max-width: 560px;
|
||||
margin: var(--space-xl) auto;
|
||||
}
|
||||
|
||||
.form-mobile {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
/* MobileTopBar + MobileBottomNav height offsets */
|
||||
.mobile-content {
|
||||
padding-top: 56px;
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
/* Card mașină readonly */
|
||||
.masina-card {
|
||||
background: var(--surface-card);
|
||||
border: 1px solid var(--surface-border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.masina-card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-sm) var(--space-md);
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.masina-card-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.masina-field-label {
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-color-secondary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.masina-field-value {
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.field-label {
|
||||
display: block;
|
||||
font-weight: var(--font-medium);
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
.req {
|
||||
color: var(--red-500);
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
/* MultiSelect chip tokens — dark mode safe */
|
||||
:deep(.p-multiselect-token) {
|
||||
background: var(--surface-hover);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
[data-theme="dark"] :deep(.p-multiselect-token) {
|
||||
background: var(--surface-100);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,119 +1,228 @@
|
||||
<template>
|
||||
<div class="page-container">
|
||||
<div :class="isMobile ? 'mobile-page' : 'page-container'">
|
||||
<Toast />
|
||||
|
||||
<div class="card">
|
||||
<!-- Header -->
|
||||
<div class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-sm);">
|
||||
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
|
||||
Comenzi Service
|
||||
</h2>
|
||||
<router-link to="/service-auto/comanda-noua">
|
||||
<Button label="Comandă Nouă" icon="pi pi-plus" size="small" />
|
||||
</router-link>
|
||||
<!-- Mobile chrome (CLAUDE.md: toate paginile mobile MUST use MobileTopBar + MobileBottomNav) -->
|
||||
<MobileTopBar
|
||||
v-if="isMobile"
|
||||
title="Comenzi Service"
|
||||
show-menu
|
||||
:actions="[
|
||||
{ icon: 'pi pi-filter', label: 'Filtre', active: hasActiveFilters },
|
||||
{ icon: 'pi pi-plus', label: 'Comandă nouă' },
|
||||
]"
|
||||
@action-click="onMobileAction"
|
||||
/>
|
||||
|
||||
<!-- Mobile filters — in BottomSheet, NEVER inline (CLAUDE.md) -->
|
||||
<BottomSheet v-if="isMobile" v-model="isFilterOpen">
|
||||
<div class="mobile-filter-content">
|
||||
<h3 style="font-size: var(--text-base); font-weight: var(--font-semibold); margin: 0 0 var(--space-md) 0;">Filtre</h3>
|
||||
<div class="field">
|
||||
<label class="filter-label">Status</label>
|
||||
<Dropdown
|
||||
v-model="filters.validat"
|
||||
:options="statusOptions"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="Toate"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="filter-label">De la</label>
|
||||
<Calendar
|
||||
v-model="filters.data_de_la"
|
||||
date-format="dd.mm.yy"
|
||||
placeholder="—"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="filter-label">Până la</label>
|
||||
<Calendar
|
||||
v-model="filters.data_pana_la"
|
||||
date-format="dd.mm.yy"
|
||||
placeholder="—"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div style="display: flex; gap: var(--space-sm); margin-top: var(--space-md);">
|
||||
<Button label="Resetează" severity="secondary" outlined style="flex: 1;" @click="clearFilters(); isFilterOpen = false" />
|
||||
<Button label="Aplică" style="flex: 1;" @click="resetAndLoad(); isFilterOpen = false" />
|
||||
</div>
|
||||
</div>
|
||||
</BottomSheet>
|
||||
|
||||
<!-- Filters row -->
|
||||
<div 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"
|
||||
/>
|
||||
<!-- Main content -->
|
||||
<div :class="isMobile ? 'mobile-content' : ''">
|
||||
<div class="card">
|
||||
<!-- Desktop header with Comandă Nouă button -->
|
||||
<div v-if="!isMobile" class="card-header" style="display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: var(--space-sm);">
|
||||
<h2 style="font-size: var(--text-xl); font-weight: var(--font-semibold); margin: 0;">
|
||||
Comenzi Service
|
||||
</h2>
|
||||
<router-link to="/service-auto/comanda-noua">
|
||||
<Button label="Comandă Nouă" icon="pi pi-plus" size="small" />
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<!-- 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 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>
|
||||
<!-- Table (desktop) / Card list (mobile) -->
|
||||
<div class="card-body">
|
||||
<!-- Desktop: PrimeVue DataTable -->
|
||||
<DataTable
|
||||
v-if="!isMobile"
|
||||
:value="comenzi"
|
||||
:lazy="true"
|
||||
:paginator="true"
|
||||
:rows="perPage"
|
||||
: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">
|
||||
<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>
|
||||
<!-- Mobile: card list -->
|
||||
<div v-else>
|
||||
<div v-if="loading" style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
|
||||
Se încarcă...
|
||||
</div>
|
||||
<div v-else-if="comenzi.length === 0" style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
|
||||
Nicio comandă găsită
|
||||
</div>
|
||||
<div v-else>
|
||||
<div
|
||||
v-for="comanda in comenzi"
|
||||
:key="comanda.id_ordl"
|
||||
class="comanda-card-mobile"
|
||||
:class="{ 'row-highlight-mobile': comanda.id_ordl === highlightId }"
|
||||
>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; gap: var(--space-sm);">
|
||||
<div>
|
||||
<div class="comanda-nrord">{{ comanda.nrord || '—' }}</div>
|
||||
<div class="comanda-vehicul">{{ comanda.vehicul || '—' }}</div>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column; align-items: flex-end; gap: 4px;">
|
||||
<span :class="['status-badge', statusClass(comanda)]">{{ statusLabel(comanda) }}</span>
|
||||
<span style="font-size: var(--text-xs); color: var(--text-color-secondary);">{{ comanda.datai || '' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="comanda.tip_denumire" style="margin-top: var(--space-xs); font-size: var(--text-xs); color: var(--text-color-secondary);">
|
||||
{{ comanda.tip_denumire }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-group filter-actions">
|
||||
<Button
|
||||
label="Resetează"
|
||||
icon="pi pi-filter-slash"
|
||||
severity="secondary"
|
||||
outlined
|
||||
size="small"
|
||||
@click="clearFilters"
|
||||
/>
|
||||
<!-- Mobile pagination -->
|
||||
<div v-if="total > perPage" style="display: flex; justify-content: center; gap: var(--space-sm); padding: var(--space-md) 0;">
|
||||
<Button
|
||||
icon="pi pi-chevron-left"
|
||||
text
|
||||
rounded
|
||||
:disabled="page === 1"
|
||||
@click="page--; loadComenzi()"
|
||||
/>
|
||||
<span style="line-height: 2.5rem; font-size: var(--text-sm); color: var(--text-color-secondary);">
|
||||
{{ page }} / {{ Math.ceil(total / perPage) }}
|
||||
</span>
|
||||
<Button
|
||||
icon="pi pi-chevron-right"
|
||||
text
|
||||
rounded
|
||||
:disabled="page >= Math.ceil(total / perPage)"
|
||||
@click="page++; loadComenzi()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<MobileBottomNav v-if="isMobile" :items="mobileNavItems" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useToast } from 'primevue/usetoast'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import Column from 'primevue/column'
|
||||
@@ -121,14 +230,34 @@ import Dropdown from 'primevue/dropdown'
|
||||
import Calendar from 'primevue/calendar'
|
||||
import Button from 'primevue/button'
|
||||
import Toast from 'primevue/toast'
|
||||
import MobileTopBar from '@shared/components/mobile/MobileTopBar.vue'
|
||||
import MobileBottomNav from '@shared/components/mobile/MobileBottomNav.vue'
|
||||
import BottomSheet from '@shared/components/mobile/BottomSheet.vue'
|
||||
import serviceAutoApi from '../services/api.js'
|
||||
|
||||
const toast = useToast()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// ─── Mobile detection ─────────────────────────────────────────────────────────
|
||||
|
||||
const isMobile = ref(window.innerWidth <= 900)
|
||||
const isFilterOpen = ref(false)
|
||||
|
||||
function onResize() { isMobile.value = window.innerWidth <= 900 }
|
||||
onMounted(() => window.addEventListener('resize', onResize))
|
||||
onUnmounted(() => window.removeEventListener('resize', onResize))
|
||||
|
||||
const mobileNavItems = [
|
||||
{ label: 'Comenzi', icon: 'pi pi-list', to: '/service-auto/comenzi', active: true },
|
||||
{ label: 'Comandă nouă', icon: 'pi pi-plus', to: '/service-auto/comanda-noua' },
|
||||
]
|
||||
|
||||
// ─── State ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const comenzi = ref([])
|
||||
const total = ref(0)
|
||||
const highlightId = ref(null) // D4: id_ordl from ?highlight= query param
|
||||
const loading = ref(false)
|
||||
const page = ref(1)
|
||||
const perPage = ref(20)
|
||||
@@ -139,6 +268,20 @@ const filters = ref({
|
||||
data_pana_la: null,
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() =>
|
||||
filters.value.validat !== null ||
|
||||
filters.value.data_de_la !== null ||
|
||||
filters.value.data_pana_la !== null
|
||||
)
|
||||
|
||||
function onMobileAction(action) {
|
||||
if (action.label === 'Filtre') {
|
||||
isFilterOpen.value = true
|
||||
} else if (action.label === 'Comandă nouă') {
|
||||
router.push('/service-auto/comanda-noua')
|
||||
}
|
||||
}
|
||||
|
||||
const statusOptions = [
|
||||
{ label: 'Toate', value: null },
|
||||
{ label: 'Deschisă', value: 0 },
|
||||
@@ -204,10 +347,70 @@ function onPage(event) {
|
||||
loadComenzi()
|
||||
}
|
||||
|
||||
onMounted(loadComenzi)
|
||||
onMounted(async () => {
|
||||
// D4: read highlight param before loading so it's set when rows render
|
||||
const hl = route.query.highlight
|
||||
if (hl) highlightId.value = parseInt(hl)
|
||||
await loadComenzi()
|
||||
if (highlightId.value) {
|
||||
await nextTick()
|
||||
// Clear highlight after 2s flash animation
|
||||
setTimeout(() => { highlightId.value = null }, 2000)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* MobileTopBar + MobileBottomNav height offsets */
|
||||
.mobile-content {
|
||||
padding-top: 56px;
|
||||
padding-bottom: 72px;
|
||||
}
|
||||
|
||||
.mobile-filter-content {
|
||||
padding: var(--space-md);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.mobile-filter-content .field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
/* Mobile comanda cards */
|
||||
.comanda-card-mobile {
|
||||
padding: var(--space-md);
|
||||
border-bottom: 1px solid var(--surface-border);
|
||||
}
|
||||
|
||||
.comanda-card-mobile:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.comanda-nrord {
|
||||
font-size: var(--text-base);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.comanda-vehicul {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-color-secondary);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* reuses existing @keyframes row-flash / row-flash-dark defined below */
|
||||
.row-highlight-mobile {
|
||||
animation: row-flash 2s ease-out;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .row-highlight-mobile {
|
||||
animation: row-flash-dark 2s ease-out;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -272,4 +475,25 @@ onMounted(loadComenzi)
|
||||
background: var(--surface-100);
|
||||
color: var(--text-color-secondary);
|
||||
}
|
||||
|
||||
/* D4 — highlight row flash animation după creare comandă nouă */
|
||||
:deep(.row-highlight) {
|
||||
animation: row-flash 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes row-flash {
|
||||
0% { background-color: var(--green-50); }
|
||||
60% { background-color: var(--green-50); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
|
||||
[data-theme="dark"] :deep(.row-highlight) {
|
||||
animation: row-flash-dark 2s ease-out;
|
||||
}
|
||||
|
||||
@keyframes row-flash-dark {
|
||||
0% { background-color: var(--green-900); }
|
||||
60% { background-color: var(--green-900); }
|
||||
100% { background-color: transparent; }
|
||||
}
|
||||
</style>
|
||||
|
||||
171
src/shared/components/AsyncAutoComplete.vue
Normal file
171
src/shared/components/AsyncAutoComplete.vue
Normal 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) => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
|
||||
}[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>
|
||||
Reference in New Issue
Block a user