feat(web+api): ficha do cliente — contatos, CTR, NF-e, pedidos e produtos

- Contatos: grid de cards 3 colunas, botão "Novo Contato" no topo da página
- Sync ERP↔SAR corrigido: vw_contatos aceita formato COR#<id>, trigger normaliza id_empresa (9001→1)
- CTR + NF-e: layout 50/50 — lista de títulos abertos com badge vencido/a vencer e lista de notas com botão copiar chave NF-e
- Histórico de pedidos: UNION SAR+ERP, top 5 + modal "Ver todos"
- Produtos mais comprados: top 5 com último preço + modal "Ver todos"
- Novos endpoints: ctr-list, notas, orders-history, top-produtos
- AppShell: overflow-x travado, sem scroll horizontal na aplicação

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-25 14:13:21 +00:00
parent a2bab75bad
commit 400fcb3360
23 changed files with 2532 additions and 356 deletions

View File

@@ -13,6 +13,7 @@ import { OrdersModule } from './orders/orders.module';
import { CatalogModule } from './catalog/catalog.module';
import { DashboardModule } from './dashboard/dashboard.module';
import { NotificationsModule } from './notifications/notifications.module';
import { ReportsModule } from './reports/reports.module';
import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({
@@ -29,6 +30,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
CatalogModule,
DashboardModule,
NotificationsModule,
ReportsModule,
],
providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -10,8 +10,13 @@ import {
type ClientNovoResult,
type Contato,
type ContatoResult,
type CtrSummary,
type CreateClientNovo,
type CreateContato,
type CtrTitulo,
type NotaFiscal,
type PedidoSummary,
type TopProduto,
} from '@sar/api-interface';
import { ClientsService } from './clients.service';
@@ -35,6 +40,47 @@ export class ClientsController {
return this.clients.createNovo(parsed);
}
@Get(':id/notas')
listNotasFiscais(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<NotaFiscal[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
return this.clients.listNotasFiscais(id, n);
}
@Get(':id/top-produtos')
listTopProdutos(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<TopProduto[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
return this.clients.listTopProdutos(id, n);
}
@Get(':id/orders-history')
listOrdersHistory(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<PedidoSummary[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 50, 200) : 50;
return this.clients.listOrdersHistory(id, n);
}
@Get(':id/ctr')
getCtrSummary(@Param('id', ParseIntPipe) id: number): Promise<CtrSummary> {
return this.clients.getCtrSummary(id);
}
@Get(':id/ctr-list')
listCtrTitulos(
@Param('id', ParseIntPipe) id: number,
@Query('limit') limit?: string,
): Promise<CtrTitulo[]> {
const n = limit ? Math.min(parseInt(limit, 10) || 60, 200) : 60;
return this.clients.listCtrTitulos(id, n);
}
@Get(':id/contacts')
listContacts(@Param('id', ParseIntPipe) id: number): Promise<Contato[]> {
return this.clients.listContacts(id);

View File

@@ -9,8 +9,13 @@ import type {
ClientSummary,
Contato,
ContatoResult,
CtrSummary,
CtrTitulo,
CreateClientNovo,
CreateContato,
NotaFiscal,
PedidoSummary,
TopProduto,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -364,6 +369,161 @@ export class ClientsService {
};
}
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
interface Row {
id: string;
num_ped: string;
situa: number;
dt_pedido: Date;
total: string;
fonte: string;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT id::text AS id,
num_ped_sar AS num_ped,
situa,
dt_pedido,
total::text,
'sar' AS fonte
FROM sar.pedidos
WHERE id_cliente = ${idCliente}
AND id_empresa = ${idEmpresa}
UNION ALL
SELECT ('erp-' || id_pedido::text) AS id,
COALESCE(NULLIF(TRIM(num_ped_sar::text),''), numero::text) AS num_ped,
CASE WHEN situa = 5 THEN 3 ELSE situa END AS situa,
dt_pedido,
total::text,
'erp' AS fonte
FROM sar.vw_pedidos_erp
WHERE id_cliente = ${idCliente}
AND id_empresa = ${idEmpresa}
AND situa != 5
ORDER BY dt_pedido DESC
LIMIT ${limit}
`);
return rows.map((r) => ({
id: r.id,
numPedSar: r.num_ped ?? r.id,
idCliente,
nomeCliente: null,
razaoCliente: null,
codVendedor: 0,
nomeVendedor: null,
situa: Number(r.situa),
dtPedido: r.dt_pedido.toISOString(),
total: r.total ?? '0',
descontoPerc: '0',
obs: null,
createdAt: r.dt_pedido.toISOString(),
fonte: r.fonte as 'sar' | 'erp',
}));
}
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
const idEmpresaMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
interface Row {
id_produto: number;
descricao: string;
unidade: string | null;
qtd_total: string;
num_pedidos: string;
ultima_compra: Date;
ultimo_preco: string;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT
i.produ AS id_produto,
TRIM(p.descricao) AS descricao,
TRIM(p.unidade) AS unidade,
SUM(i.qtd)::numeric(15,3)::text AS qtd_total,
COUNT(DISTINCT ped.id_pedido)::text AS num_pedidos,
MAX(ped.data) AS ultima_compra,
(
SELECT i2.pruni::numeric(15,2)::text
FROM sig.peditens i2
JOIN sig.pedidos p2 ON p2.id_pedido = i2.id_pedido
WHERE i2.produ = i.produ
AND p2.clien = ped.clien
AND p2.id_empresa = ${idEmpresa}
AND p2.situa NOT IN (5)
ORDER BY p2.data DESC, p2.id_pedido DESC
LIMIT 1
) AS ultimo_preco
FROM sig.pedidos ped
JOIN sig.peditens i ON i.id_pedido = ped.id_pedido
JOIN gestao.produto p ON p.id_erp = i.produ
AND p.id_empresa = ${idEmpresaMatriz}
WHERE ped.clien = ${idCliente}
AND ped.id_empresa = ${idEmpresa}
AND ped.situa NOT IN (5)
GROUP BY i.produ, p.descricao, p.unidade, ped.clien
ORDER BY SUM(i.qtd) DESC
LIMIT ${limit}
`);
return rows.map((r) => ({
idProduto: Number(r.id_produto),
descricao: r.descricao,
unidade: r.unidade,
qtdTotal: parseFloat(r.qtd_total ?? '0'),
numPedidos: parseInt(r.num_pedidos ?? '0', 10),
ultimaCompra: (r.ultima_compra as Date).toISOString().slice(0, 10),
ultimoPreco: parseFloat(r.ultimo_preco ?? '0'),
}));
}
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
interface Row {
qtd_aberto: string;
total_aberto: string;
qtd_vencido: string;
total_vencido: string;
maior_atraso_dias: string;
}
const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT
COUNT(*)::text AS qtd_aberto,
COALESCE(SUM(saldo), 0)::numeric(15,2)::text AS total_aberto,
(COUNT(*) FILTER (WHERE dt_vencimento < CURRENT_DATE))::text AS qtd_vencido,
COALESCE(SUM(saldo) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::numeric(15,2)::text AS total_vencido,
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
FROM sar.vw_ctr
WHERE id_cliente = ${idCliente}
AND situacao = 'A'
AND saldo > 0
`);
const r = rows[0];
return {
qtdAberto: parseInt(r?.qtd_aberto ?? '0', 10),
totalAberto: parseFloat(r?.total_aberto ?? '0'),
qtdVencido: parseInt(r?.qtd_vencido ?? '0', 10),
totalVencido: parseFloat(r?.total_vencido ?? '0'),
maiorAtrasoDias: parseInt(r?.maior_atraso_dias ?? '0', 10),
};
}
async findOne(idCliente: number): Promise<ClientDetail> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -388,6 +548,7 @@ export class ClientsService {
return {
idCliente: Number(r.id_cliente),
idEmpresa: Number(r.id_empresa),
nome: r.nome,
razao: r.razao,
@@ -413,4 +574,106 @@ export class ClientsService {
dtAtual: r.dt_atual,
};
}
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
type CtrRow = {
id_ctr: number;
numero: string;
prefixo: string;
dt_emissao: Date;
dt_vencimento: Date;
saldo: string;
};
const rows = await prisma.$queryRawUnsafe<CtrRow[]>(
`
SELECT
id_ctr,
TRIM(numero) AS numero,
TRIM(prefixo) AS prefixo,
dt_emissao,
dt_vencimento,
saldo::text AS saldo
FROM sar.vw_ctr
WHERE id_cliente = $1
AND id_empresa = 1
AND situacao = 'A'
AND saldo > 0
ORDER BY dt_vencimento ASC
LIMIT $2
`,
idCliente,
limit,
);
const toDate = (d: Date | string): string =>
d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10);
return rows.map((r: CtrRow) => ({
idCtr: Number(r.id_ctr),
numero: r.numero,
prefixo: r.prefixo,
dtEmissao: toDate(r.dt_emissao),
dtVencimento: toDate(r.dt_vencimento),
saldo: Number(r.saldo),
}));
}
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
type NfRow = {
id_nf: number;
numero: number;
serie: string;
dt_emissao: Date;
vl_nf: string;
chave_acesso_nfe: string | null;
};
const rows = await prisma.$queryRawUnsafe<NfRow[]>(
`
SELECT id_nf, numero, serie, dt_emissao, vl_nf, chave_acesso_nfe
FROM (
SELECT DISTINCT ON (nf.id_nf)
nf.id_nf,
nf.numero,
TRIM(nf.serie) AS serie,
nf.dt_emissao,
nf.vl_nf::text AS vl_nf,
TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe
FROM gestao.nf nf
JOIN sig.pedidos p
ON p.numero = nf.num_entrega
AND p.tipo = 'E'
AND p.id_empresa IN (1, 9001)
WHERE p.clien = $1
AND nf.id_empresa = 1
AND nf.status = 'E'
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
ORDER BY nf.id_nf
) sub
ORDER BY dt_emissao DESC
LIMIT $2
`,
idCliente,
limit,
);
return rows.map((r: NfRow) => ({
idNf: Number(r.id_nf),
numero: Number(r.numero),
serie: r.serie?.trim() ?? '',
dtEmissao:
(r.dt_emissao instanceof Date
? r.dt_emissao.toISOString().split('T')[0]
: String(r.dt_emissao)) ?? '',
vlNf: Number(r.vl_nf),
chaveAcessoNfe: r.chave_acesso_nfe?.trim() || null,
}));
}
}

View File

@@ -35,16 +35,14 @@ interface RealizadoGrupoRow {
peso: string;
}
interface RepRow {
taxa_com: string;
permitir_flex: number; // 0 ou 1 (char do ERP convertido)
}
interface InativoRow {
interface NaoPositivadoRow {
id_cliente: number;
nome: string;
dt_ultima_compra: Date | null;
ultima_compra_valor: string | null;
razao: string | null;
dt_ultimo_pedido: string | null; // YYYY-MM-DD ou null
dias_sem_pedido: number | null;
comprou_antes: boolean;
whatsapp: string | null;
}
interface InativosPorRepRow {
@@ -92,24 +90,7 @@ export class DashboardService {
: grRows.reduce((a, m) => a + Number(m.valor), 0);
const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const);
// 2. Taxas do representante — fonte: gestao.vendedor (via vw_representantes)
const repRows = await prisma.$queryRawUnsafe<RepRow[]>(`
SELECT taxa_com::text, COALESCE(permitir_flex, 0) AS permitir_flex
FROM vw_representantes
WHERE codigo = ${codVendedor}
LIMIT 1
`);
const commissionRate = repRows[0] ? Number(repRows[0].taxa_com) : 3;
const permitirFlex = (repRows[0]?.permitir_flex ?? 0) === 1;
// 3. Taxa flex — fonte: sar.meta_representante (override SAR; default 1%)
const flexOverride = await prisma.metaRepresentante.findUnique({
where: { codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano: year, mes: month } },
select: { taxaFlex: true },
});
const flexRate = flexOverride ? Number(flexOverride.taxaFlex) : 1;
// 4. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
// 2. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
// Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido.
const monthStartStr = monthStart.toISOString().slice(0, 10);
const monthEndStr = monthEnd.toISOString().slice(0, 10);
@@ -198,32 +179,55 @@ export class DashboardService {
const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0;
const falta = Math.max(0, targetAmount - atingido);
const fixa = Math.round(atingido * commissionRate) / 100;
const flex =
permitirFlex && targetAmount > 0 && atingido >= targetAmount
? Math.round(atingido * flexRate) / 100
: 0;
// 7. Clientes inativos — sem pedido no ERP há >30 dias
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inactiveClients = await prisma.$queryRawUnsafe<InativoRow[]>(`
// Clientes não positivados no mês: ativos do representante sem pedido desde monthStart.
// Junta o último pedido histórico (ERP+SAR) e o primeiro whatsapp ativo dos contatos.
const naoPositivadosRows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
WITH ultimo_pedido AS (
SELECT id_cliente,
MAX(dt_pedido) AS dt_max
FROM (
SELECT id_cliente, dt_pedido FROM vw_pedidos_erp
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
UNION ALL
SELECT id_cliente, dt_pedido FROM sar.pedidos
WHERE situa != 3 AND id_empresa = ${idEmpresa}
) t
GROUP BY id_cliente
),
pedido_mes AS (
SELECT DISTINCT id_cliente FROM (
SELECT id_cliente FROM vw_pedidos_erp
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
AND dt_pedido >= '${monthStartStr}'
UNION
SELECT id_cliente FROM sar.pedidos
WHERE situa != 3 AND id_empresa = ${idEmpresa}
AND dt_pedido >= '${monthStartStr}'
) t
)
SELECT
c.id_cliente,
c.nome,
MAX(p.dt_pedido) AS dt_ultima_compra,
MAX(p.total)::text AS ultima_compra_valor
TRIM(c.nome) AS nome,
TRIM(c.razao) AS razao,
TO_CHAR(up.dt_max, 'YYYY-MM-DD') AS dt_ultimo_pedido,
(CURRENT_DATE - up.dt_max::date) AS dias_sem_pedido,
(up.dt_max IS NOT NULL) AS comprou_antes,
ct.whatsapp
FROM vw_clientes c
LEFT JOIN vw_pedidos_erp p
ON p.id_cliente = c.id_cliente
AND p.id_empresa = ${idEmpresa}
AND p.situa != 5
LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
LEFT JOIN pedido_mes pm ON pm.id_cliente = c.id_cliente
LEFT JOIN LATERAL (
SELECT TRIM(whatsapp) AS whatsapp
FROM sar.vw_contatos
WHERE id_corrent = c.id_cliente
AND whatsapp IS NOT NULL AND TRIM(whatsapp) != ''
AND ativo = 1
LIMIT 1
) ct ON true
WHERE c.cod_vendedor = ${codVendedor}
AND c.ativo = 1
GROUP BY c.id_cliente, c.nome
HAVING MAX(p.dt_pedido) IS NULL
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
ORDER BY dt_ultima_compra ASC NULLS FIRST
LIMIT 10
AND pm.id_cliente IS NULL
ORDER BY up.dt_max ASC NULLS FIRST
`);
// Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo.
@@ -257,11 +261,20 @@ export class DashboardService {
})
.sort((a, b) => b.valorMeta - a.valorMeta);
const naoPositivados = naoPositivadosRows.map((c) => ({
idCliente: Number(c.id_cliente),
nome: c.nome,
razao: c.razao ?? null,
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
ultimoPedido: c.dt_ultimo_pedido ?? null,
comprouAntes: Boolean(c.comprou_antes),
whatsapp: c.whatsapp ?? null,
}));
return {
meta: { atingido, total: targetAmount, pct, falta },
metaDimensao,
metasPorGrupo,
comissao: { fixa, flex, total: fixa + flex },
pedidosMes,
pedidosRecentes: recentRows.map((o) => ({
id: `erp-${o.id_pedido}`,
@@ -281,14 +294,8 @@ export class DashboardService {
createdAt: new Date(o.dt_pedido).toISOString(),
fonte: 'erp' as const,
})),
clientesInativos: inactiveClients.map((c) => ({
idCliente: Number(c.id_cliente),
nome: c.nome,
diasSemCompra: c.dt_ultima_compra
? Math.floor((now.getTime() - c.dt_ultima_compra.getTime()) / 86_400_000)
: 999,
ultimaCompraValor: c.ultima_compra_valor,
})),
naoPositivados,
totalNaoPositivados: naoPositivados.length,
syncedAt: now.toISOString(),
};
}

View File

@@ -0,0 +1,33 @@
import { Controller, Get, Query } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod';
import {
ReportMetaQuerySchema,
ReportAbcQuerySchema,
type ReportMetaResponse,
type ReportCarteiraResponse,
type ReportAbcResponse,
} from '@sar/api-interface';
import { ReportsService } from './reports.service';
class ReportMetaQueryDto extends createZodDto(ReportMetaQuerySchema) {}
class ReportAbcQueryDto extends createZodDto(ReportAbcQuerySchema) {}
@Controller({ path: 'reports' })
export class ReportsController {
constructor(private readonly reports: ReportsService) {}
@Get('meta')
meta(@Query() query: ReportMetaQueryDto): Promise<ReportMetaResponse> {
return this.reports.metaVsRealizado(ReportMetaQuerySchema.parse(query));
}
@Get('carteira')
carteira(): Promise<ReportCarteiraResponse> {
return this.reports.carteira();
}
@Get('abc')
abc(@Query() query: ReportAbcQueryDto): Promise<ReportAbcResponse> {
return this.reports.abcProdutos(ReportAbcQuerySchema.parse(query));
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ReportsController } from './reports.controller';
import { ReportsService } from './reports.service';
@Module({
controllers: [ReportsController],
providers: [ReportsService],
})
export class ReportsModule {}

View File

@@ -0,0 +1,223 @@
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import type {
ReportMetaQuery,
ReportMetaResponse,
ReportCarteiraResponse,
ReportAbcQuery,
ReportAbcResponse,
AbcProduto,
} from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
function d(v: unknown): string {
return v != null ? String(v) : '0';
}
function n(v: unknown): number {
return v != null ? Number(v) : 0;
}
@Injectable()
export class ReportsService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
async metaVsRealizado(query: ReportMetaQuery): Promise<ReportMetaResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const { mes, ano } = query;
interface RealizadoRow {
realizado: unknown;
comissao: unknown;
total_pedidos: unknown;
}
const [realizadoRows, metaRows] = await Promise.all([
prisma.$queryRawUnsafe<RealizadoRow[]>(`
SELECT
COALESCE(SUM(total), 0) AS realizado,
COALESCE(SUM(comissao), 0) AS comissao,
COUNT(id) AS total_pedidos
FROM sar.pedidos
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa IN (2, 4)
AND EXTRACT(MONTH FROM dt_pedido) = ${mes}
AND EXTRACT(YEAR FROM dt_pedido) = ${ano}
`),
prisma.metaRepresentante.findUnique({
where: { codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano, mes } },
}),
]);
const realizado = n(realizadoRows[0]?.realizado);
const comissao = n(realizadoRows[0]?.comissao);
const totalPedidos = n(realizadoRows[0]?.total_pedidos);
const meta = metaRows ? Number(metaRows.metaValor) : 0;
const percentual = meta > 0 ? (realizado / meta) * 100 : 0;
return {
mes,
ano,
realizado: realizado.toFixed(2),
meta: meta.toFixed(2),
percentual: percentual.toFixed(2),
comissaoEstimada: comissao.toFixed(2),
totalPedidos,
};
}
async carteira(): Promise<ReportCarteiraResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
interface CarteiraRow {
id_cliente: unknown;
nome: string | null;
razao: string | null;
ativo: unknown;
ultimo_pedido: string | null;
dias_sem_pedido: unknown;
total_pedidos: unknown;
ticket_medio: unknown;
}
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
WITH ultimos AS (
SELECT
id_cliente,
MAX(dt_pedido) AS ultimo_pedido,
COUNT(id) AS total_pedidos,
CASE WHEN COUNT(id) > 0
THEN SUM(total)::numeric / COUNT(id)
ELSE 0
END AS ticket_medio
FROM sar.pedidos
WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor}
AND situa <> 3
GROUP BY id_cliente
)
SELECT
c.id_cliente,
TRIM(c.nome) AS nome,
TRIM(c.razao) AS razao,
c.ativo,
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio
FROM sar.vw_clientes c
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
WHERE c.id_empresa = ${idEmpresa}
AND c.cod_vendedor = ${codVendedor}
ORDER BY u.ultimo_pedido ASC NULLS FIRST, c.nome ASC
`);
const data = rows.map((r) => ({
idCliente: n(r.id_cliente),
nome: r.nome ?? null,
razao: r.razao ?? null,
ativo: n(r.ativo),
ultimoPedido: r.ultimo_pedido ?? null,
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
totalPedidos: n(r.total_pedidos),
ticketMedio: d(r.ticket_medio),
}));
const inativos30 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 30).length;
const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length;
const semPedido = data.filter((c) => c.totalPedidos === 0).length;
return { data, total: data.length, inativos30, inativos60, semPedido };
}
async abcProdutos(query: ReportAbcQuery): Promise<ReportAbcResponse> {
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0;
const { mes, ano } = query;
const mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : '';
const anoFilter = ano != null ? `AND EXTRACT(YEAR FROM p.dt_pedido) = ${ano}` : '';
interface AbcRow {
cod_produto: string | null;
desc_produto: string | null;
total_faturado: unknown;
qtd_vendida: unknown;
desconto_medio: unknown;
comissao_total: unknown;
}
const rows = await prisma.$queryRawUnsafe<AbcRow[]>(`
SELECT
pi.cod_produto,
pi.desc_produto,
SUM(pi.total) AS total_faturado,
SUM(pi.qtd) AS qtd_vendida,
AVG(pi.desconto_perc) AS desconto_medio,
SUM(pi.comissao) AS comissao_total
FROM sar.pedido_itens pi
JOIN sar.pedidos p ON p.id = pi.id_pedido
WHERE p.id_empresa = ${idEmpresa}
AND p.cod_vendedor = ${codVendedor}
AND p.situa NOT IN (0, 3)
${mesFilter}
${anoFilter}
GROUP BY pi.cod_produto, pi.desc_produto
ORDER BY total_faturado DESC
`);
const totalGeral = rows.reduce((s, r) => s + n(r.total_faturado), 0);
let acumulado = 0;
const data: AbcProduto[] = rows.map((r) => {
const total = n(r.total_faturado);
const participacao = totalGeral > 0 ? (total / totalGeral) * 100 : 0;
acumulado += participacao;
const curva: 'A' | 'B' | 'C' = acumulado <= 80 ? 'A' : acumulado <= 95 ? 'B' : 'C';
return {
codProduto: r.cod_produto ?? null,
descProduto: r.desc_produto ?? '(sem descrição)',
totalFaturado: total.toFixed(2),
qtdVendida: d(r.qtd_vendida),
participacao: participacao.toFixed(2),
curva,
descontoMedio: n(r.desconto_medio).toFixed(2),
comissaoTotal: n(r.comissao_total).toFixed(2),
};
});
const meses = [
'Jan',
'Fev',
'Mar',
'Abr',
'Mai',
'Jun',
'Jul',
'Ago',
'Set',
'Out',
'Nov',
'Dez',
];
const periodo =
mes != null && ano != null
? `${meses[mes - 1]}/${ano}`
: ano != null
? String(ano)
: 'Acumulado';
return { data, totalFaturado: totalGeral.toFixed(2), periodo };
}
}

