Files
roaauto/docs/superpowers/plans/2026-03-13-roaauto-implementation.md
Marius Mutu b5085bf2fa chore: plan implementare Agent Teams + API contract
- docs/PLAN.md - plan arhitectural complet (creat anterior)
- docs/api-contract.json - contract API intre backend/frontend agenti
- docs/superpowers/plans/2026-03-13-roaauto-implementation.md - plan implementare cu Agent Teams
- HANDOFF.md - context pentru sesiuni viitoare

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 17:07:04 +02:00

56 KiB

ROA AUTO SaaS - Implementation Plan (Agent Teams)

Mecanism: Foloseste Claude Code Agent Teams (experimental) - nu subagenti, ci sesiuni Claude Code separate cu task list comun si comunicare directa intre agenti.

Goal: Construieste un SaaS multi-tenant pentru service-uri auto din Romania cu offline-first (wa-sqlite), sync cloud si PDF/SMS.

Architecture: Vue 3 PWA + wa-sqlite (OPFS) in browser, FastAPI + libSQL pe server, sync custom timestamp-based. Un singur container Docker, deploy pe Proxmox via Dokploy + Cloudflare Tunnel.

Tech Stack: Python/FastAPI, SQLAlchemy 2.0/Alembic, libSQL/aiosqlite, Vue 3, Vite, Tailwind CSS 4, Pinia, wa-sqlite (WASM), WeasyPrint, SMSAPI.ro, Docker


Pasul 0: Activare Agent Teams

Adauga in ~/.claude/settings.json:

{
  "env": {
    "CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS": "1"
  },
  "teammateMode": "tmux"
}

Verifica versiunea:

claude --version
# Necesar: v2.1.32 sau mai recent

Structura Echipei

TEAM LEAD (sesiunea ta principala de Claude Code)
├── Citeste planul, creeaza task list, spawneaza teammati
├── Gestioneaza dependintele si aproba planurile
├── Monitorizeaza progresul via Shift+Down
└── Sintetizeaza rezultatele la finalul fiecarei faze

TEAMMATES:
├── backend-agent   → FastAPI, libSQL, Alembic, auth, sync, business logic, PDF, SMS
├── frontend-agent  → Vue 3, Vite, Tailwind, wa-sqlite, Pinia, toate views-urile
└── devops-agent    → Docker, docker-compose, nginx, Makefile, CI/CD

Reguli de izolare fisiere (evita conflicte):

  • backend-agent atinge DOAR backend/ si docs/api-contract.json
  • frontend-agent atinge DOAR frontend/
  • devops-agent atinge DOAR docker-compose*.yml, Makefile, .env.example, backend/Dockerfile, frontend/Dockerfile, frontend/nginx.conf
  • Team Lead atinge DOAR fisierele de coordonare (task list, documentatie)

Prompt de Start (Team Lead)

Ruleaza aceasta comanda in Claude Code pentru a porni echipa:

Citeste docs/superpowers/plans/2026-03-13-roaauto-implementation.md

Creeaza un agent team pentru implementarea proiectului ROA AUTO SaaS.
Spawneaza 3 teammates:

1. backend-agent: specialist FastAPI/Python, responsabil pentru tot ce e in backend/
2. frontend-agent: specialist Vue 3/JavaScript, responsabil pentru tot ce e in frontend/
3. devops-agent: specialist Docker/nginx, responsabil pentru docker-compose, Makefile, Dockerfiles

Reguli pentru echipa:
- Nu atingeti fisierele celuilalt agent
- Comunicati direct cand aveti nevoie de informatii de la alt agent
- Fiecare task din task list are un "owned_by" - respectati-l
- Faceti commit dupa fiecare task completat
- Inainte de a incepe implementarea unui task, verificati daca dependintele sunt COMPLETED

Creeaza task list-ul conform planului si incepe cu Faza 1.
Require plan approval pentru Task 1 (backend setup) si Task 2 (frontend setup).

Hooks de Calitate (optional, recomandat)

Creeaza .claude/hooks/teammate-idle.sh:

#!/bin/bash
# TeammateIdle hook - verifica ca testele trec inainte de idle
# Daca backend-agent se opreste, verifica teste
if echo "$CLAUDE_TEAMMATE_NAME" | grep -q "backend"; then
  cd backend && python -m pytest tests/ -q 2>&1
  if [ $? -ne 0 ]; then
    echo "Testele nu trec! Rezolva inainte de a te opri." >&2
    exit 2
  fi
fi

Task List pentru Agent Teams

Team Lead-ul creeaza aceste tasks in task list-ul comun la pornire. Teammates-ii le claim in ordine, respectand dependintele.


Faza 1: Fundatie [Saptamana 1]

TASK-001: API Contract + Structura Proiect

owned_by: team-lead depends_on: - priority: critical

Team Lead-ul executa asta inainte de a spawna teammates, ca toti sa aiba contractul.

# Creeaza structura
mkdir -p backend/app/{auth,sync,orders,vehicles,catalog,invoices,appointments,tenants,users,client_portal,sms,pdf/templates,db/models}
mkdir -p backend/{alembic/versions,data,tests}
mkdir -p frontend/src/{db,router,stores,composables,layouts,views/{auth,dashboard,orders,vehicles,appointments,catalog,settings,client},components/{common,orders,vehicles},assets/css}
touch backend/data/.gitkeep
git init

Creeaza docs/api-contract.json:

{
  "version": "1.0",
  "note": "Contract shared intre backend-agent si frontend-agent. Nu modificati fara notificarea ambilor agenti.",
  "base_url": "/api",
  "auth": {
    "POST /auth/register": {
      "body": {"email": "str", "password": "str", "tenant_name": "str", "telefon": "str"},
      "response": {"access_token": "str", "token_type": "bearer", "tenant_id": "str", "plan": "str"}
    },
    "POST /auth/login": {
      "body": {"email": "str", "password": "str"},
      "response": {"access_token": "str", "token_type": "bearer", "tenant_id": "str", "plan": "str"}
    },
    "GET /auth/me": {
      "headers": {"Authorization": "Bearer <token>"},
      "response": {"id": "str", "email": "str", "tenant_id": "str", "plan": "str", "rol": "str"}
    }
  },
  "sync": {
    "GET /sync/full": {
      "headers": {"Authorization": "Bearer <token>"},
      "response": {
        "tables": {
          "vehicles": [], "orders": [], "order_lines": [],
          "invoices": [], "appointments": [],
          "catalog_marci": [], "catalog_modele": [],
          "catalog_ansamble": [], "catalog_norme": [],
          "catalog_preturi": [], "catalog_tipuri_deviz": [],
          "catalog_tipuri_motoare": [], "mecanici": []
        },
        "synced_at": "ISO8601"
      }
    },
    "GET /sync/changes": {
      "params": {"since": "ISO8601"},
      "response": {"tables": {}, "synced_at": "str"}
    },
    "POST /sync/push": {
      "body": {"operations": [{"table": "str", "id": "uuid", "operation": "INSERT|UPDATE|DELETE", "data": {}, "timestamp": "str"}]},
      "response": {"applied": 0, "conflicts": []}
    }
  },
  "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"}},
    "GET /orders/{id}": {"response": {"id": "str", "status": "str", "lines": []}},
    "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"}},
    "GET /orders/{id}/pdf/deviz": {"response": "application/pdf"}
  },
  "client_portal": {
    "GET /p/{token}": {"response": {"order": {}, "tenant": {}, "lines": []}},
    "POST /p/{token}/accept": {"response": {"ok": true}},
    "POST /p/{token}/reject": {"response": {"ok": true}}
  },
  "health": {
    "GET /health": {"response": {"status": "ok"}}
  }
}

