feat(api,web): c2 consulta de clientes — list + search + auth flow

prisma: modelo Client + migração 20260527225728_add_client + seed dev (10 clientes)
api: GET /clients (list, busca, filtro atividade/financeiro, paginação) + GET /clients/:id
     rep vê carteira própria; supervisor/admin vê tudo; activityStatus calculado de lastOrderAt
@sar/api-interface: ClientSummarySchema, ClientDetailSchema, ClientListResponseSchema
web: ClientsPage (tabela AntD, busca, filtro), DevLogin (token dev), authStore, Bearer no apiFetch
oq-4 resolvida: creditLimit gerenciado no SAR

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:08:57 +00:00
parent 2a8be3fd82
commit 14c8350216
26 changed files with 1394 additions and 84 deletions

View File

@@ -2,5 +2,8 @@
"name": "@sar/api",
"version": "0.1.0",
"private": true,
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)"
"description": "SAR · API (NestJS 11 — CommonJS conforme CODING-RULES.md PGD-DB-004)",
"prisma": {
"seed": "tsx prisma/seed.ts"
}
}

View File

@@ -14,4 +14,7 @@ export default defineConfig({
process.env['DATABASE_URL'] ??
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev',
},
migrations: {
seed: 'tsx prisma/seed.ts',
},
});

View File

