feat: agrega km y chasis al turno/auto, combobox de marcas y modelos
Deploy / deploy (push) Failing after 13m41s

- Turno.kilometros (requerido al reservar)
- Auto.nroChasis (opcional)
- Combobox con 60 marcas mundiales y modelos precargados
- Modelo se habilita al seleccionar marca

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Beldor
2026-06-22 16:01:19 -03:00
parent b6705239a1
commit 4d259558c6
7 changed files with 198 additions and 11 deletions
+2
View File
@@ -26,6 +26,7 @@ model Auto {
marca String marca String
modelo String modelo String
anio Int anio Int
nroChasis String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
cliente Cliente @relation(fields: [clienteId], references: [id]) cliente Cliente @relation(fields: [clienteId], references: [id])
@@ -50,6 +51,7 @@ model Turno {
tipoServicioId String tipoServicioId String
fechaInicio DateTime fechaInicio DateTime
fechaFin DateTime fechaFin DateTime
kilometros Int
estado EstadoTurno @default(PENDIENTE) estado EstadoTurno @default(PENDIENTE)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
+2 -1
View File
@@ -18,7 +18,7 @@ export async function POST(req: NextRequest) {
const sesion = await verificarSesion("cliente_session"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { patente, marca, modelo, anio } = await req.json(); const { patente, marca, modelo, anio, nroChasis } = await req.json();
if (!patente || !marca || !modelo || !anio) if (!patente || !marca || !modelo || !anio)
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 }); return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 });
@@ -32,6 +32,7 @@ export async function POST(req: NextRequest) {
marca, marca,
modelo, modelo,
anio: Number(anio), anio: Number(anio),
nroChasis: nroChasis?.trim() || null,
}, },
}); });
+3 -3
View File
@@ -20,8 +20,8 @@ export async function POST(req: NextRequest) {
const sesion = await verificarSesion("cliente_session"); const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { autoId, tipoServicioId, fechaInicio } = await req.json(); const { autoId, tipoServicioId, fechaInicio, kilometros } = await req.json();
if (!autoId || !tipoServicioId || !fechaInicio) if (!autoId || !tipoServicioId || !fechaInicio || !kilometros)
return NextResponse.json({ error: "Datos incompletos" }, { status: 400 }); return NextResponse.json({ error: "Datos incompletos" }, { status: 400 });
// Verificar que el auto pertenece al cliente // Verificar que el auto pertenece al cliente
@@ -42,7 +42,7 @@ export async function POST(req: NextRequest) {
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 }); return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
const turno = await prisma.turno.create({ const turno = await prisma.turno.create({
data: { clienteId: sesion.sub, autoId, tipoServicioId, fechaInicio: inicio, fechaFin: fin }, data: { clienteId: sesion.sub, autoId, tipoServicioId, fechaInicio: inicio, fechaFin: fin, kilometros: Number(kilometros) },
include: { auto: true, tipoServicio: true }, include: { auto: true, tipoServicio: true },
}); });
+28 -4
View File
@@ -5,6 +5,8 @@ import Link from "next/link";
import Button from "@/components/ui/Button"; import Button from "@/components/ui/Button";
import Card from "@/components/ui/Card"; import Card from "@/components/ui/Card";
import Input from "@/components/ui/Input"; import Input from "@/components/ui/Input";
import Combobox from "@/components/ui/Combobox";
import { MARCAS, getModelos } from "@/lib/marcas-modelos";
interface Auto { interface Auto {
id: string; id: string;
@@ -12,6 +14,7 @@ interface Auto {
marca: string; marca: string;
modelo: string; modelo: string;
anio: number; anio: number;
nroChasis?: string | null;
} }
export default function DashboardPage() { export default function DashboardPage() {
@@ -19,7 +22,8 @@ export default function DashboardPage() {
const [autos, setAutos] = useState<Auto[]>([]); const [autos, setAutos] = useState<Auto[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [mostrarForm, setMostrarForm] = useState(false); const [mostrarForm, setMostrarForm] = useState(false);
const [form, setForm] = useState({ patente: "", marca: "", modelo: "", anio: "" }); const [form, setForm] = useState({ patente: "", marca: "", modelo: "", anio: "", nroChasis: "" });
const modelosDisponibles = getModelos(form.marca);
const [guardando, setGuardando] = useState(false); const [guardando, setGuardando] = useState(false);
const [errorForm, setErrorForm] = useState(""); const [errorForm, setErrorForm] = useState("");
@@ -51,7 +55,7 @@ export default function DashboardPage() {
setGuardando(false); setGuardando(false);
if (!res.ok) { setErrorForm(data.error || "Error al guardar"); return; } if (!res.ok) { setErrorForm(data.error || "Error al guardar"); return; }
setMostrarForm(false); setMostrarForm(false);
setForm({ patente: "", marca: "", modelo: "", anio: "" }); setForm({ patente: "", marca: "", modelo: "", anio: "", nroChasis: "" });
cargarAutos(); cargarAutos();
} }
@@ -108,10 +112,30 @@ export default function DashboardPage() {
<Card title="Agregar auto"> <Card title="Agregar auto">
<form onSubmit={agregarAuto} className="flex flex-col gap-4"> <form onSubmit={agregarAuto} className="flex flex-col gap-4">
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<Input id="marca" label="Marca" placeholder="Ford" value={form.marca} onChange={set("marca")} required /> <Combobox
<Input id="modelo" label="Modelo" placeholder="Focus" value={form.modelo} onChange={set("modelo")} required /> 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="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="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> </div>
{errorForm && <p className="text-sm text-red-500">{errorForm}</p>} {errorForm && <p className="text-sm text-red-500">{errorForm}</p>}
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
+18 -3
View File
@@ -30,6 +30,7 @@ function TurnosContent() {
const [slots, setSlots] = useState<Slot[]>([]); const [slots, setSlots] = useState<Slot[]>([]);
const [slotSel, setSlotSel] = useState<Slot | null>(null); const [slotSel, setSlotSel] = useState<Slot | null>(null);
const [loadingSlots, setLoadingSlots] = useState(false); const [loadingSlots, setLoadingSlots] = useState(false);
const [kilometros, setKilometros] = useState("");
const [reservando, setReservando] = useState(false); const [reservando, setReservando] = useState(false);
const [turnoCreado, setTurnoCreado] = useState<Turno | null>(null); const [turnoCreado, setTurnoCreado] = useState<Turno | null>(null);
const [misTurnos, setMisTurnos] = useState<Turno[]>([]); const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
@@ -56,12 +57,12 @@ function TurnosContent() {
} }
async function reservar() { async function reservar() {
if (!slotSel || !servicioSel || !autoId) return; if (!slotSel || !servicioSel || !autoId || !kilometros) return;
setReservando(true); setReservando(true);
const res = await fetch("/api/cliente/turnos", { const res = await fetch("/api/cliente/turnos", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ autoId, tipoServicioId: servicioSel.id, fechaInicio: slotSel.fechaInicio }), body: JSON.stringify({ autoId, tipoServicioId: servicioSel.id, fechaInicio: slotSel.fechaInicio, kilometros: Number(kilometros) }),
}); });
const data = await res.json(); const data = await res.json();
setReservando(false); setReservando(false);
@@ -189,9 +190,23 @@ function TurnosContent() {
); );
})} })}
</div> </div>
<div className="mt-2">
<label htmlFor="kilometros" className="block text-sm font-medium text-gray-700 mb-1">
Kilometraje actual <span className="text-red-500">*</span>
</label>
<input
id="kilometros"
type="number"
min={0}
placeholder="Ej: 85000"
value={kilometros}
onChange={(e) => setKilometros(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<Button <Button
onClick={reservar} onClick={reservar}
disabled={!slotSel} disabled={!slotSel || !kilometros}
loading={reservando} loading={reservando}
className="w-full mt-2" className="w-full mt-2"
> >
+76
View File
@@ -0,0 +1,76 @@
"use client";
import { useState, useRef, useEffect } from "react";
interface ComboboxProps {
id?: string;
label?: string;
options: string[];
value: string;
onChange: (value: string) => void;
onSelect?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
required?: boolean;
className?: string;
}
export default function Combobox({
id, label, options, value, onChange, onSelect,
placeholder, disabled, required, className,
}: ComboboxProps) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const filtered = value
? options.filter((o) => o.toLowerCase().includes(value.toLowerCase())).slice(0, 60)
: options.slice(0, 60);
useEffect(() => {
function handleClick(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
}
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, []);
function handleSelect(option: string) {
onChange(option);
onSelect?.(option);
setOpen(false);
}
return (
<div className={`relative ${className ?? ""}`} ref={ref}>
{label && (
<label htmlFor={id} className="block text-sm font-medium text-gray-700 mb-1">
{label} {required && <span className="text-red-500">*</span>}
</label>
)}
<input
id={id}
type="text"
value={value}
onChange={(e) => { onChange(e.target.value); setOpen(true); }}
onFocus={() => setOpen(true)}
placeholder={placeholder}
disabled={disabled}
required={required}
autoComplete="off"
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100 disabled:text-gray-400 disabled:cursor-not-allowed"
/>
{open && !disabled && filtered.length > 0 && (
<ul className="absolute z-50 w-full mt-1 bg-white border border-gray-200 rounded-lg shadow-lg max-h-52 overflow-y-auto">
{filtered.map((option) => (
<li
key={option}
onMouseDown={() => handleSelect(option)}
className="px-3 py-2 text-sm cursor-pointer hover:bg-blue-50 hover:text-blue-700"
>
{option}
</li>
))}
</ul>
)}
</div>
);
}
+69
View File
@@ -0,0 +1,69 @@
export const MARCAS_MODELOS: Record<string, string[]> = {
"Acura": ["ILX", "Integra", "MDX", "RDX", "TLX"],
"Alfa Romeo": ["147", "156", "159", "Brera", "Giulia", "Giulietta", "GT", "MiTo", "Spider", "Stelvio", "Tonale"],
"Aston Martin": ["DB11", "DB9", "DBX", "DBS", "Rapide", "Vantage", "Virage"],
"Audi": ["A1", "A2", "A3", "A4", "A5", "A6", "A7", "A8", "e-tron", "e-tron GT", "Q2", "Q3", "Q4 e-tron", "Q5", "Q7", "Q8", "R8", "RS3", "RS4", "RS5", "RS6", "RS7", "S3", "S4", "S5", "S6", "S7", "S8", "SQ5", "SQ7", "SQ8", "TT", "TT RS", "TTS"],
"Bentley": ["Bentayga", "Continental GT", "Continental GTC", "Flying Spur", "Mulsanne"],
"BMW": ["116i", "118i", "120i", "218i", "220i", "316i", "318i", "320i", "325i", "330i", "335i", "418i", "420i", "430i", "440i", "520i", "525i", "530i", "540i", "545i", "550i", "630i", "740i", "750i", "M2", "M3", "M4", "M5", "M8", "X1", "X2", "X3", "X4", "X5", "X6", "X7", "Z4"],
"BYD": ["Atto 3", "Dolphin", "Han", "Seal", "Song", "Song Plus", "Tang", "Yuan Plus"],
"Buick": ["Enclave", "Encore", "Encore GX", "Envision", "LaCrosse", "Regal"],
"Cadillac": ["ATS", "CT4", "CT5", "CTS", "Escalade", "SRX", "XT4", "XT5", "XT6"],
"Chery": ["Arrizo 5", "Arrizo 5 Pro", "Arrizo 6", "Arrizo 6 Pro", "Tiggo 2", "Tiggo 2 Pro", "Tiggo 3", "Tiggo 3X", "Tiggo 4", "Tiggo 4 Pro", "Tiggo 5X", "Tiggo 7", "Tiggo 7 Pro", "Tiggo 8", "Tiggo 8 Pro"],
"Chevrolet": ["Agile", "Blazer", "Bolt", "Camaro", "Captiva", "Colorado", "Corvette", "Cruze", "Equinox", "Montana", "Onix", "Onix Plus", "S10", "Silverado", "Spin", "Tahoe", "Tracker", "Trailblazer", "Traverse", "Trax"],
"Chrysler": ["300", "300C", "Pacifica", "Sebring", "Voyager"],
"Citroën": ["Berlingo", "C-Elysée", "C1", "C2", "C3", "C3 Aircross", "C4", "C4 Cactus", "C5", "C5 Aircross", "C5 X", "C6", "C8", "DS3", "DS4", "DS5", "Jumper", "Jumpy", "Nemo", "SpaceTourer", "Xantia", "Xsara"],
"Dacia": ["Duster", "Jogger", "Logan", "Sandero", "Spring"],
"Dodge": ["Challenger", "Charger", "Dart", "Durango", "Journey", "RAM 1000", "RAM 1500", "RAM 2500", "Viper"],
"DS": ["DS 3", "DS 3 Crossback", "DS 4", "DS 5", "DS 7", "DS 7 Crossback", "DS 9"],
"Ferrari": ["296 GTB", "296 GTS", "812 Competizione", "812 Superfast", "F8 Spider", "F8 Tributo", "GTC4Lusso", "Portofino", "Portofino M", "Purosangue", "Roma", "SF90 Spider", "SF90 Stradale"],
"Fiat": ["124 Spider", "500", "500L", "500X", "Argo", "Bravo", "Cronos", "Doblo", "Ducato", "Fiorino", "Linea", "Mobi", "Palio", "Punto", "Qubo", "Siena", "Strada", "Toro", "Tipo", "Uno"],
"Ford": ["Bronco", "Bronco Sport", "C-Max", "EcoSport", "Edge", "Escape", "Expedition", "Explorer", "F-150", "F-150 Lightning", "Fiesta", "Focus", "Fusion", "Galaxy", "Ka", "Ka+", "Kuga", "Maverick", "Mondeo", "Mustang", "Mustang Mach-E", "Puma", "Ranger", "S-Max", "Territory", "Transit", "Transit Connect", "Transit Custom"],
"Geely": ["Atlas", "Coolray", "Emgrand", "Monjaro", "Okavango", "Preface", "Tugella"],
"Genesis": ["G70", "G80", "G90", "GV70", "GV80"],
"GMC": ["Acadia", "Canyon", "Envoy", "Sierra", "Terrain", "Yukon"],
"Great Wall": ["Cannon", "Haval H1", "Haval H2", "Haval H4", "Haval H6", "Haval H9", "Haval Jolion", "Ora Funky Cat", "Poer", "Wingle"],
"Haval": ["F7", "H1", "H2", "H4", "H6", "H9", "Jolion"],
"Honda": ["Accord", "City", "Civic", "CR-V", "CR-Z", "Element", "Fit", "HR-V", "Insight", "Jazz", "Legend", "Odyssey", "Passport", "Pilot", "Ridgeline", "WR-V"],
"Hummer": ["EV", "H1", "H2", "H3"],
"Hyundai": ["Accent", "Azera", "Creta", "Elantra", "Getz", "HB20", "i10", "i20", "i30", "i40", "Ioniq", "Ioniq 5", "Ioniq 6", "ix35", "Kona", "Santa Cruz", "Santa Fe", "Sonata", "Terracan", "Tucson", "Veloster", "Venue"],
"Infiniti": ["Q30", "Q40", "Q50", "Q60", "Q70", "QX30", "QX50", "QX55", "QX60", "QX70", "QX80"],
"JAC": ["E10X", "J7", "JS4", "S2", "S3", "S4", "S7", "T6", "T8"],
"Jaguar": ["E-Pace", "F-Pace", "F-Type", "I-Pace", "XE", "XF", "XJ"],
"Jeep": ["Avenger", "Cherokee", "Commander", "Compass", "Gladiator", "Grand Cherokee", "Grand Cherokee 4xe", "Renegade", "Wagoneer", "Wrangler"],
"Kia": ["Carnival", "Cerato", "Ceed", "EV6", "Picanto", "Rio", "Seltos", "Sonet", "Sorento", "Soul", "Sportage", "Stinger", "Stonic", "Telluride"],
"Lamborghini": ["Aventador", "Huracán", "Revuelto", "Urus"],
"Land Rover": ["Defender", "Discovery", "Discovery Sport", "Freelander", "Range Rover", "Range Rover Evoque", "Range Rover Sport", "Range Rover Velar"],
"Lexus": ["CT", "ES", "GS", "GX", "IS", "LC", "LS", "LX", "NX", "RC", "RX", "UX"],
"Lifan": ["530", "620", "720", "820", "X50", "X60", "X70", "X80"],
"Lincoln": ["Aviator", "Corsair", "MKC", "MKT", "MKX", "MKZ", "Nautilus", "Navigator"],
"Lotus": ["Eletre", "Emira", "Evija"],
"Maserati": ["Ghibli", "GranCabrio", "GranTurismo", "Grecale", "Levante", "MC20", "Quattroporte"],
"Mazda": ["2", "3", "6", "BT-50", "CX-3", "CX-30", "CX-5", "CX-50", "CX-60", "CX-9", "MX-30", "MX-5", "RX-8"],
"McLaren": ["570S", "600LT", "650S", "720S", "750S", "Artura", "GT"],
"Mercedes-Benz": ["A 180", "A 200", "A 250", "AMG GT", "B 200", "C 180", "C 200", "C 300", "CLA 200", "CLA 250", "CLC 200", "CLE 200", "CLK 200", "CLS 350", "E 200", "E 250", "E 300", "E 350", "G 500", "G 63 AMG", "GLA 200", "GLA 250", "GLB 200", "GLC 200", "GLC 300", "GLE 350", "GLS 400", "ML 350", "S 400", "S 500", "Sprinter", "Vito"],
"MG": ["EZS", "GS", "HS", "MG3", "MG5", "RX5", "ZS", "ZS EV"],
"Mini": ["Clubman", "Convertible", "Cooper", "Cooper S", "Countryman", "JCW", "Paceman"],
"Mitsubishi": ["ASX", "Eclipse Cross", "Galant", "L200", "Lancer", "Montero", "Outlander", "Pajero", "Pajero Sport"],
"Nissan": ["370Z", "Frontier", "GT-R", "Juke", "Kicks", "Leaf", "March", "Micra", "Murano", "Note", "Pathfinder", "Qashqai", "Sentra", "Tiida", "Versa", "X-Trail"],
"Opel": ["Astra", "Corsa", "Crossland", "Grandland", "Insignia", "Meriva", "Mokka", "Vectra", "Vivaro", "Zafira"],
"Peugeot": ["1007", "107", "108", "2008", "206", "207", "208", "3008", "301", "307", "308", "408", "5008", "508", "Boxer", "Expert", "Partner", "Rifter"],
"Porsche": ["718 Boxster", "718 Cayman", "911", "Cayenne", "Macan", "Panamera", "Taycan"],
"RAM": ["700", "1000", "1500", "2500", "3500", "Promaster"],
"Renault": ["Alaskan", "Arkana", "Austral", "Captur", "Clio", "Duster", "Fluence", "Kadjar", "Kangoo", "Koleos", "Laguna", "Logan", "Master", "Megane", "Rafale", "Sandero", "Scenic", "Symbol", "Trafic", "Zoe"],
"Rolls-Royce": ["Cullinan", "Dawn", "Ghost", "Phantom", "Silver Shadow", "Spectre", "Wraith"],
"SEAT": ["Arona", "Ateca", "Ibiza", "León", "Tarraco"],
"Škoda": ["Fabia", "Kamiq", "Karoq", "Kodiaq", "Octavia", "Scala", "Superb"],
"Smart": ["EQ fortwo", "EQ forfour", "forfour", "fortwo"],
"Subaru": ["Ascent", "BRZ", "Crosstrek", "Forester", "Impreza", "Legacy", "Outback", "Solterra", "WRX", "XV"],
"Suzuki": ["Baleno", "Celerio", "Dzire", "Ertiga", "Grand Vitara", "Ignis", "Jimny", "S-Cross", "Splash", "SX4", "Swift", "Vitara"],
"Tesla": ["Cybertruck", "Model 3", "Model S", "Model X", "Model Y", "Roadster"],
"Toyota": ["4Runner", "86", "Aqua", "Avalon", "C-HR", "Camry", "Corolla", "Corolla Cross", "Crown", "Etios", "Fortuner", "GR86", "Hilux", "Land Cruiser", "Land Cruiser Prado", "Prius", "RAV4", "Rush", "Sequoia", "SW4", "Tacoma", "Tundra", "Vios", "Yaris"],
"Volkswagen": ["Amarok", "Bora", "Caddy", "Crafter", "Fox", "Gol", "Golf", "Jetta", "Nivus", "Passat", "Polo", "Saveiro", "Suran", "T-Cross", "T-Roc", "Taigo", "Taos", "Tiguan", "Touareg", "Touran", "Transporter", "Up!", "Vento", "Virtus"],
"Volvo": ["C30", "C40", "S40", "S60", "S80", "S90", "V40", "V60", "V90", "XC40", "XC60", "XC70", "XC90"],
};
export const MARCAS = Object.keys(MARCAS_MODELOS).sort((a, b) => a.localeCompare(b));
export function getModelos(marca: string): string[] {
return MARCAS_MODELOS[marca] ?? [];
}