Creeaza .env.example:

SECRET_KEY=change-me-in-production
DATABASE_URL=sqlite+aiosqlite:///./data/roaauto.db
ACCESS_TOKEN_EXPIRE_DAYS=30
TRIAL_DAYS=30
SMSAPI_TOKEN=
CORS_ORIGINS=http://localhost:5173
git add .
git commit -m "chore: project structure + API contract"
git remote add origin git@gitea.romfast.ro:marius/roaauto.git
git push -u origin main

Mesaj catre teammates dupa TASK-001:

broadcast: TASK-001 completat. docs/api-contract.json e disponibil.
backend-agent: poti incepe TASK-002.
frontend-agent: poti incepe TASK-003.
devops-agent: poti incepe TASK-004.

TASK-002: Backend - FastAPI + libSQL + Auth

owned_by: backend-agent depends_on: TASK-001 plan_approval_required: true priority: critical

Plan pe care backend-agent il trimite spre aprobare:

Plan TASK-002:
1. requirements.txt cu toate dependintele
2. app/config.py (Settings din env)
3. app/db/base.py (Base, UUIDMixin, TenantMixin, TimestampMixin)
4. app/db/session.py (aiosqlite engine)
5. app/db/models/tenant.py + user.py
6. app/auth/schemas.py + service.py + router.py
7. app/main.py (FastAPI + lifespan + CORS + routers)
8. tests/conftest.py (in-memory SQLite pentru teste)
9. tests/test_auth.py (TDD: scrie testele intai)
10. alembic init + prima migrare
Estimat: 3-4h

Implementare (dupa aprobare):

backend/requirements.txt:

fastapi>=0.115
uvicorn[standard]>=0.30
sqlalchemy>=2.0
aiosqlite>=0.20
alembic>=1.13
python-jose[cryptography]>=3.3
passlib[bcrypt]>=1.7
pydantic-settings>=2.0
pydantic[email]>=2.0
pytest>=8.0
pytest-asyncio>=0.23
httpx>=0.27
weasyprint>=62
jinja2>=3.1

backend/app/config.py:

from pydantic_settings import BaseSettings

class Settings(BaseSettings):
    SECRET_KEY: str = "dev-secret-change-me"
    DATABASE_URL: str = "sqlite+aiosqlite:///./data/roaauto.db"
    ACCESS_TOKEN_EXPIRE_DAYS: int = 30
    TRIAL_DAYS: int = 30
    SMSAPI_TOKEN: str = ""
    CORS_ORIGINS: str = "http://localhost:5173"

    class Config:
        env_file = ".env"

settings = Settings()

backend/app/db/base.py:

import uuid
from datetime import datetime, UTC
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
from sqlalchemy import String, Text

def uuid7() -> str:
    return str(uuid.uuid4())

class Base(DeclarativeBase):
    pass

class UUIDMixin:
    id: Mapped[str] = mapped_column(String(36), primary_key=True, default=uuid7)

class TenantMixin:
    tenant_id: Mapped[str] = mapped_column(String(36), nullable=False, index=True)

class TimestampMixin:
    created_at: Mapped[str] = mapped_column(Text, default=lambda: datetime.now(UTC).isoformat())
    updated_at: Mapped[str] = mapped_column(
        Text,
        default=lambda: datetime.now(UTC).isoformat(),
        onupdate=lambda: datetime.now(UTC).isoformat()
    )

backend/app/db/session.py:

from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker, AsyncSession
from app.config import settings

engine = create_async_engine(settings.DATABASE_URL, echo=False)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_db():
    async with AsyncSessionLocal() as session:
        yield session

backend/app/db/models/tenant.py:

from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Text
from app.db.base import Base, UUIDMixin, TimestampMixin

class Tenant(Base, UUIDMixin, TimestampMixin):
    __tablename__ = "tenants"
    nume: Mapped[str] = mapped_column(String(200))
    cui: Mapped[str | None] = mapped_column(String(20))
    reg_com: Mapped[str | None] = mapped_column(String(30))
    adresa: Mapped[str | None] = mapped_column(Text)
    telefon: Mapped[str | None] = mapped_column(String(20))
    email: Mapped[str | None] = mapped_column(String(200))
    iban: Mapped[str | None] = mapped_column(String(34))
    banca: Mapped[str | None] = mapped_column(String(100))
    plan: Mapped[str] = mapped_column(String(20), default="trial")
    trial_expires_at: Mapped[str | None] = mapped_column(Text)

backend/app/db/models/user.py:

from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy import String, Boolean
from app.db.base import Base, UUIDMixin, TenantMixin, TimestampMixin

class User(Base, UUIDMixin, TenantMixin, TimestampMixin):
    __tablename__ = "users"
    email: Mapped[str] = mapped_column(String(200), unique=True, index=True)
    password_hash: Mapped[str] = mapped_column(String(200))
    nume: Mapped[str] = mapped_column(String(200))
    rol: Mapped[str] = mapped_column(String(20), default="owner")
    activ: Mapped[bool] = mapped_column(Boolean, default=True)

backend/app/auth/schemas.py:

from pydantic import BaseModel, EmailStr

class RegisterRequest(BaseModel):
    email: EmailStr
    password: str
    tenant_name: str
    telefon: str

class LoginRequest(BaseModel):
    email: EmailStr
    password: str

class TokenResponse(BaseModel):
    access_token: str
    token_type: str = "bearer"
    tenant_id: str
    plan: str

backend/app/auth/service.py:

from datetime import datetime, timedelta, UTC
from jose import jwt
from passlib.context import CryptContext
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.config import settings
from app.db.models.tenant import Tenant
from app.db.models.user import User
from app.db.base import uuid7

pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")

def hash_password(p: str) -> str: return pwd_context.hash(p)
def verify_password(plain: str, hashed: str) -> bool: return pwd_context.verify(plain, hashed)

def create_token(user_id: str, tenant_id: str, plan: str) -> str:
    exp = datetime.now(UTC) + timedelta(days=settings.ACCESS_TOKEN_EXPIRE_DAYS)
    return jwt.encode({"sub": user_id, "tenant_id": tenant_id, "plan": plan, "exp": exp},
                      settings.SECRET_KEY, algorithm="HS256")

async def register(db: AsyncSession, email: str, password: str, tenant_name: str, telefon: str):
    trial_exp = (datetime.now(UTC) + timedelta(days=settings.TRIAL_DAYS)).isoformat()
    tenant = Tenant(id=uuid7(), nume=tenant_name, telefon=telefon, plan="trial", trial_expires_at=trial_exp)
    db.add(tenant)
    user = User(id=uuid7(), tenant_id=tenant.id, email=email,
                password_hash=hash_password(password), nume=email.split("@")[0], rol="owner")
    db.add(user)
    await db.commit()
    return user, tenant

