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:
parent
d1ee2bdfa1
commit
33354c7549
91
src/components/admin/DataTable.tsx
Normal file
91
src/components/admin/DataTable.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
src/components/admin/Modal.tsx
Normal file
34
src/components/admin/Modal.tsx
Normal 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
76
src/hooks/useCrud.ts
Normal 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 }
|
||||||
|
}
|
||||||
@ -3,7 +3,11 @@ import { AuthGate } from '@/components/admin/AuthGate'
|
|||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { Button } from '@/components/ui/button'
|
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() {
|
export function AdminLayout() {
|
||||||
const { user, signOut } = useAuth()
|
const { user, signOut } = useAuth()
|
||||||
|
|||||||
197
src/pages/admin/MarcasTab.tsx
Normal file
197
src/pages/admin/MarcasTab.tsx
Normal 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)
|
||||||
|
}
|
||||||
273
src/pages/admin/UnidadesTab.tsx
Normal file
273
src/pages/admin/UnidadesTab.tsx
Normal 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)
|
||||||
|
}
|
||||||
@ -3,6 +3,8 @@ import ReservationPage from '@/pages/ReservationPage'
|
|||||||
import { LoginPage } from '@/pages/admin/LoginPage'
|
import { LoginPage } from '@/pages/admin/LoginPage'
|
||||||
import { AdminLayout } from '@/pages/admin/AdminLayout'
|
import { AdminLayout } from '@/pages/admin/AdminLayout'
|
||||||
import { AparenciaTab } from '@/pages/admin/AparenciaTab'
|
import { AparenciaTab } from '@/pages/admin/AparenciaTab'
|
||||||
|
import { MarcasTab } from '@/pages/admin/MarcasTab'
|
||||||
|
import { UnidadesTab } from '@/pages/admin/UnidadesTab'
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{ path: '/', element: <ReservationPage /> },
|
{ path: '/', element: <ReservationPage /> },
|
||||||
@ -13,6 +15,8 @@ const router = createBrowserRouter([
|
|||||||
children: [
|
children: [
|
||||||
{ index: true, element: <Navigate to="aparencia" replace /> },
|
{ index: true, element: <Navigate to="aparencia" replace /> },
|
||||||
{ path: 'aparencia', element: <AparenciaTab /> },
|
{ path: 'aparencia', element: <AparenciaTab /> },
|
||||||
|
{ path: 'marcas', element: <MarcasTab /> },
|
||||||
|
{ path: 'unidades', element: <UnidadesTab /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user