@@ -0,0 +1,41 @@
-- CreateEnum
CREATE TYPE "FinancialStatus" AS ENUM ('regular', 'attention', 'blocked');
-- CreateTable
CREATE TABLE "Client" (
"id" UUID NOT NULL,
"name" TEXT NOT NULL,
"tradeName" TEXT,
"taxId" TEXT NOT NULL,
"email" TEXT,
"phone" TEXT,
"address" JSONB,
"financialStatus" "FinancialStatus" NOT NULL DEFAULT 'regular',
"creditLimit" DECIMAL(15,2),
"repId" TEXT NOT NULL,
"lastOrderAt" TIMESTAMP(3),
"lastOrderValue" DECIMAL(15,2),
"openOrdersCount" INTEGER NOT NULL DEFAULT 0,
"erpCode" TEXT,
"syncedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
"deletedAt" TIMESTAMP(3),
CONSTRAINT "Client_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "Client_taxId_key" ON "Client"("taxId");
-- CreateIndex
CREATE INDEX "Client_repId_idx" ON "Client"("repId");
-- CreateIndex
CREATE INDEX "Client_taxId_idx" ON "Client"("taxId");
-- CreateIndex
CREATE INDEX "Client_name_idx" ON "Client"("name");
-- CreateIndex
CREATE INDEX "Client_deletedAt_idx" ON "Client"("deletedAt");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

View File

@@ -9,18 +9,63 @@
// CODING-RULES PGD-DB-001: MIGRATION_DATABASE_URL aponta direto ao PG (sem PgBouncer)
generator client {
provider = "prisma-client-js"
output = "../../../node_modules/.prisma/client"
moduleFormat = "cjs"
provider = "prisma-client-js"
output = "../../../node_modules/.prisma/client"
moduleFormat = "cjs"
}
// Prisma 7: url foi removida do schema — conexão fica em prisma.config.ts (migrate)
// Prisma 7: url removida do schema — conexão em prisma.config.ts (migrate)
// e no WorkspacePrismaPool via PrismaPg adapter (runtime).
datasource db {
provider = "postgresql"
}
// ─── Modelos de domínio serão adicionados por feature ──────────────────────
// ─── Enums ───────────────────────────────────────────────────────────────────
// Situação financeira resumida do cliente — cacheável offline (FR-2.4, FR-2.5).
// Valor numérico de crédito e inadimplência requerem conexão.
enum FinancialStatus {
regular
attention
blocked
}
// ─── Client (C2) ─────────────────────────────────────────────────────────────
//
// Próximos: Client (C2), Order + OrderItem (C3/C4) — vindos das stories.
// Cada model novo exige: migration versionada + seed de dev atualizado.
// Cadastro sincronizado do ERP legado (FR-2.6). Rep não cria/edita no MVP.
// creditLimit: gerenciado no SAR — admin/supervisor define (OQ-4 resolvido 2026-05-27).
// lastOrderAt/lastOrderValue: desnormalizados, atualizados ao sincronizar Orders (C3/C4).
// activityStatus: calculado em runtime a partir de lastOrderAt (não persiste — evita drift).
model Client {
id String @id @default(uuid()) @db.Uuid
name String // razão social / nome completo
tradeName String? // nome fantasia
taxId String @unique // CNPJ (14 dígitos) ou CPF (11 dígitos), sem máscara
email String?
phone String?
address Json? // { street, number, complement?, district, city, state, zip }
// Situação financeira — resumo cacheável; detalhes numéricos requerem conexão
financialStatus FinancialStatus @default(regular)
creditLimit Decimal? @db.Decimal(15, 2)
// Desnormalizados de Orders (atualizados em C3/C4)
repId String // userId do Rep responsável (JWT sub)
lastOrderAt DateTime?
lastOrderValue Decimal? @db.Decimal(15, 2)
openOrdersCount Int @default(0)
// Controle de sync com ERP
erpCode String? // código no ERP legado
syncedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
deletedAt DateTime? // soft delete — não remove fisicamente
@@index([repId])
@@index([taxId])
@@index([name])
@@index([deletedAt]) // filtragem de soft delete eficiente
}

286
apps/api/prisma/seed.ts Normal file
View File

@@ -0,0 +1,286 @@
// Seed de desenvolvimento — popula sar_workspace_dev com dados fictícios.
// Executado via: pnpm exec prisma db seed (apps/api/)
// NUNCA rodar em staging/prod.
import { PrismaClient, FinancialStatus } from '@prisma/client';
import { PrismaPg } from '@prisma/adapter-pg';
import pg from 'pg';
const pool = new pg.Pool({
connectionString:
process.env['DATABASE_URL'] ??
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev',
max: 2,
});
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
// Rep dev padrão — mesmo userId emitido pelo POST /auth/dev/token no smoke test
const DEV_REP_ID = 'user-001';
const DEV_REP2_ID = 'user-002';
const clients = [
{
name: 'Padaria São João Ltda',
tradeName: 'Padaria São João',
taxId: '12345678000195',
email: 'contato@padariasaojoao.com.br',
phone: '(11) 3456-7890',
address: {
street: 'Rua das Flores',
number: '123',
district: 'Centro',
city: 'São Paulo',
state: 'SP',
zip: '01310100',
},
financialStatus: FinancialStatus.regular,
creditLimit: 15000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(10),
lastOrderValue: 2340.5,
openOrdersCount: 1,
erpCode: 'CLI-001',
},
{
name: 'Supermercado Bom Preço Eireli',
tradeName: 'Bom Preço',
taxId: '98765432000187',
email: 'compras@bompreco.com.br',
phone: '(11) 4567-8901',
address: {
street: 'Av. Paulista',
number: '900',
district: 'Bela Vista',
city: 'São Paulo',
state: 'SP',
zip: '01311100',
},
financialStatus: FinancialStatus.regular,
creditLimit: 50000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(5),
lastOrderValue: 12800.0,
openOrdersCount: 2,
erpCode: 'CLI-002',
},
{
name: 'Mercearia do Seu Zé ME',
tradeName: 'Mercearia Zé',
taxId: '11223344000156',
email: null,
phone: '(11) 9876-5432',
address: {
street: 'Rua Quinze de Novembro',
number: '45',
district: 'Vila Nova',
city: 'Guarulhos',
state: 'SP',
zip: '07031070',
},
financialStatus: FinancialStatus.attention,
creditLimit: 5000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(35),
lastOrderValue: 890.0,
openOrdersCount: 0,
erpCode: 'CLI-003',
},
{
name: 'Distribuidora Norte Alimentos SA',
tradeName: 'Norte Alimentos',
taxId: '55667788000143',
email: 'pedidos@nortealimentos.com.br',
phone: '(92) 3344-5566',
address: {
street: 'Av. Brasil',
number: '2200',
district: 'Industrial',
city: 'Manaus',
state: 'AM',
zip: '69075001',
},
financialStatus: FinancialStatus.regular,
creditLimit: 120000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(2),
lastOrderValue: 45600.0,
openOrdersCount: 3,
erpCode: 'CLI-004',
},
{
name: 'Bar e Lanchonete do Carlos',
tradeName: null,
taxId: '33221100000178',
email: null,
phone: '(21) 99887-6655',
address: {
street: 'Rua da Alfândega',
number: '12',
district: 'Centro',
city: 'Rio de Janeiro',
state: 'RJ',
zip: '20070002',
},
financialStatus: FinancialStatus.blocked,
creditLimit: 2000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(75),
lastOrderValue: 340.0,
openOrdersCount: 0,
erpCode: 'CLI-005',
},
{
name: 'Restaurante Sabor da Terra Ltda',
tradeName: 'Sabor da Terra',
taxId: '77889900000132',
email: 'admin@sabordaterra.com.br',
phone: '(31) 3322-1100',
address: {
street: 'Rua dos Inconfidentes',
number: '560',
district: 'Savassi',
city: 'Belo Horizonte',
state: 'MG',
zip: '30140128',
},
financialStatus: FinancialStatus.regular,
creditLimit: 30000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(18),
lastOrderValue: 7200.0,
openOrdersCount: 1,
erpCode: 'CLI-006',
},
{
name: 'Atacadão Central Comércio Ltda',
tradeName: 'Atacadão Central',
taxId: '44556677000119',
email: 'compras@atacadaocentral.com.br',
phone: '(51) 3288-9900',
address: {
street: 'Av. Assis Brasil',
number: '3970',
district: "Passo d'Areia",
city: 'Porto Alegre',
state: 'RS',
zip: '91010003',
},
financialStatus: FinancialStatus.regular,
creditLimit: 80000.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(8),
lastOrderValue: 32100.0,
openOrdersCount: 2,
erpCode: 'CLI-007',
},
{
name: 'Quitanda Boa Vista ME',
tradeName: 'Quitanda Boa Vista',
taxId: '22334455000167',
email: null,
phone: '(48) 3344-2211',
address: {
street: 'Rua Felipe Schmidt',
number: '88',
district: 'Centro',
city: 'Florianópolis',
state: 'SC',
zip: '88010001',
},
financialStatus: FinancialStatus.attention,
creditLimit: 3500.0,
repId: DEV_REP_ID,
lastOrderAt: daysAgo(45),
lastOrderValue: 560.0,
openOrdersCount: 0,
erpCode: 'CLI-008',
},
// Clientes do segundo rep (para testar filtro de carteira)
{
name: 'Empório Gourmet Curitiba Ltda',
tradeName: 'Empório Gourmet',
taxId: '66778899000124',
email: 'pedidos@emporiogourmet.com.br',
phone: '(41) 3233-4455',
address: {
street: 'Rua XV de Novembro',
number: '700',
district: 'Centro',
city: 'Curitiba',
state: 'PR',
zip: '80060000',
},
financialStatus: FinancialStatus.regular,
creditLimit: 25000.0,
repId: DEV_REP2_ID,
lastOrderAt: daysAgo(3),
lastOrderValue: 8900.0,
openOrdersCount: 1,
erpCode: 'CLI-009',
},
{
name: 'Mini Mercado Esperança',
tradeName: null,
taxId: '88990011000135',
email: null,
phone: '(85) 9988-7766',
address: {
street: 'Av. Bezerra de Menezes',
number: '1800',
district: 'São Gerardo',
city: 'Fortaleza',
state: 'CE',
zip: '60325001',
},
financialStatus: FinancialStatus.regular,
creditLimit: 8000.0,
repId: DEV_REP2_ID,
lastOrderAt: daysAgo(20),
lastOrderValue: 1200.0,
openOrdersCount: 0,
erpCode: 'CLI-010',
},
];
function daysAgo(days: number): Date {
const d = new Date();
d.setDate(d.getDate() - days);
return d;
}
async function main() {
console.log('🌱 Seed iniciado...');
for (const data of clients) {
await prisma.client.upsert({
where: { taxId: data.taxId },
create: {
...data,
creditLimit: data.creditLimit,
lastOrderValue: data.lastOrderValue,
syncedAt: new Date(),
},
update: {
name: data.name,
financialStatus: data.financialStatus,
lastOrderAt: data.lastOrderAt,
lastOrderValue: data.lastOrderValue,
openOrdersCount: data.openOrdersCount,
syncedAt: new Date(),
},
});
}
console.log(`${clients.length} clientes criados/atualizados.`);
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
await pool.end();
});

