feat(politicas): regras de desconto por grupo/subgrupo/rep e condicoes comerciais no catalogo
- Nova tabela sar.regras_desconto (grupo, subgrupo, representante, % e vigencia; campos nulos = todos) com migration idempotente, espelho no sar-erp-schema.sql e GRANT no provision-client - CRUD manager-only em /politicas/regras + aba "Regras de Desconto" na tela de Politicas Comerciais (selects com nomes de rep/grupo/subgrupo via /politicas/grupos-produto) - GET /politicas/regras/vigentes (rep ve so as que o alcancam) e GET /politicas/promocoes/vigentes para o catalogo - Regra vigente pre-aplica o desconto no carrinho do novo pedido - Selos "Promocao"/"Cond. especial" na busca de produto, catalogo do pedido e pagina de catalogo; secao "Condicoes Comerciais" no modal de detalhe do produto (descricao, % e validade) - Empty imageStyle deprecado -> styles.image no carrinho Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
-- CreateTable: sar.regras_desconto (Regras de Desconto - Politicas Comerciais)
|
||||
-- Desconto liberado pelo gerente por grupo/subgrupo de produto e/ou
|
||||
-- representante, com vigencia. Campos nulos = "todos".
|
||||
-- Definicao espelha scripts/sar-erp-schema.sql: provision-client.ts roda o schema SQL
|
||||
-- antes de `prisma migrate deploy`, entao esta migration precisa ser idempotente.
|
||||
-- ATENCAO: bancos ERP usam LATIN1 - nao usar em-dash nem box-drawing aqui.
|
||||
CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id_empresa INTEGER NOT NULL,
|
||||
descricao VARCHAR(200) NOT NULL,
|
||||
cod_grupo INTEGER,
|
||||
cod_subgrupo INTEGER,
|
||||
cod_vendedor INTEGER,
|
||||
desc_pct NUMERIC(5,2) NOT NULL,
|
||||
data_inicio DATE NOT NULL,
|
||||
data_fim DATE NOT NULL,
|
||||
ativa BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
created_by VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_regras_empresa ON sar.regras_desconto(id_empresa);
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_regras_vigente ON sar.regras_desconto(id_empresa, data_fim) WHERE ativa;
|
||||
@@ -171,6 +171,30 @@ model Promocao {
|
||||
@@map("promocoes")
|
||||
}
|
||||
|
||||
// ─── RegraDesconto (Políticas Comerciais) ────────────────────────────────────
|
||||
//
|
||||
// Regra de desconto criada pelo Gerente: percentual liberado por grupo e/ou
|
||||
// subgrupo de produto e/ou representante, com vigência. Campos nulos = "todos".
|
||||
|
||||
model RegraDesconto {
|
||||
id Int @id @default(autoincrement())
|
||||
idEmpresa Int @map("id_empresa")
|
||||
descricao String
|
||||
codGrupo Int? @map("cod_grupo")
|
||||
codSubgrupo Int? @map("cod_subgrupo")
|
||||
codVendedor Int? @map("cod_vendedor")
|
||||
descPct Decimal @db.Decimal(5, 2) @map("desc_pct")
|
||||
dataInicio DateTime @db.Date @map("data_inicio")
|
||||
dataFim DateTime @db.Date @map("data_fim")
|
||||
ativa Boolean @default(true)
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
createdBy String @map("created_by")
|
||||
|
||||
@@index([idEmpresa])
|
||||
@@index([idEmpresa, dataFim])
|
||||
@@map("regras_desconto")
|
||||
}
|
||||
|
||||
// ─── Oportunidade (Funil de Vendas) ──────────────────────────────────────────
|
||||
//
|
||||
// Oportunidade de venda do representante. Pode referenciar cliente ERP (id_cliente)
|
||||
|
||||
@@ -14,17 +14,27 @@ import {
|
||||
UpsertDescontoBodySchema,
|
||||
CreatePromocaoBodySchema,
|
||||
UpdatePromocaoBodySchema,
|
||||
CreateRegraDescontoBodySchema,
|
||||
UpdateRegraDescontoBodySchema,
|
||||
type UpsertDescontoBody,
|
||||
type CreatePromocaoBody,
|
||||
type UpdatePromocaoBody,
|
||||
type CreateRegraDescontoBody,
|
||||
type UpdateRegraDescontoBody,
|
||||
type AlcadaDescontosResponse,
|
||||
type PromocoesResponse,
|
||||
type PromocoesVigentesResponse,
|
||||
type RegrasDescontoResponse,
|
||||
type RegrasVigentesResponse,
|
||||
type GruposProdutoResponse,
|
||||
} from '@sar/api-interface';
|
||||
import { PoliticasService } from './politicas.service';
|
||||
|
||||
class UpsertDescontoDto extends createZodDto(UpsertDescontoBodySchema) {}
|
||||
class CreatePromocaoDto extends createZodDto(CreatePromocaoBodySchema) {}
|
||||
class UpdatePromocaoDto extends createZodDto(UpdatePromocaoBodySchema) {}
|
||||
class CreateRegraDescontoDto extends createZodDto(CreateRegraDescontoBodySchema) {}
|
||||
class UpdateRegraDescontoDto extends createZodDto(UpdateRegraDescontoBodySchema) {}
|
||||
|
||||
@Controller({ path: 'politicas' })
|
||||
export class PoliticasController {
|
||||
@@ -46,6 +56,11 @@ export class PoliticasController {
|
||||
return this.politicas.listPromocoes();
|
||||
}
|
||||
|
||||
@Get('promocoes/vigentes')
|
||||
listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||
return this.politicas.listPromocoesVigentes();
|
||||
}
|
||||
|
||||
@Post('promocoes')
|
||||
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
|
||||
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
||||
@@ -64,4 +79,38 @@ export class PoliticasController {
|
||||
deletePromocao(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.politicas.deletePromocao(id);
|
||||
}
|
||||
|
||||
@Get('regras')
|
||||
listRegras(): Promise<RegrasDescontoResponse> {
|
||||
return this.politicas.listRegras();
|
||||
}
|
||||
|
||||
@Get('regras/vigentes')
|
||||
listRegrasVigentes(): Promise<RegrasVigentesResponse> {
|
||||
return this.politicas.listRegrasVigentes();
|
||||
}
|
||||
|
||||
@Post('regras')
|
||||
createRegra(@Body() body: CreateRegraDescontoDto): Promise<{ id: number }> {
|
||||
return this.politicas.createRegra(body as unknown as CreateRegraDescontoBody);
|
||||
}
|
||||
|
||||
@Patch('regras/:id')
|
||||
updateRegra(
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: UpdateRegraDescontoDto,
|
||||
): Promise<void> {
|
||||
return this.politicas.updateRegra(id, body as unknown as UpdateRegraDescontoBody);
|
||||
}
|
||||
|
||||
@Delete('regras/:id')
|
||||
@HttpCode(204)
|
||||
deleteRegra(@Param('id', ParseIntPipe) id: number): Promise<void> {
|
||||
return this.politicas.deleteRegra(id);
|
||||
}
|
||||
|
||||
@Get('grupos-produto')
|
||||
listGruposProduto(): Promise<GruposProdutoResponse> {
|
||||
return this.politicas.listGruposProduto();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ import type {
|
||||
PromocoesResponse,
|
||||
CreatePromocaoBody,
|
||||
UpdatePromocaoBody,
|
||||
RegrasDescontoResponse,
|
||||
CreateRegraDescontoBody,
|
||||
UpdateRegraDescontoBody,
|
||||
RegrasVigentesResponse,
|
||||
PromocoesVigentesResponse,
|
||||
GruposProdutoResponse,
|
||||
} from '@sar/api-interface';
|
||||
import type { WorkspaceClsStore } from '../workspace/workspace.types';
|
||||
|
||||
@@ -158,4 +164,219 @@ export class PoliticasService {
|
||||
|
||||
await prisma.promocao.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// ─── Regras de Desconto ─────────────────────────────────────────────────────
|
||||
|
||||
// Grupos/subgrupos distintos de vw_produtos (empresa matriz), para combos e
|
||||
// resolução de nomes na listagem — UI sempre mostra o nome, nunca só o código.
|
||||
private async loadGruposProduto(): Promise<
|
||||
{
|
||||
cod_grupo: number | null;
|
||||
grupo: string | null;
|
||||
cod_subgrupo: number | null;
|
||||
subgrupo: string | null;
|
||||
}[]
|
||||
> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
|
||||
return prisma.$queryRawUnsafe(
|
||||
`SELECT DISTINCT cod_grupo, TRIM(grupo) AS grupo, cod_subgrupo, TRIM(subgrupo) AS subgrupo
|
||||
FROM vw_produtos
|
||||
WHERE id_empresa = ${idMatriz}
|
||||
AND (cod_grupo IS NOT NULL OR cod_subgrupo IS NOT NULL)
|
||||
ORDER BY grupo, subgrupo`,
|
||||
);
|
||||
}
|
||||
|
||||
async listGruposProduto(): Promise<GruposProdutoResponse> {
|
||||
this.assertManager();
|
||||
const rows = await this.loadGruposProduto();
|
||||
return {
|
||||
grupos: rows.map((r) => ({
|
||||
codGrupo: r.cod_grupo != null ? Number(r.cod_grupo) : null,
|
||||
grupo: r.grupo,
|
||||
codSubgrupo: r.cod_subgrupo != null ? Number(r.cod_subgrupo) : null,
|
||||
subgrupo: r.subgrupo,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async listRegras(): Promise<RegrasDescontoResponse> {
|
||||
this.assertManager();
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
const rows = await prisma.regraDesconto.findMany({
|
||||
where: { idEmpresa },
|
||||
orderBy: [{ dataFim: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
|
||||
const codigos = [
|
||||
...new Set(rows.map((r) => r.codVendedor).filter((c): c is number => c != null)),
|
||||
];
|
||||
const repRows =
|
||||
codigos.length > 0
|
||||
? await prisma.$queryRawUnsafe<{ codigo: number; nome: string | null }[]>(
|
||||
`SELECT codigo, nome FROM vw_representantes WHERE codigo IN (${codigos.join(',')})`,
|
||||
)
|
||||
: [];
|
||||
const repNameMap = new Map(repRows.map((r) => [Number(r.codigo), r.nome]));
|
||||
|
||||
const grupos = rows.some((r) => r.codGrupo != null || r.codSubgrupo != null)
|
||||
? await this.loadGruposProduto()
|
||||
: [];
|
||||
const grupoNameMap = new Map(
|
||||
grupos.filter((g) => g.cod_grupo != null).map((g) => [Number(g.cod_grupo), g.grupo]),
|
||||
);
|
||||
const subgrupoNameMap = new Map(
|
||||
grupos.filter((g) => g.cod_subgrupo != null).map((g) => [Number(g.cod_subgrupo), g.subgrupo]),
|
||||
);
|
||||
|
||||
return {
|
||||
regras: rows.map((r) => ({
|
||||
id: r.id,
|
||||
descricao: r.descricao,
|
||||
codGrupo: r.codGrupo,
|
||||
grupo: r.codGrupo != null ? (grupoNameMap.get(r.codGrupo) ?? null) : null,
|
||||
codSubgrupo: r.codSubgrupo,
|
||||
subgrupo: r.codSubgrupo != null ? (subgrupoNameMap.get(r.codSubgrupo) ?? null) : null,
|
||||
codVendedor: r.codVendedor,
|
||||
nomeVendedor: r.codVendedor != null ? (repNameMap.get(r.codVendedor) ?? null) : null,
|
||||
descPct: Number(r.descPct),
|
||||
dataInicio: r.dataInicio.toISOString().slice(0, 10),
|
||||
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||
ativa: r.ativa,
|
||||
createdAt: r.createdAt.toISOString(),
|
||||
createdBy: r.createdBy,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async createRegra(body: CreateRegraDescontoBody): Promise<{ id: number }> {
|
||||
this.assertManager();
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId') ?? 'system';
|
||||
|
||||
const r = await prisma.regraDesconto.create({
|
||||
data: {
|
||||
idEmpresa,
|
||||
descricao: body.descricao,
|
||||
codGrupo: body.codGrupo ?? null,
|
||||
codSubgrupo: body.codSubgrupo ?? null,
|
||||
codVendedor: body.codVendedor ?? null,
|
||||
descPct: body.descPct,
|
||||
dataInicio: new Date(body.dataInicio),
|
||||
dataFim: new Date(body.dataFim),
|
||||
createdBy: userId,
|
||||
},
|
||||
});
|
||||
return { id: r.id };
|
||||
}
|
||||
|
||||
async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise<void> {
|
||||
this.assertManager();
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
const exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } });
|
||||
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
|
||||
|
||||
await prisma.regraDesconto.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...(body.descricao !== undefined && { descricao: body.descricao }),
|
||||
...(body.codGrupo !== undefined && { codGrupo: body.codGrupo }),
|
||||
...(body.codSubgrupo !== undefined && { codSubgrupo: body.codSubgrupo }),
|
||||
...(body.codVendedor !== undefined && { codVendedor: body.codVendedor }),
|
||||
...(body.descPct !== undefined && { descPct: body.descPct }),
|
||||
...(body.dataInicio !== undefined && { dataInicio: new Date(body.dataInicio) }),
|
||||
...(body.dataFim !== undefined && { dataFim: new Date(body.dataFim) }),
|
||||
...(body.ativa !== undefined && { ativa: body.ativa }),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteRegra(id: number): Promise<void> {
|
||||
this.assertManager();
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
|
||||
const exists = await prisma.regraDesconto.findFirst({ where: { id, idEmpresa } });
|
||||
if (!exists) throw new NotFoundException('Regra de desconto não encontrada');
|
||||
|
||||
await prisma.regraDesconto.delete({ where: { id } });
|
||||
}
|
||||
|
||||
// Promoções vigentes hoje — qualquer usuário autenticado. Usadas pelo rep para
|
||||
// sinalizar no catálogo que o produto tem condição comercial diferenciada.
|
||||
async listPromocoesVigentes(): Promise<PromocoesVigentesResponse> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
|
||||
const hoje = new Date();
|
||||
const rows = await prisma.promocao.findMany({
|
||||
where: {
|
||||
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||
ativa: true,
|
||||
dataInicio: { lte: hoje },
|
||||
dataFim: { gte: hoje },
|
||||
},
|
||||
orderBy: [{ descPct: 'desc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
promocoes: rows.map((p) => ({
|
||||
id: p.id,
|
||||
descricao: p.descricao,
|
||||
codProduto: p.codProduto,
|
||||
grpProd: p.grpProd,
|
||||
descPct: Number(p.descPct),
|
||||
dataFim: p.dataFim.toISOString().slice(0, 10),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
// Regras vigentes hoje para o usuário logado. Rep recebe só as regras que o
|
||||
// alcançam (cod_vendedor nulo = todos, ou o próprio código); gerente vê todas.
|
||||
async listRegrasVigentes(): Promise<RegrasVigentesResponse> {
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const idMatriz = idEmpresa > 9000 ? idEmpresa - 9000 : idEmpresa;
|
||||
const role = this.cls.get('role') ?? 'rep';
|
||||
const codVendedor = parseInt(this.cls.get('userId') ?? '0', 10);
|
||||
|
||||
const hoje = new Date();
|
||||
const rows = await prisma.regraDesconto.findMany({
|
||||
where: {
|
||||
idEmpresa: { in: [idEmpresa, idMatriz] },
|
||||
ativa: true,
|
||||
dataInicio: { lte: hoje },
|
||||
dataFim: { gte: hoje },
|
||||
...(role === 'rep' ? { OR: [{ codVendedor: null }, { codVendedor }] } : {}),
|
||||
},
|
||||
orderBy: [{ descPct: 'desc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
regras: rows.map((r) => ({
|
||||
id: r.id,
|
||||
descricao: r.descricao,
|
||||
codGrupo: r.codGrupo,
|
||||
codSubgrupo: r.codSubgrupo,
|
||||
descPct: Number(r.descPct),
|
||||
dataFim: r.dataFim.toISOString().slice(0, 10),
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
InputNumber,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Select,
|
||||
Skeleton,
|
||||
Space,
|
||||
Table,
|
||||
@@ -22,7 +23,13 @@ import type { TableColumnsType } from 'antd';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlus, faTrash, faPen } from '@fortawesome/free-solid-svg-icons';
|
||||
import dayjs from 'dayjs';
|
||||
import type { AlcadaDescontoItem, Promocao } from '@sar/api-interface';
|
||||
import type {
|
||||
AlcadaDescontoItem,
|
||||
Promocao,
|
||||
RegraDesconto,
|
||||
RepStats,
|
||||
GrupoProdutoItem,
|
||||
} from '@sar/api-interface';
|
||||
import {
|
||||
usePoliticasDescontos,
|
||||
usePromocoes,
|
||||
@@ -30,6 +37,12 @@ import {
|
||||
useCreatePromocao,
|
||||
useUpdatePromocao,
|
||||
useDeletePromocao,
|
||||
useEquipe,
|
||||
useRegrasDesconto,
|
||||
useGruposProduto,
|
||||
useCreateRegraDesconto,
|
||||
useUpdateRegraDesconto,
|
||||
useDeleteRegraDesconto,
|
||||
} from '../../lib/queries/gerente';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
@@ -370,6 +383,294 @@ function TabPromocoes() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Tab Regras de Desconto ───────────────────────────────────────────────────
|
||||
|
||||
interface RegraForm {
|
||||
descricao: string;
|
||||
codVendedor?: number;
|
||||
codGrupo?: number;
|
||||
codSubgrupo?: number;
|
||||
descPct: number;
|
||||
periodo: [dayjs.Dayjs, dayjs.Dayjs];
|
||||
}
|
||||
|
||||
function TabRegras() {
|
||||
const { data, isLoading } = useRegrasDesconto();
|
||||
const { data: equipe } = useEquipe();
|
||||
const { data: gruposData } = useGruposProduto();
|
||||
const createMutation = useCreateRegraDesconto();
|
||||
const updateMutation = useUpdateRegraDesconto();
|
||||
const deleteMutation = useDeleteRegraDesconto();
|
||||
const [form] = Form.useForm<RegraForm>();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null);
|
||||
const [msg, msgCtx] = message.useMessage();
|
||||
const grupoSelecionado = Form.useWatch('codGrupo', form);
|
||||
|
||||
const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
|
||||
value: r.codVendedor,
|
||||
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
|
||||
}));
|
||||
|
||||
const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
|
||||
const grupoMap = new Map<number, { value: number; label: string }>();
|
||||
const subgrupoMap = new Map<number, { value: number; label: string }>();
|
||||
for (const g of grupoRows) {
|
||||
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) {
|
||||
grupoMap.set(g.codGrupo, { value: g.codGrupo, label: g.grupo ?? `Grupo ${g.codGrupo}` });
|
||||
}
|
||||
if (
|
||||
g.codSubgrupo != null &&
|
||||
(grupoSelecionado == null || g.codGrupo === grupoSelecionado) &&
|
||||
!subgrupoMap.has(g.codSubgrupo)
|
||||
) {
|
||||
subgrupoMap.set(g.codSubgrupo, {
|
||||
value: g.codSubgrupo,
|
||||
label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
const grupoOptions = [...grupoMap.values()];
|
||||
const subgrupoOptions = [...subgrupoMap.values()];
|
||||
|
||||
function openCreate() {
|
||||
setEditTarget(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(r: RegraDesconto) {
|
||||
setEditTarget(r);
|
||||
form.setFieldsValue({
|
||||
descricao: r.descricao,
|
||||
codVendedor: r.codVendedor ?? undefined,
|
||||
codGrupo: r.codGrupo ?? undefined,
|
||||
codSubgrupo: r.codSubgrupo ?? undefined,
|
||||
descPct: r.descPct,
|
||||
periodo: [dayjs(r.dataInicio), dayjs(r.dataFim)],
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
async function handleSubmit(values: RegraForm) {
|
||||
const [inicio, fim] = values.periodo;
|
||||
const body = {
|
||||
descricao: values.descricao,
|
||||
codVendedor: values.codVendedor ?? null,
|
||||
codGrupo: values.codGrupo ?? null,
|
||||
codSubgrupo: values.codSubgrupo ?? null,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
};
|
||||
try {
|
||||
if (editTarget) {
|
||||
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
||||
void msg.success('Regra atualizada');
|
||||
} else {
|
||||
await createMutation.mutateAsync(body);
|
||||
void msg.success('Regra criada');
|
||||
}
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||
}
|
||||
setModalOpen(false);
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
try {
|
||||
await deleteMutation.mutateAsync(id);
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient
|
||||
}
|
||||
void msg.success('Regra removida');
|
||||
}
|
||||
|
||||
const today = dayjs().format('YYYY-MM-DD');
|
||||
|
||||
const columns: TableColumnsType<RegraDesconto> = [
|
||||
{
|
||||
title: 'Descricao',
|
||||
dataIndex: 'descricao',
|
||||
},
|
||||
{
|
||||
title: 'Representante',
|
||||
width: 180,
|
||||
render: (_: unknown, row: RegraDesconto) =>
|
||||
row.codVendedor == null ? (
|
||||
<Tag>Todos</Tag>
|
||||
) : (
|
||||
<Text>{row.nomeVendedor ?? `Cód. ${row.codVendedor}`}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Grupo / Subgrupo',
|
||||
width: 220,
|
||||
render: (_: unknown, row: RegraDesconto) => {
|
||||
if (row.codGrupo == null && row.codSubgrupo == null) return <Tag>Todos</Tag>;
|
||||
const partes = [
|
||||
row.codGrupo != null ? (row.grupo ?? `Grupo ${row.codGrupo}`) : null,
|
||||
row.codSubgrupo != null ? (row.subgrupo ?? `Subgrupo ${row.codSubgrupo}`) : null,
|
||||
].filter(Boolean);
|
||||
return <Text style={{ fontSize: 'var(--text-sm)' }}>{partes.join(' / ')}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Desconto',
|
||||
dataIndex: 'descPct',
|
||||
width: 100,
|
||||
align: 'right',
|
||||
render: (v: number) => <Text className="tabular-nums">{v}%</Text>,
|
||||
},
|
||||
{
|
||||
title: 'Vigencia',
|
||||
width: 200,
|
||||
render: (_: unknown, row: RegraDesconto) => (
|
||||
<Text className="tabular-nums" style={{ fontSize: 'var(--text-sm)' }}>
|
||||
{dayjs(row.dataInicio).format('DD/MM/YY')} a {dayjs(row.dataFim).format('DD/MM/YY')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Status',
|
||||
width: 90,
|
||||
render: (_: unknown, row: RegraDesconto) => {
|
||||
if (!row.ativa) return <Tag>Inativa</Tag>;
|
||||
if (row.dataFim < today) return <Tag color="red">Expirada</Tag>;
|
||||
if (row.dataInicio > today) return <Tag color="blue">Futura</Tag>;
|
||||
return <Tag color="green">Ativa</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 100,
|
||||
render: (_: unknown, row: RegraDesconto) => (
|
||||
<Space>
|
||||
<Tooltip title="Editar">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FontAwesomeIcon icon={faPen} />}
|
||||
onClick={() => openEdit(row)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Remover regra?"
|
||||
okText="Sim"
|
||||
cancelText="Nao"
|
||||
onConfirm={() => void handleDelete(row.id)}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<FontAwesomeIcon icon={faTrash} />}
|
||||
loading={deleteMutation.isPending}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading || !data) return <Skeleton active paragraph={{ rows: 5 }} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
{msgCtx}
|
||||
<Flex justify="flex-end" style={{ marginBottom: 16 }}>
|
||||
<Button type="primary" icon={<FontAwesomeIcon icon={faPlus} />} onClick={openCreate}>
|
||||
Nova Regra
|
||||
</Button>
|
||||
</Flex>
|
||||
|
||||
<Table<RegraDesconto>
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={data.regras}
|
||||
pagination={false}
|
||||
size="small"
|
||||
locale={{ emptyText: 'Nenhuma regra de desconto cadastrada.' }}
|
||||
/>
|
||||
<Text type="secondary" style={{ fontSize: 'var(--text-xs)', display: 'block', marginTop: 8 }}>
|
||||
A regra vale para o representante, grupo e subgrupo informados (em branco = todos) e aplica
|
||||
o desconto automaticamente nos pedidos durante a vigencia.
|
||||
</Text>
|
||||
|
||||
<Modal
|
||||
title={editTarget ? 'Editar Regra de Desconto' : 'Nova Regra de Desconto'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||
width={520}
|
||||
centered
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="descricao"
|
||||
label="Descricao"
|
||||
rules={[{ required: true, message: 'Informe a descricao' }]}
|
||||
>
|
||||
<Input placeholder="Ex.: Campanha linha inverno" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="codVendedor" label="Representante">
|
||||
<Select
|
||||
options={repOptions}
|
||||
placeholder="Todos os representantes"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={grupoOptions}
|
||||
placeholder="Todos os grupos"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={subgrupoOptions}
|
||||
placeholder="Todos os subgrupos"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
|
||||
<Form.Item
|
||||
name="descPct"
|
||||
label="Desconto (%)"
|
||||
rules={[{ required: true, message: 'Informe o desconto' }]}
|
||||
>
|
||||
<InputNumber min={0} max={100} suffix="%" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="periodo"
|
||||
label="Periodo de vigencia"
|
||||
rules={[{ required: true, message: 'Informe o periodo' }]}
|
||||
>
|
||||
<DatePicker.RangePicker
|
||||
format="DD/MM/YYYY"
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['Inicio', 'Fim']}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── PoliticasPage ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function PoliticasPage() {
|
||||
@@ -384,6 +685,11 @@ export function PoliticasPage() {
|
||||
label: 'Promocoes',
|
||||
children: <TabPromocoes />,
|
||||
},
|
||||
{
|
||||
key: 'regras',
|
||||
label: 'Regras de Desconto',
|
||||
children: <TabRegras />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
@@ -4,6 +4,8 @@ import { EyeOutlined } from '@ant-design/icons';
|
||||
import type { TableColumnsType } from 'antd';
|
||||
import type { ProdutoSummary } from '@sar/api-interface';
|
||||
import { useCatalog, usePautas } from '../../lib/queries/catalog';
|
||||
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
|
||||
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||
import { ProductDetailDrawer } from './ProductDetailDrawer';
|
||||
|
||||
const { useBreakpoint } = Grid;
|
||||
@@ -16,7 +18,10 @@ function fmtPrice(v: string | null | undefined): string {
|
||||
return n > 0 ? n.toLocaleString('pt-BR', { style: 'currency', currency: 'BRL' }) : '—';
|
||||
}
|
||||
|
||||
function buildColumns(onDetail: (id: number) => void): TableColumnsType<ProdutoSummary> {
|
||||
function buildColumns(
|
||||
onDetail: (id: number) => void,
|
||||
condicoesDe: (p: ProdutoSummary) => CondicaoComercial[],
|
||||
): TableColumnsType<ProdutoSummary> {
|
||||
return [
|
||||
{
|
||||
title: 'Código',
|
||||
@@ -29,7 +34,18 @@ function buildColumns(onDetail: (id: number) => void): TableColumnsType<ProdutoS
|
||||
dataIndex: 'descricao',
|
||||
render: (v: string, row: ProdutoSummary) => (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>{v.trim()}</div>
|
||||
<div
|
||||
style={{
|
||||
fontWeight: 500,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{v.trim()}
|
||||
<CondicaoTag conds={condicoesDe(row)} compact />
|
||||
</div>
|
||||
{row.grupo && <div style={{ fontSize: 12, color: '#888' }}>{row.grupo.trim()}</div>}
|
||||
</div>
|
||||
),
|
||||
@@ -101,8 +117,9 @@ export function CatalogPage() {
|
||||
|
||||
const { data: pautas, isLoading: pautasLoading } = usePautas();
|
||||
const { data, isLoading } = useCatalog({ q: q || undefined, idPauta, page, limit });
|
||||
const condicoesDe = useCondicoesComerciais();
|
||||
|
||||
const columns = buildColumns((id) => setSelectedIdErp(id));
|
||||
const columns = buildColumns((id) => setSelectedIdErp(id), condicoesDe);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 1400, margin: '0 auto' }}>
|
||||
|
||||
@@ -41,10 +41,14 @@ import type {
|
||||
FormaPagamento,
|
||||
Pauta,
|
||||
ProdutoSummary,
|
||||
RegraVigente,
|
||||
TopProduto,
|
||||
} from '@sar/api-interface';
|
||||
import { useClientList, useClientDetail, useClientTopProdutos } from '../../lib/queries/clients';
|
||||
import { useCatalog, useFormasPagamento, usePautas } from '../../lib/queries/catalog';
|
||||
import { useRegrasVigentes } from '../../lib/queries/politicas';
|
||||
import { useCondicoesComerciais } from '../../lib/condicoes-comerciais';
|
||||
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||
import { apiFetch } from '../../lib/api-client';
|
||||
import { enqueueOrder } from '../../lib/offline/order-queue';
|
||||
|
||||
@@ -193,6 +197,7 @@ function ProductSearch({
|
||||
const screens = useBreakpoint();
|
||||
const isMobile = !screens.md;
|
||||
const { data, isFetching } = useCatalog({ q: q || undefined, idPauta, limit: 25 });
|
||||
const condicoesDe = useCondicoesComerciais();
|
||||
|
||||
const options = (data?.data ?? []).map((p) => ({
|
||||
value: String(p.idErp),
|
||||
@@ -208,8 +213,19 @@ function ProductSearch({
|
||||
<Space size={6} align="start" style={{ flex: 1, minWidth: 0 }}>
|
||||
<Tag style={{ margin: 0, fontSize: 11, flexShrink: 0 }}>{p.codigo.trim()}</Tag>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ color: '#1F2937', fontWeight: 500, lineHeight: 1.3 }}>
|
||||
<div
|
||||
style={{
|
||||
color: '#1F2937',
|
||||
fontWeight: 500,
|
||||
lineHeight: 1.3,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
{p.descricao.trim()}
|
||||
<CondicaoTag conds={condicoesDe(p)} compact />
|
||||
</div>
|
||||
{p.grupo && (
|
||||
<div style={{ fontSize: 11, color: '#94A3B8', lineHeight: 1.2 }}>
|
||||
@@ -335,6 +351,7 @@ function CatalogBrowserModal({
|
||||
page,
|
||||
limit: MODAL_LIMIT,
|
||||
});
|
||||
const condicoesDe = useCondicoesComerciais();
|
||||
|
||||
const cartMap = new Map(cart.map((it) => [it.idProduto, it.qtd]));
|
||||
|
||||
@@ -353,7 +370,10 @@ function CatalogBrowserModal({
|
||||
title: 'Produto',
|
||||
render: (_: unknown, row: ProdutoSummary) => (
|
||||
<div>
|
||||
<Text style={{ fontWeight: 500 }}>{row.descricao.trim()}</Text>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexWrap: 'wrap' }}>
|
||||
<Text style={{ fontWeight: 500 }}>{row.descricao.trim()}</Text>
|
||||
<CondicaoTag conds={condicoesDe(row)} compact />
|
||||
</div>
|
||||
{row.grupo && (
|
||||
<Text type="secondary" style={{ fontSize: 11, display: 'block' }}>
|
||||
{row.grupo.trim()}
|
||||
@@ -460,7 +480,15 @@ function CatalogBrowserModal({
|
||||
title: 'Produto',
|
||||
render: (_: unknown, row: ProdutoSummary) => (
|
||||
<div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
marginBottom: 2,
|
||||
flexWrap: 'wrap',
|
||||
}}
|
||||
>
|
||||
<Tag style={{ fontSize: 10, margin: 0, padding: '0 4px' }}>{row.codigo.trim()}</Tag>
|
||||
<div
|
||||
title={stockTitle(row.qtdEstoque)}
|
||||
@@ -472,6 +500,7 @@ function CatalogBrowserModal({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
<CondicaoTag conds={condicoesDe(row)} compact />
|
||||
</div>
|
||||
<Text style={{ fontWeight: 500, fontSize: 13 }}>{row.descricao.trim()}</Text>
|
||||
{row.grupo && (
|
||||
@@ -1114,7 +1143,7 @@ function OrderItemsTable({
|
||||
emptyText: (
|
||||
<Empty
|
||||
image={<ShoppingCartOutlined style={{ fontSize: 48, color: '#D9E2EC' }} />}
|
||||
imageStyle={{ height: 60 }}
|
||||
styles={{ image: { height: 60 } }}
|
||||
description={
|
||||
<Space orientation="vertical" size={2}>
|
||||
<Text type="secondary">Nenhum produto adicionado ao pedido ainda.</Text>
|
||||
@@ -1296,6 +1325,23 @@ export function NewOrderPage() {
|
||||
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 ──
|
||||
const addToCart = (p: ProdutoSummary, qty?: number) => {
|
||||
const lote = p.loteMulVenda ?? 1;
|
||||
@@ -1317,7 +1363,7 @@ export function NewOrderPage() {
|
||||
unidade: p.unidade ?? '',
|
||||
qtd: finalQty,
|
||||
precoUnitario: Number(p.vlPreco1),
|
||||
descontoPerc: 0,
|
||||
descontoPerc: regraDescontoPct(p.codGrupo, p.codSubgrupo),
|
||||
loteMulVenda: p.loteMulVenda,
|
||||
},
|
||||
];
|
||||
@@ -1340,7 +1386,8 @@ export function NewOrderPage() {
|
||||
unidade: p.unidade ?? '',
|
||||
qtd: qty,
|
||||
precoUnitario: p.ultimoPreco,
|
||||
descontoPerc: 0,
|
||||
// TopProduto não traz grupo/subgrupo — aplica só regra sem restrição
|
||||
descontoPerc: regraDescontoPct(null, null),
|
||||
loteMulVenda: null,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { Modal, Tag, Skeleton, Typography, Row, Col, Statistic, Divider } from 'antd';
|
||||
import { BarcodeOutlined, InboxOutlined, TagsOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
BarcodeOutlined,
|
||||
InboxOutlined,
|
||||
PercentageOutlined,
|
||||
TagsOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { PautaPreco } from '@sar/api-interface';
|
||||
import { useProdutoDetail } from '../../lib/queries/catalog';
|
||||
import { useCondicoesComerciais, type CondicaoComercial } from '../../lib/condicoes-comerciais';
|
||||
import { CondicaoTag } from '../../components/CondicaoTag';
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
@@ -168,6 +175,56 @@ function PautaPrecoList({ items }: { items: PautaPreco[] }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Condições comerciais vigentes ────────────────────────────────────────────
|
||||
|
||||
function fmtData(iso: string): string {
|
||||
const [y, m, d] = iso.split('-');
|
||||
return `${d}/${m}/${y}`;
|
||||
}
|
||||
|
||||
function CondicoesList({ conds }: { conds: CondicaoComercial[] }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
|
||||
{conds.map((c, i) => (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 12,
|
||||
padding: '10px 14px',
|
||||
borderRadius: 8,
|
||||
background: c.tipo === 'promocao' ? '#FFFBE6' : '#F0F5FF',
|
||||
border: `1px solid ${c.tipo === 'promocao' ? '#FFE58F' : '#ADC6FF'}`,
|
||||
}}
|
||||
>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text style={{ fontSize: 11, color: '#94A3B8', display: 'block', lineHeight: 1.2 }}>
|
||||
{c.tipo === 'promocao' ? 'Promoção' : 'Condição especial'} · válida até{' '}
|
||||
{fmtData(c.dataFim)}
|
||||
</Text>
|
||||
<Text strong style={{ fontSize: 13, color: '#1F2937' }}>
|
||||
{c.descricao}
|
||||
</Text>
|
||||
</div>
|
||||
<Text
|
||||
strong
|
||||
style={{
|
||||
fontSize: 16,
|
||||
color: c.tipo === 'promocao' ? '#D48806' : '#2F54EB',
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{c.descPct.toLocaleString('pt-BR')}% desc.
|
||||
</Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Modal principal ──────────────────────────────────────────────────────────
|
||||
|
||||
interface Props {
|
||||
@@ -177,6 +234,8 @@ interface Props {
|
||||
|
||||
export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
||||
const { data, isLoading } = useProdutoDetail(idErp ?? undefined);
|
||||
const condicoesDe = useCondicoesComerciais();
|
||||
const conds = data ? condicoesDe(data) : [];
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -227,6 +286,7 @@ export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
||||
justifyContent: 'flex-end',
|
||||
}}
|
||||
>
|
||||
<CondicaoTag conds={conds} />
|
||||
{data.marca && <Tag color="blue">{data.marca.trim()}</Tag>}
|
||||
{data.unidade && <Tag>{data.unidade}</Tag>}
|
||||
<Tag color={data.ativo ? 'green' : 'red'}>{data.ativo ? 'Ativo' : 'Inativo'}</Tag>
|
||||
@@ -239,6 +299,13 @@ export function ProductDetailDrawer({ idErp, onClose }: Props) {
|
||||
<Row gutter={40}>
|
||||
{/* Coluna esquerda */}
|
||||
<Col xs={24} md={14}>
|
||||
{/* Condições comerciais vigentes */}
|
||||
{conds.length > 0 && (
|
||||
<Section icon={<PercentageOutlined />} title="Condições Comerciais">
|
||||
<CondicoesList conds={conds} />
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Classificação */}
|
||||
<Section icon={<TagsOutlined />} title="Classificação">
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '0 24px' }}>
|
||||
|
||||
27
apps/web/src/components/CondicaoTag.tsx
Normal file
27
apps/web/src/components/CondicaoTag.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Tag } from 'antd';
|
||||
import type { CondicaoComercial } from '../lib/condicoes-comerciais';
|
||||
|
||||
// Selo que sinaliza produto com proposta comercial diferenciada (promoção do
|
||||
// gerente ou regra de desconto vigente). O detalhamento fica no modal do produto.
|
||||
export function CondicaoTag({ conds, compact }: { conds: CondicaoComercial[]; compact?: boolean }) {
|
||||
if (conds.length === 0) return null;
|
||||
const temPromo = conds.some((c) => c.tipo === 'promocao');
|
||||
const temRegra = conds.some((c) => c.tipo === 'regra');
|
||||
const style = compact
|
||||
? { fontSize: 10, margin: 0, padding: '0 4px', lineHeight: '16px' }
|
||||
: { fontSize: 11, margin: 0 };
|
||||
return (
|
||||
<>
|
||||
{temPromo && (
|
||||
<Tag color="gold" style={style}>
|
||||
Promoção
|
||||
</Tag>
|
||||
)}
|
||||
{temRegra && (
|
||||
<Tag color="geekblue" style={style}>
|
||||
Cond. especial
|
||||
</Tag>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
64
apps/web/src/lib/condicoes-comerciais.ts
Normal file
64
apps/web/src/lib/condicoes-comerciais.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { PromocaoVigente, RegraVigente } from '@sar/api-interface';
|
||||
import { usePromocoesVigentes, useRegrasVigentes } from './queries/politicas';
|
||||
|
||||
// Condição comercial vigente que alcança um produto: promoção (por produto ou
|
||||
// grupo) ou regra de desconto (por grupo/subgrupo/rep). Usada para sinalizar no
|
||||
// catálogo do rep que o produto tem proposta diferenciada e detalhar qual é.
|
||||
|
||||
export interface CondicaoComercial {
|
||||
tipo: 'promocao' | 'regra';
|
||||
descricao: string;
|
||||
descPct: number;
|
||||
dataFim: string;
|
||||
}
|
||||
|
||||
export interface ProdutoRef {
|
||||
codigo: string;
|
||||
codGrupo: number | null;
|
||||
codSubgrupo: number | null;
|
||||
}
|
||||
|
||||
export function condicoesDoProduto(
|
||||
p: ProdutoRef,
|
||||
promocoes: PromocaoVigente[] | undefined,
|
||||
regras: RegraVigente[] | undefined,
|
||||
): CondicaoComercial[] {
|
||||
const conds: CondicaoComercial[] = [];
|
||||
|
||||
for (const promo of promocoes ?? []) {
|
||||
const porProduto = promo.codProduto && promo.codProduto.trim() === p.codigo.trim();
|
||||
const porGrupo =
|
||||
promo.grpProd && p.codGrupo != null && promo.grpProd.trim() === String(p.codGrupo);
|
||||
if (porProduto || porGrupo) {
|
||||
conds.push({
|
||||
tipo: 'promocao',
|
||||
descricao: promo.descricao,
|
||||
descPct: promo.descPct,
|
||||
dataFim: promo.dataFim,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const regra of regras ?? []) {
|
||||
const grupoOk = regra.codGrupo == null || (p.codGrupo != null && regra.codGrupo === p.codGrupo);
|
||||
const subgrupoOk =
|
||||
regra.codSubgrupo == null || (p.codSubgrupo != null && regra.codSubgrupo === p.codSubgrupo);
|
||||
if (grupoOk && subgrupoOk) {
|
||||
conds.push({
|
||||
tipo: 'regra',
|
||||
descricao: regra.descricao,
|
||||
descPct: regra.descPct,
|
||||
dataFim: regra.dataFim,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return conds.sort((a, b) => b.descPct - a.descPct);
|
||||
}
|
||||
|
||||
// Hook: função de lookup por produto, com promoções e regras vigentes em cache
|
||||
export function useCondicoesComerciais(): (p: ProdutoRef) => CondicaoComercial[] {
|
||||
const { data: promos } = usePromocoesVigentes();
|
||||
const { data: regras } = useRegrasVigentes();
|
||||
return (p) => condicoesDoProduto(p, promos?.promocoes, regras?.regras);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
EquipeResponseSchema,
|
||||
AlcadaDescontosResponseSchema,
|
||||
PromocoesResponseSchema,
|
||||
RegrasDescontoResponseSchema,
|
||||
GruposProdutoResponseSchema,
|
||||
type ManagerDashboard,
|
||||
type EquipeResponse,
|
||||
type AlcadaDescontosResponse,
|
||||
@@ -11,6 +13,10 @@ import {
|
||||
type UpsertDescontoBody,
|
||||
type CreatePromocaoBody,
|
||||
type UpdatePromocaoBody,
|
||||
type RegrasDescontoResponse,
|
||||
type GruposProdutoResponse,
|
||||
type CreateRegraDescontoBody,
|
||||
type UpdateRegraDescontoBody,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
@@ -106,3 +112,60 @@ export function useDeletePromocao() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRegrasDesconto() {
|
||||
return useQuery<RegrasDescontoResponse>({
|
||||
queryKey: ['politicas', 'regras'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/regras');
|
||||
return RegrasDescontoResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useGruposProduto() {
|
||||
return useQuery<GruposProdutoResponse>({
|
||||
queryKey: ['politicas', 'grupos-produto'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/grupos-produto');
|
||||
return GruposProdutoResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 30 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateRegraDesconto() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateRegraDescontoBody) =>
|
||||
apiFetch('/politicas/regras', { method: 'POST', body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRegraDesconto() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: number; body: UpdateRegraDescontoBody }) =>
|
||||
apiFetch(`/politicas/regras/${id}`, { method: 'PATCH', body }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRegraDesconto() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: number) => apiFetch(`/politicas/regras/${id}`, { method: 'DELETE' }),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras'] });
|
||||
qc.invalidateQueries({ queryKey: ['politicas', 'regras-vigentes'] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
33
apps/web/src/lib/queries/politicas.ts
Normal file
33
apps/web/src/lib/queries/politicas.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
RegrasVigentesResponseSchema,
|
||||
PromocoesVigentesResponseSchema,
|
||||
type RegrasVigentesResponse,
|
||||
type PromocoesVigentesResponse,
|
||||
} from '@sar/api-interface';
|
||||
import { apiFetch } from '../api-client';
|
||||
|
||||
// Regras de desconto vigentes para o usuário logado (rep recebe só as suas).
|
||||
// Usadas na montagem do pedido para pré-aplicar o desconto liberado pelo gerente.
|
||||
export function useRegrasVigentes() {
|
||||
return useQuery<RegrasVigentesResponse>({
|
||||
queryKey: ['politicas', 'regras-vigentes'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/regras/vigentes');
|
||||
return RegrasVigentesResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
|
||||
// Promoções vigentes hoje — sinalizam condição comercial no catálogo do rep
|
||||
export function usePromocoesVigentes() {
|
||||
return useQuery<PromocoesVigentesResponse>({
|
||||
queryKey: ['politicas', 'promocoes-vigentes'],
|
||||
queryFn: async () => {
|
||||
const raw = await apiFetch('/politicas/promocoes/vigentes');
|
||||
return PromocoesVigentesResponseSchema.parse(raw);
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
}
|
||||
@@ -53,3 +53,92 @@ export const UpdatePromocaoBodySchema = CreatePromocaoBodySchema.partial().exten
|
||||
ativa: z.boolean().optional(),
|
||||
});
|
||||
export type UpdatePromocaoBody = z.infer<typeof UpdatePromocaoBodySchema>;
|
||||
|
||||
// ─── Regras de Desconto ───────────────────────────────────────────────────────
|
||||
// Regra criada pelo Gerente: % de desconto liberado por grupo e/ou subgrupo de
|
||||
// produto e/ou representante, com vigência. Campos nulos = "todos".
|
||||
|
||||
export const RegraDescontoSchema = z.object({
|
||||
id: z.number().int(),
|
||||
descricao: z.string(),
|
||||
codGrupo: z.number().int().nullable(),
|
||||
grupo: z.string().nullable(),
|
||||
codSubgrupo: z.number().int().nullable(),
|
||||
subgrupo: z.string().nullable(),
|
||||
codVendedor: z.number().int().nullable(),
|
||||
nomeVendedor: z.string().nullable(),
|
||||
descPct: z.number(),
|
||||
dataInicio: z.string(),
|
||||
dataFim: z.string(),
|
||||
ativa: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
createdBy: z.string(),
|
||||
});
|
||||
export type RegraDesconto = z.infer<typeof RegraDescontoSchema>;
|
||||
|
||||
export const RegrasDescontoResponseSchema = z.object({
|
||||
regras: z.array(RegraDescontoSchema),
|
||||
});
|
||||
export type RegrasDescontoResponse = z.infer<typeof RegrasDescontoResponseSchema>;
|
||||
|
||||
export const CreateRegraDescontoBodySchema = z.object({
|
||||
descricao: z.string().min(1).max(200),
|
||||
codGrupo: z.number().int().nullable().optional(),
|
||||
codSubgrupo: z.number().int().nullable().optional(),
|
||||
codVendedor: z.number().int().nullable().optional(),
|
||||
descPct: z.number().min(0).max(100),
|
||||
dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
dataFim: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
});
|
||||
export type CreateRegraDescontoBody = z.infer<typeof CreateRegraDescontoBodySchema>;
|
||||
|
||||
export const UpdateRegraDescontoBodySchema = CreateRegraDescontoBodySchema.partial().extend({
|
||||
ativa: z.boolean().optional(),
|
||||
});
|
||||
export type UpdateRegraDescontoBody = z.infer<typeof UpdateRegraDescontoBodySchema>;
|
||||
|
||||
// Promoções vigentes hoje — visíveis ao rep para sinalizar condição no catálogo
|
||||
export const PromocaoVigenteSchema = z.object({
|
||||
id: z.number().int(),
|
||||
descricao: z.string(),
|
||||
codProduto: z.string().nullable(),
|
||||
grpProd: z.string().nullable(),
|
||||
descPct: z.number(),
|
||||
dataFim: z.string(),
|
||||
});
|
||||
export type PromocaoVigente = z.infer<typeof PromocaoVigenteSchema>;
|
||||
|
||||
export const PromocoesVigentesResponseSchema = z.object({
|
||||
promocoes: z.array(PromocaoVigenteSchema),
|
||||
});
|
||||
export type PromocoesVigentesResponse = z.infer<typeof PromocoesVigentesResponseSchema>;
|
||||
|
||||
// Regras vigentes para o representante logado — aplicadas na montagem do pedido
|
||||
export const RegraVigenteSchema = z.object({
|
||||
id: z.number().int(),
|
||||
descricao: z.string(),
|
||||
codGrupo: z.number().int().nullable(),
|
||||
codSubgrupo: z.number().int().nullable(),
|
||||
descPct: z.number(),
|
||||
dataFim: z.string(),
|
||||
});
|
||||
export type RegraVigente = z.infer<typeof RegraVigenteSchema>;
|
||||
|
||||
export const RegrasVigentesResponseSchema = z.object({
|
||||
regras: z.array(RegraVigenteSchema),
|
||||
});
|
||||
export type RegrasVigentesResponse = z.infer<typeof RegrasVigentesResponseSchema>;
|
||||
|
||||
// Grupos/subgrupos de produto (combos do formulário do gerente)
|
||||
export const GrupoProdutoItemSchema = z.object({
|
||||
codGrupo: z.number().int().nullable(),
|
||||
grupo: z.string().nullable(),
|
||||
codSubgrupo: z.number().int().nullable(),
|
||||
subgrupo: z.string().nullable(),
|
||||
});
|
||||
export type GrupoProdutoItem = z.infer<typeof GrupoProdutoItemSchema>;
|
||||
|
||||
export const GruposProdutoResponseSchema = z.object({
|
||||
grupos: z.array(GrupoProdutoItemSchema),
|
||||
});
|
||||
export type GruposProdutoResponse = z.infer<typeof GruposProdutoResponseSchema>;
|
||||
|
||||
@@ -95,6 +95,7 @@ async function applyGrants(client: pg.Client, appRole: string): Promise<void> {
|
||||
sar.meta_representante,
|
||||
sar.push_subscription,
|
||||
sar.promocoes,
|
||||
sar.regras_desconto,
|
||||
sar.clientes_novos,
|
||||
sar.contatos_novos
|
||||
TO ${appRole};
|
||||
|
||||
@@ -716,6 +716,26 @@ CREATE TABLE IF NOT EXISTS sar.promocoes (
|
||||
created_by VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Regras de Desconto (Politicas Comerciais - criadas pelo Gerente/Admin)
|
||||
-- Desconto liberado por grupo/subgrupo de produto e/ou representante, com
|
||||
-- vigencia. Campos nulos = "todos" (ex: cod_vendedor NULL vale para todo rep).
|
||||
-- -----------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS sar.regras_desconto (
|
||||
id SERIAL PRIMARY KEY,
|
||||
id_empresa INTEGER NOT NULL,
|
||||
descricao VARCHAR(200) NOT NULL,
|
||||
cod_grupo INTEGER,
|
||||
cod_subgrupo INTEGER,
|
||||
cod_vendedor INTEGER,
|
||||
desc_pct NUMERIC(5,2) NOT NULL,
|
||||
data_inicio DATE NOT NULL,
|
||||
data_fim DATE NOT NULL,
|
||||
ativa BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
created_by VARCHAR(100) NOT NULL
|
||||
);
|
||||
|
||||
-- -----------------------------------------------------------------------------
|
||||
-- Oportunidades (Funil de Vendas)
|
||||
-- -----------------------------------------------------------------------------
|
||||
@@ -783,6 +803,8 @@ CREATE INDEX IF NOT EXISTS idx_sar_meta_vend ON sar.meta_representante(cod_v
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_alcada_vend ON sar.alcada_desconto(cod_vendedor, id_empresa);
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_promo_empresa ON sar.promocoes(id_empresa);
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_promo_vigente ON sar.promocoes(id_empresa, data_fim) WHERE ativa;
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_regras_empresa ON sar.regras_desconto(id_empresa);
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_regras_vigente ON sar.regras_desconto(id_empresa, data_fim) WHERE ativa;
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_oport_vend ON sar.oportunidades(id_empresa, cod_vendedor);
|
||||
CREATE INDEX IF NOT EXISTS idx_sar_oport_etapa ON sar.oportunidades(etapa);
|
||||
CREATE INDEX IF NOT EXISTS idx_chamados_empresa_vendedor ON sar.chamados(id_empresa, cod_vendedor);
|
||||
@@ -798,6 +820,6 @@ CREATE INDEX IF NOT EXISTS idx_chamado_mensagens_chamado ON sar.chamado_mensagen
|
||||
-- GRANT INSERT, UPDATE, DELETE ON
|
||||
-- sar.pedidos, sar.pedido_itens, sar.historico_pedido,
|
||||
-- sar.alcada_desconto, sar.meta_representante, sar.push_subscription,
|
||||
-- sar.promocoes, sar.clientes_novos, sar.contatos_novos
|
||||
-- sar.promocoes, sar.regras_desconto, sar.clientes_novos, sar.contatos_novos
|
||||
-- TO <role>;
|
||||
-- =============================================================================
|
||||
|
||||
Reference in New Issue
Block a user