Compare commits
16 Commits
fix/rep-au
...
dfdcca4674
| Author | SHA1 | Date | |
|---|---|---|---|
| dfdcca4674 | |||
| 76cd90ae95 | |||
| 756e56d8d3 | |||
| 725a94ede1 | |||
| b6b16e1e21 | |||
| 54ef63f5c7 | |||
| 2ff1f9328a | |||
| 8a5db57f35 | |||
| b33293e79b | |||
| 1e11de7f31 | |||
| 35fd773f14 | |||
| 673130d60b | |||
| 43daf7ad6d | |||
| d5279576d7 | |||
| 36430fde63 | |||
| afa6171919 |
@@ -0,0 +1,10 @@
|
|||||||
|
-- CreateTable: sar.config_empresa (configuracoes por empresa - logo da impressao)
|
||||||
|
-- logo_base64: data URL (data:image/png;base64,...) usado no cabecalho do pedido.
|
||||||
|
-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL
|
||||||
|
-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente.
|
||||||
|
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.config_empresa (
|
||||||
|
id_empresa INTEGER PRIMARY KEY,
|
||||||
|
logo_base64 TEXT,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Meta diaria de positivacao do painel gerencial - salva por empresa para
|
||||||
|
-- nao depender do navegador do gerente.
|
||||||
|
-- Idempotente (provision roda o schema espelho antes do migrate deploy).
|
||||||
|
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||||
|
ALTER TABLE sar.config_empresa ADD COLUMN IF NOT EXISTS meta_positivacao_dia INTEGER;
|
||||||
@@ -195,6 +195,20 @@ model RegraDesconto {
|
|||||||
@@map("regras_desconto")
|
@@map("regras_desconto")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── ConfigEmpresa ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Configurações por empresa. logo_base64: data URL exibido no cabeçalho da
|
||||||
|
// impressão do pedido de todos os representantes.
|
||||||
|
|
||||||
|
model ConfigEmpresa {
|
||||||
|
idEmpresa Int @id @map("id_empresa")
|
||||||
|
logoBase64 String? @map("logo_base64")
|
||||||
|
metaPositivacaoDia Int? @map("meta_positivacao_dia")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
|
@@map("config_empresa")
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Oportunidade (Funil de Vendas) ──────────────────────────────────────────
|
// ─── Oportunidade (Funil de Vendas) ──────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente)
|
// Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente)
|
||||||
|
|||||||
@@ -1,8 +1,20 @@
|
|||||||
import { Controller, Get, NotFoundException, Param, ParseIntPipe, Query } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
NotFoundException,
|
||||||
|
Param,
|
||||||
|
ParseIntPipe,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
ProdutoListQuerySchema,
|
ProdutoListQuerySchema,
|
||||||
|
UpdateLogoBodySchema,
|
||||||
type EmpresaInfo,
|
type EmpresaInfo,
|
||||||
|
type UpdateLogoBody,
|
||||||
type FormaPagamento,
|
type FormaPagamento,
|
||||||
type Municipio,
|
type Municipio,
|
||||||
type Pauta,
|
type Pauta,
|
||||||
@@ -13,6 +25,7 @@ import {
|
|||||||
import { CatalogService } from './catalog.service';
|
import { CatalogService } from './catalog.service';
|
||||||
|
|
||||||
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
|
class ProdutoListQueryDto extends createZodDto(ProdutoListQuerySchema) {}
|
||||||
|
class UpdateLogoDto extends createZodDto(UpdateLogoBodySchema) {}
|
||||||
|
|
||||||
// ADR 0006 revogado: UUID → Int para ID de produto. Sync removido (ERP direto via view).
|
// ADR 0006 revogado: UUID → Int para ID de produto. Sync removido (ERP direto via view).
|
||||||
|
|
||||||
@@ -40,6 +53,12 @@ export class CatalogController {
|
|||||||
return this.catalog.company();
|
return this.catalog.company();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('company/logo')
|
||||||
|
@HttpCode(204)
|
||||||
|
updateLogo(@Body() body: UpdateLogoDto): Promise<void> {
|
||||||
|
return this.catalog.updateLogo(body as unknown as UpdateLogoBody);
|
||||||
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
|
list(@Query() query: ProdutoListQueryDto): Promise<ProdutoListResponse> {
|
||||||
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;
|
const parsed = ProdutoListQuerySchema.parse(query) as ProdutoListQuery;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type {
|
import type {
|
||||||
EmpresaInfo,
|
EmpresaInfo,
|
||||||
|
UpdateLogoBody,
|
||||||
FormaPagamento,
|
FormaPagamento,
|
||||||
Municipio,
|
Municipio,
|
||||||
Pauta,
|
Pauta,
|
||||||
@@ -101,6 +102,8 @@ export class CatalogService {
|
|||||||
const r = rows[0];
|
const r = rows[0];
|
||||||
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`);
|
if (!r) throw new Error(`Empresa matriz ${idEmpresa} não encontrada`);
|
||||||
|
|
||||||
|
const config = await prisma.configEmpresa.findUnique({ where: { idEmpresa } });
|
||||||
|
|
||||||
const clean = (v: string | null) => {
|
const clean = (v: string | null) => {
|
||||||
const t = (v ?? '').trim();
|
const t = (v ?? '').trim();
|
||||||
return t === '' ? null : t;
|
return t === '' ? null : t;
|
||||||
@@ -120,9 +123,28 @@ export class CatalogService {
|
|||||||
cep: clean(r.cep),
|
cep: clean(r.cep),
|
||||||
telefone: clean(r.telefone),
|
telefone: clean(r.telefone),
|
||||||
email: clean(r.email),
|
email: clean(r.email),
|
||||||
|
logoBase64: config?.logoBase64 ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logo do cabeçalho da impressão — só gerente/admin altera; vale para todos
|
||||||
|
// os representantes da empresa (gravado na matriz, mesma fonte do company()).
|
||||||
|
async updateLogo(body: UpdateLogoBody): Promise<void> {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role !== 'manager' && role !== 'admin') {
|
||||||
|
throw new ForbiddenException('Apenas gerentes podem alterar o logo da empresa');
|
||||||
|
}
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
|
await prisma.configEmpresa.upsert({
|
||||||
|
where: { idEmpresa },
|
||||||
|
create: { idEmpresa, logoBase64: body.logoBase64 },
|
||||||
|
update: { logoBase64: body.logoBase64 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async municipios(q?: string): Promise<Municipio[]> {
|
async municipios(q?: string): Promise<Municipio[]> {
|
||||||
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');
|
||||||
@@ -158,13 +180,19 @@ export class CatalogService {
|
|||||||
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
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 role = this.cls.get('role') ?? 'rep';
|
||||||
|
|
||||||
interface PautaRow {
|
interface PautaRow {
|
||||||
id_pauta: number;
|
id_pauta: number;
|
||||||
codigo: number;
|
codigo: number;
|
||||||
descricao: string;
|
descricao: string;
|
||||||
|
qtd_reps?: string;
|
||||||
}
|
}
|
||||||
const rows = await prisma.$queryRawUnsafe<PautaRow[]>(`
|
// Rep vê só as pautas atribuídas a ele (cod_pauta1..6); gestor vê TODAS as
|
||||||
|
// pautas ativas, com a contagem de reps que usam cada uma.
|
||||||
|
const rows =
|
||||||
|
role === 'rep'
|
||||||
|
? await prisma.$queryRawUnsafe<PautaRow[]>(`
|
||||||
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
|
SELECT DISTINCT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao
|
||||||
FROM vw_pautas pa
|
FROM vw_pautas pa
|
||||||
JOIN vw_representantes r ON pa.codigo IN (
|
JOIN vw_representantes r ON pa.codigo IN (
|
||||||
@@ -175,12 +203,26 @@ export class CatalogService {
|
|||||||
AND pa.ativo = 1
|
AND pa.ativo = 1
|
||||||
AND r.codigo = ${codVendedor}
|
AND r.codigo = ${codVendedor}
|
||||||
ORDER BY pa.codigo
|
ORDER BY pa.codigo
|
||||||
|
`)
|
||||||
|
: await prisma.$queryRawUnsafe<PautaRow[]>(`
|
||||||
|
SELECT pa.id_pauta, pa.codigo, TRIM(pa.descricao) AS descricao,
|
||||||
|
COUNT(DISTINCT r.codigo)::text AS qtd_reps
|
||||||
|
FROM vw_pautas pa
|
||||||
|
LEFT JOIN vw_representantes r ON pa.codigo IN (
|
||||||
|
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||||
|
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||||
|
)
|
||||||
|
WHERE pa.id_empresa = ${idEmpresa}
|
||||||
|
AND pa.ativo = 1
|
||||||
|
GROUP BY pa.id_pauta, pa.codigo, pa.descricao
|
||||||
|
ORDER BY pa.codigo
|
||||||
`);
|
`);
|
||||||
|
|
||||||
return rows.map((r) => ({
|
return rows.map((r) => ({
|
||||||
idPauta: Number(r.id_pauta),
|
idPauta: Number(r.id_pauta),
|
||||||
codigo: Number(r.codigo),
|
codigo: Number(r.codigo),
|
||||||
descricao: r.descricao,
|
descricao: r.descricao,
|
||||||
|
...(r.qtd_reps != null && { qtdReps: Number(r.qtd_reps) }),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -337,12 +379,16 @@ export class CatalogService {
|
|||||||
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
|
JOIN vw_pautas pa ON pa.id_pauta = pp.id_pauta
|
||||||
AND pa.id_empresa = ${idEmpresa}
|
AND pa.id_empresa = ${idEmpresa}
|
||||||
AND pa.ativo = 1
|
AND pa.ativo = 1
|
||||||
JOIN vw_representantes r ON pa.codigo IN (
|
${
|
||||||
|
// Rep vê só as pautas dele; gestor vê os preços de TODAS as pautas
|
||||||
|
(this.cls.get('role') ?? 'rep') === 'rep'
|
||||||
|
? `JOIN vw_representantes r ON pa.codigo IN (
|
||||||
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
r.cod_pauta1, r.cod_pauta2, r.cod_pauta3,
|
||||||
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
r.cod_pauta4, r.cod_pauta5, r.cod_pauta6
|
||||||
)
|
) AND r.codigo = ${codVendedor}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
WHERE pp.id_produto = ${idErp}
|
WHERE pp.id_produto = ${idErp}
|
||||||
AND r.codigo = ${codVendedor}
|
|
||||||
AND pp.preco1 > 0
|
AND pp.preco1 > 0
|
||||||
ORDER BY pa.codigo
|
ORDER BY pa.codigo
|
||||||
`),
|
`),
|
||||||
|
|||||||
@@ -1,9 +1,18 @@
|
|||||||
import { Controller, ForbiddenException, Get, Query } from '@nestjs/common';
|
import { Body, Controller, ForbiddenException, Get, HttpCode, Put, Query } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type { RepDashboard, SupervisorDashboard, ManagerDashboard } from '@sar/api-interface';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
|
import {
|
||||||
|
SaveMetaPositivacaoBodySchema,
|
||||||
|
type RepDashboard,
|
||||||
|
type SupervisorDashboard,
|
||||||
|
type ManagerDashboard,
|
||||||
|
type SaveMetaPositivacaoBody,
|
||||||
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
import { DashboardService } from './dashboard.service';
|
import { DashboardService } from './dashboard.service';
|
||||||
|
|
||||||
|
class SaveMetaPositivacaoDto extends createZodDto(SaveMetaPositivacaoBodySchema) {}
|
||||||
|
|
||||||
@Controller({ path: 'dashboard' })
|
@Controller({ path: 'dashboard' })
|
||||||
export class DashboardController {
|
export class DashboardController {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -31,6 +40,14 @@ export class DashboardController {
|
|||||||
return this.dashboard.supervisorDashboard();
|
return this.dashboard.supervisorDashboard();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('manager/meta-positivacao-dia')
|
||||||
|
@HttpCode(204)
|
||||||
|
saveMetaPositivacaoDia(@Body() body: SaveMetaPositivacaoDto): Promise<void> {
|
||||||
|
this.assertGestor();
|
||||||
|
const parsed = SaveMetaPositivacaoBodySchema.parse(body) as SaveMetaPositivacaoBody;
|
||||||
|
return this.dashboard.saveMetaPositivacaoDia(parsed.meta);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('manager')
|
@Get('manager')
|
||||||
managerDashboard(
|
managerDashboard(
|
||||||
@Query('mes') mes?: string,
|
@Query('mes') mes?: string,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { ForbiddenException, Injectable } from '@nestjs/common';
|
||||||
import { ClsService } from 'nestjs-cls';
|
import { ClsService } from 'nestjs-cls';
|
||||||
import type {
|
import type {
|
||||||
RepDashboard,
|
RepDashboard,
|
||||||
@@ -418,6 +418,10 @@ export class DashboardService {
|
|||||||
dia: string;
|
dia: string;
|
||||||
clientes: string;
|
clientes: string;
|
||||||
}
|
}
|
||||||
|
interface NovosClientesRow {
|
||||||
|
cod_vendedor: number;
|
||||||
|
novos: string;
|
||||||
|
}
|
||||||
|
|
||||||
const [
|
const [
|
||||||
statsRows,
|
statsRows,
|
||||||
@@ -428,6 +432,7 @@ export class DashboardService {
|
|||||||
metaGrupoRows,
|
metaGrupoRows,
|
||||||
realizadoGrupoRows,
|
realizadoGrupoRows,
|
||||||
positivacaoDiariaRows,
|
positivacaoDiariaRows,
|
||||||
|
novosClientesRows,
|
||||||
] = 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
|
||||||
@@ -535,6 +540,21 @@ export class DashboardService {
|
|||||||
GROUP BY dt_pedido::date
|
GROUP BY dt_pedido::date
|
||||||
ORDER BY dia
|
ORDER BY dia
|
||||||
`),
|
`),
|
||||||
|
prisma.$queryRawUnsafe<NovosClientesRow[]>(`
|
||||||
|
SELECT c.cod_vendedor, COUNT(*)::text AS novos
|
||||||
|
FROM (
|
||||||
|
SELECT id_cliente, MIN(dt_pedido) AS primeira_compra
|
||||||
|
FROM vw_pedidos_erp
|
||||||
|
WHERE id_empresa = ${idEmpresa}
|
||||||
|
AND situa NOT IN (1, 5)
|
||||||
|
GROUP BY id_cliente
|
||||||
|
) f
|
||||||
|
JOIN (SELECT DISTINCT id_cliente, cod_vendedor FROM vw_clientes) c
|
||||||
|
ON c.id_cliente = f.id_cliente
|
||||||
|
WHERE f.primeira_compra >= '${monthStartStr}'
|
||||||
|
AND f.primeira_compra <= '${monthEndStr}'
|
||||||
|
GROUP BY c.cod_vendedor
|
||||||
|
`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const pedidosMes = Number(statsRows[0]?.count ?? 0);
|
const pedidosMes = Number(statsRows[0]?.count ?? 0);
|
||||||
@@ -585,6 +605,14 @@ export class DashboardService {
|
|||||||
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
|
const totalReps = new Set(rankingRows.map((r) => Number(r.cod_vendedor))).size;
|
||||||
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
|
const metaTotal = Number(metaTotalRows[0]?.meta_total ?? 0);
|
||||||
|
|
||||||
|
// Meta diária de positivação salva pelo gerente (config da empresa matriz)
|
||||||
|
const config = await prisma.configEmpresa.findUnique({
|
||||||
|
where: { idEmpresa: idEmpresaMatriz },
|
||||||
|
});
|
||||||
|
|
||||||
|
const novosMap = new Map(
|
||||||
|
novosClientesRows.map((n) => [Number(n.cod_vendedor), Number(n.novos)]),
|
||||||
|
);
|
||||||
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
|
const positivacaoReps: PositivacaoRep[] = positivacaoRows.map((r) => {
|
||||||
const total = Number(r.total_clientes);
|
const total = Number(r.total_clientes);
|
||||||
const positivados = Number(r.clientes_positivados);
|
const positivados = Number(r.clientes_positivados);
|
||||||
@@ -594,6 +622,7 @@ export class DashboardService {
|
|||||||
totalClientes: total,
|
totalClientes: total,
|
||||||
clientesPositivados: positivados,
|
clientesPositivados: positivados,
|
||||||
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
|
pctPositivacao: total > 0 ? Math.round((positivados / total) * 100) : 0,
|
||||||
|
novosClientes: novosMap.get(Number(r.cod_vendedor)) ?? 0,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -611,10 +640,29 @@ export class DashboardService {
|
|||||||
dia: r.dia,
|
dia: r.dia,
|
||||||
clientes: Number(r.clientes),
|
clientes: Number(r.clientes),
|
||||||
})),
|
})),
|
||||||
|
metaPositivacaoDia: config?.metaPositivacaoDia ?? null,
|
||||||
syncedAt: now.toISOString(),
|
syncedAt: now.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Salva a meta diária de positivação (config_empresa) — só gerente/admin.
|
||||||
|
async saveMetaPositivacaoDia(meta: number): Promise<void> {
|
||||||
|
const role = this.cls.get('role') ?? 'rep';
|
||||||
|
if (role !== 'manager' && role !== 'admin') {
|
||||||
|
throw new ForbiddenException('Apenas gerentes podem salvar a meta de positivação');
|
||||||
|
}
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = matrizEmpresa(this.cls.get('idEmpresa'));
|
||||||
|
|
||||||
|
const value = meta > 0 ? meta : null;
|
||||||
|
await prisma.configEmpresa.upsert({
|
||||||
|
where: { idEmpresa },
|
||||||
|
create: { idEmpresa, metaPositivacaoDia: value },
|
||||||
|
update: { metaPositivacaoDia: value },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async supervisorDashboard(): Promise<SupervisorDashboard> {
|
async supervisorDashboard(): Promise<SupervisorDashboard> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
|||||||
@@ -15,12 +15,16 @@ import { ClsService } from 'nestjs-cls';
|
|||||||
import { createZodDto } from 'nestjs-zod';
|
import { createZodDto } from 'nestjs-zod';
|
||||||
import {
|
import {
|
||||||
AprovarPedidoSchema,
|
AprovarPedidoSchema,
|
||||||
|
CancelPedidoSchema,
|
||||||
CreatePedidoSchema,
|
CreatePedidoSchema,
|
||||||
|
UpdatePedidoSchema,
|
||||||
PedidoErpConsultaQuerySchema,
|
PedidoErpConsultaQuerySchema,
|
||||||
PedidoListQuerySchema,
|
PedidoListQuerySchema,
|
||||||
RecusarPedidoSchema,
|
RecusarPedidoSchema,
|
||||||
type AprovarPedido,
|
type AprovarPedido,
|
||||||
|
type CancelPedido,
|
||||||
type CreatePedido,
|
type CreatePedido,
|
||||||
|
type UpdatePedido,
|
||||||
type PedidoDetail,
|
type PedidoDetail,
|
||||||
type PedidoErpConsultaQuery,
|
type PedidoErpConsultaQuery,
|
||||||
type PedidoErpConsultaResponse,
|
type PedidoErpConsultaResponse,
|
||||||
@@ -33,6 +37,8 @@ import { OrdersService } from './orders.service';
|
|||||||
|
|
||||||
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
class PedidoListQueryDto extends createZodDto(PedidoListQuerySchema) {}
|
||||||
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
class CreatePedidoDto extends createZodDto(CreatePedidoSchema) {}
|
||||||
|
class UpdatePedidoDto extends createZodDto(UpdatePedidoSchema) {}
|
||||||
|
class CancelPedidoDto extends createZodDto(CancelPedidoSchema) {}
|
||||||
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
class AprovarPedidoDto extends createZodDto(AprovarPedidoSchema) {}
|
||||||
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
class RecusarPedidoDto extends createZodDto(RecusarPedidoSchema) {}
|
||||||
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
|
class PedidoErpConsultaQueryDto extends createZodDto(PedidoErpConsultaQuerySchema) {}
|
||||||
@@ -85,8 +91,22 @@ export class OrdersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Patch(':id/cancel')
|
@Patch(':id/cancel')
|
||||||
cancel(@Param('id', ParseUUIDPipe) id: string): Promise<PedidoDetail> {
|
cancel(
|
||||||
return this.orders.cancel(id);
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() body: CancelPedidoDto,
|
||||||
|
): Promise<PedidoDetail> {
|
||||||
|
const parsed = CancelPedidoSchema.parse(body) as CancelPedido;
|
||||||
|
return this.orders.cancel(id, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edição de orçamento (situa 0) — depois de transmitido não é mais editável
|
||||||
|
@Patch(':id')
|
||||||
|
update(
|
||||||
|
@Param('id', ParseUUIDPipe) id: string,
|
||||||
|
@Body() body: UpdatePedidoDto,
|
||||||
|
): Promise<PedidoDetail> {
|
||||||
|
const parsed = UpdatePedidoSchema.parse(body) as UpdatePedido;
|
||||||
|
return this.orders.update(id, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('erp/:idPedido')
|
@Get('erp/:idPedido')
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import { ClsService } from 'nestjs-cls';
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import type {
|
import type {
|
||||||
AprovarPedido,
|
AprovarPedido,
|
||||||
|
CancelPedido,
|
||||||
CreatePedido,
|
CreatePedido,
|
||||||
|
UpdatePedido,
|
||||||
PedidoDetail,
|
PedidoDetail,
|
||||||
PedidoErpConsultaItem,
|
PedidoErpConsultaItem,
|
||||||
PedidoErpConsultaQuery,
|
PedidoErpConsultaQuery,
|
||||||
@@ -330,8 +332,56 @@ export class OrdersService {
|
|||||||
return this.mapDetail(o);
|
return this.mapDetail(o);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A validação de alçada e a
|
// Valida a alçada de um payload de criação/edição (mesma regra do transmit):
|
||||||
// notificação ao supervisor acontecem no transmit(), não aqui.
|
// desconto global e por item contra alcada + promoção/regra do dono do pedido.
|
||||||
|
private async assertAlcadaDto(
|
||||||
|
codVendedor: number,
|
||||||
|
dto: {
|
||||||
|
descontoPerc: number;
|
||||||
|
idPauta?: number;
|
||||||
|
itens: {
|
||||||
|
ordem: number;
|
||||||
|
idProduto: number;
|
||||||
|
codProduto?: string;
|
||||||
|
precoUnitario: number;
|
||||||
|
descontoPerc: number;
|
||||||
|
}[];
|
||||||
|
},
|
||||||
|
): Promise<void> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
|
||||||
|
const limitRows = await prisma.alcadaDesconto.findMany({ where: { codVendedor, idEmpresa } });
|
||||||
|
const limitMap = new Map(limitRows.map((r) => [r.codGrupo, Number(r.limitePerc)]));
|
||||||
|
const limiteDefault = limitMap.get(0) ?? 5;
|
||||||
|
if (dto.descontoPerc > limiteDefault) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Desconto de ${dto.descontoPerc}% acima do máximo permitido para você (${limiteDefault}%). Reduza o desconto para continuar.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await this.assertAlcadaItens(
|
||||||
|
{
|
||||||
|
codVendedor,
|
||||||
|
descontoPerc: dto.descontoPerc,
|
||||||
|
idPauta: dto.idPauta ?? null,
|
||||||
|
itens: dto.itens.map((it) => ({
|
||||||
|
ordem: it.ordem,
|
||||||
|
idProduto: it.idProduto,
|
||||||
|
codProduto: it.codProduto ?? null,
|
||||||
|
precoUnitario: it.precoUnitario,
|
||||||
|
descontoPerc: it.descontoPerc,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
limitMap,
|
||||||
|
limiteDefault,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cria novo pedido SAR como ORÇAMENTO (situa 0). A alçada de desconto valida
|
||||||
|
// JÁ AQUI (bloqueia o Concluir com a mensagem do que passou) e de novo no
|
||||||
|
// transmit() — defesa em profundidade, pois aprovação/ERP podem mudar valores.
|
||||||
|
// A notificação ao supervisor continua só no transmit().
|
||||||
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
|
// Idempotency-Key: retorna pedido existente se já processado (FR-4.3).
|
||||||
async create(dto: CreatePedido): Promise<PedidoDetail> {
|
async create(dto: CreatePedido): Promise<PedidoDetail> {
|
||||||
const prisma = this.cls.get('prisma');
|
const prisma = this.cls.get('prisma');
|
||||||
@@ -365,6 +415,9 @@ export class OrdersService {
|
|||||||
if (existing) return this.mapDetail(existing);
|
if (existing) return this.mapDetail(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alçada: bloqueia a criação do orçamento se algum desconto passa do teto
|
||||||
|
await this.assertAlcadaDto(codVendedor, dto);
|
||||||
|
|
||||||
const itemsData = dto.itens.map((it) => {
|
const itemsData = dto.itens.map((it) => {
|
||||||
const descontoValor =
|
const descontoValor =
|
||||||
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
|
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
|
||||||
@@ -496,23 +549,23 @@ export class OrdersService {
|
|||||||
|
|
||||||
// Alçada por ITEM (complementa o gate global do transmit): o desconto EFETIVO
|
// 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
|
// 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
|
// global — deve caber no maior valor entre a alçada do grupo do produto
|
||||||
// codGrupo; 0 = default), somado à maior folga entre promoção ativa
|
// (alcada_desconto por codGrupo; 0 = default), a promoção ativa
|
||||||
// (produto/grupo) e regra de desconto vigente (grupo/subgrupo/rep). Cobre o
|
// (produto/grupo) e a regra de desconto vigente (grupo/subgrupo/rep) — teto,
|
||||||
// desconto lançado no item E desconto disfarçado de preço unitário reduzido.
|
// não soma. Cobre o desconto lançado no item E desconto disfarçado de preço
|
||||||
// Preço de tabela: pauta do pedido > promocional > preço base; produto sem
|
// unitário reduzido. Preço de tabela: pauta do pedido > promocional > preço
|
||||||
// referência valida só pelo campo de desconto.
|
// base; produto sem referência valida só pelo campo de desconto.
|
||||||
private async assertAlcadaItens(
|
private async assertAlcadaItens(
|
||||||
pedido: {
|
pedido: {
|
||||||
codVendedor: number;
|
codVendedor: number;
|
||||||
descontoPerc: Prisma.Decimal;
|
descontoPerc: Prisma.Decimal | number;
|
||||||
idPauta: number | null;
|
idPauta: number | null;
|
||||||
itens: {
|
itens: {
|
||||||
ordem: number;
|
ordem: number;
|
||||||
idProduto: number;
|
idProduto: number;
|
||||||
codProduto: string | null;
|
codProduto: string | null;
|
||||||
precoUnitario: Prisma.Decimal;
|
precoUnitario: Prisma.Decimal | number;
|
||||||
descontoPerc: Prisma.Decimal;
|
descontoPerc: Prisma.Decimal | number;
|
||||||
}[];
|
}[];
|
||||||
},
|
},
|
||||||
limitMap: Map<number, number>,
|
limitMap: Map<number, number>,
|
||||||
@@ -601,9 +654,12 @@ export class OrdersService {
|
|||||||
)
|
)
|
||||||
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
|
.reduce((max, r) => Math.max(max, Number(r.descPct)), 0);
|
||||||
|
|
||||||
const limiteItem =
|
// Promoção/regra definem TETO próprio, não somam à alçada: com alçada 5%
|
||||||
(codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault) +
|
// e regra de 15%, o máximo do item é 15% (não 20%). Vale o maior entre a
|
||||||
Math.max(promoPct, regraPct);
|
// alçada do grupo e a melhor condição vigente que alcança o produto.
|
||||||
|
const alcadaBase =
|
||||||
|
codGrupo != null ? (limitMap.get(codGrupo) ?? limiteDefault) : limiteDefault;
|
||||||
|
const limiteItem = Math.max(alcadaBase, promoPct, regraPct);
|
||||||
|
|
||||||
// Preço de tabela: pauta do pedido > promocional (se menor) > preço base
|
// 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 precoPauta = prod?.preco_pauta != null ? Number(prod.preco_pauta) : 0;
|
||||||
@@ -629,7 +685,7 @@ export class OrdersService {
|
|||||||
|
|
||||||
if (problemas.length > 0) {
|
if (problemas.length > 0) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para transmitir.`,
|
`Desconto acima da sua alçada: ${problemas.join('; ')}. Ajuste preço/desconto para continuar.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -743,7 +799,98 @@ export class OrdersService {
|
|||||||
return this.mapDetail(final);
|
return this.mapDetail(final);
|
||||||
}
|
}
|
||||||
|
|
||||||
async cancel(id: string): Promise<PedidoDetail> {
|
// Edita um orçamento (situa 0). Depois de transmitido não é mais editável.
|
||||||
|
// Substitui itens e recalcula totais; alçada valida contra o dono do pedido.
|
||||||
|
async update(id: string, dto: UpdatePedido): Promise<PedidoDetail> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const role = this.cls.get('role');
|
||||||
|
const userId = this.cls.get('userId') ?? '0';
|
||||||
|
const codVendedor = parseInt(userId, 10);
|
||||||
|
|
||||||
|
const repFilter =
|
||||||
|
role === 'rep'
|
||||||
|
? { codVendedor }
|
||||||
|
: role === 'supervisor'
|
||||||
|
? { codVendedor: { in: await getTeamCodes(prisma, codVendedor) } }
|
||||||
|
: {};
|
||||||
|
const pedido = await prisma.pedido.findFirst({ where: { id, idEmpresa, ...repFilter } });
|
||||||
|
if (!pedido) throw new NotFoundException(`Pedido ${id} não encontrado`);
|
||||||
|
if (pedido.situa !== SITUA_ORCAMENTO)
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Apenas orçamentos podem ser editados — este pedido já foi transmitido',
|
||||||
|
);
|
||||||
|
|
||||||
|
// PGD-AUTHZ: rep só aponta o pedido para cliente da própria carteira.
|
||||||
|
if (role === 'rep' && dto.idCliente !== pedido.idCliente) {
|
||||||
|
const cliRows = await prisma.$queryRawUnsafe<{ cod_vendedor: number }[]>(
|
||||||
|
`SELECT cod_vendedor FROM vw_clientes WHERE id_cliente = $1 LIMIT 1`,
|
||||||
|
dto.idCliente,
|
||||||
|
);
|
||||||
|
if (!cliRows[0] || Number(cliRows[0].cod_vendedor) !== codVendedor) {
|
||||||
|
throw new NotFoundException(`Cliente ${dto.idCliente} não encontrado`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alçada do DONO do pedido (supervisor editando usa o limite do rep)
|
||||||
|
await this.assertAlcadaDto(pedido.codVendedor, dto);
|
||||||
|
|
||||||
|
const itemsData = dto.itens.map((it) => {
|
||||||
|
const descontoValor =
|
||||||
|
Math.round(it.qtd * it.precoUnitario * (it.descontoPerc / 100) * 100) / 100;
|
||||||
|
const total = Math.round(it.qtd * it.precoUnitario * (1 - it.descontoPerc / 100) * 100) / 100;
|
||||||
|
return {
|
||||||
|
ordem: it.ordem,
|
||||||
|
idProduto: it.idProduto,
|
||||||
|
codProduto: it.codProduto ?? null,
|
||||||
|
descProduto: it.descProduto,
|
||||||
|
qtd: it.qtd,
|
||||||
|
precoUnitario: it.precoUnitario,
|
||||||
|
descontoPerc: it.descontoPerc,
|
||||||
|
descontoValor,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const totalProdutos = itemsData.reduce((acc, it) => acc + it.total, 0);
|
||||||
|
const descontoValorGlobal = Math.round(totalProdutos * (dto.descontoPerc / 100) * 100) / 100;
|
||||||
|
const total = Math.round(totalProdutos * (1 - dto.descontoPerc / 100) * 100) / 100;
|
||||||
|
|
||||||
|
const final = await prisma.pedido.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
idCliente: dto.idCliente,
|
||||||
|
idPauta: dto.idPauta ?? null,
|
||||||
|
codFormapag: dto.codFormapag ?? null,
|
||||||
|
totalProdutos,
|
||||||
|
total,
|
||||||
|
descontoPerc: dto.descontoPerc,
|
||||||
|
descontoValor: descontoValorGlobal,
|
||||||
|
obs: dto.obs ?? null,
|
||||||
|
endEntrega: dto.endEntrega ?? null,
|
||||||
|
itens: { deleteMany: {}, create: itemsData },
|
||||||
|
historico: {
|
||||||
|
create: [
|
||||||
|
{
|
||||||
|
situaAnterior: SITUA_ORCAMENTO,
|
||||||
|
situaNova: SITUA_ORCAMENTO,
|
||||||
|
changedBy: codVendedor,
|
||||||
|
changedAt: new Date(),
|
||||||
|
nota: 'Orçamento editado',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
itens: { orderBy: { ordem: 'asc' } },
|
||||||
|
historico: { orderBy: { changedAt: 'asc' } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.mapDetail(final);
|
||||||
|
}
|
||||||
|
|
||||||
|
async cancel(id: string, dto: CancelPedido): 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');
|
||||||
const idEmpresa = this.cls.get('idEmpresa');
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
@@ -757,8 +904,15 @@ export class OrdersService {
|
|||||||
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
|
throw new BadRequestException('Apenas orçamentos podem ser cancelados pelo representante');
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
// ATENCAO: banco ERP e LATIN1 - sem em-dash/caracteres fora do LATIN1 aqui
|
||||||
|
const motivoCompleto = dto.descricao?.trim()
|
||||||
|
? `${dto.motivo} - ${dto.descricao.trim()}`
|
||||||
|
: dto.motivo;
|
||||||
|
|
||||||
await prisma.pedido.update({ where: { id }, data: { situa: SITUA_CANCELADO } });
|
await prisma.pedido.update({
|
||||||
|
where: { id },
|
||||||
|
data: { situa: SITUA_CANCELADO, motivoRecusa: motivoCompleto },
|
||||||
|
});
|
||||||
await prisma.historicoPedido.create({
|
await prisma.historicoPedido.create({
|
||||||
data: {
|
data: {
|
||||||
idPedido: id,
|
idPedido: id,
|
||||||
@@ -766,7 +920,7 @@ export class OrdersService {
|
|||||||
situaNova: SITUA_CANCELADO,
|
situaNova: SITUA_CANCELADO,
|
||||||
changedBy: codVendedor,
|
changedBy: codVendedor,
|
||||||
changedAt: now,
|
changedAt: now,
|
||||||
nota: 'Cancelado pelo representante',
|
nota: `Cancelado pelo representante - ${motivoCompleto}`,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -818,6 +972,8 @@ export class OrdersService {
|
|||||||
codVendedor: number;
|
codVendedor: number;
|
||||||
situa: number;
|
situa: number;
|
||||||
dtPedido: Date;
|
dtPedido: Date;
|
||||||
|
idPauta: number | null;
|
||||||
|
codFormapag: number | null;
|
||||||
total: Prisma.Decimal;
|
total: Prisma.Decimal;
|
||||||
descontoPerc: Prisma.Decimal;
|
descontoPerc: Prisma.Decimal;
|
||||||
descontoValor: Prisma.Decimal;
|
descontoValor: Prisma.Decimal;
|
||||||
@@ -856,6 +1012,41 @@ export class OrdersService {
|
|||||||
}[];
|
}[];
|
||||||
}): Promise<PedidoDetail> {
|
}): Promise<PedidoDetail> {
|
||||||
const names = await this.lookupNames(o.idCliente, o.codVendedor);
|
const names = await this.lookupNames(o.idCliente, o.codVendedor);
|
||||||
|
|
||||||
|
// Grupo/subgrupo dos produtos (vw_produtos, empresa matriz) — a edição do
|
||||||
|
// orçamento usa para calcular o teto de desconto por item no carrinho.
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||||
|
const prodIds = [...new Set(o.itens.map((i) => i.idProduto))];
|
||||||
|
const grupoRows =
|
||||||
|
prisma && prodIds.length > 0
|
||||||
|
? await prisma.$queryRawUnsafe<
|
||||||
|
{
|
||||||
|
id_erp: number;
|
||||||
|
cod_grupo: number | null;
|
||||||
|
cod_subgrupo: number | null;
|
||||||
|
peso_liquido: string | null;
|
||||||
|
vol_m3: string | null;
|
||||||
|
}[]
|
||||||
|
>(
|
||||||
|
`SELECT id_erp, cod_grupo, cod_subgrupo, peso_liquido::text, vol_m3::text
|
||||||
|
FROM vw_produtos
|
||||||
|
WHERE id_empresa = ${idMatriz} AND id_erp IN (${prodIds.join(',')})`,
|
||||||
|
)
|
||||||
|
: [];
|
||||||
|
const grupoMap = new Map(grupoRows.map((g) => [Number(g.id_erp), g]));
|
||||||
|
|
||||||
|
// Peso total (kg) e cubagem (m3) do pedido: soma de qtd x peso/vol do produto
|
||||||
|
let pesoTotal = 0;
|
||||||
|
let m3Total = 0;
|
||||||
|
for (const it of o.itens) {
|
||||||
|
const prod = grupoMap.get(it.idProduto);
|
||||||
|
const qtd = Number(it.qtd);
|
||||||
|
pesoTotal += qtd * Number(prod?.peso_liquido ?? 0);
|
||||||
|
m3Total += qtd * Number(prod?.vol_m3 ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: o.id,
|
id: o.id,
|
||||||
numPedSar: o.numPedSar,
|
numPedSar: o.numPedSar,
|
||||||
@@ -866,6 +1057,10 @@ export class OrdersService {
|
|||||||
nomeVendedor: names.nomeVendedor,
|
nomeVendedor: names.nomeVendedor,
|
||||||
situa: o.situa,
|
situa: o.situa,
|
||||||
dtPedido: o.dtPedido.toISOString(),
|
dtPedido: o.dtPedido.toISOString(),
|
||||||
|
idPauta: o.idPauta,
|
||||||
|
codFormapag: o.codFormapag,
|
||||||
|
pesoTotal: pesoTotal.toFixed(3),
|
||||||
|
m3Total: m3Total.toFixed(4),
|
||||||
total: decimalToString(o.total),
|
total: decimalToString(o.total),
|
||||||
descontoPerc: decimalToString(o.descontoPerc),
|
descontoPerc: decimalToString(o.descontoPerc),
|
||||||
obs: o.obs,
|
obs: o.obs,
|
||||||
@@ -894,6 +1089,14 @@ export class OrdersService {
|
|||||||
precoUnitario: decimalToString(it.precoUnitario),
|
precoUnitario: decimalToString(it.precoUnitario),
|
||||||
descontoPerc: decimalToString(it.descontoPerc),
|
descontoPerc: decimalToString(it.descontoPerc),
|
||||||
total: decimalToString(it.total),
|
total: decimalToString(it.total),
|
||||||
|
codGrupo:
|
||||||
|
grupoMap.get(it.idProduto)?.cod_grupo != null
|
||||||
|
? Number(grupoMap.get(it.idProduto)?.cod_grupo)
|
||||||
|
: null,
|
||||||
|
codSubgrupo:
|
||||||
|
grupoMap.get(it.idProduto)?.cod_subgrupo != null
|
||||||
|
? Number(grupoMap.get(it.idProduto)?.cod_subgrupo)
|
||||||
|
: null,
|
||||||
})),
|
})),
|
||||||
historico: o.historico.map((h) => ({
|
historico: o.historico.map((h) => ({
|
||||||
id: h.id,
|
id: h.id,
|
||||||
@@ -948,6 +1151,8 @@ export class OrdersService {
|
|||||||
preco_unitario: string;
|
preco_unitario: string;
|
||||||
desconto_perc: string;
|
desconto_perc: string;
|
||||||
total: string;
|
total: string;
|
||||||
|
peso_liquido: string | null;
|
||||||
|
vol_m3: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const vendedorFilter =
|
const vendedorFilter =
|
||||||
@@ -980,7 +1185,8 @@ export class OrdersService {
|
|||||||
SELECT ei.ordem, ei.id_produto,
|
SELECT ei.ordem, ei.id_produto,
|
||||||
TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao,
|
TRIM(p.codigo) AS codigo, TRIM(p.descricao) AS descricao,
|
||||||
ei.qtd::text, ei.preco_unitario::text,
|
ei.qtd::text, ei.preco_unitario::text,
|
||||||
ei.desconto_perc::text, ei.total::text
|
ei.desconto_perc::text, ei.total::text,
|
||||||
|
p.peso_liquido::text AS peso_liquido, p.vol_m3::text AS vol_m3
|
||||||
FROM vw_peditens_erp ei
|
FROM vw_peditens_erp ei
|
||||||
LEFT JOIN vw_produtos p
|
LEFT JOIN vw_produtos p
|
||||||
ON p.id_erp = ei.id_produto
|
ON p.id_erp = ei.id_produto
|
||||||
@@ -993,6 +1199,15 @@ export class OrdersService {
|
|||||||
if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`);
|
if (!headerRows[0]) throw new NotFoundException(`Pedido ERP ${idPedido} não encontrado`);
|
||||||
const h = headerRows[0];
|
const h = headerRows[0];
|
||||||
|
|
||||||
|
const pesoTotalErp = itemRows.reduce(
|
||||||
|
(acc, it) => acc + Number(it.qtd) * Number(it.peso_liquido ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const m3TotalErp = itemRows.reduce(
|
||||||
|
(acc, it) => acc + Number(it.qtd) * Number(it.vol_m3 ?? 0),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: `erp-${h.id_pedido}`,
|
id: `erp-${h.id_pedido}`,
|
||||||
numPedSar: (h.num_ped_sar ?? '').trim(),
|
numPedSar: (h.num_ped_sar ?? '').trim(),
|
||||||
@@ -1023,6 +1238,8 @@ export class OrdersService {
|
|||||||
aprovadoEm: null,
|
aprovadoEm: null,
|
||||||
motivoRecusa: null,
|
motivoRecusa: null,
|
||||||
idempotencyKey: null,
|
idempotencyKey: null,
|
||||||
|
pesoTotal: pesoTotalErp.toFixed(3),
|
||||||
|
m3Total: m3TotalErp.toFixed(4),
|
||||||
itens: itemRows.map((it) => ({
|
itens: itemRows.map((it) => ({
|
||||||
id: `${idPedido}-${it.ordem}`,
|
id: `${idPedido}-${it.ordem}`,
|
||||||
idProduto: Number(it.id_produto),
|
idProduto: Number(it.id_produto),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type AlcadaDescontosResponse,
|
type AlcadaDescontosResponse,
|
||||||
type PromocoesResponse,
|
type PromocoesResponse,
|
||||||
type PromocoesVigentesResponse,
|
type PromocoesVigentesResponse,
|
||||||
|
type AlcadaRepResponse,
|
||||||
type RegrasDescontoResponse,
|
type RegrasDescontoResponse,
|
||||||
type RegrasVigentesResponse,
|
type RegrasVigentesResponse,
|
||||||
type GruposProdutoResponse,
|
type GruposProdutoResponse,
|
||||||
@@ -61,8 +62,13 @@ export class PoliticasController {
|
|||||||
return this.politicas.listPromocoesVigentes();
|
return this.politicas.listPromocoesVigentes();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('alcada')
|
||||||
|
minhaAlcada(): Promise<AlcadaRepResponse> {
|
||||||
|
return this.politicas.minhaAlcada();
|
||||||
|
}
|
||||||
|
|
||||||
@Post('promocoes')
|
@Post('promocoes')
|
||||||
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
|
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ ids: number[] }> {
|
||||||
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +97,7 @@ export class PoliticasController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('regras')
|
@Post('regras')
|
||||||
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ id: number }> {
|
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ ids: number[] }> {
|
||||||
return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody);
|
return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
UpdateRegraDescontoBody,
|
UpdateRegraDescontoBody,
|
||||||
RegrasVigentesResponse,
|
RegrasVigentesResponse,
|
||||||
PromocoesVigentesResponse,
|
PromocoesVigentesResponse,
|
||||||
|
AlcadaRepResponse,
|
||||||
GruposProdutoResponse,
|
GruposProdutoResponse,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||||
@@ -108,26 +109,41 @@ export class PoliticasService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createPromocao(body: CreatePromocaoBody): Promise<{ id: number }> {
|
// Cria uma promoção POR ALVO (produto ou grupo). Sem alvo, cria uma linha
|
||||||
|
// genérica (comportamento anterior preservado).
|
||||||
|
async createPromocao(body: CreatePromocaoBody): Promise<{ ids: number[] }> {
|
||||||
this.assertManager();
|
this.assertManager();
|
||||||
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 userId = this.cls.get('userId') ?? 'system';
|
const userId = this.cls.get('userId') ?? 'system';
|
||||||
|
|
||||||
const p = await prisma.promocao.create({
|
const alvos: { codProduto: string | null; grpProd: string | null }[] = [
|
||||||
|
...(body.codProdutos ?? []).map((c) => ({ codProduto: c, grpProd: null })),
|
||||||
|
...(body.grpProds ?? []).map((g) => ({ codProduto: null, grpProd: g })),
|
||||||
|
];
|
||||||
|
if (alvos.length === 0) alvos.push({ codProduto: null, grpProd: null });
|
||||||
|
|
||||||
|
const ids = await prisma.$transaction(async (tx) => {
|
||||||
|
const created: number[] = [];
|
||||||
|
for (const alvo of alvos) {
|
||||||
|
const p = await tx.promocao.create({
|
||||||
data: {
|
data: {
|
||||||
idEmpresa,
|
idEmpresa,
|
||||||
descricao: body.descricao,
|
descricao: body.descricao,
|
||||||
codProduto: body.codProduto ?? null,
|
codProduto: alvo.codProduto,
|
||||||
grpProd: body.grpProd ?? null,
|
grpProd: alvo.grpProd,
|
||||||
descPct: body.descPct,
|
descPct: body.descPct,
|
||||||
dataInicio: new Date(body.dataInicio),
|
dataInicio: new Date(body.dataInicio),
|
||||||
dataFim: new Date(body.dataFim),
|
dataFim: new Date(body.dataFim),
|
||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { id: p.id };
|
created.push(p.id);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
return { ids };
|
||||||
}
|
}
|
||||||
|
|
||||||
async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> {
|
async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> {
|
||||||
@@ -256,19 +272,30 @@ export class PoliticasService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> {
|
// Cria uma regra POR ALVO: cada grupo selecionado vira regra por grupo e
|
||||||
|
// cada subgrupo vira regra por subgrupo. Sem alvos, regra vale para todos.
|
||||||
|
async createRegra(body: CreateRegraDescontoBody): Promise<{ ids: number[] }> {
|
||||||
this.assertManager();
|
this.assertManager();
|
||||||
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 userId = this.cls.get('userId') ?? 'system';
|
const userId = this.cls.get('userId') ?? 'system';
|
||||||
|
|
||||||
const r = await prisma.regraDesconto.create({
|
const alvos: { codGrupo: number | null; codSubgrupo: number | null }[] = [
|
||||||
|
...(body.codGrupos ?? []).map((g) => ({ codGrupo: g, codSubgrupo: null })),
|
||||||
|
...(body.codSubgrupos ?? []).map((s) => ({ codGrupo: null, codSubgrupo: s })),
|
||||||
|
];
|
||||||
|
if (alvos.length === 0) alvos.push({ codGrupo: null, codSubgrupo: null });
|
||||||
|
|
||||||
|
const ids = await prisma.$transaction(async (tx) => {
|
||||||
|
const created: number[] = [];
|
||||||
|
for (const alvo of alvos) {
|
||||||
|
const r = await tx.regraDesconto.create({
|
||||||
data: {
|
data: {
|
||||||
idEmpresa,
|
idEmpresa,
|
||||||
descricao: body.descricao,
|
descricao: body.descricao,
|
||||||
codGrupo: body.codGrupo ?? null,
|
codGrupo: alvo.codGrupo,
|
||||||
codSubgrupo: body.codSubgrupo ?? null,
|
codSubgrupo: alvo.codSubgrupo,
|
||||||
codVendedor: body.codVendedor ?? null,
|
codVendedor: body.codVendedor ?? null,
|
||||||
descPct: body.descPct,
|
descPct: body.descPct,
|
||||||
dataInicio: new Date(body.dataInicio),
|
dataInicio: new Date(body.dataInicio),
|
||||||
@@ -276,7 +303,11 @@ export class PoliticasService {
|
|||||||
createdBy: userId,
|
createdBy: userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return { id: r.id };
|
created.push(r.id);
|
||||||
|
}
|
||||||
|
return created;
|
||||||
|
});
|
||||||
|
return { ids };
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise<void> {
|
async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise<void> {
|
||||||
@@ -315,6 +346,25 @@ export class PoliticasService {
|
|||||||
await prisma.regraDesconto.delete({ where: { id } });
|
await prisma.regraDesconto.delete({ where: { id } });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alçada do próprio usuário logado — o carrinho usa para mostrar o teto de
|
||||||
|
// desconto por item (codGrupo 0 = default; sem linha 0, o backend assume 5%).
|
||||||
|
async minhaAlcada(): Promise<AlcadaRepResponse> {
|
||||||
|
const prisma = this.cls.get('prisma');
|
||||||
|
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||||
|
const idEmpresa = this.cls.get('idEmpresa');
|
||||||
|
const codVendedor = parseInt(this.cls.get('userId') ?? '0', 10);
|
||||||
|
|
||||||
|
const rows = await prisma.alcadaDesconto.findMany({
|
||||||
|
where: { codVendedor, idEmpresa },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
limites: rows.map((r) => ({
|
||||||
|
codGrupo: r.codGrupo,
|
||||||
|
limitePerc: Number(r.limitePerc),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
|
// Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
|
||||||
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
|
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
|
||||||
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||||
|
|||||||
157
apps/web/src/cockpits/ger/GerConfigPage.tsx
Normal file
157
apps/web/src/cockpits/ger/GerConfigPage.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Button, Card, Flex, Popconfirm, Skeleton, Typography, Upload, message } from 'antd';
|
||||||
|
import type { UploadProps } from 'antd';
|
||||||
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
|
import { faImage, faTrash, faUpload } from '@fortawesome/free-solid-svg-icons';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { useCompany } from '../../lib/queries/company';
|
||||||
|
import { apiFetch } from '../../lib/api-client';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const MAX_LOGO_BYTES = 500 * 1024; // ~500KB — logo de cabeçalho não precisa mais
|
||||||
|
|
||||||
|
function fileToDataUrl(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => resolve(String(reader.result));
|
||||||
|
reader.onerror = () => reject(new Error('Falha ao ler o arquivo'));
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GerConfigPage() {
|
||||||
|
const { data: empresa, isLoading } = useCompany();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const [msg, msgCtx] = message.useMessage();
|
||||||
|
const [uploading, setUploading] = useState(false);
|
||||||
|
|
||||||
|
const updateLogo = useMutation({
|
||||||
|
mutationFn: (logoBase64: string | null) =>
|
||||||
|
apiFetch('/catalog/company/logo', { method: 'PUT', body: { logoBase64 } }),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['company'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const beforeUpload: UploadProps['beforeUpload'] = async (file) => {
|
||||||
|
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
|
||||||
|
void msg.error('Use uma imagem PNG, JPG ou WebP');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
if (file.size > MAX_LOGO_BYTES) {
|
||||||
|
void msg.error('Imagem muito grande — use um arquivo de até 500KB');
|
||||||
|
return Upload.LIST_IGNORE;
|
||||||
|
}
|
||||||
|
setUploading(true);
|
||||||
|
try {
|
||||||
|
const dataUrl = await fileToDataUrl(file);
|
||||||
|
await updateLogo.mutateAsync(dataUrl);
|
||||||
|
void msg.success('Logo atualizado — já vale para as impressões de todos os representantes');
|
||||||
|
} catch {
|
||||||
|
// erro já notificado pelo handler global do QueryClient
|
||||||
|
} finally {
|
||||||
|
setUploading(false);
|
||||||
|
}
|
||||||
|
return Upload.LIST_IGNORE; // upload manual — nada vai para action padrão
|
||||||
|
};
|
||||||
|
|
||||||
|
async function removeLogo() {
|
||||||
|
try {
|
||||||
|
await updateLogo.mutateAsync(null);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void msg.success('Logo removido');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={24} style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||||
|
{msgCtx}
|
||||||
|
<Flex vertical gap={4}>
|
||||||
|
<Title level={2} style={{ margin: 0 }}>
|
||||||
|
Configurações
|
||||||
|
</Title>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-lg)' }}>
|
||||||
|
Preferências da empresa aplicadas a todos os representantes
|
||||||
|
</Text>
|
||||||
|
</Flex>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
title={
|
||||||
|
<Flex align="center" gap={8}>
|
||||||
|
<FontAwesomeIcon icon={faImage} style={{ color: 'var(--jcs-blue)' }} />
|
||||||
|
Logo da Empresa
|
||||||
|
</Flex>
|
||||||
|
}
|
||||||
|
style={{ maxWidth: 640 }}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<Skeleton active paragraph={{ rows: 3 }} />
|
||||||
|
) : (
|
||||||
|
<Flex vertical gap={16}>
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-sm)' }}>
|
||||||
|
Aparece no cabeçalho da impressão do pedido (PDF) de todos os representantes da{' '}
|
||||||
|
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? 'empresa'}. PNG com fundo
|
||||||
|
transparente fica melhor. Máx. 500KB.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Flex align="center" gap={24} wrap="wrap">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
width: 220,
|
||||||
|
height: 90,
|
||||||
|
border: '1px dashed #CBD5E1',
|
||||||
|
borderRadius: 8,
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
background: '#F8FAFC',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{empresa?.logoBase64 ? (
|
||||||
|
<img
|
||||||
|
src={empresa.logoBase64}
|
||||||
|
alt="Logo da empresa"
|
||||||
|
style={{ maxWidth: '100%', maxHeight: '100%', objectFit: 'contain' }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
|
Nenhum logo cadastrado
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Flex vertical gap={8}>
|
||||||
|
<Upload
|
||||||
|
accept="image/png,image/jpeg,image/webp"
|
||||||
|
showUploadList={false}
|
||||||
|
beforeUpload={beforeUpload}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<FontAwesomeIcon icon={faUpload} />}
|
||||||
|
loading={uploading || updateLogo.isPending}
|
||||||
|
>
|
||||||
|
{empresa?.logoBase64 ? 'Trocar logo' : 'Enviar logo'}
|
||||||
|
</Button>
|
||||||
|
</Upload>
|
||||||
|
{empresa?.logoBase64 && (
|
||||||
|
<Popconfirm
|
||||||
|
title="Remover o logo?"
|
||||||
|
okText="Sim"
|
||||||
|
cancelText="Nao"
|
||||||
|
onConfirm={() => void removeLogo()}
|
||||||
|
>
|
||||||
|
<Button danger icon={<FontAwesomeIcon icon={faTrash} />}>
|
||||||
|
Remover
|
||||||
|
</Button>
|
||||||
|
</Popconfirm>
|
||||||
|
)}
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
</Flex>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -11,7 +12,9 @@ import {
|
|||||||
Skeleton,
|
Skeleton,
|
||||||
Space,
|
Space,
|
||||||
Table,
|
Table,
|
||||||
|
Tag,
|
||||||
Typography,
|
Typography,
|
||||||
|
message,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
import { useNavigate } from '@tanstack/react-router';
|
import { useNavigate } from '@tanstack/react-router';
|
||||||
import type { TableColumnsType } from 'antd';
|
import type { TableColumnsType } from 'antd';
|
||||||
@@ -42,6 +45,7 @@ import dayjs from 'dayjs';
|
|||||||
import type { Dayjs } from 'dayjs';
|
import type { Dayjs } from 'dayjs';
|
||||||
import type { RankingRep, PositivacaoRep, PositivacaoDia, 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';
|
||||||
|
import { apiFetch } from '../../lib/api-client';
|
||||||
|
|
||||||
ChartJS.register(
|
ChartJS.register(
|
||||||
CategoryScale,
|
CategoryScale,
|
||||||
@@ -55,9 +59,6 @@ ChartJS.register(
|
|||||||
|
|
||||||
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' });
|
||||||
}
|
}
|
||||||
@@ -84,6 +85,21 @@ const positivacaoColumns: TableColumnsType<PositivacaoRep> = [
|
|||||||
</Text>
|
</Text>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Novos no mês',
|
||||||
|
key: 'novos',
|
||||||
|
width: 120,
|
||||||
|
align: 'center' as const,
|
||||||
|
sorter: (a: PositivacaoRep, b: PositivacaoRep) => a.novosClientes - b.novosClientes,
|
||||||
|
render: (_: unknown, row: PositivacaoRep) =>
|
||||||
|
row.novosClientes > 0 ? (
|
||||||
|
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
|
||||||
|
+{row.novosClientes}
|
||||||
|
</Tag>
|
||||||
|
) : (
|
||||||
|
<Text type="secondary">—</Text>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '% Positivação',
|
title: '% Positivação',
|
||||||
dataIndex: 'pctPositivacao',
|
dataIndex: 'pctPositivacao',
|
||||||
@@ -213,25 +229,31 @@ const rankingColumns: TableColumnsType<RankingRep> = [
|
|||||||
|
|
||||||
function PositivacaoDiariaCard({
|
function PositivacaoDiariaCard({
|
||||||
dados,
|
dados,
|
||||||
|
metaSalva,
|
||||||
periodo,
|
periodo,
|
||||||
periodoLabel,
|
periodoLabel,
|
||||||
isMesAtual,
|
isMesAtual,
|
||||||
}: {
|
}: {
|
||||||
dados: PositivacaoDia[];
|
dados: PositivacaoDia[];
|
||||||
|
metaSalva: number;
|
||||||
periodo: Dayjs;
|
periodo: Dayjs;
|
||||||
periodoLabel: string;
|
periodoLabel: string;
|
||||||
isMesAtual: boolean;
|
isMesAtual: boolean;
|
||||||
}) {
|
}) {
|
||||||
const [meta, setMeta] = useState<number>(() => {
|
// Meta salva no servidor (config_empresa) — vale em qualquer dispositivo
|
||||||
const saved = Number(localStorage.getItem(META_POSITIVACAO_DIA_KEY));
|
const [meta, setMeta] = useState<number>(metaSalva);
|
||||||
return Number.isFinite(saved) && saved > 0 ? saved : 0;
|
useEffect(() => setMeta(metaSalva), [metaSalva]);
|
||||||
});
|
const qc = useQueryClient();
|
||||||
|
const [msg, msgCtx] = message.useMessage();
|
||||||
|
|
||||||
function changeMeta(v: number | null) {
|
const salvarMeta = useMutation({
|
||||||
const val = v ?? 0;
|
mutationFn: (m: number) =>
|
||||||
setMeta(val);
|
apiFetch('/dashboard/manager/meta-positivacao-dia', { method: 'PUT', body: { meta: m } }),
|
||||||
localStorage.setItem(META_POSITIVACAO_DIA_KEY, String(val));
|
onSuccess: () => {
|
||||||
}
|
void qc.invalidateQueries({ queryKey: ['dashboard', 'manager'] });
|
||||||
|
void msg.success('Meta salva');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Eixo com todos os dias: até hoje no mês atual, mês inteiro nos anteriores
|
// 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 ultimoDia = isMesAtual ? dayjs().date() : periodo.endOf('month').date();
|
||||||
@@ -318,13 +340,23 @@ function PositivacaoDiariaCard({
|
|||||||
min={0}
|
min={0}
|
||||||
size="small"
|
size="small"
|
||||||
value={meta > 0 ? meta : undefined}
|
value={meta > 0 ? meta : undefined}
|
||||||
onChange={changeMeta}
|
onChange={(v) => setMeta(v ?? 0)}
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
style={{ width: 80 }}
|
style={{ width: 80 }}
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
disabled={meta === metaSalva}
|
||||||
|
loading={salvarMeta.isPending}
|
||||||
|
onClick={() => salvarMeta.mutate(meta)}
|
||||||
|
>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
{msgCtx}
|
||||||
<div style={{ height: 280 }}>
|
<div style={{ height: 280 }}>
|
||||||
<Chart type="bar" data={chartData} options={options} />
|
<Chart type="bar" data={chartData} options={options} />
|
||||||
</div>
|
</div>
|
||||||
@@ -629,6 +661,7 @@ export function GerPainel() {
|
|||||||
{/* Positivação Diária */}
|
{/* Positivação Diária */}
|
||||||
<PositivacaoDiariaCard
|
<PositivacaoDiariaCard
|
||||||
dados={positivacaoDiaria}
|
dados={positivacaoDiaria}
|
||||||
|
metaSalva={data.metaPositivacaoDia ?? 0}
|
||||||
periodo={periodo}
|
periodo={periodo}
|
||||||
periodoLabel={periodoLabel}
|
periodoLabel={periodoLabel}
|
||||||
isMesAtual={isMesAtual}
|
isMesAtual={isMesAtual}
|
||||||
@@ -643,9 +676,20 @@ export function GerPainel() {
|
|||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
extra={
|
extra={
|
||||||
|
<Flex align="center" gap={12}>
|
||||||
|
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) > 0 && (
|
||||||
|
<Tag color="green" style={{ margin: 0, fontWeight: 600 }}>
|
||||||
|
+{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0)} novo
|
||||||
|
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) !== 1 ? 's' : ''}{' '}
|
||||||
|
cliente
|
||||||
|
{positivacaoReps.reduce((acc, r) => acc + r.novosClientes, 0) !== 1 ? 's' : ''} no
|
||||||
|
período
|
||||||
|
</Tag>
|
||||||
|
)}
|
||||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
<Text type="secondary" style={{ fontSize: 'var(--text-xs)' }}>
|
||||||
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
|
{positivacaoReps.filter((r) => r.clientesPositivados > 0).length} reps com positivação
|
||||||
</Text>
|
</Text>
|
||||||
|
</Flex>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Table<PositivacaoRep>
|
<Table<PositivacaoRep>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Select,
|
Select,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
Space,
|
Space,
|
||||||
|
Switch,
|
||||||
Table,
|
Table,
|
||||||
Tabs,
|
Tabs,
|
||||||
Tag,
|
Tag,
|
||||||
@@ -44,6 +45,62 @@ import {
|
|||||||
useUpdateRegraDesconto,
|
useUpdateRegraDesconto,
|
||||||
useDeleteRegraDesconto,
|
useDeleteRegraDesconto,
|
||||||
} from '../../lib/queries/gerente';
|
} from '../../lib/queries/gerente';
|
||||||
|
import { useCatalog } from '../../lib/queries/catalog';
|
||||||
|
|
||||||
|
// ─── ProdutoSelect: busca remota por código ou descrição ─────────────────────
|
||||||
|
|
||||||
|
function ProdutoSelect({
|
||||||
|
multiple,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
multiple?: boolean;
|
||||||
|
value?: string[] | string;
|
||||||
|
onChange?: (v: string[] | string) => void;
|
||||||
|
}) {
|
||||||
|
const [q, setQ] = useState('');
|
||||||
|
const { data, isFetching } = useCatalog({ q: q || undefined, limit: 20 });
|
||||||
|
// Guarda os rótulos dos já selecionados — a lista de opções muda a cada busca
|
||||||
|
const [labels, setLabels] = useState<Map<string, string>>(new Map());
|
||||||
|
|
||||||
|
const opts = (data?.data ?? []).map((p) => ({
|
||||||
|
value: p.codigo.trim(),
|
||||||
|
label: `${p.codigo.trim()} — ${p.descricao.trim()}`,
|
||||||
|
}));
|
||||||
|
const selecionados = multiple
|
||||||
|
? ((value as string[] | undefined) ?? [])
|
||||||
|
: value
|
||||||
|
? [value as string]
|
||||||
|
: [];
|
||||||
|
const merged = [...opts];
|
||||||
|
for (const v of selecionados) {
|
||||||
|
if (!merged.some((o) => o.value === v)) merged.push({ value: v, label: labels.get(v) ?? v });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
mode={multiple ? 'multiple' : undefined}
|
||||||
|
showSearch
|
||||||
|
allowClear
|
||||||
|
filterOption={false}
|
||||||
|
onSearch={setQ}
|
||||||
|
loading={isFetching}
|
||||||
|
options={merged}
|
||||||
|
value={value}
|
||||||
|
onChange={(vals) => {
|
||||||
|
setLabels((prev) => {
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const o of opts) next.set(o.value, o.label);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setQ('');
|
||||||
|
onChange?.(vals as string[] | string);
|
||||||
|
}}
|
||||||
|
placeholder="Busque por código ou descrição"
|
||||||
|
notFoundContent={q && !isFetching ? 'Nenhum produto encontrado' : null}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -170,14 +227,18 @@ function TabDescontos() {
|
|||||||
|
|
||||||
interface PromocaoForm {
|
interface PromocaoForm {
|
||||||
descricao: string;
|
descricao: string;
|
||||||
|
codProdutos?: string[];
|
||||||
|
grpProds?: string[];
|
||||||
codProduto?: string;
|
codProduto?: string;
|
||||||
grpProd?: string;
|
grpProd?: string;
|
||||||
descPct: number;
|
descPct: number;
|
||||||
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||||
|
ativa?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabPromocoes() {
|
function TabPromocoes() {
|
||||||
const { data, isLoading } = usePromocoes();
|
const { data, isLoading } = usePromocoes();
|
||||||
|
const { data: gruposData } = useGruposProduto();
|
||||||
const createMutation = useCreatePromocao();
|
const createMutation = useCreatePromocao();
|
||||||
const updateMutation = useUpdatePromocao();
|
const updateMutation = useUpdatePromocao();
|
||||||
const deleteMutation = useDeletePromocao();
|
const deleteMutation = useDeletePromocao();
|
||||||
@@ -186,6 +247,18 @@ function TabPromocoes() {
|
|||||||
const [editTarget, setEditTarget] = useState<Promocao | null>(null);
|
const [editTarget, setEditTarget] = useState<Promocao | null>(null);
|
||||||
const [msg, msgCtx] = message.useMessage();
|
const [msg, msgCtx] = message.useMessage();
|
||||||
|
|
||||||
|
// Grupos com nome+código no rótulo — busca funciona pelos dois
|
||||||
|
const grupoOptions = [
|
||||||
|
...new Map(
|
||||||
|
(gruposData?.grupos ?? [])
|
||||||
|
.filter((g) => g.codGrupo != null)
|
||||||
|
.map((g) => [
|
||||||
|
g.codGrupo,
|
||||||
|
{ value: String(g.codGrupo), label: `${g.codGrupo} — ${g.grupo ?? 'Sem nome'}` },
|
||||||
|
]),
|
||||||
|
).values(),
|
||||||
|
];
|
||||||
|
|
||||||
function openCreate() {
|
function openCreate() {
|
||||||
setEditTarget(null);
|
setEditTarget(null);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
@@ -200,27 +273,49 @@ function TabPromocoes() {
|
|||||||
grpProd: p.grpProd ?? undefined,
|
grpProd: p.grpProd ?? undefined,
|
||||||
descPct: p.descPct,
|
descPct: p.descPct,
|
||||||
periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)],
|
periodo: [dayjs(p.dataInicio), dayjs(p.dataFim)],
|
||||||
|
ativa: p.ativa,
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleAtiva(p: Promocao, ativa: boolean) {
|
||||||
|
try {
|
||||||
|
await updateMutation.mutateAsync({ id: p.id, body: { ativa } });
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
void msg.success(ativa ? 'Promocao ativada' : 'Promocao desativada');
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(values: PromocaoForm) {
|
async function handleSubmit(values: PromocaoForm) {
|
||||||
const [inicio, fim] = values.periodo;
|
const [inicio, fim] = values.periodo;
|
||||||
const body = {
|
try {
|
||||||
|
if (editTarget) {
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
id: editTarget.id,
|
||||||
|
body: {
|
||||||
descricao: values.descricao,
|
descricao: values.descricao,
|
||||||
codProduto: values.codProduto || undefined,
|
codProduto: values.codProduto ?? null,
|
||||||
grpProd: values.grpProd || undefined,
|
grpProd: values.grpProd ?? null,
|
||||||
descPct: values.descPct,
|
descPct: values.descPct,
|
||||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
dataFim: fim.format('YYYY-MM-DD'),
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
};
|
ativa: values.ativa ?? true,
|
||||||
try {
|
},
|
||||||
if (editTarget) {
|
});
|
||||||
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
|
||||||
void msg.success('Promocao atualizada');
|
void msg.success('Promocao atualizada');
|
||||||
} else {
|
} else {
|
||||||
await createMutation.mutateAsync(body);
|
const res = (await createMutation.mutateAsync({
|
||||||
void msg.success('Promocao criada');
|
descricao: values.descricao,
|
||||||
|
codProdutos: values.codProdutos?.length ? values.codProdutos : undefined,
|
||||||
|
grpProds: values.grpProds?.length ? values.grpProds : undefined,
|
||||||
|
descPct: values.descPct,
|
||||||
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
|
})) as { ids: number[] };
|
||||||
|
void msg.success(
|
||||||
|
res.ids.length > 1 ? `${res.ids.length} promocoes criadas` : 'Promocao criada',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
@@ -280,6 +375,19 @@ function TabPromocoes() {
|
|||||||
return <Tag color="green">Ativa</Tag>;
|
return <Tag color="green">Ativa</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Ativa',
|
||||||
|
width: 70,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: unknown, row: Promocao) => (
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={row.ativa}
|
||||||
|
loading={updateMutation.isPending}
|
||||||
|
onChange={(checked) => void toggleAtiva(row, checked)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -337,7 +445,8 @@ function TabPromocoes() {
|
|||||||
onOk={() => void form.validateFields().then(handleSubmit)}
|
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||||
okText={editTarget ? 'Salvar' : 'Criar'}
|
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
width={520}
|
width={760}
|
||||||
|
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
|
||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
@@ -349,14 +458,49 @@ function TabPromocoes() {
|
|||||||
<Input placeholder="Ex.: Promocao de inverno" />
|
<Input placeholder="Ex.: Promocao de inverno" />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{editTarget ? (
|
||||||
<Flex gap={16}>
|
<Flex gap={16}>
|
||||||
<Form.Item name="codProduto" label="Codigo do produto" style={{ flex: 1 }}>
|
<Form.Item name="codProduto" label="Produto" style={{ flex: 1 }}>
|
||||||
<Input placeholder="Opcional" />
|
<ProdutoSelect />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
|
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
|
||||||
<Input placeholder="Opcional" />
|
<Select
|
||||||
|
options={grupoOptions}
|
||||||
|
placeholder="Opcional"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
name="codProdutos"
|
||||||
|
label="Produtos"
|
||||||
|
extra="Busque por código ou descrição e selecione quantos quiser — será criada uma promoção por produto"
|
||||||
|
>
|
||||||
|
<ProdutoSelect multiple />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="grpProds"
|
||||||
|
label="Grupos de produto"
|
||||||
|
extra="Busque por nome ou código — uma promoção por grupo selecionado"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
options={grupoOptions}
|
||||||
|
placeholder="Busque por nome ou código do grupo"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="descPct"
|
name="descPct"
|
||||||
@@ -388,10 +532,13 @@ function TabPromocoes() {
|
|||||||
interface RegraForm {
|
interface RegraForm {
|
||||||
descricao: string;
|
descricao: string;
|
||||||
codVendedor?: number;
|
codVendedor?: number;
|
||||||
|
codGrupos?: number[];
|
||||||
|
codSubgrupos?: number[];
|
||||||
codGrupo?: number;
|
codGrupo?: number;
|
||||||
codSubgrupo?: number;
|
codSubgrupo?: number;
|
||||||
descPct: number;
|
descPct: number;
|
||||||
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||||
|
ativa?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TabRegras() {
|
function TabRegras() {
|
||||||
@@ -406,27 +553,37 @@ function TabRegras() {
|
|||||||
const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null);
|
const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null);
|
||||||
const [msg, msgCtx] = message.useMessage();
|
const [msg, msgCtx] = message.useMessage();
|
||||||
const grupoSelecionado = Form.useWatch('codGrupo', form);
|
const grupoSelecionado = Form.useWatch('codGrupo', form);
|
||||||
|
const gruposSelecionados = Form.useWatch('codGrupos', form);
|
||||||
|
|
||||||
const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
|
const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
|
||||||
value: r.codVendedor,
|
value: r.codVendedor,
|
||||||
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
|
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Rótulos com código + nome — a busca do select encontra pelos dois.
|
||||||
|
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
|
||||||
|
const filtroGrupos = new Set<number>([
|
||||||
|
...(grupoSelecionado != null ? [grupoSelecionado] : []),
|
||||||
|
...(gruposSelecionados ?? []),
|
||||||
|
]);
|
||||||
const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
|
const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
|
||||||
const grupoMap = new Map<number, { value: number; label: string }>();
|
const grupoMap = new Map<number, { value: number; label: string }>();
|
||||||
const subgrupoMap = new Map<number, { value: number; label: string }>();
|
const subgrupoMap = new Map<number, { value: number; label: string }>();
|
||||||
for (const g of grupoRows) {
|
for (const g of grupoRows) {
|
||||||
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) {
|
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) {
|
||||||
grupoMap.set(g.codGrupo, { value: g.codGrupo, label: g.grupo ?? `Grupo ${g.codGrupo}` });
|
grupoMap.set(g.codGrupo, {
|
||||||
|
value: g.codGrupo,
|
||||||
|
label: `${g.codGrupo} — ${g.grupo ?? 'Sem nome'}`,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
g.codSubgrupo != null &&
|
g.codSubgrupo != null &&
|
||||||
(grupoSelecionado == null || g.codGrupo === grupoSelecionado) &&
|
(filtroGrupos.size === 0 || (g.codGrupo != null && filtroGrupos.has(g.codGrupo))) &&
|
||||||
!subgrupoMap.has(g.codSubgrupo)
|
!subgrupoMap.has(g.codSubgrupo)
|
||||||
) {
|
) {
|
||||||
subgrupoMap.set(g.codSubgrupo, {
|
subgrupoMap.set(g.codSubgrupo, {
|
||||||
value: g.codSubgrupo,
|
value: g.codSubgrupo,
|
||||||
label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`,
|
label: `${g.codSubgrupo} — ${g.subgrupo ?? 'Sem nome'}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -448,13 +605,27 @@ function TabRegras() {
|
|||||||
codSubgrupo: r.codSubgrupo ?? undefined,
|
codSubgrupo: r.codSubgrupo ?? undefined,
|
||||||
descPct: r.descPct,
|
descPct: r.descPct,
|
||||||
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
||||||
|
ativa: r.ativa,
|
||||||
});
|
});
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleAtiva(r: RegraDesconto, ativa: boolean) {
|
||||||
|
try {
|
||||||
|
await updateMutation.mutateAsync({ id: r.id, body: { ativa } });
|
||||||
|
} catch {
|
||||||
|
return; // erro já notificado pelo handler global do QueryClient
|
||||||
|
}
|
||||||
|
void msg.success(ativa ? 'Regra ativada' : 'Regra desativada');
|
||||||
|
}
|
||||||
|
|
||||||
async function handleSubmit(values: RegraForm) {
|
async function handleSubmit(values: RegraForm) {
|
||||||
const [inicio, fim] = values.periodo;
|
const [inicio, fim] = values.periodo;
|
||||||
const body = {
|
try {
|
||||||
|
if (editTarget) {
|
||||||
|
await updateMutation.mutateAsync({
|
||||||
|
id: editTarget.id,
|
||||||
|
body: {
|
||||||
descricao: values.descricao,
|
descricao: values.descricao,
|
||||||
codVendedor: values.codVendedor ?? null,
|
codVendedor: values.codVendedor ?? null,
|
||||||
codGrupo: values.codGrupo ?? null,
|
codGrupo: values.codGrupo ?? null,
|
||||||
@@ -462,14 +633,21 @@ function TabRegras() {
|
|||||||
descPct: values.descPct,
|
descPct: values.descPct,
|
||||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
dataFim: fim.format('YYYY-MM-DD'),
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
};
|
ativa: values.ativa ?? true,
|
||||||
try {
|
},
|
||||||
if (editTarget) {
|
});
|
||||||
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
|
||||||
void msg.success('Regra atualizada');
|
void msg.success('Regra atualizada');
|
||||||
} else {
|
} else {
|
||||||
await createMutation.mutateAsync(body);
|
const res = (await createMutation.mutateAsync({
|
||||||
void msg.success('Regra criada');
|
descricao: values.descricao,
|
||||||
|
codVendedor: values.codVendedor ?? null,
|
||||||
|
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
|
||||||
|
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
|
||||||
|
descPct: values.descPct,
|
||||||
|
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||||
|
dataFim: fim.format('YYYY-MM-DD'),
|
||||||
|
})) as { ids: number[] };
|
||||||
|
void msg.success(res.ids.length > 1 ? `${res.ids.length} regras criadas` : 'Regra criada');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||||
@@ -541,6 +719,19 @@ function TabRegras() {
|
|||||||
return <Tag color="green">Ativa</Tag>;
|
return <Tag color="green">Ativa</Tag>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Ativa',
|
||||||
|
width: 70,
|
||||||
|
align: 'center' as const,
|
||||||
|
render: (_: unknown, row: RegraDesconto) => (
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={row.ativa}
|
||||||
|
loading={updateMutation.isPending}
|
||||||
|
onChange={(checked) => void toggleAtiva(row, checked)}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '',
|
title: '',
|
||||||
width: 100,
|
width: 100,
|
||||||
@@ -602,7 +793,8 @@ function TabRegras() {
|
|||||||
onOk={() => void form.validateFields().then(handleSubmit)}
|
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||||
okText={editTarget ? 'Salvar' : 'Criar'}
|
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||||
width={520}
|
width={760}
|
||||||
|
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
|
||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||||
@@ -624,6 +816,7 @@ function TabRegras() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
|
{editTarget ? (
|
||||||
<Flex gap={16}>
|
<Flex gap={16}>
|
||||||
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||||
<Select
|
<Select
|
||||||
@@ -644,7 +837,42 @@ function TabRegras() {
|
|||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
<Form.Item name="ativa" label="Ativa" valuePropName="checked">
|
||||||
|
<Switch />
|
||||||
|
</Form.Item>
|
||||||
</Flex>
|
</Flex>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Form.Item
|
||||||
|
name="codGrupos"
|
||||||
|
label="Grupos de produto"
|
||||||
|
extra="Busque por nome ou código e selecione quantos quiser — uma regra por grupo"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
options={grupoOptions}
|
||||||
|
placeholder="Todos os grupos"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
name="codSubgrupos"
|
||||||
|
label="Subgrupos"
|
||||||
|
extra="Uma regra por subgrupo selecionado (a lista filtra pelos grupos escolhidos)"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
mode="multiple"
|
||||||
|
options={subgrupoOptions}
|
||||||
|
placeholder="Todos os subgrupos"
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
optionFilterProp="label"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name="descPct"
|
name="descPct"
|
||||||
|
|||||||
@@ -161,7 +161,10 @@ export function CatalogPage() {
|
|||||||
}}
|
}}
|
||||||
options={pautas?.map((p) => ({
|
options={pautas?.map((p) => ({
|
||||||
value: p.idPauta,
|
value: p.idPauta,
|
||||||
label: `${p.codigo} — ${p.descricao}`,
|
label:
|
||||||
|
p.qtdReps != null
|
||||||
|
? `${p.codigo} — ${p.descricao} · ${p.qtdReps} rep${p.qtdReps !== 1 ? 's' : ''}`
|
||||||
|
: `${p.codigo} — ${p.descricao}`,
|
||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
|||||||
@@ -29,9 +29,12 @@ import {
|
|||||||
CheckCircleOutlined,
|
CheckCircleOutlined,
|
||||||
CloseOutlined,
|
CloseOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
HistoryOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
ShoppingCartOutlined,
|
ShoppingCartOutlined,
|
||||||
|
UpOutlined,
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useNavigate, useSearch } from '@tanstack/react-router';
|
import { useNavigate, useSearch } from '@tanstack/react-router';
|
||||||
@@ -41,16 +44,16 @@ import type {
|
|||||||
FormaPagamento,
|
FormaPagamento,
|
||||||
Pauta,
|
Pauta,
|
||||||
ProdutoSummary,
|
ProdutoSummary,
|
||||||
RegraVigente,
|
|
||||||
TopProduto,
|
TopProduto,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
|
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
|
||||||
|
import { useOrderDetail } from '../../lib/queries/orders';
|
||||||
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
|
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
|
||||||
import { useRegrasVigentes } from '../../lib/queries/politicas';
|
import { useCondicoesComerciais, useTetoDesconto } from '../../lib/condicoes-comerciais';
|
||||||
import { useCondicoesComerciais } from '../../lib/condicoes-comerciais';
|
|
||||||
import { CondicaoTag } from '../../components/CondicaoTag';
|
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||||
import { apiFetch } from '../../lib/api-client';
|
import { apiFetch } from '../../lib/api-client';
|
||||||
import { enqueueOrder } from '../../lib/offline/order-queue';
|
import { enqueueOrder } from '../../lib/offline/order-queue';
|
||||||
|
import { randomUUID } from '../../lib/uuid';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
const { useBreakpoint } = Grid;
|
const { useBreakpoint } = Grid;
|
||||||
@@ -67,9 +70,12 @@ type CartItem = {
|
|||||||
precoUnitario: number;
|
precoUnitario: number;
|
||||||
descontoPerc: number;
|
descontoPerc: number;
|
||||||
loteMulVenda: number | null;
|
loteMulVenda: number | null;
|
||||||
|
// Grupo/subgrupo do produto — usados para calcular o teto de desconto do item
|
||||||
|
codGrupo: number | null;
|
||||||
|
codSubgrupo: number | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SearchParams = { clientId?: string };
|
type SearchParams = { clientId?: string; editar?: string };
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -630,17 +636,20 @@ function CatalogBrowserModal({
|
|||||||
|
|
||||||
function MobileCartItem({
|
function MobileCartItem({
|
||||||
item,
|
item,
|
||||||
|
teto,
|
||||||
onQtyChange,
|
onQtyChange,
|
||||||
onDiscChange,
|
onDiscChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
item: CartItem;
|
item: CartItem;
|
||||||
|
teto: number;
|
||||||
onQtyChange: (key: string, qty: number) => void;
|
onQtyChange: (key: string, qty: number) => void;
|
||||||
onDiscChange: (key: string, disc: number) => void;
|
onDiscChange: (key: string, disc: number) => void;
|
||||||
onRemove: (key: string) => void;
|
onRemove: (key: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const lote = item.loteMulVenda ?? 1;
|
const lote = item.loteMulVenda ?? 1;
|
||||||
const invalid = lote > 1 && item.qtd % lote !== 0;
|
const invalid = lote > 1 && item.qtd % lote !== 0;
|
||||||
|
const acimaTeto = item.descontoPerc > teto + 0.001;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -712,6 +721,14 @@ function MobileCartItem({
|
|||||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
|
||||||
Desc%
|
Desc%
|
||||||
</Text>
|
</Text>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
acimaTeto
|
||||||
|
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
|
||||||
|
: `Teto do item: ${teto}%`
|
||||||
|
}
|
||||||
|
color={acimaTeto ? 'red' : 'blue'}
|
||||||
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -720,8 +737,13 @@ function MobileCartItem({
|
|||||||
size="small"
|
size="small"
|
||||||
style={{ width: 88 }}
|
style={{ width: 88 }}
|
||||||
addonAfter="%"
|
addonAfter="%"
|
||||||
|
status={acimaTeto ? 'error' : undefined}
|
||||||
onChange={(n) => onDiscChange(item.key, n ?? 0)}
|
onChange={(n) => onDiscChange(item.key, n ?? 0)}
|
||||||
/>
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
<div style={{ fontSize: 10, color: acimaTeto ? '#f5222d' : '#94A3B8', marginTop: 2 }}>
|
||||||
|
{acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ marginLeft: 'auto', textAlign: 'right' }}>
|
<div style={{ marginLeft: 'auto', textAlign: 'right' }}>
|
||||||
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
|
<Text type="secondary" style={{ fontSize: 11, display: 'block', marginBottom: 2 }}>
|
||||||
@@ -749,7 +771,8 @@ function ClientHistoryPanel({
|
|||||||
}) {
|
}) {
|
||||||
const screens = useBreakpoint();
|
const screens = useBreakpoint();
|
||||||
const isMobile = !screens.md;
|
const isMobile = !screens.md;
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
// Sempre fecha ao abrir a tela — o banner chamativo convida o rep a expandir
|
||||||
|
const [collapsed, setCollapsed] = useState(true);
|
||||||
const [qtys, setQtys] = useState<Record<number, number>>({});
|
const [qtys, setQtys] = useState<Record<number, number>>({});
|
||||||
|
|
||||||
const { data: produtos = [], isLoading } = useClientTopProdutos(idCliente, 15);
|
const { data: produtos = [], isLoading } = useClientTopProdutos(idCliente, 15);
|
||||||
@@ -910,40 +933,61 @@ function ClientHistoryPanel({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 16 }}>
|
<div style={{ marginTop: 16 }}>
|
||||||
|
{/* Banner âmbar de destaque — fechado por padrão, convida o rep a abrir */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
marginBottom: 8,
|
gap: 10,
|
||||||
|
padding: '10px 14px',
|
||||||
|
background: '#FFF7E6',
|
||||||
|
border: '1px solid #FFD591',
|
||||||
|
borderRadius: collapsed ? 8 : '8px 8px 0 0',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
onClick={() => setCollapsed((c) => !c)}
|
onClick={() => setCollapsed((c) => !c)}
|
||||||
>
|
>
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, minWidth: 0 }}>
|
||||||
|
<HistoryOutlined style={{ color: '#D46B08', fontSize: 18, flexShrink: 0 }} />
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
<Text
|
<Text
|
||||||
style={{
|
strong
|
||||||
fontSize: 11,
|
style={{ color: '#873800', fontSize: 13, display: 'block', lineHeight: 1.3 }}
|
||||||
fontWeight: 700,
|
|
||||||
letterSpacing: '0.08em',
|
|
||||||
textTransform: 'uppercase',
|
|
||||||
color: '#64748B',
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Comprados anteriormente · {produtos.length} produto{produtos.length !== 1 ? 's' : ''}
|
Este cliente já comprou {produtos.length} produto{produtos.length !== 1 ? 's' : ''}{' '}
|
||||||
|
com você
|
||||||
</Text>
|
</Text>
|
||||||
<Button type="link" size="small" style={{ padding: 0, fontSize: 12 }}>
|
<Text style={{ color: '#AD6800', fontSize: 11 }}>
|
||||||
{collapsed ? 'exibir' : 'ocultar'}
|
Veja o histórico e repita itens no pedido com um clique
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
borderColor: '#FFD591',
|
||||||
|
color: '#D46B08',
|
||||||
|
background: '#fff',
|
||||||
|
fontWeight: 600,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
icon={collapsed ? <DownOutlined /> : <UpOutlined />}
|
||||||
|
iconPosition="end"
|
||||||
|
>
|
||||||
|
{collapsed ? 'Ver produtos' : 'Ocultar'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
border: '1px solid #EBF0F5',
|
border: '1px solid #FFD591',
|
||||||
borderRadius: 8,
|
borderTop: 'none',
|
||||||
|
borderRadius: '0 0 8px 8px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
background: '#FAFBFC',
|
background: '#FFFDF7',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Table<TopProduto>
|
<Table<TopProduto>
|
||||||
@@ -970,11 +1014,13 @@ function ClientHistoryPanel({
|
|||||||
|
|
||||||
function OrderItemsTable({
|
function OrderItemsTable({
|
||||||
items,
|
items,
|
||||||
|
tetoDe,
|
||||||
onQtyChange,
|
onQtyChange,
|
||||||
onDiscChange,
|
onDiscChange,
|
||||||
onRemove,
|
onRemove,
|
||||||
}: {
|
}: {
|
||||||
items: CartItem[];
|
items: CartItem[];
|
||||||
|
tetoDe: (item: CartItem) => number;
|
||||||
onQtyChange: (key: string, qty: number) => void;
|
onQtyChange: (key: string, qty: number) => void;
|
||||||
onDiscChange: (key: string, disc: number) => void;
|
onDiscChange: (key: string, disc: number) => void;
|
||||||
onRemove: (key: string) => void;
|
onRemove: (key: string) => void;
|
||||||
@@ -1015,6 +1061,7 @@ function OrderItemsTable({
|
|||||||
<MobileCartItem
|
<MobileCartItem
|
||||||
key={item.key}
|
key={item.key}
|
||||||
item={item}
|
item={item}
|
||||||
|
teto={tetoDe(item)}
|
||||||
onQtyChange={onQtyChange}
|
onQtyChange={onQtyChange}
|
||||||
onDiscChange={onDiscChange}
|
onDiscChange={onDiscChange}
|
||||||
onRemove={onRemove}
|
onRemove={onRemove}
|
||||||
@@ -1091,8 +1138,20 @@ function OrderItemsTable({
|
|||||||
{
|
{
|
||||||
title: 'Desc %',
|
title: 'Desc %',
|
||||||
dataIndex: 'descontoPerc',
|
dataIndex: 'descontoPerc',
|
||||||
width: 100,
|
width: 110,
|
||||||
render: (v: number, row: CartItem) => (
|
render: (v: number, row: CartItem) => {
|
||||||
|
const teto = tetoDe(row);
|
||||||
|
const acimaTeto = v > teto + 0.001;
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Tooltip
|
||||||
|
title={
|
||||||
|
acimaTeto
|
||||||
|
? `Acima do teto de ${teto}% — a transmissão será bloqueada`
|
||||||
|
: `Teto do item: ${teto}%`
|
||||||
|
}
|
||||||
|
color={acimaTeto ? 'red' : 'blue'}
|
||||||
|
>
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
@@ -1101,9 +1160,16 @@ function OrderItemsTable({
|
|||||||
size="small"
|
size="small"
|
||||||
style={{ width: 76 }}
|
style={{ width: 76 }}
|
||||||
addonAfter="%"
|
addonAfter="%"
|
||||||
|
status={acimaTeto ? 'error' : undefined}
|
||||||
onChange={(n) => onDiscChange(row.key, n ?? 0)}
|
onChange={(n) => onDiscChange(row.key, n ?? 0)}
|
||||||
/>
|
/>
|
||||||
),
|
</Tooltip>
|
||||||
|
<div style={{ fontSize: 10, color: acimaTeto ? '#f5222d' : '#94A3B8', marginTop: 2 }}>
|
||||||
|
{acimaTeto ? `acima do teto ${teto}%` : `teto: ${teto}%`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Total',
|
title: 'Total',
|
||||||
@@ -1251,11 +1317,16 @@ function OrderSummaryFooter({
|
|||||||
// ─── NewOrderPage ─────────────────────────────────────────────────────────────
|
// ─── NewOrderPage ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export function NewOrderPage() {
|
export function NewOrderPage() {
|
||||||
const { clientId: clientIdParam } = useSearch({ strict: false }) as SearchParams;
|
const { clientId: clientIdParam, editar: editarId } = useSearch({
|
||||||
|
strict: false,
|
||||||
|
}) as SearchParams;
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { message } = App.useApp();
|
const { message } = App.useApp();
|
||||||
|
|
||||||
|
// ── Modo edição: orçamento (situa 0) carregado para alteração ──
|
||||||
|
const { data: editOrder } = useOrderDetail(editarId);
|
||||||
|
|
||||||
// ── Dados do cliente ──
|
// ── Dados do cliente ──
|
||||||
const [clientSearch, setClientSearch] = useState('');
|
const [clientSearch, setClientSearch] = useState('');
|
||||||
const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null);
|
const [selectedClient, setSelectedClient] = useState<ClientSummary | null>(null);
|
||||||
@@ -1267,8 +1338,11 @@ export function NewOrderPage() {
|
|||||||
const { data: selectedClientDetail } = useClientDetail(
|
const { data: selectedClientDetail } = useClientDetail(
|
||||||
!clientIdParam && selectedClient ? selectedClient.idCliente : undefined,
|
!clientIdParam && selectedClient ? selectedClient.idCliente : undefined,
|
||||||
);
|
);
|
||||||
const clientDetail = selectedClientDetail ?? preloadedClient ?? null;
|
const { data: editClient } = useClientDetail(
|
||||||
const effectiveClient = selectedClient ?? preloadedClient ?? null;
|
editarId && editOrder ? editOrder.idCliente : undefined,
|
||||||
|
);
|
||||||
|
const clientDetail = selectedClientDetail ?? preloadedClient ?? editClient ?? null;
|
||||||
|
const effectiveClient = selectedClient ?? preloadedClient ?? editClient ?? null;
|
||||||
|
|
||||||
// ── Campos comerciais ──
|
// ── Campos comerciais ──
|
||||||
const { data: pautas = [] } = usePautas();
|
const { data: pautas = [] } = usePautas();
|
||||||
@@ -1293,6 +1367,39 @@ export function NewOrderPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [catalogOpen, setCatalogOpen] = useState(false);
|
const [catalogOpen, setCatalogOpen] = useState(false);
|
||||||
|
|
||||||
|
// Pré-carrega o formulário quando entra em modo edição (uma vez só)
|
||||||
|
const [editLoaded, setEditLoaded] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!editarId || !editOrder || editLoaded) return;
|
||||||
|
setEditLoaded(true);
|
||||||
|
if (editOrder.situa !== 0) {
|
||||||
|
setError('Este pedido já foi transmitido e não pode mais ser editado.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCart(
|
||||||
|
editOrder.itens.map((it) => ({
|
||||||
|
key: String(it.idProduto),
|
||||||
|
idProduto: it.idProduto,
|
||||||
|
codProduto: it.codProduto ?? '',
|
||||||
|
descProduto: it.descProduto ?? '',
|
||||||
|
unidade: '',
|
||||||
|
qtd: Number(it.qtd),
|
||||||
|
precoUnitario: Number(it.precoUnitario),
|
||||||
|
descontoPerc: Number(it.descontoPerc),
|
||||||
|
loteMulVenda: null,
|
||||||
|
codGrupo: it.codGrupo ?? null,
|
||||||
|
codSubgrupo: it.codSubgrupo ?? null,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
setIdPauta(editOrder.idPauta ?? undefined);
|
||||||
|
setCodFormapag(editOrder.codFormapag ?? undefined);
|
||||||
|
setObs(editOrder.obs ?? '');
|
||||||
|
if (editOrder.endEntrega) {
|
||||||
|
setEndTipo('outro');
|
||||||
|
setEndCustom(editOrder.endEntrega);
|
||||||
|
}
|
||||||
|
}, [editarId, editOrder, editLoaded]);
|
||||||
|
|
||||||
// Endereço cadastrado do cliente — usa Detail (tem endereco/bairro/cep), não Summary.
|
// Endereço cadastrado do cliente — usa Detail (tem endereco/bairro/cep), não Summary.
|
||||||
const clientEndStr = (() => {
|
const clientEndStr = (() => {
|
||||||
if (!clientDetail) return '';
|
if (!clientDetail) return '';
|
||||||
@@ -1306,18 +1413,29 @@ export function NewOrderPage() {
|
|||||||
})();
|
})();
|
||||||
const hasClientEnd = !!clientEndStr;
|
const hasClientEnd = !!clientEndStr;
|
||||||
|
|
||||||
|
// Teto de desconto por item (alçada + promoção/regra) — aviso em tempo real
|
||||||
|
const tetoDesconto = useTetoDesconto();
|
||||||
|
const tetoDe = (it: CartItem) =>
|
||||||
|
tetoDesconto({ codigo: it.codProduto, codGrupo: it.codGrupo, codSubgrupo: it.codSubgrupo });
|
||||||
|
|
||||||
const totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0);
|
const totalPedido = cart.reduce((acc, it) => acc + itemTotal(it), 0);
|
||||||
const loteErrors = cart.filter((it) => {
|
const loteErrors = cart.filter((it) => {
|
||||||
const lote = it.loteMulVenda ?? 1;
|
const lote = it.loteMulVenda ?? 1;
|
||||||
return lote > 1 && it.qtd % lote !== 0;
|
return lote > 1 && it.qtd % lote !== 0;
|
||||||
});
|
});
|
||||||
const canSubmit = !!effectiveClient && cart.length > 0 && loteErrors.length === 0;
|
// Desconto acima do teto bloqueia o Concluir — o backend recusa a criação
|
||||||
|
// do orçamento de qualquer forma; aqui a mensagem chega antes do clique.
|
||||||
|
const tetoErrors = cart.filter((it) => it.descontoPerc > tetoDe(it) + 0.001);
|
||||||
|
const canSubmit =
|
||||||
|
!!effectiveClient && cart.length > 0 && loteErrors.length === 0 && tetoErrors.length === 0;
|
||||||
const missingReason = !effectiveClient
|
const missingReason = !effectiveClient
|
||||||
? 'Selecione um cliente para continuar'
|
? 'Selecione um cliente para continuar'
|
||||||
: cart.length === 0
|
: cart.length === 0
|
||||||
? 'Adicione ao menos um produto ao pedido'
|
? 'Adicione ao menos um produto ao pedido'
|
||||||
: loteErrors.length > 0
|
: loteErrors.length > 0
|
||||||
? 'Corrija as quantidades com lote múltiplo'
|
? 'Corrija as quantidades com lote múltiplo'
|
||||||
|
: tetoErrors.length > 0
|
||||||
|
? `Desconto acima do teto em: ${tetoErrors.map((it) => it.codProduto.trim()).join(', ')}`
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
function clearClient() {
|
function clearClient() {
|
||||||
@@ -1325,24 +1443,10 @@ export function NewOrderPage() {
|
|||||||
setClientSearch('');
|
setClientSearch('');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Regras de desconto vigentes (criadas pelo gerente para este rep) ──
|
|
||||||
const { data: regrasData } = useRegrasVigentes();
|
|
||||||
|
|
||||||
// Maior desconto liberado por regra vigente que casa com o grupo/subgrupo do
|
|
||||||
// produto. Regra sem grupo/subgrupo vale para qualquer produto. Pré-aplicado
|
|
||||||
// ao adicionar o item; o rep pode ajustar dentro da alçada.
|
|
||||||
const regraDescontoPct = (codGrupo: number | null, codSubgrupo: number | null): number => {
|
|
||||||
const regras: RegraVigente[] = regrasData?.regras ?? [];
|
|
||||||
return 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, r.descPct), 0);
|
|
||||||
};
|
|
||||||
|
|
||||||
// ── Handlers do carrinho ──
|
// ── Handlers do carrinho ──
|
||||||
|
// Item entra SEM desconto mesmo quando há promoção/regra vigente (selo
|
||||||
|
// "Condição comercial" avisa o rep): a proposta é carta de negociação — o rep
|
||||||
|
// concede só se o cliente pedir, e a alçada no backend aceita até o % da regra.
|
||||||
const addToCart = (p: ProdutoSummary, qty?: number) => {
|
const addToCart = (p: ProdutoSummary, qty?: number) => {
|
||||||
const lote = p.loteMulVenda ?? 1;
|
const lote = p.loteMulVenda ?? 1;
|
||||||
const finalQty = qty ?? lote;
|
const finalQty = qty ?? lote;
|
||||||
@@ -1363,8 +1467,10 @@ export function NewOrderPage() {
|
|||||||
unidade: p.unidade ?? '',
|
unidade: p.unidade ?? '',
|
||||||
qtd: finalQty,
|
qtd: finalQty,
|
||||||
precoUnitario: Number(p.vlPreco1),
|
precoUnitario: Number(p.vlPreco1),
|
||||||
descontoPerc: regraDescontoPct(p.codGrupo, p.codSubgrupo),
|
descontoPerc: 0,
|
||||||
loteMulVenda: p.loteMulVenda,
|
loteMulVenda: p.loteMulVenda,
|
||||||
|
codGrupo: p.codGrupo,
|
||||||
|
codSubgrupo: p.codSubgrupo,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -1386,9 +1492,11 @@ export function NewOrderPage() {
|
|||||||
unidade: p.unidade ?? '',
|
unidade: p.unidade ?? '',
|
||||||
qtd: qty,
|
qtd: qty,
|
||||||
precoUnitario: p.ultimoPreco,
|
precoUnitario: p.ultimoPreco,
|
||||||
// TopProduto não traz grupo/subgrupo — aplica só regra sem restrição
|
descontoPerc: 0,
|
||||||
descontoPerc: regraDescontoPct(null, null),
|
|
||||||
loteMulVenda: null,
|
loteMulVenda: null,
|
||||||
|
// TopProduto não traz grupo/subgrupo — teto considera só alçada default
|
||||||
|
codGrupo: null,
|
||||||
|
codSubgrupo: null,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
@@ -1416,6 +1524,13 @@ export function NewOrderPage() {
|
|||||||
`Quantidade inválida: ${invalidos.map((it) => `${it.codProduto.trim()} (lote múltiplo: ${it.loteMulVenda})`).join(', ')}`,
|
`Quantidade inválida: ${invalidos.map((it) => `${it.codProduto.trim()} (lote múltiplo: ${it.loteMulVenda})`).join(', ')}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
// Cobre também o caminho offline (enqueueOrder não passa pelo backend)
|
||||||
|
const acimaTeto = cart.filter((it) => it.descontoPerc > tetoDe(it) + 0.001);
|
||||||
|
if (acimaTeto.length > 0) {
|
||||||
|
throw new Error(
|
||||||
|
`Desconto acima do teto: ${acimaTeto.map((it) => `${it.codProduto.trim()} (máx. ${tetoDe(it)}%)`).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const obsCompleta = [
|
const obsCompleta = [
|
||||||
contato ? `Contato: ${contato}` : null,
|
contato ? `Contato: ${contato}` : null,
|
||||||
@@ -1435,7 +1550,7 @@ export function NewOrderPage() {
|
|||||||
descontoPerc: 0,
|
descontoPerc: 0,
|
||||||
obs: obsCompleta || undefined,
|
obs: obsCompleta || undefined,
|
||||||
endEntrega: endEntregaValue,
|
endEntrega: endEntregaValue,
|
||||||
idempotencyKey: crypto.randomUUID(),
|
idempotencyKey: editarId ? undefined : randomUUID(),
|
||||||
itens: cart.map((it, idx) => ({
|
itens: cart.map((it, idx) => ({
|
||||||
idProduto: it.idProduto,
|
idProduto: it.idProduto,
|
||||||
codProduto: it.codProduto,
|
codProduto: it.codProduto,
|
||||||
@@ -1447,6 +1562,16 @@ export function NewOrderPage() {
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Edição altera o orçamento existente — requer conexão
|
||||||
|
if (editarId) {
|
||||||
|
if (!navigator.onLine) {
|
||||||
|
throw new Error('Edição de orçamento requer conexão. Tente novamente online.');
|
||||||
|
}
|
||||||
|
return apiFetch(`/orders/${editarId}`, { method: 'PATCH', body }) as Promise<{
|
||||||
|
id: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
if (!navigator.onLine) {
|
if (!navigator.onLine) {
|
||||||
await enqueueOrder(body, effectiveClient.nome);
|
await enqueueOrder(body, effectiveClient.nome);
|
||||||
window.dispatchEvent(new CustomEvent('sar:order-queued'));
|
window.dispatchEvent(new CustomEvent('sar:order-queued'));
|
||||||
@@ -1458,6 +1583,11 @@ export function NewOrderPage() {
|
|||||||
onSuccess: (created) => {
|
onSuccess: (created) => {
|
||||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
void qc.invalidateQueries({ queryKey: ['clients'] });
|
void qc.invalidateQueries({ queryKey: ['clients'] });
|
||||||
|
if (editarId && created) {
|
||||||
|
message.success('Orçamento atualizado');
|
||||||
|
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!created) {
|
if (!created) {
|
||||||
setError(null);
|
setError(null);
|
||||||
setCart([]);
|
setCart([]);
|
||||||
@@ -1475,7 +1605,7 @@ export function NewOrderPage() {
|
|||||||
}
|
}
|
||||||
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
|
void navigate({ to: '/pedidos/$id', params: { id: created.id } });
|
||||||
},
|
},
|
||||||
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao criar pedido'),
|
onError: (e: unknown) => setError(e instanceof Error ? e.message : 'Erro ao salvar pedido'),
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1490,7 +1620,9 @@ export function NewOrderPage() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
<Title level={3} style={{ margin: 0, color: '#003B8E' }}>
|
||||||
Lançamento de Pedido
|
{editarId
|
||||||
|
? `Editar Orçamento${editOrder ? ` ${editOrder.numPedSar}` : ''}`
|
||||||
|
: 'Lançamento de Pedido'}
|
||||||
</Title>
|
</Title>
|
||||||
<Button
|
<Button
|
||||||
icon={<ArrowLeftOutlined />}
|
icon={<ArrowLeftOutlined />}
|
||||||
@@ -1700,6 +1832,7 @@ export function NewOrderPage() {
|
|||||||
|
|
||||||
<OrderItemsTable
|
<OrderItemsTable
|
||||||
items={cart}
|
items={cart}
|
||||||
|
tetoDe={tetoDe}
|
||||||
onQtyChange={setQty}
|
onQtyChange={setQty}
|
||||||
onDiscChange={setDisc}
|
onDiscChange={setDisc}
|
||||||
onRemove={removeItem}
|
onRemove={removeItem}
|
||||||
|
|||||||
@@ -145,60 +145,71 @@ export function OrderPrintPage() {
|
|||||||
color: INK,
|
color: INK,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* ── Cabeçalho: empresa matriz que fatura ───────────────────────── */}
|
{/* ── Cabeçalho compacto: logo + empresa matriz + nº do pedido ────── */}
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
alignItems: 'flex-start',
|
alignItems: 'center',
|
||||||
padding: '22px 28px',
|
gap: 16,
|
||||||
borderTop: `5px solid ${BLUE}`,
|
padding: '12px 28px',
|
||||||
background: 'linear-gradient(180deg,#F8FAFD 0%,#fff 100%)',
|
borderTop: `4px solid ${BLUE}`,
|
||||||
borderBottom: `1px solid ${LINE}`,
|
borderBottom: `2px solid ${BLUE}`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ maxWidth: 460 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
|
||||||
<div style={{ fontSize: 19, fontWeight: 800, color: BLUE, lineHeight: 1.1 }}>
|
{empresa?.logoBase64 && (
|
||||||
|
<img
|
||||||
|
src={empresa.logoBase64}
|
||||||
|
alt="Logo"
|
||||||
|
style={{ maxHeight: 52, maxWidth: 140, objectFit: 'contain', flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div style={{ minWidth: 0 }}>
|
||||||
|
<div style={{ fontSize: 15, fontWeight: 800, color: BLUE, lineHeight: 1.15 }}>
|
||||||
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
|
{empresa?.nomeFantasia ?? empresa?.razaoSocial ?? '...'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 11, color: MUTED, marginTop: 2 }}>{empresa?.razaoSocial}</div>
|
<div style={{ fontSize: 8.5, color: MUTED, marginTop: 2, lineHeight: 1.45 }}>
|
||||||
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6, lineHeight: 1.5 }}>
|
{empresa?.razaoSocial && empresa.razaoSocial !== empresa.nomeFantasia && (
|
||||||
|
<>{empresa.razaoSocial} · </>
|
||||||
|
)}
|
||||||
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
|
{empresa?.cnpj && <>CNPJ {empresa.cnpj}</>}
|
||||||
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
|
{empresa?.inscricaoEstadual && <> · IE {empresa.inscricaoEstadual}</>}
|
||||||
{enderecoEmp && <div>{enderecoEmp}</div>}
|
<br />
|
||||||
{cidadeEmp && <div>{cidadeEmp}</div>}
|
{[enderecoEmp, cidadeEmp].filter(Boolean).join(' · ')}
|
||||||
{(empresa?.telefone || empresa?.email) && (
|
{(empresa?.telefone || empresa?.email) && (
|
||||||
<div>
|
<>
|
||||||
|
<br />
|
||||||
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
|
{empresa?.telefone && <>Tel {phone(empresa.telefone)}</>}
|
||||||
{empresa?.telefone && empresa?.email && <> · </>}
|
{empresa?.telefone && empresa?.email && <> · </>}
|
||||||
{empresa?.email}
|
{empresa?.email}
|
||||||
</div>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ textAlign: 'right' }}>
|
</div>
|
||||||
<div style={{ fontSize: 10, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
|
<div style={{ textAlign: 'right', flexShrink: 0 }}>
|
||||||
|
<div style={{ fontSize: 9, color: MUTED, fontWeight: 700, letterSpacing: '0.1em' }}>
|
||||||
PEDIDO
|
PEDIDO
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 22, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
|
<div style={{ fontSize: 19, fontWeight: 800, color: INK, lineHeight: 1.1 }}>
|
||||||
{order.numPedSar}
|
{order.numPedSar}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div style={{ fontSize: 9.5, color: MUTED, marginTop: 3 }}>
|
||||||
|
<span
|
||||||
style={{
|
style={{
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
marginTop: 6,
|
padding: '1px 8px',
|
||||||
padding: '2px 10px',
|
|
||||||
borderRadius: 20,
|
borderRadius: 20,
|
||||||
background: `${BLUE}12`,
|
background: `${BLUE}12`,
|
||||||
color: BLUE,
|
color: BLUE,
|
||||||
fontSize: 10.5,
|
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
|
marginRight: 6,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{SITUA_LABEL[order.situa] ?? String(order.situa)}
|
{SITUA_LABEL[order.situa] ?? String(order.situa)}
|
||||||
</div>
|
</span>
|
||||||
<div style={{ fontSize: 10.5, color: MUTED, marginTop: 6 }}>
|
{dateBR(order.dtPedido)}
|
||||||
Emissão: {dateBR(order.dtPedido)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -370,7 +381,30 @@ export function OrderPrintPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Totais ──────────────────────────────────────────────────────── */}
|
{/* ── Totais ──────────────────────────────────────────────────────── */}
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end', padding: '14px 28px 4px' }}>
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
gap: 16,
|
||||||
|
padding: '14px 28px 4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Peso e cubagem do pedido (logística/frete) */}
|
||||||
|
<div style={{ fontSize: 10.5, color: MUTED, lineHeight: 1.8 }}>
|
||||||
|
{Number(order.pesoTotal ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<strong style={{ color: INK }}>Peso total:</strong>{' '}
|
||||||
|
{Number(order.pesoTotal).toLocaleString('pt-BR', { maximumFractionDigits: 2 })} kg
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{Number(order.m3Total ?? 0) > 0 && (
|
||||||
|
<div>
|
||||||
|
<strong style={{ color: INK }}>Volume:</strong>{' '}
|
||||||
|
{Number(order.m3Total).toLocaleString('pt-BR', { maximumFractionDigits: 3 })} m³
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div style={{ width: 300 }}>
|
<div style={{ width: 300 }}>
|
||||||
<TotRow k="Total dos produtos" v={money(order.totalProdutos)} />
|
<TotRow k="Total dos produtos" v={money(order.totalProdutos)} />
|
||||||
{Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />}
|
{Number(order.totalIpi) > 0 && <TotRow k="IPI" v={money(order.totalIpi)} />}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
DatePicker,
|
DatePicker,
|
||||||
|
Form,
|
||||||
|
Input,
|
||||||
Modal,
|
Modal,
|
||||||
Dropdown,
|
Dropdown,
|
||||||
Grid,
|
Grid,
|
||||||
@@ -28,6 +30,7 @@ import {
|
|||||||
CloseCircleOutlined,
|
CloseCircleOutlined,
|
||||||
CopyOutlined,
|
CopyOutlined,
|
||||||
DollarOutlined,
|
DollarOutlined,
|
||||||
|
EditOutlined,
|
||||||
EllipsisOutlined,
|
EllipsisOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
FilePdfOutlined,
|
FilePdfOutlined,
|
||||||
@@ -244,6 +247,14 @@ function OrderStatusBadge({ situa, descr }: { situa: number; descr?: string }) {
|
|||||||
|
|
||||||
// ─── OrderActionsMenu ─────────────────────────────────────────────────────────
|
// ─── OrderActionsMenu ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const MOTIVOS_CANCELAMENTO = [
|
||||||
|
'Cliente desistiu',
|
||||||
|
'Preço',
|
||||||
|
'Prazo de entrega',
|
||||||
|
'Erro de lançamento',
|
||||||
|
'Outro',
|
||||||
|
];
|
||||||
|
|
||||||
function OrderActionsMenu({
|
function OrderActionsMenu({
|
||||||
order,
|
order,
|
||||||
onView,
|
onView,
|
||||||
@@ -253,18 +264,24 @@ function OrderActionsMenu({
|
|||||||
}) {
|
}) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { modal, message: msg } = App.useApp();
|
const { message: msg } = App.useApp();
|
||||||
|
const [cancelOpen, setCancelOpen] = useState(false);
|
||||||
|
const [cancelForm] = Form.useForm<{ motivo: string; descricao?: string }>();
|
||||||
|
|
||||||
const cancelMutation = useMutation({
|
const cancelMutation = useMutation({
|
||||||
mutationFn: () => apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH' }),
|
mutationFn: (body: { motivo: string; descricao?: string }) =>
|
||||||
|
apiFetch(`/orders/${order.id}/cancel`, { method: 'PATCH', body }),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
void msg.success('Orçamento cancelado.');
|
void msg.success('Orçamento cancelado.');
|
||||||
|
setCancelOpen(false);
|
||||||
void qc.invalidateQueries({ queryKey: ['orders'] });
|
void qc.invalidateQueries({ queryKey: ['orders'] });
|
||||||
},
|
},
|
||||||
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'),
|
onError: (e: unknown) => void msg.error(e instanceof Error ? e.message : 'Erro ao cancelar'),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Orçamento (situa 0) pode ser editado e cancelado; transmitido, não.
|
||||||
const canCancel = order.situa === 0 && order.fonte !== 'erp';
|
const canCancel = order.situa === 0 && order.fonte !== 'erp';
|
||||||
|
const canEdit = canCancel;
|
||||||
|
|
||||||
const items: MenuProps['items'] = [
|
const items: MenuProps['items'] = [
|
||||||
{
|
{
|
||||||
@@ -273,6 +290,16 @@ function OrderActionsMenu({
|
|||||||
label: 'Ver detalhes',
|
label: 'Ver detalhes',
|
||||||
onClick: () => onView(order.id),
|
onClick: () => onView(order.id),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'edit',
|
||||||
|
icon: <EditOutlined />,
|
||||||
|
label: 'Editar orçamento',
|
||||||
|
disabled: !canEdit,
|
||||||
|
onClick: () => {
|
||||||
|
if (!canEdit) return;
|
||||||
|
void navigate({ to: '/pedidos/novo', search: { editar: order.id } });
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'duplicate',
|
key: 'duplicate',
|
||||||
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
|
icon: <CopyOutlined style={{ color: '#0057D9' }} />,
|
||||||
@@ -305,22 +332,59 @@ function OrderActionsMenu({
|
|||||||
disabled: !canCancel,
|
disabled: !canCancel,
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
if (!canCancel) return;
|
if (!canCancel) return;
|
||||||
void modal.confirm({
|
cancelForm.resetFields();
|
||||||
title: 'Cancelar orçamento?',
|
setCancelOpen(true);
|
||||||
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(),
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
// stopPropagation: o clique nos "..." não pode acionar o clique da LINHA
|
||||||
|
// (que abre o detalhe) — o menu abre sozinho e só a opção escolhida age.
|
||||||
|
<span onClick={(e) => e.stopPropagation()}>
|
||||||
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
|
<Dropdown menu={{ items }} trigger={['click']} placement="bottomRight">
|
||||||
<Button type="text" icon={<EllipsisOutlined />} size="small" />
|
<Button type="text" icon={<EllipsisOutlined />} size="small" />
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title={`Cancelar orçamento ${order.numPedSar}?`}
|
||||||
|
open={cancelOpen}
|
||||||
|
onCancel={() => setCancelOpen(false)}
|
||||||
|
okText="Cancelar pedido"
|
||||||
|
okButtonProps={{ danger: true }}
|
||||||
|
cancelText="Voltar"
|
||||||
|
confirmLoading={cancelMutation.isPending}
|
||||||
|
onOk={() =>
|
||||||
|
void cancelForm.validateFields().then((values) => cancelMutation.mutate(values))
|
||||||
|
}
|
||||||
|
centered
|
||||||
|
width={480}
|
||||||
|
>
|
||||||
|
<Typography.Text type="secondary" style={{ display: 'block', marginBottom: 16 }}>
|
||||||
|
O pedido será marcado como cancelado. Esta ação não pode ser desfeita.
|
||||||
|
</Typography.Text>
|
||||||
|
<Form form={cancelForm} layout="vertical">
|
||||||
|
<Form.Item
|
||||||
|
name="motivo"
|
||||||
|
label="Motivo do cancelamento"
|
||||||
|
rules={[{ required: true, message: 'Informe o motivo' }]}
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
placeholder="Selecione o motivo"
|
||||||
|
options={MOTIVOS_CANCELAMENTO.map((m) => ({ value: m, label: m }))}
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="descricao" label="Descrição (opcional)">
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
maxLength={500}
|
||||||
|
showCount
|
||||||
|
placeholder="Detalhe o que aconteceu, se quiser"
|
||||||
|
/>
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
faFileInvoiceDollar,
|
faFileInvoiceDollar,
|
||||||
faTags,
|
faTags,
|
||||||
faHeadset,
|
faHeadset,
|
||||||
|
faGear,
|
||||||
} 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';
|
||||||
@@ -91,6 +92,11 @@ const GERENTE_ITEMS: ItemType[] = [
|
|||||||
icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faAddressCard} fixedWidth />,
|
||||||
label: 'Clientes',
|
label: 'Clientes',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: '/catalogo',
|
||||||
|
icon: <FontAwesomeIcon icon={faBoxesStacked} fixedWidth />,
|
||||||
|
label: 'Catálogo',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: '/relatorios',
|
key: '/relatorios',
|
||||||
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
icon: <FontAwesomeIcon icon={faFileInvoiceDollar} fixedWidth />,
|
||||||
@@ -102,6 +108,11 @@ const GERENTE_ITEMS: ItemType[] = [
|
|||||||
label: 'Políticas Comerciais',
|
label: 'Políticas Comerciais',
|
||||||
},
|
},
|
||||||
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
|
{ key: '/ger/chamados', icon: <FontAwesomeIcon icon={faHeadset} fixedWidth />, label: 'SAC' },
|
||||||
|
{
|
||||||
|
key: '/ger/config',
|
||||||
|
icon: <FontAwesomeIcon icon={faGear} fixedWidth />,
|
||||||
|
label: 'Configurações',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function getItems(role: string): ItemType[] {
|
function getItems(role: string): ItemType[] {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
|
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
|
||||||
import { usePromocoesVigentes, useRegrasVigentes } from './queries/politicas';
|
import { usePromocoesVigentes, useRegrasVigentes, useMinhaAlcada } from './queries/politicas';
|
||||||
|
|
||||||
// Condição comercial vigente que alcança um produto: promoção (por produto ou
|
// 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
|
// grupo) ou regra de desconto (por grupo/subgrupo/rep). Usada para sinalizar no
|
||||||
@@ -62,3 +62,17 @@ export function useCondicoesComerciais(): (p: ProdutoRef) => CondicaoComercial[]
|
|||||||
const { data: regras } = useRegrasVigentes();
|
const { data: regras } = useRegrasVigentes();
|
||||||
return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras);
|
return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Teto de desconto do item — espelha assertAlcadaItens do backend: vale o MAIOR
|
||||||
|
// entre a alçada do grupo (codGrupo 0 = default; sem linha, 5%) e a melhor
|
||||||
|
// promoção/regra vigente que alcança o produto.
|
||||||
|
export function useTetoDesconto(): (p: ProdutoRef) => number {
|
||||||
|
const condicoesDe = useCondicoesComerciais();
|
||||||
|
const { data: alcada } = useMinhaAlcada();
|
||||||
|
return (p) => {
|
||||||
|
const limites = new Map((alcada?.limites ?? []).map((l) => [l.codGrupo, l.limitePerc]));
|
||||||
|
const base = (p.codGrupo != null ? limites.get(p.codGrupo) : undefined) ?? limites.get(0) ?? 5;
|
||||||
|
const cond = condicoesDe(p).reduce((max, c) => Math.max(max, c.descPct), 0);
|
||||||
|
return Math.max(base, cond);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import type { CreatePedido } from '@sar/api-interface';
|
import type { CreatePedido } from '@sar/api-interface';
|
||||||
import { idbGetAll, idbPut, idbDelete, STORE_PENDING_ORDERS } from './idb';
|
import { idbGetAll, idbPut, idbDelete, STORE_PENDING_ORDERS } from './idb';
|
||||||
|
import { randomUUID } from '../uuid';
|
||||||
|
|
||||||
export interface PendingOrder {
|
export interface PendingOrder {
|
||||||
idempotencyKey: string; // keyPath do IndexedDB
|
idempotencyKey: string; // keyPath do IndexedDB
|
||||||
@@ -23,7 +24,7 @@ export async function enqueueOrder(
|
|||||||
payload: CreatePedido,
|
payload: CreatePedido,
|
||||||
clienteNome: string,
|
clienteNome: string,
|
||||||
): Promise<PendingOrder> {
|
): Promise<PendingOrder> {
|
||||||
const key = payload.idempotencyKey ?? crypto.randomUUID();
|
const key = payload.idempotencyKey ?? randomUUID();
|
||||||
const order: PendingOrder = {
|
const order: PendingOrder = {
|
||||||
idempotencyKey: key,
|
idempotencyKey: key,
|
||||||
payload: { ...payload, idempotencyKey: key },
|
payload: { ...payload, idempotencyKey: key },
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
RegrasVigentesResponseSchema,
|
RegrasVigentesResponseSchema,
|
||||||
PromocoesVigentesResponseSchema,
|
PromocoesVigentesResponseSchema,
|
||||||
|
AlcadaRepResponseSchema,
|
||||||
type RegrasVigentesResponse,
|
type RegrasVigentesResponse,
|
||||||
type PromocoesVigentesResponse,
|
type PromocoesVigentesResponse,
|
||||||
|
type AlcadaRepResponse,
|
||||||
} from '@sar/api-interface';
|
} from '@sar/api-interface';
|
||||||
import { apiFetch } from '../api-client';
|
import { apiFetch } from '../api-client';
|
||||||
|
|
||||||
@@ -20,6 +22,18 @@ export function useRegrasVigentes() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Alçada do usuário logado — teto de desconto por item no carrinho
|
||||||
|
export function useMinhaAlcada() {
|
||||||
|
return useQuery<AlcadaRepResponse>({
|
||||||
|
queryKey: ['politicas', 'minha-alcada'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const raw = await apiFetch('/politicas/alcada');
|
||||||
|
return AlcadaRepResponseSchema.parse(raw);
|
||||||
|
},
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep
|
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep
|
||||||
export function usePromocoesVigentes() {
|
export function usePromocoesVigentes() {
|
||||||
return useQuery<PromocoesVigentesResponse>({
|
return useQuery<PromocoesVigentesResponse>({
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ 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 { ChamadosPage } from '../cockpits/rep/ChamadosPage';
|
||||||
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
|
import { GerChamadosPage } from '../cockpits/ger/GerChamadosPage';
|
||||||
|
import { GerConfigPage } from '../cockpits/ger/GerConfigPage';
|
||||||
import { authStore } from './auth-store';
|
import { authStore } from './auth-store';
|
||||||
|
|
||||||
function getRoleFromToken(): string {
|
function getRoleFromToken(): string {
|
||||||
@@ -207,6 +208,12 @@ const gerChamadosRoute = createRoute({
|
|||||||
component: GerChamadosPage,
|
component: GerChamadosPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const gerConfigRoute = createRoute({
|
||||||
|
getParentRoute: () => rootRoute,
|
||||||
|
path: '/ger/config',
|
||||||
|
component: GerConfigPage,
|
||||||
|
});
|
||||||
|
|
||||||
const routeTree = rootRoute.addChildren([
|
const routeTree = rootRoute.addChildren([
|
||||||
indexRoute,
|
indexRoute,
|
||||||
rafaelRoute,
|
rafaelRoute,
|
||||||
@@ -228,6 +235,7 @@ const routeTree = rootRoute.addChildren([
|
|||||||
politicasRoute,
|
politicasRoute,
|
||||||
chamadosRoute,
|
chamadosRoute,
|
||||||
gerChamadosRoute,
|
gerChamadosRoute,
|
||||||
|
gerConfigRoute,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export const router = createRouter({
|
export const router = createRouter({
|
||||||
|
|||||||
12
apps/web/src/lib/uuid.ts
Normal file
12
apps/web/src/lib/uuid.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// crypto.randomUUID só existe em contexto seguro (HTTPS/localhost). Acessando o
|
||||||
|
// dev server pelo IP da rede (http://192.168...) ela é undefined — fallback gera
|
||||||
|
// UUID v4 com crypto.getRandomValues, disponível em qualquer contexto.
|
||||||
|
export function randomUUID(): string {
|
||||||
|
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||||
|
|
||||||
|
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
||||||
|
bytes[6] = (bytes[6] & 0x0f) | 0x40; // versão 4
|
||||||
|
bytes[8] = (bytes[8] & 0x3f) | 0x80; // variante RFC 4122
|
||||||
|
const hex = [...bytes].map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
@@ -17,5 +17,18 @@ export const EmpresaInfoSchema = z.object({
|
|||||||
cep: z.string().nullable(),
|
cep: z.string().nullable(),
|
||||||
telefone: z.string().nullable(),
|
telefone: z.string().nullable(),
|
||||||
email: z.string().nullable(),
|
email: z.string().nullable(),
|
||||||
|
// Logo (data URL) exibido no cabeçalho da impressão — sar.config_empresa
|
||||||
|
logoBase64: z.string().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type EmpresaInfo = z.infer<typeof EmpresaInfoSchema>;
|
export type EmpresaInfo = z.infer<typeof EmpresaInfoSchema>;
|
||||||
|
|
||||||
|
// Upload do logo pelo gerente — data URL de imagem; null remove o logo.
|
||||||
|
// ~700KB de base64 = imagem de ~500KB, suficiente para logo de cabeçalho.
|
||||||
|
export const UpdateLogoBodySchema = z.object({
|
||||||
|
logoBase64: z
|
||||||
|
.string()
|
||||||
|
.max(700_000)
|
||||||
|
.regex(/^data:image\/(png|jpeg|jpg|webp);base64,[A-Za-z0-9+/=]+$/)
|
||||||
|
.nullable(),
|
||||||
|
});
|
||||||
|
export type UpdateLogoBody = z.infer<typeof UpdateLogoBodySchema>;
|
||||||
|
|||||||
@@ -85,7 +85,10 @@ export const SupervisorDashboardSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
|
export type SupervisorDashboard = z.infer<typeof SupervisorDashboardSchema>;
|
||||||
|
|
||||||
|
// novosClientes: clientes da carteira cuja PRIMEIRA compra (historico todo)
|
||||||
|
// caiu no periodo selecionado
|
||||||
export const PositivacaoRepSchema = z.object({
|
export const PositivacaoRepSchema = z.object({
|
||||||
|
novosClientes: z.number().int().default(0),
|
||||||
codVendedor: z.number().int(),
|
codVendedor: z.number().int(),
|
||||||
nomeVendedor: z.string().nullable(),
|
nomeVendedor: z.string().nullable(),
|
||||||
totalClientes: z.number().int(),
|
totalClientes: z.number().int(),
|
||||||
@@ -122,6 +125,14 @@ export const ManagerDashboardSchema = z.object({
|
|||||||
rankingReps: z.array(RankingRepSchema),
|
rankingReps: z.array(RankingRepSchema),
|
||||||
positivacaoReps: z.array(PositivacaoRepSchema),
|
positivacaoReps: z.array(PositivacaoRepSchema),
|
||||||
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
|
positivacaoDiaria: z.array(PositivacaoDiaSchema).default([]),
|
||||||
|
// Meta diária de positivação salva pelo gerente (config_empresa)
|
||||||
|
metaPositivacaoDia: z.number().int().nullable().optional(),
|
||||||
syncedAt: z.iso.datetime(),
|
syncedAt: z.iso.datetime(),
|
||||||
});
|
});
|
||||||
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;
|
export type ManagerDashboard = z.infer<typeof ManagerDashboardSchema>;
|
||||||
|
|
||||||
|
// Salvar a meta diária de positivação (0 limpa a meta)
|
||||||
|
export const SaveMetaPositivacaoBodySchema = z.object({
|
||||||
|
meta: z.number().int().min(0).max(100000),
|
||||||
|
});
|
||||||
|
export type SaveMetaPositivacaoBody = z.infer<typeof SaveMetaPositivacaoBodySchema>;
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ export const PedidoItemSchema = z.object({
|
|||||||
precoUnitario: z.string(),
|
precoUnitario: z.string(),
|
||||||
descontoPerc: z.string(),
|
descontoPerc: z.string(),
|
||||||
total: z.string(),
|
total: z.string(),
|
||||||
|
// Grupo/subgrupo do produto (vw_produtos) — edição do orçamento usa para o
|
||||||
|
// teto de desconto. Ausente em pedidos ERP.
|
||||||
|
codGrupo: z.number().int().nullable().optional(),
|
||||||
|
codSubgrupo: z.number().int().nullable().optional(),
|
||||||
});
|
});
|
||||||
export type PedidoItem = z.infer<typeof PedidoItemSchema>;
|
export type PedidoItem = z.infer<typeof PedidoItemSchema>;
|
||||||
|
|
||||||
@@ -89,6 +93,12 @@ export const PedidoDetailSchema = PedidoSummarySchema.extend({
|
|||||||
pedFlex: z.string(),
|
pedFlex: z.string(),
|
||||||
formaPagamento: z.string().nullable().optional(),
|
formaPagamento: z.string().nullable().optional(),
|
||||||
endEntrega: z.string().nullable().optional(),
|
endEntrega: z.string().nullable().optional(),
|
||||||
|
// Pauta/forma de pagamento do pedido SAR — edição do orçamento pré-carrega
|
||||||
|
idPauta: z.number().int().nullable().optional(),
|
||||||
|
codFormapag: z.number().int().nullable().optional(),
|
||||||
|
// Peso total (kg) e cubagem (m3) — qtd x peso_liquido/vol_m3 de vw_produtos
|
||||||
|
pesoTotal: z.string().optional(),
|
||||||
|
m3Total: z.string().optional(),
|
||||||
aprovadoPor: z.number().int().nullable(),
|
aprovadoPor: z.number().int().nullable(),
|
||||||
aprovadoEm: z.iso.datetime().nullable(),
|
aprovadoEm: z.iso.datetime().nullable(),
|
||||||
motivoRecusa: z.string().nullable(),
|
motivoRecusa: z.string().nullable(),
|
||||||
@@ -149,6 +159,17 @@ export const CreatePedidoSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type CreatePedido = z.infer<typeof CreatePedidoSchema>;
|
export type CreatePedido = z.infer<typeof CreatePedidoSchema>;
|
||||||
|
|
||||||
|
// Edição de orçamento (situa 0): mesmo shape da criação, sem idempotencyKey
|
||||||
|
export const UpdatePedidoSchema = CreatePedidoSchema.omit({ idempotencyKey: true });
|
||||||
|
export type UpdatePedido = z.infer<typeof UpdatePedidoSchema>;
|
||||||
|
|
||||||
|
// Cancelamento com motivo obrigatório + descrição livre opcional
|
||||||
|
export const CancelPedidoSchema = z.object({
|
||||||
|
motivo: z.string().min(1).max(60),
|
||||||
|
descricao: z.string().max(500).optional(),
|
||||||
|
});
|
||||||
|
export type CancelPedido = z.infer<typeof CancelPedidoSchema>;
|
||||||
|
|
||||||
export const AprovarPedidoSchema = z.object({
|
export const AprovarPedidoSchema = z.object({
|
||||||
descontoPerc: z.number().min(0).max(100).optional(),
|
descontoPerc: z.number().min(0).max(100).optional(),
|
||||||
nota: z.string().optional(),
|
nota: z.string().optional(),
|
||||||
|
|||||||
@@ -39,17 +39,32 @@ export const PromocoesResponseSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type PromocoesResponse = z.infer<typeof PromocoesResponseSchema>;
|
export type PromocoesResponse = z.infer<typeof PromocoesResponseSchema>;
|
||||||
|
|
||||||
|
// Criação aceita VÁRIOS produtos e/ou grupos — o backend cria uma promoção
|
||||||
|
// por alvo (mesma descrição/%/vigência), mantendo o motor de alçada intacto.
|
||||||
export const CreatePromocaoBodySchema = z.object({
|
export const CreatePromocaoBodySchema = z.object({
|
||||||
descricao: z.string().min(1).max(255),
|
descricao: z.string().min(1).max(255),
|
||||||
codProduto: z.string().optional(),
|
codProdutos: z.array(z.string().min(1)).max(100).optional(),
|
||||||
grpProd: z.string().optional(),
|
grpProds: z.array(z.string().min(1)).max(100).optional(),
|
||||||
descPct: z.number().min(0).max(100),
|
descPct: z.number().min(0).max(100),
|
||||||
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
dataFim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
dataFim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
});
|
});
|
||||||
export type CreatePromocaoBody = z.infer<typeof CreatePromocaoBodySchema>;
|
export type CreatePromocaoBody = z.infer<typeof CreatePromocaoBodySchema>;
|
||||||
|
|
||||||
export const UpdatePromocaoBodySchema = CreatePromocaoBodySchema.partial().extend({
|
// Edição continua por linha (um alvo por promoção)
|
||||||
|
export const UpdatePromocaoBodySchema = z.object({
|
||||||
|
descricao: z.string().min(1).max(255).optional(),
|
||||||
|
codProduto: z.string().nullable().optional(),
|
||||||
|
grpProd: z.string().nullable().optional(),
|
||||||
|
descPct: z.number().min(0).max(100).optional(),
|
||||||
|
dataInicio: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.optional(),
|
||||||
|
dataFim: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.optional(),
|
||||||
ativa: z.boolean().optional(),
|
ativa: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
|
export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
|
||||||
@@ -81,10 +96,12 @@ export const RegrasDescontoResponseSchema = z.object({
|
|||||||
});
|
});
|
||||||
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
|
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
|
||||||
|
|
||||||
|
// Criação aceita VÁRIOS grupos e/ou subgrupos — uma regra por alvo (grupos
|
||||||
|
// selecionados viram regras por grupo; subgrupos viram regras por subgrupo).
|
||||||
export const CreateRegraDescontoBodySchema = z.object({
|
export const CreateRegraDescontoBodySchema = z.object({
|
||||||
descricao: z.string().min(1).max(200),
|
descricao: z.string().min(1).max(200),
|
||||||
codGrupo: z.number().int().nullable().optional(),
|
codGrupos: z.array(z.number().int()).max(100).optional(),
|
||||||
codSubgrupo: z.number().int().nullable().optional(),
|
codSubgrupos: z.array(z.number().int()).max(100).optional(),
|
||||||
codVendedor: z.number().int().nullable().optional(),
|
codVendedor: z.number().int().nullable().optional(),
|
||||||
descPct: z.number().min(0).max(100),
|
descPct: z.number().min(0).max(100),
|
||||||
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||||
@@ -92,11 +109,37 @@ export const CreateRegraDescontoBodySchema = z.object({
|
|||||||
});
|
});
|
||||||
export type CreateRegraDescontoBody = z.infer<typeof CreateRegraDescontoBodySchema>;
|
export type CreateRegraDescontoBody = z.infer<typeof CreateRegraDescontoBodySchema>;
|
||||||
|
|
||||||
export const UpdateRegraDescontoBodySchema = CreateRegraDescontoBodySchema.partial().extend({
|
// Edição continua por linha (um alvo por regra)
|
||||||
|
export const UpdateRegraDescontoBodySchema = z.object({
|
||||||
|
descricao: z.string().min(1).max(200).optional(),
|
||||||
|
codGrupo: z.number().int().nullable().optional(),
|
||||||
|
codSubgrupo: z.number().int().nullable().optional(),
|
||||||
|
codVendedor: z.number().int().nullable().optional(),
|
||||||
|
descPct: z.number().min(0).max(100).optional(),
|
||||||
|
dataInicio: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.optional(),
|
||||||
|
dataFim: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
||||||
|
.optional(),
|
||||||
ativa: z.boolean().optional(),
|
ativa: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
|
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
|
||||||
|
|
||||||
|
// Alçada do usuário logado — usada pelo carrinho para mostrar o teto por item
|
||||||
|
export const AlcadaRepItemSchema = z.object({
|
||||||
|
codGrupo: z.number().int(),
|
||||||
|
limitePerc: z.number(),
|
||||||
|
});
|
||||||
|
export type AlcadaRepItem = z.infer<typeof AlcadaRepItemSchema>;
|
||||||
|
|
||||||
|
export const AlcadaRepResponseSchema = z.object({
|
||||||
|
limites: z.array(AlcadaRepItemSchema),
|
||||||
|
});
|
||||||
|
export type AlcadaRepResponse = z.infer<typeof AlcadaRepResponseSchema>;
|
||||||
|
|
||||||
// Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
|
// Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
|
||||||
export const PromocaoVigenteSchema = z.object({
|
export const PromocaoVigenteSchema = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ export const PautaSchema = z.object({
|
|||||||
idPauta: z.number().int(),
|
idPauta: z.number().int(),
|
||||||
codigo: z.number().int(),
|
codigo: z.number().int(),
|
||||||
descricao: z.string(),
|
descricao: z.string(),
|
||||||
|
// Presente só para gestor: quantos representantes usam esta pauta
|
||||||
|
qtdReps: z.number().int().optional(),
|
||||||
});
|
});
|
||||||
export type Pauta = z.infer<typeof PautaSchema>;
|
export type Pauta = z.infer<typeof PautaSchema>;
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ async function applyGrants(client: pg.Client, appRole: string): Promise<void> {
|
|||||||
sar.push_subscription,
|
sar.push_subscription,
|
||||||
sar.promocoes,
|
sar.promocoes,
|
||||||
sar.regras_desconto,
|
sar.regras_desconto,
|
||||||
|
sar.config_empresa,
|
||||||
sar.clientes_novos,
|
sar.clientes_novos,
|
||||||
sar.contatos_novos
|
sar.contatos_novos
|
||||||
TO ${appRole};
|
TO ${appRole};
|
||||||
|
|||||||
@@ -289,7 +289,9 @@ SELECT
|
|||||||
COALESCE(p.preco_promocional, 0) AS preco_promocional,
|
COALESCE(p.preco_promocional, 0) AS preco_promocional,
|
||||||
COALESCE(p.tx_desc_lote, 0) AS tx_desc_lote,
|
COALESCE(p.tx_desc_lote, 0) AS tx_desc_lote,
|
||||||
p.lista_pauta,
|
p.lista_pauta,
|
||||||
CASE WHEN p.dt_atual > sub.da THEN p.dt_atual ELSE sub.da END AS dt_atual
|
CASE WHEN p.dt_atual > sub.da THEN p.dt_atual ELSE sub.da END AS dt_atual,
|
||||||
|
-- vol_m3 no FIM: CREATE OR REPLACE VIEW so aceita coluna nova apos as existentes
|
||||||
|
COALESCE(p.vol_m3, 0) AS vol_m3
|
||||||
FROM gestao.produto p
|
FROM gestao.produto p
|
||||||
LEFT JOIN gestao.grupo grp ON grp.codigo = p.cod_grupo AND grp.id_empresa = p.id_empresa
|
LEFT JOIN gestao.grupo grp ON grp.codigo = p.cod_grupo AND grp.id_empresa = p.id_empresa
|
||||||
LEFT JOIN gestao.grupo sub ON sub.codigo = p.cod_subgrupo AND sub.id_empresa = p.id_empresa
|
LEFT JOIN gestao.grupo sub ON sub.codigo = p.cod_subgrupo AND sub.id_empresa = p.id_empresa
|
||||||
@@ -736,6 +738,17 @@ CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
|||||||
created_by VARCHAR(100) NOT NULL
|
created_by VARCHAR(100) NOT NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
-- Configuracoes por empresa (logo da impressao do pedido, etc.)
|
||||||
|
-- logo_base64: data URL (data:image/png;base64,...)
|
||||||
|
-- -----------------------------------------------------------------------------
|
||||||
|
CREATE TABLE IF NOT EXISTS sar.config_empresa (
|
||||||
|
id_empresa INTEGER PRIMARY KEY,
|
||||||
|
logo_base64 TEXT,
|
||||||
|
meta_positivacao_dia INTEGER,
|
||||||
|
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
-- Oportunidades (Funil de Vendas)
|
-- Oportunidades (Funil de Vendas)
|
||||||
-- -----------------------------------------------------------------------------
|
-- -----------------------------------------------------------------------------
|
||||||
@@ -820,6 +833,6 @@ CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagen
|
|||||||
-- GRANT INSERT, UPDATE, DELETE ON
|
-- GRANT INSERT, UPDATE, DELETE ON
|
||||||
-- sar.pedidos, sar.pedido_itens, sar.historico_pedido,
|
-- sar.pedidos, sar.pedido_itens, sar.historico_pedido,
|
||||||
-- sar.alcada_desconto, sar.meta_representante, sar.push_subscription,
|
-- sar.alcada_desconto, sar.meta_representante, sar.push_subscription,
|
||||||
-- sar.promocoes, sar.regras_desconto, sar.clientes_novos, sar.contatos_novos
|
-- sar.promocoes, sar.regras_desconto, sar.config_empresa, sar.clientes_novos, sar.contatos_novos
|
||||||
-- TO <role>;
|
-- TO <role>;
|
||||||
-- =============================================================================
|
-- =============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user