View File

@@ -1,13 +1,35 @@
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
import {
Button,
Descriptions,
Tag,
Table,
Typography,
Spin,
Alert,
Space,
Divider,
Modal,
Tooltip,
message,
Badge,
} from 'antd';
import { useState } from 'react';
import type { TableColumnsType } from 'antd';
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
import { Link, useNavigate, useParams } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import type { CtrTitulo, NotaFiscal, PedidoSummary, TopProduto } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders';
import {
useClientDetail,
useClientCtr,
useClientCtrList,
useClientNotasFiscais,
useClientOrdersHistory,
useClientTopProdutos,
} from '../../lib/queries/clients';
import { ClientContacts } from '../../components/contacts/ClientContacts';
const { Title } = Typography;
const { Title, Text } = Typography;
const ACTIVITY_COLOR: Record<string, string> = {
active: 'success',
@@ -20,60 +42,182 @@ const ACTIVITY_LABEL: Record<string, string> = {
inactive: 'Inativo',
};
const orderColumns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Status',
dataIndex: 'situa',
width: 140,
render: (s: number) => {
const colorMap: Record<number, string> = {
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
return <Tag color={colorMap[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>;
const SITUA_COLOR: Record<number, string> = {
0: 'default',
1: 'warning',
2: 'processing',
3: 'error',
4: 'success',
};
function orderColumns(withFonte: boolean): TableColumnsType<PedidoSummary> {
const cols: TableColumnsType<PedidoSummary> = [
{
title: 'Nº Pedido',
dataIndex: 'numPedSar',
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Data',
dataIndex: 'dtPedido',
width: 130,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
{
title: 'Status',
dataIndex: 'situa',
width: 130,
render: (s: number) => (
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
),
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right' as const,
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Data',
dataIndex: 'dtPedido',
width: 110,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
},
];
if (withFonte) {
cols.push({
title: 'Origem',
dataIndex: 'fonte',
width: 70,
render: (f: string) => (
<Tag color={f === 'sar' ? 'blue' : 'default'} style={{ fontSize: 11 }}>
{f === 'sar' ? 'SAR' : 'ERP'}
</Tag>
),
});
}
return cols;
}
export function ClientDetailPage() {
const { id } = useParams({ from: '/clientes/$id' });
const idNum = Number(id);
const navigate = useNavigate();
const [addContactOpen, setAddContactOpen] = useState(false);
const [ordersModalOpen, setOrdersModalOpen] = useState(false);
const [produtosModalOpen, setProdutosModalOpen] = useState(false);
const [notasModalOpen, setNotasModalOpen] = useState(false);
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum);
const { data: orders, isLoading: ordersLoading } = useClientOrders(idNum);
const { data: ctr } = useClientCtr(idNum);
const { data: ctrList = [], isLoading: ctrListLoading } = useClientCtrList(idNum, 60);
const { data: orders = [], isLoading: ordersLoading } = useClientOrdersHistory(idNum, 50);
const { data: topProdutos = [], isLoading: produtosLoading } = useClientTopProdutos(idNum, 30);
const { data: notas = [], isLoading: notasLoading } = useClientNotasFiscais(idNum, 30);
const [msgApi, msgCtx] = message.useMessage();
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (clientError || !client)
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
return <Alert type="error" title="Cliente não encontrado." style={{ margin: 24 }} />;
const today: string = new Date().toISOString().slice(0, 10);
const ordersPreview = orders.slice(0, 5);
const produtosPreview = topProdutos.slice(0, 5);
const notasPreview = notas.slice(0, 5);
const ctrPreview = ctrList.slice(0, 8);
const notaColumns: TableColumnsType<NotaFiscal> = [
{
title: 'NF',
dataIndex: 'numero',
width: 90,
render: (v: number, r: NotaFiscal) => `${v}-${r.serie}`,
},
{
title: 'Emissão',
dataIndex: 'dtEmissao',
width: 100,
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
},
{
title: 'Valor',
dataIndex: 'vlNf',
width: 130,
align: 'right' as const,
render: (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Chave NF-e',
dataIndex: 'chaveAcessoNfe',
render: (chave: string | null) =>
chave ? (
<Space size={4}>
<Text
style={{ fontSize: 11, fontFamily: 'monospace', color: '#666' }}
ellipsis={{ tooltip: chave }}
>
{chave}
</Text>
<Tooltip title="Copiar chave">
<Button
type="text"
size="small"
icon={<CopyOutlined />}
onClick={() =>
void navigator.clipboard
.writeText(chave)
.then(() => void msgApi.success('Chave copiada!'))
}
/>
</Tooltip>
</Space>
) : (
'—'
),
},
];
const produtoColumns: TableColumnsType<TopProduto> = [
{
title: 'Produto',
dataIndex: 'descricao',
ellipsis: true,
},
{
title: 'Qtd Total',
dataIndex: 'qtdTotal',
width: 100,
align: 'right' as const,
render: (v: number, r: TopProduto) =>
`${v.toLocaleString('pt-BR', { maximumFractionDigits: 0 })} ${r.unidade ?? ''}`.trim(),
},
{
title: 'Pedidos',
dataIndex: 'numPedidos',
width: 80,
align: 'center' as const,
},
{
title: 'Último Preço',
dataIndex: 'ultimoPreco',
width: 120,
align: 'right' as const,
render: (v: number) =>
v > 0 ? v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—',
},
{
title: 'Última Compra',
dataIndex: 'ultimaCompra',
width: 120,
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
},
];
return (
<div style={{ padding: 24 }}>
<div style={{ padding: 24, maxWidth: '100%', overflowX: 'hidden' }}>
<Space align="center" style={{ marginBottom: 16 }} wrap>
<Link to="/clientes"> Clientes</Link>
<Title level={3} style={{ margin: 0 }}>
@@ -82,6 +226,9 @@ export function ClientDetailPage() {
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
{ACTIVITY_LABEL[client.activityStatus]}
</Tag>
<Button icon={<UserAddOutlined />} onClick={() => setAddContactOpen(true)}>
Novo Contato
</Button>
<Button
type="primary"
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
@@ -121,20 +268,306 @@ export function ClientDetailPage() {
</Descriptions.Item>
</Descriptions>
<ClientContacts idCliente={idNum} />
{/* ── CTR + NF-e lado a lado ── */}
{msgCtx}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
{/* ── Contas a Receber ── */}
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Space size={8}>
<Text strong style={{ fontSize: 14 }}>
Contas a Receber
</Text>
{ctr && ctr.qtdAberto > 0 && (
<Tag color={ctr.qtdVencido > 0 ? 'error' : 'warning'}>
{ctr.qtdAberto} título{ctr.qtdAberto > 1 ? 's' : ''} {' '}
{ctr.totalAberto.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
</Tag>
)}
</Space>
</div>
<Divider style={{ margin: '0 0 10px' }} />
<Table<CtrTitulo>
rowKey="idCtr"
size="small"
pagination={false}
loading={ctrListLoading}
dataSource={ctrPreview}
locale={{ emptyText: 'Nenhum título em aberto.' }}
scroll={{ x: 'max-content', y: ctrList.length > 8 ? 260 : undefined }}
style={{ maxWidth: '100%' }}
columns={[
{
title: 'Título / Parcela',
dataIndex: 'numero',
render: (num: string, r: CtrTitulo) => (
<Space size={4}>
<Text style={{ fontSize: 12 }}>
{r.prefixo}-{num}
</Text>
{r.dtVencimento < today ? (
<Badge
status="error"
text={
<Text type="danger" style={{ fontSize: 11 }}>
{Math.ceil(
(new Date(today).getTime() - new Date(r.dtVencimento).getTime()) /
86400000,
)}
d atraso
</Text>
}
/>
) : (
<Badge
status="warning"
text={
<Text type="warning" style={{ fontSize: 11 }}>
a vencer
</Text>
}
/>
)}
</Space>
),
},
{
title: 'Vencimento',
dataIndex: 'dtVencimento',
width: 100,
render: (v: string) => (
<Text type={v < today ? 'danger' : undefined} style={{ fontSize: 12 }}>
{new Date(v + 'T12:00:00').toLocaleDateString('pt-BR')}
</Text>
),
},
{
title: 'Saldo',
dataIndex: 'saldo',
width: 110,
align: 'right' as const,
render: (v: number) => (
<Text strong style={{ fontSize: 12 }}>
{v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
</Text>
),
},
]}
footer={
ctrList.length > 8
? () => (
<Text type="secondary" style={{ fontSize: 12 }}>
Exibindo 8 de {ctrList.length}
{ctrList.length === 60 ? '+' : ''} títulos total em aberto:{' '}
{ctr?.totalAberto.toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</Text>
)
: undefined
}
/>
</div>
<Divider orientation="left">Últimos Pedidos</Divider>
{/* ── Notas Fiscais ── */}
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Text strong style={{ fontSize: 14 }}>
Notas Fiscais
</Text>
{notas.length > 5 && (
<Button size="small" type="link" onClick={() => setNotasModalOpen(true)}>
Ver todas ({notas.length}
{notas.length === 30 ? '+' : ''})
</Button>
)}
</div>
<Divider style={{ margin: '0 0 10px' }} />
<Table<NotaFiscal>
rowKey="idNf"
size="small"
pagination={false}
loading={notasLoading}
dataSource={notasPreview}
locale={{ emptyText: 'Nenhuma nota fiscal.' }}
style={{ maxWidth: '100%' }}
scroll={{ x: 'max-content' }}
columns={notaColumns}
/>
</div>
</div>
<Modal
title={
<Space>
<span>Notas Fiscais {client.razao ?? client.nome}</span>
<Text type="secondary" style={{ fontSize: 13 }}>
({notas.length}
{notas.length === 30 ? '+' : ''} notas)
</Text>
</Space>
}
open={notasModalOpen}
onCancel={() => setNotasModalOpen(false)}
footer={null}
width={900}
centered
destroyOnHidden
>
<Table<NotaFiscal>
rowKey="idNf"
columns={notaColumns}
dataSource={notas}
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
size="small"
scroll={{ y: 460, x: 'max-content' }}
/>
</Modal>
<ClientContacts
idCliente={idNum}
modalOpen={addContactOpen}
onModalClose={() => setAddContactOpen(false)}
/>
{/* ── Produtos Mais Comprados ── */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Text strong style={{ fontSize: 14 }}>
Produtos Mais Comprados
</Text>
{topProdutos.length > 5 && (
<Button size="small" type="link" onClick={() => setProdutosModalOpen(true)}>
Ver todos ({topProdutos.length}
{topProdutos.length === 30 ? '+' : ''})
</Button>
)}
</div>
<Divider style={{ margin: '0 0 12px' }} />
<Table<TopProduto>
rowKey="idProduto"
columns={produtoColumns}
dataSource={produtosPreview}
loading={produtosLoading}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum produto encontrado.' }}
style={{ marginBottom: 24, maxWidth: '100%' }}
scroll={{ x: 'max-content' }}
/>
<Modal
title={
<Space>
<span>Produtos {client.razao ?? client.nome}</span>
<Text type="secondary" style={{ fontSize: 13 }}>
({topProdutos.length}
{topProdutos.length === 30 ? '+' : ''} produtos)
</Text>
</Space>
}
open={produtosModalOpen}
onCancel={() => setProdutosModalOpen(false)}
footer={null}
width={820}
centered
destroyOnHidden
>
<Table<TopProduto>
rowKey="idProduto"
columns={produtoColumns}
dataSource={topProdutos}
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
size="small"
scroll={{ y: 460 }}
/>
</Modal>
{/* ── Últimos Pedidos ── */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 6,
}}
>
<Text strong style={{ fontSize: 14 }}>
Últimos Pedidos
</Text>
{orders.length > 5 && (
<Button size="small" type="link" onClick={() => setOrdersModalOpen(true)}>
Ver todos ({orders.length}
{orders.length === 50 ? '+' : ''})
</Button>
)}
</div>
<Divider style={{ margin: '0 0 12px' }} />
<Table<PedidoSummary>
rowKey="id"
columns={orderColumns}
dataSource={orders ?? []}
columns={orderColumns(false)}
dataSource={ordersPreview}
loading={ordersLoading}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
style={{ marginBottom: 24, maxWidth: '100%' }}
scroll={{ x: 'max-content' }}
/>
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
{/* ── Modal Ver Todos Pedidos ── */}
<Modal
title={
<Space>
<span>Pedidos {client.razao ?? client.nome}</span>
<Text type="secondary" style={{ fontSize: 13 }}>
({orders.length}
{orders.length === 50 ? '+' : ''} registros)
</Text>
</Space>
}
open={ordersModalOpen}
onCancel={() => setOrdersModalOpen(false)}
footer={null}
width={780}
centered
destroyOnHidden
>
<Table<PedidoSummary>
rowKey="id"
columns={orderColumns(true)}
dataSource={orders}
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
size="small"
scroll={{ y: 460 }}
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
/>
</Modal>
</div>
);
}

View File

@@ -0,0 +1,557 @@
import { useState } from 'react';
import {
Badge,
Card,
Col,
DatePicker,
Progress,
Row,
Select,
Space,
Spin,
Statistic,
Table,
Tag,
Tabs,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { AlertOutlined, BarChartOutlined, RiseOutlined, TeamOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import type { CarteiraCliente, AbcProduto } from '@sar/api-interface';
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports';
const { Title, Text } = Typography;
const { Option } = Select;
function fmt(v: number | string): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDate(v: string | null | undefined): string {
if (!v) return '—';
return new Date(v).toLocaleDateString('pt-BR');
}
// ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
function TabMeta() {
const now = dayjs();
const [mes, setMes] = useState(now.month() + 1);
const [ano, setAno] = useState(now.year());
const { data, isLoading } = useReportMeta({ mes, ano });
const realizado = Number(data?.realizado ?? 0);
const meta = Number(data?.meta ?? 0);
const percentual = Number(data?.percentual ?? 0);
const comissao = Number(data?.comissaoEstimada ?? 0);
const bateuMeta = meta > 0 && realizado >= meta;
const progressColor = percentual >= 100 ? '#52c41a' : percentual >= 70 ? '#faad14' : '#003B8E';
const meses = [
'Jan',
'Fev',
'Mar',
'Abr',
'Mai',
'Jun',
'Jul',
'Ago',
'Set',
'Out',
'Nov',
'Dez',
];
return (
<div>
<Space style={{ marginBottom: 20 }}>
<Select value={mes} onChange={setMes} style={{ width: 90 }}>
{meses.map((m, i) => (
<Option key={i + 1} value={i + 1}>
{m}
</Option>
))}
</Select>
<DatePicker
picker="year"
value={dayjs().year(ano)}
onChange={(d) => d && setAno(d.year())}
style={{ width: 100 }}
allowClear={false}
/>
</Space>
{isLoading ? (
<div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" />
</div>
) : (
<Space direction="vertical" size={20} style={{ width: '100%' }}>
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: '24px 28px' } }}
>
<Row gutter={[32, 24]} align="middle">
<Col xs={24} md={12}>
<Text
type="secondary"
style={{ fontSize: 12, textTransform: 'uppercase', letterSpacing: '0.08em' }}
>
Realizado {meses[mes - 1]}/{ano}
</Text>
<div style={{ marginTop: 4 }}>
<Text
strong
style={{
fontSize: 32,
color: bateuMeta ? '#52c41a' : '#003B8E',
lineHeight: 1,
}}
>
{fmt(realizado)}
</Text>
{bateuMeta && (
<Tag
color="success"
style={{ marginLeft: 10, fontWeight: 700, borderRadius: 20 }}
>
Meta batida!
</Tag>
)}
</div>
<Text type="secondary" style={{ fontSize: 13 }}>
de {fmt(meta)} de meta
</Text>
<Progress
percent={Math.min(percentual, 100)}
style={{ marginTop: 12 }}
strokeColor={progressColor}
format={() => `${percentual.toFixed(1)}%`}
/>
</Col>
<Col xs={24} md={12}>
<Row gutter={[16, 16]}>
<Col span={12}>
<Statistic
title="Pedidos Transmitidos"
value={data?.totalPedidos ?? 0}
valueStyle={{ color: '#003B8E' }}
/>
</Col>
<Col span={12}>
<Statistic
title="Comissão Estimada"
value={fmt(comissao)}
valueStyle={{ color: '#52c41a', fontSize: 20 }}
/>
</Col>
<Col span={12}>
<Statistic
title="Falta para Meta"
value={meta > realizado ? fmt(meta - realizado) : 'Atingida'}
valueStyle={{ color: meta > realizado ? '#faad14' : '#52c41a', fontSize: 18 }}
/>
</Col>
<Col span={12}>
<Statistic
title="% Atingido"
value={`${percentual.toFixed(1)}%`}
valueStyle={{ color: progressColor, fontSize: 18 }}
/>
</Col>
</Row>
</Col>
</Row>
</Card>
{meta === 0 && (
<Card
style={{ borderRadius: 8, border: '1px solid #fde68a', background: '#fffbeb' }}
styles={{ body: { padding: '12px 16px' } }}
>
<Space>
<AlertOutlined style={{ color: '#d97706' }} />
<Text style={{ color: '#92400e' }}>
Nenhuma meta cadastrada para {meses[mes - 1]}/{ano}. Fale com seu supervisor.
</Text>
</Space>
</Card>
)}
</Space>
)}
</div>
);
}
// ─── Aba 2: Carteira de Clientes ─────────────────────────────────────────────
type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
function TabCarteira() {
const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
const { data, isLoading } = useReportCarteira();
const clientes = data?.data ?? [];
const filtered = clientes.filter((c: CarteiraCliente) => {
if (filtro === 'inativos30') return c.diasSemPedido != null && c.diasSemPedido >= 30;
if (filtro === 'inativos60') return c.diasSemPedido != null && c.diasSemPedido >= 60;
if (filtro === 'semPedido') return c.totalPedidos === 0;
return true;
});
function statusBadge(c: CarteiraCliente) {
if (c.totalPedidos === 0)
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedidos</Text>} />;
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
return (
<Badge
color="#ef4444"
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
/>
);
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
return (
<Badge
color="#f59e0b"
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Inativo 30d+</Text>}
/>
);
return (
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
);
}
const columns: TableColumnsType<CarteiraCliente> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: CarteiraCliente) => (
<Space direction="vertical" size={0}>
<Text strong style={{ fontSize: 13 }}>
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
</Text>
{c.razao && c.nome && (
<Text type="secondary" style={{ fontSize: 11 }}>
{c.nome}
</Text>
)}
</Space>
),
},
{
title: 'Status',
key: 'status',
width: 140,
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
},
{
title: 'Último Pedido',
dataIndex: 'ultimoPedido',
width: 130,
render: (v: string | null) => (
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
),
},
{
title: 'Dias Sem Pedido',
dataIndex: 'diasSemPedido',
width: 140,
align: 'right' as const,
render: (v: number | null) =>
v != null ? (
<Text
strong
className="tabular-nums"
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
>
{v}d
</Text>
) : (
<Text type="secondary"></Text>
),
},
{
title: 'Pedidos',
dataIndex: 'totalPedidos',
width: 90,
align: 'right' as const,
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
},
{
title: 'Ticket Médio',
dataIndex: 'ticketMedio',
width: 130,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
),
},
];
return (
<div>
{data && (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{[
{ key: 'todos', label: 'Total', value: data.total, color: '#003B8E' },
{ key: 'inativos30', label: 'Inativos 30d+', value: data.inativos30, color: '#f59e0b' },
{ key: 'inativos60', label: 'Inativos 60d+', value: data.inativos60, color: '#ef4444' },
{ key: 'semPedido', label: 'Sem Pedido', value: data.semPedido, color: '#94A3B8' },
].map((item) => (
<Col key={item.key} xs={12} md={6}>
<Card
hoverable
onClick={() => setFiltro(item.key as FiltroCarteira)}
style={{
borderRadius: 8,
border: filtro === item.key ? `2px solid ${item.color}` : '1px solid #EBF0F5',
cursor: 'pointer',
}}
styles={{ body: { padding: '12px 16px' } }}
>
<Statistic
title={item.label}
value={item.value}
valueStyle={{ color: item.color, fontSize: 24 }}
/>
</Card>
</Col>
))}
</Row>
)}
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<CarteiraCliente>
rowKey="idCliente"
columns={columns}
dataSource={filtered}
loading={isLoading}
size="middle"
pagination={{ pageSize: 15, showSizeChanger: false }}
scroll={{ x: 700 }}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
</div>
);
}
// ─── Aba 3: Curva ABC ─────────────────────────────────────────────────────────
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
function TabAbc() {
const now = dayjs();
const [mes, setMes] = useState<number | undefined>(now.month() + 1);
const [ano, setAno] = useState<number | undefined>(now.year());
const { data, isLoading } = useReportAbc({ mes, ano });
const meses = [
'Jan',
'Fev',
'Mar',
'Abr',
'Mai',
'Jun',
'Jul',
'Ago',
'Set',
'Out',
'Nov',
'Dez',
];
const columns: TableColumnsType<AbcProduto> = [
{
title: 'Curva',
dataIndex: 'curva',
width: 70,
align: 'center' as const,
render: (v: string) => (
<Tag
color={CURVA_COLOR[v]}
style={{ fontWeight: 800, borderRadius: 20, fontSize: 13, padding: '0 10px' }}
>
{v}
</Tag>
),
},
{
title: 'Produto',
key: 'produto',
render: (_: unknown, p: AbcProduto) => (
<Space direction="vertical" size={0}>
{p.codProduto && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{p.codProduto}</Text>}
<Text style={{ fontSize: 13, fontWeight: 500 }}>{p.descProduto}</Text>
</Space>
),
},
{
title: 'Total Faturado',
dataIndex: 'totalFaturado',
width: 150,
align: 'right' as const,
render: (v: string, p: AbcProduto) => (
<Space direction="vertical" size={0} style={{ alignItems: 'flex-end' }}>
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
<Text type="secondary" style={{ fontSize: 11 }}>
{Number(p.participacao).toFixed(1)}% do total
</Text>
</Space>
),
},
{
title: 'Qtd. Vendida',
dataIndex: 'qtdVendida',
width: 110,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums">
{Number(v).toLocaleString('pt-BR', { maximumFractionDigits: 0 })}
</Text>
),
},
{
title: 'Desc. Médio',
dataIndex: 'descontoMedio',
width: 110,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums" style={{ color: Number(v) > 5 ? '#f59e0b' : '#475569' }}>
{Number(v).toFixed(1)}%
</Text>
),
},
{
title: 'Comissão',
dataIndex: 'comissaoTotal',
width: 120,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums" style={{ color: '#22c55e', fontWeight: 600 }}>
{fmt(v)}
</Text>
),
},
];
return (
<div>
<Space style={{ marginBottom: 20 }}>
<Select
value={mes}
onChange={(v) => setMes(v)}
allowClear
placeholder="Mês"
style={{ width: 100 }}
onClear={() => setMes(undefined)}
>
{meses.map((m, i) => (
<Option key={i + 1} value={i + 1}>
{m}
</Option>
))}
</Select>
<DatePicker
picker="year"
value={ano ? dayjs().year(ano) : null}
onChange={(d) => setAno(d?.year())}
style={{ width: 100 }}
placeholder="Ano"
allowClear
/>
{data && (
<Text type="secondary" style={{ fontSize: 13 }}>
Período: <strong>{data.periodo}</strong> Total:{' '}
<strong>{fmt(data.totalFaturado)}</strong>
</Text>
)}
</Space>
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<AbcProduto>
rowKey={(r) => r.codProduto ?? r.descProduto}
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
size="middle"
pagination={{ pageSize: 20, showSizeChanger: false }}
scroll={{ x: 800 }}
rowClassName={(r) => (r.curva === 'A' ? 'row-curva-a' : '')}
style={{ borderRadius: 10, overflow: 'hidden' }}
/>
</Card>
<style>{`
.row-curva-a td { background: #f0f5ff !important; }
`}</style>
</div>
);
}
// ─── RelatoriosPage ───────────────────────────────────────────────────────────
export function RelatoriosPage() {
const tabs = [
{
key: 'meta',
label: (
<Space>
<RiseOutlined />
Desempenho vs. Meta
</Space>
),
children: <TabMeta />,
},
{
key: 'carteira',
label: (
<Space>
<TeamOutlined />
Carteira de Clientes
</Space>
),
children: <TabCarteira />,
},
{
key: 'abc',
label: (
<Space>
<BarChartOutlined />
Curva ABC
</Space>
),
children: <TabAbc />,
},
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
<div style={{ marginBottom: 24 }}>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
Relatórios
</Title>
<Text type="secondary" style={{ fontSize: 14 }}>
Análise de desempenho, carteira e mix de produtos.
</Text>
</div>
<Tabs defaultActiveKey="meta" items={tabs} size="large" />
</div>
);
}

View File

@@ -1,4 +1,18 @@
import { Card, Col, Flex, Progress, Row, Skeleton, Space, Table, Tag, Typography } from 'antd';
import { useState } from 'react';
import {
Button,
Card,
Col,
Flex,
Progress,
Row,
Segmented,
Skeleton,
Space,
Table,
Tag,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
@@ -7,8 +21,9 @@ import {
faCircleExclamation,
faClipboardList,
} from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router';
import type { MetaItem, PedidoSummary } from '@sar/api-interface';
import { WhatsAppOutlined } from '@ant-design/icons';
import { Link, useNavigate } from '@tanstack/react-router';
import type { MetaItem, ClienteNaoPositivado, PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth';
@@ -34,17 +49,19 @@ function greeting(): string {
}
function today(): string {
return new Date().toLocaleDateString('pt-BR', {
day: 'numeric',
month: 'long',
});
return new Date().toLocaleDateString('pt-BR', { day: 'numeric', month: 'long' });
}
function num(v: number, dec = 0): string {
return v.toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec });
}
// Célula "realizado / meta" — realizado em destaque (verde se bateu), meta abaixo.
function waLink(whatsapp: string): string {
const digits = whatsapp.replace(/\D/g, '');
const number = digits.startsWith('55') ? digits : `55${digits}`;
return `https://wa.me/${number}`;
}
function MetaCell({
real,
meta,
@@ -131,6 +148,170 @@ const metaColumns: TableColumnsType<MetaItem> = [
},
];
// ─── Não positivados ──────────────────────────────────────────────────────────
type OrdemNP = 'longe' | 'recentes';
function NaoPositivados({ clientes, total }: { clientes: ClienteNaoPositivado[]; total: number }) {
const navigate = useNavigate();
const [ordem, setOrdem] = useState<OrdemNP>('longe');
const [expandido, setExpandido] = useState(false);
const PAGE = 10;
const sorted = [...clientes].sort((a, b) => {
if (ordem === 'longe') {
return (b.diasSemPedido ?? 999) - (a.diasSemPedido ?? 999);
}
// 'recentes': compraram antes primeiro, ordenados por último pedido desc
if (a.comprouAntes && !b.comprouAntes) return -1;
if (!a.comprouAntes && b.comprouAntes) return 1;
const da = a.ultimoPedido ?? '';
const db = b.ultimoPedido ?? '';
return db.localeCompare(da);
});
const visible = expandido ? sorted : sorted.slice(0, PAGE);
const restante = total - PAGE;
const columns: TableColumnsType<ClienteNaoPositivado> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: ClienteNaoPositivado) => (
<Text
strong
style={{ cursor: 'pointer', color: 'var(--jcs-blue)' }}
onClick={() =>
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
}
>
{c.razao ?? c.nome}
</Text>
),
},
{
title: 'Último pedido',
key: 'ultimo',
width: 160,
render: (_: unknown, c: ClienteNaoPositivado) => {
if (!c.comprouAntes) {
return (
<Tag color="default" style={{ borderRadius: 20 }}>
nunca comprou
</Tag>
);
}
const dias = c.diasSemPedido ?? 999;
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
return (
<Space direction="vertical" size={0}>
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
{dias}d sem pedido
</Tag>
{c.ultimoPedido && (
<Text type="secondary" style={{ fontSize: 11 }}>
{new Date(c.ultimoPedido).toLocaleDateString('pt-BR')}
</Text>
)}
</Space>
);
},
},
{
title: '',
key: 'acoes',
width: 96,
render: (_: unknown, c: ClienteNaoPositivado) => (
<Space size={6}>
<Button
size="small"
style={{ borderRadius: 6 }}
onClick={() =>
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
}
>
Ficha
</Button>
{c.whatsapp && (
<Button
size="small"
type="primary"
style={{ borderRadius: 6, background: '#25d366', borderColor: '#25d366' }}
icon={<WhatsAppOutlined />}
href={waLink(c.whatsapp)}
target="_blank"
rel="noopener noreferrer"
/>
)}
</Space>
),
},
];
return (
<Card
title={
<Space>
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
Não positivados no mês
{total > 0 && (
<Tag color="orange" style={{ borderRadius: 20, fontWeight: 700 }}>
{total}
</Tag>
)}
</Space>
}
extra={
<Segmented<OrdemNP>
size="small"
value={ordem}
onChange={setOrdem}
options={[
{ label: 'Mais longe', value: 'longe' },
{ label: 'Compraram antes', value: 'recentes' },
]}
/>
}
>
{clientes.length === 0 ? (
<Text type="secondary">Todos os clientes positivados este mês. Ótimo trabalho!</Text>
) : (
<Flex vertical gap={12}>
<Table<ClienteNaoPositivado>
rowKey="idCliente"
columns={columns}
dataSource={visible}
size="small"
pagination={false}
showHeader={false}
style={{ borderRadius: 8 }}
/>
{!expandido && total > PAGE && (
<Button
type="link"
style={{ alignSelf: 'flex-start', padding: 0 }}
onClick={() => setExpandido(true)}
>
Ver mais {restante} cliente{restante !== 1 ? 's' : ''}
</Button>
)}
{expandido && total > PAGE && (
<Button
type="link"
style={{ alignSelf: 'flex-start', padding: 0 }}
onClick={() => setExpandido(false)}
>
Recolher
</Button>
)}
</Flex>
)}
</Card>
);
}
// ─── RepPainel ────────────────────────────────────────────────────────────────
export function RepPainel() {
const { data, isLoading } = useRepDashboard();
const { data: user } = useCurrentUser();
@@ -140,13 +321,10 @@ export function RepPainel() {
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 2 }} />
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Col xs={24} md={14}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Col xs={24} md={10}>
<Skeleton active />
</Col>
</Row>
@@ -157,10 +335,10 @@ export function RepPainel() {
const {
meta,
metasPorGrupo = [],
comissao,
pedidosMes,
pedidosRecentes = [],
clientesInativos = [],
naoPositivados = [],
totalNaoPositivados = 0,
syncedAt,
} = data;
@@ -173,21 +351,21 @@ export function RepPainel() {
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()}
{clientesInativos.length > 0 && (
{totalNaoPositivados > 0 && (
<>
{' '}
·{' '}
<span style={{ color: 'var(--orange)' }}>
{clientesInativos.length} clientes inativos
{totalNaoPositivados} clientes sem pedido no s
</span>
</>
)}
</Text>
</Flex>
{/* Linha 1 — Meta + KPIs */}
{/* Linha 1 — Meta + Pedidos do mês */}
<Row gutter={[24, 24]}>
<Col xs={24} md={12}>
<Col xs={24} md={14}>
<Card style={{ height: '100%' }}>
<Flex vertical gap={16}>
<Flex justify="space-between" align="flex-start">
@@ -228,8 +406,8 @@ export function RepPainel() {
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Col xs={24} md={10}>
<Card style={{ height: '100%' }}>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS NO MÊS
@@ -238,32 +416,14 @@ export function RepPainel() {
{pedidosMes}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
<FontAwesomeIcon icon={faArrowTrendUp} /> últimos 30 dias
<FontAwesomeIcon icon={faArrowTrendUp} /> mês corrente
</Text>
</Space>
</Card>
</Col>
<Col xs={12} md={6}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
COMISSÃO ACUMULADA
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(comissao.total)}
</Title>
{comissao.flex > 0 && (
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
FLEX: {fmt(comissao.flex)}
</Text>
)}
</Space>
</Card>
</Col>
</Row>
{/* Metas por Grupo — acompanhamento multi-medida do mês */}
{/* Metas por Grupo */}
{metasPorGrupo.length > 0 && (
<Card
title={
@@ -339,67 +499,13 @@ export function RepPainel() {
</Card>
)}
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
{/* Linha 2 — Não positivados + Pedidos recentes */}
<Row gutter={[24, 24]}>
<Col xs={24} lg={12}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
Clientes esfriando
</Space>
}
extra={
clientesInativos.length > 0 ? (
<Text type="secondary">{clientesInativos.length} clientes</Text>
) : null
}
>
{clientesInativos.length === 0 ? (
<Text type="secondary">Nenhum cliente inativo. Ótimo trabalho!</Text>
) : (
<Flex vertical gap={12}>
{clientesInativos.map((c) => (
<Flex
key={c.idCliente}
justify="space-between"
align="center"
style={{
padding: 'var(--space-sm) var(--space-md)',
borderRadius: 12,
background: c.diasSemCompra > 60 ? '#fff7e6' : 'var(--bg-surface-alt)',
}}
>
<Space orientation="vertical" size={0}>
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
<Text strong>{c.nome}</Text>
</Link>
{c.ultimaCompraValor && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Última compra:{' '}
<span className="tabular-nums">
{Number(c.ultimaCompraValor).toLocaleString('pt-BR', {
style: 'currency',
currency: 'BRL',
})}
</span>
</Text>
)}
</Space>
<Tag
color={c.diasSemCompra > 60 ? 'orange' : 'default'}
className="tabular-nums"
>
{c.diasSemCompra >= 999 ? 'nunca comprou' : `${c.diasSemCompra}d`}
</Tag>
</Flex>
))}
</Flex>
)}
</Card>
<Col xs={24} lg={14}>
<NaoPositivados clientes={naoPositivados} total={totalNaoPositivados} />
</Col>
<Col xs={24} lg={12}>
<Col xs={24} lg={10}>
<Card
title={
<Space>

View File

@@ -1,20 +1,20 @@
import { useState } from 'react';
import {
App,
Button,
Card,
Col,
DatePicker,
Divider,
Form,
Input,
Modal,
Row,
Space,
Table,
Spin,
Tag,
Tooltip,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import { MailOutlined, PhoneOutlined, PlusOutlined, WhatsAppOutlined } from '@ant-design/icons';
import { MailOutlined, PhoneOutlined, WhatsAppOutlined } from '@ant-design/icons';
import type { Contato } from '@sar/api-interface';
import { useClientContacts, useCreateContato } from '../../lib/queries/clients';
@@ -26,7 +26,7 @@ function ContactActions({ contato }: { contato: Contato }) {
const mail = contato.email?.trim();
return (
<Space size={6}>
<Space size={4} wrap>
{wa && (
<Tooltip title={`WhatsApp: ${wa}`}>
<a href={`https://wa.me/55${wa.replace(/\D/g, '')}`} target="_blank" rel="noreferrer">
@@ -51,7 +51,7 @@ function ContactActions({ contato }: { contato: Contato }) {
<Tooltip title={mail}>
<a href={`mailto:${mail}`}>
<Tag icon={<MailOutlined />} color="default" style={{ margin: 0 }}>
{mail.length > 24 ? mail.slice(0, 22) + '…' : mail}
{mail.length > 22 ? mail.slice(0, 20) + '…' : mail}
</Tag>
</a>
</Tooltip>
@@ -60,45 +60,18 @@ function ContactActions({ contato }: { contato: Contato }) {
);
}
const columns: TableColumnsType<Contato> = [
{
title: 'Nome',
dataIndex: 'nome',
render: (nome: string, r: Contato) => (
<div>
<Text strong style={{ fontSize: 13 }}>
{nome}
</Text>
{(r.cargo || r.departamento) && (
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
{[r.cargo, r.departamento].filter(Boolean).join(' · ')}
</Text>
</div>
)}
</div>
),
},
{
title: 'Contato',
key: 'contato',
render: (_: unknown, r: Contato) => <ContactActions contato={r} />,
},
{
title: 'Aniversário',
dataIndex: 'dtAniversario',
width: 120,
render: (v: string | null) => (v ? new Date(v + 'T12:00:00').toLocaleDateString('pt-BR') : '—'),
},
];
interface Props {
idCliente: number;
modalOpen?: boolean;
onModalClose?: () => void;
}
export function ClientContacts({ idCliente }: Props) {
export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: Props) {
const { message } = App.useApp();
const [open, setOpen] = useState(false);
const open = modalOpen;
const setOpen = (v: boolean) => {
if (!v) onModalClose?.();
};
const [form] = Form.useForm();
const { data: contacts = [], isLoading } = useClientContacts(idCliente);
@@ -134,38 +107,53 @@ export function ClientContacts({ idCliente }: Props) {
return (
<>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 12,
}}
>
<Divider titlePlacement="left" style={{ margin: 0, flex: 1 }}>
Contatos
</Divider>
<Button
type="default"
size="small"
icon={<PlusOutlined />}
onClick={() => setOpen(true)}
style={{ marginLeft: 16, borderRadius: 6 }}
>
Adicionar
</Button>
</div>
<Divider titlePlacement="left" style={{ marginBottom: 12 }}>
Contatos
</Divider>
<Table<Contato>
rowKey="idContato"
columns={columns}
dataSource={contacts}
loading={isLoading}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum contato cadastrado.' }}
style={{ marginBottom: 24 }}
/>
{isLoading ? (
<Spin style={{ display: 'block', margin: '16px auto' }} />
) : contacts.length === 0 ? (
<Typography.Text
type="secondary"
style={{ display: 'block', marginBottom: 24, textAlign: 'center' }}
>
Nenhum contato cadastrado.
</Typography.Text>
) : (
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: 12,
marginBottom: 24,
}}
>
{contacts.map((c: Contato) => (
<Card
key={c.idContato}
size="small"
styles={{ body: { padding: '10px 12px' } }}
style={{ borderRadius: 8 }}
>
<Text strong style={{ fontSize: 13, display: 'block', marginBottom: 2 }}>
{c.nome}
</Text>
{(c.cargo || c.departamento) && (
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 6 }}>
{[c.cargo, c.departamento].filter(Boolean).join(' · ')}
</Text>
)}
<ContactActions contato={c} />
{c.dtAniversario && (
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>
Aniversário: {new Date(c.dtAniversario + 'T12:00:00').toLocaleDateString('pt-BR')}
</Text>
)}
</Card>
))}
</div>
)}
<Modal
title="Novo Contato"
@@ -178,7 +166,7 @@ export function ClientContacts({ idCliente }: Props) {
confirmLoading={createMutation.isPending}
okText="Cadastrar"
cancelText="Cancelar"
width={560}
width={580}
destroyOnHidden
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }} requiredMark="optional">
@@ -190,40 +178,63 @@ export function ClientContacts({ idCliente }: Props) {
<Input placeholder="Nome do contato" maxLength={100} />
</Form.Item>
<Form.Item name="cargo" label="Cargo">
<Input placeholder="Comprador, Gerente..." maxLength={100} />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="cargo" label="Cargo">
<Input placeholder="Comprador, Gerente..." maxLength={100} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="departamento" label="Departamento">
<Input placeholder="Compras, Financeiro..." maxLength={100} />
</Form.Item>
</Col>
</Row>
<Form.Item name="departamento" label="Departamento">
<Input placeholder="Compras, Financeiro..." maxLength={100} />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="whatsapp" label="WhatsApp">
<Input
placeholder="48999990000"
maxLength={20}
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="celular" label="Celular">
<Input placeholder="48999990000" maxLength={20} />
</Form.Item>
</Col>
</Row>
<Form.Item name="whatsapp" label="WhatsApp">
<Input
placeholder="48999990000"
maxLength={20}
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
/>
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="telefone" label="Telefone fixo">
<Input placeholder="4833330000" maxLength={20} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="email" label="E-mail">
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
</Form.Item>
</Col>
</Row>
<Form.Item name="celular" label="Celular">
<Input placeholder="48999990000" maxLength={20} />
</Form.Item>
<Form.Item name="telefone" label="Telefone fixo">
<Input placeholder="4833330000" maxLength={20} />
</Form.Item>
<Form.Item name="email" label="E-mail">
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
</Form.Item>
<Form.Item name="dtAniversario" label="Aniversário">
<DatePicker format="DD/MM/YYYY" style={{ width: '100%' }} placeholder="dd/mm/aaaa" />
</Form.Item>
<Row gutter={12}>
<Col span={12}>
<Form.Item name="dtAniversario" label="Aniversário">
<DatePicker
format="DD/MM/YYYY"
style={{ width: '100%' }}
placeholder="dd/mm/aaaa"
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="anotacoes" label="Anotações">
<Input.TextArea rows={3} placeholder="Observações sobre o contato..." />
<Input.TextArea rows={2} placeholder="Observações sobre o contato..." />
</Form.Item>
</Form>
</Modal>

View File

@@ -23,7 +23,7 @@ export function AppShell({ children }: AppShellProps) {
useOfflineSync();
return (
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
{!isOnline && (
<Alert
type="warning"
@@ -35,14 +35,15 @@ export function AppShell({ children }: AppShellProps) {
/>
)}
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
<Flex flex={1}>
<Flex flex={1} style={{ overflow: 'hidden' }}>
<Sidebar />
<main
style={{
flex: 1,
padding: 'var(--space-xl)',
overflow: 'auto',
maxWidth: '100%',
overflowY: 'auto',
overflowX: 'hidden',
minWidth: 0,
}}
>
{children}

View File

@@ -6,11 +6,8 @@ import {
faClipboardList,
faClipboardCheck,
faFunnelDollar,
faCalendarDays,
faUsers,
faBoxesStacked,
faGear,
faPercent,
faFileInvoiceDollar,
} from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface';
@@ -28,11 +25,6 @@ export function Sidebar() {
icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />,
label: 'Painel',
},
{
key: '/agenda',
icon: <FontAwesomeIcon icon={faCalendarDays} fixedWidth />,
label: 'Agenda e Rotas',
},
{
key: '/clientes',
icon: <FontAwesomeIcon icon={faUsers} fixedWidth />,
@@ -58,24 +50,11 @@ export function Sidebar() {
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Consulta ERP',
},
{
key: '/comissao',
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,
label: 'Comissão / FLEX',
},
{
type: 'divider',
},
{
key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
label: 'Relatórios',
},
{
key: '/configuracoes',
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
label: 'Configurações',
},
];
return (

View File

@@ -5,14 +5,24 @@ import {
ClientNovoResultSchema,
ContatoResultSchema,
ContatoSchema,
CtrSummarySchema,
CtrTituloSchema,
NotaFiscalSchema,
PedidoSummarySchema,
TopProdutoSchema,
type ClientDetail,
type ClientListQuery,
type ClientListResponse,
type ClientNovoResult,
type Contato,
type ContatoResult,
type CtrSummary,
type CtrTitulo,
type CreateClientNovo,
type CreateContato,
type NotaFiscal,
type PedidoSummary,
type TopProduto,
} from '@sar/api-interface';
import { z } from 'zod';
import { apiFetch } from '../api-client';
@@ -52,6 +62,61 @@ export function useClientDetail(id: number | string | undefined) {
});
}
export function useClientTopProdutos(idCliente: number | undefined, limit = 30) {
return useQuery<TopProduto[]>({
queryKey: ['clients', 'top-produtos', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/top-produtos?limit=${limit}`);
return z.array(TopProdutoSchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientOrdersHistory(idCliente: number | undefined, limit = 50) {
return useQuery<PedidoSummary[]>({
queryKey: ['clients', 'orders-history', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/orders-history?limit=${limit}`);
return z.array(PedidoSummarySchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientCtrList(idCliente: number | undefined, limit = 60) {
return useQuery<CtrTitulo[]>({
queryKey: ['clients', 'ctr-list', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/ctr-list?limit=${limit}`);
return z.array(CtrTituloSchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientNotasFiscais(idCliente: number | undefined, limit = 30) {
return useQuery<NotaFiscal[]>({
queryKey: ['clients', 'notas', idCliente, limit],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/notas?limit=${limit}`);
return z.array(NotaFiscalSchema).parse(res);
},
enabled: !!idCliente,
});
}
export function useClientCtr(idCliente: number | undefined) {
return useQuery<CtrSummary>({
queryKey: ['clients', 'ctr', idCliente],
queryFn: async () => {
const res = await apiFetch(`/clients/${idCliente}/ctr`);
return CtrSummarySchema.parse(res);
},
enabled: !!idCliente,
});
}
export function useClientContacts(idCliente: number | undefined) {
return useQuery<Contato[]>({
queryKey: ['clients', 'contacts', idCliente],

View File

@@ -0,0 +1,37 @@
import { useQuery } from '@tanstack/react-query';
import {
ReportMetaResponseSchema,
ReportCarteiraResponseSchema,
ReportAbcResponseSchema,
type ReportMetaQuery,
type ReportAbcQuery,
type ReportMetaResponse,
type ReportCarteiraResponse,
type ReportAbcResponse,
} from '@sar/api-interface';
import { apiFetch } from '../api-client';
export function useReportMeta(params: ReportMetaQuery) {
const qs = new URLSearchParams({ mes: String(params.mes), ano: String(params.ano) });
return useQuery<ReportMetaResponse>({
queryKey: ['reports', 'meta', params],
queryFn: async () => ReportMetaResponseSchema.parse(await apiFetch(`/reports/meta?${qs}`)),
});
}
export function useReportCarteira() {
return useQuery<ReportCarteiraResponse>({
queryKey: ['reports', 'carteira'],
queryFn: async () => ReportCarteiraResponseSchema.parse(await apiFetch('/reports/carteira')),
});
}
export function useReportAbc(params: ReportAbcQuery) {
const qs = new URLSearchParams();
if (params.mes != null) qs.set('mes', String(params.mes));
if (params.ano != null) qs.set('ano', String(params.ano));
return useQuery<ReportAbcResponse>({
queryKey: ['reports', 'abc', params],
queryFn: async () => ReportAbcResponseSchema.parse(await apiFetch(`/reports/abc?${qs}`)),
});
}

View File

@@ -17,6 +17,7 @@ import { NewClientPage } from '../cockpits/rep/NewClientPage';
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { authStore } from './auth-store';
@@ -125,6 +126,12 @@ const pedidosErpRoute = createRoute({
component: OrdersErpPage,
});
const relatoriosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/relatorios',
component: RelatoriosPage,
});
const aprovacoes = createRoute({
getParentRoute: () => rootRoute,
path: '/aprovacoes',
@@ -143,6 +150,7 @@ const routeTree = rootRoute.addChildren([
pedidoPrintRoute,
catalogoRoute,
pedidosErpRoute,
relatoriosRoute,
aprovacoes,
]);

View File

@@ -0,0 +1,265 @@
# Plano de Migração — Modelo de Dados Canônico SAR
**Data:** 2026-06-22
**Autor:** Julian (Product Owner SAR)
**Status:** Proposta para avaliação
---
## 1. Contexto e Problema
### Situação atual
O SAR foi iniciado consultando diretamente as **views do ERP JCS** (`vw_clientes`, `vw_pedidos_erp`, `vw_produtos`, etc.) que existem dentro do banco de dados do cliente (`libreplast@192.168.0.43`). Isso funcionou para o cliente piloto, mas cria uma dependência estrutural que inviabiliza a expansão do produto.
### Problema identificado
| Sintoma | Consequência |
|---|---|
| SAR acessa views do ERP via SQL raw | Cada novo ERP exige reescrever queries |
| Schema `sar` vive *dentro* do banco ERP | Sem isolamento; SAR depende de acesso ao servidor do cliente |
| Views são específicas do ERP JCS | ERP diferente = produto diferente |
| Sem camada de abstração | Impossível oferecer o SAR como SaaS multi-tenant real |
### Diagnóstico técnico (levantamento 2026-06-22)
```
Módulos 100% acoplados ao ERP: auth, catalog, clients
Módulos parcialmente acoplados: orders (leitura), dashboard
Módulos já independentes: notifications, workspace
Views ERP consumidas: 11 views + 1 tabela direta
Tabelas próprias SAR existentes: 6 models Prisma
```
---
## 2. Solução Proposta
### Modelo Canônico + Connectors
```
┌─────────────────────────────────────────────────────┐
│ APP SAR │
│ (frontend + API NestJS) │
│ Lê APENAS tabelas próprias do SAR │
└──────────────────────┬──────────────────────────────┘
│ Prisma ORM
┌─────────────────────────────────────────────────────┐
│ Banco de Dados SAR │
│ (PostgreSQL isolado por workspace) │
│ tabelas canônicas: clientes, produtos, pedidos... │
└──────────┬──────────────────────┬───────────────────┘
│ │
▼ ▼
┌──────────────────┐ ┌──────────────────────────────┐
│ Connector │ │ Connector │
│ ERP JCS │ │ ERP X (futuro) │
│ (PostgreSQL) │ │ (REST API / CSV / outro) │
└──────────────────┘ └──────────────────────────────┘
```
**Princípio:** O ERP é uma **fonte de dados**, não o sistema de registro do SAR. Cada cliente tem um connector específico para seu ERP. O SAR opera exclusivamente sobre seu próprio modelo de dados.
### O que não muda
Os dados transacionais do SAR já estão corretos e **permanecem sem alteração**:
- `pedidos`, `pedido_itens`, `historico_pedido`
- `alcada_desconto`, `meta_representante`
- `push_subscription`
---
## 3. Tabelas Canônicas a Criar
### 3.1 Entidades mestres (sincronizadas do ERP)
| Tabela SAR | Substitui | Campos principais |
|---|---|---|
| `sar.clientes` | `vw_clientes` | codigo, razao_social, nome_fantasia, cnpj, cidade, uf, ativo, representante_codigo |
| `sar.representantes` | `vw_representantes` | codigo, nome, email, senha_hash, tipo (rep/supervisor), ativo |
| `sar.produtos` | `vw_produtos` | codigo, descricao, unidade, ncm, ativo |
| `sar.estoques` | `vw_estoque` | produto_codigo, deposito, saldo (snapshot com timestamp) |
| `sar.pautas` | `vw_pautas` | codigo, descricao, ativa |
| `sar.pauta_produtos` | `vw_pauta_produtos` | pauta_codigo, produto_codigo, preco, desconto_max |
| `sar.formas_pagamento` | `vw_formas_pagamento` | codigo, descricao, parcelas, ativa |
| `sar.municipios` | `vw_municipios` | ibge, nome, uf |
| `sar.empresa` | `gestao.empresa` | cnpj, razao_social, nome_fantasia, endereco, logo_url |
### 3.2 Histórico ERP (read-only, sincronizado)
| Tabela SAR | Substitui | Propósito |
|---|---|---|
| `sar.pedidos_erp` | `vw_pedidos_erp` | Histórico de pedidos anteriores ao SAR |
| `sar.pedido_itens_erp` | `vw_peditens_erp` | Itens dos pedidos ERP |
### 3.3 Controle de sincronização
| Tabela SAR | Propósito |
|---|---|
| `sar.sync_log` | Registro de cada execução de sync (início, fim, erros, registros processados) |
| `sar.sync_config` | Configuração do connector por workspace (tipo ERP, credenciais, frequência) |
---
## 4. Plano de Execução por Fases
### Fase 0 — Banco de Dados Isolado `[Fundação]`
**Objetivo:** Separar o banco SAR do banco ERP do cliente.
**Ações:**
- Provisionar banco PostgreSQL exclusivo para o SAR (`sar_db`)
- Configurar variável `DATABASE_URL` apontando para o novo banco
- Manter `ERP_DB_*` apenas no connector (não no app principal)
- Estrutura multi-tenant: um schema por workspace ou banco por workspace (já previsto na stack)
**Resultado:** SAR pode funcionar mesmo sem acesso ao ERP; connector roda separado.
**Estimativa:** 23 dias
---
### Fase 1 — Schema Canônico `[Estrutura]`
**Objetivo:** Criar todos os models Prisma das tabelas canônicas.
**Ações:**
- Adicionar 11 models ao `schema.prisma` (seção 3.1 e 3.2)
- Adicionar 2 models de controle de sync (seção 3.3)
- Gerar e aplicar migrations
- Nenhuma mudança no código da API ainda
**Resultado:** Tabelas existem no banco, prontas para receber dados.
**Estimativa:** 34 dias
---
### Fase 2 — Sync Service (Connector ERP JCS) `[Motor]`
**Objetivo:** Criar a camada que lê o ERP e popula as tabelas canônicas.
**Ações:**
- Criar módulo `SyncModule` no NestJS
- Implementar `ErpJcsConnector` com leitura das 11 views existentes
- Sync completo na inicialização do workspace
- Sync incremental por cron (ex: a cada 5 minutos para estoque, 30 min para cadastros)
- Registrar execuções em `sync_log`
- Endpoint admin `/api/v1/sync/trigger` para forçar sync manual
**Estratégia de sync:**
```
┌────────────────────────────────────────────────────┐
│ Frequências recomendadas │
│ │
│ A cada 5 min: estoques (alta volatilidade) │
│ A cada 30 min: produtos, pautas, preços │
│ A cada 60 min: clientes, representantes │
│ A cada 6h: pedidos_erp (histórico) │
│ 1x por dia: municipios, empresa, formas_pgto │
└────────────────────────────────────────────────────┘
```
**Resultado:** Tabelas SAR ficam populadas e atualizadas automaticamente.
**Estimativa:** 58 dias
---
### Fase 3 — Migração dos Módulos `[Execução]`
**Objetivo:** Substituir todos os `$queryRaw` por queries Prisma nas tabelas canônicas.
**Prioridade e ordem:**
| Ordem | Módulo | Complexidade | Motivo da prioridade |
|---|---|---|---|
| 1 | `catalog` | Baixa | Somente leitura, sem lógica de negócio complexa |
| 2 | `clients` | Média | Base do dashboard e pedidos |
| 3 | `auth` | Baixa | Bem delimitado; lê apenas `representantes` |
| 4 | `dashboard` | Média | Depende de clients migrado |
| 5 | `orders` (leitura) | Média | Parte escrita já usa Prisma corretamente |
**Processo por módulo:**
1. Com o sync rodando, os dados já estão nas tabelas canônicas
2. Substituir `$queryRaw` por `prisma.cliente.findMany(...)` etc.
3. Manter o contrato de resposta da API (frontend não muda)
4. Teste comparativo: mesmo resultado via ERP vs. via tabela SAR
5. Remover a query antiga após validação
**Resultado:** Zero dependência de views ERP no código da API.
**Estimativa:** 58 dias
---
### Fase 4 — Segundo Connector (Validação da Abstração) `[Prova]`
**Objetivo:** Onboarding de um cliente com ERP diferente do JCS.
**Ações:**
- Identificar segundo ERP (ex: TOTVS, Sankhya, outro)
- Criar `ErpXConnector` implementando a mesma interface do `ErpJcsConnector`
- Mapear campos do ERP X para o schema canônico SAR
- Sem nenhuma mudança no app SAR
**Resultado:** Prova de conceito real de que a arquitetura é ERP-agnóstica.
**Estimativa:** 46 dias (variável conforme ERP)
---
## 5. Cronograma Estimado
```
Semana 1: Fase 0 (banco isolado) + Fase 1 (schema Prisma)
Semana 2: Fase 2 (Sync Service — maior esforço)
Semana 3: Fase 3 (migração dos módulos)
Semana 4: Fase 4 (segundo connector) + testes + documentação
Total estimado: 4 semanas (1925 dias úteis)
```
---
## 6. Riscos e Mitigações
| Risco | Probabilidade | Impacto | Mitigação |
|---|---|---|---|
| Divergência de dados entre ERP e tabela SAR durante sync | Médio | Alto | Sync comparativo com log de diferenças; alertas |
| Latência do sync afeta experiência do rep | Baixo | Médio | Sync de estoque a cada 5 min é suficiente para força de vendas |
| ERP do cliente sem conectividade temporária | Médio | Baixo | SAR continua funcionando com dados do último sync |
| Campos do segundo ERP sem equivalência no schema canônico | Médio | Médio | Schema canônico flexível com campo `metadata jsonb` por entidade |
| Custo de manutenção de dois bancos | Baixo | Baixo | Banco SAR é pequeno; ERP continua como está |
---
## 7. Benefícios Esperados
| Benefício | Impacto |
|---|---|
| **Multi-ERP real** | Onboarding de qualquer cliente independente do ERP |
| **Isolamento de falhas** | Queda do ERP não derruba o SAR |
| **Performance** | Queries no banco SAR são mais rápidas (schema otimizado) |
| **Funciona offline** | Rep consulta dados mesmo sem VPN/internet no cliente |
| **Multi-tenant limpo** | Banco SAR separado por workspace desde o início |
| **Evolução independente** | SAR pode adicionar campos sem depender do ERP |
---
## 8. Decisão
O projeto SAR **não precisa ser refeito do zero**. A infraestrutura (NestJS, autenticação, frontend React, ciclo de pedidos, alçadas, metas, push notifications) está correta e é reaproveitada integralmente.
A mudança é **cirúrgica e incremental**:
- Adicionar banco e tabelas canônicas (Fases 0 e 1)
- Construir o motor de sync (Fase 2)
- Substituir queries raw módulo a módulo (Fase 3)
O risco de **não fazer** essa migração agora é maior: quanto mais o produto crescer sobre as views do ERP JCS, mais caro fica refatorar depois.
---
*Documento gerado em 2026-06-22. Para dúvidas: jcsinfo@gmail.com*

View File

@@ -6,3 +6,4 @@ export * from './lib/product.contract';
export * from './lib/dashboard.contract';
export * from './lib/notifications.contract';
export * from './lib/company.contract';
export * from './lib/report.contract';

View File

@@ -111,6 +111,54 @@ export const ContatoResultSchema = z.object({
});
export type ContatoResult = z.infer<typeof ContatoResultSchema>;
// ─── Produtos mais comprados por cliente ─────────────────────────────────────
export const TopProdutoSchema = z.object({
idProduto: z.number().int(),
descricao: z.string(),
unidade: z.string().nullable(),
qtdTotal: z.number(),
numPedidos: z.number().int(),
ultimaCompra: z.string(),
ultimoPreco: z.number(),
});
export type TopProduto = z.infer<typeof TopProdutoSchema>;
// ─── Contas a Receber (resumo por cliente) ───────────────────────────────────
export const CtrSummarySchema = z.object({
qtdAberto: z.number().int(),
totalAberto: z.number(),
qtdVencido: z.number().int(),
totalVencido: z.number(),
maiorAtrasoDias: z.number().int(),
});
export type CtrSummary = z.infer<typeof CtrSummarySchema>;
// ─── Contas a Receber (lista de títulos em aberto) ────────────────────────────
export const CtrTituloSchema = z.object({
idCtr: z.number().int(),
numero: z.string(),
prefixo: z.string(),
dtEmissao: z.string(),
dtVencimento: z.string(),
saldo: z.number(),
});
export type CtrTitulo = z.infer<typeof CtrTituloSchema>;
// ─── Notas Fiscais por cliente ───────────────────────────────────────────────
export const NotaFiscalSchema = z.object({
idNf: z.number().int(),
numero: z.number().int(),
serie: z.string(),
dtEmissao: z.string(),
vlNf: z.number(),
chaveAcessoNfe: z.string().nullable(),
});
export type NotaFiscal = z.infer<typeof NotaFiscalSchema>;
// ─── Municipio ───────────────────────────────────────────────────────────────
export const MunicipioSchema = z.object({

View File

@@ -3,13 +3,16 @@ import { PedidoSummarySchema } from './order.contract';
// ADR 0006 revogado: OrderSummary → PedidoSummary, ids numéricos.
export const ClienteInativoSchema = z.object({
export const ClienteNaoPositivadoSchema = z.object({
idCliente: z.number().int(),
nome: z.string(),
diasSemCompra: z.number().int(),
ultimaCompraValor: z.string().nullable(),
razao: z.string().nullable(),
diasSemPedido: z.number().int(), // 999 = nunca comprou
ultimoPedido: z.string().nullable(), // ISO date YYYY-MM-DD
comprouAntes: z.boolean(),
whatsapp: z.string().nullable(),
});
export type ClienteInativo = z.infer<typeof ClienteInativoSchema>;
export type ClienteNaoPositivado = z.infer<typeof ClienteNaoPositivadoSchema>;
// Dimensão de meta. O ERP (vw_metas.tipo) define como o cliente acompanha metas:
// GL = global, GR = por grupo. Motor único; outras dimensões (marca/subgrupo/
@@ -22,7 +25,7 @@ export type MetaDimensao = z.infer<typeof MetaDimensaoSchema>;
export const MetaItemSchema = z.object({
codigo: z.number().int().nullable(),
rotulo: z.string(),
pedidos: z.number().int(), // qtd de pedidos faturados no grupo (realizado)
pedidos: z.number().int(),
valorMeta: z.number(),
valorReal: z.number(),
qtdMeta: z.number(),
@@ -31,8 +34,8 @@ export const MetaItemSchema = z.object({
pesoReal: z.number(),
fatorMeta: z.number(),
fatorReal: z.number(),
pct: z.number(), // % de valor (real/meta) — base da barra de progresso
falta: z.number(), // valor faltante p/ a meta
pct: z.number(),
falta: z.number(),
});
export type MetaItem = z.infer<typeof MetaItemSchema>;
@@ -43,17 +46,12 @@ export const RepDashboardSchema = z.object({
pct: z.number(),
falta: z.number(),
}),
// Dimensão detectada do ERP e detalhamento por grupo (vazio quando global).
metaDimensao: MetaDimensaoSchema.default('global'),
metasPorGrupo: z.array(MetaItemSchema).default([]),
comissao: z.object({
fixa: z.number(),
flex: z.number(),
total: z.number(),
}),
pedidosMes: z.number().int(),
pedidosRecentes: z.array(PedidoSummarySchema),
clientesInativos: z.array(ClienteInativoSchema),
naoPositivados: z.array(ClienteNaoPositivadoSchema),
totalNaoPositivados: z.number().int(),
syncedAt: z.iso.datetime(),
});
export type RepDashboard = z.infer<typeof RepDashboardSchema>;

View File

@@ -0,0 +1,70 @@
import { z } from 'zod';
// ─── Desempenho vs. Meta ─────────────────────────────────────────────────────
export const ReportMetaQuerySchema = z.object({
mes: z.coerce.number().int().min(1).max(12),
ano: z.coerce.number().int().min(2000).max(2100),
});
export type ReportMetaQuery = z.infer<typeof ReportMetaQuerySchema>;
export const ReportMetaResponseSchema = z.object({
mes: z.number(),
ano: z.number(),
realizado: z.string(),
meta: z.string(),
percentual: z.string(),
comissaoEstimada: z.string(),
totalPedidos: z.number(),
});
export type ReportMetaResponse = z.infer<typeof ReportMetaResponseSchema>;
// ─── Carteira de Clientes ────────────────────────────────────────────────────
export const CarteiraClienteSchema = z.object({
idCliente: z.number(),
nome: z.string().nullable(),
razao: z.string().nullable(),
ativo: z.number(),
ultimoPedido: z.string().nullable(),
diasSemPedido: z.number().nullable(),
totalPedidos: z.number(),
ticketMedio: z.string(),
});
export type CarteiraCliente = z.infer<typeof CarteiraClienteSchema>;
export const ReportCarteiraResponseSchema = z.object({
data: z.array(CarteiraClienteSchema),
total: z.number(),
inativos30: z.number(),
inativos60: z.number(),
semPedido: z.number(),
});
export type ReportCarteiraResponse = z.infer<typeof ReportCarteiraResponseSchema>;
// ─── Curva ABC de Produtos ───────────────────────────────────────────────────
export const ReportAbcQuerySchema = z.object({
mes: z.coerce.number().int().min(1).max(12).optional(),
ano: z.coerce.number().int().min(2000).max(2100).optional(),
});
export type ReportAbcQuery = z.infer<typeof ReportAbcQuerySchema>;
export const AbcProdutoSchema = z.object({
codProduto: z.string().nullable(),
descProduto: z.string(),
totalFaturado: z.string(),
qtdVendida: z.string(),
participacao: z.string(),
curva: z.enum(['A', 'B', 'C']),
descontoMedio: z.string(),
comissaoTotal: z.string(),
});
export type AbcProduto = z.infer<typeof AbcProdutoSchema>;
export const ReportAbcResponseSchema = z.object({
data: z.array(AbcProdutoSchema),
totalFaturado: z.string(),
periodo: z.string(),
});
export type ReportAbcResponse = z.infer<typeof ReportAbcResponseSchema>;

View File

@@ -505,14 +505,18 @@ FROM gestao.marca;
-- -----------------------------------------------------------------------------
-- Contatos vinculados a clientes (gestao.contato)
-- id_entidade = id_corrent::text (CHAR 20, auto-pad)
-- Usado para envio via WhatsApp / ChatWoot futuramente
-- O ERP grava id_entidade como 'COR#<id_corrent>' (formato nativo).
-- O SAR legado gravava apenas o número puro. Ambos os formatos são aceitos.
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW sar.vw_contatos AS
SELECT
c.id_contato,
c.id_empresa,
TRIM(c.id_entidade)::integer AS id_corrent,
CASE
WHEN TRIM(c.id_entidade) ~ '^COR#\d+$'
THEN SUBSTRING(TRIM(c.id_entidade) FROM 5)::integer
ELSE TRIM(c.id_entidade)::integer
END AS id_corrent,
c.id_chatwoot,
TRIM(c.nome) AS nome,
TRIM(c.empresa) AS empresa,
@@ -530,9 +534,13 @@ SELECT
TRIM(cr.razao::text) AS razao_cliente,
cr.cod_vendedor
FROM gestao.contato c
LEFT JOIN sig.corrent cr
ON cr.id_corrent = TRIM(c.id_entidade)::integer
WHERE TRIM(c.id_entidade) ~ '^\d+$';
LEFT JOIN sig.corrent cr ON cr.id_corrent = CASE
WHEN TRIM(c.id_entidade) ~ '^COR#\d+$'
THEN SUBSTRING(TRIM(c.id_entidade) FROM 5)::integer
ELSE TRIM(c.id_entidade)::integer
END
WHERE TRIM(c.id_entidade) ~ '^\d+$'
OR TRIM(c.id_entidade) ~ '^COR#\d+$';
-- =============================================================================
-- PARTE 2 — TABELAS DE ESCRITA DO SAR

View File

@@ -489,8 +489,14 @@ CREATE INDEX IF NOT EXISTS idx_sar_ctnov_sync ON sar.contatos_novos(sincroniz
CREATE OR REPLACE FUNCTION sar.fn_sync_contato_to_erp()
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
DECLARE
v_id_contato INTEGER;
v_id_contato INTEGER;
v_id_empresa INTEGER;
BEGIN
-- Normaliza empresa fiscal (9001) → empresa gerencial (1), padrão do ERP
v_id_empresa := CASE WHEN NEW.id_empresa > 9000
THEN NEW.id_empresa - 9000
ELSE NEW.id_empresa END;
BEGIN
INSERT INTO gestao.contato (
id_empresa, id_entidade,
@@ -499,8 +505,8 @@ BEGIN
telefone, ramal, celular, whatsapp, email,
ativo, anotacoes
) VALUES (
NEW.id_empresa,
NEW.id_corrent::text,
v_id_empresa,
'COR#' || NEW.id_corrent::text, -- formato nativo do ERP
COALESCE(LEFT(NEW.nome, 100), ''),
LEFT(NEW.empresa, 100),
LEFT(NEW.cargo, 100),