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

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

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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