tematicas por tipo de bot
This commit is contained in:
@ -50,6 +50,20 @@ export const reservationsApi = {
|
||||
delete: (id) => api.delete(`/reservations/${id}`),
|
||||
}
|
||||
|
||||
export const serviceApi = {
|
||||
list: () => api.get('/business/me/services'),
|
||||
create: (data) => api.post('/business/me/services', data),
|
||||
update: (id, data) => api.put(`/business/me/services/${id}`, data),
|
||||
remove: (id) => api.delete(`/business/me/services/${id}`),
|
||||
}
|
||||
|
||||
export const tableApi = {
|
||||
list: () => api.get('/business/me/tables'),
|
||||
create: (data) => api.post('/business/me/tables', data),
|
||||
update: (id, data) => api.put(`/business/me/tables/${id}`, data),
|
||||
remove: (id) => api.delete(`/business/me/tables/${id}`),
|
||||
}
|
||||
|
||||
export const calendarApi = {
|
||||
getAvailability: (date) => api.get('/calendar/availability', { params: { date } }),
|
||||
getAvailabilityRange: (start, end) =>
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { format, addDays, startOfWeek, isSameDay, isToday, parseISO } from 'date-fns'
|
||||
import {
|
||||
format, addMonths, startOfMonth, endOfMonth,
|
||||
startOfWeek, endOfWeek, eachDayOfInterval,
|
||||
isSameDay, isToday, isSameMonth,
|
||||
} from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { ChevronLeft, ChevronRight, Lock, Unlock, Clock } from 'lucide-react'
|
||||
import { calendarApi } from '@/lib/api'
|
||||
@ -7,17 +11,21 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
const WEEK_DAYS = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']
|
||||
|
||||
function buildMonthGrid(date) {
|
||||
const start = startOfWeek(startOfMonth(date), { weekStartsOn: 1 })
|
||||
const end = endOfWeek(endOfMonth(date), { weekStartsOn: 1 })
|
||||
return eachDayOfInterval({ start, end })
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(new Date())
|
||||
const [slots, setSlots] = useState([])
|
||||
const [blockedDates, setBlockedDates] = useState([])
|
||||
const [loadingSlots, setLoadingSlots] = useState(false)
|
||||
const [togglingDate, setTogglingDate] = useState(false)
|
||||
|
||||
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 })
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
||||
|
||||
const monthDays = buildMonthGrid(currentMonth)
|
||||
const selectedStr = format(selectedDate, 'yyyy-MM-dd')
|
||||
const isBlocked = blockedDates.includes(selectedStr)
|
||||
|
||||
@ -26,7 +34,7 @@ export default function CalendarPage() {
|
||||
setLoadingSlots(true)
|
||||
try {
|
||||
const { data } = await calendarApi.getAvailability(selectedStr)
|
||||
setSlots(data)
|
||||
setSlots(data.slots ?? [])
|
||||
} catch {
|
||||
setSlots([])
|
||||
} finally {
|
||||
@ -53,56 +61,62 @@ export default function CalendarPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-4xl animate-fade-in">
|
||||
<div className="grid lg:grid-cols-3 gap-5">
|
||||
{/* Calendario semanal */}
|
||||
<div className="grid lg:grid-cols-3 gap-5 items-start">
|
||||
{/* Calendario mensual */}
|
||||
<div className="lg:col-span-2 card p-5">
|
||||
{/* Nav semana */}
|
||||
{/* Nav mes */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="font-display text-base font-semibold text-slate-800 capitalize">
|
||||
{format(weekStart, "MMMM yyyy", { locale: es })}
|
||||
{format(currentMonth, "MMMM yyyy", { locale: es })}
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentDate((d) => addDays(d, -7))}
|
||||
onClick={() => setCurrentMonth((d) => addMonths(d, -1))}
|
||||
className="btn-ghost p-1.5"
|
||||
aria-label="Semana anterior"
|
||||
aria-label="Mes anterior"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
onClick={() => { setCurrentMonth(new Date()); setSelectedDate(new Date()) }}
|
||||
className="btn-secondary px-3 py-1.5 text-xs"
|
||||
>
|
||||
Hoy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentDate((d) => addDays(d, 7))}
|
||||
onClick={() => setCurrentMonth((d) => addMonths(d, 1))}
|
||||
className="btn-ghost p-1.5"
|
||||
aria-label="Semana siguiente"
|
||||
aria-label="Mes siguiente"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Días */}
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{/* Cabecera días */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{WEEK_DAYS.map((d) => (
|
||||
<div key={d} className="text-center text-xs font-medium text-slate-400 pb-1.5">{d}</div>
|
||||
<div key={d} className="text-center text-xs font-medium text-slate-400 pb-1">{d}</div>
|
||||
))}
|
||||
{weekDays.map((day) => {
|
||||
</div>
|
||||
|
||||
{/* Días del mes */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{monthDays.map((day) => {
|
||||
const dayStr = format(day, 'yyyy-MM-dd')
|
||||
const selected = isSameDay(day, selectedDate)
|
||||
const today = isToday(day)
|
||||
const blocked = blockedDates.includes(dayStr)
|
||||
const otherMonth = !isSameMonth(day, currentMonth)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dayStr}
|
||||
onClick={() => setSelectedDate(day)}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center rounded-xl py-3 px-1 text-sm font-medium transition-all duration-150',
|
||||
'relative flex flex-col items-center justify-center rounded-lg aspect-square text-sm font-medium transition-all duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
otherMonth && 'opacity-25 pointer-events-none',
|
||||
selected
|
||||
? 'bg-primary-600 text-white shadow-sm'
|
||||
: blocked
|
||||
@ -114,9 +128,9 @@ export default function CalendarPage() {
|
||||
aria-pressed={selected}
|
||||
aria-label={`${format(day, 'd MMMM', { locale: es })}${blocked ? ', bloqueado' : ''}`}
|
||||
>
|
||||
<span className="text-base leading-none">{format(day, 'd')}</span>
|
||||
<span className="leading-none">{format(day, 'd')}</span>
|
||||
{blocked && !selected && (
|
||||
<Lock size={9} className="mt-1 opacity-50" />
|
||||
<Lock size={8} className="mt-0.5 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
@ -198,7 +212,7 @@ export default function CalendarPage() {
|
||||
<div className="flex flex-col gap-1.5 max-h-56 overflow-y-auto pr-1">
|
||||
{slots.map((slot) => (
|
||||
<div
|
||||
key={slot.time}
|
||||
key={slot.time_start}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3.5 py-2.5 rounded-lg text-sm border transition-colors',
|
||||
slot.available > 0
|
||||
@ -206,7 +220,7 @@ export default function CalendarPage() {
|
||||
: 'bg-slate-50 border-border text-slate-400',
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{slot.time?.slice(0, 5)}</span>
|
||||
<span className="font-medium">{slot.time_start?.slice(0, 5)}</span>
|
||||
<span className="text-xs">
|
||||
{slot.available > 0
|
||||
? `${slot.available} libre${slot.available > 1 ? 's' : ''}`
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Save, CheckCircle, AlertCircle, Wifi, WifiOff, ExternalLink } from 'lucide-react'
|
||||
import { businessApi, whatsappApi } from '@/lib/api'
|
||||
import { Save, CheckCircle, AlertCircle, Wifi, WifiOff, Phone, Unplug, UtensilsCrossed, Plus, Trash2, Pencil, Tag } from 'lucide-react'
|
||||
import { businessApi, whatsappApi, tableApi, serviceApi } from '@/lib/api'
|
||||
import { DAYS_FULL, cn } from '@/lib/utils'
|
||||
|
||||
const TONE_OPTIONS = [
|
||||
@ -20,6 +20,8 @@ function Section({ title, description, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
const WA_EMPTY = { phone_number_id: '', access_token: '', meta_business_id: '' }
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [business, setBusiness] = useState(null)
|
||||
const [config, setConfig] = useState(null)
|
||||
@ -29,17 +31,43 @@ export default function ConfigPage() {
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [waForm, setWaForm] = useState(WA_EMPTY)
|
||||
const [waConnecting, setWaConnecting] = useState(false)
|
||||
const [waDisconnecting, setWaDisconnecting] = useState(false)
|
||||
const [waError, setWaError] = useState('')
|
||||
const [waShowForm, setWaShowForm] = useState(false)
|
||||
|
||||
const SERVICE_EMPTY = { name: '', description: '', price: '', duration_minutes: '' }
|
||||
const [svcList, setSvcList] = useState([])
|
||||
const [svcForm, setSvcForm] = useState(SERVICE_EMPTY)
|
||||
const [editingSvc, setEditingSvc] = useState(null)
|
||||
const [svcError, setSvcError] = useState('')
|
||||
const [svcSaving, setSvcSaving] = useState(false)
|
||||
const [showSvcForm, setShowSvcForm] = useState(false)
|
||||
|
||||
const TABLE_EMPTY = { capacity: '', quantity: '', label: '' }
|
||||
const [tables, setTables] = useState([])
|
||||
const [tableForm, setTableForm] = useState(TABLE_EMPTY)
|
||||
const [editingTable, setEditingTable] = useState(null)
|
||||
const [tableError, setTableError] = useState('')
|
||||
const [tableSaving, setTableSaving] = useState(false)
|
||||
const [showTableForm, setShowTableForm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [bRes, cRes, wRes] = await Promise.all([
|
||||
const [bRes, cRes, wRes, tRes, sRes] = await Promise.all([
|
||||
businessApi.getMe(),
|
||||
businessApi.getConfig(),
|
||||
whatsappApi.getStatus().catch(() => ({ data: null })),
|
||||
tableApi.list().catch(() => ({ data: [] })),
|
||||
serviceApi.list().catch(() => ({ data: [] })),
|
||||
])
|
||||
setBusiness(bRes.data)
|
||||
setConfig(cRes.data)
|
||||
setWaStatus(wRes.data)
|
||||
setTables(tRes.data ?? [])
|
||||
setSvcList(sRes.data ?? [])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -59,6 +87,143 @@ export default function ConfigPage() {
|
||||
updateConfig('open_days', next)
|
||||
}
|
||||
|
||||
function startEditSvc(svc) {
|
||||
setEditingSvc(svc.id)
|
||||
setSvcForm({
|
||||
name: svc.name,
|
||||
description: svc.description ?? '',
|
||||
price: svc.price != null ? svc.price : '',
|
||||
duration_minutes: svc.duration_minutes ?? '',
|
||||
})
|
||||
setShowSvcForm(true)
|
||||
setSvcError('')
|
||||
}
|
||||
|
||||
function cancelSvcForm() {
|
||||
setEditingSvc(null)
|
||||
setSvcForm(SERVICE_EMPTY)
|
||||
setShowSvcForm(false)
|
||||
setSvcError('')
|
||||
}
|
||||
|
||||
async function handleSvcSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!svcForm.name.trim()) { setSvcError('El nombre es requerido.'); return }
|
||||
setSvcSaving(true)
|
||||
setSvcError('')
|
||||
try {
|
||||
const payload = {
|
||||
name: svcForm.name.trim(),
|
||||
description: svcForm.description.trim() || null,
|
||||
price: svcForm.price !== '' ? Number(svcForm.price) : null,
|
||||
duration_minutes: svcForm.duration_minutes !== '' ? Number(svcForm.duration_minutes) : null,
|
||||
}
|
||||
if (editingSvc) {
|
||||
const { data } = await serviceApi.update(editingSvc, payload)
|
||||
setSvcList((l) => l.map((x) => (x.id === editingSvc ? data : x)))
|
||||
} else {
|
||||
const { data } = await serviceApi.create(payload)
|
||||
setSvcList((l) => [...l, data].sort((a, b) => a.name.localeCompare(b.name)))
|
||||
}
|
||||
cancelSvcForm()
|
||||
} catch {
|
||||
setSvcError('No se pudo guardar. Intenta de nuevo.')
|
||||
} finally {
|
||||
setSvcSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSvc(id) {
|
||||
if (!confirm('¿Eliminar este servicio?')) return
|
||||
try {
|
||||
await serviceApi.remove(id)
|
||||
setSvcList((l) => l.filter((x) => x.id !== id))
|
||||
} catch {
|
||||
// silencioso
|
||||
}
|
||||
}
|
||||
|
||||
function startEditTable(table) {
|
||||
setEditingTable(table.id)
|
||||
setTableForm({ capacity: table.capacity, quantity: table.quantity, label: table.label ?? '' })
|
||||
setShowTableForm(true)
|
||||
setTableError('')
|
||||
}
|
||||
|
||||
function cancelTableForm() {
|
||||
setEditingTable(null)
|
||||
setTableForm(TABLE_EMPTY)
|
||||
setShowTableForm(false)
|
||||
setTableError('')
|
||||
}
|
||||
|
||||
async function handleTableSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!tableForm.capacity || !tableForm.quantity) {
|
||||
setTableError('Capacidad y cantidad son requeridas.')
|
||||
return
|
||||
}
|
||||
setTableSaving(true)
|
||||
setTableError('')
|
||||
try {
|
||||
const payload = {
|
||||
capacity: Number(tableForm.capacity),
|
||||
quantity: Number(tableForm.quantity),
|
||||
label: tableForm.label || null,
|
||||
}
|
||||
if (editingTable) {
|
||||
const { data } = await tableApi.update(editingTable, payload)
|
||||
setTables((t) => t.map((x) => (x.id === editingTable ? data : x)))
|
||||
} else {
|
||||
const { data } = await tableApi.create(payload)
|
||||
setTables((t) => [...t, data].sort((a, b) => a.capacity - b.capacity))
|
||||
}
|
||||
cancelTableForm()
|
||||
} catch {
|
||||
setTableError('No se pudo guardar. Intenta de nuevo.')
|
||||
} finally {
|
||||
setTableSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteTable(id) {
|
||||
if (!confirm('¿Eliminar este tipo de mesa?')) return
|
||||
try {
|
||||
await tableApi.remove(id)
|
||||
setTables((t) => t.filter((x) => x.id !== id))
|
||||
} catch {
|
||||
// silencioso
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWaConnect(e) {
|
||||
e.preventDefault()
|
||||
setWaConnecting(true)
|
||||
setWaError('')
|
||||
try {
|
||||
await whatsappApi.connect(waForm)
|
||||
const { data } = await whatsappApi.getStatus()
|
||||
setWaStatus(data)
|
||||
setWaForm(WA_EMPTY)
|
||||
setWaShowForm(false)
|
||||
} catch {
|
||||
setWaError('No se pudo conectar. Verifica los datos e intenta de nuevo.')
|
||||
} finally {
|
||||
setWaConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWaDisconnect() {
|
||||
if (!confirm('¿Desconectar WhatsApp? El bot dejará de responder mensajes.')) return
|
||||
setWaDisconnecting(true)
|
||||
try {
|
||||
await whatsappApi.disconnect()
|
||||
setWaStatus({ connected: false, phone_number_id: null, meta_business_id: null })
|
||||
} finally {
|
||||
setWaDisconnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
@ -105,31 +270,125 @@ export default function ConfigPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 max-w-2xl animate-fade-in">
|
||||
{/* WhatsApp status */}
|
||||
<div className={cn(
|
||||
'card p-5 flex items-center gap-4',
|
||||
isWaConnected ? 'border-primary-200 bg-primary-50' : 'border-warning-200 bg-warning-50',
|
||||
)}>
|
||||
{/* WhatsApp */}
|
||||
<Section
|
||||
title="WhatsApp Business"
|
||||
description="Conecta tu número para que el bot reciba y responda reservas."
|
||||
>
|
||||
{/* Estado */}
|
||||
<div className={cn(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0',
|
||||
isWaConnected ? 'bg-primary-100' : 'bg-warning-100',
|
||||
'flex items-center gap-4 rounded-xl px-4 py-3.5 border',
|
||||
isWaConnected ? 'bg-primary-50 border-primary-200' : 'bg-warning-50 border-warning-200',
|
||||
)}>
|
||||
{isWaConnected
|
||||
? <Wifi size={18} className="text-primary-600" />
|
||||
: <WifiOff size={18} className="text-warning-600" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={cn('text-sm font-semibold', isWaConnected ? 'text-primary-800' : 'text-warning-800')}>
|
||||
WhatsApp {isWaConnected ? 'conectado' : 'no conectado'}
|
||||
</p>
|
||||
<p className={cn('text-xs mt-0.5', isWaConnected ? 'text-primary-600' : 'text-warning-600')}>
|
||||
<div className={cn(
|
||||
'w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
isWaConnected ? 'bg-primary-100' : 'bg-warning-100',
|
||||
)}>
|
||||
{isWaConnected
|
||||
? `Número: ${waStatus.whatsapp_phone_number_id}`
|
||||
: 'Configura tu número de WhatsApp Business para recibir reservas.'}
|
||||
</p>
|
||||
? <Wifi size={16} className="text-primary-600" />
|
||||
: <WifiOff size={16} className="text-warning-600" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={cn('text-sm font-semibold', isWaConnected ? 'text-primary-800' : 'text-warning-800')}>
|
||||
{isWaConnected ? 'Conectado' : 'Sin conectar'}
|
||||
</p>
|
||||
<p className={cn('text-xs mt-0.5', isWaConnected ? 'text-primary-600' : 'text-warning-600')}>
|
||||
{isWaConnected
|
||||
? `Phone Number ID: ${waStatus.phone_number_id}`
|
||||
: 'El bot no puede recibir mensajes hasta que conectes un número.'}
|
||||
</p>
|
||||
</div>
|
||||
{isWaConnected ? (
|
||||
<button
|
||||
onClick={handleWaDisconnect}
|
||||
disabled={waDisconnecting}
|
||||
className="btn border border-border bg-white text-slate-700 hover:bg-slate-50 px-3 py-2 text-xs gap-1.5 flex-shrink-0"
|
||||
>
|
||||
{waDisconnecting
|
||||
? <span className="w-3.5 h-3.5 rounded-full border-2 border-current border-t-transparent animate-spin block" />
|
||||
: <Unplug size={13} />
|
||||
}
|
||||
Desconectar
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setWaShowForm((v) => !v)}
|
||||
className="btn-primary px-3 py-2 text-xs gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Phone size={13} />
|
||||
{waShowForm ? 'Cancelar' : 'Conectar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulario de conexión */}
|
||||
{!isWaConnected && waShowForm && (
|
||||
<form onSubmit={handleWaConnect} className="flex flex-col gap-4 pt-1 animate-fade-in">
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Phone Number ID</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={waForm.phone_number_id}
|
||||
onChange={(e) => setWaForm((f) => ({ ...f, phone_number_id: e.target.value }))}
|
||||
className="field-input font-mono"
|
||||
placeholder="123456789012345"
|
||||
/>
|
||||
<span className="field-helper">
|
||||
Encuéntralo en Meta for Developers → Tu app → WhatsApp → Configuración de API.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Meta Business ID</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={waForm.meta_business_id}
|
||||
onChange={(e) => setWaForm((f) => ({ ...f, meta_business_id: e.target.value }))}
|
||||
className="field-input font-mono"
|
||||
placeholder="987654321098765"
|
||||
/>
|
||||
<span className="field-helper">
|
||||
ID de tu cuenta de Meta Business Suite.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={waForm.access_token}
|
||||
onChange={(e) => setWaForm((f) => ({ ...f, access_token: e.target.value }))}
|
||||
className="field-input font-mono"
|
||||
placeholder="EAAxxxxxxxxx…"
|
||||
/>
|
||||
<span className="field-helper">
|
||||
Token de acceso permanente generado en Meta for Developers.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{waError && (
|
||||
<div className="flex items-center gap-2 text-sm text-danger-700 bg-danger-50 border border-danger-100 rounded-lg px-4 py-3">
|
||||
<AlertCircle size={14} />
|
||||
{waError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" disabled={waConnecting} className="btn-primary gap-2">
|
||||
{waConnecting
|
||||
? <span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||
: <Wifi size={15} />
|
||||
}
|
||||
{waConnecting ? 'Conectando…' : 'Conectar WhatsApp'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Negocio */}
|
||||
<Section title="Datos del negocio" description="Nombre y zona horaria visible en el panel.">
|
||||
@ -232,6 +491,272 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Mesas — solo restaurantes */}
|
||||
{business?.type === 'restaurant' && (
|
||||
<Section
|
||||
title="Configuración de mesas"
|
||||
description="Define los tipos de mesa disponibles. El bot usará esta información para asignar la mesa correcta según el tamaño del grupo."
|
||||
>
|
||||
{/* Lista de mesas */}
|
||||
{tables.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{tables.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-slate-50"
|
||||
>
|
||||
<UtensilsCrossed size={16} className="text-slate-400 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{t.quantity} mesa{t.quantity > 1 ? 's' : ''} para {t.capacity} persona{t.capacity > 1 ? 's' : ''}
|
||||
</p>
|
||||
{t.label && <p className="text-xs text-slate-400 mt-0.5">{t.label}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => startEditTable(t)}
|
||||
className="btn-ghost p-1.5 text-slate-400 hover:text-slate-600"
|
||||
aria-label="Editar"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTable(t.id)}
|
||||
className="btn-ghost p-1.5 text-slate-400 hover:text-danger-600"
|
||||
aria-label="Eliminar"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tables.length === 0 && !showTableForm && (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center border border-dashed border-border rounded-xl">
|
||||
<UtensilsCrossed size={24} className="text-slate-200 mb-2" />
|
||||
<p className="text-sm text-slate-400">No hay tipos de mesa configurados</p>
|
||||
<p className="text-xs text-slate-300 mt-0.5">Agrega tus mesas para un control preciso de reservas</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulario */}
|
||||
{showTableForm && (
|
||||
<form onSubmit={handleTableSubmit} className="flex flex-col gap-4 p-4 rounded-xl border border-primary-200 bg-primary-50 animate-fade-in">
|
||||
<p className="text-sm font-semibold text-primary-800">
|
||||
{editingTable ? 'Editar tipo de mesa' : 'Agregar tipo de mesa'}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Capacidad (personas)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
required
|
||||
value={tableForm.capacity}
|
||||
onChange={(e) => setTableForm((f) => ({ ...f, capacity: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: 4"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Cantidad de mesas</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
required
|
||||
value={tableForm.quantity}
|
||||
onChange={(e) => setTableForm((f) => ({ ...f, quantity: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: 5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Etiqueta (opcional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tableForm.label}
|
||||
onChange={(e) => setTableForm((f) => ({ ...f, label: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: Mesa familiar, Mesa terraza…"
|
||||
/>
|
||||
</div>
|
||||
{tableError && (
|
||||
<div className="flex items-center gap-2 text-sm text-danger-700 bg-danger-50 border border-danger-100 rounded-lg px-3 py-2">
|
||||
<AlertCircle size={13} />
|
||||
{tableError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={cancelTableForm} className="btn-secondary text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" disabled={tableSaving} className="btn-primary text-sm gap-1.5">
|
||||
{tableSaving
|
||||
? <span className="w-3.5 h-3.5 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||
: null
|
||||
}
|
||||
{editingTable ? 'Guardar cambios' : 'Agregar mesa'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!showTableForm && (
|
||||
<button
|
||||
onClick={() => { setShowTableForm(true); setEditingTable(null); setTableForm(TABLE_EMPTY) }}
|
||||
className="btn-secondary gap-2 self-start"
|
||||
>
|
||||
<Plus size={15} />
|
||||
Agregar tipo de mesa
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Servicios */}
|
||||
<Section
|
||||
title="Servicios y precios"
|
||||
description="El bot usará esta lista para informar a los clientes sobre tus servicios cuando pregunten."
|
||||
>
|
||||
{svcList.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{svcList.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl border',
|
||||
s.is_active ? 'bg-slate-50 border-border' : 'bg-slate-50 border-border opacity-50',
|
||||
)}
|
||||
>
|
||||
<Tag size={15} className="text-slate-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{s.name}</p>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
{s.description && (
|
||||
<p className="text-xs text-slate-400 truncate">{s.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{s.price != null && (
|
||||
<span className="text-xs font-semibold text-primary-600">
|
||||
${Number(s.price).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{s.duration_minutes && (
|
||||
<span className="text-xs text-slate-400">{s.duration_minutes} min</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => startEditSvc(s)}
|
||||
className="btn-ghost p-1.5 text-slate-400 hover:text-slate-600 flex-shrink-0"
|
||||
aria-label="Editar"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSvc(s.id)}
|
||||
className="btn-ghost p-1.5 text-slate-400 hover:text-danger-600 flex-shrink-0"
|
||||
aria-label="Eliminar"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{svcList.length === 0 && !showSvcForm && (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center border border-dashed border-border rounded-xl">
|
||||
<Tag size={24} className="text-slate-200 mb-2" />
|
||||
<p className="text-sm text-slate-400">No hay servicios configurados</p>
|
||||
<p className="text-xs text-slate-300 mt-0.5">Agrega tus servicios para que el bot pueda informar a los clientes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSvcForm && (
|
||||
<form onSubmit={handleSvcSubmit} className="flex flex-col gap-4 p-4 rounded-xl border border-primary-200 bg-primary-50 animate-fade-in">
|
||||
<p className="text-sm font-semibold text-primary-800">
|
||||
{editingSvc ? 'Editar servicio' : 'Agregar servicio'}
|
||||
</p>
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Nombre del servicio</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={svcForm.name}
|
||||
onChange={(e) => setSvcForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: Corte de cabello, Consulta general, Masaje relajante…"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Descripción</label>
|
||||
<input
|
||||
type="text"
|
||||
value={svcForm.description}
|
||||
onChange={(e) => setSvcForm((f) => ({ ...f, description: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Descripción breve (opcional)"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label className="field-label">Precio</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={svcForm.price}
|
||||
onChange={(e) => setSvcForm((f) => ({ ...f, price: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: 25000"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Duración (minutos)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={svcForm.duration_minutes}
|
||||
onChange={(e) => setSvcForm((f) => ({ ...f, duration_minutes: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: 30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{svcError && (
|
||||
<div className="flex items-center gap-2 text-sm text-danger-700 bg-danger-50 border border-danger-100 rounded-lg px-3 py-2">
|
||||
<AlertCircle size={13} />
|
||||
{svcError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={cancelSvcForm} className="btn-secondary text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" disabled={svcSaving} className="btn-primary text-sm gap-1.5">
|
||||
{svcSaving && <span className="w-3.5 h-3.5 rounded-full border-2 border-white border-t-transparent animate-spin" />}
|
||||
{editingSvc ? 'Guardar cambios' : 'Agregar servicio'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!showSvcForm && (
|
||||
<button
|
||||
onClick={() => { setShowSvcForm(true); setEditingSvc(null); setSvcForm(SERVICE_EMPTY) }}
|
||||
className="btn-secondary gap-2 self-start"
|
||||
>
|
||||
<Plus size={15} />
|
||||
Agregar servicio
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Bot */}
|
||||
<Section
|
||||
title="Configuración del asistente"
|
||||
|
||||
Reference in New Issue
Block a user