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,118 @@
|
||||
"use client";
|
||||
import { useState } from "react";
|
||||
import Card from "@/components/ui/Card";
|
||||
import Button from "@/components/ui/Button";
|
||||
import Input from "@/components/ui/Input";
|
||||
import Badge from "@/components/ui/Badge";
|
||||
|
||||
interface Auto { id: string; marca: string; modelo: string; patente: string; anio: number; }
|
||||
interface Cliente { id: string; nombre: string; apellido: string; email: string; telefono: string; autos: Auto[]; }
|
||||
interface Turno {
|
||||
id: string;
|
||||
fechaInicio: string;
|
||||
fechaFin: string;
|
||||
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
|
||||
tipoServicio: { nombre: string };
|
||||
auto: { patente: string };
|
||||
}
|
||||
|
||||
export default function ClientesPage() {
|
||||
const [busqueda, setBusqueda] = useState("");
|
||||
const [clientes, setClientes] = useState<Cliente[]>([]);
|
||||
const [buscando, setBuscando] = useState(false);
|
||||
const [clienteSel, setClienteSel] = useState<Cliente | null>(null);
|
||||
const [turnos, setTurnos] = useState<Turno[]>([]);
|
||||
|
||||
async function buscar(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setBuscando(true);
|
||||
const res = await fetch(`/api/admin/clientes?q=${encodeURIComponent(busqueda)}`);
|
||||
setClientes(await res.json());
|
||||
setBuscando(false);
|
||||
setClienteSel(null);
|
||||
}
|
||||
|
||||
async function verCliente(c: Cliente) {
|
||||
setClienteSel(c);
|
||||
const res = await fetch(`/api/admin/turnos?clienteId=${c.id}`);
|
||||
if (res.ok) setTurnos(await res.json());
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<h1 className="text-2xl font-bold text-gray-800">Clientes</h1>
|
||||
|
||||
<Card title="Buscar cliente">
|
||||
<form onSubmit={buscar} className="flex gap-2">
|
||||
<Input
|
||||
placeholder="Nombre, apellido o email..."
|
||||
value={busqueda}
|
||||
onChange={(e) => setBusqueda(e.target.value)}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button type="submit" loading={buscando}>Buscar</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{clientes.length > 0 && (
|
||||
<div className="flex flex-col gap-3">
|
||||
{clientes.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => verCliente(c)}
|
||||
className={`text-left bg-white border rounded-xl px-4 py-3 transition hover:shadow-sm ${clienteSel?.id === c.id ? "border-blue-500" : "border-gray-200"}`}
|
||||
>
|
||||
<p className="font-medium text-gray-800">{c.apellido}, {c.nombre}</p>
|
||||
<p className="text-sm text-gray-500">{c.email} · {c.telefono}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">
|
||||
{c.autos.length === 0 ? "Sin autos" : c.autos.map((a) => `${a.marca} ${a.modelo} (${a.patente})`).join(" · ")}
|
||||
</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{clienteSel && (
|
||||
<Card title={`${clienteSel.apellido}, ${clienteSel.nombre}`}>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm mb-4">
|
||||
<span className="text-gray-400">Email</span><span>{clienteSel.email}</span>
|
||||
<span className="text-gray-400">Teléfono</span><span>{clienteSel.telefono}</span>
|
||||
</div>
|
||||
|
||||
{clienteSel.autos.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Autos registrados</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{clienteSel.autos.map((a) => (
|
||||
<span key={a.id} className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded-full">
|
||||
{a.marca} {a.modelo} · <span className="font-mono">{a.patente}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{turnos.length > 0 && (
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-700 mb-2">Últimos turnos</p>
|
||||
<div className="flex flex-col gap-2">
|
||||
{turnos.slice(0, 5).map((t) => (
|
||||
<div key={t.id} className="flex items-center justify-between gap-2 text-sm">
|
||||
<div>
|
||||
<span className="text-gray-700">{t.tipoServicio.nombre}</span>
|
||||
<span className="text-gray-400 ml-2">
|
||||
{new Date(t.fechaInicio).toLocaleDateString("es-AR")} ·{" "}
|
||||
<span className="font-mono">{t.auto.patente}</span>
|
||||
</span>
|
||||
</div>
|
||||
<Badge estado={t.estado} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user