feat(fase4-e1): CRUD infra + abas Marcas e Unidades

- DataTable generico (columns, actions, loading/empty states)
- Modal reutilizavel
- useCrud hook (list/create/update/delete scoped by tenant_id)
- MarcasTab: CRUD completo, categorias/permanencias como lista CSV
- UnidadesTab: CRUD completo, vincula marca + conta_pagamento
  + chatwoot_unit_id + categorias visiveis
- Router + AdminLayout atualizados com as 2 novas abas
This commit is contained in:
Rodribm10 2026-04-14 21:15:17 -03:00
parent d1ee2bdfa1
commit 33354c7549
7 changed files with 680 additions and 1 deletions

View File

@ -0,0 +1,91 @@
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
export interface Column<T> {
key: keyof T | string
label: string
render?: (row: T) => ReactNode
width?: string
}
interface DataTableProps<T extends { id: string | number }> {
rows: T[]
columns: Column<T>[]
loading?: boolean
emptyMessage?: string
onEdit?: (row: T) => void
onDelete?: (row: T) => void
}
export function DataTable<T extends { id: string | number }>({
rows,
columns,
loading = false,
emptyMessage = 'Nenhum registro.',
onEdit,
onDelete,
}: DataTableProps<T>) {
if (loading) {
return (
<div className="rounded-2xl border border-champagne/20 bg-midnight/40 p-8 text-center text-slate">
Carregando...
</div>
)
}
if (rows.length === 0) {
return (
<div className="rounded-2xl border border-champagne/20 bg-midnight/40 p-8 text-center text-slate">
{emptyMessage}
</div>
)
}
return (
<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 text-left">
<tr>
{columns.map((col) => (
<th
key={String(col.key)}
className="px-4 py-3 font-sans text-xs uppercase tracking-widest text-champagne"
style={{ width: col.width }}
>
{col.label}
</th>
))}
{(onEdit || onDelete) && <th className="px-4 py-3 w-32"></th>}
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr key={row.id} className="border-b border-champagne/5 last:border-0 hover:bg-champagne/5">
{columns.map((col) => (
<td key={String(col.key)} className="px-4 py-3 text-ivory">
{col.render ? col.render(row) : String(row[col.key as keyof T] ?? '—')}
</td>
))}
{(onEdit || onDelete) && (
<td className="px-4 py-3 text-right">
<div className="flex gap-2 justify-end">
{onEdit && (
<Button variant="ghost" size="sm" onClick={() => onEdit(row)}>
Editar
</Button>
)}
{onDelete && (
<Button variant="destructive" size="sm" onClick={() => onDelete(row)}>
Excluir
</Button>
)}
</div>
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)
}

View File

@ -0,0 +1,34 @@
import type { ReactNode } from 'react'
import { Button } from '@/components/ui/button'
interface ModalProps {
open: boolean
title: string
onClose: () => void
children: ReactNode
footer?: ReactNode
}
export function Modal({ open, title, onClose, children, footer }: ModalProps) {
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-obsidian/80 backdrop-blur p-4"
onClick={onClose}
>
<div
className="relative w-full max-w-2xl rounded-2xl border border-champagne/30 bg-midnight p-6 glow-champagne max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-6 flex items-center justify-between">
<h2 className="font-serif text-2xl text-champagne">{title}</h2>
<Button variant="ghost" size="sm" onClick={onClose}>
</Button>
</div>
<div className="space-y-4">{children}</div>
{footer && <div className="mt-6 flex gap-3 justify-end">{footer}</div>}
</div>
</div>
)
}

76
src/hooks/useCrud.ts Normal file
View File

@ -0,0 +1,76 @@
import { useCallback, useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { useTenantId } from '@/hooks/useAppConfig'
interface UseCrudOptions {
orderBy?: string
ascending?: boolean
}
// Generic row shape: has `id` and `tenant_id`
export function useCrud<T extends { id: string | number; tenant_id?: number }>(
tableName: string,
options: UseCrudOptions = {}
) {
const tenantId = useTenantId()
const { orderBy = 'created_at', ascending = false } = options
const [rows, setRows] = useState<T[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
if (!tenantId) return
setLoading(true)
setError(null)
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { data, error: err } = await (supabase as any)
.from(tableName)
.select('*')
.eq('tenant_id', tenantId)
.order(orderBy, { ascending })
if (err) throw new Error(err.message)
setRows((data ?? []) as T[])
} catch (e) {
setError(e instanceof Error ? e.message : 'Erro ao carregar')
} finally {
setLoading(false)
}
}, [tableName, tenantId, orderBy, ascending])
useEffect(() => {
void refresh()
}, [refresh])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const create = async (payload: any) => {
if (!tenantId) throw new Error('Tenant não resolvido')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: err } = await (supabase as any)
.from(tableName)
.insert([{ ...payload, tenant_id: tenantId }])
if (err) throw new Error(err.message)
await refresh()
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const update = async (id: string | number, payload: any) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: err } = await (supabase as any)
.from(tableName)
.update(payload)
.eq('id', id)
if (err) throw new Error(err.message)
await refresh()
}
const remove = async (id: string | number) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { error: err } = await (supabase as any).from(tableName).delete().eq('id', id)
if (err) throw new Error(err.message)
await refresh()
}
return { rows, loading, error, refresh, create, update, remove, tenantId }
}

