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

View File

@@ -10,8 +10,13 @@ import {
type ClientNovoResult, type ClientNovoResult,
type Contato, type Contato,
type ContatoResult, type ContatoResult,
type CtrSummary,
type CreateClientNovo, type CreateClientNovo,
type CreateContato, type CreateContato,
type CtrTitulo,
type NotaFiscal,
type PedidoSummary,
type TopProduto,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { ClientsService } from './clients.service'; import { ClientsService } from './clients.service';
@@ -35,6 +40,47 @@ export class ClientsController {
return this.clients.createNovo(parsed); 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') @Get(':id/contacts')
listContacts(@Param('id', ParseIntPipe) id: number): Promise<Contato[]> { listContacts(@Param('id', ParseIntPipe) id: number): Promise<Contato[]> {
return this.clients.listContacts(id); return this.clients.listContacts(id);

View File

@@ -9,8 +9,13 @@ import type {
ClientSummary, ClientSummary,
Contato, Contato,
ContatoResult, ContatoResult,
CtrSummary,
CtrTitulo,
CreateClientNovo, CreateClientNovo,
CreateContato, CreateContato,
NotaFiscal,
PedidoSummary,
TopProduto,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; 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> { async findOne(idCliente: number): Promise<ClientDetail> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -388,6 +548,7 @@ export class ClientsService {
return { return {
idCliente: Number(r.id_cliente), idCliente: Number(r.id_cliente),
idEmpresa: Number(r.id_empresa), idEmpresa: Number(r.id_empresa),
nome: r.nome, nome: r.nome,
razao: r.razao, razao: r.razao,
@@ -413,4 +574,106 @@ export class ClientsService {
dtAtual: r.dt_atual, 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; peso: string;
} }
interface RepRow { interface NaoPositivadoRow {
taxa_com: string;
permitir_flex: number; // 0 ou 1 (char do ERP convertido)
}
interface InativoRow {
id_cliente: number; id_cliente: number;
nome: string; nome: string;
dt_ultima_compra: Date | null; razao: string | null;
ultima_compra_valor: 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 { interface InativosPorRepRow {
@@ -92,24 +90,7 @@ export class DashboardService {
: grRows.reduce((a, m) => a + Number(m.valor), 0); : grRows.reduce((a, m) => a + Number(m.valor), 0);
const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const); const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const);
// 2. Taxas do representante — fonte: gestao.vendedor (via vw_representantes) // 2. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
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).
// Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido. // Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido.
const monthStartStr = monthStart.toISOString().slice(0, 10); const monthStartStr = monthStart.toISOString().slice(0, 10);
const monthEndStr = monthEnd.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 pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0;
const falta = Math.max(0, targetAmount - atingido); const falta = Math.max(0, targetAmount - atingido);
const fixa = Math.round(atingido * commissionRate) / 100; // Clientes não positivados no mês: ativos do representante sem pedido desde monthStart.
const flex = // Junta o último pedido histórico (ERP+SAR) e o primeiro whatsapp ativo dos contatos.
permitirFlex && targetAmount > 0 && atingido >= targetAmount const naoPositivadosRows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
? Math.round(atingido * flexRate) / 100 WITH ultimo_pedido AS (
: 0; SELECT id_cliente,
MAX(dt_pedido) AS dt_max
// 7. Clientes inativos — sem pedido no ERP há >30 dias FROM (
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); SELECT id_cliente, dt_pedido FROM vw_pedidos_erp
const inactiveClients = await prisma.$queryRawUnsafe<InativoRow[]>(` 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 SELECT
c.id_cliente, c.id_cliente,
c.nome, TRIM(c.nome) AS nome,
MAX(p.dt_pedido) AS dt_ultima_compra, TRIM(c.razao) AS razao,
MAX(p.total)::text AS ultima_compra_valor 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 FROM vw_clientes c
LEFT JOIN vw_pedidos_erp p LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
ON p.id_cliente = c.id_cliente LEFT JOIN pedido_mes pm ON pm.id_cliente = c.id_cliente
AND p.id_empresa = ${idEmpresa} LEFT JOIN LATERAL (
AND p.situa != 5 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} WHERE c.cod_vendedor = ${codVendedor}
AND c.ativo = 1 AND c.ativo = 1
GROUP BY c.id_cliente, c.nome AND pm.id_cliente IS NULL
HAVING MAX(p.dt_pedido) IS NULL ORDER BY up.dt_max ASC NULLS FIRST
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
ORDER BY dt_ultima_compra ASC NULLS FIRST
LIMIT 10
`); `);
// Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo. // 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); .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 { return {
meta: { atingido, total: targetAmount, pct, falta }, meta: { atingido, total: targetAmount, pct, falta },
metaDimensao, metaDimensao,
metasPorGrupo, metasPorGrupo,
comissao: { fixa, flex, total: fixa + flex },
pedidosMes, pedidosMes,
pedidosRecentes: recentRows.map((o) => ({ pedidosRecentes: recentRows.map((o) => ({
id: `erp-${o.id_pedido}`, id: `erp-${o.id_pedido}`,
@@ -281,14 +294,8 @@ export class DashboardService {
createdAt: new Date(o.dt_pedido).toISOString(), createdAt: new Date(o.dt_pedido).toISOString(),
fonte: 'erp' as const, fonte: 'erp' as const,
})), })),
clientesInativos: inactiveClients.map((c) => ({ naoPositivados,
idCliente: Number(c.id_cliente), totalNaoPositivados: naoPositivados.length,
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,
})),
syncedAt: now.toISOString(), 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 type { TableColumnsType } from 'antd';
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
import { Link, useNavigate, useParams } from '@tanstack/react-router'; 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 { SITUA_LABEL } from '@sar/api-interface';
import { useClientDetail } from '../../lib/queries/clients'; import {
import { useClientOrders } from '../../lib/queries/orders'; useClientDetail,
useClientCtr,
useClientCtrList,
useClientNotasFiscais,
useClientOrdersHistory,
useClientTopProdutos,
} from '../../lib/queries/clients';
import { ClientContacts } from '../../components/contacts/ClientContacts'; import { ClientContacts } from '../../components/contacts/ClientContacts';
const { Title } = Typography; const { Title, Text } = Typography;
const ACTIVITY_COLOR: Record<string, string> = { const ACTIVITY_COLOR: Record<string, string> = {
active: 'success', active: 'success',
@@ -20,60 +42,182 @@ const ACTIVITY_LABEL: Record<string, string> = {
inactive: 'Inativo', inactive: 'Inativo',
}; };
const orderColumns: TableColumnsType<PedidoSummary> = [ const SITUA_COLOR: Record<number, string> = {
{ 0: 'default',
title: 'Nº', 1: 'warning',
dataIndex: 'numPedSar', 2: 'processing',
width: 120, 3: 'error',
render: (num: string, row: PedidoSummary) => ( 4: 'success',
<Link to="/pedidos/$id" params={{ id: row.id }}> };
{num}
</Link> function orderColumns(withFonte: boolean): TableColumnsType<PedidoSummary> {
), const cols: TableColumnsType<PedidoSummary> = [
}, {
{ title: 'Nº Pedido',
title: 'Status', dataIndex: 'numPedSar',
dataIndex: 'situa', render: (num: string, row: PedidoSummary) => (
width: 140, <Link to="/pedidos/$id" params={{ id: row.id }}>
render: (s: number) => { {num}
const colorMap: Record<number, string> = { </Link>
1: 'warning', ),
2: 'processing',
3: 'error',
4: 'success',
};
return <Tag color={colorMap[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>;
}, },
}, {
{ title: 'Status',
title: 'Total', dataIndex: 'situa',
dataIndex: 'total', width: 130,
width: 130, render: (s: number) => (
align: 'right', <Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
render: (v: string) => ),
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }), },
}, {
{ title: 'Total',
title: 'Data', dataIndex: 'total',
dataIndex: 'dtPedido', width: 130,
width: 130, align: 'right' as const,
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'), 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() { export function ClientDetailPage() {
const { id } = useParams({ from: '/clientes/$id' }); const { id } = useParams({ from: '/clientes/$id' });
const idNum = Number(id); const idNum = Number(id);
const navigate = useNavigate(); 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: 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 (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
if (clientError || !client) 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 ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24, maxWidth: '100%', overflowX: 'hidden' }}>
<Space align="center" style={{ marginBottom: 16 }} wrap> <Space align="center" style={{ marginBottom: 16 }} wrap>
<Link to="/clientes"> Clientes</Link> <Link to="/clientes"> Clientes</Link>
<Title level={3} style={{ margin: 0 }}> <Title level={3} style={{ margin: 0 }}>
@@ -82,6 +226,9 @@ export function ClientDetailPage() {
<Tag color={ACTIVITY_COLOR[client.activityStatus]}> <Tag color={ACTIVITY_COLOR[client.activityStatus]}>
{ACTIVITY_LABEL[client.activityStatus]} {ACTIVITY_LABEL[client.activityStatus]}
</Tag> </Tag>
<Button icon={<UserAddOutlined />} onClick={() => setAddContactOpen(true)}>
Novo Contato
</Button>
<Button <Button
type="primary" type="primary"
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })} onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
@@ -121,20 +268,306 @@ export function ClientDetailPage() {
</Descriptions.Item> </Descriptions.Item>
</Descriptions> </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> <Table<PedidoSummary>
rowKey="id" rowKey="id"
columns={orderColumns} columns={orderColumns(false)}
dataSource={orders ?? []} dataSource={ordersPreview}
loading={ordersLoading} loading={ordersLoading}
pagination={false} pagination={false}
size="small" size="small"
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')} rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
style={{ marginBottom: 24, maxWidth: '100%' }}
scroll={{ x: 'max-content' }}
/> />
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style> <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> </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 type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
@@ -7,8 +21,9 @@ import {
faCircleExclamation, faCircleExclamation,
faClipboardList, faClipboardList,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router'; import { WhatsAppOutlined } from '@ant-design/icons';
import type { MetaItem, PedidoSummary } from '@sar/api-interface'; import { Link, useNavigate } from '@tanstack/react-router';
import type { MetaItem, ClienteNaoPositivado, PedidoSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard'; import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth'; import { useCurrentUser } from '../../lib/queries/auth';
@@ -34,17 +49,19 @@ function greeting(): string {
} }
function today(): string { function today(): string {
return new Date().toLocaleDateString('pt-BR', { return new Date().toLocaleDateString('pt-BR', { day: 'numeric', month: 'long' });
day: 'numeric',
month: 'long',
});
} }
function num(v: number, dec = 0): string { function num(v: number, dec = 0): string {
return v.toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec }); 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({ function MetaCell({
real, real,
meta, 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() { export function RepPainel() {
const { data, isLoading } = useRepDashboard(); const { data, isLoading } = useRepDashboard();
const { data: user } = useCurrentUser(); const { data: user } = useCurrentUser();
@@ -140,13 +321,10 @@ export function RepPainel() {
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}> <Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
<Skeleton active paragraph={{ rows: 2 }} /> <Skeleton active paragraph={{ rows: 2 }} />
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} md={12}> <Col xs={24} md={14}>
<Skeleton active /> <Skeleton active />
</Col> </Col>
<Col xs={12} md={6}> <Col xs={24} md={10}>
<Skeleton active />
</Col>
<Col xs={12} md={6}>
<Skeleton active /> <Skeleton active />
</Col> </Col>
</Row> </Row>
@@ -157,10 +335,10 @@ export function RepPainel() {
const { const {
meta, meta,
metasPorGrupo = [], metasPorGrupo = [],
comissao,
pedidosMes, pedidosMes,
pedidosRecentes = [], pedidosRecentes = [],
clientesInativos = [], naoPositivados = [],
totalNaoPositivados = 0,
syncedAt, syncedAt,
} = data; } = data;
@@ -173,21 +351,21 @@ export function RepPainel() {
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()} {today()}
{clientesInativos.length > 0 && ( {totalNaoPositivados > 0 && (
<> <>
{' '} {' '}
·{' '} ·{' '}
<span style={{ color: 'var(--orange)' }}> <span style={{ color: 'var(--orange)' }}>
{clientesInativos.length} clientes inativos {totalNaoPositivados} clientes sem pedido no s
</span> </span>
</> </>
)} )}
</Text> </Text>
</Flex> </Flex>
{/* Linha 1 — Meta + KPIs */} {/* Linha 1 — Meta + Pedidos do mês */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} md={12}> <Col xs={24} md={14}>
<Card style={{ height: '100%' }}> <Card style={{ height: '100%' }}>
<Flex vertical gap={16}> <Flex vertical gap={16}>
<Flex justify="space-between" align="flex-start"> <Flex justify="space-between" align="flex-start">
@@ -228,8 +406,8 @@ export function RepPainel() {
</Card> </Card>
</Col> </Col>
<Col xs={12} md={6}> <Col xs={24} md={10}>
<Card> <Card style={{ height: '100%' }}>
<Space orientation="vertical" size={4}> <Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS NO MÊS PEDIDOS NO MÊS
@@ -238,32 +416,14 @@ export function RepPainel() {
{pedidosMes} {pedidosMes}
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
<FontAwesomeIcon icon={faArrowTrendUp} /> últimos 30 dias <FontAwesomeIcon icon={faArrowTrendUp} /> mês corrente
</Text> </Text>
</Space> </Space>
</Card> </Card>
</Col> </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> </Row>
{/* Metas por Grupo — acompanhamento multi-medida do mês */} {/* Metas por Grupo */}
{metasPorGrupo.length > 0 && ( {metasPorGrupo.length > 0 && (
<Card <Card
title={ title={
@@ -339,67 +499,13 @@ export function RepPainel() {
</Card> </Card>
)} )}
{/* Linha 2 — Clientes inativos + Pedidos recentes */} {/* Linha 2 — Não positivados + Pedidos recentes */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} lg={12}> <Col xs={24} lg={14}>
<Card <NaoPositivados clientes={naoPositivados} total={totalNaoPositivados} />
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> </Col>
<Col xs={24} lg={12}> <Col xs={24} lg={10}>
<Card <Card
title={ title={
<Space> <Space>

View File

@@ -1,20 +1,20 @@
import { useState } from 'react';
import { import {
App, App,
Button, Card,
Col,
DatePicker, DatePicker,
Divider, Divider,
Form, Form,
Input, Input,
Modal, Modal,
Row,
Space, Space,
Table, Spin,
Tag, Tag,
Tooltip, Tooltip,
Typography, Typography,
} from 'antd'; } from 'antd';
import type { TableColumnsType } from 'antd'; import { MailOutlined, PhoneOutlined, WhatsAppOutlined } from '@ant-design/icons';
import { MailOutlined, PhoneOutlined, PlusOutlined, WhatsAppOutlined } from '@ant-design/icons';
import type { Contato } from '@sar/api-interface'; import type { Contato } from '@sar/api-interface';
import { useClientContacts, useCreateContato } from '../../lib/queries/clients'; import { useClientContacts, useCreateContato } from '../../lib/queries/clients';
@@ -26,7 +26,7 @@ function ContactActions({ contato }: { contato: Contato }) {
const mail = contato.email?.trim(); const mail = contato.email?.trim();
return ( return (
<Space size={6}> <Space size={4} wrap>
{wa && ( {wa && (
<Tooltip title={`WhatsApp: ${wa}`}> <Tooltip title={`WhatsApp: ${wa}`}>
<a href={`https://wa.me/55${wa.replace(/\D/g, '')}`} target="_blank" rel="noreferrer"> <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}> <Tooltip title={mail}>
<a href={`mailto:${mail}`}> <a href={`mailto:${mail}`}>
<Tag icon={<MailOutlined />} color="default" style={{ margin: 0 }}> <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> </Tag>
</a> </a>
</Tooltip> </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 { interface Props {
idCliente: number; idCliente: number;
modalOpen?: boolean;
onModalClose?: () => void;
} }
export function ClientContacts({ idCliente }: Props) { export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: Props) {
const { message } = App.useApp(); const { message } = App.useApp();
const [open, setOpen] = useState(false); const open = modalOpen;
const setOpen = (v: boolean) => {
if (!v) onModalClose?.();
};
const [form] = Form.useForm(); const [form] = Form.useForm();
const { data: contacts = [], isLoading } = useClientContacts(idCliente); const { data: contacts = [], isLoading } = useClientContacts(idCliente);
@@ -134,38 +107,53 @@ export function ClientContacts({ idCliente }: Props) {
return ( return (
<> <>
<div <Divider titlePlacement="left" style={{ marginBottom: 12 }}>
style={{ Contatos
display: 'flex', </Divider>
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>
<Table<Contato> {isLoading ? (
rowKey="idContato" <Spin style={{ display: 'block', margin: '16px auto' }} />
columns={columns} ) : contacts.length === 0 ? (
dataSource={contacts} <Typography.Text
loading={isLoading} type="secondary"
pagination={false} style={{ display: 'block', marginBottom: 24, textAlign: 'center' }}
size="small" >
locale={{ emptyText: 'Nenhum contato cadastrado.' }} Nenhum contato cadastrado.
style={{ marginBottom: 24 }} </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 <Modal
title="Novo Contato" title="Novo Contato"
@@ -178,7 +166,7 @@ export function ClientContacts({ idCliente }: Props) {
confirmLoading={createMutation.isPending} confirmLoading={createMutation.isPending}
okText="Cadastrar" okText="Cadastrar"
cancelText="Cancelar" cancelText="Cancelar"
width={560} width={580}
destroyOnHidden destroyOnHidden
> >
<Form form={form} layout="vertical" style={{ marginTop: 16 }} requiredMark="optional"> <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} /> <Input placeholder="Nome do contato" maxLength={100} />
</Form.Item> </Form.Item>
<Form.Item name="cargo" label="Cargo"> <Row gutter={12}>
<Input placeholder="Comprador, Gerente..." maxLength={100} /> <Col span={12}>
</Form.Item> <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"> <Row gutter={12}>
<Input placeholder="Compras, Financeiro..." maxLength={100} /> <Col span={12}>
</Form.Item> <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"> <Row gutter={12}>
<Input <Col span={12}>
placeholder="48999990000" <Form.Item name="telefone" label="Telefone fixo">
maxLength={20} <Input placeholder="4833330000" maxLength={20} />
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />} </Form.Item>
/> </Col>
</Form.Item> <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"> <Row gutter={12}>
<Input placeholder="48999990000" maxLength={20} /> <Col span={12}>
</Form.Item> <Form.Item name="dtAniversario" label="Aniversário">
<DatePicker
<Form.Item name="telefone" label="Telefone fixo"> format="DD/MM/YYYY"
<Input placeholder="4833330000" maxLength={20} /> style={{ width: '100%' }}
</Form.Item> placeholder="dd/mm/aaaa"
/>
<Form.Item name="email" label="E-mail"> </Form.Item>
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" /> </Col>
</Form.Item> </Row>
<Form.Item name="dtAniversario" label="Aniversário">
<DatePicker format="DD/MM/YYYY" style={{ width: '100%' }} placeholder="dd/mm/aaaa" />
</Form.Item>
<Form.Item name="anotacoes" label="Anotações"> <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.Item>
</Form> </Form>
</Modal> </Modal>

View File

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

View File

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

View File

@@ -5,14 +5,24 @@ import {
ClientNovoResultSchema, ClientNovoResultSchema,
ContatoResultSchema, ContatoResultSchema,
ContatoSchema, ContatoSchema,
CtrSummarySchema,
CtrTituloSchema,
NotaFiscalSchema,
PedidoSummarySchema,
TopProdutoSchema,
type ClientDetail, type ClientDetail,
type ClientListQuery, type ClientListQuery,
type ClientListResponse, type ClientListResponse,
type ClientNovoResult, type ClientNovoResult,
type Contato, type Contato,
type ContatoResult, type ContatoResult,
type CtrSummary,
type CtrTitulo,
type CreateClientNovo, type CreateClientNovo,
type CreateContato, type CreateContato,
type NotaFiscal,
type PedidoSummary,
type TopProduto,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { z } from 'zod'; import { z } from 'zod';
import { apiFetch } from '../api-client'; 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) { export function useClientContacts(idCliente: number | undefined) {
return useQuery<Contato[]>({ return useQuery<Contato[]>({
queryKey: ['clients', 'contacts', idCliente], 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 { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage'; import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage'; import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage'; import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel'; import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { authStore } from './auth-store'; import { authStore } from './auth-store';
@@ -125,6 +126,12 @@ const pedidosErpRoute = createRoute({
component: OrdersErpPage, component: OrdersErpPage,
}); });
const relatoriosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/relatorios',
component: RelatoriosPage,
});
const aprovacoes = createRoute({ const aprovacoes = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/aprovacoes', path: '/aprovacoes',
@@ -143,6 +150,7 @@ const routeTree = rootRoute.addChildren([
pedidoPrintRoute, pedidoPrintRoute,
catalogoRoute, catalogoRoute,
pedidosErpRoute, pedidosErpRoute,
relatoriosRoute,
aprovacoes, 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/dashboard.contract';
export * from './lib/notifications.contract'; export * from './lib/notifications.contract';
export * from './lib/company.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>; 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 ─────────────────────────────────────────────────────────────── // ─── Municipio ───────────────────────────────────────────────────────────────
export const MunicipioSchema = z.object({ export const MunicipioSchema = z.object({

View File

@@ -3,13 +3,16 @@ import { PedidoSummarySchema } from './order.contract';
// ADR 0006 revogado: OrderSummary → PedidoSummary, ids numéricos. // ADR 0006 revogado: OrderSummary → PedidoSummary, ids numéricos.
export const ClienteInativoSchema = z.object({ export const ClienteNaoPositivadoSchema = z.object({
idCliente: z.number().int(), idCliente: z.number().int(),
nome: z.string(), nome: z.string(),
diasSemCompra: z.number().int(), razao: z.string().nullable(),
ultimaCompraValor: 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: // 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/ // 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({ export const MetaItemSchema = z.object({
codigo: z.number().int().nullable(), codigo: z.number().int().nullable(),
rotulo: z.string(), rotulo: z.string(),
pedidos: z.number().int(), // qtd de pedidos faturados no grupo (realizado) pedidos: z.number().int(),
valorMeta: z.number(), valorMeta: z.number(),
valorReal: z.number(), valorReal: z.number(),
qtdMeta: z.number(), qtdMeta: z.number(),
@@ -31,8 +34,8 @@ export const MetaItemSchema = z.object({
pesoReal: z.number(), pesoReal: z.number(),
fatorMeta: z.number(), fatorMeta: z.number(),
fatorReal: z.number(), fatorReal: z.number(),
pct: z.number(), // % de valor (real/meta) — base da barra de progresso pct: z.number(),
falta: z.number(), // valor faltante p/ a meta falta: z.number(),
}); });
export type MetaItem = z.infer<typeof MetaItemSchema>; export type MetaItem = z.infer<typeof MetaItemSchema>;
@@ -43,17 +46,12 @@ export const RepDashboardSchema = z.object({
pct: z.number(), pct: z.number(),
falta: z.number(), falta: z.number(),
}), }),
// Dimensão detectada do ERP e detalhamento por grupo (vazio quando global).
metaDimensao: MetaDimensaoSchema.default('global'), metaDimensao: MetaDimensaoSchema.default('global'),
metasPorGrupo: z.array(MetaItemSchema).default([]), metasPorGrupo: z.array(MetaItemSchema).default([]),
comissao: z.object({
fixa: z.number(),
flex: z.number(),
total: z.number(),
}),
pedidosMes: z.number().int(), pedidosMes: z.number().int(),
pedidosRecentes: z.array(PedidoSummarySchema), pedidosRecentes: z.array(PedidoSummarySchema),
clientesInativos: z.array(ClienteInativoSchema), naoPositivados: z.array(ClienteNaoPositivadoSchema),
totalNaoPositivados: z.number().int(),
syncedAt: z.iso.datetime(), syncedAt: z.iso.datetime(),
}); });
export type RepDashboard = z.infer<typeof RepDashboardSchema>; 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) -- Contatos vinculados a clientes (gestao.contato)
-- id_entidade = id_corrent::text (CHAR 20, auto-pad) -- O ERP grava id_entidade como 'COR#<id_corrent>' (formato nativo).
-- Usado para envio via WhatsApp / ChatWoot futuramente -- O SAR legado gravava apenas o número puro. Ambos os formatos são aceitos.
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW sar.vw_contatos AS CREATE OR REPLACE VIEW sar.vw_contatos AS
SELECT SELECT
c.id_contato, c.id_contato,
c.id_empresa, 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, c.id_chatwoot,
TRIM(c.nome) AS nome, TRIM(c.nome) AS nome,
TRIM(c.empresa) AS empresa, TRIM(c.empresa) AS empresa,
@@ -530,9 +534,13 @@ SELECT
TRIM(cr.razao::text) AS razao_cliente, TRIM(cr.razao::text) AS razao_cliente,
cr.cod_vendedor cr.cod_vendedor
FROM gestao.contato c FROM gestao.contato c
LEFT JOIN sig.corrent cr LEFT JOIN sig.corrent cr ON cr.id_corrent = CASE
ON cr.id_corrent = TRIM(c.id_entidade)::integer WHEN TRIM(c.id_entidade) ~ '^COR#\d+$'
WHERE TRIM(c.id_entidade) ~ '^\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 -- 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() CREATE OR REPLACE FUNCTION sar.fn_sync_contato_to_erp()
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$ RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
DECLARE DECLARE
v_id_contato INTEGER; v_id_contato INTEGER;
v_id_empresa INTEGER;
BEGIN 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 BEGIN
INSERT INTO gestao.contato ( INSERT INTO gestao.contato (
id_empresa, id_entidade, id_empresa, id_entidade,
@@ -499,8 +505,8 @@ BEGIN
telefone, ramal, celular, whatsapp, email, telefone, ramal, celular, whatsapp, email,
ativo, anotacoes ativo, anotacoes
) VALUES ( ) VALUES (
NEW.id_empresa, v_id_empresa,
NEW.id_corrent::text, 'COR#' || NEW.id_corrent::text, -- formato nativo do ERP
COALESCE(LEFT(NEW.nome, 100), ''), COALESCE(LEFT(NEW.nome, 100), ''),
LEFT(NEW.empresa, 100), LEFT(NEW.empresa, 100),
LEFT(NEW.cargo, 100), LEFT(NEW.cargo, 100),