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:
23
CLAUDE.md
23
CLAUDE.md
@@ -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`
|
- `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
|
- `auth_headers` fixture registers a user and returns `Authorization` header for authenticated tests
|
||||||
- Demo credentials (after `make seed`): `demo@roaauto.ro` / `demo123`
|
- 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
|
||||||
|
|||||||
@@ -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')
|
||||||
0
backend/app/clients/__init__.py
Normal file
0
backend/app/clients/__init__.py
Normal file
163
backend/app/clients/router.py
Normal file
163
backend/app/clients/router.py
Normal 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}
|
||||||
41
backend/app/clients/schemas.py
Normal file
41
backend/app/clients/schemas.py
Normal 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
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
from app.db.models.tenant import Tenant
|
from app.db.models.tenant import Tenant
|
||||||
from app.db.models.user import User
|
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.vehicle import Vehicle
|
||||||
from app.db.models.order import Order
|
from app.db.models.order import Order
|
||||||
from app.db.models.order_line import OrderLine
|
from app.db.models.order_line import OrderLine
|
||||||
@@ -20,6 +21,7 @@ from app.db.models.invite import InviteToken
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"Tenant",
|
"Tenant",
|
||||||
"User",
|
"User",
|
||||||
|
"Client",
|
||||||
"Vehicle",
|
"Vehicle",
|
||||||
"Order",
|
"Order",
|
||||||
"OrderLine",
|
"OrderLine",
|
||||||
|
|||||||
25
backend/app/db/models/client.py
Normal file
25
backend/app/db/models/client.py
Normal 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)
|
||||||
@@ -7,6 +7,8 @@ from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
|||||||
class Invoice(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
class Invoice(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "invoices"
|
__tablename__ = "invoices"
|
||||||
order_id: Mapped[str | None] = mapped_column(String(36), index=True)
|
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))
|
nr_factura: Mapped[str | None] = mapped_column(String(50))
|
||||||
serie_factura: Mapped[str | None] = mapped_column(String(20))
|
serie_factura: Mapped[str | None] = mapped_column(String(20))
|
||||||
data_factura: Mapped[str | None] = mapped_column(Text)
|
data_factura: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ class Order(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
|||||||
__tablename__ = "orders"
|
__tablename__ = "orders"
|
||||||
nr_comanda: Mapped[str | None] = mapped_column(String(50))
|
nr_comanda: Mapped[str | None] = mapped_column(String(50))
|
||||||
vehicle_id: Mapped[str | None] = mapped_column(String(36))
|
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))
|
tip_deviz_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
status: Mapped[str] = mapped_column(String(20), default="DRAFT", server_default="DRAFT")
|
status: Mapped[str] = mapped_column(String(20), default="DRAFT", server_default="DRAFT")
|
||||||
data_comanda: Mapped[str | None] = mapped_column(Text)
|
data_comanda: Mapped[str | None] = mapped_column(Text)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin
|
|||||||
class Vehicle(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
class Vehicle(Base, UUIDMixin, TenantMixin, TimestampMixin):
|
||||||
__tablename__ = "vehicles"
|
__tablename__ = "vehicles"
|
||||||
nr_inmatriculare: Mapped[str] = mapped_column(String(20))
|
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))
|
marca_id: Mapped[str | None] = mapped_column(String(36))
|
||||||
model_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)
|
an_fabricatie: Mapped[int | None] = mapped_column(Integer)
|
||||||
|
|||||||
@@ -52,14 +52,7 @@ ANSAMBLE = [
|
|||||||
"Revizie",
|
"Revizie",
|
||||||
]
|
]
|
||||||
|
|
||||||
TIPURI_DEVIZ = [
|
TIPURI_DEVIZ = ["Service", "ITP", "Regie", "Constatare"]
|
||||||
"Deviz reparatie",
|
|
||||||
"Deviz revizie",
|
|
||||||
"Deviz diagnosticare",
|
|
||||||
"Deviz estimativ",
|
|
||||||
"Deviz vulcanizare",
|
|
||||||
"Deviz ITP",
|
|
||||||
]
|
|
||||||
|
|
||||||
TIPURI_MOTOARE = ["Benzina", "Diesel", "Hibrid", "Electric", "GPL"]
|
TIPURI_MOTOARE = ["Benzina", "Diesel", "Hibrid", "Electric", "GPL"]
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -109,3 +111,39 @@ async def get_invoice_pdf(
|
|||||||
"Content-Disposition": f'inline; filename="factura-{invoice.nr_factura}.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}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
|
|
||||||
from app.auth.router import router as auth_router
|
from app.auth.router import router as auth_router
|
||||||
from app.client_portal.router import router as portal_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.config import settings
|
||||||
from app.db.base import Base
|
from app.db.base import Base
|
||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
@@ -35,6 +36,7 @@ app.add_middleware(
|
|||||||
)
|
)
|
||||||
app.include_router(auth_router, prefix="/api/auth")
|
app.include_router(auth_router, prefix="/api/auth")
|
||||||
app.include_router(sync_router, prefix="/api/sync")
|
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(orders_router, prefix="/api/orders")
|
||||||
app.include_router(vehicles_router, prefix="/api/vehicles")
|
app.include_router(vehicles_router, prefix="/api/vehicles")
|
||||||
app.include_router(invoices_router, prefix="/api/invoices")
|
app.include_router(invoices_router, prefix="/api/invoices")
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
from fastapi.responses import Response
|
from fastapi.responses import Response
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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 import Order
|
||||||
from app.db.models.order_line import OrderLine
|
from app.db.models.order_line import OrderLine
|
||||||
from app.db.models.tenant import Tenant
|
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"'
|
"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}
|
||||||
|
|||||||
@@ -8,6 +8,19 @@ class CreateOrderRequest(BaseModel):
|
|||||||
observatii: str | None = None
|
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):
|
class AddLineRequest(BaseModel):
|
||||||
tip: str # manopera | material
|
tip: str # manopera | material
|
||||||
descriere: str
|
descriere: str
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from sqlalchemy import text
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
SYNCABLE_TABLES = [
|
SYNCABLE_TABLES = [
|
||||||
|
"clients",
|
||||||
"vehicles",
|
"vehicles",
|
||||||
"orders",
|
"orders",
|
||||||
"order_lines",
|
"order_lines",
|
||||||
|
|||||||
60
docs/PLAN.md
60
docs/PLAN.md
@@ -245,13 +245,18 @@ catalog_tipuri_deviz (id, tenant_id, denumire)
|
|||||||
catalog_tipuri_motoare (id, tenant_id, denumire)
|
catalog_tipuri_motoare (id, tenant_id, denumire)
|
||||||
mecanici (id, tenant_id, user_id, nume, prenume, activ)
|
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
|
-- 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,
|
client_cod_fiscal, client_adresa, nr_inmatriculare,
|
||||||
marca_id, model_id, an_fabricatie, serie_sasiu,
|
marca_id, model_id, an_fabricatie, serie_sasiu,
|
||||||
tip_motor_id, created_at, updated_at)
|
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,
|
tip_deviz_id, status, km_intrare, observatii,
|
||||||
-- client snapshot (denormalized)
|
-- client snapshot (denormalized)
|
||||||
client_nume, client_telefon, nr_auto, marca_denumire, model_denumire,
|
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
|
um, cantitate, pret_unitar, -- material
|
||||||
total, mecanic_id, ordine, created_at, updated_at)
|
total, mecanic_id, ordine, created_at, updated_at)
|
||||||
|
|
||||||
invoices (id, tenant_id, order_id, nr_factura, serie_factura,
|
invoices (id, tenant_id, order_id, client_id, nr_factura, serie_factura,
|
||||||
data_factura, modalitate_plata,
|
data_factura, tip_document, modalitate_plata,
|
||||||
client_nume, client_cod_fiscal, nr_auto,
|
client_nume, client_cod_fiscal, nr_auto,
|
||||||
total_fara_tva, tva, total_general, created_at, updated_at)
|
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)
|
4. Responsive testing (phone, tablet, desktop)
|
||||||
5. Reports: sumar lunar, export CSV
|
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)
|
## 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=manopera) | `dev_oper` | SaaS unifica oper+materiale in order_lines |
|
||||||
| `order_lines` (tip=material) | `dev_estimari_produse` | Acelasi tabel, filtrat pe `tip` |
|
| `order_lines` (tip=material) | `dev_estimari_produse` | Acelasi tabel, filtrat pe `tip` |
|
||||||
| `vehicles` | `dev_masiniclienti` | Renamed, aceleasi coloane client+vehicul |
|
| `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_marci` | `dev_nom_marci` | +tenant_id |
|
||||||
| `catalog_modele` | `dev_nom_masini` | Identic |
|
| `catalog_modele` | `dev_nom_masini` | Identic |
|
||||||
| `catalog_ansamble` | `dev_nom_ansamble` | +tenant_id |
|
| `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 |
|
| `catalog_tipuri_motoare` | `dev_tipuri_motoare` | +tenant_id |
|
||||||
| `mecanici` | `dev_mecanici` | +tenant_id, +user_id |
|
| `mecanici` | `dev_mecanici` | +tenant_id, +user_id |
|
||||||
| `invoices` | `facturi` (local) | Identic structural |
|
| `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) |
|
| `tenants` | - | Doar SaaS (nu exista in Oracle) |
|
||||||
| `users` | - | Doar SaaS |
|
| `users` | - | Doar SaaS |
|
||||||
| `appointments` | - | Doar SaaS (feature nou) |
|
| `appointments` | - | Doar SaaS (feature nou) |
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
"headers": {"Authorization": "Bearer <token>"},
|
"headers": {"Authorization": "Bearer <token>"},
|
||||||
"response": {
|
"response": {
|
||||||
"tables": {
|
"tables": {
|
||||||
"vehicles": [], "orders": [], "order_lines": [],
|
"clients": [], "vehicles": [], "orders": [], "order_lines": [],
|
||||||
"invoices": [], "appointments": [],
|
"invoices": [], "appointments": [],
|
||||||
"catalog_marci": [], "catalog_modele": [],
|
"catalog_marci": [], "catalog_modele": [],
|
||||||
"catalog_ansamble": [], "catalog_norme": [],
|
"catalog_ansamble": [], "catalog_norme": [],
|
||||||
@@ -42,12 +42,22 @@
|
|||||||
},
|
},
|
||||||
"orders": {
|
"orders": {
|
||||||
"GET /orders": {"response": [{"id": "str", "status": "str", "nr_auto": "str", "total_general": 0}]},
|
"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": []}},
|
"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}/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}/validate": {"response": {"status": "VALIDAT"}},
|
||||||
|
"POST /orders/{id}/devalidate": {"response": {"status": "DRAFT"}, "note": "VALIDAT → DRAFT"},
|
||||||
"GET /orders/{id}/pdf/deviz": {"response": "application/pdf"}
|
"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": {
|
"vehicles": {
|
||||||
"GET /vehicles": {"response": [{"id": "str", "nr_auto": "str", "marca": "str", "model": "str", "an": 0}]},
|
"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"}},
|
"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}}
|
"POST /p/{token}/reject": {"response": {"ok": true}}
|
||||||
},
|
},
|
||||||
"invoices": {
|
"invoices": {
|
||||||
"POST /invoices": {"body": {"order_id": "str"}, "response": {"id": "str", "nr_factura": "str"}},
|
"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"}
|
"GET /invoices/{id}/pdf": {"response": "application/pdf"},
|
||||||
|
"DELETE /invoices/{id}": {"response": {"ok": true}, "note": "Sterge factura, comanda revine la VALIDAT"}
|
||||||
},
|
},
|
||||||
"users": {
|
"users": {
|
||||||
"GET /users": {"response": [{"id": "str", "email": "str", "rol": "str"}]},
|
"GET /users": {"response": [{"id": "str", "email": "str", "rol": "str"}]},
|
||||||
|
|||||||
68
docs/playwright-report-2026-03-14.md
Normal file
68
docs/playwright-report-2026-03-14.md
Normal 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.
|
||||||
211
frontend/src/components/clients/ClientPicker.vue
Normal file
211
frontend/src/components/clients/ClientPicker.vue
Normal 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>
|
||||||
@@ -14,15 +14,34 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Descriere -->
|
<!-- Descriere with autocomplete -->
|
||||||
<div>
|
<div class="relative">
|
||||||
<input
|
<input
|
||||||
v-model="form.descriere"
|
v-model="form.descriere"
|
||||||
type="text"
|
type="text"
|
||||||
required
|
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"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- Manopera fields -->
|
<!-- Manopera fields -->
|
||||||
@@ -99,13 +118,16 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 emit = defineEmits(['add'])
|
||||||
|
const ordersStore = useOrdersStore()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
tip: 'manopera',
|
tip: 'manopera',
|
||||||
descriere: '',
|
descriere: '',
|
||||||
|
norma_id: null,
|
||||||
ore: 0,
|
ore: 0,
|
||||||
pret_ora: 0,
|
pret_ora: 0,
|
||||||
cantitate: 0,
|
cantitate: 0,
|
||||||
@@ -113,16 +135,57 @@ const form = reactive({
|
|||||||
um: 'buc',
|
um: 'buc',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const suggestions = ref([])
|
||||||
|
const showSuggestions = ref(false)
|
||||||
|
let searchTimeout = null
|
||||||
|
|
||||||
const computedTotal = computed(() => {
|
const computedTotal = computed(() => {
|
||||||
if (form.tip === 'manopera') return (form.ore || 0) * (form.pret_ora || 0)
|
if (form.tip === 'manopera') return (form.ore || 0) * (form.pret_ora || 0)
|
||||||
return (form.cantitate || 0) * (form.pret_unitar || 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() {
|
function handleSubmit() {
|
||||||
if (!form.descriere) return
|
if (!form.descriere) return
|
||||||
emit('add', { ...form })
|
emit('add', { ...form })
|
||||||
// Reset
|
// Reset
|
||||||
form.descriere = ''
|
form.descriere = ''
|
||||||
|
form.norma_id = null
|
||||||
form.ore = 0
|
form.ore = 0
|
||||||
form.pret_ora = 0
|
form.pret_ora = 0
|
||||||
form.cantitate = 0
|
form.cantitate = 0
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
<!-- Selected vehicle display -->
|
<!-- Selected vehicle display -->
|
||||||
<div v-if="selected" class="mt-1 text-sm text-gray-600">
|
<div v-if="selected" class="mt-1 text-sm text-gray-600">
|
||||||
{{ selected.nr_inmatriculare }} - {{ selected.client_nume }}
|
{{ 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>
|
<button @click="clear" class="ml-2 text-red-500 hover:text-red-700 text-xs">Sterge</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Dropdown results -->
|
<!-- Dropdown results -->
|
||||||
@@ -40,27 +40,117 @@
|
|||||||
>
|
>
|
||||||
Niciun vehicul gasit
|
Niciun vehicul gasit
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue'
|
import { ref, reactive, watch, onMounted } from 'vue'
|
||||||
import { useVehiclesStore } from '../../stores/vehicles.js'
|
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({
|
const props = defineProps({
|
||||||
modelValue: { type: String, default: null },
|
modelValue: { type: String, default: null },
|
||||||
label: { type: String, default: 'Vehicul' },
|
label: { type: String, default: 'Vehicul' },
|
||||||
placeholder: { type: String, default: 'Cauta dupa nr. inmatriculare sau client...' },
|
placeholder: { type: String, default: 'Cauta dupa nr. inmatriculare sau client...' },
|
||||||
|
clientId: { type: String, default: null },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['update:modelValue', 'select'])
|
const emit = defineEmits(['update:modelValue', 'select'])
|
||||||
|
|
||||||
const vehiclesStore = useVehiclesStore()
|
const vehiclesStore = useVehiclesStore()
|
||||||
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const results = ref([])
|
const results = ref([])
|
||||||
const selected = ref(null)
|
const selected = ref(null)
|
||||||
const showDropdown = ref(false)
|
const showDropdown = ref(false)
|
||||||
const loading = 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
|
let searchTimeout = null
|
||||||
|
|
||||||
@@ -72,7 +162,7 @@ function onSearch() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
results.value = await vehiclesStore.search(query.value)
|
results.value = await vehiclesStore.search(query.value, props.clientId)
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}, 200)
|
}, 200)
|
||||||
}
|
}
|
||||||
@@ -81,6 +171,7 @@ function selectVehicle(v) {
|
|||||||
selected.value = v
|
selected.value = v
|
||||||
query.value = v.nr_inmatriculare
|
query.value = v.nr_inmatriculare
|
||||||
showDropdown.value = false
|
showDropdown.value = false
|
||||||
|
showNewVehicle.value = false
|
||||||
emit('update:modelValue', v.id)
|
emit('update:modelValue', v.id)
|
||||||
emit('select', v)
|
emit('select', v)
|
||||||
}
|
}
|
||||||
@@ -92,6 +183,70 @@ function clear() {
|
|||||||
emit('select', null)
|
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
|
// Load initial vehicle if modelValue is set
|
||||||
watch(() => props.modelValue, async (id) => {
|
watch(() => props.modelValue, async (id) => {
|
||||||
if (id && !selected.value) {
|
if (id && !selected.value) {
|
||||||
@@ -103,6 +258,25 @@ watch(() => props.modelValue, async (id) => {
|
|||||||
}
|
}
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
// Close dropdown on outside click
|
// When clientId changes, reset selection and load client vehicles
|
||||||
function onClickOutside() { showDropdown.value = false }
|
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>
|
</script>
|
||||||
|
|||||||
@@ -4,23 +4,50 @@ export const SCHEMA_SQL = `
|
|||||||
adresa TEXT, telefon TEXT, email TEXT, iban TEXT, banca TEXT,
|
adresa TEXT, telefon TEXT, email TEXT, iban TEXT, banca TEXT,
|
||||||
plan TEXT DEFAULT 'free', trial_expires_at TEXT, created_at TEXT, updated_at 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 (
|
CREATE TABLE IF NOT EXISTS vehicles (
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
|
||||||
|
client_id TEXT,
|
||||||
client_nume TEXT, client_telefon TEXT, client_email 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,
|
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
|
oracle_id INTEGER, created_at TEXT, updated_at TEXT
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS orders (
|
CREATE TABLE IF NOT EXISTS orders (
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
|
||||||
nr_comanda TEXT, data_comanda TEXT, vehicle_id TEXT,
|
nr_comanda TEXT, data_comanda TEXT, vehicle_id TEXT,
|
||||||
|
client_id TEXT,
|
||||||
tip_deviz_id TEXT, status TEXT DEFAULT 'DRAFT',
|
tip_deviz_id TEXT, status TEXT DEFAULT 'DRAFT',
|
||||||
km_intrare INTEGER, observatii TEXT,
|
km_intrare INTEGER, observatii TEXT,
|
||||||
|
mecanic_id TEXT,
|
||||||
client_nume TEXT, client_telefon TEXT, nr_auto TEXT,
|
client_nume TEXT, client_telefon TEXT, nr_auto TEXT,
|
||||||
marca_denumire TEXT, model_denumire TEXT,
|
marca_denumire TEXT, model_denumire TEXT,
|
||||||
total_manopera REAL DEFAULT 0, total_materiale REAL DEFAULT 0, total_general REAL DEFAULT 0,
|
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 (
|
CREATE TABLE IF NOT EXISTS order_lines (
|
||||||
id TEXT PRIMARY KEY, order_id TEXT NOT NULL, tenant_id TEXT NOT NULL,
|
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 (
|
CREATE TABLE IF NOT EXISTS invoices (
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, order_id TEXT,
|
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, order_id TEXT,
|
||||||
|
client_id TEXT,
|
||||||
nr_factura TEXT, serie_factura TEXT, data_factura 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,
|
modalitate_plata TEXT, client_nume TEXT, client_cod_fiscal TEXT, nr_auto TEXT,
|
||||||
total_fara_tva REAL, tva REAL, total_general REAL,
|
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
|
oracle_id INTEGER, created_at TEXT, updated_at TEXT
|
||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS appointments (
|
CREATE TABLE IF NOT EXISTS appointments (
|
||||||
@@ -46,31 +76,31 @@ export const SCHEMA_SQL = `
|
|||||||
);
|
);
|
||||||
CREATE TABLE IF NOT EXISTS catalog_marci (
|
CREATE TABLE IF NOT EXISTS catalog_marci (
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, activ INTEGER DEFAULT 1,
|
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 (
|
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 (
|
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 (
|
CREATE TABLE IF NOT EXISTS catalog_norme (
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, cod TEXT, denumire TEXT,
|
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 (
|
CREATE TABLE IF NOT EXISTS catalog_preturi (
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, pret REAL, um TEXT,
|
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 (
|
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 (
|
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 (
|
CREATE TABLE IF NOT EXISTS mecanici (
|
||||||
id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, user_id TEXT,
|
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 (
|
CREATE TABLE IF NOT EXISTS _sync_queue (
|
||||||
id TEXT PRIMARY KEY, table_name TEXT, row_id TEXT,
|
id TEXT PRIMARY KEY, table_name TEXT, row_id TEXT,
|
||||||
@@ -82,7 +112,7 @@ export const SCHEMA_SQL = `
|
|||||||
`;
|
`;
|
||||||
|
|
||||||
export const SYNC_TABLES = [
|
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_marci', 'catalog_modele', 'catalog_ansamble', 'catalog_norme',
|
||||||
'catalog_preturi', 'catalog_tipuri_deviz', 'catalog_tipuri_motoare', 'mecanici'
|
'catalog_preturi', 'catalog_tipuri_deviz', 'catalog_tipuri_motoare', 'mecanici'
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -87,8 +87,8 @@ const mobileMenuOpen = ref(false)
|
|||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ path: '/dashboard', label: 'Dashboard' },
|
{ path: '/dashboard', label: 'Dashboard' },
|
||||||
{ path: '/orders', label: 'Comenzi' },
|
|
||||||
{ path: '/invoices', label: 'Facturi' },
|
{ path: '/invoices', label: 'Facturi' },
|
||||||
|
{ path: '/clients', label: 'Clienti' },
|
||||||
{ path: '/vehicles', label: 'Vehicule' },
|
{ path: '/vehicles', label: 'Vehicule' },
|
||||||
{ path: '/appointments', label: 'Programari' },
|
{ path: '/appointments', label: 'Programari' },
|
||||||
{ path: '/catalog', label: 'Catalog' },
|
{ path: '/catalog', label: 'Catalog' },
|
||||||
@@ -97,7 +97,7 @@ const navItems = [
|
|||||||
|
|
||||||
const mobileNavItems = [
|
const mobileNavItems = [
|
||||||
{ path: '/dashboard', label: 'Acasa' },
|
{ path: '/dashboard', label: 'Acasa' },
|
||||||
{ path: '/orders', label: 'Comenzi' },
|
{ path: '/clients', label: 'Clienti' },
|
||||||
{ path: '/vehicles', label: 'Vehicule' },
|
{ path: '/vehicles', label: 'Vehicule' },
|
||||||
{ path: '/appointments', label: 'Programari' },
|
{ path: '/appointments', label: 'Programari' },
|
||||||
{ path: '/settings', label: 'Setari' },
|
{ path: '/settings', label: 'Setari' },
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ const router = createRouter({
|
|||||||
{ path: '/login', component: () => import('../views/auth/LoginView.vue'), meta: { layout: 'auth' } },
|
{ path: '/login', component: () => import('../views/auth/LoginView.vue'), meta: { layout: 'auth' } },
|
||||||
{ path: '/register', component: () => import('../views/auth/RegisterView.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: '/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/new', component: () => import('../views/orders/OrderCreateView.vue'), meta: { requiresAuth: true } },
|
||||||
{ path: '/orders/:id', component: () => import('../views/orders/OrderDetailView.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: '/vehicles', component: () => import('../views/vehicles/VehiclesListView.vue'), meta: { requiresAuth: true } },
|
||||||
{ path: '/appointments', component: () => import('../views/appointments/AppointmentsView.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 } },
|
{ path: '/catalog', component: () => import('../views/catalog/CatalogView.vue'), meta: { requiresAuth: true } },
|
||||||
|
|||||||
91
frontend/src/stores/clients.js
Normal file
91
frontend/src/stores/clients.js
Normal 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 }
|
||||||
|
})
|
||||||
@@ -6,13 +6,19 @@ import { useAuthStore } from './auth.js'
|
|||||||
export const useOrdersStore = defineStore('orders', () => {
|
export const useOrdersStore = defineStore('orders', () => {
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
async function getAll(statusFilter = null) {
|
async function getAll(statusFilter = null, search = '') {
|
||||||
let sql = `SELECT * FROM orders WHERE tenant_id = ? ORDER BY created_at DESC`
|
let sql = `SELECT * FROM orders WHERE tenant_id = ?`
|
||||||
const params = [auth.tenantId]
|
const params = [auth.tenantId]
|
||||||
if (statusFilter) {
|
if (statusFilter) {
|
||||||
sql = `SELECT * FROM orders WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC`
|
sql += ` AND status = ?`
|
||||||
params.push(statusFilter)
|
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)
|
return execSQL(sql, params)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,13 +46,23 @@ export const useOrdersStore = defineStore('orders', () => {
|
|||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
const nr = `CMD-${Date.now().toString(36).toUpperCase()}`
|
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
|
// Lookup vehicle info for denormalized fields
|
||||||
let clientNume = '', clientTelefon = '', nrAuto = '', marcaDenumire = '', modelDenumire = ''
|
let nrAuto = '', marcaDenumire = '', modelDenumire = ''
|
||||||
if (data.vehicle_id) {
|
if (data.vehicle_id) {
|
||||||
const [v] = await execSQL(`SELECT * FROM vehicles WHERE id = ?`, [data.vehicle_id])
|
const [v] = await execSQL(`SELECT * FROM vehicles WHERE id = ?`, [data.vehicle_id])
|
||||||
if (v) {
|
if (v) {
|
||||||
clientNume = v.client_nume || ''
|
if (!clientNume) clientNume = v.client_nume || ''
|
||||||
clientTelefon = v.client_telefon || ''
|
if (!clientTelefon) clientTelefon = v.client_telefon || ''
|
||||||
nrAuto = v.nr_inmatriculare || ''
|
nrAuto = v.nr_inmatriculare || ''
|
||||||
const [marca] = await execSQL(`SELECT denumire FROM catalog_marci WHERE id = ?`, [v.marca_id])
|
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])
|
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(
|
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)
|
`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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
[id, auth.tenantId, nr, now, data.vehicle_id || null, data.tip_deviz_id || null,
|
[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 || '',
|
'DRAFT', data.km_intrare || 0, data.observatii || '',
|
||||||
clientNume, clientTelefon, nrAuto, marcaDenumire, modelDenumire, now, now]
|
clientNume, clientTelefon, nrAuto, marcaDenumire, modelDenumire, now, now]
|
||||||
)
|
)
|
||||||
notifyTableChanged('orders')
|
notifyTableChanged('orders')
|
||||||
await syncEngine.addToQueue('orders', id, 'INSERT', {
|
await syncEngine.addToQueue('orders', id, 'INSERT', {
|
||||||
id, tenant_id: auth.tenantId, nr_comanda: nr, data_comanda: now,
|
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 || '',
|
status: 'DRAFT', km_intrare: data.km_intrare || 0, observatii: data.observatii || '',
|
||||||
client_nume: clientNume, client_telefon: clientTelefon, nr_auto: nrAuto,
|
client_nume: clientNume, client_telefon: clientTelefon, nr_auto: nrAuto,
|
||||||
marca_denumire: marcaDenumire, model_denumire: modelDenumire
|
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) {
|
async function validateOrder(orderId) {
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
await execSQL(`UPDATE orders SET status='VALIDAT', updated_at=? WHERE id=?`, [now, orderId])
|
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' })
|
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() {
|
async function getStats() {
|
||||||
const [total] = await execSQL(
|
const [total] = await execSQL(
|
||||||
`SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ?`, [auth.tenantId]
|
`SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ?`, [auth.tenantId]
|
||||||
@@ -154,20 +268,28 @@ export const useOrdersStore = defineStore('orders', () => {
|
|||||||
const [validat] = await execSQL(
|
const [validat] = await execSQL(
|
||||||
`SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ? AND status = 'VALIDAT'`, [auth.tenantId]
|
`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(
|
const [totalVehicles] = await execSQL(
|
||||||
`SELECT COUNT(*) as cnt FROM vehicles WHERE tenant_id = ?`, [auth.tenantId]
|
`SELECT COUNT(*) as cnt FROM vehicles WHERE tenant_id = ?`, [auth.tenantId]
|
||||||
)
|
)
|
||||||
const [revenue] = await execSQL(
|
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 {
|
return {
|
||||||
totalOrders: total?.cnt || 0,
|
totalOrders: total?.cnt || 0,
|
||||||
draftOrders: draft?.cnt || 0,
|
draftOrders: draft?.cnt || 0,
|
||||||
validatedOrders: validat?.cnt || 0,
|
validatedOrders: validat?.cnt || 0,
|
||||||
|
facturatedOrders: facturat?.cnt || 0,
|
||||||
totalVehicles: totalVehicles?.cnt || 0,
|
totalVehicles: totalVehicles?.cnt || 0,
|
||||||
totalRevenue: revenue?.s || 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
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -42,14 +42,26 @@ export const useVehiclesStore = defineStore('vehicles', () => {
|
|||||||
return rows[0] || null
|
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) {
|
async function create(data) {
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const now = new Date().toISOString()
|
const now = new Date().toISOString()
|
||||||
|
|
||||||
await execSQL(
|
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)
|
`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 (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
[id, auth.tenantId, data.client_nume || '', data.client_telefon || '',
|
[id, auth.tenantId, data.client_id || null, data.client_nume || '', data.client_telefon || '',
|
||||||
data.client_email || '', data.client_cod_fiscal || '', data.client_adresa || '',
|
data.client_email || '', data.client_cod_fiscal || '', data.client_adresa || '',
|
||||||
data.nr_inmatriculare || '', data.marca_id || null, data.model_id || null,
|
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]
|
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)
|
await syncEngine.addToQueue('vehicles', id, 'UPDATE', data)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function search(query) {
|
async function search(query, clientId = null) {
|
||||||
if (!query || query.length < 2) return []
|
if (!query || query.length < 2) {
|
||||||
|
// If no query but clientId, return client vehicles
|
||||||
|
if (clientId) return getByClient(clientId)
|
||||||
|
return []
|
||||||
|
}
|
||||||
const like = `%${query}%`
|
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(
|
return execSQL(
|
||||||
`SELECT v.*, m.denumire as marca_denumire, mo.denumire as model_denumire
|
`SELECT v.*, m.denumire as marca_denumire, mo.denumire as model_denumire
|
||||||
FROM vehicles v
|
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 }
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -128,6 +128,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Tab: Preturi -->
|
||||||
<div v-if="activeTab === 'preturi'">
|
<div v-if="activeTab === 'preturi'">
|
||||||
<div class="bg-white rounded-lg shadow overflow-hidden mb-6">
|
<div class="bg-white rounded-lg shadow overflow-hidden mb-6">
|
||||||
@@ -208,6 +253,7 @@ const tabs = [
|
|||||||
{ key: 'marci', label: 'Marci' },
|
{ key: 'marci', label: 'Marci' },
|
||||||
{ key: 'modele', label: 'Modele' },
|
{ key: 'modele', label: 'Modele' },
|
||||||
{ key: 'ansamble', label: 'Ansamble' },
|
{ key: 'ansamble', label: 'Ansamble' },
|
||||||
|
{ key: 'norme', label: 'Norme' },
|
||||||
{ key: 'preturi', label: 'Preturi' },
|
{ key: 'preturi', label: 'Preturi' },
|
||||||
{ key: 'tipuri_deviz', label: 'Tipuri deviz' },
|
{ 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 ----
|
// ---- Preturi ----
|
||||||
const preturi = ref([])
|
const preturi = ref([])
|
||||||
const newPret = reactive({ denumire: '', pret: null, um: 'ora' })
|
const newPret = reactive({ denumire: '', pret: null, um: 'ora' })
|
||||||
@@ -390,6 +473,7 @@ async function addTipDeviz() {
|
|||||||
onTableChange('catalog_marci', loadMarci)
|
onTableChange('catalog_marci', loadMarci)
|
||||||
onTableChange('catalog_modele', loadModele)
|
onTableChange('catalog_modele', loadModele)
|
||||||
onTableChange('catalog_ansamble', loadAnsamble)
|
onTableChange('catalog_ansamble', loadAnsamble)
|
||||||
|
onTableChange('catalog_norme', loadNorme)
|
||||||
onTableChange('catalog_preturi', loadPreturi)
|
onTableChange('catalog_preturi', loadPreturi)
|
||||||
onTableChange('catalog_tipuri_deviz', loadTipuriDeviz)
|
onTableChange('catalog_tipuri_deviz', loadTipuriDeviz)
|
||||||
|
|
||||||
@@ -398,6 +482,7 @@ watch(activeTab, (tab) => {
|
|||||||
if (tab === 'marci') loadMarci()
|
if (tab === 'marci') loadMarci()
|
||||||
else if (tab === 'modele') { loadMarci(); loadModele() }
|
else if (tab === 'modele') { loadMarci(); loadModele() }
|
||||||
else if (tab === 'ansamble') loadAnsamble()
|
else if (tab === 'ansamble') loadAnsamble()
|
||||||
|
else if (tab === 'norme') { loadAnsamble(); loadNorme() }
|
||||||
else if (tab === 'preturi') loadPreturi()
|
else if (tab === 'preturi') loadPreturi()
|
||||||
else if (tab === 'tipuri_deviz') loadTipuriDeviz()
|
else if (tab === 'tipuri_deviz') loadTipuriDeviz()
|
||||||
})
|
})
|
||||||
|
|||||||
287
frontend/src/views/clients/ClientsListView.vue
Normal file
287
frontend/src/views/clients/ClientsListView.vue
Normal 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>
|
||||||
@@ -1,88 +1,163 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<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 />
|
<UpgradeBanner />
|
||||||
|
|
||||||
<!-- Stats cards -->
|
<!-- Stats cards -->
|
||||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||||
<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-blue-300" @click="activeFilter = null">
|
||||||
<p class="text-sm text-gray-500">Total comenzi</p>
|
<p class="text-sm text-gray-500">Total comenzi</p>
|
||||||
<p class="text-2xl font-bold text-gray-900">{{ stats.totalOrders }}</p>
|
<p class="text-2xl font-bold text-gray-900">{{ stats.totalOrders }}</p>
|
||||||
</div>
|
</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-sm text-gray-500">In lucru (draft)</p>
|
||||||
<p class="text-2xl font-bold text-yellow-600">{{ stats.draftOrders }}</p>
|
<p class="text-2xl font-bold text-yellow-600">{{ stats.draftOrders }}</p>
|
||||||
</div>
|
</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-sm text-gray-500">Validate</p>
|
||||||
<p class="text-2xl font-bold text-green-600">{{ stats.validatedOrders }}</p>
|
<p class="text-2xl font-bold text-green-600">{{ stats.validatedOrders }}</p>
|
||||||
</div>
|
</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-blue-300" @click="activeFilter = 'FACTURAT'">
|
||||||
<p class="text-sm text-gray-500">Vehicule</p>
|
<p class="text-sm text-gray-500">Facturate</p>
|
||||||
<p class="text-2xl font-bold text-blue-600">{{ stats.totalVehicles }}</p>
|
<p class="text-2xl font-bold text-blue-600">{{ stats.facturatedOrders }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Revenue card -->
|
<!-- Filter tabs + Search -->
|
||||||
<div class="bg-white rounded-lg shadow p-4 mb-8">
|
<div class="flex flex-col md:flex-row md:items-center gap-3 mb-4">
|
||||||
<p class="text-sm text-gray-500">Venituri totale (comenzi validate)</p>
|
<div class="flex gap-2">
|
||||||
<p class="text-3xl font-bold text-gray-900">{{ stats.totalRevenue.toFixed(2) }} RON</p>
|
<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'"
|
||||||
|
>
|
||||||
|
{{ f.label }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Recent orders -->
|
<!-- Orders table -->
|
||||||
<div class="bg-white rounded-lg shadow">
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
<div class="px-4 py-3 border-b border-gray-200 flex items-center justify-between">
|
<div v-if="loading" class="p-4 text-center text-gray-500">Se incarca...</div>
|
||||||
<h2 class="text-lg font-semibold text-gray-900">Comenzi recente</h2>
|
<div v-else-if="orders.length === 0" class="p-8 text-center text-gray-500">
|
||||||
<router-link to="/orders" class="text-sm text-blue-600 hover:underline">Vezi toate</router-link>
|
Nicio comanda gasita.
|
||||||
</div>
|
</div>
|
||||||
<div v-if="recentOrders.length === 0" class="p-4 text-sm text-gray-500">
|
<table v-else class="min-w-full divide-y divide-gray-200">
|
||||||
Nicio comanda inca.
|
<thead class="bg-gray-50">
|
||||||
</div>
|
<tr>
|
||||||
<ul v-else class="divide-y divide-gray-100">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nr. comanda</th>
|
||||||
<li v-for="o in recentOrders" :key="o.id">
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Nr. auto</th>
|
||||||
<router-link
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Client</th>
|
||||||
:to="`/orders/${o.id}`"
|
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase hidden md:table-cell">Marca / Model</th>
|
||||||
class="flex items-center justify-between px-4 py-3 hover:bg-gray-50"
|
<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}`)"
|
||||||
>
|
>
|
||||||
<div>
|
<td class="px-4 py-3 text-sm font-medium text-gray-900">{{ o.nr_comanda }}</td>
|
||||||
<p class="text-sm font-medium text-gray-900">{{ o.nr_comanda }}</p>
|
<td class="px-4 py-3 text-sm text-gray-600">{{ o.nr_auto || '-' }}</td>
|
||||||
<p class="text-xs text-gray-500">
|
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ o.client_nume || '-' }}</td>
|
||||||
{{ o.nr_auto || 'Fara vehicul' }}
|
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">
|
||||||
<span v-if="o.client_nume"> - {{ o.client_nume }}</span>
|
{{ [o.marca_denumire, o.model_denumire].filter(Boolean).join(' ') || '-' }}
|
||||||
</p>
|
</td>
|
||||||
</div>
|
<td class="px-4 py-3">
|
||||||
<div class="text-right">
|
|
||||||
<span
|
<span
|
||||||
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium"
|
class="inline-block px-2 py-0.5 rounded-full text-xs font-medium"
|
||||||
:class="statusClass(o.status)"
|
:class="statusClass(o.status)"
|
||||||
>
|
>
|
||||||
{{ o.status }}
|
{{ o.status }}
|
||||||
</span>
|
</span>
|
||||||
<p class="text-sm font-medium text-gray-900 mt-1">{{ (o.total_general || 0).toFixed(2) }} RON</p>
|
</td>
|
||||||
</div>
|
<td class="px-4 py-3 text-sm text-right font-medium text-gray-900">
|
||||||
</router-link>
|
{{ (o.total_general || 0).toFixed(2) }}
|
||||||
</li>
|
</td>
|
||||||
</ul>
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, watch, onMounted, onUnmounted } from 'vue'
|
||||||
import { useOrdersStore } from '../../stores/orders.js'
|
import { useOrdersStore } from '../../stores/orders.js'
|
||||||
import { useSync } from '../../composables/useSync.js'
|
import { useSync } from '../../composables/useSync.js'
|
||||||
|
import { onTableChange } from '../../db/database.js'
|
||||||
import UpgradeBanner from '../../components/common/UpgradeBanner.vue'
|
import UpgradeBanner from '../../components/common/UpgradeBanner.vue'
|
||||||
|
|
||||||
useSync()
|
useSync()
|
||||||
|
|
||||||
const ordersStore = useOrdersStore()
|
const ordersStore = useOrdersStore()
|
||||||
const stats = ref({ totalOrders: 0, draftOrders: 0, validatedOrders: 0, totalVehicles: 0, totalRevenue: 0 })
|
const stats = ref({ totalOrders: 0, draftOrders: 0, validatedOrders: 0, facturatedOrders: 0, totalVehicles: 0, totalRevenue: 0 })
|
||||||
const recentOrders = ref([])
|
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 () => {
|
onMounted(async () => {
|
||||||
stats.value = await ordersStore.getStats()
|
await Promise.all([loadStats(), loadOrders()])
|
||||||
recentOrders.value = await ordersStore.getRecentOrders(5)
|
unsubOrders = onTableChange('orders', () => {
|
||||||
|
loadOrders()
|
||||||
|
loadStats()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unsubOrders) unsubOrders()
|
||||||
})
|
})
|
||||||
|
|
||||||
function statusClass(status) {
|
function statusClass(status) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<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">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">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">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>
|
<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">
|
<td class="px-4 py-3 text-sm font-medium text-gray-900">
|
||||||
{{ inv.serie_factura }}{{ inv.nr_factura }}
|
{{ inv.serie_factura }}{{ inv.nr_factura }}
|
||||||
</td>
|
</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">{{ 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.client_nume || '-' }}</td>
|
||||||
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ inv.nr_auto || '-' }}</td>
|
<td class="px-4 py-3 text-sm text-gray-600 hidden md:table-cell">{{ inv.nr_auto || '-' }}</td>
|
||||||
|
|||||||
@@ -2,77 +2,69 @@
|
|||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
<h1 class="text-2xl font-bold text-gray-900">Comanda noua</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<form @submit.prevent="handleCreate" class="bg-white rounded-lg shadow p-6 space-y-4 max-w-2xl">
|
<div class="bg-white rounded-lg shadow p-6 space-y-6 max-w-2xl">
|
||||||
<!-- Vehicle picker -->
|
<!-- Step 1: Select Client -->
|
||||||
<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>
|
|
||||||
<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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Tip deviz -->
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Tip deviz</label>
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<select v-model="form.tip_deviz_id" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm">
|
<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>
|
||||||
<option :value="null">-- Selecteaza --</option>
|
<h2 class="text-lg font-semibold text-gray-900">Selecteaza client</h2>
|
||||||
<option v-for="t in tipuriDeviz" :key="t.id" :value="t.id">{{ t.denumire }}</option>
|
</div>
|
||||||
</select>
|
<ClientPicker v-model="form.client_id" @select="onClientSelect" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- KM + Observatii -->
|
<!-- Step 2: Select Vehicle (visible after client is selected) -->
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div v-if="form.client_id" class="border-t pt-6">
|
||||||
<div>
|
<div class="flex items-center gap-2 mb-3">
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">KM intrare</label>
|
<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>
|
||||||
<input v-model.number="form.km_intrare" type="number" min="0" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
<h2 class="text-lg font-semibold text-gray-900">Selecteaza vehicul</h2>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<VehiclePicker v-model="form.vehicle_id" :client-id="form.client_id" @select="onVehicleSelect" />
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Observatii</label>
|
</div>
|
||||||
<input v-model="form.observatii" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
|
||||||
|
<!-- 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>
|
||||||
|
<select v-model="form.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>
|
||||||
|
|
||||||
|
<!-- KM + Observatii -->
|
||||||
|
<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="form.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="form.observatii" type="text" class="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
<p v-if="error" class="text-sm text-red-600">{{ error }}</p>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
v-if="form.vehicle_id"
|
||||||
|
@click="handleCreate"
|
||||||
:disabled="saving"
|
:disabled="saving"
|
||||||
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
class="px-6 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{{ saving ? 'Se creeaza...' : 'Creeaza comanda' }}
|
{{ saving ? 'Se creeaza...' : 'Creeaza comanda' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -80,31 +72,23 @@
|
|||||||
import { ref, reactive, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useOrdersStore } from '../../stores/orders.js'
|
import { useOrdersStore } from '../../stores/orders.js'
|
||||||
import { useVehiclesStore } from '../../stores/vehicles.js'
|
|
||||||
import { execSQL } from '../../db/database.js'
|
import { execSQL } from '../../db/database.js'
|
||||||
import { useAuthStore } from '../../stores/auth.js'
|
import { useAuthStore } from '../../stores/auth.js'
|
||||||
|
import ClientPicker from '../../components/clients/ClientPicker.vue'
|
||||||
import VehiclePicker from '../../components/vehicles/VehiclePicker.vue'
|
import VehiclePicker from '../../components/vehicles/VehiclePicker.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const ordersStore = useOrdersStore()
|
const ordersStore = useOrdersStore()
|
||||||
const vehiclesStore = useVehiclesStore()
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
|
client_id: null,
|
||||||
vehicle_id: null,
|
vehicle_id: null,
|
||||||
tip_deviz_id: null,
|
tip_deviz_id: null,
|
||||||
km_intrare: 0,
|
km_intrare: 0,
|
||||||
observatii: '',
|
observatii: '',
|
||||||
})
|
})
|
||||||
|
|
||||||
const newVehicle = reactive({
|
|
||||||
nr_inmatriculare: '',
|
|
||||||
client_nume: '',
|
|
||||||
client_telefon: '',
|
|
||||||
serie_sasiu: '',
|
|
||||||
})
|
|
||||||
|
|
||||||
const showNewVehicle = ref(false)
|
|
||||||
const tipuriDeviz = ref([])
|
const tipuriDeviz = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
@@ -116,18 +100,20 @@ onMounted(async () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function onClientSelect(client) {
|
||||||
|
if (!client) {
|
||||||
|
form.vehicle_id = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function onVehicleSelect(v) {
|
function onVehicleSelect(v) {
|
||||||
if (v) showNewVehicle.value = false
|
// vehicle selected
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleCreate() {
|
async function handleCreate() {
|
||||||
error.value = ''
|
error.value = ''
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
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)
|
const id = await ordersStore.create(form)
|
||||||
router.push(`/orders/${id}`)
|
router.push(`/orders/${id}`)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -1,33 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="order">
|
<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>
|
<div class="flex items-center gap-3">
|
||||||
<router-link to="/orders" class="text-sm text-gray-500 hover:text-gray-700">Comenzi</router-link>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-gray-900">{{ order.nr_comanda }}</h1>
|
<router-link to="/dashboard" class="text-sm text-gray-500 hover:text-gray-700">Comenzi</router-link>
|
||||||
</div>
|
<h1 class="text-2xl font-bold text-gray-900">{{ order.nr_comanda }}</h1>
|
||||||
<div class="flex gap-2">
|
</div>
|
||||||
<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
|
<span
|
||||||
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
|
class="inline-flex items-center px-3 py-1.5 rounded-full text-sm font-medium"
|
||||||
:class="statusClass(order.status)"
|
:class="statusClass(order.status)"
|
||||||
@@ -35,10 +13,105 @@
|
|||||||
{{ order.status }}
|
{{ order.status }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Order info -->
|
<!-- Edit form (DRAFT only) -->
|
||||||
<div class="bg-white rounded-lg shadow p-4 mb-6">
|
<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 class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-gray-500">Nr. auto</p>
|
<p class="text-gray-500">Nr. auto</p>
|
||||||
@@ -134,6 +207,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div v-else class="text-center py-12 text-gray-500">
|
<div v-else class="text-center py-12 text-gray-500">
|
||||||
@@ -142,30 +274,64 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, reactive, onMounted } from 'vue'
|
||||||
import { useRoute } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useOrdersStore } from '../../stores/orders.js'
|
import { useOrdersStore } from '../../stores/orders.js'
|
||||||
import { execSQL, onTableChange, notifyTableChanged } from '../../db/database.js'
|
import { execSQL, onTableChange, notifyTableChanged } from '../../db/database.js'
|
||||||
import { syncEngine } from '../../db/sync.js'
|
import { syncEngine } from '../../db/sync.js'
|
||||||
import { useAuthStore } from '../../stores/auth.js'
|
import { useAuthStore } from '../../stores/auth.js'
|
||||||
import OrderLineForm from '../../components/orders/OrderLineForm.vue'
|
import OrderLineForm from '../../components/orders/OrderLineForm.vue'
|
||||||
import PdfDownloadButton from '../../components/orders/PdfDownloadButton.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 route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
const ordersStore = useOrdersStore()
|
const ordersStore = useOrdersStore()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
|
|
||||||
const order = ref(null)
|
const order = ref(null)
|
||||||
const lines = ref([])
|
const lines = ref([])
|
||||||
|
const invoice = ref(null)
|
||||||
const facturand = ref(false)
|
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() {
|
async function loadOrder() {
|
||||||
order.value = await ordersStore.getById(route.params.id)
|
order.value = await ordersStore.getById(route.params.id)
|
||||||
lines.value = await ordersStore.getLines(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(() => {
|
onMounted(async () => {
|
||||||
loadOrder()
|
await loadOrder()
|
||||||
|
tipuriDeviz.value = await execSQL(
|
||||||
|
`SELECT * FROM catalog_tipuri_deviz WHERE tenant_id = ? ORDER BY denumire`,
|
||||||
|
[auth.tenantId]
|
||||||
|
)
|
||||||
onTableChange('orders', loadOrder)
|
onTableChange('orders', loadOrder)
|
||||||
onTableChange('order_lines', async () => {
|
onTableChange('order_lines', async () => {
|
||||||
lines.value = await ordersStore.getLines(route.params.id)
|
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) {
|
async function handleAddLine(lineData) {
|
||||||
await ordersStore.addLine(route.params.id, lineData)
|
await ordersStore.addLine(route.params.id, lineData)
|
||||||
}
|
}
|
||||||
@@ -185,18 +380,42 @@ async function handleValidate() {
|
|||||||
await ordersStore.validateOrder(route.params.id)
|
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() {
|
async function handleFactureaza() {
|
||||||
if (!order.value) return
|
if (!order.value) return
|
||||||
facturand.value = true
|
facturand.value = true
|
||||||
try {
|
try {
|
||||||
const id = crypto.randomUUID()
|
const id = crypto.randomUUID()
|
||||||
const now = new Date().toISOString()
|
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(
|
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)
|
`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 (?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
[id, auth.tenantId, order.value.id, nrFactura, 'ROA', now.slice(0,10),
|
[id, auth.tenantId, order.value.id, order.value.client_id || null,
|
||||||
order.value.client_nume, order.value.nr_auto,
|
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) / 1.19,
|
||||||
(order.value.total_general || 0) - (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]
|
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])
|
await execSQL(`UPDATE orders SET status='FACTURAT', updated_at=? WHERE id=?`, [now, order.value.id])
|
||||||
notifyTableChanged('invoices')
|
notifyTableChanged('invoices')
|
||||||
notifyTableChanged('orders')
|
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 {
|
} finally {
|
||||||
facturand.value = false
|
facturand.value = false
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user