62dab5e025
Revisión con IA / ai-review (pull_request) Successful in 2m18s
El cliente no tenia forma de corregir su email ni su telefono despues del alta.
Nueva pantalla /cliente/perfil ("Mis datos", enlazada desde el dashboard) que
muestra nombre, email y telefono, y permite editarlos.
- GET/PATCH /api/cliente/perfil, ambos con la sesion del cliente.
- El telefono usa el mismo campo con el +54 9 fijo que el registro, y se guarda
normalizado en telefonoNorm para que el bot lo siga reconociendo.
- aLocalArgentino() saca el prefijo para mostrar: un cliente dado de alta por
WhatsApp tiene el numero guardado como 5491141984844 y en el formulario tiene
que verse 1141984844.
- Email y telefono se validan unicos contra los demas clientes (409), no contra
uno mismo. Si cambia el email se avisa que con ese va a entrar de ahora en mas,
porque es la identidad del magic link.
- El middleware protege /cliente/perfil como el resto del area del cliente.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
160 lines
6.2 KiB
TypeScript
160 lines
6.2 KiB
TypeScript
"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";
|
|
import Combobox from "@/components/ui/Combobox";
|
|
import { MARCAS, getModelos } from "@/lib/marcas-modelos";
|
|
|
|
interface Auto {
|
|
id: string;
|
|
patente: string;
|
|
marca: string;
|
|
modelo: string;
|
|
anio: number;
|
|
nroChasis?: string | null;
|
|
}
|
|
|
|
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: "", nroChasis: "" });
|
|
const modelosDisponibles = getModelos(form.marca);
|
|
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: "", nroChasis: "" });
|
|
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-white">Mis autos</h1>
|
|
<div className="flex items-center gap-4">
|
|
<Link href="/cliente/perfil" className="text-sm text-gray-200 hover:text-white transition">
|
|
Mis datos
|
|
</Link>
|
|
<button onClick={cerrarSesion} className="text-sm text-gray-200 hover:text-red-300 transition">
|
|
Salir
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="text-center py-10 text-gray-200">Cargando...</div>
|
|
) : (
|
|
<div className="flex flex-col gap-4">
|
|
{autos.map((auto) => (
|
|
<Card key={auto.id} className="hover:shadow-md transition">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between 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}`} className="flex-1 sm:flex-none">
|
|
<Button variant="secondary" className="text-sm w-full sm:w-auto">
|
|
📋 Historial
|
|
</Button>
|
|
</Link>
|
|
<Link href={`/cliente/turnos?autoId=${auto.id}`} className="flex-1 sm:flex-none">
|
|
<Button className="text-sm w-full sm:w-auto">
|
|
📅 Sacar turno
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
))}
|
|
|
|
{autos.length === 0 && !mostrarForm && (
|
|
<p className="text-center text-gray-200 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">
|
|
<Combobox
|
|
id="marca"
|
|
label="Marca"
|
|
options={MARCAS}
|
|
value={form.marca}
|
|
onChange={(v) => setForm((f) => ({ ...f, marca: v, modelo: "" }))}
|
|
onSelect={(v) => setForm((f) => ({ ...f, marca: v, modelo: "" }))}
|
|
placeholder="Ford"
|
|
required
|
|
/>
|
|
<Combobox
|
|
id="modelo"
|
|
label="Modelo"
|
|
options={modelosDisponibles}
|
|
value={form.modelo}
|
|
onChange={(v) => setForm((f) => ({ ...f, modelo: v }))}
|
|
onSelect={(v) => setForm((f) => ({ ...f, modelo: v }))}
|
|
placeholder={form.marca ? "Focus" : "Primero elegí la marca"}
|
|
disabled={modelosDisponibles.length === 0}
|
|
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" />
|
|
<Input id="nroChasis" label="Nº Chasis (opcional)" placeholder="8AFZZZ11ZYM569874" value={form.nroChasis} onChange={set("nroChasis")} className="col-span-2" />
|
|
</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>
|
|
);
|
|
}
|