async def authenticate(db: AsyncSession, email: str, password: str):
    r = await db.execute(select(User).where(User.email == email))
    user = r.scalar_one_or_none()
    if not user or not verify_password(password, user.password_hash):
        return None, None
    r = await db.execute(select(Tenant).where(Tenant.id == user.tenant_id))
    return user, r.scalar_one_or_none()

backend/app/auth/router.py:

from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.session import get_db
from app.auth import service, schemas

router = APIRouter()

@router.post("/register", response_model=schemas.TokenResponse)
async def register(data: schemas.RegisterRequest, db: AsyncSession = Depends(get_db)):
    user, tenant = await service.register(db, data.email, data.password, data.tenant_name, data.telefon)
    return schemas.TokenResponse(
        access_token=service.create_token(user.id, tenant.id, tenant.plan),
        tenant_id=tenant.id, plan=tenant.plan
    )

@router.post("/login", response_model=schemas.TokenResponse)
async def login(data: schemas.LoginRequest, db: AsyncSession = Depends(get_db)):
    user, tenant = await service.authenticate(db, data.email, data.password)
    if not user:
        raise HTTPException(status_code=401, detail="Credentiale invalide")
    return schemas.TokenResponse(
        access_token=service.create_token(user.id, tenant.id, tenant.plan),
        tenant_id=tenant.id, plan=tenant.plan
    )

backend/app/deps.py:

from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from jose import jwt, JWTError
from app.config import settings

bearer = HTTPBearer()

async def get_current_user(creds: HTTPAuthorizationCredentials = Depends(bearer)) -> dict:
    try:
        return jwt.decode(creds.credentials, settings.SECRET_KEY, algorithms=["HS256"])
    except JWTError:
        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED)

async def get_tenant_id(user: dict = Depends(get_current_user)) -> str:
    return user["tenant_id"]

backend/app/main.py:

from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.config import settings
from app.db.session import engine
from app.db.base import Base
from app.auth.router import router as auth_router

@asynccontextmanager
async def lifespan(app: FastAPI):
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    yield

app = FastAPI(lifespan=lifespan)
app.add_middleware(CORSMiddleware,
    allow_origins=settings.CORS_ORIGINS.split(","),
    allow_methods=["*"], allow_headers=["*"], allow_credentials=True)
app.include_router(auth_router, prefix="/api/auth")

@app.get("/api/health")
async def health(): return {"status": "ok"}

backend/tests/conftest.py:

import pytest
import pytest_asyncio
from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from app.db.base import Base
from app.db.session import get_db
from app.main import app

@pytest_asyncio.fixture(autouse=True)
async def setup_test_db():
    engine = create_async_engine("sqlite+aiosqlite:///:memory:")
    async with engine.begin() as conn:
        await conn.run_sync(Base.metadata.create_all)
    session_factory = async_sessionmaker(engine, expire_on_commit=False)
    async def override_db():
        async with session_factory() as s:
            yield s
    app.dependency_overrides[get_db] = override_db
    yield
    app.dependency_overrides.clear()
    await engine.dispose()

backend/tests/test_auth.py (scrie testele INAINTE de implementare):

import pytest
from httpx import AsyncClient, ASGITransport
from app.main import app

@pytest.mark.asyncio
async def test_register_creates_tenant():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
        r = await c.post("/api/auth/register", json={
            "email": "owner@service.ro", "password": "parola123",
            "tenant_name": "Service Ionescu", "telefon": "0722000000"
        })
    assert r.status_code == 200
    data = r.json()
    assert "access_token" in data
    assert data["plan"] == "trial"

@pytest.mark.asyncio
async def test_login_returns_token():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
        await c.post("/api/auth/register", json={
            "email": "test@s.ro", "password": "abc123",
            "tenant_name": "Test", "telefon": "0722"
        })
        r = await c.post("/api/auth/login", json={"email": "test@s.ro", "password": "abc123"})
    assert r.status_code == 200
    assert "access_token" in r.json()

@pytest.mark.asyncio
async def test_login_wrong_password_returns_401():
    async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as c:
        r = await c.post("/api/auth/login", json={"email": "x@x.ro", "password": "wrong"})
    assert r.status_code == 401

Ruleaza testele:

cd backend && pip install -r requirements.txt
pytest tests/test_auth.py -v
# Expected: 3 PASSED

Alembic:

alembic init alembic
# Editeaza alembic.ini: sqlalchemy.url = sqlite+aiosqlite:///./data/roaauto.db
# Editeaza alembic/env.py pentru async + importa Base
alembic revision --autogenerate -m "initial_tenants_users"
alembic upgrade head
git add backend/
git commit -m "feat(backend): FastAPI + libSQL + auth register/login + tests (TDD)"

Mesaj catre team lead si frontend-agent dupa TASK-002:

message team-lead: TASK-002 completat. Auth endpoints live pe :8000.
POST /api/auth/register si /api/auth/login functioneaza conform api-contract.json.
Testele trec (3/3).

message frontend-agent: Auth backend e gata. Poti conecta stores/auth.js la API real.
Endpoint register: POST /api/auth/register
Endpoint login: POST /api/auth/login
Token format: JWT cu payload {sub, tenant_id, plan, exp}

TASK-003: Frontend - Vue 3 + wa-sqlite + Auth

owned_by: frontend-agent depends_on: TASK-001 plan_approval_required: true note: Poate incepe in paralel cu TASK-002 (API contract e suficient)

Plan pe care frontend-agent il trimite spre aprobare:

Plan TASK-003:
1. package.json + npm install
2. vite.config.js + tailwind.config.js
3. src/db/schema.js (toate tabelele SQLite - mirror server)
4. src/db/database.js (wa-sqlite init + OPFS)
5. src/db/sync.js (SyncEngine: fullSync, incrementalSync, pushQueue)
6. src/stores/auth.js (JWT parse, login, register, plan tier)
7. src/composables/useSqlQuery.js (reactive SQL helper)
8. src/router/index.js (routes + auth guards)
9. src/layouts/AppLayout.vue (sidebar desktop / bottom nav mobile)
10. src/layouts/AuthLayout.vue
11. src/components/common/SyncIndicator.vue
12. src/views/auth/LoginView.vue + RegisterView.vue
13. src/main.js + App.vue
14. src/assets/css/main.css
Estimat: 3-4h

frontend/package.json:

{
  "name": "roaauto-frontend",
  "version": "0.1.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "vite build",
    "preview": "vite preview"
  },
  "dependencies": {
    "vue": "^3.5",
    "vue-router": "^4.4",
    "pinia": "^2.2",
    "@journeyapps/wa-sqlite": "^1.0"
  },
  "devDependencies": {
    "@vitejs/plugin-vue": "^5.2",
    "vite": "^6.0",
    "tailwindcss": "^4.0",
    "@tailwindcss/vite": "^4.0",
    "vite-plugin-pwa": "^0.21"
  }
}

frontend/src/db/schema.js - toate tabelele din PLAN.md (mirror exact):

