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:
@@ -13,6 +13,7 @@ import { OrdersModule } from './orders/orders.module';
|
||||
import { CatalogModule } from './catalog/catalog.module';
|
||||
import { DashboardModule } from './dashboard/dashboard.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { ReportsModule } from './reports/reports.module';
|
||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||
|
||||
@Module({
|
||||
@@ -29,6 +30,7 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||
CatalogModule,
|
||||
DashboardModule,
|
||||
NotificationsModule,
|
||||
ReportsModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||
|
||||
@@ -10,8 +10,13 @@ import {
|
||||
type ClientNovoResult,
|
||||
type Contato,
|
||||
type ContatoResult,
|
||||
type CtrSummary,
|
||||
type CreateClientNovo,
|
||||
type CreateContato,
|
||||
type CtrTitulo,
|
||||
type NotaFiscal,
|
||||
type PedidoSummary,
|
||||
type TopProduto,
|
||||
} from '@sar/api-interface';
|
||||
import { ClientsService } from './clients.service';
|
||||
|
||||
@@ -35,6 +40,47 @@ export class ClientsController {
|
||||
return this.clients.createNovo(parsed);
|
||||
}
|
||||
|
||||
@Get(':id/notas')
|
||||
listNotasFiscais(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Query('limit') limit?: string,
|
||||
): Promise<NotaFiscal[]> {
|
||||
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
|
||||
return this.clients.listNotasFiscais(id, n);
|
||||
}
|
||||
|
||||
@Get(':id/top-produtos')
|
||||
listTopProdutos(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Query('limit') limit?: string,
|
||||
): Promise<TopProduto[]> {
|
||||
const n = limit ? Math.min(parseInt(limit, 10) || 30, 100) : 30;
|
||||
return this.clients.listTopProdutos(id, n);
|
||||
}
|
||||
|
||||
@Get(':id/orders-history')
|
||||
listOrdersHistory(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Query('limit') limit?: string,
|
||||
): Promise<PedidoSummary[]> {
|
||||
const n = limit ? Math.min(parseInt(limit, 10) || 50, 200) : 50;
|
||||
return this.clients.listOrdersHistory(id, n);
|
||||
}
|
||||
|
||||
@Get(':id/ctr')
|
||||
getCtrSummary(@Param('id', ParseIntPipe) id: number): Promise<CtrSummary> {
|
||||
return this.clients.getCtrSummary(id);
|
||||
}
|
||||
|
||||
@Get(':id/ctr-list')
|
||||
listCtrTitulos(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Query('limit') limit?: string,
|
||||
): Promise<CtrTitulo[]> {
|
||||
const n = limit ? Math.min(parseInt(limit, 10) || 60, 200) : 60;
|
||||
return this.clients.listCtrTitulos(id, n);
|
||||
}
|
||||
|
||||
@Get(':id/contacts')
|
||||
listContacts(@Param('id', ParseIntPipe) id: number): Promise<Contato[]> {
|
||||
return this.clients.listContacts(id);
|
||||
|
||||
@@ -9,8 +9,13 @@ import type {
|
||||
ClientSummary,
|
||||
Contato,
|
||||
ContatoResult,
|
||||
CtrSummary,
|
||||
CtrTitulo,
|
||||
CreateClientNovo,
|
||||
CreateContato,
|
||||
NotaFiscal,
|
||||
PedidoSummary,
|
||||
TopProduto,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
|
||||
@@ -364,6 +369,161 @@ export class ClientsService {
|
||||
};
|
||||
}
|
||||
|
||||
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
interface Row {
|
||||
id: string;
|
||||
num_ped: string;
|
||||
situa: number;
|
||||
dt_pedido: Date;
|
||||
total: string;
|
||||
fonte: string;
|
||||
}
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||
SELECT id::text AS id,
|
||||
num_ped_sar AS num_ped,
|
||||
situa,
|
||||
dt_pedido,
|
||||
total::text,
|
||||
'sar' AS fonte
|
||||
FROM sar.pedidos
|
||||
WHERE id_cliente = ${idCliente}
|
||||
AND id_empresa = ${idEmpresa}
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT ('erp-' || id_pedido::text) AS id,
|
||||
COALESCE(NULLIF(TRIM(num_ped_sar::text),''), numero::text) AS num_ped,
|
||||
CASE WHEN situa = 5 THEN 3 ELSE situa END AS situa,
|
||||
dt_pedido,
|
||||
total::text,
|
||||
'erp' AS fonte
|
||||
FROM sar.vw_pedidos_erp
|
||||
WHERE id_cliente = ${idCliente}
|
||||
AND id_empresa = ${idEmpresa}
|
||||
AND situa != 5
|
||||
|
||||
ORDER BY dt_pedido DESC
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
numPedSar: r.num_ped ?? r.id,
|
||||
idCliente,
|
||||
nomeCliente: null,
|
||||
razaoCliente: null,
|
||||
codVendedor: 0,
|
||||
nomeVendedor: null,
|
||||
situa: Number(r.situa),
|
||||
dtPedido: r.dt_pedido.toISOString(),
|
||||
total: r.total ?? '0',
|
||||
descontoPerc: '0',
|
||||
obs: null,
|
||||
createdAt: r.dt_pedido.toISOString(),
|
||||
fonte: r.fonte as 'sar' | 'erp',
|
||||
}));
|
||||
}
|
||||
|
||||
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
|
||||
const idEmpresaMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
|
||||
interface Row {
|
||||
id_produto: number;
|
||||
descricao: string;
|
||||
unidade: string | null;
|
||||
qtd_total: string;
|
||||
num_pedidos: string;
|
||||
ultima_compra: Date;
|
||||
ultimo_preco: string;
|
||||
}
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||
SELECT
|
||||
i.produ AS id_produto,
|
||||
TRIM(p.descricao) AS descricao,
|
||||
TRIM(p.unidade) AS unidade,
|
||||
SUM(i.qtd)::numeric(15,3)::text AS qtd_total,
|
||||
COUNT(DISTINCT ped.id_pedido)::text AS num_pedidos,
|
||||
MAX(ped.data) AS ultima_compra,
|
||||
(
|
||||
SELECT i2.pruni::numeric(15,2)::text
|
||||
FROM sig.peditens i2
|
||||
JOIN sig.pedidos p2 ON p2.id_pedido = i2.id_pedido
|
||||
WHERE i2.produ = i.produ
|
||||
AND p2.clien = ped.clien
|
||||
AND p2.id_empresa = ${idEmpresa}
|
||||
AND p2.situa NOT IN (5)
|
||||
ORDER BY p2.data DESC, p2.id_pedido DESC
|
||||
LIMIT 1
|
||||
) AS ultimo_preco
|
||||
FROM sig.pedidos ped
|
||||
JOIN sig.peditens i ON i.id_pedido = ped.id_pedido
|
||||
JOIN gestao.produto p ON p.id_erp = i.produ
|
||||
AND p.id_empresa = ${idEmpresaMatriz}
|
||||
WHERE ped.clien = ${idCliente}
|
||||
AND ped.id_empresa = ${idEmpresa}
|
||||
AND ped.situa NOT IN (5)
|
||||
GROUP BY i.produ, p.descricao, p.unidade, ped.clien
|
||||
ORDER BY SUM(i.qtd) DESC
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
return rows.map((r) => ({
|
||||
idProduto: Number(r.id_produto),
|
||||
descricao: r.descricao,
|
||||
unidade: r.unidade,
|
||||
qtdTotal: parseFloat(r.qtd_total ?? '0'),
|
||||
numPedidos: parseInt(r.num_pedidos ?? '0', 10),
|
||||
ultimaCompra: (r.ultima_compra as Date).toISOString().slice(0, 10),
|
||||
ultimoPreco: parseFloat(r.ultimo_preco ?? '0'),
|
||||
}));
|
||||
}
|
||||
|
||||
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
interface Row {
|
||||
qtd_aberto: string;
|
||||
total_aberto: string;
|
||||
qtd_vencido: string;
|
||||
total_vencido: string;
|
||||
maior_atraso_dias: string;
|
||||
}
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||
SELECT
|
||||
COUNT(*)::text AS qtd_aberto,
|
||||
COALESCE(SUM(saldo), 0)::numeric(15,2)::text AS total_aberto,
|
||||
(COUNT(*) FILTER (WHERE dt_vencimento < CURRENT_DATE))::text AS qtd_vencido,
|
||||
COALESCE(SUM(saldo) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::numeric(15,2)::text AS total_vencido,
|
||||
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
|
||||
FROM sar.vw_ctr
|
||||
WHERE id_cliente = ${idCliente}
|
||||
AND situacao = 'A'
|
||||
AND saldo > 0
|
||||
`);
|
||||
|
||||
const r = rows[0];
|
||||
return {
|
||||
qtdAberto: parseInt(r?.qtd_aberto ?? '0', 10),
|
||||
totalAberto: parseFloat(r?.total_aberto ?? '0'),
|
||||
qtdVencido: parseInt(r?.qtd_vencido ?? '0', 10),
|
||||
totalVencido: parseFloat(r?.total_vencido ?? '0'),
|
||||
maiorAtrasoDias: parseInt(r?.maior_atraso_dias ?? '0', 10),
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(idCliente: number): Promise<ClientDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
@@ -388,6 +548,7 @@ export class ClientsService {
|
||||
|
||||
return {
|
||||
idCliente: Number(r.id_cliente),
|
||||
|
||||
idEmpresa: Number(r.id_empresa),
|
||||
nome: r.nome,
|
||||
razao: r.razao,
|
||||
@@ -413,4 +574,106 @@ export class ClientsService {
|
||||
dtAtual: r.dt_atual,
|
||||
};
|
||||
}
|
||||
|
||||
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
type CtrRow = {
|
||||
id_ctr: number;
|
||||
numero: string;
|
||||
prefixo: string;
|
||||
dt_emissao: Date;
|
||||
dt_vencimento: Date;
|
||||
saldo: string;
|
||||
};
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<CtrRow[]>(
|
||||
`
|
||||
SELECT
|
||||
id_ctr,
|
||||
TRIM(numero) AS numero,
|
||||
TRIM(prefixo) AS prefixo,
|
||||
dt_emissao,
|
||||
dt_vencimento,
|
||||
saldo::text AS saldo
|
||||
FROM sar.vw_ctr
|
||||
WHERE id_cliente = $1
|
||||
AND id_empresa = 1
|
||||
AND situacao = 'A'
|
||||
AND saldo > 0
|
||||
ORDER BY dt_vencimento ASC
|
||||
LIMIT $2
|
||||
`,
|
||||
idCliente,
|
||||
limit,
|
||||
);
|
||||
|
||||
const toDate = (d: Date | string): string =>
|
||||
d instanceof Date ? d.toISOString().slice(0, 10) : String(d).slice(0, 10);
|
||||
|
||||
return rows.map((r: CtrRow) => ({
|
||||
idCtr: Number(r.id_ctr),
|
||||
numero: r.numero,
|
||||
prefixo: r.prefixo,
|
||||
dtEmissao: toDate(r.dt_emissao),
|
||||
dtVencimento: toDate(r.dt_vencimento),
|
||||
saldo: Number(r.saldo),
|
||||
}));
|
||||
}
|
||||
|
||||
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
type NfRow = {
|
||||
id_nf: number;
|
||||
numero: number;
|
||||
serie: string;
|
||||
dt_emissao: Date;
|
||||
vl_nf: string;
|
||||
chave_acesso_nfe: string | null;
|
||||
};
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<NfRow[]>(
|
||||
`
|
||||
SELECT id_nf, numero, serie, dt_emissao, vl_nf, chave_acesso_nfe
|
||||
FROM (
|
||||
SELECT DISTINCT ON (nf.id_nf)
|
||||
nf.id_nf,
|
||||
nf.numero,
|
||||
TRIM(nf.serie) AS serie,
|
||||
nf.dt_emissao,
|
||||
nf.vl_nf::text AS vl_nf,
|
||||
TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe
|
||||
FROM gestao.nf nf
|
||||
JOIN sig.pedidos p
|
||||
ON p.numero = nf.num_entrega
|
||||
AND p.tipo = 'E'
|
||||
AND p.id_empresa IN (1, 9001)
|
||||
WHERE p.clien = $1
|
||||
AND nf.id_empresa = 1
|
||||
AND nf.status = 'E'
|
||||
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
|
||||
ORDER BY nf.id_nf
|
||||
) sub
|
||||
ORDER BY dt_emissao DESC
|
||||
LIMIT $2
|
||||
`,
|
||||
idCliente,
|
||||
limit,
|
||||
);
|
||||
|
||||
return rows.map((r: NfRow) => ({
|
||||
idNf: Number(r.id_nf),
|
||||
numero: Number(r.numero),
|
||||
serie: r.serie?.trim() ?? '',
|
||||
dtEmissao:
|
||||
(r.dt_emissao instanceof Date
|
||||
? r.dt_emissao.toISOString().split('T')[0]
|
||||
: String(r.dt_emissao)) ?? '',
|
||||
vlNf: Number(r.vl_nf),
|
||||
chaveAcessoNfe: r.chave_acesso_nfe?.trim() || null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,16 +35,14 @@ interface RealizadoGrupoRow {
|
||||
peso: string;
|
||||
}
|
||||
|
||||
interface RepRow {
|
||||
taxa_com: string;
|
||||
permitir_flex: number; // 0 ou 1 (char do ERP convertido)
|
||||
}
|
||||
|
||||
interface InativoRow {
|
||||
interface NaoPositivadoRow {
|
||||
id_cliente: number;
|
||||
nome: string;
|
||||
dt_ultima_compra: Date | null;
|
||||
ultima_compra_valor: string | null;
|
||||
razao: string | null;
|
||||
dt_ultimo_pedido: string | null; // YYYY-MM-DD ou null
|
||||
dias_sem_pedido: number | null;
|
||||
comprou_antes: boolean;
|
||||
whatsapp: string | null;
|
||||
}
|
||||
|
||||
interface InativosPorRepRow {
|
||||
@@ -92,24 +90,7 @@ export class DashboardService {
|
||||
: grRows.reduce((a, m) => a + Number(m.valor), 0);
|
||||
const metaDimensao = grRows.length > 0 ? ('grupo' as const) : ('global' as const);
|
||||
|
||||
// 2. Taxas do representante — fonte: gestao.vendedor (via vw_representantes)
|
||||
const repRows = await prisma.$queryRawUnsafe<RepRow[]>(`
|
||||
SELECT taxa_com::text, COALESCE(permitir_flex, 0) AS permitir_flex
|
||||
FROM vw_representantes
|
||||
WHERE codigo = ${codVendedor}
|
||||
LIMIT 1
|
||||
`);
|
||||
const commissionRate = repRows[0] ? Number(repRows[0].taxa_com) : 3;
|
||||
const permitirFlex = (repRows[0]?.permitir_flex ?? 0) === 1;
|
||||
|
||||
// 3. Taxa flex — fonte: sar.meta_representante (override SAR; default 1%)
|
||||
const flexOverride = await prisma.metaRepresentante.findUnique({
|
||||
where: { codVendedor_idEmpresa_ano_mes: { codVendedor, idEmpresa, ano: year, mes: month } },
|
||||
select: { taxaFlex: true },
|
||||
});
|
||||
const flexRate = flexOverride ? Number(flexOverride.taxaFlex) : 1;
|
||||
|
||||
// 4. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
|
||||
// 2. Atingido do mês — realizado = tudo menos Cancelado(5) e Pendente/não-transmitido(1).
|
||||
// Inclui Liberado(2), Enviado(3,6,92,95,200) e Faturado(4). Base: data do pedido.
|
||||
const monthStartStr = monthStart.toISOString().slice(0, 10);
|
||||
const monthEndStr = monthEnd.toISOString().slice(0, 10);
|
||||
@@ -198,32 +179,55 @@ export class DashboardService {
|
||||
const pct = targetAmount > 0 ? Math.round((atingido / targetAmount) * 100) : 0;
|
||||
const falta = Math.max(0, targetAmount - atingido);
|
||||
|
||||
const fixa = Math.round(atingido * commissionRate) / 100;
|
||||
const flex =
|
||||
permitirFlex && targetAmount > 0 && atingido >= targetAmount
|
||||
? Math.round(atingido * flexRate) / 100
|
||||
: 0;
|
||||
|
||||
// 7. Clientes inativos — sem pedido no ERP há >30 dias
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const inactiveClients = await prisma.$queryRawUnsafe<InativoRow[]>(`
|
||||
// Clientes não positivados no mês: ativos do representante sem pedido desde monthStart.
|
||||
// Junta o último pedido histórico (ERP+SAR) e o primeiro whatsapp ativo dos contatos.
|
||||
const naoPositivadosRows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
|
||||
WITH ultimo_pedido AS (
|
||||
SELECT id_cliente,
|
||||
MAX(dt_pedido) AS dt_max
|
||||
FROM (
|
||||
SELECT id_cliente, dt_pedido FROM vw_pedidos_erp
|
||||
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
|
||||
UNION ALL
|
||||
SELECT id_cliente, dt_pedido FROM sar.pedidos
|
||||
WHERE situa != 3 AND id_empresa = ${idEmpresa}
|
||||
) t
|
||||
GROUP BY id_cliente
|
||||
),
|
||||
pedido_mes AS (
|
||||
SELECT DISTINCT id_cliente FROM (
|
||||
SELECT id_cliente FROM vw_pedidos_erp
|
||||
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
UNION
|
||||
SELECT id_cliente FROM sar.pedidos
|
||||
WHERE situa != 3 AND id_empresa = ${idEmpresa}
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
) t
|
||||
)
|
||||
SELECT
|
||||
c.id_cliente,
|
||||
c.nome,
|
||||
MAX(p.dt_pedido) AS dt_ultima_compra,
|
||||
MAX(p.total)::text AS ultima_compra_valor
|
||||
TRIM(c.nome) AS nome,
|
||||
TRIM(c.razao) AS razao,
|
||||
TO_CHAR(up.dt_max, 'YYYY-MM-DD') AS dt_ultimo_pedido,
|
||||
(CURRENT_DATE - up.dt_max::date) AS dias_sem_pedido,
|
||||
(up.dt_max IS NOT NULL) AS comprou_antes,
|
||||
ct.whatsapp
|
||||
FROM vw_clientes c
|
||||
LEFT JOIN vw_pedidos_erp p
|
||||
ON p.id_cliente = c.id_cliente
|
||||
AND p.id_empresa = ${idEmpresa}
|
||||
AND p.situa != 5
|
||||
LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
|
||||
LEFT JOIN pedido_mes pm ON pm.id_cliente = c.id_cliente
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT TRIM(whatsapp) AS whatsapp
|
||||
FROM sar.vw_contatos
|
||||
WHERE id_corrent = c.id_cliente
|
||||
AND whatsapp IS NOT NULL AND TRIM(whatsapp) != ''
|
||||
AND ativo = 1
|
||||
LIMIT 1
|
||||
) ct ON true
|
||||
WHERE c.cod_vendedor = ${codVendedor}
|
||||
AND c.ativo = 1
|
||||
GROUP BY c.id_cliente, c.nome
|
||||
HAVING MAX(p.dt_pedido) IS NULL
|
||||
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
|
||||
ORDER BY dt_ultima_compra ASC NULLS FIRST
|
||||
LIMIT 10
|
||||
AND pm.id_cliente IS NULL
|
||||
ORDER BY up.dt_max ASC NULLS FIRST
|
||||
`);
|
||||
|
||||
// Metas por grupo: junta meta (GR) com realizado (itens), por cod_grupo.
|
||||
@@ -257,11 +261,20 @@ export class DashboardService {
|
||||
})
|
||||
.sort((a, b) => b.valorMeta - a.valorMeta);
|
||||
|
||||
const naoPositivados = naoPositivadosRows.map((c) => ({
|
||||
idCliente: Number(c.id_cliente),
|
||||
nome: c.nome,
|
||||
razao: c.razao ?? null,
|
||||
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
|
||||
ultimoPedido: c.dt_ultimo_pedido ?? null,
|
||||
comprouAntes: Boolean(c.comprou_antes),
|
||||
whatsapp: c.whatsapp ?? null,
|
||||
}));
|
||||
|
||||
return {
|
||||
meta: { atingido, total: targetAmount, pct, falta },
|
||||
metaDimensao,
|
||||
metasPorGrupo,
|
||||
comissao: { fixa, flex, total: fixa + flex },
|
||||
pedidosMes,
|
||||
pedidosRecentes: recentRows.map((o) => ({
|
||||
id: `erp-${o.id_pedido}`,
|
||||
@@ -281,14 +294,8 @@ export class DashboardService {
|
||||
createdAt: new Date(o.dt_pedido).toISOString(),
|
||||
fonte: 'erp' as const,
|
||||
})),
|
||||
clientesInativos: inactiveClients.map((c) => ({
|
||||
idCliente: Number(c.id_cliente),
|
||||
nome: c.nome,
|
||||
diasSemCompra: c.dt_ultima_compra
|
||||
? Math.floor((now.getTime() - c.dt_ultima_compra.getTime()) / 86_400_000)
|
||||
: 999,
|
||||
ultimaCompraValor: c.ultima_compra_valor,
|
||||
})),
|
||||
naoPositivados,
|
||||
totalNaoPositivados: naoPositivados.length,
|
||||
syncedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
33
apps/api/src/app/reports/reports.controller.ts
Normal file
33
apps/api/src/app/reports/reports.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
9
apps/api/src/app/reports/reports.module.ts
Normal file
9
apps/api/src/app/reports/reports.module.ts
Normal 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 {}
|
||||
223
apps/api/src/app/reports/reports.service.ts
Normal file
223
apps/api/src/app/reports/reports.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,35 @@
|
||||
import { Button, Descriptions, Tag, Table, Typography, Spin, Alert, Space, Divider } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Tag,
|
||||
Table,
|
||||
Typography,
|
||||
Spin,
|
||||
Alert,
|
||||
Space,
|
||||
Divider,
|
||||
Modal,
|
||||
Tooltip,
|
||||
message,
|
||||
Badge,
|
||||
} from 'antd';
|
||||
import { useState } from 'react';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
||||
import type { PedidoSummary } from '@sar/api-interface';
|
||||
import type { CtrTitulo, NotaFiscal, PedidoSummary, TopProduto } from '@sar/api-interface';
|
||||
import { SITUA_LABEL } from '@sar/api-interface';
|
||||
import { useClientDetail } from '../../lib/queries/clients';
|
||||
import { useClientOrders } from '../../lib/queries/orders';
|
||||
import {
|
||||
useClientDetail,
|
||||
useClientCtr,
|
||||
useClientCtrList,
|
||||
useClientNotasFiscais,
|
||||
useClientOrdersHistory,
|
||||
useClientTopProdutos,
|
||||
} from '../../lib/queries/clients';
|
||||
import { ClientContacts } from '../../components/contacts/ClientContacts';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const ACTIVITY_COLOR: Record<string, string> = {
|
||||
active: 'success',
|
||||
@@ -20,60 +42,182 @@ const ACTIVITY_LABEL: Record<string, string> = {
|
||||
inactive: 'Inativo',
|
||||
};
|
||||
|
||||
const orderColumns: TableColumnsType<PedidoSummary> = [
|
||||
{
|
||||
title: 'Nº',
|
||||
dataIndex: 'numPedSar',
|
||||
width: 120,
|
||||
render: (num: string, row: PedidoSummary) => (
|
||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||
{num}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'situa',
|
||||
width: 140,
|
||||
render: (s: number) => {
|
||||
const colorMap: Record<number, string> = {
|
||||
1: 'warning',
|
||||
2: 'processing',
|
||||
3: 'error',
|
||||
4: 'success',
|
||||
};
|
||||
return <Tag color={colorMap[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>;
|
||||
const SITUA_COLOR: Record<number, string> = {
|
||||
0: 'default',
|
||||
1: 'warning',
|
||||
2: 'processing',
|
||||
3: 'error',
|
||||
4: 'success',
|
||||
};
|
||||
|
||||
function orderColumns(withFonte: boolean): TableColumnsType<PedidoSummary> {
|
||||
const cols: TableColumnsType<PedidoSummary> = [
|
||||
{
|
||||
title: 'Nº Pedido',
|
||||
dataIndex: 'numPedSar',
|
||||
render: (num: string, row: PedidoSummary) => (
|
||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||
{num}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Total',
|
||||
dataIndex: 'total',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: (v: string) =>
|
||||
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||
},
|
||||
{
|
||||
title: 'Data',
|
||||
dataIndex: 'dtPedido',
|
||||
width: 130,
|
||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||
},
|
||||
];
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'situa',
|
||||
width: 130,
|
||||
render: (s: number) => (
|
||||
<Tag color={SITUA_COLOR[s] ?? 'default'}>{SITUA_LABEL[s] ?? String(s)}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Total',
|
||||
dataIndex: 'total',
|
||||
width: 130,
|
||||
align: 'right' as const,
|
||||
render: (v: string) =>
|
||||
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||
},
|
||||
{
|
||||
title: 'Data',
|
||||
dataIndex: 'dtPedido',
|
||||
width: 110,
|
||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||
},
|
||||
];
|
||||
|
||||
if (withFonte) {
|
||||
cols.push({
|
||||
title: 'Origem',
|
||||
dataIndex: 'fonte',
|
||||
width: 70,
|
||||
render: (f: string) => (
|
||||
<Tag color={f === 'sar' ? 'blue' : 'default'} style={{ fontSize: 11 }}>
|
||||
{f === 'sar' ? 'SAR' : 'ERP'}
|
||||
</Tag>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return cols;
|
||||
}
|
||||
|
||||
export function ClientDetailPage() {
|
||||
const { id } = useParams({ from: '/clientes/$id' });
|
||||
const idNum = Number(id);
|
||||
const navigate = useNavigate();
|
||||
const [addContactOpen, setAddContactOpen] = useState(false);
|
||||
const [ordersModalOpen, setOrdersModalOpen] = useState(false);
|
||||
const [produtosModalOpen, setProdutosModalOpen] = useState(false);
|
||||
const [notasModalOpen, setNotasModalOpen] = useState(false);
|
||||
|
||||
const { data: client, isLoading: clientLoading, error: clientError } = useClientDetail(idNum);
|
||||
const { data: orders, isLoading: ordersLoading } = useClientOrders(idNum);
|
||||
const { data: ctr } = useClientCtr(idNum);
|
||||
const { data: ctrList = [], isLoading: ctrListLoading } = useClientCtrList(idNum, 60);
|
||||
const { data: orders = [], isLoading: ordersLoading } = useClientOrdersHistory(idNum, 50);
|
||||
const { data: topProdutos = [], isLoading: produtosLoading } = useClientTopProdutos(idNum, 30);
|
||||
const { data: notas = [], isLoading: notasLoading } = useClientNotasFiscais(idNum, 30);
|
||||
|
||||
const [msgApi, msgCtx] = message.useMessage();
|
||||
|
||||
if (clientLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||
if (clientError || !client)
|
||||
return <Alert type="error" message="Cliente não encontrado." style={{ margin: 24 }} />;
|
||||
return <Alert type="error" title="Cliente não encontrado." style={{ margin: 24 }} />;
|
||||
|
||||
const today: string = new Date().toISOString().slice(0, 10);
|
||||
const ordersPreview = orders.slice(0, 5);
|
||||
const produtosPreview = topProdutos.slice(0, 5);
|
||||
const notasPreview = notas.slice(0, 5);
|
||||
const ctrPreview = ctrList.slice(0, 8);
|
||||
|
||||
const notaColumns: TableColumnsType<NotaFiscal> = [
|
||||
{
|
||||
title: 'NF',
|
||||
dataIndex: 'numero',
|
||||
width: 90,
|
||||
render: (v: number, r: NotaFiscal) => `${v}-${r.serie}`,
|
||||
},
|
||||
{
|
||||
title: 'Emissão',
|
||||
dataIndex: 'dtEmissao',
|
||||
width: 100,
|
||||
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
|
||||
},
|
||||
{
|
||||
title: 'Valor',
|
||||
dataIndex: 'vlNf',
|
||||
width: 130,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||
},
|
||||
{
|
||||
title: 'Chave NF-e',
|
||||
dataIndex: 'chaveAcessoNfe',
|
||||
render: (chave: string | null) =>
|
||||
chave ? (
|
||||
<Space size={4}>
|
||||
<Text
|
||||
style={{ fontSize: 11, fontFamily: 'monospace', color: '#666' }}
|
||||
ellipsis={{ tooltip: chave }}
|
||||
>
|
||||
{chave}
|
||||
</Text>
|
||||
<Tooltip title="Copiar chave">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<CopyOutlined />}
|
||||
onClick={() =>
|
||||
void navigator.clipboard
|
||||
.writeText(chave)
|
||||
.then(() => void msgApi.success('Chave copiada!'))
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const produtoColumns: TableColumnsType<TopProduto> = [
|
||||
{
|
||||
title: 'Produto',
|
||||
dataIndex: 'descricao',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Qtd Total',
|
||||
dataIndex: 'qtdTotal',
|
||||
width: 100,
|
||||
align: 'right' as const,
|
||||
render: (v: number, r: TopProduto) =>
|
||||
`${v.toLocaleString('pt-BR', { maximumFractionDigits: 0 })} ${r.unidade ?? ''}`.trim(),
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'numPedidos',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
},
|
||||
{
|
||||
title: 'Último Preço',
|
||||
dataIndex: 'ultimoPreco',
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (v: number) =>
|
||||
v > 0 ? v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—',
|
||||
},
|
||||
{
|
||||
title: 'Última Compra',
|
||||
dataIndex: 'ultimaCompra',
|
||||
width: 120,
|
||||
render: (v: string) => new Date(v + 'T12:00:00').toLocaleDateString('pt-BR'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ padding: 24, maxWidth: '100%', overflowX: 'hidden' }}>
|
||||
<Space align="center" style={{ marginBottom: 16 }} wrap>
|
||||
<Link to="/clientes">← Clientes</Link>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
@@ -82,6 +226,9 @@ export function ClientDetailPage() {
|
||||
<Tag color={ACTIVITY_COLOR[client.activityStatus]}>
|
||||
{ACTIVITY_LABEL[client.activityStatus]}
|
||||
</Tag>
|
||||
<Button icon={<UserAddOutlined />} onClick={() => setAddContactOpen(true)}>
|
||||
Novo Contato
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => void navigate({ to: '/pedidos/novo', search: { clientId: id } })}
|
||||
@@ -121,20 +268,306 @@ export function ClientDetailPage() {
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<ClientContacts idCliente={idNum} />
|
||||
{/* ── CTR + NF-e lado a lado ── */}
|
||||
{msgCtx}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
|
||||
{/* ── Contas a Receber ── */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<Space size={8}>
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
Contas a Receber
|
||||
</Text>
|
||||
{ctr && ctr.qtdAberto > 0 && (
|
||||
<Tag color={ctr.qtdVencido > 0 ? 'error' : 'warning'}>
|
||||
{ctr.qtdAberto} título{ctr.qtdAberto > 1 ? 's' : ''} —{' '}
|
||||
{ctr.totalAberto.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
<Divider style={{ margin: '0 0 10px' }} />
|
||||
<Table<CtrTitulo>
|
||||
rowKey="idCtr"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={ctrListLoading}
|
||||
dataSource={ctrPreview}
|
||||
locale={{ emptyText: 'Nenhum título em aberto.' }}
|
||||
scroll={{ x: 'max-content', y: ctrList.length > 8 ? 260 : undefined }}
|
||||
style={{ maxWidth: '100%' }}
|
||||
columns={[
|
||||
{
|
||||
title: 'Título / Parcela',
|
||||
dataIndex: 'numero',
|
||||
render: (num: string, r: CtrTitulo) => (
|
||||
<Space size={4}>
|
||||
<Text style={{ fontSize: 12 }}>
|
||||
{r.prefixo}-{num}
|
||||
</Text>
|
||||
{r.dtVencimento < today ? (
|
||||
<Badge
|
||||
status="error"
|
||||
text={
|
||||
<Text type="danger" style={{ fontSize: 11 }}>
|
||||
{Math.ceil(
|
||||
(new Date(today).getTime() - new Date(r.dtVencimento).getTime()) /
|
||||
86400000,
|
||||
)}
|
||||
d atraso
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Badge
|
||||
status="warning"
|
||||
text={
|
||||
<Text type="warning" style={{ fontSize: 11 }}>
|
||||
a vencer
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Vencimento',
|
||||
dataIndex: 'dtVencimento',
|
||||
width: 100,
|
||||
render: (v: string) => (
|
||||
<Text type={v < today ? 'danger' : undefined} style={{ fontSize: 12 }}>
|
||||
{new Date(v + 'T12:00:00').toLocaleDateString('pt-BR')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Saldo',
|
||||
dataIndex: 'saldo',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => (
|
||||
<Text strong style={{ fontSize: 12 }}>
|
||||
{v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
]}
|
||||
footer={
|
||||
ctrList.length > 8
|
||||
? () => (
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Exibindo 8 de {ctrList.length}
|
||||
{ctrList.length === 60 ? '+' : ''} títulos • total em aberto:{' '}
|
||||
{ctr?.totalAberto.toLocaleString('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL',
|
||||
})}
|
||||
</Text>
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Divider orientation="left">Últimos Pedidos</Divider>
|
||||
{/* ── Notas Fiscais ── */}
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
Notas Fiscais
|
||||
</Text>
|
||||
{notas.length > 5 && (
|
||||
<Button size="small" type="link" onClick={() => setNotasModalOpen(true)}>
|
||||
Ver todas ({notas.length}
|
||||
{notas.length === 30 ? '+' : ''})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Divider style={{ margin: '0 0 10px' }} />
|
||||
<Table<NotaFiscal>
|
||||
rowKey="idNf"
|
||||
size="small"
|
||||
pagination={false}
|
||||
loading={notasLoading}
|
||||
dataSource={notasPreview}
|
||||
locale={{ emptyText: 'Nenhuma nota fiscal.' }}
|
||||
style={{ maxWidth: '100%' }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
columns={notaColumns}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<span>Notas Fiscais — {client.razao ?? client.nome}</span>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
({notas.length}
|
||||
{notas.length === 30 ? '+' : ''} notas)
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
open={notasModalOpen}
|
||||
onCancel={() => setNotasModalOpen(false)}
|
||||
footer={null}
|
||||
width={900}
|
||||
centered
|
||||
destroyOnHidden
|
||||
>
|
||||
<Table<NotaFiscal>
|
||||
rowKey="idNf"
|
||||
columns={notaColumns}
|
||||
dataSource={notas}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
|
||||
size="small"
|
||||
scroll={{ y: 460, x: 'max-content' }}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
<ClientContacts
|
||||
idCliente={idNum}
|
||||
modalOpen={addContactOpen}
|
||||
onModalClose={() => setAddContactOpen(false)}
|
||||
/>
|
||||
|
||||
{/* ── Produtos Mais Comprados ── */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
Produtos Mais Comprados
|
||||
</Text>
|
||||
{topProdutos.length > 5 && (
|
||||
<Button size="small" type="link" onClick={() => setProdutosModalOpen(true)}>
|
||||
Ver todos ({topProdutos.length}
|
||||
{topProdutos.length === 30 ? '+' : ''})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Divider style={{ margin: '0 0 12px' }} />
|
||||
|
||||
<Table<TopProduto>
|
||||
rowKey="idProduto"
|
||||
columns={produtoColumns}
|
||||
dataSource={produtosPreview}
|
||||
loading={produtosLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhum produto encontrado.' }}
|
||||
style={{ marginBottom: 24, maxWidth: '100%' }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<span>Produtos — {client.razao ?? client.nome}</span>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
({topProdutos.length}
|
||||
{topProdutos.length === 30 ? '+' : ''} produtos)
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
open={produtosModalOpen}
|
||||
onCancel={() => setProdutosModalOpen(false)}
|
||||
footer={null}
|
||||
width={820}
|
||||
centered
|
||||
destroyOnHidden
|
||||
>
|
||||
<Table<TopProduto>
|
||||
rowKey="idProduto"
|
||||
columns={produtoColumns}
|
||||
dataSource={topProdutos}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
|
||||
size="small"
|
||||
scroll={{ y: 460 }}
|
||||
/>
|
||||
</Modal>
|
||||
|
||||
{/* ── Últimos Pedidos ── */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 6,
|
||||
}}
|
||||
>
|
||||
<Text strong style={{ fontSize: 14 }}>
|
||||
Últimos Pedidos
|
||||
</Text>
|
||||
{orders.length > 5 && (
|
||||
<Button size="small" type="link" onClick={() => setOrdersModalOpen(true)}>
|
||||
Ver todos ({orders.length}
|
||||
{orders.length === 50 ? '+' : ''})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<Divider style={{ margin: '0 0 12px' }} />
|
||||
|
||||
<Table<PedidoSummary>
|
||||
rowKey="id"
|
||||
columns={orderColumns}
|
||||
dataSource={orders ?? []}
|
||||
columns={orderColumns(false)}
|
||||
dataSource={ordersPreview}
|
||||
loading={ordersLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
|
||||
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
|
||||
style={{ marginBottom: 24, maxWidth: '100%' }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
<style>{`.row-pending td { background: #fffbe6 !important; }`}</style>
|
||||
|
||||
{/* ── Modal Ver Todos Pedidos ── */}
|
||||
<Modal
|
||||
title={
|
||||
<Space>
|
||||
<span>Pedidos — {client.razao ?? client.nome}</span>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
({orders.length}
|
||||
{orders.length === 50 ? '+' : ''} registros)
|
||||
</Text>
|
||||
</Space>
|
||||
}
|
||||
open={ordersModalOpen}
|
||||
onCancel={() => setOrdersModalOpen(false)}
|
||||
footer={null}
|
||||
width={780}
|
||||
centered
|
||||
destroyOnHidden
|
||||
>
|
||||
<Table<PedidoSummary>
|
||||
rowKey="id"
|
||||
columns={orderColumns(true)}
|
||||
dataSource={orders}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false, size: 'small' }}
|
||||
size="small"
|
||||
scroll={{ y: 460 }}
|
||||
rowClassName={(row) => (row.situa === 1 ? 'row-pending' : '')}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
557
apps/web/src/cockpits/rep/RelatoriosPage.tsx
Normal file
557
apps/web/src/cockpits/rep/RelatoriosPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,18 @@
|
||||
import { Card, Col, Flex, Progress, Row, Skeleton, Space, Table, Tag, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Flex,
|
||||
Progress,
|
||||
Row,
|
||||
Segmented,
|
||||
Skeleton,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
@@ -7,8 +21,9 @@ import {
|
||||
faCircleExclamation,
|
||||
faClipboardList,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { MetaItem, PedidoSummary } from '@sar/api-interface';
|
||||
import { WhatsAppOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import type { MetaItem, ClienteNaoPositivado, PedidoSummary } from '@sar/api-interface';
|
||||
import { SITUA_LABEL } from '@sar/api-interface';
|
||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||
import { useCurrentUser } from '../../lib/queries/auth';
|
||||
@@ -34,17 +49,19 @@ function greeting(): string {
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
return new Date().toLocaleDateString('pt-BR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
return new Date().toLocaleDateString('pt-BR', { day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
function num(v: number, dec = 0): string {
|
||||
return v.toLocaleString('pt-BR', { minimumFractionDigits: dec, maximumFractionDigits: dec });
|
||||
}
|
||||
|
||||
// Célula "realizado / meta" — realizado em destaque (verde se bateu), meta abaixo.
|
||||
function waLink(whatsapp: string): string {
|
||||
const digits = whatsapp.replace(/\D/g, '');
|
||||
const number = digits.startsWith('55') ? digits : `55${digits}`;
|
||||
return `https://wa.me/${number}`;
|
||||
}
|
||||
|
||||
function MetaCell({
|
||||
real,
|
||||
meta,
|
||||
@@ -131,6 +148,170 @@ const metaColumns: TableColumnsType<MetaItem> = [
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Não positivados ──────────────────────────────────────────────────────────
|
||||
|
||||
type OrdemNP = 'longe' | 'recentes';
|
||||
|
||||
function NaoPositivados({ clientes, total }: { clientes: ClienteNaoPositivado[]; total: number }) {
|
||||
const navigate = useNavigate();
|
||||
const [ordem, setOrdem] = useState<OrdemNP>('longe');
|
||||
const [expandido, setExpandido] = useState(false);
|
||||
const PAGE = 10;
|
||||
|
||||
const sorted = [...clientes].sort((a, b) => {
|
||||
if (ordem === 'longe') {
|
||||
return (b.diasSemPedido ?? 999) - (a.diasSemPedido ?? 999);
|
||||
}
|
||||
// 'recentes': compraram antes primeiro, ordenados por último pedido desc
|
||||
if (a.comprouAntes && !b.comprouAntes) return -1;
|
||||
if (!a.comprouAntes && b.comprouAntes) return 1;
|
||||
const da = a.ultimoPedido ?? '';
|
||||
const db = b.ultimoPedido ?? '';
|
||||
return db.localeCompare(da);
|
||||
});
|
||||
|
||||
const visible = expandido ? sorted : sorted.slice(0, PAGE);
|
||||
const restante = total - PAGE;
|
||||
|
||||
const columns: TableColumnsType<ClienteNaoPositivado> = [
|
||||
{
|
||||
title: 'Cliente',
|
||||
key: 'cliente',
|
||||
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||
<Text
|
||||
strong
|
||||
style={{ cursor: 'pointer', color: 'var(--jcs-blue)' }}
|
||||
onClick={() =>
|
||||
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
|
||||
}
|
||||
>
|
||||
{c.razao ?? c.nome}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Último pedido',
|
||||
key: 'ultimo',
|
||||
width: 160,
|
||||
render: (_: unknown, c: ClienteNaoPositivado) => {
|
||||
if (!c.comprouAntes) {
|
||||
return (
|
||||
<Tag color="default" style={{ borderRadius: 20 }}>
|
||||
nunca comprou
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
const dias = c.diasSemPedido ?? 999;
|
||||
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
|
||||
{dias}d sem pedido
|
||||
</Tag>
|
||||
{c.ultimoPedido && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{new Date(c.ultimoPedido).toLocaleDateString('pt-BR')}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'acoes',
|
||||
width: 96,
|
||||
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||
<Space size={6}>
|
||||
<Button
|
||||
size="small"
|
||||
style={{ borderRadius: 6 }}
|
||||
onClick={() =>
|
||||
void navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })
|
||||
}
|
||||
>
|
||||
Ficha
|
||||
</Button>
|
||||
{c.whatsapp && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
style={{ borderRadius: 6, background: '#25d366', borderColor: '#25d366' }}
|
||||
icon={<WhatsAppOutlined />}
|
||||
href={waLink(c.whatsapp)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
|
||||
Não positivados no mês
|
||||
{total > 0 && (
|
||||
<Tag color="orange" style={{ borderRadius: 20, fontWeight: 700 }}>
|
||||
{total}
|
||||
</Tag>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Segmented<OrdemNP>
|
||||
size="small"
|
||||
value={ordem}
|
||||
onChange={setOrdem}
|
||||
options={[
|
||||
{ label: 'Mais longe', value: 'longe' },
|
||||
{ label: 'Compraram antes', value: 'recentes' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{clientes.length === 0 ? (
|
||||
<Text type="secondary">Todos os clientes já positivados este mês. Ótimo trabalho!</Text>
|
||||
) : (
|
||||
<Flex vertical gap={12}>
|
||||
<Table<ClienteNaoPositivado>
|
||||
rowKey="idCliente"
|
||||
columns={columns}
|
||||
dataSource={visible}
|
||||
size="small"
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
{!expandido && total > PAGE && (
|
||||
<Button
|
||||
type="link"
|
||||
style={{ alignSelf: 'flex-start', padding: 0 }}
|
||||
onClick={() => setExpandido(true)}
|
||||
>
|
||||
Ver mais {restante} cliente{restante !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
)}
|
||||
{expandido && total > PAGE && (
|
||||
<Button
|
||||
type="link"
|
||||
style={{ alignSelf: 'flex-start', padding: 0 }}
|
||||
onClick={() => setExpandido(false)}
|
||||
>
|
||||
Recolher
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── RepPainel ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function RepPainel() {
|
||||
const { data, isLoading } = useRepDashboard();
|
||||
const { data: user } = useCurrentUser();
|
||||
@@ -140,13 +321,10 @@ export function RepPainel() {
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
<Skeleton active paragraph={{ rows: 2 }} />
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Col xs={24} md={14}>
|
||||
<Skeleton active />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Skeleton active />
|
||||
</Col>
|
||||
<Col xs={12} md={6}>
|
||||
<Col xs={24} md={10}>
|
||||
<Skeleton active />
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -157,10 +335,10 @@ export function RepPainel() {
|
||||
const {
|
||||
meta,
|
||||
metasPorGrupo = [],
|
||||
comissao,
|
||||
pedidosMes,
|
||||
pedidosRecentes = [],
|
||||
clientesInativos = [],
|
||||
naoPositivados = [],
|
||||
totalNaoPositivados = 0,
|
||||
syncedAt,
|
||||
} = data;
|
||||
|
||||
@@ -173,21 +351,21 @@ export function RepPainel() {
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{today()}
|
||||
{clientesInativos.length > 0 && (
|
||||
{totalNaoPositivados > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<span style={{ color: 'var(--orange)' }}>
|
||||
{clientesInativos.length} clientes inativos
|
||||
{totalNaoPositivados} clientes sem pedido no mês
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* Linha 1 — Meta + KPIs */}
|
||||
{/* Linha 1 — Meta + Pedidos do mês */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} md={12}>
|
||||
<Col xs={24} md={14}>
|
||||
<Card style={{ height: '100%' }}>
|
||||
<Flex vertical gap={16}>
|
||||
<Flex justify="space-between" align="flex-start">
|
||||
@@ -228,8 +406,8 @@ export function RepPainel() {
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Col xs={24} md={10}>
|
||||
<Card style={{ height: '100%' }}>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
PEDIDOS NO MÊS
|
||||
@@ -238,32 +416,14 @@ export function RepPainel() {
|
||||
{pedidosMes}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
<FontAwesomeIcon icon={faArrowTrendUp} /> últimos 30 dias
|
||||
<FontAwesomeIcon icon={faArrowTrendUp} /> mês corrente
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={12} md={6}>
|
||||
<Card>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
COMISSÃO ACUMULADA
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(comissao.total)}
|
||||
</Title>
|
||||
{comissao.flex > 0 && (
|
||||
<Text type="success" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
FLEX: {fmt(comissao.flex)}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Metas por Grupo — acompanhamento multi-medida do mês */}
|
||||
{/* Metas por Grupo */}
|
||||
{metasPorGrupo.length > 0 && (
|
||||
<Card
|
||||
title={
|
||||
@@ -339,67 +499,13 @@ export function RepPainel() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Linha 2 — Clientes inativos + Pedidos recentes */}
|
||||
{/* Linha 2 — Não positivados + Pedidos recentes */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faCircleExclamation} style={{ color: 'var(--orange)' }} />
|
||||
Clientes esfriando
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
clientesInativos.length > 0 ? (
|
||||
<Text type="secondary">{clientesInativos.length} clientes</Text>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{clientesInativos.length === 0 ? (
|
||||
<Text type="secondary">Nenhum cliente inativo. Ótimo trabalho!</Text>
|
||||
) : (
|
||||
<Flex vertical gap={12}>
|
||||
{clientesInativos.map((c) => (
|
||||
<Flex
|
||||
key={c.idCliente}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: 'var(--space-sm) var(--space-md)',
|
||||
borderRadius: 12,
|
||||
background: c.diasSemCompra > 60 ? '#fff7e6' : 'var(--bg-surface-alt)',
|
||||
}}
|
||||
>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
|
||||
<Text strong>{c.nome}</Text>
|
||||
</Link>
|
||||
{c.ultimaCompraValor && (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Última compra:{' '}
|
||||
<span className="tabular-nums">
|
||||
{Number(c.ultimaCompraValor).toLocaleString('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL',
|
||||
})}
|
||||
</span>
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Tag
|
||||
color={c.diasSemCompra > 60 ? 'orange' : 'default'}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{c.diasSemCompra >= 999 ? 'nunca comprou' : `${c.diasSemCompra}d`}
|
||||
</Tag>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
<Col xs={24} lg={14}>
|
||||
<NaoPositivados clientes={naoPositivados} total={totalNaoPositivados} />
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Col xs={24} lg={10}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Divider,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Row,
|
||||
Space,
|
||||
Table,
|
||||
Spin,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { MailOutlined, PhoneOutlined, PlusOutlined, WhatsAppOutlined } from '@ant-design/icons';
|
||||
import { MailOutlined, PhoneOutlined, WhatsAppOutlined } from '@ant-design/icons';
|
||||
import type { Contato } from '@sar/api-interface';
|
||||
import { useClientContacts, useCreateContato } from '../../lib/queries/clients';
|
||||
|
||||
@@ -26,7 +26,7 @@ function ContactActions({ contato }: { contato: Contato }) {
|
||||
const mail = contato.email?.trim();
|
||||
|
||||
return (
|
||||
<Space size={6}>
|
||||
<Space size={4} wrap>
|
||||
{wa && (
|
||||
<Tooltip title={`WhatsApp: ${wa}`}>
|
||||
<a href={`https://wa.me/55${wa.replace(/\D/g, '')}`} target="_blank" rel="noreferrer">
|
||||
@@ -51,7 +51,7 @@ function ContactActions({ contato }: { contato: Contato }) {
|
||||
<Tooltip title={mail}>
|
||||
<a href={`mailto:${mail}`}>
|
||||
<Tag icon={<MailOutlined />} color="default" style={{ margin: 0 }}>
|
||||
{mail.length > 24 ? mail.slice(0, 22) + '…' : mail}
|
||||
{mail.length > 22 ? mail.slice(0, 20) + '…' : mail}
|
||||
</Tag>
|
||||
</a>
|
||||
</Tooltip>
|
||||
@@ -60,45 +60,18 @@ function ContactActions({ contato }: { contato: Contato }) {
|
||||
);
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<Contato> = [
|
||||
{
|
||||
title: 'Nome',
|
||||
dataIndex: 'nome',
|
||||
render: (nome: string, r: Contato) => (
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 13 }}>
|
||||
{nome}
|
||||
</Text>
|
||||
{(r.cargo || r.departamento) && (
|
||||
<div>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{[r.cargo, r.departamento].filter(Boolean).join(' · ')}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Contato',
|
||||
key: 'contato',
|
||||
render: (_: unknown, r: Contato) => <ContactActions contato={r} />,
|
||||
},
|
||||
{
|
||||
title: 'Aniversário',
|
||||
dataIndex: 'dtAniversario',
|
||||
width: 120,
|
||||
render: (v: string | null) => (v ? new Date(v + 'T12:00:00').toLocaleDateString('pt-BR') : '—'),
|
||||
},
|
||||
];
|
||||
|
||||
interface Props {
|
||||
idCliente: number;
|
||||
modalOpen?: boolean;
|
||||
onModalClose?: () => void;
|
||||
}
|
||||
|
||||
export function ClientContacts({ idCliente }: Props) {
|
||||
export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: Props) {
|
||||
const { message } = App.useApp();
|
||||
const [open, setOpen] = useState(false);
|
||||
const open = modalOpen;
|
||||
const setOpen = (v: boolean) => {
|
||||
if (!v) onModalClose?.();
|
||||
};
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const { data: contacts = [], isLoading } = useClientContacts(idCliente);
|
||||
@@ -134,38 +107,53 @@ export function ClientContacts({ idCliente }: Props) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<Divider titlePlacement="left" style={{ margin: 0, flex: 1 }}>
|
||||
Contatos
|
||||
</Divider>
|
||||
<Button
|
||||
type="default"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => setOpen(true)}
|
||||
style={{ marginLeft: 16, borderRadius: 6 }}
|
||||
>
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
<Divider titlePlacement="left" style={{ marginBottom: 12 }}>
|
||||
Contatos
|
||||
</Divider>
|
||||
|
||||
<Table<Contato>
|
||||
rowKey="idContato"
|
||||
columns={columns}
|
||||
dataSource={contacts}
|
||||
loading={isLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhum contato cadastrado.' }}
|
||||
style={{ marginBottom: 24 }}
|
||||
/>
|
||||
{isLoading ? (
|
||||
<Spin style={{ display: 'block', margin: '16px auto' }} />
|
||||
) : contacts.length === 0 ? (
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ display: 'block', marginBottom: 24, textAlign: 'center' }}
|
||||
>
|
||||
Nenhum contato cadastrado.
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: 12,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{contacts.map((c: Contato) => (
|
||||
<Card
|
||||
key={c.idContato}
|
||||
size="small"
|
||||
styles={{ body: { padding: '10px 12px' } }}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
<Text strong style={{ fontSize: 13, display: 'block', marginBottom: 2 }}>
|
||||
{c.nome}
|
||||
</Text>
|
||||
{(c.cargo || c.departamento) && (
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 6 }}>
|
||||
{[c.cargo, c.departamento].filter(Boolean).join(' · ')}
|
||||
</Text>
|
||||
)}
|
||||
<ContactActions contato={c} />
|
||||
{c.dtAniversario && (
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginTop: 4 }}>
|
||||
Aniversário: {new Date(c.dtAniversario + 'T12:00:00').toLocaleDateString('pt-BR')}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
title="Novo Contato"
|
||||
@@ -178,7 +166,7 @@ export function ClientContacts({ idCliente }: Props) {
|
||||
confirmLoading={createMutation.isPending}
|
||||
okText="Cadastrar"
|
||||
cancelText="Cancelar"
|
||||
width={560}
|
||||
width={580}
|
||||
destroyOnHidden
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }} requiredMark="optional">
|
||||
@@ -190,40 +178,63 @@ export function ClientContacts({ idCliente }: Props) {
|
||||
<Input placeholder="Nome do contato" maxLength={100} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="cargo" label="Cargo">
|
||||
<Input placeholder="Comprador, Gerente..." maxLength={100} />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="cargo" label="Cargo">
|
||||
<Input placeholder="Comprador, Gerente..." maxLength={100} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="departamento" label="Departamento">
|
||||
<Input placeholder="Compras, Financeiro..." maxLength={100} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="departamento" label="Departamento">
|
||||
<Input placeholder="Compras, Financeiro..." maxLength={100} />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="whatsapp" label="WhatsApp">
|
||||
<Input
|
||||
placeholder="48999990000"
|
||||
maxLength={20}
|
||||
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="celular" label="Celular">
|
||||
<Input placeholder="48999990000" maxLength={20} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="whatsapp" label="WhatsApp">
|
||||
<Input
|
||||
placeholder="48999990000"
|
||||
maxLength={20}
|
||||
prefix={<WhatsAppOutlined style={{ color: '#25D366' }} />}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="telefone" label="Telefone fixo">
|
||||
<Input placeholder="4833330000" maxLength={20} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="email" label="E-mail">
|
||||
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="celular" label="Celular">
|
||||
<Input placeholder="48999990000" maxLength={20} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="telefone" label="Telefone fixo">
|
||||
<Input placeholder="4833330000" maxLength={20} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="email" label="E-mail">
|
||||
<Input placeholder="contato@empresa.com.br" maxLength={150} type="email" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="dtAniversario" label="Aniversário">
|
||||
<DatePicker format="DD/MM/YYYY" style={{ width: '100%' }} placeholder="dd/mm/aaaa" />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="dtAniversario" label="Aniversário">
|
||||
<DatePicker
|
||||
format="DD/MM/YYYY"
|
||||
style={{ width: '100%' }}
|
||||
placeholder="dd/mm/aaaa"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Form.Item name="anotacoes" label="Anotações">
|
||||
<Input.TextArea rows={3} placeholder="Observações sobre o contato..." />
|
||||
<Input.TextArea rows={2} placeholder="Observações sobre o contato..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
@@ -23,7 +23,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
useOfflineSync();
|
||||
|
||||
return (
|
||||
<Flex vertical style={{ minHeight: '100vh', background: 'var(--bg-body)' }}>
|
||||
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
|
||||
{!isOnline && (
|
||||
<Alert
|
||||
type="warning"
|
||||
@@ -35,14 +35,15 @@ export function AppShell({ children }: AppShellProps) {
|
||||
/>
|
||||
)}
|
||||
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
|
||||
<Flex flex={1}>
|
||||
<Flex flex={1} style={{ overflow: 'hidden' }}>
|
||||
<Sidebar />
|
||||
<main
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 'var(--space-xl)',
|
||||
overflow: 'auto',
|
||||
maxWidth: '100%',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -6,11 +6,8 @@ import {
|
||||
faClipboardList,
|
||||
faClipboardCheck,
|
||||
faFunnelDollar,
|
||||
faCalendarDays,
|
||||
faUsers,
|
||||
faBoxesStacked,
|
||||
faGear,
|
||||
faPercent,
|
||||
faFileInvoiceDollar,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import type { ItemType } from 'antd/es/menu/interface';
|
||||
@@ -28,11 +25,6 @@ export function Sidebar() {
|
||||
icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />,
|
||||
label: 'Painel',
|
||||
},
|
||||
{
|
||||
key: '/agenda',
|
||||
icon: <FontAwesomeIcon icon={faCalendarDays} fixedWidth />,
|
||||
label: 'Agenda e Rotas',
|
||||
},
|
||||
{
|
||||
key: '/clientes',
|
||||
icon: <FontAwesomeIcon icon={faUsers} fixedWidth />,
|
||||
@@ -58,24 +50,11 @@ export function Sidebar() {
|
||||
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
|
||||
label: 'Consulta ERP',
|
||||
},
|
||||
{
|
||||
key: '/comissao',
|
||||
icon: <FontAwesomeIcon icon={faPercent} fixedWidth />,
|
||||
label: 'Comissão / FLEX',
|
||||
},
|
||||
{
|
||||
type: 'divider',
|
||||
},
|
||||
{
|
||||
key: '/relatorios',
|
||||
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
||||
label: 'Relatórios',
|
||||
},
|
||||
{
|
||||
key: '/configuracoes',
|
||||
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
|
||||
label: 'Configurações',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,14 +5,24 @@ import {
|
||||
ClientNovoResultSchema,
|
||||
ContatoResultSchema,
|
||||
ContatoSchema,
|
||||
CtrSummarySchema,
|
||||
CtrTituloSchema,
|
||||
NotaFiscalSchema,
|
||||
PedidoSummarySchema,
|
||||
TopProdutoSchema,
|
||||
type ClientDetail,
|
||||
type ClientListQuery,
|
||||
type ClientListResponse,
|
||||
type ClientNovoResult,
|
||||
type Contato,
|
||||
type ContatoResult,
|
||||
type CtrSummary,
|
||||
type CtrTitulo,
|
||||
type CreateClientNovo,
|
||||
type CreateContato,
|
||||
type NotaFiscal,
|
||||
type PedidoSummary,
|
||||
type TopProduto,
|
||||
} from '@sar/api-interface';
|
||||
import { z } from 'zod';
|
||||
import { apiFetch } from '../api-client';
|
||||
@@ -52,6 +62,61 @@ export function useClientDetail(id: number | string | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useClientTopProdutos(idCliente: number | undefined, limit = 30) {
|
||||
return useQuery<TopProduto[]>({
|
||||
queryKey: ['clients', 'top-produtos', idCliente, limit],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`/clients/${idCliente}/top-produtos?limit=${limit}`);
|
||||
return z.array(TopProdutoSchema).parse(res);
|
||||
},
|
||||
enabled: !!idCliente,
|
||||
});
|
||||
}
|
||||
|
||||
export function useClientOrdersHistory(idCliente: number | undefined, limit = 50) {
|
||||
return useQuery<PedidoSummary[]>({
|
||||
queryKey: ['clients', 'orders-history', idCliente, limit],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`/clients/${idCliente}/orders-history?limit=${limit}`);
|
||||
return z.array(PedidoSummarySchema).parse(res);
|
||||
},
|
||||
enabled: !!idCliente,
|
||||
});
|
||||
}
|
||||
|
||||
export function useClientCtrList(idCliente: number | undefined, limit = 60) {
|
||||
return useQuery<CtrTitulo[]>({
|
||||
queryKey: ['clients', 'ctr-list', idCliente, limit],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`/clients/${idCliente}/ctr-list?limit=${limit}`);
|
||||
return z.array(CtrTituloSchema).parse(res);
|
||||
},
|
||||
enabled: !!idCliente,
|
||||
});
|
||||
}
|
||||
|
||||
export function useClientNotasFiscais(idCliente: number | undefined, limit = 30) {
|
||||
return useQuery<NotaFiscal[]>({
|
||||
queryKey: ['clients', 'notas', idCliente, limit],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`/clients/${idCliente}/notas?limit=${limit}`);
|
||||
return z.array(NotaFiscalSchema).parse(res);
|
||||
},
|
||||
enabled: !!idCliente,
|
||||
});
|
||||
}
|
||||
|
||||
export function useClientCtr(idCliente: number | undefined) {
|
||||
return useQuery<CtrSummary>({
|
||||
queryKey: ['clients', 'ctr', idCliente],
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`/clients/${idCliente}/ctr`);
|
||||
return CtrSummarySchema.parse(res);
|
||||
},
|
||||
enabled: !!idCliente,
|
||||
});
|
||||
}
|
||||
|
||||
export function useClientContacts(idCliente: number | undefined) {
|
||||
return useQuery<Contato[]>({
|
||||
queryKey: ['clients', 'contacts', idCliente],
|
||||
|
||||
37
apps/web/src/lib/queries/reports.ts
Normal file
37
apps/web/src/lib/queries/reports.ts
Normal 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}`)),
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { NewClientPage } from '../cockpits/rep/NewClientPage';
|
||||
import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
||||
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
||||
import { authStore } from './auth-store';
|
||||
@@ -125,6 +126,12 @@ const pedidosErpRoute = createRoute({
|
||||
component: OrdersErpPage,
|
||||
});
|
||||
|
||||
const relatoriosRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/relatorios',
|
||||
component: RelatoriosPage,
|
||||
});
|
||||
|
||||
const aprovacoes = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/aprovacoes',
|
||||
@@ -143,6 +150,7 @@ const routeTree = rootRoute.addChildren([
|
||||
pedidoPrintRoute,
|
||||
catalogoRoute,
|
||||
pedidosErpRoute,
|
||||
relatoriosRoute,
|
||||
aprovacoes,
|
||||
]);
|
||||
|
||||
|
||||
265
docs/plano-migracao-modelo-canonico.md
Normal file
265
docs/plano-migracao-modelo-canonico.md
Normal 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:** 2–3 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:** 3–4 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:** 5–8 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:** 5–8 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:** 4–6 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 (19–25 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*
|
||||
@@ -6,3 +6,4 @@ export * from './lib/product.contract';
|
||||
export * from './lib/dashboard.contract';
|
||||
export * from './lib/notifications.contract';
|
||||
export * from './lib/company.contract';
|
||||
export * from './lib/report.contract';
|
||||
|
||||
@@ -111,6 +111,54 @@ export const ContatoResultSchema = z.object({
|
||||
});
|
||||
export type ContatoResult = z.infer<typeof ContatoResultSchema>;
|
||||
|
||||
// ─── Produtos mais comprados por cliente ─────────────────────────────────────
|
||||
|
||||
export const TopProdutoSchema = z.object({
|
||||
idProduto: z.number().int(),
|
||||
descricao: z.string(),
|
||||
unidade: z.string().nullable(),
|
||||
qtdTotal: z.number(),
|
||||
numPedidos: z.number().int(),
|
||||
ultimaCompra: z.string(),
|
||||
ultimoPreco: z.number(),
|
||||
});
|
||||
export type TopProduto = z.infer<typeof TopProdutoSchema>;
|
||||
|
||||
// ─── Contas a Receber (resumo por cliente) ───────────────────────────────────
|
||||
|
||||
export const CtrSummarySchema = z.object({
|
||||
qtdAberto: z.number().int(),
|
||||
totalAberto: z.number(),
|
||||
qtdVencido: z.number().int(),
|
||||
totalVencido: z.number(),
|
||||
maiorAtrasoDias: z.number().int(),
|
||||
});
|
||||
export type CtrSummary = z.infer<typeof CtrSummarySchema>;
|
||||
|
||||
// ─── Contas a Receber (lista de títulos em aberto) ────────────────────────────
|
||||
|
||||
export const CtrTituloSchema = z.object({
|
||||
idCtr: z.number().int(),
|
||||
numero: z.string(),
|
||||
prefixo: z.string(),
|
||||
dtEmissao: z.string(),
|
||||
dtVencimento: z.string(),
|
||||
saldo: z.number(),
|
||||
});
|
||||
export type CtrTitulo = z.infer<typeof CtrTituloSchema>;
|
||||
|
||||
// ─── Notas Fiscais por cliente ───────────────────────────────────────────────
|
||||
|
||||
export const NotaFiscalSchema = z.object({
|
||||
idNf: z.number().int(),
|
||||
numero: z.number().int(),
|
||||
serie: z.string(),
|
||||
dtEmissao: z.string(),
|
||||
vlNf: z.number(),
|
||||
chaveAcessoNfe: z.string().nullable(),
|
||||
});
|
||||
export type NotaFiscal = z.infer<typeof NotaFiscalSchema>;
|
||||
|
||||
// ─── Municipio ───────────────────────────────────────────────────────────────
|
||||
|
||||
export const MunicipioSchema = z.object({
|
||||
|
||||
@@ -3,13 +3,16 @@ import { PedidoSummarySchema } from './order.contract';
|
||||
|
||||
// ADR 0006 revogado: OrderSummary → PedidoSummary, ids numéricos.
|
||||
|
||||
export const ClienteInativoSchema = z.object({
|
||||
export const ClienteNaoPositivadoSchema = z.object({
|
||||
idCliente: z.number().int(),
|
||||
nome: z.string(),
|
||||
diasSemCompra: z.number().int(),
|
||||
ultimaCompraValor: z.string().nullable(),
|
||||
razao: z.string().nullable(),
|
||||
diasSemPedido: z.number().int(), // 999 = nunca comprou
|
||||
ultimoPedido: z.string().nullable(), // ISO date YYYY-MM-DD
|
||||
comprouAntes: z.boolean(),
|
||||
whatsapp: z.string().nullable(),
|
||||
});
|
||||
export type ClienteInativo = z.infer<typeof ClienteInativoSchema>;
|
||||
export type ClienteNaoPositivado = z.infer<typeof ClienteNaoPositivadoSchema>;
|
||||
|
||||
// Dimensão de meta. O ERP (vw_metas.tipo) define como o cliente acompanha metas:
|
||||
// GL = global, GR = por grupo. Motor único; outras dimensões (marca/subgrupo/
|
||||
@@ -22,7 +25,7 @@ export type MetaDimensao = z.infer<typeof MetaDimensaoSchema>;
|
||||
export const MetaItemSchema = z.object({
|
||||
codigo: z.number().int().nullable(),
|
||||
rotulo: z.string(),
|
||||
pedidos: z.number().int(), // qtd de pedidos faturados no grupo (realizado)
|
||||
pedidos: z.number().int(),
|
||||
valorMeta: z.number(),
|
||||
valorReal: z.number(),
|
||||
qtdMeta: z.number(),
|
||||
@@ -31,8 +34,8 @@ export const MetaItemSchema = z.object({
|
||||
pesoReal: z.number(),
|
||||
fatorMeta: z.number(),
|
||||
fatorReal: z.number(),
|
||||
pct: z.number(), // % de valor (real/meta) — base da barra de progresso
|
||||
falta: z.number(), // valor faltante p/ a meta
|
||||
pct: z.number(),
|
||||
falta: z.number(),
|
||||
});
|
||||
export type MetaItem = z.infer<typeof MetaItemSchema>;
|
||||
|
||||
@@ -43,17 +46,12 @@ export const RepDashboardSchema = z.object({
|
||||
pct: z.number(),
|
||||
falta: z.number(),
|
||||
}),
|
||||
// Dimensão detectada do ERP e detalhamento por grupo (vazio quando global).
|
||||
metaDimensao: MetaDimensaoSchema.default('global'),
|
||||
metasPorGrupo: z.array(MetaItemSchema).default([]),
|
||||
comissao: z.object({
|
||||
fixa: z.number(),
|
||||
flex: z.number(),
|
||||
total: z.number(),
|
||||
}),
|
||||
pedidosMes: z.number().int(),
|
||||
pedidosRecentes: z.array(PedidoSummarySchema),
|
||||
clientesInativos: z.array(ClienteInativoSchema),
|
||||
naoPositivados: z.array(ClienteNaoPositivadoSchema),
|
||||
totalNaoPositivados: z.number().int(),
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
||||
|
||||
70
libs/shared/api-interface/src/lib/report.contract.ts
Normal file
70
libs/shared/api-interface/src/lib/report.contract.ts
Normal 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>;
|
||||
@@ -505,14 +505,18 @@ FROM gestao.marca;
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Contatos vinculados a clientes (gestao.contato)
|
||||
-- id_entidade = id_corrent::text (CHAR 20, auto-pad)
|
||||
-- Usado para envio via WhatsApp / ChatWoot futuramente
|
||||
-- O ERP grava id_entidade como 'COR#<id_corrent>' (formato nativo).
|
||||
-- O SAR legado gravava apenas o número puro. Ambos os formatos são aceitos.
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE OR REPLACE VIEW sar.vw_contatos AS
|
||||
SELECT
|
||||
c.id_contato,
|
||||
c.id_empresa,
|
||||
TRIM(c.id_entidade)::integer AS id_corrent,
|
||||
CASE
|
||||
WHEN TRIM(c.id_entidade) ~ '^COR#\d+$'
|
||||
THEN SUBSTRING(TRIM(c.id_entidade) FROM 5)::integer
|
||||
ELSE TRIM(c.id_entidade)::integer
|
||||
END AS id_corrent,
|
||||
c.id_chatwoot,
|
||||
TRIM(c.nome) AS nome,
|
||||
TRIM(c.empresa) AS empresa,
|
||||
@@ -530,9 +534,13 @@ SELECT
|
||||
TRIM(cr.razao::text) AS razao_cliente,
|
||||
cr.cod_vendedor
|
||||
FROM gestao.contato c
|
||||
LEFT JOIN sig.corrent cr
|
||||
ON cr.id_corrent = TRIM(c.id_entidade)::integer
|
||||
WHERE TRIM(c.id_entidade) ~ '^\d+$';
|
||||
LEFT JOIN sig.corrent cr ON cr.id_corrent = CASE
|
||||
WHEN TRIM(c.id_entidade) ~ '^COR#\d+$'
|
||||
THEN SUBSTRING(TRIM(c.id_entidade) FROM 5)::integer
|
||||
ELSE TRIM(c.id_entidade)::integer
|
||||
END
|
||||
WHERE TRIM(c.id_entidade) ~ '^\d+$'
|
||||
OR TRIM(c.id_entidade) ~ '^COR#\d+$';
|
||||
|
||||
-- =============================================================================
|
||||
-- PARTE 2 — TABELAS DE ESCRITA DO SAR
|
||||
|
||||
@@ -489,8 +489,14 @@ CREATE INDEX IF NOT EXISTS idx_sar_ctnov_sync ON sar.contatos_novos(sincroniz
|
||||
CREATE OR REPLACE FUNCTION sar.fn_sync_contato_to_erp()
|
||||
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
|
||||
DECLARE
|
||||
v_id_contato INTEGER;
|
||||
v_id_contato INTEGER;
|
||||
v_id_empresa INTEGER;
|
||||
BEGIN
|
||||
-- Normaliza empresa fiscal (9001) → empresa gerencial (1), padrão do ERP
|
||||
v_id_empresa := CASE WHEN NEW.id_empresa > 9000
|
||||
THEN NEW.id_empresa - 9000
|
||||
ELSE NEW.id_empresa END;
|
||||
|
||||
BEGIN
|
||||
INSERT INTO gestao.contato (
|
||||
id_empresa, id_entidade,
|
||||
@@ -499,8 +505,8 @@ BEGIN
|
||||
telefone, ramal, celular, whatsapp, email,
|
||||
ativo, anotacoes
|
||||
) VALUES (
|
||||
NEW.id_empresa,
|
||||
NEW.id_corrent::text,
|
||||
v_id_empresa,
|
||||
'COR#' || NEW.id_corrent::text, -- formato nativo do ERP
|
||||
COALESCE(LEFT(NEW.nome, 100), ''),
|
||||
LEFT(NEW.empresa, 100),
|
||||
LEFT(NEW.cargo, 100),
|
||||
|
||||
Reference in New Issue
Block a user