View File

@@ -8,6 +8,7 @@ import { HealthModule } from './health/health.module';
import { PingModule } from './ping/ping.module';
import { AuthModule } from './auth/auth.module';
import { JwtAuthGuard } from './auth/jwt-auth.guard';
import { ClientsModule } from './clients/clients.module';
import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
@@ -19,6 +20,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
AuthModule,
HealthModule,
PingModule,
ClientsModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -48,6 +48,7 @@ export class JwtAuthGuard implements CanActivate {
const workspaceId = payload.workspace_id;
this.cls.set('workspaceId', workspaceId);
this.cls.set('userId', payload.sub);
this.cls.set('role', payload.role);
const dbUrl =
process.env['DATABASE_URL'] ??

View File

@@ -0,0 +1,33 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod';
import {
ClientListQuerySchema,
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { ClientsService } from './clients.service';
class ClientListQueryDto extends createZodDto(ClientListQuerySchema) {}
@Controller({ path: 'clients' })
export class ClientsController {
constructor(
private readonly clients: ClientsService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
@Get()
list(@Query() query: ClientListQueryDto): Promise<ClientListResponse> {
// parse aplica defaults (page=1, limit=50) definidos no schema
const parsed = ClientListQuerySchema.parse(query) as ClientListQuery;
return this.clients.list(parsed, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
}
@Get(':id')
findOne(@Param('id', ParseUUIDPipe) id: string): Promise<ClientDetail> {
return this.clients.findOne(id, this.cls.get('userId') ?? '', this.cls.get('role') ?? 'rep');
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ClientsController } from './clients.controller';
import { ClientsService } from './clients.service';
@Module({
controllers: [ClientsController],
providers: [ClientsService],
})
export class ClientsModule {}

View File

@@ -0,0 +1,132 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client';
import type {
ClientDetail,
ClientListQuery,
ClientListResponse,
ClientSummary,
ActivityStatus,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
// Thresholds de atividade (FR-2.3). Configuráveis por workspace futuramente.
const ALERT_DAYS = 30;
const INACTIVE_DAYS = 60;
function activityStatus(lastOrderAt: Date | null): ActivityStatus {
if (!lastOrderAt) return 'inactive';
const days = Math.floor((Date.now() - lastOrderAt.getTime()) / 86_400_000);
if (days >= INACTIVE_DAYS) return 'inactive';
if (days >= ALERT_DAYS) return 'alert';
return 'active';
}
function decimalToString(v: Prisma.Decimal | null): string | null {
return v ? v.toString() : null;
}
@Injectable()
export class ClientsService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async list(query: ClientListQuery, userId: string, role: string): Promise<ClientListResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const { q, status, financialStatus, page, limit } = query;
const skip = (page - 1) * limit;
// Rep vê apenas sua carteira; supervisor/manager/admin vê tudo (FR-2.1).
const repFilter: Prisma.ClientWhereInput = role === 'rep' ? { repId: userId } : {};
const searchFilter: Prisma.ClientWhereInput = q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ tradeName: { contains: q, mode: 'insensitive' } },
{ taxId: { contains: q } },
],
}
: {};
const financialFilter: Prisma.ClientWhereInput = financialStatus ? { financialStatus } : {};
const where: Prisma.ClientWhereInput = {
deletedAt: null,
...repFilter,
...searchFilter,
...financialFilter,
};
const [rows, total] = await Promise.all([
prisma.client.findMany({
where,
select: {
id: true,
name: true,
tradeName: true,
taxId: true,
financialStatus: true,
lastOrderAt: true,
lastOrderValue: true,
openOrdersCount: true,
},
skip,
take: limit,
orderBy: { name: 'asc' },
}),
prisma.client.count({ where }),
]);
// Filtra por activityStatus depois do fetch (computed field — não persiste no DB).
const mapped: ClientSummary[] = rows.map((r) => ({
id: r.id,
name: r.name,
tradeName: r.tradeName,
taxId: r.taxId,
financialStatus: r.financialStatus,
activityStatus: activityStatus(r.lastOrderAt),
lastOrderAt: r.lastOrderAt?.toISOString() ?? null,
lastOrderValue: decimalToString(r.lastOrderValue),
openOrdersCount: r.openOrdersCount,
}));
const filtered = status ? mapped.filter((c) => c.activityStatus === status) : mapped;
return { data: filtered, total, page, limit };
}
async findOne(id: string, userId: string, role: string): Promise<ClientDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const repFilter: Prisma.ClientWhereInput = role === 'rep' ? { repId: userId } : {};
const client = await prisma.client.findFirst({
where: { id, deletedAt: null, ...repFilter },
});
if (!client) throw new NotFoundException(`Cliente ${id} não encontrado`);
return {
id: client.id,
name: client.name,
tradeName: client.tradeName,
taxId: client.taxId,
email: client.email,
phone: client.phone,
address: client.address as ClientDetail['address'],
financialStatus: client.financialStatus,
activityStatus: activityStatus(client.lastOrderAt),
creditLimit: decimalToString(client.creditLimit),
lastOrderAt: client.lastOrderAt?.toISOString() ?? null,
lastOrderValue: decimalToString(client.lastOrderValue),
openOrdersCount: client.openOrdersCount,
erpCode: client.erpCode,
syncedAt: client.syncedAt?.toISOString() ?? null,
createdAt: client.createdAt.toISOString(),
updatedAt: client.updatedAt.toISOString(),
};
}
}