export const SCHEMA_SQL = `
  CREATE TABLE IF NOT EXISTS tenants (
    id TEXT PRIMARY KEY, tenant_id TEXT, nume TEXT, cui TEXT, reg_com 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
  );
  CREATE TABLE IF NOT EXISTS vehicles (
    id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL,
    client_nume TEXT, client_telefon TEXT, client_email TEXT,
    client_cod_fiscal TEXT, client_adresa TEXT,
    nr_inmatriculare TEXT, marca_id TEXT, model_id TEXT,
    an_fabricatie INTEGER, serie_sasiu TEXT, tip_motor_id 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,
    tip_deviz_id TEXT, status TEXT DEFAULT 'DRAFT',
    km_intrare INTEGER, observatii 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
  );
  CREATE TABLE IF NOT EXISTS order_lines (
    id TEXT PRIMARY KEY, order_id TEXT NOT NULL, tenant_id TEXT NOT NULL,
    tip TEXT, descriere TEXT,
    norma_id TEXT, ore REAL, pret_ora REAL,
    um TEXT, cantitate REAL, pret_unitar REAL,
    total REAL, mecanic_id TEXT, ordine INTEGER,
    oracle_id INTEGER, created_at TEXT, updated_at TEXT
  );
  CREATE TABLE IF NOT EXISTS invoices (
    id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, order_id TEXT,
    nr_factura TEXT, serie_factura TEXT, data_factura TEXT,
    modalitate_plata TEXT, client_nume TEXT, client_cod_fiscal TEXT, nr_auto TEXT,
    total_fara_tva REAL, tva REAL, total_general REAL,
    oracle_id INTEGER, created_at TEXT, updated_at TEXT
  );
  CREATE TABLE IF NOT EXISTS appointments (
    id TEXT PRIMARY KEY, tenant_id TEXT NOT NULL, vehicle_id TEXT,
    client_nume TEXT, client_telefon TEXT, data_ora TEXT,
    durata_minute INTEGER DEFAULT 60, observatii TEXT,
    status TEXT DEFAULT 'PROGRAMAT', order_id TEXT,
    oracle_id INTEGER, created_at TEXT, updated_at TEXT
  );
  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
  );
  CREATE TABLE IF NOT EXISTS catalog_modele (
    id TEXT PRIMARY KEY, marca_id TEXT, denumire TEXT, oracle_id INTEGER, 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
  );
  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
  );
  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
  );
  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
  );
  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
  );
  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
  );
  CREATE TABLE IF NOT EXISTS _sync_queue (
    id TEXT PRIMARY KEY, table_name TEXT, row_id TEXT,
    operation TEXT, data_json TEXT, created_at TEXT, synced_at TEXT
  );
  CREATE TABLE IF NOT EXISTS _sync_state (table_name TEXT PRIMARY KEY, last_sync_at TEXT);
  CREATE TABLE IF NOT EXISTS _local_settings (key TEXT PRIMARY KEY, value TEXT);
  PRAGMA journal_mode=WAL;
`;

export const SYNC_TABLES = [
  'vehicles','orders','order_lines','invoices','appointments',
  'catalog_marci','catalog_modele','catalog_ansamble','catalog_norme',
  'catalog_preturi','catalog_tipuri_deviz','catalog_tipuri_motoare','mecanici'
];

frontend/src/db/database.js:

import SQLiteESMFactory from '@journeyapps/wa-sqlite/dist/wa-sqlite-async.mjs'
import { IDBBatchAtomicVFS } from '@journeyapps/wa-sqlite/src/examples/IDBBatchAtomicVFS.js'
import * as SQLite from '@journeyapps/wa-sqlite'
import { SCHEMA_SQL } from './schema.js'

let db = null
let sqlite3 = null
const tableListeners = new Map()

export async function initDatabase() {
  if (db) return db
  const module = await SQLiteESMFactory()
  sqlite3 = SQLite.Factory(module)
  const vfs = await IDBBatchAtomicVFS.create('roaauto', module)
  sqlite3.vfs_register(vfs, true)
  db = await sqlite3.open_v2('roaauto.db',
    SQLite.SQLITE_OPEN_READWRITE | SQLite.SQLITE_OPEN_CREATE, 'roaauto')
  for (const sql of SCHEMA_SQL.split(';').filter(s => s.trim())) {
    await sqlite3.exec(db, sql)
  }
  return db
}

export function notifyTableChanged(table) {
  tableListeners.get(table)?.forEach(cb => cb())
}

export function onTableChange(table, cb) {
  if (!tableListeners.has(table)) tableListeners.set(table, new Set())
  tableListeners.get(table).add(cb)
  return () => tableListeners.get(table).delete(cb)
}

export async function execSQL(sql, params = []) {
  if (!db) throw new Error('DB not initialized')
  const results = []
  await sqlite3.exec(db, sql, (row, cols) => {
    const obj = {}
    cols.forEach((c, i) => { obj[c] = row[i] })
    results.push(obj)
  })
  return results
}

frontend/src/db/sync.js:

import { execSQL, notifyTableChanged } from './database.js'

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'

export class SyncEngine {
  constructor() {
    this.syncing = false
    this.online = navigator.onLine
    window.addEventListener('online', () => { this.online = true; this.pushQueue() })
    window.addEventListener('offline', () => { this.online = false })
  }

  getToken() { return localStorage.getItem('token') }

  async fullSync() {
    const token = this.getToken()
    if (!token) return
    const res = await fetch(`${API_URL}/sync/full`, { headers: { Authorization: `Bearer ${token}` } })
    if (!res.ok) return
    const { tables, synced_at } = await res.json()
    for (const [tableName, rows] of Object.entries(tables)) {
      for (const row of rows) {
        const cols = Object.keys(row).join(', ')
        const ph = Object.keys(row).map(() => '?').join(', ')
        await execSQL(`INSERT OR REPLACE INTO ${tableName} (${cols}) VALUES (${ph})`, Object.values(row))
      }
      notifyTableChanged(tableName)
      await execSQL(`INSERT OR REPLACE INTO _sync_state VALUES (?, ?)`, [tableName, synced_at])
    }
  }

  async incrementalSync() {
    const token = this.getToken()
    if (!token || !this.online) return
    const [state] = await execSQL(`SELECT MIN(last_sync_at) as since FROM _sync_state`)
    if (!state?.since) return this.fullSync()
    const res = await fetch(`${API_URL}/sync/changes?since=${encodeURIComponent(state.since)}`,
      { headers: { Authorization: `Bearer ${token}` } })
    if (!res.ok) return
    const { tables, synced_at } = await res.json()
    for (const [tableName, rows] of Object.entries(tables)) {
      for (const row of rows) {
        const cols = Object.keys(row).join(', ')
        const ph = Object.keys(row).map(() => '?').join(', ')
        await execSQL(`INSERT OR REPLACE INTO ${tableName} (${cols}) VALUES (${ph})`, Object.values(row))
      }
      if (rows.length) notifyTableChanged(tableName)
      await execSQL(`INSERT OR REPLACE INTO _sync_state VALUES (?, ?)`, [tableName, synced_at])
    }
  }

  async addToQueue(tableName, rowId, operation, data) {
    const id = crypto.randomUUID()
    await execSQL(
      `INSERT INTO _sync_queue (id, table_name, row_id, operation, data_json, created_at) VALUES (?,?,?,?,?,?)`,
      [id, tableName, rowId, operation, JSON.stringify(data), new Date().toISOString()]
    )
    if (this.online) this.pushQueue()
  }

