feat: add full frontend — client portal and admin panel

Páginas cliente: login magic link, registro, validación de token,
dashboard de autos, historial por auto y reserva de turnos con selector
de servicio + slots disponibles.

Páginas admin: login, dashboard de turnos del día con cambio de estado,
historial de autos (buscar cliente → auto → ver/cargar registros),
gestión de tipos de servicio y vista de clientes.

Middleware JWT para proteger rutas /cliente/* y /admin/*.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Beldor
2026-06-18 12:01:31 -03:00
parent 93ad1cb8c1
commit 4cf35f9280
24 changed files with 1687 additions and 15 deletions
+188 -2
View File
@@ -8,6 +8,7 @@
"name": "taller-turnos",
"version": "0.1.0",
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"jose": "^6.2.3",
@@ -1042,6 +1043,18 @@
"node": ">=14"
}
},
"node_modules/@prisma/adapter-pg": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/adapter-pg/-/adapter-pg-7.8.0.tgz",
"integrity": "sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/driver-adapter-utils": "7.8.0",
"@types/pg": "^8.16.0",
"pg": "^8.16.3",
"postgres-array": "3.0.4"
}
},
"node_modules/@prisma/client": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.8.0.tgz",
@@ -1115,6 +1128,15 @@
"zeptomatch": "2.1.0"
}
},
"node_modules/@prisma/driver-adapter-utils": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/driver-adapter-utils/-/driver-adapter-utils-7.8.0.tgz",
"integrity": "sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==",
"license": "Apache-2.0",
"dependencies": {
"@prisma/debug": "7.8.0"
}
},
"node_modules/@prisma/engines": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz",
@@ -1475,12 +1497,22 @@
"version": "20.19.43",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
"integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/pg": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/@types/pg/-/pg-8.20.0.tgz",
"integrity": "sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==",
"license": "MIT",
"dependencies": {
"@types/node": "*",
"pg-protocol": "*",
"pg-types": "^2.2.0"
}
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
@@ -5904,6 +5936,104 @@
"integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==",
"license": "MIT"
},
"node_modules/pg": {
"version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
"integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==",
"license": "MIT",
"dependencies": {
"pg-connection-string": "^2.13.0",
"pg-pool": "^3.14.0",
"pg-protocol": "^1.14.0",
"pg-types": "2.2.0",
"pgpass": "1.0.5"
},
"engines": {
"node": ">= 16.0.0"
},
"optionalDependencies": {
"pg-cloudflare": "^1.4.0"
},
"peerDependencies": {
"pg-native": ">=3.0.1"
},
"peerDependenciesMeta": {
"pg-native": {
"optional": true
}
}
},
"node_modules/pg-cloudflare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz",
"integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==",
"license": "MIT",
"optional": true
},
"node_modules/pg-connection-string": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz",
"integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==",
"license": "MIT"
},
"node_modules/pg-int8": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
"license": "ISC",
"engines": {
"node": ">=4.0.0"
}
},
"node_modules/pg-pool": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
"integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
"license": "MIT",
"peerDependencies": {
"pg": ">=8.0"
}
},
"node_modules/pg-protocol": {
"version": "1.14.0",
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz",
"integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==",
"license": "MIT"
},
"node_modules/pg-types": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
"license": "MIT",
"dependencies": {
"pg-int8": "1.0.1",
"postgres-array": "~2.0.0",
"postgres-bytea": "~1.0.0",
"postgres-date": "~1.0.4",
"postgres-interval": "^1.1.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/pg-types/node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/pgpass": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
"license": "MIT",
"dependencies": {
"split2": "^4.1.0"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6168,6 +6298,45 @@
"url": "https://github.com/sponsors/porsager"
}
},
"node_modules/postgres-array": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-3.0.4.tgz",
"integrity": "sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/postgres-bytea": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
"integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-date": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/postgres-interval": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
"license": "MIT",
"dependencies": {
"xtend": "^4.0.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/preact": {
"version": "10.11.3",
"resolved": "https://registry.npmjs.org/preact/-/preact-10.11.3.tgz",
@@ -6864,6 +7033,15 @@
"node": ">=0.10.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/sqlstring": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz",
@@ -7574,7 +7752,6 @@
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/unrs-resolver": {
@@ -7868,6 +8045,15 @@
"dev": true,
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
"integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
"license": "MIT",
"engines": {
"node": ">=0.4"
}
},
"node_modules/yocto-queue": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
+1
View File
@@ -16,6 +16,7 @@
"seed": "npx tsx prisma/seed.ts"
},
"dependencies": {
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"bcryptjs": "^3.0.3",
"jose": "^6.2.3",
+118
View File
@@ -0,0 +1,118 @@
"use client";
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 Auto { id: string; marca: string; modelo: string; patente: string; anio: number; }
interface Cliente { id: string; nombre: string; apellido: string; email: string; telefono: string; autos: Auto[]; }
interface Turno {
id: string;
fechaInicio: string;
fechaFin: string;
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
tipoServicio: { nombre: string };
auto: { patente: string };
}
export default function ClientesPage() {
const [busqueda, setBusqueda] = useState("");
const [clientes, setClientes] = useState<Cliente[]>([]);
const [buscando, setBuscando] = useState(false);
const [clienteSel, setClienteSel] = useState<Cliente | null>(null);
const [turnos, setTurnos] = useState<Turno[]>([]);
async function buscar(e: React.FormEvent) {
e.preventDefault();
setBuscando(true);
const res = await fetch(`/api/admin/clientes?q=${encodeURIComponent(busqueda)}`);
setClientes(await res.json());
setBuscando(false);
setClienteSel(null);
}
async function verCliente(c: Cliente) {
setClienteSel(c);
const res = await fetch(`/api/admin/turnos?clienteId=${c.id}`);
if (res.ok) setTurnos(await res.json());
}
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-bold text-gray-800">Clientes</h1>
<Card title="Buscar cliente">
<form onSubmit={buscar} className="flex gap-2">
<Input
placeholder="Nombre, apellido o email..."
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
className="flex-1"
/>
<Button type="submit" loading={buscando}>Buscar</Button>
</form>
</Card>
{clientes.length > 0 && (
<div className="flex flex-col gap-3">
{clientes.map((c) => (
<button
key={c.id}
onClick={() => verCliente(c)}
className={`text-left bg-white border rounded-xl px-4 py-3 transition hover:shadow-sm ${clienteSel?.id === c.id ? "border-blue-500" : "border-gray-200"}`}
>
<p className="font-medium text-gray-800">{c.apellido}, {c.nombre}</p>
<p className="text-sm text-gray-500">{c.email} · {c.telefono}</p>
<p className="text-xs text-gray-400 mt-1">
{c.autos.length === 0 ? "Sin autos" : c.autos.map((a) => `${a.marca} ${a.modelo} (${a.patente})`).join(" · ")}
</p>
</button>
))}
</div>
)}
{clienteSel && (
<Card title={`${clienteSel.apellido}, ${clienteSel.nombre}`}>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm mb-4">
<span className="text-gray-400">Email</span><span>{clienteSel.email}</span>
<span className="text-gray-400">Teléfono</span><span>{clienteSel.telefono}</span>
</div>
{clienteSel.autos.length > 0 && (
<div className="mb-4">
<p className="text-sm font-medium text-gray-700 mb-2">Autos registrados</p>
<div className="flex flex-wrap gap-2">
{clienteSel.autos.map((a) => (
<span key={a.id} className="text-xs bg-gray-100 text-gray-700 px-2 py-1 rounded-full">
{a.marca} {a.modelo} · <span className="font-mono">{a.patente}</span>
</span>
))}
</div>
</div>
)}
{turnos.length > 0 && (
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Últimos turnos</p>
<div className="flex flex-col gap-2">
{turnos.slice(0, 5).map((t) => (
<div key={t.id} className="flex items-center justify-between gap-2 text-sm">
<div>
<span className="text-gray-700">{t.tipoServicio.nombre}</span>
<span className="text-gray-400 ml-2">
{new Date(t.fechaInicio).toLocaleDateString("es-AR")} ·{" "}
<span className="font-mono">{t.auto.patente}</span>
</span>
</div>
<Badge estado={t.estado} />
</div>
))}
</div>
</div>
)}
</Card>
)}
</div>
);
}
+111
View File
@@ -0,0 +1,111 @@
"use client";
import { useEffect, useState } from "react";
import Card from "@/components/ui/Card";
import Badge from "@/components/ui/Badge";
import Button from "@/components/ui/Button";
interface Turno {
id: string;
fechaInicio: string;
fechaFin: string;
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
tipoServicio: { nombre: string };
auto: { marca: string; modelo: string; patente: string };
cliente: { nombre: string; apellido: string; telefono: string };
}
export default function AdminDashboard() {
const [fecha, setFecha] = useState(() => new Date().toISOString().slice(0, 10));
const [turnos, setTurnos] = useState<Turno[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => { cargar(); }, [fecha]);
async function cargar() {
setLoading(true);
const res = await fetch(`/api/admin/turnos?fecha=${fecha}`);
const data = await res.json();
setTurnos(data);
setLoading(false);
}
async function cambiarEstado(id: string, estado: string) {
await fetch(`/api/admin/turnos/${id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estado }),
});
cargar();
}
function formatHora(iso: string) {
return new Date(iso).toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<h1 className="text-2xl font-bold text-gray-800">Turnos del día</h1>
<input
type="date"
value={fecha}
onChange={(e) => setFecha(e.target.value)}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
{loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div>
) : turnos.length === 0 ? (
<Card>
<p className="text-center text-gray-400 py-4">No hay turnos para esta fecha.</p>
</Card>
) : (
<div className="flex flex-col gap-4">
{turnos.map((t) => (
<Card key={t.id}>
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between flex-wrap gap-2">
<div>
<p className="font-semibold text-gray-800">
{formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)}
</p>
<p className="text-sm text-gray-600 mt-0.5">{t.tipoServicio.nombre}</p>
</div>
<Badge estado={t.estado} />
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-sm text-gray-600 bg-gray-50 rounded-lg p-3">
<span className="text-gray-400">Cliente</span>
<span>{t.cliente.nombre} {t.cliente.apellido}</span>
<span className="text-gray-400">Teléfono</span>
<span>{t.cliente.telefono}</span>
<span className="text-gray-400">Auto</span>
<span>{t.auto.marca} {t.auto.modelo}</span>
<span className="text-gray-400">Patente</span>
<span className="font-mono">{t.auto.patente}</span>
</div>
{t.estado === "PENDIENTE" && (
<div className="flex gap-2">
<Button onClick={() => cambiarEstado(t.id, "CONFIRMADO")} className="flex-1 text-sm">
✓ Confirmar
</Button>
<Button variant="danger" onClick={() => cambiarEstado(t.id, "CANCELADO")} className="flex-1 text-sm">
✕ Cancelar
</Button>
</div>
)}
{t.estado === "CONFIRMADO" && (
<Button onClick={() => cambiarEstado(t.id, "COMPLETADO")} variant="secondary" className="text-sm">
✓ Marcar completado
</Button>
)}
</div>
</Card>
))}
</div>
)}
</div>
);
}
+211
View File
@@ -0,0 +1,211 @@
"use client";
import { useState } from "react";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
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 {
id: string;
descripcion: string;
observaciones: string | null;
fechaRealizado: string;
admin: { nombre: string };
tipoServicio: { nombre: string } | null;
}
interface TipoServicio { id: string; nombre: string; }
export default function AdminHistorialPage() {
const [busqueda, setBusqueda] = useState("");
const [clientes, setClientes] = useState<Cliente[]>([]);
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);
async function buscar(e: React.FormEvent) {
e.preventDefault();
setBuscando(true);
const res = await fetch(`/api/admin/clientes?q=${encodeURIComponent(busqueda)}`);
const data = await res.json();
setClientes(data);
setBuscando(false);
setClienteSel(null);
setAutoSel(null);
}
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));
}
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 }),
});
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);
}
return (
<div className="flex flex-col gap-6">
<h1 className="text-2xl font-bold text-gray-800">Historial de autos</h1>
{/* Buscar cliente */}
<Card title="Buscar cliente">
<form onSubmit={buscar} className="flex gap-2">
<Input
placeholder="Nombre, apellido o email..."
value={busqueda}
onChange={(e) => setBusqueda(e.target.value)}
className="flex-1"
/>
<Button type="submit" loading={buscando}>Buscar</Button>
</form>
{clientes.length > 0 && (
<div className="mt-4 flex flex-col gap-2">
{clientes.map((c) => (
<button
key={c.id}
onClick={() => { setClienteSel(c); setAutoSel(null); setHistorial([]); }}
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="text-gray-400 ml-2">{c.email}</span>
</button>
))}
</div>
)}
</Card>
{/* Autos del cliente */}
{clienteSel && (
<Card title={`Autos de ${clienteSel.nombre} ${clienteSel.apellido}`}>
{clienteSel.autos.length === 0 ? (
<p className="text-gray-400 text-sm">Este cliente no tiene autos registrados.</p>
) : (
<div className="flex flex-col gap-2">
{clienteSel.autos.map((a) => (
<button
key={a.id}
onClick={() => seleccionarAuto(a)}
className={`text-left px-3 py-2 rounded-lg border transition text-sm ${autoSel?.id === a.id ? "border-blue-500 bg-blue-50" : "border-gray-200 hover:border-blue-300"}`}
>
<span className="font-medium">{a.marca} {a.modelo} ({a.anio})</span>
<span className="font-mono text-gray-400 ml-2">{a.patente}</span>
</button>
))}
</div>
)}
</Card>
)}
{/* Historial del auto */}
{autoSel && (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<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>
) : (
historial.map((reg) => (
<Card key={reg.id}>
<div className="flex items-start justify-between gap-2 flex-wrap mb-2">
<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>
)}
</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>
)}
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
const navItems = [
{ href: "/admin/dashboard", label: "Turnos del día", icon: "📅" },
{ href: "/admin/historial", label: "Historial", icon: "📋" },
{ href: "/admin/clientes", label: "Clientes", icon: "👥" },
{ href: "/admin/servicios", label: "Servicios", icon: "⚙️" },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
async function cerrarSesion() {
await fetch("/api/auth/admin-logout", { method: "POST" });
router.push("/admin");
}
if (pathname === "/admin") return <>{children}</>;
return (
<div className="min-h-screen bg-gray-50 flex flex-col">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-5xl mx-auto px-4 h-14 flex items-center justify-between">
<span className="font-bold text-blue-600 text-lg">🔧 Taller — Admin</span>
<button onClick={cerrarSesion} className="text-sm text-gray-500 hover:text-red-600 transition">
Cerrar sesión
</button>
</div>
</header>
<div className="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col md:flex-row gap-6 py-6">
<nav className="md:w-48 shrink-0">
<ul className="flex md:flex-col gap-2 flex-wrap">
{navItems.map((item) => (
<li key={item.href}>
<Link
href={item.href}
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm font-medium transition ${
pathname.startsWith(item.href)
? "bg-blue-600 text-white"
: "text-gray-700 hover:bg-gray-100"
}`}
>
<span>{item.icon}</span>
{item.label}
</Link>
</li>
))}
</ul>
</nav>
<main className="flex-1">{children}</main>
</div>
</div>
);
}
+50
View File
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import Card from "@/components/ui/Card";
export default function AdminLoginPage() {
const router = useRouter();
const [form, setForm] = useState({ email: "", password: "" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
function set(f: string) {
return (e: React.ChangeEvent<HTMLInputElement>) => setForm((v) => ({ ...v, [f]: e.target.value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/auth/admin-login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
setLoading(false);
if (!res.ok) { setError(data.error || "Credenciales incorrectas"); return; }
router.push("/admin/dashboard");
}
return (
<div className="flex flex-col items-center justify-center min-h-screen bg-gray-50">
<Card className="w-full max-w-sm mx-4">
<div className="text-center mb-6">
<div className="text-4xl mb-2">🔧</div>
<h1 className="text-xl font-bold text-gray-800">Panel del taller</h1>
<p className="text-sm text-gray-500 mt-1">Solo para jefes mecánicos</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input id="email" type="email" label="Email" placeholder="jefe@taller.com" value={form.email} onChange={set("email")} required />
<Input id="password" type="password" label="Contraseña" placeholder="••••••••" value={form.password} onChange={set("password")} required />
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
<Button type="submit" loading={loading} className="w-full">Ingresar</Button>
</form>
</Card>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
"use client";
import { useEffect, useState } from "react";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
interface Servicio { id: string; nombre: string; duracionHoras: number; activo: boolean; }
export default function ServiciosPage() {
const [servicios, setServicios] = useState<Servicio[]>([]);
const [loading, setLoading] = useState(true);
const [mostrarForm, setMostrarForm] = useState(false);
const [form, setForm] = useState({ nombre: "", duracionHoras: "" });
const [guardando, setGuardando] = useState(false);
const [editando, setEditando] = useState<Servicio | null>(null);
const [errorForm, setErrorForm] = useState("");
useEffect(() => { cargar(); }, []);
async function cargar() {
setLoading(true);
const res = await fetch("/api/admin/servicios");
setServicios(await res.json());
setLoading(false);
}
function set(f: string) {
return (e: React.ChangeEvent<HTMLInputElement>) => setForm((v) => ({ ...v, [f]: e.target.value }));
}
async function guardar(e: React.FormEvent) {
e.preventDefault();
setGuardando(true);
setErrorForm("");
if (editando) {
const res = await fetch(`/api/admin/servicios/${editando.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nombre: form.nombre, duracionHoras: Number(form.duracionHoras) }),
});
if (!res.ok) { setErrorForm("Error al guardar"); setGuardando(false); return; }
} else {
const res = await fetch("/api/admin/servicios", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nombre: form.nombre, duracionHoras: Number(form.duracionHoras) }),
});
if (!res.ok) { setErrorForm("Error al guardar"); setGuardando(false); return; }
}
setGuardando(false);
setMostrarForm(false);
setEditando(null);
setForm({ nombre: "", duracionHoras: "" });
cargar();
}
async function toggleActivo(s: Servicio) {
await fetch(`/api/admin/servicios/${s.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ activo: !s.activo }),
});
cargar();
}
function iniciarEdicion(s: Servicio) {
setEditando(s);
setForm({ nombre: s.nombre, duracionHoras: String(s.duracionHoras) });
setMostrarForm(true);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-800">Tipos de servicio</h1>
<Button
onClick={() => { setMostrarForm(true); setEditando(null); setForm({ nombre: "", duracionHoras: "" }); }}
className="text-sm"
>
+ Nuevo servicio
</Button>
</div>
{mostrarForm && (
<Card title={editando ? "Editar servicio" : "Nuevo servicio"}>
<form onSubmit={guardar} className="flex flex-col gap-4">
<Input id="nombre" label="Nombre del servicio" placeholder="Cambio de aceite" value={form.nombre} onChange={set("nombre")} required />
<Input id="duracion" type="number" label="Duración (horas)" placeholder="1.5" step="0.5" min="0.5" max="10" value={form.duracionHoras} onChange={set("duracionHoras")} required />
{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); setEditando(null); }}>Cancelar</Button>
<Button type="submit" loading={guardando}>Guardar</Button>
</div>
</form>
</Card>
)}
{loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div>
) : (
<div className="flex flex-col gap-3">
{servicios.map((s) => (
<div
key={s.id}
className={`flex items-center justify-between gap-3 bg-white border rounded-xl px-4 py-3 ${!s.activo ? "opacity-50" : ""}`}
>
<div>
<p className={`font-medium ${s.activo ? "text-gray-800" : "text-gray-400 line-through"}`}>
{s.nombre}
</p>
<p className="text-sm text-gray-500">
{s.duracionHoras === 1 ? "1 hora" : `${s.duracionHoras} horas`}
</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => iniciarEdicion(s)} className="text-sm px-2 py-1">
✏️
</Button>
<Button
variant={s.activo ? "secondary" : "ghost"}
onClick={() => toggleActivo(s)}
className="text-sm px-3 py-1"
>
{s.activo ? "Desactivar" : "Activar"}
</Button>
</div>
</div>
))}
</div>
)}
</div>
);
}
+6 -5
View File
@@ -8,15 +8,16 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const fecha = searchParams.get("fecha"); // YYYY-MM-DD
const clienteId = searchParams.get("clienteId");
const where = fecha
? {
fechaInicio: {
const where: Record<string, unknown> = {};
if (fecha) {
where.fechaInicio = {
gte: new Date(`${fecha}T00:00:00`),
lte: new Date(`${fecha}T23:59:59`),
},
};
}
: {};
if (clienteId) where.clienteId = clienteId;
const turnos = await prisma.turno.findMany({
where,
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set("admin_session", "", { maxAge: 0, path: "/" });
return response;
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set("cliente_session", "", { maxAge: 0, path: "/" });
return response;
}
+49
View File
@@ -0,0 +1,49 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
export default function AuthPage() {
const router = useRouter();
const params = useSearchParams();
const [status, setStatus] = useState<"loading" | "error">("loading");
useEffect(() => {
const token = params.get("token");
if (!token) { setStatus("error"); return; }
fetch("/api/auth/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
})
.then((r) => r.json())
.then((data) => {
if (data.ok) router.replace("/cliente/dashboard");
else setStatus("error");
})
.catch(() => setStatus("error"));
}, [params, router]);
if (status === "loading") {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] gap-4">
<svg className="animate-spin h-10 w-10 text-blue-600" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
<p className="text-gray-500">Verificando tu acceso...</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
<div className="text-5xl">❌</div>
<h1 className="text-xl font-bold text-gray-800">Link inválido o expirado</h1>
<p className="text-gray-500">El link de acceso ya fue usado o expiró. Pedí uno nuevo.</p>
<a href="/cliente" className="text-blue-600 hover:underline font-medium">
Volver al inicio
</a>
</div>
);
}
+130
View File
@@ -0,0 +1,130 @@
"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";
interface Auto {
id: string;
patente: string;
marca: string;
modelo: string;
anio: number;
}
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: "" });
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: "" });
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-gray-800">Mis autos</h1>
<button onClick={cerrarSesion} className="text-sm text-gray-400 hover:text-red-500 transition">
Salir
</button>
</div>
{loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div>
) : (
<div className="flex flex-col gap-4">
{autos.map((auto) => (
<Card key={auto.id} className="hover:shadow-md transition">
<div className="flex items-center justify-between flex-wrap 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}`}>
<Button variant="secondary" className="text-sm">
📋 Historial
</Button>
</Link>
<Link href={`/cliente/turnos?autoId=${auto.id}`}>
<Button className="text-sm">
📅 Sacar turno
</Button>
</Link>
</div>
</div>
</Card>
))}
{autos.length === 0 && !mostrarForm && (
<p className="text-center text-gray-400 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">
<Input id="marca" label="Marca" placeholder="Ford" value={form.marca} onChange={set("marca")} required />
<Input id="modelo" label="Modelo" placeholder="Focus" value={form.modelo} onChange={set("modelo")} 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" />
</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>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
interface Registro {
id: string;
descripcion: string;
observaciones: string | null;
fechaRealizado: string;
admin: { nombre: string };
tipoServicio: { nombre: string } | null;
}
export default function HistorialPage() {
const router = useRouter();
const params = useSearchParams();
const autoId = params.get("autoId");
const [historial, setHistorial] = useState<Registro[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!autoId) { router.push("/cliente/dashboard"); return; }
fetch(`/api/cliente/historial?autoId=${autoId}`)
.then((r) => {
if (r.status === 401) { router.push("/cliente"); return r; }
return r;
})
.then((r) => r.json())
.then((data) => { setHistorial(data); setLoading(false); })
.catch(() => setLoading(false));
}, [autoId, router]);
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
<Link href="/cliente/dashboard">
<Button variant="ghost" className="px-2">← Volver</Button>
</Link>
<h1 className="text-2xl font-bold text-gray-800">Historial del auto</h1>
</div>
{loading ? (
<div className="text-center py-10 text-gray-400">Cargando...</div>
) : historial.length === 0 ? (
<Card>
<p className="text-center text-gray-400 py-4">Este auto no tiene historial registrado todavía.</p>
</Card>
) : (
<div className="flex flex-col gap-3">
{historial.map((reg) => (
<Card key={reg.id}>
<div className="flex flex-col gap-2">
<div className="flex items-start justify-between gap-2 flex-wrap">
<div>
<p className="font-semibold 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>
)}
</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-lg px-3 py-2">
{reg.observaciones}
</p>
)}
<p className="text-xs text-gray-400">Realizado por: {reg.admin.nombre}</p>
</div>
</Card>
))}
</div>
)}
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import type { Metadata } from "next";
export const metadata: Metadata = {
title: "Taller - Mi cuenta",
};
export default function ClienteLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white border-b border-gray-200 sticky top-0 z-10">
<div className="max-w-2xl mx-auto px-4 h-14 flex items-center justify-between">
<span className="font-bold text-blue-600 text-lg">🔧 Taller</span>
</div>
</header>
<main className="max-w-2xl mx-auto px-4 py-6">{children}</main>
</div>
);
}
+84
View File
@@ -0,0 +1,84 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import Card from "@/components/ui/Card";
export default function ClienteLoginPage() {
const [email, setEmail] = useState("");
const [loading, setLoading] = useState(false);
const [enviado, setEnviado] = useState(false);
const [error, setError] = useState("");
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/auth/magic-link", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
const data = await res.json();
setLoading(false);
if (!res.ok) {
setError(data.error || "Error al enviar el email");
} else {
setEnviado(true);
}
}
if (enviado) {
return (
<div className="flex flex-col items-center justify-center min-h-[60vh] text-center gap-4">
<div className="text-5xl">📧</div>
<h1 className="text-2xl font-bold text-gray-800">Revisá tu email</h1>
<p className="text-gray-500 max-w-sm">
Te enviamos un link de acceso a <strong>{email}</strong>. El link expira en 15 minutos.
</p>
<button
className="text-sm text-blue-600 hover:underline mt-2"
onClick={() => { setEnviado(false); setEmail(""); }}
>
Usar otro email
</button>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<Card className="w-full max-w-sm">
<div className="text-center mb-6">
<div className="text-4xl mb-2">🔧</div>
<h1 className="text-xl font-bold text-gray-800">Acceder al taller</h1>
<p className="text-sm text-gray-500 mt-1">Ingresá tu email y te enviamos un link</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input
id="email"
type="email"
label="Email"
placeholder="tu@email.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
error={error}
/>
<Button type="submit" loading={loading} className="w-full">
Enviar link de acceso
</Button>
</form>
<p className="text-center text-sm text-gray-500 mt-4">
¿Primera vez?{" "}
<Link href="/cliente/registro" className="text-blue-600 hover:underline font-medium">
Registrate
</Link>
</p>
</Card>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import Button from "@/components/ui/Button";
import Input from "@/components/ui/Input";
import Card from "@/components/ui/Card";
export default function RegistroPage() {
const router = useRouter();
const [form, setForm] = useState({ email: "", nombre: "", apellido: "", telefono: "" });
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
function set(field: string) {
return (e: React.ChangeEvent<HTMLInputElement>) => setForm((f) => ({ ...f, [field]: e.target.value }));
}
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setLoading(true);
setError("");
const res = await fetch("/api/cliente/registro", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
const data = await res.json();
setLoading(false);
if (!res.ok) {
setError(data.error || "Error al registrarse");
} else {
router.push(`/cliente?registered=1`);
}
}
return (
<div className="flex flex-col items-center justify-center min-h-[60vh]">
<Card className="w-full max-w-sm">
<div className="text-center mb-6">
<div className="text-4xl mb-2">👤</div>
<h1 className="text-xl font-bold text-gray-800">Crear cuenta</h1>
<p className="text-sm text-gray-500 mt-1">Completá tus datos para registrarte</p>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<Input id="nombre" label="Nombre" placeholder="Juan" value={form.nombre} onChange={set("nombre")} required />
<Input id="apellido" label="Apellido" placeholder="Pérez" value={form.apellido} onChange={set("apellido")} required />
<Input id="email" type="email" label="Email" placeholder="juan@email.com" value={form.email} onChange={set("email")} required />
<Input id="telefono" type="tel" label="Teléfono" placeholder="11 1234-5678" value={form.telefono} onChange={set("telefono")} required />
{error && <p className="text-sm text-red-500 text-center">{error}</p>}
<Button type="submit" loading={loading} className="w-full">
Crear cuenta
</Button>
</form>
<p className="text-center text-sm text-gray-500 mt-4">
¿Ya tenés cuenta?{" "}
<Link href="/cliente" className="text-blue-600 hover:underline font-medium">
Ingresar
</Link>
</p>
</Card>
</div>
);
}
+206
View File
@@ -0,0 +1,206 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import Link from "next/link";
import Card from "@/components/ui/Card";
import Button from "@/components/ui/Button";
import Badge from "@/components/ui/Badge";
interface TipoServicio { id: string; nombre: string; duracionHoras: number; }
interface Slot { fechaInicio: string; fechaFin: string; }
interface Turno {
id: string;
fechaInicio: string;
fechaFin: string;
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
tipoServicio: { nombre: string };
auto: { marca: string; modelo: string; patente: string };
}
type Step = "servicio" | "slot" | "confirmado";
export default function TurnosPage() {
const router = useRouter();
const params = useSearchParams();
const autoId = params.get("autoId");
const [step, setStep] = useState<Step>("servicio");
const [servicios, setServicios] = useState<TipoServicio[]>([]);
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
const [slots, setSlots] = useState<Slot[]>([]);
const [slotSel, setSlotSel] = useState<Slot | null>(null);
const [loadingSlots, setLoadingSlots] = useState(false);
const [reservando, setReservando] = useState(false);
const [turnoCreado, setTurnoCreado] = useState<Turno | null>(null);
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
const [loadingTurnos, setLoadingTurnos] = useState(true);
useEffect(() => {
if (!autoId) { router.push("/cliente/dashboard"); return; }
fetch("/api/admin/servicios")
.then((r) => r.json())
.then((data) => setServicios(data.filter((s: TipoServicio & { activo: boolean }) => s.activo)));
fetch("/api/cliente/turnos")
.then((r) => r.json())
.then((data) => { setMisTurnos(data.filter((t: Turno & { autoId: string }) => t.autoId === autoId)); setLoadingTurnos(false); });
}, [autoId, router]);
async function seleccionarServicio(s: TipoServicio) {
setServicioSel(s);
setLoadingSlots(true);
setStep("slot");
const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}`);
const data = await res.json();
setSlots(data);
setLoadingSlots(false);
}
async function reservar() {
if (!slotSel || !servicioSel || !autoId) return;
setReservando(true);
const res = await fetch("/api/cliente/turnos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ autoId, tipoServicioId: servicioSel.id, fechaInicio: slotSel.fechaInicio }),
});
const data = await res.json();
setReservando(false);
if (res.ok) {
setTurnoCreado(data);
setStep("confirmado");
}
}
function formatSlot(slot: Slot) {
const inicio = new Date(slot.fechaInicio);
const fin = new Date(slot.fechaFin);
const dia = inicio.toLocaleDateString("es-AR", { weekday: "long", day: "numeric", month: "long" });
const h1 = inicio.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
const h2 = fin.toLocaleTimeString("es-AR", { hour: "2-digit", minute: "2-digit" });
return { dia: dia.charAt(0).toUpperCase() + dia.slice(1), horas: `${h1} - ${h2}` };
}
if (step === "confirmado" && turnoCreado) {
const { dia, horas } = formatSlot({ fechaInicio: turnoCreado.fechaInicio, fechaFin: turnoCreado.fechaFin });
return (
<div className="flex flex-col items-center justify-center min-h-[50vh] text-center gap-4">
<div className="text-5xl">✅</div>
<h1 className="text-2xl font-bold text-gray-800">¡Turno reservado!</h1>
<Card className="text-left w-full max-w-sm">
<p className="text-sm text-gray-500 mb-1">Servicio</p>
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</p>
<p className="text-sm text-gray-500 mb-1">Fecha</p>
<p className="font-semibold mb-1">{dia}</p>
<p className="text-gray-600">{horas}</p>
</Card>
<Link href="/cliente/dashboard">
<Button variant="secondary">← Volver a mis autos</Button>
</Link>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
{step === "slot" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
) : (
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
)}
<h1 className="text-2xl font-bold text-gray-800">
{step === "servicio" ? "Elegí el servicio" : "Elegí el horario"}
</h1>
</div>
{step === "servicio" && (
<>
<div className="grid gap-3">
{servicios.map((s) => (
<button
key={s.id}
onClick={() => seleccionarServicio(s)}
className="text-left bg-white border border-gray-200 rounded-xl p-4 hover:border-blue-500 hover:shadow-sm transition flex items-center justify-between"
>
<div>
<p className="font-medium text-gray-800">{s.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">
Duración: {s.duracionHoras === 1 ? "1 hora" : `${s.duracionHoras} horas`}
</p>
</div>
<span className="text-gray-400 text-lg">→</span>
</button>
))}
</div>
{!loadingTurnos && misTurnos.length > 0 && (
<Card title="Mis turnos para este auto">
<div className="flex flex-col gap-3">
{misTurnos.map((t) => {
const { dia, horas } = formatSlot({ fechaInicio: t.fechaInicio, fechaFin: t.fechaFin });
return (
<div key={t.id} className="flex items-center justify-between flex-wrap gap-2">
<div>
<p className="text-sm font-medium">{t.tipoServicio.nombre}</p>
<p className="text-xs text-gray-500">{dia} · {horas}</p>
</div>
<Badge estado={t.estado} />
</div>
);
})}
</div>
</Card>
)}
</>
)}
{step === "slot" && (
<div className="flex flex-col gap-4">
{loadingSlots ? (
<div className="text-center py-10 text-gray-400">Buscando horarios disponibles...</div>
) : slots.length === 0 ? (
<Card>
<p className="text-center text-gray-400 py-4">
No hay horarios disponibles en los próximos 14 días. Consultá al taller.
</p>
</Card>
) : (
<>
<p className="text-sm text-gray-500">
Servicio: <strong>{servicioSel?.nombre}</strong>
</p>
<div className="grid gap-2">
{slots.map((slot, i) => {
const { dia, horas } = formatSlot(slot);
const sel = slotSel?.fechaInicio === slot.fechaInicio;
return (
<button
key={i}
onClick={() => setSlotSel(slot)}
className={`text-left rounded-xl p-4 border transition ${
sel
? "border-blue-500 bg-blue-50"
: "border-gray-200 bg-white hover:border-blue-300"
}`}
>
<p className="font-medium text-gray-800">{dia}</p>
<p className="text-sm text-gray-500">{horas}</p>
</button>
);
})}
</div>
<Button
onClick={reservar}
disabled={!slotSel}
loading={reservando}
className="w-full mt-2"
>
Confirmar turno
</Button>
</>
)}
</div>
)}
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
const variants = {
PENDIENTE: "bg-yellow-100 text-yellow-800",
CONFIRMADO: "bg-green-100 text-green-800",
CANCELADO: "bg-red-100 text-red-800",
COMPLETADO: "bg-gray-100 text-gray-700",
};
const labels = {
PENDIENTE: "Pendiente",
CONFIRMADO: "Confirmado",
CANCELADO: "Cancelado",
COMPLETADO: "Completado",
};
export default function Badge({ estado }: { estado: keyof typeof variants }) {
return (
<span className={`text-xs font-medium px-2 py-1 rounded-full ${variants[estado] ?? "bg-gray-100 text-gray-700"}`}>
{labels[estado] ?? estado}
</span>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import { ButtonHTMLAttributes } from "react";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "danger" | "ghost";
loading?: boolean;
}
const variants = {
primary: "bg-blue-600 hover:bg-blue-700 text-white",
secondary: "bg-gray-100 hover:bg-gray-200 text-gray-800",
danger: "bg-red-600 hover:bg-red-700 text-white",
ghost: "hover:bg-gray-100 text-gray-700",
};
export default function Button({
variant = "primary",
loading,
disabled,
children,
className = "",
...props
}: ButtonProps) {
return (
<button
disabled={disabled || loading}
className={`px-4 py-2 rounded-lg font-medium transition-colors disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 ${variants[variant]} ${className}`}
{...props}
>
{loading && (
<svg className="animate-spin h-4 w-4" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v8H4z" />
</svg>
)}
{children}
</button>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { HTMLAttributes } from "react";
interface CardProps extends HTMLAttributes<HTMLDivElement> {
title?: string;
}
export default function Card({ title, children, className = "", ...props }: CardProps) {
return (
<div className={`bg-white rounded-xl shadow-sm border border-gray-200 ${className}`} {...props}>
{title && (
<div className="px-5 py-4 border-b border-gray-100">
<h2 className="font-semibold text-gray-800">{title}</h2>
</div>
)}
<div className="p-5">{children}</div>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { InputHTMLAttributes } from "react";
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
error?: string;
}
export default function Input({ label, error, className = "", id, ...props }: InputProps) {
return (
<div className="flex flex-col gap-1">
{label && (
<label htmlFor={id} className="text-sm font-medium text-gray-700">
{label}
</label>
)}
<input
id={id}
className={`border rounded-lg px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition ${
error ? "border-red-400" : "border-gray-300"
} ${className}`}
{...props}
/>
{error && <p className="text-xs text-red-500">{error}</p>}
</div>
);
}
+9 -5
View File
@@ -1,12 +1,16 @@
import { PrismaClient } from "@prisma/client";
import { PrismaPg } from "@prisma/adapter-pg";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma =
globalForPrisma.prisma ||
new PrismaClient({
datasourceUrl: process.env.DATABASE_URL,
log: process.env.NODE_ENV === "development" ? ["query", "error"] : ["error"],
function createClient() {
const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL! });
return new PrismaClient({
adapter,
log: process.env.NODE_ENV === "development" ? ["error"] : ["error"],
});
}
export const prisma = globalForPrisma.prisma || createClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from "next/server";
import { jwtVerify } from "jose";
const SECRET = new TextEncoder().encode(process.env.NEXTAUTH_SECRET || "dev-secret");
async function verifyCookie(req: NextRequest, cookieName: string) {
const token = req.cookies.get(cookieName)?.value;
if (!token) return false;
try {
await jwtVerify(token, SECRET);
return true;
} catch {
return false;
}
}
export async function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
if (pathname.startsWith("/cliente/dashboard") ||
pathname.startsWith("/cliente/historial") ||
pathname.startsWith("/cliente/turnos")) {
const ok = await verifyCookie(req, "cliente_session");
if (!ok) return NextResponse.redirect(new URL("/cliente", req.url));
}
if (pathname.startsWith("/admin/dashboard") ||
pathname.startsWith("/admin/turnos") ||
pathname.startsWith("/admin/historial") ||
pathname.startsWith("/admin/servicios") ||
pathname.startsWith("/admin/clientes")) {
const ok = await verifyCookie(req, "admin_session");
if (!ok) return NextResponse.redirect(new URL("/admin", req.url));
}
return NextResponse.next();
}
export const config = {
matcher: ["/cliente/:path*", "/admin/:path*"],
};