feat: calendario mensual para reservar turnos
Deploy / deploy (push) Successful in 4m3s

- Reemplaza lista de slots por calendario cuadrado mensual
- Sábados y domingos grisados, días sin slots deshabilitados
- Click en día hábil abre modal con horarios disponibles
- Slot dot indicator en días con turnos disponibles
- Amplía búsqueda a 60 días / 500 slots para cubrir todo el mes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Beldor
2026-06-22 23:33:29 -03:00
parent 4a6023f9f3
commit 0497e936a4
3 changed files with 257 additions and 124 deletions
+1 -1
View File
@@ -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);
}
+232 -100
View File
@@ -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<TipoServicio[]>([]);
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
const [slots, setSlots] = useState<Slot[]>([]);
const [slotSel, setSlotSel] = useState<Slot | null>(null);
const [loadingSlots, setLoadingSlots] = useState(false);
const [kilometros, setKilometros] = useState("");
const [reservando, setReservando] = useState(false);
const [turnoCreado, setTurnoCreado] = useState<Turno | null>(null);
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
const [loadingTurnos, setLoadingTurnos] = useState(true);
// Calendario
const hoy = new Date();
const [mesVista, setMesVista] = useState({ year: hoy.getFullYear(), month: hoy.getMonth() });
const [diaSeleccionado, setDiaSeleccionado] = useState<string | null>(null);
const [slotSel, setSlotSel] = useState<Slot | null>(null);
const [kilometros, setKilometros] = useState("");
const [reservando, setReservando] = useState(false);
const [turnoCreado, setTurnoCreado] = useState<Turno | null>(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<string, Slot[]> = {};
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 (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center gap-4">
<div className="text-5xl">✅</div>
@@ -100,12 +166,12 @@ function TurnosContent() {
<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 mb-1">{dia}</p>
<p className="text-gray-600">{horas}</p>
<p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long"
})}</p>
<p className="text-gray-600">{horaArg(turnoCreado.fechaInicio)} – {horaArg(turnoCreado.fechaFin)}</p>
</Card>
<Link href="/cliente/dashboard">
<Button variant="secondary">← Volver a mis autos</Button>
</Link>
<Link href="/cliente/dashboard"><Button variant="secondary">← Volver a mis autos</Button></Link>
</div>
);
}
@@ -113,30 +179,26 @@ function TurnosContent() {
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
{step === "slot" ? (
{step === "calendario" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
) : (
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
)}
<h1 className="text-2xl font-bold text-gray-800">
{step === "servicio" ? "Elegí el servicio" : "Elegí el horario"}
<h1 className="text-xl sm:text-2xl font-bold text-gray-800">
{step === "servicio" ? "Elegí el servicio" : `${servicioSel?.nombre}`}
</h1>
</div>
{/* STEP: servicio */}
{step === "servicio" && (
<>
<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"
>
{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>
<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>
@@ -146,84 +208,154 @@ function TurnosContent() {
{!loadingTurnos && misTurnos.length > 0 && (
<Card title="Mis turnos para este auto">
<div className="flex flex-col gap-3">
{misTurnos.map((t) => {
const { dia, horas } = formatSlot({ fechaInicio: t.fechaInicio, fechaFin: t.fechaFin });
return (
{misTurnos.map(t => (
<div key={t.id} className="flex items-center justify-between flex-wrap gap-2">
<div>
<p className="text-sm font-medium">{t.tipoServicio.nombre}</p>
<p className="text-xs text-gray-500">{dia} · {horas}</p>
<p className="text-xs text-gray-500">
{new Date(t.fechaInicio).toLocaleDateString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires",
weekday: "short", day: "numeric", month: "short"
})} · {horaArg(t.fechaInicio)} – {horaArg(t.fechaFin)}
</p>
</div>
<Badge estado={t.estado} />
</div>
);
})}
))}
</div>
</Card>
)}
</>
)}
{step === "slot" && (
{/* STEP: calendario */}
{step === "calendario" && (
<div className="flex flex-col gap-4">
{loadingSlots ? (
<div className="text-center py-10 text-gray-400">Buscando horarios disponibles...</div>
) : slots.length === 0 ? (
<Card>
<p className="text-center text-gray-400 py-4">
No hay horarios disponibles en los próximos 14 días. Consultá al taller.
</p>
</Card>
) : (
<>
<p className="text-sm text-gray-500">
Servicio: <strong>{servicioSel?.nombre}</strong>
</p>
<div className="grid gap-2">
{slots.map((slot, i) => {
const { dia, horas } = formatSlot(slot);
const sel = slotSel?.fechaInicio === slot.fechaInicio;
{loadingSlots && (
<p className="text-center text-sm text-gray-400">Cargando disponibilidad...</p>
)}
{/* Cabecera del mes */}
<div className="flex items-center justify-between">
<button onClick={prevMes} disabled={!puedeRetroceder}
className={`p-2 rounded-lg transition ${puedeRetroceder ? "hover:bg-gray-100 text-gray-700" : "text-gray-300 cursor-default"}`}>
‹
</button>
<span className="font-semibold text-gray-800">
{MESES[mesVista.month]} {mesVista.year}
</span>
<button onClick={nextMes} className="p-2 rounded-lg hover:bg-gray-100 text-gray-700 transition">
›
</button>
</div>
{/* Grid del calendario */}
<div className="bg-white border border-gray-200 rounded-2xl overflow-hidden shadow-sm">
{/* Headers días */}
<div className="grid grid-cols-7 border-b border-gray-100">
{DIAS_HEADER.map(d => (
<div key={d} className={`text-center text-xs font-medium py-2 ${d === "Sáb" || d === "Dom" ? "text-gray-300" : "text-gray-500"}`}>
{d}
</div>
))}
</div>
{/* Celdas */}
<div className="grid grid-cols-7">
{celdas.map((dia, i) => {
if (dia === null) return <div key={`e-${i}`} className="aspect-square" />;
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 (
<button
key={i}
onClick={() => setSlotSel(slot)}
className={`text-left rounded-xl p-4 border transition ${
sel
? "border-blue-500 bg-blue-50"
: "border-gray-200 bg-white hover:border-blue-300"
}`}
key={dateStr}
disabled={deshabilitado}
onClick={() => seleccionarDia(dateStr)}
className={`aspect-square flex items-center justify-center text-sm relative transition
${deshabilitado ? "cursor-default" : "hover:bg-blue-50 cursor-pointer"}
${seleccionado ? "bg-blue-600 text-white rounded-full font-semibold" : ""}
${!seleccionado && esHoy ? "font-bold text-blue-600" : ""}
${!seleccionado && finde ? "text-gray-300" : ""}
${!seleccionado && pasado && !finde ? "text-gray-300" : ""}
${!seleccionado && !deshabilitado ? "text-gray-800" : ""}
`}
>
<p className="font-medium text-gray-800">{dia}</p>
<p className="text-sm text-gray-500">{horas}</p>
{dia}
{tieneSlots && !seleccionado && !deshabilitado && (
<span className="absolute bottom-1 left-1/2 -translate-x-1/2 w-1 h-1 rounded-full bg-blue-500" />
)}
</button>
);
})}
</div>
<div className="mt-2">
<label htmlFor="kilometros" className="block text-sm font-medium text-gray-700 mb-1">
</div>
{!loadingSlots && slots.length === 0 && (
<p className="text-center text-sm text-gray-400">No hay turnos disponibles en los próximos 60 días.</p>
)}
</div>
)}
{/* MODAL de horarios */}
{diaSeleccionado && (
<div className="fixed inset-0 z-50 flex items-end sm:items-center justify-center">
<div className="absolute inset-0 bg-black/40" onClick={() => setDiaSeleccionado(null)} />
<div className="relative bg-white w-full sm:w-96 rounded-t-2xl sm:rounded-2xl shadow-xl p-5 flex flex-col gap-4 max-h-[80vh] overflow-y-auto">
<div className="flex items-center justify-between">
<h2 className="font-semibold text-gray-800">
{new Date(`${diaSeleccionado}T12:00:00`).toLocaleDateString("es-AR", {
weekday: "long", day: "numeric", month: "long"
})}
</h2>
<button onClick={() => setDiaSeleccionado(null)} className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
</div>
{slotsDelDia.length === 0 ? (
<p className="text-sm text-gray-400 text-center py-4">No hay horarios disponibles este día.</p>
) : (
<>
<div className="grid grid-cols-3 gap-2">
{slotsDelDia.map((s, i) => {
const sel = slotSel?.fechaInicio === s.fechaInicio;
return (
<button key={i} onClick={() => setSlotSel(s)}
className={`rounded-xl py-2 text-sm font-medium border transition
${sel ? "bg-blue-600 text-white border-blue-600" : "border-gray-200 text-gray-700 hover:border-blue-400"}`}>
{horaArg(s.fechaInicio)}
</button>
);
})}
</div>
{slotSel && (
<div className="flex flex-col gap-3 border-t border-gray-100 pt-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Kilometraje actual <span className="text-red-500">*</span>
</label>
<input
id="kilometros"
type="number"
min={0}
placeholder="Ej: 85000"
type="number" min={0} placeholder="Ej: 85000"
value={kilometros}
onChange={(e) => setKilometros(e.target.value)}
onChange={e => 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"
/>
</div>
<Button
onClick={reservar}
disabled={!slotSel || !kilometros}
loading={reservando}
className="w-full mt-2"
>
Confirmar turno
<Button onClick={reservar} disabled={!kilometros} loading={reservando} className="w-full">
Confirmar turno · {horaArg(slotSel.fechaInicio)} – {horaArg(slotSel.fechaFin)}
</Button>
</div>
)}
</>
)}
</div>
</div>
)}
</div>
);
+3 -2
View File
@@ -68,7 +68,8 @@ export interface SlotDisponible {
export async function obtenerSlotsDisponibles(
duracionHoras: number,
diasAdelante = 14
diasAdelante = 14,
limite = 300
): Promise<SlotDisponible[]> {
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;