From 289c1a071e204f71c357916ae0d9e597d381d310 Mon Sep 17 00:00:00 2001 From: julian Date: Thu, 25 Jun 2026 19:14:57 +0000 Subject: [PATCH] =?UTF-8?q?feat(web+api):=20cockpit=20gerente=20=E2=80=94?= =?UTF-8?q?=20painel,=20equipe,=20pol=C3=ADticas=20e=20positiva=C3=A7?= =?UTF-8?q?=C3=A3o?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## API - GET /dashboard/manager: KPIs agregados (faturamento, pedidos, ticket médio, promoções ativas), meta total do período, ranking top 10 com clientes atendidos e % meta, positivação por representante, venda necessária/dia - Parâmetros opcionais ?mes&ano para filtrar período - GET /equipe: lista de reps com pedidos, faturamento, ticket médio e % meta (deduplicação de vw_representantes) - GET/POST /politicas/descontos: alçada de desconto por rep (AlcadaDesconto) - GET/POST/PATCH/DELETE /politicas/promocoes: CRUD de promoções com validade - Metas lidas de sar.vw_metas (tipo=GR), não de vw_metas do ERP ## Schema - Novo model Promocao (sar.promocoes) criado via prisma db execute ## Frontend - Cockpit /ger: GerPainel, EquipePage, PoliticasPage - GerPainel: filtro mes/ano, cards KPI, cards de meta vs realizado (atingimento, falta, venda/dia), ranking com clientes atendidos, positivação de clientes por representante (paginada) - Sidebar e BottomNav role-aware: rep / supervisor / gerente (manager+admin) - Clientes acessível ao gerente (carteira completa de todos os reps) - Rotas: HomeRoute redireciona por role; /ger, /ger/equipe, /ger/politicas Co-Authored-By: Claude Sonnet 4.6 --- apps/api/prisma/schema.prisma | 23 + apps/api/src/app/app.module.ts | 4 + .../src/app/dashboard/dashboard.controller.ts | 15 +- .../src/app/dashboard/dashboard.service.ts | 161 ++++++- apps/api/src/app/equipe/equipe.controller.ts | 13 + apps/api/src/app/equipe/equipe.module.ts | 9 + apps/api/src/app/equipe/equipe.service.ts | 113 +++++ .../src/app/politicas/politicas.controller.ts | 67 +++ .../api/src/app/politicas/politicas.module.ts | 9 + .../src/app/politicas/politicas.service.ts | 161 +++++++ apps/web/src/cockpits/ger/EquipePage.tsx | 122 +++++ apps/web/src/cockpits/ger/GerPainel.tsx | 428 ++++++++++++++++++ apps/web/src/cockpits/ger/PoliticasPage.tsx | 393 ++++++++++++++++ apps/web/src/cockpits/rep/CatalogPage.tsx | 12 +- .../web/src/cockpits/rep/ClientDetailPage.tsx | 22 +- apps/web/src/cockpits/rep/ClientsPage.tsx | 8 +- apps/web/src/cockpits/rep/OrderPrintPage.tsx | 107 +++-- apps/web/src/cockpits/rep/OrdersPage.tsx | 85 +++- apps/web/src/cockpits/rep/RelatoriosPage.tsx | 56 ++- apps/web/src/components/layout/AppShell.tsx | 33 +- apps/web/src/components/layout/BottomNav.tsx | 119 +++++ apps/web/src/components/layout/Sidebar.tsx | 145 ++++-- apps/web/src/components/layout/Topbar.tsx | 89 ++-- apps/web/src/lib/queries/gerente.ts | 108 +++++ apps/web/src/lib/router.tsx | 28 +- apps/web/vite.config.mts | 4 +- libs/shared/api-interface/src/index.ts | 2 + .../src/lib/dashboard.contract.ts | 32 ++ .../api-interface/src/lib/equipe.contract.ts | 18 + .../src/lib/politicas.contract.ts | 55 +++ 30 files changed, 2270 insertions(+), 171 deletions(-) create mode 100644 apps/api/src/app/equipe/equipe.controller.ts create mode 100644 apps/api/src/app/equipe/equipe.module.ts create mode 100644 apps/api/src/app/equipe/equipe.service.ts create mode 100644 apps/api/src/app/politicas/politicas.controller.ts create mode 100644 apps/api/src/app/politicas/politicas.module.ts create mode 100644 apps/api/src/app/politicas/politicas.service.ts create mode 100644 apps/web/src/cockpits/ger/EquipePage.tsx create mode 100644 apps/web/src/cockpits/ger/GerPainel.tsx create mode 100644 apps/web/src/cockpits/ger/PoliticasPage.tsx create mode 100644 apps/web/src/components/layout/BottomNav.tsx create mode 100644 apps/web/src/lib/queries/gerente.ts create mode 100644 libs/shared/api-interface/src/lib/equipe.contract.ts create mode 100644 libs/shared/api-interface/src/lib/politicas.contract.ts diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index d739275..4df1da8 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -147,6 +147,29 @@ model MetaRepresentante { @@map("meta_representante") } +// ─── Promocao (C8) ─────────────────────────────────────────────────────────── +// +// Promoção comercial com validade, criada pelo Gerente. Pode ser por produto +// ou por grupo de produto. descPct em % (ex.: 5.00 = 5%). + +model Promocao { + id Int @id @default(autoincrement()) + idEmpresa Int @map("id_empresa") + descricao String + codProduto String? @map("cod_produto") + grpProd String? @map("grp_prod") + descPct Decimal @db.Decimal(5, 2) @map("desc_pct") + dataInicio DateTime @db.Date @map("data_inicio") + dataFim DateTime @db.Date @map("data_fim") + ativa Boolean @default(true) + createdAt DateTime @default(now()) @map("created_at") + createdBy String @map("created_by") + + @@index([idEmpresa]) + @@index([idEmpresa, dataFim]) + @@map("promocoes") +} + // ─── PushSubscription (C6) ─────────────────────────────────────────────────── // // Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser. diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index fd581d1..ba116dd 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -14,6 +14,8 @@ import { CatalogModule } from './catalog/catalog.module'; import { DashboardModule } from './dashboard/dashboard.module'; import { NotificationsModule } from './notifications/notifications.module'; import { ReportsModule } from './reports/reports.module'; +import { PoliticasModule } from './politicas/politicas.module'; +import { EquipeModule } from './equipe/equipe.module'; import { ProblemDetailsFilter } from './filters/problem-details.filter'; @Module({ @@ -31,6 +33,8 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter'; DashboardModule, NotificationsModule, ReportsModule, + PoliticasModule, + EquipeModule, ], providers: [ { provide: APP_PIPE, useClass: ZodValidationPipe }, diff --git a/apps/api/src/app/dashboard/dashboard.controller.ts b/apps/api/src/app/dashboard/dashboard.controller.ts index 72d1cef..2be0234 100644 --- a/apps/api/src/app/dashboard/dashboard.controller.ts +++ b/apps/api/src/app/dashboard/dashboard.controller.ts @@ -1,6 +1,6 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Query } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; -import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface'; +import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; import { DashboardService } from './dashboard.service'; @@ -20,4 +20,15 @@ export class DashboardController { supervisorDashboard(): Promise { return this.dashboard.supervisorDashboard(); } + + @Get('manager') + managerDashboard( + @Query('mes') mes?: string, + @Query('ano') ano?: string, + ): Promise { + return this.dashboard.managerDashboard( + mes ? parseInt(mes, 10) : undefined, + ano ? parseInt(ano, 10) : undefined, + ); + } } diff --git a/apps/api/src/app/dashboard/dashboard.service.ts b/apps/api/src/app/dashboard/dashboard.service.ts index 6485671..65b188d 100644 --- a/apps/api/src/app/dashboard/dashboard.service.ts +++ b/apps/api/src/app/dashboard/dashboard.service.ts @@ -1,6 +1,12 @@ import { Injectable } from '@nestjs/common'; import { ClsService } from 'nestjs-cls'; -import type { RepDashboard, SupervisorDashboard } from '@sar/api-interface'; +import type { + RepDashboard, + SupervisorDashboard, + ManagerDashboard, + RankingRep, + PositivacaoRep, +} from '@sar/api-interface'; import type { WorkspaceClsStore } from '../workspace/workspace.types'; // Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado @@ -300,6 +306,159 @@ export class DashboardService { }; } + async managerDashboard(mes?: number, ano?: 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 now = new Date(); + + function matrizEmpresa(id: number): number { + return id > 9000 ? id - 9000 : id; + } + const idEmpresaMatriz = matrizEmpresa(idEmpresa); + const year = ano ?? now.getFullYear(); + const month = mes ?? 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 TotalRow { + count: string; + total: string; + } + interface RankingRow { + cod_vendedor: number; + nome: string | null; + pedidos: string; + clientes_atendidos: string; + faturamento: string; + meta_valor: string | null; + } + interface PromoRow { + count: string; + } + interface MetaTotalRow { + meta_total: string; + } + interface PositivacaoRow { + cod_vendedor: number; + nome_vendedor: string | null; + total_clientes: string; + clientes_positivados: string; + } + + const [statsRows, rankingRows, promoRows, metaTotalRows, positivacaoRows] = 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}' + `), + 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) = 'GR') 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}' + GROUP BY p.cod_vendedor + ORDER BY SUM(p.total) DESC + LIMIT 10 + `), + prisma.$queryRawUnsafe(` + SELECT COUNT(*)::text AS count + FROM sar.promocoes + WHERE id_empresa = ${idEmpresa} + AND ativa = true + AND data_fim >= CURRENT_DATE + `), + 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) = 'GR' + `), + 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 + 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 + `), + ]); + + const pedidosMes = Number(statsRows[0]?.count ?? 0); + const faturamentoMes = Number(statsRows[0]?.total ?? 0); + const ticketMedio = pedidosMes > 0 ? faturamentoMes / pedidosMes : 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 totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size; + const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0); + + 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, + }; + }); + + return { + faturamentoMes, + pedidosMes, + ticketMedio, + totalReps, + promocoesAtivas: Number(promoRows[0]?.count ?? 0), + metaTotal, + rankingReps, + positivacaoReps, + syncedAt: now.toISOString(), + }; + } + async supervisorDashboard(): Promise { const prisma = this.cls.get('prisma'); if (!prisma) throw new Error('prisma não disponível no CLS'); diff --git a/apps/api/src/app/equipe/equipe.controller.ts b/apps/api/src/app/equipe/equipe.controller.ts new file mode 100644 index 0000000..339d5fb --- /dev/null +++ b/apps/api/src/app/equipe/equipe.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get } from '@nestjs/common'; +import type { EquipeResponse } from '@sar/api-interface'; +import { EquipeService } from './equipe.service'; + +@Controller({ path: 'equipe' }) +export class EquipeController { + constructor(private readonly equipe: EquipeService) {} + + @Get() + listEquipe(): Promise { + return this.equipe.listEquipe(); + } +} diff --git a/apps/api/src/app/equipe/equipe.module.ts b/apps/api/src/app/equipe/equipe.module.ts new file mode 100644 index 0000000..a98be98 --- /dev/null +++ b/apps/api/src/app/equipe/equipe.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { EquipeController } from './equipe.controller'; +import { EquipeService } from './equipe.service'; + +@Module({ + controllers: [EquipeController], + providers: [EquipeService], +}) +export class EquipeModule {} diff --git a/apps/api/src/app/equipe/equipe.service.ts b/apps/api/src/app/equipe/equipe.service.ts new file mode 100644 index 0000000..5bb3f79 --- /dev/null +++ b/apps/api/src/app/equipe/equipe.service.ts @@ -0,0 +1,113 @@ +import { ForbiddenException, Injectable } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import type { EquipeResponse } from '@sar/api-interface'; +import type { WorkspaceClsStore } from '../workspace/workspace.types'; + +function matrizEmpresa(idEmpresa: number): number { + return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; +} + +interface RepRow { + cod_vendedor: number; + nome: string | null; +} + +interface PedidosRepRow { + cod_vendedor: number; + pedidos: string; + faturamento: string; +} + +interface MetaRepRow { + cod_vendedor: number; + meta_valor: string; +} + +@Injectable() +export class EquipeService { + constructor(private readonly cls: ClsService) {} + + async listEquipe(): Promise { + const role = this.cls.get('role') ?? 'rep'; + if (role !== 'manager' && role !== 'admin') { + throw new ForbiddenException('Apenas gerentes podem consultar a equipe'); + } + + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const idEmpresaMatriz = matrizEmpresa(idEmpresa); + + const now = new Date(); + const year = now.getFullYear(); + const month = now.getMonth() + 1; + const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10); + const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10); + + const [repRows, pedidosRows, metaRows] = await Promise.all([ + prisma.$queryRawUnsafe( + `SELECT codigo AS cod_vendedor, nome FROM vw_representantes ORDER BY nome`, + ), + prisma.$queryRawUnsafe(` + SELECT cod_vendedor, + COUNT(*)::text AS pedidos, + COALESCE(SUM(total), 0)::text AS faturamento + FROM vw_pedidos_erp + WHERE id_empresa = ${idEmpresa} + AND situa NOT IN (1, 5) + AND dt_pedido >= '${monthStartStr}' + AND dt_pedido <= '${monthEndStr}' + GROUP BY cod_vendedor + `), + prisma.$queryRawUnsafe(` + SELECT cod_vendedor, + SUM(valor)::text AS meta_valor + FROM sar.vw_metas + WHERE id_empresa = ${idEmpresaMatriz} + AND ano = ${year} + AND mes = ${month} + AND TRIM(tipo) = 'GR' + GROUP BY cod_vendedor + `), + ]); + + const pedidosMap = new Map( + pedidosRows.map((r) => [ + Number(r.cod_vendedor), + { pedidos: Number(r.pedidos), faturamento: Number(r.faturamento) }, + ]), + ); + const metaMap = new Map(metaRows.map((r) => [Number(r.cod_vendedor), Number(r.meta_valor)])); + + // vw_representantes pode retornar duplicatas por cod_vendedor — deduplicar + const seenCods = new Set(); + const uniqueReps = repRows.filter((r) => { + const cod = Number(r.cod_vendedor); + if (seenCods.has(cod)) return false; + seenCods.add(cod); + return true; + }); + + const reps = uniqueReps.map((r) => { + const cod = Number(r.cod_vendedor); + const stats = pedidosMap.get(cod); + const meta = metaMap.get(cod) ?? 0; + const faturamento = stats?.faturamento ?? 0; + const pedidos = stats?.pedidos ?? 0; + return { + codVendedor: cod, + nomeVendedor: r.nome ?? null, + pedidosMes: pedidos, + faturamentoMes: faturamento, + ticketMedio: pedidos > 0 ? faturamento / pedidos : 0, + pctMeta: meta > 0 ? Math.round((faturamento / meta) * 100) : 0, + }; + }); + + return { + reps, + totalReps: reps.length, + syncedAt: now.toISOString(), + }; + } +} diff --git a/apps/api/src/app/politicas/politicas.controller.ts b/apps/api/src/app/politicas/politicas.controller.ts new file mode 100644 index 0000000..8af70fd --- /dev/null +++ b/apps/api/src/app/politicas/politicas.controller.ts @@ -0,0 +1,67 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + Param, + ParseIntPipe, + Patch, + Post, +} from '@nestjs/common'; +import { createZodDto } from 'nestjs-zod'; +import { + UpsertDescontoBodySchema, + CreatePromocaoBodySchema, + UpdatePromocaoBodySchema, + type UpsertDescontoBody, + type CreatePromocaoBody, + type UpdatePromocaoBody, + type AlcadaDescontosResponse, + type PromocoesResponse, +} from '@sar/api-interface'; +import { PoliticasService } from './politicas.service'; + +class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {} +class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {} +class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {} + +@Controller({ path: 'politicas' }) +export class PoliticasController { + constructor(private readonly politicas: PoliticasService) {} + + @Get('descontos') + listDescontos(): Promise { + return this.politicas.listDescontos(); + } + + @Post('descontos') + @HttpCode(200) + upsertDesconto(@Body() body: UpsertDescontoDto): Promise { + return this.politicas.upsertDesconto(body as unknown as UpsertDescontoBody); + } + + @Get('promocoes') + listPromocoes(): Promise { + return this.politicas.listPromocoes(); + } + + @Post('promocoes') + createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> { + return this.politicas.createPromocao(body as unknown as CreatePromocaoBody); + } + + @Patch('promocoes/:id') + updatePromocao( + @Param('id', ParseIntPipe) id: number, + @Body() body: UpdatePromocaoDto, + ): Promise { + return this.politicas.updatePromocao(id, body as unknown as UpdatePromocaoBody); + } + + @Delete('promocoes/:id') + @HttpCode(204) + deletePromocao(@Param('id', ParseIntPipe) id: number): Promise { + return this.politicas.deletePromocao(id); + } +} diff --git a/apps/api/src/app/politicas/politicas.module.ts b/apps/api/src/app/politicas/politicas.module.ts new file mode 100644 index 0000000..fffb312 --- /dev/null +++ b/apps/api/src/app/politicas/politicas.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { PoliticasController } from './politicas.controller'; +import { PoliticasService } from './politicas.service'; + +@Module({ + controllers: [PoliticasController], + providers: [PoliticasService], +}) +export class PoliticasModule {} diff --git a/apps/api/src/app/politicas/politicas.service.ts b/apps/api/src/app/politicas/politicas.service.ts new file mode 100644 index 0000000..17af67a --- /dev/null +++ b/apps/api/src/app/politicas/politicas.service.ts @@ -0,0 +1,161 @@ +import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { ClsService } from 'nestjs-cls'; +import type { + AlcadaDescontosResponse, + UpsertDescontoBody, + PromocoesResponse, + CreatePromocaoBody, + UpdatePromocaoBody, +} from '@sar/api-interface'; +import type { WorkspaceClsStore } from '../workspace/workspace.types'; + +@Injectable() +export class PoliticasService { + constructor(private readonly cls: ClsService) {} + + private assertManager(): void { + const role = this.cls.get('role') ?? 'rep'; + if (role !== 'manager' && role !== 'admin') { + throw new ForbiddenException('Apenas gerentes podem gerenciar políticas comerciais'); + } + } + + async listDescontos(): Promise { + this.assertManager(); + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + + const rows = await prisma.alcadaDesconto.findMany({ + where: { idEmpresa }, + orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }], + }); + + // Busca nomes dos representantes + const codigos = [...new Set(rows.map((r) => r.codVendedor))]; + const repRows = + codigos.length > 0 + ? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>( + `SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`, + ) + : []; + const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome])); + + return { + descontos: rows.map((r) => ({ + codVendedor: r.codVendedor, + codGrupo: r.codGrupo, + limitePerc: Number(r.limitePerc), + nomeVendedor: repNameMap.get(r.codVendedor) ?? null, + })), + }; + } + + async upsertDesconto(body: UpsertDescontoBody): Promise { + this.assertManager(); + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + + await prisma.alcadaDesconto.upsert({ + where: { + codVendedor_idEmpresa_codGrupo: { + codVendedor: body.codVendedor, + idEmpresa, + codGrupo: body.codGrupo ?? 0, + }, + }, + create: { + codVendedor: body.codVendedor, + idEmpresa, + codGrupo: body.codGrupo ?? 0, + limitePerc: body.limitePerc, + }, + update: { limitePerc: body.limitePerc }, + }); + } + + async listPromocoes(): Promise { + this.assertManager(); + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + + const rows = await prisma.promocao.findMany({ + where: { idEmpresa }, + orderBy: [{ dataFim: 'desc' }, { id: 'desc' }], + }); + + return { + promocoes: rows.map((p) => ({ + id: p.id, + descricao: p.descricao, + codProduto: p.codProduto, + grpProd: p.grpProd, + descPct: Number(p.descPct), + dataInicio: p.dataInicio.toISOString().slice(0, 10), + dataFim: p.dataFim.toISOString().slice(0, 10), + ativa: p.ativa, + createdAt: p.createdAt.toISOString(), + createdBy: p.createdBy, + })), + }; + } + + async createPromocao(body: CreatePromocaoBody): Promise<{ id: number }> { + this.assertManager(); + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + const userId = this.cls.get('userId') ?? 'system'; + + const p = await prisma.promocao.create({ + data: { + idEmpresa, + descricao: body.descricao, + codProduto: body.codProduto ?? null, + grpProd: body.grpProd ?? null, + descPct: body.descPct, + dataInicio: new Date(body.dataInicio), + dataFim: new Date(body.dataFim), + createdBy: userId, + }, + }); + return { id: p.id }; + } + + async updatePromocao(id: number, body: UpdatePromocaoBody): Promise { + this.assertManager(); + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + + const exists = await prisma.promocao.findFirst({ where: { id, idEmpresa } }); + if (!exists) throw new NotFoundException('Promoção não encontrada'); + + await prisma.promocao.update({ + where: { id }, + data: { + ...(body.descricao !== undefined && { descricao: body.descricao }), + ...(body.codProduto !== undefined && { codProduto: body.codProduto }), + ...(body.grpProd !== undefined && { grpProd: body.grpProd }), + ...(body.descPct !== undefined && { descPct: body.descPct }), + ...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }), + ...(body.dataFim !== undefined && { dataFim: new Date(body.dataFim) }), + ...(body.ativa !== undefined && { ativa: body.ativa }), + }, + }); + } + + async deletePromocao(id: number): Promise { + this.assertManager(); + const prisma = this.cls.get('prisma'); + if (!prisma) throw new Error('prisma não disponível no CLS'); + const idEmpresa = this.cls.get('idEmpresa'); + + const exists = await prisma.promocao.findFirst({ where: { id, idEmpresa } }); + if (!exists) throw new NotFoundException('Promoção não encontrada'); + + await prisma.promocao.delete({ where: { id } }); + } +} diff --git a/apps/web/src/cockpits/ger/EquipePage.tsx b/apps/web/src/cockpits/ger/EquipePage.tsx new file mode 100644 index 0000000..262a3f7 --- /dev/null +++ b/apps/web/src/cockpits/ger/EquipePage.tsx @@ -0,0 +1,122 @@ +import { Card, Flex, Progress, Skeleton, Table, Tag, Typography } from 'antd'; +import type { TableColumnsType } from 'antd'; +import type { RepStats } from '@sar/api-interface'; +import { useEquipe } from '../../lib/queries/gerente'; + +const { Title, Text } = Typography; + +function fmt(v: number): string { + return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); +} + +const columns: TableColumnsType = [ + { + title: 'Representante', + key: 'rep', + render: (_: unknown, row: RepStats) => ( + + {row.nomeVendedor ?? `Cód. ${row.codVendedor}`} + {row.nomeVendedor && ( + + cód. {row.codVendedor} + + )} + + ), + }, + { + title: 'Pedidos', + dataIndex: 'pedidosMes', + width: 90, + align: 'right', + className: 'tabular-nums', + sorter: (a, b) => a.pedidosMes - b.pedidosMes, + render: (v: number) => (v === 0 ? 0 : v), + }, + { + title: 'Faturamento', + dataIndex: 'faturamentoMes', + width: 160, + align: 'right', + className: 'tabular-nums', + sorter: (a, b) => a.faturamentoMes - b.faturamentoMes, + defaultSortOrder: 'descend', + render: (v: number) => fmt(v), + }, + { + title: 'Ticket Medio', + dataIndex: 'ticketMedio', + width: 140, + align: 'right', + className: 'tabular-nums', + sorter: (a, b) => a.ticketMedio - b.ticketMedio, + render: (v: number) => (v > 0 ? fmt(v) : --), + }, + { + title: '% Meta', + dataIndex: 'pctMeta', + width: 170, + sorter: (a, b) => a.pctMeta - b.pctMeta, + render: (pct: number) => + pct === 0 ? ( + sem meta + ) : ( + + = 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'} + showInfo={false} + style={{ flex: 1, minWidth: 60 }} + /> + + {pct}% + + + ), + }, +]; + +export function EquipePage() { + const { data, isLoading } = useEquipe(); + + if (isLoading || !data) { + return ( + + + + + ); + } + + const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); + + return ( + + + + Equipe + + + Performance dos representantes — {mes} + + + + + + rowKey="codVendedor" + columns={columns} + dataSource={data.reps} + pagination={false} + size="small" + locale={{ emptyText: 'Nenhum representante encontrado.' }} + scroll={{ x: 700 }} + /> + + + + Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')} + + + ); +} diff --git a/apps/web/src/cockpits/ger/GerPainel.tsx b/apps/web/src/cockpits/ger/GerPainel.tsx new file mode 100644 index 0000000..696d791 --- /dev/null +++ b/apps/web/src/cockpits/ger/GerPainel.tsx @@ -0,0 +1,428 @@ +import { useState } from 'react'; +import { + Button, + Card, + Col, + DatePicker, + Flex, + Progress, + Row, + Skeleton, + Space, + Table, + Typography, +} from 'antd'; +import { useNavigate } from '@tanstack/react-router'; +import type { TableColumnsType } from 'antd'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + faChartBar, + faTags, + faUsers, + faFileInvoiceDollar, + faAddressCard, + faBullseye, + faArrowTrendUp, +} from '@fortawesome/free-solid-svg-icons'; +import dayjs from 'dayjs'; +import type { Dayjs } from 'dayjs'; +import type { RankingRep, PositivacaoRep } from '@sar/api-interface'; +import { useManagerDashboard } from '../../lib/queries/gerente'; + +const { Title, Text } = Typography; + +function fmt(v: number): string { + return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); +} + +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: '% 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 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}% + + + ), + }, +]; + +export function GerPainel() { + const [periodo, setPeriodo] = useState(dayjs()); + const mes = periodo.month() + 1; + const ano = periodo.year(); + + const { data, isLoading } = useManagerDashboard(mes, ano); + const navigate = useNavigate(); + + const periodoLabel = periodo + .toDate() + .toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); + const isMesAtual = mes === dayjs().month() + 1 && ano === dayjs().year(); + + if (isLoading || !data) { + return ( + + + + {[1, 2, 3, 4].map((i) => ( + + + + ))} + + + ); + } + + const { + faturamentoMes, + pedidosMes, + ticketMedio, + totalReps, + promocoesAtivas, + metaTotal, + rankingReps, + positivacaoReps, + syncedAt, + } = data; + + const pctMeta = metaTotal > 0 ? Math.round((faturamentoMes / metaTotal) * 100) : 0; + const faltaMeta = Math.max(0, metaTotal - faturamentoMes); + const superouMeta = faturamentoMes > metaTotal ? faturamentoMes - metaTotal : 0; + const metaCorColor = + pctMeta >= 100 ? 'var(--green)' : pctMeta >= 70 ? 'var(--jcs-blue)' : '#faad14'; + + // Dias restantes no período selecionado (incluindo hoje se for mês atual) + const hoje = dayjs(); + const fimMes = periodo.endOf('month'); + const diasRestantes = isMesAtual + ? Math.max(0, fimMes.date() - hoje.date() + 1) + : periodo.isBefore(hoje, 'month') + ? 0 + : fimMes.date(); // mês futuro: todos os dias + const vendaPorDia = faltaMeta > 0 && diasRestantes > 0 ? faltaMeta / diasRestantes : 0; + + return ( + + {/* Cabeçalho */} + + + + Painel Gerencial + + + {today()} + + + + + + Período: + + d && setPeriodo(d)} + format="MMM/YYYY" + allowClear={false} + disabledDate={(d) => d.isAfter(dayjs(), 'month')} + style={{ width: 130 }} + /> + + + + {/* KPIs linha 1 */} + + + + + + FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()} + + + {fmt(faturamentoMes)} + + + + + + + + + + + PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'} + + + {pedidosMes} + + + + + + + + + + + TICKET MÉDIO + + + {fmt(ticketMedio)} + + + + + + + + + + + PROMOÇÕES ATIVAS + + + {promocoesAtivas} + + + + + + + + {/* KPIs linha 2 — Meta */} + {metaTotal > 0 && ( + + + + + + META DO {isMesAtual ? 'MÊS' : 'PERÍODO'} + + + {fmt(metaTotal)} + + + + + + + + + + + + ATINGIMENTO DA META + + + + + {pctMeta}% + + + + + + + + + + + {superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'} + + 0 ? 'var(--green)' : undefined }} + className="tabular-nums" + > + {fmt(superouMeta > 0 ? superouMeta : faltaMeta)} + + {superouMeta > 0 ? ( + + {pctMeta - 100}% acima da meta + + ) : vendaPorDia > 0 ? ( + + {fmt(vendaPorDia)}/dia — {diasRestantes} dia + {diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''} + + ) : diasRestantes === 0 ? ( + + período encerrado + + ) : null} + + + + + )} + + {/* Ranking */} + + + Ranking de Representantes — {periodoLabel} + + } + extra={ + + + top 10 de {totalReps} rep{totalReps !== 1 ? 's' : ''} com pedidos + + + + } + > + + rowKey="codVendedor" + columns={rankingColumns} + dataSource={rankingReps} + pagination={false} + size="small" + locale={{ emptyText: 'Nenhum pedido registrado neste período.' }} + /> + + + {/* Positivação por Representante */} + + + Positivação de Clientes por Representante — {periodoLabel} + + } + extra={ + + {positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação + + } + > + + rowKey="codVendedor" + columns={positivacaoColumns} + dataSource={positivacaoReps} + pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }} + size="small" + locale={{ emptyText: 'Nenhum dado de carteira encontrado.' }} + /> + + + + + SAR · Força de Vendas · Powered by JCS Sistemas + + + Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} + {isMesAtual && ' · atualiza a cada 60s'} + + + + ); +} diff --git a/apps/web/src/cockpits/ger/PoliticasPage.tsx b/apps/web/src/cockpits/ger/PoliticasPage.tsx new file mode 100644 index 0000000..ab35375 --- /dev/null +++ b/apps/web/src/cockpits/ger/PoliticasPage.tsx @@ -0,0 +1,393 @@ +import { useState } from 'react'; +import { + Button, + Card, + DatePicker, + Flex, + Form, + InputNumber, + Modal, + Popconfirm, + Skeleton, + Space, + Table, + Tabs, + Tag, + Tooltip, + Typography, + Input, + message, +} from 'antd'; +import type { TableColumnsType } from 'antd'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons'; +import dayjs from 'dayjs'; +import type { AlcadaDescontoItem, Promocao } from '@sar/api-interface'; +import { + usePoliticasDescontos, + usePromocoes, + useUpsertDesconto, + useCreatePromocao, + useUpdatePromocao, + useDeletePromocao, +} from '../../lib/queries/gerente'; + +const { Title, Text } = Typography; + +// ─── Tab Descontos ──────────────────────────────────────────────────────────── + +function TabDescontos() { + const { data, isLoading } = usePoliticasDescontos(); + const upsert = useUpsertDesconto(); + const [editingKey, setEditingKey] = useState(null); + const [editValue, setEditValue] = useState(0); + const [msg, msgCtx] = message.useMessage(); + + function startEdit(row: AlcadaDescontoItem) { + setEditingKey(`${row.codVendedor}-${row.codGrupo}`); + setEditValue(row.limitePerc); + } + + async function saveEdit(row: AlcadaDescontoItem) { + await upsert.mutateAsync({ + codVendedor: row.codVendedor, + codGrupo: row.codGrupo, + limitePerc: editValue, + }); + setEditingKey(null); + void msg.success('Limite atualizado'); + } + + const columns: TableColumnsType = [ + { + title: 'Representante', + key: 'rep', + render: (_: unknown, row: AlcadaDescontoItem) => + row.nomeVendedor ?? `Cód. ${row.codVendedor}`, + }, + { + title: 'Grupo', + dataIndex: 'codGrupo', + width: 100, + render: (v: number) => (v === 0 ? Global : Grupo {v}), + }, + { + title: 'Limite de Desconto', + dataIndex: 'limitePerc', + width: 200, + render: (v: number, row: AlcadaDescontoItem) => { + const key = `${row.codVendedor}-${row.codGrupo}`; + if (editingKey === key) { + return ( + setEditValue(val ?? 0)} + min={0} + max={100} + suffix="%" + size="small" + style={{ width: 100 }} + autoFocus + /> + ); + } + return {v}%; + }, + }, + { + title: '', + width: 120, + render: (_: unknown, row: AlcadaDescontoItem) => { + const key = `${row.codVendedor}-${row.codGrupo}`; + if (editingKey === key) { + return ( + + + + + ); + } + return ( + + ); + }, + }, + ]; + + if (isLoading || !data) return ; + + return ( + <> + {msgCtx} + + rowKey={(r) => `${r.codVendedor}-${r.codGrupo}`} + columns={columns} + dataSource={data.descontos} + pagination={false} + size="small" + locale={{ emptyText: 'Nenhum limite configurado.' }} + /> + + Limite 0 = sem restricao de desconto para este representante. + + + ); +} + +// ─── Tab Promocoes ──────────────────────────────────────────────────────────── + +interface PromocaoForm { + descricao: string; + codProduto?: string; + grpProd?: string; + descPct: number; + periodo: [dayjs.Dayjs, dayjs.Dayjs]; +} + +function TabPromocoes() { + const { data, isLoading } = usePromocoes(); + const createMutation = useCreatePromocao(); + const updateMutation = useUpdatePromocao(); + const deleteMutation = useDeletePromocao(); + const [form] = Form.useForm(); + const [modalOpen, setModalOpen] = useState(false); + const [editTarget, setEditTarget] = useState(null); + const [msg, msgCtx] = message.useMessage(); + + function openCreate() { + setEditTarget(null); + form.resetFields(); + setModalOpen(true); + } + + function openEdit(p: Promocao) { + setEditTarget(p); + form.setFieldsValue({ + descricao: p.descricao, + codProduto: p.codProduto ?? undefined, + grpProd: p.grpProd ?? undefined, + descPct: p.descPct, + periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)], + }); + setModalOpen(true); + } + + async function handleSubmit(values: PromocaoForm) { + const [inicio, fim] = values.periodo; + const body = { + descricao: values.descricao, + codProduto: values.codProduto || undefined, + grpProd: values.grpProd || undefined, + descPct: values.descPct, + dataInicio: inicio.format('YYYY-MM-DD'), + dataFim: fim.format('YYYY-MM-DD'), + }; + if (editTarget) { + await updateMutation.mutateAsync({ id: editTarget.id, body }); + void msg.success('Promocao atualizada'); + } else { + await createMutation.mutateAsync(body); + void msg.success('Promocao criada'); + } + setModalOpen(false); + } + + async function handleDelete(id: number) { + await deleteMutation.mutateAsync(id); + void msg.success('Promocao removida'); + } + + const today = dayjs().format('YYYY-MM-DD'); + + const columns: TableColumnsType = [ + { + title: 'Descricao', + dataIndex: 'descricao', + render: (v: string, row: Promocao) => ( + + {v} + {(row.codProduto || row.grpProd) && ( + + {row.codProduto ? `Produto: ${row.codProduto}` : `Grupo: ${row.grpProd}`} + + )} + + ), + }, + { + title: 'Desconto', + dataIndex: 'descPct', + width: 100, + align: 'right', + render: (v: number) => {v}%, + }, + { + title: 'Vigencia', + width: 200, + render: (_: unknown, row: Promocao) => ( + + {dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')} + + ), + }, + { + title: 'Status', + width: 90, + render: (_: unknown, row: Promocao) => { + if (!row.ativa) return Inativa; + if (row.dataFim < today) return Expirada; + if (row.dataInicio > today) return Futura; + return Ativa; + }, + }, + { + title: '', + width: 100, + render: (_: unknown, row: Promocao) => ( + + + + + + + rowKey="id" + columns={columns} + dataSource={data.promocoes} + pagination={false} + size="small" + locale={{ emptyText: 'Nenhuma promocao cadastrada.' }} + /> + + setModalOpen(false)} + onOk={() => void form.validateFields().then(handleSubmit)} + okText={editTarget ? 'Salvar' : 'Criar'} + confirmLoading={createMutation.isPending || updateMutation.isPending} + width={520} + centered + > +
+ + + + + + + + + + + + + + + + + + + + +
+
+ + ); +} + +// ─── PoliticasPage ───────────────────────────────────────────────────────────── + +export function PoliticasPage() { + const items = [ + { + key: 'descontos', + label: 'Desconto por Representante', + children: , + }, + { + key: 'promocoes', + label: 'Promocoes', + children: , + }, + ]; + + return ( + + + + Politicas Comerciais + + + Limites de desconto e promocoes com vigencia + + + + + + + + ); +} diff --git a/apps/web/src/cockpits/rep/CatalogPage.tsx b/apps/web/src/cockpits/rep/CatalogPage.tsx index ea20da8..b1e1d9d 100644 --- a/apps/web/src/cockpits/rep/CatalogPage.tsx +++ b/apps/web/src/cockpits/rep/CatalogPage.tsx @@ -1,5 +1,7 @@ import { useState } from 'react'; -import { Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd'; +import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd'; + +const { useBreakpoint } = Grid; import { EyeOutlined } from '@ant-design/icons'; import type { TableColumnsType } from 'antd'; import type { ProdutoSummary } from '@sar/api-interface'; @@ -94,6 +96,8 @@ export function CatalogPage() { const [page, setPage] = useState(1); const [selectedIdErp, setSelectedIdErp] = useState(null); const limit = 50; + const screens = useBreakpoint(); + const isMobile = !screens.md; const { data: pautas, isLoading: pautasLoading } = usePautas(); const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit }); @@ -113,11 +117,11 @@ export function CatalogPage() { {/* ── Filtros ──────────────────────────────────────────────────── */} - + { setQ(v); setPage(1); @@ -133,7 +137,7 @@ export function CatalogPage() { placeholder="Selecionar pauta de preços" allowClear loading={pautasLoading} - style={{ width: 340 }} + style={{ width: isMobile ? '100%' : 340 }} onChange={(v) => { setIdPauta(v as number | undefined); setPage(1); diff --git a/apps/web/src/cockpits/rep/ClientDetailPage.tsx b/apps/web/src/cockpits/rep/ClientDetailPage.tsx index 1b4bfdf..2dfef67 100644 --- a/apps/web/src/cockpits/rep/ClientDetailPage.tsx +++ b/apps/web/src/cockpits/rep/ClientDetailPage.tsx @@ -1,6 +1,7 @@ import { Button, Descriptions, + Grid, Tag, Table, Typography, @@ -14,6 +15,8 @@ import { Badge, } from 'antd'; import { useState } from 'react'; + +const { useBreakpoint } = Grid; import type { TableColumnsType } from 'antd'; import { CopyOutlined, UserAddOutlined } from '@ant-design/icons'; import { Link, useNavigate, useParams } from '@tanstack/react-router'; @@ -105,6 +108,8 @@ export function ClientDetailPage() { const { id } = useParams({ from: '/clientes/$id' }); const idNum = Number(id); const navigate = useNavigate(); + const screens = useBreakpoint(); + const isMobile = !screens.md; const [addContactOpen, setAddContactOpen] = useState(false); const [ordersModalOpen, setOrdersModalOpen] = useState(false); const [produtosModalOpen, setProdutosModalOpen] = useState(false); @@ -237,7 +242,7 @@ export function ClientDetailPage() { - + {client.nome} {client.cgcpf ?? '—'} {client.email ?? '—'} @@ -270,7 +275,14 @@ export function ClientDetailPage() { {/* ── CTR + NF-e lado a lado ── */} {msgCtx} -
+
{/* ── Contas a Receber ── */}
setNotasModalOpen(false)} footer={null} - width={900} + width={isMobile ? '95vw' : 900} centered destroyOnHidden > @@ -491,7 +503,7 @@ export function ClientDetailPage() { open={produtosModalOpen} onCancel={() => setProdutosModalOpen(false)} footer={null} - width={820} + width={isMobile ? '95vw' : 820} centered destroyOnHidden > @@ -554,7 +566,7 @@ export function ClientDetailPage() { open={ordersModalOpen} onCancel={() => setOrdersModalOpen(false)} footer={null} - width={780} + width={isMobile ? '95vw' : 780} centered destroyOnHidden > diff --git a/apps/web/src/cockpits/rep/ClientsPage.tsx b/apps/web/src/cockpits/rep/ClientsPage.tsx index 88697c3..e8e8821 100644 --- a/apps/web/src/cockpits/rep/ClientsPage.tsx +++ b/apps/web/src/cockpits/rep/ClientsPage.tsx @@ -409,6 +409,8 @@ function CustomerDetailsModal({ onAnalyze: () => void; }) { const navigate = useNavigate(); + const screens = useBreakpoint(); + const isMobile = !screens.md; const { data: detail, isLoading: loadingDetail } = useClientDetail(summary?.idCliente); const { data: orders = [], isLoading: loadingOrders } = useClientOrders(summary?.idCliente); @@ -451,7 +453,7 @@ function CustomerDetailsModal({ open={!!summary} onCancel={onClose} centered - width={860} + width={isMobile ? '95vw' : 860} destroyOnHidden styles={{ body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' }, @@ -741,6 +743,8 @@ function CustomerAnalysisModal({ summary: ClientSummary | null; onClose: () => void; }) { + const screens = useBreakpoint(); + const isMobile = !screens.md; const { data: detail } = useClientDetail(summary?.idCliente); if (!summary) return null; @@ -771,7 +775,7 @@ function CustomerAnalysisModal({ open={!!summary} onCancel={onClose} centered - width={720} + width={isMobile ? '95vw' : 720} destroyOnHidden styles={{ body: { padding: '20px 24px' } }} footer={ diff --git a/apps/web/src/cockpits/rep/OrderPrintPage.tsx b/apps/web/src/cockpits/rep/OrderPrintPage.tsx index b00f7f9..38b6520 100644 --- a/apps/web/src/cockpits/rep/OrderPrintPage.tsx +++ b/apps/web/src/cockpits/rep/OrderPrintPage.tsx @@ -61,16 +61,6 @@ const label: React.CSSProperties = { marginBottom: 2, }; -function Field({ k, v }: { k: string; v: React.ReactNode }) { - if (v == null || v === '' || v === '—') return null; - return ( -
- {k} - {v} -
- ); -} - export function OrderPrintPage() { const { id } = useParams({ from: '/pedidos/$id/imprimir' }); const navigate = useNavigate(); @@ -213,57 +203,94 @@ export function OrderPrintPage() {
- {/* ── Cliente + Representante ─────────────────────────────────────── */} -
-
+ {/* ── Representante + Cliente ─────────────────────────────────────── */} +
+ {/* Representante */} +
- CLIENTE + Representante
-
+
+ {tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`} +
+
Cód. {order.codVendedor}
+
+ + {/* Cliente */} +
+
+ Cliente +
+
{clienteNome}
{tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && ( -
{tx(client?.nome)}
+
{tx(client?.nome)}
)} -
- - - - - -
-
-
- REPRESENTANTE + {doc(client?.cgcpf) !== '—' && ( + + {docLabel}: {doc(client?.cgcpf)} + + )} + {tx(client?.inscricaoEstadual) && ( + + IE: {tx(client?.inscricaoEstadual)} + + )} + {enderecoCli && {enderecoCli}} + {phone(client?.telefone, client?.ddd) !== '—' && ( + + Tel:{' '} + {phone(client?.telefone, client?.ddd)} + + )} + {tx(client?.email) && {tx(client?.email)}}
-
- {tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`} -
- - -
{/* ── Itens ───────────────────────────────────────────────────────── */} -
+
diff --git a/apps/web/src/cockpits/rep/OrdersPage.tsx b/apps/web/src/cockpits/rep/OrdersPage.tsx index fcc28f1..defe6b7 100644 --- a/apps/web/src/cockpits/rep/OrdersPage.tsx +++ b/apps/web/src/cockpits/rep/OrdersPage.tsx @@ -77,6 +77,52 @@ function periodRange(p: string): { from?: string; to?: string } { return {}; } +// ─── WhatsApp ───────────────────────────────────────────────────────────────── + +function WhatsAppIcon({ size = 14 }: { size?: number }) { + return ( + + + + ); +} + +function buildWhatsAppUrl(order: { + id: string; + numPedSar: string; + situa: number; + statusDescr?: string | null; + total: number | string; + dtPedido: string; + razaoCliente?: string | null; + nomeCliente?: string | null; + idCliente: number | string; +}): string { + const nome = order.razaoCliente ?? order.nomeCliente ?? `Cód. ${order.idCliente}`; + const status = + order.statusDescr ?? + STATUS[order.situa]?.label ?? + SITUA_LABEL[order.situa] ?? + String(order.situa); + const pdfUrl = `${window.location.origin}/pedidos/${order.id}/imprimir`; + const text = [ + `*Pedido ${order.numPedSar}*`, + `Cliente: ${nome}`, + `Data: ${fmtDate(order.dtPedido)}`, + `Total: ${fmt(order.total)}`, + `Status: ${status}`, + ``, + `PDF: ${pdfUrl}`, + ].join('\n'); + return `https://wa.me/?text=${encodeURIComponent(text)}`; +} + // ─── Status Config ──────────────────────────────────────────────────────────── const STATUS: Record = { @@ -226,6 +272,16 @@ function OrderActionsMenu({ label: 'Gerar PDF', onClick: () => void navigate({ to: '/pedidos/$id/imprimir', params: { id: order.id } }), }, + { + key: 'whatsapp', + icon: ( + + + + ), + label: Enviar pelo WhatsApp, + onClick: () => window.open(buildWhatsAppUrl(order), '_blank'), + }, { key: 'cancel', icon: , @@ -247,6 +303,8 @@ function OrderActionsMenu({ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) { const navigate = useNavigate(); + const screens = useBreakpoint(); + const isMobile = !screens.md; const { data, isLoading } = useOrderDetail(id ?? undefined); const timelineItems = (data?.historico ?? []).map((h) => ({ @@ -291,7 +349,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v open={!!id} onCancel={onClose} centered - width={860} + width={isMobile ? '95vw' : 860} destroyOnHidden styles={{ body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' }, @@ -299,6 +357,19 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v footer={ + {data && ( + + )} {data && ( + ); diff --git a/apps/web/src/cockpits/rep/RelatoriosPage.tsx b/apps/web/src/cockpits/rep/RelatoriosPage.tsx index e60392f..86ad573 100644 --- a/apps/web/src/cockpits/rep/RelatoriosPage.tsx +++ b/apps/web/src/cockpits/rep/RelatoriosPage.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { + Alert, Badge, Card, Col, @@ -40,7 +41,7 @@ function TabMeta() { const [mes, setMes] = useState(now.month() + 1); const [ano, setAno] = useState(now.year()); - const { data, isLoading } = useReportMeta({ mes, ano }); + const { data, isLoading, isError, error } = useReportMeta({ mes, ano }); const realizado = Number(data?.realizado ?? 0); const meta = Number(data?.meta ?? 0); @@ -88,8 +89,15 @@ function TabMeta() {
+ ) : isError ? ( + ) : ( - +
realizado ? fmt(meta - realizado) : 'Atingida'} - valueStyle={{ color: meta > realizado ? '#faad14' : '#52c41a', fontSize: 18 }} + styles={{ + content: { color: meta > realizado ? '#faad14' : '#52c41a', fontSize: 18 }, + }} /> @@ -192,7 +202,7 @@ type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido'; function TabCarteira() { const [filtro, setFiltro] = useState('todos'); - const { data, isLoading } = useReportCarteira(); + const { data, isLoading, isError, error } = useReportCarteira(); const clientes = data?.data ?? []; @@ -230,7 +240,7 @@ function TabCarteira() { title: 'Cliente', key: 'cliente', render: (_: unknown, c: CarteiraCliente) => ( - + {c.razao ?? c.nome ?? `Cód. ${c.idCliente}`} @@ -294,6 +304,17 @@ function TabCarteira() { }, ]; + if (isError) { + return ( + + ); + } + return (
{data && ( @@ -318,7 +339,7 @@ function TabCarteira() { @@ -354,7 +375,7 @@ function TabAbc() { const [mes, setMes] = useState(now.month() + 1); const [ano, setAno] = useState(now.year()); - const { data, isLoading } = useReportAbc({ mes, ano }); + const { data, isLoading, isError, error } = useReportAbc({ mes, ano }); const meses = [ 'Jan', @@ -390,7 +411,7 @@ function TabAbc() { title: 'Produto', key: 'produto', render: (_: unknown, p: AbcProduto) => ( - + {p.codProduto && {p.codProduto}} {p.descProduto} @@ -402,7 +423,7 @@ function TabAbc() { width: 150, align: 'right' as const, render: (v: string, p: AbcProduto) => ( - + {fmt(v)} @@ -447,6 +468,17 @@ function TabAbc() { }, ]; + if (isError) { + return ( + + ); + } + return (
diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx index c826ec8..cdb1c73 100644 --- a/apps/web/src/components/layout/AppShell.tsx +++ b/apps/web/src/components/layout/AppShell.tsx @@ -1,25 +1,25 @@ -import { useState, type ReactNode } from 'react'; -import { Alert, Button, Flex, Tooltip } from 'antd'; +import { type ReactNode } from 'react'; +import { Alert, Button, Flex, Grid, Tooltip } from 'antd'; import { PlusOutlined, WifiOutlined } from '@ant-design/icons'; import { useNavigate } from '@tanstack/react-router'; import { Topbar } from './Topbar'; import { Sidebar } from './Sidebar'; +import { BottomNav } from './BottomNav'; import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus'; import { useOfflineSync } from '../../lib/hooks/useOfflineSync'; +const { useBreakpoint } = Grid; + interface AppShellProps { children: ReactNode; } -/** - * AppShell canônico do SAR — topbar 80 + sidebar 260 + área de conteúdo. - * Layout para cockpits Sandra/Daniel/Alice (desktop-first). - * Variante mobile (Rafael) com bottom nav virá em ShellMobile separado. - */ export function AppShell({ children }: AppShellProps) { - const [, setSidebarOpen] = useState(true); const navigate = useNavigate(); const isOnline = useNetworkStatus(); + const screens = useBreakpoint(); + // sidebar a partir de lg (992px) — abaixo disso usa bottom nav + const isDesktop = !!screens.lg; useOfflineSync(); return ( @@ -34,13 +34,16 @@ export function AppShell({ children }: AppShellProps) { style={{ padding: '6px 16px', fontSize: 13 }} /> )} - setSidebarOpen((v) => !v)} /> + - + {isDesktop && }
- {/* FAB — Novo Pedido */} + {!isDesktop && } + + {/* FAB — Novo Pedido: acima do bottom nav em mobile */} + ); + })} + + ); +} diff --git a/apps/web/src/components/layout/Sidebar.tsx b/apps/web/src/components/layout/Sidebar.tsx index 037a75f..45d5d2c 100644 --- a/apps/web/src/components/layout/Sidebar.tsx +++ b/apps/web/src/components/layout/Sidebar.tsx @@ -7,55 +7,116 @@ import { faClipboardCheck, faFunnelDollar, faUsers, + faAddressCard, faBoxesStacked, faFileInvoiceDollar, + faTags, } from '@fortawesome/free-solid-svg-icons'; import type { ItemType } from 'antd/es/menu/interface'; +import { authStore } from '../../lib/auth-store'; + +function getRole(): string { + const token = authStore.get(); + if (!token) return 'rep'; + try { + const payload = JSON.parse(atob(token.split('.')[1] ?? '')); + return (payload.role as string) ?? 'rep'; + } catch { + return 'rep'; + } +} + +const REP_ITEMS: ItemType[] = [ + { key: '/', icon: , label: 'Painel' }, + { key: '/clientes', icon: , label: 'Clientes' }, + { + key: '/catalogo', + icon: , + label: 'Catálogo', + }, + { + key: '/funil', + icon: , + label: 'Funil de Vendas', + }, + { + key: '/pedidos', + icon: , + label: 'Pedidos', + }, + { + key: '/pedidos-erp', + icon: , + label: 'Consulta ERP', + }, + { + key: '/relatorios', + icon: , + label: 'Relatórios', + }, +]; + +const SUPERVISOR_ITEMS: ItemType[] = [ + { key: '/', icon: , label: 'Painel' }, + { + key: '/aprovacoes', + icon: , + label: 'Aprovações', + }, + { key: '/clientes', icon: , label: 'Clientes' }, + { + key: '/pedidos', + icon: , + label: 'Pedidos', + }, + { + key: '/catalogo', + icon: , + label: 'Catálogo', + }, + { + key: '/relatorios', + icon: , + label: 'Relatórios', + }, +]; + +const GERENTE_ITEMS: ItemType[] = [ + { key: '/ger', icon: , label: 'Painel' }, + { key: '/ger/equipe', icon: , label: 'Equipe' }, + { + key: '/clientes', + icon: , + label: 'Clientes', + }, + { + key: '/relatorios', + icon: , + label: 'Relatórios', + }, + { + key: '/ger/politicas', + icon: , + label: 'Políticas Comerciais', + }, +]; + +function getItems(role: string): ItemType[] { + if (role === 'manager' || role === 'admin') return GERENTE_ITEMS; + if (role === 'supervisor') return SUPERVISOR_ITEMS; + return REP_ITEMS; +} -/** - * Sidebar canônica do SAR (260px fixa — brand.md). - * Itens mockados para Rafael cockpit. Variantes por cockpit virão depois. - */ export function Sidebar() { const navigate = useNavigate(); const location = useLocation(); - const items: ItemType[] = [ - { - key: '/', - icon: , - label: 'Painel', - }, - { - key: '/clientes', - icon: , - label: 'Clientes', - }, - { - key: '/catalogo', - icon: , - label: 'Catálogo', - }, - { - key: '/funil', - icon: , - label: 'Funil de Vendas', - }, - { - key: '/pedidos', - icon: , - label: 'Pedidos', - }, - { - key: '/pedidos-erp', - icon: , - label: 'Consulta ERP', - }, - { - key: '/relatorios', - icon: , - label: 'Relatórios', - }, - ]; + const role = getRole(); + const items = getItems(role); + + const selectedKey = items.find((item) => { + const key = (item as { key?: string }).key ?? ''; + return key === '/' ? location.pathname === '/' : location.pathname.startsWith(key); + })?.key as string | undefined; return (