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,130 @@
|
||||
"use client";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Card from "@/components/ui/Card";
|
||||
import Input from "@/components/ui/Input";
|
||||
|
||||
interface Auto {
|
||||
id: string;
|
||||
patente: string;
|
||||
marca: string;
|
||||
modelo: string;
|
||||
anio: number;
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
const [autos, setAutos] = useState<Auto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [mostrarForm, setMostrarForm] = useState(false);
|
||||
const [form, setForm] = useState({ patente: "", marca: "", modelo: "", anio: "" });
|
||||
const [guardando, setGuardando] = useState(false);
|
||||
const [errorForm, setErrorForm] = useState("");
|
||||
|
||||
useEffect(() => { cargarAutos(); }, []);
|
||||
|
||||
async function cargarAutos() {
|
||||
setLoading(true);
|
||||
const res = await fetch("/api/cliente/autos");
|
||||
if (res.status === 401) { router.push("/cliente"); return; }
|
||||
const data = await res.json();
|
||||
setAutos(data);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
function set(field: string) {
|
||||
return (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [field]: e.target.value }));
|
||||
}
|
||||
|
||||
async function agregarAuto(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setGuardando(true);
|
||||
setErrorForm("");
|
||||
const res = await fetch("/api/cliente/autos", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...form, anio: Number(form.anio) }),
|
||||
});
|
||||
const data = await res.json();
|
||||
setGuardando(false);
|
||||
if (!res.ok) { setErrorForm(data.error || "Error al guardar"); return; }
|
||||
setMostrarForm(false);
|
||||
setForm({ patente: "", marca: "", modelo: "", anio: "" });
|
||||
cargarAutos();
|
||||
}
|
||||
|
||||
async function cerrarSesion() {
|
||||
await fetch("/api/auth/cliente-logout", { method: "POST" });
|
||||
router.push("/cliente");
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Mis autos</h1>
|
||||
<button onClick={cerrarSesion} className="text-sm text-gray-400 hover:text-red-500 transition">
|
||||
Salir
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-10 text-gray-400">Cargando...</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4">
|
||||
{autos.map((auto) => (
|
||||
<Card key={auto.id} className="hover:shadow-md transition">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<p className="font-semibold text-gray-800">
|
||||
{auto.marca} {auto.modelo} <span className="text-gray-500">({auto.anio})</span>
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 font-mono mt-0.5">{auto.patente}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Link href={`/cliente/historial?autoId=${auto.id}`}>
|
||||
<Button variant="secondary" className="text-sm">
|
||||
📋 Historial
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href={`/cliente/turnos?autoId=${auto.id}`}>
|
||||
<Button className="text-sm">
|
||||
📅 Sacar turno
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
{autos.length === 0 && !mostrarForm && (
|
||||
<p className="text-center text-gray-400 py-4">No tenés autos registrados todavía.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mostrarForm ? (
|
||||
<Card title="Agregar auto">
|
||||
<form onSubmit={agregarAuto} className="flex flex-col gap-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input id="marca" label="Marca" placeholder="Ford" value={form.marca} onChange={set("marca")} required />
|
||||
<Input id="modelo" label="Modelo" placeholder="Focus" value={form.modelo} onChange={set("modelo")} required />
|
||||
<Input id="anio" label="Año" type="number" placeholder="2018" value={form.anio} onChange={set("anio")} required min={1960} max={new Date().getFullYear() + 1} />
|
||||
<Input id="patente" label="Patente" placeholder="AB123CD" value={form.patente} onChange={set("patente")} required className="uppercase" />
|
||||
</div>
|
||||
{errorForm && <p className="text-sm text-red-500">{errorForm}</p>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="secondary" type="button" onClick={() => setMostrarForm(false)}>Cancelar</Button>
|
||||
<Button type="submit" loading={guardando}>Guardar</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
) : (
|
||||
<Button variant="secondary" onClick={() => setMostrarForm(true)} className="self-start">
|
||||
+ Agregar auto
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user