diff --git a/apps/api/src/app/dashboard/dashboard.controller.ts b/apps/api/src/app/dashboard/dashboard.controller.ts index 1a94922..442a299 100644 --- a/apps/api/src/app/dashboard/dashboard.controller.ts +++ b/apps/api/src/app/dashboard/dashboard.controller.ts @@ -1,9 +1,19 @@ -import { Body, Controller, ForbiddenException, Get, HttpCode, Put, Query } from '@nestjs/common'; +import { + Body, + Controller, + ForbiddenException, + Get, + HttpCode, + ParseIntPipe, + Put, + Query, +} from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; import { createZodDto } from 'nestjs-zod'; import { SaveMetaPositivacaoBodySchema, type RepDashboard, + type RepInativosDetail, type SupervisorDashboard, type ManagerDashboard, type SaveMetaPositivacaoBody, @@ -40,6 +50,14 @@ export class DashboardController { return this.dashboard.supervisorDashboard(); } + @Get('supervisor/inativos') + supervisorInativos( + @Query('codVendedor', ParseIntPipe) codVendedor: number, + ): Promise { + this.assertGestor(); + return this.dashboard.supervisorInativos(codVendedor); + } + @Put('manager/meta-positivacao-dia') @HttpCode(204) saveMetaPositivacaoDia(@Body() body: SaveMetaPositivacaoDto): Promise { diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index 5cef32f..a66fa8c 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -2,6 +2,7 @@ import { ForbiddenException, Injectable } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; import type { RepDashboard, + RepInativosDetail, SupervisorDashboard, ManagerDashboard, RankingRep, @@ -11,8 +12,6 @@ import type { WorkspaceClsStore } from '../workspace/workspace.types'; import { getTeamCodes } from '../workspace/team-scope.util'; // Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado -// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado -const SITUA_PENDENTE = 1; // vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo. const TIPO_META_GLOBAL = 'GL'; @@ -744,17 +743,6 @@ export class DashboardService { role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null; const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : ''); - // Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP) - const approvalQueue = await prisma.pedido.findMany({ - where: { - idEmpresa, - situa: SITUA_PENDENTE, - ...(team ? { codVendedor: { in: team } } : {}), - }, - orderBy: { dtPedido: 'asc' }, - take: 50, - }); - // Pedidos do dia — lê do ERP (situa != 5=Cancelado) const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const todayStr = todayStart.toISOString().slice(0, 10); @@ -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(` + 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(` + 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(` + 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(` + 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(` + SELECT c.cod_vendedor, COUNT(*)::text AS novos + FROM ( + SELECT id_cliente, MIN(dt_pedido) AS primeira_compra + FROM vw_pedidos_erp + WHERE id_empresa = ${idEmpresa} + AND situa NOT IN (1, 5) + GROUP BY id_cliente + ) f + JOIN (SELECT DISTINCT id_cliente, cod_vendedor FROM vw_clientes) c + ON c.id_cliente = f.id_cliente + WHERE f.primeira_compra >= '${monthStartStr}' + AND f.primeira_compra <= '${monthEndStr}' + ${teamSqlFilter('c.cod_vendedor')} + GROUP BY c.cod_vendedor + `), + ]); + + const pedidosMes = Number(mesRows[0]?.count ?? 0); + const faturamentoMes = Number(mesRows[0]?.total ?? 0); + const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0); + + const rankingReps: RankingRep[] = rankingRows.map((r) => { + const fat = Number(r.faturamento); + const meta = r.meta_valor ? Number(r.meta_valor) : 0; + return { + codVendedor: Number(r.cod_vendedor), + nomeVendedor: r.nome ?? null, + pedidos: Number(r.pedidos), + clientesAtendidos: Number(r.clientes_atendidos), + faturamento: fat, + pctMeta: meta > 0 ? Math.round((fat / meta) * 100) : 0, + }; + }); + + const novosMap = new Map( + novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]), + ); + const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => { + const total = Number(r.total_clientes); + const positivados = Number(r.clientes_positivados); + return { + codVendedor: Number(r.cod_vendedor), + nomeVendedor: r.nome_vendedor ?? null, + totalClientes: total, + clientesPositivados: positivados, + pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0, + novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0, + }; + }); + + // Reps com mais clientes inativos (>30 dias sem compra no ERP) const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const inativosPorRep = await prisma.$queryRawUnsafe(` SELECT inativos.cod_vendedor, @@ -808,50 +941,10 @@ export class DashboardService { ) inativos GROUP BY inativos.cod_vendedor ORDER BY COUNT(*) DESC - LIMIT 3 + LIMIT 10 `); - // Resolve nomes de cliente e representante da fila (pedidos SAR só têm os códigos) - const repCods = [...new Set(approvalQueue.map((p) => p.codVendedor))]; - const cliIds = [...new Set(approvalQueue.map((p) => p.idCliente))]; - const [repNameRows, cliNameRows] = await Promise.all([ - repCods.length - ? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>( - `SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`, - ) - : Promise.resolve([]), - cliIds.length - ? prisma.$queryRawUnsafe< - { id_cliente: number; nome: string | null; razao: string | null }[] - >( - `SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`, - ) - : Promise.resolve([]), - ]); - const repNameMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome])); - const cliNameMap = new Map( - cliNameRows.map((c) => [Number(c.id_cliente), { nome: c.nome, razao: c.razao }]), - ); - - const mapPedido = (o: (typeof approvalQueue)[number]) => ({ - id: o.id, - numPedSar: o.numPedSar, - idCliente: o.idCliente, - nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null, - razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null, - codVendedor: o.codVendedor, - nomeVendedor: repNameMap.get(o.codVendedor) ?? null, - situa: o.situa, - dtPedido: o.dtPedido.toISOString(), - total: String(o.total), - descontoPerc: String(o.descontoPerc), - obs: o.obs, - createdAt: o.createdAt.toISOString(), - fonte: 'sar' as const, - }); - return { - approvalQueue: approvalQueue.map(mapPedido), pedidosDia: { count: Number(todayRows[0]?.count ?? 0), total: Number(todayRows[0]?.total ?? 0), @@ -863,7 +956,93 @@ export class DashboardService { nomeVendedor: r.nome_vendedor ?? null, inativosCount: parseInt(r.inativos_count, 10), })), + equipeMes: { + faturamento: faturamentoMes, + pedidos: pedidosMes, + ticketMedio: pedidosMes > 0 ? faturamentoMes / pedidosMes : 0, + metaTotal, + pctMeta: metaTotal > 0 ? Math.round((faturamentoMes / metaTotal) * 100) : 0, + }, + rankingReps, + positivacaoReps, syncedAt: now.toISOString(), }; } + + // Clientes inativos (>30 dias sem compra) de um representante — detalhe do + // card "Inativos por Rep". Supervisor só consulta reps da própria equipe; + // gerente/admin consultam qualquer rep. + async supervisorInativos(codVendedor: number): Promise { + 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(` + 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(); + 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 }; + } } diff --git a/apps/api/src/app/orders/orders.controller.ts b/apps/api/src/app/orders/orders.controller.ts index 9cf490f..6cef034 100644 --- a/apps/api/src/app/orders/orders.controller.ts +++ b/apps/api/src/app/orders/orders.controller.ts @@ -1,7 +1,6 @@ import { Body, Controller, - ForbiddenException, Get, HttpCode, Param, @@ -11,17 +10,13 @@ import { Post, Query, } from '@nestjs/common'; -import { ClsService } from 'nestjs-cls'; import { createZodDto } from 'nestjs-zod'; import { - AprovarPedidoSchema, CancelPedidoSchema, CreatePedidoSchema, UpdatePedidoSchema, PedidoErpConsultaQuerySchema, PedidoListQuerySchema, - RecusarPedidoSchema, - type AprovarPedido, type CancelPedido, type CreatePedido, type UpdatePedido, @@ -30,25 +25,18 @@ import { type PedidoErpConsultaResponse, type PedidoListQuery, type PedidoListResponse, - type RecusarPedido, } from '@sar/api-interface'; -import type { WorkspaceClsStore } from '../workspace/workspace.types'; import { OrdersService } from './orders.service'; class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {} class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {} class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {} class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {} -class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {} -class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {} class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {} @Controller({ path: 'orders' }) export class OrdersController { - constructor( - private readonly orders: OrdersService, - private readonly cls: ClsService, - ) {} + constructor(private readonly orders: OrdersService) {} @Get() list(@Query() query: PedidoListQueryDto): Promise { @@ -68,28 +56,6 @@ export class OrdersController { return this.orders.transmit(id); } - @Patch(':id/approve') - approve( - @Param('id', ParseUUIDPipe) id: string, - @Body() body: AprovarPedidoDto, - ): Promise { - 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 { - const role = this.cls.get('role') ?? 'rep'; - if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos'); - const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido; - return this.orders.reject(id, parsed); - } - @Patch(':id/cancel') cancel( @Param('id', ParseUUIDPipe) id: string, diff --git a/apps/api/src/app/orders/orders.service.ts b/apps/api/src/app/orders/orders.service.ts index a64fd22..9a9bdeb 100644 --- a/apps/api/src/app/orders/orders.service.ts +++ b/apps/api/src/app/orders/orders.service.ts @@ -2,7 +2,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm import { ClsService } from 'nestjs-cls'; import { Prisma } from '@prisma/client'; import type { - AprovarPedido, CancelPedido, CreatePedido, UpdatePedido, @@ -13,16 +12,14 @@ import type { PedidoListQuery, PedidoListResponse, PedidoSummary, - RecusarPedido, } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; import { getTeamCodes } from '../workspace/team-scope.util'; import { NotificationsService } from '../notifications/notifications.service'; -// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado -// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado +// Situa SAR: 0=Orçamento, 2=Confirmado, 3=Cancelado, 4=Faturado +// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado const SITUA_ORCAMENTO = 0; -const SITUA_PENDENTE = 1; const SITUA_APROVADO = 2; const SITUA_CANCELADO = 3; @@ -290,20 +287,6 @@ export class OrdersService { return { data, total, page, limit }; } - // Supervisor só age sobre pedidos da própria equipe; gerente/admin passam - // direto. NotFound (não Forbidden) para não revelar pedidos de outras equipes. - private async assertPedidoDaEquipe(codVendedorPedido: number, idPedido: string): Promise { - 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 { const prisma = this.cls.get('prisma'); 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 { - 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 { - const prisma = this.cls.get('prisma'); - if (!prisma) throw new Error('prisma não disponível no CLS'); - const idEmpresa = this.cls.get('idEmpresa'); - const userId = this.cls.get('userId') ?? '0'; - const codVendedor = parseInt(userId, 10); - - const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } }); - if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`); - if (pedido.situa !== SITUA_PENDENTE) - throw new BadRequestException( - `Pedido não está aguardando aprovação (situa: ${pedido.situa})`, - ); - await this.assertPedidoDaEquipe(pedido.codVendedor, id); - - const now = new Date(); - - await prisma.pedido.update({ - where: { id }, - data: { situa: SITUA_CANCELADO, motivoRecusa: dto.motivo }, - }); - - await prisma.historicoPedido.create({ - data: { - idPedido: id, - situaAnterior: SITUA_PENDENTE, - situaNova: SITUA_CANCELADO, - changedBy: codVendedor, - changedAt: now, - nota: dto.motivo, - }, - }); - - const final = await prisma.pedido.findUniqueOrThrow({ - where: { id }, - include: { - itens: { orderBy: { ordem: 'asc' } }, - historico: { orderBy: { changedAt: 'asc' } }, - }, - }); - - void this.notifications.notifyUser(String(pedido.codVendedor), { - title: 'Pedido recusado', - body: `${final.numPedSar}: ${dto.motivo}`, - url: `/pedidos/${id}`, - }); - - return this.mapDetail(final); - } - // Edita um orçamento (situa 0). Depois de transmitido não é mais editável. // Substitui itens e recalcula totais; alçada valida contra o dono do pedido. async update(id: string, dto: UpdatePedido): Promise { diff --git a/apps/web/src/cockpits/ger/GerPainel.tsx b/apps/web/src/cockpits/ger/GerPainel.tsx index 528471f..10d577f 100644 --- a/apps/web/src/cockpits/ger/GerPainel.tsx +++ b/apps/web/src/cockpits/ger/GerPainel.tsx @@ -45,6 +45,10 @@ import dayjs from 'dayjs'; import type { Dayjs } from 'dayjs'; import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface'; import { useManagerDashboard } from '../../lib/queries/gerente'; +import { + positivacaoRepColumns, + rankingRepColumns, +} from '../../components/dashboard/rep-performance-columns'; import { apiFetch } from '../../lib/api-client'; import { MapaBrasilCard } from './MapaBrasilCard'; @@ -68,60 +72,6 @@ function today(): string { return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' }); } -const positivacaoColumns: TableColumnsType = [ - { - 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) => ( - - {row.clientesPositivados} / {row.totalClientes} - - ), - }, - { - 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 ? ( - - +{row.novosClientes} - - ) : ( - - ), - }, - { - title: '% Positivação', - dataIndex: 'pctPositivacao', - width: 170, - render: (pct: number) => ( - - = 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'} - showInfo={false} - style={{ flex: 1, minWidth: 60 }} - /> - - {pct}% - - - ), - }, -]; - const metasGrupoColumns: TableColumnsType = [ { title: 'Grupo', @@ -179,55 +129,6 @@ const metasGrupoColumns: TableColumnsType = [ }, ]; -const rankingColumns: TableColumnsType = [ - { - 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) => ( - - = 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'} - showInfo={false} - style={{ flex: 1, minWidth: 60 }} - /> - - {pct}% - - - ), - }, -]; - function PositivacaoDiariaCard({ dados, metaSalva, @@ -676,7 +577,7 @@ export function GerPainel() { > rowKey="codVendedor" - columns={rankingColumns} + columns={rankingRepColumns} dataSource={rankingReps} pagination={false} size="small" @@ -720,7 +621,7 @@ export function GerPainel() { > rowKey="codVendedor" - columns={positivacaoColumns} + columns={positivacaoRepColumns} dataSource={positivacaoReps} pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }} size="small" diff --git a/apps/web/src/cockpits/rep/CarteirePage.tsx b/apps/web/src/cockpits/rep/CarteirePage.tsx deleted file mode 100644 index 9052803..0000000 --- a/apps/web/src/cockpits/rep/CarteirePage.tsx +++ /dev/null @@ -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 Sem pedido} />; - if (c.diasSemPedido != null && c.diasSemPedido >= 60) - return ( - Inativo 60d+} - /> - ); - if (c.diasSemPedido != null && c.diasSemPedido >= 30) - return ( - Em risco 30d+} - /> - ); - return ( - Ativo} /> - ); -} - -function InsightCard({ - icon, - cor, - titulo, - descricao, -}: { - icon: React.ReactNode; - cor: string; - titulo: string; - descricao: string; -}) { - return ( - - - {icon} - {/* minWidth: 0 — flex item precisa poder encolher; titulo/descricao - embutem razão social e valores que podem não ter ponto de quebra. */} -
- - {titulo} - -
- - {descricao} - -
-
-
-
- ); -} - -export function CarteirePage() { - const navigate = useNavigate(); - const { data, isLoading, isError, error } = useReportCarteira(); - const [filtro, setFiltro] = useState('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: , - 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: , - 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: , - 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: , - 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 = [ - { - title: 'Cliente', - key: 'cliente', - render: (_: unknown, c: CarteiraCliente) => ( - - navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })} - > - {c.razao ?? c.nome ?? `Cód. ${c.idCliente}`} - - {c.razao && c.nome && ( - - {c.nome} - - )} - - ), - }, - { - title: 'Status', - key: 'status', - width: 140, - render: (_: unknown, c: CarteiraCliente) => statusBadge(c), - }, - { - title: 'Último Pedido', - dataIndex: 'ultimoPedido', - width: 130, - render: (v: string | null) => ( - {fmtDate(v)} - ), - 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 ? ( - = 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }} - > - {v}d - - ) : ( - - ), - sorter: (a, b) => (a.diasSemPedido ?? 9999) - (b.diasSemPedido ?? 9999), - }, - { - title: 'Faturado (total)', - dataIndex: 'faturamentoTotal', - width: 150, - align: 'right' as const, - render: (v: string) => ( - - {fmt(v)} - - ), - 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) => ( - = 10 ? 'blue' : v >= 5 ? 'geekblue' : 'default'} - style={{ borderRadius: 20, fontWeight: 600 }} - > - {v.toFixed(1)}% - - ), - sorter: (a, b) => a.participacaoPct - b.participacaoPct, - }, - { - title: 'Ticket Médio', - dataIndex: 'ticketMedio', - width: 130, - align: 'right' as const, - render: (v: string) => ( - - {fmt(v)} - - ), - sorter: (a, b) => Number(a.ticketMedio) - Number(b.ticketMedio), - }, - { - title: 'Pedidos', - dataIndex: 'totalPedidos', - width: 80, - align: 'right' as const, - render: (v: number) => {v}, - 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 ( -
- {/* Cabeçalho */} - -
- ); -} diff --git a/apps/web/src/cockpits/rep/ClientsPage.tsx b/apps/web/src/cockpits/rep/ClientsPage.tsx index 96cf4b5..18ddccc 100644 --- a/apps/web/src/cockpits/rep/ClientsPage.tsx +++ b/apps/web/src/cockpits/rep/ClientsPage.tsx @@ -35,16 +35,12 @@ import { UserOutlined, WhatsAppOutlined, } from '@ant-design/icons'; -import { Doughnut } from 'react-chartjs-2'; -import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from 'chart.js'; import { useNavigate } from '@tanstack/react-router'; import type { ActivityStatus, ClientSummary } from '@sar/api-interface'; import { SITUA_LABEL } from '@sar/api-interface'; import { useClientList, useClientDetail } from '../../lib/queries/clients'; import { useClientOrders } from '../../lib/queries/orders'; -ChartJS.register(ArcElement, ChartTooltip, Legend); - const { Title, Text } = Typography; const { useBreakpoint } = Grid; @@ -184,150 +180,6 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) { ); } -// ─── CustomerPortfolioCard ──────────────────────────────────────────────────── - -function CustomerPortfolioCard({ - stats, - onDetalhar, -}: { - stats: PortfolioStats; - onDetalhar: () => void; -}) { - const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); - const total = stats.ativos + stats.emAlerta + stats.inativos; - - const donutData = { - labels: ['Ativos', 'Em alerta', 'Inativos'], - datasets: [ - { - data: [stats.ativos, stats.emAlerta, stats.inativos], - backgroundColor: ['#52C41A', '#FAAD14', '#FF4D4F'], - borderColor: '#fff', - borderWidth: 3, - hoverOffset: 6, - }, - ], - }; - - const donutOptions = { - responsive: true, - maintainAspectRatio: true, - cutout: '68%', - plugins: { - legend: { display: false }, - tooltip: { - callbacks: { - label: (ctx: { label: string; raw: unknown }) => { - const v = ctx.raw as number; - const pct = total > 0 ? ((v / total) * 100).toFixed(1) : '0.0'; - return `${ctx.label}: ${v.toLocaleString('pt-BR')} (${pct}%)`; - }, - }, - }, - }, - }; - - const legendItems = [ - { label: 'Ativos', value: stats.ativos, color: '#52C41A' }, - { label: 'Em alerta', value: stats.emAlerta, color: '#FAAD14' }, - { label: 'Inativos', value: stats.inativos, color: '#FF4D4F' }, - ]; - - return ( - -
- - Carteira de Clientes - - - {mesAtual} - -
- -
- {stats.loaded && total > 0 ? ( - <> - -
- - {stats.total.toLocaleString('pt-BR')} - - Clientes -
- - ) : ( -
- -
- )} -
- - - {legendItems.map((item) => { - const pct = total > 0 ? ((item.value / total) * 100).toFixed(1) : '0.0'; - return ( -
- -
- {item.label} - - - - {item.value.toLocaleString('pt-BR')} - - - {pct}% - - -
- ); - })} -
- - - - - ); -} - // ─── CustomerExpandedDetail ─────────────────────────────────────────────────── function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) { @@ -1460,22 +1312,9 @@ export function ClientsPage() { - {/* ── Portfolio mobile (antes da lista) ─────────────────────────── */} - {isMobile && ( - <> - navigate({ to: '/clientes/carteira' })} - /> -
- - )} - {/* ── Área principal ────────────────────────────────────────────── */} - {/* minWidth: 0 — Col é flex item (min-width auto). Sem isto a tabela com - scroll x estoura a coluna e engole o card vizinho. */} - + {isLoading ? (
@@ -1548,15 +1387,6 @@ export function ClientsPage() { )} - - {!isMobile && ( - - navigate({ to: '/clientes/carteira' })} - /> - - )} {/* ── Modais ────────────────────────────────────────────────────── */} diff --git a/apps/web/src/cockpits/rep/OrderDetailPage.tsx b/apps/web/src/cockpits/rep/OrderDetailPage.tsx index d2e0dae..ca2766f 100644 --- a/apps/web/src/cockpits/rep/OrderDetailPage.tsx +++ b/apps/web/src/cockpits/rep/OrderDetailPage.tsx @@ -6,16 +6,12 @@ import { Button, Descriptions, Divider, - Form, - InputNumber, - Modal, Space, Spin, Table, Tag, Timeline, Typography, - Input, message, } from 'antd'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -32,7 +28,6 @@ import { apiFetch } from '../../lib/api-client'; import { authStore } from '../../lib/auth-store'; const { Title, Text } = Typography; -const { TextArea } = Input; // ─── Helpers ────────────────────────────────────────────────────────────────── @@ -144,105 +139,6 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) { ); } -// ─── Approve Modal ──────────────────────────────────────────────────────────── - -function ApproveModal({ - open, - originalDiscount, - onConfirm, - onCancel, - loading, -}: { - open: boolean; - originalDiscount: string; - onConfirm: (descontoPerc?: number, nota?: string) => void; - onCancel: () => void; - loading: boolean; -}) { - const [disc, setDisc] = useState(null); - const [nota, setNota] = useState(''); - - return ( - onConfirm(disc ?? undefined, nota || undefined)} - onCancel={onCancel} - okText="Confirmar Aprovação" - cancelText="Voltar" - confirmLoading={loading} - > -
- - setDisc(v)} - addonAfter="%" - style={{ width: 160 }} - /> - - -