feat: bootstrap taller-turnos project

Estructura inicial con Next.js 14, PostgreSQL/Prisma 7, autenticación por
magic link (clientes) y JWT (admins), lógica de scheduling con 4 slots
paralelos y API routes completas para cliente y panel admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Beldor
2026-06-18 11:51:19 -03:00
parent 4fde77e3ed
commit 93ad1cb8c1
28 changed files with 2626 additions and 23 deletions
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
const q = searchParams.get("q");
const clientes = await prisma.cliente.findMany({
where: q
? {
OR: [
{ nombre: { contains: q, mode: "insensitive" } },
{ apellido: { contains: q, mode: "insensitive" } },
{ email: { contains: q, mode: "insensitive" } },
],
}
: undefined,
include: { autos: true },
orderBy: { apellido: "asc" },
take: 50,
});
return NextResponse.json(clientes);
}
+43
View File
@@ -0,0 +1,43 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
const autoId = searchParams.get("autoId");
if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 });
const historial = await prisma.historialServicio.findMany({
where: { autoId },
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
orderBy: { fechaRealizado: "desc" },
});
return NextResponse.json(historial);
}
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { autoId, tipoServicioId, descripcion, observaciones, fechaRealizado } = await req.json();
if (!autoId || !descripcion || !fechaRealizado)
return NextResponse.json({ error: "Datos incompletos" }, { status: 400 });
const registro = await prisma.historialServicio.create({
data: {
autoId,
adminId: sesion.sub,
tipoServicioId: tipoServicioId || null,
descripcion,
observaciones: observaciones || null,
fechaRealizado: new Date(fechaRealizado),
},
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
});
return NextResponse.json(registro, { status: 201 });
}
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params;
const data = await req.json();
const servicio = await prisma.tipoServicio.update({
where: { id },
data: {
...(data.nombre !== undefined && { nombre: data.nombre }),
...(data.duracionHoras !== undefined && { duracionHoras: Number(data.duracionHoras) }),
...(data.activo !== undefined && { activo: data.activo }),
},
});
return NextResponse.json(servicio);
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET() {
const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const servicios = await prisma.tipoServicio.findMany({ orderBy: { nombre: "asc" } });
return NextResponse.json(servicios);
}
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { nombre, duracionHoras } = await req.json();
if (!nombre || !duracionHoras)
return NextResponse.json({ error: "nombre y duracionHoras requeridos" }, { status: 400 });
const servicio = await prisma.tipoServicio.create({
data: { nombre, duracionHoras: Number(duracionHoras) },
});
return NextResponse.json(servicio, { status: 201 });
}
+19
View File
@@ -0,0 +1,19 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { id } = await params;
const { estado } = await req.json();
const turno = await prisma.turno.update({
where: { id },
data: { estado },
include: { auto: true, tipoServicio: true, cliente: true },
});
return NextResponse.json(turno);
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("admin_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
const fecha = searchParams.get("fecha"); // YYYY-MM-DD
const where = fecha
? {
fechaInicio: {
gte: new Date(`${fecha}T00:00:00`),
lte: new Date(`${fecha}T23:59:59`),
},
}
: {};
const turnos = await prisma.turno.findMany({
where,
include: {
cliente: { select: { nombre: true, apellido: true, telefono: true } },
auto: true,
tipoServicio: true,
},
orderBy: { fechaInicio: "asc" },
});
return NextResponse.json(turnos);
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { crearSesionAdmin } from "@/lib/auth-admin";
import bcrypt from "bcryptjs";
export async function POST(req: NextRequest) {
const { email, password } = await req.json();
if (!email || !password)
return NextResponse.json({ error: "Credenciales requeridas" }, { status: 400 });
const admin = await prisma.admin.findUnique({ where: { email: email.toLowerCase() } });
if (!admin) return NextResponse.json({ error: "Credenciales incorrectas" }, { status: 401 });
const ok = await bcrypt.compare(password, admin.passwordHash);
if (!ok) return NextResponse.json({ error: "Credenciales incorrectas" }, { status: 401 });
const jwt = await crearSesionAdmin(admin.id);
const response = NextResponse.json({ ok: true, admin: { id: admin.id, nombre: admin.nombre } });
response.cookies.set("admin_session", jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 8,
path: "/",
});
return response;
}
+12
View File
@@ -0,0 +1,12 @@
import { NextRequest, NextResponse } from "next/server";
import { enviarMagicLink } from "@/lib/magic-link";
export async function POST(req: NextRequest) {
const { email } = await req.json();
if (!email) return NextResponse.json({ error: "Email requerido" }, { status: 400 });
const result = await enviarMagicLink(email.toLowerCase().trim());
if (!result.ok) return NextResponse.json({ error: result.error }, { status: 404 });
return NextResponse.json({ ok: true });
}
+23
View File
@@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from "next/server";
import { validarMagicToken } from "@/lib/magic-link";
import { crearSesionCliente } from "@/lib/auth-admin";
export async function POST(req: NextRequest) {
const { token } = await req.json();
if (!token) return NextResponse.json({ error: "Token requerido" }, { status: 400 });
const cliente = await validarMagicToken(token);
if (!cliente) return NextResponse.json({ error: "Token inválido o expirado" }, { status: 401 });
const jwt = await crearSesionCliente(cliente.id);
const response = NextResponse.json({ ok: true, cliente: { id: cliente.id, nombre: cliente.nombre } });
response.cookies.set("cliente_session", jwt, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24,
path: "/",
});
return response;
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET() {
const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const autos = await prisma.auto.findMany({
where: { clienteId: sesion.sub },
orderBy: { createdAt: "desc" },
});
return NextResponse.json(autos);
}
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { patente, marca, modelo, anio } = await req.json();
if (!patente || !marca || !modelo || !anio)
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 });
const existe = await prisma.auto.findUnique({ where: { patente: patente.toUpperCase() } });
if (existe) return NextResponse.json({ error: "Patente ya registrada" }, { status: 409 });
const auto = await prisma.auto.create({
data: {
clienteId: sesion.sub,
patente: patente.toUpperCase().trim(),
marca,
modelo,
anio: Number(anio),
},
});
return NextResponse.json(auto, { status: 201 });
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
const autoId = searchParams.get("autoId");
if (!autoId) return NextResponse.json({ error: "autoId requerido" }, { status: 400 });
// Verificar que el auto pertenece al cliente
const auto = await prisma.auto.findFirst({ where: { id: autoId, clienteId: sesion.sub } });
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
const historial = await prisma.historialServicio.findMany({
where: { autoId },
include: { admin: { select: { nombre: true } }, tipoServicio: { select: { nombre: true } } },
orderBy: { fechaRealizado: "desc" },
});
return NextResponse.json(historial);
}
+18
View File
@@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
export async function POST(req: NextRequest) {
const { email, nombre, apellido, telefono } = await req.json();
if (!email || !nombre || !apellido || !telefono)
return NextResponse.json({ error: "Todos los campos son requeridos" }, { status: 400 });
const existe = await prisma.cliente.findUnique({ where: { email: email.toLowerCase() } });
if (existe) return NextResponse.json({ error: "El email ya está registrado" }, { status: 409 });
const cliente = await prisma.cliente.create({
data: { email: email.toLowerCase().trim(), nombre, apellido, telefono },
});
return NextResponse.json({ ok: true, id: cliente.id }, { status: 201 });
}
+21
View File
@@ -0,0 +1,21 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import { obtenerSlotsDisponibles } from "@/lib/scheduling";
export async function GET(req: NextRequest) {
const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { searchParams } = new URL(req.url);
const tipoServicioId = searchParams.get("tipoServicioId");
if (!tipoServicioId)
return NextResponse.json({ error: "tipoServicioId requerido" }, { status: 400 });
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
if (!servicio || !servicio.activo)
return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 });
const slots = await obtenerSlotsDisponibles(servicio.duracionHoras);
return NextResponse.json(slots);
}
+50
View File
@@ -0,0 +1,50 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { verificarSesion } from "@/lib/auth-admin";
import { slotEstaDisponible } from "@/lib/scheduling";
export async function GET() {
const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const turnos = await prisma.turno.findMany({
where: { clienteId: sesion.sub },
include: { auto: true, tipoServicio: true },
orderBy: { fechaInicio: "desc" },
});
return NextResponse.json(turnos);
}
export async function POST(req: NextRequest) {
const sesion = await verificarSesion("cliente_session");
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
const { autoId, tipoServicioId, fechaInicio } = await req.json();
if (!autoId || !tipoServicioId || !fechaInicio)
return NextResponse.json({ error: "Datos incompletos" }, { status: 400 });
// Verificar que el auto pertenece al cliente
const auto = await prisma.auto.findFirst({
where: { id: autoId, clienteId: sesion.sub },
});
if (!auto) return NextResponse.json({ error: "Auto no encontrado" }, { status: 404 });
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
if (!servicio || !servicio.activo)
return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 });
const inicio = new Date(fechaInicio);
const fin = new Date(inicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
const disponible = await slotEstaDisponible(inicio, fin);
if (!disponible)
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
const turno = await prisma.turno.create({
data: { clienteId: sesion.sub, autoId, tipoServicioId, fechaInicio: inicio, fechaFin: fin },
include: { auto: true, tipoServicio: true },
});
return NextResponse.json(turno, { status: 201 });
}