feat(service-auto): phase 2 — comenzi browse, id_sucursala, cache, migrare SQL

Backend:
- GET /api/service-auto/comenzi cu paginare server-side, filtre dată/status
- ComandaRequest.id_sucursala (Optional) + FirmaItem.id_mama
- get_firme() expune id_mama din V_NOM_FIRME
- callproc SP_CREEAZA_COMANDA_PROTOTIP cu 7 argumente (+ p_id_sucursala)
- Cache TTL in-process: tip_deviz 24h, masini 5min

Frontend:
- ComenziBrowseView.vue — DataTable lazy + filtre + status badges
- ComandaNoua.vue — company store integration, idSucursala computed
- service-auto/stores/sharedStores.js (createCompaniesStore factory)
- HamburgerMenu: secțiune Service Auto (Comenzi + Comandă Nouă)
- router: /service-auto/comenzi

SQL:
- migrations/ff_2026_04_12_01_AUTO.sql — idempotent (COLUMNEXIST guard + CREATE OR REPLACE SP)
- onboarding_roa_web.sql — versioned, parametrizat cu :SCHEMA_NAME
- .claude/rules/oracle-migrations.md — convenție ff_YYYY_MM_DD_NN_MODULE.sql

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Claude Agent
2026-04-12 22:25:32 +00:00
parent 574aca31e4
commit 0a880baef9
15 changed files with 694 additions and 12 deletions

View File

@@ -0,0 +1,71 @@
---
paths: "**/*.sql,docs/service-auto/migrations/**"
---
# Oracle SQL Migration Script Rules
You are an Oracle SQL migration script writer. Transform raw DDL/DML into idempotent migration scripts following these rules:
## STRUCTURE
- Header comment: `-- brief description` (e.g. `-- adaugare coloana nom_firme.caen_revizie`)
- Body (idempotency rules below)
- Footer: `exec pack_migrare.UpdateVersiune('<filename_without_.sql>'); commit;`
## IDEMPOTENCY RULES
1. **ALTER TABLE ADD COLUMN** → wrap in:
```sql
BEGIN
IF PACK_MIGRARE.COLUMNEXIST('TABLE','COL')=0 THEN
EXECUTE IMMEDIATE '...';
END IF;
END;
/
```
2. **CREATE OR REPLACE VIEW/PROCEDURE/FUNCTION** → keep as-is (already idempotent)
3. **INSERT** → replace with:
```sql
MERGE INTO table USING DUAL ON (key condition)
WHEN NOT MATCHED THEN INSERT (cols) VALUES (vals);
```
4. **UPDATE** → keep as-is
5. **CREATE TABLE** → wrap in:
```sql
BEGIN
IF PACK_MIGRARE.OBJECTEXIST('TABLE','TABLE')=0 THEN
EXECUTE IMMEDIATE '...';
END IF;
END;
/
```
6. **DROP** → wrap in:
```sql
BEGIN
IF PACK_MIGRARE.OBJECTEXIST('OBJ')=1 THEN
EXECUTE IMMEDIATE 'DROP...';
END IF;
END;
/
```
7. **COMMENT ON** → keep as plain DDL (not inside EXECUTE IMMEDIATE)
8. In MERGE/INSERT: omit NULL-valued columns and CLOB columns entirely
## FILENAME CONVENTION
```
ff_YYYY_MM_DD_NN_<MODULE>.sql
```
- `YYYY_MM_DD` — data migrării
- `NN` — secvență 2 cifre (01, 02...)
- `<MODULE>` — modulul căruia îi aparține migrarea (ex: AUTO, FACTURARE, CONTAB)
## LANGUAGE
Comments: write in Romanian.
Output: only the SQL script, no explanation.

1
.gitignore vendored
View File

