Compare commits

..

4 Commits

Author SHA1 Message Date
9b855f63b2 feat(api,web): regras de desconto por pauta e cards de supervisor na equipe
Regras de Desconto (Politicas Comerciais):
- Nova dimensao de escopo por pauta de precos: cria 1 regra por pauta
  selecionada (idPautas[]), aplicada a todos os produtos da pauta.
- Coluna id_pauta em sar.regras_desconto (migration idempotente + schema
  canonico); motor de alcada (orders) casa regra<->pedido por idPauta.
- Remove a aba/endpoints "Desconto por Representante" (gestao de
  alcada_desconto); o teto por rep continua no motor (default 5%) e no
  carrinho (/politicas/alcada), agora liberado via Regra de Desconto.

Aba Equipe do gerente:
- GET /equipe/supervisores: cards de disputa por supervisor (faturamento,
  meta, positivacao, variacao mensal, reps ativos).
- SupervisorDetalheModal: detalhe do time (ranking + positivacao) reusando
  /dashboard/supervisor escopado por codSupervisor.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 21:12:00 +00:00
1ce8d96fea refactor(api,web): remove fila de aprovacao e consolida painel do supervisor
Alcada agora e bloqueio duro no pedido (sem fluxo de aprovar/recusar):
remove ApprovalQueuePage, CarteirePage e rota /aprovacoes, absorvendo
carteira e acompanhamento de pedidos dentro do SupervisorPainel.
Colunas de desempenho por representante extraidas para componente
compartilhado (rep-performance-columns) entre gerente e supervisor.
Ajusta dashboard e orders (controller/service/contract) para o novo fluxo.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:34:39 +00:00
ebf68523dd feat(api,web): card condicoes especiais no painel do representante
Card expansivel no painel do rep com promocoes e regras de desconto
vigentes hoje para o representante logado. Endpoints vigentes agora
resolvem nomes de produto/grupo/subgrupo; hooks com refetch de 60s
para a condicao sumir logo apos encerrar ou inativar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 17:31:55 +00:00
78612d59bd feat(api,web): mapa do brasil com faturamento por estado no painel gerencial
- KPIs financeiros agrupados (faturamento, meta, atingimento, falta) em metade da primeira dobra
- MapaBrasilCard: choropleth por UF (@svg-maps/brazil), tooltip, ranking lateral e modal Detalhar com representantes por estado
- API: faturamentoPorUf no /dashboard/manager (pedido -> cliente -> municipio -> UF)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 11:22:21 +00:00
40 changed files with 2241 additions and 2092 deletions

View File

@@ -0,0 +1,6 @@
-- AlterTable: sar.regras_desconto -- adiciona escopo por pauta de precos.
-- Regra pode ser restrita a uma pauta especifica; id_pauta NULL = qualquer pauta.
-- provision-client.ts roda scripts/sar-erp-schema.sql (idempotente) antes de
-- `prisma migrate deploy`, entao este ALTER precisa ser idempotente tambem.
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
ALTER TABLE sar.regras_desconto ADD COLUMN IF NOT EXISTS id_pauta INTEGER;

View File

@@ -174,7 +174,8 @@ model Promocao {
// ─── RegraDesconto (Políticas Comerciais) ──────────────────────────────────── // ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
// //
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou // Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos". // subgrupo de produto, pauta de preços e/ou representante, com vigência.
// Campos nulos = "todos" (id_pauta nulo = qualquer pauta).
model RegraDesconto { model RegraDesconto {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
@@ -183,6 +184,7 @@ model RegraDesconto {
codGrupo Int? @map("cod_grupo") codGrupo Int? @map("cod_grupo")
codSubgrupo Int? @map("cod_subgrupo") codSubgrupo Int? @map("cod_subgrupo")
codVendedor Int? @map("cod_vendedor") codVendedor Int? @map("cod_vendedor")
idPauta Int? @map("id_pauta")
descPct Decimal @db.Decimal(5, 2) @map("desc_pct") descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
dataInicio DateTime @db.Date @map("data_inicio") dataInicio DateTime @db.Date @map("data_inicio")
dataFim DateTime @db.Date @map("data_fim") dataFim DateTime @db.Date @map("data_fim")

View File

@@ -1,9 +1,19 @@
import { Body, Controller, ForbiddenException, Get, HttpCode, Put, Query } from '@nestjs/common'; import {
Body,
Controller,
ForbiddenException,
Get,
HttpCode,
ParseIntPipe,
Put,
Query,
} from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { import {
SaveMetaPositivacaoBodySchema, SaveMetaPositivacaoBodySchema,
type RepDashboard, type RepDashboard,
type RepInativosDetail,
type SupervisorDashboard, type SupervisorDashboard,
type ManagerDashboard, type ManagerDashboard,
type SaveMetaPositivacaoBody, type SaveMetaPositivacaoBody,
@@ -35,9 +45,21 @@ export class DashboardController {
} }
@Get('supervisor') @Get('supervisor')
supervisorDashboard(): Promise<SupervisorDashboard> { supervisorDashboard(
@Query('codSupervisor') codSupervisor?: string,
): Promise<SupervisorDashboard> {
this.assertGestor(); this.assertGestor();
return this.dashboard.supervisorDashboard(); return this.dashboard.supervisorDashboard(
codSupervisor ? parseInt(codSupervisor, 10) : undefined,
);
}
@Get('supervisor/inativos')
supervisorInativos(
@Query('codVendedor', ParseIntPipe) codVendedor: number,
): Promise<RepInativosDetail> {
this.assertGestor();
return this.dashboard.supervisorInativos(codVendedor);
} }
@Put('manager/meta-positivacao-dia') @Put('manager/meta-positivacao-dia')

View File

@@ -2,6 +2,7 @@ import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { import type {
RepDashboard, RepDashboard,
RepInativosDetail,
SupervisorDashboard, SupervisorDashboard,
ManagerDashboard, ManagerDashboard,
RankingRep, RankingRep,
@@ -11,8 +12,6 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util'; import { getTeamCodes } from '../workspace/team-scope.util';
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado // Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
const SITUA_PENDENTE = 1;
// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo. // vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
const TIPO_META_GLOBAL = 'GL'; const TIPO_META_GLOBAL = 'GL';
@@ -422,6 +421,14 @@ export class DashboardService {
cod_vendedor: number; cod_vendedor: number;
novos: string; novos: string;
} }
interface UfRepRow {
uf: string;
cod_vendedor: number;
nome_vendedor: string | null;
pedidos: string;
clientes: string;
faturamento: string;
}
const [ const [
statsRows, statsRows,
@@ -433,6 +440,7 @@ export class DashboardService {
realizadoGrupoRows, realizadoGrupoRows,
positivacaoDiariaRows, positivacaoDiariaRows,
novosClientesRows, novosClientesRows,
ufRepRows,
] = await Promise.all([ ] = await Promise.all([
prisma.$queryRawUnsafe<TotalRow[]>(` prisma.$queryRawUnsafe<TotalRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
@@ -555,6 +563,31 @@ export class DashboardService {
AND f.primeira_compra <= '${monthEndStr}' AND f.primeira_compra <= '${monthEndStr}'
GROUP BY c.cod_vendedor GROUP BY c.cod_vendedor
`), `),
// Faturamento por UF (município do cliente) e representante.
// vw_clientes pode repetir o cliente por empresa → LATERAL pega 1 município.
prisma.$queryRawUnsafe<UfRepRow[]>(`
SELECT COALESCE(NULLIF(TRIM(loc.uf), ''), 'ND') AS uf,
p.cod_vendedor,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome_vendedor,
COUNT(*)::text AS pedidos,
COUNT(DISTINCT p.id_cliente)::text AS clientes,
COALESCE(SUM(p.total), 0)::text AS faturamento
FROM vw_pedidos_erp p
LEFT JOIN LATERAL (
SELECT mu.uf::text AS uf
FROM vw_clientes c
JOIN sar.vw_municipios mu ON mu.id_municipio = c.id_municipio
WHERE c.id_cliente = p.id_cliente
LIMIT 1
) loc ON true
WHERE p.id_empresa = ${idEmpresa}
AND p.situa NOT IN (1, 5)
AND p.dt_pedido >= '${monthStartStr}'
AND p.dt_pedido <= '${monthEndStr}'
GROUP BY 1, p.cod_vendedor
ORDER BY 1, SUM(p.total) DESC
`),
]); ]);
const pedidosMes = Number(statsRows[0]?.count ?? 0); const pedidosMes = Number(statsRows[0]?.count ?? 0);
@@ -626,6 +659,38 @@ export class DashboardService {
}; };
}); });
// Agrega as linhas UF×rep em UF → { totais, reps[] } ordenado por faturamento.
const porUf = new Map<
string,
{ faturamento: number; pedidos: number; clientes: number; reps: UfRepRow[] }
>();
for (const r of ufRepRows) {
const acc = porUf.get(r.uf) ?? { faturamento: 0, pedidos: 0, clientes: 0, reps: [] };
acc.faturamento += Number(r.faturamento);
acc.pedidos += Number(r.pedidos);
acc.clientes += Number(r.clientes);
acc.reps.push(r);
porUf.set(r.uf, acc);
}
const faturamentoPorUf = [...porUf.entries()]
.map(([uf, t]) => ({
uf,
faturamento: t.faturamento,
pedidos: t.pedidos,
clientes: t.clientes,
pct: faturamentoMes > 0 ? Math.round((t.faturamento / faturamentoMes) * 1000) / 10 : 0,
reps: t.reps
.map((r) => ({
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome_vendedor ?? null,
faturamento: Number(r.faturamento),
pedidos: Number(r.pedidos),
clientes: Number(r.clientes),
}))
.sort((a, b) => b.faturamento - a.faturamento),
}))
.sort((a, b) => b.faturamento - a.faturamento);
return { return {
faturamentoMes, faturamentoMes,
pedidosMes, pedidosMes,
@@ -640,6 +705,7 @@ export class DashboardService {
dia: r.dia, dia: r.dia,
clientes: Number(r.clientes), clientes: Number(r.clientes),
})), })),
faturamentoPorUf,
metaPositivacaoDia: config?.metaPositivacaoDia ?? null, metaPositivacaoDia: config?.metaPositivacaoDia ?? null,
syncedAt: now.toISOString(), syncedAt: now.toISOString(),
}; };
@@ -663,31 +729,27 @@ export class DashboardService {
}); });
} }
async supervisorDashboard(): Promise<SupervisorDashboard> { async supervisorDashboard(codSupervisor?: number): Promise<SupervisorDashboard> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const now = new Date(); const now = new Date();
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes); // Supervisor vê só a própria equipe (cod_supervisor em vw_representantes).
// gerente/admin acessando este painel veem a empresa toda. // Gerente/admin: sem codSupervisor veem a empresa toda; com codSupervisor
// (Detalhar da aba Equipe) veem o time daquele supervisor. codSupervisor é
// ignorado para role 'supervisor' — não pode espiar outra equipe.
const role = this.cls.get('role'); const role = this.cls.get('role');
const userId = this.cls.get('userId'); const userId = this.cls.get('userId');
const team = let teamCode: number | null = null;
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null; if (role === 'supervisor') {
teamCode = userId ? parseInt(userId, 10) : 0;
} else if (codSupervisor && codSupervisor > 0) {
teamCode = codSupervisor;
}
const team = teamCode ? await getTeamCodes(prisma, teamCode) : null;
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : ''); const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
const approvalQueue = await prisma.pedido.findMany({
where: {
idEmpresa,
situa: SITUA_PENDENTE,
...(team ? { codVendedor: { in: team } } : {}),
},
orderBy: { dtPedido: 'asc' },
take: 50,
});
// Pedidos do dia — lê do ERP (situa != 5=Cancelado) // Pedidos do dia — lê do ERP (situa != 5=Cancelado)
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const todayStr = todayStart.toISOString().slice(0, 10); const todayStr = todayStart.toISOString().slice(0, 10);
@@ -718,7 +780,152 @@ export class DashboardService {
`), `),
]); ]);
// Top 3 reps com mais clientes inativos (>30 dias sem compra no ERP) // Métricas do mês corrente da equipe — mesmas contas do painel gerencial,
// escopadas pelos códigos da equipe (gerente/admin sem filtro = empresa toda).
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
const year = now.getFullYear();
const month = now.getMonth() + 1;
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
interface RankingRow {
cod_vendedor: number;
nome: string | null;
pedidos: string;
clientes_atendidos: string;
faturamento: string;
meta_valor: string | null;
}
interface MetaTotalRow {
meta_total: string;
}
interface PositivacaoRow {
cod_vendedor: number;
nome_vendedor: string | null;
total_clientes: string;
clientes_positivados: string;
}
interface NovosClientesRow {
cod_vendedor: number;
novos: string;
}
const [mesRows, rankingRows, metaTotalRows, positivacaoRows, novosClientesRows] =
await Promise.all([
prisma.$queryRawUnsafe<DayRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
${teamSqlFilter('cod_vendedor')}
`),
prisma.$queryRawUnsafe<RankingRow[]>(`
SELECT p.cod_vendedor,
(SELECT r.nome FROM vw_representantes r
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome,
COUNT(*)::text AS pedidos,
COUNT(DISTINCT p.id_cliente)::text AS clientes_atendidos,
COALESCE(SUM(p.total), 0)::text AS faturamento,
(SELECT SUM(m.valor)::text FROM sar.vw_metas m
WHERE m.id_empresa = ${idEmpresaMatriz}
AND m.cod_vendedor = p.cod_vendedor
AND m.ano = ${year}
AND m.mes = ${month}
AND TRIM(m.tipo) = '${TIPO_META_GRUPO}') AS meta_valor
FROM vw_pedidos_erp p
WHERE p.id_empresa = ${idEmpresa}
AND p.situa NOT IN (1, 5)
AND p.dt_pedido >= '${monthStartStr}'
AND p.dt_pedido <= '${monthEndStr}'
${teamSqlFilter('p.cod_vendedor')}
GROUP BY p.cod_vendedor
ORDER BY SUM(p.total) DESC
LIMIT 10
`),
prisma.$queryRawUnsafe<MetaTotalRow[]>(`
SELECT COALESCE(SUM(valor), 0)::text AS meta_total
FROM sar.vw_metas
WHERE id_empresa = ${idEmpresaMatriz}
AND ano = ${year}
AND mes = ${month}
AND TRIM(tipo) = '${TIPO_META_GRUPO}'
${teamSqlFilter('cod_vendedor')}
`),
prisma.$queryRawUnsafe<PositivacaoRow[]>(`
SELECT
c.cod_vendedor,
(SELECT r.nome FROM vw_representantes r WHERE r.codigo = c.cod_vendedor LIMIT 1) AS nome_vendedor,
COUNT(DISTINCT c.id_cliente)::text AS total_clientes,
COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS clientes_positivados
FROM vw_clientes c
LEFT JOIN (
SELECT DISTINCT id_cliente, cod_vendedor
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor
WHERE c.cod_vendedor > 0
${teamSqlFilter('c.cod_vendedor')}
GROUP BY c.cod_vendedor
HAVING COUNT(DISTINCT c.id_cliente) > 0
ORDER BY COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END) DESC
`),
prisma.$queryRawUnsafe<NovosClientesRow[]>(`
SELECT c.cod_vendedor, COUNT(*)::text AS novos
FROM (
SELECT id_cliente, MIN(dt_pedido) AS primeira_compra
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
GROUP BY id_cliente
) f
JOIN (SELECT DISTINCT id_cliente, cod_vendedor FROM vw_clientes) c
ON c.id_cliente = f.id_cliente
WHERE f.primeira_compra >= '${monthStartStr}'
AND f.primeira_compra <= '${monthEndStr}'
${teamSqlFilter('c.cod_vendedor')}
GROUP BY c.cod_vendedor
`),
]);
const pedidosMes = Number(mesRows[0]?.count ?? 0);
const faturamentoMes = Number(mesRows[0]?.total ?? 0);
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
const rankingReps: RankingRep[] = rankingRows.map((r) => {
const fat = Number(r.faturamento);
const meta = r.meta_valor ? Number(r.meta_valor) : 0;
return {
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome ?? null,
pedidos: Number(r.pedidos),
clientesAtendidos: Number(r.clientes_atendidos),
faturamento: fat,
pctMeta: meta > 0 ? Math.round((fat / meta) * 100) : 0,
};
});
const novosMap = new Map(
novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]),
);
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
const total = Number(r.total_clientes);
const positivados = Number(r.clientes_positivados);
return {
codVendedor: Number(r.cod_vendedor),
nomeVendedor: r.nome_vendedor ?? null,
totalClientes: total,
clientesPositivados: positivados,
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0,
};
});
// Reps com mais clientes inativos (>30 dias sem compra no ERP)
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(` const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
SELECT inativos.cod_vendedor, SELECT inativos.cod_vendedor,
@@ -741,50 +948,10 @@ export class DashboardService {
) inativos ) inativos
GROUP BY inativos.cod_vendedor GROUP BY inativos.cod_vendedor
ORDER BY COUNT(*) DESC ORDER BY COUNT(*) DESC
LIMIT 3 LIMIT 10
`); `);
// Resolve nomes de cliente e representante da fila (pedidos SAR só têm os códigos)
const repCods = [...new Set(approvalQueue.map((p) => p.codVendedor))];
const cliIds = [...new Set(approvalQueue.map((p) => p.idCliente))];
const [repNameRows, cliNameRows] = await Promise.all([
repCods.length
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
)
: Promise.resolve([]),
cliIds.length
? prisma.$queryRawUnsafe<
{ id_cliente: number; nome: string | null; razao: string | null }[]
>(
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
)
: Promise.resolve([]),
]);
const repNameMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
const cliNameMap = new Map(
cliNameRows.map((c) => [Number(c.id_cliente), { nome: c.nome, razao: c.razao }]),
);
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
id: o.id,
numPedSar: o.numPedSar,
idCliente: o.idCliente,
nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null,
razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null,
codVendedor: o.codVendedor,
nomeVendedor: repNameMap.get(o.codVendedor) ?? null,
situa: o.situa,
dtPedido: o.dtPedido.toISOString(),
total: String(o.total),
descontoPerc: String(o.descontoPerc),
obs: o.obs,
createdAt: o.createdAt.toISOString(),
fonte: 'sar' as const,
});
return { return {
approvalQueue: approvalQueue.map(mapPedido),
pedidosDia: { pedidosDia: {
count: Number(todayRows[0]?.count ?? 0), count: Number(todayRows[0]?.count ?? 0),
total: Number(todayRows[0]?.total ?? 0), total: Number(todayRows[0]?.total ?? 0),
@@ -796,7 +963,93 @@ export class DashboardService {
nomeVendedor: r.nome_vendedor ?? null, nomeVendedor: r.nome_vendedor ?? null,
inativosCount: parseInt(r.inativos_count, 10), inativosCount: parseInt(r.inativos_count, 10),
})), })),
equipeMes: {
faturamento: faturamentoMes,
pedidos: pedidosMes,
ticketMedio: pedidosMes > 0 ? faturamentoMes / pedidosMes : 0,
metaTotal,
pctMeta: metaTotal > 0 ? Math.round((faturamentoMes / metaTotal) * 100) : 0,
},
rankingReps,
positivacaoReps,
syncedAt: now.toISOString(), syncedAt: now.toISOString(),
}; };
} }
// Clientes inativos (>30 dias sem compra) de um representante — detalhe do
// card "Inativos por Rep". Supervisor só consulta reps da própria equipe;
// gerente/admin consultam qualquer rep.
async supervisorInativos(codVendedor: number): Promise<RepInativosDetail> {
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 role = this.cls.get('role');
const userId = this.cls.get('userId');
if (role === 'supervisor') {
const team = await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0);
if (!team.includes(codVendedor)) {
throw new ForbiddenException('Representante fora da sua equipe');
}
}
// Mesmo corte do resumo inativosPorRep: sem compra há mais de 30 dias.
const cutoffStr = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const rows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
WITH ultimo_pedido AS (
SELECT id_cliente, MAX(dt_pedido) AS dt_max
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa != 5
GROUP BY id_cliente
)
SELECT
c.id_cliente,
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 ultimo_pedido up ON up.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
AND (up.dt_max IS NULL OR up.dt_max < '${cutoffStr}')
ORDER BY up.dt_max ASC NULLS FIRST
`);
const nomeRows = await prisma.$queryRawUnsafe<{ nome: string | null }[]>(
`SELECT nome FROM vw_representantes WHERE codigo = ${codVendedor} LIMIT 1`,
);
// Deduplica por idCliente — vw_clientes pode repetir o cliente por empresa.
const seen = new Set<number>();
const clientes = rows
.filter((c) => {
const id = Number(c.id_cliente);
if (seen.has(id)) return false;
seen.add(id);
return true;
})
.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 { codVendedor, nomeVendedor: nomeRows[0]?.nome ?? null, clientes };
}
} }

