diff --git a/CLAUDE.md b/CLAUDE.md index c74e35f..bf071ec 100644 --- a/CLAUDE.md +++ b/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` - `auth_headers` fixture registers a user and returns `Authorization` header for authenticated tests - Demo credentials (after `make seed`): `demo@roaauto.ro` / `demo123` + +## Execution Preferences +- Executia task-urilor se face cu **team agents** (nu cu skill superpowers subagents) +- **Playwright testing obligatoriu**: orice implementare majora trebuie testata E2E cu Playwright + - Desktop: 1280x720, Mobile: 375x812 + - Verificari: responsive, elemente nu se suprapun, nu ies din ecran, butoane cu gap suficient + - Raportul Playwright se salveaza in `docs/playwright-report-YYYY-MM-DD.md` + +## Data Model: Clients +- Tabel `clients` - nomenclator clienti cu date eFactura ANAF, separat de vehicule +- Un client poate avea mai multe vehicule (1:N prin client_id pe vehicles) +- `tip_persoana`: PF (persoana fizica) / PJ (persoana juridica) +- Campuri eFactura: cod_fiscal, reg_com, adresa, judet, oras, cod_postal, cont_iban, banca + +## Data Model: Invoices +- `tip_document`: FACTURA (B2B, eFactura ANAF) sau BON_FISCAL (B2C, casa de marcat) +- Factura: necesita date client complete (CUI, adresa) +- Bon fiscal: format simplificat + +## Order Lifecycle +- `DRAFT` → `VALIDAT` → `FACTURAT` (cu devalidare VALIDAT → DRAFT) +- Stergere: orice nefacturat; FACTURAT = sterge factura intai +- Edit header doar in DRAFT diff --git a/backend/alembic/versions/6d8b5bd44531_add_clients_table_and_client_id_columns.py b/backend/alembic/versions/6d8b5bd44531_add_clients_table_and_client_id_columns.py new file mode 100644 index 0000000..83ce5c9 --- /dev/null +++ b/backend/alembic/versions/6d8b5bd44531_add_clients_table_and_client_id_columns.py @@ -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') diff --git a/backend/app/clients/__init__.py b/backend/app/clients/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/clients/router.py b/backend/app/clients/router.py new file mode 100644 index 0000000..1832e39 --- /dev/null +++ b/backend/app/clients/router.py @@ -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} diff --git a/backend/app/clients/schemas.py b/backend/app/clients/schemas.py new file mode 100644 index 0000000..296ad3e --- /dev/null +++ b/backend/app/clients/schemas.py @@ -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 diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index c02c417..7a4affa 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -1,5 +1,6 @@ from app.db.models.tenant import Tenant from app.db.models.user import User +from app.db.models.client import Client from app.db.models.vehicle import Vehicle from app.db.models.order import Order from app.db.models.order_line import OrderLine @@ -20,6 +21,7 @@ from app.db.models.invite import InviteToken __all__ = [ "Tenant", "User", + "Client", "Vehicle", "Order", "OrderLine", diff --git a/backend/app/db/models/client.py b/backend/app/db/models/client.py new file mode 100644 index 0000000..2b728f1 --- /dev/null +++ b/backend/app/db/models/client.py @@ -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) diff --git a/backend/app/db/models/invoice.py b/backend/app/db/models/invoice.py index de7d472..31a3ab1 100644 --- a/backend/app/db/models/invoice.py +++ b/backend/app/db/models/invoice.py @@ -7,6 +7,8 @@ from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin class Invoice(Base, UUIDMixin, TenantMixin, TimestampMixin): __tablename__ = "invoices" order_id: Mapped[str | None] = mapped_column(String(36), index=True) + client_id: Mapped[str | None] = mapped_column(String(36)) + tip_document: Mapped[str | None] = mapped_column(String(20), default="FACTURA", server_default="FACTURA") nr_factura: Mapped[str | None] = mapped_column(String(50)) serie_factura: Mapped[str | None] = mapped_column(String(20)) data_factura: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/db/models/order.py b/backend/app/db/models/order.py index 11cf8df..ea01e2c 100644 --- a/backend/app/db/models/order.py +++ b/backend/app/db/models/order.py @@ -8,6 +8,7 @@ class Order(Base, UUIDMixin, TenantMixin, TimestampMixin): __tablename__ = "orders" nr_comanda: Mapped[str | None] = mapped_column(String(50)) vehicle_id: Mapped[str | None] = mapped_column(String(36)) + client_id: Mapped[str | None] = mapped_column(String(36)) tip_deviz_id: Mapped[str | None] = mapped_column(String(36)) status: Mapped[str] = mapped_column(String(20), default="DRAFT", server_default="DRAFT") data_comanda: Mapped[str | None] = mapped_column(Text) diff --git a/backend/app/db/models/vehicle.py b/backend/app/db/models/vehicle.py index b7a406c..fb7226b 100644 --- a/backend/app/db/models/vehicle.py +++ b/backend/app/db/models/vehicle.py @@ -7,6 +7,7 @@ from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin class Vehicle(Base, UUIDMixin, TenantMixin, TimestampMixin): __tablename__ = "vehicles" nr_inmatriculare: Mapped[str] = mapped_column(String(20)) + client_id: Mapped[str | None] = mapped_column(String(36)) marca_id: Mapped[str | None] = mapped_column(String(36)) model_id: Mapped[str | None] = mapped_column(String(36)) an_fabricatie: Mapped[int | None] = mapped_column(Integer) diff --git a/backend/app/db/seed.py b/backend/app/db/seed.py index 20107a6..973e6c8 100644 --- a/backend/app/db/seed.py +++ b/backend/app/db/seed.py @@ -52,14 +52,7 @@ ANSAMBLE = [ "Revizie", ] -TIPURI_DEVIZ = [ - "Deviz reparatie", - "Deviz revizie", - "Deviz diagnosticare", - "Deviz estimativ", - "Deviz vulcanizare", - "Deviz ITP", -] +TIPURI_DEVIZ = ["Service", "ITP", "Regie", "Constatare"] TIPURI_MOTOARE = ["Benzina", "Diesel", "Hibrid", "Electric", "GPL"] diff --git a/backend/app/invoices/router.py b/backend/app/invoices/router.py index 447e505..66bcc93 100644 --- a/backend/app/invoices/router.py +++ b/backend/app/invoices/router.py @@ -1,3 +1,5 @@ +from datetime import UTC, datetime + from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import Response from sqlalchemy import select @@ -109,3 +111,39 @@ async def get_invoice_pdf( "Content-Disposition": f'inline; filename="factura-{invoice.nr_factura}.pdf"' }, ) + + +@router.delete("/{invoice_id}") +async def delete_invoice( + invoice_id: str, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + r = await db.execute( + select(Invoice).where( + Invoice.id == invoice_id, Invoice.tenant_id == tenant_id + ) + ) + invoice = r.scalar_one_or_none() + if not invoice: + raise HTTPException(status_code=404, detail="Invoice not found") + + order_id = invoice.order_id + + # Delete the invoice + await db.delete(invoice) + + # Revert the associated order status back to VALIDAT + if order_id: + r = await db.execute( + select(Order).where( + Order.id == order_id, Order.tenant_id == tenant_id + ) + ) + order = r.scalar_one_or_none() + if order: + order.status = "VALIDAT" + order.updated_at = datetime.now(UTC).isoformat() + + await db.commit() + return {"ok": True} diff --git a/backend/app/main.py b/backend/app/main.py index f24dcc3..25425e7 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -5,6 +5,7 @@ from fastapi.middleware.cors import CORSMiddleware from app.auth.router import router as auth_router from app.client_portal.router import router as portal_router +from app.clients.router import router as clients_router from app.config import settings from app.db.base import Base from app.db.session import engine @@ -35,6 +36,7 @@ app.add_middleware( ) app.include_router(auth_router, prefix="/api/auth") app.include_router(sync_router, prefix="/api/sync") +app.include_router(clients_router, prefix="/api/clients") app.include_router(orders_router, prefix="/api/orders") app.include_router(vehicles_router, prefix="/api/vehicles") app.include_router(invoices_router, prefix="/api/invoices") diff --git a/backend/app/orders/router.py b/backend/app/orders/router.py index 0763a20..1c452f0 100644 --- a/backend/app/orders/router.py +++ b/backend/app/orders/router.py @@ -1,8 +1,11 @@ +from datetime import UTC, datetime + from fastapi import APIRouter, Depends, HTTPException from fastapi.responses import Response -from sqlalchemy import select +from sqlalchemy import select, text from sqlalchemy.ext.asyncio import AsyncSession +from app.db.models.invoice import Invoice from app.db.models.order import Order from app.db.models.order_line import OrderLine from app.db.models.tenant import Tenant @@ -155,3 +158,115 @@ async def get_deviz_pdf( "Content-Disposition": f'inline; filename="deviz-{order.id[:8]}.pdf"' }, ) + + +@router.put("/{order_id}") +async def update_order( + order_id: str, + data: schemas.UpdateOrderRequest, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + r = await db.execute( + select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id) + ) + order = r.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if order.status != "DRAFT": + raise HTTPException(status_code=422, detail="Can only update DRAFT orders") + update_data = data.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(order, key, value) + order.updated_at = datetime.now(UTC).isoformat() + await db.commit() + await db.refresh(order) + return { + "id": order.id, + "vehicle_id": order.vehicle_id, + "client_id": order.client_id, + "tip_deviz_id": order.tip_deviz_id, + "status": order.status, + "km_intrare": order.km_intrare, + "observatii": order.observatii, + "client_nume": order.client_nume, + "client_telefon": order.client_telefon, + "nr_auto": order.nr_auto, + "marca_denumire": order.marca_denumire, + "model_denumire": order.model_denumire, + } + + +@router.post("/{order_id}/devalidate") +async def devalidate_order( + order_id: str, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + r = await db.execute( + select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id) + ) + order = r.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + if order.status != "VALIDAT": + raise HTTPException(status_code=422, detail="Can only devalidate VALIDAT orders") + + # Check no invoice exists for this order + r = await db.execute( + select(Invoice).where( + Invoice.order_id == order_id, Invoice.tenant_id == tenant_id + ) + ) + invoice = r.scalar_one_or_none() + if invoice: + raise HTTPException( + status_code=422, + detail="Cannot devalidate order with existing invoice" + ) + + order.status = "DRAFT" + order.updated_at = datetime.now(UTC).isoformat() + await db.commit() + return {"ok": True, "status": "DRAFT"} + + +@router.delete("/{order_id}") +async def delete_order( + order_id: str, + tenant_id: str = Depends(get_tenant_id), + db: AsyncSession = Depends(get_db), +): + r = await db.execute( + select(Order).where(Order.id == order_id, Order.tenant_id == tenant_id) + ) + order = r.scalar_one_or_none() + if not order: + raise HTTPException(status_code=404, detail="Order not found") + + if order.status == "FACTURAT": + # Check if invoice exists + r = await db.execute( + select(Invoice).where( + Invoice.order_id == order_id, Invoice.tenant_id == tenant_id + ) + ) + invoice = r.scalar_one_or_none() + if invoice: + raise HTTPException( + status_code=422, + detail="Cannot delete order with existing invoice" + ) + + # Delete order lines first + await db.execute( + text("DELETE FROM order_lines WHERE order_id = :oid AND tenant_id = :tid"), + {"oid": order_id, "tid": tenant_id}, + ) + # Delete the order + await db.execute( + text("DELETE FROM orders WHERE id = :oid AND tenant_id = :tid"), + {"oid": order_id, "tid": tenant_id}, + ) + await db.commit() + return {"ok": True} diff --git a/backend/app/orders/schemas.py b/backend/app/orders/schemas.py index fa861d2..1c38dae 100644 --- a/backend/app/orders/schemas.py +++ b/backend/app/orders/schemas.py @@ -8,6 +8,19 @@ class CreateOrderRequest(BaseModel): observatii: str | None = None +class UpdateOrderRequest(BaseModel): + vehicle_id: str | None = None + tip_deviz_id: str | None = None + km_intrare: int | None = None + observatii: str | None = None + client_id: str | None = None + client_nume: str | None = None + client_telefon: str | None = None + nr_auto: str | None = None + marca_denumire: str | None = None + model_denumire: str | None = None + + class AddLineRequest(BaseModel): tip: str # manopera | material descriere: str diff --git a/backend/app/sync/service.py b/backend/app/sync/service.py index 878ca0f..8701cb8 100644 --- a/backend/app/sync/service.py +++ b/backend/app/sync/service.py @@ -4,6 +4,7 @@ from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession SYNCABLE_TABLES = [ + "clients", "vehicles", "orders", "order_lines", diff --git a/docs/PLAN.md b/docs/PLAN.md index 8a6df7e..4a99a44 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -245,13 +245,18 @@ catalog_tipuri_deviz (id, tenant_id, denumire) catalog_tipuri_motoare (id, tenant_id, denumire) mecanici (id, tenant_id, user_id, nume, prenume, activ) +-- Clients (nomenclator clienti cu date eFactura ANAF) +clients (id, tenant_id, tip_persoana, denumire, cod_fiscal, reg_com, + adresa, judet, oras, cod_postal, tara, telefon, email, + cont_iban, banca, observatii, activ, created_at, updated_at) + -- Core Business -vehicles (id, tenant_id, client_nume, client_telefon, client_email, +vehicles (id, tenant_id, client_id, client_nume, client_telefon, client_email, client_cod_fiscal, client_adresa, nr_inmatriculare, marca_id, model_id, an_fabricatie, serie_sasiu, tip_motor_id, created_at, updated_at) -orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, +orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, client_id, tip_deviz_id, status, km_intrare, observatii, -- client snapshot (denormalized) client_nume, client_telefon, nr_auto, marca_denumire, model_denumire, @@ -266,8 +271,8 @@ order_lines (id, order_id, tenant_id, tip, descriere, um, cantitate, pret_unitar, -- material total, mecanic_id, ordine, created_at, updated_at) -invoices (id, tenant_id, order_id, nr_factura, serie_factura, - data_factura, modalitate_plata, +invoices (id, tenant_id, order_id, client_id, nr_factura, serie_factura, + data_factura, tip_document, modalitate_plata, client_nume, client_cod_fiscal, nr_auto, total_fara_tva, tva, total_general, created_at, updated_at) @@ -379,6 +384,46 @@ _sync_state (table_name, last_sync_at) 4. Responsive testing (phone, tablet, desktop) 5. Reports: sumar lunar, export CSV +### Faza 8: Nomenclator Clienti (Clients) +**Livrabil: CRUD clienti cu date eFactura ANAF, legatura 1:N cu vehicule** + +1. Model `clients` + migrare Alembic (backend) +2. `client_id` FK pe `vehicles`, `orders`, `invoices` +3. CRUD endpoints: `GET/POST /api/clients`, `GET/PUT/DELETE /api/clients/{id}` +4. wa-sqlite schema update (tabel `clients`, FK-uri) +5. Frontend: pagina Clienti (list, create, edit, delete) +6. Frontend: selector client in VehiclePicker si OrderCreate +7. Sync: adauga `clients` in `SYNCABLE_TABLES` +8. Playwright E2E tests (desktop + mobile) + +### Faza 9: Edit/Delete/Devalidare Comenzi +**Livrabil: Gestionare completa comenzi - edit, stergere, devalidare** + +1. `PUT /api/orders/{id}` - edit header comanda (doar in DRAFT) +2. `DELETE /api/orders/{id}` - stergere comanda (orice nefacturat) +3. `POST /api/orders/{id}/devalidate` - VALIDAT → DRAFT +4. `DELETE /api/invoices/{id}` - stergere factura (permite stergere comanda FACTURAT) +5. Frontend: butoane edit/delete/devalidare pe OrderDetail +6. Confirmare stergere cu modal +7. Playwright E2E tests + +### Faza 10: Integrare Nomenclator Clienti +**Livrabil: Clienti integrati in flux comenzi si facturi** + +1. Auto-populare date client pe comanda din nomenclator +2. Selectie client existent sau creare client nou la vehicul +3. Validare date client complete la facturare (CUI, adresa) +4. PDF factura cu date client din nomenclator + +### Faza 11: Bon Fiscal (tip_document) +**Livrabil: Suport dual FACTURA + BON_FISCAL pe invoices** + +1. `tip_document` pe invoices: FACTURA (B2B, eFactura) sau BON_FISCAL (B2C, casa de marcat) +2. Factura: necesita date client complete (CUI, adresa) +3. Bon fiscal: format simplificat, fara date client obligatorii +4. UI: selectie tip document la facturare +5. PDF template diferentiat pentru bon fiscal + --- ## Referinta din Prototip (doar consultare) @@ -405,6 +450,9 @@ SaaS-ul trebuie sa fie compatibil cu ROAAUTO VFP9+Oracle. Clientii care cresc po | `order_lines` (tip=manopera) | `dev_oper` | SaaS unifica oper+materiale in order_lines | | `order_lines` (tip=material) | `dev_estimari_produse` | Acelasi tabel, filtrat pe `tip` | | `vehicles` | `dev_masiniclienti` | Renamed, aceleasi coloane client+vehicul | +| `clients` | `nom_parteneri` + `adrese_parteneri` | Adrese simplificate flat | +| `clients.tip_persoana` | `nom_parteneri.tip_persoana` | PF/PJ | +| `clients.cod_fiscal` | `nom_parteneri.cod_fiscal` | CUI sau CNP | | `catalog_marci` | `dev_nom_marci` | +tenant_id | | `catalog_modele` | `dev_nom_masini` | Identic | | `catalog_ansamble` | `dev_nom_ansamble` | +tenant_id | @@ -414,6 +462,10 @@ SaaS-ul trebuie sa fie compatibil cu ROAAUTO VFP9+Oracle. Clientii care cresc po | `catalog_tipuri_motoare` | `dev_tipuri_motoare` | +tenant_id | | `mecanici` | `dev_mecanici` | +tenant_id, +user_id | | `invoices` | `facturi` (local) | Identic structural | +| `invoices.tip_document` | `vanzari.tip_factura` | FACTURA/BON_FISCAL | +| `invoices.client_id` | `vanzari.id_part` | FK la client | +| `orders.client_id` | (denormalizat) | Referinta directa la client | +| `vehicles.client_id` | (implicit in dev_masiniclienti) | 1:N client → vehicule | | `tenants` | - | Doar SaaS (nu exista in Oracle) | | `users` | - | Doar SaaS | | `appointments` | - | Doar SaaS (feature nou) | diff --git a/docs/api-contract.json b/docs/api-contract.json index aab5c2f..ec8a898 100644 --- a/docs/api-contract.json +++ b/docs/api-contract.json @@ -21,7 +21,7 @@ "headers": {"Authorization": "Bearer "}, "response": { "tables": { - "vehicles": [], "orders": [], "order_lines": [], + "clients": [], "vehicles": [], "orders": [], "order_lines": [], "invoices": [], "appointments": [], "catalog_marci": [], "catalog_modele": [], "catalog_ansamble": [], "catalog_norme": [], @@ -42,12 +42,22 @@ }, "orders": { "GET /orders": {"response": [{"id": "str", "status": "str", "nr_auto": "str", "total_general": 0}]}, - "POST /orders": {"body": {"vehicle_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "response": {"id": "str"}}, + "POST /orders": {"body": {"vehicle_id": "str", "client_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "response": {"id": "str"}}, "GET /orders/{id}": {"response": {"id": "str", "status": "str", "lines": []}}, + "PUT /orders/{id}": {"body": {"vehicle_id": "str", "client_id": "str", "tip_deviz_id": "str", "km_intrare": 0, "observatii": "str"}, "note": "Edit header - doar in DRAFT"}, + "DELETE /orders/{id}": {"response": {"ok": true}, "note": "Stergere - orice nefacturat; FACTURAT = sterge factura intai"}, "POST /orders/{id}/lines": {"body": {"tip": "manopera|material", "descriere": "str", "ore": 0, "pret_ora": 0, "cantitate": 0, "pret_unitar": 0, "um": "str"}}, "POST /orders/{id}/validate": {"response": {"status": "VALIDAT"}}, + "POST /orders/{id}/devalidate": {"response": {"status": "DRAFT"}, "note": "VALIDAT → DRAFT"}, "GET /orders/{id}/pdf/deviz": {"response": "application/pdf"} }, + "clients": { + "GET /clients": {"response": [{"id": "str", "tip_persoana": "PF|PJ", "denumire": "str", "cod_fiscal": "str", "telefon": "str", "email": "str", "activ": true}]}, + "POST /clients": {"body": {"tip_persoana": "PF|PJ", "denumire": "str", "cod_fiscal": "str", "reg_com": "str", "adresa": "str", "judet": "str", "oras": "str", "cod_postal": "str", "tara": "str", "telefon": "str", "email": "str", "cont_iban": "str", "banca": "str", "observatii": "str"}, "response": {"id": "str"}}, + "GET /clients/{id}": {"response": {"id": "str", "tip_persoana": "str", "denumire": "str", "cod_fiscal": "str", "vehicles": []}}, + "PUT /clients/{id}": {"body": {"denumire": "str", "cod_fiscal": "str", "adresa": "str"}, "response": {"id": "str"}}, + "DELETE /clients/{id}": {"response": {"ok": true}} + }, "vehicles": { "GET /vehicles": {"response": [{"id": "str", "nr_auto": "str", "marca": "str", "model": "str", "an": 0}]}, "POST /vehicles": {"body": {"nr_auto": "str", "marca_id": "str", "model_id": "str", "an_fabricatie": 0, "vin": "str", "proprietar_nume": "str", "proprietar_telefon": "str"}}, @@ -60,8 +70,9 @@ "POST /p/{token}/reject": {"response": {"ok": true}} }, "invoices": { - "POST /invoices": {"body": {"order_id": "str"}, "response": {"id": "str", "nr_factura": "str"}}, - "GET /invoices/{id}/pdf": {"response": "application/pdf"} + "POST /invoices": {"body": {"order_id": "str", "client_id": "str", "tip_document": "FACTURA|BON_FISCAL"}, "response": {"id": "str", "nr_factura": "str"}}, + "GET /invoices/{id}/pdf": {"response": "application/pdf"}, + "DELETE /invoices/{id}": {"response": {"ok": true}, "note": "Sterge factura, comanda revine la VALIDAT"} }, "users": { "GET /users": {"response": [{"id": "str", "email": "str", "rol": "str"}]}, diff --git a/docs/playwright-report-2026-03-14.md b/docs/playwright-report-2026-03-14.md new file mode 100644 index 0000000..dd50859 --- /dev/null +++ b/docs/playwright-report-2026-03-14.md @@ -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. diff --git a/frontend/src/components/clients/ClientPicker.vue b/frontend/src/components/clients/ClientPicker.vue new file mode 100644 index 0000000..4741908 --- /dev/null +++ b/frontend/src/components/clients/ClientPicker.vue @@ -0,0 +1,211 @@ + + + diff --git a/frontend/src/components/orders/OrderLineForm.vue b/frontend/src/components/orders/OrderLineForm.vue index 84bd742..87290f2 100644 --- a/frontend/src/components/orders/OrderLineForm.vue +++ b/frontend/src/components/orders/OrderLineForm.vue @@ -14,15 +14,34 @@ - -
+ +
+ +
    +
  • + {{ s.denumire }} + ({{ s.ansamblu_denumire }}) + [{{ s.cod }}] + {{ (s.pret || 0).toFixed(2) }}/{{ s.um || 'buc' }} +
  • +
@@ -99,13 +118,16 @@ diff --git a/frontend/src/db/schema.js b/frontend/src/db/schema.js index 9cfab63..ab896ba 100644 --- a/frontend/src/db/schema.js +++ b/frontend/src/db/schema.js @@ -4,23 +4,50 @@ export const SCHEMA_SQL = ` adresa TEXT, telefon TEXT, email TEXT, iban TEXT, banca TEXT, plan TEXT DEFAULT 'free', trial_expires_at TEXT, created_at TEXT, updated_at TEXT ); + CREATE TABLE IF NOT EXISTS clients ( + id TEXT PRIMARY KEY, + tenant_id TEXT NOT NULL, + tip_persoana TEXT DEFAULT 'PF', + denumire TEXT, + nume TEXT, + prenume TEXT, + cod_fiscal TEXT, + reg_com TEXT, + telefon TEXT, + email TEXT, + adresa TEXT, + judet TEXT, + oras TEXT, + cod_postal TEXT, + tara TEXT DEFAULT 'RO', + cont_iban TEXT, + banca TEXT, + activ INTEGER DEFAULT 1, + oracle_id INTEGER, + created_at TEXT, + updated_at TEXT + ); CREATE TABLE IF NOT EXISTS vehicles ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, + client_id TEXT, client_nume TEXT, client_telefon TEXT, client_email TEXT, - client_cod_fiscal TEXT, client_adresa TEXT, + client_cod_fiscal TEXT, client_cui TEXT, client_adresa TEXT, nr_inmatriculare TEXT, marca_id TEXT, model_id TEXT, - an_fabricatie INTEGER, serie_sasiu TEXT, tip_motor_id TEXT, + an_fabricatie INTEGER, vin TEXT, serie_sasiu TEXT, tip_motor_id TEXT, + capacitate_motor TEXT, putere_kw TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS orders ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, nr_comanda TEXT, data_comanda TEXT, vehicle_id TEXT, + client_id TEXT, tip_deviz_id TEXT, status TEXT DEFAULT 'DRAFT', km_intrare INTEGER, observatii TEXT, + mecanic_id TEXT, client_nume TEXT, client_telefon TEXT, nr_auto TEXT, marca_denumire TEXT, model_denumire TEXT, total_manopera REAL DEFAULT 0, total_materiale REAL DEFAULT 0, total_general REAL DEFAULT 0, - token_client TEXT, created_by TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT + token_client TEXT, status_client TEXT, created_by TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS order_lines ( id TEXT PRIMARY KEY, order_id TEXT NOT NULL, tenant_id TEXT NOT NULL, @@ -32,9 +59,12 @@ export const SCHEMA_SQL = ` ); CREATE TABLE IF NOT EXISTS invoices ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, order_id TEXT, + client_id TEXT, nr_factura TEXT, serie_factura TEXT, data_factura TEXT, + tip_document TEXT DEFAULT 'FACTURA', modalitate_plata TEXT, client_nume TEXT, client_cod_fiscal TEXT, nr_auto TEXT, total_fara_tva REAL, tva REAL, total_general REAL, + total REAL DEFAULT 0, status TEXT DEFAULT 'EMISA', oracle_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS appointments ( @@ -46,31 +76,31 @@ export const SCHEMA_SQL = ` ); CREATE TABLE IF NOT EXISTS catalog_marci ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, activ INTEGER DEFAULT 1, - oracle_id INTEGER, updated_at TEXT + oracle_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS catalog_modele ( - id TEXT PRIMARY KEY, marca_id TEXT, denumire TEXT, oracle_id INTEGER, updated_at TEXT + id TEXT PRIMARY KEY, marca_id TEXT, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS catalog_ansamble ( - id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS catalog_norme ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, cod TEXT, denumire TEXT, - ore_normate REAL, ansamblu_id TEXT, oracle_id INTEGER, updated_at TEXT + ore_normate REAL, ansamblu_id TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS catalog_preturi ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, pret REAL, um TEXT, - oracle_id INTEGER, updated_at TEXT + oracle_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS catalog_tipuri_deviz ( - id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS catalog_tipuri_motoare ( - id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, updated_at TEXT + id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, denumire TEXT, oracle_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS mecanici ( id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, user_id TEXT, - nume TEXT, prenume TEXT, activ INTEGER DEFAULT 1, oracle_id INTEGER, updated_at TEXT + nume TEXT, prenume TEXT, activ INTEGER DEFAULT 1, oracle_id INTEGER, created_at TEXT, updated_at TEXT ); CREATE TABLE IF NOT EXISTS _sync_queue ( id TEXT PRIMARY KEY, table_name TEXT, row_id TEXT, @@ -82,7 +112,7 @@ export const SCHEMA_SQL = ` `; export const SYNC_TABLES = [ - 'vehicles', 'orders', 'order_lines', 'invoices', 'appointments', + 'clients', 'vehicles', 'orders', 'order_lines', 'invoices', 'appointments', 'catalog_marci', 'catalog_modele', 'catalog_ansamble', 'catalog_norme', 'catalog_preturi', 'catalog_tipuri_deviz', 'catalog_tipuri_motoare', 'mecanici' ]; diff --git a/frontend/src/layouts/AppLayout.vue b/frontend/src/layouts/AppLayout.vue index e5e51f7..6b035d4 100644 --- a/frontend/src/layouts/AppLayout.vue +++ b/frontend/src/layouts/AppLayout.vue @@ -87,8 +87,8 @@ const mobileMenuOpen = ref(false) const navItems = [ { path: '/dashboard', label: 'Dashboard' }, - { path: '/orders', label: 'Comenzi' }, { path: '/invoices', label: 'Facturi' }, + { path: '/clients', label: 'Clienti' }, { path: '/vehicles', label: 'Vehicule' }, { path: '/appointments', label: 'Programari' }, { path: '/catalog', label: 'Catalog' }, @@ -97,7 +97,7 @@ const navItems = [ const mobileNavItems = [ { path: '/dashboard', label: 'Acasa' }, - { path: '/orders', label: 'Comenzi' }, + { path: '/clients', label: 'Clienti' }, { path: '/vehicles', label: 'Vehicule' }, { path: '/appointments', label: 'Programari' }, { path: '/settings', label: 'Setari' }, diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 84694c8..8cf75b9 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -7,9 +7,10 @@ const router = createRouter({ { path: '/login', component: () => import('../views/auth/LoginView.vue'), meta: { layout: 'auth' } }, { path: '/register', component: () => import('../views/auth/RegisterView.vue'), meta: { layout: 'auth' } }, { path: '/dashboard', component: () => import('../views/dashboard/DashboardView.vue'), meta: { requiresAuth: true } }, - { path: '/orders', component: () => import('../views/orders/OrdersListView.vue'), meta: { requiresAuth: true } }, + { path: '/orders', redirect: '/dashboard' }, { path: '/orders/new', component: () => import('../views/orders/OrderCreateView.vue'), meta: { requiresAuth: true } }, { path: '/orders/:id', component: () => import('../views/orders/OrderDetailView.vue'), meta: { requiresAuth: true } }, + { path: '/clients', component: () => import('../views/clients/ClientsListView.vue'), meta: { requiresAuth: true } }, { path: '/vehicles', component: () => import('../views/vehicles/VehiclesListView.vue'), meta: { requiresAuth: true } }, { path: '/appointments', component: () => import('../views/appointments/AppointmentsView.vue'), meta: { requiresAuth: true } }, { path: '/catalog', component: () => import('../views/catalog/CatalogView.vue'), meta: { requiresAuth: true } }, diff --git a/frontend/src/stores/clients.js b/frontend/src/stores/clients.js new file mode 100644 index 0000000..c1a97ba --- /dev/null +++ b/frontend/src/stores/clients.js @@ -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 } +}) diff --git a/frontend/src/stores/orders.js b/frontend/src/stores/orders.js index f0d6630..1ddf617 100644 --- a/frontend/src/stores/orders.js +++ b/frontend/src/stores/orders.js @@ -6,13 +6,19 @@ import { useAuthStore } from './auth.js' export const useOrdersStore = defineStore('orders', () => { const auth = useAuthStore() - async function getAll(statusFilter = null) { - let sql = `SELECT * FROM orders WHERE tenant_id = ? ORDER BY created_at DESC` + async function getAll(statusFilter = null, search = '') { + let sql = `SELECT * FROM orders WHERE tenant_id = ?` const params = [auth.tenantId] if (statusFilter) { - sql = `SELECT * FROM orders WHERE tenant_id = ? AND status = ? ORDER BY created_at DESC` + sql += ` AND status = ?` params.push(statusFilter) } + if (search && search.length >= 2) { + sql += ` AND (nr_auto LIKE ? OR client_nume LIKE ? OR nr_comanda LIKE ?)` + const like = `%${search}%` + params.push(like, like, like) + } + sql += ` ORDER BY created_at DESC` return execSQL(sql, params) } @@ -40,13 +46,23 @@ export const useOrdersStore = defineStore('orders', () => { const now = new Date().toISOString() const nr = `CMD-${Date.now().toString(36).toUpperCase()}` + // Lookup client info for denormalized fields + let clientNume = '', clientTelefon = '' + if (data.client_id) { + const [c] = await execSQL(`SELECT * FROM clients WHERE id = ?`, [data.client_id]) + if (c) { + clientNume = c.tip_persoana === 'PJ' ? (c.denumire || '') : [c.nume, c.prenume].filter(Boolean).join(' ') + clientTelefon = c.telefon || '' + } + } + // Lookup vehicle info for denormalized fields - let clientNume = '', clientTelefon = '', nrAuto = '', marcaDenumire = '', modelDenumire = '' + let nrAuto = '', marcaDenumire = '', modelDenumire = '' if (data.vehicle_id) { const [v] = await execSQL(`SELECT * FROM vehicles WHERE id = ?`, [data.vehicle_id]) if (v) { - clientNume = v.client_nume || '' - clientTelefon = v.client_telefon || '' + if (!clientNume) clientNume = v.client_nume || '' + if (!clientTelefon) clientTelefon = v.client_telefon || '' nrAuto = v.nr_inmatriculare || '' const [marca] = await execSQL(`SELECT denumire FROM catalog_marci WHERE id = ?`, [v.marca_id]) const [model] = await execSQL(`SELECT denumire FROM catalog_modele WHERE id = ?`, [v.model_id]) @@ -56,16 +72,18 @@ export const useOrdersStore = defineStore('orders', () => { } await execSQL( - `INSERT INTO orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, tip_deviz_id, status, km_intrare, observatii, client_nume, client_telefon, nr_auto, marca_denumire, model_denumire, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - [id, auth.tenantId, nr, now, data.vehicle_id || null, data.tip_deviz_id || null, + `INSERT INTO orders (id, tenant_id, nr_comanda, data_comanda, vehicle_id, client_id, tip_deviz_id, status, km_intrare, observatii, client_nume, client_telefon, nr_auto, marca_denumire, model_denumire, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [id, auth.tenantId, nr, now, data.vehicle_id || null, data.client_id || null, + data.tip_deviz_id || null, 'DRAFT', data.km_intrare || 0, data.observatii || '', clientNume, clientTelefon, nrAuto, marcaDenumire, modelDenumire, now, now] ) notifyTableChanged('orders') await syncEngine.addToQueue('orders', id, 'INSERT', { id, tenant_id: auth.tenantId, nr_comanda: nr, data_comanda: now, - vehicle_id: data.vehicle_id, tip_deviz_id: data.tip_deviz_id, + vehicle_id: data.vehicle_id, client_id: data.client_id, + tip_deviz_id: data.tip_deviz_id, status: 'DRAFT', km_intrare: data.km_intrare || 0, observatii: data.observatii || '', client_nume: clientNume, client_telefon: clientTelefon, nr_auto: nrAuto, marca_denumire: marcaDenumire, model_denumire: modelDenumire @@ -137,6 +155,49 @@ export const useOrdersStore = defineStore('orders', () => { }) } + async function updateHeader(orderId, data) { + const now = new Date().toISOString() + + // Re-denormalize client and vehicle info if IDs changed + let clientNume = data.client_nume, clientTelefon = data.client_telefon + let nrAuto = data.nr_auto, marcaDenumire = data.marca_denumire, modelDenumire = data.model_denumire + + if (data.client_id) { + const [c] = await execSQL(`SELECT * FROM clients WHERE id = ?`, [data.client_id]) + if (c) { + clientNume = c.tip_persoana === 'PJ' ? (c.denumire || '') : [c.nume, c.prenume].filter(Boolean).join(' ') + clientTelefon = c.telefon || '' + } + } + if (data.vehicle_id) { + const [v] = await execSQL(`SELECT * FROM vehicles WHERE id = ?`, [data.vehicle_id]) + if (v) { + nrAuto = v.nr_inmatriculare || '' + const [marca] = await execSQL(`SELECT denumire FROM catalog_marci WHERE id = ?`, [v.marca_id]) + const [model] = await execSQL(`SELECT denumire FROM catalog_modele WHERE id = ?`, [v.model_id]) + marcaDenumire = marca?.denumire || '' + modelDenumire = model?.denumire || '' + } + } + + await execSQL( + `UPDATE orders SET client_id=?, vehicle_id=?, tip_deviz_id=?, km_intrare=?, observatii=?, + client_nume=?, client_telefon=?, nr_auto=?, marca_denumire=?, model_denumire=?, updated_at=? + WHERE id=?`, + [data.client_id || null, data.vehicle_id || null, data.tip_deviz_id || null, + data.km_intrare || 0, data.observatii || '', + clientNume || '', clientTelefon || '', nrAuto || '', marcaDenumire || '', modelDenumire || '', + now, orderId] + ) + notifyTableChanged('orders') + await syncEngine.addToQueue('orders', orderId, 'UPDATE', { + client_id: data.client_id, vehicle_id: data.vehicle_id, tip_deviz_id: data.tip_deviz_id, + km_intrare: data.km_intrare, observatii: data.observatii, + client_nume: clientNume, client_telefon: clientTelefon, nr_auto: nrAuto, + marca_denumire: marcaDenumire, model_denumire: modelDenumire + }) + } + async function validateOrder(orderId) { const now = new Date().toISOString() await execSQL(`UPDATE orders SET status='VALIDAT', updated_at=? WHERE id=?`, [now, orderId]) @@ -144,6 +205,59 @@ export const useOrdersStore = defineStore('orders', () => { await syncEngine.addToQueue('orders', orderId, 'UPDATE', { status: 'VALIDAT' }) } + async function devalidateOrder(orderId) { + const now = new Date().toISOString() + await execSQL(`UPDATE orders SET status='DRAFT', updated_at=? WHERE id=?`, [now, orderId]) + notifyTableChanged('orders') + await syncEngine.addToQueue('orders', orderId, 'UPDATE', { status: 'DRAFT' }) + } + + async function deleteOrder(orderId) { + await execSQL(`DELETE FROM order_lines WHERE order_id = ?`, [orderId]) + await execSQL(`DELETE FROM orders WHERE id = ?`, [orderId]) + notifyTableChanged('order_lines') + notifyTableChanged('orders') + await syncEngine.addToQueue('orders', orderId, 'DELETE', {}) + } + + async function deleteInvoice(invoiceId) { + const [inv] = await execSQL(`SELECT * FROM invoices WHERE id = ?`, [invoiceId]) + if (!inv) return + // Set order back to VALIDAT + if (inv.order_id) { + const now = new Date().toISOString() + await execSQL(`UPDATE orders SET status='VALIDAT', updated_at=? WHERE id=?`, [now, inv.order_id]) + notifyTableChanged('orders') + await syncEngine.addToQueue('orders', inv.order_id, 'UPDATE', { status: 'VALIDAT' }) + } + await execSQL(`DELETE FROM invoices WHERE id = ?`, [invoiceId]) + notifyTableChanged('invoices') + await syncEngine.addToQueue('invoices', invoiceId, 'DELETE', {}) + } + + async function searchNorme(query) { + if (!query || query.length < 2) return [] + const like = `%${query}%` + return execSQL( + `SELECT n.*, a.denumire as ansamblu_denumire + FROM catalog_norme n + LEFT JOIN catalog_ansamble a ON n.ansamblu_id = a.id + WHERE n.tenant_id = ? AND (n.denumire LIKE ? OR n.cod LIKE ?) + ORDER BY n.denumire LIMIT 10`, + [auth.tenantId, like, like] + ) + } + + async function searchPreturi(query) { + if (!query || query.length < 2) return [] + const like = `%${query}%` + return execSQL( + `SELECT * FROM catalog_preturi WHERE tenant_id = ? AND denumire LIKE ? + ORDER BY denumire LIMIT 10`, + [auth.tenantId, like] + ) + } + async function getStats() { const [total] = await execSQL( `SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ?`, [auth.tenantId] @@ -154,20 +268,28 @@ export const useOrdersStore = defineStore('orders', () => { const [validat] = await execSQL( `SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ? AND status = 'VALIDAT'`, [auth.tenantId] ) + const [facturat] = await execSQL( + `SELECT COUNT(*) as cnt FROM orders WHERE tenant_id = ? AND status = 'FACTURAT'`, [auth.tenantId] + ) const [totalVehicles] = await execSQL( `SELECT COUNT(*) as cnt FROM vehicles WHERE tenant_id = ?`, [auth.tenantId] ) const [revenue] = await execSQL( - `SELECT COALESCE(SUM(total_general), 0) as s FROM orders WHERE tenant_id = ? AND status = 'VALIDAT'`, [auth.tenantId] + `SELECT COALESCE(SUM(total_general), 0) as s FROM orders WHERE tenant_id = ? AND status IN ('VALIDAT', 'FACTURAT')`, [auth.tenantId] ) return { totalOrders: total?.cnt || 0, draftOrders: draft?.cnt || 0, validatedOrders: validat?.cnt || 0, + facturatedOrders: facturat?.cnt || 0, totalVehicles: totalVehicles?.cnt || 0, totalRevenue: revenue?.s || 0, } } - return { getAll, getById, getLines, getRecentOrders, create, addLine, removeLine, validateOrder, getStats } + return { + getAll, getById, getLines, getRecentOrders, create, addLine, removeLine, + updateHeader, validateOrder, devalidateOrder, deleteOrder, deleteInvoice, + searchNorme, searchPreturi, getStats + } }) diff --git a/frontend/src/stores/vehicles.js b/frontend/src/stores/vehicles.js index 1dac68c..289ee63 100644 --- a/frontend/src/stores/vehicles.js +++ b/frontend/src/stores/vehicles.js @@ -42,14 +42,26 @@ export const useVehiclesStore = defineStore('vehicles', () => { return rows[0] || null } + async function getByClient(clientId) { + return execSQL( + `SELECT v.*, m.denumire as marca_denumire, mo.denumire as model_denumire + FROM vehicles v + LEFT JOIN catalog_marci m ON v.marca_id = m.id + LEFT JOIN catalog_modele mo ON v.model_id = mo.id + WHERE v.client_id = ? AND v.tenant_id = ? + ORDER BY v.nr_inmatriculare`, + [clientId, auth.tenantId] + ) + } + async function create(data) { const id = crypto.randomUUID() const now = new Date().toISOString() await execSQL( - `INSERT INTO vehicles (id, tenant_id, client_nume, client_telefon, client_email, client_cod_fiscal, client_adresa, nr_inmatriculare, marca_id, model_id, an_fabricatie, serie_sasiu, tip_motor_id, created_at, updated_at) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, - [id, auth.tenantId, data.client_nume || '', data.client_telefon || '', + `INSERT INTO vehicles (id, tenant_id, client_id, client_nume, client_telefon, client_email, client_cod_fiscal, client_adresa, nr_inmatriculare, marca_id, model_id, an_fabricatie, serie_sasiu, tip_motor_id, created_at, updated_at) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, + [id, auth.tenantId, data.client_id || null, data.client_nume || '', data.client_telefon || '', data.client_email || '', data.client_cod_fiscal || '', data.client_adresa || '', data.nr_inmatriculare || '', data.marca_id || null, data.model_id || null, data.an_fabricatie || null, data.serie_sasiu || '', data.tip_motor_id || null, now, now] @@ -72,9 +84,24 @@ export const useVehiclesStore = defineStore('vehicles', () => { await syncEngine.addToQueue('vehicles', id, 'UPDATE', data) } - async function search(query) { - if (!query || query.length < 2) return [] + async function search(query, clientId = null) { + if (!query || query.length < 2) { + // If no query but clientId, return client vehicles + if (clientId) return getByClient(clientId) + return [] + } const like = `%${query}%` + if (clientId) { + return execSQL( + `SELECT v.*, m.denumire as marca_denumire, mo.denumire as model_denumire + FROM vehicles v + LEFT JOIN catalog_marci m ON v.marca_id = m.id + LEFT JOIN catalog_modele mo ON v.model_id = mo.id + WHERE v.tenant_id = ? AND v.client_id = ? AND (v.nr_inmatriculare LIKE ? OR v.client_nume LIKE ?) + ORDER BY v.nr_inmatriculare LIMIT 10`, + [auth.tenantId, clientId, like, like] + ) + } return execSQL( `SELECT v.*, m.denumire as marca_denumire, mo.denumire as model_denumire FROM vehicles v @@ -100,5 +127,5 @@ export const useVehiclesStore = defineStore('vehicles', () => { ) } - return { getAll, getById, create, update, search, getMarci, getModele } + return { getAll, getById, getByClient, create, update, search, getMarci, getModele } }) diff --git a/frontend/src/views/catalog/CatalogView.vue b/frontend/src/views/catalog/CatalogView.vue index 9d83f11..f65cae6 100644 --- a/frontend/src/views/catalog/CatalogView.vue +++ b/frontend/src/views/catalog/CatalogView.vue @@ -128,6 +128,51 @@
+ +
+
+
Nicio norma gasita.
+ + + + + + + + + + + + + + + + + +
CodDenumireOre normate
{{ n.cod || '-' }}{{ n.denumire }}{{ n.ore_normate || '-' }}
+
+ +
+

Adauga norma

+
+
+ + +
+
+ + + +
+
+
+
+
@@ -208,6 +253,7 @@ const tabs = [ { key: 'marci', label: 'Marci' }, { key: 'modele', label: 'Modele' }, { key: 'ansamble', label: 'Ansamble' }, + { key: 'norme', label: 'Norme' }, { key: 'preturi', label: 'Preturi' }, { key: 'tipuri_deviz', label: 'Tipuri deviz' }, ] @@ -327,6 +373,43 @@ async function addAnsamblu() { } } +// ---- Norme ---- +const norme = ref([]) +const newNorma = reactive({ cod: '', denumire: '', ore_normate: null, ansamblu_id: null }) +const savingNorma = ref(false) + +async function loadNorme() { + norme.value = await execSQL( + `SELECT n.*, a.denumire AS ansamblu_denumire + FROM catalog_norme n + LEFT JOIN catalog_ansamble a ON a.id = n.ansamblu_id + WHERE n.tenant_id=? + ORDER BY n.denumire`, + [auth.tenantId] + ) +} + +async function addNorma() { + savingNorma.value = true + try { + const id = crypto.randomUUID() + const data = { + id, tenant_id: auth.tenantId, cod: newNorma.cod || null, + denumire: newNorma.denumire, ore_normate: newNorma.ore_normate || 0, + ansamblu_id: newNorma.ansamblu_id || null + } + await execSQL( + `INSERT INTO catalog_norme (id, tenant_id, cod, denumire, ore_normate, ansamblu_id) VALUES (?,?,?,?,?,?)`, + [id, auth.tenantId, data.cod, data.denumire, data.ore_normate, data.ansamblu_id] + ) + notifyTableChanged('catalog_norme') + syncEngine.addToQueue('catalog_norme', id, 'INSERT', data) + Object.assign(newNorma, { cod: '', denumire: '', ore_normate: null, ansamblu_id: null }) + } finally { + savingNorma.value = false + } +} + // ---- Preturi ---- const preturi = ref([]) const newPret = reactive({ denumire: '', pret: null, um: 'ora' }) @@ -390,6 +473,7 @@ async function addTipDeviz() { onTableChange('catalog_marci', loadMarci) onTableChange('catalog_modele', loadModele) onTableChange('catalog_ansamble', loadAnsamble) +onTableChange('catalog_norme', loadNorme) onTableChange('catalog_preturi', loadPreturi) onTableChange('catalog_tipuri_deviz', loadTipuriDeviz) @@ -398,6 +482,7 @@ watch(activeTab, (tab) => { if (tab === 'marci') loadMarci() else if (tab === 'modele') { loadMarci(); loadModele() } else if (tab === 'ansamble') loadAnsamble() + else if (tab === 'norme') { loadAnsamble(); loadNorme() } else if (tab === 'preturi') loadPreturi() else if (tab === 'tipuri_deviz') loadTipuriDeviz() }) diff --git a/frontend/src/views/clients/ClientsListView.vue b/frontend/src/views/clients/ClientsListView.vue new file mode 100644 index 0000000..00249be --- /dev/null +++ b/frontend/src/views/clients/ClientsListView.vue @@ -0,0 +1,287 @@ + + + diff --git a/frontend/src/views/dashboard/DashboardView.vue b/frontend/src/views/dashboard/DashboardView.vue index 0ccb3cd..c37a203 100644 --- a/frontend/src/views/dashboard/DashboardView.vue +++ b/frontend/src/views/dashboard/DashboardView.vue @@ -1,88 +1,163 @@