feat(cliente): invertir el flujo web a jefe -> trabajo
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:
Beldor
2026-08-05 09:45:21 -03:00
parent 15d6e8a2ee
commit 44aeb58e77
7 changed files with 141 additions and 55 deletions
+10 -3
View File
@@ -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" },
});
+3
View File
@@ -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);
+69 -50
View File
@@ -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>
<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>
))}
<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">{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>