feat(api,web): politicas comerciais com multi-selecao de produtos, grupos e subgrupos
- Promocao: produtos com busca remota por codigo/descricao (multi) e
grupos com busca por nome/codigo (multi) - backend cria uma promocao
por alvo na mesma transacao ({ids})
- Regra de desconto: grupos e subgrupos multi (subgrupos filtram pelos
grupos escolhidos) - uma regra por alvo
- Edicao continua por linha, com selects de busca no lugar dos inputs
de texto livre
- Modais maiores (760px de largura, corpo com altura minima e rolagem)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -68,7 +68,7 @@ export class PoliticasController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -109,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> {
|
||||||
@@ -257,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),
|
||||||
@@ -277,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> {
|
||||||
|
|||||||
@@ -44,6 +44,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,6 +226,8 @@ 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;
|
||||||
@@ -178,6 +236,7 @@ interface PromocaoForm {
|
|||||||
|
|
||||||
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 +245,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();
|
||||||
@@ -206,21 +277,32 @@ function TabPromocoes() {
|
|||||||
|
|
||||||
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'),
|
||||||
};
|
},
|
||||||
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
|
||||||
@@ -337,7 +419,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 +432,46 @@ 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>
|
||||||
</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,6 +503,8 @@ 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;
|
||||||
@@ -406,27 +523,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'}`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -454,7 +581,11 @@ function TabRegras() {
|
|||||||
|
|
||||||
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 +593,20 @@ 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'),
|
||||||
};
|
},
|
||||||
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
|
||||||
@@ -602,7 +739,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 +762,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
|
||||||
@@ -645,6 +784,38 @@ function TabRegras() {
|
|||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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"
|
||||||
|
|||||||
@@ -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,7 +109,21 @@ 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>;
|
||||||
|
|||||||
Reference in New Issue
Block a user