tematicas por tipo de bot
This commit is contained in:
47
backend/alembic/versions/0002_table_types.py
Normal file
47
backend/alembic/versions/0002_table_types.py
Normal 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")
|
||||||
38
backend/alembic/versions/0003_services.py
Normal file
38
backend/alembic/versions/0003_services.py
Normal 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")
|
||||||
@ -1,9 +1,88 @@
|
|||||||
from app.modules.bot_engine.schemas import CollectedData, ConversationContext
|
from app.modules.bot_engine.schemas import ConversationContext
|
||||||
from app.modules.business.models import Business, BusinessConfig
|
from app.modules.business.models import Business, BusinessConfig, Service, TableType
|
||||||
from app.modules.calendar.schemas import DayAvailability
|
from app.modules.calendar.schemas import DayAvailability
|
||||||
|
|
||||||
DAYS_ES = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
|
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:
|
def _format_open_days(open_days: list[int]) -> str:
|
||||||
return ", ".join(DAYS_ES[d] for d in sorted(open_days))
|
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."
|
return "No hay disponibilidad para esa fecha."
|
||||||
available = [s for s in availability.slots if s.available > 0]
|
available = [s for s in availability.slots if s.available > 0]
|
||||||
if not available:
|
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)
|
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(
|
def build_system_prompt(
|
||||||
business: Business,
|
business: Business,
|
||||||
config: BusinessConfig,
|
config: BusinessConfig,
|
||||||
availability: DayAvailability | None,
|
availability: DayAvailability | None,
|
||||||
context: ConversationContext,
|
context: ConversationContext,
|
||||||
|
table_types: list[TableType] | None = None,
|
||||||
|
services: list[Service] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
|
btype = business.type or "other"
|
||||||
|
type_ctx = BUSINESS_TYPE_CONTEXT.get(btype, BUSINESS_TYPE_CONTEXT["other"])
|
||||||
|
|
||||||
tone_instruction = (
|
tone_instruction = (
|
||||||
"Usa un tono formal y profesional."
|
"Usa un tono formal y profesional."
|
||||||
if config.tone == "formal"
|
if config.tone == "formal"
|
||||||
@ -38,29 +151,53 @@ def build_system_prompt(
|
|||||||
f"- Personas: {collected.party_size or 'pendiente'}",
|
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}.
|
return f"""Eres {config.assistant_name}, asistente virtual de {business.name}.
|
||||||
|
{type_ctx['description']}
|
||||||
{tone_instruction}
|
{tone_instruction}
|
||||||
Responde SIEMPRE en el idioma del cliente.
|
Responde SIEMPRE en el idioma del cliente.
|
||||||
|
{party_first_hint}{tables_block}{services_block}
|
||||||
HORARIO DEL NEGOCIO:
|
HORARIO DEL NEGOCIO:
|
||||||
- Días de atención: {_format_open_days(config.open_days or [])}
|
- 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")}
|
- Horario: {config.open_time.strftime("%H:%M")} a {config.close_time.strftime("%H:%M")}
|
||||||
- Duración de cada turno: {config.slot_duration} minutos
|
- 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:
|
DATOS YA RECOPILADOS DEL CLIENTE:
|
||||||
{collected_summary}
|
{collected_summary}
|
||||||
|
|
||||||
OBJETIVO: Recopilar nombre, fecha, hora y número de personas para crear la reserva.
|
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, acción = "create_reservation".
|
Si ya tienes todos los datos confirmados por el cliente, acción = "create_reservation".
|
||||||
Si el cliente quiere cancelar, acción = "cancel".
|
Si el cliente quiere cancelar o no desea continuar, acción = "cancel".
|
||||||
En cualquier otro caso, acción = "collect_more".
|
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>",
|
"message": "<texto para enviar al cliente>",
|
||||||
"action": "collect_more" | "create_reservation" | "cancel",
|
"action": "collect_more" | "create_reservation" | "cancel",
|
||||||
|
|||||||
@ -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.prompt import build_system_prompt
|
||||||
from app.modules.bot_engine.schemas import BotResponse, CollectedData, ConversationContext
|
from app.modules.bot_engine.schemas import BotResponse, CollectedData, ConversationContext
|
||||||
from app.modules.business.models import Business
|
from app.modules.business.models import Business
|
||||||
from app.modules.business.service import get_business_config
|
from app.modules.business.service import get_business_config, list_services, list_table_types
|
||||||
from app.modules.calendar.service import get_available_slots
|
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.schemas import ReservationCreate
|
||||||
from app.modules.reservations.service import create_reservation
|
from app.modules.reservations.service import create_reservation
|
||||||
from app.modules.whatsapp.client import send_text_message
|
from app.modules.whatsapp.client import send_text_message
|
||||||
@ -106,17 +106,27 @@ async def process_message(
|
|||||||
try:
|
try:
|
||||||
context = await _load_context(redis, business.id, phone)
|
context = await _load_context(redis, business.id, phone)
|
||||||
config = await get_business_config(db, business.id)
|
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
|
availability = None
|
||||||
if context.collected_data.date:
|
if context.collected_data.date:
|
||||||
try:
|
try:
|
||||||
|
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(
|
availability = await get_available_slots(
|
||||||
db, redis, business.id, date.fromisoformat(context.collected_data.date)
|
db, redis, business.id, date.fromisoformat(context.collected_data.date)
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
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})
|
context.messages.append({"role": "user", "content": text})
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,13 @@
|
|||||||
from datetime import date, time
|
from datetime import date, time
|
||||||
|
|
||||||
from sqlalchemy import (
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
Column,
|
Column,
|
||||||
Date,
|
Date,
|
||||||
Enum,
|
Enum,
|
||||||
ForeignKey,
|
ForeignKey,
|
||||||
Integer,
|
Integer,
|
||||||
|
Numeric,
|
||||||
String,
|
String,
|
||||||
Time,
|
Time,
|
||||||
func,
|
func,
|
||||||
@ -41,6 +43,8 @@ class Business(Base):
|
|||||||
users = relationship("User", back_populates="business", cascade="all, delete-orphan")
|
users = relationship("User", back_populates="business", cascade="all, delete-orphan")
|
||||||
config = relationship("BusinessConfig", back_populates="business", uselist=False)
|
config = relationship("BusinessConfig", back_populates="business", uselist=False)
|
||||||
reservations = relationship("Reservation", back_populates="business")
|
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):
|
class BusinessConfig(Base):
|
||||||
@ -63,3 +67,33 @@ class BusinessConfig(Base):
|
|||||||
welcome_message = Column(String, nullable=True)
|
welcome_message = Column(String, nullable=True)
|
||||||
|
|
||||||
business = relationship("Business", back_populates="config")
|
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")
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends, Response, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.core.database import get_db
|
from app.core.database import get_db
|
||||||
@ -40,3 +40,77 @@ async def update_my_config(
|
|||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
return await service.update_business_config(db, business_id, body)
|
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)
|
||||||
|
|||||||
@ -46,3 +46,50 @@ class BusinessConfigUpdate(BaseModel):
|
|||||||
assistant_name: str | None = None
|
assistant_name: str | None = None
|
||||||
tone: str | None = None
|
tone: str | None = None
|
||||||
welcome_message: 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}
|
||||||
|
|||||||
@ -2,8 +2,15 @@ from fastapi import HTTPException, status
|
|||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.modules.business.models import Business, BusinessConfig
|
from app.modules.business.models import Business, BusinessConfig, Service, TableType
|
||||||
from app.modules.business.schemas import BusinessConfigUpdate, BusinessUpdate
|
from app.modules.business.schemas import (
|
||||||
|
BusinessConfigUpdate,
|
||||||
|
BusinessUpdate,
|
||||||
|
ServiceCreate,
|
||||||
|
ServiceUpdate,
|
||||||
|
TableTypeCreate,
|
||||||
|
TableTypeUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_business(db: AsyncSession, business_id: int) -> Business:
|
async def get_business(db: AsyncSession, business_id: int) -> Business:
|
||||||
@ -47,3 +54,91 @@ async def update_business_config(
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(config)
|
await db.refresh(config)
|
||||||
return 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()
|
||||||
|
|||||||
@ -6,8 +6,8 @@ from fastapi import HTTPException, status
|
|||||||
from sqlalchemy import and_, func, select
|
from sqlalchemy import and_, func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.modules.business.models import BusinessConfig
|
from app.modules.business.models import BusinessConfig, TableType
|
||||||
from app.modules.business.service import get_business_config
|
from app.modules.business.service import get_business_config, list_table_types
|
||||||
from app.modules.calendar.schemas import DayAvailability, SlotRead
|
from app.modules.calendar.schemas import DayAvailability, SlotRead
|
||||||
from app.modules.reservations.models import Reservation
|
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()}"
|
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]]:
|
def _generate_slots(open_time: time, close_time: time, slot_duration: int) -> list[tuple[time, time]]:
|
||||||
slots = []
|
slots = []
|
||||||
base = date.today()
|
base = date.today()
|
||||||
@ -51,19 +55,46 @@ async def _count_reservations_per_slot(
|
|||||||
return {slot: counts.get(slot[0], 0) for slot in slots}
|
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(
|
async def get_available_slots(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
redis: aioredis.Redis,
|
redis: aioredis.Redis,
|
||||||
business_id: int,
|
business_id: int,
|
||||||
target_date: date,
|
target_date: date,
|
||||||
) -> DayAvailability:
|
) -> DayAvailability:
|
||||||
|
"""Disponibilidad genérica (sin filtro de party_size). Usada por el panel de control."""
|
||||||
cache_key = _cache_key(business_id, target_date)
|
cache_key = _cache_key(business_id, target_date)
|
||||||
cached = await redis.get(cache_key)
|
cached = await redis.get(cache_key)
|
||||||
if cached:
|
if cached:
|
||||||
return DayAvailability.model_validate_json(cached)
|
return DayAvailability.model_validate_json(cached)
|
||||||
|
|
||||||
config = await get_business_config(db, business_id)
|
config = await get_business_config(db, business_id)
|
||||||
|
|
||||||
is_open = (
|
is_open = (
|
||||||
target_date.weekday() in (config.open_days or [])
|
target_date.weekday() in (config.open_days or [])
|
||||||
and target_date not in (config.blocked_dates or [])
|
and target_date not in (config.blocked_dates or [])
|
||||||
@ -75,8 +106,23 @@ async def get_available_slots(
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
raw_slots = _generate_slots(config.open_time, config.close_time, config.slot_duration)
|
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)
|
|
||||||
|
|
||||||
|
# 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 = [
|
slots = [
|
||||||
SlotRead(
|
SlotRead(
|
||||||
time_start=s[0],
|
time_start=s[0],
|
||||||
@ -92,6 +138,87 @@ async def get_available_slots(
|
|||||||
return result
|
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(
|
async def get_availability_range(
|
||||||
db: AsyncSession,
|
db: AsyncSession,
|
||||||
redis: aioredis.Redis,
|
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:
|
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))
|
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:
|
async def add_blocked_date(db: AsyncSession, business_id: int, target_date: date) -> None:
|
||||||
|
|||||||
@ -26,6 +26,8 @@ class Reservation(Base):
|
|||||||
default="manual",
|
default="manual",
|
||||||
)
|
)
|
||||||
notes = Column(String, nullable=True)
|
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())
|
created_at = Column(Date, server_default=func.current_date())
|
||||||
|
|
||||||
business = relationship("Business", back_populates="reservations")
|
business = relationship("Business", back_populates="reservations")
|
||||||
|
table_type = relationship("TableType")
|
||||||
|
|||||||
@ -5,8 +5,10 @@ from fastapi import HTTPException, status
|
|||||||
from sqlalchemy import and_, select
|
from sqlalchemy import and_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.modules.business.service import get_business_config
|
from sqlalchemy import func
|
||||||
from app.modules.calendar.service import invalidate_slots_cache
|
|
||||||
|
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.models import Reservation
|
||||||
from app.modules.reservations.schemas import ReservationCreate, ReservationUpdate
|
from app.modules.reservations.schemas import ReservationCreate, ReservationUpdate
|
||||||
|
|
||||||
@ -55,6 +57,34 @@ async def create_reservation(
|
|||||||
config = await get_business_config(db, business_id)
|
config = await get_business_config(db, business_id)
|
||||||
time_end = _compute_time_end(data.time_start, config.slot_duration)
|
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(
|
reservation = Reservation(
|
||||||
business_id=business_id,
|
business_id=business_id,
|
||||||
client_name=data.client_name,
|
client_name=data.client_name,
|
||||||
@ -66,6 +96,7 @@ async def create_reservation(
|
|||||||
source=data.source,
|
source=data.source,
|
||||||
notes=data.notes,
|
notes=data.notes,
|
||||||
status="pending",
|
status="pending",
|
||||||
|
table_type_id=table_type_id,
|
||||||
)
|
)
|
||||||
db.add(reservation)
|
db.add(reservation)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
|
|||||||
@ -50,6 +50,20 @@ export const reservationsApi = {
|
|||||||
delete: (id) => api.delete(`/reservations/${id}`),
|
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 = {
|
export const calendarApi = {
|
||||||
getAvailability: (date) => api.get('/calendar/availability', { params: { date } }),
|
getAvailability: (date) => api.get('/calendar/availability', { params: { date } }),
|
||||||
getAvailabilityRange: (start, end) =>
|
getAvailabilityRange: (start, end) =>
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react'
|
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 { es } from 'date-fns/locale'
|
||||||
import { ChevronLeft, ChevronRight, Lock, Unlock, Clock } from 'lucide-react'
|
import { ChevronLeft, ChevronRight, Lock, Unlock, Clock } from 'lucide-react'
|
||||||
import { calendarApi } from '@/lib/api'
|
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']
|
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() {
|
export default function CalendarPage() {
|
||||||
const [currentDate, setCurrentDate] = useState(new Date())
|
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||||
const [selectedDate, setSelectedDate] = useState(new Date())
|
const [selectedDate, setSelectedDate] = useState(new Date())
|
||||||
const [slots, setSlots] = useState([])
|
const [slots, setSlots] = useState([])
|
||||||
const [blockedDates, setBlockedDates] = useState([])
|
const [blockedDates, setBlockedDates] = useState([])
|
||||||
const [loadingSlots, setLoadingSlots] = useState(false)
|
const [loadingSlots, setLoadingSlots] = useState(false)
|
||||||
const [togglingDate, setTogglingDate] = useState(false)
|
const [togglingDate, setTogglingDate] = useState(false)
|
||||||
|
|
||||||
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 })
|
const monthDays = buildMonthGrid(currentMonth)
|
||||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
|
||||||
|
|
||||||
const selectedStr = format(selectedDate, 'yyyy-MM-dd')
|
const selectedStr = format(selectedDate, 'yyyy-MM-dd')
|
||||||
const isBlocked = blockedDates.includes(selectedStr)
|
const isBlocked = blockedDates.includes(selectedStr)
|
||||||
|
|
||||||
@ -26,7 +34,7 @@ export default function CalendarPage() {
|
|||||||
setLoadingSlots(true)
|
setLoadingSlots(true)
|
||||||
try {
|
try {
|
||||||
const { data } = await calendarApi.getAvailability(selectedStr)
|
const { data } = await calendarApi.getAvailability(selectedStr)
|
||||||
setSlots(data)
|
setSlots(data.slots ?? [])
|
||||||
} catch {
|
} catch {
|
||||||
setSlots([])
|
setSlots([])
|
||||||
} finally {
|
} finally {
|
||||||
@ -53,56 +61,62 @@ export default function CalendarPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6 max-w-4xl animate-fade-in">
|
<div className="flex flex-col gap-6 max-w-4xl animate-fade-in">
|
||||||
<div className="grid lg:grid-cols-3 gap-5">
|
<div className="grid lg:grid-cols-3 gap-5 items-start">
|
||||||
{/* Calendario semanal */}
|
{/* Calendario mensual */}
|
||||||
<div className="lg:col-span-2 card p-5">
|
<div className="lg:col-span-2 card p-5">
|
||||||
{/* Nav semana */}
|
{/* Nav mes */}
|
||||||
<div className="flex items-center justify-between mb-5">
|
<div className="flex items-center justify-between mb-5">
|
||||||
<h3 className="font-display text-base font-semibold text-slate-800 capitalize">
|
<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>
|
</h3>
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentDate((d) => addDays(d, -7))}
|
onClick={() => setCurrentMonth((d) => addMonths(d, -1))}
|
||||||
className="btn-ghost p-1.5"
|
className="btn-ghost p-1.5"
|
||||||
aria-label="Semana anterior"
|
aria-label="Mes anterior"
|
||||||
>
|
>
|
||||||
<ChevronLeft size={16} />
|
<ChevronLeft size={16} />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentDate(new Date())}
|
onClick={() => { setCurrentMonth(new Date()); setSelectedDate(new Date()) }}
|
||||||
className="btn-secondary px-3 py-1.5 text-xs"
|
className="btn-secondary px-3 py-1.5 text-xs"
|
||||||
>
|
>
|
||||||
Hoy
|
Hoy
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentDate((d) => addDays(d, 7))}
|
onClick={() => setCurrentMonth((d) => addMonths(d, 1))}
|
||||||
className="btn-ghost p-1.5"
|
className="btn-ghost p-1.5"
|
||||||
aria-label="Semana siguiente"
|
aria-label="Mes siguiente"
|
||||||
>
|
>
|
||||||
<ChevronRight size={16} />
|
<ChevronRight size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Días */}
|
{/* Cabecera días */}
|
||||||
<div className="grid grid-cols-7 gap-1.5">
|
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||||
{WEEK_DAYS.map((d) => (
|
{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 dayStr = format(day, 'yyyy-MM-dd')
|
||||||
const selected = isSameDay(day, selectedDate)
|
const selected = isSameDay(day, selectedDate)
|
||||||
const today = isToday(day)
|
const today = isToday(day)
|
||||||
const blocked = blockedDates.includes(dayStr)
|
const blocked = blockedDates.includes(dayStr)
|
||||||
|
const otherMonth = !isSameMonth(day, currentMonth)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={dayStr}
|
key={dayStr}
|
||||||
onClick={() => setSelectedDate(day)}
|
onClick={() => setSelectedDate(day)}
|
||||||
className={cn(
|
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',
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||||
|
otherMonth && 'opacity-25 pointer-events-none',
|
||||||
selected
|
selected
|
||||||
? 'bg-primary-600 text-white shadow-sm'
|
? 'bg-primary-600 text-white shadow-sm'
|
||||||
: blocked
|
: blocked
|
||||||
@ -114,9 +128,9 @@ export default function CalendarPage() {
|
|||||||
aria-pressed={selected}
|
aria-pressed={selected}
|
||||||
aria-label={`${format(day, 'd MMMM', { locale: es })}${blocked ? ', bloqueado' : ''}`}
|
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 && (
|
{blocked && !selected && (
|
||||||
<Lock size={9} className="mt-1 opacity-50" />
|
<Lock size={8} className="mt-0.5 opacity-50" />
|
||||||
)}
|
)}
|
||||||
</button>
|
</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">
|
<div className="flex flex-col gap-1.5 max-h-56 overflow-y-auto pr-1">
|
||||||
{slots.map((slot) => (
|
{slots.map((slot) => (
|
||||||
<div
|
<div
|
||||||
key={slot.time}
|
key={slot.time_start}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-between px-3.5 py-2.5 rounded-lg text-sm border transition-colors',
|
'flex items-center justify-between px-3.5 py-2.5 rounded-lg text-sm border transition-colors',
|
||||||
slot.available > 0
|
slot.available > 0
|
||||||
@ -206,7 +220,7 @@ export default function CalendarPage() {
|
|||||||
: 'bg-slate-50 border-border text-slate-400',
|
: '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">
|
<span className="text-xs">
|
||||||
{slot.available > 0
|
{slot.available > 0
|
||||||
? `${slot.available} libre${slot.available > 1 ? 's' : ''}`
|
? `${slot.available} libre${slot.available > 1 ? 's' : ''}`
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { Save, CheckCircle, AlertCircle, Wifi, WifiOff, ExternalLink } from 'lucide-react'
|
import { Save, CheckCircle, AlertCircle, Wifi, WifiOff, Phone, Unplug, UtensilsCrossed, Plus, Trash2, Pencil, Tag } from 'lucide-react'
|
||||||
import { businessApi, whatsappApi } from '@/lib/api'
|
import { businessApi, whatsappApi, tableApi, serviceApi } from '@/lib/api'
|
||||||
import { DAYS_FULL, cn } from '@/lib/utils'
|
import { DAYS_FULL, cn } from '@/lib/utils'
|
||||||
|
|
||||||
const TONE_OPTIONS = [
|
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() {
|
export default function ConfigPage() {
|
||||||
const [business, setBusiness] = useState(null)
|
const [business, setBusiness] = useState(null)
|
||||||
const [config, setConfig] = useState(null)
|
const [config, setConfig] = useState(null)
|
||||||
@ -29,17 +31,43 @@ export default function ConfigPage() {
|
|||||||
const [saved, setSaved] = useState(false)
|
const [saved, setSaved] = useState(false)
|
||||||
const [error, setError] = useState('')
|
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(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
const [bRes, cRes, wRes] = await Promise.all([
|
const [bRes, cRes, wRes, tRes, sRes] = await Promise.all([
|
||||||
businessApi.getMe(),
|
businessApi.getMe(),
|
||||||
businessApi.getConfig(),
|
businessApi.getConfig(),
|
||||||
whatsappApi.getStatus().catch(() => ({ data: null })),
|
whatsappApi.getStatus().catch(() => ({ data: null })),
|
||||||
|
tableApi.list().catch(() => ({ data: [] })),
|
||||||
|
serviceApi.list().catch(() => ({ data: [] })),
|
||||||
])
|
])
|
||||||
setBusiness(bRes.data)
|
setBusiness(bRes.data)
|
||||||
setConfig(cRes.data)
|
setConfig(cRes.data)
|
||||||
setWaStatus(wRes.data)
|
setWaStatus(wRes.data)
|
||||||
|
setTables(tRes.data ?? [])
|
||||||
|
setSvcList(sRes.data ?? [])
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
@ -59,6 +87,143 @@ export default function ConfigPage() {
|
|||||||
updateConfig('open_days', next)
|
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() {
|
async function handleSave() {
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError('')
|
setError('')
|
||||||
@ -105,32 +270,126 @@ export default function ConfigPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-5 max-w-2xl animate-fade-in">
|
<div className="flex flex-col gap-5 max-w-2xl animate-fade-in">
|
||||||
{/* WhatsApp status */}
|
{/* WhatsApp */}
|
||||||
|
<Section
|
||||||
|
title="WhatsApp Business"
|
||||||
|
description="Conecta tu número para que el bot reciba y responda reservas."
|
||||||
|
>
|
||||||
|
{/* Estado */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'card p-5 flex items-center gap-4',
|
'flex items-center gap-4 rounded-xl px-4 py-3.5 border',
|
||||||
isWaConnected ? 'border-primary-200 bg-primary-50' : 'border-warning-200 bg-warning-50',
|
isWaConnected ? 'bg-primary-50 border-primary-200' : 'bg-warning-50 border-warning-200',
|
||||||
)}>
|
)}>
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
'w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0',
|
'w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||||
isWaConnected ? 'bg-primary-100' : 'bg-warning-100',
|
isWaConnected ? 'bg-primary-100' : 'bg-warning-100',
|
||||||
)}>
|
)}>
|
||||||
{isWaConnected
|
{isWaConnected
|
||||||
? <Wifi size={18} className="text-primary-600" />
|
? <Wifi size={16} className="text-primary-600" />
|
||||||
: <WifiOff size={18} className="text-warning-600" />
|
: <WifiOff size={16} className="text-warning-600" />
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<p className={cn('text-sm font-semibold', isWaConnected ? 'text-primary-800' : 'text-warning-800')}>
|
<p className={cn('text-sm font-semibold', isWaConnected ? 'text-primary-800' : 'text-warning-800')}>
|
||||||
WhatsApp {isWaConnected ? 'conectado' : 'no conectado'}
|
{isWaConnected ? 'Conectado' : 'Sin conectar'}
|
||||||
</p>
|
</p>
|
||||||
<p className={cn('text-xs mt-0.5', isWaConnected ? 'text-primary-600' : 'text-warning-600')}>
|
<p className={cn('text-xs mt-0.5', isWaConnected ? 'text-primary-600' : 'text-warning-600')}>
|
||||||
{isWaConnected
|
{isWaConnected
|
||||||
? `Número: ${waStatus.whatsapp_phone_number_id}`
|
? `Phone Number ID: ${waStatus.phone_number_id}`
|
||||||
: 'Configura tu número de WhatsApp Business para recibir reservas.'}
|
: 'El bot no puede recibir mensajes hasta que conectes un número.'}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</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 */}
|
{/* Negocio */}
|
||||||
<Section title="Datos del negocio" description="Nombre y zona horaria visible en el panel.">
|
<Section title="Datos del negocio" description="Nombre y zona horaria visible en el panel.">
|
||||||
<div className="field">
|
<div className="field">
|
||||||
@ -232,6 +491,272 @@ export default function ConfigPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Section>
|
</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 */}
|
{/* Bot */}
|
||||||
<Section
|
<Section
|
||||||
title="Configuración del asistente"
|
title="Configuración del asistente"
|
||||||
|
|||||||
Reference in New Issue
Block a user