View File

@@ -1,5 +1,5 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get } from '@nestjs/common';
import type { EquipeResponse } from '@sar/api-interface'; import type { EquipeResponse, SupervisoresResponse } from '@sar/api-interface';
import { EquipeService } from './equipe.service'; import { EquipeService } from './equipe.service';
@Controller({ path: 'equipe' }) @Controller({ path: 'equipe' })
@@ -10,4 +10,9 @@ export class EquipeController {
listEquipe(): Promise<EquipeResponse> { listEquipe(): Promise<EquipeResponse> {
return this.equipe.listEquipe(); return this.equipe.listEquipe();
} }
@Get('supervisores')
listSupervisores(): Promise<SupervisoresResponse> {
return this.equipe.listSupervisores();
}
} }

View File

@@ -1,6 +1,6 @@
import { ForbiddenException, Injectable } from '@nestjs/common'; import { ForbiddenException, Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { EquipeResponse } from '@sar/api-interface'; import type { EquipeResponse, SupervisorCard, SupervisoresResponse } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
function matrizEmpresa(idEmpresa: number): number { function matrizEmpresa(idEmpresa: number): number {
@@ -110,4 +110,173 @@ export class EquipeService {
syncedAt: now.toISOString(), syncedAt: now.toISOString(),
}; };
} }
// Cards de supervisor da aba Equipe: agrega as métricas do mês por equipe.
// Time de um supervisor = reps cujo cod_supervisor aponta para ele + ele
// próprio (mesma regra de getTeamCodes, que o Detalhe reusa via
// /dashboard/supervisor?codSupervisor=). Agregação feita em JS a partir de
// métricas por rep — as mesmas contas do painel, sem escopo por equipe.
async listSupervisores(): Promise<SupervisoresResponse> {
const role = this.cls.get('role') ?? 'rep';
if (role !== 'manager' && role !== 'admin') {
throw new ForbiddenException('Apenas gerentes podem consultar os supervisores');
}
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 idEmpresaMatriz = matrizEmpresa(idEmpresa);
const now = new Date();
const year = now.getFullYear();
const month = now.getMonth() + 1;
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
const prevStartStr = new Date(year, month - 2, 1).toISOString().slice(0, 10);
const prevEndStr = new Date(year, month - 1, 0).toISOString().slice(0, 10);
const [repRows, pedidosRows, prevRows, metaRows, positivacaoRows] = await Promise.all([
prisma.$queryRawUnsafe<
{ cod_vendedor: number; cod_supervisor: number | null; nome: string | null }[]
>(`SELECT codigo AS cod_vendedor, cod_supervisor, nome FROM vw_representantes`),
prisma.$queryRawUnsafe<PedidosRepRow[]>(`
SELECT cod_vendedor,
COUNT(*)::text AS pedidos,
COALESCE(SUM(total), 0)::text AS faturamento
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
GROUP BY cod_vendedor
`),
prisma.$queryRawUnsafe<{ cod_vendedor: number; faturamento: string }[]>(`
SELECT cod_vendedor,
COALESCE(SUM(total), 0)::text AS faturamento
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${prevStartStr}'
AND dt_pedido <= '${prevEndStr}'
GROUP BY cod_vendedor
`),
prisma.$queryRawUnsafe<MetaRepRow[]>(`
SELECT cod_vendedor,
SUM(valor)::text AS meta_valor
FROM sar.vw_metas
WHERE id_empresa = ${idEmpresaMatriz}
AND ano = ${year}
AND mes = ${month}
AND TRIM(tipo) = 'GR'
GROUP BY cod_vendedor
`),
prisma.$queryRawUnsafe<
{ cod_vendedor: number; total_clientes: string; positivados: string }[]
>(`
SELECT c.cod_vendedor,
COUNT(DISTINCT c.id_cliente)::text AS total_clientes,
COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS positivados
FROM vw_clientes c
LEFT JOIN (
SELECT DISTINCT id_cliente, cod_vendedor
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa}
AND situa NOT IN (1, 5)
AND dt_pedido >= '${monthStartStr}'
AND dt_pedido <= '${monthEndStr}'
) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor
WHERE c.cod_vendedor > 0
GROUP BY c.cod_vendedor
`),
]);
const pedidosMap = new Map(
pedidosRows.map((r) => [
Number(r.cod_vendedor),
{ pedidos: Number(r.pedidos), faturamento: Number(r.faturamento) },
]),
);
const prevMap = new Map(prevRows.map((r) => [Number(r.cod_vendedor), Number(r.faturamento)]));
const metaMap = new Map(metaRows.map((r) => [Number(r.cod_vendedor), Number(r.meta_valor)]));
const posMap = new Map(
positivacaoRows.map((r) => [
Number(r.cod_vendedor),
{ total: Number(r.total_clientes), positivados: Number(r.positivados) },
]),
);
// Monta a equipe de cada supervisor: código do supervisor -> códigos dos reps.
// O supervisor entra no próprio time (getTeamCodes inclui o supervisor).
const nomeByCod = new Map<number, string | null>();
const membrosBySup = new Map<number, Set<number>>();
for (const r of repRows) {
const cod = Number(r.cod_vendedor);
if (!nomeByCod.has(cod)) nomeByCod.set(cod, r.nome ?? null);
const sup = r.cod_supervisor != null ? Number(r.cod_supervisor) : 0;
if (sup > 0) {
let membros = membrosBySup.get(sup);
if (!membros) {
membros = new Set();
membrosBySup.set(sup, membros);
}
membros.add(cod);
}
}
const supervisores: SupervisorCard[] = [...membrosBySup.entries()].map(([sup, membros]) => {
const team = new Set(membros);
team.add(sup); // o próprio supervisor faz parte do time
let faturamento = 0;
let faturamentoAnterior = 0;
let pedidos = 0;
let meta = 0;
let totalClientes = 0;
let positivados = 0;
let repsAtivos = 0;
for (const cod of team) {
const p = pedidosMap.get(cod);
if (p) {
faturamento += p.faturamento;
pedidos += p.pedidos;
if (p.faturamento > 0) repsAtivos += 1;
}
faturamentoAnterior += prevMap.get(cod) ?? 0;
meta += metaMap.get(cod) ?? 0;
const pos = posMap.get(cod);
if (pos) {
totalClientes += pos.total;
positivados += pos.positivados;
}
}
return {
codSupervisor: sup,
nomeSupervisor: nomeByCod.get(sup) ?? null,
faturamentoMes: faturamento,
faturamentoMesAnterior: faturamentoAnterior,
variacaoPct:
faturamentoAnterior > 0
? Math.round(((faturamento - faturamentoAnterior) / faturamentoAnterior) * 100)
: null,
pedidosMes: pedidos,
ticketMedio: pedidos > 0 ? faturamento / pedidos : 0,
metaTotal: meta,
pctMeta: meta > 0 ? Math.round((faturamento / meta) * 100) : 0,
totalClientes,
clientesPositivados: positivados,
pctPositivacao: totalClientes > 0 ? Math.round((positivados / totalClientes) * 100) : 0,
repsTotal: team.size,
repsAtivos,
};
});
// Ranking/disputa: maior faturamento primeiro.
supervisores.sort((a, b) => b.faturamentoMes - a.faturamentoMes);
return {
supervisores,
syncedAt: now.toISOString(),
};
}
} }

View File

