From 33354c75498959c2eb7a963e1ba72f2f79261178 Mon Sep 17 00:00:00 2001 From: Rodribm10 Date: Tue, 14 Apr 2026 21:15:17 -0300 Subject: [PATCH] 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 --- src/components/admin/DataTable.tsx | 91 ++++++++++ src/components/admin/Modal.tsx | 34 ++++ src/hooks/useCrud.ts | 76 ++++++++ src/pages/admin/AdminLayout.tsx | 6 +- src/pages/admin/MarcasTab.tsx | 197 +++++++++++++++++++++ src/pages/admin/UnidadesTab.tsx | 273 +++++++++++++++++++++++++++++ src/router.tsx | 4 + 7 files changed, 680 insertions(+), 1 deletion(-) create mode 100644 src/components/admin/DataTable.tsx create mode 100644 src/components/admin/Modal.tsx create mode 100644 src/hooks/useCrud.ts create mode 100644 src/pages/admin/MarcasTab.tsx create mode 100644 src/pages/admin/UnidadesTab.tsx diff --git a/src/components/admin/DataTable.tsx b/src/components/admin/DataTable.tsx new file mode 100644 index 0000000..4fc609b --- /dev/null +++ b/src/components/admin/DataTable.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from 'react' +import { Button } from '@/components/ui/button' + +export interface Column { + key: keyof T | string + label: string + render?: (row: T) => ReactNode + width?: string +} + +interface DataTableProps { + rows: T[] + columns: Column[] + loading?: boolean + emptyMessage?: string + onEdit?: (row: T) => void + onDelete?: (row: T) => void +} + +export function DataTable({ + rows, + columns, + loading = false, + emptyMessage = 'Nenhum registro.', + onEdit, + onDelete, +}: DataTableProps) { + if (loading) { + return ( +
+ Carregando... +
+ ) + } + + if (rows.length === 0) { + return ( +
+ {emptyMessage} +
+ ) + } + + return ( +
+ + + + {columns.map((col) => ( + + ))} + {(onEdit || onDelete) && } + + + + {rows.map((row) => ( + + {columns.map((col) => ( + + ))} + {(onEdit || onDelete) && ( + + )} + + ))} + +
+ {col.label} +
+ {col.render ? col.render(row) : String(row[col.key as keyof T] ?? '—')} + +
+ {onEdit && ( + + )} + {onDelete && ( + + )} +
+
+
+ ) +} diff --git a/src/components/admin/Modal.tsx b/src/components/admin/Modal.tsx new file mode 100644 index 0000000..8043179 --- /dev/null +++ b/src/components/admin/Modal.tsx @@ -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 ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+
{children}
+ {footer &&
{footer}
} +
+
+ ) +} diff --git a/src/hooks/useCrud.ts b/src/hooks/useCrud.ts new file mode 100644 index 0000000..2ffa38b --- /dev/null +++ b/src/hooks/useCrud.ts @@ -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( + tableName: string, + options: UseCrudOptions = {} +) { + const tenantId = useTenantId() + const { orderBy = 'created_at', ascending = false } = options + + const [rows, setRows] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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 } +} diff --git a/src/pages/admin/AdminLayout.tsx b/src/pages/admin/AdminLayout.tsx index c90d371..423ce44 100644 --- a/src/pages/admin/AdminLayout.tsx +++ b/src/pages/admin/AdminLayout.tsx @@ -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() diff --git a/src/pages/admin/MarcasTab.tsx b/src/pages/admin/MarcasTab.tsx new file mode 100644 index 0000000..7002d85 --- /dev/null +++ b/src/pages/admin/MarcasTab.tsx @@ -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('marcas', { + orderBy: 'nome', + ascending: true, + }) + + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [form, setForm] = useState(EMPTY_FORM) + const [saving, setSaving] = useState(false) + const [formError, setFormError] = useState(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[] = [ + { 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) => ( + + {row.ativa ? 'Ativa' : 'Inativa'} + + ), + }, + ] + + return ( +
+
+
+

Marcas

+

Gerencie as marcas (redes) desta conta.

+
+ +
+ + {error && ( +
{error}
+ )} + + + + setModalOpen(false)} + footer={ + <> + + + + } + > + setForm({ ...form, nome: e.target.value })} + placeholder="Ex: Hotel 1001 Noites Prime" + /> + setForm({ ...form, descricao: e.target.value })} + placeholder="Breve descrição" + /> + setForm({ ...form, categoriasText: e.target.value })} + placeholder="Alexa, Stilo, Hidromassagem" + /> + setForm({ ...form, permanenciasText: e.target.value })} + placeholder="2hrs, 3hrs, 4hrs, Pernoite, Diária" + /> + + + {formError && ( +
+ {formError} +
+ )} +
+
+ ) +} + +function parseList(text: string): string[] { + return text + .split(',') + .map((s) => s.trim()) + .filter(Boolean) +} diff --git a/src/pages/admin/UnidadesTab.tsx b/src/pages/admin/UnidadesTab.tsx new file mode 100644 index 0000000..a8a6b87 --- /dev/null +++ b/src/pages/admin/UnidadesTab.tsx @@ -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('unidades', { + orderBy: 'nome', + ascending: true, + }) + + const [marcas, setMarcas] = useState([]) + const [contas, setContas] = useState([]) + const [modalOpen, setModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [form, setForm] = useState(EMPTY_FORM) + const [saving, setSaving] = useState(false) + const [formError, setFormError] = useState(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[] = [ + { 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) => ( + + {row.ativa ? 'Ativa' : 'Inativa'} + + ), + }, + ] + + return ( +
+
+
+

Unidades

+

Unidades físicas de cada marca, vinculadas ao Chatwoot.

+
+ +
+ + {error && ( +
{error}
+ )} + + + + setModalOpen(false)} + footer={ + <> + + + + } + > + setForm({ ...form, nome: e.target.value })} + placeholder="Ex: Prime Águas Lindas" + /> + + setForm({ ...form, id_marca: e.target.value })} + options={marcas.map((m) => ({ value: m.id, label: m.nome }))} + /> + + setForm({ ...form, id_conta_pagamento: e.target.value })} + options={contas.map((c) => ({ value: c.id, label: c.nome_identificacao }))} + /> + + setForm({ ...form, chatwoot_unit_id: e.target.value })} + placeholder="Ex: 4" + /> + + setForm({ ...form, endereco: e.target.value })} + /> + +
+ setForm({ ...form, telefone: e.target.value })} + /> + setForm({ ...form, email: e.target.value })} + /> +
+ + setForm({ ...form, categoriasVisiveisText: e.target.value })} + placeholder="Deixe vazio pra usar todas da marca" + /> + + + + {formError && ( +
+ {formError} +
+ )} +
+
+ ) +} + +function parseList(text: string): string[] { + return text.split(',').map((s) => s.trim()).filter(Boolean) +} diff --git a/src/router.tsx b/src/router.tsx index cdf5b37..ca3728c 100644 --- a/src/router.tsx +++ b/src/router.tsx @@ -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: }, @@ -13,6 +15,8 @@ const router = createBrowserRouter([ children: [ { index: true, element: }, { path: 'aparencia', element: }, + { path: 'marcas', element: }, + { path: 'unidades', element: }, ], }, ])