From 44aeb58e774d3fb8d50d70f474db3a292defba57 Mon Sep 17 00:00:00 2001 From: Beldor Date: Wed, 5 Aug 2026 09:45:21 -0300 Subject: [PATCH] feat(cliente): invertir el flujo web a jefe -> trabajo Los trabajos ahora son por jefe mecanico, asi que el cliente elige primero el mecanico y despues el trabajo, igual que en el bot de WhatsApp. Antes listaba todos los servicios del taller y recien despues pedia el jefe. - src/lib/servicios.ts: regla unica de que trabajos ofrece un jefe = los suyos + los globales del taller (jefeMecanicoId null, los carga el admin). Asi no se rompe lo que ya existe en produccion, donde todos los servicios son globales. - /api/cliente/servicios ahora exige jefeMecanicoId y aplica esa regla. - /api/cliente/slots y crearTurno rechazan un trabajo propio de OTRO jefe (400): antes se podia reservar con el jefe A la duracion del trabajo del B. - whatsapp-bot: usa la misma regla, asi la web y el bot ofrecen lo mismo. - cliente/turnos: pasos invertidos, cabeceras y back acordes, y estado vacio propio cuando el jefe no tiene trabajos. - test-whatsapp-bot: soporte de OR en el Prisma en memoria, trabajo global en el seed y escenario D (globales visibles, trabajo ajeno rechazado). 31 asserts. Co-Authored-By: Claude Opus 5 --- scripts/test-whatsapp-bot.ts | 27 ++++++ src/app/api/cliente/servicios/route.ts | 13 ++- src/app/api/cliente/slots/route.ts | 3 + src/app/cliente/turnos/page.tsx | 119 ++++++++++++++----------- src/lib/servicios.ts | 24 +++++ src/lib/turnos.ts | 5 ++ src/lib/whatsapp-bot.ts | 5 +- 7 files changed, 141 insertions(+), 55 deletions(-) create mode 100644 src/lib/servicios.ts diff --git a/scripts/test-whatsapp-bot.ts b/scripts/test-whatsapp-bot.ts index 0b7bcfc..ff56c31 100644 --- a/scripts/test-whatsapp-bot.ts +++ b/scripts/test-whatsapp-bot.ts @@ -24,6 +24,10 @@ function match(row: Row, where: Row): boolean { if (!(v as Row[]).every((w) => match(row, w))) return false; continue; } + if (k === "OR") { + if (!(v as Row[]).some((w) => match(row, w))) return false; + continue; + } const rv = row[k]; if (v !== null && typeof v === "object" && !(v instanceof Date)) { if ("in" in v && !(v.in as any[]).includes(rv)) return false; @@ -143,6 +147,8 @@ jefes.push({ id: "j1", nombre: "Carlos", email: "j1@t.com", activo: true, slotsP jefes.push({ id: "j2", nombre: "Diego", email: "j2@t.com", activo: true, slotsParalelos: 1 }); servicios.push({ id: "s1", nombre: "Cambio de aceite", duracionHoras: 1, activo: true, jefeMecanicoId: "j1" }); servicios.push({ id: "s2", nombre: "Alineacion", duracionHoras: 2, activo: true, jefeMecanicoId: "j1" }); +// Trabajo global del taller (lo carga el admin): lo ofrece cualquier jefe. +servicios.push({ id: "s3", nombre: "Revision general", duracionHoras: 1, activo: true, jefeMecanicoId: null }); clientes.push({ id: "c1", nombre: "Juan", @@ -187,6 +193,7 @@ async function main() { r = await env(A, "j1"); ok(/Cambio de aceite/i.test(flat(r)), "muestra trabajos del jefe elegido"); + ok(/Revision general/i.test(flat(r)), "incluye los trabajos globales del taller"); ok(paso(A) === "ELEGIR_TRABAJO", "paso=ELEGIR_TRABAJO"); r = await env(A, "a"); @@ -225,6 +232,26 @@ async function main() { ok(/cancel/i.test(flat(r)), "cancelar resetea"); ok(paso(B) === "INICIO", "paso=INICIO tras cancelar"); + console.log("\n── Escenario D: trabajos por jefe ──"); + const D = "5493518888888"; + await env(D, "hola"); + await env(D, "Ana"); + await env(D, "Lopez"); + r = await env(D, "ana@mail.com"); + ok(hayBotones(r), "alta rápida hasta elegir mecánico"); + r = await env(D, "j2"); + ok(/Revision general/i.test(flat(r)), "un jefe sin trabajos propios igual ofrece los globales"); + ok(!/Cambio de aceite/i.test(flat(r)), "NO ofrece los trabajos propios de otro jefe"); + + const { crearTurno } = await import("../src/lib/turnos"); + const cruzado = await crearTurno({ + clienteId: "c1", + jefeMecanicoId: "j2", + tipoServicioId: "s1", // trabajo propio de j1 + fechaInicio: new Date(Date.now() + 24 * 60 * 60 * 1000), + }); + ok(!cruzado.ok && cruzado.status === 400, "crearTurno rechaza el trabajo de otro jefe (400)"); + console.log(`\n${fallos === 0 ? "✅ TODO OK" : `❌ ${fallos} assert(s) fallaron`}\n`); process.exit(fallos === 0 ? 0 : 1); } diff --git a/src/app/api/cliente/servicios/route.ts b/src/app/api/cliente/servicios/route.ts index 0f13d0d..ffc6b00 100644 --- a/src/app/api/cliente/servicios/route.ts +++ b/src/app/api/cliente/servicios/route.ts @@ -1,13 +1,20 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { verificarSesion } from "@/lib/auth-admin"; +import { whereTrabajosDeJefe } from "@/lib/servicios"; -export async function GET() { +// Trabajos que ofrece un jefe mecánico: los suyos + los globales del taller. +// El flujo del cliente es jefe → trabajo, así que el jefe es obligatorio. +export async function GET(req: NextRequest) { const sesion = await verificarSesion("cliente_session"); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + const jefeMecanicoId = req.nextUrl.searchParams.get("jefeMecanicoId"); + if (!jefeMecanicoId) + return NextResponse.json({ error: "jefeMecanicoId requerido" }, { status: 400 }); + const servicios = await prisma.tipoServicio.findMany({ - where: { activo: true }, + where: whereTrabajosDeJefe(jefeMecanicoId), orderBy: { nombre: "asc" }, }); diff --git a/src/app/api/cliente/slots/route.ts b/src/app/api/cliente/slots/route.ts index 3540c1a..5f06b61 100644 --- a/src/app/api/cliente/slots/route.ts +++ b/src/app/api/cliente/slots/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from "next/server"; import { prisma } from "@/lib/prisma"; import { verificarSesion } from "@/lib/auth-admin"; import { obtenerSlotsDisponibles } from "@/lib/scheduling"; +import { trabajoEsDeJefe } from "@/lib/servicios"; export async function GET(req: NextRequest) { const sesion = await verificarSesion("cliente_session"); @@ -18,6 +19,8 @@ export async function GET(req: NextRequest) { const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } }); if (!servicio || !servicio.activo) return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 }); + if (!trabajoEsDeJefe(servicio, jefeMecanicoId)) + return NextResponse.json({ error: "Ese trabajo no lo hace este mecánico" }, { status: 400 }); const slots = await obtenerSlotsDisponibles(jefeMecanicoId, servicio.duracionHoras, 60, 500); return NextResponse.json(slots); diff --git a/src/app/cliente/turnos/page.tsx b/src/app/cliente/turnos/page.tsx index fb30a48..2eb2d1d 100644 --- a/src/app/cliente/turnos/page.tsx +++ b/src/app/cliente/turnos/page.tsx @@ -20,7 +20,9 @@ interface Turno { const MESES = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"]; const DIAS_HEADER = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"]; -type Step = "servicio" | "jefe" | "calendario" | "confirmado"; +// El cliente elige primero el mecánico y después el trabajo, porque cada jefe +// carga sus propios trabajos (además de los globales del taller). +type Step = "jefe" | "servicio" | "calendario" | "confirmado"; function formatDuracion(h: number) { if (h < 1) return `${Math.round(h * 60)} min`; @@ -66,12 +68,13 @@ function TurnosContent() { const params = useSearchParams(); const autoId = params.get("autoId"); - const [step, setStep] = useState("servicio"); + const [step, setStep] = useState("jefe"); const [servicios, setServicios] = useState([]); const [servicioSel, setServicioSel] = useState(null); + const [loadingServicios, setLoadingServicios] = useState(false); const [jefes, setJefes] = useState([]); const [jefeSel, setJefeSel] = useState(null); - const [loadingJefes, setLoadingJefes] = useState(false); + const [loadingJefes, setLoadingJefes] = useState(true); const [slots, setSlots] = useState([]); const [loadingSlots, setLoadingSlots] = useState(false); const [misTurnos, setMisTurnos] = useState([]); @@ -90,7 +93,12 @@ function TurnosContent() { useEffect(() => { if (!autoId) { router.push("/cliente/dashboard"); return; } - fetch("/api/cliente/servicios").then(r => r.json()).then(setServicios); + fetch("/api/cliente/jefes") + .then(r => r.json()) + .then((data) => { + setJefes(Array.isArray(data) ? data : []); + setLoadingJefes(false); + }); fetch("/api/cliente/turnos") .then(r => r.json()) .then((data) => { @@ -110,29 +118,30 @@ function TurnosContent() { return m; }, [slots]); - async function seleccionarServicio(s: TipoServicio) { - setServicioSel(s); - setJefeSel(null); + async function seleccionarJefe(j: Jefe) { + setJefeSel(j); + setServicioSel(null); + setServicios([]); setSlots([]); setDiaSeleccionado(null); setSlotSel(null); - setLoadingJefes(true); - setStep("jefe"); - const res = await fetch("/api/cliente/jefes"); + setLoadingServicios(true); + setStep("servicio"); + const res = await fetch(`/api/cliente/servicios?jefeMecanicoId=${j.id}`); const data = await res.json(); - setJefes(Array.isArray(data) ? data : []); - setLoadingJefes(false); + setServicios(Array.isArray(data) ? data : []); + setLoadingServicios(false); } - async function seleccionarJefe(j: Jefe) { - if (!servicioSel) return; - setJefeSel(j); + async function seleccionarServicio(s: TipoServicio) { + if (!jefeSel) return; + setServicioSel(s); setLoadingSlots(true); setSlots([]); setDiaSeleccionado(null); setSlotSel(null); setStep("calendario"); - const res = await fetch(`/api/cliente/slots?tipoServicioId=${servicioSel.id}&jefeMecanicoId=${j.id}`); + const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}&jefeMecanicoId=${jefeSel.id}`); const data = await res.json(); setSlots(Array.isArray(data) ? data : []); setLoadingSlots(false); @@ -213,14 +222,14 @@ function TurnosContent() {
✅

¡Turno reservado!

-

Servicio

-

{turnoCreado.tipoServicio.nombre}

{turnoCreado.jefeMecanico && ( <>

Jefe mecánico

{turnoCreado.jefeMecanico.nombre}

)} +

Servicio

+

{turnoCreado.tipoServicio.nombre}

Fecha

{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", { timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long" @@ -236,33 +245,46 @@ function TurnosContent() {

{step === "calendario" ? ( - - ) : step === "jefe" ? ( + ) : step === "servicio" ? ( + ) : ( )}

- {step === "servicio" && "Elegí el servicio"} {step === "jefe" && "Elegí el jefe mecánico"} - {step === "calendario" && `${servicioSel?.nombre}${jefeSel ? ` · ${jefeSel.nombre}` : ""}`} + {step === "servicio" && `Trabajos de ${jefeSel?.nombre}`} + {step === "calendario" && `${jefeSel?.nombre}${servicioSel ? ` · ${servicioSel.nombre}` : ""}`}

- {/* STEP: servicio */} - {step === "servicio" && ( + {/* STEP: jefe */} + {step === "jefe" && ( <> -
- {servicios.map(s => ( - - ))} +
+ {loadingJefes ? ( +

Cargando jefes...

+ ) : jefes.length === 0 ? ( + +

+ No hay jefes mecánicos disponibles en este momento. Intentá más tarde. +

+
+ ) : ( + jefes.map((j) => ( + + )) + )}
{!loadingTurnos && misTurnos.length > 0 && ( @@ -288,27 +310,24 @@ function TurnosContent() { )} - {/* STEP: jefe */} - {step === "jefe" && ( -
- {loadingJefes ? ( -

Cargando jefes...

- ) : jefes.length === 0 ? ( + {/* STEP: servicio */} + {step === "servicio" && ( +
+ {loadingServicios ? ( +

Cargando trabajos...

+ ) : servicios.length === 0 ? (

- No hay jefes mecánicos disponibles en este momento. Intentá más tarde. + {jefeSel?.nombre} todavía no cargó trabajos. Elegí otro mecánico.

) : ( - jefes.map((j) => ( - diff --git a/src/lib/servicios.ts b/src/lib/servicios.ts new file mode 100644 index 0000000..fcfd8a2 --- /dev/null +++ b/src/lib/servicios.ts @@ -0,0 +1,24 @@ +import type { Prisma, TipoServicio } from "@prisma/client"; + +/** + * Regla única de qué trabajos ofrece un jefe mecánico. + * + * Los jefes cargan sus propios trabajos desde `/jefe/servicios` (`jefeMecanicoId` = su id), + * y el admin puede cargar trabajos globales del taller (`jefeMecanicoId` = null) que ofrece + * cualquiera. El cliente elige primero el mecánico y después el trabajo, así que la lista + * siempre es: los del jefe + los globales. + */ +export function whereTrabajosDeJefe(jefeMecanicoId: string): Prisma.TipoServicioWhereInput { + return { + activo: true, + OR: [{ jefeMecanicoId }, { jefeMecanicoId: null }], + }; +} + +/** Un trabajo propio de otro jefe no se puede reservar con este. Los globales sí. */ +export function trabajoEsDeJefe( + servicio: Pick, + jefeMecanicoId: string +): boolean { + return servicio.jefeMecanicoId === null || servicio.jefeMecanicoId === jefeMecanicoId; +} diff --git a/src/lib/turnos.ts b/src/lib/turnos.ts index b84270e..2a9a13c 100644 --- a/src/lib/turnos.ts +++ b/src/lib/turnos.ts @@ -1,5 +1,6 @@ import { prisma } from "./prisma"; import { slotEstaDisponible } from "./scheduling"; +import { trabajoEsDeJefe } from "./servicios"; export interface CrearTurnoInput { clienteId: string; @@ -30,6 +31,10 @@ export async function crearTurno(input: CrearTurnoInput): Promise { const servicios = await prisma.tipoServicio.findMany({ - where: { jefeMecanicoId: datos.jefeMecanicoId, activo: true }, + where: whereTrabajosDeJefe(datos.jefeMecanicoId!), orderBy: { nombre: "asc" }, }); @@ -303,7 +304,7 @@ export async function procesarMensaje(telefonoRaw: string, textoRaw: string): Pr if (!op) return guardar(conv.id, "ELEGIR_TRABAJO", datos, [{ tipo: "texto", texto: "No entendí. Respondé con la letra del trabajo." }]); const servicio = await prisma.tipoServicio.findUnique({ where: { id: op.id } }); - if (!servicio || !servicio.activo) { + if (!servicio || !servicio.activo || !trabajoEsDeJefe(servicio, datos.jefeMecanicoId!)) { const m = await pasoElegirTrabajo(datos); return guardar(conv.id, m.paso, m.datos, [{ tipo: "texto", texto: "Ese trabajo ya no está disponible." }, ...m.resp]); }