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