@@ -543,3 +543,4 @@ scripts/ralph/usage.jsonl
# Service-auto reference material (local only, not in repo)
docs/service-auto/*.pdf
docs/service-auto/HANDOFF.md

View File

@@ -1,15 +1,16 @@
import time
from typing import List
from datetime import date
from typing import List, Optional
import oracledb
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, Query
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 (
ComandaRequest, ComandaResponse,
ComandaListResponse, ComandaRequest, ComandaResponse,
FirmaItem, TipDevizItem, MasinaClientItem,
)
from ..services.comanda_service import ComandaService
@@ -51,6 +52,26 @@ async def get_masini(_: CurrentUser = Depends(get_current_user)):
return await LookupService.get_masini()
@router.get("/comenzi", response_model=ComandaListResponse)
async def list_comenzi(
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),
):
# 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).
return await ComandaService.get_comenzi(
page=page,
per_page=per_page,
validat=validat,
data_de_la=data_de_la,
data_pana_la=data_pana_la,
)
@router.post("/comenzi", response_model=ComandaResponse)
async def creeaza_comanda(
data: ComandaRequest,

View File

@@ -1,3 +1,5 @@
from typing import List, Optional
from pydantic import BaseModel
@@ -6,6 +8,7 @@ class ComandaRequest(BaseModel):
id_masiniclient: int
solicitari: str
id_firma: int
id_sucursala: Optional[int] = None
class ComandaResponse(BaseModel):
@@ -18,6 +21,7 @@ class FirmaItem(BaseModel):
id_firma: int
firma: str
schema_name: str
id_mama: Optional[int] = None
class TipDevizItem(BaseModel):
@@ -29,3 +33,22 @@ class TipDevizItem(BaseModel):
class MasinaClientItem(BaseModel):
id_masiniclient: int
label: str # "PARTENER — MARCA MASINA, NRINMAT (ANFABRICATIE)"
class ComandaListItem(BaseModel):
id_ordl: int
nrord: str
datai: Optional[str] # ISO date "YYYY-MM-DD"
validat: int # 0=deschisă, 1=validată
inchis_fortat: int # 1=arhivată fără validare
id_tip: int
tip_denumire: str
vehicul: str # "PARTENER — MARCA MASINA, NRINMAT (AN)"
id_masiniclient: Optional[int]
class ComandaListResponse(BaseModel):
comenzi: List[ComandaListItem]
total: int
page: int
per_page: int

View File

@@ -1,12 +1,17 @@
import re
from typing import NoReturn
from datetime import date
from typing import List, NoReturn, Optional
import oracledb
from fastapi import HTTPException
from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import ComandaRequest, ComandaResponse
from ..schemas.comanda import (
ComandaListItem, ComandaListResponse, ComandaRequest, ComandaResponse,
)
from .. import logger
_MAX_PER_PAGE = 100
def _handle_oracle_error(e: Exception) -> NoReturn:
"""
@@ -81,6 +86,7 @@ class ComandaService:
data.id_masiniclient, # p_id_masiniclient IN NUMBER
data.solicitari, # p_solicitari IN VARCHAR2
data.id_firma, # p_id_firma IN NUMBER
data.id_sucursala, # p_id_sucursala IN NUMBER (None for parent firm)
out_id_ordl, # p_id_ordl OUT NUMBER
out_nrord, # p_nrord OUT VARCHAR2
],
@@ -107,3 +113,108 @@ class ComandaService:
nrord=nrord,
mesaj=f"Comanda {nrord} creata cu succes.",
)
@staticmethod
async def get_comenzi(
page: int,
per_page: int,
validat: Optional[int],
data_de_la: Optional[date],
data_pana_la: Optional[date],
) -> ComandaListResponse:
per_page = min(per_page, _MAX_PER_PAGE)
offset = (page - 1) * per_page
where_parts = ["d.sters = 0"]
filter_params: dict = {}
if validat is not None:
where_parts.append("d.validat = :validat")
filter_params["validat"] = validat
if data_de_la is not None:
where_parts.append("d.datai >= :data_de_la")
filter_params["data_de_la"] = data_de_la
if data_pana_la is not None:
# +1 day range avoids TRUNC (keeps index use on datai)
where_parts.append("d.datai < :data_pana_la + 1")
filter_params["data_pana_la"] = data_pana_la
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
ON d.id_masiniclient = mc.id_masiniclient
LEFT JOIN MARIUSM_AUTO.DEV_TIP_DEVIZ t ON d.id_tip = t.id_tip
WHERE {where_clause}
"""
count_query = f"SELECT COUNT(*) {base_from}"
data_query = f"""
SELECT d.id_ordl, l.nrord, d.datai, d.validat, d.inchis_fortat,
d.id_tip, t.denumire,
d.id_masiniclient, mc.nrinmat, mc.marca, mc.masina,
mc.anfabricatie, mc.partener
{base_from}
ORDER BY d.datai DESC, d.id_ordl DESC
OFFSET :offset ROWS FETCH NEXT :per_page ROWS ONLY
"""
try:
async with oracle_pool.get_connection("mariusm_test") as conn:
with conn.cursor() as cur:
cur.execute(count_query, filter_params)
total = cur.fetchone()[0]
cur.execute(
data_query,
{**filter_params, "offset": offset, "per_page": per_page},
)
rows = cur.fetchall()
except oracledb.DatabaseError as e:
_handle_oracle_error(e)
comenzi: List[ComandaListItem] = []
for r in rows:
(id_ordl, nrord, datai, validat_val, inchis_fortat,
id_tip, tip_denumire,
id_mc, nrinmat, marca, masina, an, partener) = r
if id_mc:
parts = []
if marca:
parts.append(marca)
if masina:
parts.append(masina)
vehicul_str = " ".join(parts) if parts else "?"
an_str = f" ({int(an)})" if an else ""
vehicul = f"{partener or '?'}{vehicul_str}, {nrinmat or '?'}{an_str}"
else:
vehicul = ""
comenzi.append(ComandaListItem(
id_ordl=int(id_ordl),
nrord=nrord or "",
datai=datai.strftime("%Y-%m-%d") if datai else None,
validat=int(validat_val),
inchis_fortat=int(inchis_fortat or 0),
id_tip=int(id_tip),
tip_denumire=tip_denumire or "",
vehicul=vehicul,
id_masiniclient=int(id_mc) if id_mc else None,
))
logger.debug(
"service_auto.get_comenzi page=%d per_page=%d total=%d",
page, per_page, total,
)
return ComandaListResponse(
comenzi=comenzi,
total=total,
page=page,
per_page=per_page,
)

View File

@@ -3,7 +3,8 @@ Lookup data for service_auto forms — tip deviz, masini, firme.
All three endpoints are read-only and infrequently changing.
"""
from typing import List
import time
from typing import List, Optional, Tuple
import oracledb
from fastapi import HTTPException
@@ -12,6 +13,23 @@ from shared.database.oracle_pool import oracle_pool
from ..schemas.comanda import FirmaItem, MasinaClientItem, TipDevizItem
from .. import logger
# 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
def _cache_get(key: str, ttl: float):
entry: Optional[Tuple] = _cache.get(key)
if entry and (time.monotonic() - entry[0]) < ttl:
return entry[1]
return None
def _cache_set(key: str, value) -> None:
_cache[key] = (time.monotonic(), value)
class LookupService:
@@ -26,7 +44,7 @@ class LookupService:
placeholders = ", ".join(f":id{i}" for i in range(len(company_ids)))
query = f"""
SELECT id_firma, firma, schema
SELECT id_firma, firma, schema, id_mama
FROM CONTAFIN_ORACLE.V_NOM_FIRME
WHERE id_firma IN ({placeholders})
ORDER BY id_firma
@@ -43,7 +61,7 @@ class LookupService:
raise HTTPException(status_code=503, detail="Eroare la încărcarea firmelor")
return [
FirmaItem(id_firma=r[0], firma=r[1], schema_name=r[2] or "")
FirmaItem(id_firma=r[0], firma=r[1], schema_name=r[2] or "", id_mama=r[3])
for r in rows
]
@@ -51,8 +69,13 @@ class LookupService:
async def get_tip_deviz() -> 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.
"""
cached = _cache_get("tip_deviz", _TTL_TIP_DEVIZ)
if cached is not None:
return cached
query = """
SELECT id_tip, denumire, inch_validare
FROM MARIUSM_AUTO.DEV_TIP_DEVIZ
@@ -67,18 +90,25 @@ class LookupService:
logger.error("get_tip_deviz Oracle error", exc_info=True)
raise HTTPException(status_code=503, detail="Eroare la încărcarea tipurilor de deviz")
return [
result = [
TipDevizItem(id_tip=r[0], denumire=r[1], inch_validare=r[2] or 0)
for r in rows
]
_cache_set("tip_deviz", result)
return result
@staticmethod
async def get_masini() -> 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)"
"""
cached = _cache_get("masini", _TTL_MASINI)
if cached is not None:
return cached
query = """
SELECT id_masiniclient, nrinmat, marca, masina, anfabricatie, partener
FROM MARIUSM_AUTO.AUTO_VMASINICLIENTI
@@ -107,4 +137,5 @@ 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)
return result

View File

@@ -117,7 +117,11 @@ GRANT EXECUTE ON FIRMA_NOUA.SP_CREEAZA_COMANDA_PROTOTIP TO ROA_WEB;
GRANT SELECT ON FIRMA_NOUA.AUTO_VMASINICLIENTI TO ROA_WEB;
GRANT SELECT ON FIRMA_NOUA.DEV_TIP_DEVIZ TO ROA_WEB;
GRANT SELECT ON FIRMA_NOUA.CALENDAR TO ROA_WEB;
-- CALENDAR folosit de shared/routes/calendar.py (period selector AppHeader)
-- CALENDAR: period selector AppHeader (shared/routes/calendar.py)
GRANT SELECT ON FIRMA_NOUA.DEV_ORDL TO ROA_WEB;
-- DEV_ORDL: GET /api/service-auto/comenzi (list comenzi)
GRANT SELECT ON FIRMA_NOUA.NOM_LUCRARI TO ROA_WEB;
-- NOM_LUCRARI: JOIN cu DEV_ORDL pentru nrord (get_comenzi)
-- adaugă orice alte SP/view-uri noi apărute de la ultimul onboarding
```
@@ -197,6 +201,7 @@ ROLLBACK;
| SP nou în toate schemele | `migration_YYYYMMDD_sp_noua_grants.sql` (loop V_NOM_FIRME) | 1 script per migrare |
| View/tabelă nouă expusă | același pattern ca SP | 1 script per migrare |
| Expunere `CALENDAR` pentru period selector | `GRANT SELECT {SCHEMA}.CALENDAR TO ROA_WEB` per schemă | 1 linie per schemă (parte din onboarding §4.1) |
| Expunere `DEV_ORDL` + `NOM_LUCRARI` pentru GET /comenzi | `GRANT SELECT {SCHEMA}.DEV_ORDL/NOM_LUCRARI TO ROA_WEB` per schemă | 2 linii per schemă (parte din onboarding §4.1) |
---

View File

@@ -0,0 +1,63 @@
-- adaugare coloana DEV_ORDL.id_sucursala + upgrade SP_CREEAZA_COMANDA_PROTOTIP
-- Rulat conectat ca schema tinta (ex: MARIUSM_AUTO), O SINGURA DATA per schema
BEGIN
IF PACK_MIGRARE.COLUMNEXIST('DEV_ORDL','ID_SUCURSALA')=0 THEN
EXECUTE IMMEDIATE 'ALTER TABLE DEV_ORDL ADD (id_sucursala NUMBER(10))';
END IF;
END;
/
CREATE OR REPLACE PROCEDURE SP_CREEAZA_COMANDA_PROTOTIP(
p_tip IN NUMBER,
p_id_masiniclient IN NUMBER,
p_solicitari IN VARCHAR2,
p_id_firma IN NUMBER,
p_id_sucursala IN NUMBER DEFAULT NULL,
p_id_ordl OUT NUMBER,
p_nrord OUT VARCHAR2
) AS
v_id_lucrare NUMBER;
v_seq NUMBER;
v_exists NUMBER;
v_now DATE := SYSDATE;
BEGIN
v_seq := SEQ_NR_LUCRARE.NEXTVAL;
p_nrord := 'P' || LPAD(p_id_firma, 2, '0') || '-' || v_seq;
SELECT COUNT(*) INTO v_exists
FROM NOM_LUCRARI
WHERE sters = 0 AND nrord = p_nrord;
IF v_exists > 0 THEN
RAISE_APPLICATION_ERROR(-20001,
'Mai exista o comanda cu numarul ' || p_nrord);
END IF;
INSERT INTO NOM_LUCRARI (nrord, id_mod)
VALUES (p_nrord, 1200)
RETURNING id_lucrare INTO v_id_lucrare;
INSERT INTO DEV_ORDL (
an, luna,
id_lucrare,
datai, dataoraad,
id_util_ad,
id_masiniclient,
id_tip,
solicitari_client,
id_sucursala
) VALUES (
EXTRACT(YEAR FROM v_now), EXTRACT(MONTH FROM v_now),
v_id_lucrare,
v_now, v_now,
0,
p_id_masiniclient,
p_tip,
p_solicitari,
p_id_sucursala
) RETURNING id_ordl INTO p_id_ordl;
END SP_CREEAZA_COMANDA_PROTOTIP;
/
exec pack_migrare.UpdateVersiune('ff_2026_04_12_01_AUTO'); commit;

View File

@@ -0,0 +1,24 @@
-- =============================================================================
-- File purpose : Script de onboarding ROA_WEB pentru o schemă nouă
-- When to run : Rulat ca CONTAFIN_ORACLE după impdp pentru fiecare firmă nouă
-- Usage : Înlocuiește :SCHEMA_NAME cu schema reală (ex: MARIUSM_AUTO)
-- Version : 2026-04-12
-- Prerequisite : ROA_WEB user creat (onboarding_roa_web_user.sql)
-- =============================================================================
GRANT EXECUTE ON :SCHEMA_NAME.SP_CREEAZA_COMANDA_PROTOTIP TO ROA_WEB;
GRANT SELECT ON :SCHEMA_NAME.AUTO_VMASINICLIENTI TO ROA_WEB;
GRANT SELECT ON :SCHEMA_NAME.DEV_TIP_DEVIZ TO ROA_WEB;
GRANT SELECT ON :SCHEMA_NAME.CALENDAR TO ROA_WEB; -- period selector AppHeader
GRANT SELECT ON :SCHEMA_NAME.DEV_ORDL TO ROA_WEB; -- GET /api/service-auto/comenzi
GRANT SELECT ON :SCHEMA_NAME.NOM_LUCRARI TO ROA_WEB; -- JOIN cu DEV_ORDL pentru nrord
-- =============================================================================
-- ROA_WEB user creation (one-time, run as SYS or CONTAFIN_ORACLE)
-- =============================================================================
-- Rulat O SINGURĂ DATĂ la setup inițial, NU pentru fiecare firmă nouă.
-- Pentru fiecare firmă nouă se rulează doar secțiunea de GRANT-uri de mai sus.
CREATE USER ROA_WEB IDENTIFIED BY "<REPLACE_WITH_STRONG_PASSWORD_FROM_VAULT>";
GRANT CREATE SESSION TO ROA_WEB;
-- Fără alte privilegii sistem. Accesul la date = exclusiv prin granturi per-obiect.

View File

@@ -63,6 +63,24 @@
</ul>
</div>
<!-- SERVICE AUTO Section -->
<div class="menu-section">
<h3 class="menu-title">Service Auto</h3>
<ul class="menu-list">
<li class="menu-item" v-for="item in serviceAutoItems" :key="item.to">
<router-link
:to="item.to"
class="menu-link"
:class="{ active: isActive(item.to, item.exactMatch) }"
@click="closeMenu"
>
<i :class="['menu-icon', item.icon]"></i>
<span>{{ item.label }}</span>
</router-link>
</li>
</ul>
</div>
<!-- ADMINISTRARE Section -->
<div class="menu-section">
<h3 class="menu-title">Administrare</h3>
@@ -144,6 +162,12 @@ export default {
{ to: '/reports/detailed-invoices', icon: 'pi pi-list', label: 'Facturi Detaliate', exactMatch: true }
]);
// SERVICE AUTO: Comenzi, Comandă Nouă
const serviceAutoItems = ref([
{ to: '/service-auto/comenzi', icon: 'pi pi-list', label: 'Comenzi', exactMatch: true },
{ to: '/service-auto/comanda-noua', icon: 'pi pi-plus', label: 'Comandă Nouă', exactMatch: true },
]);
// ADMINISTRARE: Setări
const administrareItems = ref([
{ to: '/settings', icon: 'pi pi-cog', label: 'Setări', exactMatch: false }
@@ -179,6 +203,7 @@ export default {
principaleItems,
rapoarteItems,
analizeItems,
serviceAutoItems,
administrareItems,
isActive,
closeMenu,

View File

@@ -12,5 +12,6 @@ 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),
}

View File

@@ -0,0 +1,15 @@
/**
* Service Auto Module - Shared Store Instances
*
* Instantiates the shared stores (auth, companies) with the
* Service Auto module's API service.
*/
import { createAuthStore } from '@shared/stores/auth'
import { createCompaniesStore } from '@shared/stores/companies'
import api from '../services/api.js'
const resetAllStores = () => {}
export const useAuthStore = createAuthStore(api, { onLogout: resetAllStores })
export const useCompanyStore = createCompaniesStore(api, useAuthStore)

