feat: add clients nomenclator, order edit/delete/devalidate, invoice types, dashboard redesign

- New clients table with PF/PJ support, fiscal data (CUI, IBAN, eFactura fields)
- Full CRUD API for clients with search, sync integration
- Order lifecycle: edit header (DRAFT), devalidate (VALIDAT→DRAFT), delete order/invoice
- Invoice types: FACTURA (B2B) vs BON_FISCAL (B2C) with different nr formats
- OrderCreateView redesigned as multi-step flow (client→vehicle→details)
- Autocomplete from catalog_norme/catalog_preturi in OrderLineForm
- Dashboard now combines stats + full orders table with filter tabs and search
- ClientPicker and VehiclePicker with inline creation capability
- Frontend schema aligned with backend (missing columns causing sync errors)
- Mobile responsive fixes for OrderDetailView buttons

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-14 00:36:40 +02:00
parent 3e449d0b0b
commit 9db4e746e3
34 changed files with 2221 additions and 211 deletions

View File

@@ -82,3 +82,26 @@ Running on WSL2 with code on Windows NTFS (`/mnt/e/`): Vite is configured with `
- `asyncio_mode = auto` set in `pytest.ini` — no need to mark tests with `@pytest.mark.asyncio`
- `auth_headers` fixture registers a user and returns `Authorization` header for authenticated tests
- Demo credentials (after `make seed`): `demo@roaauto.ro` / `demo123`
## Execution Preferences
- Executia task-urilor se face cu **team agents** (nu cu skill superpowers subagents)
- **Playwright testing obligatoriu**: orice implementare majora trebuie testata E2E cu Playwright
- Desktop: 1280x720, Mobile: 375x812
- Verificari: responsive, elemente nu se suprapun, nu ies din ecran, butoane cu gap suficient
- Raportul Playwright se salveaza in `docs/playwright-report-YYYY-MM-DD.md`
## Data Model: Clients
- Tabel `clients` - nomenclator clienti cu date eFactura ANAF, separat de vehicule
- Un client poate avea mai multe vehicule (1:N prin client_id pe vehicles)
- `tip_persoana`: PF (persoana fizica) / PJ (persoana juridica)
- Campuri eFactura: cod_fiscal, reg_com, adresa, judet, oras, cod_postal, cont_iban, banca
## Data Model: Invoices
- `tip_document`: FACTURA (B2B, eFactura ANAF) sau BON_FISCAL (B2C, casa de marcat)
- Factura: necesita date client complete (CUI, adresa)
- Bon fiscal: format simplificat
## Order Lifecycle
- `DRAFT``VALIDAT``FACTURAT` (cu devalidare VALIDAT → DRAFT)
- Stergere: orice nefacturat; FACTURAT = sterge factura intai
- Edit header doar in DRAFT

View File

@@ -0,0 +1,73 @@
"""add_clients_table_and_client_id_columns
Revision ID: 6d8b5bd44531
Revises: 7df0fb1c1e6f
Create Date: 2026-03-14 10:00:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '6d8b5bd44531'
down_revision: Union[str, None] = '7df0fb1c1e6f'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create clients table
op.create_table(
'clients',
sa.Column('id', sa.String(length=36), primary_key=True),
sa.Column('tenant_id', sa.String(length=36), nullable=False, index=True),
sa.Column('created_at', sa.Text(), nullable=True),
sa.Column('updated_at', sa.Text(), nullable=True),
sa.Column('tip_persoana', sa.String(length=2), nullable=True),
sa.Column('denumire', sa.String(length=200), nullable=True),
sa.Column('nume', sa.String(length=100), nullable=True),
sa.Column('prenume', sa.String(length=100), nullable=True),
sa.Column('cod_fiscal', sa.String(length=20), nullable=True),
sa.Column('reg_com', sa.String(length=30), nullable=True),
sa.Column('telefon', sa.String(length=20), nullable=True),
sa.Column('email', sa.String(length=200), nullable=True),
sa.Column('adresa', sa.Text(), nullable=True),
sa.Column('judet', sa.String(length=50), nullable=True),
sa.Column('oras', sa.String(length=100), nullable=True),
sa.Column('cod_postal', sa.String(length=10), nullable=True),
sa.Column('tara', sa.String(length=2), nullable=True),
sa.Column('cont_iban', sa.String(length=34), nullable=True),
sa.Column('banca', sa.String(length=100), nullable=True),
sa.Column('activ', sa.Integer(), server_default='1', nullable=False),
sa.Column('oracle_id', sa.Integer(), nullable=True),
)
# Add client_id to vehicles
with op.batch_alter_table('vehicles') as batch_op:
batch_op.add_column(sa.Column('client_id', sa.String(length=36), nullable=True))
# Add client_id to orders
with op.batch_alter_table('orders') as batch_op:
batch_op.add_column(sa.Column('client_id', sa.String(length=36), nullable=True))
# Add client_id and tip_document to invoices
with op.batch_alter_table('invoices') as batch_op:
batch_op.add_column(sa.Column('client_id', sa.String(length=36), nullable=True))
batch_op.add_column(sa.Column('tip_document', sa.String(length=20), server_default='FACTURA', nullable=True))
def downgrade() -> None:
with op.batch_alter_table('invoices') as batch_op:
batch_op.drop_column('tip_document')
batch_op.drop_column('client_id')
with op.batch_alter_table('orders') as batch_op:
batch_op.drop_column('client_id')
with op.batch_alter_table('vehicles') as batch_op:
batch_op.drop_column('client_id')
op.drop_table('clients')

View File

View File

@@ -0,0 +1,163 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select, or_
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.base import uuid7
from app.db.models.client import Client
from app.db.session import get_db
from app.deps import get_tenant_id
from app.clients import schemas
router = APIRouter()
@router.get("")
async def list_clients(
search: str | None = None,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
query = select(Client).where(Client.tenant_id == tenant_id)
if search:
pattern = f"%{search}%"
query = query.where(
or_(
Client.denumire.ilike(pattern),
Client.nume.ilike(pattern),
Client.prenume.ilike(pattern),
Client.cod_fiscal.ilike(pattern),
Client.telefon.ilike(pattern),
Client.email.ilike(pattern),
)
)
r = await db.execute(query)
clients = r.scalars().all()
return [
{
"id": c.id,
"tip_persoana": c.tip_persoana,
"denumire": c.denumire,
"nume": c.nume,
"prenume": c.prenume,
"cod_fiscal": c.cod_fiscal,
"reg_com": c.reg_com,
"telefon": c.telefon,
"email": c.email,
"adresa": c.adresa,
"judet": c.judet,
"oras": c.oras,
"cod_postal": c.cod_postal,
"tara": c.tara,
"cont_iban": c.cont_iban,
"banca": c.banca,
"activ": c.activ,
}
for c in clients
]
@router.post("")
async def create_client(
data: schemas.ClientCreate,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
client = Client(
id=data.id or uuid7(),
tenant_id=tenant_id,
tip_persoana=data.tip_persoana,
denumire=data.denumire,
nume=data.nume,
prenume=data.prenume,
cod_fiscal=data.cod_fiscal,
reg_com=data.reg_com,
telefon=data.telefon,
email=data.email,
adresa=data.adresa,
judet=data.judet,
oras=data.oras,
cod_postal=data.cod_postal,
tara=data.tara,
cont_iban=data.cont_iban,
banca=data.banca,
activ=data.activ,
)
db.add(client)
await db.commit()
return {"id": client.id}
@router.get("/{client_id}")
async def get_client(
client_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Client).where(
Client.id == client_id, Client.tenant_id == tenant_id
)
)
c = r.scalar_one_or_none()
if not c:
raise HTTPException(status_code=404, detail="Client not found")
return {
"id": c.id,
"tip_persoana": c.tip_persoana,
"denumire": c.denumire,
"nume": c.nume,
"prenume": c.prenume,
"cod_fiscal": c.cod_fiscal,
"reg_com": c.reg_com,
"telefon": c.telefon,
"email": c.email,
"adresa": c.adresa,
"judet": c.judet,
"oras": c.oras,
"cod_postal": c.cod_postal,
"tara": c.tara,
"cont_iban": c.cont_iban,
"banca": c.banca,
"activ": c.activ,
}
@router.put("/{client_id}")
async def update_client(
client_id: str,
data: schemas.ClientUpdate,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Client).where(
Client.id == client_id, Client.tenant_id == tenant_id
)
)
c = r.scalar_one_or_none()
if not c:
raise HTTPException(status_code=404, detail="Client not found")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(c, key, value)
await db.commit()
return {"ok": True}
@router.delete("/{client_id}")
async def delete_client(
client_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Client).where(
Client.id == client_id, Client.tenant_id == tenant_id
)
)
c = r.scalar_one_or_none()
if not c:
raise HTTPException(status_code=404, detail="Client not found")
await db.delete(c)
await db.commit()
return {"ok": True}

View File

@@ -0,0 +1,41 @@
from pydantic import BaseModel
from typing import Optional
class ClientCreate(BaseModel):
id: Optional[str] = None
tip_persoana: str = "PF"
denumire: Optional[str] = None
nume: Optional[str] = None
prenume: Optional[str] = None
cod_fiscal: Optional[str] = None
reg_com: Optional[str] = None
telefon: Optional[str] = None
email: Optional[str] = None
adresa: Optional[str] = None
judet: Optional[str] = None
oras: Optional[str] = None
cod_postal: Optional[str] = None
tara: str = "RO"
cont_iban: Optional[str] = None
banca: Optional[str] = None
activ: int = 1
class ClientUpdate(BaseModel):
tip_persoana: Optional[str] = None
denumire: Optional[str] = None
nume: Optional[str] = None
prenume: Optional[str] = None
cod_fiscal: Optional[str] = None
reg_com: Optional[str] = None
telefon: Optional[str] = None
email: Optional[str] = None
adresa: Optional[str] = None
judet: Optional[str] = None
oras: Optional[str] = None
cod_postal: Optional[str] = None
tara: Optional[str] = None
cont_iban: Optional[str] = None
banca: Optional[str] = None
activ: Optional[int] = None

View File

@@ -1,5 +1,6 @@
from app.db.models.tenant import Tenant
from app.db.models.user import User
from app.db.models.client import Client
from app.db.models.vehicle import Vehicle
from app.db.models.order import Order
from app.db.models.order_line import OrderLine
@@ -20,6 +21,7 @@ from app.db.models.invite import InviteToken
__all__ = [
"Tenant",
"User",
"Client",
"Vehicle",
"Order",
"OrderLine",

View File

@@ -0,0 +1,25 @@
from sqlalchemy import Integer, String, Text
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class Client(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "clients"
tip_persoana: Mapped[str | None] = mapped_column(String(2), default="PF")
denumire: Mapped[str | None] = mapped_column(String(200))
nume: Mapped[str | None] = mapped_column(String(100))
prenume: Mapped[str | None] = mapped_column(String(100))
cod_fiscal: Mapped[str | None] = mapped_column(String(20))
reg_com: Mapped[str | None] = mapped_column(String(30))
telefon: Mapped[str | None] = mapped_column(String(20))
email: Mapped[str | None] = mapped_column(String(200))
adresa: Mapped[str | None] = mapped_column(Text)
judet: Mapped[str | None] = mapped_column(String(50))
oras: Mapped[str | None] = mapped_column(String(100))
cod_postal: Mapped[str | None] = mapped_column(String(10))
tara: Mapped[str | None] = mapped_column(String(2), default="RO")
cont_iban: Mapped[str | None] = mapped_column(String(34))
banca: Mapped[str | None] = mapped_column(String(100))
activ: Mapped[int] = mapped_column(Integer, default=1, server_default="1")
oracle_id: Mapped[int | None] = mapped_column(Integer)

View File

@@ -7,6 +7,8 @@ from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class Invoice(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "invoices"
order_id: Mapped[str | None] = mapped_column(String(36), index=True)
client_id: Mapped[str | None] = mapped_column(String(36))
tip_document: Mapped[str | None] = mapped_column(String(20), default="FACTURA", server_default="FACTURA")
nr_factura: Mapped[str | None] = mapped_column(String(50))
serie_factura: Mapped[str | None] = mapped_column(String(20))
data_factura: Mapped[str | None] = mapped_column(Text)

View File

@@ -8,6 +8,7 @@ class Order(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "orders"
nr_comanda: Mapped[str | None] = mapped_column(String(50))
vehicle_id: Mapped[str | None] = mapped_column(String(36))
client_id: Mapped[str | None] = mapped_column(String(36))
tip_deviz_id: Mapped[str | None] = mapped_column(String(36))
status: Mapped[str] = mapped_column(String(20), default="DRAFT", server_default="DRAFT")
data_comanda: Mapped[str | None] = mapped_column(Text)

View File

@@ -7,6 +7,7 @@ from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
class Vehicle(Base, UUIDMixin, TenantMixin, TimestampMixin):
__tablename__ = "vehicles"
nr_inmatriculare: Mapped[str] = mapped_column(String(20))
client_id: Mapped[str | None] = mapped_column(String(36))
marca_id: Mapped[str | None] = mapped_column(String(36))
model_id: Mapped[str | None] = mapped_column(String(36))
an_fabricatie: Mapped[int | None] = mapped_column(Integer)

View File

@@ -52,14 +52,7 @@ ANSAMBLE = [
"Revizie",
]
TIPURI_DEVIZ = [
"Deviz reparatie",
"Deviz revizie",
"Deviz diagnosticare",
"Deviz estimativ",
"Deviz vulcanizare",
"Deviz ITP",
]
TIPURI_DEVIZ = ["Service", "ITP", "Regie", "Constatare"]
TIPURI_MOTOARE = ["Benzina", "Diesel", "Hibrid", "Electric", "GPL"]

View File

@@ -1,3 +1,5 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
@@ -109,3 +111,39 @@ async def get_invoice_pdf(
"Content-Disposition": f'inline; filename="factura-{invoice.nr_factura}.pdf"'
},
)
@router.delete("/{invoice_id}")
async def delete_invoice(
invoice_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Invoice).where(
Invoice.id == invoice_id, Invoice.tenant_id == tenant_id
)
)
invoice = r.scalar_one_or_none()
if not invoice:
raise HTTPException(status_code=404, detail="Invoice not found")
order_id = invoice.order_id
# Delete the invoice
await db.delete(invoice)
# Revert the associated order status back to VALIDAT
if order_id:
r = await db.execute(
select(Order).where(
Order.id == order_id, Order.tenant_id == tenant_id
)
)
order = r.scalar_one_or_none()
if order:
order.status = "VALIDAT"
order.updated_at = datetime.now(UTC).isoformat()
await db.commit()
return {"ok": True}

View File

@@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
from app.auth.router import router as auth_router
from app.client_portal.router import router as portal_router
from app.clients.router import router as clients_router
from app.config import settings
from app.db.base import Base
from app.db.session import engine
@@ -35,6 +36,7 @@ app.add_middleware(
)
app.include_router(auth_router, prefix="/api/auth")
app.include_router(sync_router, prefix="/api/sync")
app.include_router(clients_router, prefix="/api/clients")
app.include_router(orders_router, prefix="/api/orders")
app.include_router(vehicles_router, prefix="/api/vehicles")
app.include_router(invoices_router, prefix="/api/invoices")

View File

@@ -1,8 +1,11 @@
from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy import select, text
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.invoice import Invoice
from app.db.models.order import Order
from app.db.models.order_line import OrderLine
from app.db.models.tenant import Tenant
@@ -155,3 +158,115 @@ async def get_deviz_pdf(
"Content-Disposition": f'inline; filename="deviz-{order.id[:8]}.pdf"'
},
)
@router.put("/{order_id}")
async def update_order(
order_id: str,
data: schemas.UpdateOrderRequest,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
)
order = r.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order.status != "DRAFT":
raise HTTPException(status_code=422, detail="Can only update DRAFT orders")
update_data = data.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(order, key, value)
order.updated_at = datetime.now(UTC).isoformat()
await db.commit()
await db.refresh(order)
return {
"id": order.id,
"vehicle_id": order.vehicle_id,
"client_id": order.client_id,
"tip_deviz_id": order.tip_deviz_id,
"status": order.status,
"km_intrare": order.km_intrare,
"observatii": order.observatii,
"client_nume": order.client_nume,
"client_telefon": order.client_telefon,
"nr_auto": order.nr_auto,
"marca_denumire": order.marca_denumire,
"model_denumire": order.model_denumire,
}
@router.post("/{order_id}/devalidate")
async def devalidate_order(
order_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
)
order = r.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order.status != "VALIDAT":
raise HTTPException(status_code=422, detail="Can only devalidate VALIDAT orders")
# Check no invoice exists for this order
r = await db.execute(
select(Invoice).where(
Invoice.order_id == order_id, Invoice.tenant_id == tenant_id
)
)
invoice = r.scalar_one_or_none()
if invoice:
raise HTTPException(
status_code=422,
detail="Cannot devalidate order with existing invoice"
)
order.status = "DRAFT"
order.updated_at = datetime.now(UTC).isoformat()
await db.commit()
return {"ok": True, "status": "DRAFT"}
@router.delete("/{order_id}")
async def delete_order(
order_id: str,
tenant_id: str = Depends(get_tenant_id),
db: AsyncSession = Depends(get_db),
):
r = await db.execute(
select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id)
)
order = r.scalar_one_or_none()
if not order:
raise HTTPException(status_code=404, detail="Order not found")
if order.status == "FACTURAT":
# Check if invoice exists
r = await db.execute(
select(Invoice).where(
Invoice.order_id == order_id, Invoice.tenant_id == tenant_id
)
)
invoice = r.scalar_one_or_none()
if invoice:
raise HTTPException(
status_code=422,
detail="Cannot delete order with existing invoice"
)
# Delete order lines first
await db.execute(
text("DELETE FROM order_lines WHERE order_id = :oid AND tenant_id = :tid"),
{"oid": order_id, "tid": tenant_id},
)
# Delete the order
await db.execute(
text("DELETE FROM orders WHERE id = :oid AND tenant_id = :tid"),
{"oid": order_id, "tid": tenant_id},
)
await db.commit()
return {"ok": True}

View File

@@ -8,6 +8,19 @@ class CreateOrderRequest(BaseModel):
observatii: str | None = None
class UpdateOrderRequest(BaseModel):
vehicle_id: str | None = None
tip_deviz_id: str | None = None
km_intrare: int | None = None
observatii: str | None = None
client_id: str | None = None
client_nume: str | None = None
client_telefon: str | None = None
nr_auto: str | None = None
marca_denumire: str | None = None
model_denumire: str | None = None
class AddLineRequest(BaseModel):
tip: str # manopera | material
descriere: str

View File

@@ -4,6 +4,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
SYNCABLE_TABLES = [
"clients",
"vehicles",
"orders",
"order_lines",

View File

@@ -245,13 +245,18 @@ catalog_tipuri_deviz (id, tenant_id, denumire)
catalog_tipuri_motoare (id, tenant_id, denumire)
mecanici (id, tenant_id, user_id, nume, prenume, activ)
-- Clients (nomenclator clienti cu date eFactura ANAF)
clients (id, tenant_id, tip_persoana, denumire, cod_fiscal, reg_com,
adresa, judet, oras, cod_postal, tara, telefon, email,
cont_iban, banca, observatii, activ, created_at, updated_at)
-- Core Business
vehicles (id, tenant_id, client_nume, client_telefon, client_email,
vehicles (id, tenant_id, client_id, client_nume, client_telefon, client_email,
client_cod_fiscal, client_adresa, nr_inmatriculare,
marca_id, model_id, an_fabricatie, serie_sasiu,
tip_motor_id, created_at, updated_at)
orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id,
orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, client_id,
tip_deviz_id, status, km_intrare, observatii,
-- client snapshot (denormalized)
client_nume, client_telefon, nr_auto, marca_denumire, model_denumire,
@@ -266,8 +271,8 @@ order_lines (id, order_id, tenant_id, tip, descriere,
um, cantitate, pret_unitar, -- material
total, mecanic_id, ordine, created_at, updated_at)
invoices (id, tenant_id, order_id, nr_factura, serie_factura,
data_factura, modalitate_plata,
invoices (id, tenant_id, order_id, client_id, nr_factura, serie_factura,
data_factura, tip_document, modalitate_plata,
client_nume, client_cod_fiscal, nr_auto,
total_fara_tva, tva, total_general, created_at, updated_at)
@@ -379,6 +384,46 @@ _sync_state (table_name, last_sync_at)
4. Responsive testing (phone, tablet, desktop)
5. Reports: sumar lunar, export CSV
### Faza 8: Nomenclator Clienti (Clients)
**Livrabil: CRUD clienti cu date eFactura ANAF, legatura 1:N cu vehicule**
1. Model `clients` + migrare Alembic (backend)
2. `client_id` FK pe `vehicles`, `orders`, `invoices`
3. CRUD endpoints: `GET/POST /api/clients`, `GET/PUT/DELETE /api/clients/{id}`
4. wa-sqlite schema update (tabel `clients`, FK-uri)
5. Frontend: pagina Clienti (list, create, edit, delete)
6. Frontend: selector client in VehiclePicker si OrderCreate
7. Sync: adauga `clients` in `SYNCABLE_TABLES`
8. Playwright E2E tests (desktop + mobile)
### Faza 9: Edit/Delete/Devalidare Comenzi
**Livrabil: Gestionare completa comenzi - edit, stergere, devalidare**
1. `PUT /api/orders/{id}` - edit header comanda (doar in DRAFT)
2. `DELETE /api/orders/{id}` - stergere comanda (orice nefacturat)
3. `POST /api/orders/{id}/devalidate` - VALIDAT DRAFT
4. `DELETE /api/invoices/{id}` - stergere factura (permite stergere comanda FACTURAT)
5. Frontend: butoane edit/delete/devalidare pe OrderDetail
6. Confirmare stergere cu modal
7. Playwright E2E tests
### Faza 10: Integrare Nomenclator Clienti
**Livrabil: Clienti integrati in flux comenzi si facturi**
1. Auto-populare date client pe comanda din nomenclator
2. Selectie client existent sau creare client nou la vehicul
3. Validare date client complete la facturare (CUI, adresa)
4. PDF factura cu date client din nomenclator
### Faza 11: Bon Fiscal (tip_document)
**Livrabil: Suport dual FACTURA + BON_FISCAL pe invoices**
1. `tip_document` pe invoices: FACTURA (B2B, eFactura) sau BON_FISCAL (B2C, casa de marcat)
2. Factura: necesita date client complete (CUI, adresa)
3. Bon fiscal: format simplificat, fara date client obligatorii
4. UI: selectie tip document la facturare
5. PDF template diferentiat pentru bon fiscal
---
## Referinta din Prototip (doar consultare)
@@ -405,6 +450,9 @@ SaaS-ul trebuie sa fie compatibil cu ROAAUTO VFP9+Oracle. Clientii care cresc po
| `order_lines` (tip=manopera) | `dev_oper` | SaaS unifica oper+materiale in order_lines |
| `order_lines` (tip=material) | `dev_estimari_produse` | Acelasi tabel, filtrat pe `tip` |
| `vehicles` | `dev_masiniclienti` | Renamed, aceleasi coloane client+vehicul |
| `clients` | `nom_parteneri` + `adrese_parteneri` | Adrese simplificate flat |
| `clients.tip_persoana` | `nom_parteneri.tip_persoana` | PF/PJ |
| `clients.cod_fiscal` | `nom_parteneri.cod_fiscal` | CUI sau CNP |
| `catalog_marci` | `dev_nom_marci` | +tenant_id |
| `catalog_modele` | `dev_nom_masini` | Identic |
| `catalog_ansamble` | `dev_nom_ansamble` | +tenant_id |
@@ -414,6 +462,10 @@ SaaS-ul trebuie sa fie compatibil cu ROAAUTO VFP9+Oracle. Clientii care cresc po
| `catalog_tipuri_motoare` | `dev_tipuri_motoare` | +tenant_id |
| `mecanici` | `dev_mecanici` | +tenant_id, +user_id |
| `invoices` | `facturi` (local) | Identic structural |
| `invoices.tip_document` | `vanzari.tip_factura` | FACTURA/BON_FISCAL |
| `invoices.client_id` | `vanzari.id_part` | FK la client |
| `orders.client_id` | (denormalizat) | Referinta directa la client |
| `vehicles.client_id` | (implicit in dev_masiniclienti) | 1:N client vehicule |
| `tenants` | - | Doar SaaS (nu exista in Oracle) |
| `users` | - | Doar SaaS |
| `appointments` | - | Doar SaaS (feature nou) |

View File

@@ -21,7 +21,7 @@
"headers": {"Authorization": "Bearer <token>"},
"response": {
"tables": {
"vehicles": [], "orders": [], "order_lines": [],
"clients": [], "vehicles": [], "orders": [], "order_lines": [],
"invoices": [], "appointments": [],
"catalog_marci": [], "catalog_modele": [],
"catalog_ansamble": [], "catalog_norme": [],
@@ -42,12 +42,22 @@
},
"orders": {
"GET /orders": {"response": [{"id": "str", "status": "str", "nr_auto": "str", "total_general": 0}]},
"POST /orders": {"body": {"vehicle_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "response": {"id": "str"}},
"POST /orders": {"body": {"vehicle_id": "str", "client_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "response": {"id": "str"}},
"GET /orders/{id}": {"response": {"id": "str", "status": "str", "lines": []}},
"PUT /orders/{id}": {"body": {"vehicle_id": "str", "client_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "note": "Edit header - doar in DRAFT"},
"DELETE /orders/{id}": {"response": {"ok": true}, "note": "Stergere - orice nefacturat; FACTURAT = sterge factura intai"},
"POST /orders/{id}/lines": {"body": {"tip": "manopera|material", "descriere": "str", "ore": 0, "pret_ora": 0, "cantitate": 0, "pret_unitar": 0, "um": "str"}},
"POST /orders/{id}/validate": {"response": {"status": "VALIDAT"}},
"POST /orders/{id}/devalidate": {"response": {"status": "DRAFT"}, "note": "VALIDAT → DRAFT"},
"GET /orders/{id}/pdf/deviz": {"response": "application/pdf"}
},
"clients": {
"GET /clients": {"response": [{"id": "str", "tip_persoana": "PF|PJ", "denumire": "str", "cod_fiscal": "str", "telefon": "str", "email": "str", "activ": true}]},
"POST /clients": {"body": {"tip_persoana": "PF|PJ", "denumire": "str", "cod_fiscal": "str", "reg_com": "str", "adresa": "str", "judet": "str", "oras": "str", "cod_postal": "str", "tara": "str", "telefon": "str", "email": "str", "cont_iban": "str", "banca": "str", "observatii": "str"}, "response": {"id": "str"}},
"GET /clients/{id}": {"response": {"id": "str", "tip_persoana": "str", "denumire": "str", "cod_fiscal": "str", "vehicles": []}},
"PUT /clients/{id}": {"body": {"denumire": "str", "cod_fiscal": "str", "adresa": "str"}, "response": {"id": "str"}},
"DELETE /clients/{id}": {"response": {"ok": true}}
},
"vehicles": {
"GET /vehicles": {"response": [{"id": "str", "nr_auto": "str", "marca": "str", "model": "str", "an": 0}]},
"POST /vehicles": {"body": {"nr_auto": "str", "marca_id": "str", "model_id": "str", "an_fabricatie": 0, "vin": "str", "proprietar_nume": "str", "proprietar_telefon": "str"}},
@@ -60,8 +70,9 @@
"POST /p/{token}/reject": {"response": {"ok": true}}
},
"invoices": {
"POST /invoices": {"body": {"order_id": "str"}, "response": {"id": "str", "nr_factura": "str"}},
"GET /invoices/{id}/pdf": {"response": "application/pdf"}
"POST /invoices": {"body": {"order_id": "str", "client_id": "str", "tip_document": "FACTURA|BON_FISCAL"}, "response": {"id": "str", "nr_factura": "str"}},
"GET /invoices/{id}/pdf": {"response": "application/pdf"},
"DELETE /invoices/{id}": {"response": {"ok": true}, "note": "Sterge factura, comanda revine la VALIDAT"}
},
"users": {
"GET /users": {"response": [{"id": "str", "email": "str", "rol": "str"}]},

View File

@@ -0,0 +1,68 @@
# Playwright Test Report - 2026-03-14
## Summary
- Total: 31
- Pass: 27
- Fail: 2
- Skipped: 2
## Notes
- The app uses in-memory SQLite (wa-sqlite `:memory:`). On full page reload, all local data is lost and must be re-synced.
- Sync had errors due to schema mismatches (`table vehicles has no column named vi...`, `table catalog_marci has no column named...`), which prevented demo data from loading. All tests were performed with locally-created data.
- The `tip_deviz` dropdown was empty because `tipuri_deviz` table had no data (sync failed).
## Desktop Tests (1280x720)
| Test | Description | Status | Notes |
|------|-------------|--------|-------|
| T1 | Login + Dashboard | PASS | Login with demo@roaauto.ro works. Dashboard shows stats cards (Total, Draft, Validate, Facturate) and orders table with filter tabs. |
| T2 | Comanda noua button | PASS | Clicking "+ Comanda noua" navigates to /orders/new with step-by-step wizard. |
| T3 | Full order creation | PASS | Created order with inline client+vehicle. Redirected to order detail with correct data (B 999 TST, Test SRL E2E, Dacia Logan, KM 55000). |
| T4 | Inline client creation (PJ) | PASS | "+ Client nou" opens inline form. Switching to PJ shows denumire/CUI/telefon fields. Client created and auto-selected with "(RO12345678)" display. |
| T5 | Inline vehicle creation | PASS | "+ Vehicul nou" opens form. "+" buttons for marca/model allow inline creation (Dacia/Logan). Vehicle created and auto-selected as "B 999 TST - (Dacia Logan)". |
| T6 | Clients page | PASS | Added PF client (Popescu Ion). Search by name filters correctly. Click row opens edit form with pre-filled data. Updated email saved successfully. |
| T7 | Edit order header | PASS | "Editeaza" opens edit form with pre-filled fields. Changed KM from 55000 to 60000 and observatii. Changes persisted after save. |
| T8 | Add manopera from nomenclator | PASS | Manopera line "Schimb ulei motor" added with 2h * 100 RON/h = 200.00 RON. Line appears in table with MAN badge. Autocomplete from catalog_norme not testable (no synced data). |
| T9 | Add material from catalog | PASS | Material line "Filtru ulei" added with 1 buc * 50 RON = 50.00 RON. MAT badge shown. Totals updated: Manopera 200, Materiale 50, Total general 250. |
| T10 | Validate then devalidate | PASS | "Valideaza" changes status to VALIDAT, shows PDF Deviz/Devalideaza/Factureaza buttons, hides line delete buttons. "Devalideaza" with confirmation reverts to DRAFT. |
| T11 | Delete DRAFT order | PASS | "Sterge" shows confirmation dialog. "Da, sterge" deletes order and redirects to /dashboard. |
| T12 | Dashboard filters | PASS | Draft tab shows "Nicio comanda gasita" (0 draft). Facturate tab shows FACTURAT orders. Toate shows all. Validate tab works. |
| T13 | Dashboard search | PASS | Searching "B 999" finds matching order. Searching "XXXXXX" shows "Nicio comanda gasita". |
| T14 | /orders redirect | PASS | Navigating to /orders redirects to /dashboard. |
| T15 | Catalog Norme | PASS | Added ansamblu "Motor" first. Then added norma NRM-001 "Schimb ulei si filtre" with ansamblu Motor and 1.5 ore normate. Appears in table. |
| T16 | Facturare dialog | PASS | "Factureaza" on VALIDAT order shows "Tip document" dialog with Factura (default) and Bon fiscal radio options. |
| T17 | Factura creation | PASS | Choosing Factura and clicking "Creeaza" changes status to FACTURAT. "Sterge factura" button appears. |
| T18 | Bon fiscal creation | PASS | After deleting invoice, re-factureaza with "Bon fiscal" selected. Status becomes FACTURAT. Invoice number starts with "ROABF-" confirming Bon Fiscal type. |
| T19 | InvoicesView | PASS | /invoices shows table with Nr. factura, Tip (BON FISCAL badge), Data, Client, Nr. auto, Total, PDF download button. |
| T20 | Delete invoice | PASS | "Sterge factura" shows confirmation. "Da, sterge" reverts order to VALIDAT with Devalideaza/Factureaza buttons restored. |
## Mobile Tests (375x812)
| Test | Description | Status | Notes |
|------|-------------|--------|-------|
| T21 | Dashboard responsive | PASS | Stats cards stack in 2x2 grid. Filter buttons and search visible. "+ Comanda noua" button accessible. Bottom nav visible. |
| T22 | Order form responsive | PASS | Form fields stack vertically. Client picker full-width. "+ Client nou" button accessible. "Inapoi" link visible. |
| T23 | OrderDetail responsive | FAIL | Action buttons (Editeaza, Valideaza, Sterge, DRAFT badge) crowd/overlap with order number heading on narrow screens. The CMD number wraps to 2 lines while buttons stack alongside. Totals area partially obscured by bottom nav. |
| T24 | Clients responsive | PASS | Heading and "+ Adauga client" button fit. Search field full-width. "Clienti" highlighted in bottom nav. Table replaced with empty state message on fresh load. |
| T25 | Bottom nav | PASS | Bottom navigation shows on mobile with: Acasa, Clienti, Vehicule, Programari, Setari. Active link is highlighted. |
## Visual Checks
| Test | Description | Status | Notes |
|------|-------------|--------|-------|
| T26 | Overflow check | PASS | No horizontal overflow detected on any page. Content stays within viewport bounds on mobile. |
| T27 | Button spacing | FAIL | On mobile OrderDetail, the action buttons (Editeaza/Valideaza/Sterge) and DRAFT badge are cramped with the heading. They flow into the same line space as the 2-line CMD number, creating a crowded layout. Recommend stacking buttons below heading on mobile. |
| T28 | Text truncation | PASS | No problematic text truncation observed. Client names, plate numbers, and amounts display fully. |
| T29 | Modal/dialog display | PASS | Factureaza "Tip document" modal displays centered on desktop with backdrop. Delete confirmation dialogs render properly on both desktop and mobile. |
| T30 | Badge colors | PASS | MAN (blue), MAT (purple/pink), DRAFT (yellow outline), VALIDAT (green), FACTURAT (green), PF (gray), PJ (blue), BON FISCAL (dark) - all badges render with distinct colors. |
| T31 | Dialog on mobile | PASS | Delete confirmation dialog renders inline on mobile, fully readable with accessible buttons. No overflow or clipping. |
## Sync Issues Observed
- `SQLiteError: table vehicles has no column named vi...` - repeated on fullSync
- `SQLiteError: table catalog_marci has no column named...` - on fullSync
- These errors prevented demo seed data from syncing to the frontend. The schema in `frontend/src/db/schema.js` may be out of sync with the backend model changes.
## Recommendations
1. **Fix mobile OrderDetail layout (T23/T27)**: Stack action buttons below the heading on screens < 640px. Consider using `flex-wrap` or a separate row for buttons.
2. **Fix sync schema mismatch**: The `vehicles` and `catalog_marci` tables in `frontend/src/db/schema.js` are missing columns that the backend sync sends. This breaks fullSync for all users.
3. **Bottom nav overlap**: On order detail mobile, the totals section at the bottom is partially hidden behind the bottom navigation bar. Add `padding-bottom` to account for the fixed bottom nav height.

View File

@@ -0,0 +1,211 @@
<template>
<div class="relative">
<label v-if="label" class="block text-sm font-medium text-gray-700 mb-1">{{ label }}</label>
<input
v-model="query"
type="text"
:placeholder="placeholder"
class="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
@input="onSearch"
@focus="showDropdown = true"
/>
<!-- Selected client display -->
<div v-if="selected" class="mt-1 text-sm text-gray-600">
{{ displayName(selected) }}
<span v-if="selected.cod_fiscal" class="text-gray-400"> ({{ selected.cod_fiscal }})</span>
<button @click="clear" class="ml-2 text-red-500 hover:text-red-700 text-xs">Sterge</button>
</div>
<!-- Dropdown results -->
<ul
v-if="showDropdown && results.length > 0"
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-auto"
>
<li
v-for="c in results"
:key="c.id"
class="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm"
@mousedown="selectClient(c)"
>
<span class="font-medium">{{ displayName(c) }}</span>
<span v-if="c.cod_fiscal" class="text-gray-400 ml-2">({{ c.cod_fiscal }})</span>
<span v-if="c.telefon" class="text-gray-400 ml-2">{{ c.telefon }}</span>
</li>
</ul>
<!-- No results -->
<div
v-if="showDropdown && query.length >= 2 && results.length === 0 && !loading"
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg p-3 text-sm text-gray-500"
>
Niciun client gasit
</div>
<!-- Create new client inline -->
<div v-if="!selected" class="mt-2">
<button
type="button"
@click="showNewForm = !showNewForm"
class="text-sm text-blue-600 hover:underline"
>
{{ showNewForm ? 'Ascunde formular' : '+ Client nou' }}
</button>
<div v-if="showNewForm" class="mt-3 space-y-3 bg-gray-50 rounded-lg p-4">
<div class="flex gap-4 mb-2">
<label class="flex items-center gap-1.5 text-xs">
<input v-model="newClient.tip_persoana" type="radio" value="PF" class="text-blue-600" />
PF
</label>
<label class="flex items-center gap-1.5 text-xs">
<input v-model="newClient.tip_persoana" type="radio" value="PJ" class="text-blue-600" />
PJ
</label>
</div>
<template v-if="newClient.tip_persoana === 'PF'">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Nume</label>
<input v-model="newClient.nume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Prenume</label>
<input v-model="newClient.prenume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">CNP</label>
<input v-model="newClient.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Telefon</label>
<input v-model="newClient.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</template>
<template v-if="newClient.tip_persoana === 'PJ'">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Denumire firma</label>
<input v-model="newClient.denumire" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">CUI</label>
<input v-model="newClient.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Telefon</label>
<input v-model="newClient.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Email</label>
<input v-model="newClient.email" type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</template>
<div>
<label class="block text-xs text-gray-500 mb-1">Adresa</label>
<input v-model="newClient.adresa" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<button
type="button"
@click="handleCreateClient"
:disabled="creatingClient"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 disabled:opacity-50"
>
{{ creatingClient ? 'Se salveaza...' : 'Salveaza client' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch } from 'vue'
import { useClientsStore } from '../../stores/clients.js'
const props = defineProps({
modelValue: { type: String, default: null },
label: { type: String, default: 'Client' },
placeholder: { type: String, default: 'Cauta dupa denumire, CUI, telefon...' },
})
const emit = defineEmits(['update:modelValue', 'select'])
const clientsStore = useClientsStore()
const query = ref('')
const results = ref([])
const selected = ref(null)
const showDropdown = ref(false)
const loading = ref(false)
const showNewForm = ref(false)
const creatingClient = ref(false)
const newClient = reactive({
tip_persoana: 'PF',
denumire: '', nume: '', prenume: '',
cod_fiscal: '', telefon: '', email: '', adresa: '',
})
let searchTimeout = null
function displayName(c) {
if (c.tip_persoana === 'PJ' && c.denumire) return c.denumire
const parts = [c.nume, c.prenume].filter(Boolean)
return parts.length > 0 ? parts.join(' ') : (c.denumire || '-')
}
function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(async () => {
if (query.value.length < 2) {
results.value = []
return
}
loading.value = true
results.value = await clientsStore.search(query.value)
loading.value = false
}, 200)
}
function selectClient(c) {
selected.value = c
query.value = displayName(c)
showDropdown.value = false
showNewForm.value = false
emit('update:modelValue', c.id)
emit('select', c)
}
function clear() {
selected.value = null
query.value = ''
emit('update:modelValue', null)
emit('select', null)
}
async function handleCreateClient() {
creatingClient.value = true
try {
const row = await clientsStore.create({ ...newClient })
selectClient(row)
showNewForm.value = false
Object.assign(newClient, {
tip_persoana: 'PF', denumire: '', nume: '', prenume: '',
cod_fiscal: '', telefon: '', email: '', adresa: '',
})
} finally {
creatingClient.value = false
}
}
// Load initial client if modelValue is set
watch(() => props.modelValue, async (id) => {
if (id && !selected.value) {
const c = await clientsStore.getById(id)
if (c) {
selected.value = c
query.value = displayName(c)
}
}
}, { immediate: true })
</script>

View File

@@ -14,15 +14,34 @@
</label>
</div>
<!-- Descriere -->
<div>
<!-- Descriere with autocomplete -->
<div class="relative">
<input
v-model="form.descriere"
type="text"
required
placeholder="Descriere operatiune / material"
:placeholder="form.tip === 'manopera' ? 'Cauta norma sau scrie descriere...' : 'Cauta material sau scrie descriere...'"
class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
@input="onDescriereInput"
@focus="showSuggestions = true"
/>
<!-- Autocomplete dropdown -->
<ul
v-if="showSuggestions && suggestions.length > 0"
class="absolute z-10 mt-1 w-full bg-white border border-gray-200 rounded-md shadow-lg max-h-48 overflow-auto"
>
<li
v-for="s in suggestions"
:key="s.id"
class="px-3 py-2 hover:bg-blue-50 cursor-pointer text-sm"
@mousedown="selectSuggestion(s)"
>
<span class="font-medium">{{ s.denumire }}</span>
<span v-if="s.ansamblu_denumire" class="text-gray-400 ml-2">({{ s.ansamblu_denumire }})</span>
<span v-if="s.cod" class="text-gray-400 ml-1">[{{ s.cod }}]</span>
<span v-if="s.pret !== undefined" class="text-gray-400 ml-2">{{ (s.pret || 0).toFixed(2) }}/{{ s.um || 'buc' }}</span>
</li>
</ul>
</div>
<!-- Manopera fields -->
@@ -99,13 +118,16 @@
</template>
<script setup>
import { reactive, computed } from 'vue'
import { reactive, computed, ref, watch } from 'vue'
import { useOrdersStore } from '../../stores/orders.js'
const emit = defineEmits(['add'])
const ordersStore = useOrdersStore()
const form = reactive({
tip: 'manopera',
descriere: '',
norma_id: null,
ore: 0,
pret_ora: 0,
cantitate: 0,
@@ -113,16 +135,57 @@ const form = reactive({
um: 'buc',
})
const suggestions = ref([])
const showSuggestions = ref(false)
let searchTimeout = null
const computedTotal = computed(() => {
if (form.tip === 'manopera') return (form.ore || 0) * (form.pret_ora || 0)
return (form.cantitate || 0) * (form.pret_unitar || 0)
})
function onDescriereInput() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(async () => {
if (form.descriere.length < 2) {
suggestions.value = []
return
}
if (form.tip === 'manopera') {
suggestions.value = await ordersStore.searchNorme(form.descriere)
} else {
suggestions.value = await ordersStore.searchPreturi(form.descriere)
}
showSuggestions.value = true
}, 200)
}
function selectSuggestion(s) {
showSuggestions.value = false
if (form.tip === 'manopera') {
form.descriere = s.denumire
form.norma_id = s.id
form.ore = s.ore_normate || 0
} else {
form.descriere = s.denumire
form.pret_unitar = s.pret || 0
form.um = s.um || 'buc'
form.cantitate = 1
}
}
// Clear suggestions when switching tip
watch(() => form.tip, () => {
suggestions.value = []
showSuggestions.value = false
})
function handleSubmit() {
if (!form.descriere) return
emit('add', { ...form })
// Reset
form.descriere = ''
form.norma_id = null
form.ore = 0
form.pret_ora = 0
form.cantitate = 0

View File

@@ -12,7 +12,7 @@
<!-- Selected vehicle display -->
<div v-if="selected" class="mt-1 text-sm text-gray-600">
{{ selected.nr_inmatriculare }} - {{ selected.client_nume }}
<span v-if="selected.marca_denumire"> ({{ selected.marca_denounire }} {{ selected.model_denumire }})</span>
<span v-if="selected.marca_denumire"> ({{ selected.marca_denumire }} {{ selected.model_denumire }})</span>
<button @click="clear" class="ml-2 text-red-500 hover:text-red-700 text-xs">Sterge</button>
</div>
<!-- Dropdown results -->
@@ -40,27 +40,117 @@
>
Niciun vehicul gasit
</div>
<!-- Create new vehicle inline -->
<div v-if="!selected" class="mt-2">
<button
type="button"
@click="showNewVehicle = !showNewVehicle"
class="text-sm text-blue-600 hover:underline"
>
{{ showNewVehicle ? 'Ascunde formular vehicul' : '+ Vehicul nou' }}
</button>
<div v-if="showNewVehicle" class="mt-3 space-y-3 bg-gray-50 rounded-lg p-4">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Nr. inmatriculare</label>
<input v-model="newVehicle.nr_inmatriculare" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="B 123 ABC" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Serie sasiu (VIN)</label>
<input v-model="newVehicle.serie_sasiu" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Marca</label>
<div class="flex gap-1">
<select v-model="newVehicle.marca_id" class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" @change="onNewMarcaChange">
<option :value="null">-- Selecteaza --</option>
<option v-for="m in marci" :key="m.id" :value="m.id">{{ m.denumire }}</option>
</select>
<div v-if="showNewMarca" class="flex gap-1">
<input v-model="newMarcaName" type="text" placeholder="Marca noua" class="w-28 px-2 py-2 border border-gray-300 rounded-md text-sm" />
<button type="button" @click="createMarca" class="px-2 py-1 bg-green-600 text-white text-xs rounded-md hover:bg-green-700">OK</button>
</div>
<button v-else type="button" @click="showNewMarca = true" class="px-2 py-1 text-blue-600 text-xs hover:underline">+</button>
</div>
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Model</label>
<div class="flex gap-1">
<select v-model="newVehicle.model_id" class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm">
<option :value="null">-- Selecteaza --</option>
<option v-for="m in modele" :key="m.id" :value="m.id">{{ m.denumire }}</option>
</select>
<div v-if="showNewModel && newVehicle.marca_id" class="flex gap-1">
<input v-model="newModelName" type="text" placeholder="Model nou" class="w-28 px-2 py-2 border border-gray-300 rounded-md text-sm" />
<button type="button" @click="createModel" class="px-2 py-1 bg-green-600 text-white text-xs rounded-md hover:bg-green-700">OK</button>
</div>
<button v-else-if="newVehicle.marca_id" type="button" @click="showNewModel = true" class="px-2 py-1 text-blue-600 text-xs hover:underline">+</button>
</div>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">An fabricatie</label>
<input v-model.number="newVehicle.an_fabricatie" type="number" min="1900" max="2030" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Nume client</label>
<input v-model="newVehicle.client_nume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<button
type="button"
@click="handleCreateVehicle"
:disabled="creatingVehicle"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700 disabled:opacity-50"
>
{{ creatingVehicle ? 'Se salveaza...' : 'Salveaza vehicul' }}
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
import { ref, reactive, watch, onMounted } from 'vue'
import { useVehiclesStore } from '../../stores/vehicles.js'
import { execSQL, notifyTableChanged } from '../../db/database.js'
import { syncEngine } from '../../db/sync.js'
import { useAuthStore } from '../../stores/auth.js'
const props = defineProps({
modelValue: { type: String, default: null },
label: { type: String, default: 'Vehicul' },
placeholder: { type: String, default: 'Cauta dupa nr. inmatriculare sau client...' },
clientId: { type: String, default: null },
})
const emit = defineEmits(['update:modelValue', 'select'])
const vehiclesStore = useVehiclesStore()
const auth = useAuthStore()
const query = ref('')
const results = ref([])
const selected = ref(null)
const showDropdown = ref(false)
const loading = ref(false)
const showNewVehicle = ref(false)
const creatingVehicle = ref(false)
const marci = ref([])
const modele = ref([])
const showNewMarca = ref(false)
const showNewModel = ref(false)
const newMarcaName = ref('')
const newModelName = ref('')
const newVehicle = reactive({
nr_inmatriculare: '', serie_sasiu: '',
marca_id: null, model_id: null,
an_fabricatie: null, client_nume: '',
})
let searchTimeout = null
@@ -72,7 +162,7 @@ function onSearch() {
return
}
loading.value = true
results.value = await vehiclesStore.search(query.value)
results.value = await vehiclesStore.search(query.value, props.clientId)
loading.value = false
}, 200)
}
@@ -81,6 +171,7 @@ function selectVehicle(v) {
selected.value = v
query.value = v.nr_inmatriculare
showDropdown.value = false
showNewVehicle.value = false
emit('update:modelValue', v.id)
emit('select', v)
}
@@ -92,6 +183,70 @@ function clear() {
emit('select', null)
}
async function onNewMarcaChange() {
newVehicle.model_id = null
showNewModel.value = false
newModelName.value = ''
if (newVehicle.marca_id) {
modele.value = await vehiclesStore.getModele(newVehicle.marca_id)
} else {
modele.value = []
}
}
async function createMarca() {
if (!newMarcaName.value.trim()) return
const id = crypto.randomUUID()
const data = { id, tenant_id: auth.tenantId, denumire: newMarcaName.value.trim(), activ: 1 }
await execSQL(
`INSERT INTO catalog_marci (id, tenant_id, denumire, activ) VALUES (?,?,?,?)`,
[id, auth.tenantId, newMarcaName.value.trim(), 1]
)
notifyTableChanged('catalog_marci')
await syncEngine.addToQueue('catalog_marci', id, 'INSERT', data)
marci.value = await vehiclesStore.getMarci()
newVehicle.marca_id = id
showNewMarca.value = false
newMarcaName.value = ''
modele.value = []
}
async function createModel() {
if (!newModelName.value.trim() || !newVehicle.marca_id) return
const id = crypto.randomUUID()
const data = { id, marca_id: newVehicle.marca_id, denumire: newModelName.value.trim() }
await execSQL(
`INSERT INTO catalog_modele (id, marca_id, denumire) VALUES (?,?,?)`,
[id, newVehicle.marca_id, newModelName.value.trim()]
)
notifyTableChanged('catalog_modele')
await syncEngine.addToQueue('catalog_modele', id, 'INSERT', data)
modele.value = await vehiclesStore.getModele(newVehicle.marca_id)
newVehicle.model_id = id
showNewModel.value = false
newModelName.value = ''
}
async function handleCreateVehicle() {
if (!newVehicle.nr_inmatriculare) return
creatingVehicle.value = true
try {
const vehicleData = { ...newVehicle }
if (props.clientId) vehicleData.client_id = props.clientId
const id = await vehiclesStore.create(vehicleData)
const v = await vehiclesStore.getById(id)
if (v) selectVehicle(v)
showNewVehicle.value = false
Object.assign(newVehicle, {
nr_inmatriculare: '', serie_sasiu: '',
marca_id: null, model_id: null,
an_fabricatie: null, client_nume: '',
})
} finally {
creatingVehicle.value = false
}
}
// Load initial vehicle if modelValue is set
watch(() => props.modelValue, async (id) => {
if (id && !selected.value) {
@@ -103,6 +258,25 @@ watch(() => props.modelValue, async (id) => {
}
}, { immediate: true })
// Close dropdown on outside click
function onClickOutside() { showDropdown.value = false }
// When clientId changes, reset selection and load client vehicles
watch(() => props.clientId, async (newClientId, oldClientId) => {
if (newClientId !== oldClientId) {
selected.value = null
query.value = ''
results.value = []
emit('update:modelValue', null)
emit('select', null)
// Auto-load client vehicles
if (newClientId) {
loading.value = true
results.value = await vehiclesStore.getByClient(newClientId)
loading.value = false
if (results.value.length > 0) showDropdown.value = true
}
}
})
onMounted(async () => {
marci.value = await vehiclesStore.getMarci()
})
</script>

View File

@@ -4,23 +4,50 @@ export const SCHEMA_SQL = `
adresa TEXT, telefon TEXT, email TEXT, iban TEXT, banca TEXT,
plan TEXT DEFAULT 'free', trial_expires_at TEXT, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS clients (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
tip_persoana TEXT DEFAULT 'PF',
denumire TEXT,
nume TEXT,
prenume TEXT,
cod_fiscal TEXT,
reg_com TEXT,
telefon TEXT,
email TEXT,
adresa TEXT,
judet TEXT,
oras TEXT,
cod_postal TEXT,
tara TEXT DEFAULT 'RO',
cont_iban TEXT,
banca TEXT,
activ INTEGER DEFAULT 1,
oracle_id INTEGER,
created_at TEXT,
updated_at TEXT
);
CREATE TABLE IF NOT EXISTS vehicles (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
client_id TEXT,
client_nume TEXT, client_telefon TEXT, client_email TEXT,
client_cod_fiscal TEXT, client_adresa TEXT,
client_cod_fiscal TEXT, client_cui TEXT, client_adresa TEXT,
nr_inmatriculare TEXT, marca_id TEXT, model_id TEXT,
an_fabricatie INTEGER, serie_sasiu TEXT, tip_motor_id TEXT,
an_fabricatie INTEGER, vin TEXT, serie_sasiu TEXT, tip_motor_id TEXT,
capacitate_motor TEXT, putere_kw TEXT,
oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS orders (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
nr_comanda TEXT, data_comanda TEXT, vehicle_id TEXT,
client_id TEXT,
tip_deviz_id TEXT, status TEXT DEFAULT 'DRAFT',
km_intrare INTEGER, observatii TEXT,
mecanic_id TEXT,
client_nume TEXT, client_telefon TEXT, nr_auto TEXT,
marca_denumire TEXT, model_denumire TEXT,
total_manopera REAL DEFAULT 0, total_materiale REAL DEFAULT 0, total_general REAL DEFAULT 0,
token_client TEXT, created_by TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
token_client TEXT, status_client TEXT, created_by TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS order_lines (
id TEXT PRIMARY KEY, order_id TEXT NOT NULL, tenant_id TEXT NOT NULL,
@@ -32,9 +59,12 @@ export const SCHEMA_SQL = `
);
CREATE TABLE IF NOT EXISTS invoices (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, order_id TEXT,
client_id TEXT,
nr_factura TEXT, serie_factura TEXT, data_factura TEXT,
tip_document TEXT DEFAULT 'FACTURA',
modalitate_plata TEXT, client_nume TEXT, client_cod_fiscal TEXT, nr_auto TEXT,
total_fara_tva REAL, tva REAL, total_general REAL,
total REAL DEFAULT 0, status TEXT DEFAULT 'EMISA',
oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS appointments (
@@ -46,31 +76,31 @@ export const SCHEMA_SQL = `
);
CREATE TABLE IF NOT EXISTS catalog_marci (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, activ INTEGER DEFAULT 1,
oracle_id INTEGER, updated_at TEXT
oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_modele (
id TEXT PRIMARY KEY, marca_id TEXT, denumire TEXT, oracle_id INTEGER, updated_at TEXT
id TEXT PRIMARY KEY, marca_id TEXT, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_ansamble (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_norme (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, cod TEXT, denumire TEXT,
ore_normate REAL, ansamblu_id TEXT, oracle_id INTEGER, updated_at TEXT
ore_normate REAL, ansamblu_id TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_preturi (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, pret REAL, um TEXT,
oracle_id INTEGER, updated_at TEXT
oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_tipuri_deviz (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS catalog_tipuri_motoare (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS mecanici (
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, user_id TEXT,
nume TEXT, prenume TEXT, activ INTEGER DEFAULT 1, oracle_id INTEGER, updated_at TEXT
nume TEXT, prenume TEXT, activ INTEGER DEFAULT 1, oracle_id INTEGER, created_at TEXT, updated_at TEXT
);
CREATE TABLE IF NOT EXISTS _sync_queue (
id TEXT PRIMARY KEY, table_name TEXT, row_id TEXT,
@@ -82,7 +112,7 @@ export const SCHEMA_SQL = `
`;
export const SYNC_TABLES = [
'vehicles', 'orders', 'order_lines', 'invoices', 'appointments',
'clients', 'vehicles', 'orders', 'order_lines', 'invoices', 'appointments',
'catalog_marci', 'catalog_modele', 'catalog_ansamble', 'catalog_norme',
'catalog_preturi', 'catalog_tipuri_deviz', 'catalog_tipuri_motoare', 'mecanici'
];

View File

@@ -87,8 +87,8 @@ const mobileMenuOpen = ref(false)
const navItems = [
{ path: '/dashboard', label: 'Dashboard' },
{ path: '/orders', label: 'Comenzi' },
{ path: '/invoices', label: 'Facturi' },
{ path: '/clients', label: 'Clienti' },
{ path: '/vehicles', label: 'Vehicule' },
{ path: '/appointments', label: 'Programari' },
{ path: '/catalog', label: 'Catalog' },
@@ -97,7 +97,7 @@ const navItems = [
const mobileNavItems = [
{ path: '/dashboard', label: 'Acasa' },
{ path: '/orders', label: 'Comenzi' },
{ path: '/clients', label: 'Clienti' },
{ path: '/vehicles', label: 'Vehicule' },
{ path: '/appointments', label: 'Programari' },
{ path: '/settings', label: 'Setari' },

View File

@@ -7,9 +7,10 @@ const router = createRouter({
{ path: '/login', component: () => import('../views/auth/LoginView.vue'), meta: { layout: 'auth' } },
{ path: '/register', component: () => import('../views/auth/RegisterView.vue'), meta: { layout: 'auth' } },
{ path: '/dashboard', component: () => import('../views/dashboard/DashboardView.vue'), meta: { requiresAuth: true } },
{ path: '/orders', component: () => import('../views/orders/OrdersListView.vue'), meta: { requiresAuth: true } },
{ path: '/orders', redirect: '/dashboard' },
{ path: '/orders/new', component: () => import('../views/orders/OrderCreateView.vue'), meta: { requiresAuth: true } },
{ path: '/orders/:id', component: () => import('../views/orders/OrderDetailView.vue'), meta: { requiresAuth: true } },
{ path: '/clients', component: () => import('../views/clients/ClientsListView.vue'), meta: { requiresAuth: true } },
{ path: '/vehicles', component: () => import('../views/vehicles/VehiclesListView.vue'), meta: { requiresAuth: true } },
{ path: '/appointments', component: () => import('../views/appointments/AppointmentsView.vue'), meta: { requiresAuth: true } },
{ path: '/catalog', component: () => import('../views/catalog/CatalogView.vue'), meta: { requiresAuth: true } },

View File

@@ -0,0 +1,91 @@
import { defineStore } from 'pinia'
import { execSQL, notifyTableChanged } from '../db/database.js'
import { syncEngine } from '../db/sync.js'
import { useAuthStore } from './auth.js'
export const useClientsStore = defineStore('clients', () => {
const auth = useAuthStore()
async function getAll(search = '') {
let sql = `SELECT * FROM clients WHERE tenant_id = ?`
const params = [auth.tenantId]
if (search && search.length >= 2) {
sql += ` AND (denumire LIKE ? OR cod_fiscal LIKE ? OR telefon LIKE ? OR nume LIKE ? OR prenume LIKE ?)`
const like = `%${search}%`
params.push(like, like, like, like, like)
}
sql += ` ORDER BY denumire ASC`
return await execSQL(sql, params)
}
async function getById(id) {
const rows = await execSQL(`SELECT * FROM clients WHERE id = ? AND tenant_id = ?`, [id, auth.tenantId])
return rows[0] || null
}
async function create(data) {
const id = data.id || crypto.randomUUID()
const now = new Date().toISOString()
const row = {
id, tenant_id: auth.tenantId,
tip_persoana: data.tip_persoana || 'PF',
denumire: data.denumire || null,
nume: data.nume || null,
prenume: data.prenume || null,
cod_fiscal: data.cod_fiscal || null,
reg_com: data.reg_com || null,
telefon: data.telefon || null,
email: data.email || null,
adresa: data.adresa || null,
judet: data.judet || null,
oras: data.oras || null,
cod_postal: data.cod_postal || null,
tara: data.tara || 'RO',
cont_iban: data.cont_iban || null,
banca: data.banca || null,
activ: data.activ ?? 1,
oracle_id: data.oracle_id || null,
created_at: now, updated_at: now
}
await execSQL(
`INSERT INTO clients (id, tenant_id, tip_persoana, denumire, nume, prenume, cod_fiscal, reg_com, telefon, email, adresa, judet, oras, cod_postal, tara, cont_iban, banca, activ, oracle_id, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[row.id, row.tenant_id, row.tip_persoana, row.denumire, row.nume, row.prenume, row.cod_fiscal, row.reg_com, row.telefon, row.email, row.adresa, row.judet, row.oras, row.cod_postal, row.tara, row.cont_iban, row.banca, row.activ, row.oracle_id, row.created_at, row.updated_at]
)
await syncEngine.addToQueue('clients', id, 'INSERT', row)
notifyTableChanged('clients')
return row
}
async function update(id, data) {
const fields = []
const values = []
for (const [key, val] of Object.entries(data)) {
if (val !== undefined && key !== 'id' && key !== 'tenant_id') {
fields.push(`${key} = ?`)
values.push(val)
}
}
if (fields.length === 0) return
const now = new Date().toISOString()
fields.push('updated_at = ?')
values.push(now)
values.push(id, auth.tenantId)
await execSQL(`UPDATE clients SET ${fields.join(', ')} WHERE id = ? AND tenant_id = ?`, values)
const updated = await getById(id)
await syncEngine.addToQueue('clients', id, 'UPDATE', updated)
notifyTableChanged('clients')
return updated
}
async function search(query) {
if (!query || query.length < 2) return []
const like = `%${query}%`
return await execSQL(
`SELECT * FROM clients WHERE tenant_id = ? AND (denumire LIKE ? OR cod_fiscal LIKE ? OR telefon LIKE ? OR nume LIKE ? OR prenume LIKE ?) LIMIT 10`,
[auth.tenantId, like, like, like, like, like]
)
}
return { getAll, getById, create, update, search }
})

View File

@@ -6,13 +6,19 @@ import { useAuthStore } from './auth.js'
export const useOrdersStore = defineStore('orders', () => {
const auth = useAuthStore()
async function getAll(statusFilter = null) {
let sql = `SELECT * FROM orders WHERE tenant_id = ? ORDER BY created_at DESC`
async function getAll(statusFilter = null, search = '') {
let sql = `SELECT * FROM orders WHERE tenant_id = ?`
const params = [auth.tenantId]
if (statusFilter) {
sql = `SELECT * FROM orders WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC`
sql += ` AND status = ?`
params.push(statusFilter)
}
if (search && search.length >= 2) {
sql += ` AND (nr_auto LIKE ? OR client_nume LIKE ? OR nr_comanda LIKE ?)`
const like = `%${search}%`
params.push(like, like, like)
}
sql += ` ORDER BY created_at DESC`
return execSQL(sql, params)
}
@@ -40,13 +46,23 @@ export const useOrdersStore = defineStore('orders', () => {
const now = new Date().toISOString()
const nr = `CMD-${Date.now().toString(36).toUpperCase()}`
// Lookup client info for denormalized fields
let clientNume = '', clientTelefon = ''
if (data.client_id) {
const [c] = await execSQL(`SELECT * FROM clients WHERE id = ?`, [data.client_id])
if (c) {
clientNume = c.tip_persoana === 'PJ' ? (c.denumire || '') : [c.nume, c.prenume].filter(Boolean).join(' ')
clientTelefon = c.telefon || ''
}
}
// Lookup vehicle info for denormalized fields
let clientNume = '', clientTelefon = '', nrAuto = '', marcaDenumire = '', modelDenumire = ''
let nrAuto = '', marcaDenumire = '', modelDenumire = ''
if (data.vehicle_id) {
const [v] = await execSQL(`SELECT * FROM vehicles WHERE id = ?`, [data.vehicle_id])
if (v) {
clientNume = v.client_nume || ''
clientTelefon = v.client_telefon || ''
if (!clientNume) clientNume = v.client_nume || ''
if (!clientTelefon) clientTelefon = v.client_telefon || ''
nrAuto = v.nr_inmatriculare || ''
const [marca] = await execSQL(`SELECT denumire FROM catalog_marci WHERE id = ?`, [v.marca_id])
const [model] = await execSQL(`SELECT denumire FROM catalog_modele WHERE id = ?`, [v.model_id])
@@ -56,16 +72,18 @@ export const useOrdersStore = defineStore('orders', () => {
}
await execSQL(
`INSERT INTO orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, tip_deviz_id, status, km_intrare, observatii, client_nume, client_telefon, nr_auto, marca_denumire, model_denumire, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, nr, now, data.vehicle_id || null, data.tip_deviz_id || null,
`INSERT INTO orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, client_id, tip_deviz_id, status, km_intrare, observatii, client_nume, client_telefon, nr_auto, marca_denumire, model_denumire, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, nr, now, data.vehicle_id || null, data.client_id || null,
data.tip_deviz_id || null,
'DRAFT', data.km_intrare || 0, data.observatii || '',
clientNume, clientTelefon, nrAuto, marcaDenumire, modelDenumire, now, now]
)
notifyTableChanged('orders')
await syncEngine.addToQueue('orders', id, 'INSERT', {
id, tenant_id: auth.tenantId, nr_comanda: nr, data_comanda: now,
vehicle_id: data.vehicle_id, tip_deviz_id: data.tip_deviz_id,
vehicle_id: data.vehicle_id, client_id: data.client_id,
tip_deviz_id: data.tip_deviz_id,
status: 'DRAFT', km_intrare: data.km_intrare || 0, observatii: data.observatii || '',
client_nume: clientNume, client_telefon: clientTelefon, nr_auto: nrAuto,
marca_denumire: marcaDenumire, model_denumire: modelDenumire
@@ -137,6 +155,49 @@ export const useOrdersStore = defineStore('orders', () => {
})
}
async function updateHeader(orderId, data) {
const now = new Date().toISOString()
// Re-denormalize client and vehicle info if IDs changed
let clientNume = data.client_nume, clientTelefon = data.client_telefon
let nrAuto = data.nr_auto, marcaDenumire = data.marca_denumire, modelDenumire = data.model_denumire
if (data.client_id) {
const [c] = await execSQL(`SELECT * FROM clients WHERE id = ?`, [data.client_id])
if (c) {
clientNume = c.tip_persoana === 'PJ' ? (c.denumire || '') : [c.nume, c.prenume].filter(Boolean).join(' ')
clientTelefon = c.telefon || ''
}
}
if (data.vehicle_id) {
const [v] = await execSQL(`SELECT * FROM vehicles WHERE id = ?`, [data.vehicle_id])
if (v) {
nrAuto = v.nr_inmatriculare || ''
const [marca] = await execSQL(`SELECT denumire FROM catalog_marci WHERE id = ?`, [v.marca_id])
const [model] = await execSQL(`SELECT denumire FROM catalog_modele WHERE id = ?`, [v.model_id])
marcaDenumire = marca?.denumire || ''
modelDenumire = model?.denumire || ''
}
}
await execSQL(
`UPDATE orders SET client_id=?, vehicle_id=?, tip_deviz_id=?, km_intrare=?, observatii=?,
client_nume=?, client_telefon=?, nr_auto=?, marca_denumire=?, model_denumire=?, updated_at=?
WHERE id=?`,
[data.client_id || null, data.vehicle_id || null, data.tip_deviz_id || null,
data.km_intrare || 0, data.observatii || '',
clientNume || '', clientTelefon || '', nrAuto || '', marcaDenumire || '', modelDenumire || '',
now, orderId]
)
notifyTableChanged('orders')
await syncEngine.addToQueue('orders', orderId, 'UPDATE', {
client_id: data.client_id, vehicle_id: data.vehicle_id, tip_deviz_id: data.tip_deviz_id,
km_intrare: data.km_intrare, observatii: data.observatii,
client_nume: clientNume, client_telefon: clientTelefon, nr_auto: nrAuto,
marca_denumire: marcaDenumire, model_denumire: modelDenumire
})
}
async function validateOrder(orderId) {
const now = new Date().toISOString()
await execSQL(`UPDATE orders SET status='VALIDAT', updated_at=? WHERE id=?`, [now, orderId])
@@ -144,6 +205,59 @@ export const useOrdersStore = defineStore('orders', () => {
await syncEngine.addToQueue('orders', orderId, 'UPDATE', { status: 'VALIDAT' })
}
async function devalidateOrder(orderId) {
const now = new Date().toISOString()
await execSQL(`UPDATE orders SET status='DRAFT', updated_at=? WHERE id=?`, [now, orderId])
notifyTableChanged('orders')
await syncEngine.addToQueue('orders', orderId, 'UPDATE', { status: 'DRAFT' })
}
async function deleteOrder(orderId) {
await execSQL(`DELETE FROM order_lines WHERE order_id = ?`, [orderId])
await execSQL(`DELETE FROM orders WHERE id = ?`, [orderId])
notifyTableChanged('order_lines')
notifyTableChanged('orders')
await syncEngine.addToQueue('orders', orderId, 'DELETE', {})
}
async function deleteInvoice(invoiceId) {
const [inv] = await execSQL(`SELECT * FROM invoices WHERE id = ?`, [invoiceId])
if (!inv) return
// Set order back to VALIDAT
if (inv.order_id) {
const now = new Date().toISOString()
await execSQL(`UPDATE orders SET status='VALIDAT', updated_at=? WHERE id=?`, [now, inv.order_id])
notifyTableChanged('orders')
await syncEngine.addToQueue('orders', inv.order_id, 'UPDATE', { status: 'VALIDAT' })
}
await execSQL(`DELETE FROM invoices WHERE id = ?`, [invoiceId])
notifyTableChanged('invoices')
await syncEngine.addToQueue('invoices', invoiceId, 'DELETE', {})
}
async function searchNorme(query) {
if (!query || query.length < 2) return []
const like = `%${query}%`
return execSQL(
`SELECT n.*, a.denumire as ansamblu_denumire
FROM catalog_norme n
LEFT JOIN catalog_ansamble a ON n.ansamblu_id = a.id
WHERE n.tenant_id = ? AND (n.denumire LIKE ? OR n.cod LIKE ?)
ORDER BY n.denumire LIMIT 10`,
[auth.tenantId, like, like]
)
}
async function searchPreturi(query) {
if (!query || query.length < 2) return []
const like = `%${query}%`
return execSQL(
`SELECT * FROM catalog_preturi WHERE tenant_id = ? AND denumire LIKE ?
ORDER BY denumire LIMIT 10`,
[auth.tenantId, like]
)
}
async function getStats() {
const [total] = await execSQL(
`SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ?`, [auth.tenantId]
@@ -154,20 +268,28 @@ export const useOrdersStore = defineStore('orders', () => {
const [validat] = await execSQL(
`SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ? AND status = 'VALIDAT'`, [auth.tenantId]
)
const [facturat] = await execSQL(
`SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ? AND status = 'FACTURAT'`, [auth.tenantId]
)
const [totalVehicles] = await execSQL(
`SELECT COUNT(*) as cnt FROM vehicles WHERE tenant_id = ?`, [auth.tenantId]
)
const [revenue] = await execSQL(
`SELECT COALESCE(SUM(total_general), 0) as s FROM orders WHERE tenant_id = ? AND status = 'VALIDAT'`, [auth.tenantId]
`SELECT COALESCE(SUM(total_general), 0) as s FROM orders WHERE tenant_id = ? AND status IN ('VALIDAT', 'FACTURAT')`, [auth.tenantId]
)
return {
totalOrders: total?.cnt || 0,
draftOrders: draft?.cnt || 0,
validatedOrders: validat?.cnt || 0,
facturatedOrders: facturat?.cnt || 0,
totalVehicles: totalVehicles?.cnt || 0,
totalRevenue: revenue?.s || 0,
}
}
return { getAll, getById, getLines, getRecentOrders, create, addLine, removeLine, validateOrder, getStats }
return {
getAll, getById, getLines, getRecentOrders, create, addLine, removeLine,
updateHeader, validateOrder, devalidateOrder, deleteOrder, deleteInvoice,
searchNorme, searchPreturi, getStats
}
})

View File

@@ -42,14 +42,26 @@ export const useVehiclesStore = defineStore('vehicles', () => {
return rows[0] || null
}
async function getByClient(clientId) {
return execSQL(
`SELECT v.*, m.denumire as marca_denumire, mo.denumire as model_denumire
FROM vehicles v
LEFT JOIN catalog_marci m ON v.marca_id = m.id
LEFT JOIN catalog_modele mo ON v.model_id = mo.id
WHERE v.client_id = ? AND v.tenant_id = ?
ORDER BY v.nr_inmatriculare`,
[clientId, auth.tenantId]
)
}
async function create(data) {
const id = crypto.randomUUID()
const now = new Date().toISOString()
await execSQL(
`INSERT INTO vehicles (id, tenant_id, client_nume, client_telefon, client_email, client_cod_fiscal, client_adresa, nr_inmatriculare, marca_id, model_id, an_fabricatie, serie_sasiu, tip_motor_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, data.client_nume || '', data.client_telefon || '',
`INSERT INTO vehicles (id, tenant_id, client_id, client_nume, client_telefon, client_email, client_cod_fiscal, client_adresa, nr_inmatriculare, marca_id, model_id, an_fabricatie, serie_sasiu, tip_motor_id, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, data.client_id || null, data.client_nume || '', data.client_telefon || '',
data.client_email || '', data.client_cod_fiscal || '', data.client_adresa || '',
data.nr_inmatriculare || '', data.marca_id || null, data.model_id || null,
data.an_fabricatie || null, data.serie_sasiu || '', data.tip_motor_id || null, now, now]
@@ -72,9 +84,24 @@ export const useVehiclesStore = defineStore('vehicles', () => {
await syncEngine.addToQueue('vehicles', id, 'UPDATE', data)
}
async function search(query) {
if (!query || query.length < 2) return []
async function search(query, clientId = null) {
if (!query || query.length < 2) {
// If no query but clientId, return client vehicles
if (clientId) return getByClient(clientId)
return []
}
const like = `%${query}%`
if (clientId) {
return execSQL(
`SELECT v.*, m.denumire as marca_denumire, mo.denumire as model_denumire
FROM vehicles v
LEFT JOIN catalog_marci m ON v.marca_id = m.id
LEFT JOIN catalog_modele mo ON v.model_id = mo.id
WHERE v.tenant_id = ? AND v.client_id = ? AND (v.nr_inmatriculare LIKE ? OR v.client_nume LIKE ?)
ORDER BY v.nr_inmatriculare LIMIT 10`,
[auth.tenantId, clientId, like, like]
)
}
return execSQL(
`SELECT v.*, m.denumire as marca_denumire, mo.denumire as model_denumire
FROM vehicles v
@@ -100,5 +127,5 @@ export const useVehiclesStore = defineStore('vehicles', () => {
)
}
return { getAll, getById, create, update, search, getMarci, getModele }
return { getAll, getById, getByClient, create, update, search, getMarci, getModele }
})

View File

@@ -128,6 +128,51 @@
</div>
</div>
<!-- Tab: Norme -->
<div v-if="activeTab === 'norme'">
<div class="bg-white rounded-lg shadow overflow-hidden mb-6">
<div v-if="norme.length === 0" class="p-8 text-center text-gray-500">Nicio norma gasita.</div>
<table v-else class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Cod</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Denumire</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Ansamblu</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Ore normate</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr v-for="n in norme" :key="n.id" class="hover:bg-gray-50">
<td class="px-4 py-3 text-sm text-gray-500">{{ n.cod || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-900">{{ n.denumire }}</td>
<td class="px-4 py-3 text-sm text-gray-500 hidden md:table-cell">{{ n.ansamblu_denumire || '-' }}</td>
<td class="px-4 py-3 text-sm text-right font-medium text-gray-900">{{ n.ore_normate || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Add Norma form -->
<div class="bg-white rounded-lg shadow p-4 max-w-lg">
<h3 class="text-sm font-semibold text-gray-700 mb-3">Adauga norma</h3>
<form @submit.prevent="addNorma" class="space-y-2">
<div class="grid grid-cols-2 gap-2">
<input v-model="newNorma.cod" type="text" placeholder="Cod" class="px-3 py-2 border border-gray-300 rounded-md text-sm" />
<select v-model="newNorma.ansamblu_id" class="px-3 py-2 border border-gray-300 rounded-md text-sm">
<option :value="null">-- Ansamblu --</option>
<option v-for="a in ansamble" :key="a.id" :value="a.id">{{ a.denumire }}</option>
</select>
</div>
<div class="flex gap-2">
<input v-model="newNorma.denumire" type="text" required placeholder="Denumire norma" class="flex-1 px-3 py-2 border border-gray-300 rounded-md text-sm" />
<input v-model.number="newNorma.ore_normate" type="number" step="0.1" min="0" placeholder="Ore" class="w-20 px-3 py-2 border border-gray-300 rounded-md text-sm" />
<button type="submit" :disabled="savingNorma" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ savingNorma ? '...' : 'Adauga' }}
</button>
</div>
</form>
</div>
</div>
<!-- Tab: Preturi -->
<div v-if="activeTab === 'preturi'">
<div class="bg-white rounded-lg shadow overflow-hidden mb-6">
@@ -208,6 +253,7 @@ const tabs = [
{ key: 'marci', label: 'Marci' },
{ key: 'modele', label: 'Modele' },
{ key: 'ansamble', label: 'Ansamble' },
{ key: 'norme', label: 'Norme' },
{ key: 'preturi', label: 'Preturi' },
{ key: 'tipuri_deviz', label: 'Tipuri deviz' },
]
@@ -327,6 +373,43 @@ async function addAnsamblu() {
}
}
// ---- Norme ----
const norme = ref([])
const newNorma = reactive({ cod: '', denumire: '', ore_normate: null, ansamblu_id: null })
const savingNorma = ref(false)
async function loadNorme() {
norme.value = await execSQL(
`SELECT n.*, a.denumire AS ansamblu_denumire
FROM catalog_norme n
LEFT JOIN catalog_ansamble a ON a.id = n.ansamblu_id
WHERE n.tenant_id=?
ORDER BY n.denumire`,
[auth.tenantId]
)
}
async function addNorma() {
savingNorma.value = true
try {
const id = crypto.randomUUID()
const data = {
id, tenant_id: auth.tenantId, cod: newNorma.cod || null,
denumire: newNorma.denumire, ore_normate: newNorma.ore_normate || 0,
ansamblu_id: newNorma.ansamblu_id || null
}
await execSQL(
`INSERT INTO catalog_norme (id, tenant_id, cod, denumire, ore_normate, ansamblu_id) VALUES (?,?,?,?,?,?)`,
[id, auth.tenantId, data.cod, data.denumire, data.ore_normate, data.ansamblu_id]
)
notifyTableChanged('catalog_norme')
syncEngine.addToQueue('catalog_norme', id, 'INSERT', data)
Object.assign(newNorma, { cod: '', denumire: '', ore_normate: null, ansamblu_id: null })
} finally {
savingNorma.value = false
}
}
// ---- Preturi ----
const preturi = ref([])
const newPret = reactive({ denumire: '', pret: null, um: 'ora' })
@@ -390,6 +473,7 @@ async function addTipDeviz() {
onTableChange('catalog_marci', loadMarci)
onTableChange('catalog_modele', loadModele)
onTableChange('catalog_ansamble', loadAnsamble)
onTableChange('catalog_norme', loadNorme)
onTableChange('catalog_preturi', loadPreturi)
onTableChange('catalog_tipuri_deviz', loadTipuriDeviz)
@@ -398,6 +482,7 @@ watch(activeTab, (tab) => {
if (tab === 'marci') loadMarci()
else if (tab === 'modele') { loadMarci(); loadModele() }
else if (tab === 'ansamble') loadAnsamble()
else if (tab === 'norme') { loadAnsamble(); loadNorme() }
else if (tab === 'preturi') loadPreturi()
else if (tab === 'tipuri_deviz') loadTipuriDeviz()
})

View File

@@ -0,0 +1,287 @@
<template>
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Clienti</h1>
<button
@click="showForm = !showForm; editingId = null"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
{{ showForm ? 'Anuleaza' : '+ Adauga client' }}
</button>
</div>
<!-- New / Edit client form -->
<div v-if="showForm" class="bg-white rounded-lg shadow p-6 mb-6 max-w-2xl">
<h2 class="text-lg font-semibold mb-4">{{ editingId ? 'Editeaza client' : 'Client nou' }}</h2>
<form @submit.prevent="handleSave" class="space-y-4">
<!-- Tip persoana toggle -->
<div class="flex gap-4">
<label class="flex items-center gap-1.5 text-sm">
<input v-model="form.tip_persoana" type="radio" value="PF" class="text-blue-600" />
Persoana fizica (PF)
</label>
<label class="flex items-center gap-1.5 text-sm">
<input v-model="form.tip_persoana" type="radio" value="PJ" class="text-blue-600" />
Persoana juridica (PJ)
</label>
</div>
<!-- PF fields -->
<template v-if="form.tip_persoana === 'PF'">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Nume</label>
<input v-model="form.nume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Prenume</label>
<input v-model="form.prenume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">CNP</label>
<input v-model="form.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="CNP" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
<input v-model="form.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input v-model="form.email" type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Adresa</label>
<input v-model="form.adresa" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</template>
<!-- PJ fields -->
<template v-if="form.tip_persoana === 'PJ'">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Denumire firma</label>
<input v-model="form.denumire" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">CUI</label>
<input v-model="form.cod_fiscal" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="RO12345678" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reg. comertului</label>
<input v-model="form.reg_com" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="J40/1234/2020" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Telefon</label>
<input v-model="form.telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Email</label>
<input v-model="form.email" type="email" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Adresa</label>
<input v-model="form.adresa" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">IBAN</label>
<input v-model="form.cont_iban" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Banca</label>
<input v-model="form.banca" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</template>
<div class="flex gap-2">
<button type="submit" :disabled="saving" class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ saving ? 'Se salveaza...' : (editingId ? 'Salveaza modificarile' : 'Salveaza client') }}
</button>
<button v-if="editingId" type="button" @click="cancelEdit" class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">
Anuleaza
</button>
</div>
</form>
</div>
<!-- Search -->
<div class="mb-4">
<input
v-model="searchQuery"
type="text"
placeholder="Cauta dupa denumire, CUI, telefon..."
class="w-full max-w-md px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
@input="onSearch"
/>
</div>
<!-- Clients table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div v-if="loading" class="p-4 text-center text-gray-500">Se incarca...</div>
<div v-else-if="clients.length === 0" class="p-8 text-center text-gray-500">
Niciun client gasit.
</div>
<table v-else class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Denumire</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">CUI/CNP</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Telefon</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Email</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Tip</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr
v-for="c in clients"
:key="c.id"
class="hover:bg-gray-50 cursor-pointer"
@click="startEdit(c)"
>
<td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ clientDisplayName(c) }}
</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ c.cod_fiscal || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ c.telefon || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ c.email || '-' }}</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="c.tip_persoana === 'PJ' ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-700'"
>
{{ c.tip_persoana || 'PF' }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted, onUnmounted } from 'vue'
import { useClientsStore } from '../../stores/clients.js'
import { onTableChange } from '../../db/database.js'
const clientsStore = useClientsStore()
const clients = ref([])
const loading = ref(true)
const searchQuery = ref('')
const showForm = ref(false)
const saving = ref(false)
const editingId = ref(null)
const form = reactive({
tip_persoana: 'PF',
denumire: '',
nume: '',
prenume: '',
cod_fiscal: '',
reg_com: '',
telefon: '',
email: '',
adresa: '',
judet: '',
oras: '',
cod_postal: '',
tara: 'RO',
cont_iban: '',
banca: '',
})
let searchTimeout = null
async function loadClients() {
loading.value = true
clients.value = await clientsStore.getAll(searchQuery.value)
loading.value = false
}
function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(loadClients, 300)
}
function clientDisplayName(c) {
if (c.tip_persoana === 'PJ' && c.denumire) return c.denumire
const parts = [c.nume, c.prenume].filter(Boolean)
return parts.length > 0 ? parts.join(' ') : (c.denumire || '-')
}
function resetForm() {
Object.assign(form, {
tip_persoana: 'PF', denumire: '', nume: '', prenume: '',
cod_fiscal: '', reg_com: '', telefon: '', email: '',
adresa: '', judet: '', oras: '', cod_postal: '', tara: 'RO',
cont_iban: '', banca: '',
})
}
function startEdit(client) {
editingId.value = client.id
showForm.value = true
Object.assign(form, {
tip_persoana: client.tip_persoana || 'PF',
denumire: client.denumire || '',
nume: client.nume || '',
prenume: client.prenume || '',
cod_fiscal: client.cod_fiscal || '',
reg_com: client.reg_com || '',
telefon: client.telefon || '',
email: client.email || '',
adresa: client.adresa || '',
judet: client.judet || '',
oras: client.oras || '',
cod_postal: client.cod_postal || '',
tara: client.tara || 'RO',
cont_iban: client.cont_iban || '',
banca: client.banca || '',
})
}
function cancelEdit() {
editingId.value = null
showForm.value = false
resetForm()
}
async function handleSave() {
saving.value = true
try {
if (editingId.value) {
await clientsStore.update(editingId.value, { ...form })
editingId.value = null
} else {
await clientsStore.create({ ...form })
}
showForm.value = false
resetForm()
} finally {
saving.value = false
}
}
let unsubscribe = null
onMounted(() => {
loadClients()
unsubscribe = onTableChange('clients', loadClients)
})
onUnmounted(() => {
if (unsubscribe) unsubscribe()
})
</script>

View File

@@ -1,88 +1,163 @@
<template>
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Dashboard</h1>
<router-link
to="/orders/new"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
+ Comanda noua
</router-link>
</div>
<UpgradeBanner />
<!-- Stats cards -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="bg-white rounded-lg shadow p-4">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow p-4 cursor-pointer hover:ring-2 hover:ring-blue-300" @click="activeFilter = null">
<p class="text-sm text-gray-500">Total comenzi</p>
<p class="text-2xl font-bold text-gray-900">{{ stats.totalOrders }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="bg-white rounded-lg shadow p-4 cursor-pointer hover:ring-2 hover:ring-yellow-300" @click="activeFilter = 'DRAFT'">
<p class="text-sm text-gray-500">In lucru (draft)</p>
<p class="text-2xl font-bold text-yellow-600">{{ stats.draftOrders }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="bg-white rounded-lg shadow p-4 cursor-pointer hover:ring-2 hover:ring-green-300" @click="activeFilter = 'VALIDAT'">
<p class="text-sm text-gray-500">Validate</p>
<p class="text-2xl font-bold text-green-600">{{ stats.validatedOrders }}</p>
</div>
<div class="bg-white rounded-lg shadow p-4">
<p class="text-sm text-gray-500">Vehicule</p>
<p class="text-2xl font-bold text-blue-600">{{ stats.totalVehicles }}</p>
<div class="bg-white rounded-lg shadow p-4 cursor-pointer hover:ring-2 hover:ring-blue-300" @click="activeFilter = 'FACTURAT'">
<p class="text-sm text-gray-500">Facturate</p>
<p class="text-2xl font-bold text-blue-600">{{ stats.facturatedOrders }}</p>
</div>
</div>
<!-- Revenue card -->
<div class="bg-white rounded-lg shadow p-4 mb-8">
<p class="text-sm text-gray-500">Venituri totale (comenzi validate)</p>
<p class="text-3xl font-bold text-gray-900">{{ stats.totalRevenue.toFixed(2) }} RON</p>
</div>
<!-- Recent orders -->
<div class="bg-white rounded-lg shadow">
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900">Comenzi recente</h2>
<router-link to="/orders" class="text-sm text-blue-600 hover:underline">Vezi toate</router-link>
</div>
<div v-if="recentOrders.length === 0" class="p-4 text-sm text-gray-500">
Nicio comanda inca.
</div>
<ul v-else class="divide-y divide-gray-100">
<li v-for="o in recentOrders" :key="o.id">
<router-link
:to="`/orders/${o.id}`"
class="flex items-center justify-between px-4 py-3 hover:bg-gray-50"
<!-- Filter tabs + Search -->
<div class="flex flex-col md:flex-row md:items-center gap-3 mb-4">
<div class="flex gap-2">
<button
v-for="f in filters"
:key="f.value"
@click="activeFilter = f.value"
class="px-3 py-1.5 text-sm rounded-md"
:class="activeFilter === f.value
? 'bg-blue-600 text-white'
: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50'"
>
<div>
<p class="text-sm font-medium text-gray-900">{{ o.nr_comanda }}</p>
<p class="text-xs text-gray-500">
{{ o.nr_auto || 'Fara vehicul' }}
<span v-if="o.client_nume"> - {{ o.client_nume }}</span>
</p>
{{ f.label }}
</button>
</div>
<div class="text-right">
<input
v-model="searchQuery"
type="text"
placeholder="Cauta dupa nr. auto, client, nr. comanda..."
class="flex-1 max-w-md px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
@input="onSearch"
/>
</div>
<!-- Orders table -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<div v-if="loading" class="p-4 text-center text-gray-500">Se incarca...</div>
<div v-else-if="orders.length === 0" class="p-8 text-center text-gray-500">
Nicio comanda gasita.
</div>
<table v-else class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nr. comanda</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nr. auto</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Client</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Marca / Model</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Status</th>
<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Total</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<tr
v-for="o in orders"
:key="o.id"
class="hover:bg-gray-50 cursor-pointer"
@click="$router.push(`/orders/${o.id}`)"
>
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ o.nr_comanda }}</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ o.nr_auto || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ o.client_nume || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">
{{ [o.marca_denumire, o.model_denumire].filter(Boolean).join(' ') || '-' }}
</td>
<td class="px-4 py-3">
<span
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium"
:class="statusClass(o.status)"
>
{{ o.status }}
</span>
<p class="text-sm font-medium text-gray-900 mt-1">{{ (o.total_general || 0).toFixed(2) }} RON</p>
</div>
</router-link>
</li>
</ul>
</td>
<td class="px-4 py-3 text-sm text-right font-medium text-gray-900">
{{ (o.total_general || 0).toFixed(2) }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, watch, onMounted, onUnmounted } from 'vue'
import { useOrdersStore } from '../../stores/orders.js'
import { useSync } from '../../composables/useSync.js'
import { onTableChange } from '../../db/database.js'
import UpgradeBanner from '../../components/common/UpgradeBanner.vue'
useSync()
const ordersStore = useOrdersStore()
const stats = ref({ totalOrders: 0, draftOrders: 0, validatedOrders: 0, totalVehicles: 0, totalRevenue: 0 })
const recentOrders = ref([])
const stats = ref({ totalOrders: 0, draftOrders: 0, validatedOrders: 0, facturatedOrders: 0, totalVehicles: 0, totalRevenue: 0 })
const orders = ref([])
const loading = ref(true)
const activeFilter = ref(null)
const searchQuery = ref('')
const filters = [
{ label: 'Toate', value: null },
{ label: 'Draft', value: 'DRAFT' },
{ label: 'Validate', value: 'VALIDAT' },
{ label: 'Facturate', value: 'FACTURAT' },
]
let searchTimeout = null
async function loadOrders() {
loading.value = true
orders.value = await ordersStore.getAll(activeFilter.value, searchQuery.value)
loading.value = false
}
async function loadStats() {
stats.value = await ordersStore.getStats()
}
function onSearch() {
if (searchTimeout) clearTimeout(searchTimeout)
searchTimeout = setTimeout(loadOrders, 300)
}
watch(activeFilter, loadOrders)
let unsubOrders = null
onMounted(async () => {
stats.value = await ordersStore.getStats()
recentOrders.value = await ordersStore.getRecentOrders(5)
await Promise.all([loadStats(), loadOrders()])
unsubOrders = onTableChange('orders', () => {
loadOrders()
loadStats()
})
})
onUnmounted(() => {
if (unsubOrders) unsubOrders()
})
function statusClass(status) {

View File

@@ -11,6 +11,7 @@
<thead class="bg-gray-50">
<tr>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nr. factura</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Tip</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Data</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Client</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Nr. auto</th>
@@ -23,6 +24,14 @@
<td class="px-4 py-3 text-sm font-medium text-gray-900">
{{ inv.serie_factura }}{{ inv.nr_factura }}
</td>
<td class="px-4 py-3">
<span
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium"
:class="inv.tip_document === 'BON_FISCAL' ? 'bg-gray-100 text-gray-700' : 'bg-blue-100 text-blue-700'"
>
{{ inv.tip_document === 'BON_FISCAL' ? 'BON FISCAL' : 'FACTURA' }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-600">{{ formatDate(inv.data_factura) }}</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ inv.client_nume || '-' }}</td>
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ inv.nr_auto || '-' }}</td>

View File

@@ -2,46 +2,35 @@
<div>
<div class="flex items-center justify-between mb-6">
<h1 class="text-2xl font-bold text-gray-900">Comanda noua</h1>
<router-link to="/orders" class="text-sm text-gray-500 hover:text-gray-700">Inapoi la comenzi</router-link>
<router-link to="/dashboard" class="text-sm text-gray-500 hover:text-gray-700">Inapoi</router-link>
</div>
<form @submit.prevent="handleCreate" class="bg-white rounded-lg shadow p-6 space-y-4 max-w-2xl">
<!-- Vehicle picker -->
<VehiclePicker v-model="form.vehicle_id" @select="onVehicleSelect" />
<!-- Or create new vehicle inline -->
<div v-if="!form.vehicle_id" class="border-t pt-4">
<button
type="button"
@click="showNewVehicle = !showNewVehicle"
class="text-sm text-blue-600 hover:underline"
>
{{ showNewVehicle ? 'Ascunde formular vehicul' : '+ Vehicul nou' }}
</button>
<div v-if="showNewVehicle" class="mt-3 space-y-3 bg-gray-50 rounded-lg p-4">
<div class="grid grid-cols-2 gap-3">
<div class="bg-white rounded-lg shadow p-6 space-y-6 max-w-2xl">
<!-- Step 1: Select Client -->
<div>
<label class="block text-xs text-gray-500 mb-1">Nr. inmatriculare</label>
<input v-model="newVehicle.nr_inmatriculare" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" placeholder="B 123 ABC" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Nume client</label>
<input v-model="newVehicle.client_nume" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs text-gray-500 mb-1">Telefon client</label>
<input v-model="newVehicle.client_telefon" type="tel" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-xs text-gray-500 mb-1">Serie sasiu (VIN)</label>
<input v-model="newVehicle.serie_sasiu" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
<div class="flex items-center gap-2 mb-3">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-600 text-white text-xs font-bold">1</span>
<h2 class="text-lg font-semibold text-gray-900">Selecteaza client</h2>
</div>
<ClientPicker v-model="form.client_id" @select="onClientSelect" />
</div>
<!-- Step 2: Select Vehicle (visible after client is selected) -->
<div v-if="form.client_id" class="border-t pt-6">
<div class="flex items-center gap-2 mb-3">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-600 text-white text-xs font-bold">2</span>
<h2 class="text-lg font-semibold text-gray-900">Selecteaza vehicul</h2>
</div>
<VehiclePicker v-model="form.vehicle_id" :client-id="form.client_id" @select="onVehicleSelect" />
</div>
<!-- Step 3: Order Details (visible after vehicle is selected) -->
<div v-if="form.vehicle_id" class="border-t pt-6">
<div class="flex items-center gap-2 mb-3">
<span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-600 text-white text-xs font-bold">3</span>
<h2 class="text-lg font-semibold text-gray-900">Detalii comanda</h2>
</div>
<div class="space-y-4">
<!-- Tip deviz -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tip deviz</label>
@@ -62,17 +51,20 @@
<input v-model="form.observatii" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</div>
</div>
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
<button
type="submit"
v-if="form.vehicle_id"
@click="handleCreate"
:disabled="saving"
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{{ saving ? 'Se creeaza...' : 'Creeaza comanda' }}
</button>
</form>
</div>
</div>
</template>
@@ -80,31 +72,23 @@
import { ref, reactive, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useOrdersStore } from '../../stores/orders.js'
import { useVehiclesStore } from '../../stores/vehicles.js'
import { execSQL } from '../../db/database.js'
import { useAuthStore } from '../../stores/auth.js'
import ClientPicker from '../../components/clients/ClientPicker.vue'
import VehiclePicker from '../../components/vehicles/VehiclePicker.vue'
const router = useRouter()
const ordersStore = useOrdersStore()
const vehiclesStore = useVehiclesStore()
const auth = useAuthStore()
const form = reactive({
client_id: null,
vehicle_id: null,
tip_deviz_id: null,
km_intrare: 0,
observatii: '',
})
const newVehicle = reactive({
nr_inmatriculare: '',
client_nume: '',
client_telefon: '',
serie_sasiu: '',
})
const showNewVehicle = ref(false)
const tipuriDeviz = ref([])
const error = ref('')
const saving = ref(false)
@@ -116,18 +100,20 @@ onMounted(async () => {
)
})
function onClientSelect(client) {
if (!client) {
form.vehicle_id = null
}
}
function onVehicleSelect(v) {
if (v) showNewVehicle.value = false
// vehicle selected
}
async function handleCreate() {
error.value = ''
saving.value = true
try {
// Create new vehicle if needed
if (!form.vehicle_id && showNewVehicle.value && newVehicle.nr_inmatriculare) {
form.vehicle_id = await vehiclesStore.create({ ...newVehicle })
}
const id = await ordersStore.create(form)
router.push(`/orders/${id}`)
} catch (e) {

View File

@@ -1,33 +1,11 @@
<template>
<div v-if="order">
<div class="flex items-center justify-between mb-6">
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 mb-6">
<div class="flex items-center gap-3">
<div>
<router-link to="/orders" class="text-sm text-gray-500 hover:text-gray-700">Comenzi</router-link>
<router-link to="/dashboard" class="text-sm text-gray-500 hover:text-gray-700">Comenzi</router-link>
<h1 class="text-2xl font-bold text-gray-900">{{ order.nr_comanda }}</h1>
</div>
<div class="flex gap-2">
<PdfDownloadButton
v-if="order.status !== 'DRAFT'"
type="deviz"
:order-id="order.id"
:nr-comanda="order.nr_comanda"
label="PDF Deviz"
/>
<button
v-if="order.status === 'DRAFT'"
@click="handleValidate"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
>
Valideaza
</button>
<button
v-if="order.status === 'VALIDAT'"
@click="handleFactureaza"
:disabled="facturand"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50"
>
{{ facturand ? 'Se proceseaza...' : 'Factureaza' }}
</button>
<span
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
:class="statusClass(order.status)"
@@ -35,10 +13,105 @@
{{ order.status }}
</span>
</div>
<div class="flex gap-2 flex-wrap">
<PdfDownloadButton
v-if="order.status !== 'DRAFT'"
type="deviz"
:order-id="order.id"
:nr-comanda="order.nr_comanda"
label="PDF Deviz"
/>
<!-- Edit button (DRAFT only) -->
<button
v-if="order.status === 'DRAFT' && !editing"
@click="startEdit"
class="px-4 py-2 bg-gray-600 text-white text-sm rounded-md hover:bg-gray-700"
>
Editeaza
</button>
<!-- Validate button (DRAFT only) -->
<button
v-if="order.status === 'DRAFT' && !editing"
@click="handleValidate"
class="px-4 py-2 bg-green-600 text-white text-sm rounded-md hover:bg-green-700"
>
Valideaza
</button>
<!-- Devalidate button (VALIDAT, no invoice) -->
<button
v-if="order.status === 'VALIDAT'"
@click="confirmDevalidate = true"
class="px-4 py-2 bg-yellow-600 text-white text-sm rounded-md hover:bg-yellow-700"
>
Devalideaza
</button>
<!-- Facturare button (VALIDAT) -->
<button
v-if="order.status === 'VALIDAT'"
@click="showFacturareDialog = true"
class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700"
>
Factureaza
</button>
<!-- Delete invoice button (FACTURAT) -->
<button
v-if="order.status === 'FACTURAT' && invoice"
@click="confirmDeleteInvoice = true"
class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
Sterge factura
</button>
<!-- Delete order button (DRAFT or VALIDAT) -->
<button
v-if="order.status === 'DRAFT' || order.status === 'VALIDAT'"
@click="confirmDelete = true"
class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700"
>
Sterge
</button>
</div>
</div>
<!-- Order info -->
<div class="bg-white rounded-lg shadow p-4 mb-6">
<!-- Edit form (DRAFT only) -->
<div v-if="editing" class="bg-white rounded-lg shadow p-6 mb-6 max-w-2xl">
<h2 class="text-lg font-semibold mb-4">Editeaza comanda</h2>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Client</label>
<ClientPicker v-model="editForm.client_id" @select="onEditClientSelect" />
</div>
<div v-if="editForm.client_id">
<label class="block text-sm font-medium text-gray-700 mb-1">Vehicul</label>
<VehiclePicker v-model="editForm.vehicle_id" :client-id="editForm.client_id" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Tip deviz</label>
<select v-model="editForm.tip_deviz_id" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
<option :value="null">-- Selecteaza --</option>
<option v-for="t in tipuriDeviz" :key="t.id" :value="t.id">{{ t.denumire }}</option>
</select>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">KM intrare</label>
<input v-model.number="editForm.km_intrare" type="number" min="0" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Observatii</label>
<input v-model="editForm.observatii" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
</div>
</div>
</div>
<div class="flex gap-2 mt-4">
<button @click="handleSaveEdit" :disabled="savingEdit" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ savingEdit ? 'Se salveaza...' : 'Salveaza' }}
</button>
<button @click="cancelEdit" class="px-4 py-2 text-gray-600 hover:text-gray-800 text-sm">Anuleaza</button>
</div>
</div>
<!-- Order info (read mode) -->
<div v-if="!editing" class="bg-white rounded-lg shadow p-4 mb-6">
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<p class="text-gray-500">Nr. auto</p>
@@ -134,6 +207,65 @@
</div>
</div>
</div>
<!-- Confirm Devalidate dialog -->
<div v-if="confirmDevalidate" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="confirmDevalidate = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Devalideaza comanda?</h3>
<p class="text-sm text-gray-600 mb-4">Comanda va reveni la status DRAFT si va putea fi editata.</p>
<div class="flex gap-2 justify-end">
<button @click="confirmDevalidate = false" class="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">Anuleaza</button>
<button @click="handleDevalidate" class="px-4 py-2 bg-yellow-600 text-white text-sm rounded-md hover:bg-yellow-700">Da, devalideaza</button>
</div>
</div>
</div>
<!-- Confirm Delete dialog -->
<div v-if="confirmDelete" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="confirmDelete = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sterge comanda?</h3>
<p class="text-sm text-gray-600 mb-4">Comanda si toate liniile aferente vor fi sterse definitiv.</p>
<div class="flex gap-2 justify-end">
<button @click="confirmDelete = false" class="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">Anuleaza</button>
<button @click="handleDelete" class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700">Da, sterge</button>
</div>
</div>
</div>
<!-- Confirm Delete Invoice dialog -->
<div v-if="confirmDeleteInvoice" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="confirmDeleteInvoice = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 mb-2">Sterge factura?</h3>
<p class="text-sm text-gray-600 mb-4">Factura va fi stearsa si comanda va reveni la status VALIDAT.</p>
<div class="flex gap-2 justify-end">
<button @click="confirmDeleteInvoice = false" class="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">Anuleaza</button>
<button @click="handleDeleteInvoice" class="px-4 py-2 bg-red-600 text-white text-sm rounded-md hover:bg-red-700">Da, sterge</button>
</div>
</div>
</div>
<!-- Facturare dialog -->
<div v-if="showFacturareDialog" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50" @click.self="showFacturareDialog = false">
<div class="bg-white rounded-lg shadow-lg p-6 max-w-sm w-full mx-4">
<h3 class="text-lg font-semibold text-gray-900 mb-4">Tip document</h3>
<div class="space-y-3 mb-4">
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="facturaTip" type="radio" value="FACTURA" class="text-blue-600" />
<span>Factura</span>
</label>
<label class="flex items-center gap-2 text-sm cursor-pointer">
<input v-model="facturaTip" type="radio" value="BON_FISCAL" class="text-blue-600" />
<span>Bon fiscal</span>
</label>
</div>
<div class="flex gap-2 justify-end">
<button @click="showFacturareDialog = false" class="px-4 py-2 text-gray-600 text-sm hover:text-gray-800">Anuleaza</button>
<button @click="handleFactureaza" :disabled="facturand" class="px-4 py-2 bg-blue-600 text-white text-sm rounded-md hover:bg-blue-700 disabled:opacity-50">
{{ facturand ? 'Se proceseaza...' : 'Creeaza' }}
</button>
</div>
</div>
</div>
</div>
<div v-else class="text-center py-12 text-gray-500">
@@ -142,30 +274,64 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { ref, reactive, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useOrdersStore } from '../../stores/orders.js'
import { execSQL, onTableChange, notifyTableChanged } from '../../db/database.js'
import { syncEngine } from '../../db/sync.js'
import { useAuthStore } from '../../stores/auth.js'
import OrderLineForm from '../../components/orders/OrderLineForm.vue'
import PdfDownloadButton from '../../components/orders/PdfDownloadButton.vue'
import ClientPicker from '../../components/clients/ClientPicker.vue'
import VehiclePicker from '../../components/vehicles/VehiclePicker.vue'
const route = useRoute()
const router = useRouter()
const ordersStore = useOrdersStore()
const auth = useAuthStore()
const order = ref(null)
const lines = ref([])
const invoice = ref(null)
const facturand = ref(false)
const editing = ref(false)
const savingEdit = ref(false)
const confirmDevalidate = ref(false)
const confirmDelete = ref(false)
const confirmDeleteInvoice = ref(false)
const showFacturareDialog = ref(false)
const facturaTip = ref('FACTURA')
const tipuriDeviz = ref([])
const editForm = reactive({
client_id: null,
vehicle_id: null,
tip_deviz_id: null,
km_intrare: 0,
observatii: '',
})
async function loadOrder() {
order.value = await ordersStore.getById(route.params.id)
lines.value = await ordersStore.getLines(route.params.id)
// Load invoice for FACTURAT orders
if (order.value?.status === 'FACTURAT') {
const invoices = await execSQL(
`SELECT * FROM invoices WHERE order_id = ? AND tenant_id = ?`,
[route.params.id, auth.tenantId]
)
invoice.value = invoices[0] || null
} else {
invoice.value = null
}
}
onMounted(() => {
loadOrder()
onMounted(async () => {
await loadOrder()
tipuriDeviz.value = await execSQL(
`SELECT * FROM catalog_tipuri_deviz WHERE tenant_id = ? ORDER BY denumire`,
[auth.tenantId]
)
onTableChange('orders', loadOrder)
onTableChange('order_lines', async () => {
lines.value = await ordersStore.getLines(route.params.id)
@@ -173,6 +339,35 @@ onMounted(() => {
})
})
function startEdit() {
editForm.client_id = order.value.client_id || null
editForm.vehicle_id = order.value.vehicle_id || null
editForm.tip_deviz_id = order.value.tip_deviz_id || null
editForm.km_intrare = order.value.km_intrare || 0
editForm.observatii = order.value.observatii || ''
editing.value = true
}
function cancelEdit() {
editing.value = false
}
function onEditClientSelect(client) {
if (!client) {
editForm.vehicle_id = null
}
}
async function handleSaveEdit() {
savingEdit.value = true
try {
await ordersStore.updateHeader(route.params.id, { ...editForm })
editing.value = false
} finally {
savingEdit.value = false
}
}
async function handleAddLine(lineData) {
await ordersStore.addLine(route.params.id, lineData)
}
@@ -185,18 +380,42 @@ async function handleValidate() {
await ordersStore.validateOrder(route.params.id)
}
async function handleDevalidate() {
await ordersStore.devalidateOrder(route.params.id)
confirmDevalidate.value = false
}
async function handleDelete() {
await ordersStore.deleteOrder(route.params.id)
confirmDelete.value = false
router.push('/dashboard')
}
async function handleDeleteInvoice() {
if (invoice.value) {
await ordersStore.deleteInvoice(invoice.value.id)
}
confirmDeleteInvoice.value = false
}
async function handleFactureaza() {
if (!order.value) return
facturand.value = true
try {
const id = crypto.randomUUID()
const now = new Date().toISOString()
const nrFactura = `F${Date.now().toString().slice(-6)}`
const year = new Date().getFullYear()
const seq = Date.now().toString().slice(-4)
const nrFactura = facturaTip.value === 'FACTURA'
? `F-${year}-${seq}`
: `BF-${year}-${seq}`
await execSQL(
`INSERT INTO invoices (id, tenant_id, order_id, nr_factura, serie_factura, data_factura, client_nume, nr_auto, total_fara_tva, tva, total_general, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, order.value.id, nrFactura, 'ROA', now.slice(0,10),
order.value.client_nume, order.value.nr_auto,
`INSERT INTO invoices (id, tenant_id, order_id, client_id, nr_factura, serie_factura, data_factura, tip_document, modalitate_plata, client_nume, client_cod_fiscal, nr_auto, total_fara_tva, tva, total_general, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
[id, auth.tenantId, order.value.id, order.value.client_id || null,
nrFactura, 'ROA', now.slice(0, 10), facturaTip.value, null,
order.value.client_nume, null, order.value.nr_auto,
(order.value.total_general || 0) / 1.19,
(order.value.total_general || 0) - (order.value.total_general || 0) / 1.19,
order.value.total_general || 0, now, now]
@@ -204,7 +423,13 @@ async function handleFactureaza() {
await execSQL(`UPDATE orders SET status='FACTURAT', updated_at=? WHERE id=?`, [now, order.value.id])
notifyTableChanged('invoices')
notifyTableChanged('orders')
syncEngine.addToQueue('invoices', id, 'INSERT', { id, tenant_id: auth.tenantId, order_id: order.value.id, nr_factura: nrFactura })
await syncEngine.addToQueue('invoices', id, 'INSERT', {
id, tenant_id: auth.tenantId, order_id: order.value.id,
client_id: order.value.client_id, nr_factura: nrFactura,
tip_document: facturaTip.value
})
await syncEngine.addToQueue('orders', order.value.id, 'UPDATE', { status: 'FACTURAT' })
showFacturareDialog.value = false
} finally {
facturand.value = false
}