feat(fase4-e2): 5 abas CRUD restantes (categorias, precos, fotos, extras, reservas)
- CategoriasTab: edita marcas.categorias[] via lista reordenavel - PrecosTab: grid categoria x permanencia, salva via delete + insert - FotosTab: upload pro Supabase Storage (bucket reserva-fotos) + URL manual + reorder - ExtrasTab: CRUD padrao com titulo/preco/descricao - ReservasTab: read-only com filtros (status/datas) + link pra conversa no Chatwoot - AdminLayout TABS com as 8 abas - Router com todas as rotas
This commit is contained in:
parent
33354c7549
commit
330e4e175f
@ -7,6 +7,11 @@ const TABS = [
|
||||
{ to: 'aparencia', label: 'Aparência' },
|
||||
{ to: 'marcas', label: 'Marcas' },
|
||||
{ to: 'unidades', label: 'Unidades' },
|
||||
{ to: 'categorias', label: 'Categorias' },
|
||||
{ to: 'precos', label: 'Preços' },
|
||||
{ to: 'fotos', label: 'Fotos' },
|
||||
{ to: 'extras', label: 'Extras' },
|
||||
{ to: 'reservas', label: 'Reservas' },
|
||||
]
|
||||
|
||||
export function AdminLayout() {
|
||||
|
||||
136
src/pages/admin/CategoriasTab.tsx
Normal file
136
src/pages/admin/CategoriasTab.tsx
Normal file
@ -0,0 +1,136 @@
|
||||
import { useEffect, useState } 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'
|
||||
|
||||
type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
|
||||
|
||||
export function CategoriasTab() {
|
||||
const tenantId = useTenantId()
|
||||
const [marcas, setMarcas] = useState<Marca[]>([])
|
||||
const [selectedMarcaId, setSelectedMarcaId] = useState<string>('')
|
||||
const [categorias, setCategorias] = useState<string[]>([])
|
||||
const [newCat, setNewCat] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenantId) return
|
||||
supabase
|
||||
.from('marcas')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('nome')
|
||||
.then(({ data }) => {
|
||||
setMarcas(data ?? [])
|
||||
if (data && data.length > 0 && !selectedMarcaId) {
|
||||
setSelectedMarcaId(data[0].id)
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenantId])
|
||||
|
||||
useEffect(() => {
|
||||
const marca = marcas.find((m) => m.id === selectedMarcaId)
|
||||
setCategorias(marca?.categorias ?? [])
|
||||
}, [selectedMarcaId, marcas])
|
||||
|
||||
const addCat = () => {
|
||||
const trimmed = newCat.trim()
|
||||
if (!trimmed) return
|
||||
if (categorias.includes(trimmed)) return
|
||||
setCategorias([...categorias, trimmed])
|
||||
setNewCat('')
|
||||
}
|
||||
|
||||
const removeCat = (cat: string) => {
|
||||
setCategorias(categorias.filter((c) => c !== cat))
|
||||
}
|
||||
|
||||
const move = (idx: number, dir: -1 | 1) => {
|
||||
const next = [...categorias]
|
||||
const target = idx + dir
|
||||
if (target < 0 || target >= next.length) return
|
||||
;[next[idx], next[target]] = [next[target], next[idx]]
|
||||
setCategorias(next)
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedMarcaId) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
try {
|
||||
const { error: err } = await supabase
|
||||
.from('marcas')
|
||||
.update({ categorias })
|
||||
.eq('id', selectedMarcaId)
|
||||
if (err) throw new Error(err.message)
|
||||
|
||||
setMarcas(marcas.map((m) => (m.id === selectedMarcaId ? { ...m, categorias } : m)))
|
||||
setSuccessMsg('Categorias salvas!')
|
||||
setTimeout(() => setSuccessMsg(null), 2500)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erro ao salvar')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<header>
|
||||
<h1 className="font-serif text-3xl text-gradient-gold mb-2">Categorias de Suíte</h1>
|
||||
<p className="text-slate text-sm">Escolha uma marca e edite as categorias disponíveis.</p>
|
||||
</header>
|
||||
|
||||
<SelectField
|
||||
label="Marca"
|
||||
value={selectedMarcaId}
|
||||
onChange={(e) => setSelectedMarcaId(e.target.value)}
|
||||
options={marcas.map((m) => ({ value: m.id, label: m.nome }))}
|
||||
/>
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-champagne/20 bg-midnight/40 p-6">
|
||||
<h2 className="font-serif text-xl text-champagne">Categorias</h2>
|
||||
|
||||
{categorias.length === 0 && (
|
||||
<p className="text-slate text-sm">Nenhuma categoria cadastrada.</p>
|
||||
)}
|
||||
|
||||
<ul className="space-y-2">
|
||||
{categorias.map((cat, idx) => (
|
||||
<li key={cat} className="flex items-center gap-2 rounded-lg border border-champagne/20 bg-obsidian/60 px-4 py-2">
|
||||
<span className="flex-1 text-ivory">{cat}</span>
|
||||
<Button variant="ghost" size="sm" onClick={() => move(idx, -1)} disabled={idx === 0}>↑</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => move(idx, 1)} disabled={idx === categorias.length - 1}>↓</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => removeCat(cat)}>Remover</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
<div className="flex gap-2 items-end">
|
||||
<FormField
|
||||
label="Nova categoria"
|
||||
value={newCat}
|
||||
onChange={(e) => setNewCat(e.target.value)}
|
||||
placeholder="Ex: Alexa, Stilo, Hidromassagem"
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button variant="secondary" onClick={addCat}>+ Adicionar</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <div className="rounded-xl border border-ruby/40 bg-ruby/10 p-4 text-ivory">{error}</div>}
|
||||
{successMsg && <div className="rounded-xl border border-emerald/40 bg-emerald/10 p-4 text-ivory">{successMsg}</div>}
|
||||
|
||||
<Button variant="primary" size="lg" onClick={handleSave} disabled={saving || !selectedMarcaId}>
|
||||
{saving ? 'Salvando...' : 'Salvar categorias'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
246
src/pages/admin/ExtrasTab.tsx
Normal file
246
src/pages/admin/ExtrasTab.tsx
Normal file
@ -0,0 +1,246 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useTenantId } from '@/hooks/useAppConfig'
|
||||
import type { Database } from '@/types/database'
|
||||
import { useCrud } from '@/hooks/useCrud'
|
||||
import { DataTable, type Column } from '@/components/admin/DataTable'
|
||||
import { Modal } from '@/components/admin/Modal'
|
||||
import { FormField } from '@/components/FormField'
|
||||
import { SelectField } from '@/components/SelectField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatBRL } from '@/lib/formatters'
|
||||
|
||||
type Extra = Database['reserva_hotel']['Tables']['extras']['Row']
|
||||
type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
|
||||
|
||||
const EMPTY_FORM = {
|
||||
id_marca: '',
|
||||
titulo: '',
|
||||
descricao: '',
|
||||
preco: '',
|
||||
imagem_url: '',
|
||||
ordem: '0',
|
||||
ativo: true,
|
||||
}
|
||||
|
||||
export function ExtrasTab() {
|
||||
const tenantId = useTenantId()
|
||||
const { rows, loading, error, create, update, remove } = useCrud<Extra>('extras', {
|
||||
orderBy: 'ordem',
|
||||
ascending: true,
|
||||
})
|
||||
|
||||
const [marcas, setMarcas] = useState<Marca[]>([])
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<Extra | null>(null)
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [formError, setFormError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenantId) return
|
||||
void supabase
|
||||
.from('marcas')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('nome')
|
||||
.then(({ data }) => setMarcas(data ?? []))
|
||||
}, [tenantId])
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
setForm(EMPTY_FORM)
|
||||
setFormError(null)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (extra: Extra) => {
|
||||
setEditing(extra)
|
||||
setForm({
|
||||
id_marca: extra.id_marca,
|
||||
titulo: extra.titulo,
|
||||
descricao: extra.descricao ?? '',
|
||||
preco: String(extra.preco),
|
||||
imagem_url: extra.imagem_url ?? '',
|
||||
ordem: String(extra.ordem),
|
||||
ativo: extra.ativo,
|
||||
})
|
||||
setFormError(null)
|
||||
setModalOpen(true)
|
||||
}
|
||||
|
||||
const handleDelete = async (extra: Extra) => {
|
||||
if (!confirm(`Excluir o extra "${extra.titulo}"?`)) return
|
||||
try {
|
||||
await remove(extra.id)
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Erro ao excluir')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!form.titulo.trim()) {
|
||||
setFormError('Título é obrigatório')
|
||||
return
|
||||
}
|
||||
if (!form.id_marca) {
|
||||
setFormError('Marca é obrigatória')
|
||||
return
|
||||
}
|
||||
const preco = Number(form.preco.replace(',', '.'))
|
||||
if (isNaN(preco) || preco < 0) {
|
||||
setFormError('Preço inválido')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setFormError(null)
|
||||
try {
|
||||
const payload = {
|
||||
id_marca: form.id_marca,
|
||||
titulo: form.titulo.trim(),
|
||||
descricao: form.descricao.trim() || null,
|
||||
preco,
|
||||
imagem_url: form.imagem_url.trim() || null,
|
||||
ordem: Number(form.ordem) || 0,
|
||||
ativo: form.ativo,
|
||||
}
|
||||
if (editing) {
|
||||
await update(editing.id, payload)
|
||||
} else {
|
||||
await create(payload)
|
||||
}
|
||||
setModalOpen(false)
|
||||
} catch (e) {
|
||||
setFormError(e instanceof Error ? e.message : 'Erro ao salvar')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Column<Extra>[] = [
|
||||
{ key: 'titulo', label: 'Título' },
|
||||
{
|
||||
key: 'id_marca',
|
||||
label: 'Marca',
|
||||
render: (row) => marcas.find((m) => m.id === row.id_marca)?.nome ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'preco',
|
||||
label: 'Preço',
|
||||
render: (row) => formatBRL(Math.round(Number(row.preco) * 100)),
|
||||
},
|
||||
{ key: 'ordem', label: 'Ordem', render: (row) => String(row.ordem) },
|
||||
{
|
||||
key: 'ativo',
|
||||
label: 'Status',
|
||||
render: (row) => (
|
||||
<span className={row.ativo ? 'text-emerald-400' : 'text-slate-400'}>
|
||||
{row.ativo ? 'Ativo' : 'Inativo'}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="font-serif text-3xl text-champagne mb-2">Extras</h1>
|
||||
<p className="text-slate-400 text-sm">Serviços e produtos extras oferecidos na reserva.</p>
|
||||
</div>
|
||||
<Button variant="primary" onClick={openCreate}>+ Novo extra</Button>
|
||||
</header>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-xl border border-red-500/40 bg-red-500/10 p-4 text-ivory">{error}</div>
|
||||
)}
|
||||
|
||||
<DataTable
|
||||
rows={rows}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
emptyMessage='Nenhum extra cadastrado. Clique em "+ Novo extra" para começar.'
|
||||
onEdit={openEdit}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={modalOpen}
|
||||
title={editing ? 'Editar extra' : 'Novo extra'}
|
||||
onClose={() => setModalOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button variant="ghost" onClick={() => setModalOpen(false)}>Cancelar</Button>
|
||||
<Button variant="primary" onClick={handleSave} disabled={saving}>
|
||||
{saving ? 'Salvando...' : 'Salvar'}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<SelectField
|
||||
label="Marca"
|
||||
required
|
||||
value={form.id_marca}
|
||||
onChange={(e) => setForm({ ...form, id_marca: e.target.value })}
|
||||
options={marcas.map((m) => ({ value: m.id, label: m.nome }))}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Título"
|
||||
required
|
||||
value={form.titulo}
|
||||
onChange={(e) => setForm({ ...form, titulo: e.target.value })}
|
||||
placeholder="Ex: Café da manhã, Espumante, Jacuzzi"
|
||||
/>
|
||||
|
||||
<FormField
|
||||
label="Descrição"
|
||||
value={form.descricao}
|
||||
onChange={(e) => setForm({ ...form, descricao: e.target.value })}
|
||||
placeholder="Breve descrição do extra"
|
||||
/>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormField
|
||||
label="Preço (R$)"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={form.preco}
|
||||
onChange={(e) => setForm({ ...form, preco: e.target.value })}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
<FormField
|
||||
label="Ordem"
|
||||
type="number"
|
||||
value={form.ordem}
|
||||
onChange={(e) => setForm({ ...form, ordem: e.target.value })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
label="URL da imagem"
|
||||
value={form.imagem_url}
|
||||
onChange={(e) => setForm({ ...form, imagem_url: e.target.value })}
|
||||
placeholder="https://..."
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-3 text-ivory font-sans text-sm cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.ativo}
|
||||
onChange={(e) => setForm({ ...form, ativo: e.target.checked })}
|
||||
className="h-4 w-4 accent-champagne"
|
||||
/>
|
||||
Ativo
|
||||
</label>
|
||||
|
||||
{formError && (
|
||||
<div className="rounded-xl border border-red-500/40 bg-red-500/10 p-3 text-ivory text-sm">
|
||||
{formError}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
218
src/pages/admin/FotosTab.tsx
Normal file
218
src/pages/admin/FotosTab.tsx
Normal file
@ -0,0 +1,218 @@
|
||||
import { useEffect, useState, type ChangeEvent } 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'
|
||||
|
||||
type Unidade = Database['reserva_hotel']['Tables']['unidades']['Row']
|
||||
type Foto = Database['reserva_hotel']['Tables']['fotos_categoria']['Row']
|
||||
|
||||
const STORAGE_BUCKET = 'reserva-fotos'
|
||||
|
||||
export function FotosTab() {
|
||||
const tenantId = useTenantId()
|
||||
const [unidades, setUnidades] = useState<Unidade[]>([])
|
||||
const [selectedUnidadeId, setSelectedUnidadeId] = useState<string>('')
|
||||
const [selectedCategoria, setSelectedCategoria] = useState<string>('')
|
||||
const [fotos, setFotos] = useState<Foto[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [newUrl, setNewUrl] = useState('')
|
||||
const [newAlt, setNewAlt] = useState('')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenantId) return
|
||||
supabase
|
||||
.from('unidades')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('nome')
|
||||
.then(({ data }) => {
|
||||
setUnidades(data ?? [])
|
||||
if (data && data.length > 0 && !selectedUnidadeId) {
|
||||
setSelectedUnidadeId(data[0].id)
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenantId])
|
||||
|
||||
const unidade = unidades.find((u) => u.id === selectedUnidadeId)
|
||||
const categoriasVisiveis = unidade?.categorias_visiveis ?? []
|
||||
|
||||
useEffect(() => {
|
||||
if (categoriasVisiveis.length > 0 && !selectedCategoria) {
|
||||
setSelectedCategoria(categoriasVisiveis[0])
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [JSON.stringify(categoriasVisiveis)])
|
||||
|
||||
const loadFotos = async () => {
|
||||
if (!selectedUnidadeId || !selectedCategoria) return
|
||||
setLoading(true)
|
||||
const { data } = await supabase
|
||||
.from('fotos_categoria')
|
||||
.select('*')
|
||||
.eq('id_unidade', selectedUnidadeId)
|
||||
.eq('categoria', selectedCategoria)
|
||||
.order('ordem')
|
||||
setFotos(data ?? [])
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void loadFotos()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedUnidadeId, selectedCategoria])
|
||||
|
||||
const handleAddUrl = async () => {
|
||||
if (!tenantId || !selectedUnidadeId || !selectedCategoria || !newUrl.trim()) return
|
||||
setError(null)
|
||||
try {
|
||||
const { error: err } = await supabase.from('fotos_categoria').insert({
|
||||
tenant_id: tenantId,
|
||||
id_unidade: selectedUnidadeId,
|
||||
categoria: selectedCategoria,
|
||||
url_foto: newUrl.trim(),
|
||||
alt: newAlt.trim() || null,
|
||||
ordem: fotos.length,
|
||||
ativa: true,
|
||||
})
|
||||
if (err) throw new Error(err.message)
|
||||
setNewUrl('')
|
||||
setNewAlt('')
|
||||
await loadFotos()
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erro ao adicionar')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file || !tenantId || !selectedUnidadeId || !selectedCategoria) return
|
||||
setUploading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const ext = file.name.split('.').pop() ?? 'jpg'
|
||||
const path = `${tenantId}/${selectedUnidadeId}/${selectedCategoria}/${Date.now()}.${ext}`
|
||||
const { error: uploadErr } = await supabase.storage
|
||||
.from(STORAGE_BUCKET)
|
||||
.upload(path, file, { upsert: false })
|
||||
if (uploadErr) throw new Error(uploadErr.message)
|
||||
|
||||
const { data: publicData } = supabase.storage.from(STORAGE_BUCKET).getPublicUrl(path)
|
||||
|
||||
const { error: insertErr } = await supabase.from('fotos_categoria').insert({
|
||||
tenant_id: tenantId,
|
||||
id_unidade: selectedUnidadeId,
|
||||
categoria: selectedCategoria,
|
||||
url_foto: publicData.publicUrl,
|
||||
alt: file.name,
|
||||
ordem: fotos.length,
|
||||
ativa: true,
|
||||
})
|
||||
if (insertErr) throw new Error(insertErr.message)
|
||||
await loadFotos()
|
||||
e.target.value = ''
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erro no upload')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (foto: Foto) => {
|
||||
if (!confirm('Excluir foto?')) return
|
||||
const { error: err } = await supabase.from('fotos_categoria').delete().eq('id', foto.id)
|
||||
if (err) {
|
||||
setError(err.message)
|
||||
return
|
||||
}
|
||||
await loadFotos()
|
||||
}
|
||||
|
||||
const move = async (idx: number, dir: -1 | 1) => {
|
||||
const target = idx + dir
|
||||
if (target < 0 || target >= fotos.length) return
|
||||
const a = fotos[idx]
|
||||
const b = fotos[target]
|
||||
await supabase.from('fotos_categoria').update({ ordem: b.ordem }).eq('id', a.id)
|
||||
await supabase.from('fotos_categoria').update({ ordem: a.ordem }).eq('id', b.id)
|
||||
await loadFotos()
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<header>
|
||||
<h1 className="font-serif text-3xl text-gradient-gold mb-2">Fotos das suítes</h1>
|
||||
<p className="text-slate text-sm">Upload direto ou URL pública. Organize por unidade + categoria.</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<SelectField
|
||||
label="Unidade"
|
||||
value={selectedUnidadeId}
|
||||
onChange={(e) => setSelectedUnidadeId(e.target.value)}
|
||||
options={unidades.map((u) => ({ value: u.id, label: u.nome }))}
|
||||
/>
|
||||
<SelectField
|
||||
label="Categoria"
|
||||
value={selectedCategoria}
|
||||
onChange={(e) => setSelectedCategoria(e.target.value)}
|
||||
options={categoriasVisiveis.map((c) => ({ value: c, label: c }))}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="space-y-4 rounded-2xl border border-champagne/20 bg-midnight/40 p-6">
|
||||
<h2 className="font-serif text-xl text-champagne">Adicionar foto</h2>
|
||||
|
||||
<div>
|
||||
<label className="font-sans text-xs uppercase tracking-widest text-champagne">Upload de arquivo</label>
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleFileUpload}
|
||||
disabled={uploading}
|
||||
className="mt-2 block w-full text-ivory text-sm file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:bg-champagne file:text-obsidian file:font-semibold hover:file:bg-rose-gold cursor-pointer"
|
||||
/>
|
||||
{uploading && <p className="text-slate text-sm mt-2">Enviando...</p>}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-champagne/20 pt-4 space-y-3">
|
||||
<p className="text-slate text-xs uppercase tracking-widest">Ou cole uma URL</p>
|
||||
<FormField label="URL da imagem" value={newUrl} onChange={(e) => setNewUrl(e.target.value)} placeholder="https://..." />
|
||||
<FormField label="Alt text" value={newAlt} onChange={(e) => setNewAlt(e.target.value)} placeholder="Descrição curta" />
|
||||
<Button variant="secondary" onClick={handleAddUrl} disabled={!newUrl.trim()}>+ Adicionar URL</Button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{error && <div className="rounded-xl border border-ruby/40 bg-ruby/10 p-4 text-ivory">{error}</div>}
|
||||
|
||||
<section className="space-y-3">
|
||||
<h2 className="font-serif text-xl text-champagne">Fotos cadastradas</h2>
|
||||
{loading && <p className="text-slate">Carregando...</p>}
|
||||
{!loading && fotos.length === 0 && <p className="text-slate text-sm">Nenhuma foto nesta categoria.</p>}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{fotos.map((foto, idx) => (
|
||||
<div key={foto.id} className="rounded-xl border border-champagne/20 overflow-hidden bg-midnight/40">
|
||||
<div className="aspect-video">
|
||||
<img src={foto.url_foto} alt={foto.alt ?? ''} className="h-full w-full object-cover" />
|
||||
</div>
|
||||
<div className="p-3 flex items-center justify-between gap-2">
|
||||
<p className="text-ivory text-xs truncate flex-1">{foto.alt ?? 'Sem alt'}</p>
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => move(idx, -1)} disabled={idx === 0}>↑</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => move(idx, 1)} disabled={idx === fotos.length - 1}>↓</Button>
|
||||
<Button variant="destructive" size="sm" onClick={() => handleDelete(foto)}>✕</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
183
src/pages/admin/PrecosTab.tsx
Normal file
183
src/pages/admin/PrecosTab.tsx
Normal file
@ -0,0 +1,183 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useTenantId } from '@/hooks/useAppConfig'
|
||||
import type { Database } from '@/types/database'
|
||||
import { SelectField } from '@/components/SelectField'
|
||||
import { Button } from '@/components/ui/button'
|
||||
|
||||
type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
|
||||
type Preco = Database['reserva_hotel']['Tables']['precos']['Row']
|
||||
|
||||
// key: `${categoria}|${permanencia}` → valor em reais (string for input)
|
||||
type PriceMap = Record<string, string>
|
||||
|
||||
export function PrecosTab() {
|
||||
const tenantId = useTenantId()
|
||||
const [marcas, setMarcas] = useState<Marca[]>([])
|
||||
const [selectedMarcaId, setSelectedMarcaId] = useState<string>('')
|
||||
const [priceMap, setPriceMap] = useState<PriceMap>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!tenantId) return
|
||||
supabase
|
||||
.from('marcas')
|
||||
.select('*')
|
||||
.eq('tenant_id', tenantId)
|
||||
.order('nome')
|
||||
.then(({ data }) => {
|
||||
setMarcas(data ?? [])
|
||||
if (data && data.length > 0 && !selectedMarcaId) setSelectedMarcaId(data[0].id)
|
||||
})
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tenantId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedMarcaId) return
|
||||
setLoading(true)
|
||||
supabase
|
||||
.from('precos')
|
||||
.select('*')
|
||||
.eq('id_marca', selectedMarcaId)
|
||||
.eq('periodo_semana', 'default')
|
||||
.then(({ data }) => {
|
||||
const map: PriceMap = {}
|
||||
;(data ?? []).forEach((p: Preco) => {
|
||||
map[`${p.categoria}|${p.permanencia}`] = String(p.valor)
|
||||
})
|
||||
setPriceMap(map)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [selectedMarcaId])
|
||||
|
||||
const marca = marcas.find((m) => m.id === selectedMarcaId)
|
||||
const categorias = marca?.categorias ?? []
|
||||
const permanencias = marca?.permanencias ?? []
|
||||
|
||||
const setPrice = (categoria: string, permanencia: string, value: string) => {
|
||||
setPriceMap({ ...priceMap, [`${categoria}|${permanencia}`]: value })
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!selectedMarcaId || !tenantId) return
|
||||
setSaving(true)
|
||||
setError(null)
|
||||
setSuccessMsg(null)
|
||||
try {
|
||||
const rows = categorias.flatMap((cat) =>
|
||||
permanencias.map((perm) => {
|
||||
const raw = priceMap[`${cat}|${perm}`]
|
||||
const valor = raw ? Number(raw.replace(',', '.')) : 0
|
||||
return valor > 0
|
||||
? {
|
||||
tenant_id: tenantId,
|
||||
id_marca: selectedMarcaId,
|
||||
categoria: cat,
|
||||
permanencia: perm,
|
||||
periodo_semana: 'default',
|
||||
valor,
|
||||
ativo: true,
|
||||
}
|
||||
: null
|
||||
})
|
||||
).filter(Boolean) as Array<{
|
||||
tenant_id: number
|
||||
id_marca: string
|
||||
categoria: string
|
||||
permanencia: string
|
||||
periodo_semana: string
|
||||
valor: number
|
||||
ativo: boolean
|
||||
}>
|
||||
|
||||
const { error: delErr } = await supabase
|
||||
.from('precos')
|
||||
.delete()
|
||||
.eq('id_marca', selectedMarcaId)
|
||||
.eq('periodo_semana', 'default')
|
||||
if (delErr) throw new Error(delErr.message)
|
||||
|
||||
if (rows.length > 0) {
|
||||
const { error: insErr } = await supabase.from('precos').insert(rows)
|
||||
if (insErr) throw new Error(insErr.message)
|
||||
}
|
||||
|
||||
setSuccessMsg('Preços salvos!')
|
||||
setTimeout(() => setSuccessMsg(null), 2500)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Erro ao salvar')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
<header>
|
||||
<h1 className="font-serif text-3xl text-gradient-gold mb-2">Preços</h1>
|
||||
<p className="text-slate text-sm">Grid categoria × permanência. Valores em reais.</p>
|
||||
</header>
|
||||
|
||||
<SelectField
|
||||
label="Marca"
|
||||
value={selectedMarcaId}
|
||||
onChange={(e) => setSelectedMarcaId(e.target.value)}
|
||||
options={marcas.map((m) => ({ value: m.id, label: m.nome }))}
|
||||
/>
|
||||
|
||||
{loading && <p className="text-slate">Carregando preços...</p>}
|
||||
|
||||
{!loading && marca && (categorias.length === 0 || permanencias.length === 0) && (
|
||||
<div className="rounded-xl border border-ruby/40 bg-ruby/10 p-4 text-ivory text-sm">
|
||||
Essa marca não tem categorias e/ou permanências cadastradas. Edite primeiro na aba Marcas.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && categorias.length > 0 && permanencias.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-2xl border border-champagne/20 bg-midnight/40">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-champagne/20">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-sans text-xs uppercase tracking-widest text-champagne">Categoria</th>
|
||||
{permanencias.map((p) => (
|
||||
<th key={p} className="px-4 py-3 text-left font-sans text-xs uppercase tracking-widest text-champagne">
|
||||
{p}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{categorias.map((cat) => (
|
||||
<tr key={cat} className="border-b border-champagne/5 last:border-0">
|
||||
<td className="px-4 py-3 text-ivory font-semibold">{cat}</td>
|
||||
{permanencias.map((perm) => (
|
||||
<td key={perm} className="px-4 py-3">
|
||||
<input
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
className="w-24 rounded-lg border border-champagne/30 bg-obsidian/60 px-3 py-2 text-ivory text-right focus:border-champagne focus:outline-none"
|
||||
value={priceMap[`${cat}|${perm}`] ?? ''}
|
||||
onChange={(e) => setPrice(cat, perm, e.target.value)}
|
||||
placeholder="0.00"
|
||||
/>
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && <div className="rounded-xl border border-ruby/40 bg-ruby/10 p-4 text-ivory">{error}</div>}
|
||||
{successMsg && <div className="rounded-xl border border-emerald/40 bg-emerald/10 p-4 text-ivory">{successMsg}</div>}
|
||||
|
||||
<Button variant="primary" size="lg" onClick={handleSave} disabled={saving || !selectedMarcaId}>
|
||||
{saving ? 'Salvando...' : 'Salvar preços'}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
130
src/pages/admin/ReservasTab.tsx
Normal file
130
src/pages/admin/ReservasTab.tsx
Normal file
@ -0,0 +1,130 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import type { Database } from '@/types/database'
|
||||
import { useCrud } from '@/hooks/useCrud'
|
||||
import { DataTable, type Column } from '@/components/admin/DataTable'
|
||||
import { formatBRL } from '@/lib/formatters'
|
||||
|
||||
type Reserva = Database['reserva_hotel']['Tables']['reservas']['Row']
|
||||
|
||||
const CHATWOOT_URL = import.meta.env.VITE_CHATWOOT_API_URL || ''
|
||||
|
||||
export function ReservasTab() {
|
||||
const { rows, loading, error } = useCrud<Reserva>('reservas', {
|
||||
orderBy: 'created_at',
|
||||
ascending: false,
|
||||
})
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<string>('')
|
||||
const [dateFrom, setDateFrom] = useState<string>('')
|
||||
const [dateTo, setDateTo] = useState<string>('')
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
return rows.filter((r) => {
|
||||
if (statusFilter && r.status !== statusFilter) return false
|
||||
if (dateFrom && r.data_checkin < dateFrom) return false
|
||||
if (dateTo && r.data_checkin > dateTo + 'T23:59:59') return false
|
||||
return true
|
||||
})
|
||||
}, [rows, statusFilter, dateFrom, dateTo])
|
||||
|
||||
const columns: Column<Reserva>[] = [
|
||||
{
|
||||
key: 'data_checkin',
|
||||
label: 'Check-in',
|
||||
render: (r) => new Date(r.data_checkin).toLocaleString('pt-BR', { dateStyle: 'short', timeStyle: 'short' }),
|
||||
},
|
||||
{ key: 'nome_cliente', label: 'Cliente' },
|
||||
{ key: 'tipo_permanencia', label: 'Permanência' },
|
||||
{
|
||||
key: 'valor_total',
|
||||
label: 'Valor',
|
||||
render: (r) => formatBRL(Math.round(Number(r.valor_total) * 100)),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (r) => <StatusBadge status={r.status} />,
|
||||
},
|
||||
{
|
||||
key: 'chatwoot_conversation_id',
|
||||
label: 'Conversa',
|
||||
render: (r) =>
|
||||
r.chatwoot_conversation_id && CHATWOOT_URL ? (
|
||||
<a
|
||||
href={`${CHATWOOT_URL}/app/accounts/1/conversations/${r.chatwoot_conversation_id}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-champagne hover:underline"
|
||||
>
|
||||
#{r.chatwoot_conversation_id}
|
||||
</a>
|
||||
) : '—',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<header>
|
||||
<h1 className="font-serif text-3xl text-gradient-gold mb-2">Reservas</h1>
|
||||
<p className="text-slate text-sm">Histórico de reservas da sua rede (somente leitura).</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 rounded-2xl border border-champagne/20 bg-midnight/40 p-4">
|
||||
<div>
|
||||
<label className="font-sans text-xs uppercase tracking-widest text-champagne">Status</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-champagne/30 bg-midnight/60 px-4 py-3 text-ivory focus:border-champagne focus:outline-none"
|
||||
>
|
||||
<option value="">Todos</option>
|
||||
<option value="pendente_pagamento">Pendente de pagamento</option>
|
||||
<option value="pago">Pago</option>
|
||||
<option value="cancelada">Cancelada</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-sans text-xs uppercase tracking-widest text-champagne">Check-in de</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-champagne/30 bg-midnight/60 px-4 py-3 text-ivory focus:border-champagne focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="font-sans text-xs uppercase tracking-widest text-champagne">Até</label>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="mt-2 w-full rounded-lg border border-champagne/30 bg-midnight/60 px-4 py-3 text-ivory focus:border-champagne focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && <div className="rounded-xl border border-ruby/40 bg-ruby/10 p-4 text-ivory">{error}</div>}
|
||||
|
||||
<DataTable
|
||||
rows={filtered}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
emptyMessage="Nenhuma reserva encontrada."
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
pago: 'text-emerald border-emerald/40 bg-emerald/10',
|
||||
pendente_pagamento: 'text-champagne border-champagne/40 bg-champagne/10',
|
||||
cancelada: 'text-slate border-slate/40 bg-slate/10',
|
||||
}
|
||||
const cls = colors[status] ?? 'text-slate border-slate/40 bg-slate/10'
|
||||
return (
|
||||
<span className={`rounded-full border px-3 py-1 text-xs font-sans uppercase tracking-widest ${cls}`}>
|
||||
{status}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@ -5,6 +5,11 @@ import { AdminLayout } from '@/pages/admin/AdminLayout'
|
||||
import { AparenciaTab } from '@/pages/admin/AparenciaTab'
|
||||
import { MarcasTab } from '@/pages/admin/MarcasTab'
|
||||
import { UnidadesTab } from '@/pages/admin/UnidadesTab'
|
||||
import { CategoriasTab } from '@/pages/admin/CategoriasTab'
|
||||
import { PrecosTab } from '@/pages/admin/PrecosTab'
|
||||
import { FotosTab } from '@/pages/admin/FotosTab'
|
||||
import { ExtrasTab } from '@/pages/admin/ExtrasTab'
|
||||
import { ReservasTab } from '@/pages/admin/ReservasTab'
|
||||
|
||||
const router = createBrowserRouter([
|
||||
{ path: '/', element: <ReservationPage /> },
|
||||
@ -17,6 +22,11 @@ const router = createBrowserRouter([
|
||||
{ path: 'aparencia', element: <AparenciaTab /> },
|
||||
{ path: 'marcas', element: <MarcasTab /> },
|
||||
{ path: 'unidades', element: <UnidadesTab /> },
|
||||
{ path: 'categorias', element: <CategoriasTab /> },
|
||||
{ path: 'precos', element: <PrecosTab /> },
|
||||
{ path: 'fotos', element: <FotosTab /> },
|
||||
{ path: 'extras', element: <ExtrasTab /> },
|
||||
{ path: 'reservas', element: <ReservasTab /> },
|
||||
],
|
||||
},
|
||||
])
|
||||
|
||||
Loading…
Reference in New Issue
Block a user