Compare commits

...

14 Commits

Author SHA1 Message Date
996eff8e65 feat(auth,ger): usuarios dev por papel, escopo do supervisor por equipe e positivacao diaria
- DevLogin com 3 usuarios reais: Pavei (rep 29), Sidnei (supervisor 191)
  e Lucas (gerente 156); guard deixa de forcar DEV_REP_CODE em dev e usa
  sub/id_empresa do JWT (fallback para os valores do .env); idEmpresa do
  token dev opcional, default DEV_EMPRESA_ID
- getTeamCodes (vw_representantes.cod_supervisor): supervisor ve so a
  equipe em pedidos SAR/ERP, detalhe, aprovar/recusar, clientes (lista e
  carteira), dashboard supervisor, chamados SAC e relatorios
  carteira/curva ABC; gerente/admin seguem vendo a empresa toda
- Painel gerencial: card "Positivacao Diaria de Clientes" (clientes
  distintos por dia via vw_pedidos_erp) com campo de meta/dia
  persistido em localStorage, linha de meta tracejada e contador de
  dias na meta

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:24:54 +00:00
f543b8ac7f feat(politicas): regras de desconto por grupo/subgrupo/rep e condicoes comerciais no catalogo
- Nova tabela sar.regras_desconto (grupo, subgrupo, representante, % e
  vigencia; campos nulos = todos) com migration idempotente, espelho no
  sar-erp-schema.sql e GRANT no provision-client
- CRUD manager-only em /politicas/regras + aba "Regras de Desconto" na
  tela de Politicas Comerciais (selects com nomes de rep/grupo/subgrupo
  via /politicas/grupos-produto)
- GET /politicas/regras/vigentes (rep ve so as que o alcancam) e
  GET /politicas/promocoes/vigentes para o catalogo
- Regra vigente pre-aplica o desconto no carrinho do novo pedido
- Selos "Promocao"/"Cond. especial" na busca de produto, catalogo do
  pedido e pagina de catalogo; secao "Condicoes Comerciais" no modal de
  detalhe do produto (descricao, % e validade)
- Empty imageStyle deprecado -> styles.image no carrinho

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 17:24:21 +00:00
518769218f fix(api,web): corrige autorização da área do rep e falhas silenciosas no front
Auditoria de segurança + robustez da área do representante antes de produção.

API (autorização e injeção):
- IDOR em clients: endpoints de detalhe (findOne, notas, ctr, top-produtos,
  histórico, contatos) validam carteira do rep via assertClienteDaCarteira;
  cliente alheio retorna 404. orders.create idem antes de aceitar idCliente.
- Role guard em dashboards supervisor/manager e ger/chamados (rep -> 403).
- Datas em $queryRawUnsafe agora exigem YYYY-MM-DD (DateOnlySchema + revalidação);
  dtAniversario de contato validado e escapado.
- id_empresa de CTR/NF usa matrizEmpresa() em vez de 1/9001 hardcoded.
- Alçada de desconto: transmit valida desconto efetivo por item (preço vs tabela
  pauta>promo>base + promoção ativa), não só o desconto global.
- Idempotency-Key escopado a empresa+vendedor; unsubscribe com DTO e dono.

Web (erros nunca silenciosos):
- Remove dupla serialização (apiFetch já faz JSON.stringify) em funil, sac e
  politicas — corrige criar oportunidade, abrir chamado e editar políticas.
- Handler global de erro no QueryClient (query e mutation viram toast).
- Error boundary por rota (payload divergente não derruba o app em branco).
- Alert usa message= em vez de title= (AntD 6); sw.js usa globalThis.

Qualidade:
- Corrige ping.contract.spec (idEmpresa) e zera erros de lint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 20:40:19 +00:00
2a33a5aa4b chore(web): APIs renomeadas do AntD 6 e allowedHosts no dev server
- `<Space direction>` -> `orientation` e `children` -> `content` no
  Popconfirm, acompanhando o rename da API no Ant Design 6.
- `allowedHosts: true` no Vite para permitir acesso ao dev server por
  hostname da rede local.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:17:30 +00:00
8838db16ae fix(web): tabela larga em grid/flex engolia o card vizinho
Na ficha do cliente, o card de titulos em aberto se esticava para o lado e
empurrava a secao de NF-e para fora da tela.

Causa: um track `1fr` de CSS Grid carrega `min-width: auto` implicito e se
recusa a encolher abaixo da largura intrinseca do conteudo. Como as tabelas
usam `scroll={{ x: 'max-content' }}`, a largura intrinseca delas e a soma de
todas as colunas — com titulos suficientes, a coluna estourava os 50% e
comia a vizinha. O `maxWidth: '100%'` ja presente nas tabelas nao ajudava,
porque quem crescia era o track, nao a tabela.

`minWidth: 0` nos filhos do grid desarma o `min-width: auto` e devolve o
scroll horizontal para dentro da tabela, que era o comportamento desejado.

Aplicado tambem em ClientsPage: o `<Col>` que hospeda a tabela de clientes
e estruturalmente identico (Col e flex item, mesmo `min-width: auto`) e so
nao quebrava porque a tabela tem `overflow: 'hidden'` no style — uma linha
que se le como puramente cosmetica (arredondar canto) e que, se removida
num refactor, reintroduziria o bug. A protecao vai para o lugar canonico.

Inclui ainda o span responsivo do campo Endereco na ficha.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:17:23 +00:00
a614147fc4 fix(api): GROUP BY invalido em top-produtos e endereco da empresa
- `listTopProdutos` selecionava `TRIM(p.codigo)` sem incluir `p.codigo` no
  GROUP BY, entao GET /clients/:id/top-produtos respondia 500 com
  "column p.codigo must appear in the GROUP BY clause". Um produto tem um
  unico codigo, entao agrupar por ele nao altera o resultado.
- `/catalog/company` passa a ler `num_endereco` em vez de `numero`, que nao
  correspondia a coluna real do ERP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:17:03 +00:00
26857643fb chore(deps): nestjs-zod 4.3 -> 5.4 corrige 500 em toda falha de validacao
`nestjs-zod@4.3.1` arrasta `@nest-zod/z@2.0.0`, que registra um error map
GLOBAL no Zod chamando `zod.defaultErrorMap` — API removida no Zod 4 (o
projeto usa 4.4.3). O efeito nao era local: qualquer falha de validacao,
em qualquer endpoint, estourava durante a formatacao do erro e virava
500 em vez de resposta de validacao.

Reproduzido em GET /reports/meta sem query params; apos o upgrade retorna
422 com os campos invalidos discriminados.

A v5 nao depende mais de `@nest-zod/z` e e nativa Zod 4. So
`ZodValidationPipe` e `createZodDto` sao usados, ambos estaveis na v5.

Renomeia tambem `pnpm workspace:provision` -> `pnpm client:provision`,
completando o commit de infraestrutura (package.json e pnpm-lock.yaml
precisam viajar juntos, senao o pnpm tenta recriar o node_modules).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:16:57 +00:00
51602dd47e feat(api,web): detalhamento da carteira do representante
Tela dedicada que abre pelo botao "Detalhar carteira" na listagem de
clientes, com leitura acionavel da carteira em vez de so uma tabela.

- `/reports/carteira` passa a devolver `faturamentoTotal`,
  `participacaoPct` e `faturamentoRepTotal`, permitindo medir o peso de
  cada cliente no faturamento do rep.
- `CarteirePage`: cards de resumo (ativos / em risco / inativos / sem
  pedido), faturamento total e InsightCards que apontam concentracao em
  poucos clientes, maior cliente em risco, maior inativo e clientes nunca
  positivados.
- Clientes classificados por dias sem comprar (30d em risco, 60d inativo),
  com navegacao direta para a ficha.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:16:47 +00:00
2649bc9e94 feat(api,web): SAC de chamados sobre pedidos
Representante abre chamado a partir de um pedido e conversa com a empresa
por thread de mensagens; o gerente responde e resolve.

- API: `SacModule` com 5 rotas do rep (listar, detalhe, criar, responder,
  cancelar) e 4 da empresa (/ger/chamados: listar, detalhe, responder,
  resolver). Escopo do rep e por cod_vendedor; o da empresa, por id_empresa.
- Modelos `Chamado` + `ChamadoMensagem` e migrations correspondentes.
- O chamado pode referenciar tanto um pedido nascido no SAR (id_pedido +
  num_ped_sar) quanto um pedido historico do ERP (num_ped_erp) — dai
  id_pedido e num_ped_sar serem nullable. `nome_cliente` fica
  desnormalizado para evitar join em toda listagem.
- Web: `ChamadosPage` (rep), `GerChamadosPage` (gerente) e
  `AbrirChamadoModal`, acessivel tambem pelo detalhe do pedido. Entradas
  "SAC" no menu do rep e do gerente.
- `useOrderErpConsulta` ganha flag `enabled` para a busca de pedido ERP so
  disparar quando o modal esta aberto.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:16:16 +00:00
264305386d feat(api,web): funil de vendas do representante
Kanban de oportunidades por etapa (lead, proposta, negociacao, ganho,
perdido) com CRUD completo.

- API: `FunilModule` com GET/POST/PATCH/DELETE em /funil, escopado por
  id_empresa + cod_vendedor.
- Modelo `Oportunidade` + migration `sar.oportunidades`. Uma oportunidade
  referencia um cliente do ERP (id_cliente) ou e um prospect livre
  (nome_prospect / empresa_prospect).
- Contrato Zod compartilhado em `funil.contract.ts`.
- Web: `FunilPage` com colunas por etapa, entrada no menu do rep e rota
  /funil.

Nota: a UI ainda nao expoe seletor de cliente, entao toda oportunidade
criada pela tela nasce como prospect livre; `idCliente` e `idPedido` ja
sao suportados no backend, mas ficam inalcancaveis pela interface.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:15:40 +00:00
bf6e21ce47 refactor(infra)!: schema sar no banco ERP substitui banco-por-workspace
Revoga o ADR 0006. O isolamento multi-tenant deixa de ser um banco por
workspace e passa a ser o schema `sar` dentro do proprio banco ERP do
cliente, com `id_empresa` discriminando o tenant em todas as tabelas.

Provisionamento:
- `provision-client.ts` substitui `provision-workspace.ts`. Nao cria mais
  banco: aplica schema + views + triggers num banco ERP ja existente.
  Trata o encoding LATIN1 dos bancos ERP via `toLatinSafe()`.
- `sar-erp-schema.sql` vira a fonte unica: absorve as views que estavam em
  `sarweb_views.sql` e passa a conter tambem `sar.chamados` /
  `sar.chamado_mensagens`, que so existiam via migration.

Correcoes necessarias para o provisionamento rodar ponta a ponta:
- `migrate deploy` abortava com P3005, porque o schema SQL roda antes do
  Prisma e o schema `sar` nunca esta vazio. O script agora detecta banco
  pre-existente e faz o baseline sozinho.
- `_prisma_migrations` ia parar no schema `public` do ERP do cliente; a URL
  de migracao agora leva `?schema=sar`.
- `sar.pedidos` nao tinha `end_entrega`, coluna que o schema.prisma ja
  declarava desde a feature de endereco de entrega, e que derrubava
  GET /orders com 500. Migration adicionada.
- `vw_sitpedido` se perdeu junto com `sarweb_views.sql` e e usada pela
  consulta de pedidos ERP. Restaurada.

Remove o seed de dados demo e os fallbacks de DATABASE_URL hardcoded: sem
a variavel configurada a API agora falha explicitamente, em vez de apontar
silenciosamente para um banco de desenvolvimento inexistente.

Verificado ponta a ponta contra um banco ERP real: schema aplicado, 13
tabelas + 20 views, migrations registradas, e segunda execucao idempotente.

BREAKING CHANGE: DATABASE_URL passa a ser obrigatoria. O comando de
provisionamento muda para `pnpm client:provision --host <host> --db <banco>`
(rename efetivado no commit de dependencias).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-20 18:11:35 +00:00
32ff4b43fd fix(api): não positivados consulta só vw_pedidos_erp, remove sar.pedidos
Orçamentos SAR não integrados não devem marcar cliente como positivado.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:53:40 +00:00
795b805ae5 feat(web+api): gráfico anual realizado vs meta no painel do representante
Barras (azul/verde) por mês + linha tracejada de meta; meses futuros em cinza.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 16:51:20 +00:00
a6cb1df2aa feat(web+api): cockpit rep — novo pedido v2, endereço de entrega e cancelamento de orçamento
- NewOrderPage: etapas 1-5 — merge de cards, busca de produto melhorada
  (autocomplete com estoque + modal catálogo), responsividade mobile (tabela →
  cards, footer compacto), histórico de compras do cliente e campo de
  endereço de entrega (cadastrado vs livre)
- endEntrega: campo TEXT adicionado em sar.pedidos; contrato CreatePedidoSchema
  e PedidoDetailSchema atualizados; aparece no detalhe (OrderDetailPage,
  OrderDetailModal) e no PDF (OrderPrintPage)
- Cancelamento de orçamento: PATCH /orders/:id/cancel — rep cancela seu
  próprio orçamento (situa 0); histórico registrado; UI com Modal.confirm
  substituindo alert() em OrderActionsMenu
- Fix: useClientDetail chamado para cliente selecionado manualmente, garantindo
  que clientEndStr seja preenchido mesmo sem clientIdParam na URL

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 14:25:24 +00:00
82 changed files with 6504 additions and 1888 deletions

View File

@@ -10,11 +10,6 @@ export default defineConfig({
datasource: { datasource: {
// Prisma 7: url aqui serve apenas para o CLI (migrate/generate/studio). // Prisma 7: url aqui serve apenas para o CLI (migrate/generate/studio).
// Runtime usa WorkspacePrismaPool → PrismaClient({ adapter: new PrismaPg(pool) }). // Runtime usa WorkspacePrismaPool → PrismaClient({ adapter: new PrismaPg(pool) }).
url: url: process.env['DATABASE_URL'],
process.env['DATABASE_URL'] ??
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev',
},
migrations: {
seed: 'tsx prisma/seed.ts',
}, },
}); });

View File

@@ -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);

View File

@@ -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);

View File

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

View File

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

View File

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

View File

@@ -45,6 +45,7 @@ model Pedido {
comissao Decimal @default(0) @db.Decimal(15, 2) comissao Decimal @default(0) @db.Decimal(15, 2)
pedFlex Decimal @default(0) @db.Decimal(15, 2) @map("ped_flex") pedFlex Decimal @default(0) @db.Decimal(15, 2) @map("ped_flex")
obs String? obs String?
endEntrega String? @map("end_entrega")
aprovadoPor Int? @map("aprovado_por") aprovadoPor Int? @map("aprovado_por")
aprovadoEm DateTime? @map("aprovado_em") aprovadoEm DateTime? @map("aprovado_em")
motivoRecusa String? @map("motivo_recusa") motivoRecusa String? @map("motivo_recusa")
@@ -170,6 +171,100 @@ model Promocao {
@@map("promocoes") @@map("promocoes")
} }
// ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
//
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos".
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")
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")
}
// ─── 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) ─────────────────────────────────────────────────── // ─── PushSubscription (C6) ───────────────────────────────────────────────────
// //
// Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser. // Subscription VAPID Web Push por usuário. endpoint é único por dispositivo/browser.

File diff suppressed because it is too large Load Diff

View File

