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:
2026-06-25 19:14:57 +00:00
parent 400fcb3360
commit 289c1a071e
30 changed files with 2270 additions and 171 deletions

View File

@@ -147,6 +147,29 @@ model MetaRepresentante {
@@map("meta_representante") @@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) ─────────────────────────────────────────────────── // ─── PushSubscription (C6) ───────────────────────────────────────────────────
// //
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser. // Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.

View File

@@ -14,6 +14,8 @@ import { CatalogModule } from './catalog/catalog.module';
import { DashboardModule } from './dashboard/dashboard.module'; import { DashboardModule } from './dashboard/dashboard.module';
import { NotificationsModule } from './notifications/notifications.module'; import { NotificationsModule } from './notifications/notifications.module';
import { ReportsModule } from './reports/reports.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'; import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({ @Module({
@@ -31,6 +33,8 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
DashboardModule, DashboardModule,
NotificationsModule, NotificationsModule,
ReportsModule, ReportsModule,
PoliticasModule,
EquipeModule,
], ],
providers: [ providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe }, { provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -1,6 +1,6 @@
import { Controller, Get } from '@nestjs/common'; import { Controller, Get, Query } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; 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 type { WorkspaceClsStore } from '../workspace/workspace.types';
import { DashboardService } from './dashboard.service'; import { DashboardService } from './dashboard.service';
@@ -20,4 +20,15 @@ export class DashboardController {
supervisorDashboard(): Promise<SupervisorDashboard> { supervisorDashboard(): Promise<SupervisorDashboard> {
return this.dashboard.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,
);
}
} }

View File

@@ -1,6 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; 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'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado // 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> { async supervisorDashboard(): Promise<SupervisorDashboard> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');

View 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();
}
}

View 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 {}

View 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(),
};
}
}

View 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);
}
}

View 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 {}

View 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 } });
}
}

View 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 &mdash; {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>
);
}

View 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 &mdash; {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 &mdash; {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 &mdash; {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 &middot; Força de Vendas &middot; 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>
);
}

View 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>
);
}

View File

