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:
Generated
+188
-2
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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: {
|
||||
gte: new Date(`${fecha}T00:00:00`),
|
||||
lte: new Date(`${fecha}T23:59:59`),
|
||||
},
|
||||
}
|
||||
: {};
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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*"],
|
||||
};
|
||||
Reference in New Issue
Block a user