feat(web+api): cockpit gerente — painel, equipe, políticas e positivação
## 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<SupervisorDashboard> {
|
||||
return this.dashboard.supervisorDashboard();
|
||||
}
|
||||
|
||||
@Get('manager')
|
||||
managerDashboard(
|
||||
@Query('mes') mes?: string,
|
||||
@Query('ano') ano?: string,
|
||||
): Promise<ManagerDashboard> {
|
||||
return this.dashboard.managerDashboard(
|
||||
mes ? parseInt(mes, 10) : undefined,
|
||||
ano ? parseInt(ano, 10) : undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<ManagerDashboard> {
|
||||
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<TotalRow[]>(`
|
||||
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<RankingRow[]>(`
|
||||
SELECT p.cod_vendedor,
|
||||
(SELECT r.nome FROM vw_representantes r
|
||||
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome,
|
||||
COUNT(*)::text AS pedidos,
|
||||
COUNT(DISTINCT p.id_cliente)::text AS clientes_atendidos,
|
||||
COALESCE(SUM(p.total), 0)::text AS faturamento,
|
||||
(SELECT SUM(m.valor)::text FROM sar.vw_metas m
|
||||
WHERE m.id_empresa = ${idEmpresaMatriz}
|
||||
AND m.cod_vendedor = p.cod_vendedor
|
||||
AND m.ano = ${year}
|
||||
AND m.mes = ${month}
|
||||
AND TRIM(m.tipo) = '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<PromoRow[]>(`
|
||||
SELECT COUNT(*)::text AS count
|
||||
FROM sar.promocoes
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND ativa = true
|
||||
AND data_fim >= CURRENT_DATE
|
||||
`),
|
||||
prisma.$queryRawUnsafe<MetaTotalRow[]>(`
|
||||
SELECT COALESCE(SUM(valor), 0)::text AS meta_total
|
||||
FROM sar.vw_metas
|
||||
WHERE id_empresa = ${idEmpresaMatriz}
|
||||
AND ano = ${year}
|
||||
AND mes = ${month}
|
||||
AND TRIM(tipo) = 'GR'
|
||||
`),
|
||||
prisma.$queryRawUnsafe<PositivacaoRow[]>(`
|
||||
SELECT
|
||||
c.cod_vendedor,
|
||||
(SELECT r.nome FROM vw_representantes r WHERE r.codigo = c.cod_vendedor LIMIT 1) AS nome_vendedor,
|
||||
COUNT(DISTINCT c.id_cliente)::text AS total_clientes,
|
||||
COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS clientes_positivados
|
||||
FROM vw_clientes c
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT id_cliente, cod_vendedor
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND situa NOT IN (1, 5)
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
AND dt_pedido <= '${monthEndStr}'
|
||||
) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor
|
||||
WHERE c.cod_vendedor > 0
|
||||
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<SupervisorDashboard> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
13
apps/api/src/app/equipe/equipe.controller.ts
Normal file
13
apps/api/src/app/equipe/equipe.controller.ts
Normal file
@@ -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<EquipeResponse> {
|
||||
return this.equipe.listEquipe();
|
||||
}
|
||||
}
|
||||
9
apps/api/src/app/equipe/equipe.module.ts
Normal file
9
apps/api/src/app/equipe/equipe.module.ts
Normal file
@@ -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 {}
|
||||
113
apps/api/src/app/equipe/equipe.service.ts
Normal file
113
apps/api/src/app/equipe/equipe.service.ts
Normal file
@@ -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<WorkspaceClsStore>) {}
|
||||
|
||||
async listEquipe(): Promise<EquipeResponse> {
|
||||
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<RepRow[]>(
|
||||
`SELECT codigo AS cod_vendedor, nome FROM vw_representantes ORDER BY nome`,
|
||||
),
|
||||
prisma.$queryRawUnsafe<PedidosRepRow[]>(`
|
||||
SELECT cod_vendedor,
|
||||
COUNT(*)::text AS pedidos,
|
||||
COALESCE(SUM(total), 0)::text AS faturamento
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND situa NOT IN (1, 5)
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
AND dt_pedido <= '${monthEndStr}'
|
||||
GROUP BY cod_vendedor
|
||||
`),
|
||||
prisma.$queryRawUnsafe<MetaRepRow[]>(`
|
||||
SELECT cod_vendedor,
|
||||
SUM(valor)::text AS meta_valor
|
||||
FROM sar.vw_metas
|
||||
WHERE id_empresa = ${idEmpresaMatriz}
|
||||
AND ano = ${year}
|
||||
AND mes = ${month}
|
||||
AND TRIM(tipo) = 'GR'
|
||||
GROUP BY cod_vendedor
|
||||
`),
|
||||
]);
|
||||
|
||||
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<number>();
|
||||
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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
67
apps/api/src/app/politicas/politicas.controller.ts
Normal file
67
apps/api/src/app/politicas/politicas.controller.ts
Normal file
@@ -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<AlcadaDescontosResponse> {
|
||||
return this.politicas.listDescontos();
|
||||
}
|
||||
|
||||
@Post('descontos')
|
||||
@HttpCode(200)
|
||||
upsertDesconto(@Body() body: UpsertDescontoDto): Promise<void> {
|
||||
return this.politicas.upsertDesconto(body as unknown as UpsertDescontoBody);
|
||||
}
|
||||
|
||||
@Get('promocoes')
|
||||
listPromocoes(): Promise<PromocoesResponse> {
|
||||
return this.politicas.listPromocoes();
|
||||
}
|
||||
|
||||
@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<void> {
|
||||
return this.politicas.updatePromocao(id, body as unknown as UpdatePromocaoBody);
|
||||
}
|
||||
|
||||
@Delete('promocoes/:id')
|
||||
@HttpCode(204)
|
||||
deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.politicas.deletePromocao(id);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/app/politicas/politicas.module.ts
Normal file
9
apps/api/src/app/politicas/politicas.module.ts
Normal file
@@ -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 {}
|
||||
161
apps/api/src/app/politicas/politicas.service.ts
Normal file
161
apps/api/src/app/politicas/politicas.service.ts
Normal file
@@ -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<WorkspaceClsStore>) {}
|
||||
|
||||
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<AlcadaDescontosResponse> {
|
||||
this.assertManager();
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
const rows = await prisma.alcadaDesconto.findMany({
|
||||
where: { idEmpresa },
|
||||
orderBy: [{ codVendedor: 'asc' }, { codGrupo: 'asc' }],
|
||||
});
|
||||
|
||||
// Busca nomes dos representantes
|
||||
const codigos = [...new Set(rows.map((r) => r.codVendedor))];
|
||||
const repRows =
|
||||
codigos.length > 0
|
||||
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
|
||||
)
|
||||
: [];
|
||||
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
|
||||
|
||||
return {
|
||||
descontos: rows.map((r) => ({
|
||||
codVendedor: r.codVendedor,
|
||||
codGrupo: r.codGrupo,
|
||||
limitePerc: Number(r.limitePerc),
|
||||
nomeVendedor: repNameMap.get(r.codVendedor) ?? null,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async upsertDesconto(body: UpsertDescontoBody): Promise<void> {
|
||||
this.assertManager();
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
await prisma.alcadaDesconto.upsert({
|
||||
where: {
|
||||
codVendedor_idEmpresa_codGrupo: {
|
||||
codVendedor: body.codVendedor,
|
||||
idEmpresa,
|
||||
codGrupo: body.codGrupo ?? 0,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
codVendedor: body.codVendedor,
|
||||
idEmpresa,
|
||||
codGrupo: body.codGrupo ?? 0,
|
||||
limitePerc: body.limitePerc,
|
||||
},
|
||||
update: { limitePerc: body.limitePerc },
|
||||
});
|
||||
}
|
||||
|
||||
async listPromocoes(): Promise<PromocoesResponse> {
|
||||
this.assertManager();
|
||||
const prisma = this.cls.get('prisma');
|
||||
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<void> {
|
||||
this.assertManager();
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
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<void> {
|
||||
this.assertManager();
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
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 } });
|
||||
}
|
||||
}
|
||||
122
apps/web/src/cockpits/ger/EquipePage.tsx
Normal file
122
apps/web/src/cockpits/ger/EquipePage.tsx
Normal file
@@ -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<RepStats> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, row: RepStats) => (
|
||||
<Flex vertical gap={0}>
|
||||
<Text strong>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
||||
{row.nomeVendedor && (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
cód. {row.codVendedor}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'pedidosMes',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
sorter: (a, b) => a.pedidosMes - b.pedidosMes,
|
||||
render: (v: number) => (v === 0 ? <Tag>0</Tag> : v),
|
||||
},
|
||||
{
|
||||
title: 'Faturamento',
|
||||
dataIndex: 'faturamentoMes',
|
||||
width: 160,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
sorter: (a, b) => a.faturamentoMes - b.faturamentoMes,
|
||||
defaultSortOrder: 'descend',
|
||||
render: (v: number) => fmt(v),
|
||||
},
|
||||
{
|
||||
title: 'Ticket Medio',
|
||||
dataIndex: 'ticketMedio',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
sorter: (a, b) => a.ticketMedio - b.ticketMedio,
|
||||
render: (v: number) => (v > 0 ? fmt(v) : <Text type="secondary">--</Text>),
|
||||
},
|
||||
{
|
||||
title: '% Meta',
|
||||
dataIndex: 'pctMeta',
|
||||
width: 170,
|
||||
sorter: (a, b) => a.pctMeta - b.pctMeta,
|
||||
render: (pct: number) =>
|
||||
pct === 0 ? (
|
||||
<Text type="secondary">sem meta</Text>
|
||||
) : (
|
||||
<Flex align="center" gap={8}>
|
||||
<Progress
|
||||
percent={Math.min(pct, 100)}
|
||||
size="small"
|
||||
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
||||
showInfo={false}
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function EquipePage() {
|
||||
const { data, isLoading } = useEquipe();
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
<Skeleton active paragraph={{ rows: 1 }} />
|
||||
<Skeleton active paragraph={{ rows: 6 }} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
Equipe
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
Performance dos representantes — {mes}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Card>
|
||||
<Table<RepStats>
|
||||
rowKey="codVendedor"
|
||||
columns={columns}
|
||||
dataSource={data.reps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhum representante encontrado.' }}
|
||||
scroll={{ x: 700 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
|
||||
</Text>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
428
apps/web/src/cockpits/ger/GerPainel.tsx
Normal file
428
apps/web/src/cockpits/ger/GerPainel.tsx
Normal file
@@ -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<PositivacaoRep> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Positivados / Carteira',
|
||||
key: 'pos',
|
||||
width: 160,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: PositivacaoRep) => (
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{row.clientesPositivados} / {row.totalClientes}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '% Positivação',
|
||||
dataIndex: 'pctPositivacao',
|
||||
width: 170,
|
||||
render: (pct: number) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Progress
|
||||
percent={Math.min(pct, 100)}
|
||||
size="small"
|
||||
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
|
||||
showInfo={false}
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const rankingColumns: TableColumnsType<RankingRep> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'pedidos',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: 'Clientes',
|
||||
dataIndex: 'clientesAtendidos',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: 'Faturamento',
|
||||
dataIndex: 'faturamento',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: (v: number) => fmt(v),
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: '% Meta',
|
||||
dataIndex: 'pctMeta',
|
||||
width: 160,
|
||||
render: (pct: number) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Progress
|
||||
percent={Math.min(pct, 100)}
|
||||
size="small"
|
||||
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
||||
showInfo={false}
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function GerPainel() {
|
||||
const [periodo, setPeriodo] = useState<Dayjs>(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 (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
<Skeleton active paragraph={{ rows: 1 }} />
|
||||
<Row gutter={[24, 24]}>
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Col key={i} xs={24} sm={12} lg={6}>
|
||||
<Skeleton active />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
{/* Cabeçalho */}
|
||||
<Flex align="flex-start" justify="space-between" wrap="wrap" gap={12}>
|
||||
<Flex vertical gap={4}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
Painel Gerencial
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{today()}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex align="center" gap={8}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
Período:
|
||||
</Text>
|
||||
<DatePicker
|
||||
picker="month"
|
||||
value={periodo}
|
||||
onChange={(d) => d && setPeriodo(d)}
|
||||
format="MMM/YYYY"
|
||||
allowClear={false}
|
||||
disabledDate={(d) => d.isAfter(dayjs(), 'month')}
|
||||
style={{ width: 130 }}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* KPIs linha 1 */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(faturamentoMes)}
|
||||
</Title>
|
||||
<FontAwesomeIcon
|
||||
icon={faFileInvoiceDollar}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
PEDIDOS NO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{pedidosMes}
|
||||
</Title>
|
||||
<FontAwesomeIcon
|
||||
icon={faChartBar}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
TICKET MÉDIO
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(ticketMedio)}
|
||||
</Title>
|
||||
<FontAwesomeIcon icon={faUsers} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={6}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
PROMOÇÕES ATIVAS
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{promocoesAtivas}
|
||||
</Title>
|
||||
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--jcs-blue)', opacity: 0.5 }} />
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* KPIs linha 2 — Meta */}
|
||||
{metaTotal > 0 && (
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(metaTotal)}
|
||||
</Title>
|
||||
<FontAwesomeIcon
|
||||
icon={faBullseye}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card>
|
||||
<Flex vertical gap={8}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
ATINGIMENTO DA META
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowTrendUp}
|
||||
style={{ color: metaCorColor, opacity: 0.7 }}
|
||||
/>
|
||||
</Flex>
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: metaCorColor }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{pctMeta}%
|
||||
</Title>
|
||||
<Progress
|
||||
percent={Math.min(pctMeta, 100)}
|
||||
strokeColor={metaCorColor}
|
||||
showInfo={false}
|
||||
size="small"
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
|
||||
</Text>
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
|
||||
</Title>
|
||||
{superouMeta > 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{pctMeta - 100}% acima da meta
|
||||
</Text>
|
||||
) : vendaPorDia > 0 ? (
|
||||
<Text
|
||||
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
|
||||
>
|
||||
{fmt(vendaPorDia)}/dia — {diasRestantes} dia
|
||||
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
) : diasRestantes === 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
período encerrado
|
||||
</Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Ranking */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faChartBar} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Ranking de Representantes — {periodoLabel}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Flex align="center" gap={12}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
top 10 de {totalReps} rep{totalReps !== 1 ? 's' : ''} com pedidos
|
||||
</Text>
|
||||
<Button size="small" onClick={() => void navigate({ to: '/ger/equipe' })}>
|
||||
Ver todos
|
||||
</Button>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Table<RankingRep>
|
||||
rowKey="codVendedor"
|
||||
columns={rankingColumns}
|
||||
dataSource={rankingReps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhum pedido registrado neste período.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Positivação por Representante */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faAddressCard} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Positivação de Clientes por Representante — {periodoLabel}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Table<PositivacaoRep>
|
||||
rowKey="codVendedor"
|
||||
columns={positivacaoColumns}
|
||||
dataSource={positivacaoReps}
|
||||
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhum dado de carteira encontrado.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Flex justify="space-between" style={{ paddingTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
SAR · Força de Vendas · Powered by JCS Sistemas
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')}
|
||||
{isMesAtual && ' · atualiza a cada 60s'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
393
apps/web/src/cockpits/ger/PoliticasPage.tsx
Normal file
393
apps/web/src/cockpits/ger/PoliticasPage.tsx
Normal file
@@ -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<string | null>(null);
|
||||
const [editValue, setEditValue] = useState<number>(0);
|
||||
const [msg, msgCtx] = message.useMessage();
|
||||
|
||||
function startEdit(row: AlcadaDescontoItem) {
|
||||
setEditingKey(`${row.codVendedor}-${row.codGrupo}`);
|
||||
setEditValue(row.limitePerc);
|
||||
}
|
||||
|
||||
async function saveEdit(row: AlcadaDescontoItem) {
|
||||
await upsert.mutateAsync({
|
||||
codVendedor: row.codVendedor,
|
||||
codGrupo: row.codGrupo,
|
||||
limitePerc: editValue,
|
||||
});
|
||||
setEditingKey(null);
|
||||
void msg.success('Limite atualizado');
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<AlcadaDescontoItem> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, row: AlcadaDescontoItem) =>
|
||||
row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||
},
|
||||
{
|
||||
title: 'Grupo',
|
||||
dataIndex: 'codGrupo',
|
||||
width: 100,
|
||||
render: (v: number) => (v === 0 ? <Tag>Global</Tag> : <Tag color="blue">Grupo {v}</Tag>),
|
||||
},
|
||||
{
|
||||
title: 'Limite de Desconto',
|
||||
dataIndex: 'limitePerc',
|
||||
width: 200,
|
||||
render: (v: number, row: AlcadaDescontoItem) => {
|
||||
const key = `${row.codVendedor}-${row.codGrupo}`;
|
||||
if (editingKey === key) {
|
||||
return (
|
||||
<InputNumber
|
||||
value={editValue}
|
||||
onChange={(val) => setEditValue(val ?? 0)}
|
||||
min={0}
|
||||
max={100}
|
||||
suffix="%"
|
||||
size="small"
|
||||
style={{ width: 100 }}
|
||||
autoFocus
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <Text className="tabular-nums">{v}%</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 120,
|
||||
render: (_: unknown, row: AlcadaDescontoItem) => {
|
||||
const key = `${row.codVendedor}-${row.codGrupo}`;
|
||||
if (editingKey === key) {
|
||||
return (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
loading={upsert.isPending}
|
||||
onClick={() => void saveEdit(row)}
|
||||
>
|
||||
Salvar
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setEditingKey(null)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FontAwesomeIcon icon={faPen} />}
|
||||
onClick={() => startEdit(row)}
|
||||
>
|
||||
Editar
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{msgCtx}
|
||||
<Table<AlcadaDescontoItem>
|
||||
rowKey={(r) => `${r.codVendedor}-${r.codGrupo}`}
|
||||
columns={columns}
|
||||
dataSource={data.descontos}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhum limite configurado.' }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
||||
Limite 0 = sem restricao de desconto para este representante.
|
||||
</Text>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab Promocoes ────────────────────────────────────────────────────────────
|
||||
|
||||
interface PromocaoForm {
|
||||
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<PromocaoForm>();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<Promocao | null>(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<Promocao> = [
|
||||
{
|
||||
title: 'Descricao',
|
||||
dataIndex: 'descricao',
|
||||
render: (v: string, row: Promocao) => (
|
||||
<Flex vertical gap={2}>
|
||||
<Text>{v}</Text>
|
||||
{(row.codProduto || row.grpProd) && (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{row.codProduto ? `Produto: ${row.codProduto}` : `Grupo: ${row.grpProd}`}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Desconto',
|
||||
dataIndex: 'descPct',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v: number) => <Text className="tabular-nums">{v}%</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Vigencia',
|
||||
width: 200,
|
||||
render: (_: unknown, row: Promocao) => (
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
width: 90,
|
||||
render: (_: unknown, row: Promocao) => {
|
||||
if (!row.ativa) return <Tag>Inativa</Tag>;
|
||||
if (row.dataFim < today) return <Tag color="red">Expirada</Tag>;
|
||||
if (row.dataInicio > today) return <Tag color="blue">Futura</Tag>;
|
||||
return <Tag color="green">Ativa</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 100,
|
||||
render: (_: unknown, row: Promocao) => (
|
||||
<Space>
|
||||
<Tooltip title="Editar">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FontAwesomeIcon icon={faPen} />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Remover promocao?"
|
||||
okText="Sim"
|
||||
cancelText="Nao"
|
||||
onConfirm={() => void handleDelete(row.id)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{msgCtx}
|
||||
<Flex justify="flex-end" style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<FontAwesomeIcon icon={faPlus} />} onClick={openCreate}>
|
||||
Nova Promocao
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Table<Promocao>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data.promocoes}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhuma promocao cadastrada.' }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editTarget ? 'Editar Promocao' : 'Nova Promocao'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||
width={520}
|
||||
centered
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="descricao"
|
||||
label="Descricao"
|
||||
rules={[{ required: true, message: 'Informe a descricao' }]}
|
||||
>
|
||||
<Input placeholder="Ex.: Promocao de inverno" />
|
||||
</Form.Item>
|
||||
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="codProduto" label="Codigo do produto" style={{ flex: 1 }}>
|
||||
<Input placeholder="Opcional" />
|
||||
</Form.Item>
|
||||
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
|
||||
<Input placeholder="Opcional" />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
|
||||
<Form.Item
|
||||
name="descPct"
|
||||
label="Desconto (%)"
|
||||
rules={[{ required: true, message: 'Informe o desconto' }]}
|
||||
>
|
||||
<InputNumber min={0} max={100} suffix="%" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="periodo"
|
||||
label="Periodo de vigencia"
|
||||
rules={[{ required: true, message: 'Informe o periodo' }]}
|
||||
>
|
||||
<DatePicker.RangePicker
|
||||
format="DD/MM/YYYY"
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['Inicio', 'Fim']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PoliticasPage ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function PoliticasPage() {
|
||||
const items = [
|
||||
{
|
||||
key: 'descontos',
|
||||
label: 'Desconto por Representante',
|
||||
children: <TabDescontos />,
|
||||
},
|
||||
{
|
||||
key: 'promocoes',
|
||||
label: 'Promocoes',
|
||||
children: <TabPromocoes />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
Politicas Comerciais
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
Limites de desconto e promocoes com vigencia
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Card styles={{ body: { paddingTop: 0 } }}>
|
||||
<Tabs items={items} />
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -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<number | null>(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() {
|
||||
</div>
|
||||
|
||||
{/* ── Filtros ──────────────────────────────────────────────────── */}
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Space style={{ marginBottom: 16, width: '100%' }} wrap>
|
||||
<Search
|
||||
placeholder="Buscar por código ou descrição..."
|
||||
allowClear
|
||||
style={{ width: 300 }}
|
||||
style={{ width: isMobile ? '100%' : 300 }}
|
||||
onSearch={(v) => {
|
||||
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);
|
||||
|
||||
@@ -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() {
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions bordered size="small" column={isMobile ? 1 : 2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="Razão Social">{client.nome}</Descriptions.Item>
|
||||
<Descriptions.Item label="CNPJ / CPF">{client.cgcpf ?? '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
|
||||
@@ -270,7 +275,14 @@ export function ClientDetailPage() {
|
||||
|
||||
{/* ── CTR + NF-e lado a lado ── */}
|
||||
{msgCtx}
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16, marginBottom: 24 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : '1fr 1fr',
|
||||
gap: 16,
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
{/* ── Contas a Receber ── */}
|
||||
<div>
|
||||
<div
|
||||
@@ -425,7 +437,7 @@ export function ClientDetailPage() {
|
||||
open={notasModalOpen}
|
||||
onCancel={() => 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
|
||||
>
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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 (
|
||||
<div style={{ marginBottom: 5 }}>
|
||||
<span style={label}>{k}</span>
|
||||
<span style={{ fontSize: 11.5, color: INK }}>{v}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function OrderPrintPage() {
|
||||
const { id } = useParams({ from: '/pedidos/$id/imprimir' });
|
||||
const navigate = useNavigate();
|
||||
@@ -213,57 +203,94 @@ export function OrderPrintPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Cliente + Representante ─────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', gap: 0 }}>
|
||||
<div style={{ flex: 1.4, padding: '16px 28px', borderRight: `1px solid ${LINE}` }}>
|
||||
{/* ── Representante + Cliente ─────────────────────────────────────── */}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
color: BLUE,
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: 10,
|
||||
display: 'flex',
|
||||
borderBottom: `1px solid ${LINE}`,
|
||||
background: '#FAFBFD',
|
||||
}}
|
||||
>
|
||||
CLIENTE
|
||||
{/* Representante */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: '10px 20px 10px 28px',
|
||||
borderRight: `1px solid ${LINE}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 8.5,
|
||||
fontWeight: 800,
|
||||
color: BLUE,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Representante
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 2 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: INK, lineHeight: 1.3 }}>
|
||||
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
|
||||
</div>
|
||||
<div style={{ fontSize: 10, color: MUTED, marginTop: 2 }}>Cód. {order.codVendedor}</div>
|
||||
</div>
|
||||
|
||||
{/* Cliente */}
|
||||
<div style={{ flex: 2.2, padding: '10px 28px 10px 20px' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 8.5,
|
||||
fontWeight: 800,
|
||||
color: BLUE,
|
||||
letterSpacing: '0.12em',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
Cliente
|
||||
</div>
|
||||
<div style={{ fontSize: 12, fontWeight: 700, color: INK, lineHeight: 1.3 }}>
|
||||
{clienteNome}
|
||||
</div>
|
||||
{tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && (
|
||||
<div style={{ fontSize: 11, color: MUTED, marginBottom: 8 }}>{tx(client?.nome)}</div>
|
||||
<div style={{ fontSize: 10, color: MUTED, lineHeight: 1.3 }}>{tx(client?.nome)}</div>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Field k={docLabel} v={doc(client?.cgcpf)} />
|
||||
<Field k="Inscr. Estadual" v={tx(client?.inscricaoEstadual)} />
|
||||
<Field k="Endereço" v={enderecoCli} />
|
||||
<Field k="Telefone" v={phone(client?.telefone, client?.ddd)} />
|
||||
<Field k="E-mail" v={tx(client?.email)} />
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ flex: 1, padding: '16px 28px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '2px 18px',
|
||||
marginTop: 4,
|
||||
fontSize: 10,
|
||||
fontWeight: 800,
|
||||
color: BLUE,
|
||||
letterSpacing: '0.1em',
|
||||
marginBottom: 10,
|
||||
color: MUTED,
|
||||
}}
|
||||
>
|
||||
REPRESENTANTE
|
||||
{doc(client?.cgcpf) !== '—' && (
|
||||
<span>
|
||||
<strong style={{ color: INK }}>{docLabel}:</strong> {doc(client?.cgcpf)}
|
||||
</span>
|
||||
)}
|
||||
{tx(client?.inscricaoEstadual) && (
|
||||
<span>
|
||||
<strong style={{ color: INK }}>IE:</strong> {tx(client?.inscricaoEstadual)}
|
||||
</span>
|
||||
)}
|
||||
{enderecoCli && <span>{enderecoCli}</span>}
|
||||
{phone(client?.telefone, client?.ddd) !== '—' && (
|
||||
<span>
|
||||
<strong style={{ color: INK }}>Tel:</strong>{' '}
|
||||
{phone(client?.telefone, client?.ddd)}
|
||||
</span>
|
||||
)}
|
||||
{tx(client?.email) && <span>{tx(client?.email)}</span>}
|
||||
</div>
|
||||
<div style={{ fontSize: 13.5, fontWeight: 700, color: INK, marginBottom: 8 }}>
|
||||
{tx(order.nomeVendedor) ?? `Cód. ${order.codVendedor}`}
|
||||
</div>
|
||||
<Field k="Código" v={String(order.codVendedor)} />
|
||||
<Field k="Data do pedido" v={dateBR(order.dtPedido)} />
|
||||
<Field k="Nº do pedido" v={order.numPedSar} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Itens ───────────────────────────────────────────────────────── */}
|
||||
<div style={{ padding: '8px 28px 0' }}>
|
||||
<div style={{ padding: '0 28px 0' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#F4F7FB' }}>
|
||||
|
||||
@@ -77,6 +77,52 @@ function periodRange(p: string): { from?: string; to?: string } {
|
||||
return {};
|
||||
}
|
||||
|
||||
// ─── WhatsApp ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function WhatsAppIcon({ size = 14 }: { size?: number }) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
style={{ verticalAlign: '-2px' }}
|
||||
>
|
||||
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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<number, { label: string; color: string; rowBg: string; tagColor: string }> = {
|
||||
@@ -226,6 +272,16 @@ function OrderActionsMenu({
|
||||
label: 'Gerar PDF',
|
||||
onClick: () => void navigate({ to: '/pedidos/$id/imprimir', params: { id: order.id } }),
|
||||
},
|
||||
{
|
||||
key: 'whatsapp',
|
||||
icon: (
|
||||
<span style={{ color: '#25D366' }}>
|
||||
<WhatsAppIcon />
|
||||
</span>
|
||||
),
|
||||
label: <span style={{ color: '#25D366', fontWeight: 600 }}>Enviar pelo WhatsApp</span>,
|
||||
onClick: () => window.open(buildWhatsAppUrl(order), '_blank'),
|
||||
},
|
||||
{
|
||||
key: 'cancel',
|
||||
icon: <CloseCircleOutlined />,
|
||||
@@ -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={
|
||||
<Space>
|
||||
<Button onClick={onClose}>Fechar</Button>
|
||||
{data && (
|
||||
<Button
|
||||
icon={
|
||||
<span style={{ color: '#25D366' }}>
|
||||
<WhatsAppIcon />
|
||||
</span>
|
||||
}
|
||||
style={{ color: '#25D366', borderColor: '#25D366' }}
|
||||
onClick={() => window.open(buildWhatsAppUrl(data), '_blank')}
|
||||
>
|
||||
WhatsApp
|
||||
</Button>
|
||||
)}
|
||||
{data && (
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -514,6 +585,18 @@ function MobileOrderCard({
|
||||
>
|
||||
Duplicar
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
icon={
|
||||
<span style={{ color: '#25D366' }}>
|
||||
<WhatsAppIcon />
|
||||
</span>
|
||||
}
|
||||
style={{ borderRadius: 6 }}
|
||||
onClick={() => window.open(buildWhatsAppUrl(order), '_blank')}
|
||||
>
|
||||
WhatsApp
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -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() {
|
||||
<div style={{ textAlign: 'center', padding: 48 }}>
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
) : isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Erro ao carregar desempenho"
|
||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||
/>
|
||||
) : (
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Card
|
||||
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||
styles={{ body: { padding: '24px 28px' } }}
|
||||
@@ -138,28 +146,30 @@ function TabMeta() {
|
||||
<Statistic
|
||||
title="Pedidos Transmitidos"
|
||||
value={data?.totalPedidos ?? 0}
|
||||
valueStyle={{ color: '#003B8E' }}
|
||||
styles={{ content: { color: '#003B8E' } }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="Comissão Estimada"
|
||||
value={fmt(comissao)}
|
||||
valueStyle={{ color: '#52c41a', fontSize: 20 }}
|
||||
styles={{ content: { color: '#52c41a', fontSize: 20 } }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="Falta para Meta"
|
||||
value={meta > realizado ? fmt(meta - realizado) : 'Atingida'}
|
||||
valueStyle={{ color: meta > realizado ? '#faad14' : '#52c41a', fontSize: 18 }}
|
||||
styles={{
|
||||
content: { color: meta > realizado ? '#faad14' : '#52c41a', fontSize: 18 },
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Statistic
|
||||
title="% Atingido"
|
||||
value={`${percentual.toFixed(1)}%`}
|
||||
valueStyle={{ color: progressColor, fontSize: 18 }}
|
||||
styles={{ content: { color: progressColor, fontSize: 18 } }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
@@ -192,7 +202,7 @@ type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
|
||||
|
||||
function TabCarteira() {
|
||||
const [filtro, setFiltro] = useState<FiltroCarteira>('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) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text strong style={{ fontSize: 13 }}>
|
||||
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
|
||||
</Text>
|
||||
@@ -294,6 +304,17 @@ function TabCarteira() {
|
||||
},
|
||||
];
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Erro ao carregar carteira de clientes"
|
||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data && (
|
||||
@@ -318,7 +339,7 @@ function TabCarteira() {
|
||||
<Statistic
|
||||
title={item.label}
|
||||
value={item.value}
|
||||
valueStyle={{ color: item.color, fontSize: 24 }}
|
||||
styles={{ content: { color: item.color, fontSize: 24 } }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
@@ -354,7 +375,7 @@ function TabAbc() {
|
||||
const [mes, setMes] = useState<number | undefined>(now.month() + 1);
|
||||
const [ano, setAno] = useState<number | undefined>(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) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space orientation="vertical" size={0}>
|
||||
{p.codProduto && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{p.codProduto}</Text>}
|
||||
<Text style={{ fontSize: 13, fontWeight: 500 }}>{p.descProduto}</Text>
|
||||
</Space>
|
||||
@@ -402,7 +423,7 @@ function TabAbc() {
|
||||
width: 150,
|
||||
align: 'right' as const,
|
||||
render: (v: string, p: AbcProduto) => (
|
||||
<Space direction="vertical" size={0} style={{ alignItems: 'flex-end' }}>
|
||||
<Space orientation="vertical" size={0} style={{ alignItems: 'flex-end' }}>
|
||||
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||
{fmt(v)}
|
||||
</Text>
|
||||
@@ -447,6 +468,17 @@ function TabAbc() {
|
||||
},
|
||||
];
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Erro ao carregar curva ABC"
|
||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space style={{ marginBottom: 20 }}>
|
||||
|
||||
@@ -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 }}
|
||||
/>
|
||||
)}
|
||||
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} />
|
||||
<Topbar />
|
||||
<Flex flex={1} style={{ overflow: 'hidden' }}>
|
||||
<Sidebar />
|
||||
{isDesktop && <Sidebar />}
|
||||
<main
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: 'var(--space-xl)',
|
||||
padding: isDesktop ? 'var(--space-xl)' : 'var(--space-md)',
|
||||
paddingBottom: isDesktop
|
||||
? 'var(--space-xl)'
|
||||
: 'calc(var(--layout-bottom-nav-height) + var(--space-lg))',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
minWidth: 0,
|
||||
@@ -50,7 +53,9 @@ export function AppShell({ children }: AppShellProps) {
|
||||
</main>
|
||||
</Flex>
|
||||
|
||||
{/* FAB — Novo Pedido */}
|
||||
{!isDesktop && <BottomNav />}
|
||||
|
||||
{/* FAB — Novo Pedido: acima do bottom nav em mobile */}
|
||||
<Tooltip title="Novo Pedido" placement="left">
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -59,8 +64,8 @@ export function AppShell({ children }: AppShellProps) {
|
||||
onClick={() => void navigate({ to: '/pedidos/novo' })}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 32,
|
||||
right: 32,
|
||||
bottom: isDesktop ? 32 : 'calc(var(--layout-bottom-nav-height) + 16px)',
|
||||
right: 20,
|
||||
width: 52,
|
||||
height: 52,
|
||||
fontSize: 22,
|
||||
|
||||
119
apps/web/src/components/layout/BottomNav.tsx
Normal file
119
apps/web/src/components/layout/BottomNav.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
import { useLocation, useNavigate } from '@tanstack/react-router';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faChartLine,
|
||||
faClipboardList,
|
||||
faClipboardCheck,
|
||||
faUsers,
|
||||
faAddressCard,
|
||||
faBoxesStacked,
|
||||
faFileInvoiceDollar,
|
||||
faTags,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import type { IconDefinition } from '@fortawesome/free-solid-svg-icons';
|
||||
import { authStore } from '../../lib/auth-store';
|
||||
|
||||
interface NavItem {
|
||||
key: string;
|
||||
icon: IconDefinition;
|
||||
label: string;
|
||||
}
|
||||
|
||||
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: NavItem[] = [
|
||||
{ key: '/', icon: faChartLine, label: 'Painel' },
|
||||
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
||||
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
|
||||
{ key: '/catalogo', icon: faBoxesStacked, label: 'Catálogo' },
|
||||
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
||||
];
|
||||
|
||||
const SUPERVISOR_ITEMS: NavItem[] = [
|
||||
{ key: '/', icon: faChartLine, label: 'Painel' },
|
||||
{ key: '/aprovacoes', icon: faClipboardCheck, label: 'Aprovações' },
|
||||
{ key: '/pedidos', icon: faClipboardList, label: 'Pedidos' },
|
||||
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
||||
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
||||
];
|
||||
|
||||
const GERENTE_ITEMS: NavItem[] = [
|
||||
{ key: '/ger', icon: faChartLine, label: 'Painel' },
|
||||
{ key: '/ger/equipe', icon: faUsers, label: 'Equipe' },
|
||||
{ key: '/clientes', icon: faAddressCard, label: 'Clientes' },
|
||||
{ key: '/relatorios', icon: faFileInvoiceDollar, label: 'Relatórios' },
|
||||
{ key: '/ger/politicas', icon: faTags, label: 'Políticas' },
|
||||
];
|
||||
|
||||
function getItems(role: string): NavItem[] {
|
||||
if (role === 'manager' || role === 'admin') return GERENTE_ITEMS;
|
||||
if (role === 'supervisor') return SUPERVISOR_ITEMS;
|
||||
return REP_ITEMS;
|
||||
}
|
||||
|
||||
export function BottomNav() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const role = getRole();
|
||||
const items = getItems(role);
|
||||
|
||||
return (
|
||||
<nav
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: 'var(--layout-bottom-nav-height)',
|
||||
background: 'var(--bg-surface)',
|
||||
borderTop: '1px solid var(--border-subtle)',
|
||||
display: 'flex',
|
||||
zIndex: 'var(--z-fixed)' as unknown as number,
|
||||
boxShadow: '0 -2px 12px rgba(0,0,0,0.06)',
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isActive =
|
||||
item.key === '/' || item.key === '/ger'
|
||||
? location.pathname === item.key
|
||||
: location.pathname.startsWith(item.key);
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
onClick={() => void navigate({ to: item.key })}
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: 3,
|
||||
border: 'none',
|
||||
background: 'none',
|
||||
cursor: 'pointer',
|
||||
padding: '6px 4px',
|
||||
color: isActive ? 'var(--jcs-blue)' : 'var(--text-muted)',
|
||||
fontSize: 10,
|
||||
fontWeight: isActive ? 600 : 400,
|
||||
fontFamily: 'var(--font-family-base)',
|
||||
transition: 'color var(--duration-fast) var(--easing-standard)',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
}}
|
||||
>
|
||||
<FontAwesomeIcon icon={item.icon} style={{ fontSize: 20 }} />
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -7,29 +7,28 @@ 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';
|
||||
|
||||
/**
|
||||
* 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: <FontAwesomeIcon icon={faChartLine} fixedWidth />,
|
||||
label: 'Painel',
|
||||
},
|
||||
{
|
||||
key: '/clientes',
|
||||
icon: <FontAwesomeIcon icon={faUsers} fixedWidth />,
|
||||
label: 'Clientes',
|
||||
},
|
||||
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: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
|
||||
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
|
||||
{
|
||||
key: '/catalogo',
|
||||
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
||||
@@ -57,6 +56,68 @@ export function Sidebar() {
|
||||
},
|
||||
];
|
||||
|
||||
const SUPERVISOR_ITEMS: ItemType[] = [
|
||||
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
|
||||
{
|
||||
key: '/aprovacoes',
|
||||
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
|
||||
label: 'Aprovações',
|
||||
},
|
||||
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
|
||||
{
|
||||
key: '/pedidos',
|
||||
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
||||
label: 'Pedidos',
|
||||
},
|
||||
{
|
||||
key: '/catalogo',
|
||||
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
||||
label: 'Catálogo',
|
||||
},
|
||||
{
|
||||
key: '/relatorios',
|
||||
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
||||
label: 'Relatórios',
|
||||
},
|
||||
];
|
||||
|
||||
const GERENTE_ITEMS: ItemType[] = [
|
||||
{ key: '/ger', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
|
||||
{ key: '/ger/equipe', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Equipe' },
|
||||
{
|
||||
key: '/clientes',
|
||||
icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />,
|
||||
label: 'Clientes',
|
||||
},
|
||||
{
|
||||
key: '/relatorios',
|
||||
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
||||
label: 'Relatórios',
|
||||
},
|
||||
{
|
||||
key: '/ger/politicas',
|
||||
icon: <FontAwesomeIcon icon={faTags} fixedWidth />,
|
||||
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;
|
||||
}
|
||||
|
||||
export function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
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 (
|
||||
<aside
|
||||
style={{
|
||||
@@ -73,7 +134,7 @@ export function Sidebar() {
|
||||
>
|
||||
<Menu
|
||||
mode="inline"
|
||||
selectedKeys={[location.pathname]}
|
||||
selectedKeys={selectedKey ? [selectedKey] : [location.pathname]}
|
||||
items={items}
|
||||
onClick={({ key }) => navigate({ to: key as string })}
|
||||
style={{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Avatar, Badge, Button, Dropdown, Flex, Typography } from 'antd';
|
||||
import { Avatar, Badge, Button, Dropdown, Flex, Grid, Typography } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faBell, faBars, faBuilding, faRightFromBracket } from '@fortawesome/free-solid-svg-icons';
|
||||
import { faBell, faBuilding, faRightFromBracket } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { brandTokens } from '../../lib/theme';
|
||||
import { FoundationStatus } from './FoundationStatus';
|
||||
@@ -10,22 +10,17 @@ import { useCurrentUser } from '../../lib/queries/auth';
|
||||
import { useCompany } from '../../lib/queries/company';
|
||||
import { authStore } from '../../lib/auth-store';
|
||||
|
||||
interface TopbarProps {
|
||||
onToggleSidebar?: () => void;
|
||||
}
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
/**
|
||||
* Topbar canônica do SAR (80px de altura — brand.md).
|
||||
* Apple-inspired clean: logo à esquerda, search central, notif + perfil à direita.
|
||||
* Variantes por cockpit: search desabilitada para Rafael mobile (vai pro bottom nav).
|
||||
*/
|
||||
function logout() {
|
||||
authStore.clear();
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
export function Topbar() {
|
||||
const navigate = useNavigate();
|
||||
const screens = useBreakpoint();
|
||||
const isDesktop = !!screens.lg;
|
||||
const { data: pendingData } = usePendingCount();
|
||||
const pendingCount = pendingData?.count ?? 0;
|
||||
const { data: user } = useCurrentUser();
|
||||
@@ -76,26 +71,20 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
height: 'var(--layout-topbar-height)',
|
||||
padding: '0 var(--space-lg)',
|
||||
height: isDesktop ? 'var(--layout-topbar-height)' : 56,
|
||||
padding: isDesktop ? '0 var(--space-lg)' : '0 var(--space-md)',
|
||||
background: 'var(--bg-surface)',
|
||||
borderBottom: '1px solid var(--border-subtle)',
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 'var(--z-sticky)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{/* Lado esquerdo: hamburger (mobile) + logo */}
|
||||
<Flex align="center" gap={16}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<FontAwesomeIcon icon={faBars} />}
|
||||
onClick={onToggleSidebar}
|
||||
aria-label="Alternar menu"
|
||||
style={{ display: 'inline-flex' }}
|
||||
/>
|
||||
<Flex align="center" gap={12}>
|
||||
<img src="/sar-icon.png" alt="SAR" style={{ height: 40, width: 'auto' }} />
|
||||
{/* Logo */}
|
||||
<Flex align="center" gap={isDesktop ? 12 : 8}>
|
||||
<img src="/sar-icon.png" alt="SAR" style={{ height: isDesktop ? 40 : 32, width: 'auto' }} />
|
||||
{isDesktop && (
|
||||
<Flex vertical gap={0}>
|
||||
<span
|
||||
style={{
|
||||
@@ -119,29 +108,36 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
Força de Vendas
|
||||
</span>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Centro: empresa ativa */}
|
||||
<Flex flex={1} justify="center" style={{ margin: '0 var(--space-2xl)' }}>
|
||||
<Flex align="center" gap={10}>
|
||||
<Flex
|
||||
flex={1}
|
||||
justify="center"
|
||||
style={{
|
||||
margin: `0 ${isDesktop ? 'var(--space-2xl)' : 'var(--space-sm)'}`,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<Flex align="center" gap={isDesktop ? 10 : 6} style={{ overflow: 'hidden' }}>
|
||||
<FontAwesomeIcon
|
||||
icon={faBuilding}
|
||||
style={{ color: 'var(--text-muted)', fontSize: 16, flexShrink: 0 }}
|
||||
style={{ color: 'var(--text-muted)', fontSize: isDesktop ? 16 : 13, flexShrink: 0 }}
|
||||
/>
|
||||
<Flex vertical gap={0}>
|
||||
<Flex vertical gap={0} style={{ overflow: 'hidden' }}>
|
||||
<Typography.Text
|
||||
ellipsis
|
||||
style={{
|
||||
fontSize: 'var(--text-xl)',
|
||||
fontSize: isDesktop ? 'var(--text-xl)' : 'var(--text-sm)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
color: 'var(--text-main)',
|
||||
lineHeight: 1.2,
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{nomeEmpresa}
|
||||
</Typography.Text>
|
||||
{mostrarRazao && (
|
||||
{isDesktop && mostrarRazao && (
|
||||
<Typography.Text
|
||||
style={{
|
||||
fontSize: 'var(--text-2xs)',
|
||||
@@ -157,8 +153,9 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Lado direito: novo pedido + status fundação + notificações + perfil */}
|
||||
<Flex align="center" gap={16}>
|
||||
{/* Lado direito */}
|
||||
<Flex align="center" gap={isDesktop ? 16 : 8}>
|
||||
{isDesktop && (
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
@@ -167,23 +164,25 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
|
||||
>
|
||||
Novo Pedido
|
||||
</Button>
|
||||
<FoundationStatus />
|
||||
)}
|
||||
{isDesktop && <FoundationStatus />}
|
||||
<Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}>
|
||||
<Button
|
||||
type="text"
|
||||
size="large"
|
||||
size={isDesktop ? 'large' : 'middle'}
|
||||
icon={<FontAwesomeIcon icon={faBell} />}
|
||||
aria-label="Notificações"
|
||||
/>
|
||||
</Badge>
|
||||
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
|
||||
<Avatar
|
||||
size={40}
|
||||
size={isDesktop ? 40 : 32}
|
||||
style={{
|
||||
background: 'var(--jcs-blue-light)',
|
||||
color: 'var(--jcs-blue)',
|
||||
fontWeight: 'var(--font-weight-semibold)',
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
|
||||
108
apps/web/src/lib/queries/gerente.ts
Normal file
108
apps/web/src/lib/queries/gerente.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ManagerDashboardSchema,
|
||||
EquipeResponseSchema,
|
||||
AlcadaDescontosResponseSchema,
|
||||
PromocoesResponseSchema,
|
||||
type ManagerDashboard,
|
||||
type EquipeResponse,
|
||||
type AlcadaDescontosResponse,
|
||||
type PromocoesResponse,
|
||||
type UpsertDescontoBody,
|
||||
type CreatePromocaoBody,
|
||||
type UpdatePromocaoBody,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
export function useManagerDashboard(mes?: number, ano?: number) {
|
||||
const params = new URLSearchParams();
|
||||
if (mes) params.set('mes', String(mes));
|
||||
if (ano) params.set('ano', String(ano));
|
||||
const qs = params.size > 0 ? `?${params.toString()}` : '';
|
||||
return useQuery<ManagerDashboard>({
|
||||
queryKey: ['dashboard', 'manager', mes, ano],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch(`/dashboard/manager${qs}`);
|
||||
return ManagerDashboardSchema.parse(raw);
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEquipe() {
|
||||
return useQuery<EquipeResponse>({
|
||||
queryKey: ['equipe'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/equipe');
|
||||
return EquipeResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePoliticasDescontos() {
|
||||
return useQuery<AlcadaDescontosResponse>({
|
||||
queryKey: ['politicas', 'descontos'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/descontos');
|
||||
return AlcadaDescontosResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function usePromocoes() {
|
||||
return useQuery<PromocoesResponse>({
|
||||
queryKey: ['politicas', 'promocoes'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/promocoes');
|
||||
return PromocoesResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertDesconto() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: UpsertDescontoBody) =>
|
||||
apiFetch('/politicas/descontos', { method: 'POST', body: JSON.stringify(body) }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreatePromocao() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreatePromocaoBody) =>
|
||||
apiFetch('/politicas/promocoes', { method: 'POST', body: JSON.stringify(body) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePromocao() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) =>
|
||||
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeletePromocao() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiFetch(`/politicas/promocoes/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -20,6 +20,9 @@ import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
||||
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
|
||||
import { GerPainel } from '../cockpits/ger/GerPainel';
|
||||
import { EquipePage } from '../cockpits/ger/EquipePage';
|
||||
import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
|
||||
import { authStore } from './auth-store';
|
||||
|
||||
function getRoleFromToken(): string {
|
||||
@@ -35,7 +38,9 @@ function getRoleFromToken(): string {
|
||||
|
||||
function HomeRoute() {
|
||||
const role = getRoleFromToken();
|
||||
return role === 'supervisor' || role === 'manager' ? <SupervisorPainel /> : <RepPainel />;
|
||||
if (role === 'manager' || role === 'admin') return <GerPainel />;
|
||||
if (role === 'supervisor') return <SupervisorPainel />;
|
||||
return <RepPainel />;
|
||||
}
|
||||
|
||||
function NotFoundPage() {
|
||||
@@ -138,6 +143,24 @@ const aprovacoes = createRoute({
|
||||
component: ApprovalQueuePage,
|
||||
});
|
||||
|
||||
const gerPainelRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/ger',
|
||||
component: GerPainel,
|
||||
});
|
||||
|
||||
const equipeRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/ger/equipe',
|
||||
component: EquipePage,
|
||||
});
|
||||
|
||||
const politicasRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/ger/politicas',
|
||||
component: PoliticasPage,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
rafaelRoute,
|
||||
@@ -152,6 +175,9 @@ const routeTree = rootRoute.addChildren([
|
||||
pedidosErpRoute,
|
||||
relatoriosRoute,
|
||||
aprovacoes,
|
||||
gerPainelRoute,
|
||||
equipeRoute,
|
||||
politicasRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineConfig(() => ({
|
||||
cacheDir: '../../node_modules/.vite/apps/web',
|
||||
server: {
|
||||
port: 4200,
|
||||
host: 'localhost',
|
||||
host: '0.0.0.0',
|
||||
// Proxy /api/* → API Nest em :3000 (default API_PORT).
|
||||
// Evita CORS em dev e mantém URL relativa no código da Web — em produção,
|
||||
// mesmo origin via Nginx (/api/* → backend).
|
||||
@@ -22,7 +22,7 @@ export default defineConfig(() => ({
|
||||
},
|
||||
preview: {
|
||||
port: 4200,
|
||||
host: 'localhost',
|
||||
host: '0.0.0.0',
|
||||
},
|
||||
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
|
||||
// Uncomment this if you are using workers.
|
||||
|
||||
@@ -7,3 +7,5 @@ export * from './lib/dashboard.contract';
|
||||
export * from './lib/notifications.contract';
|
||||
export * from './lib/company.contract';
|
||||
export * from './lib/report.contract';
|
||||
export * from './lib/politicas.contract';
|
||||
export * from './lib/equipe.contract';
|
||||
|
||||
@@ -75,3 +75,35 @@ export const SupervisorDashboardSchema = z.object({
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
|
||||
|
||||
export const PositivacaoRepSchema = z.object({
|
||||
codVendedor: z.number().int(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
totalClientes: z.number().int(),
|
||||
clientesPositivados: z.number().int(),
|
||||
pctPositivacao: z.number(),
|
||||
});
|
||||
export type PositivacaoRep = z.infer<typeof PositivacaoRepSchema>;
|
||||
|
||||
export const RankingRepSchema = z.object({
|
||||
codVendedor: z.number().int(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
pedidos: z.number().int(),
|
||||
clientesAtendidos: z.number().int(),
|
||||
faturamento: z.number(),
|
||||
pctMeta: z.number(),
|
||||
});
|
||||
export type RankingRep = z.infer<typeof RankingRepSchema>;
|
||||
|
||||
export const ManagerDashboardSchema = z.object({
|
||||
faturamentoMes: z.number(),
|
||||
pedidosMes: z.number().int(),
|
||||
ticketMedio: z.number(),
|
||||
totalReps: z.number().int(),
|
||||
promocoesAtivas: z.number().int(),
|
||||
metaTotal: z.number(),
|
||||
rankingReps: z.array(RankingRepSchema),
|
||||
positivacaoReps: z.array(PositivacaoRepSchema),
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;
|
||||
|
||||
18
libs/shared/api-interface/src/lib/equipe.contract.ts
Normal file
18
libs/shared/api-interface/src/lib/equipe.contract.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const RepStatsSchema = z.object({
|
||||
codVendedor: z.number().int(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
pedidosMes: z.number().int(),
|
||||
faturamentoMes: z.number(),
|
||||
ticketMedio: z.number(),
|
||||
pctMeta: z.number(),
|
||||
});
|
||||
export type RepStats = z.infer<typeof RepStatsSchema>;
|
||||
|
||||
export const EquipeResponseSchema = z.object({
|
||||
reps: z.array(RepStatsSchema),
|
||||
totalReps: z.number().int(),
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
export type EquipeResponse = z.infer<typeof EquipeResponseSchema>;
|
||||
55
libs/shared/api-interface/src/lib/politicas.contract.ts
Normal file
55
libs/shared/api-interface/src/lib/politicas.contract.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const AlcadaDescontoItemSchema = z.object({
|
||||
codVendedor: z.number().int(),
|
||||
codGrupo: z.number().int(),
|
||||
limitePerc: z.number(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
});
|
||||
export type AlcadaDescontoItem = z.infer<typeof AlcadaDescontoItemSchema>;
|
||||
|
||||
export const AlcadaDescontosResponseSchema = z.object({
|
||||
descontos: z.array(AlcadaDescontoItemSchema),
|
||||
});
|
||||
export type AlcadaDescontosResponse = z.infer<typeof AlcadaDescontosResponseSchema>;
|
||||
|
||||
export const UpsertDescontoBodySchema = z.object({
|
||||
codVendedor: z.number().int().positive(),
|
||||
codGrupo: z.number().int().default(0),
|
||||
limitePerc: z.number().min(0).max(100),
|
||||
});
|
||||
export type UpsertDescontoBody = z.infer<typeof UpsertDescontoBodySchema>;
|
||||
|
||||
export const PromocaoSchema = z.object({
|
||||
id: z.number().int(),
|
||||
descricao: z.string(),
|
||||
codProduto: z.string().nullable(),
|
||||
grpProd: z.string().nullable(),
|
||||
descPct: z.number(),
|
||||
dataInicio: z.string(),
|
||||
dataFim: z.string(),
|
||||
ativa: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
createdBy: z.string(),
|
||||
});
|
||||
export type Promocao = z.infer<typeof PromocaoSchema>;
|
||||
|
||||
export const PromocoesResponseSchema = z.object({
|
||||
promocoes: z.array(PromocaoSchema),
|
||||
});
|
||||
export type PromocoesResponse = z.infer<typeof PromocoesResponseSchema>;
|
||||
|
||||
export const CreatePromocaoBodySchema = z.object({
|
||||
descricao: z.string().min(1).max(255),
|
||||
codProduto: z.string().optional(),
|
||||
grpProd: z.string().optional(),
|
||||
descPct: z.number().min(0).max(100),
|
||||
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
dataFim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
});
|
||||
export type CreatePromocaoBody = z.infer<typeof CreatePromocaoBodySchema>;
|
||||
|
||||
export const UpdatePromocaoBodySchema = CreatePromocaoBodySchema.partial().extend({
|
||||
ativa: z.boolean().optional(),
|
||||
});
|
||||
export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
|
||||
Reference in New Issue
Block a user