feat: historial muestra todos los turnos del auto con estado, km y comentario
Deploy / deploy (push) Successful in 3m52s
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:
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user