View File

@@ -1,5 +1,6 @@
import type { ClsStore } from 'nestjs-cls';
import type { PrismaClient } from '@prisma/client';
import type { JwtRole } from '../auth/jwt.types';
// Forma do CLS store por request — fonte da verdade para qualquer caller.
// CODING-RULES PGD-DB-009: nunca importe PrismaClient diretamente; use cls.get('prisma').
@@ -8,6 +9,7 @@ import type { PrismaClient } from '@prisma/client';
export interface WorkspaceClsStore extends ClsStore {
requestId: string;
workspaceId: string;
userId?: string; // preenchido pelo JwtAuthGuard (M3)
prisma?: PrismaClient; // preenchido pelo WorkspaceModule após WorkspacePrismaPool entrar (M5)
userId?: string; // preenchido pelo JwtAuthGuard após validar o token
role?: JwtRole; // preenchido pelo JwtAuthGuard após validar o token
prisma?: PrismaClient; // preenchido pelo JwtAuthGuard via WorkspacePrismaPool
}

View File

@@ -0,0 +1,201 @@
import { useState } from 'react';
import { Badge, Input, Select, Space, Table, Tag, Tooltip, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import { useNavigate } from '@tanstack/react-router';
import type { ActivityStatus, ClientSummary, FinancialStatus } from '@sar/api-interface';
import { useClientList } from '../../lib/queries/clients';
const { Title } = Typography;
const { Search } = Input;
// ─── Badge configs ────────────────────────────────────────────────────────────
const ACTIVITY_CONFIG: Record<ActivityStatus, { color: string; label: string }> = {
active: { color: 'success', label: 'Ativo' },
alert: { color: 'warning', label: 'Em alerta' },
inactive: { color: 'error', label: 'Inativo' },
};
const FINANCIAL_CONFIG: Record<FinancialStatus, { color: string; label: string }> = {
regular: { color: 'success', label: 'Regular' },
attention: { color: 'warning', label: 'Atenção' },
blocked: { color: 'error', label: 'Bloqueado' },
};
// ─── Columns ──────────────────────────────────────────────────────────────────
function buildColumns(navigate: ReturnType<typeof useNavigate>): TableColumnsType<ClientSummary> {
return [
{
title: 'Cliente',
dataIndex: 'name',
key: 'name',
render: (name: string, record: ClientSummary) => (
<Space direction="vertical" size={0}>
<Typography.Link
strong
onClick={() => navigate({ to: '/clientes/$id', params: { id: record.id } })}
>
{name}
</Typography.Link>
{record.tradeName && (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{record.tradeName}
</Typography.Text>
)}
</Space>
),
sorter: true,
},
{
title: 'CNPJ / CPF',
dataIndex: 'taxId',
key: 'taxId',
width: 160,
render: (v: string) => (
<Typography.Text className="tabular-nums" style={{ fontSize: 13 }}>
{v}
</Typography.Text>
),
},
{
title: 'Atividade',
dataIndex: 'activityStatus',
key: 'activityStatus',
width: 120,
render: (v: ActivityStatus) => {
const cfg = ACTIVITY_CONFIG[v];
return <Badge status={cfg.color as 'success' | 'warning' | 'error'} text={cfg.label} />;
},
},
{
title: 'Situação',
dataIndex: 'financialStatus',
key: 'financialStatus',
width: 110,
render: (v: FinancialStatus) => {
const cfg = FINANCIAL_CONFIG[v];
return <Tag color={cfg.color}>{cfg.label}</Tag>;
},
},
{
title: 'Última compra',
dataIndex: 'lastOrderAt',
key: 'lastOrderAt',
width: 140,
render: (v: string | null, record: ClientSummary) => {
if (!v) return <Typography.Text type="secondary"></Typography.Text>;
const date = new Date(v).toLocaleDateString('pt-BR');
const value = record.lastOrderValue
? `R$ ${Number(record.lastOrderValue).toLocaleString('pt-BR', { minimumFractionDigits: 2 })}`
: '';
return (
<Tooltip title={value}>
<Typography.Text className="tabular-nums">{date}</Typography.Text>
</Tooltip>
);
},
},
{
title: 'Pedidos abertos',
dataIndex: 'openOrdersCount',
key: 'openOrdersCount',
width: 120,
align: 'center',
render: (v: number) =>
v > 0 ? (
<Tag color="processing" className="tabular-nums">
{v}
</Tag>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
];
}
// ─── Page ─────────────────────────────────────────────────────────────────────
export function ClientsPage() {
const navigate = useNavigate();
const [q, setQ] = useState('');
const [search, setSearch] = useState('');
const [activityFilter, setActivityFilter] = useState<ActivityStatus | undefined>();
const [page, setPage] = useState(1);
const limit = 50;
const { data, isLoading, isFetching } = useClientList({
q: search || undefined,
status: activityFilter,
page,
limit,
});
const columns = buildColumns(navigate);
return (
<Space direction="vertical" size={24} style={{ width: '100%' }}>
{/* Cabeçalho */}
<Space direction="vertical" size={4}>
<Title level={2} style={{ margin: 0 }}>
Carteira de Clientes
</Title>
<Typography.Text type="secondary">
{data ? `${data.total} cliente${data.total !== 1 ? 's' : ''} na sua carteira` : ' '}
</Typography.Text>
</Space>
{/* Filtros */}
<Space wrap>
<Search
placeholder="Buscar por nome, razão social ou CNPJ…"
value={q}
onChange={(e) => setQ(e.target.value)}
onSearch={(v) => {
setSearch(v);
setPage(1);
}}
allowClear
style={{ width: 320 }}
/>
<Select<ActivityStatus | undefined>
placeholder="Atividade"
allowClear
style={{ width: 140 }}
value={activityFilter}
onChange={(v) => {
setActivityFilter(v);
setPage(1);
}}
options={[
{ value: 'active', label: 'Ativo' },
{ value: 'alert', label: 'Em alerta' },
{ value: 'inactive', label: 'Inativo' },
]}
/>
</Space>
{/* Tabela */}
<Table<ClientSummary>
columns={columns}
dataSource={data?.data ?? []}
rowKey="id"
loading={isLoading || isFetching}
pagination={{
current: page,
pageSize: limit,
total: data?.total ?? 0,
showSizeChanger: false,
showTotal: (total) => `${total} clientes`,
onChange: (p) => setPage(p),
}}
scroll={{ x: 900 }}
size="middle"
onRow={(record) => ({
style: { cursor: 'pointer' },
onClick: () => navigate({ to: '/clientes/$id', params: { id: record.id } }),
})}
/>
</Space>
);
}

View File

@@ -0,0 +1,53 @@
// Componente de login dev — visível apenas quando NODE_ENV !== 'production' e sem token.
// Em produção o token vem do master-login real (fora do escopo do MVP).
import { useState } from 'react';
import { Alert, Button, Card, Flex, Space, Typography } from 'antd';
import { apiFetch } from '../../lib/api-client';
import { authStore } from '../../lib/auth-store';
import { AuthTokenResponseSchema } from '@sar/api-interface';
export function DevLogin({ onLogin }: { onLogin: () => void }) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleLogin() {
setLoading(true);
setError(null);
try {
const raw = await apiFetch('/api/v1/auth/dev/token', {
method: 'POST',
body: { userId: 'user-001', workspaceId: 'dev-workspace', role: 'rep' },
});
const { accessToken } = AuthTokenResponseSchema.parse(raw);
authStore.set(accessToken);
onLogin();
} catch (e) {
setError(e instanceof Error ? e.message : 'Erro ao obter token');
} finally {
setLoading(false);
}
}
return (
<Flex justify="center" align="center" style={{ minHeight: '100vh' }}>
<Card style={{ width: 360 }}>
<Space direction="vertical" size={20} style={{ width: '100%' }}>
<Typography.Title level={3} style={{ margin: 0 }}>
SAR · Login Dev
</Typography.Title>
<Alert
type="warning"
message="Ambiente de desenvolvimento"
description="Este login automático não existe em produção."
showIcon
/>
{error && <Alert type="error" message={error} showIcon />}
<Button type="primary" block loading={loading} onClick={() => void handleLogin()}>
Entrar como Rafael (rep · user-001)
</Button>
</Space>
</Card>
</Flex>
);
}

View File

@@ -8,6 +8,8 @@
// CODING-RULES §05: 422 = validação Zod; 4xx outros = erros de domínio; 5xx = retry pelo
// QueryClient (até 2x). O ApiError carrega tudo que o caller precisa pra decidir.
import { authStore } from './auth-store';
const PROBLEM_CONTENT_TYPE = 'application/problem+json';
export interface ProblemDetails {
@@ -39,11 +41,14 @@ interface RequestOptions extends Omit<RequestInit, 'body'> {
export async function apiFetch(path: string, options: RequestOptions = {}): Promise<unknown> {
const { body, headers, ...rest } = options;
const token = authStore.get();
const init: RequestInit = {
...rest,
headers: {
Accept: 'application/json',
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...headers,
},
...(body !== undefined ? { body: JSON.stringify(body) } : {}),

View File

@@ -0,0 +1,16 @@
// Store minimalista para o token de acesso (dev: localStorage; prod: cookie httpOnly via BFF).
// Em produção o token virá do master-login real e não ficará em localStorage.
const TOKEN_KEY = 'sar_access_token';
export const authStore = {
get(): string | null {
return localStorage.getItem(TOKEN_KEY);
},
set(token: string): void {
localStorage.setItem(TOKEN_KEY, token);
},
clear(): void {
localStorage.removeItem(TOKEN_KEY);
},
};

View File

@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query';
import {
ClientListResponseSchema,
ClientDetailSchema,
type ClientListQuery,
type ClientListResponse,
type ClientDetail,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export const CLIENT_KEYS = {
all: ['clients'] as const,
list: (params: Partial<ClientListQuery>) => ['clients', 'list', params] as const,
detail: (id: string) => ['clients', 'detail', id] as const,
};
export function useClientList(params: Partial<ClientListQuery> = {}) {
const qs = new URLSearchParams();
if (params.q) qs.set('q', params.q);
if (params.status) qs.set('status', params.status);
if (params.financialStatus) qs.set('financialStatus', params.financialStatus);
if (params.page) qs.set('page', String(params.page));
if (params.limit) qs.set('limit', String(params.limit));
const query = qs.toString();
return useQuery<ClientListResponse, Error>({
queryKey: CLIENT_KEYS.list(params),
queryFn: async () => {
const raw = await apiFetch(`/api/v1/clients${query ? `?${query}` : ''}`);
return ClientListResponseSchema.parse(raw);
},
});
}
export function useClientDetail(id: string) {
return useQuery<ClientDetail, Error>({
queryKey: CLIENT_KEYS.detail(id),
queryFn: async () => {
const raw = await apiFetch(`/api/v1/clients/${id}`);
return ClientDetailSchema.parse(raw);
},
enabled: !!id,
});
}

View File

@@ -1,6 +1,7 @@
import { createRouter, createRootRoute, createRoute, Outlet } from '@tanstack/react-router';
import { AppShell } from '../components/layout/AppShell';
import { RafaelPainel } from '../cockpits/rafael/RafaelPainel';
import { ClientsPage } from '../cockpits/rafael/ClientsPage';
const rootRoute = createRootRoute({
component: () => (
@@ -16,14 +17,38 @@ const indexRoute = createRoute({
component: RafaelPainel,
});
// Placeholder routes (cockpits a implementar)
const rafaelRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/rep',
component: RafaelPainel,
});
const routeTree = rootRoute.addChildren([indexRoute, rafaelRoute]);
const clientesRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes',
component: ClientsPage,
});
// Placeholder detail route — ClientDetailPage virá em próxima iteração de C2
const clienteDetailRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/$id',
component: () => {
const { id } = clienteDetailRoute.useParams();
return (
<div style={{ padding: 24 }}>
<p>Ficha do cliente {id} em construção</p>
</div>
);
},
});
const routeTree = rootRoute.addChildren([
indexRoute,
rafaelRoute,
clientesRoute,
clienteDetailRoute,
]);
export const router = createRouter({
routeTree,

View File

@@ -1,4 +1,4 @@
import { StrictMode } from 'react';
import { StrictMode, useState } from 'react';
import { createRoot } from 'react-dom/client';
import { ConfigProvider, App as AntdApp } from 'antd';
import ptBR from 'antd/locale/pt_BR';
@@ -12,9 +12,24 @@ import './styles/global.css';
import { sarTheme } from './lib/theme';
import { queryClient } from './lib/query-client';
import { router } from './lib/router';
import { authStore } from './lib/auth-store';
import { DevLogin } from './components/dev/DevLogin';
dayjs.locale('pt-br');
const isDev = import.meta.env.DEV;
function Root() {
const [hasToken, setHasToken] = useState(() => !!authStore.get());
// Em dev, exibe DevLogin se não houver token. Em prod, fluxo de auth real virá aqui.
if (isDev && !hasToken) {
return <DevLogin onLogin={() => setHasToken(true)} />;
}
return <RouterProvider router={router} />;
}
const rootEl = document.getElementById('root');
if (!rootEl) {
throw new Error('Root element not found');
@@ -25,7 +40,7 @@ createRoot(rootEl).render(
<ConfigProvider theme={sarTheme} locale={ptBR} componentSize="middle">
<AntdApp>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<Root />
</QueryClientProvider>
</AntdApp>
</ConfigProvider>