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.business.models import Business, BusinessConfig
|
||||
from app.modules.bot_engine.schemas import ConversationContext
|
||||
from app.modules.business.models import Business, BusinessConfig, Service, TableType
|
||||
from app.modules.calendar.schemas import DayAvailability
|
||||
|
||||
DAYS_ES = ["lunes", "martes", "miércoles", "jueves", "viernes", "sábado", "domingo"]
|
||||
|
||||
# Contexto general por tipo de negocio
|
||||
BUSINESS_TYPE_CONTEXT: dict[str, dict] = {
|
||||
"restaurant": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de un restaurante. Tu misión es ayudar a los "
|
||||
"clientes a reservar una mesa. Siempre pregunta cuántas personas son antes de "
|
||||
"mostrar disponibilidad, ya que los horarios dependen del tamaño del grupo. "
|
||||
"Puedes informar sobre el menú o servicios disponibles si el cliente pregunta."
|
||||
),
|
||||
"party_term": "comensales",
|
||||
"reservation_term": "reserva de mesa",
|
||||
"ask_party_first": True,
|
||||
},
|
||||
"clinic": {
|
||||
"description": (
|
||||
"Eres el asistente de citas de una clínica o consultorio. Tu misión es ayudar "
|
||||
"a los pacientes a agendar su cita médica. Trata a los pacientes con respeto y "
|
||||
"empatía. Puedes informar sobre los servicios o especialidades disponibles si "
|
||||
"el paciente pregunta. Pregunta el motivo de la consulta solo si es necesario "
|
||||
"para determinar el servicio correcto."
|
||||
),
|
||||
"party_term": "pacientes",
|
||||
"reservation_term": "cita médica",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
"salon": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de un salón de belleza. Tu misión es ayudar a "
|
||||
"los clientes a reservar su cita. Puedes informar sobre los servicios y precios "
|
||||
"disponibles si el cliente pregunta. Pregunta qué servicio desean para asignar "
|
||||
"el tiempo correcto."
|
||||
),
|
||||
"party_term": "personas",
|
||||
"reservation_term": "cita",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
"spa": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de un spa o centro de bienestar. Tu misión es "
|
||||
"ayudar a los clientes a reservar sus tratamientos. Usa un tono relajante y "
|
||||
"acogedor. Puedes informar sobre los tratamientos y precios disponibles si el "
|
||||
"cliente pregunta."
|
||||
),
|
||||
"party_term": "personas",
|
||||
"reservation_term": "reserva de tratamiento",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
"barbershop": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de una barbería. Tu misión es ayudar a los "
|
||||
"clientes a reservar su turno. Puedes informar sobre los servicios y precios "
|
||||
"disponibles si el cliente pregunta."
|
||||
),
|
||||
"party_term": "personas",
|
||||
"reservation_term": "turno",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
"gym": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de un gimnasio o entrenador personal. Tu misión "
|
||||
"es ayudar a los clientes a reservar sus sesiones de entrenamiento. Puedes "
|
||||
"informar sobre los servicios y planes disponibles si el cliente pregunta."
|
||||
),
|
||||
"party_term": "personas",
|
||||
"reservation_term": "sesión",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
"other": {
|
||||
"description": (
|
||||
"Eres el asistente de reservas de un negocio de servicios. Tu misión es ayudar "
|
||||
"a los clientes a agendar su reserva. Puedes informar sobre los servicios "
|
||||
"disponibles si el cliente pregunta."
|
||||
),
|
||||
"party_term": "personas",
|
||||
"reservation_term": "reserva",
|
||||
"ask_party_first": False,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _format_open_days(open_days: list[int]) -> str:
|
||||
return ", ".join(DAYS_ES[d] for d in sorted(open_days))
|
||||
@ -14,16 +93,50 @@ def _format_slots(availability: DayAvailability | None) -> str:
|
||||
return "No hay disponibilidad para esa fecha."
|
||||
available = [s for s in availability.slots if s.available > 0]
|
||||
if not available:
|
||||
return "No quedan slots disponibles para esa fecha."
|
||||
return "No quedan lugares disponibles para esa fecha."
|
||||
return ", ".join(s.time_start.strftime("%H:%M") for s in available)
|
||||
|
||||
|
||||
def _format_table_types(table_types: list[TableType]) -> str:
|
||||
lines = []
|
||||
for t in sorted(table_types, key=lambda x: x.capacity):
|
||||
label = f" ({t.label})" if t.label else ""
|
||||
lines.append(f" - {t.quantity} mesa(s) para {t.capacity} persona(s){label}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_services(services: list[Service]) -> str:
|
||||
if not services:
|
||||
return ""
|
||||
lines = []
|
||||
for s in services:
|
||||
if not s.is_active:
|
||||
continue
|
||||
parts = [f"• {s.name}"]
|
||||
if s.description:
|
||||
parts.append(f": {s.description}")
|
||||
extras = []
|
||||
if s.price is not None:
|
||||
extras.append(f"${float(s.price):,.0f}")
|
||||
if s.duration_minutes:
|
||||
extras.append(f"{s.duration_minutes} min")
|
||||
if extras:
|
||||
parts.append(f" ({', '.join(extras)})")
|
||||
lines.append("".join(parts))
|
||||
return "\n".join(lines) if lines else ""
|
||||
|
||||
|
||||
def build_system_prompt(
|
||||
business: Business,
|
||||
config: BusinessConfig,
|
||||
availability: DayAvailability | None,
|
||||
context: ConversationContext,
|
||||
table_types: list[TableType] | None = None,
|
||||
services: list[Service] | None = None,
|
||||
) -> str:
|
||||
btype = business.type or "other"
|
||||
type_ctx = BUSINESS_TYPE_CONTEXT.get(btype, BUSINESS_TYPE_CONTEXT["other"])
|
||||
|
||||
tone_instruction = (
|
||||
"Usa un tono formal y profesional."
|
||||
if config.tone == "formal"
|
||||
@ -38,29 +151,53 @@ def build_system_prompt(
|
||||
f"- Personas: {collected.party_size or 'pendiente'}",
|
||||
])
|
||||
|
||||
slots_info = _format_slots(availability)
|
||||
# Bloque de mesas (solo restaurantes con mesas configuradas)
|
||||
tables_block = ""
|
||||
if table_types:
|
||||
tables_block = f"""
|
||||
CONFIGURACIÓN DE MESAS:
|
||||
{_format_table_types(table_types)}
|
||||
Los horarios disponibles ya están filtrados según el tamaño del grupo indicado.
|
||||
Si no hay mesas disponibles para ese grupo, informa amablemente y sugiere otros horarios o fechas.
|
||||
"""
|
||||
|
||||
# Bloque de servicios
|
||||
services_block = ""
|
||||
if services:
|
||||
formatted = _format_services(services)
|
||||
if formatted:
|
||||
services_block = f"""
|
||||
SERVICIOS DISPONIBLES:
|
||||
{formatted}
|
||||
Puedes informar sobre estos servicios si el cliente pregunta. No es obligatorio elegir uno para reservar a menos que sea necesario para determinar la duración.
|
||||
"""
|
||||
|
||||
# Indicación de preguntar comensales primero (restaurantes)
|
||||
party_first_hint = ""
|
||||
if type_ctx["ask_party_first"] and not collected.party_size:
|
||||
party_first_hint = f"\nIMPORTANTE: Pregunta primero cuántos {type_ctx['party_term']} serán antes de mostrar disponibilidad.\n"
|
||||
|
||||
return f"""Eres {config.assistant_name}, asistente virtual de {business.name}.
|
||||
{type_ctx['description']}
|
||||
{tone_instruction}
|
||||
Responde SIEMPRE en el idioma del cliente.
|
||||
|
||||
{party_first_hint}{tables_block}{services_block}
|
||||
HORARIO DEL NEGOCIO:
|
||||
- Días de atención: {_format_open_days(config.open_days or [])}
|
||||
- Horario: {config.open_time.strftime("%H:%M")} a {config.close_time.strftime("%H:%M")}
|
||||
- Duración de cada turno: {config.slot_duration} minutos
|
||||
- Capacidad por turno: {config.max_per_slot} persona(s)
|
||||
|
||||
SLOTS DISPONIBLES PARA LA FECHA SOLICITADA: {slots_info}
|
||||
SLOTS DISPONIBLES: {_format_slots(availability)}
|
||||
|
||||
DATOS YA RECOPILADOS DEL CLIENTE:
|
||||
{collected_summary}
|
||||
|
||||
OBJETIVO: Recopilar nombre, fecha, hora y número de personas para crear la reserva.
|
||||
Si ya tienes todos los datos, acción = "create_reservation".
|
||||
Si el cliente quiere cancelar, acción = "cancel".
|
||||
OBJETIVO: Recopilar nombre, fecha, hora y número de {type_ctx['party_term']} para crear la {type_ctx['reservation_term']}.
|
||||
Si ya tienes todos los datos confirmados por el cliente, acción = "create_reservation".
|
||||
Si el cliente quiere cancelar o no desea continuar, acción = "cancel".
|
||||
En cualquier otro caso, acción = "collect_more".
|
||||
|
||||
Responde ÚNICAMENTE con JSON válido siguiendo este esquema exacto (sin markdown, sin explicaciones):
|
||||
Responde ÚNICAMENTE con JSON válido (sin markdown, sin explicaciones):
|
||||
{{
|
||||
"message": "<texto para enviar al cliente>",
|
||||
"action": "collect_more" | "create_reservation" | "cancel",
|
||||
|
||||
@ -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})
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -50,6 +50,20 @@ export const reservationsApi = {
|
||||
delete: (id) => api.delete(`/reservations/${id}`),
|
||||
}
|
||||
|
||||
export const serviceApi = {
|
||||
list: () => api.get('/business/me/services'),
|
||||
create: (data) => api.post('/business/me/services', data),
|
||||
update: (id, data) => api.put(`/business/me/services/${id}`, data),
|
||||
remove: (id) => api.delete(`/business/me/services/${id}`),
|
||||
}
|
||||
|
||||
export const tableApi = {
|
||||
list: () => api.get('/business/me/tables'),
|
||||
create: (data) => api.post('/business/me/tables', data),
|
||||
update: (id, data) => api.put(`/business/me/tables/${id}`, data),
|
||||
remove: (id) => api.delete(`/business/me/tables/${id}`),
|
||||
}
|
||||
|
||||
export const calendarApi = {
|
||||
getAvailability: (date) => api.get('/calendar/availability', { params: { date } }),
|
||||
getAvailabilityRange: (start, end) =>
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { format, addDays, startOfWeek, isSameDay, isToday, parseISO } from 'date-fns'
|
||||
import {
|
||||
format, addMonths, startOfMonth, endOfMonth,
|
||||
startOfWeek, endOfWeek, eachDayOfInterval,
|
||||
isSameDay, isToday, isSameMonth,
|
||||
} from 'date-fns'
|
||||
import { es } from 'date-fns/locale'
|
||||
import { ChevronLeft, ChevronRight, Lock, Unlock, Clock } from 'lucide-react'
|
||||
import { calendarApi } from '@/lib/api'
|
||||
@ -7,17 +11,21 @@ import { cn } from '@/lib/utils'
|
||||
|
||||
const WEEK_DAYS = ['Lun', 'Mar', 'Mié', 'Jue', 'Vie', 'Sáb', 'Dom']
|
||||
|
||||
function buildMonthGrid(date) {
|
||||
const start = startOfWeek(startOfMonth(date), { weekStartsOn: 1 })
|
||||
const end = endOfWeek(endOfMonth(date), { weekStartsOn: 1 })
|
||||
return eachDayOfInterval({ start, end })
|
||||
}
|
||||
|
||||
export default function CalendarPage() {
|
||||
const [currentDate, setCurrentDate] = useState(new Date())
|
||||
const [currentMonth, setCurrentMonth] = useState(new Date())
|
||||
const [selectedDate, setSelectedDate] = useState(new Date())
|
||||
const [slots, setSlots] = useState([])
|
||||
const [blockedDates, setBlockedDates] = useState([])
|
||||
const [loadingSlots, setLoadingSlots] = useState(false)
|
||||
const [togglingDate, setTogglingDate] = useState(false)
|
||||
|
||||
const weekStart = startOfWeek(currentDate, { weekStartsOn: 1 })
|
||||
const weekDays = Array.from({ length: 7 }, (_, i) => addDays(weekStart, i))
|
||||
|
||||
const monthDays = buildMonthGrid(currentMonth)
|
||||
const selectedStr = format(selectedDate, 'yyyy-MM-dd')
|
||||
const isBlocked = blockedDates.includes(selectedStr)
|
||||
|
||||
@ -26,7 +34,7 @@ export default function CalendarPage() {
|
||||
setLoadingSlots(true)
|
||||
try {
|
||||
const { data } = await calendarApi.getAvailability(selectedStr)
|
||||
setSlots(data)
|
||||
setSlots(data.slots ?? [])
|
||||
} catch {
|
||||
setSlots([])
|
||||
} finally {
|
||||
@ -53,56 +61,62 @@ export default function CalendarPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 max-w-4xl animate-fade-in">
|
||||
<div className="grid lg:grid-cols-3 gap-5">
|
||||
{/* Calendario semanal */}
|
||||
<div className="grid lg:grid-cols-3 gap-5 items-start">
|
||||
{/* Calendario mensual */}
|
||||
<div className="lg:col-span-2 card p-5">
|
||||
{/* Nav semana */}
|
||||
{/* Nav mes */}
|
||||
<div className="flex items-center justify-between mb-5">
|
||||
<h3 className="font-display text-base font-semibold text-slate-800 capitalize">
|
||||
{format(weekStart, "MMMM yyyy", { locale: es })}
|
||||
{format(currentMonth, "MMMM yyyy", { locale: es })}
|
||||
</h3>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setCurrentDate((d) => addDays(d, -7))}
|
||||
onClick={() => setCurrentMonth((d) => addMonths(d, -1))}
|
||||
className="btn-ghost p-1.5"
|
||||
aria-label="Semana anterior"
|
||||
aria-label="Mes anterior"
|
||||
>
|
||||
<ChevronLeft size={16} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentDate(new Date())}
|
||||
onClick={() => { setCurrentMonth(new Date()); setSelectedDate(new Date()) }}
|
||||
className="btn-secondary px-3 py-1.5 text-xs"
|
||||
>
|
||||
Hoy
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCurrentDate((d) => addDays(d, 7))}
|
||||
onClick={() => setCurrentMonth((d) => addMonths(d, 1))}
|
||||
className="btn-ghost p-1.5"
|
||||
aria-label="Semana siguiente"
|
||||
aria-label="Mes siguiente"
|
||||
>
|
||||
<ChevronRight size={16} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Días */}
|
||||
<div className="grid grid-cols-7 gap-1.5">
|
||||
{/* Cabecera días */}
|
||||
<div className="grid grid-cols-7 gap-1 mb-1">
|
||||
{WEEK_DAYS.map((d) => (
|
||||
<div key={d} className="text-center text-xs font-medium text-slate-400 pb-1.5">{d}</div>
|
||||
<div key={d} className="text-center text-xs font-medium text-slate-400 pb-1">{d}</div>
|
||||
))}
|
||||
{weekDays.map((day) => {
|
||||
</div>
|
||||
|
||||
{/* Días del mes */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{monthDays.map((day) => {
|
||||
const dayStr = format(day, 'yyyy-MM-dd')
|
||||
const selected = isSameDay(day, selectedDate)
|
||||
const today = isToday(day)
|
||||
const blocked = blockedDates.includes(dayStr)
|
||||
const otherMonth = !isSameMonth(day, currentMonth)
|
||||
|
||||
return (
|
||||
<button
|
||||
key={dayStr}
|
||||
onClick={() => setSelectedDate(day)}
|
||||
className={cn(
|
||||
'relative flex flex-col items-center justify-center rounded-xl py-3 px-1 text-sm font-medium transition-all duration-150',
|
||||
'relative flex flex-col items-center justify-center rounded-lg aspect-square text-sm font-medium transition-all duration-150',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
|
||||
otherMonth && 'opacity-25 pointer-events-none',
|
||||
selected
|
||||
? 'bg-primary-600 text-white shadow-sm'
|
||||
: blocked
|
||||
@ -114,9 +128,9 @@ export default function CalendarPage() {
|
||||
aria-pressed={selected}
|
||||
aria-label={`${format(day, 'd MMMM', { locale: es })}${blocked ? ', bloqueado' : ''}`}
|
||||
>
|
||||
<span className="text-base leading-none">{format(day, 'd')}</span>
|
||||
<span className="leading-none">{format(day, 'd')}</span>
|
||||
{blocked && !selected && (
|
||||
<Lock size={9} className="mt-1 opacity-50" />
|
||||
<Lock size={8} className="mt-0.5 opacity-50" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
@ -198,7 +212,7 @@ export default function CalendarPage() {
|
||||
<div className="flex flex-col gap-1.5 max-h-56 overflow-y-auto pr-1">
|
||||
{slots.map((slot) => (
|
||||
<div
|
||||
key={slot.time}
|
||||
key={slot.time_start}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-3.5 py-2.5 rounded-lg text-sm border transition-colors',
|
||||
slot.available > 0
|
||||
@ -206,7 +220,7 @@ export default function CalendarPage() {
|
||||
: 'bg-slate-50 border-border text-slate-400',
|
||||
)}
|
||||
>
|
||||
<span className="font-medium">{slot.time?.slice(0, 5)}</span>
|
||||
<span className="font-medium">{slot.time_start?.slice(0, 5)}</span>
|
||||
<span className="text-xs">
|
||||
{slot.available > 0
|
||||
? `${slot.available} libre${slot.available > 1 ? 's' : ''}`
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Save, CheckCircle, AlertCircle, Wifi, WifiOff, ExternalLink } from 'lucide-react'
|
||||
import { businessApi, whatsappApi } from '@/lib/api'
|
||||
import { Save, CheckCircle, AlertCircle, Wifi, WifiOff, Phone, Unplug, UtensilsCrossed, Plus, Trash2, Pencil, Tag } from 'lucide-react'
|
||||
import { businessApi, whatsappApi, tableApi, serviceApi } from '@/lib/api'
|
||||
import { DAYS_FULL, cn } from '@/lib/utils'
|
||||
|
||||
const TONE_OPTIONS = [
|
||||
@ -20,6 +20,8 @@ function Section({ title, description, children }) {
|
||||
)
|
||||
}
|
||||
|
||||
const WA_EMPTY = { phone_number_id: '', access_token: '', meta_business_id: '' }
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [business, setBusiness] = useState(null)
|
||||
const [config, setConfig] = useState(null)
|
||||
@ -29,17 +31,43 @@ export default function ConfigPage() {
|
||||
const [saved, setSaved] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [waForm, setWaForm] = useState(WA_EMPTY)
|
||||
const [waConnecting, setWaConnecting] = useState(false)
|
||||
const [waDisconnecting, setWaDisconnecting] = useState(false)
|
||||
const [waError, setWaError] = useState('')
|
||||
const [waShowForm, setWaShowForm] = useState(false)
|
||||
|
||||
const SERVICE_EMPTY = { name: '', description: '', price: '', duration_minutes: '' }
|
||||
const [svcList, setSvcList] = useState([])
|
||||
const [svcForm, setSvcForm] = useState(SERVICE_EMPTY)
|
||||
const [editingSvc, setEditingSvc] = useState(null)
|
||||
const [svcError, setSvcError] = useState('')
|
||||
const [svcSaving, setSvcSaving] = useState(false)
|
||||
const [showSvcForm, setShowSvcForm] = useState(false)
|
||||
|
||||
const TABLE_EMPTY = { capacity: '', quantity: '', label: '' }
|
||||
const [tables, setTables] = useState([])
|
||||
const [tableForm, setTableForm] = useState(TABLE_EMPTY)
|
||||
const [editingTable, setEditingTable] = useState(null)
|
||||
const [tableError, setTableError] = useState('')
|
||||
const [tableSaving, setTableSaving] = useState(false)
|
||||
const [showTableForm, setShowTableForm] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const [bRes, cRes, wRes] = await Promise.all([
|
||||
const [bRes, cRes, wRes, tRes, sRes] = await Promise.all([
|
||||
businessApi.getMe(),
|
||||
businessApi.getConfig(),
|
||||
whatsappApi.getStatus().catch(() => ({ data: null })),
|
||||
tableApi.list().catch(() => ({ data: [] })),
|
||||
serviceApi.list().catch(() => ({ data: [] })),
|
||||
])
|
||||
setBusiness(bRes.data)
|
||||
setConfig(cRes.data)
|
||||
setWaStatus(wRes.data)
|
||||
setTables(tRes.data ?? [])
|
||||
setSvcList(sRes.data ?? [])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@ -59,6 +87,143 @@ export default function ConfigPage() {
|
||||
updateConfig('open_days', next)
|
||||
}
|
||||
|
||||
function startEditSvc(svc) {
|
||||
setEditingSvc(svc.id)
|
||||
setSvcForm({
|
||||
name: svc.name,
|
||||
description: svc.description ?? '',
|
||||
price: svc.price != null ? svc.price : '',
|
||||
duration_minutes: svc.duration_minutes ?? '',
|
||||
})
|
||||
setShowSvcForm(true)
|
||||
setSvcError('')
|
||||
}
|
||||
|
||||
function cancelSvcForm() {
|
||||
setEditingSvc(null)
|
||||
setSvcForm(SERVICE_EMPTY)
|
||||
setShowSvcForm(false)
|
||||
setSvcError('')
|
||||
}
|
||||
|
||||
async function handleSvcSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!svcForm.name.trim()) { setSvcError('El nombre es requerido.'); return }
|
||||
setSvcSaving(true)
|
||||
setSvcError('')
|
||||
try {
|
||||
const payload = {
|
||||
name: svcForm.name.trim(),
|
||||
description: svcForm.description.trim() || null,
|
||||
price: svcForm.price !== '' ? Number(svcForm.price) : null,
|
||||
duration_minutes: svcForm.duration_minutes !== '' ? Number(svcForm.duration_minutes) : null,
|
||||
}
|
||||
if (editingSvc) {
|
||||
const { data } = await serviceApi.update(editingSvc, payload)
|
||||
setSvcList((l) => l.map((x) => (x.id === editingSvc ? data : x)))
|
||||
} else {
|
||||
const { data } = await serviceApi.create(payload)
|
||||
setSvcList((l) => [...l, data].sort((a, b) => a.name.localeCompare(b.name)))
|
||||
}
|
||||
cancelSvcForm()
|
||||
} catch {
|
||||
setSvcError('No se pudo guardar. Intenta de nuevo.')
|
||||
} finally {
|
||||
setSvcSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteSvc(id) {
|
||||
if (!confirm('¿Eliminar este servicio?')) return
|
||||
try {
|
||||
await serviceApi.remove(id)
|
||||
setSvcList((l) => l.filter((x) => x.id !== id))
|
||||
} catch {
|
||||
// silencioso
|
||||
}
|
||||
}
|
||||
|
||||
function startEditTable(table) {
|
||||
setEditingTable(table.id)
|
||||
setTableForm({ capacity: table.capacity, quantity: table.quantity, label: table.label ?? '' })
|
||||
setShowTableForm(true)
|
||||
setTableError('')
|
||||
}
|
||||
|
||||
function cancelTableForm() {
|
||||
setEditingTable(null)
|
||||
setTableForm(TABLE_EMPTY)
|
||||
setShowTableForm(false)
|
||||
setTableError('')
|
||||
}
|
||||
|
||||
async function handleTableSubmit(e) {
|
||||
e.preventDefault()
|
||||
if (!tableForm.capacity || !tableForm.quantity) {
|
||||
setTableError('Capacidad y cantidad son requeridas.')
|
||||
return
|
||||
}
|
||||
setTableSaving(true)
|
||||
setTableError('')
|
||||
try {
|
||||
const payload = {
|
||||
capacity: Number(tableForm.capacity),
|
||||
quantity: Number(tableForm.quantity),
|
||||
label: tableForm.label || null,
|
||||
}
|
||||
if (editingTable) {
|
||||
const { data } = await tableApi.update(editingTable, payload)
|
||||
setTables((t) => t.map((x) => (x.id === editingTable ? data : x)))
|
||||
} else {
|
||||
const { data } = await tableApi.create(payload)
|
||||
setTables((t) => [...t, data].sort((a, b) => a.capacity - b.capacity))
|
||||
}
|
||||
cancelTableForm()
|
||||
} catch {
|
||||
setTableError('No se pudo guardar. Intenta de nuevo.')
|
||||
} finally {
|
||||
setTableSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteTable(id) {
|
||||
if (!confirm('¿Eliminar este tipo de mesa?')) return
|
||||
try {
|
||||
await tableApi.remove(id)
|
||||
setTables((t) => t.filter((x) => x.id !== id))
|
||||
} catch {
|
||||
// silencioso
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWaConnect(e) {
|
||||
e.preventDefault()
|
||||
setWaConnecting(true)
|
||||
setWaError('')
|
||||
try {
|
||||
await whatsappApi.connect(waForm)
|
||||
const { data } = await whatsappApi.getStatus()
|
||||
setWaStatus(data)
|
||||
setWaForm(WA_EMPTY)
|
||||
setWaShowForm(false)
|
||||
} catch {
|
||||
setWaError('No se pudo conectar. Verifica los datos e intenta de nuevo.')
|
||||
} finally {
|
||||
setWaConnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWaDisconnect() {
|
||||
if (!confirm('¿Desconectar WhatsApp? El bot dejará de responder mensajes.')) return
|
||||
setWaDisconnecting(true)
|
||||
try {
|
||||
await whatsappApi.disconnect()
|
||||
setWaStatus({ connected: false, phone_number_id: null, meta_business_id: null })
|
||||
} finally {
|
||||
setWaDisconnecting(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
@ -105,31 +270,125 @@ export default function ConfigPage() {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5 max-w-2xl animate-fade-in">
|
||||
{/* WhatsApp status */}
|
||||
<div className={cn(
|
||||
'card p-5 flex items-center gap-4',
|
||||
isWaConnected ? 'border-primary-200 bg-primary-50' : 'border-warning-200 bg-warning-50',
|
||||
)}>
|
||||
{/* WhatsApp */}
|
||||
<Section
|
||||
title="WhatsApp Business"
|
||||
description="Conecta tu número para que el bot reciba y responda reservas."
|
||||
>
|
||||
{/* Estado */}
|
||||
<div className={cn(
|
||||
'w-10 h-10 rounded-xl flex items-center justify-center flex-shrink-0',
|
||||
isWaConnected ? 'bg-primary-100' : 'bg-warning-100',
|
||||
'flex items-center gap-4 rounded-xl px-4 py-3.5 border',
|
||||
isWaConnected ? 'bg-primary-50 border-primary-200' : 'bg-warning-50 border-warning-200',
|
||||
)}>
|
||||
{isWaConnected
|
||||
? <Wifi size={18} className="text-primary-600" />
|
||||
: <WifiOff size={18} className="text-warning-600" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={cn('text-sm font-semibold', isWaConnected ? 'text-primary-800' : 'text-warning-800')}>
|
||||
WhatsApp {isWaConnected ? 'conectado' : 'no conectado'}
|
||||
</p>
|
||||
<p className={cn('text-xs mt-0.5', isWaConnected ? 'text-primary-600' : 'text-warning-600')}>
|
||||
<div className={cn(
|
||||
'w-9 h-9 rounded-lg flex items-center justify-center flex-shrink-0',
|
||||
isWaConnected ? 'bg-primary-100' : 'bg-warning-100',
|
||||
)}>
|
||||
{isWaConnected
|
||||
? `Número: ${waStatus.whatsapp_phone_number_id}`
|
||||
: 'Configura tu número de WhatsApp Business para recibir reservas.'}
|
||||
</p>
|
||||
? <Wifi size={16} className="text-primary-600" />
|
||||
: <WifiOff size={16} className="text-warning-600" />
|
||||
}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className={cn('text-sm font-semibold', isWaConnected ? 'text-primary-800' : 'text-warning-800')}>
|
||||
{isWaConnected ? 'Conectado' : 'Sin conectar'}
|
||||
</p>
|
||||
<p className={cn('text-xs mt-0.5', isWaConnected ? 'text-primary-600' : 'text-warning-600')}>
|
||||
{isWaConnected
|
||||
? `Phone Number ID: ${waStatus.phone_number_id}`
|
||||
: 'El bot no puede recibir mensajes hasta que conectes un número.'}
|
||||
</p>
|
||||
</div>
|
||||
{isWaConnected ? (
|
||||
<button
|
||||
onClick={handleWaDisconnect}
|
||||
disabled={waDisconnecting}
|
||||
className="btn border border-border bg-white text-slate-700 hover:bg-slate-50 px-3 py-2 text-xs gap-1.5 flex-shrink-0"
|
||||
>
|
||||
{waDisconnecting
|
||||
? <span className="w-3.5 h-3.5 rounded-full border-2 border-current border-t-transparent animate-spin block" />
|
||||
: <Unplug size={13} />
|
||||
}
|
||||
Desconectar
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setWaShowForm((v) => !v)}
|
||||
className="btn-primary px-3 py-2 text-xs gap-1.5 flex-shrink-0"
|
||||
>
|
||||
<Phone size={13} />
|
||||
{waShowForm ? 'Cancelar' : 'Conectar'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulario de conexión */}
|
||||
{!isWaConnected && waShowForm && (
|
||||
<form onSubmit={handleWaConnect} className="flex flex-col gap-4 pt-1 animate-fade-in">
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Phone Number ID</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={waForm.phone_number_id}
|
||||
onChange={(e) => setWaForm((f) => ({ ...f, phone_number_id: e.target.value }))}
|
||||
className="field-input font-mono"
|
||||
placeholder="123456789012345"
|
||||
/>
|
||||
<span className="field-helper">
|
||||
Encuéntralo en Meta for Developers → Tu app → WhatsApp → Configuración de API.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Meta Business ID</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={waForm.meta_business_id}
|
||||
onChange={(e) => setWaForm((f) => ({ ...f, meta_business_id: e.target.value }))}
|
||||
className="field-input font-mono"
|
||||
placeholder="987654321098765"
|
||||
/>
|
||||
<span className="field-helper">
|
||||
ID de tu cuenta de Meta Business Suite.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Access Token</label>
|
||||
<input
|
||||
type="password"
|
||||
required
|
||||
value={waForm.access_token}
|
||||
onChange={(e) => setWaForm((f) => ({ ...f, access_token: e.target.value }))}
|
||||
className="field-input font-mono"
|
||||
placeholder="EAAxxxxxxxxx…"
|
||||
/>
|
||||
<span className="field-helper">
|
||||
Token de acceso permanente generado en Meta for Developers.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{waError && (
|
||||
<div className="flex items-center gap-2 text-sm text-danger-700 bg-danger-50 border border-danger-100 rounded-lg px-4 py-3">
|
||||
<AlertCircle size={14} />
|
||||
{waError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="submit" disabled={waConnecting} className="btn-primary gap-2">
|
||||
{waConnecting
|
||||
? <span className="w-4 h-4 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||
: <Wifi size={15} />
|
||||
}
|
||||
{waConnecting ? 'Conectando…' : 'Conectar WhatsApp'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Negocio */}
|
||||
<Section title="Datos del negocio" description="Nombre y zona horaria visible en el panel.">
|
||||
@ -232,6 +491,272 @@ export default function ConfigPage() {
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Mesas — solo restaurantes */}
|
||||
{business?.type === 'restaurant' && (
|
||||
<Section
|
||||
title="Configuración de mesas"
|
||||
description="Define los tipos de mesa disponibles. El bot usará esta información para asignar la mesa correcta según el tamaño del grupo."
|
||||
>
|
||||
{/* Lista de mesas */}
|
||||
{tables.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{tables.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="flex items-center gap-3 px-4 py-3 rounded-xl border border-border bg-slate-50"
|
||||
>
|
||||
<UtensilsCrossed size={16} className="text-slate-400 flex-shrink-0" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-slate-800">
|
||||
{t.quantity} mesa{t.quantity > 1 ? 's' : ''} para {t.capacity} persona{t.capacity > 1 ? 's' : ''}
|
||||
</p>
|
||||
{t.label && <p className="text-xs text-slate-400 mt-0.5">{t.label}</p>}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => startEditTable(t)}
|
||||
className="btn-ghost p-1.5 text-slate-400 hover:text-slate-600"
|
||||
aria-label="Editar"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteTable(t.id)}
|
||||
className="btn-ghost p-1.5 text-slate-400 hover:text-danger-600"
|
||||
aria-label="Eliminar"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tables.length === 0 && !showTableForm && (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center border border-dashed border-border rounded-xl">
|
||||
<UtensilsCrossed size={24} className="text-slate-200 mb-2" />
|
||||
<p className="text-sm text-slate-400">No hay tipos de mesa configurados</p>
|
||||
<p className="text-xs text-slate-300 mt-0.5">Agrega tus mesas para un control preciso de reservas</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Formulario */}
|
||||
{showTableForm && (
|
||||
<form onSubmit={handleTableSubmit} className="flex flex-col gap-4 p-4 rounded-xl border border-primary-200 bg-primary-50 animate-fade-in">
|
||||
<p className="text-sm font-semibold text-primary-800">
|
||||
{editingTable ? 'Editar tipo de mesa' : 'Agregar tipo de mesa'}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Capacidad (personas)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
required
|
||||
value={tableForm.capacity}
|
||||
onChange={(e) => setTableForm((f) => ({ ...f, capacity: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: 4"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Cantidad de mesas</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={200}
|
||||
required
|
||||
value={tableForm.quantity}
|
||||
onChange={(e) => setTableForm((f) => ({ ...f, quantity: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: 5"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Etiqueta (opcional)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tableForm.label}
|
||||
onChange={(e) => setTableForm((f) => ({ ...f, label: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: Mesa familiar, Mesa terraza…"
|
||||
/>
|
||||
</div>
|
||||
{tableError && (
|
||||
<div className="flex items-center gap-2 text-sm text-danger-700 bg-danger-50 border border-danger-100 rounded-lg px-3 py-2">
|
||||
<AlertCircle size={13} />
|
||||
{tableError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={cancelTableForm} className="btn-secondary text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" disabled={tableSaving} className="btn-primary text-sm gap-1.5">
|
||||
{tableSaving
|
||||
? <span className="w-3.5 h-3.5 rounded-full border-2 border-white border-t-transparent animate-spin" />
|
||||
: null
|
||||
}
|
||||
{editingTable ? 'Guardar cambios' : 'Agregar mesa'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!showTableForm && (
|
||||
<button
|
||||
onClick={() => { setShowTableForm(true); setEditingTable(null); setTableForm(TABLE_EMPTY) }}
|
||||
className="btn-secondary gap-2 self-start"
|
||||
>
|
||||
<Plus size={15} />
|
||||
Agregar tipo de mesa
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Servicios */}
|
||||
<Section
|
||||
title="Servicios y precios"
|
||||
description="El bot usará esta lista para informar a los clientes sobre tus servicios cuando pregunten."
|
||||
>
|
||||
{svcList.length > 0 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
{svcList.map((s) => (
|
||||
<div
|
||||
key={s.id}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3 rounded-xl border',
|
||||
s.is_active ? 'bg-slate-50 border-border' : 'bg-slate-50 border-border opacity-50',
|
||||
)}
|
||||
>
|
||||
<Tag size={15} className="text-slate-400 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-slate-800 truncate">{s.name}</p>
|
||||
<div className="flex items-center gap-3 mt-0.5">
|
||||
{s.description && (
|
||||
<p className="text-xs text-slate-400 truncate">{s.description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{s.price != null && (
|
||||
<span className="text-xs font-semibold text-primary-600">
|
||||
${Number(s.price).toLocaleString()}
|
||||
</span>
|
||||
)}
|
||||
{s.duration_minutes && (
|
||||
<span className="text-xs text-slate-400">{s.duration_minutes} min</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => startEditSvc(s)}
|
||||
className="btn-ghost p-1.5 text-slate-400 hover:text-slate-600 flex-shrink-0"
|
||||
aria-label="Editar"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDeleteSvc(s.id)}
|
||||
className="btn-ghost p-1.5 text-slate-400 hover:text-danger-600 flex-shrink-0"
|
||||
aria-label="Eliminar"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{svcList.length === 0 && !showSvcForm && (
|
||||
<div className="flex flex-col items-center justify-center py-6 text-center border border-dashed border-border rounded-xl">
|
||||
<Tag size={24} className="text-slate-200 mb-2" />
|
||||
<p className="text-sm text-slate-400">No hay servicios configurados</p>
|
||||
<p className="text-xs text-slate-300 mt-0.5">Agrega tus servicios para que el bot pueda informar a los clientes</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSvcForm && (
|
||||
<form onSubmit={handleSvcSubmit} className="flex flex-col gap-4 p-4 rounded-xl border border-primary-200 bg-primary-50 animate-fade-in">
|
||||
<p className="text-sm font-semibold text-primary-800">
|
||||
{editingSvc ? 'Editar servicio' : 'Agregar servicio'}
|
||||
</p>
|
||||
<div className="field">
|
||||
<label className="field-label field-label-required">Nombre del servicio</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
value={svcForm.name}
|
||||
onChange={(e) => setSvcForm((f) => ({ ...f, name: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: Corte de cabello, Consulta general, Masaje relajante…"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Descripción</label>
|
||||
<input
|
||||
type="text"
|
||||
value={svcForm.description}
|
||||
onChange={(e) => setSvcForm((f) => ({ ...f, description: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Descripción breve (opcional)"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="field">
|
||||
<label className="field-label">Precio</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
step="0.01"
|
||||
value={svcForm.price}
|
||||
onChange={(e) => setSvcForm((f) => ({ ...f, price: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: 25000"
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label className="field-label">Duración (minutos)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
value={svcForm.duration_minutes}
|
||||
onChange={(e) => setSvcForm((f) => ({ ...f, duration_minutes: e.target.value }))}
|
||||
className="field-input"
|
||||
placeholder="Ej: 30"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{svcError && (
|
||||
<div className="flex items-center gap-2 text-sm text-danger-700 bg-danger-50 border border-danger-100 rounded-lg px-3 py-2">
|
||||
<AlertCircle size={13} />
|
||||
{svcError}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={cancelSvcForm} className="btn-secondary text-sm">
|
||||
Cancelar
|
||||
</button>
|
||||
<button type="submit" disabled={svcSaving} className="btn-primary text-sm gap-1.5">
|
||||
{svcSaving && <span className="w-3.5 h-3.5 rounded-full border-2 border-white border-t-transparent animate-spin" />}
|
||||
{editingSvc ? 'Guardar cambios' : 'Agregar servicio'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{!showSvcForm && (
|
||||
<button
|
||||
onClick={() => { setShowSvcForm(true); setEditingSvc(null); setSvcForm(SERVICE_EMPTY) }}
|
||||
className="btn-secondary gap-2 self-start"
|
||||
>
|
||||
<Plus size={15} />
|
||||
Agregar servicio
|
||||
</button>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Bot */}
|
||||
<Section
|
||||
title="Configuración del asistente"
|
||||
|
||||
Reference in New Issue
Block a user