From 337229ce222a9c52bb193b1acd612ff4fb43cdac Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Tue, 14 Apr 2026 22:29:52 -0300 Subject: [PATCH] feat: multi-periodo de precos com CRUD na aba Precos - PrecosTab: lista periodos como chips clicaveis (editar/excluir inline) - Modal de CRUD de periodo: nome + checkboxes dos 7 dias da semana - Grid de precos filtrado pelo periodo selecionado - Salvar afeta so o periodo ativo (delete + insert scoped) - Nao permite excluir o ultimo periodo - catalogoService.findPrecoForDate resolve o periodo pelo dia da semana - useReservationForm usa findPrecoForDate quando checkinAt esta preenchido Co-Authored-By: Claude Sonnet 4.6 --- src/hooks/useReservationForm.ts | 33 +++- src/pages/admin/PrecosTab.tsx | 321 +++++++++++++++++++++++++++++--- src/services/catalogoService.ts | 38 ++++ 3 files changed, 364 insertions(+), 28 deletions(-) diff --git a/src/hooks/useReservationForm.ts b/src/hooks/useReservationForm.ts index 0f2b758..4a2df89 100644 --- a/src/hooks/useReservationForm.ts +++ b/src/hooks/useReservationForm.ts @@ -74,16 +74,35 @@ export function useReservationForm(initialPrefill?: PrefillData) { }, [tenantId, form.marcaId]) useEffect(() => { - if (!tenantId) return - if (!form.marcaId || !form.categoria || !form.permanencia) { + if (!tenantId || !form.marcaId || !form.categoria || !form.permanencia) { setPreco(null) return } - catalogoService - .findPreco(tenantId, form.marcaId, form.categoria, form.permanencia) - .then(setPreco) - .catch((err: Error) => setError(err.message)) - }, [tenantId, form.marcaId, form.categoria, form.permanencia]) + const fetchPreco = async () => { + try { + const checkinDate = form.checkinAt ? new Date(form.checkinAt) : null + const p = + checkinDate && !isNaN(checkinDate.getTime()) + ? await catalogoService.findPrecoForDate( + tenantId, + form.marcaId, + form.categoria, + form.permanencia, + checkinDate + ) + : await catalogoService.findPreco( + tenantId, + form.marcaId, + form.categoria, + form.permanencia + ) + setPreco(p) + } catch (err) { + setError(err instanceof Error ? err.message : 'Erro ao carregar preço') + } + } + void fetchPreco() + }, [tenantId, form.marcaId, form.categoria, form.permanencia, form.checkinAt]) useEffect(() => { if (!tenantId) return diff --git a/src/pages/admin/PrecosTab.tsx b/src/pages/admin/PrecosTab.tsx index 56bc778..f8d7084 100644 --- a/src/pages/admin/PrecosTab.tsx +++ b/src/pages/admin/PrecosTab.tsx @@ -1,26 +1,59 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useMemo } from 'react' import { supabase } from '@/lib/supabase' import { useTenantId } from '@/hooks/useAppConfig' import type { Database } from '@/types/database' import { SelectField } from '@/components/SelectField' +import { FormField } from '@/components/FormField' import { Button } from '@/components/ui/button' +import { Modal } from '@/components/admin/Modal' type Marca = Database['reserva_hotel']['Tables']['marcas']['Row'] type Preco = Database['reserva_hotel']['Tables']['precos']['Row'] +type Periodo = Database['reserva_hotel']['Tables']['marca_periodos']['Row'] // key: `${categoria}|${permanencia}` → valor em reais (string for input) type PriceMap = Record +const DIAS_SEMANA = [ + { value: 0, label: 'Dom' }, + { value: 1, label: 'Seg' }, + { value: 2, label: 'Ter' }, + { value: 3, label: 'Qua' }, + { value: 4, label: 'Qui' }, + { value: 5, label: 'Sex' }, + { value: 6, label: 'Sáb' }, +] + +function slugify(text: string): string { + return text + .toLowerCase() + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/(^-|-$)/g, '') + .slice(0, 50) || 'periodo' +} + export function PrecosTab() { const tenantId = useTenantId() const [marcas, setMarcas] = useState([]) const [selectedMarcaId, setSelectedMarcaId] = useState('') + const [periodos, setPeriodos] = useState([]) + const [selectedPeriodoId, setSelectedPeriodoId] = useState('') const [priceMap, setPriceMap] = useState({}) const [loading, setLoading] = useState(false) const [saving, setSaving] = useState(false) const [error, setError] = useState(null) const [successMsg, setSuccessMsg] = useState(null) + // Period management modal state + const [periodoModalOpen, setPeriodoModalOpen] = useState(false) + const [editingPeriodo, setEditingPeriodo] = useState(null) + const [periodoForm, setPeriodoForm] = useState({ nome: '', dias: [] as number[] }) + const [periodoFormError, setPeriodoFormError] = useState(null) + const [periodoSaving, setPeriodoSaving] = useState(false) + + // ----- Load marcas ----- useEffect(() => { if (!tenantId) return supabase @@ -30,19 +63,49 @@ export function PrecosTab() { .order('nome') .then(({ data }) => { setMarcas(data ?? []) - if (data && data.length > 0 && !selectedMarcaId) setSelectedMarcaId(data[0].id) + if (data && data.length > 0 && !selectedMarcaId) { + setSelectedMarcaId(data[0].id) + } }) // eslint-disable-next-line react-hooks/exhaustive-deps }, [tenantId]) + // ----- Load periodos when marca changes ----- + const loadPeriodos = async (marcaId: string) => { + if (!marcaId) return + const { data } = await supabase + .from('marca_periodos') + .select('*') + .eq('id_marca', marcaId) + .eq('ativo', true) + .order('ordem') + const list = data ?? [] + setPeriodos(list) + // Seleciona o primeiro período se nenhum está selecionado ou o atual sumiu + if (list.length > 0 && !list.find((p) => p.id === selectedPeriodoId)) { + setSelectedPeriodoId(list[0].id) + } + } + useEffect(() => { - if (!selectedMarcaId) return + if (selectedMarcaId) void loadPeriodos(selectedMarcaId) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [selectedMarcaId]) + + // ----- Load precos when marca+periodo selected ----- + const selectedPeriodo = periodos.find((p) => p.id === selectedPeriodoId) ?? null + + useEffect(() => { + if (!selectedMarcaId || !selectedPeriodo) { + setPriceMap({}) + return + } setLoading(true) supabase .from('precos') .select('*') .eq('id_marca', selectedMarcaId) - .eq('periodo_semana', 'default') + .eq('periodo_semana', selectedPeriodo.slug) .then(({ data }) => { const map: PriceMap = {} ;(data ?? []).forEach((p: Preco) => { @@ -51,18 +114,19 @@ export function PrecosTab() { setPriceMap(map) setLoading(false) }) - }, [selectedMarcaId]) + }, [selectedMarcaId, selectedPeriodo]) const marca = marcas.find((m) => m.id === selectedMarcaId) - const categorias = marca?.categorias ?? [] - const permanencias = marca?.permanencias ?? [] + const categorias = useMemo(() => marca?.categorias ?? [], [marca]) + const permanencias = useMemo(() => marca?.permanencias ?? [], [marca]) const setPrice = (categoria: string, permanencia: string, value: string) => { setPriceMap({ ...priceMap, [`${categoria}|${permanencia}`]: value }) } + // ----- Save prices for selected period ----- const handleSave = async () => { - if (!selectedMarcaId || !tenantId) return + if (!selectedMarcaId || !tenantId || !selectedPeriodo) return setSaving(true) setError(null) setSuccessMsg(null) @@ -77,7 +141,7 @@ export function PrecosTab() { id_marca: selectedMarcaId, categoria: cat, permanencia: perm, - periodo_semana: 'default', + periodo_semana: selectedPeriodo.slug, valor, ativo: true, } @@ -97,7 +161,7 @@ export function PrecosTab() { .from('precos') .delete() .eq('id_marca', selectedMarcaId) - .eq('periodo_semana', 'default') + .eq('periodo_semana', selectedPeriodo.slug) if (delErr) throw new Error(delErr.message) if (rows.length > 0) { @@ -105,7 +169,7 @@ export function PrecosTab() { if (insErr) throw new Error(insErr.message) } - setSuccessMsg('Preços salvos!') + setSuccessMsg(`Preços de "${selectedPeriodo.nome}" salvos!`) setTimeout(() => setSuccessMsg(null), 2500) } catch (e) { setError(e instanceof Error ? e.message : 'Erro ao salvar') @@ -114,11 +178,99 @@ export function PrecosTab() { } } + // ----- Period CRUD ----- + const openCreatePeriodo = () => { + setEditingPeriodo(null) + setPeriodoForm({ nome: '', dias: [] }) + setPeriodoFormError(null) + setPeriodoModalOpen(true) + } + + const openEditPeriodo = (p: Periodo) => { + setEditingPeriodo(p) + setPeriodoForm({ nome: p.nome, dias: p.dias ?? [] }) + setPeriodoFormError(null) + setPeriodoModalOpen(true) + } + + const toggleDia = (dia: number) => { + setPeriodoForm((f) => ({ + ...f, + dias: f.dias.includes(dia) ? f.dias.filter((d) => d !== dia) : [...f.dias, dia].sort(), + })) + } + + const handlePeriodoSave = async () => { + if (!tenantId || !selectedMarcaId) return + if (!periodoForm.nome.trim()) return setPeriodoFormError('Nome é obrigatório') + if (periodoForm.dias.length === 0) return setPeriodoFormError('Selecione pelo menos um dia') + + setPeriodoSaving(true) + setPeriodoFormError(null) + try { + if (editingPeriodo) { + const { error: err } = await supabase + .from('marca_periodos') + .update({ + nome: periodoForm.nome.trim(), + dias: periodoForm.dias, + }) + .eq('id', editingPeriodo.id) + if (err) throw new Error(err.message) + } else { + const slug = slugify(periodoForm.nome) + const ordem = periodos.length + const { error: err } = await supabase.from('marca_periodos').insert({ + tenant_id: tenantId, + id_marca: selectedMarcaId, + slug, + nome: periodoForm.nome.trim(), + dias: periodoForm.dias, + ordem, + ativo: true, + }) + if (err) throw new Error(err.message) + } + await loadPeriodos(selectedMarcaId) + setPeriodoModalOpen(false) + } catch (e) { + setPeriodoFormError(e instanceof Error ? e.message : 'Erro ao salvar') + } finally { + setPeriodoSaving(false) + } + } + + const handlePeriodoDelete = async (p: Periodo) => { + if (periodos.length <= 1) { + alert('Não pode excluir o último período. Crie outro antes.') + return + } + if (!confirm(`Excluir o período "${p.nome}"? Os preços cadastrados serão removidos junto.`)) + return + try { + // deleta precos do período primeiro + await supabase + .from('precos') + .delete() + .eq('id_marca', selectedMarcaId) + .eq('periodo_semana', p.slug) + // depois o período + const { error: err } = await supabase.from('marca_periodos').delete().eq('id', p.id) + if (err) throw new Error(err.message) + if (selectedPeriodoId === p.id) setSelectedPeriodoId('') + await loadPeriodos(selectedMarcaId) + } catch (e) { + alert(e instanceof Error ? e.message : 'Erro ao excluir período') + } + } + return ( -
+

Preços

-

Grid categoria × permanência. Valores em reais.

+

+ Grid categoria × permanência por período da semana. Valores em reais. +

({ value: m.id, label: m.nome }))} /> + {selectedMarcaId && ( +
+
+

Período da semana

+ +
+ + {periodos.length === 0 && ( +

Nenhum período cadastrado.

+ )} + + {periodos.length > 0 && ( +
+ {periodos.map((p) => ( +
setSelectedPeriodoId(p.id)} + > + {p.nome} + + ({p.dias.map((d) => DIAS_SEMANA[d]?.label).join(' ')}) + + + +
+ ))} +
+ )} +
+ )} + {loading &&

Carregando preços...

} {!loading && marca && (categorias.length === 0 || permanencias.length === 0) && ( @@ -136,14 +346,19 @@ export function PrecosTab() {
)} - {!loading && categorias.length > 0 && permanencias.length > 0 && ( + {!loading && selectedPeriodo && categorias.length > 0 && permanencias.length > 0 && (
- + {permanencias.map((p) => ( - ))} @@ -172,12 +387,76 @@ export function PrecosTab() { )} - {error &&
{error}
} - {successMsg &&
{successMsg}
} + {error && ( +
{error}
+ )} + {successMsg && ( +
+ {successMsg} +
+ )} - + {selectedPeriodo && ( + + )} + + {/* Period CRUD modal */} + setPeriodoModalOpen(false)} + footer={ + <> + + + + } + > + setPeriodoForm({ ...periodoForm, nome: e.target.value })} + placeholder="Ex: Segunda a Quarta" + /> + +
+ +
+ {DIAS_SEMANA.map((dia) => { + const selected = periodoForm.dias.includes(dia.value) + return ( + + ) + })} +
+
+ + {periodoFormError && ( +
+ {periodoFormError} +
+ )} +
) } diff --git a/src/services/catalogoService.ts b/src/services/catalogoService.ts index 625d437..5dd9a6c 100644 --- a/src/services/catalogoService.ts +++ b/src/services/catalogoService.ts @@ -52,6 +52,44 @@ export const catalogoService = { return data }, + async findPrecoForDate( + tenantId: number, + marcaId: string, + categoria: string, + permanencia: string, + checkinDate: Date + ): Promise { + const dayOfWeek = checkinDate.getDay() // 0=dom..6=sab + + // Resolve o periodo que contem esse dia + const { data: periodos } = await supabase + .from('marca_periodos') + .select('*') + .eq('id_marca', marcaId) + .eq('ativo', true) + .order('ordem') + + const matched = (periodos ?? []).find( + (p) => Array.isArray(p.dias) && p.dias.includes(dayOfWeek) + ) + + const slug = matched?.slug ?? 'default' + + const { data, error } = await supabase + .from('precos') + .select('*') + .eq('tenant_id', tenantId) + .eq('id_marca', marcaId) + .eq('categoria', categoria) + .eq('permanencia', permanencia) + .eq('periodo_semana', slug) + .eq('ativo', true) + .maybeSingle() + + if (error) throw new Error(error.message) + return data + }, + async listFotos(tenantId: number, unidadeId: string, categoria: string): Promise { const { data, error } = await supabase .from('fotos_categoria')
Categoria + Categoria + + {p}