fix: el paso de especialista solo ofrece a quien da ese servicio

Elegir "Corte + arreglo de barba" y luego cualquier especialista que no fuera el
barbero dejaba el asistente atascado: /slots responde 400 "El especialista no
ofrece este servicio" y la UI lo pintaba como "No pudimos cargar los horarios.
Intenta otra fecha" — un mensaje que manda a probar días al azar cuando ninguna
fecha lo resuelve.

La raíz es que el payload público listaba todos los empleados activos sin decir
qué servicios ofrece cada uno, así que el cliente no tenía con qué filtrar.

- GET /api/public/:slug devuelve ahora `service_ids` por empleado. El JOIN contra
  employees filtra por negocio: employee_services no tiene business_id propio.
- El paso 2 lista solo a los elegibles, y si nadie ofrece el servicio en concreto
  lo dice en vez de mostrar una lista vacía.
- Cambiar de servicio suelta un especialista que ya no aplique, en lugar de
  arrastrar una combinación que el servidor va a rechazar.
- El estado de error muestra el motivo real del servidor.

booking-e2e.mjs cubre el contrato: cada empleado publica service_ids, quien
ofrece el servicio responde 200 y quien no responde 400. 15/15.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
AgendaPro Dev
2026-08-28 15:24:10 -06:00
co-authored by Claude Opus 5
parent f48a9ac3bf
commit 521c206ba7
4 changed files with 80 additions and 4 deletions
+34 -2
View File
@@ -95,6 +95,14 @@ export default function BookingPage() {
[services, serviceId]
);
// Un especialista que no ofrece el servicio hace que /slots responda 400. Antes se
// listaban todos, así que el cliente podía elegir a alguien imposible y solo se
// enteraba un paso después, con un mensaje que le pedía cambiar la fecha.
const eligibleEmployees = useMemo(
() => (serviceId == null ? [] : employees.filter((e) => e.service_ids.includes(serviceId))),
[employees, serviceId]
);
const slotsQuery = useQuery({
queryKey: ["public", "slots", slug, serviceId, selectedDate, employeeId],
queryFn: () => getSlots(slug!, serviceId!, selectedDate, employeeId ?? undefined),
@@ -214,6 +222,10 @@ export default function BookingPage() {
setServiceId(id);
setSelectedSlot(null);
setBookError(null);
// El especialista elegido puede no ofrecer el servicio nuevo: soltarlo en vez
// de arrastrar una combinación que el servidor rechazará.
const stillValid = employees.some((e) => e.id === employeeId && e.service_ids.includes(id));
if (!stillValid) setEmployeeId(null);
}
};
@@ -288,9 +300,10 @@ export default function BookingPage() {
subtitle="¿Tienes una preferencia? O déjalo en cualquiera."
>
<EmployeeList
employees={employees}
employees={eligibleEmployees}
selectedId={employeeId}
onPick={pickEmployee}
serviceName={selectedService?.name}
/>
</StepShell>
)}
@@ -313,6 +326,7 @@ export default function BookingPage() {
slots={slots}
loading={slotsQuery.isLoading}
isError={slotsQuery.isError}
errorMessage={slotsQuery.error instanceof Error ? slotsQuery.error.message : undefined}
selectedSlot={selectedSlot}
onPick={pickSlot}
tz={tz}
@@ -568,10 +582,12 @@ function EmployeeList({
employees,
selectedId,
onPick,
serviceName,
}: {
employees: { id: number; name: string; role: string; color: string }[];
selectedId: number | null;
onPick: (id: number | null) => void;
serviceName?: string;
}) {
const anyActive = selectedId === null;
return (
@@ -600,6 +616,14 @@ function EmployeeList({
)}
</button>
{employees.length === 0 && (
<p className="rounded-2xl border border-slate-200 bg-slate-50 p-3.5 text-sm text-slate-600">
{serviceName
? `Para ${serviceName} asignamos al especialista disponible.`
: "Asignamos al especialista disponible."}
</p>
)}
{employees.map((e) => {
const active = e.id === selectedId;
return (
@@ -644,6 +668,7 @@ function DateTimePicker({
slots,
loading,
isError,
errorMessage,
selectedSlot,
onPick,
tz,
@@ -655,6 +680,7 @@ function DateTimePicker({
slots: PublicSlot[];
loading: boolean;
isError: boolean;
errorMessage?: string;
selectedSlot: PublicSlot | null;
onPick: (s: PublicSlot) => void;
tz: string;
@@ -700,7 +726,13 @@ function DateTimePicker({
{!loading && isError && (
<div className="flex flex-col items-center gap-2 py-8 text-center">
<AlertCircle className="h-6 w-6 text-rose-400" />
<p className="text-sm text-slate-500">No pudimos cargar los horarios. Intenta otra fecha.</p>
{/* Se muestra el motivo real del servidor. El texto fijo anterior
("intenta otra fecha") mandaba al cliente a probar días al azar
cuando el problema era el especialista, y ninguna fecha lo resolvía. */}
<p className="text-sm font-medium text-slate-700">
{errorMessage || "No pudimos cargar los horarios."}
</p>
<p className="text-xs text-slate-400">Prueba con otro especialista u otra fecha.</p>
</div>
)}
{!loading && !isError && slots.length === 0 && (