@@ -16,6 +16,8 @@ import { NotificationsModule } from './notifications/notifications.module';
import { ReportsModule } from './reports/reports.module'; import { ReportsModule } from './reports/reports.module';
import { PoliticasModule } from './politicas/politicas.module'; import { PoliticasModule } from './politicas/politicas.module';
import { EquipeModule } from './equipe/equipe.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'; import { ProblemDetailsFilter } from './filters/problem-details.filter';
@Module({ @Module({
@@ -35,6 +37,8 @@ import { ProblemDetailsFilter } from './filters/problem-details.filter';
ReportsModule, ReportsModule,
PoliticasModule, PoliticasModule,
EquipeModule, EquipeModule,
FunilModule,
SacModule,
], ],
providers: [ providers: [
{ provide: APP_PIPE, useClass: ZodValidationPipe }, { provide: APP_PIPE, useClass: ZodValidationPipe },

View File

@@ -20,11 +20,13 @@ export class DevAuthController {
private readonly secret: Uint8Array; private readonly secret: Uint8Array;
private readonly expiresIn: number; private readonly expiresIn: number;
private readonly isProd: boolean; private readonly isProd: boolean;
private readonly devEmpresaId: number;
constructor(config: ConfigService<Env, true>) { constructor(config: ConfigService<Env, true>) {
this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true })); this.secret = new TextEncoder().encode(config.get('MASTER_LOGIN_JWT_SECRET', { infer: true }));
this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true }); this.expiresIn = config.get('JWT_ACCESS_EXPIRATION', { infer: true });
this.isProd = config.get('NODE_ENV', { infer: true }) === 'production'; this.isProd = config.get('NODE_ENV', { infer: true }) === 'production';
this.devEmpresaId = config.get('DEV_EMPRESA_ID', { infer: true });
} }
@Post('token') @Post('token')
@@ -33,7 +35,7 @@ export class DevAuthController {
if (this.isProd) throw new NotFoundException(); if (this.isProd) throw new NotFoundException();
const accessToken = await new SignJWT({ const accessToken = await new SignJWT({
id_empresa: dto.idEmpresa, id_empresa: dto.idEmpresa ?? this.devEmpresaId,
role: dto.role, role: dto.role,
}) })
.setProtectedHeader({ alg: 'HS256' }) .setProtectedHeader({ alg: 'HS256' })

View File

@@ -51,17 +51,21 @@ export class JwtAuthGuard implements CanActivate {
(req as Request & { user: JwtPayload }).user = payload as JwtPayload; (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 dev: usa sub/id_empresa do próprio JWT (emitido pelo /auth/dev/token,
// Em prod: usa os valores reais do JWT. // que permite logar como Pavei/Sidnei/Lucas) e cai para DEV_REP_CODE /
const idEmpresa = this.isProd ? payload.id_empresa : this.devEmpresaId; // DEV_EMPRESA_ID apenas se o token não trouxer os campos.
const userId = this.isProd ? payload.sub : this.devRepCode; // 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('idEmpresa', idEmpresa);
this.cls.set('userId', userId); this.cls.set('userId', userId);
this.cls.set('role', payload.role); this.cls.set('role', payload.role);
const baseUrl = const baseUrl = process.env['DATABASE_URL'];
process.env['DATABASE_URL'] ?? if (!baseUrl) throw new Error('DATABASE_URL não configurada');
'postgresql://sar:sar_dev_password@localhost:5432/sar_workspace_dev';
this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, baseUrl)); this.cls.set('prisma', this.pool.getOrCreate(idEmpresa, baseUrl));
return true; return true;

View File

@@ -84,16 +84,16 @@ export class CatalogService {
TRIM(e.nome) AS nome_fantasia, TRIM(e.nome) AS nome_fantasia,
TRIM(e.cnpj) AS cnpj, TRIM(e.cnpj) AS cnpj,
TRIM(e.inscr_estadual) AS inscr_estadual, TRIM(e.inscr_estadual) AS inscr_estadual,
TRIM(e.endereco) AS endereco, TRIM(e.endereco) AS endereco,
NULLIF(e.numero, 0)::text AS numero, e.num_endereco::text AS numero,
NULLIF(TRIM(e.complemento), '.') AS complemento, e.complemento,
TRIM(e.bairro) AS bairro, TRIM(e.bairro) AS bairro,
TRIM(m.nome) AS cidade, TRIM(m.nome) AS cidade,
TRIM(e.estado::text) AS uf, TRIM(e.uf) AS uf,
TRIM(e.cep::text) AS cep, e.cep,
TRIM(e.telefone::text) AS telefone, e.telefone,
TRIM(e.email) AS email TRIM(e.email) AS email
FROM gestao.empresa e FROM sar.vw_empresas e
LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio LEFT JOIN sar.vw_municipios m ON m.id_municipio = e.id_municipio
WHERE e.id_empresa = ${idEmpresa} WHERE e.id_empresa = ${idEmpresa}
LIMIT 1 LIMIT 1

View File

@@ -18,6 +18,7 @@ import type {
TopProduto, TopProduto,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; 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. // Thresholds de atividade (FR-2.3). Configuráveis por empresa futuramente.
const ALERT_DAYS = 30; const ALERT_DAYS = 30;
@@ -36,6 +37,12 @@ function escSql(s: string): string {
return s.replace(/'/g, "''"); 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 // Row bruta do $queryRawUnsafe
interface ClientRow { interface ClientRow {
id_cliente: number; id_cliente: number;
@@ -108,6 +115,29 @@ const ACTIVITY_CASE = (alias_erp = 'erp_ped', alias_sar = 'sar_ped') => `
export class ClientsService { export class ClientsService {
constructor(private readonly cls: ClsService<WorkspaceClsStore>) {} 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> { async list(query: ClientListQuery): Promise<ClientListResponse> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -119,8 +149,13 @@ export class ClientsService {
const { q, status, page, limit } = query; const { q, status, page, limit } = query;
const offset = (page - 1) * limit; const offset = (page - 1) * limit;
// Rep vê apenas sua carteira (cod_vendedor = seu código) // Rep vê apenas sua carteira; supervisor vê as carteiras da equipe
const vendedorFilter = role === 'rep' ? `AND c.cod_vendedor = ${codVendedor}` : ''; const vendedorFilter =
role === 'rep'
? `AND c.cod_vendedor = ${codVendedor}`
: role === 'supervisor'
? `AND c.cod_vendedor IN (${(await getTeamCodes(prisma, codVendedor)).join(',')})`
: '';
const searchFilter = q const searchFilter = q
? `AND (c.nome ILIKE '%${escSql(q)}%' OR c.cgcpf LIKE '%${escSql(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[]> { async listContacts(idCorrent: number): Promise<Contato[]> {
await this.assertClienteDaCarteira(idCorrent);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -316,6 +352,7 @@ export class ClientsService {
} }
async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> { async createContato(idCorrent: number, dto: CreateContato): Promise<ContatoResult> {
await this.assertClienteDaCarteira(idCorrent);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
@@ -337,7 +374,7 @@ export class ClientsService {
${idEmpresa}, ${codVendedor}, ${idCorrent}, ${idEmpresa}, ${codVendedor}, ${idCorrent},
'${esc(dto.nome)}', '${esc(dto.nome)}',
${opt(dto.empresa)}, ${opt(dto.cargo)}, ${opt(dto.departamento)}, ${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.telefone)}, ${opt(dto.ramal)}, ${opt(dto.celular)},
${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)} ${opt(dto.whatsapp)}, ${opt(dto.email)}, ${opt(dto.anotacoes)}
) )
@@ -370,6 +407,7 @@ export class ClientsService {
} }
async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> { async listOrdersHistory(idCliente: number, limit: number): Promise<PedidoSummary[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
@@ -430,15 +468,17 @@ export class ClientsService {
} }
async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> { async listTopProdutos(idCliente: number, limit: number): Promise<TopProduto[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
// Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos // Normaliza empresa fiscal (9001) → gerencial (1) onde ficam os produtos
const idEmpresaMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa; const idEmpresaMatriz = matrizEmpresa(idEmpresa);
interface Row { interface Row {
id_produto: number; id_produto: number;
codigo: string;
descricao: string; descricao: string;
unidade: string | null; unidade: string | null;
qtd_total: string; qtd_total: string;
@@ -449,37 +489,39 @@ export class ClientsService {
const rows = await prisma.$queryRawUnsafe<Row[]>(` const rows = await prisma.$queryRawUnsafe<Row[]>(`
SELECT SELECT
i.produ AS id_produto, i.id_produto,
TRIM(p.codigo) AS codigo,
TRIM(p.descricao) AS descricao, TRIM(p.descricao) AS descricao,
TRIM(p.unidade) AS unidade, TRIM(p.unidade) AS unidade,
SUM(i.qtd)::numeric(15,3)::text AS qtd_total, SUM(i.qtd)::numeric(15,3)::text AS qtd_total,
COUNT(DISTINCT ped.id_pedido)::text AS num_pedidos, 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 SELECT i2.preco_unitario::numeric(15,2)::text
FROM sig.peditens i2 FROM sar.vw_peditens_erp i2
JOIN sig.pedidos p2 ON p2.id_pedido = i2.id_pedido JOIN sar.vw_pedidos_erp p2 ON p2.id_pedido = i2.id_pedido
WHERE i2.produ = i.produ WHERE i2.id_produto = i.id_produto
AND p2.clien = ped.clien AND p2.id_cliente = ped.id_cliente
AND p2.id_empresa = ${idEmpresa} AND p2.id_empresa = ${idEmpresa}
AND p2.situa NOT IN (5) AND p2.situa NOT IN (5)
ORDER BY p2.data DESC, p2.id_pedido DESC ORDER BY p2.dt_pedido DESC, p2.id_pedido DESC
LIMIT 1 LIMIT 1
) AS ultimo_preco ) AS ultimo_preco
FROM sig.pedidos ped FROM sar.vw_pedidos_erp ped
JOIN sig.peditens i ON i.id_pedido = ped.id_pedido JOIN sar.vw_peditens_erp i ON i.id_pedido = ped.id_pedido
JOIN gestao.produto p ON p.id_erp = i.produ JOIN sar.vw_produtos p ON p.id_erp = i.id_produto
AND p.id_empresa = ${idEmpresaMatriz} AND p.id_empresa = ${idEmpresaMatriz}
WHERE ped.clien = ${idCliente} WHERE ped.id_cliente = ${idCliente}
AND ped.id_empresa = ${idEmpresa} AND ped.id_empresa = ${idEmpresa}
AND ped.situa NOT IN (5) AND ped.situa NOT IN (5)
GROUP BY i.produ, p.descricao, p.unidade, ped.clien GROUP BY i.id_produto, p.codigo, p.descricao, p.unidade, ped.id_cliente
ORDER BY SUM(i.qtd) DESC ORDER BY SUM(i.qtd) DESC
LIMIT ${limit} LIMIT ${limit}
`); `);
return rows.map((r) => ({ return rows.map((r) => ({
idProduto: Number(r.id_produto), idProduto: Number(r.id_produto),
codProduto: r.codigo ?? '',
descricao: r.descricao, descricao: r.descricao,
unidade: r.unidade, unidade: r.unidade,
qtdTotal: parseFloat(r.qtd_total ?? '0'), qtdTotal: parseFloat(r.qtd_total ?? '0'),
@@ -490,8 +532,10 @@ export class ClientsService {
} }
async getCtrSummary(idCliente: number): Promise<CtrSummary> { async getCtrSummary(idCliente: number): Promise<CtrSummary> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
interface Row { interface Row {
qtd_aberto: string; 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 COALESCE(MAX(CURRENT_DATE - dt_vencimento) FILTER (WHERE dt_vencimento < CURRENT_DATE), 0)::text AS maior_atraso_dias
FROM sar.vw_ctr FROM sar.vw_ctr
WHERE id_cliente = ${idCliente} WHERE id_cliente = ${idCliente}
AND id_empresa = ${idEmpresaMatriz}
AND situacao = 'A' AND situacao = 'A'
AND saldo > 0 AND saldo > 0
`); `);
@@ -525,6 +570,7 @@ export class ClientsService {
} }
async findOne(idCliente: number): Promise<ClientDetail> { async findOne(idCliente: number): Promise<ClientDetail> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
@@ -576,8 +622,10 @@ export class ClientsService {
} }
async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> { async listCtrTitulos(idCliente: number, limit: number): Promise<CtrTitulo[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresaMatriz = matrizEmpresa(this.cls.get('idEmpresa'));
type CtrRow = { type CtrRow = {
id_ctr: number; id_ctr: number;
@@ -599,7 +647,7 @@ export class ClientsService {
saldo::text AS saldo saldo::text AS saldo
FROM sar.vw_ctr FROM sar.vw_ctr
WHERE id_cliente = $1 WHERE id_cliente = $1
AND id_empresa = 1 AND id_empresa = $3
AND situacao = 'A' AND situacao = 'A'
AND saldo > 0 AND saldo > 0
ORDER BY dt_vencimento ASC ORDER BY dt_vencimento ASC
@@ -607,6 +655,7 @@ export class ClientsService {
`, `,
idCliente, idCliente,
limit, limit,
idEmpresaMatriz,
); );
const toDate = (d: Date | string): string => const toDate = (d: Date | string): string =>
@@ -623,8 +672,12 @@ export class ClientsService {
} }
async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> { async listNotasFiscais(idCliente: number, limit: number): Promise<NotaFiscal[]> {
await this.assertClienteDaCarteira(idCliente);
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
// 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 = { type NfRow = {
id_nf: number; id_nf: number;
@@ -646,13 +699,13 @@ export class ClientsService {
nf.dt_emissao, nf.dt_emissao,
nf.vl_nf::text AS vl_nf, nf.vl_nf::text AS vl_nf,
TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe
FROM gestao.nf nf FROM sar.vw_nf nf
JOIN sig.pedidos p JOIN sar.vw_pedidos_erp p
ON p.numero = nf.num_entrega ON p.numero = nf.num_entrega
AND p.tipo = 'E' AND p.tipo = 'E'
AND p.id_empresa IN (1, 9001) AND p.id_empresa IN ($3, $4)
WHERE p.clien = $1 WHERE p.id_cliente = $1
AND nf.id_empresa = 1 AND nf.id_empresa = $3
AND nf.status = 'E' AND nf.status = 'E'
AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != '' AND TRIM(COALESCE(nf.chave_acesso_nfe, '')) != ''
ORDER BY nf.id_nf ORDER BY nf.id_nf
@@ -662,6 +715,8 @@ export class ClientsService {
`, `,
idCliente, idCliente,
limit, limit,
idEmpresaMatriz,
idEmpresaFiscal,
); );
return rows.map((r: NfRow) => ({ return rows.map((r: NfRow) => ({

View File

@@ -1,4 +1,4 @@
import { Controller, Get, Query } from '@nestjs/common'; import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
import { ClsService } from 'nestjs-cls'; import { ClsService } from 'nestjs-cls';
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface'; import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -11,6 +11,15 @@ export class DashboardController {
private readonly cls: ClsService<WorkspaceClsStore>, 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') @Get('rep')
repDashboard(): Promise<RepDashboard> { repDashboard(): Promise<RepDashboard> {
return this.dashboard.repDashboard(this.cls.get('userId') ?? ''); return this.dashboard.repDashboard(this.cls.get('userId') ?? '');
@@ -18,6 +27,7 @@ export class DashboardController {
@Get('supervisor') @Get('supervisor')
supervisorDashboard(): Promise<SupervisorDashboard> { supervisorDashboard(): Promise<SupervisorDashboard> {
this.assertGestor();
return this.dashboard.supervisorDashboard(); return this.dashboard.supervisorDashboard();
} }
@@ -26,6 +36,7 @@ export class DashboardController {
@Query('mes') mes?: string, @Query('mes') mes?: string,
@Query('ano') ano?: string, @Query('ano') ano?: string,
): Promise<ManagerDashboard> { ): Promise<ManagerDashboard> {
this.assertGestor();
return this.dashboard.managerDashboard( return this.dashboard.managerDashboard(
mes ? parseInt(mes, 10) : undefined, mes ? parseInt(mes, 10) : undefined,
ano ? parseInt(ano, 10) : undefined, ano ? parseInt(ano, 10) : undefined,

View File

@@ -8,6 +8,7 @@ import type {
PositivacaoRep, PositivacaoRep,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
// Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado // Situa ERP: 2=Liberado, 4=Faturado, 5=Cancelado
// Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado // Situa SAR (pedidos novos): 1=Pendente, 2=Aprovado, 3=Cancelado, 4=Faturado
@@ -124,7 +125,24 @@ export class DashboardService {
obs: string | null; 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[]>(` prisma.$queryRawUnsafe<TotalRow[]>(`
SELECT COALESCE(SUM(total), 0)::text AS total SELECT COALESCE(SUM(total), 0)::text AS total
FROM vw_pedidos_erp FROM vw_pedidos_erp
@@ -178,6 +196,30 @@ export class DashboardService {
AND e.dt_pedido <= '${monthEndStr}' AND e.dt_pedido <= '${monthEndStr}'
GROUP BY p.cod_grupo, grupo 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); const atingido = Number(atingidoRows[0]?.total ?? 0);
@@ -191,25 +233,17 @@ export class DashboardService {
WITH ultimo_pedido AS ( WITH ultimo_pedido AS (
SELECT id_cliente, SELECT id_cliente,
MAX(dt_pedido) AS dt_max MAX(dt_pedido) AS dt_max
FROM ( FROM vw_pedidos_erp
SELECT id_cliente, dt_pedido FROM vw_pedidos_erp WHERE situa NOT IN (5)
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa} AND id_empresa = ${idEmpresa}
UNION ALL
SELECT id_cliente, dt_pedido FROM sar.pedidos
WHERE situa != 3 AND id_empresa = ${idEmpresa}
) t
GROUP BY id_cliente GROUP BY id_cliente
), ),
pedido_mes AS ( pedido_mes AS (
SELECT DISTINCT id_cliente FROM ( SELECT DISTINCT id_cliente
SELECT id_cliente FROM vw_pedidos_erp FROM vw_pedidos_erp
WHERE situa NOT IN (5) AND id_empresa = ${idEmpresa} WHERE situa NOT IN (5)
AND dt_pedido >= '${monthStartStr}' AND id_empresa = ${idEmpresa}
UNION AND dt_pedido >= '${monthStartStr}'
SELECT id_cliente FROM sar.pedidos
WHERE situa != 3 AND id_empresa = ${idEmpresa}
AND dt_pedido >= '${monthStartStr}'
) t
) )
SELECT SELECT
c.id_cliente, c.id_cliente,
@@ -267,21 +301,48 @@ export class DashboardService {
}) })
.sort((a, b) => b.valorMeta - a.valorMeta); .sort((a, b) => b.valorMeta - a.valorMeta);
const naoPositivados = naoPositivadosRows.map((c) => ({ // Deduplica por idCliente — vw_clientes pode retornar o mesmo cliente em
idCliente: Number(c.id_cliente), // mais de uma empresa; mantemos a primeira ocorrência (ORDER BY dt_max ASC NULLS FIRST).
nome: c.nome, const seenNP = new Set<number>();
razao: c.razao ?? null, const naoPositivados = naoPositivadosRows
diasSemPedido: c.dias_sem_pedido != null ? Number(c.dias_sem_pedido) : 999, .filter((c) => {
ultimoPedido: c.dt_ultimo_pedido ?? null, const id = Number(c.id_cliente);
comprouAntes: Boolean(c.comprou_antes), if (seenNP.has(id)) return false;
whatsapp: c.whatsapp ?? null, 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 { return {
meta: { atingido, total: targetAmount, pct, falta }, meta: { atingido, total: targetAmount, pct, falta },
metaDimensao, metaDimensao,
metasPorGrupo, metasPorGrupo,
pedidosMes, pedidosMes,
historicoMensal,
pedidosRecentes: recentRows.map((o) => ({ pedidosRecentes: recentRows.map((o) => ({
id: `erp-${o.id_pedido}`, id: `erp-${o.id_pedido}`,
numPedSar: (o.num_ped_sar ?? '').trim(), numPedSar: (o.num_ped_sar ?? '').trim(),
@@ -353,6 +414,10 @@ export class DashboardService {
total_clientes: string; total_clientes: string;
clientes_positivados: string; clientes_positivados: string;
} }
interface PositivacaoDiaRow {
dia: string;
clientes: string;
}
const [ const [
statsRows, statsRows,
@@ -362,6 +427,7 @@ export class DashboardService {
positivacaoRows, positivacaoRows,
metaGrupoRows, metaGrupoRows,
realizadoGrupoRows, realizadoGrupoRows,
positivacaoDiariaRows,
] = await Promise.all([ ] = await Promise.all([
prisma.$queryRawUnsafe<TotalRow[]>(` prisma.$queryRawUnsafe<TotalRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total SELECT COUNT(*)::text AS count, COALESCE(SUM(total), 0)::text AS total
@@ -458,6 +524,17 @@ export class DashboardService {
AND e.dt_pedido <= '${monthEndStr}' AND e.dt_pedido <= '${monthEndStr}'
GROUP BY p.cod_grupo, grupo 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
`),
]); ]);
const pedidosMes = Number(statsRows[0]?.count ?? 0); const pedidosMes = Number(statsRows[0]?.count ?? 0);
@@ -530,6 +607,10 @@ export class DashboardService {
metasPorGrupo, metasPorGrupo,
rankingReps, rankingReps,
positivacaoReps, positivacaoReps,
positivacaoDiaria: positivacaoDiariaRows.map((r) => ({
dia: r.dia,
clientes: Number(r.clientes),
})),
syncedAt: now.toISOString(), syncedAt: now.toISOString(),
}; };
} }
@@ -540,9 +621,21 @@ export class DashboardService {
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const now = new Date(); const now = new Date();
// Supervisor vê só a própria equipe (cod_supervisor em vw_representantes);
// gerente/admin acessando este painel veem a empresa toda.
const role = this.cls.get('role');
const userId = this.cls.get('userId');
const team =
role === 'supervisor' ? await getTeamCodes(prisma, userId ? parseInt(userId, 10) : 0) : null;
const teamSqlFilter = (col: string) => (team ? `AND ${col} IN (${team.join(',')})` : '');
// Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP) // Fila de aprovações — pedidos SAR pendentes (novos, ainda não integrados ao ERP)
const approvalQueue = await prisma.pedido.findMany({ const approvalQueue = await prisma.pedido.findMany({
where: { idEmpresa, situa: SITUA_PENDENTE }, where: {
idEmpresa,
situa: SITUA_PENDENTE,
...(team ? { codVendedor: { in: team } } : {}),
},
orderBy: { dtPedido: 'asc' }, orderBy: { dtPedido: 'asc' },
take: 50, take: 50,
}); });
@@ -566,12 +659,14 @@ export class DashboardService {
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
FROM vw_pedidos_erp FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}' WHERE id_empresa = ${idEmpresa} AND situa != 5 AND dt_pedido >= '${todayStr}'
${teamSqlFilter('cod_vendedor')}
`), `),
prisma.$queryRawUnsafe<DayRow[]>(` prisma.$queryRawUnsafe<DayRow[]>(`
SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total SELECT COUNT(*)::text AS count, COALESCE(SUM(total),0)::text AS total
FROM vw_pedidos_erp FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa} AND situa != 5 WHERE id_empresa = ${idEmpresa} AND situa != 5
AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}' AND dt_pedido >= '${lastWeekStr}' AND dt_pedido < '${lastWeekEndStr}'
${teamSqlFilter('cod_vendedor')}
`), `),
]); ]);
@@ -591,6 +686,7 @@ export class DashboardService {
AND p.id_empresa = ${idEmpresa} AND p.id_empresa = ${idEmpresa}
AND p.situa != 5 AND p.situa != 5
WHERE c.ativo = 1 WHERE c.ativo = 1
${teamSqlFilter('c.cod_vendedor')}
GROUP BY c.id_cliente, c.cod_vendedor GROUP BY c.id_cliente, c.cod_vendedor
HAVING MAX(p.dt_pedido) IS NULL HAVING MAX(p.dt_pedido) IS NULL
OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}' OR MAX(p.dt_pedido) < '${thirtyDaysAgo.toISOString().slice(0, 10)}'

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

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

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

View File

@@ -1,10 +1,15 @@
import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common'; import { Body, Controller, Delete, Get, HttpCode, HttpStatus, Post, Req } from '@nestjs/common';
import { createZodDto } from 'nestjs-zod'; 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 type { AuthenticatedRequest } from '../auth/jwt.types';
import { NotificationsService } from './notifications.service'; import { NotificationsService } from './notifications.service';
class SubscribeDto extends createZodDto(SubscribePayloadSchema) {} class SubscribeDto extends createZodDto(SubscribePayloadSchema) {}
class UnsubscribeDto extends createZodDto(UnsubscribePayloadSchema) {}
@Controller('notifications') @Controller('notifications')
export class NotificationsController { export class NotificationsController {
@@ -18,8 +23,8 @@ export class NotificationsController {
@Delete('unsubscribe') @Delete('unsubscribe')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
async unsubscribe(@Body() body: { endpoint: string }): Promise<void> { async unsubscribe(@Req() req: AuthenticatedRequest, @Body() body: UnsubscribeDto): Promise<void> {
await this.svc.unsubscribe(body.endpoint); await this.svc.unsubscribe(req.user.sub, body.endpoint);
} }
@Get('pending-count') @Get('pending-count')

View File

@@ -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'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
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> { async pendingCount(userId: string, role: string): Promise<PendingCountResponse> {

View File

@@ -84,6 +84,11 @@ export class OrdersController {
return this.orders.reject(id, parsed); return this.orders.reject(id, parsed);
} }
@Patch(':id/cancel')
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
return this.orders.cancel(id);
}
@Get('erp/:idPedido') @Get('erp/:idPedido')
findOneErp(@Param('idPedido', ParseIntPipe) idPedido: number): Promise<PedidoDetail> { findOneErp(@Param('idPedido', ParseIntPipe) idPedido: number): Promise<PedidoDetail> {
return this.orders.findOneErp(idPedido); return this.orders.findOneErp(idPedido);

View File

@@ -14,6 +14,7 @@ import type {
RecusarPedido, RecusarPedido,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
import { NotificationsService } from '../notifications/notifications.service'; import { NotificationsService } from '../notifications/notifications.service';
// Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado // Situa SAR: 0=Orçamento, 1=Ag.Aprovação, 2=Confirmado, 3=Cancelado, 4=Faturado
@@ -51,9 +52,13 @@ export class OrdersService {
const { idCliente, situa, numPedSar, from, to, page, limit } = query; const { idCliente, situa, numPedSar, from, to, page, limit } = query;
const offset = (page - 1) * limit; 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 = { const where: Prisma.PedidoWhereInput = {
idEmpresa, idEmpresa,
...(role === 'rep' ? { codVendedor } : {}), ...(role === 'rep' ? { codVendedor } : {}),
...(role === 'supervisor' ? { codVendedor: { in: team } } : {}),
...(idCliente != null ? { idCliente } : {}), ...(idCliente != null ? { idCliente } : {}),
...(situa != null ? { situa } : {}), ...(situa != null ? { situa } : {}),
...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}), ...(numPedSar ? { numPedSar: { contains: numPedSar, mode: 'insensitive' as const } } : {}),
@@ -127,11 +132,23 @@ export class OrdersService {
dataHistorico.setDate(dataHistorico.getDate() - 90); dataHistorico.setDate(dataHistorico.getDate() - 90);
const dataHistoricoStr = dataHistorico.toISOString().split('T')[0]; const dataHistoricoStr = dataHistorico.toISOString().split('T')[0];
const vendedorFilter = role === 'rep' ? `AND a.cod_vendedor = ${codVendedor}` : ''; // Defesa em profundidade: o contrato já exige YYYY-MM-DD, mas como as datas
const fromFilterE = from ? `AND COALESCE(a.data, b.data) >= '${from}'` : ''; // são interpoladas no SQL abaixo, revalidamos antes de montar a query.
const toFilterE = to ? `AND COALESCE(a.data, b.data) <= '${to}'` : ''; const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
const fromFilterP = from ? `AND a.data >= '${from}'` : ''; if ((from && !DATE_RE.test(from)) || (to && !DATE_RE.test(to))) {
const toFilterP = to ? `AND a.data <= '${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 safe = (s: string) => s.replace(/'/g, "''");
const searchFilter = search 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)}%'))` ? `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 +181,14 @@ export class OrdersService {
WITH all_rows AS ( WITH all_rows AS (
SELECT SELECT
b.numero AS numero_pedido, b.numero AS numero_pedido,
COALESCE(b.data, a.data) AS data, COALESCE(b.dt_pedido, a.dt_pedido) AS data,
a.dtprevent AS dt_prevent, a.dt_prevent,
a.data_emissao, a.dt_emissao AS data_emissao,
a.situa, a.situa,
NULL::text AS status_descr, NULL::text AS status_descr,
a.clien::integer AS id_cliente, a.id_cliente,
a.cod_vendedor, a.cod_vendedor,
TRIM(c.descr) AS forma_pagamento, a.forma_pagamento,
a.total::text, a.total::text,
a.obs, a.obs,
'E'::varchar(1) AS tipo, 'E'::varchar(1) AS tipo,
@@ -181,36 +198,33 @@ export class OrdersService {
d.chave_acesso_nfe, d.chave_acesso_nfe,
b.situa AS sit_ped, b.situa AS sit_ped,
a.id_pedido a.id_pedido
FROM sig.pedidos a FROM sar.vw_pedidos_erp a
LEFT JOIN sig.pedidos b LEFT JOIN sar.vw_pedidos_erp b
ON a.numer_ped_vinc = b.numero ON a.numer_ped_vinc = b.numero
AND a.id_empresa = b.id_empresa AND a.id_empresa = b.id_empresa
AND b.tipo = 'P' AND b.tipo = 'P'
LEFT JOIN gestao.formapag c LEFT JOIN sar.vw_nf d
ON c.id_empresa = ${idMatriz}
AND a.cod_formapag = c.codigo
LEFT JOIN gestao.nf d
ON a.numero = d.num_entrega ON a.numero = d.num_entrega
AND d.id_empresa = ${idMatriz} AND d.id_empresa = ${idMatriz}
WHERE a.id_empresa = ${idEmpresa} WHERE a.id_empresa = ${idEmpresa}
AND a.tipo = 'E' AND a.tipo = 'E'
${vendedorFilter} ${vendedorFilter}
AND b.id_pedido IS NOT NULL 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} ${fromFilterE} ${toFilterE}
UNION ALL UNION ALL
SELECT SELECT
a.numero AS numero_pedido, a.numero AS numero_pedido,
a.data, a.dt_pedido AS data,
a.dtprevent AS dt_prevent, a.dt_prevent,
a.data_emissao, a.dt_emissao AS data_emissao,
a.situa, a.situa,
c.descricao AS status_descr, c.descricao AS status_descr,
a.clien::integer AS id_cliente, a.id_cliente,
a.cod_vendedor, a.cod_vendedor,
TRIM(b.descr) AS forma_pagamento, a.forma_pagamento,
a.total::text, a.total::text,
a.obs, a.obs,
'P'::varchar(1) AS tipo, 'P'::varchar(1) AS tipo,
@@ -220,10 +234,7 @@ export class OrdersService {
NULL::varchar AS chave_acesso_nfe, NULL::varchar AS chave_acesso_nfe,
NULL::integer AS sit_ped, NULL::integer AS sit_ped,
a.id_pedido a.id_pedido
FROM sig.pedidos a FROM sar.vw_pedidos_erp a
LEFT JOIN gestao.formapag b
ON b.id_empresa = 1
AND a.cod_formapag = b.codigo
LEFT JOIN vw_sitpedido c LEFT JOIN vw_sitpedido c
ON c.id_sitpedido = a.situa ON c.id_sitpedido = a.situa
WHERE a.id_empresa = ${idEmpresa} WHERE a.id_empresa = ${idEmpresa}
@@ -277,6 +288,20 @@ export class OrdersService {
return { data, total, page, limit }; return { data, total, page, limit };
} }
// Supervisor só age sobre pedidos da própria equipe; gerente/admin passam
// direto. NotFound (não Forbidden) para não revelar pedidos de outras equipes.
private async assertPedidoDaEquipe(codVendedorPedido: number, idPedido: string): Promise<void> {
const role = this.cls.get('role');
if (role !== 'supervisor') return;
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const userId = this.cls.get('userId') ?? '0';
const team = await getTeamCodes(prisma, parseInt(userId, 10));
if (!team.includes(codVendedorPedido)) {
throw new NotFoundException(`Pedido ${idPedido} não encontrado`);
}
}
async findOne(id: string): Promise<PedidoDetail> { async findOne(id: string): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -285,7 +310,12 @@ export class OrdersService {
const userId = this.cls.get('userId'); const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0; 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({ const o = await prisma.pedido.findFirst({
where: { id, idEmpresa, ...repFilter }, where: { id, idEmpresa, ...repFilter },
@@ -307,13 +337,26 @@ export class OrdersService {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa'); const idEmpresa = this.cls.get('idEmpresa');
const role = this.cls.get('role');
const userId = this.cls.get('userId') ?? '0'; const userId = this.cls.get('userId') ?? '0';
const codVendedor = parseInt(userId, 10); 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) { if (dto.idempotencyKey) {
const existing = await prisma.pedido.findUnique({ const existing = await prisma.pedido.findFirst({
where: { idempotencyKey: dto.idempotencyKey }, where: { idempotencyKey: dto.idempotencyKey, idEmpresa, codVendedor },
include: { include: {
itens: { orderBy: { ordem: 'asc' } }, itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } }, historico: { orderBy: { changedAt: 'asc' } },
@@ -369,6 +412,7 @@ export class OrdersService {
descontoPerc: dto.descontoPerc, descontoPerc: dto.descontoPerc,
descontoValor: descontoValorGlobal, descontoValor: descontoValorGlobal,
obs: dto.obs ?? null, obs: dto.obs ?? null,
endEntrega: dto.endEntrega ?? null,
idempotencyKey: dto.idempotencyKey ?? null, idempotencyKey: dto.idempotencyKey ?? null,
itens: { create: itemsData }, itens: { create: itemsData },
historico: { historico: {
@@ -404,7 +448,10 @@ export class OrdersService {
// Rep só transmite o próprio orçamento // Rep só transmite o próprio orçamento
const repFilter = role === 'rep' ? { codVendedor } : {}; 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) throw new NotFoundException(`Pedido ${id} não encontrado`);
if (pedido.situa !== SITUA_ORCAMENTO) if (pedido.situa !== SITUA_ORCAMENTO)
throw new BadRequestException( throw new BadRequestException(
@@ -422,6 +469,8 @@ export class OrdersService {
); );
} }
await this.assertAlcadaItens(pedido, limitMap, limiteMax);
const now = new Date(); const now = new Date();
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } }); await prisma.pedido.update({ where: { id }, data: { situa: SITUA_APROVADO } });
await prisma.historicoPedido.create({ await prisma.historicoPedido.create({
@@ -445,6 +494,146 @@ export class OrdersService {
return this.mapDetail(final); return this.mapDetail(final);
} }
// 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 limite do grupo do produto (alcada_desconto por
// codGrupo; 0 = default), somado à maior folga entre promoção ativa
// (produto/grupo) e regra de desconto vigente (grupo/subgrupo/rep). 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;
idPauta: number | null;
itens: {
ordem: number;
idProduto: number;
codProduto: string | null;
precoUnitario: Prisma.Decimal;
descontoPerc: Prisma.Decimal;
}[];
},
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 grupo/subgrupo vale para qualquer produto; com grupo e/ou
// subgrupo, o produto precisa casar com o que a regra especifica.
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)),
)
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
const limiteItem =
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) +
Math.max(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 transmitir.`,
);
}
}
async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> { async approve(id: string, dto: AprovarPedido): Promise<PedidoDetail> {
const prisma = this.cls.get('prisma'); const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS'); if (!prisma) throw new Error('prisma não disponível no CLS');
@@ -458,6 +647,7 @@ export class OrdersService {
throw new BadRequestException( throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`, `Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
); );
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date(); const now = new Date();
const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc); const newDescontoPerc = dto.descontoPerc ?? Number(pedido.descontoPerc);
@@ -516,6 +706,7 @@ export class OrdersService {
throw new BadRequestException( throw new BadRequestException(
`Pedido não está aguardando aprovação (situa: ${pedido.situa})`, `Pedido não está aguardando aprovação (situa: ${pedido.situa})`,
); );
await this.assertPedidoDaEquipe(pedido.codVendedor, id);
const now = new Date(); const now = new Date();
@@ -552,6 +743,44 @@ export class OrdersService {
return this.mapDetail(final); return this.mapDetail(final);
} }
async cancel(id: string): 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);
// 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_ORCAMENTO)
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
const now = new Date();
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_CANCELADO } });
await prisma.historicoPedido.create({
data: {
idPedido: id,
situaAnterior: SITUA_ORCAMENTO,
situaNova: SITUA_CANCELADO,
changedBy: codVendedor,
changedAt: now,
nota: 'Cancelado pelo representante',
},
});
const final = await prisma.pedido.findUniqueOrThrow({
where: { id },
include: {
itens: { orderBy: { ordem: 'asc' } },
historico: { orderBy: { changedAt: 'asc' } },
},
});
return this.mapDetail(final);
}
// Resolve nome do cliente (nome + razão) e nome do representante a partir dos // Resolve nome do cliente (nome + razão) e nome do representante a partir dos
// códigos, lendo das views do ERP. Usado no detalhe de pedidos SAR-nativos. // códigos, lendo das views do ERP. Usado no detalhe de pedidos SAR-nativos.
private async lookupNames( private async lookupNames(
@@ -602,6 +831,7 @@ export class OrdersService {
aprovadoEm: Date | null; aprovadoEm: Date | null;
motivoRecusa: string | null; motivoRecusa: string | null;
obs: string | null; obs: string | null;
endEntrega: string | null;
idempotencyKey: string | null; idempotencyKey: string | null;
createdAt: Date; createdAt: Date;
updatedAt: Date; updatedAt: Date;
@@ -639,6 +869,7 @@ export class OrdersService {
total: decimalToString(o.total), total: decimalToString(o.total),
descontoPerc: decimalToString(o.descontoPerc), descontoPerc: decimalToString(o.descontoPerc),
obs: o.obs, obs: o.obs,
endEntrega: o.endEntrega,
createdAt: o.createdAt.toISOString(), createdAt: o.createdAt.toISOString(),
totalProdutos: decimalToString(o.totalProdutos), totalProdutos: decimalToString(o.totalProdutos),
totalIpi: decimalToString(o.totalIpi), totalIpi: decimalToString(o.totalIpi),
@@ -719,7 +950,12 @@ export class OrdersService {
total: string; total: string;
} }
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 idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
const [headerRows, itemRows] = await Promise.all([ const [headerRows, itemRows] = await Promise.all([

View File

@@ -14,17 +14,27 @@ import {
UpsertDescontoBodySchema, UpsertDescontoBodySchema,
CreatePromocaoBodySchema, CreatePromocaoBodySchema,
UpdatePromocaoBodySchema, UpdatePromocaoBodySchema,
CreateRegraDescontoBodySchema,
UpdateRegraDescontoBodySchema,
type UpsertDescontoBody, type UpsertDescontoBody,
type CreatePromocaoBody, type CreatePromocaoBody,
type UpdatePromocaoBody, type UpdatePromocaoBody,
type CreateRegraDescontoBody,
type UpdateRegraDescontoBody,
type AlcadaDescontosResponse, type AlcadaDescontosResponse,
type PromocoesResponse, type PromocoesResponse,
type PromocoesVigentesResponse,
type RegrasDescontoResponse,
type RegrasVigentesResponse,
type GruposProdutoResponse,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { PoliticasService } from './politicas.service'; import { PoliticasService } from './politicas.service';
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {} class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {} class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {} class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema) {}
@Controller({ path: 'politicas' }) @Controller({ path: 'politicas' })
export class PoliticasController { export class PoliticasController {
@@ -46,6 +56,11 @@ export class PoliticasController {
return this.politicas.listPromocoes(); return this.politicas.listPromocoes();
} }
@Get('promocoes/vigentes')
listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
return this.politicas.listPromocoesVigentes();
}
@Post('promocoes') @Post('promocoes')
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> { createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody); return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
@@ -64,4 +79,38 @@ export class PoliticasController {
deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> { deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> {
return this.politicas.deletePromocao(id); 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<{ id: 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();
}
} }

View File

@@ -6,6 +6,12 @@ import type {
PromocoesResponse, PromocoesResponse,
CreatePromocaoBody, CreatePromocaoBody,
UpdatePromocaoBody, UpdatePromocaoBody,
RegrasDescontoResponse,
CreateRegraDescontoBody,
UpdateRegraDescontoBody,
RegrasVigentesResponse,
PromocoesVigentesResponse,
GruposProdutoResponse,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
@@ -158,4 +164,219 @@ export class PoliticasService {
await prisma.promocao.delete({ where: { id } }); 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,
})),
};
}
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]),
);
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,
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,
})),
};
}
async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> {
this.assertManager();
const prisma = this.cls.get('prisma');
if (!prisma) throw new Error('prisma não disponível no CLS');
const idEmpresa = this.cls.get('idEmpresa');
const userId = this.cls.get('userId') ?? 'system';
const r = await prisma.regraDesconto.create({
data: {
idEmpresa,
descricao: body.descricao,
codGrupo: body.codGrupo ?? null,
codSubgrupo: body.codSubgrupo ?? null,
codVendedor: body.codVendedor ?? null,
descPct: body.descPct,
dataInicio: new Date(body.dataInicio),
dataFim: new Date(body.dataFim),
createdBy: userId,
},
});
return { id: r.id };
}
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.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 } });
}
// 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' }],
});
return {
promocoes: rows.map((p) => ({
id: p.id,
descricao: p.descricao,
codProduto: p.codProduto,
grpProd: p.grpProd,
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' }],
});
return {
regras: rows.map((r) => ({
id: r.id,
descricao: r.descricao,
codGrupo: r.codGrupo,
codSubgrupo: r.codSubgrupo,
descPct: Number(r.descPct),
dataFim: r.dataFim.toISOString().slice(0, 10),
})),
};
}
} }

View File

@@ -9,6 +9,7 @@ import type {
AbcProduto, AbcProduto,
} from '@sar/api-interface'; } from '@sar/api-interface';
import type { WorkspaceClsStore } from '../workspace/workspace.types'; import type { WorkspaceClsStore } from '../workspace/workspace.types';
import { getTeamCodes } from '../workspace/team-scope.util';
function d(v: unknown): string { function d(v: unknown): string {
return v != null ? String(v) : '0'; return v != null ? String(v) : '0';
@@ -77,6 +78,12 @@ export class ReportsService {
const userId = this.cls.get('userId'); const userId = this.cls.get('userId');
const codVendedor = userId ? parseInt(userId, 10) : 0; 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 { interface CarteiraRow {
id_cliente: unknown; id_cliente: unknown;
nome: string | null; nome: string | null;
@@ -86,56 +93,83 @@ export class ReportsService {
dias_sem_pedido: unknown; dias_sem_pedido: unknown;
total_pedidos: unknown; total_pedidos: unknown;
ticket_medio: unknown; ticket_medio: unknown;
faturamento_total: unknown;
} }
const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(` const rows = await prisma.$queryRawUnsafe<CarteiraRow[]>(`
WITH ultimos AS ( WITH ultimos AS (
SELECT SELECT
id_cliente, id_cliente,
MAX(dt_pedido) AS ultimo_pedido, MAX(dt_pedido) AS ultimo_pedido,
COUNT(id) AS total_pedidos, COUNT(*) AS total_pedidos,
CASE WHEN COUNT(id) > 0 CASE WHEN COUNT(*) > 0
THEN SUM(total)::numeric / COUNT(id) THEN SUM(total)::numeric / COUNT(*)
ELSE 0 ELSE 0
END AS ticket_medio END AS ticket_medio,
FROM sar.pedidos COALESCE(SUM(total), 0)::numeric AS faturamento_total
FROM vw_pedidos_erp
WHERE id_empresa = ${idEmpresa} WHERE id_empresa = ${idEmpresa}
AND cod_vendedor = ${codVendedor} AND ${vendCond('cod_vendedor')}
AND situa <> 3 AND situa NOT IN (1, 5)
GROUP BY id_cliente GROUP BY id_cliente
) )
SELECT SELECT
c.id_cliente, c.id_cliente,
TRIM(c.nome) AS nome, TRIM(c.nome) AS nome,
TRIM(c.razao) AS razao, TRIM(c.razao) AS razao,
c.ativo, c.ativo,
TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido, TO_CHAR(u.ultimo_pedido, 'YYYY-MM-DD') AS ultimo_pedido,
(CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido, (CURRENT_DATE - u.ultimo_pedido::date) AS dias_sem_pedido,
COALESCE(u.total_pedidos, 0)::int AS total_pedidos, COALESCE(u.total_pedidos, 0)::int AS total_pedidos,
COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio COALESCE(u.ticket_medio, 0)::numeric AS ticket_medio,
FROM sar.vw_clientes c COALESCE(u.faturamento_total, 0)::numeric AS faturamento_total
FROM vw_clientes c
LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente LEFT JOIN ultimos u ON u.id_cliente = c.id_cliente
WHERE c.id_empresa = ${idEmpresa} WHERE ${vendCond('c.cod_vendedor')}
AND c.cod_vendedor = ${codVendedor} AND c.ativo = 1
ORDER BY u.ultimo_pedido ASC NULLS FIRST, c.nome ASC ORDER BY u.faturamento_total DESC NULLS LAST, c.nome ASC
`); `);
const data = rows.map((r) => ({ // Deduplica por id_cliente — vw_clientes pode retornar o mesmo cliente em mais de uma empresa.
idCliente: n(r.id_cliente), const seen = new Set<number>();
nome: r.nome ?? null, const unique = rows.filter((r) => {
razao: r.razao ?? null, const id = n(r.id_cliente);
ativo: n(r.ativo), if (seen.has(id)) return false;
ultimoPedido: r.ultimo_pedido ?? null, seen.add(id);
diasSemPedido: r.dias_sem_pedido != null ? n(r.dias_sem_pedido) : null, return true;
totalPedidos: n(r.total_pedidos), });
ticketMedio: d(r.ticket_medio),
})); 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 inativos30 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 30).length;
const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length; const inativos60 = data.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60).length;
const semPedido = data.filter((c) => c.totalPedidos === 0).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> { 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 mesFilter = mes != null ? `AND EXTRACT(MONTH FROM p.dt_pedido) = ${mes}` : '';
const anoFilter = ano != null ? `AND EXTRACT(YEAR FROM p.dt_pedido) = ${ano}` : ''; 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 { interface AbcRow {
cod_produto: string | null; cod_produto: string | null;
desc_produto: string | null; desc_produto: string | null;
@@ -169,7 +210,7 @@ export class ReportsService {
FROM sar.pedido_itens pi FROM sar.pedido_itens pi
JOIN sar.pedidos p ON p.id = pi.id_pedido JOIN sar.pedidos p ON p.id = pi.id_pedido
WHERE p.id_empresa = ${idEmpresa} WHERE p.id_empresa = ${idEmpresa}
AND p.cod_vendedor = ${codVendedor} AND ${vendCond}
AND p.situa NOT IN (0, 3) AND p.situa NOT IN (0, 3)
${mesFilter} ${mesFilter}
${anoFilter} ${anoFilter}

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

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

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

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

View File

@@ -18,7 +18,7 @@ const CACHEABLE_API = [
// ── Fetch ────────────────────────────────────────────────────────────────────── // ── Fetch ──────────────────────────────────────────────────────────────────────
self.addEventListener('fetch', (event) => { globalThis.addEventListener('fetch', (event) => {
const { request } = event; const { request } = event;
if (request.method !== 'GET') return; if (request.method !== 'GET') return;
@@ -102,10 +102,10 @@ function offlineResponse() {
// ── Push (C6) ───────────────────────────────────────────────────────────────── // ── Push (C6) ─────────────────────────────────────────────────────────────────
self.addEventListener('push', (event) => { globalThis.addEventListener('push', (event) => {
const data = event.data?.json() ?? {}; const data = event.data?.json() ?? {};
event.waitUntil( event.waitUntil(
self.registration.showNotification(data.title ?? 'SAR', { globalThis.registration.showNotification(data.title ?? 'SAR', {
body: data.body ?? '', body: data.body ?? '',
icon: '/sar-icon.png', icon: '/sar-icon.png',
badge: '/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(); event.notification.close();
const url = event.notification.data?.url; const url = event.notification.data?.url;
if (url) { if (url) {

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

View File

@@ -5,6 +5,7 @@ import {
Col, Col,
DatePicker, DatePicker,
Flex, Flex,
InputNumber,
Progress, Progress,
Row, Row,
Skeleton, Skeleton,
@@ -23,15 +24,40 @@ import {
faAddressCard, faAddressCard,
faBullseye, faBullseye,
faArrowTrendUp, faArrowTrendUp,
faCalendarDay,
faLayerGroup, faLayerGroup,
} from '@fortawesome/free-solid-svg-icons'; } 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 dayjs from 'dayjs';
import type { 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 { useManagerDashboard } from '../../lib/queries/gerente';
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ChartTooltip,
Legend,
);
const { Title, Text } = Typography; const { Title, Text } = Typography;
// Meta diária de positivação é preferência local do gerente (não vem do ERP)
const META_POSITIVACAO_DIA_KEY = 'sar:ger:meta-positivacao-dia';
function fmt(v: number): string { function fmt(v: number): string {
return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }); return v.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
} }
@@ -185,6 +211,127 @@ const rankingColumns: TableColumnsType<RankingRep> = [
}, },
]; ];
function PositivacaoDiariaCard({
dados,
periodo,
periodoLabel,
isMesAtual,
}: {
dados: PositivacaoDia[];
periodo: Dayjs;
periodoLabel: string;
isMesAtual: boolean;
}) {
const [meta, setMeta] = useState<number>(() => {
const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY));
return Number.isFinite(saved) && saved > 0 ? saved : 0;
});
function changeMeta(v: number | null) {
const val = v ?? 0;
setMeta(val);
localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val));
}
// 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 &mdash; {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={changeMeta}
placeholder="—"
style={{ width: 80 }}
/>
</Flex>
}
>
<div style={{ height: 280 }}>
<Chart type="bar" data={chartData} options={options} />
</div>
</Card>
);
}
export function GerPainel() { export function GerPainel() {
const [periodo, setPeriodo] = useState<Dayjs>(dayjs()); const [periodo, setPeriodo] = useState<Dayjs>(dayjs());
const mes = periodo.month() + 1; const mes = periodo.month() + 1;
@@ -223,6 +370,7 @@ export function GerPainel() {
metasPorGrupo, metasPorGrupo,
rankingReps, rankingReps,
positivacaoReps, positivacaoReps,
positivacaoDiaria,
syncedAt, syncedAt,
} = data; } = data;
@@ -478,6 +626,14 @@ export function GerPainel() {
/> />
</Card> </Card>
{/* Positivação Diária */}
<PositivacaoDiariaCard
dados={positivacaoDiaria}
periodo={periodo}
periodoLabel={periodoLabel}
isMesAtual={isMesAtual}
/>
{/* Positivação por Representante */} {/* Positivação por Representante */}
<Card <Card
title={ title={

View File

@@ -8,6 +8,7 @@ import {
InputNumber, InputNumber,
Modal, Modal,
Popconfirm, Popconfirm,
Select,
Skeleton, Skeleton,
Space, Space,
Table, Table,
@@ -22,7 +23,13 @@ import type { TableColumnsType } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons'; import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { AlcadaDescontoItem, Promocao } from '@sar/api-interface'; import type {
AlcadaDescontoItem,
Promocao,
RegraDesconto,
RepStats,
GrupoProdutoItem,
} from '@sar/api-interface';
import { import {
usePoliticasDescontos, usePoliticasDescontos,
usePromocoes, usePromocoes,
@@ -30,6 +37,12 @@ import {
useCreatePromocao, useCreatePromocao,
useUpdatePromocao, useUpdatePromocao,
useDeletePromocao, useDeletePromocao,
useEquipe,
useRegrasDesconto,
useGruposProduto,
useCreateRegraDesconto,
useUpdateRegraDesconto,
useDeleteRegraDesconto,
} from '../../lib/queries/gerente'; } from '../../lib/queries/gerente';
const { Title, Text } = Typography; const { Title, Text } = Typography;
@@ -49,11 +62,15 @@ function TabDescontos() {
} }
async function saveEdit(row: AlcadaDescontoItem) { async function saveEdit(row: AlcadaDescontoItem) {
await upsert.mutateAsync({ try {
codVendedor: row.codVendedor, await upsert.mutateAsync({
codGrupo: row.codGrupo, codVendedor: row.codVendedor,
limitePerc: editValue, codGrupo: row.codGrupo,
}); limitePerc: editValue,
});
} catch {
return; // erro já notificado pelo handler global do QueryClient
}
setEditingKey(null); setEditingKey(null);
void msg.success('Limite atualizado'); void msg.success('Limite atualizado');
} }
@@ -197,18 +214,26 @@ function TabPromocoes() {
dataInicio: inicio.format('YYYY-MM-DD'), dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'), dataFim: fim.format('YYYY-MM-DD'),
}; };
if (editTarget) { try {
await updateMutation.mutateAsync({ id: editTarget.id, body }); if (editTarget) {
void msg.success('Promocao atualizada'); await updateMutation.mutateAsync({ id: editTarget.id, body });
} else { void msg.success('Promocao atualizada');
await createMutation.mutateAsync(body); } else {
void msg.success('Promocao criada'); await createMutation.mutateAsync(body);
void msg.success('Promocao criada');
}
} catch {
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
} }
setModalOpen(false); setModalOpen(false);
} }
async function handleDelete(id: number) { 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'); void msg.success('Promocao removida');
} }
@@ -358,6 +383,294 @@ function TabPromocoes() {
); );
} }
// ─── Tab Regras de Desconto ───────────────────────────────────────────────────
interface RegraForm {
descricao: string;
codVendedor?: number;
codGrupo?: number;
codSubgrupo?: number;
descPct: number;
periodo: [dayjs.Dayjs, dayjs.Dayjs];
}
function TabRegras() {
const { data, isLoading } = useRegrasDesconto();
const { data: equipe } = useEquipe();
const { data: gruposData } = useGruposProduto();
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 repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
value: r.codVendedor,
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
}));
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.grupo ?? `Grupo ${g.codGrupo}` });
}
if (
g.codSubgrupo != null &&
(grupoSelecionado == null || g.codGrupo === grupoSelecionado) &&
!subgrupoMap.has(g.codSubgrupo)
) {
subgrupoMap.set(g.codSubgrupo, {
value: g.codSubgrupo,
label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`,
});
}
}
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,
descPct: r.descPct,
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
});
setModalOpen(true);
}
async function handleSubmit(values: RegraForm) {
const [inicio, fim] = values.periodo;
const body = {
descricao: values.descricao,
codVendedor: values.codVendedor ?? null,
codGrupo: values.codGrupo ?? null,
codSubgrupo: values.codSubgrupo ?? null,
descPct: values.descPct,
dataInicio: inicio.format('YYYY-MM-DD'),
dataFim: fim.format('YYYY-MM-DD'),
};
try {
if (editTarget) {
await updateMutation.mutateAsync({ id: editTarget.id, body });
void msg.success('Regra atualizada');
} else {
await createMutation.mutateAsync(body);
void msg.success('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: 220,
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: '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: '',
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 e subgrupo 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={520}
centered
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Form.Item
name="descricao"
label="Descricao"
rules={[{ required: true, message: 'Informe a descricao' }]}
>
<Input placeholder="Ex.: Campanha linha inverno" />
</Form.Item>
<Form.Item name="codVendedor" label="Representante">
<Select
options={repOptions}
placeholder="Todos os representantes"
allowClear
showSearch
optionFilterProp="label"
/>
</Form.Item>
<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>
</Flex>
<Form.Item
name="descPct"
label="Desconto (%)"
rules={[{ required: true, message: 'Informe o desconto' }]}
>
<InputNumber min={0} max={100} suffix="%" style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="periodo"
label="Periodo de vigencia"
rules={[{ required: true, message: 'Informe o periodo' }]}
>
<DatePicker.RangePicker
format="DD/MM/YYYY"
style={{ width: '100%' }}
placeholder={['Inicio', 'Fim']}
/>
</Form.Item>
</Form>
</Modal>
</>
);
}
// ─── PoliticasPage ───────────────────────────────────────────────────────────── // ─── PoliticasPage ─────────────────────────────────────────────────────────────
export function PoliticasPage() { export function PoliticasPage() {
@@ -372,6 +685,11 @@ export function PoliticasPage() {
label: 'Promocoes', label: 'Promocoes',
children: <TabPromocoes />, children: <TabPromocoes />,
}, },
{
key: 'regras',
label: 'Regras de Desconto',
children: <TabRegras />,
},
]; ];
return ( return (

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

View File

@@ -0,0 +1,398 @@
import { useState } from 'react';
import {
Alert,
Badge,
Button,
Card,
Col,
Flex,
Row,
Skeleton,
Space,
Statistic,
Table,
Tag,
Typography,
} from 'antd';
import type { TableColumnsType } from 'antd';
import {
ArrowLeftOutlined,
ExclamationCircleOutlined,
FireOutlined,
RiseOutlined,
TeamOutlined,
WarningOutlined,
} from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router';
import type { CarteiraCliente } from '@sar/api-interface';
import { useReportCarteira } from '../../lib/queries/reports';
const { Title, Text } = Typography;
function fmt(v: number | string): string {
return Number(v).toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' });
}
function fmtDate(v: string | null | undefined): string {
if (!v) return '—';
return new Date(v + 'T00:00:00').toLocaleDateString('pt-BR');
}
type Filtro = 'todos' | 'ativos' | 'risco' | 'inativos' | 'semPedido';
function statusBadge(c: CarteiraCliente) {
if (c.totalPedidos === 0)
return <Badge color="#94A3B8" text={<Text style={{ fontSize: 12 }}>Sem pedido</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' }}>Em risco 30d+</Text>}
/>
);
return (
<Badge color="#22c55e" text={<Text style={{ fontSize: 12, color: '#22c55e' }}>Ativo</Text>} />
);
}
function InsightCard({
icon,
cor,
titulo,
descricao,
}: {
icon: React.ReactNode;
cor: string;
titulo: string;
descricao: string;
}) {
return (
<Card
styles={{ body: { padding: '14px 18px' } }}
style={{ borderLeft: `4px solid ${cor}`, borderRadius: 8 }}
>
<Flex gap={12} align="flex-start">
<span style={{ color: cor, fontSize: 18, marginTop: 2 }}>{icon}</span>
{/* minWidth: 0 — flex item precisa poder encolher; titulo/descricao
embutem razão social e valores que podem não ter ponto de quebra. */}
<div style={{ minWidth: 0 }}>
<Text strong style={{ fontSize: 13, color: '#1e293b' }}>
{titulo}
</Text>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
{descricao}
</Text>
</div>
</div>
</Flex>
</Card>
);
}
export function CarteirePage() {
const navigate = useNavigate();
const { data, isLoading, isError, error } = useReportCarteira();
const [filtro, setFiltro] = useState<Filtro>('todos');
const clientes = data?.data ?? [];
const ativos = clientes.filter(
(c) => c.totalPedidos > 0 && (c.diasSemPedido == null || c.diasSemPedido < 30),
);
const emRisco = clientes.filter(
(c) => c.diasSemPedido != null && c.diasSemPedido >= 30 && c.diasSemPedido < 60,
);
const inativos = clientes.filter((c) => c.diasSemPedido != null && c.diasSemPedido >= 60);
const semPedido = clientes.filter((c) => c.totalPedidos === 0);
const filtered = (() => {
if (filtro === 'ativos') return ativos;
if (filtro === 'risco') return emRisco;
if (filtro === 'inativos') return inativos;
if (filtro === 'semPedido') return semPedido;
return clientes;
})();
// Insights calculados
const top5Fat = clientes
.slice()
.sort((a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal))
.slice(0, 5);
const top5Pct = top5Fat.reduce((acc, c) => acc + c.participacaoPct, 0);
const maiorCliEmRisco = emRisco.sort(
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
)[0];
const maiorCliInativo = inativos.sort(
(a, b) => Number(b.faturamentoTotal) - Number(a.faturamentoTotal),
)[0];
const insights: { icon: React.ReactNode; cor: string; titulo: string; descricao: string }[] = [];
if (top5Pct >= 60) {
insights.push({
icon: <ExclamationCircleOutlined />,
cor: '#f59e0b',
titulo: `Concentração: top 5 clientes = ${top5Pct.toFixed(0)}% do faturamento`,
descricao: 'Risco alto de dependência. Considere expandir outros clientes da carteira.',
});
}
if (maiorCliEmRisco) {
const nome =
maiorCliEmRisco.razao ?? maiorCliEmRisco.nome ?? `Cliente ${maiorCliEmRisco.idCliente}`;
insights.push({
icon: <WarningOutlined />,
cor: '#f59e0b',
titulo: `${nome} está ${maiorCliEmRisco.diasSemPedido}d sem comprar`,
descricao: `Faturamento histórico: ${fmt(maiorCliEmRisco.faturamentoTotal)} — priorize o contato.`,
});
}
if (maiorCliInativo) {
const nome =
maiorCliInativo.razao ?? maiorCliInativo.nome ?? `Cliente ${maiorCliInativo.idCliente}`;
insights.push({
icon: <FireOutlined />,
cor: '#ef4444',
titulo: `${nome} inativo há ${maiorCliInativo.diasSemPedido}d`,
descricao: `Era um cliente de ${fmt(maiorCliInativo.faturamentoTotal)} — vale reativar.`,
});
}
if (semPedido.length > 0) {
insights.push({
icon: <RiseOutlined />,
cor: '#003B8E',
titulo: `${semPedido.length} cliente${semPedido.length > 1 ? 's' : ''} sem nenhum pedido`,
descricao: 'Potencial inexplorado na sua carteira. Primeira visita pode gerar receita nova.',
});
}
const columns: TableColumnsType<CarteiraCliente> = [
{
title: 'Cliente',
key: 'cliente',
render: (_: unknown, c: CarteiraCliente) => (
<Space orientation="vertical" size={0}>
<Text
strong
style={{ fontSize: 13, cursor: 'pointer', color: '#003B8E' }}
onClick={() => navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } })}
>
{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>
),
sorter: (a, b) => (a.ultimoPedido ?? '').localeCompare(b.ultimoPedido ?? ''),
},
{
title: 'Dias Sem Compra',
dataIndex: 'diasSemPedido',
width: 140,
align: 'right' as const,
render: (v: number | null) =>
v != null ? (
<Text
strong
className="tabular-nums"
style={{ color: v >= 60 ? '#ef4444' : v >= 30 ? '#f59e0b' : '#22c55e' }}
>
{v}d
</Text>
) : (
<Text type="secondary"></Text>
),
sorter: (a, b) => (a.diasSemPedido ?? 9999) - (b.diasSemPedido ?? 9999),
},
{
title: 'Faturado (total)',
dataIndex: 'faturamentoTotal',
width: 150,
align: 'right' as const,
render: (v: string) => (
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{fmt(v)}
</Text>
),
sorter: (a, b) => Number(a.faturamentoTotal) - Number(b.faturamentoTotal),
defaultSortOrder: 'descend' as const,
},
{
title: 'Participação',
dataIndex: 'participacaoPct',
width: 110,
align: 'right' as const,
render: (v: number) => (
<Tag
color={v >= 10 ? 'blue' : v >= 5 ? 'geekblue' : 'default'}
style={{ borderRadius: 20, fontWeight: 600 }}
>
{v.toFixed(1)}%
</Tag>
),
sorter: (a, b) => a.participacaoPct - b.participacaoPct,
},
{
title: 'Ticket Médio',
dataIndex: 'ticketMedio',
width: 130,
align: 'right' as const,
render: (v: string) => (
<Text className="tabular-nums" style={{ color: '#475569' }}>
{fmt(v)}
</Text>
),
sorter: (a, b) => Number(a.ticketMedio) - Number(b.ticketMedio),
},
{
title: 'Pedidos',
dataIndex: 'totalPedidos',
width: 80,
align: 'right' as const,
render: (v: number) => <Text className="tabular-nums">{v}</Text>,
sorter: (a, b) => a.totalPedidos - b.totalPedidos,
},
];
const filtros: { key: Filtro; label: string; count: number; cor: string }[] = [
{ key: 'todos', label: 'Todos', count: clientes.length, cor: '#64748b' },
{ key: 'ativos', label: 'Ativos', count: ativos.length, cor: '#22c55e' },
{ key: 'risco', label: 'Em risco (30d+)', count: emRisco.length, cor: '#f59e0b' },
{ key: 'inativos', label: 'Inativos (60d+)', count: inativos.length, cor: '#ef4444' },
{ key: 'semPedido', label: 'Sem pedido', count: semPedido.length, cor: '#94A3B8' },
];
return (
<div style={{ maxWidth: 1200, margin: '0 auto' }}>
{/* Cabeçalho */}
<Flex align="center" gap={12} style={{ marginBottom: 24 }}>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate({ to: '/clientes' })}
type="text"
style={{ color: '#64748b' }}
/>
<div>
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
<TeamOutlined style={{ marginRight: 8 }} />
Carteira de Clientes
</Title>
<Text type="secondary" style={{ fontSize: 13 }}>
Visão completa da sua carteira com indicadores de saúde e oportunidades
</Text>
</div>
</Flex>
{isError && (
<Alert
type="error"
showIcon
message="Erro ao carregar carteira"
description={error instanceof Error ? error.message : 'Erro desconhecido'}
style={{ marginBottom: 24 }}
/>
)}
{isLoading ? (
<Skeleton active paragraph={{ rows: 10 }} />
) : (
<>
{/* Cards de resumo */}
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{filtros.map((f) => (
<Col key={f.key} xs={12} sm={8} md={4} style={{ minWidth: 120 }}>
<Card
hoverable
onClick={() => setFiltro(f.key)}
styles={{ body: { padding: '12px 16px' } }}
style={{
borderRadius: 8,
border: filtro === f.key ? `2px solid ${f.cor}` : '1px solid #EBF0F5',
cursor: 'pointer',
}}
>
<Statistic
title={f.label}
value={f.count}
styles={{ content: { color: f.cor, fontSize: 24 } }}
/>
</Card>
</Col>
))}
{data && (
<Col xs={12} sm={8} md={6}>
<Card styles={{ body: { padding: '12px 16px' } }} style={{ borderRadius: 8 }}>
<Statistic
title="Faturamento Total (ERP)"
value={fmt(data.faturamentoRepTotal)}
styles={{ content: { color: '#003B8E', fontSize: 18 } }}
/>
</Card>
</Col>
)}
</Row>
{/* Insights */}
{insights.length > 0 && (
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
{insights.map((ins, i) => (
<Col key={i} xs={24} md={12}>
<InsightCard {...ins} />
</Col>
))}
</Row>
)}
{/* Tabela */}
<Card
style={{ borderRadius: 10, border: '1px solid #EBF0F5' }}
styles={{ body: { padding: 0 } }}
>
<Table<CarteiraCliente>
rowKey="idCliente"
columns={columns}
dataSource={filtered}
size="middle"
pagination={{ pageSize: 20, showSizeChanger: false }}
scroll={{ x: 900 }}
style={{ borderRadius: 10, overflow: 'hidden' }}
onRow={(c) => ({
onClick: () =>
navigate({ to: '/clientes/$id', params: { id: String(c.idCliente) } }),
style: { cursor: 'pointer' },
})}
/>
</Card>
</>
)}
</div>
);
}

View File

@@ -1,13 +1,15 @@
import { useState } from 'react'; import { useState } from 'react';
import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd'; import { Grid, Table, Input, Select, Space, Typography, Tag, Button, Tooltip } from 'antd';
const { useBreakpoint } = Grid;
import { EyeOutlined } from '@ant-design/icons'; import { EyeOutlined } from '@ant-design/icons';
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import type { ProdutoSummary } from '@sar/api-interface'; import type { ProdutoSummary } from '@sar/api-interface';
import { useCatalog, usePautas } from '../../lib/queries/catalog'; import { useCatalog, usePautas } from '../../lib/queries/catalog';
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
import { CondicaoTag } from '../../components/CondicaoTag';
import { ProductDetailDrawer } from './ProductDetailDrawer'; import { ProductDetailDrawer } from './ProductDetailDrawer';
const { useBreakpoint } = Grid;
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { Search } = Input; 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' }) : '—'; 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 [ return [
{ {
title: 'Código', title: 'Código',
@@ -29,7 +34,18 @@ function buildColumns(onDetail: (id: number) => void): TableColumnsType<ProdutoS
dataIndex: 'descricao', dataIndex: 'descricao',
render: (v: string, row: ProdutoSummary) => ( render: (v: string, row: ProdutoSummary) => (
<div> <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>} {row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
</div> </div>
), ),
@@ -101,8 +117,9 @@ export function CatalogPage() {
const { data: pautas, isLoading: pautasLoading } = usePautas(); const { data: pautas, isLoading: pautasLoading } = usePautas();
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit }); const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
const condicoesDe = useCondicoesComerciais();
const columns = buildColumns((id) => setSelectedIdErp(id)); const columns = buildColumns((id) => setSelectedIdErp(id), condicoesDe);
return ( return (
<div style={{ maxWidth: 1400, margin: '0 auto' }}> <div style={{ maxWidth: 1400, margin: '0 auto' }}>

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

View File

@@ -15,8 +15,6 @@ import {
Badge, Badge,
} from 'antd'; } from 'antd';
import { useState } from 'react'; import { useState } from 'react';
const { useBreakpoint } = Grid;
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import { CopyOutlined, UserAddOutlined } from '@ant-design/icons'; import { CopyOutlined, UserAddOutlined } from '@ant-design/icons';
import { Link, useNavigate, useParams } from '@tanstack/react-router'; import { Link, useNavigate, useParams } from '@tanstack/react-router';
@@ -32,6 +30,8 @@ import {
} from '../../lib/queries/clients'; } from '../../lib/queries/clients';
import { ClientContacts } from '../../components/contacts/ClientContacts'; import { ClientContacts } from '../../components/contacts/ClientContacts';
const { useBreakpoint } = Grid;
const { Title, Text } = Typography; const { Title, Text } = Typography;
const ACTIVITY_COLOR: Record<string, string> = { const ACTIVITY_COLOR: Record<string, string> = {
@@ -251,7 +251,7 @@ export function ClientDetailPage() {
{client.telefone ?? '—'} {client.telefone ?? '—'}
</Descriptions.Item> </Descriptions.Item>
{client.endereco && ( {client.endereco && (
<Descriptions.Item label="Endereço" span={2}> <Descriptions.Item label="Endereço" span={isMobile ? 1 : 2}>
{client.endereco} {client.endereco}
{client.numEndereco ? `, ${client.numEndereco}` : ''} {client.numEndereco ? `, ${client.numEndereco}` : ''}
{client.bairro ? `${client.bairro}` : ''} {client.bairro ? `${client.bairro}` : ''}
@@ -284,7 +284,9 @@ export function ClientDetailPage() {
}} }}
> >
{/* ── Contas a Receber ── */} {/* ── 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 <div
style={{ style={{
display: 'flex', display: 'flex',
@@ -390,7 +392,7 @@ export function ClientDetailPage() {
</div> </div>
{/* ── Notas Fiscais ── */} {/* ── Notas Fiscais ── */}
<div> <div style={{ minWidth: 0 }}>
<div <div
style={{ style={{
display: 'flex', display: 'flex',

View File

@@ -186,7 +186,13 @@ function CustomerMetrics({ stats }: { stats: PortfolioStats }) {
// ─── CustomerPortfolioCard ──────────────────────────────────────────────────── // ─── CustomerPortfolioCard ────────────────────────────────────────────────────
function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) { function CustomerPortfolioCard({
stats,
onDetalhar,
}: {
stats: PortfolioStats;
onDetalhar: () => void;
}) {
const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' }); const mesAtual = new Date().toLocaleDateString('pt-BR', { month: 'long', year: 'numeric' });
const total = stats.ativos + stats.emAlerta + stats.inativos; const total = stats.ativos + stats.emAlerta + stats.inativos;
@@ -313,6 +319,7 @@ function CustomerPortfolioCard({ stats }: { stats: PortfolioStats }) {
<Divider style={{ margin: '12px 0' }} /> <Divider style={{ margin: '12px 0' }} />
<Button <Button
block block
onClick={onDetalhar}
style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }} style={{ borderRadius: 8, fontWeight: 600, color: '#003B8E', borderColor: '#003B8E' }}
> >
Detalhar carteira Detalhar carteira
@@ -500,7 +507,7 @@ function CustomerDetailsModal({
</Space> </Space>
} }
> >
<Space direction="vertical" size={20} style={{ width: '100%' }}> <Space orientation="vertical" size={20} style={{ width: '100%' }}>
{/* Identificação */} {/* Identificação */}
<Card <Card
style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }} style={{ borderRadius: 8, background: '#F8FAFC', border: '1px solid #EBF0F5' }}
@@ -544,7 +551,7 @@ function CustomerDetailsModal({
> >
Dados Cadastrais Dados Cadastrais
</Text> </Text>
<Space direction="vertical" size={10} style={{ width: '100%' }}> <Space orientation="vertical" size={10} style={{ width: '100%' }}>
<div> <div>
<span style={label}>Telefone</span> <span style={label}>Telefone</span>
<Space size={4}> <Space size={4}>
@@ -596,7 +603,7 @@ function CustomerDetailsModal({
> >
Dados Comerciais Dados Comerciais
</Text> </Text>
<Space direction="vertical" size={10} style={{ width: '100%' }}> <Space orientation="vertical" size={10} style={{ width: '100%' }}>
{limiteFormatado !== '—' && ( {limiteFormatado !== '—' && (
<div> <div>
<span style={label}>Limite de Crédito</span> <span style={label}>Limite de Crédito</span>
@@ -657,7 +664,7 @@ function CustomerDetailsModal({
{loadingOrders ? ( {loadingOrders ? (
<Spin size="small" /> <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) => ( {orders.slice(0, 5).map((p) => (
<div <div
key={p.id} key={p.id}
@@ -811,7 +818,7 @@ function CustomerAnalysisModal({
</Space> </Space>
} }
> >
<Space direction="vertical" size={20} style={{ width: '100%' }}> <Space orientation="vertical" size={20} style={{ width: '100%' }}>
<div> <div>
<Space size={8}> <Space size={8}>
<CustomerStatusBadge status={summary.activityStatus} /> <CustomerStatusBadge status={summary.activityStatus} />
@@ -1080,7 +1087,7 @@ export function ClientsPage() {
limit, limit,
}); });
const rows = data?.data ?? []; const rows = useMemo(() => data?.data ?? [], [data]);
const sorted = useMemo(() => { const sorted = useMemo(() => {
const r = [...rows]; const r = [...rows];
@@ -1456,14 +1463,19 @@ export function ClientsPage() {
{/* ── Portfolio mobile (antes da lista) ─────────────────────────── */} {/* ── Portfolio mobile (antes da lista) ─────────────────────────── */}
{isMobile && ( {isMobile && (
<> <>
<CustomerPortfolioCard stats={stats} /> <CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
<div style={{ marginBottom: 16 }} /> <div style={{ marginBottom: 16 }} />
</> </>
)} )}
{/* ── Área principal ────────────────────────────────────────────── */} {/* ── Área principal ────────────────────────────────────────────── */}
<Row gutter={[20, 20]}> <Row gutter={[20, 20]}>
<Col xs={24} lg={17}> {/* minWidth: 0 — Col é flex item (min-width auto). Sem isto a tabela com
scroll x estoura a coluna e engole o card vizinho. */}
<Col xs={24} lg={17} style={{ minWidth: 0 }}>
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: 'center', padding: 64 }}> <div style={{ textAlign: 'center', padding: 64 }}>
<Spin size="large" /> <Spin size="large" />
@@ -1539,7 +1551,10 @@ export function ClientsPage() {
{!isMobile && ( {!isMobile && (
<Col lg={7}> <Col lg={7}>
<CustomerPortfolioCard stats={stats} /> <CustomerPortfolioCard
stats={stats}
onDetalhar={() => navigate({ to: '/clientes/carteira' })}
/>
</Col> </Col>
)} )}
</Row> </Row>

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

View File

@@ -129,7 +129,12 @@ export function NewClientPage() {
codPauta: values.codPauta ? Number(values.codPauta) : undefined, 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) { if (!result.sincronizado && result.erroSync) {
setSyncError(result.erroSync); setSyncError(result.erroSync);

File diff suppressed because it is too large Load Diff

View File

@@ -20,7 +20,8 @@ import {
} from 'antd'; } from 'antd';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faShareNodes } from '@fortawesome/free-solid-svg-icons'; 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 type { TableColumnsType } from 'antd';
import { Link, useParams, useNavigate } from '@tanstack/react-router'; import { Link, useParams, useNavigate } from '@tanstack/react-router';
import type { PedidoItem, HistoricoPedido } from '@sar/api-interface'; import type { PedidoItem, HistoricoPedido } from '@sar/api-interface';
@@ -118,7 +119,7 @@ function HistoryTimeline({ history }: { history: HistoricoPedido[] }) {
: SITUA_COLOR[h.situaNova] === 'error' : SITUA_COLOR[h.situaNova] === 'error'
? 'red' ? 'red'
: 'blue', : 'blue',
children: ( content: (
<div> <div>
<Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text> <Text strong>{SITUA_LABEL[h.situaNova] ?? String(h.situaNova)}</Text>
{h.situaAnterior != null && ( {h.situaAnterior != null && (
@@ -263,6 +264,7 @@ export function OrderDetailPage() {
const [approveOpen, setApproveOpen] = useState(false); const [approveOpen, setApproveOpen] = useState(false);
const [rejectOpen, setRejectOpen] = useState(false); const [rejectOpen, setRejectOpen] = useState(false);
const [chamadoOpen, setChamadoOpen] = useState(false);
const [actionError, setActionError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null);
const approveMutation = useMutation({ const approveMutation = useMutation({
@@ -383,6 +385,11 @@ export function OrderDetailPage() {
Compartilhar Compartilhar
</Button> </Button>
)} )}
{role === 'rep' && !isErp && (order.situa === 2 || order.situa === 4) && (
<Button icon={<CustomerServiceOutlined />} onClick={() => setChamadoOpen(true)}>
Abrir Chamado
</Button>
)}
</Space> </Space>
{actionError && ( {actionError && (
@@ -425,24 +432,29 @@ export function OrderDetailPage() {
)} )}
<Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item> <Descriptions.Item label="Total produtos">{fmt(order.totalProdutos)}</Descriptions.Item>
<Descriptions.Item label="Desc. Global">{order.descontoPerc}%</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 }}> <Text strong style={{ fontSize: 16 }}>
{fmt(order.total)} {fmt(order.total)}
</Text> </Text>
</Descriptions.Item> </Descriptions.Item>
{order.endEntrega && (
<Descriptions.Item label="End. Entrega" span={isOrcamento ? 3 : 2}>
{order.endEntrega}
</Descriptions.Item>
)}
{order.obs && ( {order.obs && (
<Descriptions.Item label="Observações" span={2}> <Descriptions.Item label="Observações" span={isOrcamento ? 3 : 2}>
{order.obs} {order.obs}
</Descriptions.Item> </Descriptions.Item>
)} )}
{order.motivoRecusa && ( {order.motivoRecusa && (
<Descriptions.Item label="Motivo Recusa" span={2}> <Descriptions.Item label="Motivo Recusa" span={isOrcamento ? 3 : 2}>
<Text type="danger">{order.motivoRecusa}</Text> <Text type="danger">{order.motivoRecusa}</Text>
</Descriptions.Item> </Descriptions.Item>
)} )}
</Descriptions> </Descriptions>
<Divider orientation="left">Itens ({order.itens.length})</Divider> <Divider titlePlacement="left">Itens ({order.itens.length})</Divider>
<Table<PedidoItem> <Table<PedidoItem>
rowKey="id" rowKey="id"
columns={itemColumns} columns={itemColumns}
@@ -454,7 +466,7 @@ export function OrderDetailPage() {
{clientOrders && clientOrders.length > 0 && ( {clientOrders && clientOrders.length > 0 && (
<> <>
<Divider orientation="left">Outros Pedidos do Cliente</Divider> <Divider titlePlacement="left">Outros Pedidos do Cliente</Divider>
<Table <Table
rowKey="id" rowKey="id"
size="small" size="small"
@@ -496,7 +508,7 @@ export function OrderDetailPage() {
</> </>
)} )}
<Divider orientation="left">Histórico do Pedido</Divider> <Divider titlePlacement="left">Histórico do Pedido</Divider>
<HistoryTimeline history={order.historico} /> <HistoryTimeline history={order.historico} />
<ApproveModal <ApproveModal
@@ -512,6 +524,17 @@ export function OrderDetailPage() {
onCancel={() => setRejectOpen(false)} onCancel={() => setRejectOpen(false)}
loading={rejectMutation.isPending} 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> </div>
); );
} }

View File

@@ -399,11 +399,23 @@ export function OrderPrintPage() {
</div> </div>
</div> </div>
{/* ── Observações + rodapé ────────────────────────────────────────── */} {/* ── Endereço de entrega + Observações ───────────────────────────── */}
{order.obs && ( {(order.endEntrega || order.obs) && (
<div style={{ padding: '10px 28px 0' }}> <div style={{ padding: '10px 28px 0', display: 'flex', gap: 24, flexWrap: 'wrap' }}>
<span style={label}>Observações</span> {order.endEntrega && (
<div style={{ fontSize: 11, color: '#475569', lineHeight: 1.5 }}>{order.obs}</div> <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>
)} )}
<div <div

View File

@@ -116,7 +116,7 @@ function ErpConsultaModal({
footer={<Button onClick={onClose}>Fechar</Button>} footer={<Button onClick={onClose}>Fechar</Button>}
> >
{!row ? null : ( {!row ? null : (
<Space direction="vertical" size={20} style={{ width: '100%' }}> <Space orientation="vertical" size={20} style={{ width: '100%' }}>
{/* ── Cabeçalho ── */} {/* ── Cabeçalho ── */}
<Card <Card
styles={{ body: { padding: '14px 16px' } }} styles={{ body: { padding: '14px 16px' } }}
@@ -257,7 +257,7 @@ function ErpConsultaModal({
title: 'Produto', title: 'Produto',
key: 'produto', key: 'produto',
render: (_: unknown, item) => ( render: (_: unknown, item) => (
<Space direction="vertical" size={0}> <Space orientation="vertical" size={0}>
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text> <Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
<Text style={{ fontWeight: 500, fontSize: 13 }}>{item.descProduto}</Text> <Text style={{ fontWeight: 500, fontSize: 13 }}>{item.descProduto}</Text>
</Space> </Space>
@@ -276,7 +276,7 @@ function ErpConsultaModal({
width: 120, width: 120,
align: 'right' as const, align: 'right' as const,
render: (v: string, item) => ( 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> <Text className="tabular-nums">{fmt(Number(v))}</Text>
{Number(item.descontoPerc) > 0 && ( {Number(item.descontoPerc) > 0 && (
<Text style={{ fontSize: 11, color: '#f59e0b' }}> <Text style={{ fontSize: 11, color: '#f59e0b' }}>
@@ -364,7 +364,7 @@ export function OrdersErpPage() {
key: 'pedido', key: 'pedido',
width: 110, width: 110,
render: (_: unknown, row: PedidoErpConsultaItem) => ( render: (_: unknown, row: PedidoErpConsultaItem) => (
<Space direction="vertical" size={0}> <Space orientation="vertical" size={0}>
<Text strong className="tabular-nums" style={{ color: '#003B8E' }}> <Text strong className="tabular-nums" style={{ color: '#003B8E' }}>
{row.numeroPedido} {row.numeroPedido}
</Text> </Text>
@@ -446,7 +446,7 @@ export function OrdersErpPage() {
render: (_: unknown, row: PedidoErpConsultaItem) => { render: (_: unknown, row: PedidoErpConsultaItem) => {
if (row.tipo !== 'E') return <Text type="secondary"></Text>; if (row.tipo !== 'E') return <Text type="secondary"></Text>;
return ( return (
<Space direction="vertical" size={2}> <Space orientation="vertical" size={2}>
{row.numeroEntrega && ( {row.numeroEntrega && (
<Text className="tabular-nums" style={{ fontSize: 12, color: '#475569' }}> <Text className="tabular-nums" style={{ fontSize: 12, color: '#475569' }}>
Entrega {row.numeroEntrega} Entrega {row.numeroEntrega}

View File

@@ -1,4 +1,5 @@
import { useState, useMemo } from 'react'; import { useState, useMemo } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
App, App,
Button, Button,
@@ -21,8 +22,6 @@ import {
import type { TableColumnsType } from 'antd'; import type { TableColumnsType } from 'antd';
import type { MenuProps } from 'antd'; import type { MenuProps } from 'antd';
import type { Dayjs } from 'dayjs'; import type { Dayjs } from 'dayjs';
const { RangePicker } = DatePicker;
import { import {
CheckCircleOutlined, CheckCircleOutlined,
ClockCircleOutlined, ClockCircleOutlined,
@@ -43,6 +42,9 @@ import { SITUA_LABEL } from '@sar/api-interface';
import { useOrderList, useOrderDetail } from '../../lib/queries/orders'; import { useOrderList, useOrderDetail } from '../../lib/queries/orders';
import { usePendingOrders } from '../../lib/hooks/usePendingOrders'; import { usePendingOrders } from '../../lib/hooks/usePendingOrders';
import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue'; import { removePendingOrder, retryPendingOrder } from '../../lib/offline/order-queue';
import { apiFetch } from '../../lib/api-client';
const { RangePicker } = DatePicker;
const { Title, Text } = Typography; const { Title, Text } = Typography;
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@@ -250,6 +252,19 @@ function OrderActionsMenu({
onView: (id: string) => void; onView: (id: string) => void;
}) { }) {
const navigate = useNavigate(); const navigate = useNavigate();
const qc = useQueryClient();
const { modal, message: msg } = App.useApp();
const cancelMutation = useMutation({
mutationFn: () => apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH' }),
onSuccess: () => {
void msg.success('Orçamento cancelado.');
void qc.invalidateQueries({ queryKey: ['orders'] });
},
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'),
});
const canCancel = order.situa === 0 && order.fonte !== 'erp';
const items: MenuProps['items'] = [ const items: MenuProps['items'] = [
{ {
@@ -287,8 +302,18 @@ function OrderActionsMenu({
icon: <CloseCircleOutlined />, icon: <CloseCircleOutlined />,
label: 'Cancelar pedido', label: 'Cancelar pedido',
danger: true, danger: true,
disabled: order.situa === 3, disabled: !canCancel,
onClick: () => alert('Cancelamento em breve'), onClick: () => {
if (!canCancel) return;
void modal.confirm({
title: 'Cancelar orçamento?',
content: `O pedido ${order.numPedSar} será marcado como cancelado. Esta ação não pode ser desfeita.`,
okText: 'Cancelar pedido',
okButtonProps: { danger: true },
cancelText: 'Voltar',
onOk: () => cancelMutation.mutate(),
});
},
}, },
]; ];
@@ -316,7 +341,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
) : ( ) : (
<ClockCircleOutlined style={{ color: '#d46b08' }} /> <ClockCircleOutlined style={{ color: '#d46b08' }} />
), ),
children: ( content: (
<span style={{ fontSize: 13 }}> <span style={{ fontSize: 13 }}>
<Text type="secondary">{new Date(h.changedAt).toLocaleString('pt-BR')}</Text> <Text type="secondary">{new Date(h.changedAt).toLocaleString('pt-BR')}</Text>
{' — '} {' — '}
@@ -387,7 +412,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
{isLoading && <Spin style={{ display: 'block', margin: '48px auto' }} />} {isLoading && <Spin style={{ display: 'block', margin: '48px auto' }} />}
{data && ( {data && (
<Space direction="vertical" size={20} style={{ width: '100%' }}> <Space orientation="vertical" size={20} style={{ width: '100%' }}>
{/* Status */} {/* Status */}
<div> <div>
<OrderStatusBadge situa={data.situa} descr={data.statusDescr} /> <OrderStatusBadge situa={data.situa} descr={data.statusDescr} />
@@ -434,6 +459,12 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
<span style={label}>Representante</span> <span style={label}>Representante</span>
<Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text> <Text>{data.nomeVendedor ?? `Cód. ${data.codVendedor}`}</Text>
</Col> </Col>
{data.endEntrega && (
<Col span={24}>
<span style={label}>End. Entrega</span>
<Text type="secondary">{data.endEntrega}</Text>
</Col>
)}
{data.obs && ( {data.obs && (
<Col span={24}> <Col span={24}>
<span style={label}>Observações</span> <span style={label}>Observações</span>
@@ -460,7 +491,7 @@ function OrderDetailModal({ id, onClose }: { id: string | null; onClose: () => v
title: 'Produto', title: 'Produto',
key: 'produto', key: 'produto',
render: (_: unknown, item) => ( render: (_: unknown, item) => (
<Space direction="vertical" size={0}> <Space orientation="vertical" size={0}>
<Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text> <Text style={{ fontSize: 11, color: '#94A3B8' }}>{item.codProduto}</Text>
<Text style={{ fontWeight: 500 }}>{item.descProduto}</Text> <Text style={{ fontWeight: 500 }}>{item.descProduto}</Text>
</Space> </Space>
@@ -636,7 +667,7 @@ export function OrdersPage() {
limit, limit,
}); });
const rows = data?.data ?? []; const rows = useMemo(() => data?.data ?? [], [data]);
const total = data?.total ?? 0; const total = data?.total ?? 0;
const hasFilters = !!query || !!situaFilter || !!period || !!range; const hasFilters = !!query || !!situaFilter || !!period || !!range;

View File

@@ -1,7 +1,14 @@
import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd'; 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 type { PautaPreco } from '@sar/api-interface';
import { useProdutoDetail } from '../../lib/queries/catalog'; import { useProdutoDetail } from '../../lib/queries/catalog';
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
import { CondicaoTag } from '../../components/CondicaoTag';
const { Text, Title } = Typography; 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 ────────────────────────────────────────────────────────── // ─── Modal principal ──────────────────────────────────────────────────────────
interface Props { interface Props {
@@ -177,6 +234,8 @@ interface Props {
export function ProductDetailDrawer({ idErp, onClose }: Props) { export function ProductDetailDrawer({ idErp, onClose }: Props) {
const { data, isLoading } = useProdutoDetail(idErp ?? undefined); const { data, isLoading } = useProdutoDetail(idErp ?? undefined);
const condicoesDe = useCondicoesComerciais();
const conds = data ? condicoesDe(data) : [];
return ( return (
<Modal <Modal
@@ -227,6 +286,7 @@ export function ProductDetailDrawer({ idErp, onClose }: Props) {
justifyContent: 'flex-end', justifyContent: 'flex-end',
}} }}
> >
<CondicaoTag conds={conds} />
{data.marca && <Tag color="blue">{data.marca.trim()}</Tag>} {data.marca && <Tag color="blue">{data.marca.trim()}</Tag>}
{data.unidade && <Tag>{data.unidade}</Tag>} {data.unidade && <Tag>{data.unidade}</Tag>}
<Tag color={data.ativo ? 'green' : 'red'}>{data.ativo ? 'Ativo' : 'Inativo'}</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}> <Row gutter={40}>
{/* Coluna esquerda */} {/* Coluna esquerda */}
<Col xs={24} md={14}> <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 */} {/* Classificação */}
<Section icon={<TagsOutlined />} title="Classificação"> <Section icon={<TagsOutlined />} title="Classificação">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}> <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>

View File

@@ -18,16 +18,38 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { import {
faArrowTrendUp, faArrowTrendUp,
faBullseye, faBullseye,
faChartBar,
faCircleExclamation, faCircleExclamation,
faClipboardList, faClipboardList,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import { WhatsAppOutlined } from '@ant-design/icons'; import { WhatsAppOutlined } from '@ant-design/icons';
import { Link, useNavigate } from '@tanstack/react-router'; 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 { SITUA_LABEL } from '@sar/api-interface';
import { useRepDashboard } from '../../lib/queries/dashboard'; import { useRepDashboard } from '../../lib/queries/dashboard';
import { useCurrentUser } from '../../lib/queries/auth'; import { useCurrentUser } from '../../lib/queries/auth';
ChartJS.register(
CategoryScale,
LinearScale,
BarElement,
LineElement,
PointElement,
ChartTooltip,
Legend,
);
const { Title, Text } = Typography; const { Title, Text } = Typography;
const SITUA_COLOR: Record<number, string> = { const SITUA_COLOR: Record<number, string> = {
@@ -148,6 +170,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 ────────────────────────────────────────────────────────── // ─── Não positivados ──────────────────────────────────────────────────────────
type OrdemNP = 'longe' | 'recentes'; type OrdemNP = 'longe' | 'recentes';
@@ -204,7 +329,7 @@ function NaoPositivados({ clientes, total }: { clientes: ClienteNaoPositivado[];
const dias = c.diasSemPedido ?? 999; const dias = c.diasSemPedido ?? 999;
const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default'; const cor = dias >= 60 ? 'error' : dias >= 30 ? 'warning' : 'default';
return ( return (
<Space direction="vertical" size={0}> <Space orientation="vertical" size={0}>
<Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}> <Tag color={cor} className="tabular-nums" style={{ borderRadius: 20 }}>
{dias}d sem pedido {dias}d sem pedido
</Tag> </Tag>
@@ -339,9 +464,14 @@ export function RepPainel() {
pedidosRecentes = [], pedidosRecentes = [],
naoPositivados = [], naoPositivados = [],
totalNaoPositivados = 0, totalNaoPositivados = 0,
historicoMensal = [],
syncedAt, syncedAt,
} = data; } = data;
const now = new Date();
const anoAtual = now.getFullYear();
const mesAtual = now.getMonth() + 1;
return ( return (
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}> <Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
{/* Saudação */} {/* Saudação */}
@@ -499,6 +629,9 @@ export function RepPainel() {
</Card> </Card>
)} )}
{/* Gráfico anual */}
<GraficoAnual dados={historicoMensal} ano={anoAtual} mesAtual={mesAtual} />
{/* Linha 2 — Não positivados + Pedidos recentes */} {/* Linha 2 — Não positivados + Pedidos recentes */}
<Row gutter={[24, 24]}> <Row gutter={[24, 24]}>
<Col xs={24} lg={14}> <Col xs={24} lg={14}>

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

View File

@@ -94,7 +94,12 @@ export function ClientContacts({ idCliente, modalOpen = false, onModalClose }: P
anotacoes: values.anotacoes || undefined, 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) { if (!result.sincronizado && result.erroSync) {
void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`); void message.warning(`Contato salvo, mas não sincronizou com ERP: ${result.erroSync}`);

View File

@@ -9,12 +9,14 @@ import { AuthTokenResponseSchema } from '@sar/api-interface';
type DevUser = { key: string; userId: string; role: string; label: string }; type DevUser = { key: string; userId: string; role: string; label: string };
// userId = cod_vendedor como string; idEmpresa = empresa no ERP (dev default = 1) // userId = cod_vendedor como string; idEmpresa fica a cargo do backend (DEV_EMPRESA_ID).
// Em dev, o backend força DEV_REP_CODE=29 independente do userId enviado. // 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[] = [ const DEV_USERS: DevUser[] = [
{ key: 'rep-29', userId: '29', role: 'rep', label: 'Representante (cód. 29)' }, { key: 'rep-29', userId: '29', role: 'rep', label: 'Pavei — Representante (cód. 29)' },
{ key: 'sup-29', userId: '29', role: 'supervisor', label: 'Supervisor (cód. 29)' }, { key: 'sup-191', userId: '191', role: 'supervisor', label: 'Sidnei — Supervisor (cód. 191)' },
{ key: 'mgr-29', userId: '29', role: 'manager', label: 'Gerente (cód. 29)' }, { key: 'mgr-156', userId: '156', role: 'manager', label: 'Lucas — Gerente/Admin (cód. 156)' },
]; ];
export function DevLogin({ onLogin }: { onLogin: () => void }) { export function DevLogin({ onLogin }: { onLogin: () => void }) {
@@ -27,7 +29,7 @@ export function DevLogin({ onLogin }: { onLogin: () => void }) {
try { try {
const raw = await apiFetch('/auth/dev/token', { const raw = await apiFetch('/auth/dev/token', {
method: 'POST', method: 'POST',
body: { userId: user.userId, idEmpresa: 1, role: user.role }, body: { userId: user.userId, role: user.role },
}); });
const { accessToken } = AuthTokenResponseSchema.parse(raw); const { accessToken } = AuthTokenResponseSchema.parse(raw);
authStore.set(accessToken); authStore.set(accessToken);

View File

@@ -1,5 +1,5 @@
import { type ReactNode } from 'react'; import { useEffect, type ReactNode } from 'react';
import { Alert, Button, Flex, Grid, Tooltip } from 'antd'; import { Alert, App, Button, Flex, Grid, Tooltip } from 'antd';
import { PlusOutlined, WifiOutlined } from '@ant-design/icons'; import { PlusOutlined, WifiOutlined } from '@ant-design/icons';
import { useNavigate } from '@tanstack/react-router'; import { useNavigate } from '@tanstack/react-router';
import { Topbar } from './Topbar'; import { Topbar } from './Topbar';
@@ -7,6 +7,7 @@ import { Sidebar } from './Sidebar';
import { BottomNav } from './BottomNav'; import { BottomNav } from './BottomNav';
import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus'; import { useNetworkStatus } from '../../lib/hooks/useNetworkStatus';
import { useOfflineSync } from '../../lib/hooks/useOfflineSync'; import { useOfflineSync } from '../../lib/hooks/useOfflineSync';
import { registerMessageApi } from '../../lib/feedback';
const { useBreakpoint } = Grid; const { useBreakpoint } = Grid;
@@ -18,10 +19,16 @@ export function AppShell({ children }: AppShellProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const isOnline = useNetworkStatus(); const isOnline = useNetworkStatus();
const screens = useBreakpoint(); const screens = useBreakpoint();
const { message } = App.useApp();
// sidebar a partir de lg (992px) — abaixo disso usa bottom nav // sidebar a partir de lg (992px) — abaixo disso usa bottom nav
const isDesktop = !!screens.lg; const isDesktop = !!screens.lg;
useOfflineSync(); useOfflineSync();
// Disponibiliza o message (com tema) para os caches do TanStack Query
useEffect(() => {
registerMessageApi(message);
}, [message]);
return ( return (
<Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}> <Flex vertical style={{ height: '100vh', overflow: 'hidden', background: 'var(--bg-body)' }}>
{!isOnline && ( {!isOnline && (

View File

@@ -11,6 +11,7 @@ import {
faBoxesStacked, faBoxesStacked,
faFileInvoiceDollar, faFileInvoiceDollar,
faTags, faTags,
faHeadset,
} from '@fortawesome/free-solid-svg-icons'; } from '@fortawesome/free-solid-svg-icons';
import type { ItemType } from 'antd/es/menu/interface'; import type { ItemType } from 'antd/es/menu/interface';
import { authStore } from '../../lib/auth-store'; import { authStore } from '../../lib/auth-store';
@@ -28,17 +29,17 @@ function getRole(): string {
const REP_ITEMS: ItemType[] = [ const REP_ITEMS: ItemType[] = [
{ key: '/', icon: <FontAwesomeIcon icon={faChartLine} fixedWidth />, label: 'Painel' }, { 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: '/clientes', icon: <FontAwesomeIcon icon={faUsers} fixedWidth />, label: 'Clientes' },
{ {
key: '/catalogo', key: '/catalogo',
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />, icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
label: 'Catálogo', label: 'Catálogo',
}, },
{
key: '/funil',
icon: <FontAwesomeIcon icon={faFunnelDollar} fixedWidth />,
label: 'Funil de Vendas',
},
{ {
key: '/pedidos', key: '/pedidos',
icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />, icon: <FontAwesomeIcon icon={faClipboardList} fixedWidth />,
@@ -49,6 +50,7 @@ const REP_ITEMS: ItemType[] = [
icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />, icon: <FontAwesomeIcon icon={faClipboardCheck} fixedWidth />,
label: 'Consulta ERP', label: 'Consulta ERP',
}, },
{ key: '/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
{ {
key: '/relatorios', key: '/relatorios',
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />, icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
@@ -99,6 +101,7 @@ const GERENTE_ITEMS: ItemType[] = [
icon: <FontAwesomeIcon icon={faTags} fixedWidth />, icon: <FontAwesomeIcon icon={faTags} fixedWidth />,
label: 'Políticas Comerciais', label: 'Políticas Comerciais',
}, },
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
]; ];
function getItems(role: string): ItemType[] { function getItems(role: string): ItemType[] {

View File

@@ -0,0 +1,64 @@
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
import { usePromocoesVigentes, useRegrasVigentes } 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);
}

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

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

View File

@@ -4,6 +4,8 @@ import {
EquipeResponseSchema, EquipeResponseSchema,
AlcadaDescontosResponseSchema, AlcadaDescontosResponseSchema,
PromocoesResponseSchema, PromocoesResponseSchema,
RegrasDescontoResponseSchema,
GruposProdutoResponseSchema,
type ManagerDashboard, type ManagerDashboard,
type EquipeResponse, type EquipeResponse,
type AlcadaDescontosResponse, type AlcadaDescontosResponse,
@@ -11,6 +13,10 @@ import {
type UpsertDescontoBody, type UpsertDescontoBody,
type CreatePromocaoBody, type CreatePromocaoBody,
type UpdatePromocaoBody, type UpdatePromocaoBody,
type RegrasDescontoResponse,
type GruposProdutoResponse,
type CreateRegraDescontoBody,
type UpdateRegraDescontoBody,
} from '@sar/api-interface'; } from '@sar/api-interface';
import { apiFetch } from '../api-client'; import { apiFetch } from '../api-client';
@@ -67,7 +73,7 @@ export function useUpsertDesconto() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (body: UpsertDescontoBody) => mutationFn: (body: UpsertDescontoBody) =>
apiFetch('/politicas/descontos', { method: 'POST', body: JSON.stringify(body) }), apiFetch('/politicas/descontos', { method: 'POST', body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['politicas', 'descontos'] }),
}); });
} }
@@ -76,7 +82,7 @@ export function useCreatePromocao() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (body: CreatePromocaoBody) => mutationFn: (body: CreatePromocaoBody) =>
apiFetch('/politicas/promocoes', { method: 'POST', body: JSON.stringify(body) }), apiFetch('/politicas/promocoes', { method: 'POST', body }),
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] }); qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] }); qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
@@ -88,7 +94,7 @@ export function useUpdatePromocao() {
const qc = useQueryClient(); const qc = useQueryClient();
return useMutation({ return useMutation({
mutationFn: ({ id, body }: { id: number; body: UpdatePromocaoBody }) => 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: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] }); qc.invalidateQueries({ queryKey: ['politicas', 'promocoes'] });
qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] }); qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
@@ -106,3 +112,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'] });
},
});
}

View File

@@ -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(); const search = new URLSearchParams();
if (params.search) search.set('search', params.search); if (params.search) search.set('search', params.search);
if (params.from) search.set('from', params.from); if (params.from) search.set('from', params.from);
@@ -70,6 +70,7 @@ export function useOrderErpConsulta(params: Partial<PedidoErpConsultaQuery> = {}
const qs = search.toString(); const qs = search.toString();
return useQuery<PedidoErpConsultaResponse>({ return useQuery<PedidoErpConsultaResponse>({
queryKey: ['orders-erp-consulta', params], queryKey: ['orders-erp-consulta', params],
enabled,
queryFn: async () => { queryFn: async () => {
const res = await apiFetch(`/orders/erp-consulta${qs ? `?${qs}` : ''}`); const res = await apiFetch(`/orders/erp-consulta${qs ? `?${qs}` : ''}`);
return PedidoErpConsultaResponseSchema.parse(res); return PedidoErpConsultaResponseSchema.parse(res);

View File

@@ -0,0 +1,33 @@
import { useQuery } from '@tanstack/react-query';
import {
RegrasVigentesResponseSchema,
PromocoesVigentesResponseSchema,
type RegrasVigentesResponse,
type PromocoesVigentesResponse,
} 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.
export function useRegrasVigentes() {
return useQuery<RegrasVigentesResponse>({
queryKey: ['politicas', 'regras-vigentes'],
queryFn: async () => {
const raw = await apiFetch('/politicas/regras/vigentes');
return RegrasVigentesResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep
export function usePromocoesVigentes() {
return useQuery<PromocoesVigentesResponse>({
queryKey: ['politicas', 'promocoes-vigentes'],
queryFn: async () => {
const raw = await apiFetch('/politicas/promocoes/vigentes');
return PromocoesVigentesResponseSchema.parse(raw);
},
staleTime: 5 * 60 * 1000,
});
}

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

View File

@@ -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. * QueryClient canônico do SAR.
* Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"), * Defaults conservadores: refetch on focus desabilitado (Visual DNA: "sereno"),
* stale-while-revalidate para tempo real bater no Socket.IO, não em polling. * 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({ 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: { defaultOptions: {
queries: { queries: {
staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos staleTime: 30_000, // 30s — Socket.IO atualiza antes na maioria dos casos

View File

@@ -5,7 +5,7 @@ import {
Outlet, Outlet,
notFound, notFound,
} from '@tanstack/react-router'; } from '@tanstack/react-router';
import { Typography } from 'antd'; import { Button, Result, Typography } from 'antd';
import { AppShell } from '../components/layout/AppShell'; import { AppShell } from '../components/layout/AppShell';
import { RepPainel } from '../cockpits/rep/RepPainel'; import { RepPainel } from '../cockpits/rep/RepPainel';
import { ClientsPage } from '../cockpits/rep/ClientsPage'; import { ClientsPage } from '../cockpits/rep/ClientsPage';
@@ -18,11 +18,15 @@ import { NewOrderPage } from '../cockpits/rep/NewOrderPage';
import { CatalogPage } from '../cockpits/rep/CatalogPage'; import { CatalogPage } from '../cockpits/rep/CatalogPage';
import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage'; import { OrdersErpPage } from '../cockpits/rep/OrdersErpPage';
import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage'; import { RelatoriosPage } from '../cockpits/rep/RelatoriosPage';
import { CarteirePage } from '../cockpits/rep/CarteirePage';
import { FunilPage } from '../cockpits/rep/FunilPage';
import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage'; import { ApprovalQueuePage } from '../cockpits/supervisor/ApprovalQueuePage';
import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel'; import { SupervisorPainel } from '../cockpits/supervisor/SupervisorPainel';
import { GerPainel } from '../cockpits/ger/GerPainel'; import { GerPainel } from '../cockpits/ger/GerPainel';
import { EquipePage } from '../cockpits/ger/EquipePage'; import { EquipePage } from '../cockpits/ger/EquipePage';
import { PoliticasPage } from '../cockpits/ger/PoliticasPage'; import { PoliticasPage } from '../cockpits/ger/PoliticasPage';
import { ChamadosPage } from '../cockpits/rep/ChamadosPage';
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
import { authStore } from './auth-store'; import { authStore } from './auth-store';
function getRoleFromToken(): string { function getRoleFromToken(): string {
@@ -43,6 +47,23 @@ function HomeRoute() {
return <RepPainel />; 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() { function NotFoundPage() {
return ( return (
<div style={{ padding: 48, textAlign: 'center' }}> <div style={{ padding: 48, textAlign: 'center' }}>
@@ -63,6 +84,7 @@ const rootRoute = createRootRoute({
</AppShell> </AppShell>
), ),
notFoundComponent: NotFoundPage, notFoundComponent: NotFoundPage,
errorComponent: RouteErrorPage,
}); });
const indexRoute = createRoute({ const indexRoute = createRoute({
@@ -137,6 +159,18 @@ const relatoriosRoute = createRoute({
component: RelatoriosPage, component: RelatoriosPage,
}); });
const carteiraRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/clientes/carteira',
component: CarteirePage,
});
const funilRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/funil',
component: FunilPage,
});
const aprovacoes = createRoute({ const aprovacoes = createRoute({
getParentRoute: () => rootRoute, getParentRoute: () => rootRoute,
path: '/aprovacoes', path: '/aprovacoes',
@@ -161,6 +195,18 @@ const politicasRoute = createRoute({
component: PoliticasPage, component: PoliticasPage,
}); });
const chamadosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/chamados',
component: ChamadosPage,
});
const gerChamadosRoute = createRoute({
getParentRoute: () => rootRoute,
path: '/ger/chamados',
component: GerChamadosPage,
});
const routeTree = rootRoute.addChildren([ const routeTree = rootRoute.addChildren([
indexRoute, indexRoute,
rafaelRoute, rafaelRoute,
@@ -174,16 +220,21 @@ const routeTree = rootRoute.addChildren([
catalogoRoute, catalogoRoute,
pedidosErpRoute, pedidosErpRoute,
relatoriosRoute, relatoriosRoute,
carteiraRoute,
funilRoute,
aprovacoes, aprovacoes,
gerPainelRoute, gerPainelRoute,
equipeRoute, equipeRoute,
politicasRoute, politicasRoute,
chamadosRoute,
gerChamadosRoute,
]); ]);
export const router = createRouter({ export const router = createRouter({
routeTree, routeTree,
defaultPreload: 'intent', defaultPreload: 'intent',
defaultPreloadStaleTime: 0, defaultPreloadStaleTime: 0,
defaultErrorComponent: RouteErrorPage,
}); });
declare module '@tanstack/react-router' { declare module '@tanstack/react-router' {

View File

@@ -10,6 +10,7 @@ export default defineConfig(() => ({
server: { server: {
port: 4200, port: 4200,
host: '0.0.0.0', host: '0.0.0.0',
allowedHosts: true,
// Proxy /api/* → API Nest em :3000 (default API_PORT). // Proxy /api/* → API Nest em :3000 (default API_PORT).
// Evita CORS em dev e mantém URL relativa no código da Web — em produção, // Evita CORS em dev e mantém URL relativa no código da Web — em produção,
// mesmo origin via Nginx (/api/* → backend). // mesmo origin via Nginx (/api/* → backend).
@@ -43,6 +44,7 @@ export default defineConfig(() => ({
globals: true, globals: true,
environment: 'jsdom', environment: 'jsdom',
include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'], include: ['{src,tests}/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
passWithNoTests: true,
reporters: ['default'], reporters: ['default'],
coverage: { coverage: {
reportsDirectory: '../../coverage/apps/web', reportsDirectory: '../../coverage/apps/web',

View File

@@ -20,7 +20,6 @@ services:
# subdir por major-version pra suportar pg_upgrade --link sem # subdir por major-version pra suportar pg_upgrade --link sem
# boundary issues. Ref: docker-library/postgres#1259. # boundary issues. Ref: docker-library/postgres#1259.
- sar-postgres-data:/var/lib/postgresql - sar-postgres-data:/var/lib/postgresql
- ./scripts/postgres-init:/docker-entrypoint-initdb.d:ro
healthcheck: healthcheck:
test: ['CMD-SHELL', 'pg_isready -U sar -d sar_master'] test: ['CMD-SHELL', 'pg_isready -U sar -d sar_master']
interval: 10s interval: 10s

View File

@@ -9,3 +9,5 @@ export * from './lib/company.contract';
export * from './lib/report.contract'; export * from './lib/report.contract';
export * from './lib/politicas.contract'; export * from './lib/politicas.contract';
export * from './lib/equipe.contract'; export * from './lib/equipe.contract';
export * from './lib/funil.contract';
export * from './lib/sac.contract';

View File

@@ -9,7 +9,8 @@ const JwtRoleSchema = z.enum(['rep', 'supervisor', 'manager', 'admin']);
export const DevTokenRequestSchema = z.object({ export const DevTokenRequestSchema = z.object({
userId: z.string().min(1), 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, role: JwtRoleSchema,
}); });

View File

@@ -93,7 +93,10 @@ export const CreateContatoSchema = z.object({
empresa: z.string().max(100).optional(), empresa: z.string().max(100).optional(),
cargo: z.string().max(100).optional(), cargo: z.string().max(100).optional(),
departamento: 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(), telefone: z.string().max(20).optional(),
ramal: z.string().max(10).optional(), ramal: z.string().max(10).optional(),
celular: z.string().max(20).optional(), celular: z.string().max(20).optional(),
@@ -115,6 +118,7 @@ export type ContatoResult = z.infer<typeof ContatoResultSchema>;
export const TopProdutoSchema = z.object({ export const TopProdutoSchema = z.object({
idProduto: z.number().int(), idProduto: z.number().int(),
codProduto: z.string(),
descricao: z.string(), descricao: z.string(),
unidade: z.string().nullable(), unidade: z.string().nullable(),
qtdTotal: z.number(), qtdTotal: z.number(),

View File

@@ -39,6 +39,14 @@ export const MetaItemSchema = z.object({
}); });
export type MetaItem = z.infer<typeof MetaItemSchema>; 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({ export const RepDashboardSchema = z.object({
meta: z.object({ meta: z.object({
atingido: z.number(), atingido: z.number(),
@@ -52,6 +60,7 @@ export const RepDashboardSchema = z.object({
pedidosRecentes: z.array(PedidoSummarySchema), pedidosRecentes: z.array(PedidoSummarySchema),
naoPositivados: z.array(ClienteNaoPositivadoSchema), naoPositivados: z.array(ClienteNaoPositivadoSchema),
totalNaoPositivados: z.number().int(), totalNaoPositivados: z.number().int(),
historicoMensal: z.array(MesAnoSchema).default([]),
syncedAt: z.iso.datetime(), syncedAt: z.iso.datetime(),
}); });
export type RepDashboard = z.infer<typeof RepDashboardSchema>; export type RepDashboard = z.infer<typeof RepDashboardSchema>;
@@ -95,6 +104,13 @@ export const RankingRepSchema = z.object({
}); });
export type RankingRep = z.infer<typeof RankingRepSchema>; export type RankingRep = z.infer<typeof RankingRepSchema>;
// 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>;
export const ManagerDashboardSchema = z.object({ export const ManagerDashboardSchema = z.object({
faturamentoMes: z.number(), faturamentoMes: z.number(),
pedidosMes: z.number().int(), pedidosMes: z.number().int(),
@@ -105,6 +121,7 @@ export const ManagerDashboardSchema = z.object({
metasPorGrupo: z.array(MetaItemSchema).default([]), metasPorGrupo: z.array(MetaItemSchema).default([]),
rankingReps: z.array(RankingRepSchema), rankingReps: z.array(RankingRepSchema),
positivacaoReps: z.array(PositivacaoRepSchema), positivacaoReps: z.array(PositivacaoRepSchema),
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
syncedAt: z.iso.datetime(), syncedAt: z.iso.datetime(),
}); });
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>; export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;

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

View File

@@ -11,6 +11,11 @@ export const SubscribePayloadSchema = z.object({
}); });
export type SubscribePayload = z.infer<typeof SubscribePayloadSchema>; 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({ export const PendingCountResponseSchema = z.object({
count: z.number().int().min(0), count: z.number().int().min(0),
}); });

View File

@@ -88,6 +88,7 @@ export const PedidoDetailSchema = PedidoSummarySchema.extend({
comissao: z.string(), comissao: z.string(),
pedFlex: z.string(), pedFlex: z.string(),
formaPagamento: z.string().nullable().optional(), formaPagamento: z.string().nullable().optional(),
endEntrega: z.string().nullable().optional(),
aprovadoPor: z.number().int().nullable(), aprovadoPor: z.number().int().nullable(),
aprovadoEm: z.iso.datetime().nullable(), aprovadoEm: z.iso.datetime().nullable(),
motivoRecusa: z.string().nullable(), motivoRecusa: z.string().nullable(),
@@ -100,12 +101,16 @@ export type PedidoDetail = z.infer<typeof PedidoDetailSchema>;
// ─── List query + response ──────────────────────────────────────────────────── // ─── 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({ export const PedidoListQuerySchema = z.object({
idCliente: z.coerce.number().int().optional(), idCliente: z.coerce.number().int().optional(),
situa: z.coerce.number().int().optional(), situa: z.coerce.number().int().optional(),
numPedSar: z.string().optional(), numPedSar: z.string().optional(),
from: z.string().optional(), from: DateOnlySchema.optional(),
to: z.string().optional(), to: DateOnlySchema.optional(),
page: z.coerce.number().int().positive().default(1), page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50), limit: z.coerce.number().int().min(1).max(200).default(50),
}); });
@@ -138,6 +143,7 @@ export const CreatePedidoSchema = z.object({
idPauta: z.number().int().optional(), idPauta: z.number().int().optional(),
codFormapag: z.number().int().optional(), codFormapag: z.number().int().optional(),
obs: z.string().optional(), obs: z.string().optional(),
endEntrega: z.string().optional(),
idempotencyKey: z.string().optional(), idempotencyKey: z.string().optional(),
itens: z.array(CreatePedidoItemSchema).min(1), itens: z.array(CreatePedidoItemSchema).min(1),
}); });
@@ -182,8 +188,8 @@ export type PedidoErpConsultaItem = z.infer<typeof PedidoErpConsultaItemSchema>;
export const PedidoErpConsultaQuerySchema = z.object({ export const PedidoErpConsultaQuerySchema = z.object({
search: z.string().optional(), search: z.string().optional(),
from: z.string().optional(), from: DateOnlySchema.optional(),
to: z.string().optional(), to: DateOnlySchema.optional(),
page: z.coerce.number().int().positive().default(1), page: z.coerce.number().int().positive().default(1),
limit: z.coerce.number().int().min(1).max(200).default(50), limit: z.coerce.number().int().min(1).max(200).default(50),
}); });

View File

@@ -5,7 +5,7 @@ describe('PingResponseSchema', () => {
status: 'ok', status: 'ok',
service: 'sar-api', service: 'sar-api',
version: '0.1.0', version: '0.1.0',
workspaceId: 'dev-workspace', idEmpresa: 1,
requestId: '550e8400-e29b-41d4-a716-446655440000', requestId: '550e8400-e29b-41d4-a716-446655440000',
uptimeSeconds: 42, uptimeSeconds: 42,
now: '2026-05-27T12:34:56.000Z', now: '2026-05-27T12:34:56.000Z',
@@ -36,8 +36,8 @@ describe('PingResponseSchema', () => {
expect(() => PingResponseSchema.parse(bad)).toThrow(); expect(() => PingResponseSchema.parse(bad)).toThrow();
}); });
it('rejeita workspaceId vazio', () => { it('rejeita idEmpresa não inteiro', () => {
const bad = { ...validPayload, workspaceId: '' }; const bad = { ...validPayload, idEmpresa: 1.5 };
expect(() => PingResponseSchema.parse(bad)).toThrow(); expect(() => PingResponseSchema.parse(bad)).toThrow();
}); });
}); });

View File

@@ -53,3 +53,92 @@ export const UpdatePromocaoBodySchema = CreatePromocaoBodySchema.partial().exten
ativa: z.boolean().optional(), ativa: z.boolean().optional(),
}); });
export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>; export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
// ─── Regras de Desconto ───────────────────────────────────────────────────────
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
// produto 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(),
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>;
export const CreateRegraDescontoBodySchema = z.object({
descricao: z.string().min(1).max(200),
codGrupo: z.number().int().nullable().optional(),
codSubgrupo: z.number().int().nullable().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>;
export const UpdateRegraDescontoBodySchema = CreateRegraDescontoBodySchema.partial().extend({
ativa: z.boolean().optional(),
});
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
// 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(),
grpProd: 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(),
codSubgrupo: z.number().int().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>;

View File

@@ -30,6 +30,8 @@ export const CarteiraClienteSchema = z.object({
diasSemPedido: z.number().nullable(), diasSemPedido: z.number().nullable(),
totalPedidos: z.number(), totalPedidos: z.number(),
ticketMedio: z.string(), ticketMedio: z.string(),
faturamentoTotal: z.string(),
participacaoPct: z.number(),
}); });
export type CarteiraCliente = z.infer<typeof CarteiraClienteSchema>; export type CarteiraCliente = z.infer<typeof CarteiraClienteSchema>;
@@ -39,6 +41,7 @@ export const ReportCarteiraResponseSchema = z.object({
inativos30: z.number(), inativos30: z.number(),
inativos60: z.number(), inativos60: z.number(),
semPedido: z.number(), semPedido: z.number(),
faturamentoRepTotal: z.string(),
}); });
export type ReportCarteiraResponse = z.infer<typeof ReportCarteiraResponseSchema>; export type ReportCarteiraResponse = z.infer<typeof ReportCarteiraResponseSchema>;

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

View File

@@ -17,7 +17,7 @@
"e2e": "nx run-many -t e2e", "e2e": "nx run-many -t e2e",
"dev:api": "nx run api:serve", "dev:api": "nx run api:serve",
"dev:web": "nx run web: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:up": "docker compose -f docker-compose.dev.yml up -d",
"dev:down": "docker compose -f docker-compose.dev.yml down", "dev:down": "docker compose -f docker-compose.dev.yml down",
"dev:logs": "docker compose -f docker-compose.dev.yml logs -f", "dev:logs": "docker compose -f docker-compose.dev.yml logs -f",
@@ -110,7 +110,7 @@
"lru-cache": "^11.5.0", "lru-cache": "^11.5.0",
"nestjs-cls": "^5.4.3", "nestjs-cls": "^5.4.3",
"nestjs-pino": "^4.6.1", "nestjs-pino": "^4.6.1",
"nestjs-zod": "^4.3.1", "nestjs-zod": "^5.4.0",
"pg": "^8.21.0", "pg": "^8.21.0",
"pino": "^9.14.0", "pino": "^9.14.0",
"pino-http": "^10.5.0", "pino-http": "^10.5.0",

48
pnpm-lock.yaml generated
View File

@@ -63,8 +63,8 @@ importers:
specifier: ^4.6.1 specifier: ^4.6.1
version: 4.6.1(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.1) version: 4.6.1(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(pino-http@10.5.0)(pino@9.14.0)(rxjs@7.8.1)
nestjs-zod: nestjs-zod:
specifier: ^4.3.1 specifier: ^5.4.0
version: 4.3.1(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@11.1.24)(zod@4.4.3) version: 5.4.0(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(rxjs@7.8.1)(zod@4.4.3)
pg: pg:
specifier: ^8.21.0 specifier: ^8.21.0
version: 8.21.0 version: 8.21.0
@@ -2002,21 +2002,6 @@ packages:
'@emnapi/core': ^1.7.1 '@emnapi/core': ^1.7.1
'@emnapi/runtime': ^1.7.1 '@emnapi/runtime': ^1.7.1
'@nest-zod/z@2.0.0':
resolution: {integrity: sha512-OqmJUZlpcx9ECzPYSIjaR/q/xLKrm9FkV+0FIWLEb9MVc9YeP21GmnQJBxE1dPRlLJ1kybNFBlt4D/PB8YAPkA==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0
zod: '>= 3.14.3'
peerDependenciesMeta:
'@nestjs/common':
optional: true
'@nestjs/core':
optional: true
'@nestjs/swagger':
optional: true
'@nestjs/common@11.1.24': '@nestjs/common@11.1.24':
resolution: {integrity: sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==} resolution: {integrity: sha512-9zHxaDDM+oXW9As6UsP5yYB+UqczBmpeSCIFWdPEtEukMnZhxODG1BBjaUcdBB8Sc1uzojSJSJlp3yFp853t1g==}
peerDependencies: peerDependencies:
@@ -7357,18 +7342,14 @@ packages:
pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 pino-http: ^6.4.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0
rxjs: ^7.1.0 rxjs: ^7.1.0
nestjs-zod@4.3.1: nestjs-zod@5.4.0:
resolution: {integrity: sha512-gl10NOf/AhWt4HLcnMI1J0WUlBCKyteJBftD6SwNyX71/vzAorr5MJQWiDJoPPkou9QL2CRHHBtyjr55o7++Xw==} resolution: {integrity: sha512-dxVpy1fjfK4kp+ztK+7xQP46fpvZxkeR/jcEdIvEGh/2o71iwXuy/hrKOWSPhJ1nQXV4iBdHqMizndn2GTaXDg==}
peerDependencies: peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0 '@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0 '@nestjs/swagger': ^7.4.2 || ^8.0.0 || ^11.0.0
zod: '>= 3.14.3' rxjs: ^7.0.0
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta: peerDependenciesMeta:
'@nestjs/common':
optional: true
'@nestjs/core':
optional: true
'@nestjs/swagger': '@nestjs/swagger':
optional: true optional: true
@@ -11787,13 +11768,6 @@ snapshots:
'@tybys/wasm-util': 0.10.2 '@tybys/wasm-util': 0.10.2
optional: true optional: true
'@nest-zod/z@2.0.0(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@11.1.24)(zod@4.4.3)':
dependencies:
zod: 4.4.3
optionalDependencies:
'@nestjs/common': 11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1)
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.1.14)(rxjs@7.8.1)
'@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1)': '@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1)':
dependencies: dependencies:
file-type: 21.3.4 file-type: 21.3.4
@@ -18092,14 +18066,12 @@ snapshots:
pino-http: 10.5.0 pino-http: 10.5.0
rxjs: 7.8.1 rxjs: 7.8.1
nestjs-zod@4.3.1(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@11.1.24)(zod@4.4.3): nestjs-zod@5.4.0(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(rxjs@7.8.1)(zod@4.4.3):
dependencies: dependencies:
'@nest-zod/z': 2.0.0(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/core@11.1.24)(zod@4.4.3)
deepmerge: 4.3.1
zod: 4.4.3
optionalDependencies:
'@nestjs/common': 11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1) '@nestjs/common': 11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1)
'@nestjs/core': 11.1.24(@nestjs/common@11.1.24(reflect-metadata@0.1.14)(rxjs@7.8.1))(@nestjs/platform-express@11.1.24)(reflect-metadata@0.1.14)(rxjs@7.8.1) deepmerge: 4.3.1
rxjs: 7.8.1
zod: 4.4.3
no-case@3.0.4: no-case@3.0.4:
dependencies: dependencies:

View File

@@ -1,124 +0,0 @@
-- =============================================================================
-- sar_views.sql
-- Views PostgreSQL do projeto SAR — schema `sar` no banco ERP (libreplast)
--
-- Executar como: psql -U postgres -d libreplast -f sar_views.sql
--
-- DECISÃO DE ARQUITETURA: todas as views residem no schema `sar` (mesmo schema
-- das tabelas Prisma). O search_path da conexão runtime é `sar`, portanto as
-- queries do backend usam os nomes curtos (vw_clientes, vw_pedidos_erp).
--
-- ERP base: SIG (schema sig.*)
-- Empresa gerencial: id_empresa = 1 (gestao.empresa)
-- Empresa fiscal: id_empresa = 9001 (sig.corrent / sig.pedidos)
-- Cancelados SIG: situa = 5 (≠ SAR que usa situa = 3)
-- Faturados SIG: situa = 4 (coincide com SAR)
-- =============================================================================
-- ---------------------------------------------------------------------------
-- 1. CLIENTES
-- Fonte: sig.corrent
-- Obs: COALESCE(id_empresa, 1) cobre registros antigos sem id_empresa.
-- cod_vendedor determina a carteira do representante.
-- ---------------------------------------------------------------------------
CREATE OR REPLACE VIEW sar.vw_clientes AS
SELECT
COALESCE(c.id_empresa, 1) AS id_empresa,
c.id_corrent AS id_cliente,
c.ativo,
COALESCE(NULLIF(TRIM(c.nome), ''), TRIM(c.razao)) AS nome,
TRIM(c.razao) AS razao,
c.pesso AS pessoa,
c.consfinal,
c.cgcpf,
c.suf_cgcpf,
c.inscr AS inscricao_estadual,
c.endereco,
COALESCE(c.num_endereco, '') AS num_endereco,
c.bairr AS bairro,
c.id_municipio,
c.cep,
c.ddd,
c.telef AS telefone,
c.e_mail AS email,
c.data AS dt_cadastro,
c.obs,
c.cod_formapag,
(
SELECT fp.id_formapag
FROM gestao.formapag fp
LEFT JOIN gestao.empresa e ON e.id_empresa = COALESCE(c.id_empresa, 1)
WHERE fp.id_empresa = COALESCE(e.id_matriz, COALESCE(c.id_empresa, 1))
AND fp.codigo = c.cod_formapag
LIMIT 1
) AS id_formapag,
c.indicador_ie,
c.cod_pauta,
c.st_especifica,
COALESCE(c.limcred, 0) AS limite_credito,
c.cod_vendedor,
c.dt_atual
FROM sig.corrent c;
-- ---------------------------------------------------------------------------
-- 2. PEDIDOS ERP
-- Fonte: sig.pedidos
-- Situa SIG → SAR: 5=Cancelado(SIG) vs 3=Cancelado(SAR). O backend
-- normaliza em runtime (sigToSar / sarToSig em orders.service.ts).
-- ---------------------------------------------------------------------------
CREATE OR REPLACE VIEW sar.vw_pedidos_erp AS
SELECT
p.id_empresa,
p.id_pedido,
p.num_ped_sar,
p.numero,
p.tipo,
p.situa,
CASE p.situa
WHEN 1 THEN 'Pendente'
WHEN 2 THEN 'Liberado'
WHEN 4 THEN 'Faturado'
WHEN 5 THEN 'Cancelado'
ELSE 'Enviado'
END AS status_descr,
p.data AS dt_pedido,
p.data_emissao AS dt_emissao,
p.clien AS id_cliente,
p.cod_vendedor,
p.cod_formapag,
fp.id_formapag,
fp.descr AS forma_pagamento,
pau.id_pauta,
COALESCE(p.obs, '') AS obs,
p.totpr AS total_produtos,
COALESCE(p.ipi, 0) AS total_ipi,
0 AS total_icmsst,
COALESCE(p.total, 0) AS total,
COALESCE(p.descp, 0) AS desconto_perc,
COALESCE(p.descv, 0) AS desconto_valor,
COALESCE(p.tx_acrescimo, 0) AS acrescimo,
COALESCE(p.com_fat, 0) AS comissao,
COALESCE(p.ped_flex::integer, 0) AS ped_flex,
p.cod_vend2 AS cod_supervisor,
p.tx_com_vend2 AS taxa_com_super
FROM sig.pedidos p
LEFT JOIN gestao.formapag fp
ON fp.codigo = p.cod_formapag
AND fp.id_empresa = CASE WHEN p.id_empresa > 9000
THEN p.id_empresa - 9000
ELSE p.id_empresa END
LEFT JOIN gestao.pauta pau
ON pau.codigo = p.cod_pauta
AND pau.id_empresa = CASE WHEN p.id_empresa > 9000
THEN p.id_empresa - 9000
ELSE p.id_empresa END;
-- ---------------------------------------------------------------------------
-- 3. STATUS DE PEDIDO ERP
-- Fonte: sig.sitpedido
-- ---------------------------------------------------------------------------
CREATE OR REPLACE VIEW sar.vw_sitpedido AS
SELECT
s.id_sitpedido,
TRIM(s.descricao) AS descricao
FROM sig.sitpedido s;

View File

@@ -1,16 +0,0 @@
-- SAR dev bootstrap
-- Cria o BD do workspace de exemplo para desenvolvimento local.
-- Multi-tenancy real (BD-por-workspace) vem com master-login na próxima sessão.
CREATE DATABASE sar_workspace_dev OWNER sar;
GRANT ALL PRIVILEGES ON DATABASE sar_workspace_dev TO sar;
-- Habilita pgcrypto (PII encryption — STACK.md §22)
\c sar_workspace_dev
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Cria role app_reader (que será usada para conexão runtime — não admin)
-- Padrão ADR 0006: app cliente conecta como app_reader, senha vem do master-login
\c sar_master
CREATE EXTENSION IF NOT EXISTS pgcrypto;

190
scripts/provision-client.ts Normal file
View File

@@ -0,0 +1,190 @@
#!/usr/bin/env tsx
/**
* SAR — Provisionamento de cliente
*
* Aplica o schema SAR (views + tabelas + triggers) no banco ERP do cliente.
* Não requer psql — usa a lib pg que já é dependência do projeto.
*
* Uso:
* pnpm client:provision --host <host> --db <banco> [opções]
*
* Opções:
* --host Host do PostgreSQL (obrigatório)
* --db Nome do banco ERP (obrigatório)
* --port Porta (default: 5432)
* --user Usuário (default: postgres)
* --password Senha (default: lê PG_PASSWORD do env)
* --role Role do app SAR — aplica GRANTs se informado (ex: sar_app)
*
* O que faz:
* 1. Executa scripts/sar-erp-schema.sql (schema sar + views + tabelas SAR)
* 2. Executa scripts/sar-triggers.sql (triggers de sync ERP + staging tables)
* 3. Aplica GRANTs se --role foi informado
* 4. Executa `prisma migrate deploy` (aplica migrations Prisma pendentes)
*/
import { readFileSync, readdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { execSync } from 'child_process';
import pg from 'pg';
// ─── CLI args ─────────────────────────────────────────────────────────────────
function arg(flag: string): string | undefined {
const idx = process.argv.indexOf(flag);
return idx !== -1 ? process.argv[idx + 1] : undefined;
}
const host = arg('--host');
const db = arg('--db');
const port = parseInt(arg('--port') ?? '5432', 10);
const user = arg('--user') ?? 'postgres';
const password = arg('--password') ?? process.env['PG_PASSWORD'] ?? '';
const role = arg('--role');
if (!host || !db) {
console.error(
'Uso: pnpm client:provision --host <host> --db <banco> [--port 5432] [--user postgres] [--password ...] [--role <pg-role>]',
);
console.error(
'Exemplo: pnpm client:provision --host 192.168.0.43 --db libreplast --role sar_app',
);
process.exit(1);
}
// ─── Paths ────────────────────────────────────────────────────────────────────
const SCRIPTS_DIR = dirname(fileURLToPath(import.meta.url));
const MONOREPO_ROOT = resolve(SCRIPTS_DIR, '..');
const API_DIR = resolve(MONOREPO_ROOT, 'apps/api');
// Banco ERP usa LATIN1 — strip chars fora do range 0x00-0xFF (decorações nos comentários SQL)
function toLatinSafe(sql: string): string {
// O range LATIN1 começa em \x00 por definição — control chars intencionais.
// eslint-disable-next-line no-control-regex
return sql.replace(/[^\x00-\xFF]/g, (c) => {
if (c === '—' || c === '') return '--'; // em dash / en dash
return '-'; // box-drawing chars e outros
});
}
const SQL_SCHEMA = toLatinSafe(readFileSync(resolve(SCRIPTS_DIR, 'sar-erp-schema.sql'), 'utf8'));
const SQL_TRIGGERS = toLatinSafe(readFileSync(resolve(SCRIPTS_DIR, 'sar-triggers.sql'), 'utf8'));
const DATABASE_URL = `postgresql://${user}:${encodeURIComponent(password)}@${host}:${port}/${db}`;
// ─── Helpers ──────────────────────────────────────────────────────────────────
async function runSql(client: pg.Client, label: string, sql: string): Promise<void> {
console.log(` Executando ${label}...`);
await client.query(sql);
console.log(`${label} aplicado`);
}
async function applyGrants(client: pg.Client, appRole: string): Promise<void> {
console.log(` Aplicando GRANTs para role "${appRole}"...`);
await client.query(`
GRANT USAGE ON SCHEMA sar TO ${appRole};
GRANT SELECT ON ALL TABLES IN SCHEMA sar TO ${appRole};
GRANT INSERT, UPDATE, DELETE ON
sar.pedidos,
sar.pedido_itens,
sar.historico_pedido,
sar.alcada_desconto,
sar.meta_representante,
sar.push_subscription,
sar.promocoes,
sar.regras_desconto,
sar.clientes_novos,
sar.contatos_novos
TO ${appRole};
GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA sar TO ${appRole};
`);
console.log(` ✓ GRANTs aplicados`);
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
console.log('\nSAR — Provisionamento de Cliente');
console.log('──────────────────────────────────');
console.log(`Host: ${host}:${port}`);
console.log(`Banco: ${db}`);
console.log(`User: ${user}`);
if (role) console.log(`Role: ${role} (GRANTs serão aplicados)`);
console.log('');
const client = new pg.Client({ host, port, database: db, user, password });
await client.connect();
let needsBaseline = false;
try {
console.log('[1/3] Schema SAR...');
await runSql(client, 'sar-erp-schema.sql', SQL_SCHEMA);
console.log('[2/3] Triggers e tabelas staging...');
await runSql(client, 'sar-triggers.sql', SQL_TRIGGERS);
if (role) {
console.log('[+] GRANTs...');
await applyGrants(client, role);
}
// O schema SQL acima já criou as tabelas, então o schema `sar` nunca está
// vazio quando o Prisma roda. Sem histórico de migrations o `migrate deploy`
// aborta com P3005 — é preciso marcar a migration baseline como aplicada.
const { rows } = await client.query(
`SELECT to_regclass('sar._prisma_migrations') IS NOT NULL AS existe`,
);
needsBaseline = !rows[0]?.existe;
} finally {
await client.end();
}
// _prisma_migrations precisa morar no schema sar, não no public do ERP.
const MIGRATE_URL = `${DATABASE_URL}?schema=sar`;
const prismaEnv = { ...process.env, DATABASE_URL: MIGRATE_URL };
console.log('[3/3] Migrations Prisma...');
if (needsBaseline) {
const baseline = readdirSync(resolve(API_DIR, 'prisma/migrations'), { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort()[0];
if (!baseline) throw new Error('nenhuma migration encontrada para baseline');
console.log(` Banco pré-existente: marcando "${baseline}" como aplicada (baseline)...`);
execSync(`pnpm exec prisma migrate resolve --applied ${baseline}`, {
cwd: API_DIR,
env: prismaEnv,
stdio: 'inherit',
});
}
execSync('pnpm exec prisma migrate deploy', {
cwd: API_DIR,
env: prismaEnv,
stdio: 'inherit',
});
console.log(' ✓ Migrations aplicadas');
console.log('');
console.log('════════════════════════════════════');
console.log('✓ Cliente provisionado com sucesso');
console.log('════════════════════════════════════');
console.log('');
console.log('Próximos passos:');
console.log(` 1. Configure DATABASE_URL=${DATABASE_URL}`);
console.log(` no ambiente de produção (Vault ou .env do cliente)`);
if (!role) {
console.log(' 2. Execute os GRANTs manualmente ou rode novamente com --role <pg-role>');
}
console.log('');
}
main().catch((e: unknown) => {
console.error('\nErro durante provisionamento:', e);
process.exit(1);
});

View File

@@ -1,167 +0,0 @@
#!/usr/bin/env tsx
/**
* SAR — Provisionamento de workspace (C9)
*
* Uso:
* pnpm workspace:provision --id <workspace-id> [--name <nome>] [--with-seed]
*
* O que faz:
* 1. Cria o banco sar_workspace_<id> (ignora se já existe)
* 2. Habilita pgcrypto + uuid-ossp
* 3. Roda `prisma migrate deploy` no novo banco
* 4. Se --with-seed: popula dados demo (mesmos do seed de dev)
*
* Variáveis de ambiente (todas opcionais — padrões para dev local):
* PG_HOST (default: localhost)
* PG_PORT (default: 5432)
* PG_USER (default: sar)
* PG_PASSWORD (default: sar_dev_password)
* PG_ADMIN_DB (default: postgres)
*
* Convenção BD-por-workspace (ADR 0006):
* O JwtAuthGuard constrói a URL como sar_workspace_{workspaceId} automaticamente.
* Nenhuma entrada em master DB é necessária para o MVP.
*/
import { execSync } from 'child_process';
import { resolve } from 'path';
import pg from 'pg';
// ─── CLI args ─────────────────────────────────────────────────────────────────
function arg(flag: string): string | undefined {
const idx = process.argv.indexOf(flag);
return idx !== -1 ? process.argv[idx + 1] : undefined;
}
const workspaceId = arg('--id');
const workspaceName = arg('--name') ?? workspaceId;
const withSeed = process.argv.includes('--with-seed');
if (!workspaceId) {
console.error('Uso: pnpm workspace:provision --id <workspace-id> [--name <nome>] [--with-seed]');
console.error('Exemplo: pnpm workspace:provision --id acme-001 --name "Acme Distribuidora"');
process.exit(1);
}
if (!/^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$/.test(workspaceId)) {
console.error('Erro: --id deve conter apenas letras minúsculas, números e hífens (3-64 chars).');
process.exit(1);
}
// ─── Config ───────────────────────────────────────────────────────────────────
const PG_HOST = process.env['PG_HOST'] ?? 'localhost';
const PG_PORT = parseInt(process.env['PG_PORT'] ?? '5432', 10);
const PG_USER = process.env['PG_USER'] ?? 'sar';
const PG_PASSWORD = process.env['PG_PASSWORD'] ?? 'sar_dev_password';
const PG_ADMIN_DB = process.env['PG_ADMIN_DB'] ?? 'postgres';
const DB_NAME = `sar_workspace_${workspaceId}`;
const DB_URL = `postgresql://${PG_USER}:${PG_PASSWORD}@${PG_HOST}:${PG_PORT}/${DB_NAME}`;
const MONOREPO_ROOT = resolve(import.meta.dirname, '..');
const API_DIR = resolve(MONOREPO_ROOT, 'apps/api');
// ─── Steps ────────────────────────────────────────────────────────────────────
async function createDatabase(): Promise<void> {
const pool = new pg.Pool({
host: PG_HOST,
port: PG_PORT,
user: PG_USER,
password: PG_PASSWORD,
database: PG_ADMIN_DB,
});
try {
await pool.query(`CREATE DATABASE "${DB_NAME}" OWNER ${PG_USER}`);
console.log(` ✓ Banco criado: ${DB_NAME}`);
} catch (e: unknown) {
if ((e as { code?: string }).code === '42P04') {
console.log(` ~ Banco já existe: ${DB_NAME} — pulando criação`);
} else {
throw e;
}
} finally {
await pool.end();
}
// Extensões no novo banco
const wsPool = new pg.Pool({
host: PG_HOST,
port: PG_PORT,
user: PG_USER,
password: PG_PASSWORD,
database: DB_NAME,
});
try {
await wsPool.query(`CREATE EXTENSION IF NOT EXISTS pgcrypto`);
await wsPool.query(`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`);
console.log(` ✓ Extensões habilitadas (pgcrypto, uuid-ossp)`);
} finally {
await wsPool.end();
}
}
function runMigrations(): void {
execSync(`pnpm exec prisma migrate deploy`, {
cwd: API_DIR,
env: { ...process.env, DATABASE_URL: DB_URL },
stdio: 'inherit',
});
console.log(` ✓ Migrations aplicadas`);
}
function runSeed(): void {
execSync(`pnpm exec prisma db seed`, {
cwd: API_DIR,
env: { ...process.env, DATABASE_URL: DB_URL },
stdio: 'inherit',
});
console.log(` ✓ Seed de demo executado`);
}
// ─── Main ─────────────────────────────────────────────────────────────────────
async function main(): Promise<void> {
console.log(`\nSAR — Provisionamento de Workspace`);
console.log(`────────────────────────────────────`);
console.log(`ID: ${workspaceId}`);
console.log(`Nome: ${workspaceName}`);
console.log(`Banco: ${DB_NAME}`);
console.log(`Seed: ${withSeed ? 'sim (--with-seed)' : 'não'}`);
console.log('');
console.log(`[1/3] Criando banco de dados...`);
await createDatabase();
console.log(`[2/3] Aplicando migrations...`);
runMigrations();
if (withSeed) {
console.log(`[3/3] Populando dados demo...`);
runSeed();
} else {
console.log(`[3/3] Seed pulado (use --with-seed para dados demo)`);
}
console.log('');
console.log(`════════════════════════════════════`);
console.log(`✓ Workspace provisionado com sucesso`);
console.log(`════════════════════════════════════`);
console.log('');
console.log(`Próximos passos:`);
console.log(` 1. Emita um JWT com workspace_id = "${workspaceId}"`);
console.log(` usando MASTER_LOGIN_JWT_SECRET do ambiente alvo`);
console.log(` 2. O JwtAuthGuard conecta automaticamente ao banco`);
console.log(` via convenção sar_workspace_{id} (ADR 0006)`);
console.log(` 3. Em produção, defina DATABASE_URL no ambiente`);
console.log(` se o banco não seguir a convenção de nome padrão`);
console.log('');
}
main().catch((e: unknown) => {
console.error('\nErro durante provisionamento:', e);
process.exit(1);
});

View File

@@ -82,9 +82,20 @@ SELECT
e.nome, e.nome,
e.razao_social, e.razao_social,
e.cnpj, e.cnpj,
e.estado AS uf, e.estado AS uf,
e.id_matriz, e.id_matriz,
e.id_portador_padrao, e.id_portador_padrao,
-- Endereço e contato
e.inscr_estadual,
e.endereco,
NULLIF(e.numero, 0) AS num_endereco,
NULLIF(TRIM(e.complemento), '.') AS complemento,
e.bairro,
e.id_municipio,
e.cep::text AS cep,
e.telefone::text AS telefone,
e.email,
-- Configurações SAR (gestao.sarcfg)
s.origem_descmax, s.origem_descmax,
s.tp_estoque, s.tp_estoque,
s.bloq_preco_pedido, s.bloq_preco_pedido,
@@ -381,6 +392,15 @@ SELECT
NULL::integer AS tp_pauta NULL::integer AS tp_pauta
FROM gestao.pauxpro pp; FROM gestao.pauxpro pp;
-- -----------------------------------------------------------------------------
-- 12b. Situações de pedido do ERP (sig.sitpedido — somente leitura)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW sar.vw_sitpedido AS
SELECT
s.id_sitpedido,
TRIM(s.descricao) AS descricao
FROM sig.sitpedido s;
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
-- 13. Pedidos históricos do ERP (sig.pedidos — somente leitura) -- 13. Pedidos históricos do ERP (sig.pedidos — somente leitura)
-- ----------------------------------------------------------------------------- -- -----------------------------------------------------------------------------
@@ -418,7 +438,10 @@ SELECT
COALESCE(p.com_fat, 0) AS comissao, COALESCE(p.com_fat, 0) AS comissao,
COALESCE(p.ped_flex, 0) AS ped_flex, COALESCE(p.ped_flex, 0) AS ped_flex,
p.cod_vend2 AS cod_supervisor, p.cod_vend2 AS cod_supervisor,
p.tx_com_vend2 AS taxa_com_super p.tx_com_vend2 AS taxa_com_super,
-- Campos usados internamente pelas views de consulta composta
p.dtprevent AS dt_prevent,
p.numer_ped_vinc
FROM sig.pedidos p FROM sig.pedidos p
LEFT JOIN gestao.formapag fp ON fp.codigo = p.cod_formapag LEFT JOIN gestao.formapag fp ON fp.codigo = p.cod_formapag
AND fp.id_empresa = CASE AND fp.id_empresa = CASE
@@ -542,6 +565,22 @@ LEFT JOIN sig.corrent cr ON cr.id_corrent = CASE
WHERE TRIM(c.id_entidade) ~ '^\d+$' WHERE TRIM(c.id_entidade) ~ '^\d+$'
OR TRIM(c.id_entidade) ~ '^COR#\d+$'; OR TRIM(c.id_entidade) ~ '^COR#\d+$';
-- -----------------------------------------------------------------------------
-- 18. Notas Fiscais emitidas (gestao.nf — somente leitura)
-- -----------------------------------------------------------------------------
CREATE OR REPLACE VIEW sar.vw_nf AS
SELECT
nf.id_nf,
nf.id_empresa,
nf.numero,
TRIM(nf.serie) AS serie,
nf.dt_emissao,
nf.vl_nf,
nf.num_entrega,
TRIM(nf.chave_acesso_nfe) AS chave_acesso_nfe,
nf.status
FROM gestao.nf nf;
-- ============================================================================= -- =============================================================================
-- PARTE 2 — TABELAS DE ESCRITA DO SAR -- PARTE 2 — TABELAS DE ESCRITA DO SAR
-- Dados gerados pelo SAR — não existem no ERP. -- Dados gerados pelo SAR — não existem no ERP.
@@ -571,6 +610,7 @@ CREATE TABLE IF NOT EXISTS sar.pedidos (
comissao NUMERIC(15,2) NOT NULL DEFAULT 0, comissao NUMERIC(15,2) NOT NULL DEFAULT 0,
ped_flex NUMERIC(15,2) NOT NULL DEFAULT 0, ped_flex NUMERIC(15,2) NOT NULL DEFAULT 0,
obs TEXT, obs TEXT,
end_entrega TEXT, -- endereco de entrega alternativo
aprovado_por INTEGER, -- cod_vendedor do supervisor aprovado_por INTEGER, -- cod_vendedor do supervisor
aprovado_em TIMESTAMP, aprovado_em TIMESTAMP,
motivo_recusa TEXT, motivo_recusa TEXT,
@@ -658,6 +698,94 @@ CREATE TABLE IF NOT EXISTS sar.push_subscription (
updated_at TIMESTAMP NOT NULL DEFAULT NOW() updated_at TIMESTAMP NOT NULL DEFAULT NOW()
); );
-- -----------------------------------------------------------------------------
-- Promoções comerciais (C8 — criadas pelo Gerente)
-- descPct em % (ex.: 5.00 = 5%). Pode ser por produto ou grupo de produto.
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sar.promocoes (
id SERIAL PRIMARY KEY,
id_empresa INTEGER NOT NULL,
descricao VARCHAR(200) NOT NULL,
cod_produto VARCHAR(30),
grp_prod VARCHAR(30),
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
);
-- -----------------------------------------------------------------------------
-- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin)
-- Desconto liberado por grupo/subgrupo de produto e/ou representante, com
-- vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL vale para todo rep).
-- -----------------------------------------------------------------------------
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
);
-- -----------------------------------------------------------------------------
-- Oportunidades (Funil de Vendas)
-- -----------------------------------------------------------------------------
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()
);
-- -----------------------------------------------------------------------------
-- Chamados (SAC) — pedido pode ser SAR (id_pedido/num_ped_sar) ou ERP (num_ped_erp)
-- -----------------------------------------------------------------------------
CREATE TABLE IF NOT EXISTS sar.chamados (
id SERIAL PRIMARY KEY,
id_empresa INTEGER NOT NULL,
cod_vendedor INTEGER NOT NULL,
id_pedido UUID REFERENCES sar.pedidos(id) ON DELETE SET NULL,
num_ped_sar INTEGER,
num_ped_erp INTEGER,
id_cliente INTEGER NOT NULL,
nome_cliente VARCHAR(200),
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 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()
);
-- ============================================================================= -- =============================================================================
-- ÍNDICES -- ÍNDICES
-- ============================================================================= -- =============================================================================
@@ -673,17 +801,25 @@ CREATE INDEX IF NOT EXISTS idx_sar_push_empresa ON sar.push_subscription(id_emp
CREATE INDEX IF NOT EXISTS idx_sar_push_vend ON sar.push_subscription(cod_vendedor); CREATE INDEX IF NOT EXISTS idx_sar_push_vend ON sar.push_subscription(cod_vendedor);
CREATE INDEX IF NOT EXISTS idx_sar_meta_vend ON sar.meta_representante(cod_vendedor, id_empresa); CREATE INDEX IF NOT EXISTS idx_sar_meta_vend ON sar.meta_representante(cod_vendedor, id_empresa);
CREATE INDEX IF NOT EXISTS idx_sar_alcada_vend ON sar.alcada_desconto(cod_vendedor, id_empresa); CREATE INDEX IF NOT EXISTS idx_sar_alcada_vend ON sar.alcada_desconto(cod_vendedor, id_empresa);
CREATE INDEX IF NOT EXISTS idx_sar_promo_empresa ON sar.promocoes(id_empresa);
CREATE INDEX IF NOT EXISTS idx_sar_promo_vigente ON sar.promocoes(id_empresa, data_fim) WHERE ativa;
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;
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);
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_chamado_mensagens_chamado ON sar.chamado_mensagens(id_chamado);
-- ============================================================================= -- =============================================================================
-- GRANTS (descomentar e ajustar o role conforme o ambiente) -- GRANTS
-- Executado pelo provision-client.ts quando --role <nome> é informado.
-- Referência manual:
-- GRANT USAGE ON SCHEMA sar TO <role>;
-- GRANT SELECT ON ALL TABLES IN SCHEMA sar TO <role>;
-- GRANT INSERT, UPDATE, DELETE ON
-- sar.pedidos, sar.pedido_itens, sar.historico_pedido,
-- sar.alcada_desconto, sar.meta_representante, sar.push_subscription,
-- sar.promocoes, sar.regras_desconto, sar.clientes_novos, sar.contatos_novos
-- TO <role>;
-- ============================================================================= -- =============================================================================
-- GRANT USAGE ON SCHEMA sar TO sar_app;
-- GRANT SELECT ON ALL TABLES IN SCHEMA sar TO sar_app;
-- GRANT INSERT, UPDATE, DELETE ON
-- sar.pedidos,
-- sar.pedido_itens,
-- sar.historico_pedido,
-- sar.alcada_desconto,
-- sar.meta_representante,
-- sar.push_subscription
-- TO sar_app;