@@ -1,7 +1,6 @@
import { import {
Body, Body,
Controller, Controller,
ForbiddenException,
Get, Get,
HttpCode, HttpCode,
Param, Param,
@@ -11,17 +10,13 @@ import {
Post, Post,
Query, Query,
} from '@nestjs/common'; } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { import {
AprovarPedidoSchema,
CancelPedidoSchema, CancelPedidoSchema,
CreatePedidoSchema, CreatePedidoSchema,
UpdatePedidoSchema, UpdatePedidoSchema,
PedidoErpConsultaQuerySchema, PedidoErpConsultaQuerySchema,
PedidoListQuerySchema, PedidoListQuerySchema,
RecusarPedidoSchema,
type AprovarPedido,
type CancelPedido, type CancelPedido,
type CreatePedido, type CreatePedido,
type UpdatePedido, type UpdatePedido,
@@ -30,25 +25,18 @@ import {
type PedidoErpConsultaResponse, type PedidoErpConsultaResponse,
type PedidoListQuery, type PedidoListQuery,
type PedidoListResponse, type PedidoListResponse,
type RecusarPedido,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { OrdersService } from './orders.service'; import { OrdersService } from './orders.service';
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {} class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {} class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {} class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {} class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {} class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
@Controller({ path: 'orders' }) @Controller({ path: 'orders' })
export class OrdersController { export class OrdersController {
constructor( constructor(private readonly orders: OrdersService) {}
private readonly orders: OrdersService,
private readonly cls: ClsService<WorkspaceClsStore>,
) {}
@Get() @Get()
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> { list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
@@ -68,28 +56,6 @@ export class OrdersController {
return this.orders.transmit(id); return this.orders.transmit(id);
} }
@Patch(':id/approve')
approve(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: AprovarPedidoDto,
): Promise<PedidoDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
const parsed = AprovarPedidoSchema.parse(body) as AprovarPedido;
return this.orders.approve(id, parsed);
}
@Patch(':id/reject')
reject(
@Param('id', ParseUUIDPipe) id: string,
@Body() body: RecusarPedidoDto,
): Promise<PedidoDetail> {
const role = this.cls.get('role') ?? 'rep';
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido;
return this.orders.reject(id, parsed);
}
@Patch(':id/cancel') @Patch(':id/cancel')
cancel( cancel(
@Param('id', ParseUUIDPipe) id: string, @Param('id', ParseUUIDPipe) id: string,

View File

@@ -2,7 +2,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import type { import type {
AprovarPedido,
CancelPedido, CancelPedido,
CreatePedido, CreatePedido,
UpdatePedido, UpdatePedido,
@@ -13,16 +12,14 @@ import type {
PedidoListQuery, PedidoListQuery,
PedidoListResponse, PedidoListResponse,
PedidoSummary, PedidoSummary,
RecusarPedido,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util'; import { getTeamCodes } from '../workspace/team-scope.util';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado // Situa SAR: 0=Orçamento, 2=Confirmado, 3=Cancelado, 4=Faturado
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado // Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
const SITUA_ORCAMENTO = 0; const SITUA_ORCAMENTO = 0;
const SITUA_PENDENTE = 1;
const SITUA_APROVADO = 2; const SITUA_APROVADO = 2;
const SITUA_CANCELADO = 3; const SITUA_CANCELADO = 3;
@@ -290,20 +287,6 @@ export class OrdersService {
return { data, total, page, limit }; return { data, total, page, limit };
} }
// Supervisor só age sobre pedidos da própria equipe; gerente/admin passam
// direto. NotFound (não Forbidden) para não revelar pedidos de outras equipes.
private async assertPedidoDaEquipe(codVendedorPedido: number, idPedido: string): Promise<void> {
const role = this.cls.get('role');
if (role !== 'supervisor') return;
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const userId = this.cls.get('userId') ?? '0';
const team = await getTeamCodes(prisma, parseInt(userId, 10));
if (!team.includes(codVendedorPedido)) {
throw new NotFoundException(`Pedido ${idPedido} não encontrado`);
}
}
async findOne(id: string): Promise<PedidoDetail> { async findOne(id: string): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -643,14 +626,16 @@ export class OrdersService {
) )
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0); .reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
// Regra sem grupo/subgrupo vale para qualquer produto; com grupo e/ou // Regra sem alvo vale para qualquer produto; com grupo, subgrupo e/ou
// subgrupo, o produto precisa casar com o que a regra especifica. // pauta, precisa casar com o que a regra especifica. Regra por pauta só
// vale quando o pedido usa aquela pauta (id_pauta nulo = qualquer pauta).
const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null; const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null;
const regraPct = regras const regraPct = regras
.filter( .filter(
(r) => (r) =>
(r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) && (r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) &&
(r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)), (r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)) &&
(r.idPauta == null || (pedido.idPauta != null && r.idPauta === pedido.idPauta)),
) )
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0); .reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
@@ -690,115 +675,6 @@ export class OrdersService {
} }
} }
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
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') ?? '0';
const codVendedor = parseInt(userId, 10);
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_PENDENTE)
throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date();
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
const newTotal =
Math.round(Number(pedido.totalProdutos) * (1 - newDescontoPerc / 100) * 100) / 100;
await prisma.pedido.update({
where: { id },
data: {
situa: SITUA_APROVADO,
descontoPerc: newDescontoPerc,
total: newTotal,
aprovadoPor: codVendedor,
aprovadoEm: now,
},
});
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_PENDENTE,
situaNova: SITUA_APROVADO,
changedBy: codVendedor,
changedAt: now,
nota: dto.nota ?? null,
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
void this.notifications.notifyUser(String(pedido.codVendedor), {
title: 'Pedido aprovado',
body: `${final.numPedSar} aprovado${dto.descontoPerc !== undefined ? ` com ${newDescontoPerc}% de desconto` : ''}`,
url: `/pedidos/${id}`,
});
return this.mapDetail(final);
}
async reject(id: string, dto: RecusarPedido): Promise<PedidoDetail> {
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') ?? '0';
const codVendedor = parseInt(userId, 10);
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_PENDENTE)
throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
);
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date();
await prisma.pedido.update({
where: { id },
data: { situa: SITUA_CANCELADO, motivoRecusa: dto.motivo },
});
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_PENDENTE,
situaNova: SITUA_CANCELADO,
changedBy: codVendedor,
changedAt: now,
nota: dto.motivo,
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
void this.notifications.notifyUser(String(pedido.codVendedor), {
title: 'Pedido recusado',
body: `${final.numPedSar}: ${dto.motivo}`,
url: `/pedidos/${id}`,
});
return this.mapDetail(final);
}
// Edita um orçamento (situa 0). Depois de transmitido não é mais editável. // Edita um orçamento (situa 0). Depois de transmitido não é mais editável.
// Substitui itens e recalcula totais; alçada valida contra o dono do pedido. // Substitui itens e recalcula totais; alçada valida contra o dono do pedido.
async update(id: string, dto: UpdatePedido): Promise<PedidoDetail> { async update(id: string, dto: UpdatePedido): Promise<PedidoDetail> {

View File

@@ -11,17 +11,14 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod'; import { createZodDto } from 'nestjs-zod';
import { import {
UpsertDescontoBodySchema,
CreatePromocaoBodySchema, CreatePromocaoBodySchema,
UpdatePromocaoBodySchema, UpdatePromocaoBodySchema,
CreateRegraDescontoBodySchema, CreateRegraDescontoBodySchema,
UpdateRegraDescontoBodySchema, UpdateRegraDescontoBodySchema,
type UpsertDescontoBody,
type CreatePromocaoBody, type CreatePromocaoBody,
type UpdatePromocaoBody, type UpdatePromocaoBody,
type CreateRegraDescontoBody, type CreateRegraDescontoBody,
type UpdateRegraDescontoBody, type UpdateRegraDescontoBody,
type AlcadaDescontosResponse,
type PromocoesResponse, type PromocoesResponse,
type PromocoesVigentesResponse, type PromocoesVigentesResponse,
type AlcadaRepResponse, type AlcadaRepResponse,
@@ -31,7 +28,6 @@ import {
} from '@sar/api-interface'; } from '@sar/api-interface';
import { PoliticasService } from './politicas.service'; import { PoliticasService } from './politicas.service';
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {} class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {} class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {} class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
@@ -41,17 +37,6 @@ class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema)
export class PoliticasController { export class PoliticasController {
constructor(private readonly politicas: PoliticasService) {} constructor(private readonly politicas: PoliticasService) {}
@Get('descontos')
listDescontos(): Promise<AlcadaDescontosResponse> {
return this.politicas.listDescontos();
}
@Post('descontos')
@HttpCode(200)
upsertDesconto(@Body() body: UpsertDescontoDto): Promise<void> {
return this.politicas.upsertDesconto(body as unknown as UpsertDescontoBody);
}
@Get('promocoes') @Get('promocoes')
listPromocoes(): Promise<PromocoesResponse> { listPromocoes(): Promise<PromocoesResponse> {
return this.politicas.listPromocoes(); return this.politicas.listPromocoes();

View File

@@ -1,8 +1,6 @@
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { import type {
AlcadaDescontosResponse,
UpsertDescontoBody,
PromocoesResponse, PromocoesResponse,
CreatePromocaoBody, CreatePromocaoBody,
UpdatePromocaoBody, UpdatePromocaoBody,
@@ -27,61 +25,6 @@ export class PoliticasService {
} }
} }
async listDescontos(): Promise<AlcadaDescontosResponse> {
this.assertManager();
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 rows = await prisma.alcadaDesconto.findMany({
where: { idEmpresa },
orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }],
});
// Busca nomes dos representantes
const codigos = [...new Set(rows.map((r) => r.codVendedor))];
const repRows =
codigos.length > 0
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
)
: [];
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
return {
descontos: rows.map((r) => ({
codVendedor: r.codVendedor,
codGrupo: r.codGrupo,
limitePerc: Number(r.limitePerc),
nomeVendedor: repNameMap.get(r.codVendedor) ?? null,
})),
};
}
async upsertDesconto(body: UpsertDescontoBody): Promise<void> {
this.assertManager();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
await prisma.alcadaDesconto.upsert({
where: {
codVendedor_idEmpresa_codGrupo: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
},
},
create: {
codVendedor: body.codVendedor,
idEmpresa,
codGrupo: body.codGrupo ?? 0,
limitePerc: body.limitePerc,
},
update: { limitePerc: body.limitePerc },
});
}
async listPromocoes(): Promise<PromocoesResponse> { async listPromocoes(): Promise<PromocoesResponse> {
this.assertManager(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
@@ -220,6 +163,23 @@ export class PoliticasService {
}; };
} }
// Descrições das pautas informadas — UI sempre mostra o nome, nunca só o id.
private async loadPautaNames(idPautas: number[]): Promise<Map<number, string | null>> {
if (idPautas.length === 0) return new Map();
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
const rows = await prisma.$queryRawUnsafe<{ id_pauta: number; descricao: string | null }[]>(
`SELECT id_pauta, TRIM(descricao) AS descricao
FROM vw_pautas
WHERE id_empresa = ${idMatriz}
AND id_pauta IN (${idPautas.join(',')})`,
);
return new Map(rows.map((r) => [Number(r.id_pauta), r.descricao]));
}
async listRegras(): Promise<RegrasDescontoResponse> { async listRegras(): Promise<RegrasDescontoResponse> {
this.assertManager(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
@@ -252,6 +212,9 @@ export class PoliticasService {
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]), grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
); );
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
const pautaNameMap = await this.loadPautaNames(idPautas);
return { return {
regras: rows.map((r) => ({ regras: rows.map((r) => ({
id: r.id, id: r.id,
@@ -260,6 +223,8 @@ export class PoliticasService {
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null, grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
codSubgrupo: r.codSubgrupo, codSubgrupo: r.codSubgrupo,
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null, subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
idPauta: r.idPauta,
pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null,
codVendedor: r.codVendedor, codVendedor: r.codVendedor,
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null, nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
descPct: Number(r.descPct), descPct: Number(r.descPct),
@@ -272,8 +237,9 @@ export class PoliticasService {
}; };
} }
// Cria uma regra POR ALVO: cada grupo selecionado vira regra por grupo e // Cria uma regra POR ALVO: cada grupo vira regra por grupo, cada subgrupo
// cada subgrupo vira regra por subgrupo. Sem alvos, regra vale para todos. // vira regra por subgrupo e cada pauta vira regra por pauta. O representante
// escolhido é copiado em todas. Sem alvos, regra vale para todos.
async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: number[] }> { async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: number[] }> {
this.assertManager(); this.assertManager();
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
@@ -281,11 +247,17 @@ export class PoliticasService {
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? 'system'; const userId = this.cls.get('userId') ?? 'system';
const alvos: { codGrupo: number | null; codSubgrupo: number | null }[] = [ const alvos: { codGrupo: number | null; codSubgrupo: number | null; idPauta: number | null }[] =
...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null })), [
...(body.codSubgrupos ?? []).map((s) => ({ codGrupo: null, codSubgrupo: s })), ...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null, idPauta: null })),
...(body.codSubgrupos ?? []).map((s) => ({
codGrupo: null,
codSubgrupo: s,
idPauta: null,
})),
...(body.idPautas ?? []).map((p) => ({ codGrupo: null, codSubgrupo: null, idPauta: p })),
]; ];
if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null }); if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null, idPauta: null });
const ids = await prisma.$transaction(async (tx) => { const ids = await prisma.$transaction(async (tx) => {
const created: number[] = []; const created: number[] = [];
@@ -296,6 +268,7 @@ export class PoliticasService {
descricao: body.descricao, descricao: body.descricao,
codGrupo: alvo.codGrupo, codGrupo: alvo.codGrupo,
codSubgrupo: alvo.codSubgrupo, codSubgrupo: alvo.codSubgrupo,
idPauta: alvo.idPauta,
codVendedor: body.codVendedor ?? null, codVendedor: body.codVendedor ?? null,
descPct: body.descPct, descPct: body.descPct,
dataInicio: new Date(body.dataInicio), dataInicio: new Date(body.dataInicio),
@@ -325,6 +298,7 @@ export class PoliticasService {
...(body.descricao !== undefined && { descricao: body.descricao }), ...(body.descricao !== undefined && { descricao: body.descricao }),
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }), ...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }), ...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
...(body.idPauta !== undefined && { idPauta: body.idPauta }),
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }), ...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
...(body.descPct !== undefined && { descPct: body.descPct }), ...(body.descPct !== undefined && { descPct: body.descPct }),
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }), ...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
@@ -384,12 +358,36 @@ export class PoliticasService {
orderBy: [{ descPct: 'desc' }], orderBy: [{ descPct: 'desc' }],
}); });
// Nomes de produto e grupo — UI sempre mostra o nome, nunca só o código
const codProdutos = [
...new Set(
rows.map((p) => p.codProduto?.trim()).filter((c): c is string => !!c && c.length > 0),
),
];
const prodRows =
codProdutos.length > 0
? await prisma.$queryRawUnsafe<{ codigo: string; descricao: string | null }[]>(
`SELECT TRIM(codigo) AS codigo, TRIM(descricao) AS descricao
FROM vw_produtos
WHERE id_empresa = ${idMatriz}
AND TRIM(codigo) IN (${codProdutos.map((c) => `'${c.replace(/'/g, "''")}'`).join(',')})`,
)
: [];
const prodNameMap = new Map(prodRows.map((r) => [r.codigo, r.descricao]));
const grupos = rows.some((p) => p.grpProd != null) ? await this.loadGruposProduto() : [];
const grupoNameMap = new Map(
grupos.filter((g) => g.cod_grupo != null).map((g) => [String(Number(g.cod_grupo)), g.grupo]),
);
return { return {
promocoes: rows.map((p) => ({ promocoes: rows.map((p) => ({
id: p.id, id: p.id,
descricao: p.descricao, descricao: p.descricao,
codProduto: p.codProduto, codProduto: p.codProduto,
nomeProduto: p.codProduto ? (prodNameMap.get(p.codProduto.trim()) ?? null) : null,
grpProd: p.grpProd, grpProd: p.grpProd,
grupo: p.grpProd ? (grupoNameMap.get(p.grpProd.trim()) ?? null) : null,
descPct: Number(p.descPct), descPct: Number(p.descPct),
dataFim: p.dataFim.toISOString().slice(0, 10), dataFim: p.dataFim.toISOString().slice(0, 10),
})), })),
@@ -418,12 +416,30 @@ export class PoliticasService {
orderBy: [{ descPct: 'desc' }], orderBy: [{ descPct: 'desc' }],
}); });
// Nomes de grupo/subgrupo — UI sempre mostra o nome, nunca só o código
const grupos = rows.some((r) => r.codGrupo != null || r.codSubgrupo != null)
? await this.loadGruposProduto()
: [];
const grupoNameMap = new Map(
grupos.filter((g) => g.cod_grupo != null).map((g) => [Number(g.cod_grupo), g.grupo]),
);
const subgrupoNameMap = new Map(
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
);
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
const pautaNameMap = await this.loadPautaNames(idPautas);
return { return {
regras: rows.map((r) => ({ regras: rows.map((r) => ({
id: r.id, id: r.id,
descricao: r.descricao, descricao: r.descricao,
codGrupo: r.codGrupo, codGrupo: r.codGrupo,
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
codSubgrupo: r.codSubgrupo, codSubgrupo: r.codSubgrupo,
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
idPauta: r.idPauta,
pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null,
descPct: Number(r.descPct), descPct: Number(r.descPct),
dataFim: r.dataFim.toISOString().slice(0, 10), dataFim: r.dataFim.toISOString().slice(0, 10),
})), })),

View File

@@ -12,6 +12,7 @@
"@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0",
"@fortawesome/react-fontawesome": "^3.3.1", "@fortawesome/react-fontawesome": "^3.3.1",
"@hookform/resolvers": "^5.4.0", "@hookform/resolvers": "^5.4.0",
"@svg-maps/brazil": "^2.0.0",
"@tanstack/react-query": "^5.100.14", "@tanstack/react-query": "^5.100.14",
"@tanstack/react-router": "^1.170.8", "@tanstack/react-router": "^1.170.8",
"antd": "^6.4.3", "antd": "^6.4.3",

View File

@@ -1,7 +1,8 @@
import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd'; import { useState } from 'react';
import type { TableColumnsType } from 'antd'; import { Button, Card, Col, Empty, Flex, Progress, Row, Skeleton, Space, Typography } from 'antd';
import type { RepStats } from '@sar/api-interface'; import type { SupervisorCard } from '@sar/api-interface';
import { useEquipe } from '../../lib/queries/gerente'; import { useSupervisores } from '../../lib/queries/gerente';
import { SupervisorDetalheModal } from './SupervisorDetalheModal';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -9,86 +10,119 @@ function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
} }
const columns: TableColumnsType<RepStats> = [ function metaColor(pct: number): string {
{ return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
title: 'Representante', }
key: 'rep',
render: (_: unknown, row: RepStats) => ( const MEDALHAS = ['🥇', '🥈', '🥉'];
<Flex vertical gap={0}>
<Text strong>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text> function posicaoLabel(i: number): string {
{row.nomeVendedor && ( return MEDALHAS[i] ?? `${i + 1}º`;
}
function SupervisorCardView({
s,
posicao,
onDetalhar,
}: {
s: SupervisorCard;
posicao: number;
onDetalhar: () => void;
}) {
const lider = posicao === 0;
return (
<Card
style={lider ? { borderColor: 'var(--jcs-blue)', borderWidth: 2 } : undefined}
styles={{ body: { padding: 'var(--space-lg)' } }}
>
<Flex vertical gap={12}>
{/* Cabeçalho: posição + nome */}
<Flex align="center" gap={10}>
<span style={{ fontSize: 22, lineHeight: 1, minWidth: 30, textAlign: 'center' }}>
{posicaoLabel(posicao)}
</span>
<Flex vertical gap={0} style={{ minWidth: 0, flex: 1 }}>
<Text strong ellipsis style={{ fontSize: 'var(--text-lg)' }}>
{s.nomeSupervisor ?? `Supervisor cód. ${s.codSupervisor}`}
</Text>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
cód. {row.codVendedor} cód. {s.codSupervisor} · {s.repsAtivos}/{s.repsTotal} reps ativos
</Text>
</Flex>
</Flex>
{/* Faturamento + variação */}
<Flex align="baseline" gap={10} wrap>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(s.faturamentoMes)}
</Title>
{s.variacaoPct != null && (
<Text
className="tabular-nums"
style={{
fontSize: 'var(--text-sm)',
color: s.variacaoPct >= 0 ? 'var(--green)' : '#cf1322',
}}
>
{s.variacaoPct >= 0 ? '▲' : '▼'} {s.variacaoPct >= 0 ? '+' : ''}
{s.variacaoPct}% vs. mês ant.
</Text> </Text>
)} )}
</Flex> </Flex>
),
}, {/* Meta do time */}
{ <div>
title: 'Pedidos', <Flex justify="space-between" align="baseline" style={{ marginBottom: 4 }}>
dataIndex: 'pedidosMes', <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
width: 90, Meta do time
align: 'right', </Text>
className: 'tabular-nums', <Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
sorter: (a, b) => a.pedidosMes - b.pedidosMes, {s.metaTotal > 0 ? `${s.pctMeta}%` : 'sem meta'}
render: (v: number) => (v === 0 ? <Tag>0</Tag> : v),
},
{
title: 'Faturamento',
dataIndex: 'faturamentoMes',
width: 160,
align: 'right',
className: 'tabular-nums',
sorter: (a, b) => a.faturamentoMes - b.faturamentoMes,
defaultSortOrder: 'descend',
render: (v: number) => fmt(v),
},
{
title: 'Ticket Medio',
dataIndex: 'ticketMedio',
width: 140,
align: 'right',
className: 'tabular-nums',
sorter: (a, b) => a.ticketMedio - b.ticketMedio,
render: (v: number) => (v > 0 ? fmt(v) : <Text type="secondary">--</Text>),
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 170,
sorter: (a, b) => a.pctMeta - b.pctMeta,
render: (pct: number) =>
pct === 0 ? (
<Text type="secondary">sem meta</Text>
) : (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text> </Text>
</Flex> </Flex>
), <Progress
}, percent={s.metaTotal > 0 ? Math.min(s.pctMeta, 100) : 0}
]; size="small"
strokeColor={metaColor(s.pctMeta)}
showInfo={false}
/>
</div>
export function EquipePage() { {/* Ticket + positivação */}
const { data, isLoading } = useEquipe(); <Flex justify="space-between" gap={12}>
<Space orientation="vertical" size={0}>
if (isLoading || !data) { <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
return ( TICKET MÉDIO
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}> </Text>
<Skeleton active paragraph={{ rows: 1 }} /> <Text strong className="tabular-nums">
<Skeleton active paragraph={{ rows: 6 }} /> {s.ticketMedio > 0 ? fmt(s.ticketMedio) : '—'}
</Text>
</Space>
<Space orientation="vertical" size={0} style={{ textAlign: 'right' }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
POSITIVAÇÃO
</Text>
<Text strong className="tabular-nums">
{s.pctPositivacao}%{' '}
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
({s.clientesPositivados}/{s.totalClientes})
</Text>
</Text>
</Space>
</Flex> </Flex>
<Button block onClick={onDetalhar}>
Detalhar
</Button>
</Flex>
</Card>
); );
} }
export function EquipePage() {
const { data, isLoading } = useSupervisores();
const [detalhe, setDetalhe] = useState<SupervisorCard | null>(null);
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
return ( return (
@@ -98,25 +132,41 @@ export function EquipePage() {
Equipe Equipe
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Performance dos representantes &mdash; {mes} Disputa dos supervisores &mdash; {mes}
</Text> </Text>
</Flex> </Flex>
{isLoading || !data ? (
<Row gutter={[24, 24]}>
{[1, 2, 3].map((i) => (
<Col key={i} xs={24} md={12} lg={8}>
<Card> <Card>
<Table<RepStats> <Skeleton active paragraph={{ rows: 4 }} />
rowKey="codVendedor"
columns={columns}
dataSource={data.reps}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum representante encontrado.' }}
scroll={{ x: 700 }}
/>
</Card> </Card>
</Col>
))}
</Row>
) : data.supervisores.length === 0 ? (
<Card>
<Empty description="Nenhum supervisor com equipe encontrado." />
</Card>
) : (
<>
<Row gutter={[24, 24]}>
{data.supervisores.map((s: SupervisorCard, i: number) => (
<Col key={s.codSupervisor} xs={24} md={12} lg={8}>
<SupervisorCardView s={s} posicao={i} onDetalhar={() => setDetalhe(s)} />
</Col>
))}
</Row>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')} Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
</Text> </Text>
</>
)}
<SupervisorDetalheModal supervisor={detalhe} onClose={() => setDetalhe(null)} />
</Flex> </Flex>
); );
} }

View File

