From dcd77a3982b9a1d5ca1282348bb43683f2ac2830 Mon Sep 17 00:00:00 2001 From: Hanzo_dev <2002samudiojohan@gmail.com> Date: Wed, 29 Apr 2026 09:39:56 -0500 Subject: [PATCH] tematicas por tipo de bot --- backend/alembic/versions/0002_table_types.py | 47 ++ backend/alembic/versions/0003_services.py | 38 ++ backend/app/modules/bot_engine/prompt.py | 159 ++++- backend/app/modules/bot_engine/service.py | 22 +- backend/app/modules/business/models.py | 34 ++ backend/app/modules/business/router.py | 76 ++- backend/app/modules/business/schemas.py | 47 ++ backend/app/modules/business/service.py | 99 +++- backend/app/modules/calendar/service.py | 159 ++++- backend/app/modules/reservations/models.py | 2 + backend/app/modules/reservations/service.py | 35 +- frontend/src/lib/api.js | 14 + frontend/src/pages/CalendarPage.jsx | 62 +- frontend/src/pages/ConfigPage.jsx | 573 ++++++++++++++++++- 14 files changed, 1284 insertions(+), 83 deletions(-) create mode 100644 backend/alembic/versions/0002_table_types.py create mode 100644 backend/alembic/versions/0003_services.py diff --git a/backend/alembic/versions/0002_table_types.py b/backend/alembic/versions/0002_table_types.py new file mode 100644 index 0000000..d7a26f3 --- /dev/null +++ b/backend/alembic/versions/0002_table_types.py @@ -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") diff --git a/backend/alembic/versions/0003_services.py b/backend/alembic/versions/0003_services.py new file mode 100644 index 0000000..644ad7d --- /dev/null +++ b/backend/alembic/versions/0003_services.py @@ -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") diff --git a/backend/app/modules/bot_engine/prompt.py b/backend/app/modules/bot_engine/prompt.py index 9f41ef6..fbfcc79 100644 --- a/backend/app/modules/bot_engine/prompt.py +++ b/backend/app/modules/bot_engine/prompt.py @@ -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": "", "action": "collect_more" | "create_reservation" | "cancel", diff --git a/backend/app/modules/bot_engine/service.py b/backend/app/modules/bot_engine/service.py index f34931b..c0ed98f 100644 --- a/backend/app/modules/bot_engine/service.py +++ b/backend/app/modules/bot_engine/service.py @@ -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}) diff --git a/backend/app/modules/business/models.py b/backend/app/modules/business/models.py index d25545e..3f82f0f 100644 --- a/backend/app/modules/business/models.py +++ b/backend/app/modules/business/models.py @@ -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") diff --git a/backend/app/modules/business/router.py b/backend/app/modules/business/router.py index f8f78a8..5456a0f 100644 --- a/backend/app/modules/business/router.py +++ b/backend/app/modules/business/router.py @@ -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) diff --git a/backend/app/modules/business/schemas.py b/backend/app/modules/business/schemas.py index 1a5f521..8dd69d0 100644 --- a/backend/app/modules/business/schemas.py +++ b/backend/app/modules/business/schemas.py @@ -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} diff --git a/backend/app/modules/business/service.py b/backend/app/modules/business/service.py index 792206b..017ba50 100644 --- a/backend/app/modules/business/service.py +++ b/backend/app/modules/business/service.py @@ -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() diff --git a/backend/app/modules/calendar/service.py b/backend/app/modules/calendar/service.py index a89b20c..c2bde4a 100644 --- a/backend/app/modules/calendar/service.py +++ b/backend/app/modules/calendar/service.py @@ -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: diff --git a/backend/app/modules/reservations/models.py b/backend/app/modules/reservations/models.py index c265b60..0446d5f 100644 --- a/backend/app/modules/reservations/models.py +++ b/backend/app/modules/reservations/models.py @@ -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") diff --git a/backend/app/modules/reservations/service.py b/backend/app/modules/reservations/service.py index 86d0720..0a97f6e 100644 --- a/backend/app/modules/reservations/service.py +++ b/backend/app/modules/reservations/service.py @@ -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() diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 4f00680..b32e624 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -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) => diff --git a/frontend/src/pages/CalendarPage.jsx b/frontend/src/pages/CalendarPage.jsx index cc525e2..a38749d 100644 --- a/frontend/src/pages/CalendarPage.jsx +++ b/frontend/src/pages/CalendarPage.jsx @@ -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 (
-
- {/* Calendario semanal */} +
+ {/* Calendario mensual */}
- {/* Nav semana */} + {/* Nav mes */}

- {format(weekStart, "MMMM yyyy", { locale: es })} + {format(currentMonth, "MMMM yyyy", { locale: es })}

