diff --git a/src/app/api/cliente/slots/route.ts b/src/app/api/cliente/slots/route.ts index 6d38a80..f26cba0 100644 --- a/src/app/api/cliente/slots/route.ts +++ b/src/app/api/cliente/slots/route.ts @@ -16,6 +16,6 @@ export async function GET(req: NextRequest) { if (!servicio || !servicio.activo) return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 }); - const slots = await obtenerSlotsDisponibles(servicio.duracionHoras); + const slots = await obtenerSlotsDisponibles(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 1679263..4001f03 100644 --- a/src/app/cliente/turnos/page.tsx +++ b/src/app/cliente/turnos/page.tsx @@ -1,5 +1,5 @@ "use client"; -import { Suspense, useEffect, useState } from "react"; +import { Suspense, useEffect, useMemo, useState } from "react"; import { useRouter, useSearchParams } from "next/navigation"; import Link from "next/link"; import Card from "@/components/ui/Card"; @@ -9,23 +9,53 @@ import Badge from "@/components/ui/Badge"; interface TipoServicio { id: string; nombre: string; duracionHoras: number; } interface Slot { fechaInicio: string; fechaFin: string; } interface Turno { - id: string; - fechaInicio: string; - fechaFin: string; + id: string; fechaInicio: string; fechaFin: string; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO"; - tipoServicio: { nombre: string }; - auto: { marca: string; modelo: string; patente: string }; + tipoServicio: { nombre: string }; auto: { marca: string; modelo: string; patente: string }; } -type Step = "servicio" | "slot" | "confirmado"; +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"]; -function formatDuracion(horas: number): string { - if (horas < 1) return `${Math.round(horas * 60)} min`; - if (horas === 1) return "1 hora"; - if (Number.isInteger(horas)) return `${horas} horas`; - const h = Math.floor(horas); - const m = Math.round((horas % 1) * 60); - return `${h} h ${m} min`; +type Step = "servicio" | "calendario" | "confirmado"; + +function formatDuracion(h: number) { + if (h < 1) return `${Math.round(h * 60)} min`; + if (h === 1) return "1 hora"; + if (Number.isInteger(h)) return `${h} horas`; + return `${Math.floor(h)} h ${Math.round((h % 1) * 60)} min`; +} + +// Convierte ISO UTC a fecha Argentina "YYYY-MM-DD" +function fechaArg(iso: string) { + return new Date(iso).toLocaleDateString("sv-SE", { timeZone: "America/Argentina/Buenos_Aires" }); +} + +// Convierte ISO UTC a hora Argentina "HH:MM" +function horaArg(iso: string) { + return new Date(iso).toLocaleTimeString("es-AR", { + timeZone: "America/Argentina/Buenos_Aires", hour: "2-digit", minute: "2-digit", + }); +} + +// Hoy en Argentina "YYYY-MM-DD" +function hoyArg() { + return new Date().toLocaleDateString("sv-SE", { timeZone: "America/Argentina/Buenos_Aires" }); +} + +// Celdas del mes (null = celda vacía antes del día 1) +function celdasMes(year: number, month: number): (number | null)[] { + const primer = new Date(year, month, 1); + const total = new Date(year, month + 1, 0).getDate(); + const offset = (primer.getDay() + 6) % 7; // Lunes = 0 + const result: (number | null)[] = Array(offset).fill(null); + for (let d = 1; d <= total; d++) result.push(d); + return result; +} + +function esFinDeSemana(year: number, month: number, day: number) { + const dow = new Date(year, month, day).getDay(); + return dow === 0 || dow === 6; } function TurnosContent() { @@ -37,34 +67,60 @@ function TurnosContent() { const [servicios, setServicios] = useState([]); const [servicioSel, setServicioSel] = useState(null); const [slots, setSlots] = useState([]); - const [slotSel, setSlotSel] = useState(null); const [loadingSlots, setLoadingSlots] = useState(false); - const [kilometros, setKilometros] = useState(""); - const [reservando, setReservando] = useState(false); - const [turnoCreado, setTurnoCreado] = useState(null); const [misTurnos, setMisTurnos] = useState([]); const [loadingTurnos, setLoadingTurnos] = useState(true); + // Calendario + const hoy = new Date(); + const [mesVista, setMesVista] = useState({ year: hoy.getFullYear(), month: hoy.getMonth() }); + const [diaSeleccionado, setDiaSeleccionado] = useState(null); + const [slotSel, setSlotSel] = useState(null); + const [kilometros, setKilometros] = useState(""); + const [reservando, setReservando] = useState(false); + const [turnoCreado, setTurnoCreado] = useState(null); + useEffect(() => { if (!autoId) { router.push("/cliente/dashboard"); return; } - fetch("/api/cliente/servicios") - .then((r) => r.json()) - .then((data) => setServicios(data)); + fetch("/api/cliente/servicios").then(r => r.json()).then(setServicios); fetch("/api/cliente/turnos") - .then((r) => r.json()) - .then((data) => { setMisTurnos(data.filter((t: Turno & { autoId: string }) => t.autoId === autoId)); setLoadingTurnos(false); }); + .then(r => r.json()) + .then((data) => { + setMisTurnos(data.filter((t: Turno & { autoId: string }) => t.autoId === autoId)); + setLoadingTurnos(false); + }); }, [autoId, router]); + // Agrupar slots por fecha Argentina + const slotsPorDia = useMemo(() => { + const m: Record = {}; + for (const s of slots) { + const d = fechaArg(s.fechaInicio); + if (!m[d]) m[d] = []; + m[d].push(s); + } + return m; + }, [slots]); + async function seleccionarServicio(s: TipoServicio) { setServicioSel(s); setLoadingSlots(true); - setStep("slot"); + setSlots([]); + setDiaSeleccionado(null); + setSlotSel(null); + setStep("calendario"); const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}`); const data = await res.json(); setSlots(data); setLoadingSlots(false); } + function seleccionarDia(dateStr: string) { + setDiaSeleccionado(dateStr); + setSlotSel(null); + setKilometros(""); + } + async function reservar() { if (!slotSel || !servicioSel || !autoId || !kilometros) return; setReservando(true); @@ -75,23 +131,33 @@ function TurnosContent() { }); const data = await res.json(); setReservando(false); - if (res.ok) { - setTurnoCreado(data); - setStep("confirmado"); - } + if (res.ok) { setTurnoCreado(data); setDiaSeleccionado(null); setStep("confirmado"); } } - function formatSlot(slot: Slot) { - const inicio = new Date(slot.fechaInicio); - const fin = new Date(slot.fechaFin); - const dia = inicio.toLocaleDateString("es-AR", { weekday: "long", day: "numeric", month: "long" }); - const h1 = inicio.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" }); - const h2 = fin.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" }); - return { dia: dia.charAt(0).toUpperCase() + dia.slice(1), horas: `${h1} - ${h2}` }; + // Navegación de mes + const hoyStr = hoyArg(); + const mesActual = { year: hoy.getFullYear(), month: hoy.getMonth() }; + const puedeRetroceder = mesVista.year > mesActual.year || (mesVista.year === mesActual.year && mesVista.month > mesActual.month); + + function prevMes() { + if (!puedeRetroceder) return; + setMesVista(m => { + if (m.month === 0) return { year: m.year - 1, month: 11 }; + return { year: m.year, month: m.month - 1 }; + }); } + function nextMes() { + setMesVista(m => { + if (m.month === 11) return { year: m.year + 1, month: 0 }; + return { year: m.year, month: m.month + 1 }; + }); + } + + const celdas = celdasMes(mesVista.year, mesVista.month); + const slotsDelDia = diaSeleccionado ? (slotsPorDia[diaSeleccionado] ?? []) : []; + if (step === "confirmado" && turnoCreado) { - const { dia, horas } = formatSlot({ fechaInicio: turnoCreado.fechaInicio, fechaFin: turnoCreado.fechaFin }); return (
✅
@@ -100,12 +166,12 @@ function TurnosContent() {

Servicio

{turnoCreado.tipoServicio.nombre}

Fecha

-

{dia}

-

{horas}

+

{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", { + timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long" + })}

+

{horaArg(turnoCreado.fechaInicio)} – {horaArg(turnoCreado.fechaFin)}

- - - +
); } @@ -113,30 +179,26 @@ function TurnosContent() { return (
- {step === "slot" ? ( + {step === "calendario" ? ( ) : ( )} -

- {step === "servicio" ? "Elegí el servicio" : "Elegí el horario"} +

+ {step === "servicio" ? "Elegí el servicio" : `${servicioSel?.nombre}`}

+ {/* STEP: servicio */} {step === "servicio" && ( <>
- {servicios.map((s) => ( - @@ -146,83 +208,153 @@ function TurnosContent() { {!loadingTurnos && misTurnos.length > 0 && (
- {misTurnos.map((t) => { - const { dia, horas } = formatSlot({ fechaInicio: t.fechaInicio, fechaFin: t.fechaFin }); - return ( -
-
-

{t.tipoServicio.nombre}

-

{dia} · {horas}

-
- + {misTurnos.map(t => ( +
+
+

{t.tipoServicio.nombre}

+

+ {new Date(t.fechaInicio).toLocaleDateString("es-AR", { + timeZone: "America/Argentina/Buenos_Aires", + weekday: "short", day: "numeric", month: "short" + })} · {horaArg(t.fechaInicio)} – {horaArg(t.fechaFin)} +

- ); - })} + +
+ ))}
)} )} - {step === "slot" && ( + {/* STEP: calendario */} + {step === "calendario" && (
- {loadingSlots ? ( -
Buscando horarios disponibles...
- ) : slots.length === 0 ? ( - -

- No hay horarios disponibles en los próximos 14 días. Consultá al taller. -

-
- ) : ( - <> -

- Servicio: {servicioSel?.nombre} -

-
- {slots.map((slot, i) => { - const { dia, horas } = formatSlot(slot); - const sel = slotSel?.fechaInicio === slot.fechaInicio; - return ( - - ); - })} -
-
- - setKilometros(e.target.value)} - className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" - /> -
- - + {loadingSlots && ( +

Cargando disponibilidad...

)} + + {/* Cabecera del mes */} +
+ + + {MESES[mesVista.month]} {mesVista.year} + + +
+ + {/* Grid del calendario */} +
+ {/* Headers días */} +
+ {DIAS_HEADER.map(d => ( +
+ {d} +
+ ))} +
+ + {/* Celdas */} +
+ {celdas.map((dia, i) => { + if (dia === null) return
; + + const dateStr = `${mesVista.year}-${String(mesVista.month + 1).padStart(2, "0")}-${String(dia).padStart(2, "0")}`; + const finde = esFinDeSemana(mesVista.year, mesVista.month, dia); + const pasado = dateStr < hoyStr; + const tieneSlots = !!slotsPorDia[dateStr]; + const seleccionado = diaSeleccionado === dateStr; + const esHoy = dateStr === hoyStr; + const deshabilitado = finde || pasado || (!tieneSlots && !loadingSlots); + + return ( + + ); + })} +
+
+ + {!loadingSlots && slots.length === 0 && ( +

No hay turnos disponibles en los próximos 60 días.

+ )} +
+ )} + + {/* MODAL de horarios */} + {diaSeleccionado && ( +
+
setDiaSeleccionado(null)} /> +
+
+

+ {new Date(`${diaSeleccionado}T12:00:00`).toLocaleDateString("es-AR", { + weekday: "long", day: "numeric", month: "long" + })} +

+ +
+ + {slotsDelDia.length === 0 ? ( +

No hay horarios disponibles este día.

+ ) : ( + <> +
+ {slotsDelDia.map((s, i) => { + const sel = slotSel?.fechaInicio === s.fechaInicio; + return ( + + ); + })} +
+ + {slotSel && ( +
+
+ + setKilometros(e.target.value)} + className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+ +
+ )} + + )} +
)}
diff --git a/src/lib/scheduling.ts b/src/lib/scheduling.ts index 13bfeb6..65855b0 100644 --- a/src/lib/scheduling.ts +++ b/src/lib/scheduling.ts @@ -68,7 +68,8 @@ export interface SlotDisponible { export async function obtenerSlotsDisponibles( duracionHoras: number, - diasAdelante = 14 + diasAdelante = 14, + limite = 300 ): Promise { const slots: SlotDisponible[] = []; const ahora = new Date(); @@ -83,7 +84,7 @@ export async function obtenerSlotsDisponibles( cursor = new Date(cursor.getTime() + avanzarMin * 60 * 1000); cursor.setUTCSeconds(0, 0); - while (cursor < fechaLimite && slots.length < 20) { + while (cursor < fechaLimite && slots.length < limite) { if (!esDiaHabil(cursor)) { cursor = inicioJornada(new Date(cursor.getTime() + 24 * 60 * 60 * 1000)); continue;