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')
|
||||
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ id: number }> {
|
||||
createPromocao(@Body() body: CreatePromocaoDto): Promise<{ ids: number[] }> {
|
||||
return this.politicas.createPromocao(body as unknown as CreatePromocaoBody);
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ export class PoliticasController {
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
const prisma = this.cls.get('prisma');
|
||||
if (!prisma) throw new Error('prisma não disponível no CLS');
|
||||
const idEmpresa = this.cls.get('idEmpresa');
|
||||
const userId = this.cls.get('userId') ?? 'system';
|
||||
|
||||
const p = await prisma.promocao.create({
|
||||
data: {
|
||||
idEmpresa,
|
||||
descricao: body.descricao,
|
||||
codProduto: body.codProduto ?? null,
|
||||
grpProd: body.grpProd ?? null,
|
||||
descPct: body.descPct,
|
||||
dataInicio: new Date(body.dataInicio),
|
||||
dataFim: new Date(body.dataFim),
|
||||
createdBy: userId,
|
||||
},
|
||||
const alvos: { codProduto: string | null; grpProd: string | null }[] = [
|
||||
...(body.codProdutos ?? []).map((c) => ({ codProduto: c, grpProd: null })),
|
||||
...(body.grpProds ?? []).map((g) => ({ codProduto: null, grpProd: g })),
|
||||
];
|
||||
if (alvos.length === 0) alvos.push({ codProduto: null, grpProd: null });
|
||||
|
||||
const ids = await prisma.$transaction(async (tx) => {
|
||||
const created: number[] = [];
|
||||
for (const alvo of alvos) {
|
||||
const p = await tx.promocao.create({
|
||||
data: {
|
||||
idEmpresa,
|
||||
descricao: body.descricao,
|
||||
codProduto: alvo.codProduto,
|
||||
grpProd: alvo.grpProd,
|
||||
descPct: body.descPct,
|
||||
dataInicio: new Date(body.dataInicio),
|
||||
dataFim: new Date(body.dataFim),
|
||||
createdBy: userId,
|
||||
},
|
||||
});
|
||||
created.push(p.id);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
return { id: p.id };
|
||||
return { ids };
|
||||
}
|
||||
|
||||
async updatePromocao(id: number, body: UpdatePromocaoBody): Promise<void> {
|
||||
@@ -257,27 +272,42 @@ 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();
|
||||
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,
|
||||
},
|
||||
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: {
|
||||
idEmpresa,
|
||||
descricao: body.descricao,
|
||||
codGrupo: alvo.codGrupo,
|
||||
codSubgrupo: alvo.codSubgrupo,
|
||||
codVendedor: body.codVendedor ?? null,
|
||||
descPct: body.descPct,
|
||||
dataInicio: new Date(body.dataInicio),
|
||||
dataFim: new Date(body.dataFim),
|
||||
createdBy: userId,
|
||||
},
|
||||
});
|
||||
created.push(r.id);
|
||||
}
|
||||
return created;
|
||||
});
|
||||
return { id: r.id };
|
||||
return { ids };
|
||||
}
|
||||
|
||||
async updateRegra(id: number, body: UpdateRegraDescontoBody): Promise<void> {
|
||||
|
||||
@@ -44,6 +44,62 @@ import {
|
||||
useUpdateRegraDesconto,
|
||||
useDeleteRegraDesconto,
|
||||
} 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;
|
||||
|
||||
@@ -170,6 +226,8 @@ function TabDescontos() {
|
||||
|
||||
interface PromocaoForm {
|
||||
descricao: string;
|
||||
codProdutos?: string[];
|
||||
grpProds?: string[];
|
||||
codProduto?: string;
|
||||
grpProd?: string;
|
||||
descPct: number;
|
||||
@@ -178,6 +236,7 @@ interface PromocaoForm {
|
||||
|
||||
function TabPromocoes() {
|
||||
const { data, isLoading } = usePromocoes();
|
||||
const { data: gruposData } = useGruposProduto();
|
||||
const createMutation = useCreatePromocao();
|
||||
const updateMutation = useUpdatePromocao();
|
||||
const deleteMutation = useDeletePromocao();
|
||||
@@ -186,6 +245,18 @@ function TabPromocoes() {
|
||||
const [editTarget, setEditTarget] = useState<Promocao | null>(null);
|
||||
const [msg, msgCtx] = message.useMessage();
|
||||
|
||||
// Grupos com nome+código no rótulo — busca funciona pelos dois
|
||||
const grupoOptions = [
|
||||
...new Map(
|
||||
(gruposData?.grupos ?? [])
|
||||
.filter((g) => g.codGrupo != null)
|
||||
.map((g) => [
|
||||
g.codGrupo,
|
||||
{ value: String(g.codGrupo), label: `${g.codGrupo} — ${g.grupo ?? 'Sem nome'}` },
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
|
||||
function openCreate() {
|
||||
setEditTarget(null);
|
||||
form.resetFields();
|
||||
@@ -206,21 +277,32 @@ function TabPromocoes() {
|
||||
|
||||
async function handleSubmit(values: PromocaoForm) {
|
||||
const [inicio, fim] = values.periodo;
|
||||
const body = {
|
||||
descricao: values.descricao,
|
||||
codProduto: values.codProduto || undefined,
|
||||
grpProd: values.grpProd || undefined,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
};
|
||||
try {
|
||||
if (editTarget) {
|
||||
await updateMutation.mutateAsync({ id: editTarget.id, body });
|
||||
await updateMutation.mutateAsync({
|
||||
id: editTarget.id,
|
||||
body: {
|
||||
descricao: values.descricao,
|
||||
codProduto: values.codProduto ?? null,
|
||||
grpProd: values.grpProd ?? null,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
},
|
||||
});
|
||||
void msg.success('Promocao atualizada');
|
||||
} else {
|
||||
await createMutation.mutateAsync(body);
|
||||
void msg.success('Promocao criada');
|
||||
const res = (await createMutation.mutateAsync({
|
||||
descricao: values.descricao,
|
||||
codProdutos: values.codProdutos?.length ? values.codProdutos : undefined,
|
||||
grpProds: values.grpProds?.length ? values.grpProds : undefined,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
})) as { ids: number[] };
|
||||
void msg.success(
|
||||
res.ids.length > 1 ? `${res.ids.length} promocoes criadas` : 'Promocao criada',
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||
@@ -337,7 +419,8 @@ function TabPromocoes() {
|
||||
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||
width={520}
|
||||
width={760}
|
||||
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
|
||||
centered
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
@@ -349,14 +432,46 @@ function TabPromocoes() {
|
||||
<Input placeholder="Ex.: Promocao de inverno" />
|
||||
</Form.Item>
|
||||
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="codProduto" label="Codigo do produto" style={{ flex: 1 }}>
|
||||
<Input placeholder="Opcional" />
|
||||
</Form.Item>
|
||||
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
|
||||
<Input placeholder="Opcional" />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
{editTarget ? (
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="codProduto" label="Produto" style={{ flex: 1 }}>
|
||||
<ProdutoSelect />
|
||||
</Form.Item>
|
||||
<Form.Item name="grpProd" label="Grupo de produto" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={grupoOptions}
|
||||
placeholder="Opcional"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
) : (
|
||||
<>
|
||||
<Form.Item
|
||||
name="codProdutos"
|
||||
label="Produtos"
|
||||
extra="Busque por código ou descrição e selecione quantos quiser — será criada uma promoção por produto"
|
||||
>
|
||||
<ProdutoSelect multiple />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="grpProds"
|
||||
label="Grupos de produto"
|
||||
extra="Busque por nome ou código — uma promoção por grupo selecionado"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={grupoOptions}
|
||||
placeholder="Busque por nome ou código do grupo"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
name="descPct"
|
||||
@@ -388,6 +503,8 @@ function TabPromocoes() {
|
||||
interface RegraForm {
|
||||
descricao: string;
|
||||
codVendedor?: number;
|
||||
codGrupos?: number[];
|
||||
codSubgrupos?: number[];
|
||||
codGrupo?: number;
|
||||
codSubgrupo?: number;
|
||||
descPct: number;
|
||||
@@ -406,27 +523,37 @@ function TabRegras() {
|
||||
const [editTarget, setEditTarget] = useState<RegraDesconto | null>(null);
|
||||
const [msg, msgCtx] = message.useMessage();
|
||||
const grupoSelecionado = Form.useWatch('codGrupo', form);
|
||||
const gruposSelecionados = Form.useWatch('codGrupos', form);
|
||||
|
||||
const repOptions = (equipe?.reps ?? []).map((r: RepStats) => ({
|
||||
value: r.codVendedor,
|
||||
label: r.nomeVendedor ?? `Cód. ${r.codVendedor}`,
|
||||
}));
|
||||
|
||||
// Rótulos com código + nome — a busca do select encontra pelos dois.
|
||||
// Subgrupos filtram pelos grupos escolhidos (single na edição, multi na criação).
|
||||
const filtroGrupos = new Set<number>([
|
||||
...(grupoSelecionado != null ? [grupoSelecionado] : []),
|
||||
...(gruposSelecionados ?? []),
|
||||
]);
|
||||
const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? [];
|
||||
const grupoMap = new Map<number, { value: number; label: string }>();
|
||||
const subgrupoMap = new Map<number, { value: number; label: string }>();
|
||||
for (const g of grupoRows) {
|
||||
if (g.codGrupo != null && !grupoMap.has(g.codGrupo)) {
|
||||
grupoMap.set(g.codGrupo, { value: g.codGrupo, label: g.grupo ?? `Grupo ${g.codGrupo}` });
|
||||
grupoMap.set(g.codGrupo, {
|
||||
value: g.codGrupo,
|
||||
label: `${g.codGrupo} — ${g.grupo ?? 'Sem nome'}`,
|
||||
});
|
||||
}
|
||||
if (
|
||||
g.codSubgrupo != null &&
|
||||
(grupoSelecionado == null || g.codGrupo === grupoSelecionado) &&
|
||||
(filtroGrupos.size === 0 || (g.codGrupo != null && filtroGrupos.has(g.codGrupo))) &&
|
||||
!subgrupoMap.has(g.codSubgrupo)
|
||||
) {
|
||||
subgrupoMap.set(g.codSubgrupo, {
|
||||
value: g.codSubgrupo,
|
||||
label: g.subgrupo ?? `Subgrupo ${g.codSubgrupo}`,
|
||||
label: `${g.codSubgrupo} — ${g.subgrupo ?? 'Sem nome'}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -454,22 +581,32 @@ function TabRegras() {
|
||||
|
||||
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 });
|
||||
await updateMutation.mutateAsync({
|
||||
id: editTarget.id,
|
||||
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'),
|
||||
},
|
||||
});
|
||||
void msg.success('Regra atualizada');
|
||||
} else {
|
||||
await createMutation.mutateAsync(body);
|
||||
void msg.success('Regra criada');
|
||||
const res = (await createMutation.mutateAsync({
|
||||
descricao: values.descricao,
|
||||
codVendedor: values.codVendedor ?? null,
|
||||
codGrupos: values.codGrupos?.length ? values.codGrupos : undefined,
|
||||
codSubgrupos: values.codSubgrupos?.length ? values.codSubgrupos : undefined,
|
||||
descPct: values.descPct,
|
||||
dataInicio: inicio.format('YYYY-MM-DD'),
|
||||
dataFim: fim.format('YYYY-MM-DD'),
|
||||
})) as { ids: number[] };
|
||||
void msg.success(res.ids.length > 1 ? `${res.ids.length} regras criadas` : 'Regra criada');
|
||||
}
|
||||
} catch {
|
||||
return; // erro já notificado pelo handler global do QueryClient; modal fica aberto
|
||||
@@ -602,7 +739,8 @@ function TabRegras() {
|
||||
onOk={() => void form.validateFields().then(handleSubmit)}
|
||||
okText={editTarget ? 'Salvar' : 'Criar'}
|
||||
confirmLoading={createMutation.isPending || updateMutation.isPending}
|
||||
width={520}
|
||||
width={760}
|
||||
styles={{ body: { minHeight: 380, maxHeight: '72vh', overflowY: 'auto' } }}
|
||||
centered
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
@@ -624,27 +762,60 @@ function TabRegras() {
|
||||
/>
|
||||
</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>
|
||||
{editTarget ? (
|
||||
<Flex gap={16}>
|
||||
<Form.Item name="codGrupo" label="Grupo de produto" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={grupoOptions}
|
||||
placeholder="Todos os grupos"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
onChange={() => form.setFieldValue('codSubgrupo', undefined)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="codSubgrupo" label="Subgrupo" style={{ flex: 1 }}>
|
||||
<Select
|
||||
options={subgrupoOptions}
|
||||
placeholder="Todos os subgrupos"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
</Form.Item>
|
||||
</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
|
||||
name="descPct"
|
||||
|
||||
Reference in New Issue
Block a user