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
+39 -108
View File
@@ -2,19 +2,19 @@
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 Cliente { id: string; nombre: string; apellido: string; email: string; autos: Auto[]; }
interface Auto { id: string; marca: string; modelo: string; patente: string; anio: number; }
interface Registro {
interface Turno {
id: string;
descripcion: string;
observaciones: string | null;
fechaRealizado: string;
admin: { nombre: string };
tipoServicio: { nombre: string } | null;
fechaInicio: string;
fechaFin: string;
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
kilometros: number;
comentario: string | null;
tipoServicio: { nombre: string };
}
interface TipoServicio { id: string; nombre: string; }
export default function AdminHistorialPage() {
const [busqueda, setBusqueda] = useState("");
@@ -22,11 +22,7 @@ export default function AdminHistorialPage() {
const [buscando, setBuscando] = useState(false);
const [clienteSel, setClienteSel] = useState<Cliente | null>(null);
const [autoSel, setAutoSel] = useState<Auto | null>(null);
const [historial, setHistorial] = useState<Registro[]>([]);
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);
const [turnos, setTurnos] = useState<Turno[]>([]);
async function buscar(e: React.FormEvent) {
e.preventDefault();
@@ -41,36 +37,18 @@ export default function AdminHistorialPage() {
async function seleccionarAuto(auto: Auto) {
setAutoSel(auto);
const [hRes, sRes] = await Promise.all([
fetch(`/api/admin/historial?autoId=${auto.id}`),
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));
const res = await fetch(`/api/admin/historial?autoId=${auto.id}`);
setTurnos(await res.json());
}
function set(f: string) {
return (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) =>
setForm((v) => ({ ...v, [f]: e.target.value }));
}
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 }),
function formatFecha(iso: string) {
return new Date(iso).toLocaleDateString("es-AR", {
weekday: "short", day: "2-digit", month: "short", year: "numeric",
});
if (res.ok) {
const nuevo = await res.json();
setHistorial((h) => [nuevo, ...h]);
setMostrarForm(false);
setForm({ descripcion: "", observaciones: "", tipoServicioId: "", fechaRealizado: new Date().toISOString().slice(0, 10) });
}
setGuardando(false);
function formatHora(iso: string) {
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
}
return (
@@ -80,11 +58,11 @@ export default function AdminHistorialPage() {
{/* Buscar cliente */}
<Card title="Buscar cliente">
<form onSubmit={buscar} className="flex gap-2">
<Input
<input
placeholder="Nombre, apellido o email..."
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
className="flex-1"
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setBusqueda(e.target.value)}
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>
</form>
@@ -94,7 +72,7 @@ export default function AdminHistorialPage() {
{clientes.map((c) => (
<button
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"}`}
>
<span className="font-medium">{c.apellido}, {c.nombre}</span>
@@ -129,79 +107,32 @@ export default function AdminHistorialPage() {
{/* Historial del auto */}
{autoSel && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3">
<h2 className="text-lg font-semibold text-gray-700">
{autoSel.marca} {autoSel.modelo} · <span className="font-mono">{autoSel.patente}</span>
</h2>
<Button onClick={() => setMostrarForm(true)} className="text-sm">+ Agregar registro</Button>
</div>
{mostrarForm && (
<Card title="Nuevo registro">
<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>
{turnos.length === 0 ? (
<Card><p className="text-center text-gray-400 py-4">Sin turnos registrados para este auto.</p></Card>
) : (
historial.map((reg) => (
<Card key={reg.id}>
<div className="flex items-start justify-between gap-2 flex-wrap mb-2">
turnos.map((t) => (
<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">
<div>
<p className="font-medium text-gray-800">{reg.descripcion}</p>
{reg.tipoServicio && (
<span className="text-xs bg-blue-50 text-blue-700 px-2 py-0.5 rounded-full">{reg.tipoServicio.nombre}</span>
<p className="font-semibold text-gray-800">{t.tipoServicio.nombre}</p>
<p className="text-xs text-gray-500 capitalize mt-0.5">{formatFecha(t.fechaInicio)}</p>
<p className="text-xs text-gray-400">{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}</p>
</div>
<Badge estado={t.estado} />
</div>
<p className="text-xs text-gray-400">🔢 {t.kilometros.toLocaleString("es-AR")} km al ingreso</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>
)}
</div>
<p className="text-sm text-gray-400 shrink-0">
{new Date(reg.fechaRealizado).toLocaleDateString("es-AR", { day: "2-digit", month: "long", year: "numeric" })}
</p>
</div>
{reg.observaciones && (
<p className="text-sm text-gray-500 bg-gray-50 rounded px-3 py-2">{reg.observaciones}</p>
)}
<p className="text-xs text-gray-400 mt-2">Por: {reg.admin.nombre}</p>
</Card>
))
)}
</div>
+4 -4
View File
@@ -10,13 +10,13 @@ export async function GET(req: NextRequest) {
const autoId = searchParams.get("autoId");
if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 });
const historial = await prisma.historialServicio.findMany({
const turnos = await prisma.turno.findMany({
where: { autoId },
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
orderBy: { fechaRealizado: "desc" },
include: { tipoServicio: { select: { nombre: true } } },
orderBy: { fechaInicio: "desc" },
});
return NextResponse.json(historial);
return NextResponse.json(turnos);
}
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 } });
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 },
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
orderBy: { fechaRealizado: "desc" },
include: { tipoServicio: { select: { nombre: true } } },
orderBy: { fechaInicio: "desc" },
});
return NextResponse.json(historial);
return NextResponse.json(turnos);
}
+40 -37
View File
@@ -3,21 +3,23 @@ import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
interface Registro {
interface Turno {
id: string;
descripcion: string;
observaciones: string | null;
fechaRealizado: string;
admin: { nombre: string };
tipoServicio: { nombre: string } | null;
fechaInicio: string;
fechaFin: string;
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
kilometros: number;
comentario: string | null;
tipoServicio: { nombre: string };
}
function HistorialContent() {
const router = useRouter();
const params = useSearchParams();
const autoId = params.get("autoId");
const [historial, setHistorial] = useState<Registro[]>([]);
const [turnos, setTurnos] = useState<Turno[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
@@ -28,10 +30,20 @@ function HistorialContent() {
return r;
})
.then((r) => r.json())
.then((data) => { setHistorial(data); setLoading(false); })
.then((data) => { setTurnos(data); setLoading(false); })
.catch(() => setLoading(false));
}, [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 (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-3">
@@ -43,46 +55,37 @@ function HistorialContent() {
{loading ? (
<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">
<p className="text-4xl mb-3">🔧</p>
<p className="text-gray-500 font-medium">Sin servicios registrados</p>
<p className="text-sm text-gray-400 mt-1">El historial aparecerá aquí cuando el taller registre servicios.</p>
<p className="text-gray-500 font-medium">Sin turnos registrados</p>
<p className="text-sm text-gray-400 mt-1">Los turnos aparecerán aquí cuando reservés uno.</p>
</div>
) : (
<div className="flex flex-col gap-4">
{historial.map((reg) => {
const fecha = new Date(reg.fechaRealizado);
const dia = fecha.toLocaleDateString("es-AR", { day: "2-digit", month: "long", year: "numeric" });
const diaSemana = fecha.toLocaleDateString("es-AR", { weekday: "long" });
return (
<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">
<div className="flex flex-col gap-3">
{turnos.map((t) => (
<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">
<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>
<p className="font-semibold text-gray-800">{t.tipoServicio.nombre}</p>
<p className="text-xs text-gray-500 capitalize mt-0.5">{formatFecha(t.fechaInicio)}</p>
<p className="text-xs text-gray-400">{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}</p>
</div>
<Badge estado={t.estado} />
</div>
{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 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>
)}
<p className="text-xs text-gray-400 text-right">Realizado por: {reg.admin.nombre}</p>
</div>
);
})}
))}
</div>
)}
</div>