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:
@@ -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>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user