@@ -45,7 +45,12 @@ import dayjs from 'dayjs';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface'; import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
import { useManagerDashboard } from '../../lib/queries/gerente'; import { useManagerDashboard } from '../../lib/queries/gerente';
import {
positivacaoRepColumns,
rankingRepColumns,
} from '../../components/dashboard/rep-performance-columns';
import { apiFetch } from '../../lib/api-client'; import { apiFetch } from '../../lib/api-client';
import { MapaBrasilCard } from './MapaBrasilCard';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@@ -67,60 +72,6 @@ function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }); return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
} }
const positivacaoColumns: TableColumnsType<PositivacaoRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
ellipsis: true,
},
{
title: 'Positivados / Carteira',
key: 'pos',
width: 160,
align: 'center' as const,
render: (_: unknown, row: PositivacaoRep) => (
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{row.clientesPositivados} / {row.totalClientes}
</Text>
),
},
{
title: 'Novos no mês',
key: 'novos',
width: 120,
align: 'center' as const,
sorter: (a: PositivacaoRep, b: PositivacaoRep) => a.novosClientes - b.novosClientes,
render: (_: unknown, row: PositivacaoRep) =>
row.novosClientes > 0 ? (
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
+{row.novosClientes}
</Tag>
) : (
<Text type="secondary"></Text>
),
},
{
title: '% Positivação',
dataIndex: 'pctPositivacao',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
const metasGrupoColumns: TableColumnsType<MetaItem> = [ const metasGrupoColumns: TableColumnsType<MetaItem> = [
{ {
title: 'Grupo', title: 'Grupo',
@@ -178,55 +129,6 @@ const metasGrupoColumns: TableColumnsType<MetaItem> = [
}, },
]; ];
const rankingColumns: TableColumnsType<RankingRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientesAtendidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 160,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
function PositivacaoDiariaCard({ function PositivacaoDiariaCard({
dados, dados,
metaSalva, metaSalva,
@@ -403,6 +305,7 @@ export function GerPainel() {
rankingReps, rankingReps,
positivacaoReps, positivacaoReps,
positivacaoDiaria, positivacaoDiaria,
faturamentoPorUf,
syncedAt, syncedAt,
} = data; } = data;
@@ -451,93 +354,60 @@ export function GerPainel() {
</Flex> </Flex>
</Flex> </Flex>
{/* KPIs linha 1 */} {/* Primeira dobra: visão financeira (½) + mapa do Brasil (½) */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={6}> <Col xs={24} xl={12}>
<Card> <Flex vertical gap={16} style={{ height: '100%' }}>
<Row gutter={[16, 16]} style={{ flex: 1 }}>
<Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card style={{ flex: 1 }}>
<Flex vertical gap={4}> <Flex vertical gap={4}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()} FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
</Text> </Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(faturamentoMes)}
</Title>
<FontAwesomeIcon <FontAwesomeIcon
icon={faFileInvoiceDollar} icon={faFileInvoiceDollar}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/> />
</Flex> </Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums"> <Title level={3} style={{ margin: 0 }} className="tabular-nums">
{pedidosMes} {fmt(faturamentoMes)}
</Title> </Title>
<FontAwesomeIcon <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
icon={faChartBar} {pedidosMes} pedido{pedidosMes !== 1 ? 's' : ''} no{' '}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} {isMesAtual ? 'mês' : 'período'}
/> </Text>
</Flex> </Flex>
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} lg={6}> <Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card> <Card style={{ flex: 1 }}>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
TICKET MÉDIO
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(ticketMedio)}
</Title>
<FontAwesomeIcon icon={faUsers} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
</Flex>
</Card>
</Col>
<Col xs={24} sm={12} lg={6}>
<Card>
<Flex vertical gap={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
PROMOÇÕES ATIVAS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{promocoesAtivas}
</Title>
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
</Flex>
</Card>
</Col>
</Row>
{/* KPIs linha 2 — Meta */}
{metaTotal > 0 && (
<Row gutter={[24, 24]}>
<Col xs={24} sm={12} lg={8}>
<Card>
<Flex vertical gap={4}> <Flex vertical gap={4}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'} META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
</Text> </Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(metaTotal)}
</Title>
<FontAwesomeIcon <FontAwesomeIcon
icon={faBullseye} icon={faBullseye}
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
/> />
</Flex> </Flex>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{metaTotal > 0 ? fmt(metaTotal) : '—'}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{metaTotal > 0
? `soma das metas por representante`
: 'sem meta cadastrada no ERP'}
</Text>
</Flex>
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card> <Card style={{ flex: 1 }}>
<Flex vertical gap={8}> <Flex vertical gap={8}>
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
@@ -550,13 +420,13 @@ export function GerPainel() {
</Flex> </Flex>
<Title <Title
level={3} level={3}
style={{ margin: 0, color: metaCorColor }} style={{ margin: 0, color: metaTotal > 0 ? metaCorColor : undefined }}
className="tabular-nums" className="tabular-nums"
> >
{pctMeta}% {metaTotal > 0 ? `${pctMeta}%` : '—'}
</Title> </Title>
<Progress <Progress
percent={Math.min(pctMeta, 100)} percent={metaTotal > 0 ? Math.min(pctMeta, 100) : 0}
strokeColor={metaCorColor} strokeColor={metaCorColor}
showInfo={false} showInfo={false}
size="small" size="small"
@@ -565,20 +435,33 @@ export function GerPainel() {
</Card> </Card>
</Col> </Col>
<Col xs={24} sm={12} lg={8}> <Col xs={24} sm={12} style={{ display: 'flex' }}>
<Card> <Card style={{ flex: 1 }}>
<Flex vertical gap={4}> <Flex vertical gap={4}>
<Flex justify="space-between" align="center">
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'} {superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
</Text> </Text>
<FontAwesomeIcon
icon={faChartBar}
style={{
color: superouMeta > 0 ? 'var(--green)' : 'var(--jcs-blue)',
opacity: 0.5,
}}
/>
</Flex>
<Title <Title
level={3} level={3}
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }} style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
className="tabular-nums" className="tabular-nums"
> >
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)} {metaTotal > 0 ? fmt(superouMeta > 0 ? superouMeta : faltaMeta) : '—'}
</Title> </Title>
{superouMeta > 0 ? ( {metaTotal === 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
sem meta cadastrada no ERP
</Text>
) : superouMeta > 0 ? (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{pctMeta - 100}% acima da meta {pctMeta - 100}% acima da meta
</Text> </Text>
@@ -598,7 +481,51 @@ export function GerPainel() {
</Card> </Card>
</Col> </Col>
</Row> </Row>
)}
{/* Indicadores secundários */}
<Card styles={{ body: { padding: '12px 24px' } }}>
<Flex justify="space-around" align="center" wrap="wrap" gap={16}>
<Flex vertical align="center" gap={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
TICKET MÉDIO
</Text>
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
{fmt(ticketMedio)}
</Text>
</Flex>
<Flex vertical align="center" gap={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
REPS COM PEDIDOS
</Text>
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
<FontAwesomeIcon
icon={faUsers}
style={{ color: 'var(--jcs-blue)', opacity: 0.5, marginRight: 6 }}
/>
{totalReps}
</Text>
</Flex>
<Flex vertical align="center" gap={0}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
PROMOÇÕES ATIVAS
</Text>
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
<FontAwesomeIcon
icon={faTags}
style={{ color: 'var(--jcs-blue)', opacity: 0.5, marginRight: 6 }}
/>
{promocoesAtivas}
</Text>
</Flex>
</Flex>
</Card>
</Flex>
</Col>
<Col xs={24} xl={12}>
<MapaBrasilCard dados={faturamentoPorUf} periodoLabel={periodoLabel} />
</Col>
</Row>
{/* Metas por Grupo */} {/* Metas por Grupo */}
{metasPorGrupo.length > 0 && ( {metasPorGrupo.length > 0 && (
@@ -650,7 +577,7 @@ export function GerPainel() {
> >
<Table<RankingRep> <Table<RankingRep>
rowKey="codVendedor" rowKey="codVendedor"
columns={rankingColumns} columns={rankingRepColumns}
dataSource={rankingReps} dataSource={rankingReps}
pagination={false} pagination={false}
size="small" size="small"
@@ -694,7 +621,7 @@ export function GerPainel() {
> >
<Table<PositivacaoRep> <Table<PositivacaoRep>
rowKey="codVendedor" rowKey="codVendedor"
columns={positivacaoColumns} columns={positivacaoRepColumns}
dataSource={positivacaoReps} dataSource={positivacaoReps}
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }} pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
size="small" size="small"

View File

@@ -0,0 +1,360 @@
import { useMemo, useState } from 'react';
import { Button, Card, Empty, Flex, Modal, Progress, Table, Tag, Tooltip, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faMapLocationDot, faUserTie } from '@fortawesome/free-solid-svg-icons';
import type { UfFaturamento, UfRep } from '@sar/api-interface';
import brazilMap from '@svg-maps/brazil';
const { Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
// R$ compacto para a lista lateral (R$ 12,4 mil / R$ 1,2 mi)
function fmtCompacto(v: number): string {
if (v >= 1_000_000)
return `R$ ${(v / 1_000_000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} mi`;
if (v >= 1_000)
return `R$ ${(v / 1_000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} mil`;
return fmt(v);
}
// Escala sequencial (um matiz, claro→escuro) derivada do azul JCS.
const SEQ_STEPS = ['#dbe9f6', '#aecde9', '#7BA7D7', '#3d74b8', '#004a99'];
const COR_SEM_VENDA = '#edf0f3';
function corDaUf(valor: number, max: number): string {
if (valor <= 0 || max <= 0) return COR_SEM_VENDA;
const idx = Math.min(SEQ_STEPS.length - 1, Math.floor((valor / max) * SEQ_STEPS.length));
return SEQ_STEPS[idx] ?? COR_SEM_VENDA;
}
const NOME_UF = new Map(brazilMap.locations.map((l) => [l.id.toUpperCase(), l.name]));
const repColumns: TableColumnsType<UfRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, r: UfRep) => (
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faUserTie} style={{ color: 'var(--jcs-blue)', opacity: 0.45 }} />
{r.nomeVendedor ?? `Cód. ${r.codVendedor}`}
</Flex>
),
ellipsis: true,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientes',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
];
function detalheColumns(): TableColumnsType<UfFaturamento> {
return [
{
title: 'Estado',
key: 'uf',
render: (_: unknown, r: UfFaturamento) => (
<Flex align="center" gap={8}>
<Tag style={{ margin: 0, fontWeight: 700, width: 40, textAlign: 'center' }}>{r.uf}</Tag>
<Text>{NOME_UF.get(r.uf) ?? 'Sem UF cadastrada'}</Text>
</Flex>
),
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientes',
width: 90,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => (
<Text strong className="tabular-nums">
{fmt(v)}
</Text>
),
},
{
title: 'Participação',
dataIndex: 'pct',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor="var(--jcs-blue)"
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 48 }}>
{pct.toLocaleString('pt-BR')}%
</Text>
</Flex>
),
},
];
}
export function MapaBrasilCard({
dados,
periodoLabel,
}: {
dados: UfFaturamento[];
periodoLabel: string;
}) {
const [detalheAberto, setDetalheAberto] = useState(false);
const [ufsExpandidas, setUfsExpandidas] = useState<string[]>([]);
const [ufHover, setUfHover] = useState<string | null>(null);
const porUf = useMemo(() => new Map(dados.map((d) => [d.uf, d])), [dados]);
const max = dados.reduce((m, d) => Math.max(m, d.faturamento), 0);
const comVenda = dados.filter((d) => d.faturamento > 0 && d.uf !== 'ND');
const topUfs = comVenda.slice(0, 8);
const semUf = dados.find((d) => d.uf === 'ND');
const abrirDetalhe = (uf?: string) => {
const primeira = comVenda[0]?.uf;
setUfsExpandidas(uf ? [uf] : primeira ? [primeira] : []);
setDetalheAberto(true);
};
return (
<Card
title={
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faMapLocationDot} style={{ color: 'var(--jcs-blue)' }} />
Faturamento por Estado
</Flex>
}
extra={
<Button type="primary" ghost size="small" onClick={() => abrirDetalhe()}>
Detalhar
</Button>
}
style={{ height: '100%' }}
styles={{ body: { paddingTop: 12 } }}
>
{dados.length === 0 ? (
<Empty description="Nenhuma venda no período." image={Empty.PRESENTED_IMAGE_SIMPLE} />
) : (
<Flex gap={16} wrap="wrap">
{/* Mapa choropleth */}
<Flex vertical gap={8} style={{ flex: '1 1 260px', minWidth: 240 }}>
<svg
viewBox={brazilMap.viewBox}
role="img"
aria-label={`Mapa do Brasil com faturamento por estado — ${periodoLabel}`}
style={{ width: '100%', height: 'auto', maxHeight: 330 }}
>
{brazilMap.locations.map((loc) => {
const uf = loc.id.toUpperCase();
const dado = porUf.get(uf);
const valor = dado?.faturamento ?? 0;
const hover = ufHover === uf;
return (
<Tooltip
key={loc.id}
title={
<Flex vertical gap={2}>
<Text strong style={{ color: '#fff' }}>
{loc.name} ({uf})
</Text>
{dado ? (
<>
<span>
{fmt(valor)} · {dado.pct.toLocaleString('pt-BR')}% do total
</span>
<span>
{dado.pedidos} pedido{dado.pedidos !== 1 ? 's' : ''} · {dado.clientes}{' '}
cliente{dado.clientes !== 1 ? 's' : ''}
</span>
</>
) : (
<span>Sem vendas no período</span>
)}
</Flex>
}
>
<path
d={loc.path}
fill={corDaUf(valor, max)}
stroke={hover ? 'var(--jcs-blue-hover)' : '#fff'}
strokeWidth={hover ? 2 : 1}
style={{ cursor: dado ? 'pointer' : 'default', transition: 'fill 0.15s' }}
onMouseEnter={() => setUfHover(uf)}
onMouseLeave={() => setUfHover(null)}
onClick={() => dado && abrirDetalhe(uf)}
/>
</Tooltip>
);
})}
</svg>
{/* Legenda da escala */}
<Flex align="center" gap={6} style={{ paddingInline: 4 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Sem venda
</Text>
<span
style={{
width: 14,
height: 10,
background: COR_SEM_VENDA,
border: '1px solid #d9d9d9',
borderRadius: 2,
}}
/>
<span style={{ width: 8 }} />
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Menor
</Text>
{SEQ_STEPS.map((c) => (
<span key={c} style={{ width: 14, height: 10, background: c, borderRadius: 2 }} />
))}
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
Maior
</Text>
</Flex>
</Flex>
{/* Ranking de estados */}
<Flex vertical gap={6} style={{ flex: '1 1 190px', minWidth: 180 }}>
{topUfs.map((d, i) => (
<Flex
key={d.uf}
align="center"
gap={8}
style={{
padding: '4px 8px',
borderRadius: 6,
background: ufHover === d.uf ? 'var(--jcs-blue-light)' : undefined,
cursor: 'pointer',
}}
onMouseEnter={() => setUfHover(d.uf)}
onMouseLeave={() => setUfHover(null)}
onClick={() => abrirDetalhe(d.uf)}
>
<Text
type="secondary"
className="tabular-nums"
style={{ width: 16, fontSize: 'var(--text-xs)' }}
>
{i + 1}º
</Text>
<span
style={{
width: 10,
height: 10,
borderRadius: 2,
background: corDaUf(d.faturamento, max),
border: '1px solid rgba(0,0,0,0.08)',
flexShrink: 0,
}}
/>
<Text strong style={{ width: 28 }}>
{d.uf}
</Text>
<Text
className="tabular-nums"
style={{ marginLeft: 'auto', fontSize: 'var(--text-sm)' }}
>
{fmtCompacto(d.faturamento)}
</Text>
<Text
type="secondary"
className="tabular-nums"
style={{ width: 44, textAlign: 'right', fontSize: 'var(--text-xs)' }}
>
{d.pct.toLocaleString('pt-BR')}%
</Text>
</Flex>
))}
{comVenda.length > topUfs.length && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', paddingInline: 8 }}>
+ {comVenda.length - topUfs.length} outro
{comVenda.length - topUfs.length !== 1 ? 's' : ''} estado
{comVenda.length - topUfs.length !== 1 ? 's' : ''} ver Detalhar
</Text>
)}
{semUf && (
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', paddingInline: 8 }}>
{fmtCompacto(semUf.faturamento)} sem UF cadastrada
</Text>
)}
</Flex>
</Flex>
)}
{/* Detalhe por estado → representantes */}
<Modal
title={
<Flex align="center" gap={8}>
<FontAwesomeIcon icon={faMapLocationDot} style={{ color: 'var(--jcs-blue)' }} />
Faturamento por Estado &mdash; {periodoLabel}
</Flex>
}
open={detalheAberto}
onCancel={() => setDetalheAberto(false)}
footer={null}
width={900}
centered
>
<Table<UfFaturamento>
rowKey="uf"
columns={detalheColumns()}
dataSource={dados}
pagination={false}
size="small"
scroll={{ y: 420 }}
expandable={{
expandedRowKeys: ufsExpandidas,
onExpandedRowsChange: (keys) => setUfsExpandidas([...keys] as string[]),
expandedRowRender: (r) => (
<Table<UfRep>
rowKey="codVendedor"
columns={repColumns}
dataSource={r.reps}
pagination={false}
size="small"
/>
),
}}
locale={{ emptyText: 'Nenhuma venda no período.' }}
/>
</Modal>
</Card>
);
}

View File

@@ -24,17 +24,9 @@ import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons'; import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { import type { Promocao, RegraDesconto, RepStats, GrupoProdutoItem } from '@sar/api-interface';
AlcadaDescontoItem,
Promocao,
RegraDesconto,
RepStats,
GrupoProdutoItem,
} from '@sar/api-interface';
import { import {
usePoliticasDescontos,
usePromocoes, usePromocoes,
useUpsertDesconto,
useCreatePromocao, useCreatePromocao,
useUpdatePromocao, useUpdatePromocao,
useDeletePromocao, useDeletePromocao,
@@ -45,7 +37,7 @@ import {
useUpdateRegraDesconto, useUpdateRegraDesconto,
useDeleteRegraDesconto, useDeleteRegraDesconto,
} from '../../lib/queries/gerente'; } from '../../lib/queries/gerente';
import { useCatalog } from '../../lib/queries/catalog'; import { useCatalog, usePautas } from '../../lib/queries/catalog';
// ─── ProdutoSelect: busca remota por código ou descrição ───────────────────── // ─── ProdutoSelect: busca remota por código ou descrição ─────────────────────
@@ -104,125 +96,6 @@ function ProdutoSelect({
const { Title, Text } = Typography; const { Title, Text } = Typography;
// ─── Tab Descontos ────────────────────────────────────────────────────────────
function TabDescontos() {
const { data, isLoading } = usePoliticasDescontos();
const upsert = useUpsertDesconto();
const [editingKey, setEditingKey] = useState<string | null>(null);
const [editValue, setEditValue] = useState<number>(0);
const [msg, msgCtx] = message.useMessage();
function startEdit(row: AlcadaDescontoItem) {
setEditingKey(`${row.codVendedor}-${row.codGrupo}`);
setEditValue(row.limitePerc);
}
async function saveEdit(row: AlcadaDescontoItem) {
try {
await upsert.mutateAsync({
codVendedor: row.codVendedor,
codGrupo: row.codGrupo,
limitePerc: editValue,
});
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
setEditingKey(null);
void msg.success('Limite atualizado');
}
const columns: TableColumnsType<AlcadaDescontoItem> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: AlcadaDescontoItem) =>
row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Grupo',
dataIndex: 'codGrupo',
width: 100,
render: (v: number) => (v === 0 ? <Tag>Global</Tag> : <Tag color="blue">Grupo {v}</Tag>),
},
{
title: 'Limite de Desconto',
dataIndex: 'limitePerc',
width: 200,
render: (v: number, row: AlcadaDescontoItem) => {
const key = `${row.codVendedor}-${row.codGrupo}`;
if (editingKey === key) {
return (
<InputNumber
value={editValue}
onChange={(val) => setEditValue(val ?? 0)}
min={0}
max={100}
suffix="%"
size="small"
style={{ width: 100 }}
autoFocus
/>
);
}
return <Text className="tabular-nums">{v}%</Text>;
},
},
{
title: '',
width: 120,
render: (_: unknown, row: AlcadaDescontoItem) => {
const key = `${row.codVendedor}-${row.codGrupo}`;
if (editingKey === key) {
return (
<Space>
<Button
size="small"
type="primary"
loading={upsert.isPending}
onClick={() => void saveEdit(row)}
>
Salvar
</Button>
<Button size="small" onClick={() => setEditingKey(null)}>
Cancelar
</Button>
</Space>
);
}
return (
<Button
size="small"
icon={<FontAwesomeIcon icon={faPen} />}
onClick={() => startEdit(row)}
>
Editar
</Button>
);
},
},
];
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
return (
<>
{msgCtx}
<Table<AlcadaDescontoItem>
rowKey={(r) => `${r.codVendedor}-${r.codGrupo}`}
columns={columns}
dataSource={data.descontos}
pagination={false}
size="small"
locale={{ emptyText: 'Nenhum limite configurado.' }}
/>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
Limite 0 = sem restricao de desconto para este representante.
</Text>
</>
);
}
// ─── Tab Promocoes ──────────────────────────────────────────────────────────── // ─── Tab Promocoes ────────────────────────────────────────────────────────────
interface PromocaoForm { interface PromocaoForm {
@@ -534,8 +407,10 @@ interface RegraForm {
codVendedor?: number; codVendedor?: number;
codGrupos?: number[]; codGrupos?: number[];
codSubgrupos?: number[]; codSubgrupos?: number[];
idPautas?: number[];
codGrupo?: number; codGrupo?: number;
codSubgrupo?: number; codSubgrupo?: number;
idPauta?: number;
descPct: number; descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs]; periodo: [dayjs.Dayjs, dayjs.Dayjs];
ativa?: boolean; ativa?: boolean;
@@ -545,6 +420,7 @@ function TabRegras() {
const { data, isLoading } = useRegrasDesconto(); const { data, isLoading } = useRegrasDesconto();
const { data: equipe } = useEquipe(); const { data: equipe } = useEquipe();
const { data: gruposData } = useGruposProduto(); const { data: gruposData } = useGruposProduto();
const { data: pautas } = usePautas();
const createMutation = useCreateRegraDesconto(); const createMutation = useCreateRegraDesconto();
const updateMutation = useUpdateRegraDesconto(); const updateMutation = useUpdateRegraDesconto();
const deleteMutation = useDeleteRegraDesconto(); const deleteMutation = useDeleteRegraDesconto();
@@ -560,6 +436,12 @@ function TabRegras() {
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`, label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
})); }));
// Pautas de preços — rótulo com código + descrição (busca encontra pelos dois)
const pautaOptions = (pautas ?? []).map((p) => ({
value: p.idPauta,
label: `${p.codigo}${p.descricao}`,
}));
// Rótulos com código + nome — a busca do select encontra pelos dois. // Rótulos com código + nome — a busca do select encontra pelos dois.
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação). // Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
const filtroGrupos = new Set<number>([ const filtroGrupos = new Set<number>([
@@ -603,6 +485,7 @@ function TabRegras() {
codVendedor: r.codVendedor ?? undefined, codVendedor: r.codVendedor ?? undefined,
codGrupo: r.codGrupo ?? undefined, codGrupo: r.codGrupo ?? undefined,
codSubgrupo: r.codSubgrupo ?? undefined, codSubgrupo: r.codSubgrupo ?? undefined,
idPauta: r.idPauta ?? undefined,
descPct: r.descPct, descPct: r.descPct,
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)], periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
ativa: r.ativa, ativa: r.ativa,
@@ -630,6 +513,7 @@ function TabRegras() {
codVendedor: values.codVendedor ?? null, codVendedor: values.codVendedor ?? null,
codGrupo: values.codGrupo ?? null, codGrupo: values.codGrupo ?? null,
codSubgrupo: values.codSubgrupo ?? null, codSubgrupo: values.codSubgrupo ?? null,
idPauta: values.idPauta ?? null,
descPct: values.descPct, descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'), dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'), dataFim: fim.format('YYYY-MM-DD'),
@@ -643,6 +527,7 @@ function TabRegras() {
codVendedor: values.codVendedor ?? null, codVendedor: values.codVendedor ?? null,
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined, codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined, codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
idPautas: values.idPautas?.length ? values.idPautas : undefined,
descPct: values.descPct, descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'), dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'), dataFim: fim.format('YYYY-MM-DD'),
@@ -683,7 +568,7 @@ function TabRegras() {
}, },
{ {
title: 'Grupo / Subgrupo', title: 'Grupo / Subgrupo',
width: 220, width: 200,
render: (_: unknown, row: RegraDesconto) => { render: (_: unknown, row: RegraDesconto) => {
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>; if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
const partes = [ const partes = [
@@ -693,6 +578,16 @@ function TabRegras() {
return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>; return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>;
}, },
}, },
{
title: 'Pauta',
width: 160,
render: (_: unknown, row: RegraDesconto) =>
row.idPauta == null ? (
<Tag>Todas</Tag>
) : (
<Text style={{ fontSize: 'var(--text-sm)' }}>{row.pauta ?? `Pauta ${row.idPauta}`}</Text>
),
},
{ {
title: 'Desconto', title: 'Desconto',
dataIndex: 'descPct', dataIndex: 'descPct',
@@ -782,8 +677,8 @@ function TabRegras() {
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }} locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
/> />
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
A regra vale para o representante, grupo e subgrupo informados (em branco = todos) e aplica A regra vale para o representante, grupo, subgrupo e pauta informados (em branco = todos) e
o desconto automaticamente nos pedidos durante a vigencia. aplica o desconto automaticamente nos pedidos durante a vigencia.
</Text> </Text>
<Modal <Modal
@@ -817,6 +712,7 @@ function TabRegras() {
</Form.Item> </Form.Item>
{editTarget ? ( {editTarget ? (
<>
<Flex gap={16}> <Flex gap={16}>
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}> <Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
<Select <Select
@@ -841,6 +737,16 @@ function TabRegras() {
<Switch /> <Switch />
</Form.Item> </Form.Item>
</Flex> </Flex>
<Form.Item name="idPauta" label="Pauta de precos">
<Select
options={pautaOptions}
placeholder="Todas as pautas"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
</>
) : ( ) : (
<> <>
<Form.Item <Form.Item
@@ -871,6 +777,20 @@ function TabRegras() {
optionFilterProp="label" optionFilterProp="label"
/> />
</Form.Item> </Form.Item>
<Form.Item
name="idPautas"
label="Pautas de precos"
extra="Selecione 1 ou mais pautas — uma regra por pauta, aplicada a todos os produtos dela"
>
<Select
mode="multiple"
options={pautaOptions}
placeholder="Todas as pautas"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
</> </>
)} )}
@@ -903,11 +823,6 @@ function TabRegras() {
export function PoliticasPage() { export function PoliticasPage() {
const items = [ const items = [
{
key: 'descontos',
label: 'Desconto por Representante',
children: <TabDescontos />,
},
{ {
key: 'promocoes', key: 'promocoes',
label: 'Promocoes', label: 'Promocoes',
@@ -927,7 +842,7 @@ export function PoliticasPage() {
Politicas Comerciais Politicas Comerciais
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
Limites de desconto e promocoes com vigencia Promocoes e regras de desconto com vigencia
</Text> </Text>
</Flex> </Flex>

View File

@@ -0,0 +1,164 @@
import { Card, Col, Flex, Modal, Progress, Row, Space, Spin, Table, Typography } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faAddressCard, faRankingStar } from '@fortawesome/free-solid-svg-icons';
import type { PositivacaoRep, RankingRep, SupervisorCard } from '@sar/api-interface';
import { useSupervisorDashboardByCod } from '../../lib/queries/dashboard';
import {
positivacaoRepColumns,
rankingRepColumns,
} from '../../components/dashboard/rep-performance-columns';
const { Title, Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function metaColor(pct: number): string {
return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
}
// Detalhe/análise do time de um supervisor — aberto pelo gerente na aba Equipe.
// Reusa /dashboard/supervisor escopado por codSupervisor e as mesmas tabelas do
// painel do supervisor (ranking + positivação).
export function SupervisorDetalheModal({
supervisor,
onClose,
}: {
supervisor: SupervisorCard | null;
onClose: () => void;
}) {
const { data, isLoading } = useSupervisorDashboardByCod(supervisor?.codSupervisor ?? null);
const titulo = supervisor
? `${supervisor.nomeSupervisor ?? `Supervisor cód. ${supervisor.codSupervisor}`}`
: '';
return (
<Modal
open={supervisor !== null}
onCancel={onClose}
footer={null}
width={900}
centered
destroyOnHidden
title={
<Space>
<FontAwesomeIcon icon={faRankingStar} style={{ color: 'var(--jcs-blue)' }} />
{titulo}
</Space>
}
>
{isLoading || !data || !supervisor ? (
<Spin style={{ display: 'block', margin: '48px auto' }} />
) : (
<Flex vertical gap={20}>
{/* KPIs do time no mês */}
<Row gutter={[16, 16]}>
<Col xs={24} md={8}>
<Card size="small">
<Space orientation="vertical" size={2}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
FATURAMENTO NO MÊS
</Text>
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
{fmt(data.equipeMes.faturamento)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{data.equipeMes.pedidos} pedido{data.equipeMes.pedidos !== 1 ? 's' : ''} ·{' '}
{supervisor.repsAtivos}/{supervisor.repsTotal} reps ativos
</Text>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card size="small">
<Space orientation="vertical" size={2} style={{ width: '100%' }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
META DO TIME
</Text>
{data.equipeMes.metaTotal > 0 ? (
<>
<Flex align="baseline" gap={6}>
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
{data.equipeMes.pctMeta}%
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
de {fmt(data.equipeMes.metaTotal)}
</Text>
</Flex>
<Progress
percent={Math.min(data.equipeMes.pctMeta, 100)}
size="small"
strokeColor={metaColor(data.equipeMes.pctMeta)}
showInfo={false}
/>
</>
) : (
<Text type="secondary">Sem meta cadastrada.</Text>
)}
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card size="small">
<Space orientation="vertical" size={2}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
TICKET MÉDIO
</Text>
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
{fmt(data.equipeMes.ticketMedio)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
positivação {supervisor.pctPositivacao}% ({supervisor.clientesPositivados}/
{supervisor.totalClientes})
</Text>
</Space>
</Card>
</Col>
</Row>
{/* Ranking dos reps do time */}
<div>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
<FontAwesomeIcon
icon={faRankingStar}
style={{ color: 'var(--jcs-blue)', marginRight: 8 }}
/>
Ranking dos representantes
</Text>
<Table<RankingRep>
rowKey="codVendedor"
columns={rankingRepColumns}
dataSource={data.rankingReps}
pagination={false}
size="small"
scroll={{ x: 640 }}
locale={{ emptyText: 'Nenhum pedido do time no mês.' }}
/>
</div>
{/* Positivação dos reps do time */}
<div>
<Text strong style={{ display: 'block', marginBottom: 12 }}>
<FontAwesomeIcon
icon={faAddressCard}
style={{ color: 'var(--jcs-blue)', marginRight: 8 }}
/>
Positivação de clientes
</Text>
<Table<PositivacaoRep>
rowKey="codVendedor"
columns={positivacaoRepColumns}
dataSource={data.positivacaoReps}
pagination={false}
size="small"
scroll={{ x: 640 }}
locale={{ emptyText: 'Nenhum representante com carteira no time.' }}
/>
</div>
</Flex>
)}
</Modal>
);
}

View File

@@ -1,398 +0,0 @@
import { useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
Col,
Flex,
Row,
Skeleton,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
ArrowLeftOutlined,
ExclamationCircleOutlined,
FireOutlined,
RiseOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router';
import type { CarteiraCliente } from '@sar/api-interface';
import { useReportCarteira } from '../../lib/queries/reports';
const { Title, Text } = Typography;
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 + 'T00:00:00').toLocaleDateString('pt-BR');
}
type Filtro = 'todos' | 'ativos' | 'risco' | 'inativos' | 'semPedido';
function statusBadge(c: CarteiraCliente) {
if (c.totalPedidos === 0)
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedido</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' }}>Em risco 30d+</Text>}
/>
);
return (
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
);
}
function InsightCard({
icon,
cor,
titulo,
descricao,
}: {
icon: React.ReactNode;
cor: string;
titulo: string;
descricao: string;
}) {
return (
<Card
styles={{ body: { padding: '14px 18px' } }}
style={{ borderLeft: `4px solid ${cor}`, borderRadius: 8 }}
>
<Flex gap={12} align="flex-start">
<span style={{ color: cor, fontSize: 18, marginTop: 2 }}>{icon}</span>
{/* minWidth: 0 — flex item precisa poder encolher; titulo/descricao
embutem razão social e valores que podem não ter ponto de quebra. */}
<div style={{ minWidth: 0 }}>
<Text strong style={{ fontSize: 13, color: '#1e293b' }}>
{titulo}
</Text>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
{descricao}
</Text>
</div>
</div>
</Flex>
</Card>
);
}
export function CarteirePage() {
const navigate = useNavigate();
const { data, isLoading, isError, error } = useReportCarteira();
const [filtro, setFiltro] = useState<Filtro>('todos');
const clientes = data?.data ?? [];
const ativos = clientes.filter(
(c) => c.totalPedidos > 0 && (c.diasSemPedido == null || c.diasSemPedido < 30),
);
const emRisco = clientes.filter(
(c) => c.diasSemPedido != null && c.diasSemPedido >= 30 && c.diasSemPedido < 60,
);
const inativos = clientes.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60);
const semPedido = clientes.filter((c) => c.totalPedidos === 0);
const filtered = (() => {
if (filtro === 'ativos') return ativos;
if (filtro === 'risco') return emRisco;
if (filtro === 'inativos') return inativos;
if (filtro === 'semPedido') return semPedido;
return clientes;
})();
// Insights calculados
const top5Fat = clientes
.slice()
.sort((a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal))
.slice(0, 5);
const top5Pct = top5Fat.reduce((acc, c) => acc + c.participacaoPct, 0);
const maiorCliEmRisco = emRisco.sort(
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
)[0];
const maiorCliInativo = inativos.sort(
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
)[0];
const insights: { icon: React.ReactNode; cor: string; titulo: string; descricao: string }[] = [];
if (top5Pct >= 60) {
insights.push({
icon: <ExclamationCircleOutlined />,
cor: '#f59e0b',
titulo: `Concentração: top 5 clientes = ${top5Pct.toFixed(0)}% do faturamento`,
descricao: 'Risco alto de dependência. Considere expandir outros clientes da carteira.',
});
}
if (maiorCliEmRisco) {
const nome =
maiorCliEmRisco.razao ?? maiorCliEmRisco.nome ?? `Cliente ${maiorCliEmRisco.idCliente}`;
insights.push({
icon: <WarningOutlined />,
cor: '#f59e0b',
titulo: `${nome} está ${maiorCliEmRisco.diasSemPedido}d sem comprar`,
descricao: `Faturamento histórico: ${fmt(maiorCliEmRisco.faturamentoTotal)} — priorize o contato.`,
});
}
if (maiorCliInativo) {
const nome =
maiorCliInativo.razao ?? maiorCliInativo.nome ?? `Cliente ${maiorCliInativo.idCliente}`;
insights.push({
icon: <FireOutlined />,
cor: '#ef4444',
titulo: `${nome} inativo há ${maiorCliInativo.diasSemPedido}d`,
descricao: `Era um cliente de ${fmt(maiorCliInativo.faturamentoTotal)} — vale reativar.`,
});
}
if (semPedido.length > 0) {
insights.push({
icon: <RiseOutlined />,
cor: '#003B8E',
titulo: `${semPedido.length} cliente${semPedido.length > 1 ? 's' : ''} sem nenhum pedido`,
descricao: 'Potencial inexplorado na sua carteira. Primeira visita pode gerar receita nova.',
});
}
const columns: TableColumnsType<CarteiraCliente> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: CarteiraCliente) => (
<Space orientation="vertical" size={0}>
<Text
strong
style={{ fontSize: 13, cursor: 'pointer', color: '#003B8E' }}
onClick={() => navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })}
>
{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>
),
sorter: (a, b) => (a.ultimoPedido ?? '').localeCompare(b.ultimoPedido ?? ''),
},
{
title: 'Dias Sem Compra',
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>
),
sorter: (a, b) => (a.diasSemPedido ?? 9999) - (b.diasSemPedido ?? 9999),
},
{
title: 'Faturado (total)',
dataIndex: 'faturamentoTotal',
width: 150,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
),
sorter: (a, b) => Number(a.faturamentoTotal) - Number(b.faturamentoTotal),
defaultSortOrder: 'descend' as const,
},
{
title: 'Participação',
dataIndex: 'participacaoPct',
width: 110,
align: 'right' as const,
render: (v: number) => (
<Tag
color={v >= 10 ? 'blue' : v >= 5 ? 'geekblue' : 'default'}
style={{ borderRadius: 20, fontWeight: 600 }}
>
{v.toFixed(1)}%
</Tag>
),
sorter: (a, b) => a.participacaoPct - b.participacaoPct,
},
{
title: 'Ticket Médio',
dataIndex: 'ticketMedio',
width: 130,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums" style={{ color: '#475569' }}>
{fmt(v)}
</Text>
),
sorter: (a, b) => Number(a.ticketMedio) - Number(b.ticketMedio),
},
{
title: 'Pedidos',
dataIndex: 'totalPedidos',
width: 80,
align: 'right' as const,
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
sorter: (a, b) => a.totalPedidos - b.totalPedidos,
},
];
const filtros: { key: Filtro; label: string; count: number; cor: string }[] = [
{ key: 'todos', label: 'Todos', count: clientes.length, cor: '#64748b' },
{ key: 'ativos', label: 'Ativos', count: ativos.length, cor: '#22c55e' },
{ key: 'risco', label: 'Em risco (30d+)', count: emRisco.length, cor: '#f59e0b' },
{ key: 'inativos', label: 'Inativos (60d+)', count: inativos.length, cor: '#ef4444' },
{ key: 'semPedido', label: 'Sem pedido', count: semPedido.length, cor: '#94A3B8' },
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
{/* Cabeçalho */}
<Flex align="center" gap={12} style={{ marginBottom: 24 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate({ to: '/clientes' })}
type="text"
style={{ color: '#64748b' }}
/>
<div>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
<TeamOutlined style={{ marginRight: 8 }} />
Carteira de Clientes
</Title>
<Text type="secondary" style={{ fontSize: 13 }}>
Visão completa da sua carteira com indicadores de saúde e oportunidades
</Text>
</div>
</Flex>
{isError && (
<Alert
type="error"
showIcon
message="Erro ao carregar carteira"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
style={{ marginBottom: 24 }}
/>
)}
{isLoading ? (
<Skeleton active paragraph={{ rows: 10 }} />
) : (
<>
{/* Cards de resumo */}
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{filtros.map((f) => (
<Col key={f.key} xs={12} sm={8} md={4} style={{ minWidth: 120 }}>
<Card
hoverable
onClick={() => setFiltro(f.key)}
styles={{ body: { padding: '12px 16px' } }}
style={{
borderRadius: 8,
border: filtro === f.key ? `2px solid ${f.cor}` : '1px solid #EBF0F5',
cursor: 'pointer',
}}
>
<Statistic
title={f.label}
value={f.count}
styles={{ content: { color: f.cor, fontSize: 24 } }}
/>
</Card>
</Col>
))}
{data && (
<Col xs={12} sm={8} md={6}>
<Card styles={{ body: { padding: '12px 16px' } }} style={{ borderRadius: 8 }}>
<Statistic
title="Faturamento Total (ERP)"
value={fmt(data.faturamentoRepTotal)}
styles={{ content: { color: '#003B8E', fontSize: 18 } }}
/>
</Card>
</Col>
)}
</Row>
{/* Insights */}
{insights.length > 0 && (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{insights.map((ins, i) => (
<Col key={i} xs={24} md={12}>
<InsightCard {...ins} />
</Col>
))}
</Row>
)}
{/* Tabela */}
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<CarteiraCliente>
rowKey="idCliente"
columns={columns}
dataSource={filtered}
size="middle"
pagination={{ pageSize: 20, showSizeChanger: false }}
scroll={{ x: 900 }}
style={{ borderRadius: 10, overflow: 'hidden' }}
onRow={(c) => ({
onClick: () =>
navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } }),
style: { cursor: 'pointer' },
})}
/>
</Card>
</>
)}
</div>
);
}

View File

@@ -35,16 +35,12 @@ import {
UserOutlined, UserOutlined,
WhatsAppOutlined, WhatsAppOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { Doughnut } from 'react-chartjs-2';
import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from 'chart.js';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import type { ActivityStatus, ClientSummary } from '@sar/api-interface'; import type { ActivityStatus, ClientSummary } from '@sar/api-interface';
import { SITUA_LABEL } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface';
import { useClientList, useClientDetail } from '../../lib/queries/clients'; import { useClientList, useClientDetail } from '../../lib/queries/clients';
import { useClientOrders } from '../../lib/queries/orders'; import { useClientOrders } from '../../lib/queries/orders';
ChartJS.register(ArcElement, ChartTooltip, Legend);
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@@ -184,150 +180,6 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) {
); );
} }
// ─── CustomerPortfolioCard ────────────────────────────────────────────────────
function CustomerPortfolioCard({
stats,
onDetalhar,
}: {
stats: PortfolioStats;
onDetalhar: () => void;
}) {
const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
const total = stats.ativos + stats.emAlerta + stats.inativos;
const donutData = {
labels: ['Ativos', 'Em alerta', 'Inativos'],
datasets: [
{
data: [stats.ativos, stats.emAlerta, stats.inativos],
backgroundColor: ['#52C41A', '#FAAD14', '#FF4D4F'],
borderColor: '#fff',
borderWidth: 3,
hoverOffset: 6,
},
],
};
const donutOptions = {
responsive: true,
maintainAspectRatio: true,
cutout: '68%',
plugins: {
legend: { display: false },
tooltip: {
callbacks: {
label: (ctx: { label: string; raw: unknown }) => {
const v = ctx.raw as number;
const pct = total > 0 ? ((v / total) * 100).toFixed(1) : '0.0';
return `${ctx.label}: ${v.toLocaleString('pt-BR')} (${pct}%)`;
},
},
},
},
};
const legendItems = [
{ label: 'Ativos', value: stats.ativos, color: '#52C41A' },
{ label: 'Em alerta', value: stats.emAlerta, color: '#FAAD14' },
{ label: 'Inativos', value: stats.inativos, color: '#FF4D4F' },
];
return (
<Card
style={{
borderRadius: 12,
border: '1px solid #EBF0F5',
boxShadow: '0 1px 6px rgba(0,0,0,0.07)',
}}
styles={{ body: { padding: '20px 20px 16px' } }}
>
<div style={{ marginBottom: 16 }}>
<Text strong style={{ fontSize: 14, color: '#1F2937', display: 'block' }}>
Carteira de Clientes
</Text>
<Text style={{ fontSize: 12, color: '#64748B', textTransform: 'capitalize' }}>
{mesAtual}
</Text>
</div>
<div style={{ position: 'relative', width: 180, margin: '0 auto 16px' }}>
{stats.loaded && total > 0 ? (
<>
<Doughnut data={donutData} options={donutOptions} />
<div
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%,-50%)',
textAlign: 'center',
pointerEvents: 'none',
}}
>
<Text
strong
style={{ fontSize: 22, color: '#1F2937', display: 'block', lineHeight: 1.1 }}
>
{stats.total.toLocaleString('pt-BR')}
</Text>
<Text style={{ fontSize: 11, color: '#64748B' }}>Clientes</Text>
</div>
</>
) : (
<div
style={{ height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<Spin />
</div>
)}
</div>
<Space orientation="vertical" size={6} style={{ width: '100%', marginBottom: 16 }}>
{legendItems.map((item) => {
const pct = total > 0 ? ((item.value / total) * 100).toFixed(1) : '0.0';
return (
<div
key={item.label}
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
>
<Space size={6}>
<div
style={{
width: 10,
height: 10,
borderRadius: 2,
background: item.color,
flexShrink: 0,
}}
/>
<Text style={{ fontSize: 12, color: '#475569' }}>{item.label}</Text>
</Space>
<Space size={8}>
<Text strong style={{ fontSize: 12 }}>
{item.value.toLocaleString('pt-BR')}
</Text>
<Text style={{ fontSize: 11, color: '#94A3B8', width: 44, textAlign: 'right' }}>
{pct}%
</Text>
</Space>
</div>
);
})}
</Space>
<Divider style={{ margin: '12px 0' }} />
<Button
block
onClick={onDetalhar}
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
>
Detalhar carteira
</Button>
</Card>
);
}
// ─── CustomerExpandedDetail ─────────────────────────────────────────────────── // ─── CustomerExpandedDetail ───────────────────────────────────────────────────
function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) { function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
@@ -1460,22 +1312,9 @@ export function ClientsPage() {
</Row> </Row>
</Card> </Card>
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
{isMobile && (
<>
<CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
<div style={{ marginBottom: 16 }} />
</>
)}
{/* ── Área principal ────────────────────────────────────────────── */} {/* ── Área principal ────────────────────────────────────────────── */}
<Row gutter={[20, 20]}> <Row gutter={[20, 20]}>
{/* minWidth: 0 — Col é flex item (min-width auto). Sem isto a tabela com <Col xs={24} style={{ minWidth: 0 }}>
scroll x estoura a coluna e engole o card vizinho. */}
<Col xs={24} lg={17} style={{ minWidth: 0 }}>
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}> <div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" /> <Spin size="large" />
@@ -1548,15 +1387,6 @@ export function ClientsPage() {
</Card> </Card>
)} )}
</Col> </Col>
{!isMobile && (
<Col lg={7}>
<CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
</Col>
)}
</Row> </Row>
{/* ── Modais ────────────────────────────────────────────────────── */} {/* ── Modais ────────────────────────────────────────────────────── */}

View File

@@ -0,0 +1,146 @@
import { useState } from 'react';
import { Button, Card, Divider, Flex, Space, Tag, Typography } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faChevronDown, faTags } from '@fortawesome/free-solid-svg-icons';
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
import { usePromocoesVigentes, useRegrasVigentes } from '../../lib/queries/politicas';
const { Text } = Typography;
function fmtData(iso: string): string {
return new Date(`${iso}T00:00:00`).toLocaleDateString('pt-BR');
}
function alvoPromocao(p: PromocaoVigente): string {
if (p.codProduto) return p.nomeProduto ?? `Produto ${p.codProduto}`;
if (p.grpProd) return `Grupo ${p.grupo ?? p.grpProd}`;
return 'Todos os produtos';
}
function alvoRegra(r: RegraVigente): string {
if (r.codGrupo != null) return `Grupo ${r.grupo ?? r.codGrupo}`;
if (r.codSubgrupo != null) return `Subgrupo ${r.subgrupo ?? r.codSubgrupo}`;
return 'Todos os produtos';
}
function LinhaCondicao({
tipo,
descricao,
alvo,
descPct,
dataFim,
}: {
tipo: 'promocao' | 'regra';
descricao: string;
alvo: string;
descPct: number;
dataFim: string;
}) {
return (
<Flex justify="space-between" align="center" gap={12} wrap>
<Space orientation="vertical" size={0}>
<Space size={8}>
<Tag
color={tipo === 'promocao' ? 'orange' : 'blue'}
style={{ borderRadius: 20, fontSize: 10, fontWeight: 700, marginInlineEnd: 0 }}
>
{tipo === 'promocao' ? 'PROMOÇÃO' : 'DESCONTO LIBERADO'}
</Tag>
<Text strong>{descricao}</Text>
</Space>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
{alvo}
</Text>
</Space>
<Space size={8}>
<Tag color="green" className="tabular-nums" style={{ borderRadius: 20, fontWeight: 700 }}>
{descPct}% off
</Tag>
<Text type="secondary" className="tabular-nums" style={{ fontSize: 'var(--text-xs)' }}>
até {fmtData(dataFim)}
</Text>
</Space>
</Flex>
);
}
// Card expansível do painel do rep: promoções e regras de desconto vigentes
// HOJE para o representante logado. Some sozinho quando a condição encerra ou
// é inativada pelo gerente (refetch a cada minuto nos hooks de vigentes).
export function CondicoesEspeciaisCard() {
const { data: promosData } = usePromocoesVigentes();
const { data: regrasData } = useRegrasVigentes();
const [expandido, setExpandido] = useState(false);
const promocoes = promosData?.promocoes ?? [];
const regras = regrasData?.regras ?? [];
const total = promocoes.length + regras.length;
if (total === 0) return null;
return (
<Card
styles={{ body: expandido ? {} : { display: 'none' } }}
title={
<Flex
justify="space-between"
align="center"
style={{ cursor: 'pointer' }}
onClick={() => setExpandido((v) => !v)}
>
<Space>
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--orange)' }} />
Condições Especiais
<Tag color="orange" style={{ borderRadius: 20, fontWeight: 700 }}>
{total}
</Tag>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', fontWeight: 400 }}>
vigentes hoje
</Text>
</Space>
<Button
type="text"
size="small"
aria-label={expandido ? 'Recolher' : 'Expandir'}
icon={
<FontAwesomeIcon
icon={faChevronDown}
style={{
transition: 'transform 0.2s',
transform: expandido ? 'rotate(180deg)' : undefined,
}}
/>
}
/>
</Flex>
}
>
<Flex vertical gap={12}>
{promocoes.map((p: PromocaoVigente, i: number) => (
<div key={`p-${p.id}`}>
{i > 0 && <Divider style={{ margin: '0 0 12px' }} />}
<LinhaCondicao
tipo="promocao"
descricao={p.descricao}
alvo={alvoPromocao(p)}
descPct={p.descPct}
dataFim={p.dataFim}
/>
</div>
))}
{regras.map((r: RegraVigente, i: number) => (
<div key={`r-${r.id}`}>
{(i > 0 || promocoes.length > 0) && <Divider style={{ margin: '0 0 12px' }} />}
<LinhaCondicao
tipo="regra"
descricao={r.descricao}
alvo={alvoRegra(r)}
descPct={r.descPct}
dataFim={r.dataFim}
/>
</div>
))}
</Flex>
</Card>
);
}

View File

@@ -6,16 +6,12 @@ import {
Button, Button,
Descriptions, Descriptions,
Divider, Divider,
Form,
InputNumber,
Modal,
Space, Space,
Spin, Spin,
Table, Table,
Tag, Tag,
Timeline, Timeline,
Typography, Typography,
Input,
message, message,
} from 'antd'; } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
@@ -32,7 +28,6 @@ import { apiFetch } from '../../lib/api-client';
import { authStore } from '../../lib/auth-store'; import { authStore } from '../../lib/auth-store';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { TextArea } = Input;
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
@@ -144,105 +139,6 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
); );
} }
// ─── Approve Modal ────────────────────────────────────────────────────────────
function ApproveModal({
open,
originalDiscount,
onConfirm,
onCancel,
loading,
}: {
open: boolean;
originalDiscount: string;
onConfirm: (descontoPerc?: number, nota?: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [disc, setDisc] = useState<number | null>(null);
const [nota, setNota] = useState('');
return (
<Modal
title="Aprovar Pedido"
open={open}
onOk={() => onConfirm(disc ?? undefined, nota || undefined)}
onCancel={onCancel}
okText="Confirmar Aprovação"
cancelText="Voltar"
confirmLoading={loading}
>
<Form layout="vertical">
<Form.Item
label={`Desconto global (original: ${originalDiscount}%)`}
help="Deixe em branco para manter o desconto solicitado."
>
<InputNumber
min={0}
max={100}
step={0.5}
placeholder={originalDiscount}
value={disc}
onChange={(v) => setDisc(v)}
addonAfter="%"
style={{ width: 160 }}
/>
</Form.Item>
<Form.Item label="Observação (opcional)">
<TextArea
rows={2}
value={nota}
onChange={(e) => setNota(e.target.value)}
maxLength={300}
/>
</Form.Item>
</Form>
</Modal>
);
}
// ─── Reject Modal ─────────────────────────────────────────────────────────────
function RejectModal({
open,
onConfirm,
onCancel,
loading,
}: {
open: boolean;
onConfirm: (motivo: string) => void;
onCancel: () => void;
loading: boolean;
}) {
const [motivo, setMotivo] = useState('');
return (
<Modal
title="Recusar Pedido"
open={open}
onOk={() => motivo.trim() && onConfirm(motivo.trim())}
onCancel={onCancel}
okText="Confirmar Recusa"
okButtonProps={{ danger: true, disabled: !motivo.trim() }}
cancelText="Voltar"
confirmLoading={loading}
>
<Form layout="vertical">
<Form.Item label="Motivo da recusa" required>
<TextArea
rows={3}
value={motivo}
onChange={(e) => setMotivo(e.target.value)}
maxLength={500}
showCount
placeholder="Informe o motivo para o representante..."
/>
</Form.Item>
</Form>
</Modal>
);
}
// ─── OrderDetailPage ────────────────────────────────────────────────────────── // ─── OrderDetailPage ──────────────────────────────────────────────────────────
export function OrderDetailPage() { export function OrderDetailPage() {
@@ -254,7 +150,6 @@ export function OrderDetailPage() {
const role = getRoleFromToken(); const role = getRoleFromToken();
const isErp = order?.fonte === 'erp'; const isErp = order?.fonte === 'erp';
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
const canTransmit = !isErp && role === 'rep' && order?.situa === 0; const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
const canShare = const canShare =
role === 'rep' && role === 'rep' &&
@@ -262,39 +157,9 @@ export function OrderDetailPage() {
typeof navigator !== 'undefined' && typeof navigator !== 'undefined' &&
!!navigator.share; !!navigator.share;
const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false);
const [chamadoOpen, setChamadoOpen] = useState(false); const [chamadoOpen, setChamadoOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const approveMutation = useMutation({
mutationFn: ({ descontoPerc, nota }: { descontoPerc?: number; nota?: string }) =>
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { descontoPerc, nota } }),
onSuccess: () => {
setApproveOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => {
setApproveOpen(false);
setActionError(e instanceof Error ? e.message : 'Erro ao aprovar');
},
});
const rejectMutation = useMutation({
mutationFn: (motivo: string) =>
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { motivo } }),
onSuccess: () => {
setRejectOpen(false);
void qc.invalidateQueries({ queryKey: ['orders', id] });
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => {
setRejectOpen(false);
setActionError(e instanceof Error ? e.message : 'Erro ao recusar');
},
});
const transmitMutation = useMutation({ const transmitMutation = useMutation({
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }), mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
onSuccess: () => { onSuccess: () => {
@@ -309,11 +174,6 @@ export function OrderDetailPage() {
if (error || !order) if (error || !order)
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />; return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
const timeWaiting =
order.situa === 1
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
: null;
// Orçamento: tela mais larga para consulta/revisão com o cliente. // Orçamento: tela mais larga para consulta/revisão com o cliente.
const isOrcamento = order.situa === 0; const isOrcamento = order.situa === 0;
@@ -339,9 +199,6 @@ export function OrderDetailPage() {
</Tag> </Tag>
} }
/> />
{timeWaiting !== null && timeWaiting > 2 && (
<Tag color="red">Urgente {timeWaiting}h aguardando</Tag>
)}
<Button <Button
icon={<FilePdfOutlined />} icon={<FilePdfOutlined />}
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })} onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
@@ -361,16 +218,6 @@ export function OrderDetailPage() {
Transmitir pedido Transmitir pedido
</Button> </Button>
)} )}
{canAct && (
<Space>
<Button type="primary" onClick={() => setApproveOpen(true)}>
Aprovar
</Button>
<Button danger onClick={() => setRejectOpen(true)}>
Recusar
</Button>
</Space>
)}
{canShare && ( {canShare && (
<Button <Button
icon={<FontAwesomeIcon icon={faShareNodes} />} icon={<FontAwesomeIcon icon={faShareNodes} />}
@@ -420,11 +267,6 @@ export function OrderDetailPage() {
<Descriptions.Item label="Data"> <Descriptions.Item label="Data">
{new Date(order.dtPedido).toLocaleDateString('pt-BR')} {new Date(order.dtPedido).toLocaleDateString('pt-BR')}
</Descriptions.Item> </Descriptions.Item>
{order.aprovadoEm && (
<Descriptions.Item label="Aprovado em">
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} cód. {order.aprovadoPor}
</Descriptions.Item>
)}
{order.formaPagamento && ( {order.formaPagamento && (
<Descriptions.Item label="Cond. Pagamento" span={2}> <Descriptions.Item label="Cond. Pagamento" span={2}>
{order.formaPagamento} {order.formaPagamento}
@@ -511,19 +353,6 @@ export function OrderDetailPage() {
<Divider titlePlacement="left">Histórico do Pedido</Divider> <Divider titlePlacement="left">Histórico do Pedido</Divider>
<HistoryTimeline history={order.historico} /> <HistoryTimeline history={order.historico} />
<ApproveModal
open={approveOpen}
originalDiscount={order.descontoPerc}
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })}
onCancel={() => setApproveOpen(false)}
loading={approveMutation.isPending}
/>
<RejectModal
open={rejectOpen}
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
onCancel={() => setRejectOpen(false)}
loading={rejectMutation.isPending}
/>
{order && ( {order && (
<AbrirChamadoModal <AbrirChamadoModal
open={chamadoOpen} open={chamadoOpen}

View File

@@ -142,13 +142,11 @@ const STATUS: Record<number, { label: string; color: string; rowBg: string; tagC
function useOrderStats() { function useOrderStats() {
const all = useOrderList({ limit: 1 }); const all = useOrderList({ limit: 1 });
const pendentes = useOrderList({ limit: 1, situa: 1 }); const transmitidos = useOrderList({ limit: 1, situa: 2 });
const aprovados = useOrderList({ limit: 1, situa: 2 });
const faturados = useOrderList({ limit: 1, situa: 4 }); const faturados = useOrderList({ limit: 1, situa: 4 });
return { return {
total: all.data?.total ?? 0, total: all.data?.total ?? 0,
pendentes: pendentes.data?.total ?? 0, transmitidos: transmitidos.data?.total ?? 0,
aprovados: aprovados.data?.total ?? 0,
faturados: faturados.data?.total ?? 0, faturados: faturados.data?.total ?? 0,
loaded: !!all.data, loaded: !!all.data,
}; };
@@ -167,19 +165,18 @@ function OrdersMetrics({ stats }: { stats: OrderStats }) {
color: '#003B8E', color: '#003B8E',
}, },
{ {
label: 'Ag. Aprovação', label: 'Transmitidos',
value: stats.pendentes, value: stats.transmitidos,
icon: <ClockCircleOutlined />, icon: <CheckCircleOutlined />,
color: '#d46b08', color: '#389e0d',
}, },
{ label: 'Aprovados', value: stats.aprovados, icon: <CheckCircleOutlined />, color: '#389e0d' },
{ label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' }, { label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' },
]; ];
return ( return (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}> <Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{metrics.map((m) => ( {metrics.map((m) => (
<Col key={m.label} xs={12} sm={6}> <Col key={m.label} xs={12} sm={8}>
<Card <Card
style={{ style={{
borderRadius: 10, borderRadius: 10,
@@ -865,7 +862,7 @@ export function OrdersPage() {
Pedidos Pedidos
</Title> </Title>
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}> <p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
Acompanhe seus pedidos, status de aprovação e histórico comercial. Acompanhe seus pedidos, status de transmissão e histórico comercial.
</p> </p>
</div> </div>
{!isMobile && ( {!isMobile && (
@@ -949,7 +946,6 @@ export function OrdersPage() {
}} }}
options={[ options={[
{ value: 0, label: 'Orçamento' }, { value: 0, label: 'Orçamento' },
{ value: 1, label: 'Ag. Aprovação' },
{ value: 2, label: 'Transmitido' }, { value: 2, label: 'Transmitido' },
{ value: 3, label: 'Cancelado' }, { value: 3, label: 'Cancelado' },
{ value: 4, label: 'Faturado' }, { value: 4, label: 'Faturado' },

View File

@@ -1,7 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Alert, Alert,
Badge,
Card, Card,
Col, Col,
DatePicker, DatePicker,
@@ -17,10 +16,10 @@ import {
Typography, Typography,
} from 'antd'; } from 'antd';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import { AlertOutlined, BarChartOutlined, RiseOutlined, TeamOutlined } from '@ant-design/icons'; import { AlertOutlined, BarChartOutlined, RiseOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { CarteiraCliente, AbcProduto } from '@sar/api-interface'; import type { AbcProduto } from '@sar/api-interface';
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports'; import { useReportMeta, useReportAbc } from '../../lib/queries/reports';
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Option } = Select; const { Option } = Select;
@@ -29,11 +28,6 @@ function fmt(v: number | string): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); 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 ─────────────────────────────────────────────── // ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
function TabMeta() { function TabMeta() {
@@ -196,177 +190,7 @@ function TabMeta() {
); );
} }
// ─── Aba 2: Carteira de Clientes ───────────────────────────────────────────── // ─── Aba 2: Curva ABC ─────────────────────────────────────────────────────────
type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
function TabCarteira() {
const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
const { data, isLoading, isError, error } = 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 orientation="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>
),
},
];
if (isError) {
return (
<Alert
type="error"
showIcon
message="Erro ao carregar carteira de clientes"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
/>
);
}
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}
styles={{ content: { 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' }; const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
@@ -550,16 +374,6 @@ export function RelatoriosPage() {
), ),
children: <TabMeta />, children: <TabMeta />,
}, },
{
key: 'carteira',
label: (
<Space>
<TeamOutlined />
Carteira de Clientes
</Space>
),
children: <TabCarteira />,
},
{ {
key: 'abc', key: 'abc',
label: ( label: (
@@ -579,7 +393,7 @@ export function RelatoriosPage() {
Relatórios Relatórios
</Title> </Title>
<Text type="secondary" style={{ fontSize: 14 }}> <Text type="secondary" style={{ fontSize: 14 }}>
Análise de desempenho, carteira e mix de produtos. Análise de desempenho e mix de produtos.
</Text> </Text>
</div> </div>

View File

@@ -39,6 +39,7 @@ import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar
import { SITUA_LABEL } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard'; import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth'; import { useCurrentUser } from '../../lib/queries/auth';
import { CondicoesEspeciaisCard } from './CondicoesEspeciaisCard';
ChartJS.register( ChartJS.register(
CategoryScale, CategoryScale,
@@ -553,6 +554,9 @@ export function RepPainel() {
</Col> </Col>
</Row> </Row>
{/* Condições especiais vigentes hoje (promoções + descontos liberados) */}
<CondicoesEspeciaisCard />
{/* Metas por Grupo */} {/* Metas por Grupo */}
{metasPorGrupo.length > 0 && ( {metasPorGrupo.length > 0 && (
<Card <Card

View File

@@ -1,108 +0,0 @@
import { Table, Tag, Typography, Badge, Space } from 'antd';
import type { TableColumnsType } from 'antd';
import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface';
import { useOrderList } from '../../lib/queries/orders';
const { Title } = Typography;
function hoursWaiting(createdAt: string): number {
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
}
const columns: TableColumnsType<PedidoSummary> = [
{
title: 'Nº',
dataIndex: 'numPedSar',
width: 120,
render: (num: string, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
{num}
</Link>
),
},
{
title: 'Representante',
key: 'rep',
width: 160,
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Cliente',
key: 'cliente',
width: 200,
render: (_: unknown, row: PedidoSummary) =>
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`,
},
{
title: 'Total',
dataIndex: 'total',
width: 130,
align: 'right',
render: (v: string) =>
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
},
{
title: 'Desc. %',
dataIndex: 'descontoPerc',
width: 90,
align: 'right',
render: (v: string) => `${v}%`,
},
{
title: 'Aguardando',
dataIndex: 'createdAt',
width: 130,
render: (v: string) => {
const h = hoursWaiting(v);
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>;
},
},
{
title: '',
width: 100,
render: (_: unknown, row: PedidoSummary) => (
<Link to="/pedidos/$id" params={{ id: row.id }}>
<Tag color="blue" style={{ cursor: 'pointer' }}>
Analisar
</Tag>
</Link>
),
},
];
export function ApprovalQueuePage() {
// situa=1 = Pendente de Aprovação
const { data, isLoading } = useOrderList({ situa: 1, limit: 200 });
const urgentCount = data?.data.filter((o) => hoursWaiting(o.createdAt) > 2).length ?? 0;
return (
<div style={{ padding: 24 }}>
<Space align="center" style={{ marginBottom: 16 }}>
<Title level={3} style={{ margin: 0 }}>
Fila de Aprovações
</Title>
{urgentCount > 0 && (
<Badge
count={urgentCount}
style={{ backgroundColor: '#cf1322' }}
title={`${urgentCount} urgente(s) — mais de 2h aguardando`}
/>
)}
</Space>
<Table<PedidoSummary>
rowKey="id"
columns={columns}
dataSource={data?.data ?? []}
loading={isLoading}
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
pagination={false}
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
/>
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
</div>
);
}

View File

@@ -1,15 +1,41 @@
import { Badge, Card, Col, Flex, Row, Skeleton, Space, Table, Tag, Typography } from 'antd'; import { useState } from 'react';
import {
Button,
Card,
Col,
Flex,
Modal,
Progress,
Row,
Skeleton,
Space,
Spin,
Table,
Tag,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import { WhatsAppOutlined } from '@ant-design/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faCheckCircle, faAddressCard,
faCircleExclamation, faCircleExclamation,
faClipboardList, faClipboardList,
faRankingStar,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { Link } from '@tanstack/react-router'; import { Link } from '@tanstack/react-router';
import type { PedidoSummary } from '@sar/api-interface'; import type {
import { useSupervisorDashboard } from '../../lib/queries/dashboard'; ClienteNaoPositivado,
PositivacaoRep,
RankingRep,
RepInativosSummary,
} from '@sar/api-interface';
import { useRepInativos, useSupervisorDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth'; import { useCurrentUser } from '../../lib/queries/auth';
import {
positivacaoRepColumns,
rankingRepColumns,
} from '../../components/dashboard/rep-performance-columns';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -17,10 +43,6 @@ function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
} }
function hoursWaiting(createdAt: string): number {
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
}
function delta(current: number, previous: number): { label: string; positive: boolean } | null { function delta(current: number, previous: number): { label: string; positive: boolean } | null {
if (previous === 0) return null; if (previous === 0) return null;
const pct = Math.round(((current - previous) / previous) * 100); const pct = Math.round(((current - previous) / previous) * 100);
@@ -38,62 +60,116 @@ function today(): string {
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }); return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
} }
const queueColumns: TableColumnsType<PedidoSummary> = [ function mesLabel(): string {
{ return new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
title: 'Pedido', }
dataIndex: 'numPedSar',
width: 120, function waLink(whatsapp: string): string {
render: (num: string, row: PedidoSummary) => ( const digits = whatsapp.replace(/\D/g, '');
<Link to="/pedidos/$id" params={{ id: row.id }}> const number = digits.startsWith('55') ? digits : `55${digits}`;
{num} return `https://wa.me/${number}`;
</Link> }
),
}, // ─── Modal de clientes inativos de um rep ─────────────────────────────────────
{
title: 'Representante', const inativosColumns: TableColumnsType<ClienteNaoPositivado> = [
key: 'rep',
width: 150,
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{ {
title: 'Cliente', title: 'Cliente',
key: 'cliente', key: 'cliente',
width: 180, render: (_: unknown, c: ClienteNaoPositivado) => (
render: (_: unknown, row: PedidoSummary) => <Space orientation="vertical" size={0}>
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`, <Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
{c.razao ?? c.nome}
</Link>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
cód. {c.idCliente}
</Text>
</Space>
),
}, },
{ {
title: 'Total', title: 'Última compra',
dataIndex: 'total', dataIndex: 'ultimoPedido',
width: 130, width: 140,
align: 'right', render: (v: string | null) =>
render: (v: string) => fmt(Number(v)), v ? new Date(`${v}T00:00:00`).toLocaleDateString('pt-BR') : <Tag>Nunca comprou</Tag>,
}, },
{ {
title: 'Aguardando', title: 'Sem compra',
dataIndex: 'createdAt', dataIndex: 'diasSemPedido',
width: 120, width: 120,
render: (v: string) => { render: (d: number, c: ClienteNaoPositivado) =>
const h = hoursWaiting(v); c.comprouAntes ? (
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>; <Tag color={d > 90 ? 'red' : 'orange'} className="tabular-nums">
}, {d} dias
</Tag>
) : (
'—'
),
}, },
{ {
title: '', title: '',
width: 90, key: 'acoes',
render: (_: unknown, row: PedidoSummary) => ( width: 120,
<Link to="/pedidos/$id" params={{ id: row.id }}> render: (_: unknown, c: ClienteNaoPositivado) => (
<Tag color="blue" style={{ cursor: 'pointer' }}> <Space>
Analisar <Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
</Tag> <Button size="small">Ficha</Button>
</Link> </Link>
{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>
), ),
}, },
]; ];
function InativosRepModal({
rep,
onClose,
}: {
rep: RepInativosSummary | null;
onClose: () => void;
}) {
const { data, isLoading } = useRepInativos(rep?.codVendedor ?? null);
return (
<Modal
open={rep !== null}
onCancel={onClose}
footer={null}
width={900}
centered
title={rep ? `Clientes Inativos — ${rep.nomeVendedor ?? `Rep cód. ${rep.codVendedor}`}` : ''}
>
{isLoading ? (
<Spin style={{ display: 'block', margin: '32px auto' }} />
) : (
<Table<ClienteNaoPositivado>
rowKey="idCliente"
columns={inativosColumns}
dataSource={data?.clientes ?? []}
size="small"
pagination={{ pageSize: 10, hideOnSinglePage: true }}
locale={{ emptyText: 'Nenhum cliente inativo.' }}
/>
)}
</Modal>
);
}
export function SupervisorPainel() { export function SupervisorPainel() {
const { data, isLoading } = useSupervisorDashboard(); const { data, isLoading } = useSupervisorDashboard();
const { data: user } = useCurrentUser(); const { data: user } = useCurrentUser();
const [inativosRep, setInativosRep] = useState<RepInativosSummary | null>(null);
if (isLoading || !data) { if (isLoading || !data) {
return ( return (
@@ -110,8 +186,7 @@ export function SupervisorPainel() {
); );
} }
const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data; const { pedidosDia, inativosPorRep, equipeMes, rankingReps, positivacaoReps, syncedAt } = data;
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.createdAt) > 2).length;
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior); const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior); const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
@@ -124,49 +199,12 @@ export function SupervisorPainel() {
</Title> </Title>
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
{today()} {today()}
{urgentCount > 0 && (
<>
{' '}
·{' '}
<span style={{ color: '#cf1322' }}>
{urgentCount} aprovação{urgentCount > 1 ? 'ões' : ''} urgente
{urgentCount > 1 ? 's' : ''}
</span>
</>
)}
</Text> </Text>
</Flex> </Flex>
{/* KPIs */} {/* KPIs */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} md={8}> <Col xs={24} md={12}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
APROVAÇÕES PENDENTES
</Text>
<Flex align="center" gap={8}>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{approvalQueue.length}
</Title>
{urgentCount > 0 && (
<Badge
count={urgentCount}
style={{ backgroundColor: '#cf1322' }}
title={`${urgentCount} urgente(s) — mais de 2h`}
/>
)}
</Flex>
<Link to="/aprovacoes">
<Text style={{ fontSize: 'var(--text-sm)', color: 'var(--jcs-blue)' }}>
Ver fila completa
</Text>
</Link>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card> <Card>
<Space orientation="vertical" size={4}> <Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
@@ -189,7 +227,7 @@ export function SupervisorPainel() {
</Card> </Card>
</Col> </Col>
<Col xs={24} md={8}> <Col xs={24} md={12}>
<Card> <Card>
<Space orientation="vertical" size={4}> <Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
@@ -213,38 +251,9 @@ export function SupervisorPainel() {
</Col> </Col>
</Row> </Row>
{/* Fila de aprovações + Inativos por rep */} {/* Inativos por rep + Pedidos de hoje */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} lg={16}> <Col xs={24} lg={12}>
<Card
title={
<Space>
<FontAwesomeIcon icon={faCheckCircle} style={{ color: 'var(--jcs-blue)' }} />
Fila de Aprovações
{approvalQueue.length > 0 && (
<Badge
count={approvalQueue.length}
style={{ backgroundColor: 'var(--jcs-blue)' }}
/>
)}
</Space>
}
extra={<Link to="/aprovacoes">Ver todas</Link>}
>
<Table<PedidoSummary>
rowKey="id"
columns={queueColumns}
dataSource={approvalQueue.slice(0, 8)}
pagination={false}
size="small"
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
/>
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
</Card>
</Col>
<Col xs={24} lg={8}>
<Card <Card
title={ title={
<Space> <Space>
@@ -281,20 +290,26 @@ export function SupervisorPainel() {
</Text> </Text>
)} )}
</Space> </Space>
<Space>
<Tag <Tag
color={r.inativosCount >= 3 ? 'orange' : 'default'} color={r.inativosCount >= 3 ? 'orange' : 'default'}
className="tabular-nums" className="tabular-nums"
> >
{r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''} {r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''}
</Tag> </Tag>
<Button size="small" onClick={() => setInativosRep(r)}>
Detalhar
</Button>
</Space>
</Flex> </Flex>
))} ))}
</Flex> </Flex>
)} )}
</Card> </Card>
</Col>
<Col xs={24} lg={12}>
<Card <Card
style={{ marginTop: 24 }}
title={ title={
<Space> <Space>
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} /> <FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
@@ -328,6 +343,130 @@ export function SupervisorPainel() {
</Col> </Col>
</Row> </Row>
{/* Métricas da equipe no mês */}
<Row gutter={[24, 24]}>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
FATURAMENTO NO MÊS
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(equipeMes.faturamento)}
</Title>
<Text
type="secondary"
style={{ fontSize: 'var(--text-sm)' }}
className="tabular-nums"
>
{equipeMes.pedidos} pedido{equipeMes.pedidos !== 1 ? 's' : ''}
</Text>
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
META DA EQUIPE
</Text>
{equipeMes.metaTotal > 0 ? (
<>
<Flex align="baseline" gap={8}>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{equipeMes.pctMeta}%
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
de {fmt(equipeMes.metaTotal)}
</Text>
</Flex>
<Progress
percent={Math.min(equipeMes.pctMeta, 100)}
size="small"
strokeColor={
equipeMes.pctMeta >= 100
? 'var(--green)'
: equipeMes.pctMeta >= 70
? 'var(--jcs-blue)'
: '#faad14'
}
showInfo={false}
/>
<Text
type="secondary"
style={{ fontSize: 'var(--text-sm)' }}
className="tabular-nums"
>
{equipeMes.metaTotal > equipeMes.faturamento
? `Faltam ${fmt(equipeMes.metaTotal - equipeMes.faturamento)}`
: 'Meta atingida 🎉'}
</Text>
</>
) : (
<Text type="secondary">Sem meta cadastrada para o mês.</Text>
)}
</Space>
</Card>
</Col>
<Col xs={24} md={8}>
<Card>
<Space orientation="vertical" size={4}>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
TICKET MÉDIO
</Text>
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
{fmt(equipeMes.ticketMedio)}
</Title>
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
por pedido no mês
</Text>
</Space>
</Card>
</Col>
</Row>
{/* Ranking da equipe */}
<Card
title={
<Space>
<FontAwesomeIcon icon={faRankingStar} style={{ color: 'var(--jcs-blue)' }} />
Ranking da Equipe &mdash; {mesLabel()}
</Space>
}
>
<Table<RankingRep>
rowKey="codVendedor"
columns={rankingRepColumns}
dataSource={rankingReps}
pagination={false}
size="small"
scroll={{ x: 700 }}
locale={{ emptyText: 'Nenhum pedido da equipe no mês.' }}
/>
</Card>
{/* Positivação por representante */}
<Card
title={
<Space>
<FontAwesomeIcon icon={faAddressCard} style={{ color: 'var(--jcs-blue)' }} />
Positivação de Clientes &mdash; {mesLabel()}
</Space>
}
>
<Table<PositivacaoRep>
rowKey="codVendedor"
columns={positivacaoRepColumns}
dataSource={positivacaoReps}
pagination={false}
size="small"
scroll={{ x: 700 }}
locale={{ emptyText: 'Nenhum representante com carteira na equipe.' }}
/>
</Card>
<Flex justify="space-between" style={{ paddingTop: 8 }}> <Flex justify="space-between" style={{ paddingTop: 8 }}>
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}> <Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
SAR · Força de Vendas · Powered by JCS Sistemas SAR · Força de Vendas · Powered by JCS Sistemas
@@ -336,6 +475,8 @@ export function SupervisorPainel() {
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} · atualiza a cada 30s Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} · atualiza a cada 30s
</Text> </Text>
</Flex> </Flex>
<InativosRepModal rep={inativosRep} onClose={() => setInativosRep(null)} />
</Flex> </Flex>
); );
} }

View File

@@ -0,0 +1,114 @@
import { Flex, Progress, Tag, Typography } from 'antd';
import type { TableColumnsType } from 'antd';
import type { PositivacaoRep, RankingRep } from '@sar/api-interface';
const { Text } = Typography;
function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
// Colunas compartilhadas entre o painel gerencial e o do supervisor —
// mesmas métricas, mudando só o escopo (empresa toda vs equipe).
export const positivacaoRepColumns: TableColumnsType<PositivacaoRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
ellipsis: true,
},
{
title: 'Positivados / Carteira',
key: 'pos',
width: 160,
align: 'center' as const,
render: (_: unknown, row: PositivacaoRep) => (
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
{row.clientesPositivados} / {row.totalClientes}
</Text>
),
},
{
title: 'Novos no mês',
key: 'novos',
width: 120,
align: 'center' as const,
sorter: (a: PositivacaoRep, b: PositivacaoRep) => a.novosClientes - b.novosClientes,
render: (_: unknown, row: PositivacaoRep) =>
row.novosClientes > 0 ? (
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
+{row.novosClientes}
</Tag>
) : (
<Text type="secondary"></Text>
),
},
{
title: '% Positivação',
dataIndex: 'pctPositivacao',
width: 170,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];
export const rankingRepColumns: TableColumnsType<RankingRep> = [
{
title: 'Representante',
key: 'rep',
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
},
{
title: 'Pedidos',
dataIndex: 'pedidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Clientes',
dataIndex: 'clientesAtendidos',
width: 80,
align: 'right',
className: 'tabular-nums',
},
{
title: 'Faturamento',
dataIndex: 'faturamento',
width: 150,
align: 'right',
render: (v: number) => fmt(v),
className: 'tabular-nums',
},
{
title: '% Meta',
dataIndex: 'pctMeta',
width: 160,
render: (pct: number) => (
<Flex align="center" gap={8}>
<Progress
percent={Math.min(pct, 100)}
size="small"
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
showInfo={false}
style={{ flex: 1, minWidth: 60 }}
/>
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
{pct}%
</Text>
</Flex>
),
},
];

View File

@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faChartLine, faChartLine,
faClipboardList, faClipboardList,
faClipboardCheck,
faUsers, faUsers,
faAddressCard, faAddressCard,
faBoxesStacked, faBoxesStacked,
@@ -40,7 +39,6 @@ const REP_ITEMS: NavItem[] = [
const SUPERVISOR_ITEMS: NavItem[] = [ const SUPERVISOR_ITEMS: NavItem[] = [
{ key: '/', icon: faChartLine, label: 'Painel' }, { key: '/', icon: faChartLine, label: 'Painel' },
{ key: '/aprovacoes', icon: faClipboardCheck, label: 'Aprovações' },
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' }, { key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' }, { key: '/clientes', icon: faAddressCard, label: 'Clientes' },
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' }, { key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },

View File

@@ -61,11 +61,6 @@ const REP_ITEMS: ItemType[] = [
const SUPERVISOR_ITEMS: ItemType[] = [ const SUPERVISOR_ITEMS: ItemType[] = [
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' }, { key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
{
key: '/aprovacoes',
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Aprovações',
},
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' }, { key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
{ {
key: '/pedidos', key: '/pedidos',
@@ -127,10 +122,12 @@ export function Sidebar() {
const role = getRole(); const role = getRole();
const items = getItems(role); const items = getItems(role);
const selectedKey = items.find((item) => { const selectedKey = items.reduce<string | undefined>((best, item) => {
const key = (item as { key?: string }).key ?? ''; const key = (item as { key?: string }).key ?? '';
return key === '/' ? location.pathname === '/' : location.pathname.startsWith(key); const matches = key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
})?.key as string | undefined; if (!matches) return best;
return !best || key.length > best.length ? key : best;
}, undefined);
return ( return (
<aside <aside

View File

@@ -1,8 +1,10 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { import {
RepDashboardSchema, RepDashboardSchema,
RepInativosDetailSchema,
SupervisorDashboardSchema, SupervisorDashboardSchema,
type RepDashboard, type RepDashboard,
type RepInativosDetail,
type SupervisorDashboard, type SupervisorDashboard,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { apiFetch } from '../api-client'; import { apiFetch } from '../api-client';
@@ -29,3 +31,30 @@ export function useSupervisorDashboard() {
refetchInterval: 30 * 1000, refetchInterval: 30 * 1000,
}); });
} }
// Painel de um supervisor específico — gerente/admin abrindo o "Detalhar" da
// aba Equipe. Reusa /dashboard/supervisor escopando pelo codSupervisor.
export function useSupervisorDashboardByCod(codSupervisor: number | null) {
return useQuery<SupervisorDashboard>({
queryKey: ['dashboard', 'supervisor', 'detalhe', codSupervisor],
queryFn: async () => {
const raw = await apiFetch(`/dashboard/supervisor?codSupervisor=${codSupervisor}`);
return SupervisorDashboardSchema.parse(raw);
},
enabled: codSupervisor != null,
staleTime: 60 * 1000,
});
}
// Detalhe dos clientes inativos de um rep (modal do card "Inativos por Rep")
export function useRepInativos(codVendedor: number | null) {
return useQuery<RepInativosDetail>({
queryKey: ['dashboard', 'supervisor', 'inativos', codVendedor],
queryFn: async () => {
const raw = await apiFetch(`/dashboard/supervisor/inativos?codVendedor=${codVendedor}`);
return RepInativosDetailSchema.parse(raw);
},
enabled: codVendedor != null,
staleTime: 5 * 60 * 1000,
});
}

View File

@@ -2,15 +2,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
ManagerDashboardSchema, ManagerDashboardSchema,
EquipeResponseSchema, EquipeResponseSchema,
AlcadaDescontosResponseSchema, SupervisoresResponseSchema,
PromocoesResponseSchema, PromocoesResponseSchema,
RegrasDescontoResponseSchema, RegrasDescontoResponseSchema,
GruposProdutoResponseSchema, GruposProdutoResponseSchema,
type ManagerDashboard, type ManagerDashboard,
type EquipeResponse, type EquipeResponse,
type AlcadaDescontosResponse, type SupervisoresResponse,
type PromocoesResponse, type PromocoesResponse,
type UpsertDescontoBody,
type CreatePromocaoBody, type CreatePromocaoBody,
type UpdatePromocaoBody, type UpdatePromocaoBody,
type RegrasDescontoResponse, type RegrasDescontoResponse,
@@ -47,14 +46,14 @@ export function useEquipe() {
}); });
} }
export function usePoliticasDescontos() { export function useSupervisores() {
return useQuery<AlcadaDescontosResponse>({ return useQuery<SupervisoresResponse>({
queryKey: ['politicas', 'descontos'], queryKey: ['equipe', 'supervisores'],
queryFn: async () => { queryFn: async () => {
const raw = await apiFetch('/politicas/descontos'); const raw = await apiFetch('/equipe/supervisores');
return AlcadaDescontosResponseSchema.parse(raw); return SupervisoresResponseSchema.parse(raw);
}, },
staleTime: 10 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
} }
@@ -69,15 +68,6 @@ export function usePromocoes() {
}); });
} }
export function useUpsertDesconto() {
const qc = useQueryClient();
return useMutation({
mutationFn: (body: UpsertDescontoBody) =>
apiFetch('/politicas/descontos', { method: 'POST', body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
});
}
export function useCreatePromocao() { export function useCreatePromocao() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({

View File

@@ -11,6 +11,7 @@ import { apiFetch } from '../api-client';
// Regras de desconto vigentes para o usuário logado (rep recebe só as suas). // Regras de desconto vigentes para o usuário logado (rep recebe só as suas).
// Usadas na montagem do pedido para pré-aplicar o desconto liberado pelo gerente. // Usadas na montagem do pedido para pré-aplicar o desconto liberado pelo gerente.
// Refetch curto: regra encerrada/inativada some da tela do rep em até 1 minuto.
export function useRegrasVigentes() { export function useRegrasVigentes() {
return useQuery<RegrasVigentesResponse>({ return useQuery<RegrasVigentesResponse>({
queryKey: ['politicas', 'regras-vigentes'], queryKey: ['politicas', 'regras-vigentes'],
@@ -18,7 +19,8 @@ export function useRegrasVigentes() {
const raw = await apiFetch('/politicas/regras/vigentes'); const raw = await apiFetch('/politicas/regras/vigentes');
return RegrasVigentesResponseSchema.parse(raw); return RegrasVigentesResponseSchema.parse(raw);
}, },
staleTime: 5 * 60 * 1000, staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
}); });
} }
@@ -34,7 +36,8 @@ export function useMinhaAlcada() {
}); });
} }
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep // Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep.
// Refetch curto: promoção encerrada/inativada some da tela do rep em até 1 minuto.
export function usePromocoesVigentes() { export function usePromocoesVigentes() {
return useQuery<PromocoesVigentesResponse>({ return useQuery<PromocoesVigentesResponse>({
queryKey: ['politicas', 'promocoes-vigentes'], queryKey: ['politicas', 'promocoes-vigentes'],
@@ -42,6 +45,7 @@ export function usePromocoesVigentes() {
const raw = await apiFetch('/politicas/promocoes/vigentes'); const raw = await apiFetch('/politicas/promocoes/vigentes');
return PromocoesVigentesResponseSchema.parse(raw); return PromocoesVigentesResponseSchema.parse(raw);
}, },
staleTime: 5 * 60 * 1000, staleTime: 60 * 1000,
refetchInterval: 60 * 1000,
}); });
} }

View File

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

View File

@@ -18,9 +18,7 @@ import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage'; import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage'; import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage'; import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
import { CarteirePage } from '../cockpits/rep/CarteirePage';
import { FunilPage } from '../cockpits/rep/FunilPage'; import { FunilPage } from '../cockpits/rep/FunilPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel'; import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { GerPainel } from '../cockpits/ger/GerPainel'; import { GerPainel } from '../cockpits/ger/GerPainel';
import { EquipePage } from '../cockpits/ger/EquipePage'; import { EquipePage } from '../cockpits/ger/EquipePage';
@@ -160,24 +158,12 @@ const relatoriosRoute = createRoute({
component: RelatoriosPage, component: RelatoriosPage,
}); });
const carteiraRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/carteira',
component: CarteirePage,
});
const funilRoute = createRoute({ const funilRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/funil', path: '/funil',
component: FunilPage, component: FunilPage,
}); });
const aprovacoes = createRoute({
getParentRoute: () => rootRoute,
path: '/aprovacoes',
component: ApprovalQueuePage,
});
const gerPainelRoute = createRoute({ const gerPainelRoute = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/ger', path: '/ger',
@@ -227,9 +213,7 @@ const routeTree = rootRoute.addChildren([
catalogoRoute, catalogoRoute,
pedidosErpRoute, pedidosErpRoute,
relatoriosRoute, relatoriosRoute,
carteiraRoute,
funilRoute, funilRoute,
aprovacoes,
gerPainelRoute, gerPainelRoute,
equipeRoute, equipeRoute,
politicasRoute, politicasRoute,

14
apps/web/src/svg-maps.d.ts vendored Normal file
View File

@@ -0,0 +1,14 @@
declare module '@svg-maps/brazil' {
interface SvgMapLocation {
id: string; // sigla da UF em minúsculas (sp, pr, …)
name: string;
path: string;
}
interface SvgMap {
label: string;
viewBox: string;
locations: SvgMapLocation[];
}
const map: SvgMap;
export default map;
}

View File

@@ -72,18 +72,13 @@ export const RepInativosSummarySchema = z.object({
}); });
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>; export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
export const SupervisorDashboardSchema = z.object({ // Detalhe dos clientes inativos (>30 dias sem compra) de um representante
approvalQueue: z.array(PedidoSummarySchema), export const RepInativosDetailSchema = z.object({
pedidosDia: z.object({ codVendedor: z.number().int(),
count: z.number().int(), nomeVendedor: z.string().nullable(),
total: z.number(), clientes: z.array(ClienteNaoPositivadoSchema),
countSemanaAnterior: z.number().int(),
totalSemanaAnterior: z.number(),
}),
inativosPorRep: z.array(RepInativosSummarySchema),
syncedAt: z.iso.datetime(),
}); });
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>; export type RepInativosDetail = z.infer<typeof RepInativosDetailSchema>;
// novosClientes: clientes da carteira cuja PRIMEIRA compra (historico todo) // novosClientes: clientes da carteira cuja PRIMEIRA compra (historico todo)
// caiu no periodo selecionado // caiu no periodo selecionado
@@ -107,6 +102,30 @@ export const RankingRepSchema = z.object({
}); });
export type RankingRep = z.infer<typeof RankingRepSchema>; export type RankingRep = z.infer<typeof RankingRepSchema>;
export const SupervisorDashboardSchema = z.object({
pedidosDia: z.object({
count: z.number().int(),
total: z.number(),
countSemanaAnterior: z.number().int(),
totalSemanaAnterior: z.number(),
}),
inativosPorRep: z.array(RepInativosSummarySchema),
// Métricas do mês corrente, escopadas à equipe do supervisor
equipeMes: z
.object({
faturamento: z.number(),
pedidos: z.number().int(),
ticketMedio: z.number(),
metaTotal: z.number(),
pctMeta: z.number(),
})
.default({ faturamento: 0, pedidos: 0, ticketMedio: 0, metaTotal: 0, pctMeta: 0 }),
rankingReps: z.array(RankingRepSchema).default([]),
positivacaoReps: z.array(PositivacaoRepSchema).default([]),
syncedAt: z.iso.datetime(),
});
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
// Clientes distintos positivados por dia do período (YYYY-MM-DD) // Clientes distintos positivados por dia do período (YYYY-MM-DD)
export const PositivacaoDiaSchema = z.object({ export const PositivacaoDiaSchema = z.object({
dia: z.string(), dia: z.string(),
@@ -114,6 +133,26 @@ export const PositivacaoDiaSchema = z.object({
}); });
export type PositivacaoDia = z.infer<typeof PositivacaoDiaSchema>; export type PositivacaoDia = z.infer<typeof PositivacaoDiaSchema>;
// Faturamento por estado (UF do município do cliente), com detalhe por representante.
export const UfRepSchema = z.object({
codVendedor: z.number().int(),
nomeVendedor: z.string().nullable(),
faturamento: z.number(),
pedidos: z.number().int(),
clientes: z.number().int(),
});
export type UfRep = z.infer<typeof UfRepSchema>;
export const UfFaturamentoSchema = z.object({
uf: z.string(), // sigla (SP, PR…); 'ND' = cliente sem município/UF
faturamento: z.number(),
pedidos: z.number().int(),
clientes: z.number().int(),
pct: z.number(), // participação no faturamento do período
reps: z.array(UfRepSchema),
});
export type UfFaturamento = z.infer<typeof UfFaturamentoSchema>;
export const ManagerDashboardSchema = z.object({ export const ManagerDashboardSchema = z.object({
faturamentoMes: z.number(), faturamentoMes: z.number(),
pedidosMes: z.number().int(), pedidosMes: z.number().int(),
@@ -125,6 +164,7 @@ export const ManagerDashboardSchema = z.object({
rankingReps: z.array(RankingRepSchema), rankingReps: z.array(RankingRepSchema),
positivacaoReps: z.array(PositivacaoRepSchema), positivacaoReps: z.array(PositivacaoRepSchema),
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]), positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
faturamentoPorUf: z.array(UfFaturamentoSchema).default([]),
// Meta diária de positivação salva pelo gerente (config_empresa) // Meta diária de positivação salva pelo gerente (config_empresa)
metaPositivacaoDia: z.number().int().nullable().optional(), metaPositivacaoDia: z.number().int().nullable().optional(),
syncedAt: z.iso.datetime(), syncedAt: z.iso.datetime(),

View File

@@ -16,3 +16,30 @@ export const EquipeResponseSchema = z.object({
syncedAt: z.iso.datetime(), syncedAt: z.iso.datetime(),
}); });
export type EquipeResponse = z.infer<typeof EquipeResponseSchema>; export type EquipeResponse = z.infer<typeof EquipeResponseSchema>;
// Card de disputa/análise por supervisor na aba Equipe (visão gerente/admin).
// Time = reps cujo cod_supervisor aponta para este supervisor + o próprio
// supervisor (mesma semântica de getTeamCodes). Métricas agregadas do time no mês.
export const SupervisorCardSchema = z.object({
codSupervisor: z.number().int(),
nomeSupervisor: z.string().nullable(),
faturamentoMes: z.number(),
faturamentoMesAnterior: z.number(),
variacaoPct: z.number().nullable(), // null quando não há base no mês anterior
pedidosMes: z.number().int(),
ticketMedio: z.number(),
metaTotal: z.number(),
pctMeta: z.number(),
totalClientes: z.number().int(),
clientesPositivados: z.number().int(),
pctPositivacao: z.number(),
repsTotal: z.number().int(),
repsAtivos: z.number().int(), // reps do time que faturaram no mês
});
export type SupervisorCard = z.infer<typeof SupervisorCardSchema>;
export const SupervisoresResponseSchema = z.object({
supervisores: z.array(SupervisorCardSchema),
syncedAt: z.iso.datetime(),
});
export type SupervisoresResponse = z.infer<typeof SupervisoresResponseSchema>;

View File

@@ -6,9 +6,9 @@ import { z } from 'zod';
// ─── Situa ──────────────────────────────────────────────────────────────────── // ─── Situa ────────────────────────────────────────────────────────────────────
// Ciclo de vida do pedido SAR: // Ciclo de vida do pedido SAR: 0=Orçamento → 2=Transmitido.
// 0=Orçamento → 1=Ag. Aprovação (se desconto > alçada) → 2=Transmitido // Desconto acima da alçada é bloqueio duro na transmissão — não há fila de
// Estados que o SAR controla: Orçamento e Transmitido (1 é o gate de desconto). // aprovação. O valor 1 (Ag. Aprovação) é legado, mantido só para dados antigos.
// Após Transmitido, o status passa a refletir o ERP (Emitido/Cancelado/Aguardando…) // Após Transmitido, o status passa a refletir o ERP (Emitido/Cancelado/Aguardando…)
// — espelhado quando a integração existir. // — espelhado quando a integração existir.
export const SituaPedidoSchema = z.union([ export const SituaPedidoSchema = z.union([
@@ -170,17 +170,6 @@ export const CancelPedidoSchema = z.object({
}); });
export type CancelPedido = z.infer<typeof CancelPedidoSchema>; export type CancelPedido = z.infer<typeof CancelPedidoSchema>;
export const AprovarPedidoSchema = z.object({
descontoPerc: z.number().min(0).max(100).optional(),
nota: z.string().optional(),
});
export type AprovarPedido = z.infer<typeof AprovarPedidoSchema>;
export const RecusarPedidoSchema = z.object({
motivo: z.string().min(1),
});
export type RecusarPedido = z.infer<typeof RecusarPedidoSchema>;
// ─── ERP Consulta ───────────────────────────────────────────────────────────── // ─── ERP Consulta ─────────────────────────────────────────────────────────────
export const PedidoErpConsultaItemSchema = z.object({ export const PedidoErpConsultaItemSchema = z.object({

View File

@@ -1,25 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
export const AlcadaDescontoItemSchema = z.object({
codVendedor: z.number().int(),
codGrupo: z.number().int(),
limitePerc: z.number(),
nomeVendedor: z.string().nullable(),
});
export type AlcadaDescontoItem = z.infer<typeof AlcadaDescontoItemSchema>;
export const AlcadaDescontosResponseSchema = z.object({
descontos: z.array(AlcadaDescontoItemSchema),
});
export type AlcadaDescontosResponse = z.infer<typeof AlcadaDescontosResponseSchema>;
export const UpsertDescontoBodySchema = z.object({
codVendedor: z.number().int().positive(),
codGrupo: z.number().int().default(0),
limitePerc: z.number().min(0).max(100),
});
export type UpsertDescontoBody = z.infer<typeof UpsertDescontoBodySchema>;
export const PromocaoSchema = z.object({ export const PromocaoSchema = z.object({
id: z.number().int(), id: z.number().int(),
descricao: z.string(), descricao: z.string(),
@@ -71,7 +51,7 @@ export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
// ─── Regras de Desconto ─────────────────────────────────────────────────────── // ─── Regras de Desconto ───────────────────────────────────────────────────────
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de // Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
// produto e/ou representante, com vigência. Campos nulos = "todos". // produto, pauta de preços e/ou representante, com vigência. Campos nulos = "todos".
export const RegraDescontoSchema = z.object({ export const RegraDescontoSchema = z.object({
id: z.number().int(), id: z.number().int(),
@@ -80,6 +60,8 @@ export const RegraDescontoSchema = z.object({
grupo: z.string().nullable(), grupo: z.string().nullable(),
codSubgrupo: z.number().int().nullable(), codSubgrupo: z.number().int().nullable(),
subgrupo: z.string().nullable(), subgrupo: z.string().nullable(),
idPauta: z.number().int().nullable(),
pauta: z.string().nullable(),
codVendedor: z.number().int().nullable(), codVendedor: z.number().int().nullable(),
nomeVendedor: z.string().nullable(), nomeVendedor: z.string().nullable(),
descPct: z.number(), descPct: z.number(),
@@ -96,12 +78,14 @@ export const RegrasDescontoResponseSchema = z.object({
}); });
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>; export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
// Criação aceita VÁRIOS grupos e/ou subgrupos — uma regra por alvo (grupos // Criação aceita VÁRIOS grupos, subgrupos e/ou pautas — uma regra por alvo
// selecionados viram regras por grupo; subgrupos viram regras por subgrupo). // (cada grupo vira regra por grupo; cada subgrupo, regra por subgrupo; cada
// pauta, regra por pauta). O representante escolhido é copiado em todas.
export const CreateRegraDescontoBodySchema = z.object({ export const CreateRegraDescontoBodySchema = z.object({
descricao: z.string().min(1).max(200), descricao: z.string().min(1).max(200),
codGrupos: z.array(z.number().int()).max(100).optional(), codGrupos: z.array(z.number().int()).max(100).optional(),
codSubgrupos: z.array(z.number().int()).max(100).optional(), codSubgrupos: z.array(z.number().int()).max(100).optional(),
idPautas: z.array(z.number().int()).max(100).optional(),
codVendedor: z.number().int().nullable().optional(), codVendedor: z.number().int().nullable().optional(),
descPct: z.number().min(0).max(100), descPct: z.number().min(0).max(100),
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
@@ -114,6 +98,7 @@ export const UpdateRegraDescontoBodySchema = z.object({
descricao: z.string().min(1).max(200).optional(), descricao: z.string().min(1).max(200).optional(),
codGrupo: z.number().int().nullable().optional(), codGrupo: z.number().int().nullable().optional(),
codSubgrupo: z.number().int().nullable().optional(), codSubgrupo: z.number().int().nullable().optional(),
idPauta: z.number().int().nullable().optional(),
codVendedor: z.number().int().nullable().optional(), codVendedor: z.number().int().nullable().optional(),
descPct: z.number().min(0).max(100).optional(), descPct: z.number().min(0).max(100).optional(),
dataInicio: z dataInicio: z
@@ -145,7 +130,9 @@ export const PromocaoVigenteSchema = z.object({
id: z.number().int(), id: z.number().int(),
descricao: z.string(), descricao: z.string(),
codProduto: z.string().nullable(), codProduto: z.string().nullable(),
nomeProduto: z.string().nullable(),
grpProd: z.string().nullable(), grpProd: z.string().nullable(),
grupo: z.string().nullable(),
descPct: z.number(), descPct: z.number(),
dataFim: z.string(), dataFim: z.string(),
}); });
@@ -161,7 +148,11 @@ export const RegraVigenteSchema = z.object({
id: z.number().int(), id: z.number().int(),
descricao: z.string(), descricao: z.string(),
codGrupo: z.number().int().nullable(), codGrupo: z.number().int().nullable(),
grupo: z.string().nullable(),
codSubgrupo: z.number().int().nullable(), codSubgrupo: z.number().int().nullable(),
subgrupo: z.string().nullable(),
idPauta: z.number().int().nullable(),
pauta: z.string().nullable(),
descPct: z.number(), descPct: z.number(),
dataFim: z.string(), dataFim: z.string(),
}); });

8
pnpm-lock.yaml generated
View File

@@ -319,6 +319,9 @@ importers:
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^5.4.0 specifier: ^5.4.0
version: 5.4.0(react-hook-form@7.76.1(react@19.2.6)) version: 5.4.0(react-hook-form@7.76.1(react@19.2.6))
'@svg-maps/brazil':
specifier: ^2.0.0
version: 2.0.0
'@tanstack/react-query': '@tanstack/react-query':
specifier: ^5.100.14 specifier: ^5.100.14
version: 5.100.14(react@19.2.6) version: 5.100.14(react@19.2.6)
@@ -3380,6 +3383,9 @@ packages:
'@standard-schema/utils@0.3.0': '@standard-schema/utils@0.3.0':
resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==}
'@svg-maps/brazil@2.0.0':
resolution: {integrity: sha512-6le594K1tO6c8Y/0KgWyurksyLnSU/8mTSnr48cKH0eaiG153UQP8aadQ/pKoeCBVd8khLVdmV7wKbkfIAbqBA==}
'@svgr/babel-plugin-add-jsx-attribute@8.0.0': '@svgr/babel-plugin-add-jsx-attribute@8.0.0':
resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==} resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -13385,6 +13391,8 @@ snapshots:
'@standard-schema/utils@0.3.0': {} '@standard-schema/utils@0.3.0': {}
'@svg-maps/brazil@2.0.0': {}
'@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)': '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.29.7)':
dependencies: dependencies:
'@babel/core': 7.29.7 '@babel/core': 7.29.7

View File

@@ -720,8 +720,9 @@ CREATE TABLE IF NOT EXISTS sar.promocoes (
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin) -- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin)
-- Desconto liberado por grupo/subgrupo de produto e/ou representante, com -- Desconto liberado por grupo/subgrupo de produto, pauta de precos e/ou
-- vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL vale para todo rep). -- representante, com vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL
-- vale para todo rep; id_pauta NULL vale para qualquer pauta).
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sar.regras_desconto ( CREATE TABLE IF NOT EXISTS sar.regras_desconto (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
@@ -730,6 +731,7 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
cod_grupo INTEGER, cod_grupo INTEGER,
cod_subgrupo INTEGER, cod_subgrupo INTEGER,
cod_vendedor INTEGER, cod_vendedor INTEGER,
id_pauta INTEGER,
desc_pct NUMERIC(5,2) NOT NULL, desc_pct NUMERIC(5,2) NOT NULL,
data_inicio DATE NOT NULL, data_inicio DATE NOT NULL,
data_fim DATE NOT NULL, data_fim DATE NOT NULL,