  async pushQueue() {
    if (this.syncing) return
    const token = this.getToken()
    if (!token) return
    this.syncing = true
    try {
      const queue = await execSQL(`SELECT * FROM _sync_queue WHERE synced_at IS NULL ORDER BY created_at`)
      if (!queue.length) return
      const res = await fetch(`${API_URL}/sync/push`, {
        method: 'POST',
        headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
        body: JSON.stringify({ operations: queue.map(q => ({
          table: q.table_name, id: q.row_id, operation: q.operation,
          data: JSON.parse(q.data_json), timestamp: q.created_at
        })) })
      })
      if (res.ok) {
        const ids = queue.map(() => '?').join(',')
        await execSQL(`UPDATE _sync_queue SET synced_at=? WHERE id IN (${ids})`,
          [new Date().toISOString(), ...queue.map(q => q.id)])
      }
    } finally {
      this.syncing = false
    }
  }
}

export const syncEngine = new SyncEngine()

frontend/src/stores/auth.js:

import { defineStore } from 'pinia'
import { ref, computed } from 'vue'

const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'

export const useAuthStore = defineStore('auth', () => {
  const token = ref(localStorage.getItem('token'))
  const payload = computed(() => {
    if (!token.value) return null
    try { return JSON.parse(atob(token.value.split('.')[1])) } catch { return null }
  })
  const isAuthenticated = computed(() => !!token.value && payload.value?.exp * 1000 > Date.now())
  const tenantId = computed(() => payload.value?.tenant_id)
  const plan = computed(() => payload.value?.plan || 'free')

  async function login(email, password) {
    const res = await fetch(`${API_URL}/auth/login`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password })
    })
    if (!res.ok) throw new Error('Credentiale invalide')
    const data = await res.json()
    token.value = data.access_token
    localStorage.setItem('token', data.access_token)
    return data
  }

  async function register(email, password, tenant_name, telefon) {
    const res = await fetch(`${API_URL}/auth/register`, {
      method: 'POST', headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ email, password, tenant_name, telefon })
    })
    if (!res.ok) throw new Error('Inregistrare esuata')
    const data = await res.json()
    token.value = data.access_token
    localStorage.setItem('token', data.access_token)
    return data
  }

  function logout() { token.value = null; localStorage.removeItem('token') }

  return { token, isAuthenticated, tenantId, plan, login, register, logout }
})

frontend/src/composables/useSqlQuery.js:

import { ref, onMounted, onUnmounted } from 'vue'
import { execSQL, onTableChange } from '../db/database.js'

export function useSqlQuery(sql, params = [], watchTables = []) {
  const rows = ref([])
  const loading = ref(true)

  async function refresh() {
    loading.value = true
    try { rows.value = await execSQL(sql, params) }
    finally { loading.value = false }
  }

  const unsubs = []
  onMounted(() => {
    refresh()
    watchTables.forEach(t => unsubs.push(onTableChange(t, refresh)))
  })
  onUnmounted(() => unsubs.forEach(fn => fn()))

  return { rows, loading, refresh }
}

frontend/src/router/index.js:

import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth.js'

