feat: historial muestra todos los turnos del auto con estado, km y comentario
Deploy / deploy (push) Successful in 3m52s

Reemplaza HistorialServicio por Turno en ambas APIs y frontends (cliente y admin).
Muestra: servicio, fecha, horario, estado (badge), km al ingreso, observaciones del jefe.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Beldor
2026-06-22 23:19:53 -03:00
parent 112f4a07bb
commit 4a6023f9f3
4 changed files with 95 additions and 161 deletions
+42 -111
View File
@@ -2,19 +2,19 @@
import { useState } from "react"; import { useState } from "react";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input"; import Badge from "@/components/ui/Badge";
interface Cliente { id: string; nombre: string; apellido: string; email: string; autos: Auto[]; } interface Cliente { id: string; nombre: string; apellido: string; email: string; autos: Auto[]; }
interface Auto { id: string; marca: string; modelo: string; patente: string; anio: number; } interface Auto { id: string; marca: string; modelo: string; patente: string; anio: number; }
interface Registro { interface Turno {
id: string; id: string;
descripcion: string; fechaInicio: string;
observaciones: string | null; fechaFin: string;
fechaRealizado: string; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
admin: { nombre: string }; kilometros: number;
tipoServicio: { nombre: string } | null; comentario: string | null;
tipoServicio: { nombre: string };
} }
interface TipoServicio { id: string; nombre: string; }
export default function AdminHistorialPage() { export default function AdminHistorialPage() {
const [busqueda, setBusqueda] = useState(""); const [busqueda, setBusqueda] = useState("");
@@ -22,11 +22,7 @@ export default function AdminHistorialPage() {
const [buscando, setBuscando] = useState(false); const [buscando, setBuscando] = useState(false);
const [clienteSel, setClienteSel] = useState<Cliente | null>(null); const [clienteSel, setClienteSel] = useState<Cliente | null>(null);
const [autoSel, setAutoSel] = useState<Auto | null>(null); const [autoSel, setAutoSel] = useState<Auto | null>(null);
const [historial, setHistorial] = useState<Registro[]>([]); const [turnos, setTurnos] = useState<Turno[]>([]);
const [servicios, setServicios] = useState<TipoServicio[]>([]);
const [mostrarForm, setMostrarForm] = useState(false);
const [form, setForm] = useState({ descripcion: "", observaciones: "", tipoServicioId: "", fechaRealizado: new Date().toISOString().slice(0, 10) });
const [guardando, setGuardando] = useState(false);
async function buscar(e: React.FormEvent) { async function buscar(e: React.FormEvent) {
e.preventDefault(); e.preventDefault();
@@ -41,36 +37,18 @@ export default function AdminHistorialPage() {
async function seleccionarAuto(auto: Auto) { async function seleccionarAuto(auto: Auto) {
setAutoSel(auto); setAutoSel(auto);
const [hRes, sRes] = await Promise.all([ const res = await fetch(`/api/admin/historial?autoId=${auto.id}`);
fetch(`/api/admin/historial?autoId=${auto.id}`), setTurnos(await res.json());
fetch(`/api/admin/servicios`),
]);
const [h, s] = await Promise.all([hRes.json(), sRes.json()]);
setHistorial(h);
setServicios(s.filter((sv: TipoServicio & { activo: boolean }) => sv.activo));
} }
function set(f: string) { function formatFecha(iso: string) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => return new Date(iso).toLocaleDateString("es-AR", {
setForm((v) => ({ ...v, [f]: e.target.value })); weekday: "short", day: "2-digit", month: "short", year: "numeric",
}
async function guardarRegistro(e: React.FormEvent) {
e.preventDefault();
if (!autoSel) return;
setGuardando(true);
const res = await fetch("/api/admin/historial", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ autoId: autoSel.id, ...form, tipoServicioId: form.tipoServicioId || null }),
}); });
if (res.ok) { }
const nuevo = await res.json();
setHistorial((h) => [nuevo, ...h]); function formatHora(iso: string) {
setMostrarForm(false); return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
setForm({ descripcion: "", observaciones: "", tipoServicioId: "", fechaRealizado: new Date().toISOString().slice(0, 10) });
}
setGuardando(false);
} }
return ( return (
@@ -80,11 +58,11 @@ export default function AdminHistorialPage() {
{/* Buscar cliente */} {/* Buscar cliente */}
<Card title="Buscar cliente"> <Card title="Buscar cliente">
<form onSubmit={buscar} className="flex gap-2"> <form onSubmit={buscar} className="flex gap-2">
<Input <input
placeholder="Nombre, apellido o email..." placeholder="Nombre, apellido o email..."
value={busqueda} value={busqueda}
onChange={(e) => setBusqueda(e.target.value)} onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBusqueda(e.target.value)}
className="flex-1" className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500"
/> />
<Button type="submit" loading={buscando}>Buscar</Button> <Button type="submit" loading={buscando}>Buscar</Button>
</form> </form>
@@ -94,7 +72,7 @@ export default function AdminHistorialPage() {
{clientes.map((c) => ( {clientes.map((c) => (
<button <button
key={c.id} key={c.id}
onClick={() => { setClienteSel(c); setAutoSel(null); setHistorial([]); }} onClick={() => { setClienteSel(c); setAutoSel(null); setTurnos([]); }}
className={`text-left px-3 py-2 rounded-lg border transition text-sm ${clienteSel?.id === c.id ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-blue-300"}`} className={`text-left px-3 py-2 rounded-lg border transition text-sm ${clienteSel?.id === c.id ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-blue-300"}`}
> >
<span className="font-medium">{c.apellido}, {c.nombre}</span> <span className="font-medium">{c.apellido}, {c.nombre}</span>
@@ -129,79 +107,32 @@ export default function AdminHistorialPage() {
{/* Historial del auto */} {/* Historial del auto */}
{autoSel && ( {autoSel && (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3">
<div className="flex items-center justify-between"> <h2 className="text-lg font-semibold text-gray-700">
<h2 className="text-lg font-semibold text-gray-700"> {autoSel.marca} {autoSel.modelo} · <span className="font-mono">{autoSel.patente}</span>
{autoSel.marca} {autoSel.modelo} · <span className="font-mono">{autoSel.patente}</span> </h2>
</h2>
<Button onClick={() => setMostrarForm(true)} className="text-sm">+ Agregar registro</Button>
</div>
{mostrarForm && ( {turnos.length === 0 ? (
<Card title="Nuevo registro"> <Card><p className="text-center text-gray-400 py-4">Sin turnos registrados para este auto.</p></Card>
<form onSubmit={guardarRegistro} className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Servicio (opcional)</label>
<select
value={form.tipoServicioId}
onChange={set("tipoServicioId")}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Sin categoría</option>
{servicios.map((s) => <option key={s.id} value={s.id}>{s.nombre}</option>)}
</select>
</div>
<Input id="fechaRealizado" type="date" label="Fecha realizado" value={form.fechaRealizado} onChange={set("fechaRealizado")} required />
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Descripción *</label>
<textarea
value={form.descripcion}
onChange={set("descripcion")}
required
rows={2}
placeholder="¿Qué se hizo?"
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-sm font-medium text-gray-700">Observaciones</label>
<textarea
value={form.observaciones}
onChange={set("observaciones")}
rows={2}
placeholder="Notas adicionales..."
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<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>
)}
{historial.length === 0 ? (
<Card><p className="text-center text-gray-400 py-4">Sin historial registrado.</p></Card>
) : ( ) : (
historial.map((reg) => ( turnos.map((t) => (
<Card key={reg.id}> <div key={t.id} className="bg-white border border-gray-200 rounded-2xl px-4 py-3 flex flex-col gap-2 shadow-sm">
<div className="flex items-start justify-between gap-2 flex-wrap mb-2"> <div className="flex items-start justify-between gap-2 flex-wrap">
<div> <div>
<p className="font-medium text-gray-800">{reg.descripcion}</p> <p className="font-semibold text-gray-800">{t.tipoServicio.nombre}</p>
{reg.tipoServicio && ( <p className="text-xs text-gray-500 capitalize mt-0.5">{formatFecha(t.fechaInicio)}</p>
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full">{reg.tipoServicio.nombre}</span> <p className="text-xs text-gray-400">{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}</p>
)}
</div> </div>
<p className="text-sm text-gray-400 shrink-0"> <Badge estado={t.estado} />
{new Date(reg.fechaRealizado).toLocaleDateString("es-AR", { day: "2-digit", month: "long", year: "numeric" })}
</p>
</div> </div>
{reg.observaciones && ( <p className="text-xs text-gray-400">🔢 {t.kilometros.toLocaleString("es-AR")} km al ingreso</p>
<p className="text-sm text-gray-500 bg-gray-50 rounded px-3 py-2">{reg.observaciones}</p> {t.comentario && (
<div className="bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
<p className="text-xs text-gray-400 font-medium mb-0.5">Observaciones</p>
<p className="text-sm text-gray-700">{t.comentario}</p>
</div>
)} )}
<p className="text-xs text-gray-400 mt-2">Por: {reg.admin.nombre}</p> </div>
</Card>
)) ))
)} )}
</div> </div>
+4 -4
View File
@@ -10,13 +10,13 @@ export async function GET(req: NextRequest) {
const autoId = searchParams.get("autoId"); const autoId = searchParams.get("autoId");
if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 }); if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 });
const historial = await prisma.historialServicio.findMany({ const turnos = await prisma.turno.findMany({
where: { autoId }, where: { autoId },
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } }, include: { tipoServicio: { select: { nombre: true } } },
orderBy: { fechaRealizado: "desc" }, orderBy: { fechaInicio: "desc" },
}); });
return NextResponse.json(historial); return NextResponse.json(turnos);
} }
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
+4 -4
View File
@@ -14,11 +14,11 @@ export async function GET(req: NextRequest) {
const auto = await prisma.auto.findFirst({ where: { id: autoId, clienteId: sesion.sub } }); const auto = await prisma.auto.findFirst({ where: { id: autoId, clienteId: sesion.sub } });
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 }); if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
const historial = await prisma.historialServicio.findMany({ const turnos = await prisma.turno.findMany({
where: { autoId }, where: { autoId },
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } }, include: { tipoServicio: { select: { nombre: true } } },
orderBy: { fechaRealizado: "desc" }, orderBy: { fechaInicio: "desc" },
}); });
return NextResponse.json(historial); return NextResponse.json(turnos);
} }
+45 -42
View File
@@ -3,21 +3,23 @@ import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation"; import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
interface Registro { interface Turno {
id: string; id: string;
descripcion: string; fechaInicio: string;
observaciones: string | null; fechaFin: string;
fechaRealizado: string; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
admin: { nombre: string }; kilometros: number;
tipoServicio: { nombre: string } | null; comentario: string | null;
tipoServicio: { nombre: string };
} }
function HistorialContent() { function HistorialContent() {
const router = useRouter(); const router = useRouter();
const params = useSearchParams(); const params = useSearchParams();
const autoId = params.get("autoId"); const autoId = params.get("autoId");
const [historial, setHistorial] = useState<Registro[]>([]); const [turnos, setTurnos] = useState<Turno[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
@@ -28,10 +30,20 @@ function HistorialContent() {
return r; return r;
}) })
.then((r) => r.json()) .then((r) => r.json())
.then((data) => { setHistorial(data); setLoading(false); }) .then((data) => { setTurnos(data); setLoading(false); })
.catch(() => setLoading(false)); .catch(() => setLoading(false));
}, [autoId, router]); }, [autoId, router]);
function formatFecha(iso: string) {
return new Date(iso).toLocaleDateString("es-AR", {
weekday: "long", day: "2-digit", month: "long", year: "numeric",
});
}
function formatHora(iso: string) {
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
}
return ( return (
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -43,46 +55,37 @@ function HistorialContent() {
{loading ? ( {loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div> <div className="text-center py-10 text-gray-400">Cargando...</div>
) : historial.length === 0 ? ( ) : turnos.length === 0 ? (
<div className="bg-white border border-gray-200 rounded-2xl p-8 text-center"> <div className="bg-white border border-gray-200 rounded-2xl p-8 text-center">
<p className="text-4xl mb-3">🔧</p> <p className="text-4xl mb-3">🔧</p>
<p className="text-gray-500 font-medium">Sin servicios registrados</p> <p className="text-gray-500 font-medium">Sin turnos registrados</p>
<p className="text-sm text-gray-400 mt-1">El historial aparecerá aquí cuando el taller registre servicios.</p> <p className="text-sm text-gray-400 mt-1">Los turnos aparecerán aquí cuando reservés uno.</p>
</div> </div>
) : ( ) : (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-3">
{historial.map((reg) => { {turnos.map((t) => (
const fecha = new Date(reg.fechaRealizado); <div key={t.id} className="bg-white border border-gray-200 rounded-2xl px-4 py-3 flex flex-col gap-2 shadow-sm">
const dia = fecha.toLocaleDateString("es-AR", { day: "2-digit", month: "long", year: "numeric" }); <div className="flex items-start justify-between gap-2">
const diaSemana = fecha.toLocaleDateString("es-AR", { weekday: "long" }); <div>
return ( <p className="font-semibold text-gray-800">{t.tipoServicio.nombre}</p>
<div key={reg.id} className="bg-white border border-gray-200 rounded-2xl px-4 py-3 flex flex-col gap-2 shadow-sm hover:shadow-md transition"> <p className="text-xs text-gray-500 capitalize mt-0.5">{formatFecha(t.fechaInicio)}</p>
<div className="flex items-start justify-between gap-2"> <p className="text-xs text-gray-400">{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}</p>
<div className="flex items-center gap-2">
<span className="text-lg">🔧</span>
<span className="font-semibold text-gray-800 text-sm sm:text-base">
{reg.tipoServicio?.nombre ?? reg.descripcion}
</span>
</div>
<div className="text-right shrink-0">
<p className="text-gray-400 text-xs capitalize">{diaSemana}</p>
<p className="text-gray-600 text-xs font-medium">{dia}</p>
</div>
</div> </div>
<Badge estado={t.estado} />
{reg.tipoServicio && reg.descripcion !== reg.tipoServicio.nombre && (
<p className="text-sm text-gray-700">{reg.descripcion}</p>
)}
{reg.observaciones && (
<div className="bg-amber-50 border border-amber-100 rounded-lg px-3 py-2">
<p className="text-xs text-amber-700 font-medium mb-0.5">Observaciones</p>
<p className="text-sm text-amber-900">{reg.observaciones}</p>
</div>
)}
<p className="text-xs text-gray-400 text-right">Realizado por: {reg.admin.nombre}</p>
</div> </div>
);
})} <div className="flex gap-4 text-xs text-gray-500">
<span>🔢 {t.kilometros.toLocaleString("es-AR")} km</span>
</div>
{t.comentario && (
<div className="bg-gray-50 border border-gray-100 rounded-lg px-3 py-2">
<p className="text-xs text-gray-400 font-medium mb-0.5">Observaciones del taller</p>
<p className="text-sm text-gray-700">{t.comentario}</p>
</div>
)}
</div>
))}
</div> </div>
)} )}
</div> </div>