- {/* Días */} -
+ {/* Cabecera días */} +
{WEEK_DAYS.map((d) => ( -
{d}
+
{d}
))} - {weekDays.map((day) => { +
+ + {/* Días del mes */} +
+ {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 ( ) @@ -198,7 +212,7 @@ export default function CalendarPage() {
{slots.map((slot) => (
0 @@ -206,7 +220,7 @@ export default function CalendarPage() { : 'bg-slate-50 border-border text-slate-400', )} > - {slot.time?.slice(0, 5)} + {slot.time_start?.slice(0, 5)} {slot.available > 0 ? `${slot.available} libre${slot.available > 1 ? 's' : ''}` diff --git a/frontend/src/pages/ConfigPage.jsx b/frontend/src/pages/ConfigPage.jsx index de54dbb..b26919a 100644 --- a/frontend/src/pages/ConfigPage.jsx +++ b/frontend/src/pages/ConfigPage.jsx @@ -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 (
- {/* WhatsApp status */} -
+ {/* WhatsApp */} +
+ {/* Estado */}
- {isWaConnected - ? - : - } -
-
-

- WhatsApp {isWaConnected ? 'conectado' : 'no conectado'} -

-

+

{isWaConnected - ? `Número: ${waStatus.whatsapp_phone_number_id}` - : 'Configura tu número de WhatsApp Business para recibir reservas.'} -

+ ? + : + } +
+
+

+ {isWaConnected ? 'Conectado' : 'Sin conectar'} +

+

+ {isWaConnected + ? `Phone Number ID: ${waStatus.phone_number_id}` + : 'El bot no puede recibir mensajes hasta que conectes un número.'} +

+
+ {isWaConnected ? ( + + ) : ( + + )}
-
+ + {/* Formulario de conexión */} + {!isWaConnected && waShowForm && ( +
+
+ + setWaForm((f) => ({ ...f, phone_number_id: e.target.value }))} + className="field-input font-mono" + placeholder="123456789012345" + /> + + Encuéntralo en Meta for Developers → Tu app → WhatsApp → Configuración de API. + +
+ +
+ + setWaForm((f) => ({ ...f, meta_business_id: e.target.value }))} + className="field-input font-mono" + placeholder="987654321098765" + /> + + ID de tu cuenta de Meta Business Suite. + +
+ +
+ + setWaForm((f) => ({ ...f, access_token: e.target.value }))} + className="field-input font-mono" + placeholder="EAAxxxxxxxxx…" + /> + + Token de acceso permanente generado en Meta for Developers. + +
+ + {waError && ( +
+ + {waError} +
+ )} + +
+ +
+
+ )} + {/* Negocio */}
@@ -232,6 +491,272 @@ export default function ConfigPage() {
+ {/* Mesas — solo restaurantes */} + {business?.type === 'restaurant' && ( +
+ {/* Lista de mesas */} + {tables.length > 0 && ( +
+ {tables.map((t) => ( +
+ +
+

+ {t.quantity} mesa{t.quantity > 1 ? 's' : ''} para {t.capacity} persona{t.capacity > 1 ? 's' : ''} +

+ {t.label &&

{t.label}

} +
+ + +
+ ))} +
+ )} + + {tables.length === 0 && !showTableForm && ( +
+ +

No hay tipos de mesa configurados

+

Agrega tus mesas para un control preciso de reservas

+
+ )} + + {/* Formulario */} + {showTableForm && ( +
+

+ {editingTable ? 'Editar tipo de mesa' : 'Agregar tipo de mesa'} +

+
+
+ + setTableForm((f) => ({ ...f, capacity: e.target.value }))} + className="field-input" + placeholder="Ej: 4" + /> +
+
+ + setTableForm((f) => ({ ...f, quantity: e.target.value }))} + className="field-input" + placeholder="Ej: 5" + /> +
+
+
+ + setTableForm((f) => ({ ...f, label: e.target.value }))} + className="field-input" + placeholder="Ej: Mesa familiar, Mesa terraza…" + /> +
+ {tableError && ( +
+ + {tableError} +
+ )} +
+ + +
+
+ )} + + {!showTableForm && ( + + )} +
+ )} + + {/* Servicios */} +
+ {svcList.length > 0 && ( +
+ {svcList.map((s) => ( +
+ +
+

{s.name}

+
+ {s.description && ( +

{s.description}

+ )} +
+ {s.price != null && ( + + ${Number(s.price).toLocaleString()} + + )} + {s.duration_minutes && ( + {s.duration_minutes} min + )} +
+
+
+ + +
+ ))} +
+ )} + + {svcList.length === 0 && !showSvcForm && ( +
+ +

No hay servicios configurados

+

Agrega tus servicios para que el bot pueda informar a los clientes

+
+ )} + + {showSvcForm && ( +
+

+ {editingSvc ? 'Editar servicio' : 'Agregar servicio'} +

+
+ + setSvcForm((f) => ({ ...f, name: e.target.value }))} + className="field-input" + placeholder="Ej: Corte de cabello, Consulta general, Masaje relajante…" + /> +
+
+ + setSvcForm((f) => ({ ...f, description: e.target.value }))} + className="field-input" + placeholder="Descripción breve (opcional)" + /> +
+
+
+ + setSvcForm((f) => ({ ...f, price: e.target.value }))} + className="field-input" + placeholder="Ej: 25000" + /> +
+
+ + setSvcForm((f) => ({ ...f, duration_minutes: e.target.value }))} + className="field-input" + placeholder="Ej: 30" + /> +
+
+ {svcError && ( +
+ + {svcError} +
+ )} +
+ + +
+
+ )} + + {!showSvcForm && ( + + )} +
+ {/* Bot */}