refactor(api,web): remove fila de aprovacao e consolida painel do supervisor
Alcada agora e bloqueio duro no pedido (sem fluxo de aprovar/recusar): remove ApprovalQueuePage, CarteirePage e rota /aprovacoes, absorvendo carteira e acompanhamento de pedidos dentro do SupervisorPainel. Colunas de desempenho por representante extraidas para componente compartilhado (rep-performance-columns) entre gerente e supervisor. Ajusta dashboard e orders (controller/service/contract) para o novo fluxo. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,19 @@
|
|||||||
import { Body, Controller, ForbiddenException, Get, HttpCode, Put, Query } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
ForbiddenException,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
ParseIntPipe,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
SaveMetaPositivacaoBodySchema,
|
SaveMetaPositivacaoBodySchema,
|
||||||
type RepDashboard,
|
type RepDashboard,
|
||||||
|
type RepInativosDetail,
|
||||||
type SupervisorDashboard,
|
type SupervisorDashboard,
|
||||||
type ManagerDashboard,
|
type ManagerDashboard,
|
||||||
type SaveMetaPositivacaoBody,
|
type SaveMetaPositivacaoBody,
|
||||||
@@ -40,6 +50,14 @@ export class DashboardController {
|
|||||||
return this.dashboard.supervisorDashboard();
|
return this.dashboard.supervisorDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('supervisor/inativos')
|
||||||
|
supervisorInativos(
|
||||||
|
@Query('codVendedor', ParseIntPipe) codVendedor: number,
|
||||||
|
): Promise<RepInativosDetail> {
|
||||||
|
this.assertGestor();
|
||||||
|
return this.dashboard.supervisorInativos(codVendedor);
|
||||||
|
}
|
||||||
|
|
||||||
@Put('manager/meta-positivacao-dia')
|
@Put('manager/meta-positivacao-dia')
|
||||||
@HttpCode(204)
|
@HttpCode(204)
|
||||||
saveMetaPositivacaoDia(@Body() body: SaveMetaPositivacaoDto): Promise<void> {
|
saveMetaPositivacaoDia(@Body() body: SaveMetaPositivacaoDto): Promise<void> {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ForbiddenException, Injectable } from '@nestjs/common';
|
|||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type {
|
import type {
|
||||||
RepDashboard,
|
RepDashboard,
|
||||||
|
RepInativosDetail,
|
||||||
SupervisorDashboard,
|
SupervisorDashboard,
|
||||||
ManagerDashboard,
|
ManagerDashboard,
|
||||||
RankingRep,
|
RankingRep,
|
||||||
@@ -11,8 +12,6 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
|||||||
import { getTeamCodes } from '../workspace/team-scope.util';
|
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||||
|
|
||||||
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
|
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
|
||||||
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
|
|
||||||
const SITUA_PENDENTE = 1;
|
|
||||||
|
|
||||||
// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
|
// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
|
||||||
const TIPO_META_GLOBAL = 'GL';
|
const TIPO_META_GLOBAL = 'GL';
|
||||||
@@ -744,17 +743,6 @@ export class DashboardService {
|
|||||||
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null;
|
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null;
|
||||||
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
|
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
|
||||||
|
|
||||||
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
|
|
||||||
const approvalQueue = await prisma.pedido.findMany({
|
|
||||||
where: {
|
|
||||||
idEmpresa,
|
|
||||||
situa: SITUA_PENDENTE,
|
|
||||||
...(team ? { codVendedor: { in: team } } : {}),
|
|
||||||
},
|
|
||||||
orderBy: { dtPedido: 'asc' },
|
|
||||||
take: 50,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Pedidos do dia — lê do ERP (situa != 5=Cancelado)
|
// Pedidos do dia — lê do ERP (situa != 5=Cancelado)
|
||||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||||
const todayStr = todayStart.toISOString().slice(0, 10);
|
const todayStr = todayStart.toISOString().slice(0, 10);
|
||||||
@@ -785,7 +773,152 @@ export class DashboardService {
|
|||||||
`),
|
`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Top 3 reps com mais clientes inativos (>30 dias sem compra no ERP)
|
// Métricas do mês corrente da equipe — mesmas contas do painel gerencial,
|
||||||
|
// escopadas pelos códigos da equipe (gerente/admin sem filtro = empresa toda).
|
||||||
|
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = now.getMonth() + 1;
|
||||||
|
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
|
||||||
|
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
interface RankingRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome: string | null;
|
||||||
|
pedidos: string;
|
||||||
|
clientes_atendidos: string;
|
||||||
|
faturamento: string;
|
||||||
|
meta_valor: string | null;
|
||||||
|
}
|
||||||
|
interface MetaTotalRow {
|
||||||
|
meta_total: string;
|
||||||
|
}
|
||||||
|
interface PositivacaoRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
nome_vendedor: string | null;
|
||||||
|
total_clientes: string;
|
||||||
|
clientes_positivados: string;
|
||||||
|
}
|
||||||
|
interface NovosClientesRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
novos: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [mesRows, rankingRows, metaTotalRows, positivacaoRows, novosClientesRows] =
|
||||||
|
await Promise.all([
|
||||||
|
prisma.$queryRawUnsafe<DayRow[]>(`
|
||||||
|
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
${teamSqlFilter('cod_vendedor')}
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<RankingRow[]>(`
|
||||||
|
SELECT p.cod_vendedor,
|
||||||
|
(SELECT r.nome FROM vw_representantes r
|
||||||
|
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome,
|
||||||
|
COUNT(*)::text AS pedidos,
|
||||||
|
COUNT(DISTINCT p.id_cliente)::text AS clientes_atendidos,
|
||||||
|
COALESCE(SUM(p.total), 0)::text AS faturamento,
|
||||||
|
(SELECT SUM(m.valor)::text FROM sar.vw_metas m
|
||||||
|
WHERE m.id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND m.cod_vendedor = p.cod_vendedor
|
||||||
|
AND m.ano = ${year}
|
||||||
|
AND m.mes = ${month}
|
||||||
|
AND TRIM(m.tipo) = '${TIPO_META_GRUPO}') AS meta_valor
|
||||||
|
FROM vw_pedidos_erp p
|
||||||
|
WHERE p.id_empresa = ${idEmpresa}
|
||||||
|
AND p.situa NOT IN (1, 5)
|
||||||
|
AND p.dt_pedido >= '${monthStartStr}'
|
||||||
|
AND p.dt_pedido <= '${monthEndStr}'
|
||||||
|
${teamSqlFilter('p.cod_vendedor')}
|
||||||
|
GROUP BY p.cod_vendedor
|
||||||
|
ORDER BY SUM(p.total) DESC
|
||||||
|
LIMIT 10
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<MetaTotalRow[]>(`
|
||||||
|
SELECT COALESCE(SUM(valor), 0)::text AS meta_total
|
||||||
|
FROM sar.vw_metas
|
||||||
|
WHERE id_empresa = ${idEmpresaMatriz}
|
||||||
|
AND ano = ${year}
|
||||||
|
AND mes = ${month}
|
||||||
|
AND TRIM(tipo) = '${TIPO_META_GRUPO}'
|
||||||
|
${teamSqlFilter('cod_vendedor')}
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<PositivacaoRow[]>(`
|
||||||
|
SELECT
|
||||||
|
c.cod_vendedor,
|
||||||
|
(SELECT r.nome FROM vw_representantes r WHERE r.codigo = c.cod_vendedor LIMIT 1) AS nome_vendedor,
|
||||||
|
COUNT(DISTINCT c.id_cliente)::text AS total_clientes,
|
||||||
|
COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS clientes_positivados
|
||||||
|
FROM vw_clientes c
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT DISTINCT id_cliente, cod_vendedor
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
AND dt_pedido >= '${monthStartStr}'
|
||||||
|
AND dt_pedido <= '${monthEndStr}'
|
||||||
|
) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor
|
||||||
|
WHERE c.cod_vendedor > 0
|
||||||
|
${teamSqlFilter('c.cod_vendedor')}
|
||||||
|
GROUP BY c.cod_vendedor
|
||||||
|
HAVING COUNT(DISTINCT c.id_cliente) > 0
|
||||||
|
ORDER BY COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END) DESC
|
||||||
|
`),
|
||||||
|
prisma.$queryRawUnsafe<NovosClientesRow[]>(`
|
||||||
|
SELECT c.cod_vendedor, COUNT(*)::text AS novos
|
||||||
|
FROM (
|
||||||
|
SELECT id_cliente, MIN(dt_pedido) AS primeira_compra
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
GROUP BY id_cliente
|
||||||
|
) f
|
||||||
|
JOIN (SELECT DISTINCT id_cliente, cod_vendedor FROM vw_clientes) c
|
||||||
|
ON c.id_cliente = f.id_cliente
|
||||||
|
WHERE f.primeira_compra >= '${monthStartStr}'
|
||||||
|
AND f.primeira_compra <= '${monthEndStr}'
|
||||||
|
${teamSqlFilter('c.cod_vendedor')}
|
||||||
|
GROUP BY c.cod_vendedor
|
||||||
|
`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const pedidosMes = Number(mesRows[0]?.count ?? 0);
|
||||||
|
const faturamentoMes = Number(mesRows[0]?.total ?? 0);
|
||||||
|
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
|
||||||
|
|
||||||
|
const rankingReps: RankingRep[] = rankingRows.map((r) => {
|
||||||
|
const fat = Number(r.faturamento);
|
||||||
|
const meta = r.meta_valor ? Number(r.meta_valor) : 0;
|
||||||
|
return {
|
||||||
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome ?? null,
|
||||||
|
pedidos: Number(r.pedidos),
|
||||||
|
clientesAtendidos: Number(r.clientes_atendidos),
|
||||||
|
faturamento: fat,
|
||||||
|
pctMeta: meta > 0 ? Math.round((fat / meta) * 100) : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const novosMap = new Map(
|
||||||
|
novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]),
|
||||||
|
);
|
||||||
|
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
|
||||||
|
const total = Number(r.total_clientes);
|
||||||
|
const positivados = Number(r.clientes_positivados);
|
||||||
|
return {
|
||||||
|
codVendedor: Number(r.cod_vendedor),
|
||||||
|
nomeVendedor: r.nome_vendedor ?? null,
|
||||||
|
totalClientes: total,
|
||||||
|
clientesPositivados: positivados,
|
||||||
|
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
|
||||||
|
novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reps com mais clientes inativos (>30 dias sem compra no ERP)
|
||||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||||
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
|
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
|
||||||
SELECT inativos.cod_vendedor,
|
SELECT inativos.cod_vendedor,
|
||||||
@@ -808,50 +941,10 @@ export class DashboardService {
|
|||||||
) inativos
|
) inativos
|
||||||
GROUP BY inativos.cod_vendedor
|
GROUP BY inativos.cod_vendedor
|
||||||
ORDER BY COUNT(*) DESC
|
ORDER BY COUNT(*) DESC
|
||||||
LIMIT 3
|
LIMIT 10
|
||||||
`);
|
`);
|
||||||
|
|
||||||
// Resolve nomes de cliente e representante da fila (pedidos SAR só têm os códigos)
|
|
||||||
const repCods = [...new Set(approvalQueue.map((p) => p.codVendedor))];
|
|
||||||
const cliIds = [...new Set(approvalQueue.map((p) => p.idCliente))];
|
|
||||||
const [repNameRows, cliNameRows] = await Promise.all([
|
|
||||||
repCods.length
|
|
||||||
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
|
||||||
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
|
|
||||||
)
|
|
||||||
: Promise.resolve([]),
|
|
||||||
cliIds.length
|
|
||||||
? prisma.$queryRawUnsafe<
|
|
||||||
{ id_cliente: number; nome: string | null; razao: string | null }[]
|
|
||||||
>(
|
|
||||||
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
|
|
||||||
)
|
|
||||||
: Promise.resolve([]),
|
|
||||||
]);
|
|
||||||
const repNameMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
|
|
||||||
const cliNameMap = new Map(
|
|
||||||
cliNameRows.map((c) => [Number(c.id_cliente), { nome: c.nome, razao: c.razao }]),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
|
|
||||||
id: o.id,
|
|
||||||
numPedSar: o.numPedSar,
|
|
||||||
idCliente: o.idCliente,
|
|
||||||
nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null,
|
|
||||||
razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null,
|
|
||||||
codVendedor: o.codVendedor,
|
|
||||||
nomeVendedor: repNameMap.get(o.codVendedor) ?? null,
|
|
||||||
situa: o.situa,
|
|
||||||
dtPedido: o.dtPedido.toISOString(),
|
|
||||||
total: String(o.total),
|
|
||||||
descontoPerc: String(o.descontoPerc),
|
|
||||||
obs: o.obs,
|
|
||||||
createdAt: o.createdAt.toISOString(),
|
|
||||||
fonte: 'sar' as const,
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
approvalQueue: approvalQueue.map(mapPedido),
|
|
||||||
pedidosDia: {
|
pedidosDia: {
|
||||||
count: Number(todayRows[0]?.count ?? 0),
|
count: Number(todayRows[0]?.count ?? 0),
|
||||||
total: Number(todayRows[0]?.total ?? 0),
|
total: Number(todayRows[0]?.total ?? 0),
|
||||||
@@ -863,7 +956,93 @@ export class DashboardService {
|
|||||||
nomeVendedor: r.nome_vendedor ?? null,
|
nomeVendedor: r.nome_vendedor ?? null,
|
||||||
inativosCount: parseInt(r.inativos_count, 10),
|
inativosCount: parseInt(r.inativos_count, 10),
|
||||||
})),
|
})),
|
||||||
|
equipeMes: {
|
||||||
|
faturamento: faturamentoMes,
|
||||||
|
pedidos: pedidosMes,
|
||||||
|
ticketMedio: pedidosMes > 0 ? faturamentoMes / pedidosMes : 0,
|
||||||
|
metaTotal,
|
||||||
|
pctMeta: metaTotal > 0 ? Math.round((faturamentoMes / metaTotal) * 100) : 0,
|
||||||
|
},
|
||||||
|
rankingReps,
|
||||||
|
positivacaoReps,
|
||||||
syncedAt: now.toISOString(),
|
syncedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clientes inativos (>30 dias sem compra) de um representante — detalhe do
|
||||||
|
// card "Inativos por Rep". Supervisor só consulta reps da própria equipe;
|
||||||
|
// gerente/admin consultam qualquer rep.
|
||||||
|
async supervisorInativos(codVendedor: number): Promise<RepInativosDetail> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId');
|
||||||
|
if (role === 'supervisor') {
|
||||||
|
const team = await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0);
|
||||||
|
if (!team.includes(codVendedor)) {
|
||||||
|
throw new ForbiddenException('Representante fora da sua equipe');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mesmo corte do resumo inativosPorRep: sem compra há mais de 30 dias.
|
||||||
|
const cutoffStr = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||||
|
const rows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
|
||||||
|
WITH ultimo_pedido AS (
|
||||||
|
SELECT id_cliente, MAX(dt_pedido) AS dt_max
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa != 5
|
||||||
|
GROUP BY id_cliente
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
c.id_cliente,
|
||||||
|
TRIM(c.nome) AS nome,
|
||||||
|
TRIM(c.razao) AS razao,
|
||||||
|
TO_CHAR(up.dt_max, 'YYYY-MM-DD') AS dt_ultimo_pedido,
|
||||||
|
(CURRENT_DATE - up.dt_max::date) AS dias_sem_pedido,
|
||||||
|
(up.dt_max IS NOT NULL) AS comprou_antes,
|
||||||
|
ct.whatsapp
|
||||||
|
FROM vw_clientes c
|
||||||
|
LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT TRIM(whatsapp) AS whatsapp
|
||||||
|
FROM sar.vw_contatos
|
||||||
|
WHERE id_corrent = c.id_cliente
|
||||||
|
AND whatsapp IS NOT NULL AND TRIM(whatsapp) != ''
|
||||||
|
AND ativo = 1
|
||||||
|
LIMIT 1
|
||||||
|
) ct ON true
|
||||||
|
WHERE c.cod_vendedor = ${codVendedor}
|
||||||
|
AND c.ativo = 1
|
||||||
|
AND (up.dt_max IS NULL OR up.dt_max < '${cutoffStr}')
|
||||||
|
ORDER BY up.dt_max ASC NULLS FIRST
|
||||||
|
`);
|
||||||
|
|
||||||
|
const nomeRows = await prisma.$queryRawUnsafe<{ nome: string | null }[]>(
|
||||||
|
`SELECT nome FROM vw_representantes WHERE codigo = ${codVendedor} LIMIT 1`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Deduplica por idCliente — vw_clientes pode repetir o cliente por empresa.
|
||||||
|
const seen = new Set<number>();
|
||||||
|
const clientes = rows
|
||||||
|
.filter((c) => {
|
||||||
|
const id = Number(c.id_cliente);
|
||||||
|
if (seen.has(id)) return false;
|
||||||
|
seen.add(id);
|
||||||
|
return true;
|
||||||
|
})
|
||||||
|
.map((c) => ({
|
||||||
|
idCliente: Number(c.id_cliente),
|
||||||
|
nome: c.nome,
|
||||||
|
razao: c.razao ?? null,
|
||||||
|
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
|
||||||
|
ultimoPedido: c.dt_ultimo_pedido ?? null,
|
||||||
|
comprouAntes: Boolean(c.comprou_antes),
|
||||||
|
whatsapp: c.whatsapp ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return { codVendedor, nomeVendedor: nomeRows[0]?.nome ?? null, clientes };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
ForbiddenException,
|
|
||||||
Get,
|
Get,
|
||||||
HttpCode,
|
HttpCode,
|
||||||
Param,
|
Param,
|
||||||
@@ -11,17 +10,13 @@ import {
|
|||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
AprovarPedidoSchema,
|
|
||||||
CancelPedidoSchema,
|
CancelPedidoSchema,
|
||||||
CreatePedidoSchema,
|
CreatePedidoSchema,
|
||||||
UpdatePedidoSchema,
|
UpdatePedidoSchema,
|
||||||
PedidoErpConsultaQuerySchema,
|
PedidoErpConsultaQuerySchema,
|
||||||
PedidoListQuerySchema,
|
PedidoListQuerySchema,
|
||||||
RecusarPedidoSchema,
|
|
||||||
type AprovarPedido,
|
|
||||||
type CancelPedido,
|
type CancelPedido,
|
||||||
type CreatePedido,
|
type CreatePedido,
|
||||||
type UpdatePedido,
|
type UpdatePedido,
|
||||||
@@ -30,25 +25,18 @@ import {
|
|||||||
type PedidoErpConsultaResponse,
|
type PedidoErpConsultaResponse,
|
||||||
type PedidoListQuery,
|
type PedidoListQuery,
|
||||||
type PedidoListResponse,
|
type PedidoListResponse,
|
||||||
type RecusarPedido,
|
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
|
||||||
import { OrdersService } from './orders.service';
|
import { OrdersService } from './orders.service';
|
||||||
|
|
||||||
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
||||||
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
||||||
class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
|
class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
|
||||||
class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
|
class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
|
||||||
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
|
||||||
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
|
||||||
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
|
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
|
||||||
|
|
||||||
@Controller({ path: 'orders' })
|
@Controller({ path: 'orders' })
|
||||||
export class OrdersController {
|
export class OrdersController {
|
||||||
constructor(
|
constructor(private readonly orders: OrdersService) {}
|
||||||
private readonly orders: OrdersService,
|
|
||||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
|
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
|
||||||
@@ -68,28 +56,6 @@ export class OrdersController {
|
|||||||
return this.orders.transmit(id);
|
return this.orders.transmit(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/approve')
|
|
||||||
approve(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@Body() body: AprovarPedidoDto,
|
|
||||||
): Promise<PedidoDetail> {
|
|
||||||
const role = this.cls.get('role') ?? 'rep';
|
|
||||||
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
|
|
||||||
const parsed = AprovarPedidoSchema.parse(body) as AprovarPedido;
|
|
||||||
return this.orders.approve(id, parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':id/reject')
|
|
||||||
reject(
|
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
|
||||||
@Body() body: RecusarPedidoDto,
|
|
||||||
): Promise<PedidoDetail> {
|
|
||||||
const role = this.cls.get('role') ?? 'rep';
|
|
||||||
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
|
|
||||||
const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido;
|
|
||||||
return this.orders.reject(id, parsed);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Patch(':id/cancel')
|
@Patch(':id/cancel')
|
||||||
cancel(
|
cancel(
|
||||||
@Param('id', ParseUUIDPipe) id: string,
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
|||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import type {
|
import type {
|
||||||
AprovarPedido,
|
|
||||||
CancelPedido,
|
CancelPedido,
|
||||||
CreatePedido,
|
CreatePedido,
|
||||||
UpdatePedido,
|
UpdatePedido,
|
||||||
@@ -13,16 +12,14 @@ import type {
|
|||||||
PedidoListQuery,
|
PedidoListQuery,
|
||||||
PedidoListResponse,
|
PedidoListResponse,
|
||||||
PedidoSummary,
|
PedidoSummary,
|
||||||
RecusarPedido,
|
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
import { getTeamCodes } from '../workspace/team-scope.util';
|
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||||
import { NotificationsService } from '../notifications/notifications.service';
|
import { NotificationsService } from '../notifications/notifications.service';
|
||||||
|
|
||||||
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
|
// Situa SAR: 0=Orçamento, 2=Confirmado, 3=Cancelado, 4=Faturado
|
||||||
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
|
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
|
||||||
const SITUA_ORCAMENTO = 0;
|
const SITUA_ORCAMENTO = 0;
|
||||||
const SITUA_PENDENTE = 1;
|
|
||||||
const SITUA_APROVADO = 2;
|
const SITUA_APROVADO = 2;
|
||||||
const SITUA_CANCELADO = 3;
|
const SITUA_CANCELADO = 3;
|
||||||
|
|
||||||
@@ -290,20 +287,6 @@ export class OrdersService {
|
|||||||
return { data, total, page, limit };
|
return { data, total, page, limit };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Supervisor só age sobre pedidos da própria equipe; gerente/admin passam
|
|
||||||
// direto. NotFound (não Forbidden) para não revelar pedidos de outras equipes.
|
|
||||||
private async assertPedidoDaEquipe(codVendedorPedido: number, idPedido: string): Promise<void> {
|
|
||||||
const role = this.cls.get('role');
|
|
||||||
if (role !== 'supervisor') return;
|
|
||||||
const prisma = this.cls.get('prisma');
|
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
|
||||||
const userId = this.cls.get('userId') ?? '0';
|
|
||||||
const team = await getTeamCodes(prisma, parseInt(userId, 10));
|
|
||||||
if (!team.includes(codVendedorPedido)) {
|
|
||||||
throw new NotFoundException(`Pedido ${idPedido} não encontrado`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async findOne(id: string): Promise<PedidoDetail> {
|
async findOne(id: string): Promise<PedidoDetail> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
@@ -690,115 +673,6 @@ export class OrdersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
|
||||||
const prisma = this.cls.get('prisma');
|
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
|
||||||
const userId = this.cls.get('userId') ?? '0';
|
|
||||||
const codVendedor = parseInt(userId, 10);
|
|
||||||
|
|
||||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
|
|
||||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
|
||||||
if (pedido.situa !== SITUA_PENDENTE)
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
|
||||||
);
|
|
||||||
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
|
|
||||||
const newTotal =
|
|
||||||
Math.round(Number(pedido.totalProdutos) * (1 - newDescontoPerc / 100) * 100) / 100;
|
|
||||||
|
|
||||||
await prisma.pedido.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
situa: SITUA_APROVADO,
|
|
||||||
descontoPerc: newDescontoPerc,
|
|
||||||
total: newTotal,
|
|
||||||
aprovadoPor: codVendedor,
|
|
||||||
aprovadoEm: now,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.historicoPedido.create({
|
|
||||||
data: {
|
|
||||||
idPedido: id,
|
|
||||||
situaAnterior: SITUA_PENDENTE,
|
|
||||||
situaNova: SITUA_APROVADO,
|
|
||||||
changedBy: codVendedor,
|
|
||||||
changedAt: now,
|
|
||||||
nota: dto.nota ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const final = await prisma.pedido.findUniqueOrThrow({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
itens: { orderBy: { ordem: 'asc' } },
|
|
||||||
historico: { orderBy: { changedAt: 'asc' } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
void this.notifications.notifyUser(String(pedido.codVendedor), {
|
|
||||||
title: 'Pedido aprovado',
|
|
||||||
body: `${final.numPedSar} aprovado${dto.descontoPerc !== undefined ? ` com ${newDescontoPerc}% de desconto` : ''}`,
|
|
||||||
url: `/pedidos/${id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.mapDetail(final);
|
|
||||||
}
|
|
||||||
|
|
||||||
async reject(id: string, dto: RecusarPedido): Promise<PedidoDetail> {
|
|
||||||
const prisma = this.cls.get('prisma');
|
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
|
||||||
const userId = this.cls.get('userId') ?? '0';
|
|
||||||
const codVendedor = parseInt(userId, 10);
|
|
||||||
|
|
||||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
|
|
||||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
|
||||||
if (pedido.situa !== SITUA_PENDENTE)
|
|
||||||
throw new BadRequestException(
|
|
||||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
|
||||||
);
|
|
||||||
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
|
|
||||||
await prisma.pedido.update({
|
|
||||||
where: { id },
|
|
||||||
data: { situa: SITUA_CANCELADO, motivoRecusa: dto.motivo },
|
|
||||||
});
|
|
||||||
|
|
||||||
await prisma.historicoPedido.create({
|
|
||||||
data: {
|
|
||||||
idPedido: id,
|
|
||||||
situaAnterior: SITUA_PENDENTE,
|
|
||||||
situaNova: SITUA_CANCELADO,
|
|
||||||
changedBy: codVendedor,
|
|
||||||
changedAt: now,
|
|
||||||
nota: dto.motivo,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const final = await prisma.pedido.findUniqueOrThrow({
|
|
||||||
where: { id },
|
|
||||||
include: {
|
|
||||||
itens: { orderBy: { ordem: 'asc' } },
|
|
||||||
historico: { orderBy: { changedAt: 'asc' } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
void this.notifications.notifyUser(String(pedido.codVendedor), {
|
|
||||||
title: 'Pedido recusado',
|
|
||||||
body: `${final.numPedSar}: ${dto.motivo}`,
|
|
||||||
url: `/pedidos/${id}`,
|
|
||||||
});
|
|
||||||
|
|
||||||
return this.mapDetail(final);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edita um orçamento (situa 0). Depois de transmitido não é mais editável.
|
// Edita um orçamento (situa 0). Depois de transmitido não é mais editável.
|
||||||
// Substitui itens e recalcula totais; alçada valida contra o dono do pedido.
|
// Substitui itens e recalcula totais; alçada valida contra o dono do pedido.
|
||||||
async update(id: string, dto: UpdatePedido): Promise<PedidoDetail> {
|
async update(id: string, dto: UpdatePedido): Promise<PedidoDetail> {
|
||||||
|
|||||||
@@ -45,6 +45,10 @@ import dayjs from 'dayjs';
|
|||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
|
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
|
||||||
import { useManagerDashboard } from '../../lib/queries/gerente';
|
import { useManagerDashboard } from '../../lib/queries/gerente';
|
||||||
|
import {
|
||||||
|
positivacaoRepColumns,
|
||||||
|
rankingRepColumns,
|
||||||
|
} from '../../components/dashboard/rep-performance-columns';
|
||||||
import { apiFetch } from '../../lib/api-client';
|
import { apiFetch } from '../../lib/api-client';
|
||||||
import { MapaBrasilCard } from './MapaBrasilCard';
|
import { MapaBrasilCard } from './MapaBrasilCard';
|
||||||
|
|
||||||
@@ -68,60 +72,6 @@ function today(): string {
|
|||||||
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
|
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const positivacaoColumns: TableColumnsType<PositivacaoRep> = [
|
|
||||||
{
|
|
||||||
title: 'Representante',
|
|
||||||
key: 'rep',
|
|
||||||
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
|
||||||
ellipsis: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Positivados / Carteira',
|
|
||||||
key: 'pos',
|
|
||||||
width: 160,
|
|
||||||
align: 'center' as const,
|
|
||||||
render: (_: unknown, row: PositivacaoRep) => (
|
|
||||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
|
||||||
{row.clientesPositivados} / {row.totalClientes}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Novos no mês',
|
|
||||||
key: 'novos',
|
|
||||||
width: 120,
|
|
||||||
align: 'center' as const,
|
|
||||||
sorter: (a: PositivacaoRep, b: PositivacaoRep) => a.novosClientes - b.novosClientes,
|
|
||||||
render: (_: unknown, row: PositivacaoRep) =>
|
|
||||||
row.novosClientes > 0 ? (
|
|
||||||
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
|
|
||||||
+{row.novosClientes}
|
|
||||||
</Tag>
|
|
||||||
) : (
|
|
||||||
<Text type="secondary">—</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '% Positivação',
|
|
||||||
dataIndex: 'pctPositivacao',
|
|
||||||
width: 170,
|
|
||||||
render: (pct: number) => (
|
|
||||||
<Flex align="center" gap={8}>
|
|
||||||
<Progress
|
|
||||||
percent={Math.min(pct, 100)}
|
|
||||||
size="small"
|
|
||||||
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
|
|
||||||
showInfo={false}
|
|
||||||
style={{ flex: 1, minWidth: 60 }}
|
|
||||||
/>
|
|
||||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
|
||||||
{pct}%
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const metasGrupoColumns: TableColumnsType<MetaItem> = [
|
const metasGrupoColumns: TableColumnsType<MetaItem> = [
|
||||||
{
|
{
|
||||||
title: 'Grupo',
|
title: 'Grupo',
|
||||||
@@ -179,55 +129,6 @@ const metasGrupoColumns: TableColumnsType<MetaItem> = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const rankingColumns: TableColumnsType<RankingRep> = [
|
|
||||||
{
|
|
||||||
title: 'Representante',
|
|
||||||
key: 'rep',
|
|
||||||
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Pedidos',
|
|
||||||
dataIndex: 'pedidos',
|
|
||||||
width: 80,
|
|
||||||
align: 'right',
|
|
||||||
className: 'tabular-nums',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Clientes',
|
|
||||||
dataIndex: 'clientesAtendidos',
|
|
||||||
width: 80,
|
|
||||||
align: 'right',
|
|
||||||
className: 'tabular-nums',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Faturamento',
|
|
||||||
dataIndex: 'faturamento',
|
|
||||||
width: 150,
|
|
||||||
align: 'right',
|
|
||||||
render: (v: number) => fmt(v),
|
|
||||||
className: 'tabular-nums',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '% Meta',
|
|
||||||
dataIndex: 'pctMeta',
|
|
||||||
width: 160,
|
|
||||||
render: (pct: number) => (
|
|
||||||
<Flex align="center" gap={8}>
|
|
||||||
<Progress
|
|
||||||
percent={Math.min(pct, 100)}
|
|
||||||
size="small"
|
|
||||||
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
|
||||||
showInfo={false}
|
|
||||||
style={{ flex: 1, minWidth: 60 }}
|
|
||||||
/>
|
|
||||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
|
||||||
{pct}%
|
|
||||||
</Text>
|
|
||||||
</Flex>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function PositivacaoDiariaCard({
|
function PositivacaoDiariaCard({
|
||||||
dados,
|
dados,
|
||||||
metaSalva,
|
metaSalva,
|
||||||
@@ -676,7 +577,7 @@ export function GerPainel() {
|
|||||||
>
|
>
|
||||||
<Table<RankingRep>
|
<Table<RankingRep>
|
||||||
rowKey="codVendedor"
|
rowKey="codVendedor"
|
||||||
columns={rankingColumns}
|
columns={rankingRepColumns}
|
||||||
dataSource={rankingReps}
|
dataSource={rankingReps}
|
||||||
pagination={false}
|
pagination={false}
|
||||||
size="small"
|
size="small"
|
||||||
@@ -720,7 +621,7 @@ export function GerPainel() {
|
|||||||
>
|
>
|
||||||
<Table<PositivacaoRep>
|
<Table<PositivacaoRep>
|
||||||
rowKey="codVendedor"
|
rowKey="codVendedor"
|
||||||
columns={positivacaoColumns}
|
columns={positivacaoRepColumns}
|
||||||
dataSource={positivacaoReps}
|
dataSource={positivacaoReps}
|
||||||
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
|
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -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,
|
UserOutlined,
|
||||||
WhatsAppOutlined,
|
WhatsAppOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { Doughnut } from 'react-chartjs-2';
|
|
||||||
import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from 'chart.js';
|
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import type { ActivityStatus, ClientSummary } from '@sar/api-interface';
|
import type { ActivityStatus, ClientSummary } from '@sar/api-interface';
|
||||||
import { SITUA_LABEL } from '@sar/api-interface';
|
import { SITUA_LABEL } from '@sar/api-interface';
|
||||||
import { useClientList, useClientDetail } from '../../lib/queries/clients';
|
import { useClientList, useClientDetail } from '../../lib/queries/clients';
|
||||||
import { useClientOrders } from '../../lib/queries/orders';
|
import { useClientOrders } from '../../lib/queries/orders';
|
||||||
|
|
||||||
ChartJS.register(ArcElement, ChartTooltip, Legend);
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
|
|
||||||
@@ -184,150 +180,6 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── CustomerPortfolioCard ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function CustomerPortfolioCard({
|
|
||||||
stats,
|
|
||||||
onDetalhar,
|
|
||||||
}: {
|
|
||||||
stats: PortfolioStats;
|
|
||||||
onDetalhar: () => void;
|
|
||||||
}) {
|
|
||||||
const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
|
||||||
const total = stats.ativos + stats.emAlerta + stats.inativos;
|
|
||||||
|
|
||||||
const donutData = {
|
|
||||||
labels: ['Ativos', 'Em alerta', 'Inativos'],
|
|
||||||
datasets: [
|
|
||||||
{
|
|
||||||
data: [stats.ativos, stats.emAlerta, stats.inativos],
|
|
||||||
backgroundColor: ['#52C41A', '#FAAD14', '#FF4D4F'],
|
|
||||||
borderColor: '#fff',
|
|
||||||
borderWidth: 3,
|
|
||||||
hoverOffset: 6,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const donutOptions = {
|
|
||||||
responsive: true,
|
|
||||||
maintainAspectRatio: true,
|
|
||||||
cutout: '68%',
|
|
||||||
plugins: {
|
|
||||||
legend: { display: false },
|
|
||||||
tooltip: {
|
|
||||||
callbacks: {
|
|
||||||
label: (ctx: { label: string; raw: unknown }) => {
|
|
||||||
const v = ctx.raw as number;
|
|
||||||
const pct = total > 0 ? ((v / total) * 100).toFixed(1) : '0.0';
|
|
||||||
return `${ctx.label}: ${v.toLocaleString('pt-BR')} (${pct}%)`;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const legendItems = [
|
|
||||||
{ label: 'Ativos', value: stats.ativos, color: '#52C41A' },
|
|
||||||
{ label: 'Em alerta', value: stats.emAlerta, color: '#FAAD14' },
|
|
||||||
{ label: 'Inativos', value: stats.inativos, color: '#FF4D4F' },
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
style={{
|
|
||||||
borderRadius: 12,
|
|
||||||
border: '1px solid #EBF0F5',
|
|
||||||
boxShadow: '0 1px 6px rgba(0,0,0,0.07)',
|
|
||||||
}}
|
|
||||||
styles={{ body: { padding: '20px 20px 16px' } }}
|
|
||||||
>
|
|
||||||
<div style={{ marginBottom: 16 }}>
|
|
||||||
<Text strong style={{ fontSize: 14, color: '#1F2937', display: 'block' }}>
|
|
||||||
Carteira de Clientes
|
|
||||||
</Text>
|
|
||||||
<Text style={{ fontSize: 12, color: '#64748B', textTransform: 'capitalize' }}>
|
|
||||||
{mesAtual}
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div style={{ position: 'relative', width: 180, margin: '0 auto 16px' }}>
|
|
||||||
{stats.loaded && total > 0 ? (
|
|
||||||
<>
|
|
||||||
<Doughnut data={donutData} options={donutOptions} />
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'absolute',
|
|
||||||
top: '50%',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translate(-50%,-50%)',
|
|
||||||
textAlign: 'center',
|
|
||||||
pointerEvents: 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
style={{ fontSize: 22, color: '#1F2937', display: 'block', lineHeight: 1.1 }}
|
|
||||||
>
|
|
||||||
{stats.total.toLocaleString('pt-BR')}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ fontSize: 11, color: '#64748B' }}>Clientes</Text>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
style={{ height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
|
||||||
>
|
|
||||||
<Spin />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Space orientation="vertical" size={6} style={{ width: '100%', marginBottom: 16 }}>
|
|
||||||
{legendItems.map((item) => {
|
|
||||||
const pct = total > 0 ? ((item.value / total) * 100).toFixed(1) : '0.0';
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={item.label}
|
|
||||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
|
||||||
>
|
|
||||||
<Space size={6}>
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
borderRadius: 2,
|
|
||||||
background: item.color,
|
|
||||||
flexShrink: 0,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Text style={{ fontSize: 12, color: '#475569' }}>{item.label}</Text>
|
|
||||||
</Space>
|
|
||||||
<Space size={8}>
|
|
||||||
<Text strong style={{ fontSize: 12 }}>
|
|
||||||
{item.value.toLocaleString('pt-BR')}
|
|
||||||
</Text>
|
|
||||||
<Text style={{ fontSize: 11, color: '#94A3B8', width: 44, textAlign: 'right' }}>
|
|
||||||
{pct}%
|
|
||||||
</Text>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Space>
|
|
||||||
|
|
||||||
<Divider style={{ margin: '12px 0' }} />
|
|
||||||
<Button
|
|
||||||
block
|
|
||||||
onClick={onDetalhar}
|
|
||||||
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
|
|
||||||
>
|
|
||||||
Detalhar carteira
|
|
||||||
</Button>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── CustomerExpandedDetail ───────────────────────────────────────────────────
|
// ─── CustomerExpandedDetail ───────────────────────────────────────────────────
|
||||||
|
|
||||||
function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
|
function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
|
||||||
@@ -1460,22 +1312,9 @@ export function ClientsPage() {
|
|||||||
</Row>
|
</Row>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
|
|
||||||
{isMobile && (
|
|
||||||
<>
|
|
||||||
<CustomerPortfolioCard
|
|
||||||
stats={stats}
|
|
||||||
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
|
|
||||||
/>
|
|
||||||
<div style={{ marginBottom: 16 }} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* ── Área principal ────────────────────────────────────────────── */}
|
{/* ── Área principal ────────────────────────────────────────────── */}
|
||||||
<Row gutter={[20, 20]}>
|
<Row gutter={[20, 20]}>
|
||||||
{/* minWidth: 0 — Col é flex item (min-width auto). Sem isto a tabela com
|
<Col xs={24} style={{ minWidth: 0 }}>
|
||||||
scroll x estoura a coluna e engole o card vizinho. */}
|
|
||||||
<Col xs={24} lg={17} style={{ minWidth: 0 }}>
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<div style={{ textAlign: 'center', padding: 64 }}>
|
<div style={{ textAlign: 'center', padding: 64 }}>
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
@@ -1548,15 +1387,6 @@ export function ClientsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
{!isMobile && (
|
|
||||||
<Col lg={7}>
|
|
||||||
<CustomerPortfolioCard
|
|
||||||
stats={stats}
|
|
||||||
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
|
|
||||||
/>
|
|
||||||
</Col>
|
|
||||||
)}
|
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* ── Modais ────────────────────────────────────────────────────── */}
|
{/* ── Modais ────────────────────────────────────────────────────── */}
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
Descriptions,
|
Descriptions,
|
||||||
Divider,
|
Divider,
|
||||||
Form,
|
|
||||||
InputNumber,
|
|
||||||
Modal,
|
|
||||||
Space,
|
Space,
|
||||||
Spin,
|
Spin,
|
||||||
Table,
|
Table,
|
||||||
Tag,
|
Tag,
|
||||||
Timeline,
|
Timeline,
|
||||||
Typography,
|
Typography,
|
||||||
Input,
|
|
||||||
message,
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
@@ -32,7 +28,6 @@ import { apiFetch } from '../../lib/api-client';
|
|||||||
import { authStore } from '../../lib/auth-store';
|
import { authStore } from '../../lib/auth-store';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { TextArea } = Input;
|
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -144,105 +139,6 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Approve Modal ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function ApproveModal({
|
|
||||||
open,
|
|
||||||
originalDiscount,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
originalDiscount: string;
|
|
||||||
onConfirm: (descontoPerc?: number, nota?: string) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
loading: boolean;
|
|
||||||
}) {
|
|
||||||
const [disc, setDisc] = useState<number | null>(null);
|
|
||||||
const [nota, setNota] = useState('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title="Aprovar Pedido"
|
|
||||||
open={open}
|
|
||||||
onOk={() => onConfirm(disc ?? undefined, nota || undefined)}
|
|
||||||
onCancel={onCancel}
|
|
||||||
okText="Confirmar Aprovação"
|
|
||||||
cancelText="Voltar"
|
|
||||||
confirmLoading={loading}
|
|
||||||
>
|
|
||||||
<Form layout="vertical">
|
|
||||||
<Form.Item
|
|
||||||
label={`Desconto global (original: ${originalDiscount}%)`}
|
|
||||||
help="Deixe em branco para manter o desconto solicitado."
|
|
||||||
>
|
|
||||||
<InputNumber
|
|
||||||
min={0}
|
|
||||||
max={100}
|
|
||||||
step={0.5}
|
|
||||||
placeholder={originalDiscount}
|
|
||||||
value={disc}
|
|
||||||
onChange={(v) => setDisc(v)}
|
|
||||||
addonAfter="%"
|
|
||||||
style={{ width: 160 }}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
<Form.Item label="Observação (opcional)">
|
|
||||||
<TextArea
|
|
||||||
rows={2}
|
|
||||||
value={nota}
|
|
||||||
onChange={(e) => setNota(e.target.value)}
|
|
||||||
maxLength={300}
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Reject Modal ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function RejectModal({
|
|
||||||
open,
|
|
||||||
onConfirm,
|
|
||||||
onCancel,
|
|
||||||
loading,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onConfirm: (motivo: string) => void;
|
|
||||||
onCancel: () => void;
|
|
||||||
loading: boolean;
|
|
||||||
}) {
|
|
||||||
const [motivo, setMotivo] = useState('');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
title="Recusar Pedido"
|
|
||||||
open={open}
|
|
||||||
onOk={() => motivo.trim() && onConfirm(motivo.trim())}
|
|
||||||
onCancel={onCancel}
|
|
||||||
okText="Confirmar Recusa"
|
|
||||||
okButtonProps={{ danger: true, disabled: !motivo.trim() }}
|
|
||||||
cancelText="Voltar"
|
|
||||||
confirmLoading={loading}
|
|
||||||
>
|
|
||||||
<Form layout="vertical">
|
|
||||||
<Form.Item label="Motivo da recusa" required>
|
|
||||||
<TextArea
|
|
||||||
rows={3}
|
|
||||||
value={motivo}
|
|
||||||
onChange={(e) => setMotivo(e.target.value)}
|
|
||||||
maxLength={500}
|
|
||||||
showCount
|
|
||||||
placeholder="Informe o motivo para o representante..."
|
|
||||||
/>
|
|
||||||
</Form.Item>
|
|
||||||
</Form>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── OrderDetailPage ──────────────────────────────────────────────────────────
|
// ─── OrderDetailPage ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function OrderDetailPage() {
|
export function OrderDetailPage() {
|
||||||
@@ -254,7 +150,6 @@ export function OrderDetailPage() {
|
|||||||
|
|
||||||
const role = getRoleFromToken();
|
const role = getRoleFromToken();
|
||||||
const isErp = order?.fonte === 'erp';
|
const isErp = order?.fonte === 'erp';
|
||||||
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
|
|
||||||
const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
|
const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
|
||||||
const canShare =
|
const canShare =
|
||||||
role === 'rep' &&
|
role === 'rep' &&
|
||||||
@@ -262,39 +157,9 @@ export function OrderDetailPage() {
|
|||||||
typeof navigator !== 'undefined' &&
|
typeof navigator !== 'undefined' &&
|
||||||
!!navigator.share;
|
!!navigator.share;
|
||||||
|
|
||||||
const [approveOpen, setApproveOpen] = useState(false);
|
|
||||||
const [rejectOpen, setRejectOpen] = useState(false);
|
|
||||||
const [chamadoOpen, setChamadoOpen] = useState(false);
|
const [chamadoOpen, setChamadoOpen] = useState(false);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
|
||||||
const approveMutation = useMutation({
|
|
||||||
mutationFn: ({ descontoPerc, nota }: { descontoPerc?: number; nota?: string }) =>
|
|
||||||
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { descontoPerc, nota } }),
|
|
||||||
onSuccess: () => {
|
|
||||||
setApproveOpen(false);
|
|
||||||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
|
||||||
},
|
|
||||||
onError: (e: unknown) => {
|
|
||||||
setApproveOpen(false);
|
|
||||||
setActionError(e instanceof Error ? e.message : 'Erro ao aprovar');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const rejectMutation = useMutation({
|
|
||||||
mutationFn: (motivo: string) =>
|
|
||||||
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { motivo } }),
|
|
||||||
onSuccess: () => {
|
|
||||||
setRejectOpen(false);
|
|
||||||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
|
||||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
|
||||||
},
|
|
||||||
onError: (e: unknown) => {
|
|
||||||
setRejectOpen(false);
|
|
||||||
setActionError(e instanceof Error ? e.message : 'Erro ao recusar');
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const transmitMutation = useMutation({
|
const transmitMutation = useMutation({
|
||||||
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
|
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -309,11 +174,6 @@ export function OrderDetailPage() {
|
|||||||
if (error || !order)
|
if (error || !order)
|
||||||
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
||||||
|
|
||||||
const timeWaiting =
|
|
||||||
order.situa === 1
|
|
||||||
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
// Orçamento: tela mais larga para consulta/revisão com o cliente.
|
// Orçamento: tela mais larga para consulta/revisão com o cliente.
|
||||||
const isOrcamento = order.situa === 0;
|
const isOrcamento = order.situa === 0;
|
||||||
|
|
||||||
@@ -339,9 +199,6 @@ export function OrderDetailPage() {
|
|||||||
</Tag>
|
</Tag>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
{timeWaiting !== null && timeWaiting > 2 && (
|
|
||||||
<Tag color="red">Urgente — {timeWaiting}h aguardando</Tag>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
icon={<FilePdfOutlined />}
|
icon={<FilePdfOutlined />}
|
||||||
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
|
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
|
||||||
@@ -361,16 +218,6 @@ export function OrderDetailPage() {
|
|||||||
Transmitir pedido
|
Transmitir pedido
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{canAct && (
|
|
||||||
<Space>
|
|
||||||
<Button type="primary" onClick={() => setApproveOpen(true)}>
|
|
||||||
Aprovar
|
|
||||||
</Button>
|
|
||||||
<Button danger onClick={() => setRejectOpen(true)}>
|
|
||||||
Recusar
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
{canShare && (
|
{canShare && (
|
||||||
<Button
|
<Button
|
||||||
icon={<FontAwesomeIcon icon={faShareNodes} />}
|
icon={<FontAwesomeIcon icon={faShareNodes} />}
|
||||||
@@ -420,11 +267,6 @@ export function OrderDetailPage() {
|
|||||||
<Descriptions.Item label="Data">
|
<Descriptions.Item label="Data">
|
||||||
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
|
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
|
||||||
</Descriptions.Item>
|
</Descriptions.Item>
|
||||||
{order.aprovadoEm && (
|
|
||||||
<Descriptions.Item label="Aprovado em">
|
|
||||||
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} — cód. {order.aprovadoPor}
|
|
||||||
</Descriptions.Item>
|
|
||||||
)}
|
|
||||||
{order.formaPagamento && (
|
{order.formaPagamento && (
|
||||||
<Descriptions.Item label="Cond. Pagamento" span={2}>
|
<Descriptions.Item label="Cond. Pagamento" span={2}>
|
||||||
{order.formaPagamento}
|
{order.formaPagamento}
|
||||||
@@ -511,19 +353,6 @@ export function OrderDetailPage() {
|
|||||||
<Divider titlePlacement="left">Histórico do Pedido</Divider>
|
<Divider titlePlacement="left">Histórico do Pedido</Divider>
|
||||||
<HistoryTimeline history={order.historico} />
|
<HistoryTimeline history={order.historico} />
|
||||||
|
|
||||||
<ApproveModal
|
|
||||||
open={approveOpen}
|
|
||||||
originalDiscount={order.descontoPerc}
|
|
||||||
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })}
|
|
||||||
onCancel={() => setApproveOpen(false)}
|
|
||||||
loading={approveMutation.isPending}
|
|
||||||
/>
|
|
||||||
<RejectModal
|
|
||||||
open={rejectOpen}
|
|
||||||
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
|
|
||||||
onCancel={() => setRejectOpen(false)}
|
|
||||||
loading={rejectMutation.isPending}
|
|
||||||
/>
|
|
||||||
{order && (
|
{order && (
|
||||||
<AbrirChamadoModal
|
<AbrirChamadoModal
|
||||||
open={chamadoOpen}
|
open={chamadoOpen}
|
||||||
|
|||||||
@@ -142,13 +142,11 @@ const STATUS: Record<number, { label: string; color: string; rowBg: string; tagC
|
|||||||
|
|
||||||
function useOrderStats() {
|
function useOrderStats() {
|
||||||
const all = useOrderList({ limit: 1 });
|
const all = useOrderList({ limit: 1 });
|
||||||
const pendentes = useOrderList({ limit: 1, situa: 1 });
|
const transmitidos = useOrderList({ limit: 1, situa: 2 });
|
||||||
const aprovados = useOrderList({ limit: 1, situa: 2 });
|
|
||||||
const faturados = useOrderList({ limit: 1, situa: 4 });
|
const faturados = useOrderList({ limit: 1, situa: 4 });
|
||||||
return {
|
return {
|
||||||
total: all.data?.total ?? 0,
|
total: all.data?.total ?? 0,
|
||||||
pendentes: pendentes.data?.total ?? 0,
|
transmitidos: transmitidos.data?.total ?? 0,
|
||||||
aprovados: aprovados.data?.total ?? 0,
|
|
||||||
faturados: faturados.data?.total ?? 0,
|
faturados: faturados.data?.total ?? 0,
|
||||||
loaded: !!all.data,
|
loaded: !!all.data,
|
||||||
};
|
};
|
||||||
@@ -167,19 +165,18 @@ function OrdersMetrics({ stats }: { stats: OrderStats }) {
|
|||||||
color: '#003B8E',
|
color: '#003B8E',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Ag. Aprovação',
|
label: 'Transmitidos',
|
||||||
value: stats.pendentes,
|
value: stats.transmitidos,
|
||||||
icon: <ClockCircleOutlined />,
|
icon: <CheckCircleOutlined />,
|
||||||
color: '#d46b08',
|
color: '#389e0d',
|
||||||
},
|
},
|
||||||
{ label: 'Aprovados', value: stats.aprovados, icon: <CheckCircleOutlined />, color: '#389e0d' },
|
|
||||||
{ label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' },
|
{ label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||||
{metrics.map((m) => (
|
{metrics.map((m) => (
|
||||||
<Col key={m.label} xs={12} sm={6}>
|
<Col key={m.label} xs={12} sm={8}>
|
||||||
<Card
|
<Card
|
||||||
style={{
|
style={{
|
||||||
borderRadius: 10,
|
borderRadius: 10,
|
||||||
@@ -865,7 +862,7 @@ export function OrdersPage() {
|
|||||||
Pedidos
|
Pedidos
|
||||||
</Title>
|
</Title>
|
||||||
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
|
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
|
||||||
Acompanhe seus pedidos, status de aprovação e histórico comercial.
|
Acompanhe seus pedidos, status de transmissão e histórico comercial.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
@@ -949,7 +946,6 @@ export function OrdersPage() {
|
|||||||
}}
|
}}
|
||||||
options={[
|
options={[
|
||||||
{ value: 0, label: 'Orçamento' },
|
{ value: 0, label: 'Orçamento' },
|
||||||
{ value: 1, label: 'Ag. Aprovação' },
|
|
||||||
{ value: 2, label: 'Transmitido' },
|
{ value: 2, label: 'Transmitido' },
|
||||||
{ value: 3, label: 'Cancelado' },
|
{ value: 3, label: 'Cancelado' },
|
||||||
{ value: 4, label: 'Faturado' },
|
{ value: 4, label: 'Faturado' },
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
@@ -17,10 +16,10 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
import { AlertOutlined, BarChartOutlined, RiseOutlined, TeamOutlined } from '@ant-design/icons';
|
import { AlertOutlined, BarChartOutlined, RiseOutlined } from '@ant-design/icons';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import type { CarteiraCliente, AbcProduto } from '@sar/api-interface';
|
import type { AbcProduto } from '@sar/api-interface';
|
||||||
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports';
|
import { useReportMeta, useReportAbc } from '../../lib/queries/reports';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { Option } = Select;
|
const { Option } = Select;
|
||||||
@@ -29,11 +28,6 @@ function fmt(v: number | string): string {
|
|||||||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtDate(v: string | null | undefined): string {
|
|
||||||
if (!v) return '—';
|
|
||||||
return new Date(v).toLocaleDateString('pt-BR');
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
|
// ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
|
||||||
|
|
||||||
function TabMeta() {
|
function TabMeta() {
|
||||||
@@ -196,177 +190,7 @@ function TabMeta() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Aba 2: Carteira de Clientes ─────────────────────────────────────────────
|
// ─── Aba 2: Curva ABC ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
|
|
||||||
|
|
||||||
function TabCarteira() {
|
|
||||||
const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
|
|
||||||
const { data, isLoading, isError, error } = useReportCarteira();
|
|
||||||
|
|
||||||
const clientes = data?.data ?? [];
|
|
||||||
|
|
||||||
const filtered = clientes.filter((c: CarteiraCliente) => {
|
|
||||||
if (filtro === 'inativos30') return c.diasSemPedido != null && c.diasSemPedido >= 30;
|
|
||||||
if (filtro === 'inativos60') return c.diasSemPedido != null && c.diasSemPedido >= 60;
|
|
||||||
if (filtro === 'semPedido') return c.totalPedidos === 0;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
function statusBadge(c: CarteiraCliente) {
|
|
||||||
if (c.totalPedidos === 0)
|
|
||||||
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedidos</Text>} />;
|
|
||||||
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
color="#ef4444"
|
|
||||||
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
|
|
||||||
return (
|
|
||||||
<Badge
|
|
||||||
color="#f59e0b"
|
|
||||||
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Inativo 30d+</Text>}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const columns: TableColumnsType<CarteiraCliente> = [
|
|
||||||
{
|
|
||||||
title: 'Cliente',
|
|
||||||
key: 'cliente',
|
|
||||||
render: (_: unknown, c: CarteiraCliente) => (
|
|
||||||
<Space orientation="vertical" size={0}>
|
|
||||||
<Text strong style={{ fontSize: 13 }}>
|
|
||||||
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
|
|
||||||
</Text>
|
|
||||||
{c.razao && c.nome && (
|
|
||||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
|
||||||
{c.nome}
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Status',
|
|
||||||
key: 'status',
|
|
||||||
width: 140,
|
|
||||||
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Último Pedido',
|
|
||||||
dataIndex: 'ultimoPedido',
|
|
||||||
width: 130,
|
|
||||||
render: (v: string | null) => (
|
|
||||||
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Dias Sem Pedido',
|
|
||||||
dataIndex: 'diasSemPedido',
|
|
||||||
width: 140,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (v: number | null) =>
|
|
||||||
v != null ? (
|
|
||||||
<Text
|
|
||||||
strong
|
|
||||||
className="tabular-nums"
|
|
||||||
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
|
|
||||||
>
|
|
||||||
{v}d
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text type="secondary">—</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Pedidos',
|
|
||||||
dataIndex: 'totalPedidos',
|
|
||||||
width: 90,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Ticket Médio',
|
|
||||||
dataIndex: 'ticketMedio',
|
|
||||||
width: 130,
|
|
||||||
align: 'right' as const,
|
|
||||||
render: (v: string) => (
|
|
||||||
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
|
||||||
{fmt(v)}
|
|
||||||
</Text>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
if (isError) {
|
|
||||||
return (
|
|
||||||
<Alert
|
|
||||||
type="error"
|
|
||||||
showIcon
|
|
||||||
message="Erro ao carregar carteira de clientes"
|
|
||||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{data && (
|
|
||||||
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
|
||||||
{[
|
|
||||||
{ key: 'todos', label: 'Total', value: data.total, color: '#003B8E' },
|
|
||||||
{ key: 'inativos30', label: 'Inativos 30d+', value: data.inativos30, color: '#f59e0b' },
|
|
||||||
{ key: 'inativos60', label: 'Inativos 60d+', value: data.inativos60, color: '#ef4444' },
|
|
||||||
{ key: 'semPedido', label: 'Sem Pedido', value: data.semPedido, color: '#94A3B8' },
|
|
||||||
].map((item) => (
|
|
||||||
<Col key={item.key} xs={12} md={6}>
|
|
||||||
<Card
|
|
||||||
hoverable
|
|
||||||
onClick={() => setFiltro(item.key as FiltroCarteira)}
|
|
||||||
style={{
|
|
||||||
borderRadius: 8,
|
|
||||||
border: filtro === item.key ? `2px solid ${item.color}` : '1px solid #EBF0F5',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
styles={{ body: { padding: '12px 16px' } }}
|
|
||||||
>
|
|
||||||
<Statistic
|
|
||||||
title={item.label}
|
|
||||||
value={item.value}
|
|
||||||
styles={{ content: { color: item.color, fontSize: 24 } }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
))}
|
|
||||||
</Row>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card
|
|
||||||
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
|
||||||
styles={{ body: { padding: 0 } }}
|
|
||||||
>
|
|
||||||
<Table<CarteiraCliente>
|
|
||||||
rowKey="idCliente"
|
|
||||||
columns={columns}
|
|
||||||
dataSource={filtered}
|
|
||||||
loading={isLoading}
|
|
||||||
size="middle"
|
|
||||||
pagination={{ pageSize: 15, showSizeChanger: false }}
|
|
||||||
scroll={{ x: 700 }}
|
|
||||||
style={{ borderRadius: 10, overflow: 'hidden' }}
|
|
||||||
/>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Aba 3: Curva ABC ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
|
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
|
||||||
|
|
||||||
@@ -550,16 +374,6 @@ export function RelatoriosPage() {
|
|||||||
),
|
),
|
||||||
children: <TabMeta />,
|
children: <TabMeta />,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: 'carteira',
|
|
||||||
label: (
|
|
||||||
<Space>
|
|
||||||
<TeamOutlined />
|
|
||||||
Carteira de Clientes
|
|
||||||
</Space>
|
|
||||||
),
|
|
||||||
children: <TabCarteira />,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: 'abc',
|
key: 'abc',
|
||||||
label: (
|
label: (
|
||||||
@@ -579,7 +393,7 @@ export function RelatoriosPage() {
|
|||||||
Relatórios
|
Relatórios
|
||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary" style={{ fontSize: 14 }}>
|
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||||
Análise de desempenho, carteira e mix de produtos.
|
Análise de desempenho e mix de produtos.
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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 type { TableColumnsType } from 'antd';
|
||||||
|
import { WhatsAppOutlined } from '@ant-design/icons';
|
||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import {
|
import {
|
||||||
faCheckCircle,
|
faAddressCard,
|
||||||
faCircleExclamation,
|
faCircleExclamation,
|
||||||
faClipboardList,
|
faClipboardList,
|
||||||
|
faRankingStar,
|
||||||
} from '@fortawesome/free-solid-svg-icons';
|
} from '@fortawesome/free-solid-svg-icons';
|
||||||
import { Link } from '@tanstack/react-router';
|
import { Link } from '@tanstack/react-router';
|
||||||
import type { PedidoSummary } from '@sar/api-interface';
|
import type {
|
||||||
import { useSupervisorDashboard } from '../../lib/queries/dashboard';
|
ClienteNaoPositivado,
|
||||||
|
PositivacaoRep,
|
||||||
|
RankingRep,
|
||||||
|
RepInativosSummary,
|
||||||
|
} from '@sar/api-interface';
|
||||||
|
import { useRepInativos, useSupervisorDashboard } from '../../lib/queries/dashboard';
|
||||||
import { useCurrentUser } from '../../lib/queries/auth';
|
import { useCurrentUser } from '../../lib/queries/auth';
|
||||||
|
import {
|
||||||
|
positivacaoRepColumns,
|
||||||
|
rankingRepColumns,
|
||||||
|
} from '../../components/dashboard/rep-performance-columns';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -17,10 +43,6 @@ function fmt(v: number): string {
|
|||||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||||
}
|
}
|
||||||
|
|
||||||
function hoursWaiting(createdAt: string): number {
|
|
||||||
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function delta(current: number, previous: number): { label: string; positive: boolean } | null {
|
function delta(current: number, previous: number): { label: string; positive: boolean } | null {
|
||||||
if (previous === 0) return null;
|
if (previous === 0) return null;
|
||||||
const pct = Math.round(((current - previous) / previous) * 100);
|
const pct = Math.round(((current - previous) / previous) * 100);
|
||||||
@@ -38,62 +60,116 @@ function today(): string {
|
|||||||
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
|
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const queueColumns: TableColumnsType<PedidoSummary> = [
|
function mesLabel(): string {
|
||||||
{
|
return new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||||
title: 'Pedido',
|
}
|
||||||
dataIndex: 'numPedSar',
|
|
||||||
width: 120,
|
function waLink(whatsapp: string): string {
|
||||||
render: (num: string, row: PedidoSummary) => (
|
const digits = whatsapp.replace(/\D/g, '');
|
||||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
const number = digits.startsWith('55') ? digits : `55${digits}`;
|
||||||
{num}
|
return `https://wa.me/${number}`;
|
||||||
</Link>
|
}
|
||||||
),
|
|
||||||
},
|
// ─── Modal de clientes inativos de um rep ─────────────────────────────────────
|
||||||
{
|
|
||||||
title: 'Representante',
|
const inativosColumns: TableColumnsType<ClienteNaoPositivado> = [
|
||||||
key: 'rep',
|
|
||||||
width: 150,
|
|
||||||
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: 'Cliente',
|
title: 'Cliente',
|
||||||
key: 'cliente',
|
key: 'cliente',
|
||||||
width: 180,
|
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||||
render: (_: unknown, row: PedidoSummary) =>
|
<Space orientation="vertical" size={0}>
|
||||||
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`,
|
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
|
||||||
|
{c.razao ?? c.nome}
|
||||||
|
</Link>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
cód. {c.idCliente}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total',
|
title: 'Última compra',
|
||||||
dataIndex: 'total',
|
dataIndex: 'ultimoPedido',
|
||||||
width: 130,
|
width: 140,
|
||||||
align: 'right',
|
render: (v: string | null) =>
|
||||||
render: (v: string) => fmt(Number(v)),
|
v ? new Date(`${v}T00:00:00`).toLocaleDateString('pt-BR') : <Tag>Nunca comprou</Tag>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Aguardando',
|
title: 'Sem compra',
|
||||||
dataIndex: 'createdAt',
|
dataIndex: 'diasSemPedido',
|
||||||
width: 120,
|
width: 120,
|
||||||
render: (v: string) => {
|
render: (d: number, c: ClienteNaoPositivado) =>
|
||||||
const h = hoursWaiting(v);
|
c.comprouAntes ? (
|
||||||
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>;
|
<Tag color={d > 90 ? 'red' : 'orange'} className="tabular-nums">
|
||||||
},
|
{d} dias
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
'—'
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
width: 90,
|
key: 'acoes',
|
||||||
render: (_: unknown, row: PedidoSummary) => (
|
width: 120,
|
||||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||||
<Tag color="blue" style={{ cursor: 'pointer' }}>
|
<Space>
|
||||||
Analisar
|
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
|
||||||
</Tag>
|
<Button size="small">Ficha</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{c.whatsapp && (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
style={{ borderRadius: 6, background: '#25d366', borderColor: '#25d366' }}
|
||||||
|
icon={<WhatsAppOutlined />}
|
||||||
|
href={waLink(c.whatsapp)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function InativosRepModal({
|
||||||
|
rep,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
rep: RepInativosSummary | null;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const { data, isLoading } = useRepInativos(rep?.codVendedor ?? null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open={rep !== null}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={900}
|
||||||
|
centered
|
||||||
|
title={rep ? `Clientes Inativos — ${rep.nomeVendedor ?? `Rep cód. ${rep.codVendedor}`}` : ''}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Spin style={{ display: 'block', margin: '32px auto' }} />
|
||||||
|
) : (
|
||||||
|
<Table<ClienteNaoPositivado>
|
||||||
|
rowKey="idCliente"
|
||||||
|
columns={inativosColumns}
|
||||||
|
dataSource={data?.clientes ?? []}
|
||||||
|
size="small"
|
||||||
|
pagination={{ pageSize: 10, hideOnSinglePage: true }}
|
||||||
|
locale={{ emptyText: 'Nenhum cliente inativo.' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SupervisorPainel() {
|
export function SupervisorPainel() {
|
||||||
const { data, isLoading } = useSupervisorDashboard();
|
const { data, isLoading } = useSupervisorDashboard();
|
||||||
const { data: user } = useCurrentUser();
|
const { data: user } = useCurrentUser();
|
||||||
|
const [inativosRep, setInativosRep] = useState<RepInativosSummary | null>(null);
|
||||||
|
|
||||||
if (isLoading || !data) {
|
if (isLoading || !data) {
|
||||||
return (
|
return (
|
||||||
@@ -110,8 +186,7 @@ export function SupervisorPainel() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data;
|
const { pedidosDia, inativosPorRep, equipeMes, rankingReps, positivacaoReps, syncedAt } = data;
|
||||||
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.createdAt) > 2).length;
|
|
||||||
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
|
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
|
||||||
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
|
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
|
||||||
|
|
||||||
@@ -124,49 +199,12 @@ export function SupervisorPainel() {
|
|||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
{today()}
|
{today()}
|
||||||
{urgentCount > 0 && (
|
|
||||||
<>
|
|
||||||
{' '}
|
|
||||||
·{' '}
|
|
||||||
<span style={{ color: '#cf1322' }}>
|
|
||||||
{urgentCount} aprovação{urgentCount > 1 ? 'ões' : ''} urgente
|
|
||||||
{urgentCount > 1 ? 's' : ''}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
{/* KPIs */}
|
{/* KPIs */}
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={12}>
|
||||||
<Card>
|
|
||||||
<Space orientation="vertical" size={4}>
|
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
|
||||||
APROVAÇÕES PENDENTES
|
|
||||||
</Text>
|
|
||||||
<Flex align="center" gap={8}>
|
|
||||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
|
||||||
{approvalQueue.length}
|
|
||||||
</Title>
|
|
||||||
{urgentCount > 0 && (
|
|
||||||
<Badge
|
|
||||||
count={urgentCount}
|
|
||||||
style={{ backgroundColor: '#cf1322' }}
|
|
||||||
title={`${urgentCount} urgente(s) — mais de 2h`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Flex>
|
|
||||||
<Link to="/aprovacoes">
|
|
||||||
<Text style={{ fontSize: 'var(--text-sm)', color: 'var(--jcs-blue)' }}>
|
|
||||||
Ver fila completa →
|
|
||||||
</Text>
|
|
||||||
</Link>
|
|
||||||
</Space>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} md={8}>
|
|
||||||
<Card>
|
<Card>
|
||||||
<Space orientation="vertical" size={4}>
|
<Space orientation="vertical" size={4}>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
@@ -189,7 +227,7 @@ export function SupervisorPainel() {
|
|||||||
</Card>
|
</Card>
|
||||||
</Col>
|
</Col>
|
||||||
|
|
||||||
<Col xs={24} md={8}>
|
<Col xs={24} md={12}>
|
||||||
<Card>
|
<Card>
|
||||||
<Space orientation="vertical" size={4}>
|
<Space orientation="vertical" size={4}>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
@@ -213,38 +251,9 @@ export function SupervisorPainel() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
{/* Fila de aprovações + Inativos por rep */}
|
{/* Inativos por rep + Pedidos de hoje */}
|
||||||
<Row gutter={[24, 24]}>
|
<Row gutter={[24, 24]}>
|
||||||
<Col xs={24} lg={16}>
|
<Col xs={24} lg={12}>
|
||||||
<Card
|
|
||||||
title={
|
|
||||||
<Space>
|
|
||||||
<FontAwesomeIcon icon={faCheckCircle} style={{ color: 'var(--jcs-blue)' }} />
|
|
||||||
Fila de Aprovações
|
|
||||||
{approvalQueue.length > 0 && (
|
|
||||||
<Badge
|
|
||||||
count={approvalQueue.length}
|
|
||||||
style={{ backgroundColor: 'var(--jcs-blue)' }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Space>
|
|
||||||
}
|
|
||||||
extra={<Link to="/aprovacoes">Ver todas</Link>}
|
|
||||||
>
|
|
||||||
<Table<PedidoSummary>
|
|
||||||
rowKey="id"
|
|
||||||
columns={queueColumns}
|
|
||||||
dataSource={approvalQueue.slice(0, 8)}
|
|
||||||
pagination={false}
|
|
||||||
size="small"
|
|
||||||
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
|
|
||||||
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
|
|
||||||
/>
|
|
||||||
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
|
|
||||||
</Card>
|
|
||||||
</Col>
|
|
||||||
|
|
||||||
<Col xs={24} lg={8}>
|
|
||||||
<Card
|
<Card
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
@@ -281,20 +290,26 @@ export function SupervisorPainel() {
|
|||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
<Tag
|
<Space>
|
||||||
color={r.inativosCount >= 3 ? 'orange' : 'default'}
|
<Tag
|
||||||
className="tabular-nums"
|
color={r.inativosCount >= 3 ? 'orange' : 'default'}
|
||||||
>
|
className="tabular-nums"
|
||||||
{r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''}
|
>
|
||||||
</Tag>
|
{r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''}
|
||||||
|
</Tag>
|
||||||
|
<Button size="small" onClick={() => setInativosRep(r)}>
|
||||||
|
Detalhar
|
||||||
|
</Button>
|
||||||
|
</Space>
|
||||||
</Flex>
|
</Flex>
|
||||||
))}
|
))}
|
||||||
</Flex>
|
</Flex>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} lg={12}>
|
||||||
<Card
|
<Card
|
||||||
style={{ marginTop: 24 }}
|
|
||||||
title={
|
title={
|
||||||
<Space>
|
<Space>
|
||||||
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
|
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
@@ -328,6 +343,130 @@ export function SupervisorPainel() {
|
|||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
|
{/* Métricas da equipe no mês */}
|
||||||
|
<Row gutter={[24, 24]}>
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Card>
|
||||||
|
<Space orientation="vertical" size={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
FATURAMENTO NO MÊS
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{fmt(equipeMes.faturamento)}
|
||||||
|
</Title>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 'var(--text-sm)' }}
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{equipeMes.pedidos} pedido{equipeMes.pedidos !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Card>
|
||||||
|
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
META DA EQUIPE
|
||||||
|
</Text>
|
||||||
|
{equipeMes.metaTotal > 0 ? (
|
||||||
|
<>
|
||||||
|
<Flex align="baseline" gap={8}>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{equipeMes.pctMeta}%
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
de {fmt(equipeMes.metaTotal)}
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
<Progress
|
||||||
|
percent={Math.min(equipeMes.pctMeta, 100)}
|
||||||
|
size="small"
|
||||||
|
strokeColor={
|
||||||
|
equipeMes.pctMeta >= 100
|
||||||
|
? 'var(--green)'
|
||||||
|
: equipeMes.pctMeta >= 70
|
||||||
|
? 'var(--jcs-blue)'
|
||||||
|
: '#faad14'
|
||||||
|
}
|
||||||
|
showInfo={false}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
type="secondary"
|
||||||
|
style={{ fontSize: 'var(--text-sm)' }}
|
||||||
|
className="tabular-nums"
|
||||||
|
>
|
||||||
|
{equipeMes.metaTotal > equipeMes.faturamento
|
||||||
|
? `Faltam ${fmt(equipeMes.metaTotal - equipeMes.faturamento)}`
|
||||||
|
: 'Meta atingida 🎉'}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">Sem meta cadastrada para o mês.</Text>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
|
||||||
|
<Col xs={24} md={8}>
|
||||||
|
<Card>
|
||||||
|
<Space orientation="vertical" size={4}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
TICKET MÉDIO
|
||||||
|
</Text>
|
||||||
|
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||||
|
{fmt(equipeMes.ticketMedio)}
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
por pedido no mês
|
||||||
|
</Text>
|
||||||
|
</Space>
|
||||||
|
</Card>
|
||||||
|
</Col>
|
||||||
|
</Row>
|
||||||
|
|
||||||
|
{/* Ranking da equipe */}
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<FontAwesomeIcon icon={faRankingStar} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Ranking da Equipe — {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 }}>
|
<Flex justify="space-between" style={{ paddingTop: 8 }}>
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
SAR · Força de Vendas · Powered by JCS Sistemas
|
SAR · Força de Vendas · Powered by JCS Sistemas
|
||||||
@@ -336,6 +475,8 @@ export function SupervisorPainel() {
|
|||||||
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} · atualiza a cada 30s
|
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} · atualiza a cada 30s
|
||||||
</Text>
|
</Text>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|
||||||
|
<InativosRepModal rep={inativosRep} onClose={() => setInativosRep(null)} />
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
import {
|
||||||
faChartLine,
|
faChartLine,
|
||||||
faClipboardList,
|
faClipboardList,
|
||||||
faClipboardCheck,
|
|
||||||
faUsers,
|
faUsers,
|
||||||
faAddressCard,
|
faAddressCard,
|
||||||
faBoxesStacked,
|
faBoxesStacked,
|
||||||
@@ -40,7 +39,6 @@ const REP_ITEMS: NavItem[] = [
|
|||||||
|
|
||||||
const SUPERVISOR_ITEMS: NavItem[] = [
|
const SUPERVISOR_ITEMS: NavItem[] = [
|
||||||
{ key: '/', icon: faChartLine, label: 'Painel' },
|
{ key: '/', icon: faChartLine, label: 'Painel' },
|
||||||
{ key: '/aprovacoes', icon: faClipboardCheck, label: 'Aprovações' },
|
|
||||||
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
|
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
|
||||||
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
||||||
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
||||||
|
|||||||
@@ -61,11 +61,6 @@ const REP_ITEMS: ItemType[] = [
|
|||||||
|
|
||||||
const SUPERVISOR_ITEMS: ItemType[] = [
|
const SUPERVISOR_ITEMS: ItemType[] = [
|
||||||
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
|
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
|
||||||
{
|
|
||||||
key: '/aprovacoes',
|
|
||||||
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
|
|
||||||
label: 'Aprovações',
|
|
||||||
},
|
|
||||||
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
|
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
|
||||||
{
|
{
|
||||||
key: '/pedidos',
|
key: '/pedidos',
|
||||||
@@ -127,10 +122,12 @@ export function Sidebar() {
|
|||||||
const role = getRole();
|
const role = getRole();
|
||||||
const items = getItems(role);
|
const items = getItems(role);
|
||||||
|
|
||||||
const selectedKey = items.find((item) => {
|
const selectedKey = items.reduce<string | undefined>((best, item) => {
|
||||||
const key = (item as { key?: string }).key ?? '';
|
const key = (item as { key?: string }).key ?? '';
|
||||||
return key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
|
const matches = key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
|
||||||
})?.key as string | undefined;
|
if (!matches) return best;
|
||||||
|
return !best || key.length > best.length ? key : best;
|
||||||
|
}, undefined);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
RepDashboardSchema,
|
RepDashboardSchema,
|
||||||
|
RepInativosDetailSchema,
|
||||||
SupervisorDashboardSchema,
|
SupervisorDashboardSchema,
|
||||||
type RepDashboard,
|
type RepDashboard,
|
||||||
|
type RepInativosDetail,
|
||||||
type SupervisorDashboard,
|
type SupervisorDashboard,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { apiFetch } from '../api-client';
|
import { apiFetch } from '../api-client';
|
||||||
@@ -29,3 +31,16 @@ export function useSupervisorDashboard() {
|
|||||||
refetchInterval: 30 * 1000,
|
refetchInterval: 30 * 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ReportMetaResponseSchema,
|
ReportMetaResponseSchema,
|
||||||
ReportCarteiraResponseSchema,
|
|
||||||
ReportAbcResponseSchema,
|
ReportAbcResponseSchema,
|
||||||
type ReportMetaQuery,
|
type ReportMetaQuery,
|
||||||
type ReportAbcQuery,
|
type ReportAbcQuery,
|
||||||
type ReportMetaResponse,
|
type ReportMetaResponse,
|
||||||
type ReportCarteiraResponse,
|
|
||||||
type ReportAbcResponse,
|
type ReportAbcResponse,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { apiFetch } from '../api-client';
|
import { apiFetch } from '../api-client';
|
||||||
@@ -19,13 +17,6 @@ export function useReportMeta(params: ReportMetaQuery) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useReportCarteira() {
|
|
||||||
return useQuery<ReportCarteiraResponse>({
|
|
||||||
queryKey: ['reports', 'carteira'],
|
|
||||||
queryFn: async () => ReportCarteiraResponseSchema.parse(await apiFetch('/reports/carteira')),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useReportAbc(params: ReportAbcQuery) {
|
export function useReportAbc(params: ReportAbcQuery) {
|
||||||
const qs = new URLSearchParams();
|
const qs = new URLSearchParams();
|
||||||
if (params.mes != null) qs.set('mes', String(params.mes));
|
if (params.mes != null) qs.set('mes', String(params.mes));
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
|||||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||||
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
||||||
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
||||||
import { CarteirePage } from '../cockpits/rep/CarteirePage';
|
|
||||||
import { FunilPage } from '../cockpits/rep/FunilPage';
|
import { FunilPage } from '../cockpits/rep/FunilPage';
|
||||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
|
||||||
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
||||||
import { GerPainel } from '../cockpits/ger/GerPainel';
|
import { GerPainel } from '../cockpits/ger/GerPainel';
|
||||||
import { EquipePage } from '../cockpits/ger/EquipePage';
|
import { EquipePage } from '../cockpits/ger/EquipePage';
|
||||||
@@ -160,24 +158,12 @@ const relatoriosRoute = createRoute({
|
|||||||
component: RelatoriosPage,
|
component: RelatoriosPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const carteiraRoute = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: '/clientes/carteira',
|
|
||||||
component: CarteirePage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const funilRoute = createRoute({
|
const funilRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/funil',
|
path: '/funil',
|
||||||
component: FunilPage,
|
component: FunilPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
const aprovacoes = createRoute({
|
|
||||||
getParentRoute: () => rootRoute,
|
|
||||||
path: '/aprovacoes',
|
|
||||||
component: ApprovalQueuePage,
|
|
||||||
});
|
|
||||||
|
|
||||||
const gerPainelRoute = createRoute({
|
const gerPainelRoute = createRoute({
|
||||||
getParentRoute: () => rootRoute,
|
getParentRoute: () => rootRoute,
|
||||||
path: '/ger',
|
path: '/ger',
|
||||||
@@ -227,9 +213,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
catalogoRoute,
|
catalogoRoute,
|
||||||
pedidosErpRoute,
|
pedidosErpRoute,
|
||||||
relatoriosRoute,
|
relatoriosRoute,
|
||||||
carteiraRoute,
|
|
||||||
funilRoute,
|
funilRoute,
|
||||||
aprovacoes,
|
|
||||||
gerPainelRoute,
|
gerPainelRoute,
|
||||||
equipeRoute,
|
equipeRoute,
|
||||||
politicasRoute,
|
politicasRoute,
|
||||||
|
|||||||
@@ -72,18 +72,13 @@ export const RepInativosSummarySchema = z.object({
|
|||||||
});
|
});
|
||||||
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
|
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
|
||||||
|
|
||||||
export const SupervisorDashboardSchema = z.object({
|
// Detalhe dos clientes inativos (>30 dias sem compra) de um representante
|
||||||
approvalQueue: z.array(PedidoSummarySchema),
|
export const RepInativosDetailSchema = z.object({
|
||||||
pedidosDia: z.object({
|
codVendedor: z.number().int(),
|
||||||
count: z.number().int(),
|
nomeVendedor: z.string().nullable(),
|
||||||
total: z.number(),
|
clientes: z.array(ClienteNaoPositivadoSchema),
|
||||||
countSemanaAnterior: z.number().int(),
|
|
||||||
totalSemanaAnterior: z.number(),
|
|
||||||
}),
|
|
||||||
inativosPorRep: z.array(RepInativosSummarySchema),
|
|
||||||
syncedAt: z.iso.datetime(),
|
|
||||||
});
|
});
|
||||||
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
|
export type RepInativosDetail = z.infer<typeof RepInativosDetailSchema>;
|
||||||
|
|
||||||
// novosClientes: clientes da carteira cuja PRIMEIRA compra (historico todo)
|
// novosClientes: clientes da carteira cuja PRIMEIRA compra (historico todo)
|
||||||
// caiu no periodo selecionado
|
// caiu no periodo selecionado
|
||||||
@@ -107,6 +102,30 @@ export const RankingRepSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type RankingRep = z.infer<typeof RankingRepSchema>;
|
export type RankingRep = z.infer<typeof RankingRepSchema>;
|
||||||
|
|
||||||
|
export const SupervisorDashboardSchema = z.object({
|
||||||
|
pedidosDia: z.object({
|
||||||
|
count: z.number().int(),
|
||||||
|
total: z.number(),
|
||||||
|
countSemanaAnterior: z.number().int(),
|
||||||
|
totalSemanaAnterior: z.number(),
|
||||||
|
}),
|
||||||
|
inativosPorRep: z.array(RepInativosSummarySchema),
|
||||||
|
// Métricas do mês corrente, escopadas à equipe do supervisor
|
||||||
|
equipeMes: z
|
||||||
|
.object({
|
||||||
|
faturamento: z.number(),
|
||||||
|
pedidos: z.number().int(),
|
||||||
|
ticketMedio: z.number(),
|
||||||
|
metaTotal: z.number(),
|
||||||
|
pctMeta: z.number(),
|
||||||
|
})
|
||||||
|
.default({ faturamento: 0, pedidos: 0, ticketMedio: 0, metaTotal: 0, pctMeta: 0 }),
|
||||||
|
rankingReps: z.array(RankingRepSchema).default([]),
|
||||||
|
positivacaoReps: z.array(PositivacaoRepSchema).default([]),
|
||||||
|
syncedAt: z.iso.datetime(),
|
||||||
|
});
|
||||||
|
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
|
||||||
|
|
||||||
// Clientes distintos positivados por dia do período (YYYY-MM-DD)
|
// Clientes distintos positivados por dia do período (YYYY-MM-DD)
|
||||||
export const PositivacaoDiaSchema = z.object({
|
export const PositivacaoDiaSchema = z.object({
|
||||||
dia: z.string(),
|
dia: z.string(),
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
// ─── Situa ────────────────────────────────────────────────────────────────────
|
// ─── Situa ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Ciclo de vida do pedido SAR:
|
// Ciclo de vida do pedido SAR: 0=Orçamento → 2=Transmitido.
|
||||||
// 0=Orçamento → 1=Ag. Aprovação (se desconto > alçada) → 2=Transmitido
|
// Desconto acima da alçada é bloqueio duro na transmissão — não há fila de
|
||||||
// Estados que o SAR controla: Orçamento e Transmitido (1 é o gate de desconto).
|
// aprovação. O valor 1 (Ag. Aprovação) é legado, mantido só para dados antigos.
|
||||||
// Após Transmitido, o status passa a refletir o ERP (Emitido/Cancelado/Aguardando…)
|
// Após Transmitido, o status passa a refletir o ERP (Emitido/Cancelado/Aguardando…)
|
||||||
// — espelhado quando a integração existir.
|
// — espelhado quando a integração existir.
|
||||||
export const SituaPedidoSchema = z.union([
|
export const SituaPedidoSchema = z.union([
|
||||||
@@ -170,17 +170,6 @@ export const CancelPedidoSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type CancelPedido = z.infer<typeof CancelPedidoSchema>;
|
export type CancelPedido = z.infer<typeof CancelPedidoSchema>;
|
||||||
|
|
||||||
export const AprovarPedidoSchema = z.object({
|
|
||||||
descontoPerc: z.number().min(0).max(100).optional(),
|
|
||||||
nota: z.string().optional(),
|
|
||||||
});
|
|
||||||
export type AprovarPedido = z.infer<typeof AprovarPedidoSchema>;
|
|
||||||
|
|
||||||
export const RecusarPedidoSchema = z.object({
|
|
||||||
motivo: z.string().min(1),
|
|
||||||
});
|
|
||||||
export type RecusarPedido = z.infer<typeof RecusarPedidoSchema>;
|
|
||||||
|
|
||||||
// ─── ERP Consulta ─────────────────────────────────────────────────────────────
|
// ─── ERP Consulta ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const PedidoErpConsultaItemSchema = z.object({
|
export const PedidoErpConsultaItemSchema = z.object({
|
||||||
|
|||||||
Reference in New Issue
Block a user