Compare commits
4 Commits
dfdcca4674
...
9b855f63b2
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b855f63b2 | |||
| 1ce8d96fea | |||
| ebf68523dd | |||
| 78612d59bd |
@@ -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;
|
||||
@@ -174,7 +174,8 @@ model Promocao {
|
||||
// ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
|
||||
//
|
||||
// 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 {
|
||||
id Int @id @default(autoincrement())
|
||||
@@ -183,6 +184,7 @@ model RegraDesconto {
|
||||
codGrupo Int? @map("cod_grupo")
|
||||
codSubgrupo Int? @map("cod_subgrupo")
|
||||
codVendedor Int? @map("cod_vendedor")
|
||||
idPauta Int? @map("id_pauta")
|
||||
descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
|
||||
dataInicio DateTime @db.Date @map("data_inicio")
|
||||
dataFim DateTime @db.Date @map("data_fim")
|
||||
|
||||
@@ -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 { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
SaveMetaPositivacaoBodySchema,
|
||||
type RepDashboard,
|
||||
type RepInativosDetail,
|
||||
type SupervisorDashboard,
|
||||
type ManagerDashboard,
|
||||
type SaveMetaPositivacaoBody,
|
||||
@@ -35,9 +45,21 @@ export class DashboardController {
|
||||
}
|
||||
|
||||
@Get('supervisor')
|
||||
supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||
supervisorDashboard(
|
||||
@Query('codSupervisor') codSupervisor?: string,
|
||||
): Promise<SupervisorDashboard> {
|
||||
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')
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type {
|
||||
RepDashboard,
|
||||
RepInativosDetail,
|
||||
SupervisorDashboard,
|
||||
ManagerDashboard,
|
||||
RankingRep,
|
||||
@@ -11,8 +12,6 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||
|
||||
// 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.
|
||||
const TIPO_META_GLOBAL = 'GL';
|
||||
@@ -422,6 +421,14 @@ export class DashboardService {
|
||||
cod_vendedor: number;
|
||||
novos: string;
|
||||
}
|
||||
interface UfRepRow {
|
||||
uf: string;
|
||||
cod_vendedor: number;
|
||||
nome_vendedor: string | null;
|
||||
pedidos: string;
|
||||
clientes: string;
|
||||
faturamento: string;
|
||||
}
|
||||
|
||||
const [
|
||||
statsRows,
|
||||
@@ -433,6 +440,7 @@ export class DashboardService {
|
||||
realizadoGrupoRows,
|
||||
positivacaoDiariaRows,
|
||||
novosClientesRows,
|
||||
ufRepRows,
|
||||
] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
|
||||
@@ -555,6 +563,31 @@ export class DashboardService {
|
||||
AND f.primeira_compra <= '${monthEndStr}'
|
||||
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);
|
||||
@@ -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 {
|
||||
faturamentoMes,
|
||||
pedidosMes,
|
||||
@@ -640,6 +705,7 @@ export class DashboardService {
|
||||
dia: r.dia,
|
||||
clientes: Number(r.clientes),
|
||||
})),
|
||||
faturamentoPorUf,
|
||||
metaPositivacaoDia: config?.metaPositivacaoDia ?? null,
|
||||
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');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const now = new Date();
|
||||
|
||||
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes);
|
||||
// gerente/admin acessando este painel veem a empresa toda.
|
||||
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes).
|
||||
// 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 userId = this.cls.get('userId');
|
||||
const team =
|
||||
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null;
|
||||
let teamCode: number | null = 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(',')})` : '');
|
||||
|
||||
// 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)
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
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 inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
|
||||
SELECT inativos.cod_vendedor,
|
||||
@@ -741,50 +948,10 @@ export class DashboardService {
|
||||
) inativos
|
||||
GROUP BY inativos.cod_vendedor
|
||||
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 {
|
||||
approvalQueue: approvalQueue.map(mapPedido),
|
||||
pedidosDia: {
|
||||
count: Number(todayRows[0]?.count ?? 0),
|
||||
total: Number(todayRows[0]?.total ?? 0),
|
||||
@@ -796,7 +963,93 @@ export class DashboardService {
|
||||
nomeVendedor: r.nome_vendedor ?? null,
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
// 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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
|
||||
@Controller({ path: 'equipe' })
|
||||
@@ -10,4 +10,9 @@ export class EquipeController {
|
||||
listEquipe(): Promise<EquipeResponse> {
|
||||
return this.equipe.listEquipe();
|
||||
}
|
||||
|
||||
@Get('supervisores')
|
||||
listSupervisores(): Promise<SupervisoresResponse> {
|
||||
return this.equipe.listSupervisores();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
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';
|
||||
|
||||
function matrizEmpresa(idEmpresa: number): number {
|
||||
@@ -110,4 +110,173 @@ export class EquipeService {
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
@@ -11,17 +10,13 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
AprovarPedidoSchema,
|
||||
CancelPedidoSchema,
|
||||
CreatePedidoSchema,
|
||||
UpdatePedidoSchema,
|
||||
PedidoErpConsultaQuerySchema,
|
||||
PedidoListQuerySchema,
|
||||
RecusarPedidoSchema,
|
||||
type AprovarPedido,
|
||||
type CancelPedido,
|
||||
type CreatePedido,
|
||||
type UpdatePedido,
|
||||
@@ -30,25 +25,18 @@ import {
|
||||
type PedidoErpConsultaResponse,
|
||||
type PedidoListQuery,
|
||||
type PedidoListResponse,
|
||||
type RecusarPedido,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
||||
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
||||
class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
|
||||
class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
|
||||
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
||||
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
||||
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
|
||||
|
||||
@Controller({ path: 'orders' })
|
||||
export class OrdersController {
|
||||
constructor(
|
||||
private readonly orders: OrdersService,
|
||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||
) {}
|
||||
constructor(private readonly orders: OrdersService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
|
||||
@@ -68,28 +56,6 @@ export class OrdersController {
|
||||
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')
|
||||
cancel(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
AprovarPedido,
|
||||
CancelPedido,
|
||||
CreatePedido,
|
||||
UpdatePedido,
|
||||
@@ -13,16 +12,14 @@ import type {
|
||||
PedidoListQuery,
|
||||
PedidoListResponse,
|
||||
PedidoSummary,
|
||||
RecusarPedido,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||
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
|
||||
const SITUA_ORCAMENTO = 0;
|
||||
const SITUA_PENDENTE = 1;
|
||||
const SITUA_APROVADO = 2;
|
||||
const SITUA_CANCELADO = 3;
|
||||
|
||||
@@ -290,20 +287,6 @@ export class OrdersService {
|
||||
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> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
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);
|
||||
|
||||
// Regra sem grupo/subgrupo vale para qualquer produto; com grupo e/ou
|
||||
// subgrupo, o produto precisa casar com o que a regra especifica.
|
||||
// Regra sem alvo vale para qualquer produto; com grupo, subgrupo e/ou
|
||||
// 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 regraPct = regras
|
||||
.filter(
|
||||
(r) =>
|
||||
(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);
|
||||
|
||||
@@ -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.
|
||||
// Substitui itens e recalcula totais; alçada valida contra o dono do pedido.
|
||||
async update(id: string, dto: UpdatePedido): Promise<PedidoDetail> {
|
||||
|
||||
@@ -11,17 +11,14 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
UpsertDescontoBodySchema,
|
||||
CreatePromocaoBodySchema,
|
||||
UpdatePromocaoBodySchema,
|
||||
CreateRegraDescontoBodySchema,
|
||||
UpdateRegraDescontoBodySchema,
|
||||
type UpsertDescontoBody,
|
||||
type CreatePromocaoBody,
|
||||
type UpdatePromocaoBody,
|
||||
type CreateRegraDescontoBody,
|
||||
type UpdateRegraDescontoBody,
|
||||
type AlcadaDescontosResponse,
|
||||
type PromocoesResponse,
|
||||
type PromocoesVigentesResponse,
|
||||
type AlcadaRepResponse,
|
||||
@@ -31,7 +28,6 @@ import {
|
||||
} from '@sar/api-interface';
|
||||
import { PoliticasService } from './politicas.service';
|
||||
|
||||
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
|
||||
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
|
||||
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
|
||||
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
|
||||
@@ -41,17 +37,6 @@ class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema)
|
||||
export class PoliticasController {
|
||||
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')
|
||||
listPromocoes(): Promise<PromocoesResponse> {
|
||||
return this.politicas.listPromocoes();
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type {
|
||||
AlcadaDescontosResponse,
|
||||
UpsertDescontoBody,
|
||||
PromocoesResponse,
|
||||
CreatePromocaoBody,
|
||||
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> {
|
||||
this.assertManager();
|
||||
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> {
|
||||
this.assertManager();
|
||||
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]),
|
||||
);
|
||||
|
||||
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
|
||||
const pautaNameMap = await this.loadPautaNames(idPautas);
|
||||
|
||||
return {
|
||||
regras: rows.map((r) => ({
|
||||
id: r.id,
|
||||
@@ -260,6 +223,8 @@ export class PoliticasService {
|
||||
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||
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,
|
||||
codVendedor: r.codVendedor,
|
||||
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
|
||||
descPct: Number(r.descPct),
|
||||
@@ -272,8 +237,9 @@ export class PoliticasService {
|
||||
};
|
||||
}
|
||||
|
||||
// Cria uma regra POR ALVO: cada grupo selecionado vira regra por grupo e
|
||||
// cada subgrupo vira regra por subgrupo. Sem alvos, regra vale para todos.
|
||||
// Cria uma regra POR ALVO: cada grupo vira regra por grupo, cada subgrupo
|
||||
// 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[] }> {
|
||||
this.assertManager();
|
||||
const prisma = this.cls.get('prisma');
|
||||
@@ -281,11 +247,17 @@ export class PoliticasService {
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId') ?? 'system';
|
||||
|
||||
const alvos: { codGrupo: number | null; codSubgrupo: number | null }[] = [
|
||||
...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null })),
|
||||
...(body.codSubgrupos ?? []).map((s) => ({ codGrupo: null, codSubgrupo: s })),
|
||||
const alvos: { codGrupo: number | null; codSubgrupo: number | null; idPauta: number | null }[] =
|
||||
[
|
||||
...(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 created: number[] = [];
|
||||
@@ -296,6 +268,7 @@ export class PoliticasService {
|
||||
descricao: body.descricao,
|
||||
codGrupo: alvo.codGrupo,
|
||||
codSubgrupo: alvo.codSubgrupo,
|
||||
idPauta: alvo.idPauta,
|
||||
codVendedor: body.codVendedor ?? null,
|
||||
descPct: body.descPct,
|
||||
dataInicio: new Date(body.dataInicio),
|
||||
@@ -325,6 +298,7 @@ export class PoliticasService {
|
||||
...(body.descricao !== undefined && { descricao: body.descricao }),
|
||||
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
|
||||
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
|
||||
...(body.idPauta !== undefined && { idPauta: body.idPauta }),
|
||||
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
|
||||
...(body.descPct !== undefined && { descPct: body.descPct }),
|
||||
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
|
||||
@@ -384,12 +358,36 @@ export class PoliticasService {
|
||||
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 {
|
||||
promocoes: rows.map((p) => ({
|
||||
id: p.id,
|
||||
descricao: p.descricao,
|
||||
codProduto: p.codProduto,
|
||||
nomeProduto: p.codProduto ? (prodNameMap.get(p.codProduto.trim()) ?? null) : null,
|
||||
grpProd: p.grpProd,
|
||||
grupo: p.grpProd ? (grupoNameMap.get(p.grpProd.trim()) ?? null) : null,
|
||||
descPct: Number(p.descPct),
|
||||
dataFim: p.dataFim.toISOString().slice(0, 10),
|
||||
})),
|
||||
@@ -418,12 +416,30 @@ export class PoliticasService {
|
||||
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 {
|
||||
regras: rows.map((r) => ({
|
||||
id: r.id,
|
||||
descricao: r.descricao,
|
||||
codGrupo: r.codGrupo,
|
||||
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||
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),
|
||||
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||
})),
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@svg-maps/brazil": "^2.0.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-router": "^1.170.8",
|
||||
"antd": "^6.4.3",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import type { RepStats } from '@sar/api-interface';
|
||||
import { useEquipe } from '../../lib/queries/gerente';
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Col, Empty, Flex, Progress, Row, Skeleton, Space, Typography } from 'antd';
|
||||
import type { SupervisorCard } from '@sar/api-interface';
|
||||
import { useSupervisores } from '../../lib/queries/gerente';
|
||||
import { SupervisorDetalheModal } from './SupervisorDetalheModal';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -9,86 +10,119 @@ function fmt(v: number): string {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<RepStats> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, row: RepStats) => (
|
||||
<Flex vertical gap={0}>
|
||||
<Text strong>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
||||
{row.nomeVendedor && (
|
||||
function metaColor(pct: number): string {
|
||||
return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
|
||||
}
|
||||
|
||||
const MEDALHAS = ['🥇', '🥈', '🥉'];
|
||||
|
||||
function posicaoLabel(i: number): string {
|
||||
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)' }}>
|
||||
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>
|
||||
)}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'pedidosMes',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
sorter: (a, b) => a.pedidosMes - b.pedidosMes,
|
||||
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}%
|
||||
|
||||
{/* Meta do time */}
|
||||
<div>
|
||||
<Flex justify="space-between" align="baseline" style={{ marginBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
Meta do time
|
||||
</Text>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{s.metaTotal > 0 ? `${s.pctMeta}%` : 'sem meta'}
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
<Progress
|
||||
percent={s.metaTotal > 0 ? Math.min(s.pctMeta, 100) : 0}
|
||||
size="small"
|
||||
strokeColor={metaColor(s.pctMeta)}
|
||||
showInfo={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
export function EquipePage() {
|
||||
const { data, isLoading } = useEquipe();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
<Skeleton active paragraph={{ rows: 1 }} />
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
{/* Ticket + positivação */}
|
||||
<Flex justify="space-between" gap={12}>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
TICKET MÉDIO
|
||||
</Text>
|
||||
<Text strong className="tabular-nums">
|
||||
{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>
|
||||
|
||||
<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' });
|
||||
|
||||
return (
|
||||
@@ -98,25 +132,41 @@ export function EquipePage() {
|
||||
Equipe
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
Performance dos representantes — {mes}
|
||||
Disputa dos supervisores — {mes}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{isLoading || !data ? (
|
||||
<Row gutter={[24, 24]}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Col key={i} xs={24} md={12} lg={8}>
|
||||
<Card>
|
||||
<Table<RepStats>
|
||||
rowKey="codVendedor"
|
||||
columns={columns}
|
||||
dataSource={data.reps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhum representante encontrado.' }}
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</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)' }}>
|
||||
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SupervisorDetalheModal supervisor={detalhe} onClose={() => setDetalhe(null)} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,12 @@ import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
|
||||
import { useManagerDashboard } from '../../lib/queries/gerente';
|
||||
import {
|
||||
positivacaoRepColumns,
|
||||
rankingRepColumns,
|
||||
} from '../../components/dashboard/rep-performance-columns';
|
||||
import { apiFetch } from '../../lib/api-client';
|
||||
import { MapaBrasilCard } from './MapaBrasilCard';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
@@ -67,60 +72,6 @@ function today(): string {
|
||||
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> = [
|
||||
{
|
||||
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({
|
||||
dados,
|
||||
metaSalva,
|
||||
@@ -403,6 +305,7 @@ export function GerPainel() {
|
||||
rankingReps,
|
||||
positivacaoReps,
|
||||
positivacaoDiaria,
|
||||
faturamentoPorUf,
|
||||
syncedAt,
|
||||
} = data;
|
||||
|
||||
@@ -451,93 +354,60 @@ export function GerPainel() {
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* KPIs linha 1 */}
|
||||
{/* Primeira dobra: visão financeira (½) + mapa do Brasil (½) */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Col xs={24} xl={12}>
|
||||
<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 justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(faturamentoMes)}
|
||||
</Title>
|
||||
<FontAwesomeIcon
|
||||
icon={faFileInvoiceDollar}
|
||||
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)' }}>
|
||||
PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{pedidosMes}
|
||||
{fmt(faturamentoMes)}
|
||||
</Title>
|
||||
<FontAwesomeIcon
|
||||
icon={faChartBar}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{pedidosMes} pedido{pedidosMes !== 1 ? 's' : ''} no{' '}
|
||||
{isMesAtual ? 'mês' : 'período'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<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>
|
||||
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(metaTotal)}
|
||||
</Title>
|
||||
<FontAwesomeIcon
|
||||
icon={faBullseye}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</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>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card>
|
||||
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<Flex vertical gap={8}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
@@ -550,13 +420,13 @@ export function GerPainel() {
|
||||
</Flex>
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: metaCorColor }}
|
||||
style={{ margin: 0, color: metaTotal > 0 ? metaCorColor : undefined }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{pctMeta}%
|
||||
{metaTotal > 0 ? `${pctMeta}%` : '—'}
|
||||
</Title>
|
||||
<Progress
|
||||
percent={Math.min(pctMeta, 100)}
|
||||
percent={metaTotal > 0 ? Math.min(pctMeta, 100) : 0}
|
||||
strokeColor={metaCorColor}
|
||||
showInfo={false}
|
||||
size="small"
|
||||
@@ -565,20 +435,33 @@ export function GerPainel() {
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card>
|
||||
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={faChartBar}
|
||||
style={{
|
||||
color: superouMeta > 0 ? 'var(--green)' : 'var(--jcs-blue)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
|
||||
{metaTotal > 0 ? fmt(superouMeta > 0 ? superouMeta : faltaMeta) : '—'}
|
||||
</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)' }}>
|
||||
{pctMeta - 100}% acima da meta
|
||||
</Text>
|
||||
@@ -598,7 +481,51 @@ export function GerPainel() {
|
||||
</Card>
|
||||
</Col>
|
||||
</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 */}
|
||||
{metasPorGrupo.length > 0 && (
|
||||
@@ -650,7 +577,7 @@ export function GerPainel() {
|
||||
>
|
||||
<Table<RankingRep>
|
||||
rowKey="codVendedor"
|
||||
columns={rankingColumns}
|
||||
columns={rankingRepColumns}
|
||||
dataSource={rankingReps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
@@ -694,7 +621,7 @@ export function GerPainel() {
|
||||
>
|
||||
<Table<PositivacaoRep>
|
||||
rowKey="codVendedor"
|
||||
columns={positivacaoColumns}
|
||||
columns={positivacaoRepColumns}
|
||||
dataSource={positivacaoReps}
|
||||
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
|
||||
size="small"
|
||||
|
||||
360
apps/web/src/cockpits/ger/MapaBrasilCard.tsx
Normal file
360
apps/web/src/cockpits/ger/MapaBrasilCard.tsx
Normal 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 — {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>
|
||||
);
|
||||
}
|
||||
@@ -24,17 +24,9 @@ import type { TableColumnsType } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type {
|
||||
AlcadaDescontoItem,
|
||||
Promocao,
|
||||
RegraDesconto,
|
||||
RepStats,
|
||||
GrupoProdutoItem,
|
||||
} from '@sar/api-interface';
|
||||
import type { Promocao, RegraDesconto, RepStats, GrupoProdutoItem } from '@sar/api-interface';
|
||||
import {
|
||||
usePoliticasDescontos,
|
||||
usePromocoes,
|
||||
useUpsertDesconto,
|
||||
useCreatePromocao,
|
||||
useUpdatePromocao,
|
||||
useDeletePromocao,
|
||||
@@ -45,7 +37,7 @@ import {
|
||||
useUpdateRegraDesconto,
|
||||
useDeleteRegraDesconto,
|
||||
} 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 ─────────────────────
|
||||
|
||||
@@ -104,125 +96,6 @@ function ProdutoSelect({
|
||||
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
|
||||
interface PromocaoForm {
|
||||
@@ -534,8 +407,10 @@ interface RegraForm {
|
||||
codVendedor?: number;
|
||||
codGrupos?: number[];
|
||||
codSubgrupos?: number[];
|
||||
idPautas?: number[];
|
||||
codGrupo?: number;
|
||||
codSubgrupo?: number;
|
||||
idPauta?: number;
|
||||
descPct: number;
|
||||
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||
ativa?: boolean;
|
||||
@@ -545,6 +420,7 @@ function TabRegras() {
|
||||
const { data, isLoading } = useRegrasDesconto();
|
||||
const { data: equipe } = useEquipe();
|
||||
const { data: gruposData } = useGruposProduto();
|
||||
const { data: pautas } = usePautas();
|
||||
const createMutation = useCreateRegraDesconto();
|
||||
const updateMutation = useUpdateRegraDesconto();
|
||||
const deleteMutation = useDeleteRegraDesconto();
|
||||
@@ -560,6 +436,12 @@ function TabRegras() {
|
||||
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.
|
||||
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
|
||||
const filtroGrupos = new Set<number>([
|
||||
@@ -603,6 +485,7 @@ function TabRegras() {
|
||||
codVendedor: r.codVendedor ?? undefined,
|
||||
codGrupo: r.codGrupo ?? undefined,
|
||||
codSubgrupo: r.codSubgrupo ?? undefined,
|
||||
idPauta: r.idPauta ?? undefined,
|
||||
descPct: r.descPct,
|
||||
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
||||
ativa: r.ativa,
|
||||
@@ -630,6 +513,7 @@ function TabRegras() {
|
||||
codVendedor: values.codVendedor ?? null,
|
||||
codGrupo: values.codGrupo ?? null,
|
||||
codSubgrupo: values.codSubgrupo ?? null,
|
||||
idPauta: values.idPauta ?? null,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
@@ -643,6 +527,7 @@ function TabRegras() {
|
||||
codVendedor: values.codVendedor ?? null,
|
||||
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
|
||||
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
|
||||
idPautas: values.idPautas?.length ? values.idPautas : undefined,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
@@ -683,7 +568,7 @@ function TabRegras() {
|
||||
},
|
||||
{
|
||||
title: 'Grupo / Subgrupo',
|
||||
width: 220,
|
||||
width: 200,
|
||||
render: (_: unknown, row: RegraDesconto) => {
|
||||
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
|
||||
const partes = [
|
||||
@@ -693,6 +578,16 @@ function TabRegras() {
|
||||
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',
|
||||
dataIndex: 'descPct',
|
||||
@@ -782,8 +677,8 @@ function TabRegras() {
|
||||
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
|
||||
/>
|
||||
<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
|
||||
o desconto automaticamente nos pedidos durante a vigencia.
|
||||
A regra vale para o representante, grupo, subgrupo e pauta informados (em branco = todos) e
|
||||
aplica o desconto automaticamente nos pedidos durante a vigencia.
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
@@ -817,6 +712,7 @@ function TabRegras() {
|
||||
</Form.Item>
|
||||
|
||||
{editTarget ? (
|
||||
<>
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||
<Select
|
||||
@@ -841,6 +737,16 @@ function TabRegras() {
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
<Form.Item name="idPauta" label="Pauta de precos">
|
||||
<Select
|
||||
options={pautaOptions}
|
||||
placeholder="Todas as pautas"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
@@ -871,6 +777,20 @@ function TabRegras() {
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</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() {
|
||||
const items = [
|
||||
{
|
||||
key: 'descontos',
|
||||
label: 'Desconto por Representante',
|
||||
children: <TabDescontos />,
|
||||
},
|
||||
{
|
||||
key: 'promocoes',
|
||||
label: 'Promocoes',
|
||||
@@ -927,7 +842,7 @@ export function PoliticasPage() {
|
||||
Politicas Comerciais
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
Limites de desconto e promocoes com vigencia
|
||||
Promocoes e regras de desconto com vigencia
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
|
||||
164
apps/web/src/cockpits/ger/SupervisorDetalheModal.tsx
Normal file
164
apps/web/src/cockpits/ger/SupervisorDetalheModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -35,16 +35,12 @@ import {
|
||||
UserOutlined,
|
||||
WhatsAppOutlined,
|
||||
} 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 type { ActivityStatus, ClientSummary } from '@sar/api-interface';
|
||||
import { SITUA_LABEL } from '@sar/api-interface';
|
||||
import { useClientList, useClientDetail } from '../../lib/queries/clients';
|
||||
import { useClientOrders } from '../../lib/queries/orders';
|
||||
|
||||
ChartJS.register(ArcElement, ChartTooltip, Legend);
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
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 ───────────────────────────────────────────────────
|
||||
|
||||
function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
|
||||
@@ -1460,22 +1312,9 @@ export function ClientsPage() {
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
|
||||
{isMobile && (
|
||||
<>
|
||||
<CustomerPortfolioCard
|
||||
stats={stats}
|
||||
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
|
||||
/>
|
||||
<div style={{ marginBottom: 16 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Área principal ────────────────────────────────────────────── */}
|
||||
<Row gutter={[20, 20]}>
|
||||
{/* minWidth: 0 — Col é flex item (min-width auto). Sem isto a tabela com
|
||||
scroll x estoura a coluna e engole o card vizinho. */}
|
||||
<Col xs={24} lg={17} style={{ minWidth: 0 }}>
|
||||
<Col xs={24} style={{ minWidth: 0 }}>
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 64 }}>
|
||||
<Spin size="large" />
|
||||
@@ -1548,15 +1387,6 @@ export function ClientsPage() {
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
{!isMobile && (
|
||||
<Col lg={7}>
|
||||
<CustomerPortfolioCard
|
||||
stats={stats}
|
||||
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{/* ── Modais ────────────────────────────────────────────────────── */}
|
||||
|
||||
146
apps/web/src/cockpits/rep/CondicoesEspeciaisCard.tsx
Normal file
146
apps/web/src/cockpits/rep/CondicoesEspeciaisCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -6,16 +6,12 @@ import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Form,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Timeline,
|
||||
Typography,
|
||||
Input,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
@@ -32,7 +28,6 @@ import { apiFetch } from '../../lib/api-client';
|
||||
import { authStore } from '../../lib/auth-store';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
// ─── 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 ──────────────────────────────────────────────────────────
|
||||
|
||||
export function OrderDetailPage() {
|
||||
@@ -254,7 +150,6 @@ export function OrderDetailPage() {
|
||||
|
||||
const role = getRoleFromToken();
|
||||
const isErp = order?.fonte === 'erp';
|
||||
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
|
||||
const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
|
||||
const canShare =
|
||||
role === 'rep' &&
|
||||
@@ -262,39 +157,9 @@ export function OrderDetailPage() {
|
||||
typeof navigator !== 'undefined' &&
|
||||
!!navigator.share;
|
||||
|
||||
const [approveOpen, setApproveOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [chamadoOpen, setChamadoOpen] = useState(false);
|
||||
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({
|
||||
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
|
||||
onSuccess: () => {
|
||||
@@ -309,11 +174,6 @@ export function OrderDetailPage() {
|
||||
if (error || !order)
|
||||
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.
|
||||
const isOrcamento = order.situa === 0;
|
||||
|
||||
@@ -339,9 +199,6 @@ export function OrderDetailPage() {
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
{timeWaiting !== null && timeWaiting > 2 && (
|
||||
<Tag color="red">Urgente — {timeWaiting}h aguardando</Tag>
|
||||
)}
|
||||
<Button
|
||||
icon={<FilePdfOutlined />}
|
||||
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
|
||||
@@ -361,16 +218,6 @@ export function OrderDetailPage() {
|
||||
Transmitir pedido
|
||||
</Button>
|
||||
)}
|
||||
{canAct && (
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => setApproveOpen(true)}>
|
||||
Aprovar
|
||||
</Button>
|
||||
<Button danger onClick={() => setRejectOpen(true)}>
|
||||
Recusar
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
{canShare && (
|
||||
<Button
|
||||
icon={<FontAwesomeIcon icon={faShareNodes} />}
|
||||
@@ -420,11 +267,6 @@ export function OrderDetailPage() {
|
||||
<Descriptions.Item label="Data">
|
||||
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
|
||||
</Descriptions.Item>
|
||||
{order.aprovadoEm && (
|
||||
<Descriptions.Item label="Aprovado em">
|
||||
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} — cód. {order.aprovadoPor}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{order.formaPagamento && (
|
||||
<Descriptions.Item label="Cond. Pagamento" span={2}>
|
||||
{order.formaPagamento}
|
||||
@@ -511,19 +353,6 @@ export function OrderDetailPage() {
|
||||
<Divider titlePlacement="left">Histórico do Pedido</Divider>
|
||||
<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 && (
|
||||
<AbrirChamadoModal
|
||||
open={chamadoOpen}
|
||||
|
||||
@@ -142,13 +142,11 @@ const STATUS: Record<number, { label: string; color: string; rowBg: string; tagC
|
||||
|
||||
function useOrderStats() {
|
||||
const all = useOrderList({ limit: 1 });
|
||||
const pendentes = useOrderList({ limit: 1, situa: 1 });
|
||||
const aprovados = useOrderList({ limit: 1, situa: 2 });
|
||||
const transmitidos = useOrderList({ limit: 1, situa: 2 });
|
||||
const faturados = useOrderList({ limit: 1, situa: 4 });
|
||||
return {
|
||||
total: all.data?.total ?? 0,
|
||||
pendentes: pendentes.data?.total ?? 0,
|
||||
aprovados: aprovados.data?.total ?? 0,
|
||||
transmitidos: transmitidos.data?.total ?? 0,
|
||||
faturados: faturados.data?.total ?? 0,
|
||||
loaded: !!all.data,
|
||||
};
|
||||
@@ -167,19 +165,18 @@ function OrdersMetrics({ stats }: { stats: OrderStats }) {
|
||||
color: '#003B8E',
|
||||
},
|
||||
{
|
||||
label: 'Ag. Aprovação',
|
||||
value: stats.pendentes,
|
||||
icon: <ClockCircleOutlined />,
|
||||
color: '#d46b08',
|
||||
label: 'Transmitidos',
|
||||
value: stats.transmitidos,
|
||||
icon: <CheckCircleOutlined />,
|
||||
color: '#389e0d',
|
||||
},
|
||||
{ label: 'Aprovados', value: stats.aprovados, icon: <CheckCircleOutlined />, color: '#389e0d' },
|
||||
{ label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||
{metrics.map((m) => (
|
||||
<Col key={m.label} xs={12} sm={6}>
|
||||
<Col key={m.label} xs={12} sm={8}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
@@ -865,7 +862,7 @@ export function OrdersPage() {
|
||||
Pedidos
|
||||
</Title>
|
||||
<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>
|
||||
</div>
|
||||
{!isMobile && (
|
||||
@@ -949,7 +946,6 @@ export function OrdersPage() {
|
||||
}}
|
||||
options={[
|
||||
{ value: 0, label: 'Orçamento' },
|
||||
{ value: 1, label: 'Ag. Aprovação' },
|
||||
{ value: 2, label: 'Transmitido' },
|
||||
{ value: 3, label: 'Cancelado' },
|
||||
{ value: 4, label: 'Faturado' },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
@@ -17,10 +16,10 @@ import {
|
||||
Typography,
|
||||
} 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 type { CarteiraCliente, AbcProduto } from '@sar/api-interface';
|
||||
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports';
|
||||
import type { AbcProduto } from '@sar/api-interface';
|
||||
import { useReportMeta, useReportAbc } from '../../lib/queries/reports';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
@@ -29,11 +28,6 @@ function fmt(v: number | string): string {
|
||||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function fmtDate(v: string | null | undefined): string {
|
||||
if (!v) return '—';
|
||||
return new Date(v).toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
// ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
|
||||
|
||||
function TabMeta() {
|
||||
@@ -196,177 +190,7 @@ function TabMeta() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Aba 2: Carteira de Clientes ─────────────────────────────────────────────
|
||||
|
||||
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 ─────────────────────────────────────────────────────────
|
||||
// ─── Aba 2: Curva ABC ─────────────────────────────────────────────────────────
|
||||
|
||||
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
|
||||
|
||||
@@ -550,16 +374,6 @@ export function RelatoriosPage() {
|
||||
),
|
||||
children: <TabMeta />,
|
||||
},
|
||||
{
|
||||
key: 'carteira',
|
||||
label: (
|
||||
<Space>
|
||||
<TeamOutlined />
|
||||
Carteira de Clientes
|
||||
</Space>
|
||||
),
|
||||
children: <TabCarteira />,
|
||||
},
|
||||
{
|
||||
key: 'abc',
|
||||
label: (
|
||||
@@ -579,7 +393,7 @@ export function RelatoriosPage() {
|
||||
Relatórios
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||
Análise de desempenho, carteira e mix de produtos.
|
||||
Análise de desempenho e mix de produtos.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar
|
||||
import { SITUA_LABEL } from '@sar/api-interface';
|
||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||
import { useCurrentUser } from '../../lib/queries/auth';
|
||||
import { CondicoesEspeciaisCard } from './CondicoesEspeciaisCard';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
@@ -553,6 +554,9 @@ export function RepPainel() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Condições especiais vigentes hoje (promoções + descontos liberados) */}
|
||||
<CondicoesEspeciaisCard />
|
||||
|
||||
{/* Metas por Grupo */}
|
||||
{metasPorGrupo.length > 0 && (
|
||||
<Card
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 { WhatsAppOutlined } from '@ant-design/icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faCheckCircle,
|
||||
faAddressCard,
|
||||
faCircleExclamation,
|
||||
faClipboardList,
|
||||
faRankingStar,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { PedidoSummary } from '@sar/api-interface';
|
||||
import { useSupervisorDashboard } from '../../lib/queries/dashboard';
|
||||
import type {
|
||||
ClienteNaoPositivado,
|
||||
PositivacaoRep,
|
||||
RankingRep,
|
||||
RepInativosSummary,
|
||||
} from '@sar/api-interface';
|
||||
import { useRepInativos, useSupervisorDashboard } from '../../lib/queries/dashboard';
|
||||
import { useCurrentUser } from '../../lib/queries/auth';
|
||||
import {
|
||||
positivacaoRepColumns,
|
||||
rankingRepColumns,
|
||||
} from '../../components/dashboard/rep-performance-columns';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -17,10 +43,6 @@ function fmt(v: number): string {
|
||||
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 {
|
||||
if (previous === 0) return null;
|
||||
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' });
|
||||
}
|
||||
|
||||
const queueColumns: TableColumnsType<PedidoSummary> = [
|
||||
{
|
||||
title: 'Pedido',
|
||||
dataIndex: 'numPedSar',
|
||||
width: 120,
|
||||
render: (num: string, row: PedidoSummary) => (
|
||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||
{num}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
width: 150,
|
||||
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||
},
|
||||
function mesLabel(): string {
|
||||
return new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function waLink(whatsapp: string): string {
|
||||
const digits = whatsapp.replace(/\D/g, '');
|
||||
const number = digits.startsWith('55') ? digits : `55${digits}`;
|
||||
return `https://wa.me/${number}`;
|
||||
}
|
||||
|
||||
// ─── Modal de clientes inativos de um rep ─────────────────────────────────────
|
||||
|
||||
const inativosColumns: TableColumnsType<ClienteNaoPositivado> = [
|
||||
{
|
||||
title: 'Cliente',
|
||||
key: 'cliente',
|
||||
width: 180,
|
||||
render: (_: unknown, row: PedidoSummary) =>
|
||||
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`,
|
||||
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||
<Space orientation="vertical" size={0}>
|
||||
<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',
|
||||
dataIndex: 'total',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: (v: string) => fmt(Number(v)),
|
||||
title: 'Última compra',
|
||||
dataIndex: 'ultimoPedido',
|
||||
width: 140,
|
||||
render: (v: string | null) =>
|
||||
v ? new Date(`${v}T00:00:00`).toLocaleDateString('pt-BR') : <Tag>Nunca comprou</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Aguardando',
|
||||
dataIndex: 'createdAt',
|
||||
title: 'Sem compra',
|
||||
dataIndex: 'diasSemPedido',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
const h = hoursWaiting(v);
|
||||
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>;
|
||||
},
|
||||
render: (d: number, c: ClienteNaoPositivado) =>
|
||||
c.comprouAntes ? (
|
||||
<Tag color={d > 90 ? 'red' : 'orange'} className="tabular-nums">
|
||||
{d} dias
|
||||
</Tag>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 90,
|
||||
render: (_: unknown, row: PedidoSummary) => (
|
||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||
<Tag color="blue" style={{ cursor: 'pointer' }}>
|
||||
Analisar
|
||||
</Tag>
|
||||
key: 'acoes',
|
||||
width: 120,
|
||||
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||
<Space>
|
||||
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
|
||||
<Button size="small">Ficha</Button>
|
||||
</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() {
|
||||
const { data, isLoading } = useSupervisorDashboard();
|
||||
const { data: user } = useCurrentUser();
|
||||
const [inativosRep, setInativosRep] = useState<RepInativosSummary | null>(null);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
@@ -110,8 +186,7 @@ export function SupervisorPainel() {
|
||||
);
|
||||
}
|
||||
|
||||
const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data;
|
||||
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.createdAt) > 2).length;
|
||||
const { pedidosDia, inativosPorRep, equipeMes, rankingReps, positivacaoReps, syncedAt } = data;
|
||||
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
|
||||
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
|
||||
|
||||
@@ -124,49 +199,12 @@ export function SupervisorPainel() {
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{today()}
|
||||
{urgentCount > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<span style={{ color: '#cf1322' }}>
|
||||
{urgentCount} aprovação{urgentCount > 1 ? 'ões' : ''} urgente
|
||||
{urgentCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* KPIs */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} md={8}>
|
||||
<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}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
@@ -189,7 +227,7 @@ export function SupervisorPainel() {
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={8}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
@@ -213,38 +251,9 @@ export function SupervisorPainel() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Fila de aprovações + Inativos por rep */}
|
||||
{/* Inativos por rep + Pedidos de hoje */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} lg={16}>
|
||||
<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}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
@@ -281,20 +290,26 @@ export function SupervisorPainel() {
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Space>
|
||||
<Tag
|
||||
color={r.inativosCount >= 3 ? 'orange' : 'default'}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''}
|
||||
</Tag>
|
||||
<Button size="small" onClick={() => setInativosRep(r)}>
|
||||
Detalhar
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
style={{ marginTop: 24 }}
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
|
||||
@@ -328,6 +343,130 @@ export function SupervisorPainel() {
|
||||
</Col>
|
||||
</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 — {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 — {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 }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
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
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<InativosRepModal rep={inativosRep} onClose={() => setInativosRep(null)} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
114
apps/web/src/components/dashboard/rep-performance-columns.tsx
Normal file
114
apps/web/src/components/dashboard/rep-performance-columns.tsx
Normal 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>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faChartLine,
|
||||
faClipboardList,
|
||||
faClipboardCheck,
|
||||
faUsers,
|
||||
faAddressCard,
|
||||
faBoxesStacked,
|
||||
@@ -40,7 +39,6 @@ const REP_ITEMS: NavItem[] = [
|
||||
|
||||
const SUPERVISOR_ITEMS: NavItem[] = [
|
||||
{ key: '/', icon: faChartLine, label: 'Painel' },
|
||||
{ key: '/aprovacoes', icon: faClipboardCheck, label: 'Aprovações' },
|
||||
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
|
||||
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
||||
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
||||
|
||||
@@ -61,11 +61,6 @@ const REP_ITEMS: ItemType[] = [
|
||||
|
||||
const SUPERVISOR_ITEMS: ItemType[] = [
|
||||
{ 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: '/pedidos',
|
||||
@@ -127,10 +122,12 @@ export function Sidebar() {
|
||||
const role = getRole();
|
||||
const items = getItems(role);
|
||||
|
||||
const selectedKey = items.find((item) => {
|
||||
const selectedKey = items.reduce<string | undefined>((best, item) => {
|
||||
const key = (item as { key?: string }).key ?? '';
|
||||
return key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
|
||||
})?.key as string | undefined;
|
||||
const matches = key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
|
||||
if (!matches) return best;
|
||||
return !best || key.length > best.length ? key : best;
|
||||
}, undefined);
|
||||
|
||||
return (
|
||||
<aside
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
RepDashboardSchema,
|
||||
RepInativosDetailSchema,
|
||||
SupervisorDashboardSchema,
|
||||
type RepDashboard,
|
||||
type RepInativosDetail,
|
||||
type SupervisorDashboard,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
@@ -29,3 +31,30 @@ export function useSupervisorDashboard() {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2,15 +2,14 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ManagerDashboardSchema,
|
||||
EquipeResponseSchema,
|
||||
AlcadaDescontosResponseSchema,
|
||||
SupervisoresResponseSchema,
|
||||
PromocoesResponseSchema,
|
||||
RegrasDescontoResponseSchema,
|
||||
GruposProdutoResponseSchema,
|
||||
type ManagerDashboard,
|
||||
type EquipeResponse,
|
||||
type AlcadaDescontosResponse,
|
||||
type SupervisoresResponse,
|
||||
type PromocoesResponse,
|
||||
type UpsertDescontoBody,
|
||||
type CreatePromocaoBody,
|
||||
type UpdatePromocaoBody,
|
||||
type RegrasDescontoResponse,
|
||||
@@ -47,14 +46,14 @@ export function useEquipe() {
|
||||
});
|
||||
}
|
||||
|
||||
export function usePoliticasDescontos() {
|
||||
return useQuery<AlcadaDescontosResponse>({
|
||||
queryKey: ['politicas', 'descontos'],
|
||||
export function useSupervisores() {
|
||||
return useQuery<SupervisoresResponse>({
|
||||
queryKey: ['equipe', 'supervisores'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/descontos');
|
||||
return AlcadaDescontosResponseSchema.parse(raw);
|
||||
const raw = await apiFetch('/equipe/supervisores');
|
||||
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() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -11,6 +11,7 @@ import { apiFetch } from '../api-client';
|
||||
|
||||
// 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.
|
||||
// Refetch curto: regra encerrada/inativada some da tela do rep em até 1 minuto.
|
||||
export function useRegrasVigentes() {
|
||||
return useQuery<RegrasVigentesResponse>({
|
||||
queryKey: ['politicas', 'regras-vigentes'],
|
||||
@@ -18,7 +19,8 @@ export function useRegrasVigentes() {
|
||||
const raw = await apiFetch('/politicas/regras/vigentes');
|
||||
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() {
|
||||
return useQuery<PromocoesVigentesResponse>({
|
||||
queryKey: ['politicas', 'promocoes-vigentes'],
|
||||
@@ -42,6 +45,7 @@ export function usePromocoesVigentes() {
|
||||
const raw = await apiFetch('/politicas/promocoes/vigentes');
|
||||
return PromocoesVigentesResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ReportMetaResponseSchema,
|
||||
ReportCarteiraResponseSchema,
|
||||
ReportAbcResponseSchema,
|
||||
type ReportMetaQuery,
|
||||
type ReportAbcQuery,
|
||||
type ReportMetaResponse,
|
||||
type ReportCarteiraResponse,
|
||||
type ReportAbcResponse,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
@@ -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) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.mes != null) qs.set('mes', String(params.mes));
|
||||
|
||||
@@ -18,9 +18,7 @@ import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
||||
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
||||
import { CarteirePage } from '../cockpits/rep/CarteirePage';
|
||||
import { FunilPage } from '../cockpits/rep/FunilPage';
|
||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
||||
import { GerPainel } from '../cockpits/ger/GerPainel';
|
||||
import { EquipePage } from '../cockpits/ger/EquipePage';
|
||||
@@ -160,24 +158,12 @@ const relatoriosRoute = createRoute({
|
||||
component: RelatoriosPage,
|
||||
});
|
||||
|
||||
const carteiraRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/clientes/carteira',
|
||||
component: CarteirePage,
|
||||
});
|
||||
|
||||
const funilRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/funil',
|
||||
component: FunilPage,
|
||||
});
|
||||
|
||||
const aprovacoes = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/aprovacoes',
|
||||
component: ApprovalQueuePage,
|
||||
});
|
||||
|
||||
const gerPainelRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/ger',
|
||||
@@ -227,9 +213,7 @@ const routeTree = rootRoute.addChildren([
|
||||
catalogoRoute,
|
||||
pedidosErpRoute,
|
||||
relatoriosRoute,
|
||||
carteiraRoute,
|
||||
funilRoute,
|
||||
aprovacoes,
|
||||
gerPainelRoute,
|
||||
equipeRoute,
|
||||
politicasRoute,
|
||||
|
||||
14
apps/web/src/svg-maps.d.ts
vendored
Normal file
14
apps/web/src/svg-maps.d.ts
vendored
Normal 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;
|
||||
}
|
||||
@@ -72,18 +72,13 @@ export const RepInativosSummarySchema = z.object({
|
||||
});
|
||||
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
|
||||
|
||||
export const SupervisorDashboardSchema = z.object({
|
||||
approvalQueue: z.array(PedidoSummarySchema),
|
||||
pedidosDia: z.object({
|
||||
count: z.number().int(),
|
||||
total: z.number(),
|
||||
countSemanaAnterior: z.number().int(),
|
||||
totalSemanaAnterior: z.number(),
|
||||
}),
|
||||
inativosPorRep: z.array(RepInativosSummarySchema),
|
||||
syncedAt: z.iso.datetime(),
|
||||
// Detalhe dos clientes inativos (>30 dias sem compra) de um representante
|
||||
export const RepInativosDetailSchema = z.object({
|
||||
codVendedor: z.number().int(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
clientes: z.array(ClienteNaoPositivadoSchema),
|
||||
});
|
||||
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
|
||||
export type RepInativosDetail = z.infer<typeof RepInativosDetailSchema>;
|
||||
|
||||
// novosClientes: clientes da carteira cuja PRIMEIRA compra (historico todo)
|
||||
// caiu no periodo selecionado
|
||||
@@ -107,6 +102,30 @@ export const RankingRepSchema = z.object({
|
||||
});
|
||||
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)
|
||||
export const PositivacaoDiaSchema = z.object({
|
||||
dia: z.string(),
|
||||
@@ -114,6 +133,26 @@ export const PositivacaoDiaSchema = z.object({
|
||||
});
|
||||
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({
|
||||
faturamentoMes: z.number(),
|
||||
pedidosMes: z.number().int(),
|
||||
@@ -125,6 +164,7 @@ export const ManagerDashboardSchema = z.object({
|
||||
rankingReps: z.array(RankingRepSchema),
|
||||
positivacaoReps: z.array(PositivacaoRepSchema),
|
||||
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
|
||||
faturamentoPorUf: z.array(UfFaturamentoSchema).default([]),
|
||||
// Meta diária de positivação salva pelo gerente (config_empresa)
|
||||
metaPositivacaoDia: z.number().int().nullable().optional(),
|
||||
syncedAt: z.iso.datetime(),
|
||||
|
||||
@@ -16,3 +16,30 @@ export const EquipeResponseSchema = z.object({
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
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>;
|
||||
|
||||
@@ -6,9 +6,9 @@ import { z } from 'zod';
|
||||
|
||||
// ─── Situa ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Ciclo de vida do pedido SAR:
|
||||
// 0=Orçamento → 1=Ag. Aprovação (se desconto > alçada) → 2=Transmitido
|
||||
// Estados que o SAR controla: Orçamento e Transmitido (1 é o gate de desconto).
|
||||
// Ciclo de vida do pedido SAR: 0=Orçamento → 2=Transmitido.
|
||||
// Desconto acima da alçada é bloqueio duro na transmissão — não há fila de
|
||||
// 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…)
|
||||
// — espelhado quando a integração existir.
|
||||
export const SituaPedidoSchema = z.union([
|
||||
@@ -170,17 +170,6 @@ export const CancelPedidoSchema = z.object({
|
||||
});
|
||||
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 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export const PedidoErpConsultaItemSchema = z.object({
|
||||
|
||||
@@ -1,25 +1,5 @@
|
||||
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({
|
||||
id: z.number().int(),
|
||||
descricao: z.string(),
|
||||
@@ -71,7 +51,7 @@ export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
|
||||
|
||||
// ─── Regras de Desconto ───────────────────────────────────────────────────────
|
||||
// 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({
|
||||
id: z.number().int(),
|
||||
@@ -80,6 +60,8 @@ export const RegraDescontoSchema = z.object({
|
||||
grupo: z.string().nullable(),
|
||||
codSubgrupo: z.number().int().nullable(),
|
||||
subgrupo: z.string().nullable(),
|
||||
idPauta: z.number().int().nullable(),
|
||||
pauta: z.string().nullable(),
|
||||
codVendedor: z.number().int().nullable(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
descPct: z.number(),
|
||||
@@ -96,12 +78,14 @@ export const RegrasDescontoResponseSchema = z.object({
|
||||
});
|
||||
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
|
||||
|
||||
// Criação aceita VÁRIOS grupos e/ou subgrupos — uma regra por alvo (grupos
|
||||
// selecionados viram regras por grupo; subgrupos viram regras por subgrupo).
|
||||
// Criação aceita VÁRIOS grupos, subgrupos e/ou pautas — uma regra por alvo
|
||||
// (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({
|
||||
descricao: z.string().min(1).max(200),
|
||||
codGrupos: 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(),
|
||||
descPct: z.number().min(0).max(100),
|
||||
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(),
|
||||
codGrupo: z.number().int().nullable().optional(),
|
||||
codSubgrupo: z.number().int().nullable().optional(),
|
||||
idPauta: z.number().int().nullable().optional(),
|
||||
codVendedor: z.number().int().nullable().optional(),
|
||||
descPct: z.number().min(0).max(100).optional(),
|
||||
dataInicio: z
|
||||
@@ -145,7 +130,9 @@ export const PromocaoVigenteSchema = z.object({
|
||||
id: z.number().int(),
|
||||
descricao: z.string(),
|
||||
codProduto: z.string().nullable(),
|
||||
nomeProduto: z.string().nullable(),
|
||||
grpProd: z.string().nullable(),
|
||||
grupo: z.string().nullable(),
|
||||
descPct: z.number(),
|
||||
dataFim: z.string(),
|
||||
});
|
||||
@@ -161,7 +148,11 @@ export const RegraVigenteSchema = z.object({
|
||||
id: z.number().int(),
|
||||
descricao: z.string(),
|
||||
codGrupo: z.number().int().nullable(),
|
||||
grupo: z.string().nullable(),
|
||||
codSubgrupo: z.number().int().nullable(),
|
||||
subgrupo: z.string().nullable(),
|
||||
idPauta: z.number().int().nullable(),
|
||||
pauta: z.string().nullable(),
|
||||
descPct: z.number(),
|
||||
dataFim: z.string(),
|
||||
});
|
||||
|
||||
8
pnpm-lock.yaml
generated
8
pnpm-lock.yaml
generated
@@ -319,6 +319,9 @@ importers:
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.4.0
|
||||
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':
|
||||
specifier: ^5.100.14
|
||||
version: 5.100.14(react@19.2.6)
|
||||
@@ -3380,6 +3383,9 @@ packages:
|
||||
'@standard-schema/utils@0.3.0':
|
||||
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':
|
||||
resolution: {integrity: sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -13385,6 +13391,8 @@ snapshots:
|
||||
|
||||
'@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)':
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
|
||||
@@ -720,8 +720,9 @@ CREATE TABLE IF NOT EXISTS sar.promocoes (
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin)
|
||||
-- Desconto liberado por grupo/subgrupo de produto e/ou representante, com
|
||||
-- vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL vale para todo rep).
|
||||
-- Desconto liberado por grupo/subgrupo de produto, pauta de precos e/ou
|
||||
-- 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 (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -730,6 +731,7 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
||||
cod_grupo INTEGER,
|
||||
cod_subgrupo INTEGER,
|
||||
cod_vendedor INTEGER,
|
||||
id_pauta INTEGER,
|
||||
desc_pct NUMERIC(5,2) NOT NULL,
|
||||
data_inicio DATE NOT NULL,
|
||||
data_fim DATE NOT NULL,
|
||||
|
||||
Reference in New Issue
Block a user