diff --git a/package-lock.json b/package-lock.json index fc4d8f0..c534ade 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 03bd5d7..d1de675 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/admin/clientes/page.tsx b/src/app/admin/clientes/page.tsx new file mode 100644 index 0000000..bf7f94d --- /dev/null +++ b/src/app/admin/clientes/page.tsx @@ -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([]); + const [buscando, setBuscando] = useState(false); + const [clienteSel, setClienteSel] = useState(null); + const [turnos, setTurnos] = useState([]); + + 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 ( +
+

Clientes

+ + +
+ setBusqueda(e.target.value)} + className="flex-1" + /> + +
+
+ + {clientes.length > 0 && ( +
+ {clientes.map((c) => ( + + ))} +
+ )} + + {clienteSel && ( + +
+ Email{clienteSel.email} + Teléfono{clienteSel.telefono} +
+ + {clienteSel.autos.length > 0 && ( +
+

Autos registrados

+
+ {clienteSel.autos.map((a) => ( + + {a.marca} {a.modelo} · {a.patente} + + ))} +
+
+ )} + + {turnos.length > 0 && ( +
+

Últimos turnos

+
+ {turnos.slice(0, 5).map((t) => ( +
+
+ {t.tipoServicio.nombre} + + {new Date(t.fechaInicio).toLocaleDateString("es-AR")} ·{" "} + {t.auto.patente} + +
+ +
+ ))} +
+
+ )} +
+ )} +
+ ); +} diff --git a/src/app/admin/dashboard/page.tsx b/src/app/admin/dashboard/page.tsx new file mode 100644 index 0000000..3b7ec31 --- /dev/null +++ b/src/app/admin/dashboard/page.tsx @@ -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([]); + 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 ( +
+
+

Turnos del día

+ 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" + /> +
+ + {loading ? ( +
Cargando...
+ ) : turnos.length === 0 ? ( + +

No hay turnos para esta fecha.

+
+ ) : ( +
+ {turnos.map((t) => ( + +
+
+
+

+ {formatHora(t.fechaInicio)} – {formatHora(t.fechaFin)} +

+

{t.tipoServicio.nombre}

+
+ +
+ +
+ Cliente + {t.cliente.nombre} {t.cliente.apellido} + Teléfono + {t.cliente.telefono} + Auto + {t.auto.marca} {t.auto.modelo} + Patente + {t.auto.patente} +
+ + {t.estado === "PENDIENTE" && ( +
+ + +
+ )} + {t.estado === "CONFIRMADO" && ( + + )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/admin/historial/page.tsx b/src/app/admin/historial/page.tsx new file mode 100644 index 0000000..80d1ed3 --- /dev/null +++ b/src/app/admin/historial/page.tsx @@ -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([]); + const [buscando, setBuscando] = useState(false); + const [clienteSel, setClienteSel] = useState(null); + const [autoSel, setAutoSel] = useState(null); + const [historial, setHistorial] = useState([]); + const [servicios, setServicios] = useState([]); + 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) => + 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 ( +
+

Historial de autos

+ + {/* Buscar cliente */} + +
+ setBusqueda(e.target.value)} + className="flex-1" + /> + +
+ + {clientes.length > 0 && ( +
+ {clientes.map((c) => ( + + ))} +
+ )} +
+ + {/* Autos del cliente */} + {clienteSel && ( + + {clienteSel.autos.length === 0 ? ( +

Este cliente no tiene autos registrados.

+ ) : ( +
+ {clienteSel.autos.map((a) => ( + + ))} +
+ )} +
+ )} + + {/* Historial del auto */} + {autoSel && ( +
+
+

+ {autoSel.marca} {autoSel.modelo} · {autoSel.patente} +

+ +
+ + {mostrarForm && ( + +
+
+ + +
+ +
+ +