tematicas por tipo de bot

This commit is contained in:
2026-04-29 09:39:56 -05:00
parent f548a2d9bd
commit dcd77a3982
14 changed files with 1284 additions and 83 deletions

View File

@ -0,0 +1,47 @@
"""table types for restaurants
Revision ID: 0002
Revises: 0001
Create Date: 2026-04-29
"""
from alembic import op
import sqlalchemy as sa
revision = "0002"
down_revision = "0001"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"table_types",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"business_id",
sa.Integer(),
sa.ForeignKey("businesses.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("capacity", sa.Integer(), nullable=False),
sa.Column("quantity", sa.Integer(), nullable=False),
sa.Column("label", sa.String(), nullable=True),
)
op.create_index("ix_table_types_id", "table_types", ["id"])
op.create_index("ix_table_types_business_id", "table_types", ["business_id"])
op.add_column(
"reservations",
sa.Column(
"table_type_id",
sa.Integer(),
sa.ForeignKey("table_types.id", ondelete="SET NULL"),
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("reservations", "table_type_id")
op.drop_table("table_types")

View File

@ -0,0 +1,38 @@
"""services catalog per business
Revision ID: 0003
Revises: 0002
Create Date: 2026-04-29
"""
from alembic import op
import sqlalchemy as sa
revision = "0003"
down_revision = "0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"services",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column(
"business_id",
sa.Integer(),
sa.ForeignKey("businesses.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column("name", sa.String(), nullable=False),
sa.Column("description", sa.String(), nullable=True),
sa.Column("price", sa.Numeric(10, 2), nullable=True),
sa.Column("duration_minutes", sa.Integer(), nullable=True),
sa.Column("is_active", sa.Boolean(), nullable=False, server_default="true"),
)
op.create_index("ix_services_id", "services", ["id"])
op.create_index("ix_services_business_id", "services", ["business_id"])
def downgrade() -> None:
op.drop_table("services")

View File

@ -1,9 +1,88 @@
from app.modules.bot_engine.schemas import CollectedData, ConversationContext
from app.modules.business.models import Business, BusinessConfig
from app.modules.bot_engine.schemas import ConversationContext
from app.modules.business.models import Business, BusinessConfig, Service, TableType
from app.modules.calendar.schemas import DayAvailability
DAYS_ES = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
# Contexto general por tipo de negocio
BUSINESS_TYPE_CONTEXT: dict[str, dict] = {
"restaurant": {
"description": (
"Eres el asistente de reservas de un restaurante. Tu misión es ayudar a los "
"clientes a reservar una mesa. Siempre pregunta cuántas personas son antes de "
"mostrar disponibilidad, ya que los horarios dependen del tamaño del grupo. "
"Puedes informar sobre el menú o servicios disponibles si el cliente pregunta."
),
"party_term": "comensales",
"reservation_term": "reserva de mesa",
"ask_party_first": True,
},
"clinic": {
"description": (
"Eres el asistente de citas de una clínica o consultorio. Tu misión es ayudar "
"a los pacientes a agendar su cita médica. Trata a los pacientes con respeto y "
"empatía. Puedes informar sobre los servicios o especialidades disponibles si "
"el paciente pregunta. Pregunta el motivo de la consulta solo si es necesario "
"para determinar el servicio correcto."
),
"party_term": "pacientes",
"reservation_term": "cita médica",
"ask_party_first": False,
},
"salon": {
"description": (
"Eres el asistente de reservas de un salón de belleza. Tu misión es ayudar a "
"los clientes a reservar su cita. Puedes informar sobre los servicios y precios "
"disponibles si el cliente pregunta. Pregunta qué servicio desean para asignar "
"el tiempo correcto."
),
"party_term": "personas",
"reservation_term": "cita",
"ask_party_first": False,
},
"spa": {
"description": (
"Eres el asistente de reservas de un spa o centro de bienestar. Tu misión es "
"ayudar a los clientes a reservar sus tratamientos. Usa un tono relajante y "
"acogedor. Puedes informar sobre los tratamientos y precios disponibles si el "
"cliente pregunta."
),
"party_term": "personas",
"reservation_term": "reserva de tratamiento",
"ask_party_first": False,
},
"barbershop": {
"description": (
"Eres el asistente de reservas de una barbería. Tu misión es ayudar a los "
"clientes a reservar su turno. Puedes informar sobre los servicios y precios "
"disponibles si el cliente pregunta."
),
"party_term": "personas",
"reservation_term": "turno",
"ask_party_first": False,
},
"gym": {
"description": (
"Eres el asistente de reservas de un gimnasio o entrenador personal. Tu misión "
"es ayudar a los clientes a reservar sus sesiones de entrenamiento. Puedes "
"informar sobre los servicios y planes disponibles si el cliente pregunta."
),
"party_term": "personas",
"reservation_term": "sesión",
"ask_party_first": False,
},
"other": {
"description": (
"Eres el asistente de reservas de un negocio de servicios. Tu misión es ayudar "
"a los clientes a agendar su reserva. Puedes informar sobre los servicios "
"disponibles si el cliente pregunta."
),
"party_term": "personas",
"reservation_term": "reserva",
"ask_party_first": False,
},
}
def _format_open_days(open_days: list[int]) -> str:
return ", ".join(DAYS_ES[d] for d in sorted(open_days))
@ -14,16 +93,50 @@ def _format_slots(availability: DayAvailability | None) -> str:
return "No hay disponibilidad para esa fecha."
available = [s for s in availability.slots if s.available > 0]
if not available:
return "No quedan slots disponibles para esa fecha."
return "No quedan lugares disponibles para esa fecha."
return ", ".join(s.time_start.strftime("%H:%M") for s in available)
def _format_table_types(table_types: list[TableType]) -> str:
lines = []
for t in sorted(table_types, key=lambda x: x.capacity):
label = f" ({t.label})" if t.label else ""
lines.append(f" - {t.quantity} mesa(s) para {t.capacity} persona(s){label}")
return "\n".join(lines)
def _format_services(services: list[Service]) -> str:
if not services:
return ""
lines = []
for s in services:
if not s.is_active:
continue
parts = [f"{s.name}"]
if s.description:
parts.append(f": {s.description}")
extras = []
if s.price is not None:
extras.append(f"${float(s.price):,.0f}")
if s.duration_minutes:
extras.append(f"{s.duration_minutes} min")
if extras:
parts.append(f" ({', '.join(extras)})")
lines.append("".join(parts))
return "\n".join(lines) if lines else ""
def build_system_prompt(
business: Business,
config: BusinessConfig,
availability: DayAvailability | None,
context: ConversationContext,
table_types: list[TableType] | None = None,
services: list[Service] | None = None,
) -> str:
btype = business.type or "other"
type_ctx = BUSINESS_TYPE_CONTEXT.get(btype, BUSINESS_TYPE_CONTEXT["other"])
tone_instruction = (
"Usa un tono formal y profesional."
if config.tone == "formal"
@ -38,29 +151,53 @@ def build_system_prompt(
f"- Personas: {collected.party_size or 'pendiente'}",
])
slots_info = _format_slots(availability)
# Bloque de mesas (solo restaurantes con mesas configuradas)
tables_block = ""
if table_types:
tables_block = f"""
CONFIGURACIÓN DE MESAS:
{_format_table_types(table_types)}
Los horarios disponibles ya están filtrados según el tamaño del grupo indicado.
Si no hay mesas disponibles para ese grupo, informa amablemente y sugiere otros horarios o fechas.
"""
# Bloque de servicios
services_block = ""
if services:
formatted = _format_services(services)
if formatted:
services_block = f"""
SERVICIOS DISPONIBLES:
{formatted}
Puedes informar sobre estos servicios si el cliente pregunta. No es obligatorio elegir uno para reservar a menos que sea necesario para determinar la duración.
"""
# Indicación de preguntar comensales primero (restaurantes)
party_first_hint = ""
if type_ctx["ask_party_first"] and not collected.party_size:
party_first_hint = f"\nIMPORTANTE: Pregunta primero cuántos {type_ctx['party_term']} serán antes de mostrar disponibilidad.\n"
return f"""Eres {config.assistant_name}, asistente virtual de {business.name}.
{type_ctx['description']}
{tone_instruction}
Responde SIEMPRE en el idioma del cliente.
{party_first_hint}{tables_block}{services_block}
HORARIO DEL NEGOCIO:
- Días de atención: {_format_open_days(config.open_days or [])}
- Horario: {config.open_time.strftime("%H:%M")} a {config.close_time.strftime("%H:%M")}
- Duración de cada turno: {config.slot_duration} minutos
- Capacidad por turno: {config.max_per_slot} persona(s)
SLOTS DISPONIBLES PARA LA FECHA SOLICITADA: {slots_info}
SLOTS DISPONIBLES: {_format_slots(availability)}
DATOS YA RECOPILADOS DEL CLIENTE:
{collected_summary}
OBJETIVO: Recopilar nombre, fecha, hora y número de personas para crear la reserva.
Si ya tienes todos los datos, acción = "create_reservation".
Si el cliente quiere cancelar, acción = "cancel".
OBJETIVO: Recopilar nombre, fecha, hora y número de {type_ctx['party_term']} para crear la {type_ctx['reservation_term']}.
Si ya tienes todos los datos confirmados por el cliente, acción = "create_reservation".
Si el cliente quiere cancelar o no desea continuar, acción = "cancel".
En cualquier otro caso, acción = "collect_more".
Responde ÚNICAMENTE con JSON válido siguiendo este esquema exacto (sin markdown, sin explicaciones):
Responde ÚNICAMENTE con JSON válido (sin markdown, sin explicaciones):
{{
"message": "<texto para enviar al cliente>",
"action": "collect_more" | "create_reservation" | "cancel",

View File

@ -10,8 +10,8 @@ from app.core.config import settings
from app.modules.bot_engine.prompt import build_system_prompt
from app.modules.bot_engine.schemas import BotResponse, CollectedData, ConversationContext
from app.modules.business.models import Business
from app.modules.business.service import get_business_config
from app.modules.calendar.service import get_available_slots
from app.modules.business.service import get_business_config, list_services, list_table_types
from app.modules.calendar.service import get_available_slots, get_available_slots_for_party
from app.modules.reservations.schemas import ReservationCreate
from app.modules.reservations.service import create_reservation
from app.modules.whatsapp.client import send_text_message
@ -106,17 +106,27 @@ async def process_message(
try:
context = await _load_context(redis, business.id, phone)
config = await get_business_config(db, business.id)
table_types = await list_table_types(db, business.id)
services = await list_services(db, business.id)
availability = None
if context.collected_data.date:
try:
availability = await get_available_slots(
db, redis, business.id, date.fromisoformat(context.collected_data.date)
)
party_size = context.collected_data.party_size or 1
if table_types:
availability = await get_available_slots_for_party(
db, redis, business.id,
date.fromisoformat(context.collected_data.date),
party_size,
)
else:
availability = await get_available_slots(
db, redis, business.id, date.fromisoformat(context.collected_data.date)
)
except Exception:
pass
system_prompt = build_system_prompt(business, config, availability, context)
system_prompt = build_system_prompt(business, config, availability, context, table_types, services)
context.messages.append({"role": "user", "content": text})

View File

@ -1,11 +1,13 @@
from datetime import date, time
from sqlalchemy import (
Boolean,
Column,
Date,
Enum,
ForeignKey,
Integer,
Numeric,
String,
Time,
func,
@ -41,6 +43,8 @@ class Business(Base):
users = relationship("User", back_populates="business", cascade="all, delete-orphan")
config = relationship("BusinessConfig", back_populates="business", uselist=False)
reservations = relationship("Reservation", back_populates="business")
table_types = relationship("TableType", back_populates="business", cascade="all, delete-orphan")
services = relationship("Service", back_populates="business", cascade="all, delete-orphan")
class BusinessConfig(Base):
@ -63,3 +67,33 @@ class BusinessConfig(Base):
welcome_message = Column(String, nullable=True)
business = relationship("Business", back_populates="config")
class TableType(Base):
__tablename__ = "table_types"
id = Column(Integer, primary_key=True, index=True)
business_id = Column(
Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True
)
capacity = Column(Integer, nullable=False)
quantity = Column(Integer, nullable=False)
label = Column(String, nullable=True)
business = relationship("Business", back_populates="table_types")
class Service(Base):
__tablename__ = "services"
id = Column(Integer, primary_key=True, index=True)
business_id = Column(
Integer, ForeignKey("businesses.id", ondelete="CASCADE"), nullable=False, index=True
)
name = Column(String, nullable=False)
description = Column(String, nullable=True)
price = Column(Numeric(10, 2), nullable=True)
duration_minutes = Column(Integer, nullable=True)
is_active = Column(Boolean, nullable=False, default=True)
business = relationship("Business", back_populates="services")

View File

@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, Response, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
@ -40,3 +40,77 @@ async def update_my_config(
db: AsyncSession = Depends(get_db),
):
return await service.update_business_config(db, business_id, body)
@router.get("/me/tables", response_model=list[schemas.TableTypeRead])
async def list_tables(
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.list_table_types(db, business_id)
@router.post("/me/tables", response_model=schemas.TableTypeRead, status_code=201)
async def create_table(
body: schemas.TableTypeCreate,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.create_table_type(db, business_id, body)
@router.put("/me/tables/{table_id}", response_model=schemas.TableTypeRead)
async def update_table(
table_id: int,
body: schemas.TableTypeUpdate,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.update_table_type(db, business_id, table_id, body)
@router.delete("/me/tables/{table_id}", status_code=204)
async def delete_table(
table_id: int,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
await service.delete_table_type(db, business_id, table_id)
return Response(status_code=204)
@router.get("/me/services", response_model=list[schemas.ServiceRead])
async def list_services(
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.list_services(db, business_id)
@router.post("/me/services", response_model=schemas.ServiceRead, status_code=201)
async def create_service(
body: schemas.ServiceCreate,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.create_service(db, business_id, body)
@router.put("/me/services/{service_id}", response_model=schemas.ServiceRead)
async def update_service(
service_id: int,
body: schemas.ServiceUpdate,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
return await service.update_service(db, business_id, service_id, body)
@router.delete("/me/services/{service_id}", status_code=204)
async def delete_service(
service_id: int,
business_id: int = Depends(get_current_business),
db: AsyncSession = Depends(get_db),
):
await service.delete_service(db, business_id, service_id)
return Response(status_code=204)

View File

@ -46,3 +46,50 @@ class BusinessConfigUpdate(BaseModel):
assistant_name: str | None = None
tone: str | None = None
welcome_message: str | None = None
class TableTypeCreate(BaseModel):
capacity: int
quantity: int
label: str | None = None
class TableTypeUpdate(BaseModel):
capacity: int | None = None
quantity: int | None = None
label: str | None = None
class TableTypeRead(BaseModel):
id: int
capacity: int
quantity: int
label: str | None
model_config = {"from_attributes": True}
class ServiceCreate(BaseModel):
name: str
description: str | None = None
price: float | None = None
duration_minutes: int | None = None
class ServiceUpdate(BaseModel):
name: str | None = None
description: str | None = None
price: float | None = None
duration_minutes: int | None = None
is_active: bool | None = None
class ServiceRead(BaseModel):
id: int
name: str
description: str | None
price: float | None
duration_minutes: int | None
is_active: bool
model_config = {"from_attributes": True}

View File

@ -2,8 +2,15 @@ from fastapi import HTTPException, status
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.business.models import Business, BusinessConfig
from app.modules.business.schemas import BusinessConfigUpdate, BusinessUpdate
from app.modules.business.models import Business, BusinessConfig, Service, TableType
from app.modules.business.schemas import (
BusinessConfigUpdate,
BusinessUpdate,
ServiceCreate,
ServiceUpdate,
TableTypeCreate,
TableTypeUpdate,
)
async def get_business(db: AsyncSession, business_id: int) -> Business:
@ -47,3 +54,91 @@ async def update_business_config(
await db.commit()
await db.refresh(config)
return config
async def list_table_types(db: AsyncSession, business_id: int) -> list[TableType]:
result = await db.execute(
select(TableType)
.where(TableType.business_id == business_id)
.order_by(TableType.capacity)
)
return list(result.scalars().all())
async def create_table_type(db: AsyncSession, business_id: int, data: TableTypeCreate) -> TableType:
table = TableType(business_id=business_id, **data.model_dump())
db.add(table)
await db.commit()
await db.refresh(table)
return table
async def update_table_type(
db: AsyncSession, business_id: int, table_id: int, data: TableTypeUpdate
) -> TableType:
result = await db.execute(
select(TableType).where(TableType.id == table_id, TableType.business_id == business_id)
)
table = result.scalar_one_or_none()
if not table:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tipo de mesa no encontrado")
for field, value in data.model_dump(exclude_none=True).items():
setattr(table, field, value)
await db.commit()
await db.refresh(table)
return table
async def delete_table_type(db: AsyncSession, business_id: int, table_id: int) -> None:
result = await db.execute(
select(TableType).where(TableType.id == table_id, TableType.business_id == business_id)
)
table = result.scalar_one_or_none()
if not table:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tipo de mesa no encontrado")
await db.delete(table)
await db.commit()
async def list_services(db: AsyncSession, business_id: int) -> list[Service]:
result = await db.execute(
select(Service)
.where(Service.business_id == business_id)
.order_by(Service.name)
)
return list(result.scalars().all())
async def create_service(db: AsyncSession, business_id: int, data: ServiceCreate) -> Service:
service = Service(business_id=business_id, **data.model_dump())
db.add(service)
await db.commit()
await db.refresh(service)
return service
async def update_service(
db: AsyncSession, business_id: int, service_id: int, data: ServiceUpdate
) -> Service:
result = await db.execute(
select(Service).where(Service.id == service_id, Service.business_id == business_id)
)
service = result.scalar_one_or_none()
if not service:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Servicio no encontrado")
for field, value in data.model_dump(exclude_none=True).items():
setattr(service, field, value)
await db.commit()
await db.refresh(service)
return service
async def delete_service(db: AsyncSession, business_id: int, service_id: int) -> None:
result = await db.execute(
select(Service).where(Service.id == service_id, Service.business_id == business_id)
)
service = result.scalar_one_or_none()
if not service:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Servicio no encontrado")
await db.delete(service)
await db.commit()

View File

@ -6,8 +6,8 @@ from fastapi import HTTPException, status
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.business.models import BusinessConfig
from app.modules.business.service import get_business_config
from app.modules.business.models import BusinessConfig, TableType
from app.modules.business.service import get_business_config, list_table_types
from app.modules.calendar.schemas import DayAvailability, SlotRead
from app.modules.reservations.models import Reservation
@ -18,6 +18,10 @@ def _cache_key(business_id: int, target_date: date) -> str:
return f"slots:{business_id}:{target_date.isoformat()}"
def _cache_key_party(business_id: int, target_date: date, party_size: int) -> str:
return f"slots:{business_id}:{target_date.isoformat()}:p{party_size}"
def _generate_slots(open_time: time, close_time: time, slot_duration: int) -> list[tuple[time, time]]:
slots = []
base = date.today()
@ -51,19 +55,46 @@ async def _count_reservations_per_slot(
return {slot: counts.get(slot[0], 0) for slot in slots}
async def _count_reservations_by_table_type(
db: AsyncSession,
business_id: int,
target_date: date,
slots: list[tuple[time, time]],
) -> dict[tuple[time, time], dict[int, int]]:
"""Devuelve {slot: {table_type_id: count}} para slots con table_type_id asignado."""
result = await db.execute(
select(Reservation.time_start, Reservation.table_type_id, func.count(Reservation.id))
.where(
and_(
Reservation.business_id == business_id,
Reservation.date == target_date,
Reservation.status.in_(["pending", "confirmed"]),
Reservation.table_type_id.isnot(None),
)
)
.group_by(Reservation.time_start, Reservation.table_type_id)
)
slot_counts: dict[tuple[time, time], dict[int, int]] = {s: {} for s in slots}
for time_start, table_type_id, count in result.all():
for slot in slots:
if slot[0] == time_start:
slot_counts[slot][table_type_id] = count
return slot_counts
async def get_available_slots(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
target_date: date,
) -> DayAvailability:
"""Disponibilidad genérica (sin filtro de party_size). Usada por el panel de control."""
cache_key = _cache_key(business_id, target_date)
cached = await redis.get(cache_key)
if cached:
return DayAvailability.model_validate_json(cached)
config = await get_business_config(db, business_id)
is_open = (
target_date.weekday() in (config.open_days or [])
and target_date not in (config.blocked_dates or [])
@ -75,23 +106,119 @@ async def get_available_slots(
return result
raw_slots = _generate_slots(config.open_time, config.close_time, config.slot_duration)
counts = await _count_reservations_per_slot(db, business_id, target_date, raw_slots)
slots = [
SlotRead(
time_start=s[0],
time_end=s[1],
available=max(0, config.max_per_slot - counts[s]),
max_per_slot=config.max_per_slot,
)
for s in raw_slots
]
# Restaurantes con mesas configuradas: mostrar disponibilidad agregada
table_types = await list_table_types(db, business_id)
if table_types:
slot_table_counts = await _count_reservations_by_table_type(db, business_id, target_date, raw_slots)
total_tables = sum(t.quantity for t in table_types)
slots = []
for s in raw_slots:
used = sum(slot_table_counts[s].values())
slots.append(SlotRead(
time_start=s[0],
time_end=s[1],
available=max(0, total_tables - used),
max_per_slot=total_tables,
))
else:
counts = await _count_reservations_per_slot(db, business_id, target_date, raw_slots)
slots = [
SlotRead(
time_start=s[0],
time_end=s[1],
available=max(0, config.max_per_slot - counts[s]),
max_per_slot=config.max_per_slot,
)
for s in raw_slots
]
result = DayAvailability(date=target_date, is_open=True, slots=slots)
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
return result
async def get_available_slots_for_party(
db: AsyncSession,
redis: aioredis.Redis,
business_id: int,
target_date: date,
party_size: int,
) -> DayAvailability:
"""Disponibilidad filtrada por tamaño de grupo. Usada por el bot para restaurantes."""
cache_key = _cache_key_party(business_id, target_date, party_size)
cached = await redis.get(cache_key)
if cached:
return DayAvailability.model_validate_json(cached)
config = await get_business_config(db, business_id)
is_open = (
target_date.weekday() in (config.open_days or [])
and target_date not in (config.blocked_dates or [])
)
if not is_open:
result = DayAvailability(date=target_date, is_open=False, slots=[])
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
return result
raw_slots = _generate_slots(config.open_time, config.close_time, config.slot_duration)
table_types = await list_table_types(db, business_id)
if not table_types:
# Negocio sin mesas configuradas → usa max_per_slot genérico
counts = await _count_reservations_per_slot(db, business_id, target_date, raw_slots)
slots = [
SlotRead(
time_start=s[0],
time_end=s[1],
available=max(0, config.max_per_slot - counts[s]),
max_per_slot=config.max_per_slot,
)
for s in raw_slots
]
else:
# Mesas configuradas: buscar tipos con capacidad >= party_size
fitting_types = [t for t in table_types if t.capacity >= party_size]
slot_table_counts = await _count_reservations_by_table_type(db, business_id, target_date, raw_slots)
slots = []
for s in raw_slots:
available_tables = 0
total_fitting = 0
for t in fitting_types:
used = slot_table_counts[s].get(t.id, 0)
free = max(0, t.quantity - used)
available_tables += free
total_fitting += t.quantity
slots.append(SlotRead(
time_start=s[0],
time_end=s[1],
available=available_tables,
max_per_slot=total_fitting,
))
result = DayAvailability(date=target_date, is_open=True, slots=slots)
await redis.setex(cache_key, SLOTS_CACHE_TTL, result.model_dump_json())
return result
async def find_best_table_for_party(
table_types: list[TableType],
slot_table_counts: dict[int, int],
party_size: int,
) -> TableType | None:
"""Devuelve la mesa más pequeña que cabe el grupo y que tiene disponibilidad."""
fitting = sorted(
[t for t in table_types if t.capacity >= party_size],
key=lambda t: t.capacity,
)
for t in fitting:
used = slot_table_counts.get(t.id, 0)
if used < t.quantity:
return t
return None
async def get_availability_range(
db: AsyncSession,
redis: aioredis.Redis,
@ -113,7 +240,13 @@ async def get_availability_range(
async def invalidate_slots_cache(redis: aioredis.Redis, business_id: int, target_date: date) -> None:
# Invalida cache genérico y cualquier cache por party_size (patrón)
await redis.delete(_cache_key(business_id, target_date))
# Borra claves con party_size para esa fecha
pattern = f"slots:{business_id}:{target_date.isoformat()}:p*"
keys = await redis.keys(pattern)
if keys:
await redis.delete(*keys)
async def add_blocked_date(db: AsyncSession, business_id: int, target_date: date) -> None:

View File

@ -26,6 +26,8 @@ class Reservation(Base):
default="manual",
)
notes = Column(String, nullable=True)
table_type_id = Column(Integer, ForeignKey("table_types.id", ondelete="SET NULL"), nullable=True)
created_at = Column(Date, server_default=func.current_date())
business = relationship("Business", back_populates="reservations")
table_type = relationship("TableType")

View File

@ -5,8 +5,10 @@ from fastapi import HTTPException, status
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.modules.business.service import get_business_config
from app.modules.calendar.service import invalidate_slots_cache
from sqlalchemy import func
from app.modules.business.service import get_business_config, list_table_types
from app.modules.calendar.service import find_best_table_for_party, invalidate_slots_cache
from app.modules.reservations.models import Reservation
from app.modules.reservations.schemas import ReservationCreate, ReservationUpdate
@ -55,6 +57,34 @@ async def create_reservation(
config = await get_business_config(db, business_id)
time_end = _compute_time_end(data.time_start, config.slot_duration)
# Asignar mesa automáticamente si el negocio tiene tipos de mesa configurados
table_type_id = None
table_types = await list_table_types(db, business_id)
if table_types:
# Contar reservas por table_type en ese slot
result = await db.execute(
select(Reservation.table_type_id, func.count(Reservation.id))
.where(
and_(
Reservation.business_id == business_id,
Reservation.date == data.date,
Reservation.time_start == data.time_start,
Reservation.status.in_(["pending", "confirmed"]),
Reservation.table_type_id.isnot(None),
)
)
.group_by(Reservation.table_type_id)
)
slot_table_counts = {row[0]: row[1] for row in result.all()}
best_table = await find_best_table_for_party(table_types, slot_table_counts, data.party_size)
if best_table is None:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="No hay mesas disponibles para ese número de personas en ese horario.",
)
table_type_id = best_table.id
reservation = Reservation(
business_id=business_id,
client_name=data.client_name,
@ -66,6 +96,7 @@ async def create_reservation(
source=data.source,
notes=data.notes,
status="pending",
table_type_id=table_type_id,
)
db.add(reservation)
await db.commit()

View File

@ -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) =>

View File

@ -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' : ''}`

View File

@ -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"