View File

@@ -104,8 +104,10 @@ import Textarea from 'primevue/textarea'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import serviceAutoApi from '../services/api.js'
import { useCompanyStore } from '../stores/sharedStores.js'
const toast = useToast()
const companyStore = useCompanyStore()
const clientDropdownRef = ref(null)
const isSubmitting = ref(false)
@@ -131,9 +133,13 @@ async function loadLookups() {
if (firmeRes.status === 'fulfilled') {
firme.value = firmeRes.value.data
// Default: first company
// Default: selected company from AppHeader store, fallback to first
if (firme.value.length > 0 && form.value.id_firma === null) {
form.value.id_firma = firme.value[0].id_firma
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
}
}
} else {
toast.add({ severity: 'warn', summary: 'Firme', detail: 'Nu s-au putut încărca firmele', life: 4000 })
@@ -169,6 +175,9 @@ const emptyForm = () => ({
const form = ref(emptyForm())
const selectedFirma = computed(() => firme.value.find(f => f.id_firma === form.value.id_firma) || null)
const idSucursala = computed(() => selectedFirma.value?.id_mama != null ? form.value.id_firma : null)
const isFormValid = computed(() =>
form.value.id_firma !== null &&
form.value.tip_id !== null &&
@@ -188,6 +197,7 @@ async function submitComanda() {
id_masiniclient: form.value.id_masiniclient,
solicitari: form.value.solicitari.trim(),
id_firma: form.value.id_firma,
id_sucursala: idSucursala.value,
})
toast.add({

View File

@@ -0,0 +1,275 @@
<template>
<div class="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>
</div>
<!-- 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"
/>
</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>
<!-- Table -->
<div class="card-body">
<DataTable
:value="comenzi"
:lazy="true"
:paginator="true"
:rows="perPage"
:total-records="total"
:loading="loading"
class="p-datatable-sm"
striped-rows
@page="onPage"
>
<template #empty>
<div style="text-align: center; padding: var(--space-xl); color: var(--text-color-secondary);">
Nicio comandă găsită
</div>
</template>
<Column field="nrord" header="Nr. Ord." style="min-width: 100px;" />
<Column field="datai" header="Data" style="min-width: 100px;" />
<Column header="Status" style="min-width: 110px;">
<template #body="{ data }">
<span :class="['status-badge', statusClass(data)]">
{{ statusLabel(data) }}
</span>
</template>
</Column>
<Column header="Tip" style="min-width: 130px;">
<template #body="{ data }">
{{ data.tip_denumire }}
</template>
</Column>
<Column header="Client / Vehicul" style="min-width: 240px;">
<template #body="{ data }">
<span style="color: var(--text-color);">{{ data.vehicul || '—' }}</span>
</template>
</Column>
</DataTable>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useToast } from 'primevue/usetoast'
import DataTable from 'primevue/datatable'
import Column from 'primevue/column'
import Dropdown from 'primevue/dropdown'
import Calendar from 'primevue/calendar'
import Button from 'primevue/button'
import Toast from 'primevue/toast'
import serviceAutoApi from '../services/api.js'
const toast = useToast()
// ─── State ────────────────────────────────────────────────────────────────────
const comenzi = ref([])
const total = ref(0)
const loading = ref(false)
const page = ref(1)
const perPage = ref(20)
const filters = ref({
validat: null,
data_de_la: null,
data_pana_la: null,
})
const statusOptions = [
{ label: 'Toate', value: null },
{ label: 'Deschisă', value: 0 },
{ label: 'Validată', value: 1 },
]
// ─── Helpers ──────────────────────────────────────────────────────────────────
const fmtDate = (d) => d ? d.toISOString().slice(0, 10) : undefined
function statusLabel(row) {
if (row.inchis_fortat) return 'Arhivată'
return row.validat ? 'Validată' : 'Deschisă'
}
function statusClass(row) {
if (row.inchis_fortat) return 'status-archived'
return row.validat ? 'status-validated' : 'status-open'
}
// ─── Data loading ─────────────────────────────────────────────────────────────
async function loadComenzi() {
loading.value = true
try {
const params = {
page: page.value,
per_page: perPage.value,
}
if (filters.value.validat !== null) params.validat = filters.value.validat
if (filters.value.data_de_la) params.data_de_la = fmtDate(filters.value.data_de_la)
if (filters.value.data_pana_la) params.data_pana_la = fmtDate(filters.value.data_pana_la)
const { data } = await serviceAutoApi.getComenzi(params)
comenzi.value = data.comenzi
total.value = data.total
} catch (err) {
const status = err.response?.status
if (status === 503) {
toast.add({ severity: 'error', summary: 'Eroare conexiune', detail: 'Serviciul bazei de date nu este disponibil', life: 5000 })
} else {
toast.add({ severity: 'error', summary: 'Eroare', detail: 'Nu s-au putut încărca comenzile', life: 4000 })
}
} finally {
loading.value = false
}
}
function resetAndLoad() {
page.value = 1
loadComenzi()
}
function clearFilters() {
filters.value = { validat: null, data_de_la: null, data_pana_la: null }
page.value = 1
loadComenzi()
}
function onPage(event) {
page.value = event.page + 1 // PrimeVue is 0-indexed
perPage.value = event.rows
loadComenzi()
}
onMounted(loadComenzi)
</script>
<style scoped>
.filters-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-md);
align-items: flex-end;
margin-bottom: var(--space-md);
}
.filter-group {
display: flex;
flex-direction: column;
gap: var(--space-xs);
min-width: 150px;
}
.filter-group.filter-actions {
justify-content: flex-end;
padding-bottom: 2px;
}
.filter-label {
font-size: var(--text-sm);
font-weight: var(--font-medium);
color: var(--text-color-secondary);
}
/* Status badges */
.status-badge {
display: inline-block;
padding: 2px var(--space-sm);
border-radius: var(--radius-sm);
font-size: var(--text-sm);
font-weight: var(--font-medium);
}
.status-open {
background: var(--green-50);
color: var(--green-600);
}
.status-validated {
background: var(--blue-50);
color: var(--blue-600);
}
.status-archived {
background: var(--surface-hover);
color: var(--text-color-secondary);
}
[data-theme="dark"] .status-open {
background: var(--green-900);
color: var(--green-200);
}
[data-theme="dark"] .status-validated {
background: var(--blue-900);
color: var(--blue-200);
}
[data-theme="dark"] .status-archived {
background: var(--surface-100);
color: var(--text-color-secondary);
}
</style>

View File

@@ -140,6 +140,12 @@ const routes = [
path: '/service-auto',
meta: { requiresAuth: true },
children: [
{
path: 'comenzi',
name: 'Comenzi',
component: () => import('@/modules/service-auto/views/ComenziBrowseView.vue'),
meta: { requiresAuth: true, title: 'Comenzi - Service Auto' }
},
{
path: 'comanda-noua',
name: 'ComandaNoua',