@@ -1,5 +1,7 @@
import { useState } from 'react'; 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 { EyeOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface'; import type { ProdutoSummary } from '@sar/api-interface';
@@ -94,6 +96,8 @@ export function CatalogPage() {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [selectedIdErp, setSelectedIdErp] = useState<number | null>(null); const [selectedIdErp, setSelectedIdErp] = useState<number | null>(null);
const limit = 50; const limit = 50;
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: pautas, isLoading: pautasLoading } = usePautas(); const { data: pautas, isLoading: pautasLoading } = usePautas();
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit }); const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
@@ -113,11 +117,11 @@ export function CatalogPage() {
</div> </div>
{/* ── Filtros ──────────────────────────────────────────────────── */} {/* ── Filtros ──────────────────────────────────────────────────── */}
<Space style={{ marginBottom: 16 }} wrap> <Space style={{ marginBottom: 16, width: '100%' }} wrap>
<Search <Search
placeholder="Buscar por código ou descrição..." placeholder="Buscar por código ou descrição..."
allowClear allowClear
style={{ width: 300 }} style={{ width: isMobile ? '100%' : 300 }}
onSearch={(v) => { onSearch={(v) => {
setQ(v); setQ(v);
setPage(1); setPage(1);
@@ -133,7 +137,7 @@ export function CatalogPage() {
placeholder="Selecionar pauta de preços" placeholder="Selecionar pauta de preços"
allowClear allowClear
loading={pautasLoading} loading={pautasLoading}
style={{ width: 340 }} style={{ width: isMobile ? '100%' : 340 }}
onChange={(v) => { onChange={(v) => {
setIdPauta(v as number | undefined); setIdPauta(v as number | undefined);
setPage(1); setPage(1);

View File

@@ -1,6 +1,7 @@
import { import {
Button, Button,
Descriptions, Descriptions,
Grid,
Tag, Tag,
Table, Table,
Typography, Typography,
@@ -14,6 +15,8 @@ import {
Badge, Badge,
} from 'antd'; } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
const { useBreakpoint } = Grid;
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons'; import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
import { Link, useNavigate, useParams } from '@tanstack/react-router'; import { Link, useNavigate, useParams } from '@tanstack/react-router';
@@ -105,6 +108,8 @@ export function ClientDetailPage() {
const { id } = useParams({ from: '/clientes/$id' }); const { id } = useParams({ from: '/clientes/$id' });
const idNum = Number(id); const idNum = Number(id);
const navigate = useNavigate(); const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const [addContactOpen, setAddContactOpen] = useState(false); const [addContactOpen, setAddContactOpen] = useState(false);
const [ordersModalOpen, setOrdersModalOpen] = useState(false); const [ordersModalOpen, setOrdersModalOpen] = useState(false);
const [produtosModalOpen, setProdutosModalOpen] = useState(false); const [produtosModalOpen, setProdutosModalOpen] = useState(false);
@@ -237,7 +242,7 @@ export function ClientDetailPage() {
</Button> </Button>
</Space> </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="Razão Social">{client.nome}</Descriptions.Item>
<Descriptions.Item label="CNPJ / CPF">{client.cgcpf ?? '—'}</Descriptions.Item> <Descriptions.Item label="CNPJ / CPF">{client.cgcpf ?? '—'}</Descriptions.Item>
<Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item> <Descriptions.Item label="E-mail">{client.email ?? '—'}</Descriptions.Item>
@@ -270,7 +275,14 @@ export function ClientDetailPage() {
{/* ── CTR + NF-e lado a lado ── */} {/* ── CTR + NF-e lado a lado ── */}
{msgCtx} {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 ── */} {/* ── Contas a Receber ── */}
<div> <div>
<div <div
@@ -425,7 +437,7 @@ export function ClientDetailPage() {
open={notasModalOpen} open={notasModalOpen}
onCancel={() => setNotasModalOpen(false)} onCancel={() => setNotasModalOpen(false)}
footer={null} footer={null}
width={900} width={isMobile ? '95vw' : 900}
centered centered
destroyOnHidden destroyOnHidden
> >
@@ -491,7 +503,7 @@ export function ClientDetailPage() {
open={produtosModalOpen} open={produtosModalOpen}
onCancel={() => setProdutosModalOpen(false)} onCancel={() => setProdutosModalOpen(false)}
footer={null} footer={null}
width={820} width={isMobile ? '95vw' : 820}
centered centered
destroyOnHidden destroyOnHidden
> >
@@ -554,7 +566,7 @@ export function ClientDetailPage() {
open={ordersModalOpen} open={ordersModalOpen}
onCancel={() => setOrdersModalOpen(false)} onCancel={() => setOrdersModalOpen(false)}
footer={null} footer={null}
width={780} width={isMobile ? '95vw' : 780}
centered centered
destroyOnHidden destroyOnHidden
> >

View File

@@ -409,6 +409,8 @@ function CustomerDetailsModal({
onAnalyze: () => void; onAnalyze: () => void;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: detail, isLoading: loadingDetail } = useClientDetail(summary?.idCliente); const { data: detail, isLoading: loadingDetail } = useClientDetail(summary?.idCliente);
const { data: orders = [], isLoading: loadingOrders } = useClientOrders(summary?.idCliente); const { data: orders = [], isLoading: loadingOrders } = useClientOrders(summary?.idCliente);
@@ -451,7 +453,7 @@ function CustomerDetailsModal({
open={!!summary} open={!!summary}
onCancel={onClose} onCancel={onClose}
centered centered
width={860} width={isMobile ? '95vw' : 860}
destroyOnHidden destroyOnHidden
styles={{ styles={{
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' }, body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
@@ -741,6 +743,8 @@ function CustomerAnalysisModal({
summary: ClientSummary | null; summary: ClientSummary | null;
onClose: () => void; onClose: () => void;
}) { }) {
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data: detail } = useClientDetail(summary?.idCliente); const { data: detail } = useClientDetail(summary?.idCliente);
if (!summary) return null; if (!summary) return null;
@@ -771,7 +775,7 @@ function CustomerAnalysisModal({
open={!!summary} open={!!summary}
onCancel={onClose} onCancel={onClose}
centered centered
width={720} width={isMobile ? '95vw' : 720}
destroyOnHidden destroyOnHidden
styles={{ body: { padding: '20px 24px' } }} styles={{ body: { padding: '20px 24px' } }}
footer={ footer={

View File

@@ -61,16 +61,6 @@ const label: React.CSSProperties = {
marginBottom: 2, 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() { export function OrderPrintPage() {
const { id } = useParams({ from: '/pedidos/$id/imprimir' }); const { id } = useParams({ from: '/pedidos/$id/imprimir' });
const navigate = useNavigate(); const navigate = useNavigate();
@@ -213,57 +203,94 @@ export function OrderPrintPage() {
</div> </div>
</div> </div>
{/* ── Cliente + Representante ─────────────────────────────────────── */} {/* ── Representante + Cliente ─────────────────────────────────────── */}
<div style={{ display: 'flex', gap: 0 }}>
<div style={{ flex: 1.4, padding: '16px 28px', borderRight: `1px solid ${LINE}` }}>
<div <div
style={{ style={{
fontSize: 10, display: 'flex',
fontWeight: 800, borderBottom: `1px solid ${LINE}`,
color: BLUE, background: '#FAFBFD',
letterSpacing: '0.1em',
marginBottom: 10,
}} }}
> >
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>
<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} {clienteNome}
</div> </div>
{tx(client?.nome) && tx(client?.razao) && tx(client?.nome) !== tx(client?.razao) && ( {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 <div
style={{ style={{
display: 'flex',
flexWrap: 'wrap',
gap: '2px 18px',
marginTop: 4,
fontSize: 10, fontSize: 10,
fontWeight: 800, color: MUTED,
color: BLUE,
letterSpacing: '0.1em',
marginBottom: 10,
}} }}
> >
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>
<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>
</div> </div>
{/* ── Itens ───────────────────────────────────────────────────────── */} {/* ── Itens ───────────────────────────────────────────────────────── */}
<div style={{ padding: '8px 28px 0' }}> <div style={{ padding: '0 28px 0' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}> <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 11 }}>
<thead> <thead>
<tr style={{ background: '#F4F7FB' }}> <tr style={{ background: '#F4F7FB' }}>

View File

@@ -77,6 +77,52 @@ function periodRange(p: string): { from?: string; to?: string } {
return {}; 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 ──────────────────────────────────────────────────────────── // ─── Status Config ────────────────────────────────────────────────────────────
const STATUS: Record<number, { label: string; color: string; rowBg: string; tagColor: string }> = { const STATUS: Record<number, { label: string; color: string; rowBg: string; tagColor: string }> = {
@@ -226,6 +272,16 @@ function OrderActionsMenu({
label: 'Gerar PDF', label: 'Gerar PDF',
onClick: () => void navigate({ to: '/pedidos/$id/imprimir', params: { id: order.id } }), 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', key: 'cancel',
icon: <CloseCircleOutlined />, icon: <CloseCircleOutlined />,
@@ -247,6 +303,8 @@ function OrderActionsMenu({
function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) { function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => void }) {
const navigate = useNavigate(); const navigate = useNavigate();
const screens = useBreakpoint();
const isMobile = !screens.md;
const { data, isLoading } = useOrderDetail(id ?? undefined); const { data, isLoading } = useOrderDetail(id ?? undefined);
const timelineItems = (data?.historico ?? []).map((h) => ({ const timelineItems = (data?.historico ?? []).map((h) => ({
@@ -291,7 +349,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
open={!!id} open={!!id}
onCancel={onClose} onCancel={onClose}
centered centered
width={860} width={isMobile ? '95vw' : 860}
destroyOnHidden destroyOnHidden
styles={{ styles={{
body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' }, body: { padding: '20px 24px', maxHeight: 'calc(85vh - 110px)', overflowY: 'auto' },
@@ -299,6 +357,19 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
footer={ footer={
<Space> <Space>
<Button onClick={onClose}>Fechar</Button> <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 && ( {data && (
<Button <Button
type="primary" type="primary"
@@ -514,6 +585,18 @@ function MobileOrderCard({
> >
Duplicar Duplicar
</Button> </Button>
<Button
size="small"
icon={
<span style={{ color: '#25D366' }}>
<WhatsAppIcon />
</span>
}
style={{ borderRadius: 6 }}
onClick={() => window.open(buildWhatsAppUrl(order), '_blank')}
>
WhatsApp
</Button>
</div> </div>
</Card> </Card>
); );

View File

@@ -1,5 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { import {
Alert,
Badge, Badge,
Card, Card,
Col, Col,
@@ -40,7 +41,7 @@ function TabMeta() {
const [mes, setMes] = useState(now.month() + 1); const [mes, setMes] = useState(now.month() + 1);
const [ano, setAno] = useState(now.year()); 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 realizado = Number(data?.realizado ?? 0);
const meta = Number(data?.meta ?? 0); const meta = Number(data?.meta ?? 0);
@@ -88,8 +89,15 @@ function TabMeta() {
<div style={{ textAlign: 'center', padding: 48 }}> <div style={{ textAlign: 'center', padding: 48 }}>
<Spin size="large" /> <Spin size="large" />
</div> </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 <Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }} style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: '24px 28px' } }} styles={{ body: { padding: '24px 28px' } }}
@@ -138,28 +146,30 @@ function TabMeta() {
<Statistic <Statistic
title="Pedidos Transmitidos" title="Pedidos Transmitidos"
value={data?.totalPedidos ?? 0} value={data?.totalPedidos ?? 0}
valueStyle={{ color: '#003B8E' }} styles={{ content: { color: '#003B8E' } }}
/> />
</Col> </Col>
<Col span={12}> <Col span={12}>
<Statistic <Statistic
title="Comissão Estimada" title="Comissão Estimada"
value={fmt(comissao)} value={fmt(comissao)}
valueStyle={{ color: '#52c41a', fontSize: 20 }} styles={{ content: { color: '#52c41a', fontSize: 20 } }}
/> />
</Col> </Col>
<Col span={12}> <Col span={12}>
<Statistic <Statistic
title="Falta para Meta" title="Falta para Meta"
value={meta > realizado ? fmt(meta - realizado) : 'Atingida'} 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>
<Col span={12}> <Col span={12}>
<Statistic <Statistic
title="% Atingido" title="% Atingido"
value={`${percentual.toFixed(1)}%`} value={`${percentual.toFixed(1)}%`}
valueStyle={{ color: progressColor, fontSize: 18 }} styles={{ content: { color: progressColor, fontSize: 18 } }}
/> />
</Col> </Col>
</Row> </Row>
@@ -192,7 +202,7 @@ type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
function TabCarteira() { function TabCarteira() {
const [filtro, setFiltro] = useState<FiltroCarteira>('todos'); const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
const { data, isLoading } = useReportCarteira(); const { data, isLoading, isError, error } = useReportCarteira();
const clientes = data?.data ?? []; const clientes = data?.data ?? [];
@@ -230,7 +240,7 @@ function TabCarteira() {
title: 'Cliente', title: 'Cliente',
key: 'cliente', key: 'cliente',
render: (_: unknown, c: CarteiraCliente) => ( render: (_: unknown, c: CarteiraCliente) => (
<Space direction="vertical" size={0}> <Space orientation="vertical" size={0}>
<Text strong style={{ fontSize: 13 }}> <Text strong style={{ fontSize: 13 }}>
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`} {c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
</Text> </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 ( return (
<div> <div>
{data && ( {data && (
@@ -318,7 +339,7 @@ function TabCarteira() {
<Statistic <Statistic
title={item.label} title={item.label}
value={item.value} value={item.value}
valueStyle={{ color: item.color, fontSize: 24 }} styles={{ content: { color: item.color, fontSize: 24 } }}
/> />
</Card> </Card>
</Col> </Col>
@@ -354,7 +375,7 @@ function TabAbc() {
const [mes, setMes] = useState<number | undefined>(now.month() + 1); const [mes, setMes] = useState<number | undefined>(now.month() + 1);
const [ano, setAno] = useState<number | undefined>(now.year()); const [ano, setAno] = useState<number | undefined>(now.year());
const { data, isLoading } = useReportAbc({ mes, ano }); const { data, isLoading, isError, error } = useReportAbc({ mes, ano });
const meses = [ const meses = [
'Jan', 'Jan',
@@ -390,7 +411,7 @@ function TabAbc() {
title: 'Produto', title: 'Produto',
key: 'produto', key: 'produto',
render: (_: unknown, p: AbcProduto) => ( 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>} {p.codProduto && <Text style={{ fontSize: 11, color: '#94A3B8' }}>{p.codProduto}</Text>}
<Text style={{ fontSize: 13, fontWeight: 500 }}>{p.descProduto}</Text> <Text style={{ fontSize: 13, fontWeight: 500 }}>{p.descProduto}</Text>
</Space> </Space>
@@ -402,7 +423,7 @@ function TabAbc() {
width: 150, width: 150,
align: 'right' as const, align: 'right' as const,
render: (v: string, p: AbcProduto) => ( 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' }}> <Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)} {fmt(v)}
</Text> </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 ( return (
<div> <div>
<Space style={{ marginBottom: 20 }}> <Space style={{ marginBottom: 20 }}>

View File

@@ -1,25 +1,25 @@
import { useState, type ReactNode } from 'react'; import { type ReactNode } from 'react';
import { Alert, Button, Flex, Tooltip } from 'antd'; import { Alert, Button, Flex, Grid, Tooltip } from 'antd';
import { PlusOutlined, WifiOutlined } from '@ant-design/icons'; import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { Topbar } from './Topbar'; import { Topbar } from './Topbar';
import { Sidebar } from './Sidebar'; import { Sidebar } from './Sidebar';
import { BottomNav } from './BottomNav';
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus'; import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
import { useOfflineSync } from '../../lib/hooks/useOfflineSync'; import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
const { useBreakpoint } = Grid;
interface AppShellProps { interface AppShellProps {
children: ReactNode; 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) { export function AppShell({ children }: AppShellProps) {
const [, setSidebarOpen] = useState(true);
const navigate = useNavigate(); const navigate = useNavigate();
const isOnline = useNetworkStatus(); const isOnline = useNetworkStatus();
const screens = useBreakpoint();
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
const isDesktop = !!screens.lg;
useOfflineSync(); useOfflineSync();
return ( return (
@@ -34,13 +34,16 @@ export function AppShell({ children }: AppShellProps) {
style={{ padding: '6px 16px', fontSize: 13 }} style={{ padding: '6px 16px', fontSize: 13 }}
/> />
)} )}
<Topbar onToggleSidebar={() => setSidebarOpen((v) => !v)} /> <Topbar />
<Flex flex={1} style={{ overflow: 'hidden' }}> <Flex flex={1} style={{ overflow: 'hidden' }}>
<Sidebar /> {isDesktop && <Sidebar />}
<main <main
style={{ style={{
flex: 1, 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', overflowY: 'auto',
overflowX: 'hidden', overflowX: 'hidden',
minWidth: 0, minWidth: 0,
@@ -50,7 +53,9 @@ export function AppShell({ children }: AppShellProps) {
</main> </main>
</Flex> </Flex>
{/* FAB — Novo Pedido */} {!isDesktop && <BottomNav />}
{/* FAB — Novo Pedido: acima do bottom nav em mobile */}
<Tooltip title="Novo Pedido" placement="left"> <Tooltip title="Novo Pedido" placement="left">
<Button <Button
type="primary" type="primary"
@@ -59,8 +64,8 @@ export function AppShell({ children }: AppShellProps) {
onClick={() => void navigate({ to: '/pedidos/novo' })} onClick={() => void navigate({ to: '/pedidos/novo' })}
style={{ style={{
position: 'fixed', position: 'fixed',
bottom: 32, bottom: isDesktop ? 32 : 'calc(var(--layout-bottom-nav-height) + 16px)',
right: 32, right: 20,
width: 52, width: 52,
height: 52, height: 52,
fontSize: 22, fontSize: 22,

View 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>
);
}

View File

@@ -7,29 +7,28 @@ import {
faClipboardCheck, faClipboardCheck,
faFunnelDollar, faFunnelDollar,
faUsers, faUsers,
faAddressCard,
faBoxesStacked, faBoxesStacked,
faFileInvoiceDollar, faFileInvoiceDollar,
faTags,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface'; import type { ItemType } from 'antd/es/menu/interface';
import { authStore } from '../../lib/auth-store';
/** function getRole(): string {
* Sidebar canônica do SAR (260px fixa — brand.md). const token = authStore.get();
* Itens mockados para Rafael cockpit. Variantes por cockpit virão depois. if (!token) return 'rep';
*/ try {
export function Sidebar() { const payload = JSON.parse(atob(token.split('.')[1] ?? ''));
const navigate = useNavigate(); return (payload.role as string) ?? 'rep';
const location = useLocation(); } catch {
const items: ItemType[] = [ return 'rep';
{ }
key: '/', }
icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />,
label: 'Painel', const REP_ITEMS: ItemType[] = [
}, { key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
{ { key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
key: '/clientes',
icon: <FontAwesomeIcon icon={faUsers} fixedWidth />,
label: 'Clientes',
},
{ {
key: '/catalogo', key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />, 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 ( return (
<aside <aside
style={{ style={{
@@ -73,7 +134,7 @@ export function Sidebar() {
> >
<Menu <Menu
mode="inline" mode="inline"
selectedKeys={[location.pathname]} selectedKeys={selectedKey ? [selectedKey] : [location.pathname]}
items={items} items={items}
onClick={({ key }) => navigate({ to: key as string })} onClick={({ key }) => navigate({ to: key as string })}
style={{ style={{

View File

@@ -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 { PlusOutlined } from '@ant-design/icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; 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 { useNavigate } from '@tanstack/react-router';
import { brandTokens } from '../../lib/theme'; import { brandTokens } from '../../lib/theme';
import { FoundationStatus } from './FoundationStatus'; import { FoundationStatus } from './FoundationStatus';
@@ -10,22 +10,17 @@ import { useCurrentUser } from '../../lib/queries/auth';
import { useCompany } from '../../lib/queries/company'; import { useCompany } from '../../lib/queries/company';
import { authStore } from '../../lib/auth-store'; import { authStore } from '../../lib/auth-store';
interface TopbarProps { const { useBreakpoint } = Grid;
onToggleSidebar?: () => void;
}
/**
* 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() { function logout() {
authStore.clear(); authStore.clear();
window.location.reload(); window.location.reload();
} }
export function Topbar({ onToggleSidebar }: TopbarProps) { export function Topbar() {
const navigate = useNavigate(); const navigate = useNavigate();
const screens = useBreakpoint();
const isDesktop = !!screens.lg;
const { data: pendingData } = usePendingCount(); const { data: pendingData } = usePendingCount();
const pendingCount = pendingData?.count ?? 0; const pendingCount = pendingData?.count ?? 0;
const { data: user } = useCurrentUser(); const { data: user } = useCurrentUser();
@@ -76,26 +71,20 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
align="center" align="center"
justify="space-between" justify="space-between"
style={{ style={{
height: 'var(--layout-topbar-height)', height: isDesktop ? 'var(--layout-topbar-height)' : 56,
padding: '0 var(--space-lg)', padding: isDesktop ? '0 var(--space-lg)' : '0 var(--space-md)',
background: 'var(--bg-surface)', background: 'var(--bg-surface)',
borderBottom: '1px solid var(--border-subtle)', borderBottom: '1px solid var(--border-subtle)',
position: 'sticky', position: 'sticky',
top: 0, top: 0,
zIndex: 'var(--z-sticky)', zIndex: 'var(--z-sticky)',
flexShrink: 0,
}} }}
> >
{/* Lado esquerdo: hamburger (mobile) + logo */} {/* Logo */}
<Flex align="center" gap={16}> <Flex align="center" gap={isDesktop ? 12 : 8}>
<Button <img src="/sar-icon.png" alt="SAR" style={{ height: isDesktop ? 40 : 32, width: 'auto' }} />
type="text" {isDesktop && (
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' }} />
<Flex vertical gap={0}> <Flex vertical gap={0}>
<span <span
style={{ style={{
@@ -119,29 +108,36 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
Força de Vendas Força de Vendas
</span> </span>
</Flex> </Flex>
</Flex> )}
</Flex> </Flex>
{/* Centro: empresa ativa */} {/* Centro: empresa ativa */}
<Flex flex={1} justify="center" style={{ margin: '0 var(--space-2xl)' }}> <Flex
<Flex align="center" gap={10}> 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 <FontAwesomeIcon
icon={faBuilding} 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 <Typography.Text
ellipsis
style={{ style={{
fontSize: 'var(--text-xl)', fontSize: isDesktop ? 'var(--text-xl)' : 'var(--text-sm)',
fontWeight: 'var(--font-weight-semibold)', fontWeight: 'var(--font-weight-semibold)',
color: 'var(--text-main)', color: 'var(--text-main)',
lineHeight: 1.2, lineHeight: 1.2,
whiteSpace: 'nowrap',
}} }}
> >
{nomeEmpresa} {nomeEmpresa}
</Typography.Text> </Typography.Text>
{mostrarRazao && ( {isDesktop && mostrarRazao && (
<Typography.Text <Typography.Text
style={{ style={{
fontSize: 'var(--text-2xs)', fontSize: 'var(--text-2xs)',
@@ -157,8 +153,9 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
</Flex> </Flex>
</Flex> </Flex>
{/* Lado direito: novo pedido + status fundação + notificações + perfil */} {/* Lado direito */}
<Flex align="center" gap={16}> <Flex align="center" gap={isDesktop ? 16 : 8}>
{isDesktop && (
<Button <Button
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
@@ -167,23 +164,25 @@ export function Topbar({ onToggleSidebar }: TopbarProps) {
> >
Novo Pedido Novo Pedido
</Button> </Button>
<FoundationStatus /> )}
{isDesktop && <FoundationStatus />}
<Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}> <Badge count={pendingCount} color={brandTokens.red} offset={[-4, 4]}>
<Button <Button
type="text" type="text"
size="large" size={isDesktop ? 'large' : 'middle'}
icon={<FontAwesomeIcon icon={faBell} />} icon={<FontAwesomeIcon icon={faBell} />}
aria-label="Notificações" aria-label="Notificações"
/> />
</Badge> </Badge>
<Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight"> <Dropdown menu={{ items: userMenuItems }} trigger={['click']} placement="bottomRight">
<Avatar <Avatar
size={40} size={isDesktop ? 40 : 32}
style={{ style={{
background: 'var(--jcs-blue-light)', background: 'var(--jcs-blue-light)',
color: 'var(--jcs-blue)', color: 'var(--jcs-blue)',
fontWeight: 'var(--font-weight-semibold)', fontWeight: 'var(--font-weight-semibold)',
cursor: 'pointer', cursor: 'pointer',
flexShrink: 0,
}} }}
> >
{initials} {initials}

View 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'] });
},
});
}

View File

@@ -20,6 +20,9 @@ import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage'; import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage'; import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel'; import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { GerPainel } from '../cockpits/ger/GerPainel';
import { EquipePage } from '../cockpits/ger/EquipePage';
import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
import { authStore } from './auth-store'; import { authStore } from './auth-store';
function getRoleFromToken(): string { function getRoleFromToken(): string {
@@ -35,7 +38,9 @@ function getRoleFromToken(): string {
function HomeRoute() { function HomeRoute() {
const role = getRoleFromToken(); 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() { function NotFoundPage() {
@@ -138,6 +143,24 @@ const aprovacoes = createRoute({
component: ApprovalQueuePage, 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([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
rafaelRoute, rafaelRoute,
@@ -152,6 +175,9 @@ const routeTree = rootRoute.addChildren([
pedidosErpRoute, pedidosErpRoute,
relatoriosRoute, relatoriosRoute,
aprovacoes, aprovacoes,
gerPainelRoute,
equipeRoute,
politicasRoute,
]); ]);
export const router = createRouter({ export const router = createRouter({

View File

@@ -9,7 +9,7 @@ export default defineConfig(() => ({
cacheDir: '../../node_modules/.vite/apps/web', cacheDir: '../../node_modules/.vite/apps/web',
server: { server: {
port: 4200, port: 4200,
host: 'localhost', host: '0.0.0.0',
// Proxy /api/* → API Nest em :3000 (default API_PORT). // 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, // Evita CORS em dev e mantém URL relativa no código da Web — em produção,
// mesmo origin via Nginx (/api/* → backend). // mesmo origin via Nginx (/api/* → backend).
@@ -22,7 +22,7 @@ export default defineConfig(() => ({
}, },
preview: { preview: {
port: 4200, port: 4200,
host: 'localhost', host: '0.0.0.0',
}, },
plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])], plugins: [react(), nxViteTsPaths(), nxCopyAssetsPlugin(['*.md'])],
// Uncomment this if you are using workers. // Uncomment this if you are using workers.

View File

@@ -7,3 +7,5 @@ export * from './lib/dashboard.contract';
export * from './lib/notifications.contract'; export * from './lib/notifications.contract';
export * from './lib/company.contract'; export * from './lib/company.contract';
export * from './lib/report.contract'; export * from './lib/report.contract';
export * from './lib/politicas.contract';
export * from './lib/equipe.contract';

View File

@@ -75,3 +75,35 @@ export const SupervisorDashboardSchema = z.object({
syncedAt: z.iso.datetime(), syncedAt: z.iso.datetime(),
}); });
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>; 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>;

View 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>;

View 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>;