feat(cliente): invertir el flujo web a jefe -> trabajo
Revisión con IA / ai-review (pull_request) Failing after 2m12s
Revisión con IA / ai-review (pull_request) Failing after 2m12s
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<Step>("servicio");
|
||||
const [step, setStep] = useState<Step>("jefe");
|
||||
const [servicios, setServicios] = useState<TipoServicio[]>([]);
|
||||
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
|
||||
const [loadingServicios, setLoadingServicios] = useState(false);
|
||||
const [jefes, setJefes] = useState<Jefe[]>([]);
|
||||
const [jefeSel, setJefeSel] = useState<Jefe | null>(null);
|
||||
const [loadingJefes, setLoadingJefes] = useState(false);
|
||||
const [loadingJefes, setLoadingJefes] = useState(true);
|
||||
const [slots, setSlots] = useState<Slot[]>([]);
|
||||
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
|
||||
@@ -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() {
|
||||
<div className="text-5xl">✅</div>
|
||||
<h1 className="text-2xl font-bold text-white">¡Turno reservado!</h1>
|
||||
<Card className="text-left w-full max-w-sm">
|
||||
<p className="text-sm text-gray-500 mb-1">Servicio</p>
|
||||
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
|
||||
{turnoCreado.jefeMecanico && (
|
||||
<>
|
||||
<p className="text-sm text-gray-500 mb-1">Jefe mecánico</p>
|
||||
<p className="font-semibold mb-3">{turnoCreado.jefeMecanico.nombre}</p>
|
||||
</>
|
||||
)}
|
||||
<p className="text-sm text-gray-500 mb-1">Servicio</p>
|
||||
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
|
||||
<p className="text-sm text-gray-500 mb-1">Fecha</p>
|
||||
<p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
|
||||
timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long"
|
||||
@@ -236,33 +245,46 @@ function TurnosContent() {
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{step === "calendario" ? (
|
||||
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
|
||||
) : step === "jefe" ? (
|
||||
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
|
||||
) : step === "servicio" ? (
|
||||
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
|
||||
) : (
|
||||
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
|
||||
)}
|
||||
<h1 className="text-xl sm:text-2xl font-bold text-white">
|
||||
{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}` : ""}`}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* STEP: servicio */}
|
||||
{step === "servicio" && (
|
||||
{/* STEP: jefe */}
|
||||
{step === "jefe" && (
|
||||
<>
|
||||
<div className="grid gap-3">
|
||||
{servicios.map(s => (
|
||||
<button key={s.id} onClick={() => seleccionarServicio(s)}
|
||||
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between">
|
||||
<div className="flex flex-col gap-3">
|
||||
{loadingJefes ? (
|
||||
<p className="text-center text-sm text-gray-200">Cargando jefes...</p>
|
||||
) : jefes.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
jefes.map((j) => (
|
||||
<button
|
||||
key={j.id}
|
||||
onClick={() => seleccionarJefe(j)}
|
||||
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{s.nombre}</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
|
||||
<p className="font-medium text-gray-800">{j.nombre}</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Ver sus trabajos</p>
|
||||
</div>
|
||||
<span className="text-gray-400 text-lg">→</span>
|
||||
</button>
|
||||
))}
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!loadingTurnos && misTurnos.length > 0 && (
|
||||
@@ -288,27 +310,24 @@ function TurnosContent() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* STEP: jefe */}
|
||||
{step === "jefe" && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{loadingJefes ? (
|
||||
<p className="text-center text-sm text-gray-200">Cargando jefes...</p>
|
||||
) : jefes.length === 0 ? (
|
||||
{/* STEP: servicio */}
|
||||
{step === "servicio" && (
|
||||
<div className="grid gap-3">
|
||||
{loadingServicios ? (
|
||||
<p className="text-center text-sm text-gray-200">Cargando trabajos...</p>
|
||||
) : servicios.length === 0 ? (
|
||||
<Card>
|
||||
<p className="text-sm text-gray-600 text-center">
|
||||
No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
|
||||
{jefeSel?.nombre} todavía no cargó trabajos. Elegí otro mecánico.
|
||||
</p>
|
||||
</Card>
|
||||
) : (
|
||||
jefes.map((j) => (
|
||||
<button
|
||||
key={j.id}
|
||||
onClick={() => seleccionarJefe(j)}
|
||||
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
|
||||
>
|
||||
servicios.map(s => (
|
||||
<button key={s.id} onClick={() => seleccionarServicio(s)}
|
||||
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between">
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{j.nombre}</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Ver disponibilidad</p>
|
||||
<p className="font-medium text-gray-800">{s.nombre}</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">Duración: {formatDuracion(s.duracionHoras)}</p>
|
||||
</div>
|
||||
<span className="text-gray-400 text-lg">→</span>
|
||||
</button>
|
||||
|
||||
@@ -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<TipoServicio, "jefeMecanicoId">,
|
||||
jefeMecanicoId: string
|
||||
): boolean {
|
||||
return servicio.jefeMecanicoId === null || servicio.jefeMecanicoId === jefeMecanicoId;
|
||||
}
|
||||
@@ -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<CrearTurnoResu
|
||||
if (!jefe || !jefe.activo)
|
||||
return { ok: false, error: "Jefe mecánico no disponible", status: 404 };
|
||||
|
||||
// Un trabajo propio de otro jefe no se reserva con éste (los globales sí).
|
||||
if (!trabajoEsDeJefe(servicio, jefeMecanicoId))
|
||||
return { ok: false, error: "Ese trabajo no lo hace este mecánico", status: 400 };
|
||||
|
||||
const fin = new Date(fechaInicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
|
||||
|
||||
const disponible = await slotEstaDisponible(fechaInicio, fin, jefeMecanicoId);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from "./prisma";
|
||||
import { obtenerSlotsDisponibles } from "./scheduling";
|
||||
import { crearTurno } from "./turnos";
|
||||
import { normalizarTelefono } from "./telefono";
|
||||
import { trabajoEsDeJefe, whereTrabajosDeJefe } from "./servicios";
|
||||
|
||||
// ── Tipos de respuesta que el bot le pide al canal (F0 los traduce a la API de Meta) ──
|
||||
export type BotonBot = { id: string; titulo: string };
|
||||
@@ -96,7 +97,7 @@ async function pasoElegirMecanico(): Promise<{ resp: RespuestaBot[]; datos: Dato
|
||||
|
||||
async function pasoElegirTrabajo(datos: DatosConv): Promise<{ resp: RespuestaBot[]; datos: DatosConv; paso: string }> {
|
||||
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]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user