feat: add full frontend — client portal and admin panel
Páginas cliente: login magic link, registro, validación de token, dashboard de autos, historial por auto y reserva de turnos con selector de servicio + slots disponibles. Páginas admin: login, dashboard de turnos del día con cambio de estado, historial de autos (buscar cliente → auto → ver/cargar registros), gestión de tipos de servicio y vista de clientes. Middleware JWT para proteger rutas /cliente/* y /admin/*. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,206 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Card from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
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;
|
||||
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
|
||||
tipoServicio: { nombre: string };
|
||||
auto: { marca: string; modelo: string; patente: string };
|
||||
}
|
||||
|
||||
type Step = "servicio" | "slot" | "confirmado";
|
||||
|
||||
export default function TurnosPage() {
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const autoId = params.get("autoId");
|
||||
|
||||
const [step, setStep] = useState<Step>("servicio");
|
||||
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 [reservando, setReservando] = useState(false);
|
||||
const [turnoCreado, setTurnoCreado] = useState<Turno | null>(null);
|
||||
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
|
||||
const [loadingTurnos, setLoadingTurnos] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoId) { router.push("/cliente/dashboard"); return; }
|
||||
fetch("/api/admin/servicios")
|
||||
.then((r) => r.json())
|
||||
.then((data) => setServicios(data.filter((s: TipoServicio & { activo: boolean }) => s.activo)));
|
||||
fetch("/api/cliente/turnos")
|
||||
.then((r) => r.json())
|
||||
.then((data) => { setMisTurnos(data.filter((t: Turno & { autoId: string }) => t.autoId === autoId)); setLoadingTurnos(false); });
|
||||
}, [autoId, router]);
|
||||
|
||||
async function seleccionarServicio(s: TipoServicio) {
|
||||
setServicioSel(s);
|
||||
setLoadingSlots(true);
|
||||
setStep("slot");
|
||||
const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}`);
|
||||
const data = await res.json();
|
||||
setSlots(data);
|
||||
setLoadingSlots(false);
|
||||
}
|
||||
|
||||
async function reservar() {
|
||||
if (!slotSel || !servicioSel || !autoId) return;
|
||||
setReservando(true);
|
||||
const res = await fetch("/api/cliente/turnos", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ autoId, tipoServicioId: servicioSel.id, fechaInicio: slotSel.fechaInicio }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setReservando(false);
|
||||
if (res.ok) {
|
||||
setTurnoCreado(data);
|
||||
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}` };
|
||||
}
|
||||
|
||||
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>
|
||||
<h1 className="text-2xl font-bold text-gray-800">¡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>
|
||||
<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>
|
||||
</Card>
|
||||
<Link href="/cliente/dashboard">
|
||||
<Button variant="secondary">← Volver a mis autos</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{step === "slot" ? (
|
||||
<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>
|
||||
</div>
|
||||
|
||||
{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"
|
||||
>
|
||||
<div>
|
||||
<p className="font-medium text-gray-800">{s.nombre}</p>
|
||||
<p className="text-sm text-gray-500 mt-0.5">
|
||||
Duración: {s.duracionHoras === 1 ? "1 hora" : `${s.duracionHoras} horas`}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-gray-400 text-lg">→</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!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 (
|
||||
<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>
|
||||
</div>
|
||||
<Badge estado={t.estado} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "slot" && (
|
||||
<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;
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<p className="font-medium text-gray-800">{dia}</p>
|
||||
<p className="text-sm text-gray-500">{horas}</p>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
onClick={reservar}
|
||||
disabled={!slotSel}
|
||||
loading={reservando}
|
||||
className="w-full mt-2"
|
||||
>
|
||||
Confirmar turno
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user