diff --git a/apps/api/src/app/politicas/politicas.controller.ts b/apps/api/src/app/politicas/politicas.controller.ts index e151a7f..625529b 100644 --- a/apps/api/src/app/politicas/politicas.controller.ts +++ b/apps/api/src/app/politicas/politicas.controller.ts @@ -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); } diff --git a/apps/api/src/app/politicas/politicas.service.ts b/apps/api/src/app/politicas/politicas.service.ts index ecfb543..845cb15 100644 --- a/apps/api/src/app/politicas/politicas.service.ts +++ b/apps/api/src/app/politicas/politicas.service.ts @@ -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 { @@ -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 { diff --git a/apps/web/src/cockpits/ger/PoliticasPage.tsx b/apps/web/src/cockpits/ger/PoliticasPage.tsx index 064c51f..a57f1da 100644 --- a/apps/web/src/cockpits/ger/PoliticasPage.tsx +++ b/apps/web/src/cockpits/ger/PoliticasPage.tsx @@ -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>(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 ( + - - - - - - - - + {editTarget ? ( + + + + + + + + + )} (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([ + ...(grupoSelecionado != null ? [grupoSelecionado] : []), + ...(gruposSelecionados ?? []), + ]); const grupoRows: GrupoProdutoItem[] = gruposData?.grupos ?? []; const grupoMap = new Map(); const subgrupoMap = new Map(); 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 >
@@ -624,27 +762,60 @@ function TabRegras() { /> - - - - - + {editTarget ? ( + + + + + + ) : ( + <> + + + + + )} ; +// 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({ descricao: z.string().min(1).max(255), - codProduto: z.string().optional(), - grpProd: z.string().optional(), + codProdutos: z.array(z.string().min(1)).max(100).optional(), + grpProds: z.array(z.string().min(1)).max(100).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 CreatePromocaoBody = z.infer; -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(), }); export type UpdatePromocaoBody = z.infer; @@ -81,10 +96,12 @@ export const RegrasDescontoResponseSchema = z.object({ }); export type RegrasDescontoResponse = z.infer; +// 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({ descricao: z.string().min(1).max(200), - codGrupo: z.number().int().nullable().optional(), - codSubgrupo: z.number().int().nullable().optional(), + codGrupos: z.array(z.number().int()).max(100).optional(), + codSubgrupos: z.array(z.number().int()).max(100).optional(), codVendedor: z.number().int().nullable().optional(), descPct: z.number().min(0).max(100), dataInicio: z.string().regex(/^\d{4}-\d{2}-\d{2}$/), @@ -92,7 +109,21 @@ export const CreateRegraDescontoBodySchema = z.object({ }); export type CreateRegraDescontoBody = z.infer; -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(), }); export type UpdateRegraDescontoBody = z.infer;