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:
Rodribm10 2026-04-14 21:20:35 -03:00
parent 33354c7549
commit 330e4e175f
7 changed files with 928 additions and 0 deletions

View File

@ -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() {

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@ -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 /> },
],
},
])