Compare commits
35 Commits
5bda00009f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9107b0a825 | |||
| 9b855f63b2 | |||
| 1ce8d96fea | |||
| ebf68523dd | |||
| 78612d59bd | |||
| dfdcca4674 | |||
| 76cd90ae95 | |||
| 756e56d8d3 | |||
| 725a94ede1 | |||
| b6b16e1e21 | |||
| 54ef63f5c7 | |||
| 2ff1f9328a | |||
| 8a5db57f35 | |||
| b33293e79b | |||
| 1e11de7f31 | |||
| 35fd773f14 | |||
| 673130d60b | |||
| 43daf7ad6d | |||
| d5279576d7 | |||
| 36430fde63 | |||
| afa6171919 | |||
| 996eff8e65 | |||
| f543b8ac7f | |||
| 518769218f | |||
| 2a33a5aa4b | |||
| 8838db16ae | |||
| a614147fc4 | |||
| 26857643fb | |||
| 51602dd47e | |||
| 2649bc9e94 | |||
| 264305386d | |||
| bf6e21ce47 | |||
| 32ff4b43fd | |||
| 795b805ae5 | |||
| a6cb1df2aa |
@@ -10,11 +10,6 @@ export default defineConfig({
|
||||
datasource: {
|
||||
// Prisma 7: url aqui serve apenas para o CLI (migrate/generate/studio).
|
||||
// Runtime usa WorkspacePrismaPool → PrismaClient({ adapter: new PrismaPg(pool) }).
|
||||
url:
|
||||
process.env['DATABASE_URL'] ??
|
||||
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev',
|
||||
},
|
||||
migrations: {
|
||||
seed: 'tsx prisma/seed.ts',
|
||||
url: process.env['DATABASE_URL'],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
-- CreateTable: sar.oportunidades (Funil de Vendas)
|
||||
-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL
|
||||
-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente.
|
||||
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||
CREATE TABLE IF NOT EXISTS sar.oportunidades (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id_empresa INTEGER NOT NULL,
|
||||
cod_vendedor INTEGER NOT NULL,
|
||||
id_cliente INTEGER,
|
||||
nome_prospect VARCHAR(200),
|
||||
empresa_prospect VARCHAR(200),
|
||||
titulo VARCHAR(200) NOT NULL,
|
||||
etapa VARCHAR(20) NOT NULL DEFAULT 'lead',
|
||||
valor_estimado NUMERIC(15,2),
|
||||
observacoes TEXT,
|
||||
id_pedido UUID REFERENCES sar.pedidos(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_oport_vend ON sar.oportunidades(id_empresa, cod_vendedor);
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_oport_etapa ON sar.oportunidades(etapa);
|
||||
@@ -0,0 +1,33 @@
|
||||
-- CreateTable chamados
|
||||
CREATE TABLE IF NOT EXISTS sar.chamados (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id_empresa INTEGER NOT NULL,
|
||||
cod_vendedor INTEGER NOT NULL,
|
||||
id_pedido UUID NOT NULL,
|
||||
num_ped_sar INTEGER NOT NULL,
|
||||
id_cliente INTEGER NOT NULL,
|
||||
tipo VARCHAR(50) NOT NULL,
|
||||
assunto VARCHAR(200) NOT NULL,
|
||||
descricao TEXT NOT NULL,
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'aberto',
|
||||
resolucao VARCHAR(50),
|
||||
nota_resolucao TEXT,
|
||||
itens_afetados JSONB,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor);
|
||||
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_status ON sar.chamados(id_empresa, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_chamados_pedido ON sar.chamados(id_pedido);
|
||||
|
||||
-- CreateTable chamado_mensagens
|
||||
CREATE TABLE IF NOT EXISTS sar.chamado_mensagens (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id_chamado INTEGER NOT NULL REFERENCES sar.chamados(id) ON DELETE CASCADE,
|
||||
autor VARCHAR(10) NOT NULL,
|
||||
texto TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagens(id_chamado);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Torna id_pedido e num_ped_sar opcionais (chamados podem referenciar pedidos ERP)
|
||||
ALTER TABLE sar.chamados ALTER COLUMN id_pedido DROP NOT NULL;
|
||||
ALTER TABLE sar.chamados ALTER COLUMN num_ped_sar DROP NOT NULL;
|
||||
|
||||
-- Referência ao pedido ERP (numero inteiro do SIG)
|
||||
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS num_ped_erp INTEGER;
|
||||
|
||||
-- Nome do cliente desnormalizado (evita join em toda consulta)
|
||||
ALTER TABLE sar.chamados ADD COLUMN IF NOT EXISTS nome_cliente VARCHAR(200);
|
||||
|
||||
-- Remove index por id_pedido (agora nullable, menos útil como index único)
|
||||
DROP INDEX IF EXISTS sar.idx_chamados_pedido;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Endereco de entrega alternativo do pedido (feature "novo pedido v2").
|
||||
-- O campo existia no schema.prisma sem migration correspondente.
|
||||
-- Idempotente: provision-client.ts roda sar-erp-schema.sql antes do migrate deploy.
|
||||
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||
ALTER TABLE sar.pedidos ADD COLUMN IF NOT EXISTS end_entrega TEXT;
|
||||
@@ -0,0 +1,23 @@
|
||||
-- CreateTable: sar.regras_desconto (Regras de Desconto - Politicas Comerciais)
|
||||
-- Desconto liberado pelo gerente por grupo/subgrupo de produto e/ou
|
||||
-- representante, com vigencia. Campos nulos = "todos".
|
||||
-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL
|
||||
-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente.
|
||||
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||
CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id_empresa INTEGER NOT NULL,
|
||||
descricao VARCHAR(200) NOT NULL,
|
||||
cod_grupo INTEGER,
|
||||
cod_subgrupo INTEGER,
|
||||
cod_vendedor INTEGER,
|
||||
desc_pct NUMERIC(5,2) NOT NULL,
|
||||
data_inicio DATE NOT NULL,
|
||||
data_fim DATE NOT NULL,
|
||||
ativa BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
created_by VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_regras_empresa ON sar.regras_desconto(id_empresa);
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_regras_vigente ON sar.regras_desconto(id_empresa, data_fim) WHERE ativa;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- CreateTable: sar.config_empresa (configuracoes por empresa - logo da impressao)
|
||||
-- logo_base64: data URL (data:image/png;base64,...) usado no cabecalho do pedido.
|
||||
-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL
|
||||
-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente.
|
||||
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||
CREATE TABLE IF NOT EXISTS sar.config_empresa (
|
||||
id_empresa INTEGER PRIMARY KEY,
|
||||
logo_base64 TEXT,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Meta diaria de positivacao do painel gerencial - salva por empresa para
|
||||
-- nao depender do navegador do gerente.
|
||||
-- Idempotente (provision roda o schema espelho antes do migrate deploy).
|
||||
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||
ALTER TABLE sar.config_empresa ADD COLUMN IF NOT EXISTS meta_positivacao_dia INTEGER;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- AlterTable: sar.regras_desconto -- adiciona escopo por pauta de precos.
|
||||
-- Regra pode ser restrita a uma pauta especifica; id_pauta NULL = qualquer pauta.
|
||||
-- provision-client.ts roda scripts/sar-erp-schema.sql (idempotente) antes de
|
||||
-- `prisma migrate deploy`, entao este ALTER precisa ser idempotente tambem.
|
||||
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||
ALTER TABLE sar.regras_desconto ADD COLUMN IF NOT EXISTS id_pauta INTEGER;
|
||||
@@ -45,6 +45,7 @@ model Pedido {
|
||||
comissao Decimal @default(0) @db.Decimal(15, 2)
|
||||
pedFlex Decimal @default(0) @db.Decimal(15, 2) @map("ped_flex")
|
||||
obs String?
|
||||
endEntrega String? @map("end_entrega")
|
||||
aprovadoPor Int? @map("aprovado_por")
|
||||
aprovadoEm DateTime? @map("aprovado_em")
|
||||
motivoRecusa String? @map("motivo_recusa")
|
||||
@@ -170,6 +171,116 @@ model Promocao {
|
||||
@@map("promocoes")
|
||||
}
|
||||
|
||||
// ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
|
||||
//
|
||||
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
|
||||
// subgrupo de produto, pauta de preços e/ou representante, com vigência.
|
||||
// Campos nulos = "todos" (id_pauta nulo = qualquer pauta).
|
||||
|
||||
model RegraDesconto {
|
||||
id Int @id @default(autoincrement())
|
||||
idEmpresa Int @map("id_empresa")
|
||||
descricao String
|
||||
codGrupo Int? @map("cod_grupo")
|
||||
codSubgrupo Int? @map("cod_subgrupo")
|
||||
codVendedor Int? @map("cod_vendedor")
|
||||
idPauta Int? @map("id_pauta")
|
||||
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("regras_desconto")
|
||||
}
|
||||
|
||||
// ─── ConfigEmpresa ───────────────────────────────────────────────────────────
|
||||
//
|
||||
// Configurações por empresa. logo_base64: data URL exibido no cabeçalho da
|
||||
// impressão do pedido de todos os representantes.
|
||||
|
||||
model ConfigEmpresa {
|
||||
idEmpresa Int @id @map("id_empresa")
|
||||
logoBase64 String? @map("logo_base64")
|
||||
metaPositivacaoDia Int? @map("meta_positivacao_dia")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("config_empresa")
|
||||
}
|
||||
|
||||
// ─── Oportunidade (Funil de Vendas) ──────────────────────────────────────────
|
||||
//
|
||||
// Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente)
|
||||
// ou ser um prospect livre (nome_prospect / empresa_prospect).
|
||||
// etapa: lead | proposta | negociacao | ganho | perdido
|
||||
|
||||
model Oportunidade {
|
||||
id Int @id @default(autoincrement())
|
||||
idEmpresa Int @map("id_empresa")
|
||||
codVendedor Int @map("cod_vendedor")
|
||||
idCliente Int? @map("id_cliente")
|
||||
nomeProspect String? @map("nome_prospect") @db.VarChar(200)
|
||||
empresaProspect String? @map("empresa_prospect") @db.VarChar(200)
|
||||
titulo String @db.VarChar(200)
|
||||
etapa String @default("lead") @db.VarChar(20)
|
||||
valorEstimado Decimal? @db.Decimal(15, 2) @map("valor_estimado")
|
||||
observacoes String?
|
||||
idPedido String? @db.Uuid @map("id_pedido")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([idEmpresa, codVendedor])
|
||||
@@index([etapa])
|
||||
@@map("oportunidades")
|
||||
}
|
||||
|
||||
// ─── Chamados SAC ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Chamado aberto pelo representante vinculado a um pedido SAR.
|
||||
// tipo: avaria | quantidade_divergente | produto_errado | prazo_entrega | outro
|
||||
// status: aberto | em_analise | aguardando_rep | resolvido | cancelado
|
||||
// resolucao: reposicao | desconto | cancelamento_item | sem_acao | outro
|
||||
|
||||
model Chamado {
|
||||
id Int @id @default(autoincrement())
|
||||
idEmpresa Int @map("id_empresa")
|
||||
codVendedor Int @map("cod_vendedor")
|
||||
idPedido String? @db.Uuid @map("id_pedido")
|
||||
numPedSar Int? @map("num_ped_sar")
|
||||
numPedErp Int? @map("num_ped_erp")
|
||||
idCliente Int @map("id_cliente")
|
||||
nomeCliente String? @db.VarChar(200) @map("nome_cliente")
|
||||
tipo String @db.VarChar(50)
|
||||
assunto String @db.VarChar(200)
|
||||
descricao String
|
||||
status String @default("aberto") @db.VarChar(30)
|
||||
resolucao String? @db.VarChar(50)
|
||||
notaResolucao String? @map("nota_resolucao")
|
||||
itensAfetados Json? @map("itens_afetados")
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
mensagens ChamadoMensagem[]
|
||||
|
||||
@@index([idEmpresa, codVendedor])
|
||||
@@index([idEmpresa, status])
|
||||
@@map("chamados")
|
||||
}
|
||||
|
||||
model ChamadoMensagem {
|
||||
id Int @id @default(autoincrement())
|
||||
idChamado Int @map("id_chamado")
|
||||
autor String @db.VarChar(10)
|
||||
texto String
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
chamado Chamado @relation(fields: [idChamado], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([idChamado])
|
||||
@@map("chamado_mensagens")
|
||||
}
|
||||
|
||||
// ─── PushSubscription (C6) ───────────────────────────────────────────────────
|
||||
//
|
||||
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,8 @@ import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { ReportsModule } from './reports/reports.module';
|
||||
import { PoliticasModule } from './politicas/politicas.module';
|
||||
import { EquipeModule } from './equipe/equipe.module';
|
||||
import { FunilModule } from './funil/funil.module';
|
||||
import { SacModule } from './sac/sac.module';
|
||||
import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||
|
||||
@Module({
|
||||
@@ -35,6 +37,8 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
|
||||
ReportsModule,
|
||||
PoliticasModule,
|
||||
EquipeModule,
|
||||
FunilModule,
|
||||
SacModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: APP_PIPE, useClass: ZodValidationPipe },
|
||||
|
||||
@@ -20,11 +20,13 @@ export class DevAuthController {
|
||||
private readonly secret: Uint8Array;
|
||||
private readonly expiresIn: number;
|
||||
private readonly isProd: boolean;
|
||||
private readonly devEmpresaId: number;
|
||||
|
||||
constructor(config: ConfigService<Env, true>) {
|
||||
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
|
||||
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
|
||||
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
|
||||
this.devEmpresaId = config.get('DEV_EMPRESA_ID', { infer: true });
|
||||
}
|
||||
|
||||
@Post('token')
|
||||
@@ -33,7 +35,7 @@ export class DevAuthController {
|
||||
if (this.isProd) throw new NotFoundException();
|
||||
|
||||
const accessToken = await new SignJWT({
|
||||
id_empresa: dto.idEmpresa,
|
||||
id_empresa: dto.idEmpresa ?? this.devEmpresaId,
|
||||
role: dto.role,
|
||||
})
|
||||
.setProtectedHeader({ alg: 'HS256' })
|
||||
|
||||
@@ -51,17 +51,21 @@ export class JwtAuthGuard implements CanActivate {
|
||||
|
||||
(req as Request & { user: JwtPayload }).user = payload as JwtPayload;
|
||||
|
||||
// Em dev: força representante fixo (DEV_REP_CODE / DEV_EMPRESA_ID) ignorando o JWT.
|
||||
// Em prod: usa os valores reais do JWT.
|
||||
const idEmpresa = this.isProd ? payload.id_empresa : this.devEmpresaId;
|
||||
const userId = this.isProd ? payload.sub : this.devRepCode;
|
||||
// Em dev: usa sub/id_empresa do próprio JWT (emitido pelo /auth/dev/token,
|
||||
// que permite logar como Pavei/Sidnei/Lucas) e cai para DEV_REP_CODE /
|
||||
// DEV_EMPRESA_ID apenas se o token não trouxer os campos.
|
||||
// Em prod: usa os valores reais do JWT do master-login.
|
||||
const idEmpresa = payload.id_empresa ?? (this.isProd ? undefined : this.devEmpresaId);
|
||||
const userId = payload.sub ?? (this.isProd ? undefined : this.devRepCode);
|
||||
if (idEmpresa == null || userId == null) {
|
||||
throw new UnauthorizedException('token sem sub/id_empresa');
|
||||
}
|
||||
this.cls.set('idEmpresa', idEmpresa);
|
||||
this.cls.set('userId', userId);
|
||||
this.cls.set('role', payload.role);
|
||||
|
||||
const baseUrl =
|
||||
process.env['DATABASE_URL'] ??
|
||||
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev';
|
||||
const baseUrl = process.env['DATABASE_URL'];
|
||||
if (!baseUrl) throw new Error('DATABASE_URL não configurada');
|
||||
this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, baseUrl));
|
||||
|
||||
return true;
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
HttpCode,
|
||||
NotFoundException,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
ProdutoListQuerySchema,
|
||||
UpdateLogoBodySchema,
|
||||
type EmpresaInfo,
|
||||
type UpdateLogoBody,
|
||||
type FormaPagamento,
|
||||
type Municipio,
|
||||
type Pauta,
|
||||
@@ -13,6 +25,7 @@ import {
|
||||
import { CatalogService } from './catalog.service';
|
||||
|
||||
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
|
||||
class UpdateLogoDto extends createZodDto(UpdateLogoBodySchema) {}
|
||||
|
||||
// ADR 0006 revogado: UUID → Int para ID de produto. Sync removido (ERP direto via view).
|
||||
|
||||
@@ -40,6 +53,12 @@ export class CatalogController {
|
||||
return this.catalog.company();
|
||||
}
|
||||
|
||||
@Put('company/logo')
|
||||
@HttpCode(204)
|
||||
updateLogo(@Body() body: UpdateLogoDto): Promise<void> {
|
||||
return this.catalog.updateLogo(body as unknown as UpdateLogoBody);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
|
||||
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type {
|
||||
EmpresaInfo,
|
||||
UpdateLogoBody,
|
||||
FormaPagamento,
|
||||
Municipio,
|
||||
Pauta,
|
||||
@@ -84,16 +85,16 @@ export class CatalogService {
|
||||
TRIM(e.nome) AS nome_fantasia,
|
||||
TRIM(e.cnpj) AS cnpj,
|
||||
TRIM(e.inscr_estadual) AS inscr_estadual,
|
||||
TRIM(e.endereco) AS endereco,
|
||||
NULLIF(e.numero, 0)::text AS numero,
|
||||
NULLIF(TRIM(e.complemento), '.') AS complemento,
|
||||
TRIM(e.bairro) AS bairro,
|
||||
TRIM(m.nome) AS cidade,
|
||||
TRIM(e.estado::text) AS uf,
|
||||
TRIM(e.cep::text) AS cep,
|
||||
TRIM(e.telefone::text) AS telefone,
|
||||
TRIM(e.email) AS email
|
||||
FROM gestao.empresa e
|
||||
TRIM(e.endereco) AS endereco,
|
||||
e.num_endereco::text AS numero,
|
||||
e.complemento,
|
||||
TRIM(e.bairro) AS bairro,
|
||||
TRIM(m.nome) AS cidade,
|
||||
TRIM(e.uf) AS uf,
|
||||
e.cep,
|
||||
e.telefone,
|
||||
TRIM(e.email) AS email
|
||||
FROM sar.vw_empresas e
|
||||
LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio
|
||||
WHERE e.id_empresa = ${idEmpresa}
|
||||
LIMIT 1
|
||||
@@ -101,6 +102,8 @@ export class CatalogService {
|
||||
const r = rows[0];
|
||||
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`);
|
||||
|
||||
const config = await prisma.configEmpresa.findUnique({ where: { idEmpresa } });
|
||||
|
||||
const clean = (v: string | null) => {
|
||||
const t = (v ?? '').trim();
|
||||
return t === '' ? null : t;
|
||||
@@ -120,9 +123,28 @@ export class CatalogService {
|
||||
cep: clean(r.cep),
|
||||
telefone: clean(r.telefone),
|
||||
email: clean(r.email),
|
||||
logoBase64: config?.logoBase64 ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Logo do cabeçalho da impressão — só gerente/admin altera; vale para todos
|
||||
// os representantes da empresa (gravado na matriz, mesma fonte do company()).
|
||||
async updateLogo(body: UpdateLogoBody): Promise<void> {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role !== 'manager' && role !== 'admin') {
|
||||
throw new ForbiddenException('Apenas gerentes podem alterar o logo da empresa');
|
||||
}
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
|
||||
await prisma.configEmpresa.upsert({
|
||||
where: { idEmpresa },
|
||||
create: { idEmpresa, logoBase64: body.logoBase64 },
|
||||
update: { logoBase64: body.logoBase64 },
|
||||
});
|
||||
}
|
||||
|
||||
async municipios(q?: string): Promise<Municipio[]> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
@@ -158,29 +180,49 @@ export class CatalogService {
|
||||
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
|
||||
interface PautaRow {
|
||||
id_pauta: number;
|
||||
codigo: number;
|
||||
descricao: string;
|
||||
qtd_reps?: string;
|
||||
}
|
||||
const rows = await prisma.$queryRawUnsafe<PautaRow[]>(`
|
||||
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
|
||||
FROM vw_pautas pa
|
||||
JOIN vw_representantes r ON pa.codigo IN (
|
||||
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||
)
|
||||
WHERE pa.id_empresa = ${idEmpresa}
|
||||
AND pa.ativo = 1
|
||||
AND r.codigo = ${codVendedor}
|
||||
ORDER BY pa.codigo
|
||||
`);
|
||||
// Rep vê só as pautas atribuídas a ele (cod_pauta1..6); gestor vê TODAS as
|
||||
// pautas ativas, com a contagem de reps que usam cada uma.
|
||||
const rows =
|
||||
role === 'rep'
|
||||
? await prisma.$queryRawUnsafe<PautaRow[]>(`
|
||||
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
|
||||
FROM vw_pautas pa
|
||||
JOIN vw_representantes r ON pa.codigo IN (
|
||||
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||
)
|
||||
WHERE pa.id_empresa = ${idEmpresa}
|
||||
AND pa.ativo = 1
|
||||
AND r.codigo = ${codVendedor}
|
||||
ORDER BY pa.codigo
|
||||
`)
|
||||
: await prisma.$queryRawUnsafe<PautaRow[]>(`
|
||||
SELECT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao,
|
||||
COUNT(DISTINCT r.codigo)::text AS qtd_reps
|
||||
FROM vw_pautas pa
|
||||
LEFT JOIN vw_representantes r ON pa.codigo IN (
|
||||
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||
)
|
||||
WHERE pa.id_empresa = ${idEmpresa}
|
||||
AND pa.ativo = 1
|
||||
GROUP BY pa.id_pauta, pa.codigo, pa.descricao
|
||||
ORDER BY pa.codigo
|
||||
`);
|
||||
|
||||
return rows.map((r) => ({
|
||||
idPauta: Number(r.id_pauta),
|
||||
codigo: Number(r.codigo),
|
||||
descricao: r.descricao,
|
||||
...(r.qtd_reps != null && { qtdReps: Number(r.qtd_reps) }),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -188,10 +230,22 @@ export class CatalogService {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
|
||||
const { q, codGrupo, idPauta, page, limit } = query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// PGD-AUTHZ: o rep só enxerga produtos/preços das pautas vinculadas ao
|
||||
// cadastro dele (cod_pauta1..6). Sem pauta selecionada → lista vazia (precisa
|
||||
// escolher a pauta primeiro); pauta de terceiro → recusa. Gestor/admin
|
||||
// navegam livremente (preço base ou pauta que escolherem).
|
||||
if (role === 'rep') {
|
||||
if (idPauta == null) return { data: [], total: 0, page, limit };
|
||||
await this.assertPautaDoRep(idPauta, codVendedor, idEmpresa);
|
||||
}
|
||||
|
||||
const grupoFilter = codGrupo != null ? `AND p.cod_grupo = ${codGrupo}` : '';
|
||||
const searchFilter = q
|
||||
? `AND (p.descricao ILIKE '%${escSql(q)}%' OR p.codigo ILIKE '%${escSql(q)}%')`
|
||||
@@ -268,6 +322,33 @@ export class CatalogService {
|
||||
return { data: rows.map((p) => this.mapRow(p, p.vl_preco1)), total, page, limit };
|
||||
}
|
||||
|
||||
// Confere que a pauta pertence às pautas do rep (cod_pauta1..6 do cadastro).
|
||||
// codVendedor/idPauta são Int (JWT/query coercida) — interpolação segura.
|
||||
private async assertPautaDoRep(
|
||||
idPauta: number,
|
||||
codVendedor: number,
|
||||
idMatriz: number,
|
||||
): Promise<void> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const rows = await prisma.$queryRawUnsafe<{ id_pauta: number }[]>(`
|
||||
SELECT DISTINCT pa.id_pauta
|
||||
FROM vw_pautas pa
|
||||
JOIN vw_representantes r ON pa.codigo IN (
|
||||
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||
)
|
||||
WHERE pa.id_empresa = ${idMatriz}
|
||||
AND pa.ativo = 1
|
||||
AND r.codigo = ${codVendedor}
|
||||
AND pa.id_pauta = ${Number(idPauta)}
|
||||
LIMIT 1
|
||||
`);
|
||||
if (!rows[0]) {
|
||||
throw new ForbiddenException('Pauta de preços não vinculada ao seu cadastro');
|
||||
}
|
||||
}
|
||||
|
||||
private mapRow(p: ProdutoRow, preco: string): ProdutoSummary {
|
||||
return {
|
||||
idErp: Number(p.id_erp),
|
||||
@@ -337,12 +418,16 @@ export class CatalogService {
|
||||
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
|
||||
AND pa.id_empresa = ${idEmpresa}
|
||||
AND pa.ativo = 1
|
||||
JOIN vw_representantes r ON pa.codigo IN (
|
||||
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||
)
|
||||
${
|
||||
// Rep vê só as pautas dele; gestor vê os preços de TODAS as pautas
|
||||
(this.cls.get('role') ?? 'rep') === 'rep'
|
||||
? `JOIN vw_representantes r ON pa.codigo IN (
|
||||
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||
) AND r.codigo = ${codVendedor}`
|
||||
: ''
|
||||
}
|
||||
WHERE pp.id_produto = ${idErp}
|
||||
AND r.codigo = ${codVendedor}
|
||||
AND pp.preco1 > 0
|
||||
ORDER BY pa.codigo
|
||||
`),
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
TopProduto,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||
|
||||
// Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
|
||||
const ALERT_DAYS = 30;
|
||||
@@ -36,6 +37,12 @@ function escSql(s: string): string {
|
||||
return s.replace(/'/g, "''");
|
||||
}
|
||||
|
||||
// Financeiro (CTR) e NF vivem na empresa MATRIZ. O ERP usa códigos > 9000 para
|
||||
// origem de pedido (ex.: 9001 → matriz 1), espelhando catalog/dashboard/equipe.
|
||||
function matrizEmpresa(idEmpresa: number): number {
|
||||
return idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
}
|
||||
|
||||
// Row bruta do $queryRawUnsafe
|
||||
interface ClientRow {
|
||||
id_cliente: number;
|
||||
@@ -108,6 +115,29 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
|
||||
export class ClientsService {
|
||||
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {}
|
||||
|
||||
// PGD-AUTHZ: rep só acessa clientes da própria carteira; supervisor acessa as
|
||||
// carteiras da sua equipe (cod_supervisor); gerente/admin passam direto.
|
||||
// NotFound (não Forbidden) para não revelar a existência de clientes de terceiros.
|
||||
private async assertClienteDaCarteira(idCliente: number): Promise<void> {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role !== 'rep' && role !== 'supervisor') return;
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||
idCliente,
|
||||
);
|
||||
const dono = rows[0] ? Number(rows[0].cod_vendedor) : null;
|
||||
const permitidos =
|
||||
role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : [codVendedor];
|
||||
if (dono == null || !permitidos.includes(dono)) {
|
||||
throw new NotFoundException(`Cliente ${idCliente} não encontrado`);
|
||||
}
|
||||
}
|
||||
|
||||
async list(query: ClientListQuery): Promise<ClientListResponse> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
@@ -119,8 +149,13 @@ export class ClientsService {
|
||||
const { q, status, page, limit } = query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Rep vê apenas sua carteira (cod_vendedor = seu código)
|
||||
const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : '';
|
||||
// Rep vê apenas sua carteira; supervisor vê as carteiras da equipe
|
||||
const vendedorFilter =
|
||||
role === 'rep'
|
||||
? `AND c.cod_vendedor = ${codVendedor}`
|
||||
: role === 'supervisor'
|
||||
? `AND c.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
|
||||
: '';
|
||||
const searchFilter = q
|
||||
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(q)}%')`
|
||||
: '';
|
||||
@@ -257,6 +292,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async listContacts(idCorrent: number): Promise<Contato[]> {
|
||||
await this.assertClienteDaCarteira(idCorrent);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
|
||||
@@ -316,6 +352,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
|
||||
await this.assertClienteDaCarteira(idCorrent);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
@@ -337,7 +374,7 @@ export class ClientsService {
|
||||
${idEmpresa}, ${codVendedor}, ${idCorrent},
|
||||
'${esc(dto.nome)}',
|
||||
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)},
|
||||
${dto.dtAniversario ? `'${dto.dtAniversario}'` : 'NULL'},
|
||||
${dto.dtAniversario ? `'${esc(dto.dtAniversario)}'` : 'NULL'},
|
||||
${opt(dto.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
|
||||
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
|
||||
)
|
||||
@@ -370,6 +407,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
@@ -430,15 +468,17 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
|
||||
const idEmpresaMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||
|
||||
interface Row {
|
||||
id_produto: number;
|
||||
codigo: string;
|
||||
descricao: string;
|
||||
unidade: string | null;
|
||||
qtd_total: string;
|
||||
@@ -449,37 +489,39 @@ export class ClientsService {
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<Row[]>(`
|
||||
SELECT
|
||||
i.produ AS id_produto,
|
||||
i.id_produto,
|
||||
TRIM(p.codigo) AS codigo,
|
||||
TRIM(p.descricao) AS descricao,
|
||||
TRIM(p.unidade) AS unidade,
|
||||
SUM(i.qtd)::numeric(15,3)::text AS qtd_total,
|
||||
COUNT(DISTINCT ped.id_pedido)::text AS num_pedidos,
|
||||
MAX(ped.data) AS ultima_compra,
|
||||
MAX(ped.dt_pedido) AS ultima_compra,
|
||||
(
|
||||
SELECT i2.pruni::numeric(15,2)::text
|
||||
FROM sig.peditens i2
|
||||
JOIN sig.pedidos p2 ON p2.id_pedido = i2.id_pedido
|
||||
WHERE i2.produ = i.produ
|
||||
AND p2.clien = ped.clien
|
||||
AND p2.id_empresa = ${idEmpresa}
|
||||
AND p2.situa NOT IN (5)
|
||||
ORDER BY p2.data DESC, p2.id_pedido DESC
|
||||
SELECT i2.preco_unitario::numeric(15,2)::text
|
||||
FROM sar.vw_peditens_erp i2
|
||||
JOIN sar.vw_pedidos_erp p2 ON p2.id_pedido = i2.id_pedido
|
||||
WHERE i2.id_produto = i.id_produto
|
||||
AND p2.id_cliente = ped.id_cliente
|
||||
AND p2.id_empresa = ${idEmpresa}
|
||||
AND p2.situa NOT IN (5)
|
||||
ORDER BY p2.dt_pedido DESC, p2.id_pedido DESC
|
||||
LIMIT 1
|
||||
) AS ultimo_preco
|
||||
FROM sig.pedidos ped
|
||||
JOIN sig.peditens i ON i.id_pedido = ped.id_pedido
|
||||
JOIN gestao.produto p ON p.id_erp = i.produ
|
||||
AND p.id_empresa = ${idEmpresaMatriz}
|
||||
WHERE ped.clien = ${idCliente}
|
||||
AND ped.id_empresa = ${idEmpresa}
|
||||
AND ped.situa NOT IN (5)
|
||||
GROUP BY i.produ, p.descricao, p.unidade, ped.clien
|
||||
FROM sar.vw_pedidos_erp ped
|
||||
JOIN sar.vw_peditens_erp i ON i.id_pedido = ped.id_pedido
|
||||
JOIN sar.vw_produtos p ON p.id_erp = i.id_produto
|
||||
AND p.id_empresa = ${idEmpresaMatriz}
|
||||
WHERE ped.id_cliente = ${idCliente}
|
||||
AND ped.id_empresa = ${idEmpresa}
|
||||
AND ped.situa NOT IN (5)
|
||||
GROUP BY i.id_produto, p.codigo, p.descricao, p.unidade, ped.id_cliente
|
||||
ORDER BY SUM(i.qtd) DESC
|
||||
LIMIT ${limit}
|
||||
`);
|
||||
|
||||
return rows.map((r) => ({
|
||||
idProduto: Number(r.id_produto),
|
||||
codProduto: r.codigo ?? '',
|
||||
descricao: r.descricao,
|
||||
unidade: r.unidade,
|
||||
qtdTotal: parseFloat(r.qtd_total ?? '0'),
|
||||
@@ -490,8 +532,10 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async getCtrSummary(idCliente: number): Promise<CtrSummary> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
|
||||
interface Row {
|
||||
qtd_aberto: string;
|
||||
@@ -510,6 +554,7 @@ export class ClientsService {
|
||||
COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
|
||||
FROM sar.vw_ctr
|
||||
WHERE id_cliente = ${idCliente}
|
||||
AND id_empresa = ${idEmpresaMatriz}
|
||||
AND situacao = 'A'
|
||||
AND saldo > 0
|
||||
`);
|
||||
@@ -525,6 +570,7 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async findOne(idCliente: number): Promise<ClientDetail> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
@@ -576,8 +622,10 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
|
||||
type CtrRow = {
|
||||
id_ctr: number;
|
||||
@@ -599,7 +647,7 @@ export class ClientsService {
|
||||
saldo::text AS saldo
|
||||
FROM sar.vw_ctr
|
||||
WHERE id_cliente = $1
|
||||
AND id_empresa = 1
|
||||
AND id_empresa = $3
|
||||
AND situacao = 'A'
|
||||
AND saldo > 0
|
||||
ORDER BY dt_vencimento ASC
|
||||
@@ -607,6 +655,7 @@ export class ClientsService {
|
||||
`,
|
||||
idCliente,
|
||||
limit,
|
||||
idEmpresaMatriz,
|
||||
);
|
||||
|
||||
const toDate = (d: Date | string): string =>
|
||||
@@ -623,8 +672,12 @@ export class ClientsService {
|
||||
}
|
||||
|
||||
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
|
||||
await this.assertClienteDaCarteira(idCliente);
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
// NF fica na matriz; pedidos podem ter origem na matriz ou na empresa fiscal (matriz+9000)
|
||||
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
const idEmpresaFiscal = idEmpresaMatriz + 9000;
|
||||
|
||||
type NfRow = {
|
||||
id_nf: number;
|
||||
@@ -646,13 +699,13 @@ export class ClientsService {
|
||||
nf.dt_emissao,
|
||||
nf.vl_nf::text AS vl_nf,
|
||||
TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe
|
||||
FROM gestao.nf nf
|
||||
JOIN sig.pedidos p
|
||||
FROM sar.vw_nf nf
|
||||
JOIN sar.vw_pedidos_erp p
|
||||
ON p.numero = nf.num_entrega
|
||||
AND p.tipo = 'E'
|
||||
AND p.id_empresa IN (1, 9001)
|
||||
WHERE p.clien = $1
|
||||
AND nf.id_empresa = 1
|
||||
AND p.id_empresa IN ($3, $4)
|
||||
WHERE p.id_cliente = $1
|
||||
AND nf.id_empresa = $3
|
||||
AND nf.status = 'E'
|
||||
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
|
||||
ORDER BY nf.id_nf
|
||||
@@ -662,6 +715,8 @@ export class ClientsService {
|
||||
`,
|
||||
idCliente,
|
||||
limit,
|
||||
idEmpresaMatriz,
|
||||
idEmpresaFiscal,
|
||||
);
|
||||
|
||||
return rows.map((r: NfRow) => ({
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
import { Controller, Get, Query } from '@nestjs/common';
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
ParseIntPipe,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
SaveMetaPositivacaoBodySchema,
|
||||
type RepDashboard,
|
||||
type RepInativosDetail,
|
||||
type SupervisorDashboard,
|
||||
type ManagerDashboard,
|
||||
type SaveMetaPositivacaoBody,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { DashboardService } from './dashboard.service';
|
||||
|
||||
class SaveMetaPositivacaoDto extends createZodDto(SaveMetaPositivacaoBodySchema) {}
|
||||
|
||||
@Controller({ path: 'dashboard' })
|
||||
export class DashboardController {
|
||||
constructor(
|
||||
@@ -11,14 +30,44 @@ export class DashboardController {
|
||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||
) {}
|
||||
|
||||
// PGD-AUTHZ: dashboards gerenciais agregam dados da empresa toda (faturamento,
|
||||
// ranking de todos os reps) — rep não pode acessá-los.
|
||||
private assertGestor(): void {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role === 'rep') {
|
||||
throw new ForbiddenException('Apenas supervisores e gerentes podem acessar este painel');
|
||||
}
|
||||
}
|
||||
|
||||
@Get('rep')
|
||||
repDashboard(): Promise<RepDashboard> {
|
||||
return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
|
||||
}
|
||||
|
||||
@Get('supervisor')
|
||||
supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||
return this.dashboard.supervisorDashboard();
|
||||
supervisorDashboard(
|
||||
@Query('codSupervisor') codSupervisor?: string,
|
||||
): Promise<SupervisorDashboard> {
|
||||
this.assertGestor();
|
||||
return this.dashboard.supervisorDashboard(
|
||||
codSupervisor ? parseInt(codSupervisor, 10) : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('supervisor/inativos')
|
||||
supervisorInativos(
|
||||
@Query('codVendedor', ParseIntPipe) codVendedor: number,
|
||||
): Promise<RepInativosDetail> {
|
||||
this.assertGestor();
|
||||
return this.dashboard.supervisorInativos(codVendedor);
|
||||
}
|
||||
|
||||
@Put('manager/meta-positivacao-dia')
|
||||
@HttpCode(204)
|
||||
saveMetaPositivacaoDia(@Body() body: SaveMetaPositivacaoDto): Promise<void> {
|
||||
this.assertGestor();
|
||||
const parsed = SaveMetaPositivacaoBodySchema.parse(body) as SaveMetaPositivacaoBody;
|
||||
return this.dashboard.saveMetaPositivacaoDia(parsed.meta);
|
||||
}
|
||||
|
||||
@Get('manager')
|
||||
@@ -26,6 +75,7 @@ export class DashboardController {
|
||||
@Query('mes') mes?: string,
|
||||
@Query('ano') ano?: string,
|
||||
): Promise<ManagerDashboard> {
|
||||
this.assertGestor();
|
||||
return this.dashboard.managerDashboard(
|
||||
mes ? parseInt(mes, 10) : undefined,
|
||||
ano ? parseInt(ano, 10) : undefined,
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type {
|
||||
RepDashboard,
|
||||
RepInativosDetail,
|
||||
SupervisorDashboard,
|
||||
ManagerDashboard,
|
||||
RankingRep,
|
||||
PositivacaoRep,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||
|
||||
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
|
||||
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
|
||||
const SITUA_PENDENTE = 1;
|
||||
|
||||
// vw_metas.tipo (gestao.metavenda): GL = meta global, GR = meta por grupo.
|
||||
const TIPO_META_GLOBAL = 'GL';
|
||||
@@ -124,7 +124,24 @@ export class DashboardService {
|
||||
obs: string | null;
|
||||
}
|
||||
|
||||
const [atingidoRows, pedidosMesRows, recentRows, realizadoGrupoRows] = await Promise.all([
|
||||
interface RealizadoMesRow {
|
||||
mes: string;
|
||||
valor: string;
|
||||
}
|
||||
interface MetaMesRow {
|
||||
mes: string;
|
||||
tipo: string;
|
||||
valor: string;
|
||||
}
|
||||
|
||||
const [
|
||||
atingidoRows,
|
||||
pedidosMesRows,
|
||||
recentRows,
|
||||
realizadoGrupoRows,
|
||||
realizadoMesRows,
|
||||
metaMesRows,
|
||||
] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||
SELECT COALESCE(SUM(total), 0)::text AS total
|
||||
FROM vw_pedidos_erp
|
||||
@@ -178,6 +195,30 @@ export class DashboardService {
|
||||
AND e.dt_pedido <= '${monthEndStr}'
|
||||
GROUP BY p.cod_grupo, grupo
|
||||
`),
|
||||
// Realizado por mês do ano corrente.
|
||||
prisma.$queryRawUnsafe<RealizadoMesRow[]>(`
|
||||
SELECT EXTRACT(MONTH FROM dt_pedido)::int::text AS mes,
|
||||
COALESCE(SUM(total), 0)::text AS valor
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND cod_vendedor = ${codVendedor}
|
||||
AND situa NOT IN (1, 5)
|
||||
AND EXTRACT(YEAR FROM dt_pedido) = ${year}
|
||||
GROUP BY mes
|
||||
ORDER BY mes
|
||||
`),
|
||||
// Meta (GL + GR) por mês do ano corrente.
|
||||
prisma.$queryRawUnsafe<MetaMesRow[]>(`
|
||||
SELECT mes::text, TRIM(tipo) AS tipo,
|
||||
COALESCE(SUM(valor), 0)::text AS valor
|
||||
FROM vw_metas
|
||||
WHERE id_empresa = ${idEmpresaMatriz}
|
||||
AND cod_vendedor = ${codVendedor}
|
||||
AND ano = ${year}
|
||||
AND TRIM(tipo) IN ('GL', 'GR')
|
||||
GROUP BY mes, tipo
|
||||
ORDER BY mes
|
||||
`),
|
||||
]);
|
||||
|
||||
const atingido = Number(atingidoRows[0]?.total ?? 0);
|
||||
@@ -191,25 +232,17 @@ export class DashboardService {
|
||||
WITH ultimo_pedido AS (
|
||||
SELECT id_cliente,
|
||||
MAX(dt_pedido) AS dt_max
|
||||
FROM (
|
||||
SELECT id_cliente, dt_pedido FROM vw_pedidos_erp
|
||||
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
|
||||
UNION ALL
|
||||
SELECT id_cliente, dt_pedido FROM sar.pedidos
|
||||
WHERE situa != 3 AND id_empresa = ${idEmpresa}
|
||||
) t
|
||||
FROM vw_pedidos_erp
|
||||
WHERE situa NOT IN (5)
|
||||
AND id_empresa = ${idEmpresa}
|
||||
GROUP BY id_cliente
|
||||
),
|
||||
pedido_mes AS (
|
||||
SELECT DISTINCT id_cliente FROM (
|
||||
SELECT id_cliente FROM vw_pedidos_erp
|
||||
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa}
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
UNION
|
||||
SELECT id_cliente FROM sar.pedidos
|
||||
WHERE situa != 3 AND id_empresa = ${idEmpresa}
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
) t
|
||||
SELECT DISTINCT id_cliente
|
||||
FROM vw_pedidos_erp
|
||||
WHERE situa NOT IN (5)
|
||||
AND id_empresa = ${idEmpresa}
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
)
|
||||
SELECT
|
||||
c.id_cliente,
|
||||
@@ -267,21 +300,48 @@ export class DashboardService {
|
||||
})
|
||||
.sort((a, b) => b.valorMeta - a.valorMeta);
|
||||
|
||||
const naoPositivados = naoPositivadosRows.map((c) => ({
|
||||
idCliente: Number(c.id_cliente),
|
||||
nome: c.nome,
|
||||
razao: c.razao ?? null,
|
||||
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
|
||||
ultimoPedido: c.dt_ultimo_pedido ?? null,
|
||||
comprouAntes: Boolean(c.comprou_antes),
|
||||
whatsapp: c.whatsapp ?? null,
|
||||
}));
|
||||
// Deduplica por idCliente — vw_clientes pode retornar o mesmo cliente em
|
||||
// mais de uma empresa; mantemos a primeira ocorrência (ORDER BY dt_max ASC NULLS FIRST).
|
||||
const seenNP = new Set<number>();
|
||||
const naoPositivados = naoPositivadosRows
|
||||
.filter((c) => {
|
||||
const id = Number(c.id_cliente);
|
||||
if (seenNP.has(id)) return false;
|
||||
seenNP.add(id);
|
||||
return true;
|
||||
})
|
||||
.map((c) => ({
|
||||
idCliente: Number(c.id_cliente),
|
||||
nome: c.nome,
|
||||
razao: c.razao ?? null,
|
||||
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
|
||||
ultimoPedido: c.dt_ultimo_pedido ?? null,
|
||||
comprouAntes: Boolean(c.comprou_antes),
|
||||
whatsapp: c.whatsapp ?? null,
|
||||
}));
|
||||
|
||||
// Monta histório mensal: 12 meses do ano, cruzando realizado e meta.
|
||||
const realizadoPorMes = new Map(realizadoMesRows.map((r) => [Number(r.mes), Number(r.valor)]));
|
||||
const metaGlPorMes = new Map<number, number>();
|
||||
const metaGrPorMes = new Map<number, number>();
|
||||
for (const r of metaMesRows) {
|
||||
const m = Number(r.mes);
|
||||
if (r.tipo === 'GL') metaGlPorMes.set(m, (metaGlPorMes.get(m) ?? 0) + Number(r.valor));
|
||||
else metaGrPorMes.set(m, (metaGrPorMes.get(m) ?? 0) + Number(r.valor));
|
||||
}
|
||||
const historicoMensal = Array.from({ length: 12 }, (_, i) => {
|
||||
const m = i + 1;
|
||||
const valor = realizadoPorMes.get(m) ?? 0;
|
||||
const meta = metaGlPorMes.has(m) ? (metaGlPorMes.get(m) ?? 0) : (metaGrPorMes.get(m) ?? 0);
|
||||
return { mes: m, valor, meta, atingiu: meta > 0 && valor >= meta };
|
||||
});
|
||||
|
||||
return {
|
||||
meta: { atingido, total: targetAmount, pct, falta },
|
||||
metaDimensao,
|
||||
metasPorGrupo,
|
||||
pedidosMes,
|
||||
historicoMensal,
|
||||
pedidosRecentes: recentRows.map((o) => ({
|
||||
id: `erp-${o.id_pedido}`,
|
||||
numPedSar: (o.num_ped_sar ?? '').trim(),
|
||||
@@ -353,6 +413,22 @@ export class DashboardService {
|
||||
total_clientes: string;
|
||||
clientes_positivados: string;
|
||||
}
|
||||
interface PositivacaoDiaRow {
|
||||
dia: string;
|
||||
clientes: string;
|
||||
}
|
||||
interface NovosClientesRow {
|
||||
cod_vendedor: number;
|
||||
novos: string;
|
||||
}
|
||||
interface UfRepRow {
|
||||
uf: string;
|
||||
cod_vendedor: number;
|
||||
nome_vendedor: string | null;
|
||||
pedidos: string;
|
||||
clientes: string;
|
||||
faturamento: string;
|
||||
}
|
||||
|
||||
const [
|
||||
statsRows,
|
||||
@@ -362,6 +438,9 @@ export class DashboardService {
|
||||
positivacaoRows,
|
||||
metaGrupoRows,
|
||||
realizadoGrupoRows,
|
||||
positivacaoDiariaRows,
|
||||
novosClientesRows,
|
||||
ufRepRows,
|
||||
] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<TotalRow[]>(`
|
||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
|
||||
@@ -458,6 +537,57 @@ export class DashboardService {
|
||||
AND e.dt_pedido <= '${monthEndStr}'
|
||||
GROUP BY p.cod_grupo, grupo
|
||||
`),
|
||||
prisma.$queryRawUnsafe<PositivacaoDiaRow[]>(`
|
||||
SELECT dt_pedido::date::text AS dia,
|
||||
COUNT(DISTINCT id_cliente)::text AS clientes
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND situa NOT IN (1, 5)
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
AND dt_pedido <= '${monthEndStr}'
|
||||
GROUP BY dt_pedido::date
|
||||
ORDER BY dia
|
||||
`),
|
||||
prisma.$queryRawUnsafe<NovosClientesRow[]>(`
|
||||
SELECT c.cod_vendedor, COUNT(*)::text AS novos
|
||||
FROM (
|
||||
SELECT id_cliente, MIN(dt_pedido) AS primeira_compra
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND situa NOT IN (1, 5)
|
||||
GROUP BY id_cliente
|
||||
) f
|
||||
JOIN (SELECT DISTINCT id_cliente, cod_vendedor FROM vw_clientes) c
|
||||
ON c.id_cliente = f.id_cliente
|
||||
WHERE f.primeira_compra >= '${monthStartStr}'
|
||||
AND f.primeira_compra <= '${monthEndStr}'
|
||||
GROUP BY c.cod_vendedor
|
||||
`),
|
||||
// Faturamento por UF (município do cliente) e representante.
|
||||
// vw_clientes pode repetir o cliente por empresa → LATERAL pega 1 município.
|
||||
prisma.$queryRawUnsafe<UfRepRow[]>(`
|
||||
SELECT COALESCE(NULLIF(TRIM(loc.uf), ''), 'ND') AS uf,
|
||||
p.cod_vendedor,
|
||||
(SELECT r.nome FROM vw_representantes r
|
||||
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome_vendedor,
|
||||
COUNT(*)::text AS pedidos,
|
||||
COUNT(DISTINCT p.id_cliente)::text AS clientes,
|
||||
COALESCE(SUM(p.total), 0)::text AS faturamento
|
||||
FROM vw_pedidos_erp p
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT mu.uf::text AS uf
|
||||
FROM vw_clientes c
|
||||
JOIN sar.vw_municipios mu ON mu.id_municipio = c.id_municipio
|
||||
WHERE c.id_cliente = p.id_cliente
|
||||
LIMIT 1
|
||||
) loc ON true
|
||||
WHERE p.id_empresa = ${idEmpresa}
|
||||
AND p.situa NOT IN (1, 5)
|
||||
AND p.dt_pedido >= '${monthStartStr}'
|
||||
AND p.dt_pedido <= '${monthEndStr}'
|
||||
GROUP BY 1, p.cod_vendedor
|
||||
ORDER BY 1, SUM(p.total) DESC
|
||||
`),
|
||||
]);
|
||||
|
||||
const pedidosMes = Number(statsRows[0]?.count ?? 0);
|
||||
@@ -508,6 +638,14 @@ export class DashboardService {
|
||||
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
|
||||
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
|
||||
|
||||
// Meta diária de positivação salva pelo gerente (config da empresa matriz)
|
||||
const config = await prisma.configEmpresa.findUnique({
|
||||
where: { idEmpresa: idEmpresaMatriz },
|
||||
});
|
||||
|
||||
const novosMap = new Map(
|
||||
novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]),
|
||||
);
|
||||
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
|
||||
const total = Number(r.total_clientes);
|
||||
const positivados = Number(r.clientes_positivados);
|
||||
@@ -517,9 +655,42 @@ export class DashboardService {
|
||||
totalClientes: total,
|
||||
clientesPositivados: positivados,
|
||||
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
|
||||
novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Agrega as linhas UF×rep em UF → { totais, reps[] } ordenado por faturamento.
|
||||
const porUf = new Map<
|
||||
string,
|
||||
{ faturamento: number; pedidos: number; clientes: number; reps: UfRepRow[] }
|
||||
>();
|
||||
for (const r of ufRepRows) {
|
||||
const acc = porUf.get(r.uf) ?? { faturamento: 0, pedidos: 0, clientes: 0, reps: [] };
|
||||
acc.faturamento += Number(r.faturamento);
|
||||
acc.pedidos += Number(r.pedidos);
|
||||
acc.clientes += Number(r.clientes);
|
||||
acc.reps.push(r);
|
||||
porUf.set(r.uf, acc);
|
||||
}
|
||||
const faturamentoPorUf = [...porUf.entries()]
|
||||
.map(([uf, t]) => ({
|
||||
uf,
|
||||
faturamento: t.faturamento,
|
||||
pedidos: t.pedidos,
|
||||
clientes: t.clientes,
|
||||
pct: faturamentoMes > 0 ? Math.round((t.faturamento / faturamentoMes) * 1000) / 10 : 0,
|
||||
reps: t.reps
|
||||
.map((r) => ({
|
||||
codVendedor: Number(r.cod_vendedor),
|
||||
nomeVendedor: r.nome_vendedor ?? null,
|
||||
faturamento: Number(r.faturamento),
|
||||
pedidos: Number(r.pedidos),
|
||||
clientes: Number(r.clientes),
|
||||
}))
|
||||
.sort((a, b) => b.faturamento - a.faturamento),
|
||||
}))
|
||||
.sort((a, b) => b.faturamento - a.faturamento);
|
||||
|
||||
return {
|
||||
faturamentoMes,
|
||||
pedidosMes,
|
||||
@@ -530,22 +701,54 @@ export class DashboardService {
|
||||
metasPorGrupo,
|
||||
rankingReps,
|
||||
positivacaoReps,
|
||||
positivacaoDiaria: positivacaoDiariaRows.map((r) => ({
|
||||
dia: r.dia,
|
||||
clientes: Number(r.clientes),
|
||||
})),
|
||||
faturamentoPorUf,
|
||||
metaPositivacaoDia: config?.metaPositivacaoDia ?? null,
|
||||
syncedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||
// Salva a meta diária de positivação (config_empresa) — só gerente/admin.
|
||||
async saveMetaPositivacaoDia(meta: number): Promise<void> {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role !== 'manager' && role !== 'admin') {
|
||||
throw new ForbiddenException('Apenas gerentes podem salvar a meta de positivação');
|
||||
}
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||
|
||||
const value = meta > 0 ? meta : null;
|
||||
await prisma.configEmpresa.upsert({
|
||||
where: { idEmpresa },
|
||||
create: { idEmpresa, metaPositivacaoDia: value },
|
||||
update: { metaPositivacaoDia: value },
|
||||
});
|
||||
}
|
||||
|
||||
async supervisorDashboard(codSupervisor?: number): Promise<SupervisorDashboard> {
|
||||
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();
|
||||
|
||||
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
|
||||
const approvalQueue = await prisma.pedido.findMany({
|
||||
where: { idEmpresa, situa: SITUA_PENDENTE },
|
||||
orderBy: { dtPedido: 'asc' },
|
||||
take: 50,
|
||||
});
|
||||
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes).
|
||||
// Gerente/admin: sem codSupervisor veem a empresa toda; com codSupervisor
|
||||
// (Detalhar da aba Equipe) veem o time daquele supervisor. codSupervisor é
|
||||
// ignorado para role 'supervisor' — não pode espiar outra equipe.
|
||||
const role = this.cls.get('role');
|
||||
const userId = this.cls.get('userId');
|
||||
let teamCode: number | null = null;
|
||||
if (role === 'supervisor') {
|
||||
teamCode = userId ? parseInt(userId, 10) : 0;
|
||||
} else if (codSupervisor && codSupervisor > 0) {
|
||||
teamCode = codSupervisor;
|
||||
}
|
||||
const team = teamCode ? await getTeamCodes(prisma, teamCode) : null;
|
||||
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
|
||||
|
||||
// Pedidos do dia — lê do ERP (situa != 5=Cancelado)
|
||||
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
@@ -566,16 +769,163 @@ export class DashboardService {
|
||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}'
|
||||
${teamSqlFilter('cod_vendedor')}
|
||||
`),
|
||||
prisma.$queryRawUnsafe<DayRow[]>(`
|
||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa} AND situa != 5
|
||||
AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}'
|
||||
${teamSqlFilter('cod_vendedor')}
|
||||
`),
|
||||
]);
|
||||
|
||||
// Top 3 reps com mais clientes inativos (>30 dias sem compra no ERP)
|
||||
// Métricas do mês corrente da equipe — mesmas contas do painel gerencial,
|
||||
// escopadas pelos códigos da equipe (gerente/admin sem filtro = empresa toda).
|
||||
const idEmpresaMatriz = matrizEmpresa(idEmpresa);
|
||||
const year = now.getFullYear();
|
||||
const month = now.getMonth() + 1;
|
||||
const monthStartStr = new Date(year, month - 1, 1).toISOString().slice(0, 10);
|
||||
const monthEndStr = new Date(year, month, 0).toISOString().slice(0, 10);
|
||||
|
||||
interface RankingRow {
|
||||
cod_vendedor: number;
|
||||
nome: string | null;
|
||||
pedidos: string;
|
||||
clientes_atendidos: string;
|
||||
faturamento: string;
|
||||
meta_valor: string | null;
|
||||
}
|
||||
interface MetaTotalRow {
|
||||
meta_total: string;
|
||||
}
|
||||
interface PositivacaoRow {
|
||||
cod_vendedor: number;
|
||||
nome_vendedor: string | null;
|
||||
total_clientes: string;
|
||||
clientes_positivados: string;
|
||||
}
|
||||
interface NovosClientesRow {
|
||||
cod_vendedor: number;
|
||||
novos: string;
|
||||
}
|
||||
|
||||
const [mesRows, rankingRows, metaTotalRows, positivacaoRows, novosClientesRows] =
|
||||
await Promise.all([
|
||||
prisma.$queryRawUnsafe<DayRow[]>(`
|
||||
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND situa NOT IN (1, 5)
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
AND dt_pedido <= '${monthEndStr}'
|
||||
${teamSqlFilter('cod_vendedor')}
|
||||
`),
|
||||
prisma.$queryRawUnsafe<RankingRow[]>(`
|
||||
SELECT p.cod_vendedor,
|
||||
(SELECT r.nome FROM vw_representantes r
|
||||
WHERE r.codigo = p.cod_vendedor LIMIT 1) AS nome,
|
||||
COUNT(*)::text AS pedidos,
|
||||
COUNT(DISTINCT p.id_cliente)::text AS clientes_atendidos,
|
||||
COALESCE(SUM(p.total), 0)::text AS faturamento,
|
||||
(SELECT SUM(m.valor)::text FROM sar.vw_metas m
|
||||
WHERE m.id_empresa = ${idEmpresaMatriz}
|
||||
AND m.cod_vendedor = p.cod_vendedor
|
||||
AND m.ano = ${year}
|
||||
AND m.mes = ${month}
|
||||
AND TRIM(m.tipo) = '${TIPO_META_GRUPO}') AS meta_valor
|
||||
FROM vw_pedidos_erp p
|
||||
WHERE p.id_empresa = ${idEmpresa}
|
||||
AND p.situa NOT IN (1, 5)
|
||||
AND p.dt_pedido >= '${monthStartStr}'
|
||||
AND p.dt_pedido <= '${monthEndStr}'
|
||||
${teamSqlFilter('p.cod_vendedor')}
|
||||
GROUP BY p.cod_vendedor
|
||||
ORDER BY SUM(p.total) DESC
|
||||
LIMIT 10
|
||||
`),
|
||||
prisma.$queryRawUnsafe<MetaTotalRow[]>(`
|
||||
SELECT COALESCE(SUM(valor), 0)::text AS meta_total
|
||||
FROM sar.vw_metas
|
||||
WHERE id_empresa = ${idEmpresaMatriz}
|
||||
AND ano = ${year}
|
||||
AND mes = ${month}
|
||||
AND TRIM(tipo) = '${TIPO_META_GRUPO}'
|
||||
${teamSqlFilter('cod_vendedor')}
|
||||
`),
|
||||
prisma.$queryRawUnsafe<PositivacaoRow[]>(`
|
||||
SELECT
|
||||
c.cod_vendedor,
|
||||
(SELECT r.nome FROM vw_representantes r WHERE r.codigo = c.cod_vendedor LIMIT 1) AS nome_vendedor,
|
||||
COUNT(DISTINCT c.id_cliente)::text AS total_clientes,
|
||||
COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END)::text AS clientes_positivados
|
||||
FROM vw_clientes c
|
||||
LEFT JOIN (
|
||||
SELECT DISTINCT id_cliente, cod_vendedor
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND situa NOT IN (1, 5)
|
||||
AND dt_pedido >= '${monthStartStr}'
|
||||
AND dt_pedido <= '${monthEndStr}'
|
||||
) ped ON ped.id_cliente = c.id_cliente AND ped.cod_vendedor = c.cod_vendedor
|
||||
WHERE c.cod_vendedor > 0
|
||||
${teamSqlFilter('c.cod_vendedor')}
|
||||
GROUP BY c.cod_vendedor
|
||||
HAVING COUNT(DISTINCT c.id_cliente) > 0
|
||||
ORDER BY COUNT(DISTINCT CASE WHEN ped.id_cliente IS NOT NULL THEN c.id_cliente END) DESC
|
||||
`),
|
||||
prisma.$queryRawUnsafe<NovosClientesRow[]>(`
|
||||
SELECT c.cod_vendedor, COUNT(*)::text AS novos
|
||||
FROM (
|
||||
SELECT id_cliente, MIN(dt_pedido) AS primeira_compra
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND situa NOT IN (1, 5)
|
||||
GROUP BY id_cliente
|
||||
) f
|
||||
JOIN (SELECT DISTINCT id_cliente, cod_vendedor FROM vw_clientes) c
|
||||
ON c.id_cliente = f.id_cliente
|
||||
WHERE f.primeira_compra >= '${monthStartStr}'
|
||||
AND f.primeira_compra <= '${monthEndStr}'
|
||||
${teamSqlFilter('c.cod_vendedor')}
|
||||
GROUP BY c.cod_vendedor
|
||||
`),
|
||||
]);
|
||||
|
||||
const pedidosMes = Number(mesRows[0]?.count ?? 0);
|
||||
const faturamentoMes = Number(mesRows[0]?.total ?? 0);
|
||||
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
|
||||
|
||||
const rankingReps: RankingRep[] = rankingRows.map((r) => {
|
||||
const fat = Number(r.faturamento);
|
||||
const meta = r.meta_valor ? Number(r.meta_valor) : 0;
|
||||
return {
|
||||
codVendedor: Number(r.cod_vendedor),
|
||||
nomeVendedor: r.nome ?? null,
|
||||
pedidos: Number(r.pedidos),
|
||||
clientesAtendidos: Number(r.clientes_atendidos),
|
||||
faturamento: fat,
|
||||
pctMeta: meta > 0 ? Math.round((fat / meta) * 100) : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const novosMap = new Map(
|
||||
novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]),
|
||||
);
|
||||
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
|
||||
const total = Number(r.total_clientes);
|
||||
const positivados = Number(r.clientes_positivados);
|
||||
return {
|
||||
codVendedor: Number(r.cod_vendedor),
|
||||
nomeVendedor: r.nome_vendedor ?? null,
|
||||
totalClientes: total,
|
||||
clientesPositivados: positivados,
|
||||
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
|
||||
novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0,
|
||||
};
|
||||
});
|
||||
|
||||
// Reps com mais clientes inativos (>30 dias sem compra no ERP)
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000);
|
||||
const inativosPorRep = await prisma.$queryRawUnsafe<InativosPorRepRow[]>(`
|
||||
SELECT inativos.cod_vendedor,
|
||||
@@ -591,56 +941,17 @@ export class DashboardService {
|
||||
AND p.id_empresa = ${idEmpresa}
|
||||
AND p.situa != 5
|
||||
WHERE c.ativo = 1
|
||||
${teamSqlFilter('c.cod_vendedor')}
|
||||
GROUP BY c.id_cliente, c.cod_vendedor
|
||||
HAVING MAX(p.dt_pedido) IS NULL
|
||||
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'
|
||||
) inativos
|
||||
GROUP BY inativos.cod_vendedor
|
||||
ORDER BY COUNT(*) DESC
|
||||
LIMIT 3
|
||||
LIMIT 10
|
||||
`);
|
||||
|
||||
// Resolve nomes de cliente e representante da fila (pedidos SAR só têm os códigos)
|
||||
const repCods = [...new Set(approvalQueue.map((p) => p.codVendedor))];
|
||||
const cliIds = [...new Set(approvalQueue.map((p) => p.idCliente))];
|
||||
const [repNameRows, cliNameRows] = await Promise.all([
|
||||
repCods.length
|
||||
? prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${repCods.join(',')})`,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
cliIds.length
|
||||
? prisma.$queryRawUnsafe<
|
||||
{ id_cliente: number; nome: string | null; razao: string | null }[]
|
||||
>(
|
||||
`SELECT id_cliente, nome, razao FROM vw_clientes WHERE id_cliente IN (${cliIds.join(',')})`,
|
||||
)
|
||||
: Promise.resolve([]),
|
||||
]);
|
||||
const repNameMap = new Map(repNameRows.map((r) => [Number(r.codigo), r.nome]));
|
||||
const cliNameMap = new Map(
|
||||
cliNameRows.map((c) => [Number(c.id_cliente), { nome: c.nome, razao: c.razao }]),
|
||||
);
|
||||
|
||||
const mapPedido = (o: (typeof approvalQueue)[number]) => ({
|
||||
id: o.id,
|
||||
numPedSar: o.numPedSar,
|
||||
idCliente: o.idCliente,
|
||||
nomeCliente: cliNameMap.get(o.idCliente)?.nome ?? null,
|
||||
razaoCliente: cliNameMap.get(o.idCliente)?.razao ?? null,
|
||||
codVendedor: o.codVendedor,
|
||||
nomeVendedor: repNameMap.get(o.codVendedor) ?? null,
|
||||
situa: o.situa,
|
||||
dtPedido: o.dtPedido.toISOString(),
|
||||
total: String(o.total),
|
||||
descontoPerc: String(o.descontoPerc),
|
||||
obs: o.obs,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
fonte: 'sar' as const,
|
||||
});
|
||||
|
||||
return {
|
||||
approvalQueue: approvalQueue.map(mapPedido),
|
||||
pedidosDia: {
|
||||
count: Number(todayRows[0]?.count ?? 0),
|
||||
total: Number(todayRows[0]?.total ?? 0),
|
||||
@@ -652,7 +963,93 @@ export class DashboardService {
|
||||
nomeVendedor: r.nome_vendedor ?? null,
|
||||
inativosCount: parseInt(r.inativos_count, 10),
|
||||
})),
|
||||
equipeMes: {
|
||||
faturamento: faturamentoMes,
|
||||
pedidos: pedidosMes,
|
||||
ticketMedio: pedidosMes > 0 ? faturamentoMes / pedidosMes : 0,
|
||||
metaTotal,
|
||||
pctMeta: metaTotal > 0 ? Math.round((faturamentoMes / metaTotal) * 100) : 0,
|
||||
},
|
||||
rankingReps,
|
||||
positivacaoReps,
|
||||
syncedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Clientes inativos (>30 dias sem compra) de um representante — detalhe do
|
||||
// card "Inativos por Rep". Supervisor só consulta reps da própria equipe;
|
||||
// gerente/admin consultam qualquer rep.
|
||||
async supervisorInativos(codVendedor: number): Promise<RepInativosDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
const role = this.cls.get('role');
|
||||
const userId = this.cls.get('userId');
|
||||
if (role === 'supervisor') {
|
||||
const team = await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0);
|
||||
if (!team.includes(codVendedor)) {
|
||||
throw new ForbiddenException('Representante fora da sua equipe');
|
||||
}
|
||||
}
|
||||
|
||||
// Mesmo corte do resumo inativosPorRep: sem compra há mais de 30 dias.
|
||||
const cutoffStr = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
|
||||
const rows = await prisma.$queryRawUnsafe<NaoPositivadoRow[]>(`
|
||||
WITH ultimo_pedido AS (
|
||||
SELECT id_cliente, MAX(dt_pedido) AS dt_max
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND situa != 5
|
||||
GROUP BY id_cliente
|
||||
)
|
||||
SELECT
|
||||
c.id_cliente,
|
||||
TRIM(c.nome) AS nome,
|
||||
TRIM(c.razao) AS razao,
|
||||
TO_CHAR(up.dt_max, 'YYYY-MM-DD') AS dt_ultimo_pedido,
|
||||
(CURRENT_DATE - up.dt_max::date) AS dias_sem_pedido,
|
||||
(up.dt_max IS NOT NULL) AS comprou_antes,
|
||||
ct.whatsapp
|
||||
FROM vw_clientes c
|
||||
LEFT JOIN ultimo_pedido up ON up.id_cliente = c.id_cliente
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT TRIM(whatsapp) AS whatsapp
|
||||
FROM sar.vw_contatos
|
||||
WHERE id_corrent = c.id_cliente
|
||||
AND whatsapp IS NOT NULL AND TRIM(whatsapp) != ''
|
||||
AND ativo = 1
|
||||
LIMIT 1
|
||||
) ct ON true
|
||||
WHERE c.cod_vendedor = ${codVendedor}
|
||||
AND c.ativo = 1
|
||||
AND (up.dt_max IS NULL OR up.dt_max < '${cutoffStr}')
|
||||
ORDER BY up.dt_max ASC NULLS FIRST
|
||||
`);
|
||||
|
||||
const nomeRows = await prisma.$queryRawUnsafe<{ nome: string | null }[]>(
|
||||
`SELECT nome FROM vw_representantes WHERE codigo = ${codVendedor} LIMIT 1`,
|
||||
);
|
||||
|
||||
// Deduplica por idCliente — vw_clientes pode repetir o cliente por empresa.
|
||||
const seen = new Set<number>();
|
||||
const clientes = rows
|
||||
.filter((c) => {
|
||||
const id = Number(c.id_cliente);
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
})
|
||||
.map((c) => ({
|
||||
idCliente: Number(c.id_cliente),
|
||||
nome: c.nome,
|
||||
razao: c.razao ?? null,
|
||||
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999,
|
||||
ultimoPedido: c.dt_ultimo_pedido ?? null,
|
||||
comprouAntes: Boolean(c.comprou_antes),
|
||||
whatsapp: c.whatsapp ?? null,
|
||||
}));
|
||||
|
||||
return { codVendedor, nomeVendedor: nomeRows[0]?.nome ?? null, clientes };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import type { EquipeResponse } from '@sar/api-interface';
|
||||
import type { EquipeResponse, SupervisoresResponse } from '@sar/api-interface';
|
||||
import { EquipeService } from './equipe.service';
|
||||
|
||||
@Controller({ path: 'equipe' })
|
||||
@@ -10,4 +10,9 @@ export class EquipeController {
|
||||
listEquipe(): Promise<EquipeResponse> {
|
||||
return this.equipe.listEquipe();
|
||||
}
|
||||
|
||||
@Get('supervisores')
|
||||
listSupervisores(): Promise<SupervisoresResponse> {
|
||||
return this.equipe.listSupervisores();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type { EquipeResponse } from '@sar/api-interface';
|
||||
import type { EquipeResponse, SupervisorCard, SupervisoresResponse } from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
|
||||
function matrizEmpresa(idEmpresa: number): number {
|
||||
@@ -110,4 +110,173 @@ export class EquipeService {
|
||||
syncedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Cards de supervisor da aba Equipe: agrega as métricas do mês por equipe.
|
||||
// Time de um supervisor = reps cujo cod_supervisor aponta para ele + ele
|
||||
// próprio (mesma regra de getTeamCodes, que o Detalhe reusa via
|
||||
// /dashboard/supervisor?codSupervisor=). Agregação feita em JS a partir de
|
||||
// métricas por rep — as mesmas contas do painel, sem escopo por equipe.
|
||||
async listSupervisores(): Promise<SupervisoresResponse> {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role !== 'manager' && role !== 'admin') {
|
||||
throw new ForbiddenException('Apenas gerentes podem consultar os supervisores');
|
||||
}
|
||||
|
||||
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 prevStartStr = new Date(year, month - 2, 1).toISOString().slice(0, 10);
|
||||
const prevEndStr = new Date(year, month - 1, 0).toISOString().slice(0, 10);
|
||||
|
||||
const [repRows, pedidosRows, prevRows, metaRows, positivacaoRows] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<
|
||||
{ cod_vendedor: number; cod_supervisor: number | null; nome: string | null }[]
|
||||
>(`SELECT codigo AS cod_vendedor, cod_supervisor, nome FROM vw_representantes`),
|
||||
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<{ cod_vendedor: number; faturamento: string }[]>(`
|
||||
SELECT cod_vendedor,
|
||||
COALESCE(SUM(total), 0)::text AS faturamento
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND situa NOT IN (1, 5)
|
||||
AND dt_pedido >= '${prevStartStr}'
|
||||
AND dt_pedido <= '${prevEndStr}'
|
||||
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
|
||||
`),
|
||||
prisma.$queryRawUnsafe<
|
||||
{ cod_vendedor: number; total_clientes: string; positivados: string }[]
|
||||
>(`
|
||||
SELECT c.cod_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 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
|
||||
`),
|
||||
]);
|
||||
|
||||
const pedidosMap = new Map(
|
||||
pedidosRows.map((r) => [
|
||||
Number(r.cod_vendedor),
|
||||
{ pedidos: Number(r.pedidos), faturamento: Number(r.faturamento) },
|
||||
]),
|
||||
);
|
||||
const prevMap = new Map(prevRows.map((r) => [Number(r.cod_vendedor), Number(r.faturamento)]));
|
||||
const metaMap = new Map(metaRows.map((r) => [Number(r.cod_vendedor), Number(r.meta_valor)]));
|
||||
const posMap = new Map(
|
||||
positivacaoRows.map((r) => [
|
||||
Number(r.cod_vendedor),
|
||||
{ total: Number(r.total_clientes), positivados: Number(r.positivados) },
|
||||
]),
|
||||
);
|
||||
|
||||
// Monta a equipe de cada supervisor: código do supervisor -> códigos dos reps.
|
||||
// O supervisor entra no próprio time (getTeamCodes inclui o supervisor).
|
||||
const nomeByCod = new Map<number, string | null>();
|
||||
const membrosBySup = new Map<number, Set<number>>();
|
||||
for (const r of repRows) {
|
||||
const cod = Number(r.cod_vendedor);
|
||||
if (!nomeByCod.has(cod)) nomeByCod.set(cod, r.nome ?? null);
|
||||
const sup = r.cod_supervisor != null ? Number(r.cod_supervisor) : 0;
|
||||
if (sup > 0) {
|
||||
let membros = membrosBySup.get(sup);
|
||||
if (!membros) {
|
||||
membros = new Set();
|
||||
membrosBySup.set(sup, membros);
|
||||
}
|
||||
membros.add(cod);
|
||||
}
|
||||
}
|
||||
|
||||
const supervisores: SupervisorCard[] = [...membrosBySup.entries()].map(([sup, membros]) => {
|
||||
const team = new Set(membros);
|
||||
team.add(sup); // o próprio supervisor faz parte do time
|
||||
|
||||
let faturamento = 0;
|
||||
let faturamentoAnterior = 0;
|
||||
let pedidos = 0;
|
||||
let meta = 0;
|
||||
let totalClientes = 0;
|
||||
let positivados = 0;
|
||||
let repsAtivos = 0;
|
||||
for (const cod of team) {
|
||||
const p = pedidosMap.get(cod);
|
||||
if (p) {
|
||||
faturamento += p.faturamento;
|
||||
pedidos += p.pedidos;
|
||||
if (p.faturamento > 0) repsAtivos += 1;
|
||||
}
|
||||
faturamentoAnterior += prevMap.get(cod) ?? 0;
|
||||
meta += metaMap.get(cod) ?? 0;
|
||||
const pos = posMap.get(cod);
|
||||
if (pos) {
|
||||
totalClientes += pos.total;
|
||||
positivados += pos.positivados;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
codSupervisor: sup,
|
||||
nomeSupervisor: nomeByCod.get(sup) ?? null,
|
||||
faturamentoMes: faturamento,
|
||||
faturamentoMesAnterior: faturamentoAnterior,
|
||||
variacaoPct:
|
||||
faturamentoAnterior > 0
|
||||
? Math.round(((faturamento - faturamentoAnterior) / faturamentoAnterior) * 100)
|
||||
: null,
|
||||
pedidosMes: pedidos,
|
||||
ticketMedio: pedidos > 0 ? faturamento / pedidos : 0,
|
||||
metaTotal: meta,
|
||||
pctMeta: meta > 0 ? Math.round((faturamento / meta) * 100) : 0,
|
||||
totalClientes,
|
||||
clientesPositivados: positivados,
|
||||
pctPositivacao: totalClientes > 0 ? Math.round((positivados / totalClientes) * 100) : 0,
|
||||
repsTotal: team.size,
|
||||
repsAtivos,
|
||||
};
|
||||
});
|
||||
|
||||
// Ranking/disputa: maior faturamento primeiro.
|
||||
supervisores.sort((a, b) => b.faturamentoMes - a.faturamentoMes);
|
||||
|
||||
return {
|
||||
supervisores,
|
||||
syncedAt: now.toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
48
apps/api/src/app/funil/funil.controller.ts
Normal file
48
apps/api/src/app/funil/funil.controller.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
CreateOportunidadeSchema,
|
||||
UpdateOportunidadeSchema,
|
||||
type FunilResponse,
|
||||
type Oportunidade,
|
||||
} from '@sar/api-interface';
|
||||
import { FunilService } from './funil.service';
|
||||
|
||||
class CreateDto extends createZodDto(CreateOportunidadeSchema) {}
|
||||
class UpdateDto extends createZodDto(UpdateOportunidadeSchema) {}
|
||||
|
||||
@Controller('funil')
|
||||
export class FunilController {
|
||||
constructor(private readonly funil: FunilService) {}
|
||||
|
||||
@Get()
|
||||
listar(): Promise<FunilResponse> {
|
||||
return this.funil.listar();
|
||||
}
|
||||
|
||||
@Post()
|
||||
criar(@Body() dto: CreateDto): Promise<Oportunidade> {
|
||||
return this.funil.criar(CreateOportunidadeSchema.parse(dto));
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
atualizar(@Param('id', ParseIntPipe) id: number, @Body() dto: UpdateDto): Promise<Oportunidade> {
|
||||
return this.funil.atualizar(id, UpdateOportunidadeSchema.parse(dto));
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@HttpCode(204)
|
||||
deletar(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.funil.deletar(id);
|
||||
}
|
||||
}
|
||||
9
apps/api/src/app/funil/funil.module.ts
Normal file
9
apps/api/src/app/funil/funil.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { FunilController } from './funil.controller';
|
||||
import { FunilService } from './funil.service';
|
||||
|
||||
@Module({
|
||||
controllers: [FunilController],
|
||||
providers: [FunilService],
|
||||
})
|
||||
export class FunilModule {}
|
||||
141
apps/api/src/app/funil/funil.service.ts
Normal file
141
apps/api/src/app/funil/funil.service.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type {
|
||||
CreateOportunidadeDto,
|
||||
UpdateOportunidadeDto,
|
||||
FunilResponse,
|
||||
EtapaFunil,
|
||||
Oportunidade,
|
||||
} from '@sar/api-interface';
|
||||
|
||||
type OportRow = {
|
||||
id: number;
|
||||
idCliente: number | null;
|
||||
nomeProspect: string | null;
|
||||
empresaProspect: string | null;
|
||||
titulo: string;
|
||||
etapa: string;
|
||||
valorEstimado: unknown;
|
||||
observacoes: string | null;
|
||||
idPedido: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type PrismaCtx = {
|
||||
oportunidade: {
|
||||
findMany: (args: object) => Promise<OportRow[]>;
|
||||
create: (args: object) => Promise<OportRow>;
|
||||
update: (args: object) => Promise<OportRow>;
|
||||
delete: (args: object) => Promise<void>;
|
||||
};
|
||||
$queryRawUnsafe: <T>(sql: string, ...args: unknown[]) => Promise<T[]>;
|
||||
};
|
||||
|
||||
type ClienteRow = { id_cliente: number; nome: string | null; razao: string | null };
|
||||
|
||||
@Injectable()
|
||||
export class FunilService {
|
||||
constructor(private readonly cls: ClsService) {}
|
||||
|
||||
private ctx(): { prisma: PrismaCtx; idEmpresa: number; codVendedor: number } {
|
||||
const prisma = this.cls.get('prisma') as PrismaCtx;
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa') as number;
|
||||
const userId = this.cls.get('userId') as string | undefined;
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
return { prisma, idEmpresa, codVendedor };
|
||||
}
|
||||
|
||||
private toOp(r: OportRow, nomeMap: Map<number, string>): Oportunidade {
|
||||
return {
|
||||
id: r.id,
|
||||
idCliente: r.idCliente,
|
||||
nomeProspect: r.nomeProspect,
|
||||
empresaProspect: r.empresaProspect,
|
||||
titulo: r.titulo,
|
||||
etapa: r.etapa as EtapaFunil,
|
||||
valorEstimado: r.valorEstimado != null ? String(r.valorEstimado) : null,
|
||||
observacoes: r.observacoes,
|
||||
idPedido: r.idPedido,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
updatedAt: r.updatedAt.toISOString(),
|
||||
nomeCliente: r.idCliente ? (nomeMap.get(r.idCliente) ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async listar(): Promise<FunilResponse> {
|
||||
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||
|
||||
const rows = await prisma.oportunidade.findMany({
|
||||
where: { idEmpresa, codVendedor },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
|
||||
const idClientes = [
|
||||
...new Set(rows.filter((r) => r.idCliente).map((r) => r.idCliente as number)),
|
||||
];
|
||||
const nomeMap = new Map<number, string>();
|
||||
if (idClientes.length > 0) {
|
||||
const ids = idClientes.join(',');
|
||||
const clientes = await prisma.$queryRawUnsafe<ClienteRow>(
|
||||
`SELECT id_cliente, TRIM(nome) AS nome, TRIM(razao) AS razao FROM vw_clientes WHERE id_cliente IN (${ids}) AND id_empresa = ${idEmpresa}`,
|
||||
);
|
||||
for (const c of clientes) {
|
||||
nomeMap.set(c.id_cliente, c.razao ?? c.nome ?? `Cód. ${c.id_cliente}`);
|
||||
}
|
||||
}
|
||||
|
||||
const byEtapa = (etapa: EtapaFunil) =>
|
||||
rows.filter((r) => r.etapa === etapa).map((r) => this.toOp(r, nomeMap));
|
||||
|
||||
return {
|
||||
lead: byEtapa('lead'),
|
||||
proposta: byEtapa('proposta'),
|
||||
negociacao: byEtapa('negociacao'),
|
||||
ganho: byEtapa('ganho'),
|
||||
perdido: byEtapa('perdido'),
|
||||
};
|
||||
}
|
||||
|
||||
async criar(dto: CreateOportunidadeDto): Promise<Oportunidade> {
|
||||
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||
const row = await prisma.oportunidade.create({
|
||||
data: {
|
||||
idEmpresa,
|
||||
codVendedor,
|
||||
idCliente: dto.idCliente ?? null,
|
||||
nomeProspect: dto.nomeProspect ?? null,
|
||||
empresaProspect: dto.empresaProspect ?? null,
|
||||
titulo: dto.titulo,
|
||||
etapa: dto.etapa ?? 'lead',
|
||||
valorEstimado: dto.valorEstimado ?? null,
|
||||
observacoes: dto.observacoes ?? null,
|
||||
},
|
||||
});
|
||||
return this.toOp(row, new Map());
|
||||
}
|
||||
|
||||
async atualizar(id: number, dto: UpdateOportunidadeDto): Promise<Oportunidade> {
|
||||
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||
const row = await prisma.oportunidade.update({
|
||||
where: { id, idEmpresa, codVendedor },
|
||||
data: {
|
||||
...(dto.idCliente !== undefined && { idCliente: dto.idCliente }),
|
||||
...(dto.nomeProspect !== undefined && { nomeProspect: dto.nomeProspect }),
|
||||
...(dto.empresaProspect !== undefined && { empresaProspect: dto.empresaProspect }),
|
||||
...(dto.titulo !== undefined && { titulo: dto.titulo }),
|
||||
...(dto.etapa !== undefined && { etapa: dto.etapa }),
|
||||
...(dto.valorEstimado !== undefined && { valorEstimado: dto.valorEstimado }),
|
||||
...(dto.observacoes !== undefined && { observacoes: dto.observacoes }),
|
||||
...(dto.idPedido !== undefined && { idPedido: dto.idPedido }),
|
||||
},
|
||||
});
|
||||
return this.toOp(row, new Map());
|
||||
}
|
||||
|
||||
async deletar(id: number): Promise<void> {
|
||||
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||
await prisma.oportunidade.delete({ where: { id, idEmpresa, codVendedor } });
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,15 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { SubscribePayloadSchema, type SubscribePayload } from '@sar/api-interface';
|
||||
import {
|
||||
SubscribePayloadSchema,
|
||||
UnsubscribePayloadSchema,
|
||||
type SubscribePayload,
|
||||
} from '@sar/api-interface';
|
||||
import type { AuthenticatedRequest } from '../auth/jwt.types';
|
||||
import { NotificationsService } from './notifications.service';
|
||||
|
||||
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
|
||||
class UnsubscribeDto extends createZodDto(UnsubscribePayloadSchema) {}
|
||||
|
||||
@Controller('notifications')
|
||||
export class NotificationsController {
|
||||
@@ -18,8 +23,8 @@ export class NotificationsController {
|
||||
|
||||
@Delete('unsubscribe')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async unsubscribe(@Body() body: { endpoint: string }): Promise<void> {
|
||||
await this.svc.unsubscribe(body.endpoint);
|
||||
async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise<void> {
|
||||
await this.svc.unsubscribe(req.user.sub, body.endpoint);
|
||||
}
|
||||
|
||||
@Get('pending-count')
|
||||
|
||||
@@ -40,11 +40,15 @@ export class NotificationsService {
|
||||
});
|
||||
}
|
||||
|
||||
async unsubscribe(endpoint: string): Promise<void> {
|
||||
// Escopado ao dono: só remove subscription do próprio usuário — quem souber o
|
||||
// endpoint de terceiro não consegue desregistrá-lo.
|
||||
async unsubscribe(userId: string, endpoint: string): Promise<void> {
|
||||
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 codVendedor = userId ? parseInt(userId, 10) : null;
|
||||
|
||||
await prisma.pushSubscription.deleteMany({ where: { endpoint } });
|
||||
await prisma.pushSubscription.deleteMany({ where: { endpoint, idEmpresa, codVendedor } });
|
||||
}
|
||||
|
||||
async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
HttpCode,
|
||||
Param,
|
||||
@@ -11,38 +10,33 @@ import {
|
||||
Post,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
AprovarPedidoSchema,
|
||||
CancelPedidoSchema,
|
||||
CreatePedidoSchema,
|
||||
UpdatePedidoSchema,
|
||||
PedidoErpConsultaQuerySchema,
|
||||
PedidoListQuerySchema,
|
||||
RecusarPedidoSchema,
|
||||
type AprovarPedido,
|
||||
type CancelPedido,
|
||||
type CreatePedido,
|
||||
type UpdatePedido,
|
||||
type PedidoDetail,
|
||||
type PedidoErpConsultaQuery,
|
||||
type PedidoErpConsultaResponse,
|
||||
type PedidoListQuery,
|
||||
type PedidoListResponse,
|
||||
type RecusarPedido,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
||||
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
||||
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
||||
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
||||
class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
|
||||
class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
|
||||
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
|
||||
|
||||
@Controller({ path: 'orders' })
|
||||
export class OrdersController {
|
||||
constructor(
|
||||
private readonly orders: OrdersService,
|
||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||
) {}
|
||||
constructor(private readonly orders: OrdersService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: PedidoListQueryDto): Promise<PedidoListResponse> {
|
||||
@@ -62,26 +56,23 @@ export class OrdersController {
|
||||
return this.orders.transmit(id);
|
||||
}
|
||||
|
||||
@Patch(':id/approve')
|
||||
approve(
|
||||
@Patch(':id/cancel')
|
||||
cancel(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: AprovarPedidoDto,
|
||||
@Body() body: CancelPedidoDto,
|
||||
): Promise<PedidoDetail> {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem aprovar pedidos');
|
||||
const parsed = AprovarPedidoSchema.parse(body) as AprovarPedido;
|
||||
return this.orders.approve(id, parsed);
|
||||
const parsed = CancelPedidoSchema.parse(body) as CancelPedido;
|
||||
return this.orders.cancel(id, parsed);
|
||||
}
|
||||
|
||||
@Patch(':id/reject')
|
||||
reject(
|
||||
// Edição de orçamento (situa 0) — depois de transmitido não é mais editável
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@Body() body: RecusarPedidoDto,
|
||||
@Body() body: UpdatePedidoDto,
|
||||
): Promise<PedidoDetail> {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role === 'rep') throw new ForbiddenException('Apenas supervisores podem recusar pedidos');
|
||||
const parsed = RecusarPedidoSchema.parse(body) as RecusarPedido;
|
||||
return this.orders.reject(id, parsed);
|
||||
const parsed = UpdatePedidoSchema.parse(body) as UpdatePedido;
|
||||
return this.orders.update(id, parsed);
|
||||
}
|
||||
|
||||
@Get('erp/:idPedido')
|
||||
|
||||
@@ -2,8 +2,9 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/comm
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import type {
|
||||
AprovarPedido,
|
||||
CancelPedido,
|
||||
CreatePedido,
|
||||
UpdatePedido,
|
||||
PedidoDetail,
|
||||
PedidoErpConsultaItem,
|
||||
PedidoErpConsultaQuery,
|
||||
@@ -11,15 +12,14 @@ import type {
|
||||
PedidoListQuery,
|
||||
PedidoListResponse,
|
||||
PedidoSummary,
|
||||
RecusarPedido,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
|
||||
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
|
||||
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
|
||||
// Situa SAR: 0=Orçamento, 2=Confirmado, 3=Cancelado, 4=Faturado
|
||||
// Situa SIG: 1=Pendente, 2=Liberado, 5=Cancelado, 4=Faturado
|
||||
const SITUA_ORCAMENTO = 0;
|
||||
const SITUA_PENDENTE = 1;
|
||||
const SITUA_APROVADO = 2;
|
||||
const SITUA_CANCELADO = 3;
|
||||
|
||||
@@ -51,9 +51,13 @@ export class OrdersService {
|
||||
const { idCliente, situa, numPedSar, from, to, page, limit } = query;
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Rep vê só os próprios pedidos; supervisor vê os da sua equipe
|
||||
// (cod_supervisor em vw_representantes); gerente/admin veem tudo.
|
||||
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : [];
|
||||
const where: Prisma.PedidoWhereInput = {
|
||||
idEmpresa,
|
||||
...(role === 'rep' ? { codVendedor } : {}),
|
||||
...(role === 'supervisor' ? { codVendedor: { in: team } } : {}),
|
||||
...(idCliente != null ? { idCliente } : {}),
|
||||
...(situa != null ? { situa } : {}),
|
||||
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
|
||||
@@ -127,11 +131,23 @@ export class OrdersService {
|
||||
dataHistorico.setDate(dataHistorico.getDate() - 90);
|
||||
const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
|
||||
|
||||
const vendedorFilter = role === 'rep' ? `AND a.cod_vendedor = ${codVendedor}` : '';
|
||||
const fromFilterE = from ? `AND COALESCE(a.data, b.data) >= '${from}'` : '';
|
||||
const toFilterE = to ? `AND COALESCE(a.data, b.data) <= '${to}'` : '';
|
||||
const fromFilterP = from ? `AND a.data >= '${from}'` : '';
|
||||
const toFilterP = to ? `AND a.data <= '${to}'` : '';
|
||||
// Defesa em profundidade: o contrato já exige YYYY-MM-DD, mas como as datas
|
||||
// são interpoladas no SQL abaixo, revalidamos antes de montar a query.
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
if ((from && !DATE_RE.test(from)) || (to && !DATE_RE.test(to))) {
|
||||
throw new BadRequestException('Datas devem estar no formato YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const vendedorFilter =
|
||||
role === 'rep'
|
||||
? `AND a.cod_vendedor = ${codVendedor}`
|
||||
: role === 'supervisor'
|
||||
? `AND a.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
|
||||
: '';
|
||||
const fromFilterE = from ? `AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${from}'` : '';
|
||||
const toFilterE = to ? `AND COALESCE(a.dt_pedido, b.dt_pedido) <= '${to}'` : '';
|
||||
const fromFilterP = from ? `AND a.dt_pedido >= '${from}'` : '';
|
||||
const toFilterP = to ? `AND a.dt_pedido <= '${to}'` : '';
|
||||
const safe = (s: string) => s.replace(/'/g, "''");
|
||||
const searchFilter = search
|
||||
? `AND (r.numero_pedido::text ILIKE '%${safe(search)}%' OR LOWER(cl.nome) LIKE LOWER('%${safe(search)}%') OR LOWER(cl.razao) LIKE LOWER('%${safe(search)}%'))`
|
||||
@@ -164,14 +180,14 @@ export class OrdersService {
|
||||
WITH all_rows AS (
|
||||
SELECT
|
||||
b.numero AS numero_pedido,
|
||||
COALESCE(b.data, a.data) AS data,
|
||||
a.dtprevent AS dt_prevent,
|
||||
a.data_emissao,
|
||||
COALESCE(b.dt_pedido, a.dt_pedido) AS data,
|
||||
a.dt_prevent,
|
||||
a.dt_emissao AS data_emissao,
|
||||
a.situa,
|
||||
NULL::text AS status_descr,
|
||||
a.clien::integer AS id_cliente,
|
||||
a.id_cliente,
|
||||
a.cod_vendedor,
|
||||
TRIM(c.descr) AS forma_pagamento,
|
||||
a.forma_pagamento,
|
||||
a.total::text,
|
||||
a.obs,
|
||||
'E'::varchar(1) AS tipo,
|
||||
@@ -181,36 +197,33 @@ export class OrdersService {
|
||||
d.chave_acesso_nfe,
|
||||
b.situa AS sit_ped,
|
||||
a.id_pedido
|
||||
FROM sig.pedidos a
|
||||
LEFT JOIN sig.pedidos b
|
||||
FROM sar.vw_pedidos_erp a
|
||||
LEFT JOIN sar.vw_pedidos_erp b
|
||||
ON a.numer_ped_vinc = b.numero
|
||||
AND a.id_empresa = b.id_empresa
|
||||
AND b.tipo = 'P'
|
||||
LEFT JOIN gestao.formapag c
|
||||
ON c.id_empresa = ${idMatriz}
|
||||
AND a.cod_formapag = c.codigo
|
||||
LEFT JOIN gestao.nf d
|
||||
LEFT JOIN sar.vw_nf d
|
||||
ON a.numero = d.num_entrega
|
||||
AND d.id_empresa = ${idMatriz}
|
||||
WHERE a.id_empresa = ${idEmpresa}
|
||||
AND a.tipo = 'E'
|
||||
${vendedorFilter}
|
||||
AND b.id_pedido IS NOT NULL
|
||||
AND COALESCE(a.data, b.data) >= '${dataHistoricoStr}'
|
||||
AND COALESCE(a.dt_pedido, b.dt_pedido) >= '${dataHistoricoStr}'
|
||||
${fromFilterE} ${toFilterE}
|
||||
|
||||
UNION ALL
|
||||
|
||||
SELECT
|
||||
a.numero AS numero_pedido,
|
||||
a.data,
|
||||
a.dtprevent AS dt_prevent,
|
||||
a.data_emissao,
|
||||
a.dt_pedido AS data,
|
||||
a.dt_prevent,
|
||||
a.dt_emissao AS data_emissao,
|
||||
a.situa,
|
||||
c.descricao AS status_descr,
|
||||
a.clien::integer AS id_cliente,
|
||||
a.id_cliente,
|
||||
a.cod_vendedor,
|
||||
TRIM(b.descr) AS forma_pagamento,
|
||||
a.forma_pagamento,
|
||||
a.total::text,
|
||||
a.obs,
|
||||
'P'::varchar(1) AS tipo,
|
||||
@@ -220,10 +233,7 @@ export class OrdersService {
|
||||
NULL::varchar AS chave_acesso_nfe,
|
||||
NULL::integer AS sit_ped,
|
||||
a.id_pedido
|
||||
FROM sig.pedidos a
|
||||
LEFT JOIN gestao.formapag b
|
||||
ON b.id_empresa = 1
|
||||
AND a.cod_formapag = b.codigo
|
||||
FROM sar.vw_pedidos_erp a
|
||||
LEFT JOIN vw_sitpedido c
|
||||
ON c.id_sitpedido = a.situa
|
||||
WHERE a.id_empresa = ${idEmpresa}
|
||||
@@ -285,7 +295,12 @@ export class OrdersService {
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
|
||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||
const repFilter =
|
||||
role === 'rep'
|
||||
? { codVendedor }
|
||||
: role === 'supervisor'
|
||||
? { codVendedor: { in: await getTeamCodes(prisma, codVendedor) } }
|
||||
: {};
|
||||
|
||||
const o = await prisma.pedido.findFirst({
|
||||
where: { id, idEmpresa, ...repFilter },
|
||||
@@ -300,20 +315,144 @@ export class OrdersService {
|
||||
return this.mapDetail(o);
|
||||
}
|
||||
|
||||
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a
|
||||
// notificação ao supervisor acontecem no transmit(), não aqui.
|
||||
// PGD-AUTHZ: o rep só vende produtos/preços das pautas vinculadas ao cadastro
|
||||
// dele (cod_pauta1..6). Valida que (a) a pauta do pedido é do rep e (b) todo
|
||||
// item existe naquela pauta com preço > 0 — fecha preço forjado (ex.: 0,01) e
|
||||
// produto fantasma/fora da pauta. O TETO de desconto (alçada + promoção/regra
|
||||
// ativa da política comercial) continua em assertAlcadaItens; aqui só ancora o
|
||||
// preço de tabela na pauta do rep para que aquele cálculo nunca caia no
|
||||
// fallback de "sem preço de referência".
|
||||
private async assertPautaDoRep(
|
||||
codVendedor: number,
|
||||
idPauta: number | null | undefined,
|
||||
itens: { ordem: number; idProduto: number; codProduto?: string | null }[],
|
||||
): Promise<void> {
|
||||
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
|
||||
if (idPauta == null) {
|
||||
throw new BadRequestException('Selecione a pauta de preços para lançar o pedido.');
|
||||
}
|
||||
|
||||
// codVendedor (JWT) e idPauta/idProduto (payload validado como Int) — seguros.
|
||||
const pautaRows = await prisma.$queryRawUnsafe<{ id_pauta: number }[]>(`
|
||||
SELECT DISTINCT pa.id_pauta
|
||||
FROM vw_pautas pa
|
||||
JOIN vw_representantes r ON pa.codigo IN (
|
||||
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||
)
|
||||
WHERE pa.id_empresa = ${idMatriz}
|
||||
AND pa.ativo = 1
|
||||
AND r.codigo = ${codVendedor}
|
||||
AND pa.id_pauta = ${Number(idPauta)}
|
||||
LIMIT 1
|
||||
`);
|
||||
if (!pautaRows[0]) {
|
||||
throw new BadRequestException('Pauta de preços não vinculada ao seu cadastro.');
|
||||
}
|
||||
|
||||
const prodIds = [...new Set(itens.map((i) => i.idProduto))];
|
||||
if (prodIds.length === 0) return;
|
||||
const okRows = await prisma.$queryRawUnsafe<{ id_produto: number }[]>(`
|
||||
SELECT pp.id_produto
|
||||
FROM vw_pauta_produtos pp
|
||||
JOIN vw_produtos p ON p.id_erp = pp.id_produto
|
||||
AND p.id_empresa = ${idMatriz}
|
||||
AND p.ativo = 1
|
||||
WHERE pp.id_pauta = ${Number(idPauta)}
|
||||
AND pp.preco1 > 0
|
||||
AND pp.id_produto IN (${prodIds.join(',')})
|
||||
`);
|
||||
const validos = new Set(okRows.map((r) => Number(r.id_produto)));
|
||||
const foraDaPauta = itens.filter((it) => !validos.has(it.idProduto));
|
||||
if (foraDaPauta.length > 0) {
|
||||
const lista = foraDaPauta
|
||||
.map((it) => `item ${it.ordem} (${it.codProduto ?? it.idProduto})`)
|
||||
.join(', ');
|
||||
throw new BadRequestException(
|
||||
`Produto fora da pauta selecionada: ${lista}. Só é possível vender itens da sua pauta de preços.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Valida a alçada de um payload de criação/edição (mesma regra do transmit):
|
||||
// desconto global e por item contra alcada + promoção/regra do dono do pedido.
|
||||
private async assertAlcadaDto(
|
||||
codVendedor: number,
|
||||
dto: {
|
||||
descontoPerc: number;
|
||||
idPauta?: number;
|
||||
itens: {
|
||||
ordem: number;
|
||||
idProduto: number;
|
||||
codProduto?: string;
|
||||
precoUnitario: number;
|
||||
descontoPerc: number;
|
||||
}[];
|
||||
},
|
||||
): Promise<void> {
|
||||
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 limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
|
||||
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
||||
const limiteDefault = limitMap.get(0) ?? 5;
|
||||
if (dto.descontoPerc > limiteDefault) {
|
||||
throw new BadRequestException(
|
||||
`Desconto de ${dto.descontoPerc}% acima do máximo permitido para você (${limiteDefault}%). Reduza o desconto para continuar.`,
|
||||
);
|
||||
}
|
||||
await this.assertAlcadaItens(
|
||||
{
|
||||
codVendedor,
|
||||
descontoPerc: dto.descontoPerc,
|
||||
idPauta: dto.idPauta ?? null,
|
||||
itens: dto.itens.map((it) => ({
|
||||
ordem: it.ordem,
|
||||
idProduto: it.idProduto,
|
||||
codProduto: it.codProduto ?? null,
|
||||
precoUnitario: it.precoUnitario,
|
||||
descontoPerc: it.descontoPerc,
|
||||
})),
|
||||
},
|
||||
limitMap,
|
||||
limiteDefault,
|
||||
);
|
||||
}
|
||||
|
||||
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A alçada de desconto valida
|
||||
// JÁ AQUI (bloqueia o Concluir com a mensagem do que passou) e de novo no
|
||||
// transmit() — defesa em profundidade, pois aprovação/ERP podem mudar valores.
|
||||
// A notificação ao supervisor continua só no transmit().
|
||||
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
|
||||
async create(dto: CreatePedido): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const role = this.cls.get('role');
|
||||
const userId = this.cls.get('userId') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
// Idempotency-Key: retorna pedido existente sem re-processar
|
||||
// PGD-AUTHZ: rep só lança pedido para cliente da própria carteira.
|
||||
if (role === 'rep') {
|
||||
const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||
dto.idCliente,
|
||||
);
|
||||
if (!cliRows[0] || Number(cliRows[0].cod_vendedor) !== codVendedor) {
|
||||
throw new NotFoundException(`Cliente ${dto.idCliente} não encontrado`);
|
||||
}
|
||||
}
|
||||
|
||||
// Idempotency-Key: retorna pedido existente sem re-processar.
|
||||
// Escopado a empresa + vendedor — chave de terceiro não vaza pedido alheio.
|
||||
if (dto.idempotencyKey) {
|
||||
const existing = await prisma.pedido.findUnique({
|
||||
where: { idempotencyKey: dto.idempotencyKey },
|
||||
const existing = await prisma.pedido.findFirst({
|
||||
where: { idempotencyKey: dto.idempotencyKey, idEmpresa, codVendedor },
|
||||
include: {
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
@@ -322,6 +461,14 @@ export class OrdersService {
|
||||
if (existing) return this.mapDetail(existing);
|
||||
}
|
||||
|
||||
// Rep: pauta do pedido e itens têm que ser da pauta vinculada ao cadastro
|
||||
if (role === 'rep') {
|
||||
await this.assertPautaDoRep(codVendedor, dto.idPauta, dto.itens);
|
||||
}
|
||||
|
||||
// Alçada: bloqueia a criação do orçamento se algum desconto passa do teto
|
||||
await this.assertAlcadaDto(codVendedor, dto);
|
||||
|
||||
const itemsData = dto.itens.map((it) => {
|
||||
const descontoValor =
|
||||
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
|
||||
@@ -369,6 +516,7 @@ export class OrdersService {
|
||||
descontoPerc: dto.descontoPerc,
|
||||
descontoValor: descontoValorGlobal,
|
||||
obs: dto.obs ?? null,
|
||||
endEntrega: dto.endEntrega ?? null,
|
||||
idempotencyKey: dto.idempotencyKey ?? null,
|
||||
itens: { create: itemsData },
|
||||
historico: {
|
||||
@@ -404,7 +552,10 @@ export class OrdersService {
|
||||
|
||||
// Rep só transmite o próprio orçamento
|
||||
const repFilter = role === 'rep' ? { codVendedor } : {};
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
||||
const pedido = await prisma.pedido.findFirst({
|
||||
where: { id, idEmpresa, ...repFilter },
|
||||
include: { itens: { orderBy: { ordem: 'asc' } } },
|
||||
});
|
||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||
throw new BadRequestException(
|
||||
@@ -422,6 +573,8 @@ export class OrdersService {
|
||||
);
|
||||
}
|
||||
|
||||
await this.assertAlcadaItens(pedido, limitMap, limiteMax);
|
||||
|
||||
const now = new Date();
|
||||
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
|
||||
await prisma.historicoPedido.create({
|
||||
@@ -445,93 +598,278 @@ export class OrdersService {
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
|
||||
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
|
||||
// de cada item — preço cobrado vs preço de tabela, combinado com o desconto
|
||||
// global — deve caber no maior valor entre a alçada do grupo do produto
|
||||
// (alcada_desconto por codGrupo; 0 = default), a promoção ativa
|
||||
// (produto/grupo) e a regra de desconto vigente (grupo/subgrupo/rep) — teto,
|
||||
// não soma. Cobre o desconto lançado no item E desconto disfarçado de preço
|
||||
// unitário reduzido. Preço de tabela: pauta do pedido > promocional > preço
|
||||
// base; produto sem referência valida só pelo campo de desconto.
|
||||
private async assertAlcadaItens(
|
||||
pedido: {
|
||||
codVendedor: number;
|
||||
descontoPerc: Prisma.Decimal | number;
|
||||
idPauta: number | null;
|
||||
itens: {
|
||||
ordem: number;
|
||||
idProduto: number;
|
||||
codProduto: string | null;
|
||||
precoUnitario: Prisma.Decimal | number;
|
||||
descontoPerc: Prisma.Decimal | number;
|
||||
}[];
|
||||
},
|
||||
limitMap: Map<number, number>,
|
||||
limiteDefault: number,
|
||||
): Promise<void> {
|
||||
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
|
||||
if (pedido.itens.length === 0) return;
|
||||
const prodIds = [...new Set(pedido.itens.map((i) => i.idProduto))];
|
||||
|
||||
interface ProdRow {
|
||||
id_erp: number;
|
||||
codigo: string | null;
|
||||
cod_grupo: number | null;
|
||||
cod_subgrupo: number | null;
|
||||
vl_preco1: string | null;
|
||||
preco_promocional: string | null;
|
||||
preco_pauta: string | null;
|
||||
}
|
||||
// prodIds/idPauta vêm do banco (Int), não do payload — interpolação segura.
|
||||
const prodRows = await prisma.$queryRawUnsafe<ProdRow[]>(`
|
||||
SELECT p.id_erp, TRIM(p.codigo) AS codigo, p.cod_grupo, p.cod_subgrupo,
|
||||
p.vl_preco1::text, p.preco_promocional::text,
|
||||
${
|
||||
pedido.idPauta != null
|
||||
? `(SELECT pp.preco1::text FROM vw_pauta_produtos pp
|
||||
WHERE pp.id_pauta = ${Number(pedido.idPauta)}
|
||||
AND pp.id_produto = p.id_erp LIMIT 1)`
|
||||
: 'NULL'
|
||||
} AS preco_pauta
|
||||
FROM vw_produtos p
|
||||
WHERE p.id_empresa = ${idMatriz}
|
||||
AND p.id_erp IN (${prodIds.join(',')})
|
||||
`);
|
||||
const prodMap = new Map(prodRows.map((p) => [Number(p.id_erp), p]));
|
||||
|
||||
const hoje = new Date();
|
||||
const promos = await prisma.promocao.findMany({
|
||||
where: {
|
||||
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||
ativa: true,
|
||||
dataInicio: { lte: hoje },
|
||||
dataFim: { gte: hoje },
|
||||
},
|
||||
});
|
||||
|
||||
// Regras de desconto vigentes que alcançam o rep dono do pedido
|
||||
// (cod_vendedor nulo = todos os reps).
|
||||
const regras = await prisma.regraDesconto.findMany({
|
||||
where: {
|
||||
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||
ativa: true,
|
||||
dataInicio: { lte: hoje },
|
||||
dataFim: { gte: hoje },
|
||||
OR: [{ codVendedor: null }, { codVendedor: pedido.codVendedor }],
|
||||
},
|
||||
});
|
||||
|
||||
const descGlobal = Number(pedido.descontoPerc);
|
||||
const problemas: string[] = [];
|
||||
|
||||
for (const it of pedido.itens) {
|
||||
const prod = prodMap.get(it.idProduto);
|
||||
const codGrupo = prod?.cod_grupo != null ? Number(prod.cod_grupo) : null;
|
||||
const codigo = prod?.codigo?.trim() || it.codProduto || String(it.idProduto);
|
||||
|
||||
const promoPct = promos
|
||||
.filter(
|
||||
(p) =>
|
||||
(p.codProduto && prod?.codigo && p.codProduto.trim() === prod.codigo.trim()) ||
|
||||
(p.grpProd && codGrupo != null && p.grpProd.trim() === String(codGrupo)),
|
||||
)
|
||||
.reduce((max, p) => Math.max(max, Number(p.descPct)), 0);
|
||||
|
||||
// Regra sem alvo vale para qualquer produto; com grupo, subgrupo e/ou
|
||||
// pauta, precisa casar com o que a regra especifica. Regra por pauta só
|
||||
// vale quando o pedido usa aquela pauta (id_pauta nulo = qualquer pauta).
|
||||
const codSubgrupo = prod?.cod_subgrupo != null ? Number(prod.cod_subgrupo) : null;
|
||||
const regraPct = regras
|
||||
.filter(
|
||||
(r) =>
|
||||
(r.codGrupo == null || (codGrupo != null && r.codGrupo === codGrupo)) &&
|
||||
(r.codSubgrupo == null || (codSubgrupo != null && r.codSubgrupo === codSubgrupo)) &&
|
||||
(r.idPauta == null || (pedido.idPauta != null && r.idPauta === pedido.idPauta)),
|
||||
)
|
||||
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
|
||||
|
||||
// Promoção/regra definem TETO próprio, não somam à alçada: com alçada 5%
|
||||
// e regra de 15%, o máximo do item é 15% (não 20%). Vale o maior entre a
|
||||
// alçada do grupo e a melhor condição vigente que alcança o produto.
|
||||
const alcadaBase =
|
||||
codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault;
|
||||
const limiteItem = Math.max(alcadaBase, promoPct, regraPct);
|
||||
|
||||
// Preço de tabela: pauta do pedido > promocional (se menor) > preço base
|
||||
const precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
|
||||
const precoBase = precoPauta > 0 ? precoPauta : Number(prod?.vl_preco1 ?? 0);
|
||||
const precoPromo = Number(prod?.preco_promocional ?? 0);
|
||||
const tabela = precoPromo > 0 && precoPromo < precoBase ? precoPromo : precoBase;
|
||||
|
||||
const descItem = Number(it.descontoPerc);
|
||||
const precoLiquido = Number(it.precoUnitario) * (1 - descItem / 100) * (1 - descGlobal / 100);
|
||||
|
||||
// Desconto efetivo: vs tabela quando há referência; senão só os percentuais
|
||||
const descEfetivo =
|
||||
tabela > 0
|
||||
? (1 - precoLiquido / tabela) * 100
|
||||
: (1 - (1 - descItem / 100) * (1 - descGlobal / 100)) * 100;
|
||||
|
||||
if (descEfetivo > limiteItem + 0.01) {
|
||||
problemas.push(
|
||||
`item ${it.ordem} (${codigo}) com desconto efetivo de ${descEfetivo.toFixed(1)}% — máximo permitido ${limiteItem}%`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (problemas.length > 0) {
|
||||
throw new BadRequestException(
|
||||
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para continuar.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Edita um orçamento (situa 0). Depois de transmitido não é mais editável.
|
||||
// Substitui itens e recalcula totais; alçada valida contra o dono do pedido.
|
||||
async update(id: string, dto: UpdatePedido): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const role = this.cls.get('role');
|
||||
const userId = this.cls.get('userId') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
|
||||
const repFilter =
|
||||
role === 'rep'
|
||||
? { codVendedor }
|
||||
: role === 'supervisor'
|
||||
? { codVendedor: { in: await getTeamCodes(prisma, codVendedor) } }
|
||||
: {};
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (pedido.situa !== SITUA_PENDENTE)
|
||||
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||
throw new BadRequestException(
|
||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||
'Apenas orçamentos podem ser editados — este pedido já foi transmitido',
|
||||
);
|
||||
|
||||
const now = new Date();
|
||||
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
|
||||
const newTotal =
|
||||
Math.round(Number(pedido.totalProdutos) * (1 - newDescontoPerc / 100) * 100) / 100;
|
||||
// PGD-AUTHZ: rep só aponta o pedido para cliente da própria carteira.
|
||||
if (role === 'rep' && dto.idCliente !== pedido.idCliente) {
|
||||
const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||
dto.idCliente,
|
||||
);
|
||||
if (!cliRows[0] || Number(cliRows[0].cod_vendedor) !== codVendedor) {
|
||||
throw new NotFoundException(`Cliente ${dto.idCliente} não encontrado`);
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.pedido.update({
|
||||
// Rep: pauta do pedido e itens têm que ser da pauta vinculada ao cadastro
|
||||
if (role === 'rep') {
|
||||
await this.assertPautaDoRep(pedido.codVendedor, dto.idPauta, dto.itens);
|
||||
}
|
||||
|
||||
// Alçada do DONO do pedido (supervisor editando usa o limite do rep)
|
||||
await this.assertAlcadaDto(pedido.codVendedor, dto);
|
||||
|
||||
const itemsData = dto.itens.map((it) => {
|
||||
const descontoValor =
|
||||
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
|
||||
const total = Math.round(it.qtd * it.precoUnitario * (1 - it.descontoPerc / 100) * 100) / 100;
|
||||
return {
|
||||
ordem: it.ordem,
|
||||
idProduto: it.idProduto,
|
||||
codProduto: it.codProduto ?? null,
|
||||
descProduto: it.descProduto,
|
||||
qtd: it.qtd,
|
||||
precoUnitario: it.precoUnitario,
|
||||
descontoPerc: it.descontoPerc,
|
||||
descontoValor,
|
||||
total,
|
||||
};
|
||||
});
|
||||
const totalProdutos = itemsData.reduce((acc, it) => acc + it.total, 0);
|
||||
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
|
||||
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
|
||||
|
||||
const final = await prisma.pedido.update({
|
||||
where: { id },
|
||||
data: {
|
||||
situa: SITUA_APROVADO,
|
||||
descontoPerc: newDescontoPerc,
|
||||
total: newTotal,
|
||||
aprovadoPor: codVendedor,
|
||||
aprovadoEm: now,
|
||||
idCliente: dto.idCliente,
|
||||
idPauta: dto.idPauta ?? null,
|
||||
codFormapag: dto.codFormapag ?? null,
|
||||
totalProdutos,
|
||||
total,
|
||||
descontoPerc: dto.descontoPerc,
|
||||
descontoValor: descontoValorGlobal,
|
||||
obs: dto.obs ?? null,
|
||||
endEntrega: dto.endEntrega ?? null,
|
||||
itens: { deleteMany: {}, create: itemsData },
|
||||
historico: {
|
||||
create: [
|
||||
{
|
||||
situaAnterior: SITUA_ORCAMENTO,
|
||||
situaNova: SITUA_ORCAMENTO,
|
||||
changedBy: codVendedor,
|
||||
changedAt: new Date(),
|
||||
nota: 'Orçamento editado',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await prisma.historicoPedido.create({
|
||||
data: {
|
||||
idPedido: id,
|
||||
situaAnterior: SITUA_PENDENTE,
|
||||
situaNova: SITUA_APROVADO,
|
||||
changedBy: codVendedor,
|
||||
changedAt: now,
|
||||
nota: dto.nota ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
const final = await prisma.pedido.findUniqueOrThrow({
|
||||
where: { id },
|
||||
include: {
|
||||
itens: { orderBy: { ordem: 'asc' } },
|
||||
historico: { orderBy: { changedAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
void this.notifications.notifyUser(String(pedido.codVendedor), {
|
||||
title: 'Pedido aprovado',
|
||||
body: `${final.numPedSar} aprovado${dto.descontoPerc !== undefined ? ` com ${newDescontoPerc}% de desconto` : ''}`,
|
||||
url: `/pedidos/${id}`,
|
||||
});
|
||||
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
async reject(id: string, dto: RecusarPedido): Promise<PedidoDetail> {
|
||||
async cancel(id: string, dto: CancelPedido): Promise<PedidoDetail> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId') ?? '0';
|
||||
const codVendedor = parseInt(userId, 10);
|
||||
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa } });
|
||||
// Rep só pode cancelar seu próprio orçamento (situa 0).
|
||||
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, codVendedor } });
|
||||
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||
if (pedido.situa !== SITUA_PENDENTE)
|
||||
throw new BadRequestException(
|
||||
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
|
||||
);
|
||||
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
|
||||
|
||||
const now = new Date();
|
||||
// ATENCAO: banco ERP e LATIN1 - sem em-dash/caracteres fora do LATIN1 aqui
|
||||
const motivoCompleto = dto.descricao?.trim()
|
||||
? `${dto.motivo} - ${dto.descricao.trim()}`
|
||||
: dto.motivo;
|
||||
|
||||
await prisma.pedido.update({
|
||||
where: { id },
|
||||
data: { situa: SITUA_CANCELADO, motivoRecusa: dto.motivo },
|
||||
data: { situa: SITUA_CANCELADO, motivoRecusa: motivoCompleto },
|
||||
});
|
||||
|
||||
await prisma.historicoPedido.create({
|
||||
data: {
|
||||
idPedido: id,
|
||||
situaAnterior: SITUA_PENDENTE,
|
||||
situaAnterior: SITUA_ORCAMENTO,
|
||||
situaNova: SITUA_CANCELADO,
|
||||
changedBy: codVendedor,
|
||||
changedAt: now,
|
||||
nota: dto.motivo,
|
||||
nota: `Cancelado pelo representante - ${motivoCompleto}`,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -543,12 +881,6 @@ export class OrdersService {
|
||||
},
|
||||
});
|
||||
|
||||
void this.notifications.notifyUser(String(pedido.codVendedor), {
|
||||
title: 'Pedido recusado',
|
||||
body: `${final.numPedSar}: ${dto.motivo}`,
|
||||
url: `/pedidos/${id}`,
|
||||
});
|
||||
|
||||
return this.mapDetail(final);
|
||||
}
|
||||
|
||||
@@ -589,6 +921,8 @@ export class OrdersService {
|
||||
codVendedor: number;
|
||||
situa: number;
|
||||
dtPedido: Date;
|
||||
idPauta: number | null;
|
||||
codFormapag: number | null;
|
||||
total: Prisma.Decimal;
|
||||
descontoPerc: Prisma.Decimal;
|
||||
descontoValor: Prisma.Decimal;
|
||||
@@ -602,6 +936,7 @@ export class OrdersService {
|
||||
aprovadoEm: Date | null;
|
||||
motivoRecusa: string | null;
|
||||
obs: string | null;
|
||||
endEntrega: string | null;
|
||||
idempotencyKey: string | null;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
@@ -626,6 +961,41 @@ export class OrdersService {
|
||||
}[];
|
||||
}): Promise<PedidoDetail> {
|
||||
const names = await this.lookupNames(o.idCliente, o.codVendedor);
|
||||
|
||||
// Grupo/subgrupo dos produtos (vw_produtos, empresa matriz) — a edição do
|
||||
// orçamento usa para calcular o teto de desconto por item no carrinho.
|
||||
const prisma = this.cls.get('prisma');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
const prodIds = [...new Set(o.itens.map((i) => i.idProduto))];
|
||||
const grupoRows =
|
||||
prisma && prodIds.length > 0
|
||||
? await prisma.$queryRawUnsafe<
|
||||
{
|
||||
id_erp: number;
|
||||
cod_grupo: number | null;
|
||||
cod_subgrupo: number | null;
|
||||
peso_liquido: string | null;
|
||||
vol_m3: string | null;
|
||||
}[]
|
||||
>(
|
||||
`SELECT id_erp, cod_grupo, cod_subgrupo, peso_liquido::text, vol_m3::text
|
||||
FROM vw_produtos
|
||||
WHERE id_empresa = ${idMatriz} AND id_erp IN (${prodIds.join(',')})`,
|
||||
)
|
||||
: [];
|
||||
const grupoMap = new Map(grupoRows.map((g) => [Number(g.id_erp), g]));
|
||||
|
||||
// Peso total (kg) e cubagem (m3) do pedido: soma de qtd x peso/vol do produto
|
||||
let pesoTotal = 0;
|
||||
let m3Total = 0;
|
||||
for (const it of o.itens) {
|
||||
const prod = grupoMap.get(it.idProduto);
|
||||
const qtd = Number(it.qtd);
|
||||
pesoTotal += qtd * Number(prod?.peso_liquido ?? 0);
|
||||
m3Total += qtd * Number(prod?.vol_m3 ?? 0);
|
||||
}
|
||||
|
||||
return {
|
||||
id: o.id,
|
||||
numPedSar: o.numPedSar,
|
||||
@@ -636,9 +1006,14 @@ export class OrdersService {
|
||||
nomeVendedor: names.nomeVendedor,
|
||||
situa: o.situa,
|
||||
dtPedido: o.dtPedido.toISOString(),
|
||||
idPauta: o.idPauta,
|
||||
codFormapag: o.codFormapag,
|
||||
pesoTotal: pesoTotal.toFixed(3),
|
||||
m3Total: m3Total.toFixed(4),
|
||||
total: decimalToString(o.total),
|
||||
descontoPerc: decimalToString(o.descontoPerc),
|
||||
obs: o.obs,
|
||||
endEntrega: o.endEntrega,
|
||||
createdAt: o.createdAt.toISOString(),
|
||||
totalProdutos: decimalToString(o.totalProdutos),
|
||||
totalIpi: decimalToString(o.totalIpi),
|
||||
@@ -663,6 +1038,14 @@ export class OrdersService {
|
||||
precoUnitario: decimalToString(it.precoUnitario),
|
||||
descontoPerc: decimalToString(it.descontoPerc),
|
||||
total: decimalToString(it.total),
|
||||
codGrupo:
|
||||
grupoMap.get(it.idProduto)?.cod_grupo != null
|
||||
? Number(grupoMap.get(it.idProduto)?.cod_grupo)
|
||||
: null,
|
||||
codSubgrupo:
|
||||
grupoMap.get(it.idProduto)?.cod_subgrupo != null
|
||||
? Number(grupoMap.get(it.idProduto)?.cod_subgrupo)
|
||||
: null,
|
||||
})),
|
||||
historico: o.historico.map((h) => ({
|
||||
id: h.id,
|
||||
@@ -717,9 +1100,16 @@ export class OrdersService {
|
||||
preco_unitario: string;
|
||||
desconto_perc: string;
|
||||
total: string;
|
||||
peso_liquido: string | null;
|
||||
vol_m3: string | null;
|
||||
}
|
||||
|
||||
const vendedorFilter = role === 'rep' ? `AND e.cod_vendedor = ${codVendedor}` : '';
|
||||
const vendedorFilter =
|
||||
role === 'rep'
|
||||
? `AND e.cod_vendedor = ${codVendedor}`
|
||||
: role === 'supervisor'
|
||||
? `AND e.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
|
||||
: '';
|
||||
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
|
||||
const [headerRows, itemRows] = await Promise.all([
|
||||
@@ -744,7 +1134,8 @@ export class OrdersService {
|
||||
SELECT ei.ordem, ei.id_produto,
|
||||
TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao,
|
||||
ei.qtd::text, ei.preco_unitario::text,
|
||||
ei.desconto_perc::text, ei.total::text
|
||||
ei.desconto_perc::text, ei.total::text,
|
||||
p.peso_liquido::text AS peso_liquido, p.vol_m3::text AS vol_m3
|
||||
FROM vw_peditens_erp ei
|
||||
LEFT JOIN vw_produtos p
|
||||
ON p.id_erp = ei.id_produto
|
||||
@@ -757,6 +1148,15 @@ export class OrdersService {
|
||||
if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`);
|
||||
const h = headerRows[0];
|
||||
|
||||
const pesoTotalErp = itemRows.reduce(
|
||||
(acc, it) => acc + Number(it.qtd) * Number(it.peso_liquido ?? 0),
|
||||
0,
|
||||
);
|
||||
const m3TotalErp = itemRows.reduce(
|
||||
(acc, it) => acc + Number(it.qtd) * Number(it.vol_m3 ?? 0),
|
||||
0,
|
||||
);
|
||||
|
||||
return {
|
||||
id: `erp-${h.id_pedido}`,
|
||||
numPedSar: (h.num_ped_sar ?? '').trim(),
|
||||
@@ -787,6 +1187,8 @@ export class OrdersService {
|
||||
aprovadoEm: null,
|
||||
motivoRecusa: null,
|
||||
idempotencyKey: null,
|
||||
pesoTotal: pesoTotalErp.toFixed(3),
|
||||
m3Total: m3TotalErp.toFixed(4),
|
||||
itens: itemRows.map((it) => ({
|
||||
id: `${idPedido}-${it.ordem}`,
|
||||
idProduto: Number(it.id_produto),
|
||||
|
||||
@@ -11,43 +11,49 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import {
|
||||
UpsertDescontoBodySchema,
|
||||
CreatePromocaoBodySchema,
|
||||
UpdatePromocaoBodySchema,
|
||||
type UpsertDescontoBody,
|
||||
CreateRegraDescontoBodySchema,
|
||||
UpdateRegraDescontoBodySchema,
|
||||
type CreatePromocaoBody,
|
||||
type UpdatePromocaoBody,
|
||||
type AlcadaDescontosResponse,
|
||||
type CreateRegraDescontoBody,
|
||||
type UpdateRegraDescontoBody,
|
||||
type PromocoesResponse,
|
||||
type PromocoesVigentesResponse,
|
||||
type AlcadaRepResponse,
|
||||
type RegrasDescontoResponse,
|
||||
type RegrasVigentesResponse,
|
||||
type GruposProdutoResponse,
|
||||
} from '@sar/api-interface';
|
||||
import { PoliticasService } from './politicas.service';
|
||||
|
||||
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
|
||||
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
|
||||
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
|
||||
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
|
||||
class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema) {}
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
@Get('promocoes/vigentes')
|
||||
listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||
return this.politicas.listPromocoesVigentes();
|
||||
}
|
||||
|
||||
@Get('alcada')
|
||||
minhaAlcada(): Promise<AlcadaRepResponse> {
|
||||
return this.politicas.minhaAlcada();
|
||||
}
|
||||
|
||||
@Post('promocoes')
|
||||
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
|
||||
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ ids: number[] }> {
|
||||
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
||||
}
|
||||
|
||||
@@ -64,4 +70,38 @@ export class PoliticasController {
|
||||
deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.politicas.deletePromocao(id);
|
||||
}
|
||||
|
||||
@Get('regras')
|
||||
listRegras(): Promise<RegrasDescontoResponse> {
|
||||
return this.politicas.listRegras();
|
||||
}
|
||||
|
||||
@Get('regras/vigentes')
|
||||
listRegrasVigentes(): Promise<RegrasVigentesResponse> {
|
||||
return this.politicas.listRegrasVigentes();
|
||||
}
|
||||
|
||||
@Post('regras')
|
||||
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ ids: number[] }> {
|
||||
return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody);
|
||||
}
|
||||
|
||||
@Patch('regras/:id')
|
||||
updateRegra(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: UpdateRegraDescontoDto,
|
||||
): Promise<void> {
|
||||
return this.politicas.updateRegra(id, body as unknown as UpdateRegraDescontoBody);
|
||||
}
|
||||
|
||||
@Delete('regras/:id')
|
||||
@HttpCode(204)
|
||||
deleteRegra(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.politicas.deleteRegra(id);
|
||||
}
|
||||
|
||||
@Get('grupos-produto')
|
||||
listGruposProduto(): Promise<GruposProdutoResponse> {
|
||||
return this.politicas.listGruposProduto();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type {
|
||||
AlcadaDescontosResponse,
|
||||
UpsertDescontoBody,
|
||||
PromocoesResponse,
|
||||
CreatePromocaoBody,
|
||||
UpdatePromocaoBody,
|
||||
RegrasDescontoResponse,
|
||||
CreateRegraDescontoBody,
|
||||
UpdateRegraDescontoBody,
|
||||
RegrasVigentesResponse,
|
||||
PromocoesVigentesResponse,
|
||||
AlcadaRepResponse,
|
||||
GruposProdutoResponse,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
|
||||
@@ -20,61 +25,6 @@ export class PoliticasService {
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -102,26 +52,41 @@ export class PoliticasService {
|
||||
};
|
||||
}
|
||||
|
||||
async createPromocao(body: CreatePromocaoBody): Promise<{ id: number }> {
|
||||
// Cria uma promoção POR ALVO (produto ou grupo). Sem alvo, cria uma linha
|
||||
// genérica (comportamento anterior preservado).
|
||||
async createPromocao(body: CreatePromocaoBody): Promise<{ ids: 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,
|
||||
},
|
||||
const alvos: { codProduto: string | null; grpProd: string | null }[] = [
|
||||
...(body.codProdutos ?? []).map((c) => ({ codProduto: c, grpProd: null })),
|
||||
...(body.grpProds ?? []).map((g) => ({ codProduto: null, grpProd: g })),
|
||||
];
|
||||
if (alvos.length === 0) alvos.push({ codProduto: null, grpProd: null });
|
||||
|
||||
const ids = await prisma.$transaction(async (tx) => {
|
||||
const created: number[] = [];
|
||||
for (const alvo of alvos) {
|
||||
const p = await tx.promocao.create({
|
||||
data: {
|
||||
idEmpresa,
|
||||
descricao: body.descricao,
|
||||
codProduto: alvo.codProduto,
|
||||
grpProd: alvo.grpProd,
|
||||
descPct: body.descPct,
|
||||
dataInicio: new Date(body.dataInicio),
|
||||
dataFim: new Date(body.dataFim),
|
||||
createdBy: userId,
|
||||
},
|
||||
});
|
||||
created.push(p.id);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
return { id: p.id };
|
||||
return { ids };
|
||||
}
|
||||
|
||||
async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> {
|
||||
@@ -158,4 +123,326 @@ export class PoliticasService {
|
||||
|
||||
await prisma.promocao.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ─── Regras de Desconto ─────────────────────────────────────────────────────
|
||||
|
||||
// Grupos/subgrupos distintos de vw_produtos (empresa matriz), para combos e
|
||||
// resolução de nomes na listagem — UI sempre mostra o nome, nunca só o código.
|
||||
private async loadGruposProduto(): Promise<
|
||||
{
|
||||
cod_grupo: number | null;
|
||||
grupo: string | null;
|
||||
cod_subgrupo: number | null;
|
||||
subgrupo: string | null;
|
||||
}[]
|
||||
> {
|
||||
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
|
||||
return prisma.$queryRawUnsafe(
|
||||
`SELECT DISTINCT cod_grupo, TRIM(grupo) AS grupo, cod_subgrupo, TRIM(subgrupo) AS subgrupo
|
||||
FROM vw_produtos
|
||||
WHERE id_empresa = ${idMatriz}
|
||||
AND (cod_grupo IS NOT NULL OR cod_subgrupo IS NOT NULL)
|
||||
ORDER BY grupo, subgrupo`,
|
||||
);
|
||||
}
|
||||
|
||||
async listGruposProduto(): Promise<GruposProdutoResponse> {
|
||||
this.assertManager();
|
||||
const rows = await this.loadGruposProduto();
|
||||
return {
|
||||
grupos: rows.map((r) => ({
|
||||
codGrupo: r.cod_grupo != null ? Number(r.cod_grupo) : null,
|
||||
grupo: r.grupo,
|
||||
codSubgrupo: r.cod_subgrupo != null ? Number(r.cod_subgrupo) : null,
|
||||
subgrupo: r.subgrupo,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Descrições das pautas informadas — UI sempre mostra o nome, nunca só o id.
|
||||
private async loadPautaNames(idPautas: number[]): Promise<Map<number, string | null>> {
|
||||
if (idPautas.length === 0) return new Map();
|
||||
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<{ id_pauta: number; descricao: string | null }[]>(
|
||||
`SELECT id_pauta, TRIM(descricao) AS descricao
|
||||
FROM vw_pautas
|
||||
WHERE id_empresa = ${idMatriz}
|
||||
AND id_pauta IN (${idPautas.join(',')})`,
|
||||
);
|
||||
return new Map(rows.map((r) => [Number(r.id_pauta), r.descricao]));
|
||||
}
|
||||
|
||||
async listRegras(): Promise<RegrasDescontoResponse> {
|
||||
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.regraDesconto.findMany({
|
||||
where: { idEmpresa },
|
||||
orderBy: [{ dataFim: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
|
||||
const codigos = [
|
||||
...new Set(rows.map((r) => r.codVendedor).filter((c): c is number => c != null)),
|
||||
];
|
||||
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]));
|
||||
|
||||
const grupos = rows.some((r) => r.codGrupo != null || r.codSubgrupo != null)
|
||||
? await this.loadGruposProduto()
|
||||
: [];
|
||||
const grupoNameMap = new Map(
|
||||
grupos.filter((g) => g.cod_grupo != null).map((g) => [Number(g.cod_grupo), g.grupo]),
|
||||
);
|
||||
const subgrupoNameMap = new Map(
|
||||
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
|
||||
);
|
||||
|
||||
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
|
||||
const pautaNameMap = await this.loadPautaNames(idPautas);
|
||||
|
||||
return {
|
||||
regras: rows.map((r) => ({
|
||||
id: r.id,
|
||||
descricao: r.descricao,
|
||||
codGrupo: r.codGrupo,
|
||||
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||
codSubgrupo: r.codSubgrupo,
|
||||
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
|
||||
idPauta: r.idPauta,
|
||||
pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null,
|
||||
codVendedor: r.codVendedor,
|
||||
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
|
||||
descPct: Number(r.descPct),
|
||||
dataInicio: r.dataInicio.toISOString().slice(0, 10),
|
||||
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||
ativa: r.ativa,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
createdBy: r.createdBy,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Cria uma regra POR ALVO: cada grupo vira regra por grupo, cada subgrupo
|
||||
// vira regra por subgrupo e cada pauta vira regra por pauta. O representante
|
||||
// escolhido é copiado em todas. Sem alvos, regra vale para todos.
|
||||
async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: 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 alvos: { codGrupo: number | null; codSubgrupo: number | null; idPauta: number | null }[] =
|
||||
[
|
||||
...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null, idPauta: null })),
|
||||
...(body.codSubgrupos ?? []).map((s) => ({
|
||||
codGrupo: null,
|
||||
codSubgrupo: s,
|
||||
idPauta: null,
|
||||
})),
|
||||
...(body.idPautas ?? []).map((p) => ({ codGrupo: null, codSubgrupo: null, idPauta: p })),
|
||||
];
|
||||
if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null, idPauta: null });
|
||||
|
||||
const ids = await prisma.$transaction(async (tx) => {
|
||||
const created: number[] = [];
|
||||
for (const alvo of alvos) {
|
||||
const r = await tx.regraDesconto.create({
|
||||
data: {
|
||||
idEmpresa,
|
||||
descricao: body.descricao,
|
||||
codGrupo: alvo.codGrupo,
|
||||
codSubgrupo: alvo.codSubgrupo,
|
||||
idPauta: alvo.idPauta,
|
||||
codVendedor: body.codVendedor ?? null,
|
||||
descPct: body.descPct,
|
||||
dataInicio: new Date(body.dataInicio),
|
||||
dataFim: new Date(body.dataFim),
|
||||
createdBy: userId,
|
||||
},
|
||||
});
|
||||
created.push(r.id);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
return { ids };
|
||||
}
|
||||
|
||||
async updateRegra(id: number, body: UpdateRegraDescontoBody): 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.regraDesconto.findFirst({ where: { id, idEmpresa } });
|
||||
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
|
||||
|
||||
await prisma.regraDesconto.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.descricao !== undefined && { descricao: body.descricao }),
|
||||
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
|
||||
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
|
||||
...(body.idPauta !== undefined && { idPauta: body.idPauta }),
|
||||
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
|
||||
...(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 deleteRegra(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.regraDesconto.findFirst({ where: { id, idEmpresa } });
|
||||
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
|
||||
|
||||
await prisma.regraDesconto.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// Alçada do próprio usuário logado — o carrinho usa para mostrar o teto de
|
||||
// desconto por item (codGrupo 0 = default; sem linha 0, o backend assume 5%).
|
||||
async minhaAlcada(): Promise<AlcadaRepResponse> {
|
||||
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 codVendedor = parseInt(this.cls.get('userId') ?? '0', 10);
|
||||
|
||||
const rows = await prisma.alcadaDesconto.findMany({
|
||||
where: { codVendedor, idEmpresa },
|
||||
});
|
||||
return {
|
||||
limites: rows.map((r) => ({
|
||||
codGrupo: r.codGrupo,
|
||||
limitePerc: Number(r.limitePerc),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
|
||||
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
|
||||
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
|
||||
const hoje = new Date();
|
||||
const rows = await prisma.promocao.findMany({
|
||||
where: {
|
||||
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||
ativa: true,
|
||||
dataInicio: { lte: hoje },
|
||||
dataFim: { gte: hoje },
|
||||
},
|
||||
orderBy: [{ descPct: 'desc' }],
|
||||
});
|
||||
|
||||
// Nomes de produto e grupo — UI sempre mostra o nome, nunca só o código
|
||||
const codProdutos = [
|
||||
...new Set(
|
||||
rows.map((p) => p.codProduto?.trim()).filter((c): c is string => !!c && c.length > 0),
|
||||
),
|
||||
];
|
||||
const prodRows =
|
||||
codProdutos.length > 0
|
||||
? await prisma.$queryRawUnsafe<{ codigo: string; descricao: string | null }[]>(
|
||||
`SELECT TRIM(codigo) AS codigo, TRIM(descricao) AS descricao
|
||||
FROM vw_produtos
|
||||
WHERE id_empresa = ${idMatriz}
|
||||
AND TRIM(codigo) IN (${codProdutos.map((c) => `'${c.replace(/'/g, "''")}'`).join(',')})`,
|
||||
)
|
||||
: [];
|
||||
const prodNameMap = new Map(prodRows.map((r) => [r.codigo, r.descricao]));
|
||||
|
||||
const grupos = rows.some((p) => p.grpProd != null) ? await this.loadGruposProduto() : [];
|
||||
const grupoNameMap = new Map(
|
||||
grupos.filter((g) => g.cod_grupo != null).map((g) => [String(Number(g.cod_grupo)), g.grupo]),
|
||||
);
|
||||
|
||||
return {
|
||||
promocoes: rows.map((p) => ({
|
||||
id: p.id,
|
||||
descricao: p.descricao,
|
||||
codProduto: p.codProduto,
|
||||
nomeProduto: p.codProduto ? (prodNameMap.get(p.codProduto.trim()) ?? null) : null,
|
||||
grpProd: p.grpProd,
|
||||
grupo: p.grpProd ? (grupoNameMap.get(p.grpProd.trim()) ?? null) : null,
|
||||
descPct: Number(p.descPct),
|
||||
dataFim: p.dataFim.toISOString().slice(0, 10),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Regras vigentes hoje para o usuário logado. Rep recebe só as regras que o
|
||||
// alcançam (cod_vendedor nulo = todos, ou o próprio código); gerente vê todas.
|
||||
async listRegrasVigentes(): Promise<RegrasVigentesResponse> {
|
||||
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
const codVendedor = parseInt(this.cls.get('userId') ?? '0', 10);
|
||||
|
||||
const hoje = new Date();
|
||||
const rows = await prisma.regraDesconto.findMany({
|
||||
where: {
|
||||
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||
ativa: true,
|
||||
dataInicio: { lte: hoje },
|
||||
dataFim: { gte: hoje },
|
||||
...(role === 'rep' ? { OR: [{ codVendedor: null }, { codVendedor }] } : {}),
|
||||
},
|
||||
orderBy: [{ descPct: 'desc' }],
|
||||
});
|
||||
|
||||
// Nomes de grupo/subgrupo — UI sempre mostra o nome, nunca só o código
|
||||
const grupos = rows.some((r) => r.codGrupo != null || r.codSubgrupo != null)
|
||||
? await this.loadGruposProduto()
|
||||
: [];
|
||||
const grupoNameMap = new Map(
|
||||
grupos.filter((g) => g.cod_grupo != null).map((g) => [Number(g.cod_grupo), g.grupo]),
|
||||
);
|
||||
const subgrupoNameMap = new Map(
|
||||
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
|
||||
);
|
||||
|
||||
const idPautas = [...new Set(rows.map((r) => r.idPauta).filter((p): p is number => p != null))];
|
||||
const pautaNameMap = await this.loadPautaNames(idPautas);
|
||||
|
||||
return {
|
||||
regras: rows.map((r) => ({
|
||||
id: r.id,
|
||||
descricao: r.descricao,
|
||||
codGrupo: r.codGrupo,
|
||||
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||
codSubgrupo: r.codSubgrupo,
|
||||
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
|
||||
idPauta: r.idPauta,
|
||||
pauta: r.idPauta != null ? (pautaNameMap.get(r.idPauta) ?? null) : null,
|
||||
descPct: Number(r.descPct),
|
||||
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
AbcProduto,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { getTeamCodes } from '../workspace/team-scope.util';
|
||||
|
||||
function d(v: unknown): string {
|
||||
return v != null ? String(v) : '0';
|
||||
@@ -77,6 +78,12 @@ export class ReportsService {
|
||||
const userId = this.cls.get('userId');
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
|
||||
// Rep vê a própria carteira; supervisor vê as carteiras da equipe
|
||||
const role = this.cls.get('role');
|
||||
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null;
|
||||
const vendCond = (col: string) =>
|
||||
team ? `${col} IN (${team.join(',')})` : `${col} = ${codVendedor}`;
|
||||
|
||||
interface CarteiraRow {
|
||||
id_cliente: unknown;
|
||||
nome: string | null;
|
||||
@@ -86,56 +93,83 @@ export class ReportsService {
|
||||
dias_sem_pedido: unknown;
|
||||
total_pedidos: unknown;
|
||||
ticket_medio: unknown;
|
||||
faturamento_total: unknown;
|
||||
}
|
||||
|
||||
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
|
||||
WITH ultimos AS (
|
||||
SELECT
|
||||
id_cliente,
|
||||
MAX(dt_pedido) AS ultimo_pedido,
|
||||
COUNT(id) AS total_pedidos,
|
||||
CASE WHEN COUNT(id) > 0
|
||||
THEN SUM(total)::numeric / COUNT(id)
|
||||
MAX(dt_pedido) AS ultimo_pedido,
|
||||
COUNT(*) AS total_pedidos,
|
||||
CASE WHEN COUNT(*) > 0
|
||||
THEN SUM(total)::numeric / COUNT(*)
|
||||
ELSE 0
|
||||
END AS ticket_medio
|
||||
FROM sar.pedidos
|
||||
END AS ticket_medio,
|
||||
COALESCE(SUM(total), 0)::numeric AS faturamento_total
|
||||
FROM vw_pedidos_erp
|
||||
WHERE id_empresa = ${idEmpresa}
|
||||
AND cod_vendedor = ${codVendedor}
|
||||
AND situa <> 3
|
||||
AND ${vendCond('cod_vendedor')}
|
||||
AND situa NOT IN (1, 5)
|
||||
GROUP BY id_cliente
|
||||
)
|
||||
SELECT
|
||||
c.id_cliente,
|
||||
TRIM(c.nome) AS nome,
|
||||
TRIM(c.razao) AS razao,
|
||||
TRIM(c.nome) AS nome,
|
||||
TRIM(c.razao) AS razao,
|
||||
c.ativo,
|
||||
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
|
||||
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
|
||||
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
|
||||
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio
|
||||
FROM sar.vw_clientes c
|
||||
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
|
||||
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
|
||||
COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
|
||||
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio,
|
||||
COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
|
||||
FROM vw_clientes c
|
||||
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
|
||||
WHERE c.id_empresa = ${idEmpresa}
|
||||
AND c.cod_vendedor = ${codVendedor}
|
||||
ORDER BY u.ultimo_pedido ASC NULLS FIRST, c.nome ASC
|
||||
WHERE ${vendCond('c.cod_vendedor')}
|
||||
AND c.ativo = 1
|
||||
ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC
|
||||
`);
|
||||
|
||||
const data = rows.map((r) => ({
|
||||
idCliente: n(r.id_cliente),
|
||||
nome: r.nome ?? null,
|
||||
razao: r.razao ?? null,
|
||||
ativo: n(r.ativo),
|
||||
ultimoPedido: r.ultimo_pedido ?? null,
|
||||
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
|
||||
totalPedidos: n(r.total_pedidos),
|
||||
ticketMedio: d(r.ticket_medio),
|
||||
}));
|
||||
// Deduplica por id_cliente — vw_clientes pode retornar o mesmo cliente em mais de uma empresa.
|
||||
const seen = new Set<number>();
|
||||
const unique = rows.filter((r) => {
|
||||
const id = n(r.id_cliente);
|
||||
if (seen.has(id)) return false;
|
||||
seen.add(id);
|
||||
return true;
|
||||
});
|
||||
|
||||
const faturamentoRepTotal = unique.reduce((acc, r) => acc + n(r.faturamento_total), 0);
|
||||
|
||||
const data = unique.map((r) => {
|
||||
const fat = n(r.faturamento_total);
|
||||
return {
|
||||
idCliente: n(r.id_cliente),
|
||||
nome: r.nome ?? null,
|
||||
razao: r.razao ?? null,
|
||||
ativo: n(r.ativo),
|
||||
ultimoPedido: r.ultimo_pedido ?? null,
|
||||
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null,
|
||||
totalPedidos: n(r.total_pedidos),
|
||||
ticketMedio: d(r.ticket_medio),
|
||||
faturamentoTotal: fat.toFixed(2),
|
||||
participacaoPct:
|
||||
faturamentoRepTotal > 0 ? Math.round((fat / faturamentoRepTotal) * 1000) / 10 : 0,
|
||||
};
|
||||
});
|
||||
|
||||
const inativos30 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 30).length;
|
||||
const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length;
|
||||
const semPedido = data.filter((c) => c.totalPedidos === 0).length;
|
||||
|
||||
return { data, total: data.length, inativos30, inativos60, semPedido };
|
||||
return {
|
||||
data,
|
||||
total: data.length,
|
||||
inativos30,
|
||||
inativos60,
|
||||
semPedido,
|
||||
faturamentoRepTotal: faturamentoRepTotal.toFixed(2),
|
||||
};
|
||||
}
|
||||
|
||||
async abcProdutos(query: ReportAbcQuery): Promise<ReportAbcResponse> {
|
||||
@@ -149,6 +183,13 @@ export class ReportsService {
|
||||
const mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : '';
|
||||
const anoFilter = ano != null ? `AND EXTRACT(YEAR FROM p.dt_pedido) = ${ano}` : '';
|
||||
|
||||
// Rep vê os próprios pedidos; supervisor vê os da equipe
|
||||
const role = this.cls.get('role');
|
||||
const team = role === 'supervisor' ? await getTeamCodes(prisma, codVendedor) : null;
|
||||
const vendCond = team
|
||||
? `p.cod_vendedor IN (${team.join(',')})`
|
||||
: `p.cod_vendedor = ${codVendedor}`;
|
||||
|
||||
interface AbcRow {
|
||||
cod_produto: string | null;
|
||||
desc_produto: string | null;
|
||||
@@ -169,7 +210,7 @@ export class ReportsService {
|
||||
FROM sar.pedido_itens pi
|
||||
JOIN sar.pedidos p ON p.id = pi.id_pedido
|
||||
WHERE p.id_empresa = ${idEmpresa}
|
||||
AND p.cod_vendedor = ${codVendedor}
|
||||
AND ${vendCond}
|
||||
AND p.situa NOT IN (0, 3)
|
||||
${mesFilter}
|
||||
${anoFilter}
|
||||
|
||||
91
apps/api/src/app/sac/sac.controller.ts
Normal file
91
apps/api/src/app/sac/sac.controller.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
ForbiddenException,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import { createZodDto } from 'nestjs-zod';
|
||||
import { CreateChamadoSchema, AddMensagemSchema, ResolverChamadoSchema } from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
import { SacService } from './sac.service';
|
||||
|
||||
class CreateChamadoDto extends createZodDto(CreateChamadoSchema) {}
|
||||
class AddMensagemDto extends createZodDto(AddMensagemSchema) {}
|
||||
class ResolverDto extends createZodDto(ResolverChamadoSchema) {}
|
||||
|
||||
@Controller('chamados')
|
||||
export class SacRepController {
|
||||
constructor(private readonly sac: SacService) {}
|
||||
|
||||
@Get()
|
||||
listar() {
|
||||
return this.sac.listarRep();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detalhe(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.sac.detalhe(id, 'rep');
|
||||
}
|
||||
|
||||
@Post()
|
||||
criar(@Body() dto: CreateChamadoDto) {
|
||||
return this.sac.criar(CreateChamadoSchema.parse(dto));
|
||||
}
|
||||
|
||||
@Post(':id/mensagens')
|
||||
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
|
||||
return this.sac.addMensagemRep(id, AddMensagemSchema.parse(dto));
|
||||
}
|
||||
|
||||
@Patch(':id/cancelar')
|
||||
cancelar(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.sac.cancelarRep(id);
|
||||
}
|
||||
}
|
||||
|
||||
@Controller('ger/chamados')
|
||||
export class SacGerController {
|
||||
constructor(
|
||||
private readonly sac: SacService,
|
||||
private readonly cls: ClsService<WorkspaceClsStore>,
|
||||
) {}
|
||||
|
||||
// PGD-AUTHZ: visão da empresa toda (detalhe sem checagem de dono) — só gestores.
|
||||
private assertGestor(): void {
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
if (role === 'rep') {
|
||||
throw new ForbiddenException(
|
||||
'Apenas supervisores e gerentes podem acessar os chamados da empresa',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Get()
|
||||
listar() {
|
||||
this.assertGestor();
|
||||
return this.sac.listarEmpresa();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
detalhe(@Param('id', ParseIntPipe) id: number) {
|
||||
this.assertGestor();
|
||||
return this.sac.detalhe(id, 'empresa');
|
||||
}
|
||||
|
||||
@Post(':id/mensagens')
|
||||
addMensagem(@Param('id', ParseIntPipe) id: number, @Body() dto: AddMensagemDto) {
|
||||
this.assertGestor();
|
||||
return this.sac.addMensagemEmpresa(id, AddMensagemSchema.parse(dto));
|
||||
}
|
||||
|
||||
@Patch(':id/resolver')
|
||||
resolver(@Param('id', ParseIntPipe) id: number, @Body() dto: ResolverDto) {
|
||||
this.assertGestor();
|
||||
return this.sac.resolver(id, ResolverChamadoSchema.parse(dto));
|
||||
}
|
||||
}
|
||||
9
apps/api/src/app/sac/sac.module.ts
Normal file
9
apps/api/src/app/sac/sac.module.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { SacService } from './sac.service';
|
||||
import { SacRepController, SacGerController } from './sac.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [SacRepController, SacGerController],
|
||||
providers: [SacService],
|
||||
})
|
||||
export class SacModule {}
|
||||
195
apps/api/src/app/sac/sac.service.ts
Normal file
195
apps/api/src/app/sac/sac.service.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import type { CreateChamadoDto, AddMensagemDto, ResolverChamadoDto } from '@sar/api-interface';
|
||||
|
||||
type PrismaCtx = {
|
||||
chamado: {
|
||||
findMany: (args: object) => Promise<unknown[]>;
|
||||
findFirst: (args: object) => Promise<unknown | null>;
|
||||
findUnique: (args: object) => Promise<unknown | null>;
|
||||
create: (args: object) => Promise<unknown>;
|
||||
update: (args: object) => Promise<unknown>;
|
||||
};
|
||||
chamadoMensagem: { create: (args: object) => Promise<unknown> };
|
||||
$queryRawUnsafe: <T>(sql: string, ...args: unknown[]) => Promise<T[]>;
|
||||
};
|
||||
|
||||
type ClienteRow = { id_cliente: number; nome: string | null; razao: string | null };
|
||||
|
||||
@Injectable()
|
||||
export class SacService {
|
||||
constructor(private readonly cls: ClsService) {}
|
||||
|
||||
private ctx(): { prisma: PrismaCtx; idEmpresa: number; codVendedor: number } {
|
||||
const prisma = this.cls.get('prisma') as PrismaCtx;
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa') as number;
|
||||
const userId = this.cls.get('userId') as string | undefined;
|
||||
const codVendedor = userId ? parseInt(userId, 10) : 0;
|
||||
return { prisma, idEmpresa, codVendedor };
|
||||
}
|
||||
|
||||
private async enrichNomes(
|
||||
prisma: PrismaCtx,
|
||||
idEmpresa: number,
|
||||
rows: { idCliente: number }[],
|
||||
): Promise<Map<number, string>> {
|
||||
const ids = [...new Set(rows.map((r) => r.idCliente))];
|
||||
if (!ids.length) return new Map();
|
||||
const clientes = await prisma.$queryRawUnsafe<ClienteRow>(
|
||||
`SELECT id_cliente, TRIM(nome) AS nome, TRIM(razao) AS razao FROM vw_clientes WHERE id_cliente IN (${ids.join(',')}) AND id_empresa = ${idEmpresa}`,
|
||||
);
|
||||
const map = new Map<number, string>();
|
||||
for (const c of clientes) {
|
||||
map.set(c.id_cliente, c.razao ?? c.nome ?? `Cód. ${c.id_cliente}`);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async listarRep() {
|
||||
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||
const all = (await prisma.chamado.findMany({
|
||||
where: { idEmpresa, codVendedor },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})) as { idCliente: number; status: string; [k: string]: unknown }[];
|
||||
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
|
||||
const enrich = (row: (typeof all)[0]) => ({
|
||||
...row,
|
||||
nomeCliente:
|
||||
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
|
||||
});
|
||||
return {
|
||||
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||
};
|
||||
}
|
||||
|
||||
// Códigos da equipe do supervisor (cod_supervisor em vw_representantes),
|
||||
// incluindo o próprio. Retorna null para gerente/admin (sem filtro).
|
||||
private async teamCodes(): Promise<number[] | null> {
|
||||
const role = (this.cls.get('role') as string | undefined) ?? 'rep';
|
||||
if (role !== 'supervisor') return null;
|
||||
const { prisma, codVendedor } = this.ctx();
|
||||
const rows = await prisma.$queryRawUnsafe<{ codigo: number }>(
|
||||
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codVendedor}`,
|
||||
);
|
||||
return [...new Set([codVendedor, ...rows.map((r) => Number(r.codigo))])];
|
||||
}
|
||||
|
||||
async listarEmpresa() {
|
||||
const { prisma, idEmpresa } = this.ctx();
|
||||
// Supervisor vê só os chamados da sua equipe; gerente/admin veem a empresa toda
|
||||
const team = await this.teamCodes();
|
||||
const all = (await prisma.chamado.findMany({
|
||||
where: { idEmpresa, ...(team ? { codVendedor: { in: team } } : {}) },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
})) as { idCliente: number; status: string; [k: string]: unknown }[];
|
||||
const nomes = await this.enrichNomes(prisma, idEmpresa, all);
|
||||
const enrich = (row: (typeof all)[0]) => ({
|
||||
...row,
|
||||
nomeCliente:
|
||||
(row as { nomeCliente?: string | null }).nomeCliente ?? nomes.get(row.idCliente) ?? null,
|
||||
});
|
||||
return {
|
||||
abertos: all.filter((c) => !['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||
fechados: all.filter((c) => ['resolvido', 'cancelado'].includes(c.status)).map(enrich),
|
||||
};
|
||||
}
|
||||
|
||||
async detalhe(id: number, role: 'rep' | 'empresa') {
|
||||
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||
const chamado = (await prisma.chamado.findFirst({
|
||||
where: { id, idEmpresa },
|
||||
include: { mensagens: { orderBy: { createdAt: 'asc' } } },
|
||||
})) as { idCliente: number; codVendedor: number; [k: string]: unknown } | null;
|
||||
if (!chamado) throw new NotFoundException('Chamado não encontrado');
|
||||
if (role === 'rep' && chamado.codVendedor !== codVendedor) throw new ForbiddenException();
|
||||
if (role === 'empresa') {
|
||||
const team = await this.teamCodes();
|
||||
if (team && !team.includes(chamado.codVendedor)) throw new ForbiddenException();
|
||||
}
|
||||
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
|
||||
return { ...chamado, nomeCliente: nomes.get(chamado.idCliente) ?? null };
|
||||
}
|
||||
|
||||
async criar(dto: CreateChamadoDto) {
|
||||
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||
const chamado = (await prisma.chamado.create({
|
||||
data: {
|
||||
idEmpresa,
|
||||
codVendedor,
|
||||
idPedido: dto.idPedido ?? null,
|
||||
numPedSar: dto.numPedSar ?? null,
|
||||
numPedErp: dto.numPedErp ?? null,
|
||||
nomeCliente: dto.nomeCliente ?? null,
|
||||
idCliente: dto.idCliente,
|
||||
tipo: dto.tipo,
|
||||
assunto: dto.assunto,
|
||||
descricao: dto.descricao,
|
||||
itensAfetados: dto.itensAfetados ?? [],
|
||||
},
|
||||
include: { mensagens: true },
|
||||
})) as { idCliente: number; nomeCliente: string | null; [k: string]: unknown };
|
||||
const nomes = await this.enrichNomes(prisma, idEmpresa, [chamado]);
|
||||
return { ...chamado, nomeCliente: chamado.nomeCliente ?? nomes.get(chamado.idCliente) ?? null };
|
||||
}
|
||||
|
||||
async addMensagemRep(id: number, dto: AddMensagemDto) {
|
||||
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
|
||||
codVendedor: number;
|
||||
} | null;
|
||||
if (!c) throw new NotFoundException();
|
||||
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
|
||||
await prisma.chamadoMensagem.create({
|
||||
data: { idChamado: id, autor: 'rep', texto: dto.texto },
|
||||
});
|
||||
await prisma.chamado.update({ where: { id }, data: { status: 'em_analise' } });
|
||||
return this.detalhe(id, 'rep');
|
||||
}
|
||||
|
||||
async addMensagemEmpresa(id: number, dto: AddMensagemDto) {
|
||||
const { prisma, idEmpresa } = this.ctx();
|
||||
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
|
||||
if (!c) throw new NotFoundException();
|
||||
await prisma.chamadoMensagem.create({
|
||||
data: { idChamado: id, autor: 'empresa', texto: dto.texto },
|
||||
});
|
||||
await prisma.chamado.update({ where: { id }, data: { status: 'aguardando_rep' } });
|
||||
return this.detalhe(id, 'empresa');
|
||||
}
|
||||
|
||||
async resolver(id: number, dto: ResolverChamadoDto) {
|
||||
const { prisma, idEmpresa } = this.ctx();
|
||||
const c = await prisma.chamado.findFirst({ where: { id, idEmpresa } });
|
||||
if (!c) throw new NotFoundException();
|
||||
if (dto.mensagemFinal) {
|
||||
await prisma.chamadoMensagem.create({
|
||||
data: { idChamado: id, autor: 'empresa', texto: dto.mensagemFinal },
|
||||
});
|
||||
}
|
||||
await prisma.chamado.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: 'resolvido',
|
||||
resolucao: dto.resolucao,
|
||||
notaResolucao: dto.notaResolucao ?? null,
|
||||
},
|
||||
});
|
||||
return this.detalhe(id, 'empresa');
|
||||
}
|
||||
|
||||
async cancelarRep(id: number) {
|
||||
const { prisma, idEmpresa, codVendedor } = this.ctx();
|
||||
const c = (await prisma.chamado.findFirst({ where: { id, idEmpresa } })) as {
|
||||
codVendedor: number;
|
||||
status: string;
|
||||
} | null;
|
||||
if (!c) throw new NotFoundException();
|
||||
if (c.codVendedor !== codVendedor) throw new ForbiddenException();
|
||||
if (['resolvido', 'cancelado'].includes(c.status))
|
||||
throw new ForbiddenException('Chamado já encerrado');
|
||||
await prisma.chamado.update({ where: { id }, data: { status: 'cancelado' } });
|
||||
return this.detalhe(id, 'rep');
|
||||
}
|
||||
}
|
||||
14
apps/api/src/app/workspace/team-scope.util.ts
Normal file
14
apps/api/src/app/workspace/team-scope.util.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
|
||||
// Códigos de vendedor da equipe de um supervisor (vw_representantes.cod_supervisor
|
||||
// aponta para o código do supervisor), incluindo o próprio supervisor. Usado para
|
||||
// escopar as telas do supervisor à sua equipe — gerente/admin veem a empresa toda.
|
||||
// codSupervisor vem do JWT (parseInt) — interpolação segura.
|
||||
export async function getTeamCodes(prisma: PrismaClient, codSupervisor: number): Promise<number[]> {
|
||||
const rows = await prisma.$queryRawUnsafe<{ codigo: number }[]>(
|
||||
`SELECT DISTINCT codigo FROM vw_representantes WHERE cod_supervisor = ${codSupervisor}`,
|
||||
);
|
||||
const codes = new Set(rows.map((r) => Number(r.codigo)));
|
||||
codes.add(codSupervisor);
|
||||
return [...codes];
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||
"@fortawesome/react-fontawesome": "^3.3.1",
|
||||
"@hookform/resolvers": "^5.4.0",
|
||||
"@svg-maps/brazil": "^2.0.0",
|
||||
"@tanstack/react-query": "^5.100.14",
|
||||
"@tanstack/react-router": "^1.170.8",
|
||||
"antd": "^6.4.3",
|
||||
|
||||
@@ -18,7 +18,7 @@ const CACHEABLE_API = [
|
||||
|
||||
// ── Fetch ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
self.addEventListener('fetch', (event) => {
|
||||
globalThis.addEventListener('fetch', (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== 'GET') return;
|
||||
|
||||
@@ -102,10 +102,10 @@ function offlineResponse() {
|
||||
|
||||
// ── Push (C6) ─────────────────────────────────────────────────────────────────
|
||||
|
||||
self.addEventListener('push', (event) => {
|
||||
globalThis.addEventListener('push', (event) => {
|
||||
const data = event.data?.json() ?? {};
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(data.title ?? 'SAR', {
|
||||
globalThis.registration.showNotification(data.title ?? 'SAR', {
|
||||
body: data.body ?? '',
|
||||
icon: '/sar-icon.png',
|
||||
badge: '/sar-icon.png',
|
||||
@@ -114,7 +114,7 @@ self.addEventListener('push', (event) => {
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener('notificationclick', (event) => {
|
||||
globalThis.addEventListener('notificationclick', (event) => {
|
||||
event.notification.close();
|
||||
const url = event.notification.data?.url;
|
||||
if (url) {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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';
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Col, Empty, Flex, Progress, Row, Skeleton, Space, Typography } from 'antd';
|
||||
import type { SupervisorCard } from '@sar/api-interface';
|
||||
import { useSupervisores } from '../../lib/queries/gerente';
|
||||
import { SupervisorDetalheModal } from './SupervisorDetalheModal';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -9,85 +10,118 @@ 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>
|
||||
function metaColor(pct: number): string {
|
||||
return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
|
||||
}
|
||||
|
||||
const MEDALHAS = ['🥇', '🥈', '🥉'];
|
||||
|
||||
function posicaoLabel(i: number): string {
|
||||
return MEDALHAS[i] ?? `${i + 1}º`;
|
||||
}
|
||||
|
||||
function SupervisorCardView({
|
||||
s,
|
||||
posicao,
|
||||
onDetalhar,
|
||||
}: {
|
||||
s: SupervisorCard;
|
||||
posicao: number;
|
||||
onDetalhar: () => void;
|
||||
}) {
|
||||
const lider = posicao === 0;
|
||||
return (
|
||||
<Card
|
||||
style={lider ? { borderColor: 'var(--jcs-blue)', borderWidth: 2 } : undefined}
|
||||
styles={{ body: { padding: 'var(--space-lg)' } }}
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
{/* Cabeçalho: posição + nome */}
|
||||
<Flex align="center" gap={10}>
|
||||
<span style={{ fontSize: 22, lineHeight: 1, minWidth: 30, textAlign: 'center' }}>
|
||||
{posicaoLabel(posicao)}
|
||||
</span>
|
||||
<Flex vertical gap={0} style={{ minWidth: 0, flex: 1 }}>
|
||||
<Text strong ellipsis style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{s.nomeSupervisor ?? `Supervisor cód. ${s.codSupervisor}`}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
cód. {s.codSupervisor} · {s.repsAtivos}/{s.repsTotal} reps ativos
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
{/* Faturamento + variação */}
|
||||
<Flex align="baseline" gap={10} wrap>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(s.faturamentoMes)}
|
||||
</Title>
|
||||
{s.variacaoPct != null && (
|
||||
<Text
|
||||
className="tabular-nums"
|
||||
style={{
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: s.variacaoPct >= 0 ? 'var(--green)' : '#cf1322',
|
||||
}}
|
||||
>
|
||||
{s.variacaoPct >= 0 ? '▲' : '▼'} {s.variacaoPct >= 0 ? '+' : ''}
|
||||
{s.variacaoPct}% vs. mês ant.
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Meta do time */}
|
||||
<div>
|
||||
<Flex justify="space-between" align="baseline" style={{ marginBottom: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
Meta do time
|
||||
</Text>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{s.metaTotal > 0 ? `${s.pctMeta}%` : 'sem meta'}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Progress
|
||||
percent={s.metaTotal > 0 ? Math.min(s.pctMeta, 100) : 0}
|
||||
size="small"
|
||||
strokeColor={metaColor(s.pctMeta)}
|
||||
showInfo={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ticket + positivação */}
|
||||
<Flex justify="space-between" gap={12}>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
TICKET MÉDIO
|
||||
</Text>
|
||||
<Text strong className="tabular-nums">
|
||||
{s.ticketMedio > 0 ? fmt(s.ticketMedio) : '—'}
|
||||
</Text>
|
||||
</Space>
|
||||
<Space orientation="vertical" size={0} style={{ textAlign: 'right' }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
POSITIVAÇÃO
|
||||
</Text>
|
||||
<Text strong className="tabular-nums">
|
||||
{s.pctPositivacao}%{' '}
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
({s.clientesPositivados}/{s.totalClientes})
|
||||
</Text>
|
||||
</Text>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<Button block onClick={onDetalhar}>
|
||||
Detalhar
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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 { data, isLoading } = useSupervisores();
|
||||
const [detalhe, setDetalhe] = useState<SupervisorCard | null>(null);
|
||||
|
||||
const mes = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||
|
||||
@@ -98,25 +132,41 @@ export function EquipePage() {
|
||||
Equipe
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
Performance dos representantes — {mes}
|
||||
Disputa dos supervisores — {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>
|
||||
{isLoading || !data ? (
|
||||
<Row gutter={[24, 24]}>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Col key={i} xs={24} md={12} lg={8}>
|
||||
<Card>
|
||||
<Skeleton active paragraph={{ rows: 4 }} />
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
) : data.supervisores.length === 0 ? (
|
||||
<Card>
|
||||
<Empty description="Nenhum supervisor com equipe encontrado." />
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<Row gutter={[24, 24]}>
|
||||
{data.supervisores.map((s: SupervisorCard, i: number) => (
|
||||
<Col key={s.codSupervisor} xs={24} md={12} lg={8}>
|
||||
<SupervisorCardView s={s} posicao={i} onDetalhar={() => setDetalhe(s)} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Sync: {new Date(data.syncedAt).toLocaleTimeString('pt-BR')}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
|
||||
<SupervisorDetalheModal supervisor={detalhe} onClose={() => setDetalhe(null)} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
346
apps/web/src/cockpits/ger/GerChamadosPage.tsx
Normal file
346
apps/web/src/cockpits/ger/GerChamadosPage.tsx
Normal file
@@ -0,0 +1,346 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Tabs,
|
||||
} from 'antd';
|
||||
import { CustomerServiceOutlined } from '@ant-design/icons';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
STATUS_CHAMADO_LABEL,
|
||||
TIPO_CHAMADO_LABEL,
|
||||
RESOLUCAO_CHAMADO,
|
||||
RESOLUCAO_CHAMADO_LABEL,
|
||||
type Chamado,
|
||||
type ChamadoMensagem,
|
||||
type ItemAfetado,
|
||||
type ResolverChamadoDto,
|
||||
} from '@sar/api-interface';
|
||||
import {
|
||||
useChamadosGer,
|
||||
useChamadoGer,
|
||||
useAddMensagemEmpresa,
|
||||
useResolverChamado,
|
||||
} from '../../lib/queries/sac';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
aberto: 'blue',
|
||||
em_analise: 'orange',
|
||||
aguardando_rep: 'purple',
|
||||
resolvido: 'green',
|
||||
cancelado: 'default',
|
||||
};
|
||||
|
||||
function ResolverModal({ id, open, onClose }: { id: number; open: boolean; onClose: () => void }) {
|
||||
const [form] = Form.useForm<ResolverChamadoDto>();
|
||||
const resolver = useResolverChamado(id);
|
||||
|
||||
function handleOk() {
|
||||
form.validateFields().then((values) => {
|
||||
resolver.mutate(values, {
|
||||
onSuccess: () => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Resolver Chamado"
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={() => {
|
||||
form.resetFields();
|
||||
onClose();
|
||||
}}
|
||||
okText="Confirmar Resolução"
|
||||
cancelText="Voltar"
|
||||
confirmLoading={resolver.isPending}
|
||||
width={560}
|
||||
centered
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item name="resolucao" label="Tipo de Resolução" rules={[{ required: true }]}>
|
||||
<Select placeholder="Selecione...">
|
||||
{RESOLUCAO_CHAMADO.map((r) => (
|
||||
<Select.Option key={r} value={r}>
|
||||
{RESOLUCAO_CHAMADO_LABEL[r]}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item name="notaResolucao" label="Nota / Detalhes">
|
||||
<TextArea rows={3} maxLength={1000} showCount />
|
||||
</Form.Item>
|
||||
<Form.Item name="mensagemFinal" label="Mensagem final para o representante (opcional)">
|
||||
<TextArea rows={2} maxLength={500} showCount />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ChamadoDetalheModal({
|
||||
id,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [texto, setTexto] = useState('');
|
||||
const [resolverOpen, setResolverOpen] = useState(false);
|
||||
const { data: chamado, isLoading } = useChamadoGer(id);
|
||||
const addMsg = useAddMensagemEmpresa(id);
|
||||
|
||||
function enviar() {
|
||||
if (!texto.trim()) return;
|
||||
addMsg.mutate({ texto: texto.trim() }, { onSuccess: () => setTexto('') });
|
||||
}
|
||||
|
||||
const fechado = chamado && ['resolvido', 'cancelado'].includes(chamado.status);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title={chamado ? `Chamado #${chamado.id} — ${chamado.assunto}` : 'Chamado'}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={720}
|
||||
centered
|
||||
>
|
||||
{isLoading || !chamado ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space wrap>
|
||||
<Tag color={STATUS_COLOR[chamado.status]}>{STATUS_CHAMADO_LABEL[chamado.status]}</Tag>
|
||||
<Tag>{TIPO_CHAMADO_LABEL[chamado.tipo] ?? chamado.tipo}</Tag>
|
||||
<Text type="secondary">Rep: {chamado.codVendedor}</Text>
|
||||
<Text type="secondary">Cliente: {chamado.nomeCliente ?? chamado.idCliente}</Text>
|
||||
</Space>
|
||||
|
||||
<Text type="secondary">{chamado.descricao}</Text>
|
||||
|
||||
{chamado.notaResolucao && (
|
||||
<div
|
||||
style={{
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: 6,
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<Text strong>Resolução: </Text>
|
||||
<Text>{chamado.notaResolucao}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chamado.itensAfetados && chamado.itensAfetados.length > 0 && (
|
||||
<div>
|
||||
<Text strong>Itens afetados: </Text>
|
||||
{(chamado.itensAfetados as ItemAfetado[]).map((it, i) => (
|
||||
<Tag key={`${it.codProduto}-${i}`}>
|
||||
{it.codProduto} — {it.nomeProduto} (Qtd: {it.qtd})
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
maxHeight: 320,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{(chamado.mensagens ?? []).length === 0 ? (
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ textAlign: 'center', display: 'block', padding: '16px 0' }}
|
||||
>
|
||||
Sem mensagens ainda.
|
||||
</Text>
|
||||
) : (
|
||||
((chamado.mensagens as ChamadoMensagem[]) ?? []).map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: m.autor === 'empresa' ? 'flex-end' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '72%',
|
||||
background: m.autor === 'empresa' ? '#1677ff' : '#f5f5f5',
|
||||
color: m.autor === 'empresa' ? '#fff' : '#000',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 2 }}>
|
||||
{m.autor === 'empresa' ? 'Empresa' : 'Representante'}
|
||||
</div>
|
||||
<div>{m.texto}</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4, textAlign: 'right' }}>
|
||||
{new Date(m.createdAt).toLocaleString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!fechado && (
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="Resposta">
|
||||
<TextArea
|
||||
rows={2}
|
||||
value={texto}
|
||||
onChange={(e) => setTexto(e.target.value)}
|
||||
maxLength={2000}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={enviar}
|
||||
loading={addMsg.isPending}
|
||||
disabled={!texto.trim()}
|
||||
>
|
||||
Responder
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ backgroundColor: '#52c41a', borderColor: '#52c41a' }}
|
||||
onClick={() => setResolverOpen(true)}
|
||||
>
|
||||
Resolver
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
<ResolverModal id={id} open={resolverOpen} onClose={() => setResolverOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<Chamado> = [
|
||||
{ title: '#', dataIndex: 'id', width: 60 },
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
width: 140,
|
||||
render: (s: string) => (
|
||||
<Tag color={STATUS_COLOR[s] ?? 'default'}>{STATUS_CHAMADO_LABEL[s] ?? s}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Tipo',
|
||||
dataIndex: 'tipo',
|
||||
width: 160,
|
||||
render: (t: string) => TIPO_CHAMADO_LABEL[t] ?? t,
|
||||
},
|
||||
{ title: 'Assunto', dataIndex: 'assunto', ellipsis: true },
|
||||
{ title: 'Rep', dataIndex: 'codVendedor', width: 70 },
|
||||
{ title: 'Cliente', dataIndex: 'nomeCliente', width: 160 },
|
||||
{
|
||||
title: 'Atualizado',
|
||||
dataIndex: 'updatedAt',
|
||||
width: 110,
|
||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||
},
|
||||
];
|
||||
|
||||
export function GerChamadosPage() {
|
||||
const { data, isLoading } = useChamadosGer();
|
||||
const [detalheId, setDetalheId] = useState<number | null>(null);
|
||||
|
||||
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||
|
||||
const abertos = data?.abertos ?? [];
|
||||
const fechados = data?.fechados ?? [];
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 1200, margin: '0 auto' }}>
|
||||
<Space align="center" style={{ marginBottom: 24 }}>
|
||||
<CustomerServiceOutlined style={{ fontSize: 24, color: '#1677ff' }} />
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
SAC — Chamados da Empresa
|
||||
</Title>
|
||||
{abertos.length > 0 && <Badge count={abertos.length} />}
|
||||
</Space>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'abertos',
|
||||
label: `Abertos (${abertos.length})`,
|
||||
children: (
|
||||
<Table<Chamado>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={abertos}
|
||||
pagination={false}
|
||||
size="small"
|
||||
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||
locale={{ emptyText: 'Nenhum chamado aberto.' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fechados',
|
||||
label: `Fechados (${fechados.length})`,
|
||||
children: (
|
||||
<Table<Chamado>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={fechados}
|
||||
pagination={{ pageSize: 20 }}
|
||||
size="small"
|
||||
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||
locale={{ emptyText: 'Nenhum chamado fechado.' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{detalheId !== null && (
|
||||
<ChamadoDetalheModal
|
||||
id={detalheId}
|
||||
open={detalheId !== null}
|
||||
onClose={() => setDetalheId(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
apps/web/src/cockpits/ger/GerConfigPage.tsx
Normal file
157
apps/web/src/cockpits/ger/GerConfigPage.tsx
Normal file
@@ -0,0 +1,157 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Flex, Popconfirm, Skeleton, Typography, Upload, message } from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faImage, faTrash, faUpload } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCompany } from '../../lib/queries/company';
|
||||
import { apiFetch } from '../../lib/api-client';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const MAX_LOGO_BYTES = 500 * 1024; // ~500KB — logo de cabeçalho não precisa mais
|
||||
|
||||
function fileToDataUrl(file: File): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => resolve(String(reader.result));
|
||||
reader.onerror = () => reject(new Error('Falha ao ler o arquivo'));
|
||||
reader.readAsDataURL(file);
|
||||
});
|
||||
}
|
||||
|
||||
export function GerConfigPage() {
|
||||
const { data: empresa, isLoading } = useCompany();
|
||||
const qc = useQueryClient();
|
||||
const [msg, msgCtx] = message.useMessage();
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const updateLogo = useMutation({
|
||||
mutationFn: (logoBase64: string | null) =>
|
||||
apiFetch('/catalog/company/logo', { method: 'PUT', body: { logoBase64 } }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['company'] }),
|
||||
});
|
||||
|
||||
const beforeUpload: UploadProps['beforeUpload'] = async (file) => {
|
||||
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
||||
void msg.error('Use uma imagem PNG, JPG ou WebP');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
if (file.size > MAX_LOGO_BYTES) {
|
||||
void msg.error('Imagem muito grande — use um arquivo de até 500KB');
|
||||
return Upload.LIST_IGNORE;
|
||||
}
|
||||
setUploading(true);
|
||||
try {
|
||||
const dataUrl = await fileToDataUrl(file);
|
||||
await updateLogo.mutateAsync(dataUrl);
|
||||
void msg.success('Logo atualizado — já vale para as impressões de todos os representantes');
|
||||
} catch {
|
||||
// erro já notificado pelo handler global do QueryClient
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
return Upload.LIST_IGNORE; // upload manual — nada vai para action padrão
|
||||
};
|
||||
|
||||
async function removeLogo() {
|
||||
try {
|
||||
await updateLogo.mutateAsync(null);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
void msg.success('Logo removido');
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
{msgCtx}
|
||||
<Flex vertical gap={4}>
|
||||
<Title level={2} style={{ margin: 0 }}>
|
||||
Configurações
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
Preferências da empresa aplicadas a todos os representantes
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<FontAwesomeIcon icon={faImage} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Logo da Empresa
|
||||
</Flex>
|
||||
}
|
||||
style={{ maxWidth: 640 }}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 3 }} />
|
||||
) : (
|
||||
<Flex vertical gap={16}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
Aparece no cabeçalho da impressão do pedido (PDF) de todos os representantes da{' '}
|
||||
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? 'empresa'}. PNG com fundo
|
||||
transparente fica melhor. Máx. 500KB.
|
||||
</Text>
|
||||
|
||||
<Flex align="center" gap={24} wrap="wrap">
|
||||
<div
|
||||
style={{
|
||||
width: 220,
|
||||
height: 90,
|
||||
border: '1px dashed #CBD5E1',
|
||||
borderRadius: 8,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: '#F8FAFC',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{empresa?.logoBase64 ? (
|
||||
<img
|
||||
src={empresa.logoBase64}
|
||||
alt="Logo da empresa"
|
||||
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Nenhum logo cadastrado
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Flex vertical gap={8}>
|
||||
<Upload
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
showUploadList={false}
|
||||
beforeUpload={beforeUpload}
|
||||
>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<FontAwesomeIcon icon={faUpload} />}
|
||||
loading={uploading || updateLogo.isPending}
|
||||
>
|
||||
{empresa?.logoBase64 ? 'Trocar logo' : 'Enviar logo'}
|
||||
</Button>
|
||||
</Upload>
|
||||
{empresa?.logoBase64 && (
|
||||
<Popconfirm
|
||||
title="Remover o logo?"
|
||||
okText="Sim"
|
||||
cancelText="Nao"
|
||||
onConfirm={() => void removeLogo()}
|
||||
>
|
||||
<Button danger icon={<FontAwesomeIcon icon={faTrash} />}>
|
||||
Remover
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,20 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Flex,
|
||||
InputNumber,
|
||||
Progress,
|
||||
Row,
|
||||
Skeleton,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
@@ -23,12 +27,40 @@ import {
|
||||
faAddressCard,
|
||||
faBullseye,
|
||||
faArrowTrendUp,
|
||||
faCalendarDay,
|
||||
faLayerGroup,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip as ChartTooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import dayjs from 'dayjs';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
import type { RankingRep, PositivacaoRep, MetaItem } from '@sar/api-interface';
|
||||
import type { RankingRep, PositivacaoRep, PositivacaoDia, MetaItem } from '@sar/api-interface';
|
||||
import { useManagerDashboard } from '../../lib/queries/gerente';
|
||||
import {
|
||||
positivacaoRepColumns,
|
||||
rankingRepColumns,
|
||||
} from '../../components/dashboard/rep-performance-columns';
|
||||
import { apiFetch } from '../../lib/api-client';
|
||||
import { MapaBrasilCard } from './MapaBrasilCard';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
ChartTooltip,
|
||||
Legend,
|
||||
);
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -40,45 +72,6 @@ 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 metasGrupoColumns: TableColumnsType<MetaItem> = [
|
||||
{
|
||||
title: 'Grupo',
|
||||
@@ -136,54 +129,142 @@ const metasGrupoColumns: TableColumnsType<MetaItem> = [
|
||||
},
|
||||
];
|
||||
|
||||
const rankingColumns: TableColumnsType<RankingRep> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'pedidos',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: 'Clientes',
|
||||
dataIndex: 'clientesAtendidos',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: 'Faturamento',
|
||||
dataIndex: 'faturamento',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: (v: number) => fmt(v),
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: '% Meta',
|
||||
dataIndex: 'pctMeta',
|
||||
width: 160,
|
||||
render: (pct: number) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Progress
|
||||
percent={Math.min(pct, 100)}
|
||||
size="small"
|
||||
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
||||
showInfo={false}
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
function PositivacaoDiariaCard({
|
||||
dados,
|
||||
metaSalva,
|
||||
periodo,
|
||||
periodoLabel,
|
||||
isMesAtual,
|
||||
}: {
|
||||
dados: PositivacaoDia[];
|
||||
metaSalva: number;
|
||||
periodo: Dayjs;
|
||||
periodoLabel: string;
|
||||
isMesAtual: boolean;
|
||||
}) {
|
||||
// Meta salva no servidor (config_empresa) — vale em qualquer dispositivo
|
||||
const [meta, setMeta] = useState<number>(metaSalva);
|
||||
useEffect(() => setMeta(metaSalva), [metaSalva]);
|
||||
const qc = useQueryClient();
|
||||
const [msg, msgCtx] = message.useMessage();
|
||||
|
||||
const salvarMeta = useMutation({
|
||||
mutationFn: (m: number) =>
|
||||
apiFetch('/dashboard/manager/meta-positivacao-dia', { method: 'PUT', body: { meta: m } }),
|
||||
onSuccess: () => {
|
||||
void qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||
void msg.success('Meta salva');
|
||||
},
|
||||
});
|
||||
|
||||
// Eixo com todos os dias: até hoje no mês atual, mês inteiro nos anteriores
|
||||
const ultimoDia = isMesAtual ? dayjs().date() : periodo.endOf('month').date();
|
||||
const porDia = new Map(dados.map((d) => [dayjs(d.dia).date(), d.clientes]));
|
||||
const labels = Array.from({ length: ultimoDia }, (_, i) => String(i + 1));
|
||||
const valores = labels.map((d) => porDia.get(Number(d)) ?? 0);
|
||||
const diasComMeta = meta > 0 ? valores.filter((v) => v >= meta).length : 0;
|
||||
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: 'Clientes positivados',
|
||||
data: valores,
|
||||
backgroundColor: 'rgba(0, 74, 153, 0.8)',
|
||||
borderRadius: 4,
|
||||
order: 2,
|
||||
},
|
||||
...(meta > 0
|
||||
? [
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: `Meta (${meta}/dia)`,
|
||||
data: labels.map(() => meta),
|
||||
borderColor: '#f5222d',
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 4],
|
||||
pointRadius: 0,
|
||||
order: 1,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index' as const, intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
title: (items: { label?: string }[]) => `Dia ${items[0]?.label ?? ''}`,
|
||||
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
||||
const val = ctx.parsed.y;
|
||||
if (val == null) return '';
|
||||
return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR')}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false } },
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: { precision: 0, font: { size: 10 } },
|
||||
grid: { color: '#f0f0f0' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faCalendarDay} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Positivação Diária de Clientes — {periodoLabel}
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Flex align="center" gap={8}>
|
||||
{meta > 0 && (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{diasComMeta} de {ultimoDia} dia{ultimoDia !== 1 ? 's' : ''} na meta
|
||||
</Text>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
Meta/dia:
|
||||
</Text>
|
||||
<InputNumber
|
||||
min={0}
|
||||
size="small"
|
||||
value={meta > 0 ? meta : undefined}
|
||||
onChange={(v) => setMeta(v ?? 0)}
|
||||
placeholder="—"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
disabled={meta === metaSalva}
|
||||
loading={salvarMeta.isPending}
|
||||
onClick={() => salvarMeta.mutate(meta)}
|
||||
>
|
||||
Salvar
|
||||
</Button>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
{msgCtx}
|
||||
<div style={{ height: 280 }}>
|
||||
<Chart type="bar" data={chartData} options={options} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export function GerPainel() {
|
||||
const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
|
||||
@@ -223,6 +304,8 @@ export function GerPainel() {
|
||||
metasPorGrupo,
|
||||
rankingReps,
|
||||
positivacaoReps,
|
||||
positivacaoDiaria,
|
||||
faturamentoPorUf,
|
||||
syncedAt,
|
||||
} = data;
|
||||
|
||||
@@ -271,155 +354,179 @@ export function GerPainel() {
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* KPIs linha 1 */}
|
||||
{/* Primeira dobra: visão financeira (½) + mapa do Brasil (½) */}
|
||||
<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 xs={24} xl={12}>
|
||||
<Flex vertical gap={16} style={{ height: '100%' }}>
|
||||
<Row gutter={[16, 16]} style={{ flex: 1 }}>
|
||||
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
FATURAMENTO {isMesAtual ? 'DO MÊS' : periodoLabel.toUpperCase()}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={faFileInvoiceDollar}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</Flex>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(faturamentoMes)}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{pedidosMes} pedido{pedidosMes !== 1 ? 's' : ''} no{' '}
|
||||
{isMesAtual ? 'mês' : 'período'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={faBullseye}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</Flex>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{metaTotal > 0 ? fmt(metaTotal) : '—'}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{metaTotal > 0
|
||||
? `soma das metas por representante`
|
||||
: 'sem meta cadastrada no ERP'}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<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: metaTotal > 0 ? metaCorColor : undefined }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{metaTotal > 0 ? `${pctMeta}%` : '—'}
|
||||
</Title>
|
||||
<Progress
|
||||
percent={metaTotal > 0 ? Math.min(pctMeta, 100) : 0}
|
||||
strokeColor={metaCorColor}
|
||||
showInfo={false}
|
||||
size="small"
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} style={{ display: 'flex' }}>
|
||||
<Card style={{ flex: 1 }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={faChartBar}
|
||||
style={{
|
||||
color: superouMeta > 0 ? 'var(--green)' : 'var(--jcs-blue)',
|
||||
opacity: 0.5,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{metaTotal > 0 ? fmt(superouMeta > 0 ? superouMeta : faltaMeta) : '—'}
|
||||
</Title>
|
||||
{metaTotal === 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
sem meta cadastrada no ERP
|
||||
</Text>
|
||||
) : superouMeta > 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{pctMeta - 100}% acima da meta
|
||||
</Text>
|
||||
) : vendaPorDia > 0 ? (
|
||||
<Text
|
||||
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
|
||||
>
|
||||
{fmt(vendaPorDia)}/dia — {diasRestantes} dia
|
||||
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
) : diasRestantes === 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
período encerrado
|
||||
</Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Indicadores secundários */}
|
||||
<Card styles={{ body: { padding: '12px 24px' } }}>
|
||||
<Flex justify="space-around" align="center" wrap="wrap" gap={16}>
|
||||
<Flex vertical align="center" gap={0}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
TICKET MÉDIO
|
||||
</Text>
|
||||
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{fmt(ticketMedio)}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex vertical align="center" gap={0}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
REPS COM PEDIDOS
|
||||
</Text>
|
||||
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
<FontAwesomeIcon
|
||||
icon={faUsers}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5, marginRight: 6 }}
|
||||
/>
|
||||
{totalReps}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Flex vertical align="center" gap={0}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
PROMOÇÕES ATIVAS
|
||||
</Text>
|
||||
<Text strong className="tabular-nums" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
<FontAwesomeIcon
|
||||
icon={faTags}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5, marginRight: 6 }}
|
||||
/>
|
||||
{promocoesAtivas}
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Flex>
|
||||
</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 xs={24} xl={12}>
|
||||
<MapaBrasilCard dados={faturamentoPorUf} periodoLabel={periodoLabel} />
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* KPIs linha 2 — Meta */}
|
||||
{metaTotal > 0 && (
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
META DO {isMesAtual ? 'MÊS' : 'PERÍODO'}
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(metaTotal)}
|
||||
</Title>
|
||||
<FontAwesomeIcon
|
||||
icon={faBullseye}
|
||||
style={{ color: 'var(--jcs-blue)', opacity: 0.5 }}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card>
|
||||
<Flex vertical gap={8}>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
ATINGIMENTO DA META
|
||||
</Text>
|
||||
<FontAwesomeIcon
|
||||
icon={faArrowTrendUp}
|
||||
style={{ color: metaCorColor, opacity: 0.7 }}
|
||||
/>
|
||||
</Flex>
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: metaCorColor }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{pctMeta}%
|
||||
</Title>
|
||||
<Progress
|
||||
percent={Math.min(pctMeta, 100)}
|
||||
strokeColor={metaCorColor}
|
||||
showInfo={false}
|
||||
size="small"
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={12} lg={8}>
|
||||
<Card>
|
||||
<Flex vertical gap={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{superouMeta > 0 ? 'SUPEROU A META EM' : 'FALTA PARA A META'}
|
||||
</Text>
|
||||
<Title
|
||||
level={3}
|
||||
style={{ margin: 0, color: superouMeta > 0 ? 'var(--green)' : undefined }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{fmt(superouMeta > 0 ? superouMeta : faltaMeta)}
|
||||
</Title>
|
||||
{superouMeta > 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{pctMeta - 100}% acima da meta
|
||||
</Text>
|
||||
) : vendaPorDia > 0 ? (
|
||||
<Text
|
||||
style={{ fontSize: 'var(--text-xs)', color: metaCorColor, fontWeight: 600 }}
|
||||
>
|
||||
{fmt(vendaPorDia)}/dia — {diasRestantes} dia
|
||||
{diasRestantes !== 1 ? 's' : ''} restante{diasRestantes !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
) : diasRestantes === 0 ? (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
período encerrado
|
||||
</Text>
|
||||
) : null}
|
||||
</Flex>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
|
||||
{/* Metas por Grupo */}
|
||||
{metasPorGrupo.length > 0 && (
|
||||
<Card
|
||||
@@ -470,7 +577,7 @@ export function GerPainel() {
|
||||
>
|
||||
<Table<RankingRep>
|
||||
rowKey="codVendedor"
|
||||
columns={rankingColumns}
|
||||
columns={rankingRepColumns}
|
||||
dataSource={rankingReps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
@@ -478,6 +585,15 @@ export function GerPainel() {
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Positivação Diária */}
|
||||
<PositivacaoDiariaCard
|
||||
dados={positivacaoDiaria}
|
||||
metaSalva={data.metaPositivacaoDia ?? 0}
|
||||
periodo={periodo}
|
||||
periodoLabel={periodoLabel}
|
||||
isMesAtual={isMesAtual}
|
||||
/>
|
||||
|
||||
{/* Positivação por Representante */}
|
||||
<Card
|
||||
title={
|
||||
@@ -487,14 +603,25 @@ export function GerPainel() {
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
|
||||
</Text>
|
||||
<Flex align="center" gap={12}>
|
||||
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) > 0 && (
|
||||
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
|
||||
+{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0)} novo
|
||||
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) !== 1 ? 's' : ''}{' '}
|
||||
cliente
|
||||
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) !== 1 ? 's' : ''} no
|
||||
período
|
||||
</Tag>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
|
||||
</Text>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Table<PositivacaoRep>
|
||||
rowKey="codVendedor"
|
||||
columns={positivacaoColumns}
|
||||
columns={positivacaoRepColumns}
|
||||
dataSource={positivacaoReps}
|
||||
pagination={{ pageSize: 10, size: 'small', showSizeChanger: false }}
|
||||
size="small"
|
||||
|
||||
360
apps/web/src/cockpits/ger/MapaBrasilCard.tsx
Normal file
360
apps/web/src/cockpits/ger/MapaBrasilCard.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Button, Card, Empty, Flex, Modal, Progress, Table, Tag, Tooltip, Typography } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faMapLocationDot, faUserTie } from '@fortawesome/free-solid-svg-icons';
|
||||
import type { UfFaturamento, UfRep } from '@sar/api-interface';
|
||||
import brazilMap from '@svg-maps/brazil';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function fmt(v: number): string {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
// R$ compacto para a lista lateral (R$ 12,4 mil / R$ 1,2 mi)
|
||||
function fmtCompacto(v: number): string {
|
||||
if (v >= 1_000_000)
|
||||
return `R$ ${(v / 1_000_000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} mi`;
|
||||
if (v >= 1_000)
|
||||
return `R$ ${(v / 1_000).toLocaleString('pt-BR', { maximumFractionDigits: 1 })} mil`;
|
||||
return fmt(v);
|
||||
}
|
||||
|
||||
// Escala sequencial (um matiz, claro→escuro) derivada do azul JCS.
|
||||
const SEQ_STEPS = ['#dbe9f6', '#aecde9', '#7BA7D7', '#3d74b8', '#004a99'];
|
||||
const COR_SEM_VENDA = '#edf0f3';
|
||||
|
||||
function corDaUf(valor: number, max: number): string {
|
||||
if (valor <= 0 || max <= 0) return COR_SEM_VENDA;
|
||||
const idx = Math.min(SEQ_STEPS.length - 1, Math.floor((valor / max) * SEQ_STEPS.length));
|
||||
return SEQ_STEPS[idx] ?? COR_SEM_VENDA;
|
||||
}
|
||||
|
||||
const NOME_UF = new Map(brazilMap.locations.map((l) => [l.id.toUpperCase(), l.name]));
|
||||
|
||||
const repColumns: TableColumnsType<UfRep> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, r: UfRep) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<FontAwesomeIcon icon={faUserTie} style={{ color: 'var(--jcs-blue)', opacity: 0.45 }} />
|
||||
{r.nomeVendedor ?? `Cód. ${r.codVendedor}`}
|
||||
</Flex>
|
||||
),
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'pedidos',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: 'Clientes',
|
||||
dataIndex: 'clientes',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: 'Faturamento',
|
||||
dataIndex: 'faturamento',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: (v: number) => fmt(v),
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
];
|
||||
|
||||
function detalheColumns(): TableColumnsType<UfFaturamento> {
|
||||
return [
|
||||
{
|
||||
title: 'Estado',
|
||||
key: 'uf',
|
||||
render: (_: unknown, r: UfFaturamento) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Tag style={{ margin: 0, fontWeight: 700, width: 40, textAlign: 'center' }}>{r.uf}</Tag>
|
||||
<Text>{NOME_UF.get(r.uf) ?? 'Sem UF cadastrada'}</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'pedidos',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: 'Clientes',
|
||||
dataIndex: 'clientes',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: 'Faturamento',
|
||||
dataIndex: 'faturamento',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: (v: number) => (
|
||||
<Text strong className="tabular-nums">
|
||||
{fmt(v)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Participação',
|
||||
dataIndex: 'pct',
|
||||
width: 170,
|
||||
render: (pct: number) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Progress
|
||||
percent={Math.min(pct, 100)}
|
||||
size="small"
|
||||
strokeColor="var(--jcs-blue)"
|
||||
showInfo={false}
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 48 }}>
|
||||
{pct.toLocaleString('pt-BR')}%
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function MapaBrasilCard({
|
||||
dados,
|
||||
periodoLabel,
|
||||
}: {
|
||||
dados: UfFaturamento[];
|
||||
periodoLabel: string;
|
||||
}) {
|
||||
const [detalheAberto, setDetalheAberto] = useState(false);
|
||||
const [ufsExpandidas, setUfsExpandidas] = useState<string[]>([]);
|
||||
const [ufHover, setUfHover] = useState<string | null>(null);
|
||||
|
||||
const porUf = useMemo(() => new Map(dados.map((d) => [d.uf, d])), [dados]);
|
||||
const max = dados.reduce((m, d) => Math.max(m, d.faturamento), 0);
|
||||
const comVenda = dados.filter((d) => d.faturamento > 0 && d.uf !== 'ND');
|
||||
const topUfs = comVenda.slice(0, 8);
|
||||
const semUf = dados.find((d) => d.uf === 'ND');
|
||||
|
||||
const abrirDetalhe = (uf?: string) => {
|
||||
const primeira = comVenda[0]?.uf;
|
||||
setUfsExpandidas(uf ? [uf] : primeira ? [primeira] : []);
|
||||
setDetalheAberto(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<FontAwesomeIcon icon={faMapLocationDot} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Faturamento por Estado
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
<Button type="primary" ghost size="small" onClick={() => abrirDetalhe()}>
|
||||
Detalhar
|
||||
</Button>
|
||||
}
|
||||
style={{ height: '100%' }}
|
||||
styles={{ body: { paddingTop: 12 } }}
|
||||
>
|
||||
{dados.length === 0 ? (
|
||||
<Empty description="Nenhuma venda no período." image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||
) : (
|
||||
<Flex gap={16} wrap="wrap">
|
||||
{/* Mapa choropleth */}
|
||||
<Flex vertical gap={8} style={{ flex: '1 1 260px', minWidth: 240 }}>
|
||||
<svg
|
||||
viewBox={brazilMap.viewBox}
|
||||
role="img"
|
||||
aria-label={`Mapa do Brasil com faturamento por estado — ${periodoLabel}`}
|
||||
style={{ width: '100%', height: 'auto', maxHeight: 330 }}
|
||||
>
|
||||
{brazilMap.locations.map((loc) => {
|
||||
const uf = loc.id.toUpperCase();
|
||||
const dado = porUf.get(uf);
|
||||
const valor = dado?.faturamento ?? 0;
|
||||
const hover = ufHover === uf;
|
||||
return (
|
||||
<Tooltip
|
||||
key={loc.id}
|
||||
title={
|
||||
<Flex vertical gap={2}>
|
||||
<Text strong style={{ color: '#fff' }}>
|
||||
{loc.name} ({uf})
|
||||
</Text>
|
||||
{dado ? (
|
||||
<>
|
||||
<span>
|
||||
{fmt(valor)} · {dado.pct.toLocaleString('pt-BR')}% do total
|
||||
</span>
|
||||
<span>
|
||||
{dado.pedidos} pedido{dado.pedidos !== 1 ? 's' : ''} · {dado.clientes}{' '}
|
||||
cliente{dado.clientes !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Sem vendas no período</span>
|
||||
)}
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<path
|
||||
d={loc.path}
|
||||
fill={corDaUf(valor, max)}
|
||||
stroke={hover ? 'var(--jcs-blue-hover)' : '#fff'}
|
||||
strokeWidth={hover ? 2 : 1}
|
||||
style={{ cursor: dado ? 'pointer' : 'default', transition: 'fill 0.15s' }}
|
||||
onMouseEnter={() => setUfHover(uf)}
|
||||
onMouseLeave={() => setUfHover(null)}
|
||||
onClick={() => dado && abrirDetalhe(uf)}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
{/* Legenda da escala */}
|
||||
<Flex align="center" gap={6} style={{ paddingInline: 4 }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Sem venda
|
||||
</Text>
|
||||
<span
|
||||
style={{
|
||||
width: 14,
|
||||
height: 10,
|
||||
background: COR_SEM_VENDA,
|
||||
border: '1px solid #d9d9d9',
|
||||
borderRadius: 2,
|
||||
}}
|
||||
/>
|
||||
<span style={{ width: 8 }} />
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Menor
|
||||
</Text>
|
||||
{SEQ_STEPS.map((c) => (
|
||||
<span key={c} style={{ width: 14, height: 10, background: c, borderRadius: 2 }} />
|
||||
))}
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
Maior
|
||||
</Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{/* Ranking de estados */}
|
||||
<Flex vertical gap={6} style={{ flex: '1 1 190px', minWidth: 180 }}>
|
||||
{topUfs.map((d, i) => (
|
||||
<Flex
|
||||
key={d.uf}
|
||||
align="center"
|
||||
gap={8}
|
||||
style={{
|
||||
padding: '4px 8px',
|
||||
borderRadius: 6,
|
||||
background: ufHover === d.uf ? 'var(--jcs-blue-light)' : undefined,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onMouseEnter={() => setUfHover(d.uf)}
|
||||
onMouseLeave={() => setUfHover(null)}
|
||||
onClick={() => abrirDetalhe(d.uf)}
|
||||
>
|
||||
<Text
|
||||
type="secondary"
|
||||
className="tabular-nums"
|
||||
style={{ width: 16, fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
{i + 1}º
|
||||
</Text>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 2,
|
||||
background: corDaUf(d.faturamento, max),
|
||||
border: '1px solid rgba(0,0,0,0.08)',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text strong style={{ width: 28 }}>
|
||||
{d.uf}
|
||||
</Text>
|
||||
<Text
|
||||
className="tabular-nums"
|
||||
style={{ marginLeft: 'auto', fontSize: 'var(--text-sm)' }}
|
||||
>
|
||||
{fmtCompacto(d.faturamento)}
|
||||
</Text>
|
||||
<Text
|
||||
type="secondary"
|
||||
className="tabular-nums"
|
||||
style={{ width: 44, textAlign: 'right', fontSize: 'var(--text-xs)' }}
|
||||
>
|
||||
{d.pct.toLocaleString('pt-BR')}%
|
||||
</Text>
|
||||
</Flex>
|
||||
))}
|
||||
{comVenda.length > topUfs.length && (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', paddingInline: 8 }}>
|
||||
+ {comVenda.length - topUfs.length} outro
|
||||
{comVenda.length - topUfs.length !== 1 ? 's' : ''} estado
|
||||
{comVenda.length - topUfs.length !== 1 ? 's' : ''} — ver Detalhar
|
||||
</Text>
|
||||
)}
|
||||
{semUf && (
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', paddingInline: 8 }}>
|
||||
{fmtCompacto(semUf.faturamento)} sem UF cadastrada
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
{/* Detalhe por estado → representantes */}
|
||||
<Modal
|
||||
title={
|
||||
<Flex align="center" gap={8}>
|
||||
<FontAwesomeIcon icon={faMapLocationDot} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Faturamento por Estado — {periodoLabel}
|
||||
</Flex>
|
||||
}
|
||||
open={detalheAberto}
|
||||
onCancel={() => setDetalheAberto(false)}
|
||||
footer={null}
|
||||
width={900}
|
||||
centered
|
||||
>
|
||||
<Table<UfFaturamento>
|
||||
rowKey="uf"
|
||||
columns={detalheColumns()}
|
||||
dataSource={dados}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ y: 420 }}
|
||||
expandable={{
|
||||
expandedRowKeys: ufsExpandidas,
|
||||
onExpandedRowsChange: (keys) => setUfsExpandidas([...keys] as string[]),
|
||||
expandedRowRender: (r) => (
|
||||
<Table<UfRep>
|
||||
rowKey="codVendedor"
|
||||
columns={repColumns}
|
||||
dataSource={r.reps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
}}
|
||||
locale={{ emptyText: 'Nenhuma venda no período.' }}
|
||||
/>
|
||||
</Modal>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
@@ -22,145 +24,94 @@ 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 type { Promocao, RegraDesconto, RepStats, GrupoProdutoItem } from '@sar/api-interface';
|
||||
import {
|
||||
usePoliticasDescontos,
|
||||
usePromocoes,
|
||||
useUpsertDesconto,
|
||||
useCreatePromocao,
|
||||
useUpdatePromocao,
|
||||
useDeletePromocao,
|
||||
useEquipe,
|
||||
useRegrasDesconto,
|
||||
useGruposProduto,
|
||||
useCreateRegraDesconto,
|
||||
useUpdateRegraDesconto,
|
||||
useDeleteRegraDesconto,
|
||||
} from '../../lib/queries/gerente';
|
||||
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
// ─── ProdutoSelect: busca remota por código ou descrição ─────────────────────
|
||||
|
||||
// ─── Tab Descontos ────────────────────────────────────────────────────────────
|
||||
function ProdutoSelect({
|
||||
multiple,
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
multiple?: boolean;
|
||||
value?: string[] | string;
|
||||
onChange?: (v: string[] | string) => void;
|
||||
}) {
|
||||
const [q, setQ] = useState('');
|
||||
const { data, isFetching } = useCatalog({ q: q || undefined, limit: 20 });
|
||||
// Guarda os rótulos dos já selecionados — a lista de opções muda a cada busca
|
||||
const [labels, setLabels] = useState<Map<string, string>>(new Map());
|
||||
|
||||
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);
|
||||
const opts = (data?.data ?? []).map((p) => ({
|
||||
value: p.codigo.trim(),
|
||||
label: `${p.codigo.trim()} — ${p.descricao.trim()}`,
|
||||
}));
|
||||
const selecionados = multiple
|
||||
? ((value as string[] | undefined) ?? [])
|
||||
: value
|
||||
? [value as string]
|
||||
: [];
|
||||
const merged = [...opts];
|
||||
for (const v of selecionados) {
|
||||
if (!merged.some((o) => o.value === v)) merged.push({ value: v, label: labels.get(v) ?? v });
|
||||
}
|
||||
|
||||
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>
|
||||
</>
|
||||
<Select
|
||||
mode={multiple ? 'multiple' : undefined}
|
||||
showSearch
|
||||
allowClear
|
||||
filterOption={false}
|
||||
onSearch={setQ}
|
||||
loading={isFetching}
|
||||
options={merged}
|
||||
value={value}
|
||||
onChange={(vals) => {
|
||||
setLabels((prev) => {
|
||||
const next = new Map(prev);
|
||||
for (const o of opts) next.set(o.value, o.label);
|
||||
return next;
|
||||
});
|
||||
setQ('');
|
||||
onChange?.(vals as string[] | string);
|
||||
}}
|
||||
placeholder="Busque por código ou descrição"
|
||||
notFoundContent={q && !isFetching ? 'Nenhum produto encontrado' : null}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// ─── Tab Promocoes ────────────────────────────────────────────────────────────
|
||||
|
||||
interface PromocaoForm {
|
||||
descricao: string;
|
||||
codProdutos?: string[];
|
||||
grpProds?: string[];
|
||||
codProduto?: string;
|
||||
grpProd?: string;
|
||||
descPct: number;
|
||||
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||
ativa?: boolean;
|
||||
}
|
||||
|
||||
function TabPromocoes() {
|
||||
const { data, isLoading } = usePromocoes();
|
||||
const { data: gruposData } = useGruposProduto();
|
||||
const createMutation = useCreatePromocao();
|
||||
const updateMutation = useUpdatePromocao();
|
||||
const deleteMutation = useDeletePromocao();
|
||||
@@ -169,6 +120,18 @@ function TabPromocoes() {
|
||||
const [editTarget, setEditTarget] = useState<Promocao | null>(null);
|
||||
const [msg, msgCtx] = message.useMessage();
|
||||
|
||||
// Grupos com nome+código no rótulo — busca funciona pelos dois
|
||||
const grupoOptions = [
|
||||
...new Map(
|
||||
(gruposData?.grupos ?? [])
|
||||
.filter((g) => g.codGrupo != null)
|
||||
.map((g) => [
|
||||
g.codGrupo,
|
||||
{ value: String(g.codGrupo), label: `${g.codGrupo} — ${g.grupo ?? 'Sem nome'}` },
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
|
||||
function openCreate() {
|
||||
setEditTarget(null);
|
||||
form.resetFields();
|
||||
@@ -183,32 +146,62 @@ function TabPromocoes() {
|
||||
grpProd: p.grpProd ?? undefined,
|
||||
descPct: p.descPct,
|
||||
periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)],
|
||||
ativa: p.ativa,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function toggleAtiva(p: Promocao, ativa: boolean) {
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: p.id, body: { ativa } });
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient
|
||||
}
|
||||
void msg.success(ativa ? 'Promocao ativada' : 'Promocao desativada');
|
||||
}
|
||||
|
||||
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');
|
||||
try {
|
||||
if (editTarget) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: editTarget.id,
|
||||
body: {
|
||||
descricao: values.descricao,
|
||||
codProduto: values.codProduto ?? null,
|
||||
grpProd: values.grpProd ?? null,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
ativa: values.ativa ?? true,
|
||||
},
|
||||
});
|
||||
void msg.success('Promocao atualizada');
|
||||
} else {
|
||||
const res = (await createMutation.mutateAsync({
|
||||
descricao: values.descricao,
|
||||
codProdutos: values.codProdutos?.length ? values.codProdutos : undefined,
|
||||
grpProds: values.grpProds?.length ? values.grpProds : undefined,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
})) as { ids: number[] };
|
||||
void msg.success(
|
||||
res.ids.length > 1 ? `${res.ids.length} promocoes criadas` : 'Promocao criada',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||
}
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient
|
||||
}
|
||||
void msg.success('Promocao removida');
|
||||
}
|
||||
|
||||
@@ -255,6 +248,19 @@ function TabPromocoes() {
|
||||
return <Tag color="green">Ativa</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Ativa',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: Promocao) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={row.ativa}
|
||||
loading={updateMutation.isPending}
|
||||
onChange={(checked) => void toggleAtiva(row, checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 100,
|
||||
@@ -312,7 +318,8 @@ function TabPromocoes() {
|
||||
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||
width={520}
|
||||
width={760}
|
||||
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
|
||||
centered
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
@@ -324,14 +331,468 @@ function TabPromocoes() {
|
||||
<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>
|
||||
{editTarget ? (
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="codProduto" label="Produto" style={{ flex: 1 }}>
|
||||
<ProdutoSelect />
|
||||
</Form.Item>
|
||||
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={grupoOptions}
|
||||
placeholder="Opcional"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
name="codProdutos"
|
||||
label="Produtos"
|
||||
extra="Busque por código ou descrição e selecione quantos quiser — será criada uma promoção por produto"
|
||||
>
|
||||
<ProdutoSelect multiple />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="grpProds"
|
||||
label="Grupos de produto"
|
||||
extra="Busque por nome ou código — uma promoção por grupo selecionado"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={grupoOptions}
|
||||
placeholder="Busque por nome ou código do grupo"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab Regras de Desconto ───────────────────────────────────────────────────
|
||||
|
||||
interface RegraForm {
|
||||
descricao: string;
|
||||
codVendedor?: number;
|
||||
codGrupos?: number[];
|
||||
codSubgrupos?: number[];
|
||||
idPautas?: number[];
|
||||
codGrupo?: number;
|
||||
codSubgrupo?: number;
|
||||
idPauta?: number;
|
||||
descPct: number;
|
||||
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||
ativa?: boolean;
|
||||
}
|
||||
|
||||
function TabRegras() {
|
||||
const { data, isLoading } = useRegrasDesconto();
|
||||
const { data: equipe } = useEquipe();
|
||||
const { data: gruposData } = useGruposProduto();
|
||||
const { data: pautas } = usePautas();
|
||||
const createMutation = useCreateRegraDesconto();
|
||||
const updateMutation = useUpdateRegraDesconto();
|
||||
const deleteMutation = useDeleteRegraDesconto();
|
||||
const [form] = Form.useForm<RegraForm>();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null);
|
||||
const [msg, msgCtx] = message.useMessage();
|
||||
const grupoSelecionado = Form.useWatch('codGrupo', form);
|
||||
const gruposSelecionados = Form.useWatch('codGrupos', form);
|
||||
|
||||
const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
|
||||
value: r.codVendedor,
|
||||
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
|
||||
}));
|
||||
|
||||
// Pautas de preços — rótulo com código + descrição (busca encontra pelos dois)
|
||||
const pautaOptions = (pautas ?? []).map((p) => ({
|
||||
value: p.idPauta,
|
||||
label: `${p.codigo} — ${p.descricao}`,
|
||||
}));
|
||||
|
||||
// Rótulos com código + nome — a busca do select encontra pelos dois.
|
||||
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
|
||||
const filtroGrupos = new Set<number>([
|
||||
...(grupoSelecionado != null ? [grupoSelecionado] : []),
|
||||
...(gruposSelecionados ?? []),
|
||||
]);
|
||||
const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
|
||||
const grupoMap = new Map<number, { value: number; label: string }>();
|
||||
const subgrupoMap = new Map<number, { value: number; label: string }>();
|
||||
for (const g of grupoRows) {
|
||||
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) {
|
||||
grupoMap.set(g.codGrupo, {
|
||||
value: g.codGrupo,
|
||||
label: `${g.codGrupo} — ${g.grupo ?? 'Sem nome'}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
g.codSubgrupo != null &&
|
||||
(filtroGrupos.size === 0 || (g.codGrupo != null && filtroGrupos.has(g.codGrupo))) &&
|
||||
!subgrupoMap.has(g.codSubgrupo)
|
||||
) {
|
||||
subgrupoMap.set(g.codSubgrupo, {
|
||||
value: g.codSubgrupo,
|
||||
label: `${g.codSubgrupo} — ${g.subgrupo ?? 'Sem nome'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const grupoOptions = [...grupoMap.values()];
|
||||
const subgrupoOptions = [...subgrupoMap.values()];
|
||||
|
||||
function openCreate() {
|
||||
setEditTarget(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(r: RegraDesconto) {
|
||||
setEditTarget(r);
|
||||
form.setFieldsValue({
|
||||
descricao: r.descricao,
|
||||
codVendedor: r.codVendedor ?? undefined,
|
||||
codGrupo: r.codGrupo ?? undefined,
|
||||
codSubgrupo: r.codSubgrupo ?? undefined,
|
||||
idPauta: r.idPauta ?? undefined,
|
||||
descPct: r.descPct,
|
||||
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
||||
ativa: r.ativa,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function toggleAtiva(r: RegraDesconto, ativa: boolean) {
|
||||
try {
|
||||
await updateMutation.mutateAsync({ id: r.id, body: { ativa } });
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient
|
||||
}
|
||||
void msg.success(ativa ? 'Regra ativada' : 'Regra desativada');
|
||||
}
|
||||
|
||||
async function handleSubmit(values: RegraForm) {
|
||||
const [inicio, fim] = values.periodo;
|
||||
try {
|
||||
if (editTarget) {
|
||||
await updateMutation.mutateAsync({
|
||||
id: editTarget.id,
|
||||
body: {
|
||||
descricao: values.descricao,
|
||||
codVendedor: values.codVendedor ?? null,
|
||||
codGrupo: values.codGrupo ?? null,
|
||||
codSubgrupo: values.codSubgrupo ?? null,
|
||||
idPauta: values.idPauta ?? null,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
ativa: values.ativa ?? true,
|
||||
},
|
||||
});
|
||||
void msg.success('Regra atualizada');
|
||||
} else {
|
||||
const res = (await createMutation.mutateAsync({
|
||||
descricao: values.descricao,
|
||||
codVendedor: values.codVendedor ?? null,
|
||||
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
|
||||
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
|
||||
idPautas: values.idPautas?.length ? values.idPautas : undefined,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
})) as { ids: number[] };
|
||||
void msg.success(res.ids.length > 1 ? `${res.ids.length} regras criadas` : 'Regra criada');
|
||||
}
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||
}
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient
|
||||
}
|
||||
void msg.success('Regra removida');
|
||||
}
|
||||
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const columns: TableColumnsType<RegraDesconto> = [
|
||||
{
|
||||
title: 'Descricao',
|
||||
dataIndex: 'descricao',
|
||||
},
|
||||
{
|
||||
title: 'Representante',
|
||||
width: 180,
|
||||
render: (_: unknown, row: RegraDesconto) =>
|
||||
row.codVendedor == null ? (
|
||||
<Tag>Todos</Tag>
|
||||
) : (
|
||||
<Text>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Grupo / Subgrupo',
|
||||
width: 200,
|
||||
render: (_: unknown, row: RegraDesconto) => {
|
||||
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
|
||||
const partes = [
|
||||
row.codGrupo != null ? (row.grupo ?? `Grupo ${row.codGrupo}`) : null,
|
||||
row.codSubgrupo != null ? (row.subgrupo ?? `Subgrupo ${row.codSubgrupo}`) : null,
|
||||
].filter(Boolean);
|
||||
return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Pauta',
|
||||
width: 160,
|
||||
render: (_: unknown, row: RegraDesconto) =>
|
||||
row.idPauta == null ? (
|
||||
<Tag>Todas</Tag>
|
||||
) : (
|
||||
<Text style={{ fontSize: 'var(--text-sm)' }}>{row.pauta ?? `Pauta ${row.idPauta}`}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Desconto',
|
||||
dataIndex: 'descPct',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v: number) => <Text className="tabular-nums">{v}%</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Vigencia',
|
||||
width: 200,
|
||||
render: (_: unknown, row: RegraDesconto) => (
|
||||
<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: RegraDesconto) => {
|
||||
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: 'Ativa',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: RegraDesconto) => (
|
||||
<Switch
|
||||
size="small"
|
||||
checked={row.ativa}
|
||||
loading={updateMutation.isPending}
|
||||
onChange={(checked) => void toggleAtiva(row, checked)}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 100,
|
||||
render: (_: unknown, row: RegraDesconto) => (
|
||||
<Space>
|
||||
<Tooltip title="Editar">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FontAwesomeIcon icon={faPen} />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Remover regra?"
|
||||
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 Regra
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Table<RegraDesconto>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data.regras}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
||||
A regra vale para o representante, grupo, subgrupo e pauta informados (em branco = todos) e
|
||||
aplica o desconto automaticamente nos pedidos durante a vigencia.
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
title={editTarget ? 'Editar Regra de Desconto' : 'Nova Regra de Desconto'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||
width={760}
|
||||
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
|
||||
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.: Campanha linha inverno" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="codVendedor" label="Representante">
|
||||
<Select
|
||||
options={repOptions}
|
||||
placeholder="Todos os representantes"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
{editTarget ? (
|
||||
<>
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={grupoOptions}
|
||||
placeholder="Todos os grupos"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={subgrupoOptions}
|
||||
placeholder="Todos os subgrupos"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
<Form.Item name="idPauta" label="Pauta de precos">
|
||||
<Select
|
||||
options={pautaOptions}
|
||||
placeholder="Todas as pautas"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
name="codGrupos"
|
||||
label="Grupos de produto"
|
||||
extra="Busque por nome ou código e selecione quantos quiser — uma regra por grupo"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={grupoOptions}
|
||||
placeholder="Todos os grupos"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="codSubgrupos"
|
||||
label="Subgrupos"
|
||||
extra="Uma regra por subgrupo selecionado (a lista filtra pelos grupos escolhidos)"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={subgrupoOptions}
|
||||
placeholder="Todos os subgrupos"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="idPautas"
|
||||
label="Pautas de precos"
|
||||
extra="Selecione 1 ou mais pautas — uma regra por pauta, aplicada a todos os produtos dela"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={pautaOptions}
|
||||
placeholder="Todas as pautas"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="descPct"
|
||||
@@ -362,16 +823,16 @@ function TabPromocoes() {
|
||||
|
||||
export function PoliticasPage() {
|
||||
const items = [
|
||||
{
|
||||
key: 'descontos',
|
||||
label: 'Desconto por Representante',
|
||||
children: <TabDescontos />,
|
||||
},
|
||||
{
|
||||
key: 'promocoes',
|
||||
label: 'Promocoes',
|
||||
children: <TabPromocoes />,
|
||||
},
|
||||
{
|
||||
key: 'regras',
|
||||
label: 'Regras de Desconto',
|
||||
children: <TabRegras />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -381,7 +842,7 @@ export function PoliticasPage() {
|
||||
Politicas Comerciais
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
Limites de desconto e promocoes com vigencia
|
||||
Promocoes e regras de desconto com vigencia
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
|
||||
164
apps/web/src/cockpits/ger/SupervisorDetalheModal.tsx
Normal file
164
apps/web/src/cockpits/ger/SupervisorDetalheModal.tsx
Normal file
@@ -0,0 +1,164 @@
|
||||
import { Card, Col, Flex, Modal, Progress, Row, Space, Spin, Table, Typography } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faAddressCard, faRankingStar } from '@fortawesome/free-solid-svg-icons';
|
||||
import type { PositivacaoRep, RankingRep, SupervisorCard } from '@sar/api-interface';
|
||||
import { useSupervisorDashboardByCod } from '../../lib/queries/dashboard';
|
||||
import {
|
||||
positivacaoRepColumns,
|
||||
rankingRepColumns,
|
||||
} from '../../components/dashboard/rep-performance-columns';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
function fmt(v: number): string {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function metaColor(pct: number): string {
|
||||
return pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14';
|
||||
}
|
||||
|
||||
// Detalhe/análise do time de um supervisor — aberto pelo gerente na aba Equipe.
|
||||
// Reusa /dashboard/supervisor escopado por codSupervisor e as mesmas tabelas do
|
||||
// painel do supervisor (ranking + positivação).
|
||||
export function SupervisorDetalheModal({
|
||||
supervisor,
|
||||
onClose,
|
||||
}: {
|
||||
supervisor: SupervisorCard | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data, isLoading } = useSupervisorDashboardByCod(supervisor?.codSupervisor ?? null);
|
||||
|
||||
const titulo = supervisor
|
||||
? `${supervisor.nomeSupervisor ?? `Supervisor cód. ${supervisor.codSupervisor}`}`
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={supervisor !== null}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={900}
|
||||
centered
|
||||
destroyOnHidden
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faRankingStar} style={{ color: 'var(--jcs-blue)' }} />
|
||||
{titulo}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
{isLoading || !data || !supervisor ? (
|
||||
<Spin style={{ display: 'block', margin: '48px auto' }} />
|
||||
) : (
|
||||
<Flex vertical gap={20}>
|
||||
{/* KPIs do time no mês */}
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card size="small">
|
||||
<Space orientation="vertical" size={2}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
FATURAMENTO NO MÊS
|
||||
</Text>
|
||||
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(data.equipeMes.faturamento)}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{data.equipeMes.pedidos} pedido{data.equipeMes.pedidos !== 1 ? 's' : ''} ·{' '}
|
||||
{supervisor.repsAtivos}/{supervisor.repsTotal} reps ativos
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card size="small">
|
||||
<Space orientation="vertical" size={2} style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
META DO TIME
|
||||
</Text>
|
||||
{data.equipeMes.metaTotal > 0 ? (
|
||||
<>
|
||||
<Flex align="baseline" gap={6}>
|
||||
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
|
||||
{data.equipeMes.pctMeta}%
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
de {fmt(data.equipeMes.metaTotal)}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Progress
|
||||
percent={Math.min(data.equipeMes.pctMeta, 100)}
|
||||
size="small"
|
||||
strokeColor={metaColor(data.equipeMes.pctMeta)}
|
||||
showInfo={false}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary">Sem meta cadastrada.</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Card size="small">
|
||||
<Space orientation="vertical" size={2}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
TICKET MÉDIO
|
||||
</Text>
|
||||
<Title level={4} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(data.equipeMes.ticketMedio)}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
positivação {supervisor.pctPositivacao}% ({supervisor.clientesPositivados}/
|
||||
{supervisor.totalClientes})
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Ranking dos reps do time */}
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 12 }}>
|
||||
<FontAwesomeIcon
|
||||
icon={faRankingStar}
|
||||
style={{ color: 'var(--jcs-blue)', marginRight: 8 }}
|
||||
/>
|
||||
Ranking dos representantes
|
||||
</Text>
|
||||
<Table<RankingRep>
|
||||
rowKey="codVendedor"
|
||||
columns={rankingRepColumns}
|
||||
dataSource={data.rankingReps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 640 }}
|
||||
locale={{ emptyText: 'Nenhum pedido do time no mês.' }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Positivação dos reps do time */}
|
||||
<div>
|
||||
<Text strong style={{ display: 'block', marginBottom: 12 }}>
|
||||
<FontAwesomeIcon
|
||||
icon={faAddressCard}
|
||||
style={{ color: 'var(--jcs-blue)', marginRight: 8 }}
|
||||
/>
|
||||
Positivação de clientes
|
||||
</Text>
|
||||
<Table<PositivacaoRep>
|
||||
rowKey="codVendedor"
|
||||
columns={positivacaoRepColumns}
|
||||
dataSource={data.positivacaoReps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 640 }}
|
||||
locale={{ emptyText: 'Nenhum representante com carteira no time.' }}
|
||||
/>
|
||||
</div>
|
||||
</Flex>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
159
apps/web/src/cockpits/rep/AbrirChamadoModal.tsx
Normal file
159
apps/web/src/cockpits/rep/AbrirChamadoModal.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import { useState } from 'react';
|
||||
import { Modal, Form, Select, Input, Checkbox, Typography, Space } from 'antd';
|
||||
import { TIPOS_CHAMADO, TIPO_CHAMADO_LABEL, type PedidoItem } from '@sar/api-interface';
|
||||
import { useCreateChamado } from '../../lib/queries/sac';
|
||||
|
||||
const { TextArea } = Input;
|
||||
const { Text } = Typography;
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
idPedido?: string;
|
||||
numPedSar?: number;
|
||||
numPedErp?: number;
|
||||
nomeCliente?: string | null;
|
||||
idCliente: number;
|
||||
itens: PedidoItem[];
|
||||
}
|
||||
|
||||
export function AbrirChamadoModal({
|
||||
open,
|
||||
onClose,
|
||||
idPedido,
|
||||
numPedSar,
|
||||
numPedErp,
|
||||
nomeCliente,
|
||||
idCliente,
|
||||
itens,
|
||||
}: Props) {
|
||||
const [form] = Form.useForm();
|
||||
const [itensSelecionados, setItensSelecionados] = useState<string[]>([]);
|
||||
const criar = useCreateChamado();
|
||||
|
||||
const pedidoLabel = numPedErp
|
||||
? `ERP #${numPedErp}`
|
||||
: numPedSar
|
||||
? `SAR-${String(numPedSar).padStart(5, '0')}`
|
||||
: 'Pedido';
|
||||
|
||||
function handleOk() {
|
||||
form.validateFields().then((values) => {
|
||||
const itensAfetados = itens
|
||||
.filter((it) => itensSelecionados.includes(it.id))
|
||||
.map((it) => ({
|
||||
codProduto: it.codProduto ?? '',
|
||||
nomeProduto: it.descProduto ?? it.codProduto ?? '',
|
||||
qtd: Number(it.qtd),
|
||||
}));
|
||||
criar.mutate(
|
||||
{
|
||||
idPedido,
|
||||
numPedSar,
|
||||
numPedErp,
|
||||
nomeCliente: nomeCliente ?? undefined,
|
||||
idCliente,
|
||||
tipo: values.tipo,
|
||||
assunto: values.assunto,
|
||||
descricao: values.descricao,
|
||||
itensAfetados,
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
form.resetFields();
|
||||
setItensSelecionados([]);
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
form.resetFields();
|
||||
setItensSelecionados([]);
|
||||
onClose();
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={
|
||||
<span>
|
||||
Abrir Chamado — {pedidoLabel}
|
||||
{nomeCliente && (
|
||||
<Text type="secondary" style={{ fontWeight: 400, marginLeft: 8 }}>
|
||||
· {nomeCliente}
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
open={open}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
okText="Abrir Chamado"
|
||||
cancelText="Cancelar"
|
||||
confirmLoading={criar.isPending}
|
||||
width={640}
|
||||
centered
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="tipo"
|
||||
label="Tipo de Ocorrência"
|
||||
rules={[{ required: true, message: 'Selecione o tipo' }]}
|
||||
>
|
||||
<Select placeholder="Selecione...">
|
||||
{TIPOS_CHAMADO.map((t) => (
|
||||
<Select.Option key={t} value={t}>
|
||||
{TIPO_CHAMADO_LABEL[t]}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="assunto"
|
||||
label="Assunto"
|
||||
rules={[{ required: true, min: 5, message: 'Mínimo 5 caracteres' }]}
|
||||
>
|
||||
<Input maxLength={200} showCount placeholder="Resumo breve do problema..." />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="descricao"
|
||||
label="Descrição"
|
||||
rules={[{ required: true, min: 10, message: 'Mínimo 10 caracteres' }]}
|
||||
>
|
||||
<TextArea
|
||||
rows={4}
|
||||
maxLength={2000}
|
||||
showCount
|
||||
placeholder="Descreva detalhadamente o problema..."
|
||||
/>
|
||||
</Form.Item>
|
||||
{itens.length > 0 && (
|
||||
<Form.Item label="Produtos Afetados (opcional)">
|
||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||
{itens.map((it) => (
|
||||
<Checkbox
|
||||
key={it.id}
|
||||
checked={itensSelecionados.includes(it.id)}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setItensSelecionados((prev) => [...prev, it.id]);
|
||||
} else {
|
||||
setItensSelecionados((prev) => prev.filter((c) => c !== it.id));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Text>
|
||||
{it.codProduto} — {it.descProduto ?? it.codProduto}
|
||||
</Text>
|
||||
<Text type="secondary"> (Qtd: {it.qtd})</Text>
|
||||
</Checkbox>
|
||||
))}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
import { EyeOutlined } from '@ant-design/icons';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import type { ProdutoSummary } from '@sar/api-interface';
|
||||
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
||||
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
|
||||
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||
import { ProductDetailDrawer } from './ProductDetailDrawer';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Search } = Input;
|
||||
|
||||
@@ -16,7 +18,10 @@ function fmtPrice(v: string | null | undefined): string {
|
||||
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
|
||||
}
|
||||
|
||||
function buildColumns(onDetail: (id: number) => void): TableColumnsType<ProdutoSummary> {
|
||||
function buildColumns(
|
||||
onDetail: (id: number) => void,
|
||||
condicoesDe: (p: ProdutoSummary) => CondicaoComercial[],
|
||||
): TableColumnsType<ProdutoSummary> {
|
||||
return [
|
||||
{
|
||||
title: 'Código',
|
||||
@@ -29,7 +34,18 @@ function buildColumns(onDetail: (id: number) => void): TableColumnsType<ProdutoS
|
||||
dataIndex: 'descricao',
|
||||
render: (v: string, row: ProdutoSummary) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{v.trim()}</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{v.trim()}
|
||||
<CondicaoTag conds={condicoesDe(row)} compact />
|
||||
</div>
|
||||
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
|
||||
</div>
|
||||
),
|
||||
@@ -101,8 +117,9 @@ export function CatalogPage() {
|
||||
|
||||
const { data: pautas, isLoading: pautasLoading } = usePautas();
|
||||
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
|
||||
const condicoesDe = useCondicoesComerciais();
|
||||
|
||||
const columns = buildColumns((id) => setSelectedIdErp(id));
|
||||
const columns = buildColumns((id) => setSelectedIdErp(id), condicoesDe);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||
@@ -144,7 +161,10 @@ export function CatalogPage() {
|
||||
}}
|
||||
options={pautas?.map((p) => ({
|
||||
value: p.idPauta,
|
||||
label: `${p.codigo} — ${p.descricao}`,
|
||||
label:
|
||||
p.qtdReps != null
|
||||
? `${p.codigo} — ${p.descricao} · ${p.qtdReps} rep${p.qtdReps !== 1 ? 's' : ''}`
|
||||
: `${p.codigo} — ${p.descricao}`,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
432
apps/web/src/cockpits/rep/ChamadosPage.tsx
Normal file
432
apps/web/src/cockpits/rep/ChamadosPage.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
import { useState } from 'react';
|
||||
import { Badge, Button, Modal, Input, Space, Spin, Table, Tag, Typography, Tabs, Form } from 'antd';
|
||||
import { CustomerServiceOutlined, PlusOutlined, SearchOutlined } from '@ant-design/icons';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import {
|
||||
STATUS_CHAMADO_LABEL,
|
||||
TIPO_CHAMADO_LABEL,
|
||||
RESOLUCAO_CHAMADO_LABEL,
|
||||
type Chamado,
|
||||
type ItemAfetado,
|
||||
type ChamadoMensagem,
|
||||
type PedidoErpConsultaItem,
|
||||
} from '@sar/api-interface';
|
||||
import {
|
||||
useChamados,
|
||||
useAddMensagemRep,
|
||||
useCancelChamado,
|
||||
useChamado,
|
||||
} from '../../lib/queries/sac';
|
||||
import { useOrderErpConsulta, useOrderDetail } from '../../lib/queries/orders';
|
||||
import { AbrirChamadoModal } from './AbrirChamadoModal';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
aberto: 'blue',
|
||||
em_analise: 'orange',
|
||||
aguardando_rep: 'purple',
|
||||
resolvido: 'green',
|
||||
cancelado: 'default',
|
||||
};
|
||||
|
||||
function pedidoLabel(chamado: Chamado) {
|
||||
if (chamado.numPedErp) return `ERP #${chamado.numPedErp}`;
|
||||
if (chamado.numPedSar) return `SAR-${String(chamado.numPedSar).padStart(5, '0')}`;
|
||||
return '—';
|
||||
}
|
||||
|
||||
// Busca pedido ERP por texto livre (cliente, número, etc.) e abre chamado
|
||||
function BuscarPedidoModal({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [searchAtivo, setSearchAtivo] = useState('');
|
||||
const [pedidoSelecionado, setPedidoSelecionado] = useState<PedidoErpConsultaItem | null>(null);
|
||||
|
||||
// idPedido é o ID interno do ERP (usado no endpoint /orders/erp/:id)
|
||||
// numeroPedido é o número externo visível ao usuário
|
||||
const erp_id = pedidoSelecionado?.idPedido ?? null;
|
||||
const consulta = useOrderErpConsulta({ search: searchAtivo, limit: 20 }, !!searchAtivo);
|
||||
const detalhe = useOrderDetail(erp_id ? `erp-${erp_id}` : undefined);
|
||||
|
||||
function buscar() {
|
||||
const trimmed = searchInput.trim();
|
||||
if (!trimmed) return;
|
||||
setSearchAtivo(trimmed);
|
||||
setPedidoSelecionado(null);
|
||||
}
|
||||
|
||||
function fechar() {
|
||||
setSearchInput('');
|
||||
setSearchAtivo('');
|
||||
setPedidoSelecionado(null);
|
||||
onClose();
|
||||
}
|
||||
|
||||
// Detalhe carregado — abre o formulário de chamado
|
||||
if (detalhe.data && pedidoSelecionado) {
|
||||
return (
|
||||
<AbrirChamadoModal
|
||||
open={true}
|
||||
onClose={fechar}
|
||||
numPedErp={pedidoSelecionado.numeroPedido}
|
||||
nomeCliente={pedidoSelecionado.razaoCliente ?? pedidoSelecionado.nomeCliente}
|
||||
idCliente={pedidoSelecionado.idCliente}
|
||||
itens={detalhe.data.itens}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const resultados = consulta.data?.data ?? [];
|
||||
const carregandoDetalhe = detalhe.isLoading;
|
||||
|
||||
const colunas: TableColumnsType<PedidoErpConsultaItem> = [
|
||||
{ title: 'Pedido', dataIndex: 'numeroPedido', width: 90 },
|
||||
{
|
||||
title: 'Cliente',
|
||||
render: (_: unknown, r: PedidoErpConsultaItem) => r.razaoCliente ?? r.nomeCliente ?? '—',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Data',
|
||||
dataIndex: 'data',
|
||||
width: 95,
|
||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||
},
|
||||
{
|
||||
title: 'Total',
|
||||
dataIndex: 'total',
|
||||
width: 100,
|
||||
render: (v: string) =>
|
||||
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Novo Chamado — Selecionar Pedido"
|
||||
open={open}
|
||||
onCancel={fechar}
|
||||
footer={null}
|
||||
width={680}
|
||||
centered
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: '100%', marginTop: 16 }} size="middle">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Input
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="Buscar por cliente, número do pedido..."
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.target.value)}
|
||||
onPressEnter={buscar}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={buscar}
|
||||
loading={consulta.isLoading}
|
||||
disabled={!searchInput.trim()}
|
||||
>
|
||||
Buscar
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
|
||||
{searchAtivo && (
|
||||
<Table<PedidoErpConsultaItem>
|
||||
rowKey="numeroPedido"
|
||||
columns={colunas}
|
||||
dataSource={resultados}
|
||||
loading={consulta.isLoading || carregandoDetalhe}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ y: 320 }}
|
||||
onRow={(r) => ({
|
||||
onClick: () => (r.idPedido ? setPedidoSelecionado(r) : undefined),
|
||||
style: {
|
||||
cursor: r.idPedido ? 'pointer' : 'not-allowed',
|
||||
opacity: r.idPedido ? 1 : 0.4,
|
||||
},
|
||||
})}
|
||||
locale={{ emptyText: 'Nenhum pedido encontrado.' }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ChamadoDetalheModal({
|
||||
id,
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
id: number;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [texto, setTexto] = useState('');
|
||||
const { data: chamado, isLoading } = useChamado(id);
|
||||
const addMsg = useAddMensagemRep(id);
|
||||
const cancel = useCancelChamado();
|
||||
|
||||
function enviar() {
|
||||
if (!texto.trim()) return;
|
||||
addMsg.mutate({ texto: texto.trim() }, { onSuccess: () => setTexto('') });
|
||||
}
|
||||
|
||||
const fechado = chamado && ['resolvido', 'cancelado'].includes(chamado.status);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={chamado ? `Chamado #${chamado.id} — ${chamado.assunto}` : 'Chamado'}
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={700}
|
||||
centered
|
||||
>
|
||||
{isLoading || !chamado ? (
|
||||
<Spin />
|
||||
) : (
|
||||
<Space orientation="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Space wrap>
|
||||
<Tag color={STATUS_COLOR[chamado.status]}>{STATUS_CHAMADO_LABEL[chamado.status]}</Tag>
|
||||
<Tag>{TIPO_CHAMADO_LABEL[chamado.tipo] ?? chamado.tipo}</Tag>
|
||||
{chamado.resolucao && (
|
||||
<Tag color="green">
|
||||
{RESOLUCAO_CHAMADO_LABEL[chamado.resolucao] ?? chamado.resolucao}
|
||||
</Tag>
|
||||
)}
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{pedidoLabel(chamado)}
|
||||
{chamado.nomeCliente ? ` · ${chamado.nomeCliente}` : ''}
|
||||
</Text>
|
||||
</Space>
|
||||
|
||||
<Text type="secondary">{chamado.descricao}</Text>
|
||||
|
||||
{chamado.notaResolucao && (
|
||||
<div
|
||||
style={{
|
||||
background: '#f6ffed',
|
||||
border: '1px solid #b7eb8f',
|
||||
borderRadius: 6,
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<Text strong>Resolução: </Text>
|
||||
<Text>{chamado.notaResolucao}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{chamado.itensAfetados && chamado.itensAfetados.length > 0 && (
|
||||
<div>
|
||||
<Text strong style={{ fontSize: 12 }}>
|
||||
Produtos afetados:{' '}
|
||||
</Text>
|
||||
{(chamado.itensAfetados as ItemAfetado[]).map((it, i) => (
|
||||
<Tag key={`${it.codProduto}-${i}`}>
|
||||
{it.codProduto} — {it.nomeProduto} ({it.qtd} un)
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid #f0f0f0',
|
||||
borderRadius: 8,
|
||||
padding: 12,
|
||||
maxHeight: 320,
|
||||
overflowY: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{(chamado.mensagens ?? []).length === 0 ? (
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ textAlign: 'center', display: 'block', padding: '16px 0' }}
|
||||
>
|
||||
Sem mensagens ainda.
|
||||
</Text>
|
||||
) : (
|
||||
((chamado.mensagens as ChamadoMensagem[]) ?? []).map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: m.autor === 'rep' ? 'flex-end' : 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: '72%',
|
||||
background: m.autor === 'rep' ? '#1677ff' : '#f5f5f5',
|
||||
color: m.autor === 'rep' ? '#fff' : '#000',
|
||||
borderRadius: 10,
|
||||
padding: '8px 12px',
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
<div style={{ fontSize: 11, opacity: 0.7, marginBottom: 2 }}>
|
||||
{m.autor === 'rep' ? 'Você' : 'Empresa'}
|
||||
</div>
|
||||
<div>{m.texto}</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4, textAlign: 'right' }}>
|
||||
{new Date(m.createdAt).toLocaleString('pt-BR')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!fechado && (
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="Nova mensagem">
|
||||
<TextArea
|
||||
rows={2}
|
||||
value={texto}
|
||||
onChange={(e) => setTexto(e.target.value)}
|
||||
maxLength={2000}
|
||||
showCount
|
||||
/>
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={enviar}
|
||||
loading={addMsg.isPending}
|
||||
disabled={!texto.trim()}
|
||||
>
|
||||
Enviar
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={() => cancel.mutate(id, { onSuccess: onClose })}
|
||||
loading={cancel.isPending}
|
||||
>
|
||||
Cancelar Chamado
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<Chamado> = [
|
||||
{ title: '#', dataIndex: 'id', width: 60 },
|
||||
{
|
||||
title: 'Status',
|
||||
dataIndex: 'status',
|
||||
width: 140,
|
||||
render: (s: string) => (
|
||||
<Tag color={STATUS_COLOR[s] ?? 'default'}>{STATUS_CHAMADO_LABEL[s] ?? s}</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Tipo',
|
||||
dataIndex: 'tipo',
|
||||
width: 160,
|
||||
render: (t: string) => TIPO_CHAMADO_LABEL[t] ?? t,
|
||||
},
|
||||
{ title: 'Assunto', dataIndex: 'assunto', ellipsis: true },
|
||||
{
|
||||
title: 'Pedido',
|
||||
width: 100,
|
||||
render: (_: unknown, r: Chamado) => pedidoLabel(r),
|
||||
},
|
||||
{ title: 'Cliente', dataIndex: 'nomeCliente', width: 160, ellipsis: true },
|
||||
{
|
||||
title: 'Data',
|
||||
dataIndex: 'createdAt',
|
||||
width: 110,
|
||||
render: (v: string) => new Date(v).toLocaleDateString('pt-BR'),
|
||||
},
|
||||
];
|
||||
|
||||
export function ChamadosPage() {
|
||||
const { data, isLoading } = useChamados();
|
||||
const [detalheId, setDetalheId] = useState<number | null>(null);
|
||||
const [novoOpen, setNovoOpen] = useState(false);
|
||||
|
||||
if (isLoading) return <Spin style={{ display: 'block', marginTop: 64 }} />;
|
||||
|
||||
const abertos = data?.abertos ?? [];
|
||||
const fechados = data?.fechados ?? [];
|
||||
const totalAbertos = abertos.length;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24, maxWidth: 1100, margin: '0 auto' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
<Space align="center">
|
||||
<CustomerServiceOutlined style={{ fontSize: 24, color: '#1677ff' }} />
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
SAC — Chamados
|
||||
</Title>
|
||||
{totalAbertos > 0 && <Badge count={totalAbertos} />}
|
||||
</Space>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => setNovoOpen(true)}>
|
||||
Novo Chamado
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'abertos',
|
||||
label: `Abertos (${abertos.length})`,
|
||||
children: (
|
||||
<Table<Chamado>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={abertos}
|
||||
pagination={false}
|
||||
size="small"
|
||||
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||
locale={{ emptyText: 'Nenhum chamado aberto.' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fechados',
|
||||
label: `Fechados (${fechados.length})`,
|
||||
children: (
|
||||
<Table<Chamado>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={fechados}
|
||||
pagination={{ pageSize: 20 }}
|
||||
size="small"
|
||||
onRow={(r) => ({ onClick: () => setDetalheId(r.id), style: { cursor: 'pointer' } })}
|
||||
locale={{ emptyText: 'Nenhum chamado fechado.' }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{detalheId !== null && (
|
||||
<ChamadoDetalheModal
|
||||
id={detalheId}
|
||||
open={detalheId !== null}
|
||||
onClose={() => setDetalheId(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<BuscarPedidoModal open={novoOpen} onClose={() => setNovoOpen(false)} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,6 @@ import {
|
||||
Badge,
|
||||
} from 'antd';
|
||||
import { useState } from 'react';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate, useParams } from '@tanstack/react-router';
|
||||
@@ -32,6 +30,8 @@ import {
|
||||
} from '../../lib/queries/clients';
|
||||
import { ClientContacts } from '../../components/contacts/ClientContacts';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const ACTIVITY_COLOR: Record<string, string> = {
|
||||
@@ -251,7 +251,7 @@ export function ClientDetailPage() {
|
||||
{client.telefone ?? '—'}
|
||||
</Descriptions.Item>
|
||||
{client.endereco && (
|
||||
<Descriptions.Item label="Endereço" span={2}>
|
||||
<Descriptions.Item label="Endereço" span={isMobile ? 1 : 2}>
|
||||
{client.endereco}
|
||||
{client.numEndereco ? `, ${client.numEndereco}` : ''}
|
||||
{client.bairro ? ` — ${client.bairro}` : ''}
|
||||
@@ -284,7 +284,9 @@ export function ClientDetailPage() {
|
||||
}}
|
||||
>
|
||||
{/* ── Contas a Receber ── */}
|
||||
<div>
|
||||
{/* minWidth: 0 — track 1fr tem min-width auto; sem isto a tabela com
|
||||
scroll x 'max-content' estoura a coluna e engole a vizinha (NF-e). */}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
@@ -390,7 +392,7 @@ export function ClientDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* ── Notas Fiscais ── */}
|
||||
<div>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -35,16 +35,12 @@ import {
|
||||
UserOutlined,
|
||||
WhatsAppOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import { Chart as ChartJS, ArcElement, Tooltip as ChartTooltip, Legend } from 'chart.js';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import type { ActivityStatus, ClientSummary } from '@sar/api-interface';
|
||||
import { SITUA_LABEL } from '@sar/api-interface';
|
||||
import { useClientList, useClientDetail } from '../../lib/queries/clients';
|
||||
import { useClientOrders } from '../../lib/queries/orders';
|
||||
|
||||
ChartJS.register(ArcElement, ChartTooltip, Legend);
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
@@ -184,143 +180,6 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CustomerPortfolioCard ────────────────────────────────────────────────────
|
||||
|
||||
function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) {
|
||||
const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||
const total = stats.ativos + stats.emAlerta + stats.inativos;
|
||||
|
||||
const donutData = {
|
||||
labels: ['Ativos', 'Em alerta', 'Inativos'],
|
||||
datasets: [
|
||||
{
|
||||
data: [stats.ativos, stats.emAlerta, stats.inativos],
|
||||
backgroundColor: ['#52C41A', '#FAAD14', '#FF4D4F'],
|
||||
borderColor: '#fff',
|
||||
borderWidth: 3,
|
||||
hoverOffset: 6,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const donutOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: true,
|
||||
cutout: '68%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: { label: string; raw: unknown }) => {
|
||||
const v = ctx.raw as number;
|
||||
const pct = total > 0 ? ((v / total) * 100).toFixed(1) : '0.0';
|
||||
return `${ctx.label}: ${v.toLocaleString('pt-BR')} (${pct}%)`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const legendItems = [
|
||||
{ label: 'Ativos', value: stats.ativos, color: '#52C41A' },
|
||||
{ label: 'Em alerta', value: stats.emAlerta, color: '#FAAD14' },
|
||||
{ label: 'Inativos', value: stats.inativos, color: '#FF4D4F' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: 12,
|
||||
border: '1px solid #EBF0F5',
|
||||
boxShadow: '0 1px 6px rgba(0,0,0,0.07)',
|
||||
}}
|
||||
styles={{ body: { padding: '20px 20px 16px' } }}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Text strong style={{ fontSize: 14, color: '#1F2937', display: 'block' }}>
|
||||
Carteira de Clientes
|
||||
</Text>
|
||||
<Text style={{ fontSize: 12, color: '#64748B', textTransform: 'capitalize' }}>
|
||||
{mesAtual}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', width: 180, margin: '0 auto 16px' }}>
|
||||
{stats.loaded && total > 0 ? (
|
||||
<>
|
||||
<Doughnut data={donutData} options={donutOptions} />
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%,-50%)',
|
||||
textAlign: 'center',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
<Text
|
||||
strong
|
||||
style={{ fontSize: 22, color: '#1F2937', display: 'block', lineHeight: 1.1 }}
|
||||
>
|
||||
{stats.total.toLocaleString('pt-BR')}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 11, color: '#64748B' }}>Clientes</Text>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div
|
||||
style={{ height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<Spin />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Space orientation="vertical" size={6} style={{ width: '100%', marginBottom: 16 }}>
|
||||
{legendItems.map((item) => {
|
||||
const pct = total > 0 ? ((item.value / total) * 100).toFixed(1) : '0.0';
|
||||
return (
|
||||
<div
|
||||
key={item.label}
|
||||
style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}
|
||||
>
|
||||
<Space size={6}>
|
||||
<div
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: 2,
|
||||
background: item.color,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<Text style={{ fontSize: 12, color: '#475569' }}>{item.label}</Text>
|
||||
</Space>
|
||||
<Space size={8}>
|
||||
<Text strong style={{ fontSize: 12 }}>
|
||||
{item.value.toLocaleString('pt-BR')}
|
||||
</Text>
|
||||
<Text style={{ fontSize: 11, color: '#94A3B8', width: 44, textAlign: 'right' }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Space>
|
||||
|
||||
<Divider style={{ margin: '12px 0' }} />
|
||||
<Button
|
||||
block
|
||||
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
|
||||
>
|
||||
Detalhar carteira
|
||||
</Button>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── CustomerExpandedDetail ───────────────────────────────────────────────────
|
||||
|
||||
function CustomerExpandedDetail({ summary }: { summary: ClientSummary }) {
|
||||
@@ -500,7 +359,7 @@ function CustomerDetailsModal({
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||
{/* Identificação */}
|
||||
<Card
|
||||
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
|
||||
@@ -544,7 +403,7 @@ function CustomerDetailsModal({
|
||||
>
|
||||
Dados Cadastrais
|
||||
</Text>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<span style={label}>Telefone</span>
|
||||
<Space size={4}>
|
||||
@@ -596,7 +455,7 @@ function CustomerDetailsModal({
|
||||
>
|
||||
Dados Comerciais
|
||||
</Text>
|
||||
<Space direction="vertical" size={10} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={10} style={{ width: '100%' }}>
|
||||
{limiteFormatado !== '—' && (
|
||||
<div>
|
||||
<span style={label}>Limite de Crédito</span>
|
||||
@@ -657,7 +516,7 @@ function CustomerDetailsModal({
|
||||
{loadingOrders ? (
|
||||
<Spin size="small" />
|
||||
) : (
|
||||
<Space direction="vertical" size={6} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={6} style={{ width: '100%' }}>
|
||||
{orders.slice(0, 5).map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
@@ -811,7 +670,7 @@ function CustomerAnalysisModal({
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||
<div>
|
||||
<Space size={8}>
|
||||
<CustomerStatusBadge status={summary.activityStatus} />
|
||||
@@ -1080,7 +939,7 @@ export function ClientsPage() {
|
||||
limit,
|
||||
});
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
const r = [...rows];
|
||||
@@ -1453,17 +1312,9 @@ export function ClientsPage() {
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
|
||||
{isMobile && (
|
||||
<>
|
||||
<CustomerPortfolioCard stats={stats} />
|
||||
<div style={{ marginBottom: 16 }} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Área principal ────────────────────────────────────────────── */}
|
||||
<Row gutter={[20, 20]}>
|
||||
<Col xs={24} lg={17}>
|
||||
<Col xs={24} style={{ minWidth: 0 }}>
|
||||
{isLoading ? (
|
||||
<div style={{ textAlign: 'center', padding: 64 }}>
|
||||
<Spin size="large" />
|
||||
@@ -1536,12 +1387,6 @@ export function ClientsPage() {
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
|
||||
{!isMobile && (
|
||||
<Col lg={7}>
|
||||
<CustomerPortfolioCard stats={stats} />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
|
||||
{/* ── Modais ────────────────────────────────────────────────────── */}
|
||||
|
||||
146
apps/web/src/cockpits/rep/CondicoesEspeciaisCard.tsx
Normal file
146
apps/web/src/cockpits/rep/CondicoesEspeciaisCard.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Card, Divider, Flex, Space, Tag, Typography } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faChevronDown, faTags } from '@fortawesome/free-solid-svg-icons';
|
||||
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
|
||||
import { usePromocoesVigentes, useRegrasVigentes } from '../../lib/queries/politicas';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function fmtData(iso: string): string {
|
||||
return new Date(`${iso}T00:00:00`).toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
function alvoPromocao(p: PromocaoVigente): string {
|
||||
if (p.codProduto) return p.nomeProduto ?? `Produto ${p.codProduto}`;
|
||||
if (p.grpProd) return `Grupo ${p.grupo ?? p.grpProd}`;
|
||||
return 'Todos os produtos';
|
||||
}
|
||||
|
||||
function alvoRegra(r: RegraVigente): string {
|
||||
if (r.codGrupo != null) return `Grupo ${r.grupo ?? r.codGrupo}`;
|
||||
if (r.codSubgrupo != null) return `Subgrupo ${r.subgrupo ?? r.codSubgrupo}`;
|
||||
return 'Todos os produtos';
|
||||
}
|
||||
|
||||
function LinhaCondicao({
|
||||
tipo,
|
||||
descricao,
|
||||
alvo,
|
||||
descPct,
|
||||
dataFim,
|
||||
}: {
|
||||
tipo: 'promocao' | 'regra';
|
||||
descricao: string;
|
||||
alvo: string;
|
||||
descPct: number;
|
||||
dataFim: string;
|
||||
}) {
|
||||
return (
|
||||
<Flex justify="space-between" align="center" gap={12} wrap>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Space size={8}>
|
||||
<Tag
|
||||
color={tipo === 'promocao' ? 'orange' : 'blue'}
|
||||
style={{ borderRadius: 20, fontSize: 10, fontWeight: 700, marginInlineEnd: 0 }}
|
||||
>
|
||||
{tipo === 'promocao' ? 'PROMOÇÃO' : 'DESCONTO LIBERADO'}
|
||||
</Tag>
|
||||
<Text strong>{descricao}</Text>
|
||||
</Space>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
{alvo}
|
||||
</Text>
|
||||
</Space>
|
||||
<Space size={8}>
|
||||
<Tag color="green" className="tabular-nums" style={{ borderRadius: 20, fontWeight: 700 }}>
|
||||
{descPct}% off
|
||||
</Tag>
|
||||
<Text type="secondary" className="tabular-nums" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
até {fmtData(dataFim)}
|
||||
</Text>
|
||||
</Space>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
// Card expansível do painel do rep: promoções e regras de desconto vigentes
|
||||
// HOJE para o representante logado. Some sozinho quando a condição encerra ou
|
||||
// é inativada pelo gerente (refetch a cada minuto nos hooks de vigentes).
|
||||
export function CondicoesEspeciaisCard() {
|
||||
const { data: promosData } = usePromocoesVigentes();
|
||||
const { data: regrasData } = useRegrasVigentes();
|
||||
const [expandido, setExpandido] = useState(false);
|
||||
|
||||
const promocoes = promosData?.promocoes ?? [];
|
||||
const regras = regrasData?.regras ?? [];
|
||||
const total = promocoes.length + regras.length;
|
||||
|
||||
if (total === 0) return null;
|
||||
|
||||
return (
|
||||
<Card
|
||||
styles={{ body: expandido ? {} : { display: 'none' } }}
|
||||
title={
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setExpandido((v) => !v)}
|
||||
>
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faTags} style={{ color: 'var(--orange)' }} />
|
||||
Condições Especiais
|
||||
<Tag color="orange" style={{ borderRadius: 20, fontWeight: 700 }}>
|
||||
{total}
|
||||
</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', fontWeight: 400 }}>
|
||||
vigentes hoje
|
||||
</Text>
|
||||
</Space>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
aria-label={expandido ? 'Recolher' : 'Expandir'}
|
||||
icon={
|
||||
<FontAwesomeIcon
|
||||
icon={faChevronDown}
|
||||
style={{
|
||||
transition: 'transform 0.2s',
|
||||
transform: expandido ? 'rotate(180deg)' : undefined,
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
{promocoes.map((p: PromocaoVigente, i: number) => (
|
||||
<div key={`p-${p.id}`}>
|
||||
{i > 0 && <Divider style={{ margin: '0 0 12px' }} />}
|
||||
<LinhaCondicao
|
||||
tipo="promocao"
|
||||
descricao={p.descricao}
|
||||
alvo={alvoPromocao(p)}
|
||||
descPct={p.descPct}
|
||||
dataFim={p.dataFim}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{regras.map((r: RegraVigente, i: number) => (
|
||||
<div key={`r-${r.id}`}>
|
||||
{(i > 0 || promocoes.length > 0) && <Divider style={{ margin: '0 0 12px' }} />}
|
||||
<LinhaCondicao
|
||||
tipo="regra"
|
||||
descricao={r.descricao}
|
||||
alvo={alvoRegra(r)}
|
||||
descPct={r.descPct}
|
||||
dataFim={r.dataFim}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
552
apps/web/src/cockpits/rep/FunilPage.tsx
Normal file
552
apps/web/src/cockpits/rep/FunilPage.tsx
Normal file
@@ -0,0 +1,552 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Collapse,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Row,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
ArrowRightOutlined,
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
PlusOutlined,
|
||||
TrophyOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type {
|
||||
Oportunidade,
|
||||
EtapaFunil,
|
||||
CreateOportunidadeDto,
|
||||
UpdateOportunidadeDto,
|
||||
} from '@sar/api-interface';
|
||||
import { ETAPAS_FUNIL } from '@sar/api-interface';
|
||||
import {
|
||||
useFunil,
|
||||
useCreateOportunidade,
|
||||
useUpdateOportunidade,
|
||||
useDeleteOportunidade,
|
||||
} from '../../lib/queries/funil';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const ETAPA_CONFIG: Record<EtapaFunil, { label: string; cor: string; corBg: string }> = {
|
||||
lead: { label: 'Lead', cor: '#64748b', corBg: '#F8FAFC' },
|
||||
proposta: { label: 'Proposta', cor: '#2563EB', corBg: '#EFF6FF' },
|
||||
negociacao: { label: 'Negociação', cor: '#D97706', corBg: '#FFFBEB' },
|
||||
ganho: { label: 'Ganho', cor: '#16A34A', corBg: '#F0FDF4' },
|
||||
perdido: { label: 'Perdido', cor: '#DC2626', corBg: '#FEF2F2' },
|
||||
};
|
||||
|
||||
const PROXIMA: Partial<Record<EtapaFunil, EtapaFunil>> = {
|
||||
lead: 'proposta',
|
||||
proposta: 'negociacao',
|
||||
negociacao: 'ganho',
|
||||
};
|
||||
|
||||
const ANTERIOR: Partial<Record<EtapaFunil, EtapaFunil>> = {
|
||||
proposta: 'lead',
|
||||
negociacao: 'proposta',
|
||||
ganho: 'negociacao',
|
||||
};
|
||||
|
||||
function fmt(v: string | null | undefined): string {
|
||||
if (!v) return '';
|
||||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function diasDesde(iso: string): number {
|
||||
return Math.floor((Date.now() - new Date(iso).getTime()) / 86_400_000);
|
||||
}
|
||||
|
||||
function nomePrincipal(op: Oportunidade): string {
|
||||
return op.nomeCliente ?? op.nomeProspect ?? op.empresaProspect ?? `Oport. #${op.id}`;
|
||||
}
|
||||
|
||||
// ─── Card ────────────────────────────────────────────────────────────────────
|
||||
|
||||
function OportCard({
|
||||
op,
|
||||
onEdit,
|
||||
onMover,
|
||||
onDeletar,
|
||||
}: {
|
||||
op: Oportunidade;
|
||||
onEdit: () => void;
|
||||
onMover: (etapa: EtapaFunil) => void;
|
||||
onDeletar: () => void;
|
||||
}) {
|
||||
const dias = diasDesde(op.updatedAt);
|
||||
const prox = PROXIMA[op.etapa as EtapaFunil];
|
||||
const ant = ANTERIOR[op.etapa as EtapaFunil];
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
style={{ marginBottom: 10, borderRadius: 8, border: '1px solid #E2E8F0' }}
|
||||
styles={{ body: { padding: '10px 12px' } }}
|
||||
>
|
||||
<Flex justify="space-between" align="flex-start" gap={8}>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text strong style={{ fontSize: 13, color: '#1e293b', display: 'block' }} ellipsis>
|
||||
{nomePrincipal(op)}
|
||||
</Text>
|
||||
{op.titulo && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }} ellipsis>
|
||||
{op.titulo}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Space size={2}>
|
||||
<Tooltip title="Editar">
|
||||
<Button size="small" type="text" icon={<EditOutlined />} onClick={onEdit} />
|
||||
</Tooltip>
|
||||
<Tooltip title="Excluir">
|
||||
<Button
|
||||
size="small"
|
||||
type="text"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: 'Excluir oportunidade?',
|
||||
content: 'Esta ação não pode ser desfeita.',
|
||||
okText: 'Excluir',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: onDeletar,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
</Flex>
|
||||
|
||||
<Flex justify="space-between" align="center" style={{ marginTop: 6 }}>
|
||||
<Text style={{ fontSize: 12, color: '#003B8E', fontWeight: 600 }}>
|
||||
{op.valorEstimado ? fmt(op.valorEstimado) : '—'}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{dias === 0 ? 'hoje' : `${dias}d`}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<Flex gap={4} style={{ marginTop: 8 }}>
|
||||
{ant && (
|
||||
<Button
|
||||
size="small"
|
||||
style={{ fontSize: 11, padding: '0 6px', color: '#64748b', borderColor: '#CBD5E1' }}
|
||||
onClick={() => onMover(ant)}
|
||||
>
|
||||
← {ETAPA_CONFIG[ant].label}
|
||||
</Button>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
{op.etapa !== 'perdido' && op.etapa !== 'ganho' && (
|
||||
<Tooltip title="Marcar como perdido">
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
ghost
|
||||
style={{ fontSize: 11, padding: '0 6px' }}
|
||||
onClick={() => onMover('perdido')}
|
||||
>
|
||||
Perdido
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
{prox && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
style={{
|
||||
fontSize: 11,
|
||||
padding: '0 6px',
|
||||
background: ETAPA_CONFIG[prox].cor,
|
||||
borderColor: ETAPA_CONFIG[prox].cor,
|
||||
}}
|
||||
onClick={() => onMover(prox)}
|
||||
>
|
||||
{ETAPA_CONFIG[prox].label} <ArrowRightOutlined />
|
||||
</Button>
|
||||
)}
|
||||
{op.etapa === 'ganho' && (
|
||||
<Tag color="green" style={{ fontSize: 11, margin: 0 }}>
|
||||
<TrophyOutlined /> Ganho
|
||||
</Tag>
|
||||
)}
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Modal criar/editar ───────────────────────────────────────────────────────
|
||||
|
||||
type FormValues = {
|
||||
titulo: string;
|
||||
etapa: EtapaFunil;
|
||||
nomeProspect?: string;
|
||||
empresaProspect?: string;
|
||||
valorEstimado?: number;
|
||||
observacoes?: string;
|
||||
};
|
||||
|
||||
function OportModal({
|
||||
open,
|
||||
initial,
|
||||
etapaInicial,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
initial?: Oportunidade | null;
|
||||
etapaInicial: EtapaFunil;
|
||||
onClose: () => void;
|
||||
onSave: (dto: CreateOportunidadeDto | UpdateOportunidadeDto) => Promise<void>;
|
||||
}) {
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleOk = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave({
|
||||
titulo: values.titulo,
|
||||
etapa: values.etapa,
|
||||
nomeProspect: values.nomeProspect ?? null,
|
||||
empresaProspect: values.empresaProspect ?? null,
|
||||
valorEstimado: values.valorEstimado ?? null,
|
||||
observacoes: values.observacoes ?? null,
|
||||
});
|
||||
onClose();
|
||||
} catch {
|
||||
// erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
title={initial ? 'Editar Oportunidade' : 'Nova Oportunidade'}
|
||||
width={520}
|
||||
centered
|
||||
onCancel={onClose}
|
||||
onOk={handleOk}
|
||||
okText={initial ? 'Salvar' : 'Criar'}
|
||||
confirmLoading={saving}
|
||||
afterOpenChange={(vis) => {
|
||||
if (vis) {
|
||||
form.setFieldsValue(
|
||||
initial
|
||||
? {
|
||||
titulo: initial.titulo,
|
||||
etapa: initial.etapa as EtapaFunil,
|
||||
nomeProspect: initial.nomeProspect ?? undefined,
|
||||
empresaProspect: initial.empresaProspect ?? undefined,
|
||||
valorEstimado: initial.valorEstimado ? Number(initial.valorEstimado) : undefined,
|
||||
observacoes: initial.observacoes ?? undefined,
|
||||
}
|
||||
: { etapa: etapaInicial },
|
||||
);
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 8 }}>
|
||||
<Form.Item
|
||||
name="titulo"
|
||||
label="Título / produto de interesse"
|
||||
rules={[{ required: true, message: 'Informe o título' }]}
|
||||
>
|
||||
<Input placeholder="Ex: Reposição de estoque Q3" />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="nomeProspect" label="Nome do contato">
|
||||
<Input placeholder="João Silva" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="empresaProspect" label="Empresa">
|
||||
<Input placeholder="Empresa XYZ" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Row gutter={12}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="valorEstimado" label="Valor estimado (R$)">
|
||||
<InputNumber
|
||||
style={{ width: '100%' }}
|
||||
min={0}
|
||||
precision={2}
|
||||
formatter={(v) => String(v).replace(/\B(?=(\d{3})+(?!\d))/g, '.')}
|
||||
parser={(v) => Number((v ?? '').replace(/\./g, '').replace(',', '.'))}
|
||||
placeholder="0,00"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="etapa" label="Estágio">
|
||||
<Select
|
||||
options={ETAPAS_FUNIL.map((e) => ({
|
||||
value: e,
|
||||
label: ETAPA_CONFIG[e].label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
<Form.Item name="observacoes" label="Observações">
|
||||
<Input.TextArea rows={3} placeholder="Anotações sobre o lead..." />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Coluna kanban ────────────────────────────────────────────────────────────
|
||||
|
||||
function KanbanColuna({
|
||||
etapa,
|
||||
ops,
|
||||
onEdit,
|
||||
onMover,
|
||||
onDeletar,
|
||||
onNovo,
|
||||
}: {
|
||||
etapa: EtapaFunil;
|
||||
ops: Oportunidade[];
|
||||
onEdit: (op: Oportunidade) => void;
|
||||
onMover: (id: number, etapa: EtapaFunil) => void;
|
||||
onDeletar: (id: number) => void;
|
||||
onNovo: (etapa: EtapaFunil) => void;
|
||||
}) {
|
||||
const cfg = ETAPA_CONFIG[etapa];
|
||||
const total = ops.reduce((s, o) => s + (o.valorEstimado ? Number(o.valorEstimado) : 0), 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
minWidth: 240,
|
||||
flex: 1,
|
||||
background: cfg.corBg,
|
||||
borderRadius: 10,
|
||||
border: `1px solid ${cfg.cor}33`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 14px 8px',
|
||||
borderBottom: `2px solid ${cfg.cor}`,
|
||||
borderRadius: '10px 10px 0 0',
|
||||
}}
|
||||
>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Space size={6}>
|
||||
<Text strong style={{ color: cfg.cor, fontSize: 13 }}>
|
||||
{cfg.label}
|
||||
</Text>
|
||||
<Badge count={ops.length} color={cfg.cor} style={{ fontSize: 11 }} />
|
||||
</Space>
|
||||
{total > 0 && (
|
||||
<Text style={{ fontSize: 11, color: cfg.cor, fontWeight: 600 }}>
|
||||
{total.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}
|
||||
</Text>
|
||||
)}
|
||||
</Flex>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '10px 10px 4px',
|
||||
flex: 1,
|
||||
overflowY: 'auto',
|
||||
maxHeight: 'calc(100vh - 260px)',
|
||||
}}
|
||||
>
|
||||
{ops.map((op) => (
|
||||
<OportCard
|
||||
key={op.id}
|
||||
op={op}
|
||||
onEdit={() => onEdit(op)}
|
||||
onMover={(e) => onMover(op.id, e)}
|
||||
onDeletar={() => onDeletar(op.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div style={{ padding: '4px 10px 10px' }}>
|
||||
<Button
|
||||
block
|
||||
type="dashed"
|
||||
icon={<PlusOutlined />}
|
||||
style={{ borderColor: cfg.cor, color: cfg.cor, fontSize: 12 }}
|
||||
onClick={() => onNovo(etapa)}
|
||||
>
|
||||
Adicionar
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── FunilPage ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function FunilPage() {
|
||||
const { data, isLoading, isError, error } = useFunil();
|
||||
const criar = useCreateOportunidade();
|
||||
const atualizar = useUpdateOportunidade();
|
||||
const deletar = useDeleteOportunidade();
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editOp, setEditOp] = useState<Oportunidade | null>(null);
|
||||
const [etapaModal, setEtapaModal] = useState<EtapaFunil>('lead');
|
||||
|
||||
const abrirNovo = (etapa: EtapaFunil) => {
|
||||
setEditOp(null);
|
||||
setEtapaModal(etapa);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const abrirEditar = (op: Oportunidade) => {
|
||||
setEditOp(op);
|
||||
setEtapaModal(op.etapa as EtapaFunil);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async (dto: CreateOportunidadeDto | UpdateOportunidadeDto) => {
|
||||
if (editOp) {
|
||||
await atualizar.mutateAsync({ id: editOp.id, dto });
|
||||
} else {
|
||||
await criar.mutateAsync(dto as CreateOportunidadeDto);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMover = (id: number, etapa: EtapaFunil) => {
|
||||
atualizar.mutate({ id, dto: { etapa } });
|
||||
};
|
||||
|
||||
const handleDeletar = (id: number) => {
|
||||
deletar.mutate(id);
|
||||
};
|
||||
|
||||
const etapasPrincipais: EtapaFunil[] = ['lead', 'proposta', 'negociacao', 'ganho'];
|
||||
const perdidos = data?.perdido ?? [];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
height: 'calc(100vh - var(--layout-topbar-height) - 48px)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Flex align="center" justify="space-between" style={{ marginBottom: 16, flexShrink: 0 }}>
|
||||
<div>
|
||||
<Title level={4} style={{ margin: 0, color: '#003B8E' }}>
|
||||
Funil de Vendas
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 13 }}>
|
||||
Acompanhe seus leads até o fechamento do pedido
|
||||
</Text>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => abrirNovo('lead')}
|
||||
style={{ background: '#003B8E' }}
|
||||
>
|
||||
Novo Lead
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
{isError && (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Erro ao carregar funil"
|
||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||
style={{ marginBottom: 16, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton active paragraph={{ rows: 8 }} />
|
||||
) : (
|
||||
<>
|
||||
<div style={{ display: 'flex', gap: 12, flex: 1, overflowX: 'auto', paddingBottom: 8 }}>
|
||||
{etapasPrincipais.map((etapa) => (
|
||||
<KanbanColuna
|
||||
key={etapa}
|
||||
etapa={etapa}
|
||||
ops={data?.[etapa] ?? []}
|
||||
onEdit={abrirEditar}
|
||||
onMover={handleMover}
|
||||
onDeletar={handleDeletar}
|
||||
onNovo={abrirNovo}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{perdidos.length > 0 && (
|
||||
<div style={{ marginTop: 12, flexShrink: 0 }}>
|
||||
<Collapse
|
||||
ghost
|
||||
size="small"
|
||||
items={[
|
||||
{
|
||||
key: 'perdidos',
|
||||
label: (
|
||||
<Text style={{ color: '#DC2626', fontSize: 13 }}>
|
||||
Perdidos ({perdidos.length})
|
||||
</Text>
|
||||
),
|
||||
children: (
|
||||
<Row gutter={[10, 10]}>
|
||||
{perdidos.map((op) => (
|
||||
<Col key={op.id} xs={24} sm={12} md={8} lg={6}>
|
||||
<OportCard
|
||||
op={op}
|
||||
onEdit={() => abrirEditar(op)}
|
||||
onMover={(e) => handleMover(op.id, e)}
|
||||
onDeletar={() => handleDeletar(op.id)}
|
||||
/>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<OportModal
|
||||
open={modalOpen}
|
||||
initial={editOp}
|
||||
etapaInicial={etapaModal}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -129,7 +129,12 @@ export function NewClientPage() {
|
||||
codPauta: values.codPauta ? Number(values.codPauta) : undefined,
|
||||
};
|
||||
|
||||
const result = await createMutation.mutateAsync(payload);
|
||||
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||
try {
|
||||
result = await createMutation.mutateAsync(payload);
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient
|
||||
}
|
||||
|
||||
if (!result.sincronizado && result.erroSync) {
|
||||
setSyncError(result.erroSync);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,21 +6,18 @@ import {
|
||||
Button,
|
||||
Descriptions,
|
||||
Divider,
|
||||
Form,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Timeline,
|
||||
Typography,
|
||||
Input,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faShareNodes } from '@fortawesome/free-solid-svg-icons';
|
||||
import { FilePdfOutlined } from '@ant-design/icons';
|
||||
import { FilePdfOutlined, CustomerServiceOutlined } from '@ant-design/icons';
|
||||
import { AbrirChamadoModal } from './AbrirChamadoModal';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { Link, useParams, useNavigate } from '@tanstack/react-router';
|
||||
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
|
||||
@@ -31,7 +28,6 @@ import { apiFetch } from '../../lib/api-client';
|
||||
import { authStore } from '../../lib/auth-store';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { TextArea } = Input;
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -118,7 +114,7 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
|
||||
: SITUA_COLOR[h.situaNova] === 'error'
|
||||
? 'red'
|
||||
: 'blue',
|
||||
children: (
|
||||
content: (
|
||||
<div>
|
||||
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
|
||||
{h.situaAnterior != null && (
|
||||
@@ -143,105 +139,6 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Approve Modal ────────────────────────────────────────────────────────────
|
||||
|
||||
function ApproveModal({
|
||||
open,
|
||||
originalDiscount,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading,
|
||||
}: {
|
||||
open: boolean;
|
||||
originalDiscount: string;
|
||||
onConfirm: (descontoPerc?: number, nota?: string) => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [disc, setDisc] = useState<number | null>(null);
|
||||
const [nota, setNota] = useState('');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Aprovar Pedido"
|
||||
open={open}
|
||||
onOk={() => onConfirm(disc ?? undefined, nota || undefined)}
|
||||
onCancel={onCancel}
|
||||
okText="Confirmar Aprovação"
|
||||
cancelText="Voltar"
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item
|
||||
label={`Desconto global (original: ${originalDiscount}%)`}
|
||||
help="Deixe em branco para manter o desconto solicitado."
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={100}
|
||||
step={0.5}
|
||||
placeholder={originalDiscount}
|
||||
value={disc}
|
||||
onChange={(v) => setDisc(v)}
|
||||
addonAfter="%"
|
||||
style={{ width: 160 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="Observação (opcional)">
|
||||
<TextArea
|
||||
rows={2}
|
||||
value={nota}
|
||||
onChange={(e) => setNota(e.target.value)}
|
||||
maxLength={300}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Reject Modal ─────────────────────────────────────────────────────────────
|
||||
|
||||
function RejectModal({
|
||||
open,
|
||||
onConfirm,
|
||||
onCancel,
|
||||
loading,
|
||||
}: {
|
||||
open: boolean;
|
||||
onConfirm: (motivo: string) => void;
|
||||
onCancel: () => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const [motivo, setMotivo] = useState('');
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Recusar Pedido"
|
||||
open={open}
|
||||
onOk={() => motivo.trim() && onConfirm(motivo.trim())}
|
||||
onCancel={onCancel}
|
||||
okText="Confirmar Recusa"
|
||||
okButtonProps={{ danger: true, disabled: !motivo.trim() }}
|
||||
cancelText="Voltar"
|
||||
confirmLoading={loading}
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="Motivo da recusa" required>
|
||||
<TextArea
|
||||
rows={3}
|
||||
value={motivo}
|
||||
onChange={(e) => setMotivo(e.target.value)}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder="Informe o motivo para o representante..."
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── OrderDetailPage ──────────────────────────────────────────────────────────
|
||||
|
||||
export function OrderDetailPage() {
|
||||
@@ -253,7 +150,6 @@ export function OrderDetailPage() {
|
||||
|
||||
const role = getRoleFromToken();
|
||||
const isErp = order?.fonte === 'erp';
|
||||
const canAct = !isErp && role !== 'rep' && order?.situa === 1;
|
||||
const canTransmit = !isErp && role === 'rep' && order?.situa === 0;
|
||||
const canShare =
|
||||
role === 'rep' &&
|
||||
@@ -261,38 +157,9 @@ export function OrderDetailPage() {
|
||||
typeof navigator !== 'undefined' &&
|
||||
!!navigator.share;
|
||||
|
||||
const [approveOpen, setApproveOpen] = useState(false);
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [chamadoOpen, setChamadoOpen] = useState(false);
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const approveMutation = useMutation({
|
||||
mutationFn: ({ descontoPerc, nota }: { descontoPerc?: number; nota?: string }) =>
|
||||
apiFetch(`/orders/${id}/approve`, { method: 'PATCH', body: { descontoPerc, nota } }),
|
||||
onSuccess: () => {
|
||||
setApproveOpen(false);
|
||||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
setApproveOpen(false);
|
||||
setActionError(e instanceof Error ? e.message : 'Erro ao aprovar');
|
||||
},
|
||||
});
|
||||
|
||||
const rejectMutation = useMutation({
|
||||
mutationFn: (motivo: string) =>
|
||||
apiFetch(`/orders/${id}/reject`, { method: 'PATCH', body: { motivo } }),
|
||||
onSuccess: () => {
|
||||
setRejectOpen(false);
|
||||
void qc.invalidateQueries({ queryKey: ['orders', id] });
|
||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
onError: (e: unknown) => {
|
||||
setRejectOpen(false);
|
||||
setActionError(e instanceof Error ? e.message : 'Erro ao recusar');
|
||||
},
|
||||
});
|
||||
|
||||
const transmitMutation = useMutation({
|
||||
mutationFn: () => apiFetch(`/orders/${id}/transmit`, { method: 'PATCH' }),
|
||||
onSuccess: () => {
|
||||
@@ -307,11 +174,6 @@ export function OrderDetailPage() {
|
||||
if (error || !order)
|
||||
return <Alert type="error" message="Pedido não encontrado." style={{ margin: 24 }} />;
|
||||
|
||||
const timeWaiting =
|
||||
order.situa === 1
|
||||
? Math.floor((Date.now() - new Date(order.createdAt).getTime()) / 3_600_000)
|
||||
: null;
|
||||
|
||||
// Orçamento: tela mais larga para consulta/revisão com o cliente.
|
||||
const isOrcamento = order.situa === 0;
|
||||
|
||||
@@ -337,9 +199,6 @@ export function OrderDetailPage() {
|
||||
</Tag>
|
||||
}
|
||||
/>
|
||||
{timeWaiting !== null && timeWaiting > 2 && (
|
||||
<Tag color="red">Urgente — {timeWaiting}h aguardando</Tag>
|
||||
)}
|
||||
<Button
|
||||
icon={<FilePdfOutlined />}
|
||||
onClick={() => navigate({ to: '/pedidos/$id/imprimir', params: { id } })}
|
||||
@@ -359,16 +218,6 @@ export function OrderDetailPage() {
|
||||
Transmitir pedido
|
||||
</Button>
|
||||
)}
|
||||
{canAct && (
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => setApproveOpen(true)}>
|
||||
Aprovar
|
||||
</Button>
|
||||
<Button danger onClick={() => setRejectOpen(true)}>
|
||||
Recusar
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
{canShare && (
|
||||
<Button
|
||||
icon={<FontAwesomeIcon icon={faShareNodes} />}
|
||||
@@ -383,6 +232,11 @@ export function OrderDetailPage() {
|
||||
Compartilhar
|
||||
</Button>
|
||||
)}
|
||||
{role === 'rep' && !isErp && (order.situa === 2 || order.situa === 4) && (
|
||||
<Button icon={<CustomerServiceOutlined />} onClick={() => setChamadoOpen(true)}>
|
||||
Abrir Chamado
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{actionError && (
|
||||
@@ -413,11 +267,6 @@ export function OrderDetailPage() {
|
||||
<Descriptions.Item label="Data">
|
||||
{new Date(order.dtPedido).toLocaleDateString('pt-BR')}
|
||||
</Descriptions.Item>
|
||||
{order.aprovadoEm && (
|
||||
<Descriptions.Item label="Aprovado em">
|
||||
{new Date(order.aprovadoEm).toLocaleString('pt-BR')} — cód. {order.aprovadoPor}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{order.formaPagamento && (
|
||||
<Descriptions.Item label="Cond. Pagamento" span={2}>
|
||||
{order.formaPagamento}
|
||||
@@ -425,24 +274,29 @@ export function OrderDetailPage() {
|
||||
)}
|
||||
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
|
||||
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</Descriptions.Item>
|
||||
<Descriptions.Item label="Total">
|
||||
<Descriptions.Item label="Total" span={isOrcamento ? 1 : 2}>
|
||||
<Text strong style={{ fontSize: 16 }}>
|
||||
{fmt(order.total)}
|
||||
</Text>
|
||||
</Descriptions.Item>
|
||||
{order.endEntrega && (
|
||||
<Descriptions.Item label="End. Entrega" span={isOrcamento ? 3 : 2}>
|
||||
{order.endEntrega}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{order.obs && (
|
||||
<Descriptions.Item label="Observações" span={2}>
|
||||
<Descriptions.Item label="Observações" span={isOrcamento ? 3 : 2}>
|
||||
{order.obs}
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
{order.motivoRecusa && (
|
||||
<Descriptions.Item label="Motivo Recusa" span={2}>
|
||||
<Descriptions.Item label="Motivo Recusa" span={isOrcamento ? 3 : 2}>
|
||||
<Text type="danger">{order.motivoRecusa}</Text>
|
||||
</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
<Divider orientation="left">Itens ({order.itens.length})</Divider>
|
||||
<Divider titlePlacement="left">Itens ({order.itens.length})</Divider>
|
||||
<Table<PedidoItem>
|
||||
rowKey="id"
|
||||
columns={itemColumns}
|
||||
@@ -454,7 +308,7 @@ export function OrderDetailPage() {
|
||||
|
||||
{clientOrders && clientOrders.length > 0 && (
|
||||
<>
|
||||
<Divider orientation="left">Outros Pedidos do Cliente</Divider>
|
||||
<Divider titlePlacement="left">Outros Pedidos do Cliente</Divider>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
@@ -496,22 +350,20 @@ export function OrderDetailPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Divider orientation="left">Histórico do Pedido</Divider>
|
||||
<Divider titlePlacement="left">Histórico do Pedido</Divider>
|
||||
<HistoryTimeline history={order.historico} />
|
||||
|
||||
<ApproveModal
|
||||
open={approveOpen}
|
||||
originalDiscount={order.descontoPerc}
|
||||
onConfirm={(descontoPerc, nota) => approveMutation.mutate({ descontoPerc, nota })}
|
||||
onCancel={() => setApproveOpen(false)}
|
||||
loading={approveMutation.isPending}
|
||||
/>
|
||||
<RejectModal
|
||||
open={rejectOpen}
|
||||
onConfirm={(motivo) => rejectMutation.mutate(motivo)}
|
||||
onCancel={() => setRejectOpen(false)}
|
||||
loading={rejectMutation.isPending}
|
||||
/>
|
||||
{order && (
|
||||
<AbrirChamadoModal
|
||||
open={chamadoOpen}
|
||||
onClose={() => setChamadoOpen(false)}
|
||||
idPedido={order.id}
|
||||
numPedSar={parseInt(order.numPedSar.replace(/\D/g, ''), 10) || undefined}
|
||||
nomeCliente={order.razaoCliente ?? order.nomeCliente}
|
||||
idCliente={order.idCliente}
|
||||
itens={order.itens}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -145,60 +145,71 @@ export function OrderPrintPage() {
|
||||
color: INK,
|
||||
}}
|
||||
>
|
||||
{/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */}
|
||||
{/* ── Cabeçalho compacto: logo + empresa matriz + nº do pedido ────── */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
padding: '22px 28px',
|
||||
borderTop: `5px solid ${BLUE}`,
|
||||
background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)',
|
||||
borderBottom: `1px solid ${LINE}`,
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
padding: '12px 28px',
|
||||
borderTop: `4px solid ${BLUE}`,
|
||||
borderBottom: `2px solid ${BLUE}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ maxWidth: 460 }}>
|
||||
<div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}>
|
||||
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div>
|
||||
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}>
|
||||
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
|
||||
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
|
||||
{enderecoEmp && <div>{enderecoEmp}</div>}
|
||||
{cidadeEmp && <div>{cidadeEmp}</div>}
|
||||
{(empresa?.telefone || empresa?.email) && (
|
||||
<div>
|
||||
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
|
||||
{empresa?.telefone && empresa?.email && <> · </>}
|
||||
{empresa?.email}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
|
||||
{empresa?.logoBase64 && (
|
||||
<img
|
||||
src={empresa.logoBase64}
|
||||
alt="Logo"
|
||||
style={{ maxHeight: 52, maxWidth: 140, objectFit: 'contain', flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 15, fontWeight: 800, color: BLUE, lineHeight: 1.15 }}>
|
||||
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
|
||||
</div>
|
||||
<div style={{ fontSize: 8.5, color: MUTED, marginTop: 2, lineHeight: 1.45 }}>
|
||||
{empresa?.razaoSocial && empresa.razaoSocial !== empresa.nomeFantasia && (
|
||||
<>{empresa.razaoSocial} · </>
|
||||
)}
|
||||
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
|
||||
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
|
||||
<br />
|
||||
{[enderecoEmp, cidadeEmp].filter(Boolean).join(' · ')}
|
||||
{(empresa?.telefone || empresa?.email) && (
|
||||
<>
|
||||
<br />
|
||||
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
|
||||
{empresa?.telefone && empresa?.email && <> · </>}
|
||||
{empresa?.email}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div style={{ fontSize: 10, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
|
||||
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||
<div style={{ fontSize: 9, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
|
||||
PEDIDO
|
||||
</div>
|
||||
<div style={{ fontSize: 22, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
|
||||
<div style={{ fontSize: 19, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
|
||||
{order.numPedSar}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
marginTop: 6,
|
||||
padding: '2px 10px',
|
||||
borderRadius: 20,
|
||||
background: `${BLUE}12`,
|
||||
color: BLUE,
|
||||
fontSize: 10.5,
|
||||
fontWeight: 700,
|
||||
}}
|
||||
>
|
||||
{SITUA_LABEL[order.situa] ?? String(order.situa)}
|
||||
</div>
|
||||
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}>
|
||||
Emissão: {dateBR(order.dtPedido)}
|
||||
<div style={{ fontSize: 9.5, color: MUTED, marginTop: 3 }}>
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
padding: '1px 8px',
|
||||
borderRadius: 20,
|
||||
background: `${BLUE}12`,
|
||||
color: BLUE,
|
||||
fontWeight: 700,
|
||||
marginRight: 6,
|
||||
}}
|
||||
>
|
||||
{SITUA_LABEL[order.situa] ?? String(order.situa)}
|
||||
</span>
|
||||
{dateBR(order.dtPedido)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -370,7 +381,30 @@ export function OrderPrintPage() {
|
||||
</div>
|
||||
|
||||
{/* ── Totais ──────────────────────────────────────────────────────── */}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '14px 28px 4px' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-end',
|
||||
gap: 16,
|
||||
padding: '14px 28px 4px',
|
||||
}}
|
||||
>
|
||||
{/* Peso e cubagem do pedido (logística/frete) */}
|
||||
<div style={{ fontSize: 10.5, color: MUTED, lineHeight: 1.8 }}>
|
||||
{Number(order.pesoTotal ?? 0) > 0 && (
|
||||
<div>
|
||||
<strong style={{ color: INK }}>Peso total:</strong>{' '}
|
||||
{Number(order.pesoTotal).toLocaleString('pt-BR', { maximumFractionDigits: 2 })} kg
|
||||
</div>
|
||||
)}
|
||||
{Number(order.m3Total ?? 0) > 0 && (
|
||||
<div>
|
||||
<strong style={{ color: INK }}>Volume:</strong>{' '}
|
||||
{Number(order.m3Total).toLocaleString('pt-BR', { maximumFractionDigits: 3 })} m³
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ width: 300 }}>
|
||||
<TotRow k="Total dos produtos" v={money(order.totalProdutos)} />
|
||||
{Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />}
|
||||
@@ -399,11 +433,23 @@ export function OrderPrintPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Observações + rodapé ────────────────────────────────────────── */}
|
||||
{order.obs && (
|
||||
<div style={{ padding: '10px 28px 0' }}>
|
||||
<span style={label}>Observações</span>
|
||||
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div>
|
||||
{/* ── Endereço de entrega + Observações ───────────────────────────── */}
|
||||
{(order.endEntrega || order.obs) && (
|
||||
<div style={{ padding: '10px 28px 0', display: 'flex', gap: 24, flexWrap: 'wrap' }}>
|
||||
{order.endEntrega && (
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<span style={label}>Endereço de entrega</span>
|
||||
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>
|
||||
{order.endEntrega}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{order.obs && (
|
||||
<div style={{ flex: 1, minWidth: 220 }}>
|
||||
<span style={label}>Observações</span>
|
||||
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
|
||||
@@ -116,7 +116,7 @@ function ErpConsultaModal({
|
||||
footer={<Button onClick={onClose}>Fechar</Button>}
|
||||
>
|
||||
{!row ? null : (
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||
{/* ── Cabeçalho ── */}
|
||||
<Card
|
||||
styles={{ body: { padding: '14px 16px' } }}
|
||||
@@ -257,7 +257,7 @@ function ErpConsultaModal({
|
||||
title: 'Produto',
|
||||
key: 'produto',
|
||||
render: (_: unknown, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
|
||||
<Text style={{ fontWeight: 500, fontSize: 13 }}>{item.descProduto}</Text>
|
||||
</Space>
|
||||
@@ -276,7 +276,7 @@ function ErpConsultaModal({
|
||||
width: 120,
|
||||
align: 'right' as const,
|
||||
render: (v: string, item) => (
|
||||
<Space direction="vertical" size={0} style={{ alignItems: 'flex-end' }}>
|
||||
<Space orientation="vertical" size={0} style={{ alignItems: 'flex-end' }}>
|
||||
<Text className="tabular-nums">{fmt(Number(v))}</Text>
|
||||
{Number(item.descontoPerc) > 0 && (
|
||||
<Text style={{ fontSize: 11, color: '#f59e0b' }}>
|
||||
@@ -364,7 +364,7 @@ export function OrdersErpPage() {
|
||||
key: 'pedido',
|
||||
width: 110,
|
||||
render: (_: unknown, row: PedidoErpConsultaItem) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||
{row.numeroPedido}
|
||||
</Text>
|
||||
@@ -446,7 +446,7 @@ export function OrdersErpPage() {
|
||||
render: (_: unknown, row: PedidoErpConsultaItem) => {
|
||||
if (row.tipo !== 'E') return <Text type="secondary">—</Text>;
|
||||
return (
|
||||
<Space direction="vertical" size={2}>
|
||||
<Space orientation="vertical" size={2}>
|
||||
{row.numeroEntrega && (
|
||||
<Text className="tabular-nums" style={{ fontSize: 12, color: '#475569' }}>
|
||||
Entrega {row.numeroEntrega}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Dropdown,
|
||||
Grid,
|
||||
@@ -21,14 +24,13 @@ import {
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import type { MenuProps } from 'antd';
|
||||
import type { Dayjs } from 'dayjs';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
import {
|
||||
CheckCircleOutlined,
|
||||
ClockCircleOutlined,
|
||||
CloseCircleOutlined,
|
||||
CopyOutlined,
|
||||
DollarOutlined,
|
||||
EditOutlined,
|
||||
EllipsisOutlined,
|
||||
EyeOutlined,
|
||||
FilePdfOutlined,
|
||||
@@ -43,6 +45,9 @@ import { SITUA_LABEL } from '@sar/api-interface';
|
||||
import { useOrderList, useOrderDetail } from '../../lib/queries/orders';
|
||||
import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
|
||||
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
|
||||
import { apiFetch } from '../../lib/api-client';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { useBreakpoint } = Grid;
|
||||
@@ -137,13 +142,11 @@ const STATUS: Record<number, { label: string; color: string; rowBg: string; tagC
|
||||
|
||||
function useOrderStats() {
|
||||
const all = useOrderList({ limit: 1 });
|
||||
const pendentes = useOrderList({ limit: 1, situa: 1 });
|
||||
const aprovados = useOrderList({ limit: 1, situa: 2 });
|
||||
const transmitidos = useOrderList({ limit: 1, situa: 2 });
|
||||
const faturados = useOrderList({ limit: 1, situa: 4 });
|
||||
return {
|
||||
total: all.data?.total ?? 0,
|
||||
pendentes: pendentes.data?.total ?? 0,
|
||||
aprovados: aprovados.data?.total ?? 0,
|
||||
transmitidos: transmitidos.data?.total ?? 0,
|
||||
faturados: faturados.data?.total ?? 0,
|
||||
loaded: !!all.data,
|
||||
};
|
||||
@@ -162,19 +165,18 @@ function OrdersMetrics({ stats }: { stats: OrderStats }) {
|
||||
color: '#003B8E',
|
||||
},
|
||||
{
|
||||
label: 'Ag. Aprovação',
|
||||
value: stats.pendentes,
|
||||
icon: <ClockCircleOutlined />,
|
||||
color: '#d46b08',
|
||||
label: 'Transmitidos',
|
||||
value: stats.transmitidos,
|
||||
icon: <CheckCircleOutlined />,
|
||||
color: '#389e0d',
|
||||
},
|
||||
{ label: 'Aprovados', value: stats.aprovados, icon: <CheckCircleOutlined />, color: '#389e0d' },
|
||||
{ label: 'Faturados', value: stats.faturados, icon: <DollarOutlined />, color: '#1d39c4' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||
{metrics.map((m) => (
|
||||
<Col key={m.label} xs={12} sm={6}>
|
||||
<Col key={m.label} xs={12} sm={8}>
|
||||
<Card
|
||||
style={{
|
||||
borderRadius: 10,
|
||||
@@ -242,6 +244,14 @@ function OrderStatusBadge({ situa, descr }: { situa: number; descr?: string }) {
|
||||
|
||||
// ─── OrderActionsMenu ─────────────────────────────────────────────────────────
|
||||
|
||||
const MOTIVOS_CANCELAMENTO = [
|
||||
'Cliente desistiu',
|
||||
'Preço',
|
||||
'Prazo de entrega',
|
||||
'Erro de lançamento',
|
||||
'Outro',
|
||||
];
|
||||
|
||||
function OrderActionsMenu({
|
||||
order,
|
||||
onView,
|
||||
@@ -250,6 +260,25 @@ function OrderActionsMenu({
|
||||
onView: (id: string) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const { message: msg } = App.useApp();
|
||||
const [cancelOpen, setCancelOpen] = useState(false);
|
||||
const [cancelForm] = Form.useForm<{ motivo: string; descricao?: string }>();
|
||||
|
||||
const cancelMutation = useMutation({
|
||||
mutationFn: (body: { motivo: string; descricao?: string }) =>
|
||||
apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH', body }),
|
||||
onSuccess: () => {
|
||||
void msg.success('Orçamento cancelado.');
|
||||
setCancelOpen(false);
|
||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||
},
|
||||
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'),
|
||||
});
|
||||
|
||||
// Orçamento (situa 0) pode ser editado e cancelado; transmitido, não.
|
||||
const canCancel = order.situa === 0 && order.fonte !== 'erp';
|
||||
const canEdit = canCancel;
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
@@ -258,6 +287,16 @@ function OrderActionsMenu({
|
||||
label: 'Ver detalhes',
|
||||
onClick: () => onView(order.id),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
icon: <EditOutlined />,
|
||||
label: 'Editar orçamento',
|
||||
disabled: !canEdit,
|
||||
onClick: () => {
|
||||
if (!canEdit) return;
|
||||
void navigate({ to: '/pedidos/novo', search: { editar: order.id } });
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'duplicate',
|
||||
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
|
||||
@@ -287,15 +326,62 @@ function OrderActionsMenu({
|
||||
icon: <CloseCircleOutlined />,
|
||||
label: 'Cancelar pedido',
|
||||
danger: true,
|
||||
disabled: order.situa === 3,
|
||||
onClick: () => alert('Cancelamento em breve'),
|
||||
disabled: !canCancel,
|
||||
onClick: () => {
|
||||
if (!canCancel) return;
|
||||
cancelForm.resetFields();
|
||||
setCancelOpen(true);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
|
||||
<Button type="text" icon={<EllipsisOutlined />} size="small" />
|
||||
</Dropdown>
|
||||
// stopPropagation: o clique nos "..." não pode acionar o clique da LINHA
|
||||
// (que abre o detalhe) — o menu abre sozinho e só a opção escolhida age.
|
||||
<span onClick={(e) => e.stopPropagation()}>
|
||||
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
|
||||
<Button type="text" icon={<EllipsisOutlined />} size="small" />
|
||||
</Dropdown>
|
||||
|
||||
<Modal
|
||||
title={`Cancelar orçamento ${order.numPedSar}?`}
|
||||
open={cancelOpen}
|
||||
onCancel={() => setCancelOpen(false)}
|
||||
okText="Cancelar pedido"
|
||||
okButtonProps={{ danger: true }}
|
||||
cancelText="Voltar"
|
||||
confirmLoading={cancelMutation.isPending}
|
||||
onOk={() =>
|
||||
void cancelForm.validateFields().then((values) => cancelMutation.mutate(values))
|
||||
}
|
||||
centered
|
||||
width={480}
|
||||
>
|
||||
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||
O pedido será marcado como cancelado. Esta ação não pode ser desfeita.
|
||||
</Typography.Text>
|
||||
<Form form={cancelForm} layout="vertical">
|
||||
<Form.Item
|
||||
name="motivo"
|
||||
label="Motivo do cancelamento"
|
||||
rules={[{ required: true, message: 'Informe o motivo' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="Selecione o motivo"
|
||||
options={MOTIVOS_CANCELAMENTO.map((m) => ({ value: m, label: m }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="descricao" label="Descrição (opcional)">
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
showCount
|
||||
placeholder="Detalhe o que aconteceu, se quiser"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -316,7 +402,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
|
||||
) : (
|
||||
<ClockCircleOutlined style={{ color: '#d46b08' }} />
|
||||
),
|
||||
children: (
|
||||
content: (
|
||||
<span style={{ fontSize: 13 }}>
|
||||
<Text type="secondary">{new Date(h.changedAt).toLocaleString('pt-BR')}</Text>
|
||||
{' — '}
|
||||
@@ -387,7 +473,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
|
||||
{isLoading && <Spin style={{ display: 'block', margin: '48px auto' }} />}
|
||||
|
||||
{data && (
|
||||
<Space direction="vertical" size={20} style={{ width: '100%' }}>
|
||||
<Space orientation="vertical" size={20} style={{ width: '100%' }}>
|
||||
{/* Status */}
|
||||
<div>
|
||||
<OrderStatusBadge situa={data.situa} descr={data.statusDescr} />
|
||||
@@ -434,6 +520,12 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
|
||||
<span style={label}>Representante</span>
|
||||
<Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text>
|
||||
</Col>
|
||||
{data.endEntrega && (
|
||||
<Col span={24}>
|
||||
<span style={label}>End. Entrega</span>
|
||||
<Text type="secondary">{data.endEntrega}</Text>
|
||||
</Col>
|
||||
)}
|
||||
{data.obs && (
|
||||
<Col span={24}>
|
||||
<span style={label}>Observações</span>
|
||||
@@ -460,7 +552,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
|
||||
title: 'Produto',
|
||||
key: 'produto',
|
||||
render: (_: unknown, item) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
|
||||
<Text style={{ fontWeight: 500 }}>{item.descProduto}</Text>
|
||||
</Space>
|
||||
@@ -636,7 +728,7 @@ export function OrdersPage() {
|
||||
limit,
|
||||
});
|
||||
|
||||
const rows = data?.data ?? [];
|
||||
const rows = useMemo(() => data?.data ?? [], [data]);
|
||||
const total = data?.total ?? 0;
|
||||
|
||||
const hasFilters = !!query || !!situaFilter || !!period || !!range;
|
||||
@@ -770,7 +862,7 @@ export function OrdersPage() {
|
||||
Pedidos
|
||||
</Title>
|
||||
<p style={{ margin: '4px 0 0', color: '#64748B', fontSize: 14 }}>
|
||||
Acompanhe seus pedidos, status de aprovação e histórico comercial.
|
||||
Acompanhe seus pedidos, status de transmissão e histórico comercial.
|
||||
</p>
|
||||
</div>
|
||||
{!isMobile && (
|
||||
@@ -854,7 +946,6 @@ export function OrdersPage() {
|
||||
}}
|
||||
options={[
|
||||
{ value: 0, label: 'Orçamento' },
|
||||
{ value: 1, label: 'Ag. Aprovação' },
|
||||
{ value: 2, label: 'Transmitido' },
|
||||
{ value: 3, label: 'Cancelado' },
|
||||
{ value: 4, label: 'Faturado' },
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd';
|
||||
import { BarcodeOutlined, InboxOutlined, TagsOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
BarcodeOutlined,
|
||||
InboxOutlined,
|
||||
PercentageOutlined,
|
||||
TagsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { PautaPreco } from '@sar/api-interface';
|
||||
import { useProdutoDetail } from '../../lib/queries/catalog';
|
||||
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
|
||||
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -168,6 +175,56 @@ function PautaPrecoList({ items }: { items: PautaPreco[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Condições comerciais vigentes ────────────────────────────────────────────
|
||||
|
||||
function fmtData(iso: string): string {
|
||||
const [y, m, d] = iso.split('-');
|
||||
return `${d}/${m}/${y}`;
|
||||
}
|
||||
|
||||
function CondicoesList({ conds }: { conds: CondicaoComercial[] }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{conds.map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
background: c.tipo === 'promocao' ? '#FFFBE6' : '#F0F5FF',
|
||||
border: `1px solid ${c.tipo === 'promocao' ? '#FFE58F' : '#ADC6FF'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', lineHeight: 1.2 }}>
|
||||
{c.tipo === 'promocao' ? 'Promoção' : 'Condição especial'} · válida até{' '}
|
||||
{fmtData(c.dataFim)}
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 13, color: '#1F2937' }}>
|
||||
{c.descricao}
|
||||
</Text>
|
||||
</div>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: c.tipo === 'promocao' ? '#D48806' : '#2F54EB',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{c.descPct.toLocaleString('pt-BR')}% desc.
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Modal principal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
@@ -177,6 +234,8 @@ interface Props {
|
||||
|
||||
export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
||||
const { data, isLoading } = useProdutoDetail(idErp ?? undefined);
|
||||
const condicoesDe = useCondicoesComerciais();
|
||||
const conds = data ? condicoesDe(data) : [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -227,6 +286,7 @@ export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<CondicaoTag conds={conds} />
|
||||
{data.marca && <Tag color="blue">{data.marca.trim()}</Tag>}
|
||||
{data.unidade && <Tag>{data.unidade}</Tag>}
|
||||
<Tag color={data.ativo ? 'green' : 'red'}>{data.ativo ? 'Ativo' : 'Inativo'}</Tag>
|
||||
@@ -239,6 +299,13 @@ export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
||||
<Row gutter={40}>
|
||||
{/* Coluna esquerda */}
|
||||
<Col xs={24} md={14}>
|
||||
{/* Condições comerciais vigentes */}
|
||||
{conds.length > 0 && (
|
||||
<Section icon={<PercentageOutlined />} title="Condições Comerciais">
|
||||
<CondicoesList conds={conds} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Classificação */}
|
||||
<Section icon={<TagsOutlined />} title="Classificação">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Col,
|
||||
DatePicker,
|
||||
@@ -17,10 +16,10 @@ import {
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { AlertOutlined, BarChartOutlined, RiseOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { AlertOutlined, BarChartOutlined, RiseOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { CarteiraCliente, AbcProduto } from '@sar/api-interface';
|
||||
import { useReportMeta, useReportCarteira, useReportAbc } from '../../lib/queries/reports';
|
||||
import type { AbcProduto } from '@sar/api-interface';
|
||||
import { useReportMeta, useReportAbc } from '../../lib/queries/reports';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
const { Option } = Select;
|
||||
@@ -29,11 +28,6 @@ function fmt(v: number | string): string {
|
||||
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function fmtDate(v: string | null | undefined): string {
|
||||
if (!v) return '—';
|
||||
return new Date(v).toLocaleDateString('pt-BR');
|
||||
}
|
||||
|
||||
// ─── Aba 1: Desempenho vs. Meta ───────────────────────────────────────────────
|
||||
|
||||
function TabMeta() {
|
||||
@@ -196,177 +190,7 @@ function TabMeta() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Aba 2: Carteira de Clientes ─────────────────────────────────────────────
|
||||
|
||||
type FiltroCarteira = 'todos' | 'inativos30' | 'inativos60' | 'semPedido';
|
||||
|
||||
function TabCarteira() {
|
||||
const [filtro, setFiltro] = useState<FiltroCarteira>('todos');
|
||||
const { data, isLoading, isError, error } = useReportCarteira();
|
||||
|
||||
const clientes = data?.data ?? [];
|
||||
|
||||
const filtered = clientes.filter((c: CarteiraCliente) => {
|
||||
if (filtro === 'inativos30') return c.diasSemPedido != null && c.diasSemPedido >= 30;
|
||||
if (filtro === 'inativos60') return c.diasSemPedido != null && c.diasSemPedido >= 60;
|
||||
if (filtro === 'semPedido') return c.totalPedidos === 0;
|
||||
return true;
|
||||
});
|
||||
|
||||
function statusBadge(c: CarteiraCliente) {
|
||||
if (c.totalPedidos === 0)
|
||||
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedidos</Text>} />;
|
||||
if (c.diasSemPedido != null && c.diasSemPedido >= 60)
|
||||
return (
|
||||
<Badge
|
||||
color="#ef4444"
|
||||
text={<Text style={{ fontSize: 12, color: '#ef4444' }}>Inativo 60d+</Text>}
|
||||
/>
|
||||
);
|
||||
if (c.diasSemPedido != null && c.diasSemPedido >= 30)
|
||||
return (
|
||||
<Badge
|
||||
color="#f59e0b"
|
||||
text={<Text style={{ fontSize: 12, color: '#f59e0b' }}>Inativo 30d+</Text>}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
|
||||
);
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<CarteiraCliente> = [
|
||||
{
|
||||
title: 'Cliente',
|
||||
key: 'cliente',
|
||||
render: (_: unknown, c: CarteiraCliente) => (
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Text strong style={{ fontSize: 13 }}>
|
||||
{c.razao ?? c.nome ?? `Cód. ${c.idCliente}`}
|
||||
</Text>
|
||||
{c.razao && c.nome && (
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
{c.nome}
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
key: 'status',
|
||||
width: 140,
|
||||
render: (_: unknown, c: CarteiraCliente) => statusBadge(c),
|
||||
},
|
||||
{
|
||||
title: 'Último Pedido',
|
||||
dataIndex: 'ultimoPedido',
|
||||
width: 130,
|
||||
render: (v: string | null) => (
|
||||
<Text style={{ fontSize: 13, color: '#475569' }}>{fmtDate(v)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Dias Sem Pedido',
|
||||
dataIndex: 'diasSemPedido',
|
||||
width: 140,
|
||||
align: 'right' as const,
|
||||
render: (v: number | null) =>
|
||||
v != null ? (
|
||||
<Text
|
||||
strong
|
||||
className="tabular-nums"
|
||||
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
|
||||
>
|
||||
{v}d
|
||||
</Text>
|
||||
) : (
|
||||
<Text type="secondary">—</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'totalPedidos',
|
||||
width: 90,
|
||||
align: 'right' as const,
|
||||
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Ticket Médio',
|
||||
dataIndex: 'ticketMedio',
|
||||
width: 130,
|
||||
align: 'right' as const,
|
||||
render: (v: string) => (
|
||||
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
|
||||
{fmt(v)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
message="Erro ao carregar carteira de clientes"
|
||||
description={error instanceof Error ? error.message : 'Erro desconhecido'}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{data && (
|
||||
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
|
||||
{[
|
||||
{ key: 'todos', label: 'Total', value: data.total, color: '#003B8E' },
|
||||
{ key: 'inativos30', label: 'Inativos 30d+', value: data.inativos30, color: '#f59e0b' },
|
||||
{ key: 'inativos60', label: 'Inativos 60d+', value: data.inativos60, color: '#ef4444' },
|
||||
{ key: 'semPedido', label: 'Sem Pedido', value: data.semPedido, color: '#94A3B8' },
|
||||
].map((item) => (
|
||||
<Col key={item.key} xs={12} md={6}>
|
||||
<Card
|
||||
hoverable
|
||||
onClick={() => setFiltro(item.key as FiltroCarteira)}
|
||||
style={{
|
||||
borderRadius: 8,
|
||||
border: filtro === item.key ? `2px solid ${item.color}` : '1px solid #EBF0F5',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
styles={{ body: { padding: '12px 16px' } }}
|
||||
>
|
||||
<Statistic
|
||||
title={item.label}
|
||||
value={item.value}
|
||||
styles={{ content: { color: item.color, fontSize: 24 } }}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
)}
|
||||
|
||||
<Card
|
||||
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<Table<CarteiraCliente>
|
||||
rowKey="idCliente"
|
||||
columns={columns}
|
||||
dataSource={filtered}
|
||||
loading={isLoading}
|
||||
size="middle"
|
||||
pagination={{ pageSize: 15, showSizeChanger: false }}
|
||||
scroll={{ x: 700 }}
|
||||
style={{ borderRadius: 10, overflow: 'hidden' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Aba 3: Curva ABC ─────────────────────────────────────────────────────────
|
||||
// ─── Aba 2: Curva ABC ─────────────────────────────────────────────────────────
|
||||
|
||||
const CURVA_COLOR: Record<string, string> = { A: '#003B8E', B: '#f59e0b', C: '#94A3B8' };
|
||||
|
||||
@@ -550,16 +374,6 @@ export function RelatoriosPage() {
|
||||
),
|
||||
children: <TabMeta />,
|
||||
},
|
||||
{
|
||||
key: 'carteira',
|
||||
label: (
|
||||
<Space>
|
||||
<TeamOutlined />
|
||||
Carteira de Clientes
|
||||
</Space>
|
||||
),
|
||||
children: <TabCarteira />,
|
||||
},
|
||||
{
|
||||
key: 'abc',
|
||||
label: (
|
||||
@@ -579,7 +393,7 @@ export function RelatoriosPage() {
|
||||
Relatórios
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 14 }}>
|
||||
Análise de desempenho, carteira e mix de produtos.
|
||||
Análise de desempenho e mix de produtos.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -18,15 +18,38 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faArrowTrendUp,
|
||||
faBullseye,
|
||||
faChartBar,
|
||||
faCircleExclamation,
|
||||
faClipboardList,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { WhatsAppOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate } from '@tanstack/react-router';
|
||||
import type { MetaItem, ClienteNaoPositivado, PedidoSummary } from '@sar/api-interface';
|
||||
import {
|
||||
Chart as ChartJS,
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
Tooltip as ChartTooltip,
|
||||
Legend,
|
||||
} from 'chart.js';
|
||||
import { Chart } from 'react-chartjs-2';
|
||||
import type { MetaItem, ClienteNaoPositivado, PedidoSummary, MesAno } from '@sar/api-interface';
|
||||
import { SITUA_LABEL } from '@sar/api-interface';
|
||||
import { useRepDashboard } from '../../lib/queries/dashboard';
|
||||
import { useCurrentUser } from '../../lib/queries/auth';
|
||||
import { CondicoesEspeciaisCard } from './CondicoesEspeciaisCard';
|
||||
|
||||
ChartJS.register(
|
||||
CategoryScale,
|
||||
LinearScale,
|
||||
BarElement,
|
||||
LineElement,
|
||||
PointElement,
|
||||
ChartTooltip,
|
||||
Legend,
|
||||
);
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -148,6 +171,109 @@ const metaColumns: TableColumnsType<MetaItem> = [
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Gráfico anual ────────────────────────────────────────────────────────────
|
||||
|
||||
const MESES = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
|
||||
|
||||
function GraficoAnual({
|
||||
dados,
|
||||
ano,
|
||||
mesAtual,
|
||||
}: {
|
||||
dados: MesAno[];
|
||||
ano: number;
|
||||
mesAtual: number;
|
||||
}) {
|
||||
const labels = MESES;
|
||||
|
||||
const barColors = dados.map((d) => {
|
||||
if (d.mes > mesAtual) return 'rgba(203,213,225,0.5)'; // futuro — cinza claro
|
||||
if (d.mes === mesAtual) return d.atingiu ? 'rgba(56,158,13,0.75)' : 'rgba(0,59,142,0.75)'; // mês atual
|
||||
return d.atingiu ? 'rgba(56,158,13,0.85)' : 'rgba(0,59,142,0.65)'; // passado
|
||||
});
|
||||
|
||||
const borderColors = dados.map((d) => (d.mes === mesAtual ? '#1a1a1a' : 'transparent'));
|
||||
|
||||
const chartData = {
|
||||
labels,
|
||||
datasets: [
|
||||
{
|
||||
type: 'bar' as const,
|
||||
label: 'Realizado',
|
||||
data: dados.map((d) => (d.mes <= mesAtual ? d.valor : null)),
|
||||
backgroundColor: barColors,
|
||||
borderColor: borderColors,
|
||||
borderWidth: dados.map((d) => (d.mes === mesAtual ? 2 : 0)),
|
||||
borderRadius: 4,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
type: 'line' as const,
|
||||
label: 'Meta',
|
||||
data: dados.map((d) => (d.meta > 0 ? d.meta : null)),
|
||||
borderColor: '#f5222d',
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 2,
|
||||
borderDash: [5, 4],
|
||||
pointRadius: dados.map((d) => (d.meta > 0 ? 4 : 0)),
|
||||
pointBackgroundColor: dados.map((d) =>
|
||||
d.meta > 0 && d.mes <= mesAtual ? (d.atingiu ? '#52c41a' : '#f5222d') : '#f5222d',
|
||||
),
|
||||
order: 1,
|
||||
tension: 0.1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const options = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: { mode: 'index' as const, intersect: false },
|
||||
plugins: {
|
||||
legend: { position: 'top' as const, labels: { boxWidth: 12, font: { size: 11 } } },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
||||
const val = ctx.parsed.y;
|
||||
if (val == null) return '';
|
||||
return ` ${ctx.dataset.label}: ${val.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' })}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
x: { grid: { display: false } },
|
||||
y: {
|
||||
ticks: {
|
||||
callback: (v: number | string) =>
|
||||
Number(v).toLocaleString('pt-BR', {
|
||||
style: 'currency',
|
||||
currency: 'BRL',
|
||||
notation: 'compact',
|
||||
}),
|
||||
font: { size: 10 },
|
||||
},
|
||||
grid: { color: '#f0f0f0' },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faChartBar} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Realizado vs Meta — {ano}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<div style={{ height: 240 }}>
|
||||
<Chart type="bar" data={chartData} options={options} />
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Não positivados ──────────────────────────────────────────────────────────
|
||||
|
||||
type OrdemNP = 'longe' | 'recentes';
|
||||
@@ -204,7 +330,7 @@ function NaoPositivados({ clientes, total }: { clientes: ClienteNaoPositivado[];
|
||||
const dias = c.diasSemPedido ?? 999;
|
||||
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
|
||||
return (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
|
||||
{dias}d sem pedido
|
||||
</Tag>
|
||||
@@ -339,9 +465,14 @@ export function RepPainel() {
|
||||
pedidosRecentes = [],
|
||||
naoPositivados = [],
|
||||
totalNaoPositivados = 0,
|
||||
historicoMensal = [],
|
||||
syncedAt,
|
||||
} = data;
|
||||
|
||||
const now = new Date();
|
||||
const anoAtual = now.getFullYear();
|
||||
const mesAtual = now.getMonth() + 1;
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
{/* Saudação */}
|
||||
@@ -423,6 +554,9 @@ export function RepPainel() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Condições especiais vigentes hoje (promoções + descontos liberados) */}
|
||||
<CondicoesEspeciaisCard />
|
||||
|
||||
{/* Metas por Grupo */}
|
||||
{metasPorGrupo.length > 0 && (
|
||||
<Card
|
||||
@@ -499,6 +633,9 @@ export function RepPainel() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Gráfico anual */}
|
||||
<GraficoAnual dados={historicoMensal} ano={anoAtual} mesAtual={mesAtual} />
|
||||
|
||||
{/* Linha 2 — Não positivados + Pedidos recentes */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} lg={14}>
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Table, Tag, Typography, Badge, Space } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { PedidoSummary } from '@sar/api-interface';
|
||||
import { useOrderList } from '../../lib/queries/orders';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
function hoursWaiting(createdAt: string): number {
|
||||
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
|
||||
}
|
||||
|
||||
const columns: TableColumnsType<PedidoSummary> = [
|
||||
{
|
||||
title: 'Nº',
|
||||
dataIndex: 'numPedSar',
|
||||
width: 120,
|
||||
render: (num: string, row: PedidoSummary) => (
|
||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||
{num}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
width: 160,
|
||||
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||
},
|
||||
{
|
||||
title: 'Cliente',
|
||||
key: 'cliente',
|
||||
width: 200,
|
||||
render: (_: unknown, row: PedidoSummary) =>
|
||||
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`,
|
||||
},
|
||||
{
|
||||
title: 'Total',
|
||||
dataIndex: 'total',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: (v: string) =>
|
||||
Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }),
|
||||
},
|
||||
{
|
||||
title: 'Desc. %',
|
||||
dataIndex: 'descontoPerc',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (v: string) => `${v}%`,
|
||||
},
|
||||
{
|
||||
title: 'Aguardando',
|
||||
dataIndex: 'createdAt',
|
||||
width: 130,
|
||||
render: (v: string) => {
|
||||
const h = hoursWaiting(v);
|
||||
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 100,
|
||||
render: (_: unknown, row: PedidoSummary) => (
|
||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||
<Tag color="blue" style={{ cursor: 'pointer' }}>
|
||||
Analisar
|
||||
</Tag>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export function ApprovalQueuePage() {
|
||||
// situa=1 = Pendente de Aprovação
|
||||
const { data, isLoading } = useOrderList({ situa: 1, limit: 200 });
|
||||
|
||||
const urgentCount = data?.data.filter((o) => hoursWaiting(o.createdAt) > 2).length ?? 0;
|
||||
|
||||
return (
|
||||
<div style={{ padding: 24 }}>
|
||||
<Space align="center" style={{ marginBottom: 16 }}>
|
||||
<Title level={3} style={{ margin: 0 }}>
|
||||
Fila de Aprovações
|
||||
</Title>
|
||||
{urgentCount > 0 && (
|
||||
<Badge
|
||||
count={urgentCount}
|
||||
style={{ backgroundColor: '#cf1322' }}
|
||||
title={`${urgentCount} urgente(s) — mais de 2h aguardando`}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
<Table<PedidoSummary>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data?.data ?? []}
|
||||
loading={isLoading}
|
||||
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
|
||||
pagination={false}
|
||||
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
|
||||
/>
|
||||
|
||||
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,41 @@
|
||||
import { Badge, Card, Col, Flex, Row, Skeleton, Space, Table, Tag, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Flex,
|
||||
Modal,
|
||||
Progress,
|
||||
Row,
|
||||
Skeleton,
|
||||
Space,
|
||||
Spin,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import { WhatsAppOutlined } from '@ant-design/icons';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faCheckCircle,
|
||||
faAddressCard,
|
||||
faCircleExclamation,
|
||||
faClipboardList,
|
||||
faRankingStar,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import { Link } from '@tanstack/react-router';
|
||||
import type { PedidoSummary } from '@sar/api-interface';
|
||||
import { useSupervisorDashboard } from '../../lib/queries/dashboard';
|
||||
import type {
|
||||
ClienteNaoPositivado,
|
||||
PositivacaoRep,
|
||||
RankingRep,
|
||||
RepInativosSummary,
|
||||
} from '@sar/api-interface';
|
||||
import { useRepInativos, useSupervisorDashboard } from '../../lib/queries/dashboard';
|
||||
import { useCurrentUser } from '../../lib/queries/auth';
|
||||
import {
|
||||
positivacaoRepColumns,
|
||||
rankingRepColumns,
|
||||
} from '../../components/dashboard/rep-performance-columns';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -17,10 +43,6 @@ function fmt(v: number): string {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
function hoursWaiting(createdAt: string): number {
|
||||
return Math.floor((Date.now() - new Date(createdAt).getTime()) / 3_600_000);
|
||||
}
|
||||
|
||||
function delta(current: number, previous: number): { label: string; positive: boolean } | null {
|
||||
if (previous === 0) return null;
|
||||
const pct = Math.round(((current - previous) / previous) * 100);
|
||||
@@ -38,62 +60,116 @@ function today(): string {
|
||||
return new Date().toLocaleDateString('pt-BR', { weekday: 'long', day: 'numeric', month: 'long' });
|
||||
}
|
||||
|
||||
const queueColumns: TableColumnsType<PedidoSummary> = [
|
||||
{
|
||||
title: 'Pedido',
|
||||
dataIndex: 'numPedSar',
|
||||
width: 120,
|
||||
render: (num: string, row: PedidoSummary) => (
|
||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||
{num}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
width: 150,
|
||||
render: (_: unknown, row: PedidoSummary) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||
},
|
||||
function mesLabel(): string {
|
||||
return new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
function waLink(whatsapp: string): string {
|
||||
const digits = whatsapp.replace(/\D/g, '');
|
||||
const number = digits.startsWith('55') ? digits : `55${digits}`;
|
||||
return `https://wa.me/${number}`;
|
||||
}
|
||||
|
||||
// ─── Modal de clientes inativos de um rep ─────────────────────────────────────
|
||||
|
||||
const inativosColumns: TableColumnsType<ClienteNaoPositivado> = [
|
||||
{
|
||||
title: 'Cliente',
|
||||
key: 'cliente',
|
||||
width: 180,
|
||||
render: (_: unknown, row: PedidoSummary) =>
|
||||
row.razaoCliente ?? row.nomeCliente ?? `Cód. ${row.idCliente}`,
|
||||
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||
<Space orientation="vertical" size={0}>
|
||||
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
|
||||
{c.razao ?? c.nome}
|
||||
</Link>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
cód. {c.idCliente}
|
||||
</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Total',
|
||||
dataIndex: 'total',
|
||||
width: 130,
|
||||
align: 'right',
|
||||
render: (v: string) => fmt(Number(v)),
|
||||
title: 'Última compra',
|
||||
dataIndex: 'ultimoPedido',
|
||||
width: 140,
|
||||
render: (v: string | null) =>
|
||||
v ? new Date(`${v}T00:00:00`).toLocaleDateString('pt-BR') : <Tag>Nunca comprou</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Aguardando',
|
||||
dataIndex: 'createdAt',
|
||||
title: 'Sem compra',
|
||||
dataIndex: 'diasSemPedido',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
const h = hoursWaiting(v);
|
||||
return <Tag color={h > 2 ? 'red' : 'orange'}>{h}h</Tag>;
|
||||
},
|
||||
render: (d: number, c: ClienteNaoPositivado) =>
|
||||
c.comprouAntes ? (
|
||||
<Tag color={d > 90 ? 'red' : 'orange'} className="tabular-nums">
|
||||
{d} dias
|
||||
</Tag>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 90,
|
||||
render: (_: unknown, row: PedidoSummary) => (
|
||||
<Link to="/pedidos/$id" params={{ id: row.id }}>
|
||||
<Tag color="blue" style={{ cursor: 'pointer' }}>
|
||||
Analisar
|
||||
</Tag>
|
||||
</Link>
|
||||
key: 'acoes',
|
||||
width: 120,
|
||||
render: (_: unknown, c: ClienteNaoPositivado) => (
|
||||
<Space>
|
||||
<Link to="/clientes/$id" params={{ id: String(c.idCliente) }}>
|
||||
<Button size="small">Ficha</Button>
|
||||
</Link>
|
||||
{c.whatsapp && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
style={{ borderRadius: 6, background: '#25d366', borderColor: '#25d366' }}
|
||||
icon={<WhatsAppOutlined />}
|
||||
href={waLink(c.whatsapp)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function InativosRepModal({
|
||||
rep,
|
||||
onClose,
|
||||
}: {
|
||||
rep: RepInativosSummary | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { data, isLoading } = useRepInativos(rep?.codVendedor ?? null);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={rep !== null}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width={900}
|
||||
centered
|
||||
title={rep ? `Clientes Inativos — ${rep.nomeVendedor ?? `Rep cód. ${rep.codVendedor}`}` : ''}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Spin style={{ display: 'block', margin: '32px auto' }} />
|
||||
) : (
|
||||
<Table<ClienteNaoPositivado>
|
||||
rowKey="idCliente"
|
||||
columns={inativosColumns}
|
||||
dataSource={data?.clientes ?? []}
|
||||
size="small"
|
||||
pagination={{ pageSize: 10, hideOnSinglePage: true }}
|
||||
locale={{ emptyText: 'Nenhum cliente inativo.' }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function SupervisorPainel() {
|
||||
const { data, isLoading } = useSupervisorDashboard();
|
||||
const { data: user } = useCurrentUser();
|
||||
const [inativosRep, setInativosRep] = useState<RepInativosSummary | null>(null);
|
||||
|
||||
if (isLoading || !data) {
|
||||
return (
|
||||
@@ -110,8 +186,7 @@ export function SupervisorPainel() {
|
||||
);
|
||||
}
|
||||
|
||||
const { approvalQueue, pedidosDia, inativosPorRep, syncedAt } = data;
|
||||
const urgentCount = approvalQueue.filter((o) => hoursWaiting(o.createdAt) > 2).length;
|
||||
const { pedidosDia, inativosPorRep, equipeMes, rankingReps, positivacaoReps, syncedAt } = data;
|
||||
const countDelta = delta(pedidosDia.count, pedidosDia.countSemanaAnterior);
|
||||
const totalDelta = delta(pedidosDia.total, pedidosDia.totalSemanaAnterior);
|
||||
|
||||
@@ -124,49 +199,12 @@ export function SupervisorPainel() {
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||
{today()}
|
||||
{urgentCount > 0 && (
|
||||
<>
|
||||
{' '}
|
||||
·{' '}
|
||||
<span style={{ color: '#cf1322' }}>
|
||||
{urgentCount} aprovação{urgentCount > 1 ? 'ões' : ''} urgente
|
||||
{urgentCount > 1 ? 's' : ''}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
{/* KPIs */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
APROVAÇÕES PENDENTES
|
||||
</Text>
|
||||
<Flex align="center" gap={8}>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{approvalQueue.length}
|
||||
</Title>
|
||||
{urgentCount > 0 && (
|
||||
<Badge
|
||||
count={urgentCount}
|
||||
style={{ backgroundColor: '#cf1322' }}
|
||||
title={`${urgentCount} urgente(s) — mais de 2h`}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
<Link to="/aprovacoes">
|
||||
<Text style={{ fontSize: 'var(--text-sm)', color: 'var(--jcs-blue)' }}>
|
||||
Ver fila completa →
|
||||
</Text>
|
||||
</Link>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={8}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
@@ -189,7 +227,7 @@ export function SupervisorPainel() {
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={8}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
@@ -213,38 +251,9 @@ export function SupervisorPainel() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Fila de aprovações + Inativos por rep */}
|
||||
{/* Inativos por rep + Pedidos de hoje */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} lg={16}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faCheckCircle} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Fila de Aprovações
|
||||
{approvalQueue.length > 0 && (
|
||||
<Badge
|
||||
count={approvalQueue.length}
|
||||
style={{ backgroundColor: 'var(--jcs-blue)' }}
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
extra={<Link to="/aprovacoes">Ver todas</Link>}
|
||||
>
|
||||
<Table<PedidoSummary>
|
||||
rowKey="id"
|
||||
columns={queueColumns}
|
||||
dataSource={approvalQueue.slice(0, 8)}
|
||||
pagination={false}
|
||||
size="small"
|
||||
rowClassName={(row) => (hoursWaiting(row.createdAt) > 2 ? 'row-urgent' : '')}
|
||||
locale={{ emptyText: 'Nenhum pedido aguardando aprovação.' }}
|
||||
/>
|
||||
<style>{`.row-urgent td { background: #fff1f0 !important; }`}</style>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={8}>
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
@@ -281,20 +290,26 @@ export function SupervisorPainel() {
|
||||
</Text>
|
||||
)}
|
||||
</Space>
|
||||
<Tag
|
||||
color={r.inativosCount >= 3 ? 'orange' : 'default'}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''}
|
||||
</Tag>
|
||||
<Space>
|
||||
<Tag
|
||||
color={r.inativosCount >= 3 ? 'orange' : 'default'}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{r.inativosCount} cliente{r.inativosCount > 1 ? 's' : ''}
|
||||
</Tag>
|
||||
<Button size="small" onClick={() => setInativosRep(r)}>
|
||||
Detalhar
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} lg={12}>
|
||||
<Card
|
||||
style={{ marginTop: 24 }}
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faClipboardList} style={{ color: 'var(--jcs-blue)' }} />
|
||||
@@ -328,6 +343,130 @@ export function SupervisorPainel() {
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Métricas da equipe no mês */}
|
||||
<Row gutter={[24, 24]}>
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
FATURAMENTO NO MÊS
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(equipeMes.faturamento)}
|
||||
</Title>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 'var(--text-sm)' }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{equipeMes.pedidos} pedido{equipeMes.pedidos !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Space orientation="vertical" size={4} style={{ width: '100%' }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
META DA EQUIPE
|
||||
</Text>
|
||||
{equipeMes.metaTotal > 0 ? (
|
||||
<>
|
||||
<Flex align="baseline" gap={8}>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{equipeMes.pctMeta}%
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
de {fmt(equipeMes.metaTotal)}
|
||||
</Text>
|
||||
</Flex>
|
||||
<Progress
|
||||
percent={Math.min(equipeMes.pctMeta, 100)}
|
||||
size="small"
|
||||
strokeColor={
|
||||
equipeMes.pctMeta >= 100
|
||||
? 'var(--green)'
|
||||
: equipeMes.pctMeta >= 70
|
||||
? 'var(--jcs-blue)'
|
||||
: '#faad14'
|
||||
}
|
||||
showInfo={false}
|
||||
/>
|
||||
<Text
|
||||
type="secondary"
|
||||
style={{ fontSize: 'var(--text-sm)' }}
|
||||
className="tabular-nums"
|
||||
>
|
||||
{equipeMes.metaTotal > equipeMes.faturamento
|
||||
? `Faltam ${fmt(equipeMes.metaTotal - equipeMes.faturamento)}`
|
||||
: 'Meta atingida 🎉'}
|
||||
</Text>
|
||||
</>
|
||||
) : (
|
||||
<Text type="secondary">Sem meta cadastrada para o mês.</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} md={8}>
|
||||
<Card>
|
||||
<Space orientation="vertical" size={4}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
TICKET MÉDIO
|
||||
</Text>
|
||||
<Title level={3} style={{ margin: 0 }} className="tabular-nums">
|
||||
{fmt(equipeMes.ticketMedio)}
|
||||
</Title>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
por pedido no mês
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{/* Ranking da equipe */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faRankingStar} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Ranking da Equipe — {mesLabel()}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table<RankingRep>
|
||||
rowKey="codVendedor"
|
||||
columns={rankingRepColumns}
|
||||
dataSource={rankingReps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 700 }}
|
||||
locale={{ emptyText: 'Nenhum pedido da equipe no mês.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Positivação por representante */}
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<FontAwesomeIcon icon={faAddressCard} style={{ color: 'var(--jcs-blue)' }} />
|
||||
Positivação de Clientes — {mesLabel()}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Table<PositivacaoRep>
|
||||
rowKey="codVendedor"
|
||||
columns={positivacaoRepColumns}
|
||||
dataSource={positivacaoReps}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 700 }}
|
||||
locale={{ emptyText: 'Nenhum representante com carteira na equipe.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Flex justify="space-between" style={{ paddingTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||
SAR · Força de Vendas · Powered by JCS Sistemas
|
||||
@@ -336,6 +475,8 @@ export function SupervisorPainel() {
|
||||
Sync: {new Date(syncedAt).toLocaleTimeString('pt-BR')} · atualiza a cada 30s
|
||||
</Text>
|
||||
</Flex>
|
||||
|
||||
<InativosRepModal rep={inativosRep} onClose={() => setInativosRep(null)} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
27
apps/web/src/components/CondicaoTag.tsx
Normal file
27
apps/web/src/components/CondicaoTag.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Tag } from 'antd';
|
||||
import type { CondicaoComercial } from '../lib/condicoes-comerciais';
|
||||
|
||||
// Selo que sinaliza produto com proposta comercial diferenciada (promoção do
|
||||
// gerente ou regra de desconto vigente). O detalhamento fica no modal do produto.
|
||||
export function CondicaoTag({ conds, compact }: { conds: CondicaoComercial[]; compact?: boolean }) {
|
||||
if (conds.length === 0) return null;
|
||||
const temPromo = conds.some((c) => c.tipo === 'promocao');
|
||||
const temRegra = conds.some((c) => c.tipo === 'regra');
|
||||
const style = compact
|
||||
? { fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '16px' }
|
||||
: { fontSize: 11, margin: 0 };
|
||||
return (
|
||||
<>
|
||||
{temPromo && (
|
||||
<Tag color="gold" style={style}>
|
||||
Promoção
|
||||
</Tag>
|
||||
)}
|
||||
{temRegra && (
|
||||
<Tag color="geekblue" style={style}>
|
||||
Cond. especial
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -94,7 +94,12 @@ export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: P
|
||||
anotacoes: values.anotacoes || undefined,
|
||||
};
|
||||
|
||||
const result = await createMutation.mutateAsync(payload);
|
||||
let result: Awaited<ReturnType<typeof createMutation.mutateAsync>>;
|
||||
try {
|
||||
result = await createMutation.mutateAsync(payload);
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||
}
|
||||
|
||||
if (!result.sincronizado && result.erroSync) {
|
||||
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);
|
||||
|
||||
114
apps/web/src/components/dashboard/rep-performance-columns.tsx
Normal file
114
apps/web/src/components/dashboard/rep-performance-columns.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { Flex, Progress, Tag, Typography } from 'antd';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import type { PositivacaoRep, RankingRep } from '@sar/api-interface';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
function fmt(v: number): string {
|
||||
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
|
||||
}
|
||||
|
||||
// Colunas compartilhadas entre o painel gerencial e o do supervisor —
|
||||
// mesmas métricas, mudando só o escopo (empresa toda vs equipe).
|
||||
export const positivacaoRepColumns: TableColumnsType<PositivacaoRep> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, row: PositivacaoRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: 'Positivados / Carteira',
|
||||
key: 'pos',
|
||||
width: 160,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: PositivacaoRep) => (
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{row.clientesPositivados} / {row.totalClientes}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Novos no mês',
|
||||
key: 'novos',
|
||||
width: 120,
|
||||
align: 'center' as const,
|
||||
sorter: (a: PositivacaoRep, b: PositivacaoRep) => a.novosClientes - b.novosClientes,
|
||||
render: (_: unknown, row: PositivacaoRep) =>
|
||||
row.novosClientes > 0 ? (
|
||||
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
|
||||
+{row.novosClientes}
|
||||
</Tag>
|
||||
) : (
|
||||
<Text type="secondary">—</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '% Positivação',
|
||||
dataIndex: 'pctPositivacao',
|
||||
width: 170,
|
||||
render: (pct: number) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Progress
|
||||
percent={Math.min(pct, 100)}
|
||||
size="small"
|
||||
strokeColor={pct >= 30 ? 'var(--green)' : pct >= 15 ? 'var(--jcs-blue)' : '#faad14'}
|
||||
showInfo={false}
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const rankingRepColumns: TableColumnsType<RankingRep> = [
|
||||
{
|
||||
title: 'Representante',
|
||||
key: 'rep',
|
||||
render: (_: unknown, row: RankingRep) => row.nomeVendedor ?? `Cód. ${row.codVendedor}`,
|
||||
},
|
||||
{
|
||||
title: 'Pedidos',
|
||||
dataIndex: 'pedidos',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: 'Clientes',
|
||||
dataIndex: 'clientesAtendidos',
|
||||
width: 80,
|
||||
align: 'right',
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: 'Faturamento',
|
||||
dataIndex: 'faturamento',
|
||||
width: 150,
|
||||
align: 'right',
|
||||
render: (v: number) => fmt(v),
|
||||
className: 'tabular-nums',
|
||||
},
|
||||
{
|
||||
title: '% Meta',
|
||||
dataIndex: 'pctMeta',
|
||||
width: 160,
|
||||
render: (pct: number) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Progress
|
||||
percent={Math.min(pct, 100)}
|
||||
size="small"
|
||||
strokeColor={pct >= 100 ? 'var(--green)' : pct >= 70 ? 'var(--jcs-blue)' : '#faad14'}
|
||||
showInfo={false}
|
||||
style={{ flex: 1, minWidth: 60 }}
|
||||
/>
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)', width: 36 }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
@@ -9,12 +9,14 @@ import { AuthTokenResponseSchema } from '@sar/api-interface';
|
||||
|
||||
type DevUser = { key: string; userId: string; role: string; label: string };
|
||||
|
||||
// userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1)
|
||||
// Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado.
|
||||
// userId = cod_vendedor como string; idEmpresa fica a cargo do backend (DEV_EMPRESA_ID).
|
||||
// Usuários provisórios para testar as telas por papel enquanto o cadastro de
|
||||
// usuários não existe: Pavei (carteira própria), Sidnei (equipe via
|
||||
// vw_representantes.cod_supervisor = 191), Lucas (empresa toda).
|
||||
const DEV_USERS: DevUser[] = [
|
||||
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Representante (cód. 29)' },
|
||||
{ key: 'sup-29', userId: '29', role: 'supervisor', label: 'Supervisor (cód. 29)' },
|
||||
{ key: 'mgr-29', userId: '29', role: 'manager', label: 'Gerente (cód. 29)' },
|
||||
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Pavei — Representante (cód. 29)' },
|
||||
{ key: 'sup-191', userId: '191', role: 'supervisor', label: 'Sidnei — Supervisor (cód. 191)' },
|
||||
{ key: 'mgr-156', userId: '156', role: 'manager', label: 'Lucas — Gerente/Admin (cód. 156)' },
|
||||
];
|
||||
|
||||
export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
||||
@@ -27,7 +29,7 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
|
||||
try {
|
||||
const raw = await apiFetch('/auth/dev/token', {
|
||||
method: 'POST',
|
||||
body: { userId: user.userId, idEmpresa: 1, role: user.role },
|
||||
body: { userId: user.userId, role: user.role },
|
||||
});
|
||||
const { accessToken } = AuthTokenResponseSchema.parse(raw);
|
||||
authStore.set(accessToken);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { Alert, Button, Flex, Grid, Tooltip } from 'antd';
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import { Alert, App, Button, Flex, Grid, Tooltip } from 'antd';
|
||||
import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from '@tanstack/react-router';
|
||||
import { Topbar } from './Topbar';
|
||||
@@ -7,6 +7,7 @@ import { Sidebar } from './Sidebar';
|
||||
import { BottomNav } from './BottomNav';
|
||||
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
|
||||
import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
|
||||
import { registerMessageApi } from '../../lib/feedback';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
|
||||
@@ -18,10 +19,16 @@ export function AppShell({ children }: AppShellProps) {
|
||||
const navigate = useNavigate();
|
||||
const isOnline = useNetworkStatus();
|
||||
const screens = useBreakpoint();
|
||||
const { message } = App.useApp();
|
||||
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav
|
||||
const isDesktop = !!screens.lg;
|
||||
useOfflineSync();
|
||||
|
||||
// Disponibiliza o message (com tema) para os caches do TanStack Query
|
||||
useEffect(() => {
|
||||
registerMessageApi(message);
|
||||
}, [message]);
|
||||
|
||||
return (
|
||||
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
|
||||
{!isOnline && (
|
||||
|
||||
@@ -3,7 +3,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import {
|
||||
faChartLine,
|
||||
faClipboardList,
|
||||
faClipboardCheck,
|
||||
faUsers,
|
||||
faAddressCard,
|
||||
faBoxesStacked,
|
||||
@@ -40,7 +39,6 @@ const REP_ITEMS: NavItem[] = [
|
||||
|
||||
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' },
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
faBoxesStacked,
|
||||
faFileInvoiceDollar,
|
||||
faTags,
|
||||
faHeadset,
|
||||
faGear,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
import type { ItemType } from 'antd/es/menu/interface';
|
||||
import { authStore } from '../../lib/auth-store';
|
||||
@@ -28,17 +30,17 @@ function getRole(): string {
|
||||
|
||||
const REP_ITEMS: ItemType[] = [
|
||||
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' },
|
||||
{
|
||||
key: '/funil',
|
||||
icon: <FontAwesomeIcon icon={faFunnelDollar} fixedWidth />,
|
||||
label: 'Funil de Vendas',
|
||||
},
|
||||
{ key: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
|
||||
{
|
||||
key: '/catalogo',
|
||||
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
||||
label: 'Catálogo',
|
||||
},
|
||||
{
|
||||
key: '/funil',
|
||||
icon: <FontAwesomeIcon icon={faFunnelDollar} fixedWidth />,
|
||||
label: 'Funil de Vendas',
|
||||
},
|
||||
{
|
||||
key: '/pedidos',
|
||||
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
|
||||
@@ -49,6 +51,7 @@ const REP_ITEMS: ItemType[] = [
|
||||
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
|
||||
label: 'Consulta ERP',
|
||||
},
|
||||
{ key: '/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
|
||||
{
|
||||
key: '/relatorios',
|
||||
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
||||
@@ -58,11 +61,6 @@ const REP_ITEMS: ItemType[] = [
|
||||
|
||||
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',
|
||||
@@ -89,6 +87,11 @@ const GERENTE_ITEMS: ItemType[] = [
|
||||
icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />,
|
||||
label: 'Clientes',
|
||||
},
|
||||
{
|
||||
key: '/catalogo',
|
||||
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
||||
label: 'Catálogo',
|
||||
},
|
||||
{
|
||||
key: '/relatorios',
|
||||
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
||||
@@ -99,6 +102,12 @@ const GERENTE_ITEMS: ItemType[] = [
|
||||
icon: <FontAwesomeIcon icon={faTags} fixedWidth />,
|
||||
label: 'Políticas Comerciais',
|
||||
},
|
||||
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
|
||||
{
|
||||
key: '/ger/config',
|
||||
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
|
||||
label: 'Configurações',
|
||||
},
|
||||
];
|
||||
|
||||
function getItems(role: string): ItemType[] {
|
||||
@@ -113,10 +122,12 @@ export function Sidebar() {
|
||||
const role = getRole();
|
||||
const items = getItems(role);
|
||||
|
||||
const selectedKey = items.find((item) => {
|
||||
const selectedKey = items.reduce<string | undefined>((best, item) => {
|
||||
const key = (item as { key?: string }).key ?? '';
|
||||
return key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
|
||||
})?.key as string | undefined;
|
||||
const matches = key === '/' ? location.pathname === '/' : location.pathname.startsWith(key);
|
||||
if (!matches) return best;
|
||||
return !best || key.length > best.length ? key : best;
|
||||
}, undefined);
|
||||
|
||||
return (
|
||||
<aside
|
||||
|
||||
78
apps/web/src/lib/condicoes-comerciais.ts
Normal file
78
apps/web/src/lib/condicoes-comerciais.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
|
||||
import { usePromocoesVigentes, useRegrasVigentes, useMinhaAlcada } from './queries/politicas';
|
||||
|
||||
// Condição comercial vigente que alcança um produto: promoção (por produto ou
|
||||
// grupo) ou regra de desconto (por grupo/subgrupo/rep). Usada para sinalizar no
|
||||
// catálogo do rep que o produto tem proposta diferenciada e detalhar qual é.
|
||||
|
||||
export interface CondicaoComercial {
|
||||
tipo: 'promocao' | 'regra';
|
||||
descricao: string;
|
||||
descPct: number;
|
||||
dataFim: string;
|
||||
}
|
||||
|
||||
export interface ProdutoRef {
|
||||
codigo: string;
|
||||
codGrupo: number | null;
|
||||
codSubgrupo: number | null;
|
||||
}
|
||||
|
||||
export function condicoesDoProduto(
|
||||
p: ProdutoRef,
|
||||
promocoes: PromocaoVigente[] | undefined,
|
||||
regras: RegraVigente[] | undefined,
|
||||
): CondicaoComercial[] {
|
||||
const conds: CondicaoComercial[] = [];
|
||||
|
||||
for (const promo of promocoes ?? []) {
|
||||
const porProduto = promo.codProduto && promo.codProduto.trim() === p.codigo.trim();
|
||||
const porGrupo =
|
||||
promo.grpProd && p.codGrupo != null && promo.grpProd.trim() === String(p.codGrupo);
|
||||
if (porProduto || porGrupo) {
|
||||
conds.push({
|
||||
tipo: 'promocao',
|
||||
descricao: promo.descricao,
|
||||
descPct: promo.descPct,
|
||||
dataFim: promo.dataFim,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const regra of regras ?? []) {
|
||||
const grupoOk = regra.codGrupo == null || (p.codGrupo != null && regra.codGrupo === p.codGrupo);
|
||||
const subgrupoOk =
|
||||
regra.codSubgrupo == null || (p.codSubgrupo != null && regra.codSubgrupo === p.codSubgrupo);
|
||||
if (grupoOk && subgrupoOk) {
|
||||
conds.push({
|
||||
tipo: 'regra',
|
||||
descricao: regra.descricao,
|
||||
descPct: regra.descPct,
|
||||
dataFim: regra.dataFim,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conds.sort((a, b) => b.descPct - a.descPct);
|
||||
}
|
||||
|
||||
// Hook: função de lookup por produto, com promoções e regras vigentes em cache
|
||||
export function useCondicoesComerciais(): (p: ProdutoRef) => CondicaoComercial[] {
|
||||
const { data: promos } = usePromocoesVigentes();
|
||||
const { data: regras } = useRegrasVigentes();
|
||||
return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras);
|
||||
}
|
||||
|
||||
// Teto de desconto do item — espelha assertAlcadaItens do backend: vale o MAIOR
|
||||
// entre a alçada do grupo (codGrupo 0 = default; sem linha, 5%) e a melhor
|
||||
// promoção/regra vigente que alcança o produto.
|
||||
export function useTetoDesconto(): (p: ProdutoRef) => number {
|
||||
const condicoesDe = useCondicoesComerciais();
|
||||
const { data: alcada } = useMinhaAlcada();
|
||||
return (p) => {
|
||||
const limites = new Map((alcada?.limites ?? []).map((l) => [l.codGrupo, l.limitePerc]));
|
||||
const base = (p.codGrupo != null ? limites.get(p.codGrupo) : undefined) ?? limites.get(0) ?? 5;
|
||||
const cond = condicoesDe(p).reduce((max, c) => Math.max(max, c.descPct), 0);
|
||||
return Math.max(base, cond);
|
||||
};
|
||||
}
|
||||
15
apps/web/src/lib/feedback.ts
Normal file
15
apps/web/src/lib/feedback.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { MessageInstance } from 'antd/es/message/interface';
|
||||
|
||||
// Instância do message obtida via App.useApp() (herda tema/contexto), registrada
|
||||
// pelo AppShell no mount. Fora do React (ex.: caches do TanStack Query) usamos
|
||||
// esta referência em vez do message estático do AntD.
|
||||
let messageApi: MessageInstance | null = null;
|
||||
|
||||
export function registerMessageApi(api: MessageInstance): void {
|
||||
messageApi = api;
|
||||
}
|
||||
|
||||
// key fixa colapsa erros repetidos (várias queries falhando juntas) num toast só.
|
||||
export function notifyError(content: string, key?: string): void {
|
||||
messageApi?.error({ content, key, duration: 5 });
|
||||
}
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import type { CreatePedido } from '@sar/api-interface';
|
||||
import { idbGetAll, idbPut, idbDelete, STORE_PENDING_ORDERS } from './idb';
|
||||
import { randomUUID } from '../uuid';
|
||||
|
||||
export interface PendingOrder {
|
||||
idempotencyKey: string; // keyPath do IndexedDB
|
||||
@@ -23,7 +24,7 @@ export async function enqueueOrder(
|
||||
payload: CreatePedido,
|
||||
clienteNome: string,
|
||||
): Promise<PendingOrder> {
|
||||
const key = payload.idempotencyKey ?? crypto.randomUUID();
|
||||
const key = payload.idempotencyKey ?? randomUUID();
|
||||
const order: PendingOrder = {
|
||||
idempotencyKey: key,
|
||||
payload: { ...payload, idempotencyKey: key },
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
RepDashboardSchema,
|
||||
RepInativosDetailSchema,
|
||||
SupervisorDashboardSchema,
|
||||
type RepDashboard,
|
||||
type RepInativosDetail,
|
||||
type SupervisorDashboard,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
@@ -29,3 +31,30 @@ export function useSupervisorDashboard() {
|
||||
refetchInterval: 30 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Painel de um supervisor específico — gerente/admin abrindo o "Detalhar" da
|
||||
// aba Equipe. Reusa /dashboard/supervisor escopando pelo codSupervisor.
|
||||
export function useSupervisorDashboardByCod(codSupervisor: number | null) {
|
||||
return useQuery<SupervisorDashboard>({
|
||||
queryKey: ['dashboard', 'supervisor', 'detalhe', codSupervisor],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch(`/dashboard/supervisor?codSupervisor=${codSupervisor}`);
|
||||
return SupervisorDashboardSchema.parse(raw);
|
||||
},
|
||||
enabled: codSupervisor != null,
|
||||
staleTime: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Detalhe dos clientes inativos de um rep (modal do card "Inativos por Rep")
|
||||
export function useRepInativos(codVendedor: number | null) {
|
||||
return useQuery<RepInativosDetail>({
|
||||
queryKey: ['dashboard', 'supervisor', 'inativos', codVendedor],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch(`/dashboard/supervisor/inativos?codVendedor=${codVendedor}`);
|
||||
return RepInativosDetailSchema.parse(raw);
|
||||
},
|
||||
enabled: codVendedor != null,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
44
apps/web/src/lib/queries/funil.ts
Normal file
44
apps/web/src/lib/queries/funil.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
FunilResponseSchema,
|
||||
OportunidadeSchema,
|
||||
type CreateOportunidadeDto,
|
||||
type UpdateOportunidadeDto,
|
||||
type Oportunidade,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
export function useFunil() {
|
||||
return useQuery({
|
||||
queryKey: ['funil'],
|
||||
queryFn: async () => FunilResponseSchema.parse(await apiFetch('/funil')),
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateOportunidade() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateOportunidadeDto): Promise<Oportunidade> =>
|
||||
apiFetch('/funil', { method: 'POST', body: dto }).then((r) => OportunidadeSchema.parse(r)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateOportunidade() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, dto }: { id: number; dto: UpdateOportunidadeDto }): Promise<Oportunidade> =>
|
||||
apiFetch(`/funil/${id}`, { method: 'PATCH', body: dto }).then((r) =>
|
||||
OportunidadeSchema.parse(r),
|
||||
),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteOportunidade() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number): Promise<void> => apiFetch(`/funil/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['funil'] }),
|
||||
});
|
||||
}
|
||||
@@ -2,15 +2,20 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ManagerDashboardSchema,
|
||||
EquipeResponseSchema,
|
||||
AlcadaDescontosResponseSchema,
|
||||
SupervisoresResponseSchema,
|
||||
PromocoesResponseSchema,
|
||||
RegrasDescontoResponseSchema,
|
||||
GruposProdutoResponseSchema,
|
||||
type ManagerDashboard,
|
||||
type EquipeResponse,
|
||||
type AlcadaDescontosResponse,
|
||||
type SupervisoresResponse,
|
||||
type PromocoesResponse,
|
||||
type UpsertDescontoBody,
|
||||
type CreatePromocaoBody,
|
||||
type UpdatePromocaoBody,
|
||||
type RegrasDescontoResponse,
|
||||
type GruposProdutoResponse,
|
||||
type CreateRegraDescontoBody,
|
||||
type UpdateRegraDescontoBody,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
@@ -41,14 +46,14 @@ export function useEquipe() {
|
||||
});
|
||||
}
|
||||
|
||||
export function usePoliticasDescontos() {
|
||||
return useQuery<AlcadaDescontosResponse>({
|
||||
queryKey: ['politicas', 'descontos'],
|
||||
export function useSupervisores() {
|
||||
return useQuery<SupervisoresResponse>({
|
||||
queryKey: ['equipe', 'supervisores'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/descontos');
|
||||
return AlcadaDescontosResponseSchema.parse(raw);
|
||||
const raw = await apiFetch('/equipe/supervisores');
|
||||
return SupervisoresResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 10 * 60 * 1000,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -63,20 +68,11 @@ export function usePromocoes() {
|
||||
});
|
||||
}
|
||||
|
||||
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) }),
|
||||
apiFetch('/politicas/promocoes', { method: 'POST', body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||
@@ -88,7 +84,7 @@ 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) }),
|
||||
apiFetch(`/politicas/promocoes/${id}`, { method: 'PATCH', body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
|
||||
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||
@@ -106,3 +102,60 @@ export function useDeletePromocao() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegrasDesconto() {
|
||||
return useQuery<RegrasDescontoResponse>({
|
||||
queryKey: ['politicas', 'regras'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/regras');
|
||||
return RegrasDescontoResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGruposProduto() {
|
||||
return useQuery<GruposProdutoResponse>({
|
||||
queryKey: ['politicas', 'grupos-produto'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/grupos-produto');
|
||||
return GruposProdutoResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 30 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRegraDesconto() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateRegraDescontoBody) =>
|
||||
apiFetch('/politicas/regras', { method: 'POST', body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRegraDesconto() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: UpdateRegraDescontoBody }) =>
|
||||
apiFetch(`/politicas/regras/${id}`, { method: 'PATCH', body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRegraDesconto() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiFetch(`/politicas/regras/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function useClientOrders(idCliente: number | undefined) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}) {
|
||||
export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}, enabled = true) {
|
||||
const search = new URLSearchParams();
|
||||
if (params.search) search.set('search', params.search);
|
||||
if (params.from) search.set('from', params.from);
|
||||
@@ -70,6 +70,7 @@ export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}
|
||||
const qs = search.toString();
|
||||
return useQuery<PedidoErpConsultaResponse>({
|
||||
queryKey: ['orders-erp-consulta', params],
|
||||
enabled,
|
||||
queryFn: async () => {
|
||||
const res = await apiFetch(`/orders/erp-consulta${qs ? `?${qs}` : ''}`);
|
||||
return PedidoErpConsultaResponseSchema.parse(res);
|
||||
|
||||
51
apps/web/src/lib/queries/politicas.ts
Normal file
51
apps/web/src/lib/queries/politicas.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
RegrasVigentesResponseSchema,
|
||||
PromocoesVigentesResponseSchema,
|
||||
AlcadaRepResponseSchema,
|
||||
type RegrasVigentesResponse,
|
||||
type PromocoesVigentesResponse,
|
||||
type AlcadaRepResponse,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
// Regras de desconto vigentes para o usuário logado (rep recebe só as suas).
|
||||
// Usadas na montagem do pedido para pré-aplicar o desconto liberado pelo gerente.
|
||||
// Refetch curto: regra encerrada/inativada some da tela do rep em até 1 minuto.
|
||||
export function useRegrasVigentes() {
|
||||
return useQuery<RegrasVigentesResponse>({
|
||||
queryKey: ['politicas', 'regras-vigentes'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/regras/vigentes');
|
||||
return RegrasVigentesResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Alçada do usuário logado — teto de desconto por item no carrinho
|
||||
export function useMinhaAlcada() {
|
||||
return useQuery<AlcadaRepResponse>({
|
||||
queryKey: ['politicas', 'minha-alcada'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/alcada');
|
||||
return AlcadaRepResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep.
|
||||
// Refetch curto: promoção encerrada/inativada some da tela do rep em até 1 minuto.
|
||||
export function usePromocoesVigentes() {
|
||||
return useQuery<PromocoesVigentesResponse>({
|
||||
queryKey: ['politicas', 'promocoes-vigentes'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/promocoes/vigentes');
|
||||
return PromocoesVigentesResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 60 * 1000,
|
||||
refetchInterval: 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -1,12 +1,10 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
ReportMetaResponseSchema,
|
||||
ReportCarteiraResponseSchema,
|
||||
ReportAbcResponseSchema,
|
||||
type ReportMetaQuery,
|
||||
type ReportAbcQuery,
|
||||
type ReportMetaResponse,
|
||||
type ReportCarteiraResponse,
|
||||
type ReportAbcResponse,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
@@ -19,13 +17,6 @@ export function useReportMeta(params: ReportMetaQuery) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useReportCarteira() {
|
||||
return useQuery<ReportCarteiraResponse>({
|
||||
queryKey: ['reports', 'carteira'],
|
||||
queryFn: async () => ReportCarteiraResponseSchema.parse(await apiFetch('/reports/carteira')),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReportAbc(params: ReportAbcQuery) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.mes != null) qs.set('mes', String(params.mes));
|
||||
|
||||
102
apps/web/src/lib/queries/sac.ts
Normal file
102
apps/web/src/lib/queries/sac.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
ChamadoListResponseSchema,
|
||||
ChamadoSchema,
|
||||
type CreateChamadoDto,
|
||||
type AddMensagemDto,
|
||||
type ResolverChamadoDto,
|
||||
type Chamado,
|
||||
type ChamadoListResponse,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
export function useChamados() {
|
||||
return useQuery<ChamadoListResponse>({
|
||||
queryKey: ['chamados'],
|
||||
queryFn: async () => ChamadoListResponseSchema.parse(await apiFetch('/chamados')),
|
||||
});
|
||||
}
|
||||
|
||||
export function useChamado(id: number) {
|
||||
return useQuery<Chamado>({
|
||||
queryKey: ['chamados', id],
|
||||
queryFn: async () => ChamadoSchema.parse(await apiFetch(`/chamados/${id}`)),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateChamado() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: CreateChamadoDto): Promise<Chamado> =>
|
||||
apiFetch('/chamados', { method: 'POST', body: dto }).then((r) => ChamadoSchema.parse(r)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddMensagemRep(id: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
||||
apiFetch(`/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
|
||||
ChamadoSchema.parse(r),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['chamados', id] });
|
||||
qc.invalidateQueries({ queryKey: ['chamados'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCancelChamado() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number): Promise<Chamado> =>
|
||||
apiFetch(`/chamados/${id}/cancelar`, { method: 'PATCH' }).then((r) => ChamadoSchema.parse(r)),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['chamados'] }),
|
||||
});
|
||||
}
|
||||
|
||||
// Gerente
|
||||
export function useChamadosGer() {
|
||||
return useQuery<ChamadoListResponse>({
|
||||
queryKey: ['ger', 'chamados'],
|
||||
queryFn: async () => ChamadoListResponseSchema.parse(await apiFetch('/ger/chamados')),
|
||||
});
|
||||
}
|
||||
|
||||
export function useChamadoGer(id: number) {
|
||||
return useQuery<Chamado>({
|
||||
queryKey: ['ger', 'chamados', id],
|
||||
queryFn: async () => ChamadoSchema.parse(await apiFetch(`/ger/chamados/${id}`)),
|
||||
enabled: id > 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function useAddMensagemEmpresa(id: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: AddMensagemDto): Promise<Chamado> =>
|
||||
apiFetch(`/ger/chamados/${id}/mensagens`, { method: 'POST', body: dto }).then((r) =>
|
||||
ChamadoSchema.parse(r),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
||||
qc.invalidateQueries({ queryKey: ['ger', 'chamados'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useResolverChamado(id: number) {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (dto: ResolverChamadoDto): Promise<Chamado> =>
|
||||
apiFetch(`/ger/chamados/${id}/resolver`, { method: 'PATCH', body: dto }).then((r) =>
|
||||
ChamadoSchema.parse(r),
|
||||
),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['ger', 'chamados', id] });
|
||||
qc.invalidateQueries({ queryKey: ['ger', 'chamados'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,11 +1,34 @@
|
||||
import { QueryClient } from '@tanstack/react-query';
|
||||
import { MutationCache, QueryCache, QueryClient } from '@tanstack/react-query';
|
||||
import { ApiError } from './api-client';
|
||||
import { notifyError } from './feedback';
|
||||
|
||||
function describeError(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
return error.problem.detail ?? error.problem.title ?? 'Erro no servidor';
|
||||
}
|
||||
if (error instanceof Error && error.message) return error.message;
|
||||
return 'Erro inesperado';
|
||||
}
|
||||
|
||||
/**
|
||||
* QueryClient canônico do SAR.
|
||||
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
|
||||
* stale-while-revalidate para tempo real bater no Socket.IO, não em polling.
|
||||
* Erros nunca são silenciosos: falha de query/mutation sem tratamento local
|
||||
* vira toast — Sandra/Daniel jamais devem "achar que salvou".
|
||||
*/
|
||||
export const queryClient = new QueryClient({
|
||||
queryCache: new QueryCache({
|
||||
onError: (error) => {
|
||||
notifyError(`Falha ao carregar dados: ${describeError(error)}`, 'query-error');
|
||||
},
|
||||
}),
|
||||
mutationCache: new MutationCache({
|
||||
onError: (error, _variables, _context, mutation) => {
|
||||
if (mutation.options.onError) return; // a tela já dá feedback próprio
|
||||
notifyError(describeError(error), 'mutation-error');
|
||||
},
|
||||
}),
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
Outlet,
|
||||
notFound,
|
||||
} from '@tanstack/react-router';
|
||||
import { Typography } from 'antd';
|
||||
import { Button, Result, Typography } from 'antd';
|
||||
import { AppShell } from '../components/layout/AppShell';
|
||||
import { RepPainel } from '../cockpits/rep/RepPainel';
|
||||
import { ClientsPage } from '../cockpits/rep/ClientsPage';
|
||||
@@ -18,11 +18,14 @@ import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
|
||||
import { CatalogPage } from '../cockpits/rep/CatalogPage';
|
||||
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
|
||||
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
|
||||
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
|
||||
import { FunilPage } from '../cockpits/rep/FunilPage';
|
||||
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 { ChamadosPage } from '../cockpits/rep/ChamadosPage';
|
||||
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
|
||||
import { GerConfigPage } from '../cockpits/ger/GerConfigPage';
|
||||
import { authStore } from './auth-store';
|
||||
|
||||
function getRoleFromToken(): string {
|
||||
@@ -43,6 +46,23 @@ function HomeRoute() {
|
||||
return <RepPainel />;
|
||||
}
|
||||
|
||||
// Error boundary por rota: uma exceção de render (ex.: ZodError de payload
|
||||
// inesperado) mostra esta tela em vez de derrubar o app inteiro em branco.
|
||||
function RouteErrorPage({ error }: { error: Error }) {
|
||||
return (
|
||||
<Result
|
||||
status="error"
|
||||
title="Algo deu errado"
|
||||
subTitle={error.message || 'Erro inesperado ao exibir esta tela.'}
|
||||
extra={
|
||||
<Button type="primary" onClick={() => window.location.reload()}>
|
||||
Recarregar
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function NotFoundPage() {
|
||||
return (
|
||||
<div style={{ padding: 48, textAlign: 'center' }}>
|
||||
@@ -63,6 +83,7 @@ const rootRoute = createRootRoute({
|
||||
</AppShell>
|
||||
),
|
||||
notFoundComponent: NotFoundPage,
|
||||
errorComponent: RouteErrorPage,
|
||||
});
|
||||
|
||||
const indexRoute = createRoute({
|
||||
@@ -137,10 +158,10 @@ const relatoriosRoute = createRoute({
|
||||
component: RelatoriosPage,
|
||||
});
|
||||
|
||||
const aprovacoes = createRoute({
|
||||
const funilRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/aprovacoes',
|
||||
component: ApprovalQueuePage,
|
||||
path: '/funil',
|
||||
component: FunilPage,
|
||||
});
|
||||
|
||||
const gerPainelRoute = createRoute({
|
||||
@@ -161,6 +182,24 @@ const politicasRoute = createRoute({
|
||||
component: PoliticasPage,
|
||||
});
|
||||
|
||||
const chamadosRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/chamados',
|
||||
component: ChamadosPage,
|
||||
});
|
||||
|
||||
const gerChamadosRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/ger/chamados',
|
||||
component: GerChamadosPage,
|
||||
});
|
||||
|
||||
const gerConfigRoute = createRoute({
|
||||
getParentRoute: () => rootRoute,
|
||||
path: '/ger/config',
|
||||
component: GerConfigPage,
|
||||
});
|
||||
|
||||
const routeTree = rootRoute.addChildren([
|
||||
indexRoute,
|
||||
rafaelRoute,
|
||||
@@ -174,16 +213,20 @@ const routeTree = rootRoute.addChildren([
|
||||
catalogoRoute,
|
||||
pedidosErpRoute,
|
||||
relatoriosRoute,
|
||||
aprovacoes,
|
||||
funilRoute,
|
||||
gerPainelRoute,
|
||||
equipeRoute,
|
||||
politicasRoute,
|
||||
chamadosRoute,
|
||||
gerChamadosRoute,
|
||||
gerConfigRoute,
|
||||
]);
|
||||
|
||||
export const router = createRouter({
|
||||
routeTree,
|
||||
defaultPreload: 'intent',
|
||||
defaultPreloadStaleTime: 0,
|
||||
defaultErrorComponent: RouteErrorPage,
|
||||
});
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
|
||||
12
apps/web/src/lib/uuid.ts
Normal file
12
apps/web/src/lib/uuid.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// crypto.randomUUID só existe em contexto seguro (HTTPS/localhost). Acessando o
|
||||
// dev server pelo IP da rede (http://192.168...) ela é undefined — fallback gera
|
||||
// UUID v4 com crypto.getRandomValues, disponível em qualquer contexto.
|
||||
export function randomUUID(): string {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40; // versão 4
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variante RFC 4122
|
||||
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||
}
|
||||
14
apps/web/src/svg-maps.d.ts
vendored
Normal file
14
apps/web/src/svg-maps.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
declare module '@svg-maps/brazil' {
|
||||
interface SvgMapLocation {
|
||||
id: string; // sigla da UF em minúsculas (sp, pr, …)
|
||||
name: string;
|
||||
path: string;
|
||||
}
|
||||
interface SvgMap {
|
||||
label: string;
|
||||
viewBox: string;
|
||||
locations: SvgMapLocation[];
|
||||
}
|
||||
const map: SvgMap;
|
||||
export default map;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export default defineConfig(() => ({
|
||||
server: {
|
||||
port: 4200,
|
||||
host: '0.0.0.0',
|
||||
allowedHosts: true,
|
||||
// Proxy /api/* → API Nest em :3000 (default API_PORT).
|
||||
// Evita CORS em dev e mantém URL relativa no código da Web — em produção,
|
||||
// mesmo origin via Nginx (/api/* → backend).
|
||||
@@ -43,6 +44,7 @@ export default defineConfig(() => ({
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
|
||||
passWithNoTests: true,
|
||||
reporters: ['default'],
|
||||
coverage: {
|
||||
reportsDirectory: '../../coverage/apps/web',
|
||||
|
||||
@@ -20,7 +20,6 @@ services:
|
||||
# subdir por major-version pra suportar pg_upgrade --link sem
|
||||
# boundary issues. Ref: docker-library/postgres#1259.
|
||||
- sar-postgres-data:/var/lib/postgresql
|
||||
- ./scripts/postgres-init:/docker-entrypoint-initdb.d:ro
|
||||
healthcheck:
|
||||
test: ['CMD-SHELL', 'pg_isready -U sar -d sar_master']
|
||||
interval: 10s
|
||||
|
||||
@@ -9,3 +9,5 @@ export * from './lib/company.contract';
|
||||
export * from './lib/report.contract';
|
||||
export * from './lib/politicas.contract';
|
||||
export * from './lib/equipe.contract';
|
||||
export * from './lib/funil.contract';
|
||||
export * from './lib/sac.contract';
|
||||
|
||||
@@ -9,7 +9,8 @@ const JwtRoleSchema = z.enum(['rep', 'supervisor', 'manager', 'admin']);
|
||||
|
||||
export const DevTokenRequestSchema = z.object({
|
||||
userId: z.string().min(1),
|
||||
idEmpresa: z.coerce.number().int().positive(),
|
||||
// Opcional: quando ausente, o backend usa DEV_EMPRESA_ID do .env
|
||||
idEmpresa: z.coerce.number().int().positive().optional(),
|
||||
role: JwtRoleSchema,
|
||||
});
|
||||
|
||||
|
||||
@@ -93,7 +93,10 @@ export const CreateContatoSchema = z.object({
|
||||
empresa: z.string().max(100).optional(),
|
||||
cargo: z.string().max(100).optional(),
|
||||
departamento: z.string().max(100).optional(),
|
||||
dtAniversario: z.string().optional(),
|
||||
dtAniversario: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD')
|
||||
.optional(),
|
||||
telefone: z.string().max(20).optional(),
|
||||
ramal: z.string().max(10).optional(),
|
||||
celular: z.string().max(20).optional(),
|
||||
@@ -115,6 +118,7 @@ export type ContatoResult = z.infer<typeof ContatoResultSchema>;
|
||||
|
||||
export const TopProdutoSchema = z.object({
|
||||
idProduto: z.number().int(),
|
||||
codProduto: z.string(),
|
||||
descricao: z.string(),
|
||||
unidade: z.string().nullable(),
|
||||
qtdTotal: z.number(),
|
||||
|
||||
@@ -17,5 +17,18 @@ export const EmpresaInfoSchema = z.object({
|
||||
cep: z.string().nullable(),
|
||||
telefone: z.string().nullable(),
|
||||
email: z.string().nullable(),
|
||||
// Logo (data URL) exibido no cabeçalho da impressão — sar.config_empresa
|
||||
logoBase64: z.string().nullable().optional(),
|
||||
});
|
||||
export type EmpresaInfo = z.infer<typeof EmpresaInfoSchema>;
|
||||
|
||||
// Upload do logo pelo gerente — data URL de imagem; null remove o logo.
|
||||
// ~700KB de base64 = imagem de ~500KB, suficiente para logo de cabeçalho.
|
||||
export const UpdateLogoBodySchema = z.object({
|
||||
logoBase64: z
|
||||
.string()
|
||||
.max(700_000)
|
||||
.regex(/^data:image\/(png|jpeg|jpg|webp);base64,[A-Za-z0-9+/=]+$/)
|
||||
.nullable(),
|
||||
});
|
||||
export type UpdateLogoBody = z.infer<typeof UpdateLogoBodySchema>;
|
||||
|
||||
@@ -39,6 +39,14 @@ export const MetaItemSchema = z.object({
|
||||
});
|
||||
export type MetaItem = z.infer<typeof MetaItemSchema>;
|
||||
|
||||
export const MesAnoSchema = z.object({
|
||||
mes: z.number().int().min(1).max(12),
|
||||
valor: z.number(),
|
||||
meta: z.number(),
|
||||
atingiu: z.boolean(),
|
||||
});
|
||||
export type MesAno = z.infer<typeof MesAnoSchema>;
|
||||
|
||||
export const RepDashboardSchema = z.object({
|
||||
meta: z.object({
|
||||
atingido: z.number(),
|
||||
@@ -52,6 +60,7 @@ export const RepDashboardSchema = z.object({
|
||||
pedidosRecentes: z.array(PedidoSummarySchema),
|
||||
naoPositivados: z.array(ClienteNaoPositivadoSchema),
|
||||
totalNaoPositivados: z.number().int(),
|
||||
historicoMensal: z.array(MesAnoSchema).default([]),
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
export type RepDashboard = z.infer<typeof RepDashboardSchema>;
|
||||
@@ -63,20 +72,18 @@ export const RepInativosSummarySchema = z.object({
|
||||
});
|
||||
export type RepInativosSummary = z.infer<typeof RepInativosSummarySchema>;
|
||||
|
||||
export const SupervisorDashboardSchema = z.object({
|
||||
approvalQueue: z.array(PedidoSummarySchema),
|
||||
pedidosDia: z.object({
|
||||
count: z.number().int(),
|
||||
total: z.number(),
|
||||
countSemanaAnterior: z.number().int(),
|
||||
totalSemanaAnterior: z.number(),
|
||||
}),
|
||||
inativosPorRep: z.array(RepInativosSummarySchema),
|
||||
syncedAt: z.iso.datetime(),
|
||||
// Detalhe dos clientes inativos (>30 dias sem compra) de um representante
|
||||
export const RepInativosDetailSchema = z.object({
|
||||
codVendedor: z.number().int(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
clientes: z.array(ClienteNaoPositivadoSchema),
|
||||
});
|
||||
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
|
||||
export type RepInativosDetail = z.infer<typeof RepInativosDetailSchema>;
|
||||
|
||||
// novosClientes: clientes da carteira cuja PRIMEIRA compra (historico todo)
|
||||
// caiu no periodo selecionado
|
||||
export const PositivacaoRepSchema = z.object({
|
||||
novosClientes: z.number().int().default(0),
|
||||
codVendedor: z.number().int(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
totalClientes: z.number().int(),
|
||||
@@ -95,6 +102,57 @@ export const RankingRepSchema = z.object({
|
||||
});
|
||||
export type RankingRep = z.infer<typeof RankingRepSchema>;
|
||||
|
||||
export const SupervisorDashboardSchema = z.object({
|
||||
pedidosDia: z.object({
|
||||
count: z.number().int(),
|
||||
total: z.number(),
|
||||
countSemanaAnterior: z.number().int(),
|
||||
totalSemanaAnterior: z.number(),
|
||||
}),
|
||||
inativosPorRep: z.array(RepInativosSummarySchema),
|
||||
// Métricas do mês corrente, escopadas à equipe do supervisor
|
||||
equipeMes: z
|
||||
.object({
|
||||
faturamento: z.number(),
|
||||
pedidos: z.number().int(),
|
||||
ticketMedio: z.number(),
|
||||
metaTotal: z.number(),
|
||||
pctMeta: z.number(),
|
||||
})
|
||||
.default({ faturamento: 0, pedidos: 0, ticketMedio: 0, metaTotal: 0, pctMeta: 0 }),
|
||||
rankingReps: z.array(RankingRepSchema).default([]),
|
||||
positivacaoReps: z.array(PositivacaoRepSchema).default([]),
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
|
||||
|
||||
// Clientes distintos positivados por dia do período (YYYY-MM-DD)
|
||||
export const PositivacaoDiaSchema = z.object({
|
||||
dia: z.string(),
|
||||
clientes: z.number().int(),
|
||||
});
|
||||
export type PositivacaoDia = z.infer<typeof PositivacaoDiaSchema>;
|
||||
|
||||
// Faturamento por estado (UF do município do cliente), com detalhe por representante.
|
||||
export const UfRepSchema = z.object({
|
||||
codVendedor: z.number().int(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
faturamento: z.number(),
|
||||
pedidos: z.number().int(),
|
||||
clientes: z.number().int(),
|
||||
});
|
||||
export type UfRep = z.infer<typeof UfRepSchema>;
|
||||
|
||||
export const UfFaturamentoSchema = z.object({
|
||||
uf: z.string(), // sigla (SP, PR…); 'ND' = cliente sem município/UF
|
||||
faturamento: z.number(),
|
||||
pedidos: z.number().int(),
|
||||
clientes: z.number().int(),
|
||||
pct: z.number(), // participação no faturamento do período
|
||||
reps: z.array(UfRepSchema),
|
||||
});
|
||||
export type UfFaturamento = z.infer<typeof UfFaturamentoSchema>;
|
||||
|
||||
export const ManagerDashboardSchema = z.object({
|
||||
faturamentoMes: z.number(),
|
||||
pedidosMes: z.number().int(),
|
||||
@@ -105,6 +163,16 @@ export const ManagerDashboardSchema = z.object({
|
||||
metasPorGrupo: z.array(MetaItemSchema).default([]),
|
||||
rankingReps: z.array(RankingRepSchema),
|
||||
positivacaoReps: z.array(PositivacaoRepSchema),
|
||||
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
|
||||
faturamentoPorUf: z.array(UfFaturamentoSchema).default([]),
|
||||
// Meta diária de positivação salva pelo gerente (config_empresa)
|
||||
metaPositivacaoDia: z.number().int().nullable().optional(),
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;
|
||||
|
||||
// Salvar a meta diária de positivação (0 limpa a meta)
|
||||
export const SaveMetaPositivacaoBodySchema = z.object({
|
||||
meta: z.number().int().min(0).max(100000),
|
||||
});
|
||||
export type SaveMetaPositivacaoBody = z.infer<typeof SaveMetaPositivacaoBodySchema>;
|
||||
|
||||
@@ -16,3 +16,30 @@ export const EquipeResponseSchema = z.object({
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
export type EquipeResponse = z.infer<typeof EquipeResponseSchema>;
|
||||
|
||||
// Card de disputa/análise por supervisor na aba Equipe (visão gerente/admin).
|
||||
// Time = reps cujo cod_supervisor aponta para este supervisor + o próprio
|
||||
// supervisor (mesma semântica de getTeamCodes). Métricas agregadas do time no mês.
|
||||
export const SupervisorCardSchema = z.object({
|
||||
codSupervisor: z.number().int(),
|
||||
nomeSupervisor: z.string().nullable(),
|
||||
faturamentoMes: z.number(),
|
||||
faturamentoMesAnterior: z.number(),
|
||||
variacaoPct: z.number().nullable(), // null quando não há base no mês anterior
|
||||
pedidosMes: z.number().int(),
|
||||
ticketMedio: z.number(),
|
||||
metaTotal: z.number(),
|
||||
pctMeta: z.number(),
|
||||
totalClientes: z.number().int(),
|
||||
clientesPositivados: z.number().int(),
|
||||
pctPositivacao: z.number(),
|
||||
repsTotal: z.number().int(),
|
||||
repsAtivos: z.number().int(), // reps do time que faturaram no mês
|
||||
});
|
||||
export type SupervisorCard = z.infer<typeof SupervisorCardSchema>;
|
||||
|
||||
export const SupervisoresResponseSchema = z.object({
|
||||
supervisores: z.array(SupervisorCardSchema),
|
||||
syncedAt: z.iso.datetime(),
|
||||
});
|
||||
export type SupervisoresResponse = z.infer<typeof SupervisoresResponseSchema>;
|
||||
|
||||
45
libs/shared/api-interface/src/lib/funil.contract.ts
Normal file
45
libs/shared/api-interface/src/lib/funil.contract.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ETAPAS_FUNIL = ['lead', 'proposta', 'negociacao', 'ganho', 'perdido'] as const;
|
||||
export type EtapaFunil = (typeof ETAPAS_FUNIL)[number];
|
||||
|
||||
export const OportunidadeSchema = z.object({
|
||||
id: z.number(),
|
||||
idCliente: z.number().nullable(),
|
||||
nomeProspect: z.string().nullable(),
|
||||
empresaProspect: z.string().nullable(),
|
||||
titulo: z.string(),
|
||||
etapa: z.enum(ETAPAS_FUNIL),
|
||||
valorEstimado: z.string().nullable(),
|
||||
observacoes: z.string().nullable(),
|
||||
idPedido: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
nomeCliente: z.string().nullable(),
|
||||
});
|
||||
export type Oportunidade = z.infer<typeof OportunidadeSchema>;
|
||||
|
||||
export const CreateOportunidadeSchema = z.object({
|
||||
idCliente: z.number().int().positive().nullable().optional(),
|
||||
nomeProspect: z.string().max(200).nullable().optional(),
|
||||
empresaProspect: z.string().max(200).nullable().optional(),
|
||||
titulo: z.string().min(1).max(200),
|
||||
etapa: z.enum(ETAPAS_FUNIL).default('lead'),
|
||||
valorEstimado: z.number().positive().nullable().optional(),
|
||||
observacoes: z.string().max(2000).nullable().optional(),
|
||||
});
|
||||
export type CreateOportunidadeDto = z.infer<typeof CreateOportunidadeSchema>;
|
||||
|
||||
export const UpdateOportunidadeSchema = CreateOportunidadeSchema.partial().extend({
|
||||
idPedido: z.string().nullable().optional(),
|
||||
});
|
||||
export type UpdateOportunidadeDto = z.infer<typeof UpdateOportunidadeSchema>;
|
||||
|
||||
export const FunilResponseSchema = z.object({
|
||||
lead: z.array(OportunidadeSchema),
|
||||
proposta: z.array(OportunidadeSchema),
|
||||
negociacao: z.array(OportunidadeSchema),
|
||||
ganho: z.array(OportunidadeSchema),
|
||||
perdido: z.array(OportunidadeSchema),
|
||||
});
|
||||
export type FunilResponse = z.infer<typeof FunilResponseSchema>;
|
||||
@@ -11,6 +11,11 @@ export const SubscribePayloadSchema = z.object({
|
||||
});
|
||||
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>;
|
||||
|
||||
export const UnsubscribePayloadSchema = z.object({
|
||||
endpoint: z.string().url(),
|
||||
});
|
||||
export type UnsubscribePayload = z.infer<typeof UnsubscribePayloadSchema>;
|
||||
|
||||
export const PendingCountResponseSchema = z.object({
|
||||
count: z.number().int().min(0),
|
||||
});
|
||||
|
||||
@@ -6,9 +6,9 @@ import { z } from 'zod';
|
||||
|
||||
// ─── Situa ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Ciclo de vida do pedido SAR:
|
||||
// 0=Orçamento → 1=Ag. Aprovação (se desconto > alçada) → 2=Transmitido
|
||||
// Estados que o SAR controla: Orçamento e Transmitido (1 é o gate de desconto).
|
||||
// Ciclo de vida do pedido SAR: 0=Orçamento → 2=Transmitido.
|
||||
// Desconto acima da alçada é bloqueio duro na transmissão — não há fila de
|
||||
// aprovação. O valor 1 (Ag. Aprovação) é legado, mantido só para dados antigos.
|
||||
// Após Transmitido, o status passa a refletir o ERP (Emitido/Cancelado/Aguardando…)
|
||||
// — espelhado quando a integração existir.
|
||||
export const SituaPedidoSchema = z.union([
|
||||
@@ -40,6 +40,10 @@ export const PedidoItemSchema = z.object({
|
||||
precoUnitario: z.string(),
|
||||
descontoPerc: z.string(),
|
||||
total: z.string(),
|
||||
// Grupo/subgrupo do produto (vw_produtos) — edição do orçamento usa para o
|
||||
// teto de desconto. Ausente em pedidos ERP.
|
||||
codGrupo: z.number().int().nullable().optional(),
|
||||
codSubgrupo: z.number().int().nullable().optional(),
|
||||
});
|
||||
export type PedidoItem = z.infer<typeof PedidoItemSchema>;
|
||||
|
||||
@@ -88,6 +92,13 @@ export const PedidoDetailSchema = PedidoSummarySchema.extend({
|
||||
comissao: z.string(),
|
||||
pedFlex: z.string(),
|
||||
formaPagamento: z.string().nullable().optional(),
|
||||
endEntrega: z.string().nullable().optional(),
|
||||
// Pauta/forma de pagamento do pedido SAR — edição do orçamento pré-carrega
|
||||
idPauta: z.number().int().nullable().optional(),
|
||||
codFormapag: z.number().int().nullable().optional(),
|
||||
// Peso total (kg) e cubagem (m3) — qtd x peso_liquido/vol_m3 de vw_produtos
|
||||
pesoTotal: z.string().optional(),
|
||||
m3Total: z.string().optional(),
|
||||
aprovadoPor: z.number().int().nullable(),
|
||||
aprovadoEm: z.iso.datetime().nullable(),
|
||||
motivoRecusa: z.string().nullable(),
|
||||
@@ -100,12 +111,16 @@ export type PedidoDetail = z.infer<typeof PedidoDetailSchema>;
|
||||
|
||||
// ─── List query + response ────────────────────────────────────────────────────
|
||||
|
||||
// Datas de filtro sempre YYYY-MM-DD — interpoladas em SQL no servidor, o formato
|
||||
// fechado é parte da defesa contra injection, não só validação de UX.
|
||||
export const DateOnlySchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Data deve ser YYYY-MM-DD');
|
||||
|
||||
export const PedidoListQuerySchema = z.object({
|
||||
idCliente: z.coerce.number().int().optional(),
|
||||
situa: z.coerce.number().int().optional(),
|
||||
numPedSar: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
from: DateOnlySchema.optional(),
|
||||
to: DateOnlySchema.optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
});
|
||||
@@ -138,21 +153,22 @@ export const CreatePedidoSchema = z.object({
|
||||
idPauta: z.number().int().optional(),
|
||||
codFormapag: z.number().int().optional(),
|
||||
obs: z.string().optional(),
|
||||
endEntrega: z.string().optional(),
|
||||
idempotencyKey: z.string().optional(),
|
||||
itens: z.array(CreatePedidoItemSchema).min(1),
|
||||
});
|
||||
export type CreatePedido = z.infer<typeof CreatePedidoSchema>;
|
||||
|
||||
export const AprovarPedidoSchema = z.object({
|
||||
descontoPerc: z.number().min(0).max(100).optional(),
|
||||
nota: z.string().optional(),
|
||||
});
|
||||
export type AprovarPedido = z.infer<typeof AprovarPedidoSchema>;
|
||||
// Edição de orçamento (situa 0): mesmo shape da criação, sem idempotencyKey
|
||||
export const UpdatePedidoSchema = CreatePedidoSchema.omit({ idempotencyKey: true });
|
||||
export type UpdatePedido = z.infer<typeof UpdatePedidoSchema>;
|
||||
|
||||
export const RecusarPedidoSchema = z.object({
|
||||
motivo: z.string().min(1),
|
||||
// Cancelamento com motivo obrigatório + descrição livre opcional
|
||||
export const CancelPedidoSchema = z.object({
|
||||
motivo: z.string().min(1).max(60),
|
||||
descricao: z.string().max(500).optional(),
|
||||
});
|
||||
export type RecusarPedido = z.infer<typeof RecusarPedidoSchema>;
|
||||
export type CancelPedido = z.infer<typeof CancelPedidoSchema>;
|
||||
|
||||
// ─── ERP Consulta ─────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -182,8 +198,8 @@ export type PedidoErpConsultaItem = z.infer<typeof PedidoErpConsultaItemSchema>;
|
||||
|
||||
export const PedidoErpConsultaQuerySchema = z.object({
|
||||
search: z.string().optional(),
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
from: DateOnlySchema.optional(),
|
||||
to: DateOnlySchema.optional(),
|
||||
page: z.coerce.number().int().positive().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).default(50),
|
||||
});
|
||||
|
||||
@@ -5,7 +5,7 @@ describe('PingResponseSchema', () => {
|
||||
status: 'ok',
|
||||
service: 'sar-api',
|
||||
version: '0.1.0',
|
||||
workspaceId: 'dev-workspace',
|
||||
idEmpresa: 1,
|
||||
requestId: '550e8400-e29b-41d4-a716-446655440000',
|
||||
uptimeSeconds: 42,
|
||||
now: '2026-05-27T12:34:56.000Z',
|
||||
@@ -36,8 +36,8 @@ describe('PingResponseSchema', () => {
|
||||
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
||||
});
|
||||
|
||||
it('rejeita workspaceId vazio', () => {
|
||||
const bad = { ...validPayload, workspaceId: '' };
|
||||
it('rejeita idEmpresa não inteiro', () => {
|
||||
const bad = { ...validPayload, idEmpresa: 1.5 };
|
||||
expect(() => PingResponseSchema.parse(bad)).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,25 +1,5 @@
|
||||
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(),
|
||||
@@ -39,17 +19,160 @@ export const PromocoesResponseSchema = z.object({
|
||||
});
|
||||
export type PromocoesResponse = z.infer<typeof PromocoesResponseSchema>;
|
||||
|
||||
// Criação aceita VÁRIOS produtos e/ou grupos — o backend cria uma promoção
|
||||
// por alvo (mesma descrição/%/vigência), mantendo o motor de alçada intacto.
|
||||
export const CreatePromocaoBodySchema = z.object({
|
||||
descricao: z.string().min(1).max(255),
|
||||
codProduto: z.string().optional(),
|
||||
grpProd: z.string().optional(),
|
||||
codProdutos: z.array(z.string().min(1)).max(100).optional(),
|
||||
grpProds: z.array(z.string().min(1)).max(100).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({
|
||||
// Edição continua por linha (um alvo por promoção)
|
||||
export const UpdatePromocaoBodySchema = z.object({
|
||||
descricao: z.string().min(1).max(255).optional(),
|
||||
codProduto: z.string().nullable().optional(),
|
||||
grpProd: z.string().nullable().optional(),
|
||||
descPct: z.number().min(0).max(100).optional(),
|
||||
dataInicio: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
dataFim: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
ativa: z.boolean().optional(),
|
||||
});
|
||||
export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
|
||||
|
||||
// ─── Regras de Desconto ───────────────────────────────────────────────────────
|
||||
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
|
||||
// produto, pauta de preços e/ou representante, com vigência. Campos nulos = "todos".
|
||||
|
||||
export const RegraDescontoSchema = z.object({
|
||||
id: z.number().int(),
|
||||
descricao: z.string(),
|
||||
codGrupo: z.number().int().nullable(),
|
||||
grupo: z.string().nullable(),
|
||||
codSubgrupo: z.number().int().nullable(),
|
||||
subgrupo: z.string().nullable(),
|
||||
idPauta: z.number().int().nullable(),
|
||||
pauta: z.string().nullable(),
|
||||
codVendedor: z.number().int().nullable(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
descPct: z.number(),
|
||||
dataInicio: z.string(),
|
||||
dataFim: z.string(),
|
||||
ativa: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
createdBy: z.string(),
|
||||
});
|
||||
export type RegraDesconto = z.infer<typeof RegraDescontoSchema>;
|
||||
|
||||
export const RegrasDescontoResponseSchema = z.object({
|
||||
regras: z.array(RegraDescontoSchema),
|
||||
});
|
||||
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
|
||||
|
||||
// Criação aceita VÁRIOS grupos, subgrupos e/ou pautas — uma regra por alvo
|
||||
// (cada grupo vira regra por grupo; cada subgrupo, regra por subgrupo; cada
|
||||
// pauta, regra por pauta). O representante escolhido é copiado em todas.
|
||||
export const CreateRegraDescontoBodySchema = z.object({
|
||||
descricao: z.string().min(1).max(200),
|
||||
codGrupos: z.array(z.number().int()).max(100).optional(),
|
||||
codSubgrupos: z.array(z.number().int()).max(100).optional(),
|
||||
idPautas: z.array(z.number().int()).max(100).optional(),
|
||||
codVendedor: z.number().int().nullable().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 CreateRegraDescontoBody = z.infer<typeof CreateRegraDescontoBodySchema>;
|
||||
|
||||
// Edição continua por linha (um alvo por regra)
|
||||
export const UpdateRegraDescontoBodySchema = z.object({
|
||||
descricao: z.string().min(1).max(200).optional(),
|
||||
codGrupo: z.number().int().nullable().optional(),
|
||||
codSubgrupo: z.number().int().nullable().optional(),
|
||||
idPauta: z.number().int().nullable().optional(),
|
||||
codVendedor: z.number().int().nullable().optional(),
|
||||
descPct: z.number().min(0).max(100).optional(),
|
||||
dataInicio: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
dataFim: z
|
||||
.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||
.optional(),
|
||||
ativa: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
|
||||
|
||||
// Alçada do usuário logado — usada pelo carrinho para mostrar o teto por item
|
||||
export const AlcadaRepItemSchema = z.object({
|
||||
codGrupo: z.number().int(),
|
||||
limitePerc: z.number(),
|
||||
});
|
||||
export type AlcadaRepItem = z.infer<typeof AlcadaRepItemSchema>;
|
||||
|
||||
export const AlcadaRepResponseSchema = z.object({
|
||||
limites: z.array(AlcadaRepItemSchema),
|
||||
});
|
||||
export type AlcadaRepResponse = z.infer<typeof AlcadaRepResponseSchema>;
|
||||
|
||||
// Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
|
||||
export const PromocaoVigenteSchema = z.object({
|
||||
id: z.number().int(),
|
||||
descricao: z.string(),
|
||||
codProduto: z.string().nullable(),
|
||||
nomeProduto: z.string().nullable(),
|
||||
grpProd: z.string().nullable(),
|
||||
grupo: z.string().nullable(),
|
||||
descPct: z.number(),
|
||||
dataFim: z.string(),
|
||||
});
|
||||
export type PromocaoVigente = z.infer<typeof PromocaoVigenteSchema>;
|
||||
|
||||
export const PromocoesVigentesResponseSchema = z.object({
|
||||
promocoes: z.array(PromocaoVigenteSchema),
|
||||
});
|
||||
export type PromocoesVigentesResponse = z.infer<typeof PromocoesVigentesResponseSchema>;
|
||||
|
||||
// Regras vigentes para o representante logado — aplicadas na montagem do pedido
|
||||
export const RegraVigenteSchema = z.object({
|
||||
id: z.number().int(),
|
||||
descricao: z.string(),
|
||||
codGrupo: z.number().int().nullable(),
|
||||
grupo: z.string().nullable(),
|
||||
codSubgrupo: z.number().int().nullable(),
|
||||
subgrupo: z.string().nullable(),
|
||||
idPauta: z.number().int().nullable(),
|
||||
pauta: z.string().nullable(),
|
||||
descPct: z.number(),
|
||||
dataFim: z.string(),
|
||||
});
|
||||
export type RegraVigente = z.infer<typeof RegraVigenteSchema>;
|
||||
|
||||
export const RegrasVigentesResponseSchema = z.object({
|
||||
regras: z.array(RegraVigenteSchema),
|
||||
});
|
||||
export type RegrasVigentesResponse = z.infer<typeof RegrasVigentesResponseSchema>;
|
||||
|
||||
// Grupos/subgrupos de produto (combos do formulário do gerente)
|
||||
export const GrupoProdutoItemSchema = z.object({
|
||||
codGrupo: z.number().int().nullable(),
|
||||
grupo: z.string().nullable(),
|
||||
codSubgrupo: z.number().int().nullable(),
|
||||
subgrupo: z.string().nullable(),
|
||||
});
|
||||
export type GrupoProdutoItem = z.infer<typeof GrupoProdutoItemSchema>;
|
||||
|
||||
export const GruposProdutoResponseSchema = z.object({
|
||||
grupos: z.array(GrupoProdutoItemSchema),
|
||||
});
|
||||
export type GruposProdutoResponse = z.infer<typeof GruposProdutoResponseSchema>;
|
||||
|
||||
@@ -27,6 +27,8 @@ export const PautaSchema = z.object({
|
||||
idPauta: z.number().int(),
|
||||
codigo: z.number().int(),
|
||||
descricao: z.string(),
|
||||
// Presente só para gestor: quantos representantes usam esta pauta
|
||||
qtdReps: z.number().int().optional(),
|
||||
});
|
||||
export type Pauta = z.infer<typeof PautaSchema>;
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ export const CarteiraClienteSchema = z.object({
|
||||
diasSemPedido: z.number().nullable(),
|
||||
totalPedidos: z.number(),
|
||||
ticketMedio: z.string(),
|
||||
faturamentoTotal: z.string(),
|
||||
participacaoPct: z.number(),
|
||||
});
|
||||
export type CarteiraCliente = z.infer<typeof CarteiraClienteSchema>;
|
||||
|
||||
@@ -39,6 +41,7 @@ export const ReportCarteiraResponseSchema = z.object({
|
||||
inativos30: z.number(),
|
||||
inativos60: z.number(),
|
||||
semPedido: z.number(),
|
||||
faturamentoRepTotal: z.string(),
|
||||
});
|
||||
export type ReportCarteiraResponse = z.infer<typeof ReportCarteiraResponseSchema>;
|
||||
|
||||
|
||||
127
libs/shared/api-interface/src/lib/sac.contract.ts
Normal file
127
libs/shared/api-interface/src/lib/sac.contract.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const TIPOS_CHAMADO = [
|
||||
'avaria',
|
||||
'quantidade_divergente',
|
||||
'produto_errado',
|
||||
'prazo_entrega',
|
||||
'outro',
|
||||
] as const;
|
||||
|
||||
export const STATUS_CHAMADO = [
|
||||
'aberto',
|
||||
'em_analise',
|
||||
'aguardando_rep',
|
||||
'resolvido',
|
||||
'cancelado',
|
||||
] as const;
|
||||
|
||||
export const RESOLUCAO_CHAMADO = [
|
||||
'reposicao',
|
||||
'desconto',
|
||||
'cancelamento_item',
|
||||
'sem_acao',
|
||||
'outro',
|
||||
] as const;
|
||||
|
||||
export const TIPO_CHAMADO_LABEL: Record<string, string> = {
|
||||
avaria: 'Avaria / Dano',
|
||||
quantidade_divergente: 'Quantidade Divergente',
|
||||
produto_errado: 'Produto Errado',
|
||||
prazo_entrega: 'Prazo de Entrega',
|
||||
outro: 'Outro',
|
||||
};
|
||||
|
||||
export const STATUS_CHAMADO_LABEL: Record<string, string> = {
|
||||
aberto: 'Aberto',
|
||||
em_analise: 'Em Análise',
|
||||
aguardando_rep: 'Aguardando Rep.',
|
||||
resolvido: 'Resolvido',
|
||||
cancelado: 'Cancelado',
|
||||
};
|
||||
|
||||
export const RESOLUCAO_CHAMADO_LABEL: Record<string, string> = {
|
||||
reposicao: 'Reposição do Item',
|
||||
desconto: 'Desconto / Abatimento',
|
||||
cancelamento_item: 'Cancelamento do Item',
|
||||
sem_acao: 'Sem Ação Necessária',
|
||||
outro: 'Outro',
|
||||
};
|
||||
|
||||
export const ItemAfetadoSchema = z.object({
|
||||
codProduto: z.string(),
|
||||
nomeProduto: z.string(),
|
||||
qtd: z.number(),
|
||||
});
|
||||
|
||||
export const ChamadoMensagemSchema = z.object({
|
||||
id: z.number(),
|
||||
idChamado: z.number(),
|
||||
autor: z.enum(['rep', 'empresa']),
|
||||
texto: z.string(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export const ChamadoSchema = z.object({
|
||||
id: z.number(),
|
||||
idEmpresa: z.number(),
|
||||
codVendedor: z.number(),
|
||||
idPedido: z.string().nullable().optional(),
|
||||
numPedSar: z.number().nullable().optional(),
|
||||
numPedErp: z.number().nullable().optional(),
|
||||
idCliente: z.number(),
|
||||
nomeCliente: z.string().nullable().optional(),
|
||||
tipo: z.string(),
|
||||
assunto: z.string(),
|
||||
descricao: z.string(),
|
||||
status: z.string(),
|
||||
resolucao: z.string().nullable().optional(),
|
||||
notaResolucao: z.string().nullable().optional(),
|
||||
itensAfetados: z.array(ItemAfetadoSchema).nullable().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
mensagens: z.array(ChamadoMensagemSchema).optional(),
|
||||
});
|
||||
|
||||
export const CreateChamadoSchema = z.object({
|
||||
idPedido: z.string().uuid().optional(),
|
||||
numPedSar: z.number().int().positive().optional(),
|
||||
numPedErp: z.number().int().positive().optional(),
|
||||
nomeCliente: z.string().optional(),
|
||||
idCliente: z.number().int().positive(),
|
||||
tipo: z.enum(TIPOS_CHAMADO),
|
||||
assunto: z.string().min(5).max(200),
|
||||
descricao: z.string().min(10),
|
||||
itensAfetados: z.array(ItemAfetadoSchema).optional(),
|
||||
});
|
||||
|
||||
export const AddMensagemSchema = z.object({
|
||||
texto: z.string().min(1).max(2000),
|
||||
});
|
||||
|
||||
export const ResolverChamadoSchema = z.object({
|
||||
resolucao: z.enum(RESOLUCAO_CHAMADO),
|
||||
notaResolucao: z.string().optional(),
|
||||
mensagemFinal: z.string().optional(),
|
||||
});
|
||||
|
||||
export const CancelChamadoSchema = z.object({
|
||||
motivo: z.string().optional(),
|
||||
});
|
||||
|
||||
export const ChamadoListResponseSchema = z.object({
|
||||
abertos: z.array(ChamadoSchema),
|
||||
fechados: z.array(ChamadoSchema),
|
||||
});
|
||||
|
||||
export type TipoChamado = (typeof TIPOS_CHAMADO)[number];
|
||||
export type StatusChamado = (typeof STATUS_CHAMADO)[number];
|
||||
export type ResolucaoChamado = (typeof RESOLUCAO_CHAMADO)[number];
|
||||
export type ItemAfetado = z.infer<typeof ItemAfetadoSchema>;
|
||||
export type ChamadoMensagem = z.infer<typeof ChamadoMensagemSchema>;
|
||||
export type Chamado = z.infer<typeof ChamadoSchema>;
|
||||
export type CreateChamadoDto = z.infer<typeof CreateChamadoSchema>;
|
||||
export type AddMensagemDto = z.infer<typeof AddMensagemSchema>;
|
||||
export type ResolverChamadoDto = z.infer<typeof ResolverChamadoSchema>;
|
||||
export type CancelChamadoDto = z.infer<typeof CancelChamadoSchema>;
|
||||
export type ChamadoListResponse = z.infer<typeof ChamadoListResponseSchema>;
|
||||
@@ -17,7 +17,7 @@
|
||||
"e2e": "nx run-many -t e2e",
|
||||
"dev:api": "nx run api:serve",
|
||||
"dev:web": "nx run web:serve",
|
||||
"workspace:provision": "tsx scripts/provision-workspace.ts",
|
||||
"client:provision": "tsx scripts/provision-client.ts",
|
||||
"dev:up": "docker compose -f docker-compose.dev.yml up -d",
|
||||
"dev:down": "docker compose -f docker-compose.dev.yml down",
|
||||
"dev:logs": "docker compose -f docker-compose.dev.yml logs -f",
|
||||
@@ -110,7 +110,7 @@
|
||||
"lru-cache": "^11.5.0",
|
||||
"nestjs-cls": "^5.4.3",
|
||||
"nestjs-pino": "^4.6.1",
|
||||
"nestjs-zod": "^4.3.1",
|
||||
"nestjs-zod": "^5.4.0",
|
||||
"pg": "^8.21.0",
|
||||
"pino": "^9.14.0",
|
||||
"pino-http": "^10.5.0",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user