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

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

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

View File

@@ -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(),
};
}