export default createRouter({
  history: createWebHistory(),
  routes: [
    { 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/new', component: () => import('../views/orders/OrderCreateView.vue'), meta: { requiresAuth: true } },
    { path: '/orders/:id', component: () => import('../views/orders/OrderDetailView.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 } },
    { path: '/settings', component: () => import('../views/settings/SettingsView.vue'), meta: { requiresAuth: true } },
    { path: '/p/:token', component: () => import('../views/client/DevizPublicView.vue') },
    { path: '/', redirect: '/dashboard' },
  ],
  scrollBehavior: () => ({ top: 0 })
})

// Guard
const router = createRouter({ history: createWebHistory(), routes: [] })
router.beforeEach((to) => {
  if (to.meta.requiresAuth && !useAuthStore().isAuthenticated) return '/login'
})

Layouts, views auth, SyncIndicator: implementeaza conform spec-ului vizual din PLAN.md.

cd frontend && npm install
npm run dev
# Verifica: login apare, guard functioneaza, SyncIndicator arata Online/Offline
git add frontend/
git commit -m "feat(frontend): Vue 3 + wa-sqlite + sync engine + auth + layouts"

Mesaj catre team lead si backend-agent dupa TASK-003:

message team-lead: TASK-003 completat. Frontend ruleaza pe :5173.
wa-sqlite initializat, sync engine functional, auth store connected la API.
Verifica: http://localhost:5173

message backend-agent: Frontend e gata. sync.js asteapta GET /api/sync/full.
Cand TASK-005 (sync endpoints) e gata, fac full integration test.

TASK-004: DevOps - Docker Dev + Makefile

owned_by: devops-agent depends_on: TASK-001 priority: high

docker-compose.dev.yml:

services:
  backend:
    build:
      context: ./backend
      dockerfile: Dockerfile.dev
    ports:
      - "8000:8000"
    volumes:
      - ./backend:/app
      - ./backend/data:/app/data
    environment:
      - DATABASE_URL=sqlite+aiosqlite:///./data/roaauto.db
      - SECRET_KEY=dev-secret-key-change-in-prod
      - CORS_ORIGINS=http://localhost:5173
    command: uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload

  frontend:
    image: node:20-alpine
    ports:
      - "5173:5173"
    volumes:
      - ./frontend:/app
    working_dir: /app
    environment:
      - VITE_API_URL=http://localhost:8000/api
    command: sh -c "npm install && npm run dev -- --host"

backend/Dockerfile.dev:

FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

Makefile:

.PHONY: dev migrate seed backup test shell

dev:
	docker compose -f docker-compose.dev.yml up

migrate:
	docker compose -f docker-compose.dev.yml exec backend alembic upgrade head

seed:
	docker compose -f docker-compose.dev.yml exec backend python -m app.db.seed

backup:
	cp backend/data/roaauto.db backend/data/backup-$(shell date +%Y%m%d-%H%M%S).db

test:
	docker compose -f docker-compose.dev.yml exec backend pytest tests/ -v

shell:
	docker compose -f docker-compose.dev.yml exec backend bash
git add docker-compose.dev.yml backend/Dockerfile.dev Makefile
git commit -m "chore(devops): docker-compose dev + Makefile"

Mesaj catre team lead:

message team-lead: TASK-004 completat. `make dev` porneste backend + frontend.
`make test` ruleaza testele. `make migrate` aplica migrarile.

Faza 2: Sync + Business Logic [Saptamana 2]

TASK-005: Backend - Sync Endpoints + All Models + Seed

owned_by: backend-agent depends_on: TASK-002 priority: critical

Implementeaza in aceasta ordine (TDD):

1. Toate modelele SQLAlchemy (db/models/vehicle.py, order.py, order_line.py, catalog.py, invoice.py, appointment.py):

  • Fiecare: id TEXT PK, tenant_id TEXT NOT NULL INDEX, oracle_id INTEGER NULL, updated_at TEXT
  • Respecta exact schema din docs/PLAN.md

2. Migrarile Alembic:

alembic revision --autogenerate -m "catalog_and_vehicles"
alembic revision --autogenerate -m "orders_and_lines"
alembic revision --autogenerate -m "invoices_appointments"
alembic upgrade head

3. Seed data (app/db/seed.py):

# Referinta: roa-auto-mobile/backend/seed.py
MARCI = [
    "Audi", "BMW", "Citroen", "Dacia", "Fiat", "Ford", "Honda",
    "Hyundai", "Kia", "Mazda", "Mercedes-Benz", "Mitsubishi", "Nissan",
    "Opel", "Peugeot", "Renault", "Seat", "Skoda", "Suzuki",
    "Toyota", "Volkswagen", "Volvo", "Alfa Romeo", "Jeep"
]  # 24 marci

ANSAMBLE = [
    "Motor", "Cutie de viteze", "Frane", "Directie", "Suspensie",
    "Climatizare", "Electrica", "Caroserie", "Esapament", "Transmisie", "Revizie"
]  # 11 ansamble

TIPURI_DEVIZ = [
    "Deviz reparatie", "Deviz revizie", "Deviz diagnosticare",
    "Deviz estimativ", "Deviz vulcanizare", "Deviz ITP"
]

TIPURI_MOTOARE = ["Benzina", "Diesel", "Hibrid", "Electric", "GPL"]

PRETURI = [
    {"denumire": "Manopera standard", "pret": 150.0, "um": "ora"},
    {"denumire": "Revizie ulei + filtru", "pret": 250.0, "um": "buc"},
    {"denumire": "Diagnosticare", "pret": 100.0, "um": "buc"},
]

4. Sync endpoints (app/sync/router.py, app/sync/service.py):

tests/test_sync.py (FAIL first):

async def test_full_sync_returns_all_tables(client, auth_headers):
    r = await client.get("/api/sync/full", headers=auth_headers)
    assert r.status_code == 200
    data = r.json()
    assert "tables" in data and "synced_at" in data
    assert "vehicles" in data["tables"]
    assert "catalog_marci" in data["tables"]

async def test_sync_push_insert_vehicle(client, auth_headers, tenant_id):
    import uuid; from datetime import datetime, UTC
    vid = str(uuid.uuid4()); now = datetime.now(UTC).isoformat()
    r = await client.post("/api/sync/push", headers=auth_headers, json={"operations": [{
        "table": "vehicles", "id": vid, "operation": "INSERT",
        "data": {"id": vid, "tenant_id": tenant_id, "nr_inmatriculare": "CTA01ABC",
                 "client_nume": "Popescu", "created_at": now, "updated_at": now},
        "timestamp": now
    }]})
    assert r.status_code == 200
    assert r.json()["applied"] == 1

app/sync/service.py:

SYNCABLE_TABLES = [
    "vehicles", "orders", "order_lines", "invoices", "appointments",
    "catalog_marci", "catalog_modele", "catalog_ansamble", "catalog_norme",
    "catalog_preturi", "catalog_tipuri_deviz", "catalog_tipuri_motoare", "mecanici"
]

async def get_full(db, tenant_id: str) -> dict:
    result = {}
    for table in SYNCABLE_TABLES:
        # catalog_modele nu are tenant_id direct - join via catalog_marci
        if table == "catalog_modele":
            rows = await db.execute(
                text("SELECT cm.* FROM catalog_modele cm "
                     "JOIN catalog_marci marc ON cm.marca_id = marc.id "
                     "WHERE marc.tenant_id = :tid"), {"tid": tenant_id})
        else:
            rows = await db.execute(
                text(f"SELECT * FROM {table} WHERE tenant_id = :tid"), {"tid": tenant_id})
        result[table] = [dict(r._mapping) for r in rows]
    return result

async def apply_push(db, tenant_id: str, operations: list) -> dict:
    applied = 0
    for op in operations:
        # Valideaza tenant isolation
        if op["data"].get("tenant_id") and op["data"]["tenant_id"] != tenant_id:
            continue
        op["data"]["tenant_id"] = tenant_id  # enforce
        if op["operation"] in ("INSERT", "UPDATE"):
            cols = ", ".join(op["data"].keys())
            ph = ", ".join(f":{k}" for k in op["data"].keys())
            await db.execute(
                text(f"INSERT OR REPLACE INTO {op['table']} ({cols}) VALUES ({ph})"),
                op["data"]
            )
            applied += 1
        elif op["operation"] == "DELETE":
            await db.execute(
                text(f"DELETE FROM {op['table']} WHERE id = :id AND tenant_id = :tid"),
                {"id": op["id"], "tid": tenant_id}
            )
            applied += 1
    await db.commit()
    return {"applied": applied, "conflicts": []}

5. Order Service (app/orders/service.py, app/orders/router.py):

tests/test_orders.py:

async def test_order_workflow(client, auth_headers):
    # Creeaza vehicul via sync push
    # Creeaza comanda (DRAFT)
    # Adauga linie manopera: 2h x 150 = 300
    # Adauga linie material: 2 buc x 50 = 100
    # POST /orders/{id}/validate → VALIDAT
    # GET /orders/{id} → total_manopera=300, total_materiale=100, total_general=400

async def test_cannot_add_line_to_validat_order(client, auth_headers):
    # DRAFT → VALIDAT → add line → 422

OrderService state machine:

TRANSITIONS = {"DRAFT": ["VALIDAT"], "VALIDAT": ["FACTURAT"]}

async def recalc_totals(db, order_id: str):
    lines = await db.execute(
        text("SELECT tip, COALESCE(SUM(total), 0) as sub FROM order_lines "
             "WHERE order_id = :oid GROUP BY tip"), {"oid": order_id})
    totals = {r.tip: r.sub for r in lines}
    manopera = totals.get("manopera", 0)
    materiale = totals.get("material", 0)
    await db.execute(
        text("UPDATE orders SET total_manopera=:m, total_materiale=:mat, "
             "total_general=:g, updated_at=:u WHERE id=:id"),
        {"m": manopera, "mat": materiale, "g": manopera + materiale,
         "u": datetime.now(UTC).isoformat(), "id": order_id}
    )
pytest tests/ -v
# Expected: toate trec

make seed
# Verifica: 24 marci, 11 ansamble

git commit -m "feat(backend): sync endpoints + all models + seed + order workflow"

Mesaj catre frontend-agent:

message frontend-agent: TASK-005 completat!
GET /api/sync/full returneaza toate tabelele inclusiv seed data (24 marci, etc).
POST /api/sync/push functioneaza cu INSERT/UPDATE/DELETE.
Order endpoints: POST /api/orders, POST /api/orders/{id}/lines, POST /api/orders/{id}/validate.
Poti face integration test complet acum.

TASK-006: Frontend - Dashboard + Orders UI + Vehicle Picker

owned_by: frontend-agent depends_on: TASK-003 note: Poate incepe cu TASK-003 gata; conecteaza la API real cand TASK-005 e gata

frontend/src/stores/orders.js:

import { defineStore } from 'pinia'
import { execSQL, notifyTableChanged } from '../db/database.js'
import { syncEngine } from '../db/sync.js'
import { useAuthStore } from './auth.js'

export const useOrdersStore = defineStore('orders', () => {
  async function createOrder(vehicleId, extraData = {}) {
    const auth = useAuthStore()
    const id = crypto.randomUUID()
    const now = new Date().toISOString()
    const data = {
      id, tenant_id: auth.tenantId, vehicle_id: vehicleId,
      status: 'DRAFT', data_comanda: now.split('T')[0],
      total_manopera: 0, total_materiale: 0, total_general: 0,
      token_client: crypto.randomUUID(),
      created_at: now, updated_at: now, ...extraData
    }
    const cols = Object.keys(data).join(', ')
    const ph = Object.keys(data).map(() => '?').join(', ')
    await execSQL(`INSERT INTO orders (${cols}) VALUES (${ph})`, Object.values(data))
    notifyTableChanged('orders')
    await syncEngine.addToQueue('orders', id, 'INSERT', data)
    return id
  }

  async function addLine(orderId, lineData) {
    const auth = useAuthStore()
    const id = crypto.randomUUID()
    const now = new Date().toISOString()
    const total = lineData.tip === 'manopera'
      ? (Number(lineData.ore) || 0) * (Number(lineData.pret_ora) || 0)
      : (Number(lineData.cantitate) || 0) * (Number(lineData.pret_unitar) || 0)
    const data = { id, order_id: orderId, tenant_id: auth.tenantId, total, created_at: now, updated_at: now, ...lineData }
    const cols = Object.keys(data).join(', ')
    const ph = Object.keys(data).map(() => '?').join(', ')
    await execSQL(`INSERT INTO order_lines (${cols}) VALUES (${ph})`, Object.values(data))
    await recalcTotals(orderId)
    notifyTableChanged('order_lines')
    await syncEngine.addToQueue('order_lines', id, 'INSERT', data)
  }

  async function recalcTotals(orderId) {
    const rows = await execSQL(
      `SELECT tip, COALESCE(SUM(total),0) as sub FROM order_lines WHERE order_id=? GROUP BY tip`,
      [orderId]
    )
    let manopera = 0, materiale = 0
    rows.forEach(r => { if (r.tip === 'manopera') manopera = r.sub; else materiale = r.sub })
    const now = new Date().toISOString()
    await execSQL(
      `UPDATE orders SET total_manopera=?, total_materiale=?, total_general=?, updated_at=? WHERE id=?`,
      [manopera, materiale, manopera + materiale, now, orderId]
    )
    notifyTableChanged('orders')
  }

  async function changeStatus(orderId, newStatus) {
    const now = new Date().toISOString()
    await execSQL(`UPDATE orders SET status=?, updated_at=? WHERE id=?`, [newStatus, now, orderId])
    notifyTableChanged('orders')
    const [order] = await execSQL(`SELECT * FROM orders WHERE id=?`, [orderId])
    await syncEngine.addToQueue('orders', orderId, 'UPDATE', order)
  }

  return { createOrder, addLine, recalcTotals, changeStatus }
})

frontend/src/components/vehicles/VehiclePicker.vue (search-as-you-type pe wa-sqlite):

<template>
  <div class="relative">
    <input v-model="search" @input="onSearch"
      placeholder="Nr. inmatriculare sau client..."
      class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm
             focus:outline-none focus:ring-2 focus:ring-blue-500" />
    <ul v-if="results.length"
      class="absolute z-20 w-full bg-white border border-gray-200 rounded-lg mt-1
             shadow-lg max-h-48 overflow-y-auto">
      <li v-for="v in results" :key="v.id" @click="select(v)"
        class="px-3 py-2.5 text-sm hover:bg-gray-50 cursor-pointer border-b border-gray-50 last:border-0">
        <span class="font-medium text-gray-900">{{ v.nr_inmatriculare }}</span>
        <span class="text-gray-500 ml-2 text-xs">{{ v.client_nume }}</span>
      </li>
    </ul>
  </div>
</template>
<script setup>
import { ref } from 'vue'
import { execSQL } from '../../db/database.js'
const emit = defineEmits(['select'])
const search = ref(''), results = ref([])
async function onSearch() {
  if (search.value.length < 2) { results.value = []; return }
  results.value = await execSQL(
    `SELECT * FROM vehicles WHERE nr_inmatriculare LIKE ? OR client_nume LIKE ? LIMIT 10`,
    [`%${search.value}%`, `%${search.value}%`]
  )
}
function select(v) {
  search.value = `${v.nr_inmatriculare} - ${v.client_nume}`
  results.value = []
  emit('select', v)
}
</script>

Views: DashboardView, OrdersListView, OrderCreateView, OrderDetailView. Toate reads din wa-sqlite local, writes via ordersStore + sync queue.

git commit -m "feat(frontend): dashboard + orders CRUD + vehicle picker + offline-first"

Faza 3: PDF + Portal + SMS [Saptamana 3]

TASK-007: Backend - PDF + Portal Client + SMS + Invoices

owned_by: backend-agent depends_on: TASK-005 priority: high

PDF (WeasyPrint):

backend/app/pdf/templates/deviz.html:

<!DOCTYPE html>
<html lang="ro"><head>
<meta charset="UTF-8">
<style>
  @page { size: A4; margin: 2cm; }
  body { font-family: DejaVu Sans, sans-serif; font-size: 11pt; color: #111; }
  .header { display: flex; justify-content: space-between; margin-bottom: 24px; }
  h2 { margin: 0; font-size: 16pt; }
  h3 { font-size: 11pt; margin: 16px 0 6px; color: #374151; }
  table { width: 100%; border-collapse: collapse; }
  th { background: #f3f4f6; padding: 6px 8px; text-align: left; font-size: 10pt; }
  td { padding: 5px 8px; border-bottom: 1px solid #e5e7eb; font-size: 10pt; }
  .totals { margin-top: 20px; text-align: right; }
  .totals div { margin-bottom: 4px; }
  .total-final { font-weight: bold; font-size: 13pt; border-top: 2px solid #111; padding-top: 6px; }
</style>
</head><body>
<div class="header">
  <div>
    <strong>{{ tenant.nume }}</strong><br>
    {% if tenant.cui %}CUI: {{ tenant.cui }}<br>{% endif %}
    {% if tenant.adresa %}{{ tenant.adresa }}<br>{% endif %}
    {% if tenant.telefon %}Tel: {{ tenant.telefon }}{% endif %}
  </div>
  <div style="text-align:right">
    <h2>DEVIZ Nr. {{ order.nr_comanda or order.id[:8].upper() }}</h2>
    <div>Data: {{ order.data_comanda }}</div>
    <div>Auto: <strong>{{ order.nr_auto }}</strong></div>
    <div>{{ order.marca_denumire }} {{ order.model_denumire }}</div>
    <div>Client: {{ order.client_nume }}</div>
  </div>
</div>

{% if manopera %}
<h3>Operatii manopera</h3>
<table>
  <tr><th>Descriere</th><th>Ore</th><th>Pret/ora (RON)</th><th>Total (RON)</th></tr>
  {% for l in manopera %}
  <tr>
    <td>{{ l.descriere }}</td><td>{{ l.ore }}</td>
    <td>{{ "%.2f"|format(l.pret_ora or 0) }}</td>
    <td>{{ "%.2f"|format(l.total or 0) }}</td>
  </tr>
  {% endfor %}
</table>
{% endif %}

{% if materiale %}
<h3>Materiale</h3>
<table>
  <tr><th>Descriere</th><th>UM</th><th>Cant.</th><th>Pret unit. (RON)</th><th>Total (RON)</th></tr>
  {% for l in materiale %}
  <tr>
    <td>{{ l.descriere }}</td><td>{{ l.um }}</td><td>{{ l.cantitate }}</td>
    <td>{{ "%.2f"|format(l.pret_unitar or 0) }}</td>
    <td>{{ "%.2f"|format(l.total or 0) }}</td>
  </tr>
  {% endfor %}
</table>
{% endif %}

<div class="totals">
  <div>Manopera: {{ "%.2f"|format(order.total_manopera or 0) }} RON</div>
  <div>Materiale: {{ "%.2f"|format(order.total_materiale or 0) }} RON</div>
  <div class="total-final">TOTAL: {{ "%.2f"|format(order.total_general or 0) }} RON</div>
</div>
</body></html>

app/pdf/service.py:

from pathlib import Path
from weasyprint import HTML
from jinja2 import Environment, FileSystemLoader

TEMPLATES = Path(__file__).parent / "templates"

def generate_deviz(order: dict, lines: list, tenant: dict) -> bytes:
    env = Environment(loader=FileSystemLoader(str(TEMPLATES)))
    html = env.get_template("deviz.html").render(
        order=order, tenant=tenant,
        manopera=[l for l in lines if l.get("tip") == "manopera"],
        materiale=[l for l in lines if l.get("tip") == "material"]
    )
    return HTML(string=html).write_pdf()

Test:

async def test_pdf_deviz_returns_pdf_content_type(client, auth_headers):
    # create order + lines → GET /api/orders/{id}/pdf/deviz
    # assert content_type == "application/pdf"
    pass

Client Portal (app/client_portal/router.py) - no auth:

@router.get("/p/{token}")
async def get_deviz(token: str, db=Depends(get_db)):
    r = await db.execute(select(Order).where(Order.token_client == token))
    order = r.scalar_one_or_none()
    if not order: raise HTTPException(404)
    # Returneaza order + lines + tenant (fara password_hash)

@router.post("/p/{token}/accept")
async def accept(token: str, db=Depends(get_db)):
    await db.execute(text("UPDATE orders SET status_client='ACCEPTAT' WHERE token_client=:t"), {"t": token})
    await db.commit()
    return {"ok": True}

SMS (app/sms/service.py):

import httpx
from app.config import settings

async def send_deviz_sms(telefon: str, token_client: str, tenant_name: str, base_url: str):
    if not settings.SMSAPI_TOKEN:
        return  # skip in dev/test
    url = f"{base_url}/p/{token_client}"
    msg = f"{tenant_name}: Devizul tau e gata. Vizualizeaza: {url}"
    async with httpx.AsyncClient() as c:
        await c.post("https://api.smsapi.ro/sms.do",
            headers={"Authorization": f"Bearer {settings.SMSAPI_TOKEN}"},
            data={"to": telefon, "message": msg, "from": "ROAAUTO"})
pytest tests/ -v
git commit -m "feat(backend): PDF deviz + portal client + SMS + invoice service"

TASK-008: Frontend - Portal Public + Order Detail + PDF Download

owned_by: frontend-agent depends_on: TASK-006 note: Poate fi in paralel cu TASK-007 (foloseste mock data pana backend e gata)

frontend/src/views/client/DevizPublicView.vue - pagina publica mobil-first. frontend/src/views/orders/OrderDetailView.vue - cu butoane: Valideaza, PDF, SMS, Factura.

git commit -m "feat(frontend): deviz public + order detail + PDF download"

Faza 4: Multi-user + Deployment + PWA [Saptamana 4]

TASK-009: Backend - Invite System + User Management

owned_by: backend-agent depends_on: TASK-005

Model invites, endpoints invite/accept, user list/deactivate.

git commit -m "feat(backend): invite system + user management"

TASK-010: DevOps - Docker Production

owned_by: devops-agent depends_on: TASK-004

backend/Dockerfile (productie, cu libpango pentru WeasyPrint):

FROM python:3.12-slim
RUN apt-get update && apt-get install -y \
    libpango-1.0-0 libpangoft2-1.0-0 libpangocairo-1.0-0 \
    libgdk-pixbuf2.0-0 libcairo2 curl \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]

frontend/Dockerfile:

FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json .
RUN npm ci
COPY . .
RUN npm run build

FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf

frontend/nginx.conf:

server {
    listen 80;
    root /usr/share/nginx/html;
    index index.html;
    location / { try_files $uri $uri/ /index.html; }
    location /api {
        proxy_pass http://backend:8000;
        proxy_set_header Host $host;
        proxy_read_timeout 60s;
    }
}

docker-compose.yml (productie):

services:
  backend:
    build: ./backend
    restart: unless-stopped
    volumes:
      - ./backend/data:/app/data
    environment:
      - DATABASE_URL=sqlite+aiosqlite:///./data/roaauto.db
      - SECRET_KEY=${SECRET_KEY}
      - SMSAPI_TOKEN=${SMSAPI_TOKEN:-}
      - CORS_ORIGINS=https://roaauto.romfast.ro
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:8000/api/health || exit 1"]
      interval: 30s; timeout: 10s; retries: 3

  frontend:
    build: ./frontend
    restart: unless-stopped
    ports:
      - "80:80"
    depends_on:
      backend:
        condition: service_healthy
docker compose build && docker compose up -d
curl http://localhost/api/health  # → {"status":"ok"}
docker compose down
git commit -m "chore(deploy): Docker production + nginx + health check"

TASK-011: Frontend - PWA + Backup/Restore + Upgrade Prompts

owned_by: frontend-agent depends_on: TASK-008

PWA via vite-plugin-pwa, backup JSON export/import, upgrade banners non-intrusive.

git commit -m "feat(frontend): PWA + backup/restore + upgrade prompts"

Verificare Finala [Team Lead]

Team Lead-ul face verificarea finala dupa ce toate taskurile sunt COMPLETED:

# Backend tests
make test  # Expected: toate trec

# Frontend build
cd frontend && npm run build  # fara erori

# Docker productie
docker compose build && docker compose up -d
curl http://localhost/api/health

# Flow manual:
# 1. Register → trial tenant
# 2. Login → full sync → 24 marci in wa-sqlite
# 3. Creeaza comanda OFFLINE (DevTools offline)
# 4. Reconnect → sync push → comanda pe server
# 5. Valideaza → descarca PDF (verifica ș ț ă î â)
# 6. Deschide /p/{token} incognito → accept
# 7. Install PWA → deschide offline → datele OK

git tag v0.1.0
git push origin main --tags

Mesaj final catre echipa:

broadcast: Verificare finala trecuta! v0.1.0 gata.
Va rog sa faceti cleanup (shutddown) in ordine: backend-agent, frontend-agent, devops-agent.
Multumesc pentru colaborare!
Clean up the team

Rezumat Dependinte

TASK-001 (Lead)
  ├── TASK-002 (backend-agent) ──► TASK-005 ──► TASK-007 ──► TASK-009
  ├── TASK-003 (frontend-agent) ──► TASK-006 ──► TASK-008 ──► TASK-011
  └── TASK-004 (devops-agent) ──────────────────────────────► TASK-010

Taskurile pe acelasi nivel pot rula in paralel dupa ce dependintele sunt COMPLETED.