View File

@ -3,7 +3,11 @@ import { AuthGate } from '@/components/admin/AuthGate'
import { useAuth } from '@/hooks/useAuth'
import { Button } from '@/components/ui/button'
const TABS = [{ to: 'aparencia', label: 'Aparência' }]
const TABS = [
{ to: 'aparencia', label: 'Aparência' },
{ to: 'marcas', label: 'Marcas' },
{ to: 'unidades', label: 'Unidades' },
]
export function AdminLayout() {
const { user, signOut } = useAuth()

View File

@ -0,0 +1,197 @@
import { useState } from 'react'
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 { Button } from '@/components/ui/button'
type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
const EMPTY_FORM = {
nome: '',
descricao: '',
categoriasText: '',
permanenciasText: '',
ativa: true,
}
export function MarcasTab() {
const { rows, loading, error, create, update, remove } = useCrud<Marca>('marcas', {
orderBy: 'nome',
ascending: true,
})
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<Marca | null>(null)
const [form, setForm] = useState(EMPTY_FORM)
const [saving, setSaving] = useState(false)
const [formError, setFormError] = useState<string | null>(null)
const openCreate = () => {
setEditing(null)
setForm(EMPTY_FORM)
setFormError(null)
setModalOpen(true)
}
const openEdit = (marca: Marca) => {
setEditing(marca)
setForm({
nome: marca.nome,
descricao: marca.descricao ?? '',
categoriasText: (marca.categorias ?? []).join(', '),
permanenciasText: (marca.permanencias ?? []).join(', '),
ativa: marca.ativa ?? true,
})
setFormError(null)
setModalOpen(true)
}
const handleDelete = async (marca: Marca) => {
if (!confirm(`Excluir a marca "${marca.nome}"? Essa ação é irreversível.`)) return
try {
await remove(marca.id)
} catch (e) {
alert(e instanceof Error ? e.message : 'Erro ao excluir')
}
}
const handleSave = async () => {
if (!form.nome.trim()) {
setFormError('Nome é obrigatório')
return
}
setSaving(true)
setFormError(null)
try {
const payload = {
nome: form.nome.trim(),
descricao: form.descricao.trim() || null,
categorias: parseList(form.categoriasText),
permanencias: parseList(form.permanenciasText),
ativa: form.ativa,
}
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<Marca>[] = [
{ key: 'nome', label: 'Nome' },
{
key: 'categorias',
label: 'Categorias',
render: (row) => (row.categorias ?? []).join(', ') || '—',
},
{
key: 'permanencias',
label: 'Permanências',
render: (row) => (row.permanencias ?? []).join(', ') || '—',
},
{
key: 'ativa',
label: 'Status',
render: (row) => (
<span className={row.ativa ? 'text-emerald-400' : 'text-slate-400'}>
{row.ativa ? 'Ativa' : 'Inativa'}
</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">Marcas</h1>
<p className="text-slate-400 text-sm">Gerencie as marcas (redes) desta conta.</p>
</div>
<Button variant="primary" onClick={openCreate}>+ Nova marca</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='Nenhuma marca cadastrada. Clique em "+ Nova marca" para começar.'
onEdit={openEdit}
onDelete={handleDelete}
/>
<Modal
open={modalOpen}
title={editing ? 'Editar marca' : 'Nova marca'}
onClose={() => setModalOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Salvando...' : 'Salvar'}
</Button>
</>
}
>
<FormField
label="Nome da marca"
required
value={form.nome}
onChange={(e) => setForm({ ...form, nome: e.target.value })}
placeholder="Ex: Hotel 1001 Noites Prime"
/>
<FormField
label="Descrição"
value={form.descricao}
onChange={(e) => setForm({ ...form, descricao: e.target.value })}
placeholder="Breve descrição"
/>
<FormField
label="Categorias disponíveis (separadas por vírgula)"
value={form.categoriasText}
onChange={(e) => setForm({ ...form, categoriasText: e.target.value })}
placeholder="Alexa, Stilo, Hidromassagem"
/>
<FormField
label="Permanências disponíveis (separadas por vírgula)"
value={form.permanenciasText}
onChange={(e) => setForm({ ...form, permanenciasText: e.target.value })}
placeholder="2hrs, 3hrs, 4hrs, Pernoite, Diária"
/>
<label className="flex items-center gap-3 text-ivory font-sans text-sm cursor-pointer">
<input
type="checkbox"
checked={form.ativa}
onChange={(e) => setForm({ ...form, ativa: e.target.checked })}
className="h-4 w-4 accent-champagne"
/>
Ativa
</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>
)
}
function parseList(text: string): string[] {
return text
.split(',')
.map((s) => s.trim())
.filter(Boolean)
}

View File

@ -0,0 +1,273 @@
import { useEffect, useState } from 'react'
import type { Database } from '@/types/database'
import { useCrud } from '@/hooks/useCrud'
import { useTenantId } from '@/hooks/useAppConfig'
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 { supabase } from '@/lib/supabase'
type Unidade = Database['reserva_hotel']['Tables']['unidades']['Row']
type Marca = Database['reserva_hotel']['Tables']['marcas']['Row']
type Conta = Database['reserva_hotel']['Tables']['contas_pagamento']['Row']
const EMPTY_FORM = {
nome: '',
id_marca: '',
id_conta_pagamento: '',
chatwoot_unit_id: '',
endereco: '',
telefone: '',
email: '',
categoriasVisiveisText: '',
ativa: true,
}
export function UnidadesTab() {
const tenantId = useTenantId()
const { rows, loading, error, create, update, remove } = useCrud<Unidade>('unidades', {
orderBy: 'nome',
ascending: true,
})
const [marcas, setMarcas] = useState<Marca[]>([])
const [contas, setContas] = useState<Conta[]>([])
const [modalOpen, setModalOpen] = useState(false)
const [editing, setEditing] = useState<Unidade | 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)
.eq('ativa', true)
.order('nome')
.then(({ data }) => setMarcas(data ?? []))
void supabase
.from('contas_pagamento')
.select('*')
.eq('tenant_id', tenantId)
.order('nome_identificacao')
.then(({ data }) => setContas(data ?? []))
}, [tenantId])
const openCreate = () => {
setEditing(null)
setForm(EMPTY_FORM)
setFormError(null)
setModalOpen(true)
}
const openEdit = (u: Unidade) => {
setEditing(u)
setForm({
nome: u.nome,
id_marca: u.id_marca,
id_conta_pagamento: u.id_conta_pagamento,
chatwoot_unit_id: u.chatwoot_unit_id?.toString() ?? '',
endereco: u.endereco ?? '',
telefone: u.telefone ?? '',
email: u.email ?? '',
categoriasVisiveisText: (u.categorias_visiveis ?? []).join(', '),
ativa: u.ativa ?? true,
})
setFormError(null)
setModalOpen(true)
}
const handleDelete = async (u: Unidade) => {
if (!confirm(`Excluir unidade "${u.nome}"?`)) return
try {
await remove(u.id)
} catch (e) {
alert(e instanceof Error ? e.message : 'Erro ao excluir')
}
}
const handleSave = async () => {
if (!form.nome.trim()) {
setFormError('Nome é obrigatório')
return
}
if (!form.id_marca) {
setFormError('Marca é obrigatória')
return
}
if (!form.id_conta_pagamento) {
setFormError('Conta de pagamento é obrigatória')
return
}
setSaving(true)
setFormError(null)
try {
const payload = {
nome: form.nome.trim(),
id_marca: form.id_marca,
id_conta_pagamento: form.id_conta_pagamento,
chatwoot_unit_id: form.chatwoot_unit_id ? Number(form.chatwoot_unit_id) : null,
endereco: form.endereco.trim() || null,
telefone: form.telefone.trim() || null,
email: form.email.trim() || null,
categorias_visiveis: parseList(form.categoriasVisiveisText),
ativa: form.ativa,
}
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<Unidade>[] = [
{ key: 'nome', label: 'Unidade' },
{
key: 'id_marca',
label: 'Marca',
render: (row) => marcas.find((m) => m.id === row.id_marca)?.nome ?? '—',
},
{
key: 'chatwoot_unit_id',
label: 'Chatwoot ID',
render: (row) => row.chatwoot_unit_id?.toString() ?? '—',
},
{
key: 'ativa',
label: 'Status',
render: (row) => (
<span className={row.ativa ? 'text-emerald-400' : 'text-slate-400'}>
{row.ativa ? 'Ativa' : 'Inativa'}
</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">Unidades</h1>
<p className="text-slate-400 text-sm">Unidades físicas de cada marca, vinculadas ao Chatwoot.</p>
</div>
<Button variant="primary" onClick={openCreate}>+ Nova unidade</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="Nenhuma unidade cadastrada."
onEdit={openEdit}
onDelete={handleDelete}
/>
<Modal
open={modalOpen}
title={editing ? 'Editar unidade' : 'Nova unidade'}
onClose={() => setModalOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setModalOpen(false)}>Cancelar</Button>
<Button variant="primary" onClick={handleSave} disabled={saving}>
{saving ? 'Salvando...' : 'Salvar'}
</Button>
</>
}
>
<FormField
label="Nome da unidade"
required
value={form.nome}
onChange={(e) => setForm({ ...form, nome: e.target.value })}
placeholder="Ex: Prime Águas Lindas"
/>
<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 }))}
/>
<SelectField
label="Conta de pagamento (PIX)"
required
value={form.id_conta_pagamento}
onChange={(e) => setForm({ ...form, id_conta_pagamento: e.target.value })}
options={contas.map((c) => ({ value: c.id, label: c.nome_identificacao }))}
/>
<FormField
label="ID da Unit no Chatwoot"
type="number"
value={form.chatwoot_unit_id}
onChange={(e) => setForm({ ...form, chatwoot_unit_id: e.target.value })}
placeholder="Ex: 4"
/>
<FormField
label="Endereço"
value={form.endereco}
onChange={(e) => setForm({ ...form, endereco: e.target.value })}
/>
<div className="grid grid-cols-2 gap-4">
<FormField
label="Telefone"
value={form.telefone}
onChange={(e) => setForm({ ...form, telefone: e.target.value })}
/>
<FormField
label="E-mail"
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
/>
</div>
<FormField
label="Categorias visíveis (separadas por vírgula)"
value={form.categoriasVisiveisText}
onChange={(e) => setForm({ ...form, categoriasVisiveisText: e.target.value })}
placeholder="Deixe vazio pra usar todas da marca"
/>
<label className="flex items-center gap-3 text-ivory font-sans text-sm cursor-pointer">
<input
type="checkbox"
checked={form.ativa}
onChange={(e) => setForm({ ...form, ativa: e.target.checked })}
className="h-4 w-4 accent-champagne"
/>
Ativa
</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>
)
}
function parseList(text: string): string[] {
return text.split(',').map((s) => s.trim()).filter(Boolean)
}

View File

@ -3,6 +3,8 @@ import ReservationPage from '@/pages/ReservationPage'
import { LoginPage } from '@/pages/admin/LoginPage'
import { AdminLayout } from '@/pages/admin/AdminLayout'
import { AparenciaTab } from '@/pages/admin/AparenciaTab'
import { MarcasTab } from '@/pages/admin/MarcasTab'
import { UnidadesTab } from '@/pages/admin/UnidadesTab'
const router = createBrowserRouter([
{ path: '/', element: <ReservationPage /> },
@ -13,6 +15,8 @@ const router = createBrowserRouter([
children: [
{ index: true, element: <Navigate to="aparencia" replace /> },
{ path: 'aparencia', element: <AparenciaTab /> },
{ path: 'marcas', element: <MarcasTab /> },
{ path: 'unidades', element: <UnidadesTab /> },
],
},
])