From 0792ac37678986bb23a41c536f57a429578c8843 Mon Sep 17 00:00:00 2001 From: Beldor Date: Mon, 29 Jun 2026 22:28:44 -0300 Subject: [PATCH] =?UTF-8?q?Add=20jefe=20mec=C3=A1nico=20to=20client=20book?= =?UTF-8?q?ing=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cliente now picks a jefe mecánico after choosing the service so the calendar shows that specific jefe's availability. Each jefe has its own configurable parallel slot capacity (replacing the global SLOTS_PARALELOS = 5). Admins manage jefes from /admin/jefes; jefes log in to /jefe to see their daily agenda. Co-Authored-By: Claude Opus 4.7 --- prisma/schema.prisma | 14 ++ src/app/admin/jefes/page.tsx | 213 ++++++++++++++++++++++++++ src/app/admin/layout.tsx | 1 + src/app/api/admin/jefes/[id]/route.ts | 41 +++++ src/app/api/admin/jefes/route.ts | 40 +++++ src/app/api/auth/jefe-login/route.ts | 31 ++++ src/app/api/auth/jefe-logout/route.ts | 7 + src/app/api/cliente/jefes/route.ts | 15 ++ src/app/api/cliente/slots/route.ts | 5 +- src/app/api/cliente/turnos/route.ts | 24 ++- src/app/api/jefe/turnos/route.ts | 32 ++++ src/app/cliente/turnos/page.tsx | 80 +++++++++- src/app/jefe/dashboard/page.tsx | 104 +++++++++++++ src/app/jefe/layout.tsx | 31 ++++ src/app/jefe/page.tsx | 51 ++++++ src/lib/auth-admin.ts | 8 + src/lib/scheduling.ts | 36 ++++- src/middleware.ts | 8 +- 18 files changed, 719 insertions(+), 22 deletions(-) create mode 100644 src/app/admin/jefes/page.tsx create mode 100644 src/app/api/admin/jefes/[id]/route.ts create mode 100644 src/app/api/admin/jefes/route.ts create mode 100644 src/app/api/auth/jefe-login/route.ts create mode 100644 src/app/api/auth/jefe-logout/route.ts create mode 100644 src/app/api/cliente/jefes/route.ts create mode 100644 src/app/api/jefe/turnos/route.ts create mode 100644 src/app/jefe/dashboard/page.tsx create mode 100644 src/app/jefe/layout.tsx create mode 100644 src/app/jefe/page.tsx diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 719b433..fad811c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -49,6 +49,7 @@ model Turno { clienteId String autoId String tipoServicioId String + jefeMecanicoId String fechaInicio DateTime fechaFin DateTime kilometros Int @@ -59,6 +60,19 @@ model Turno { cliente Cliente @relation(fields: [clienteId], references: [id]) auto Auto @relation(fields: [autoId], references: [id]) tipoServicio TipoServicio @relation(fields: [tipoServicioId], references: [id]) + jefeMecanico JefeMecanico @relation(fields: [jefeMecanicoId], references: [id]) +} + +model JefeMecanico { + id String @id @default(cuid()) + nombre String + email String @unique + passwordHash String + slotsParalelos Int @default(1) + activo Boolean @default(true) + createdAt DateTime @default(now()) + + turnos Turno[] } model HistorialServicio { diff --git a/src/app/admin/jefes/page.tsx b/src/app/admin/jefes/page.tsx new file mode 100644 index 0000000..232f2c7 --- /dev/null +++ b/src/app/admin/jefes/page.tsx @@ -0,0 +1,213 @@ +"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 Jefe { + id: string; + nombre: string; + email: string; + slotsParalelos: number; + activo: boolean; +} + +interface FormState { + nombre: string; + email: string; + password: string; + slotsParalelos: string; +} + +const FORM_VACIO: FormState = { nombre: "", email: "", password: "", slotsParalelos: "1" }; + +export default function JefesPage() { + const [jefes, setJefes] = useState([]); + const [loading, setLoading] = useState(true); + const [mostrarForm, setMostrarForm] = useState(false); + const [form, setForm] = useState(FORM_VACIO); + const [guardando, setGuardando] = useState(false); + const [editando, setEditando] = useState(null); + const [errorForm, setErrorForm] = useState(""); + + useEffect(() => { cargar(); }, []); + + async function cargar() { + setLoading(true); + const res = await fetch("/api/admin/jefes"); + setJefes(await res.json()); + setLoading(false); + } + + function set(f: keyof FormState) { + return (e: React.ChangeEvent) => + setForm((v) => ({ ...v, [f]: e.target.value })); + } + + async function guardar(e: React.FormEvent) { + e.preventDefault(); + setGuardando(true); + setErrorForm(""); + + const slots = Number(form.slotsParalelos); + if (!Number.isInteger(slots) || slots < 1) { + setErrorForm("Slots en paralelo debe ser un entero >= 1"); + setGuardando(false); + return; + } + + if (editando) { + const body: Record = { + nombre: form.nombre, + email: form.email, + slotsParalelos: slots, + }; + if (form.password) body.password = form.password; + const res = await fetch(`/api/admin/jefes/${editando.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setErrorForm(data?.error || "Error al guardar"); + setGuardando(false); + return; + } + } else { + if (!form.password) { + setErrorForm("Contraseña requerida"); + setGuardando(false); + return; + } + const res = await fetch("/api/admin/jefes", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + nombre: form.nombre, + email: form.email, + password: form.password, + slotsParalelos: slots, + }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setErrorForm(data?.error || "Error al guardar"); + setGuardando(false); + return; + } + } + + setGuardando(false); + setMostrarForm(false); + setEditando(null); + setForm(FORM_VACIO); + cargar(); + } + + async function toggleActivo(j: Jefe) { + await fetch(`/api/admin/jefes/${j.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ activo: !j.activo }), + }); + cargar(); + } + + function iniciarEdicion(j: Jefe) { + setEditando(j); + setForm({ + nombre: j.nombre, + email: j.email, + password: "", + slotsParalelos: String(j.slotsParalelos), + }); + setMostrarForm(true); + } + + return ( +
+
+

Jefes mecánicos

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

{errorForm}

} +
+ + +
+
+
+ )} + + {loading ? ( +
Cargando...
+ ) : ( +
+ {jefes.map((j) => ( +
+
+

+ {j.nombre} +

+

+ {j.email} · {j.slotsParalelos} {j.slotsParalelos === 1 ? "turno paralelo" : "turnos paralelos"} +

+
+
+ + +
+
+ ))} + {jefes.length === 0 && ( +

No hay jefes mecánicos cargados.

+ )} +
+ )} +
+ ); +} diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx index bda7b3d..cb3c288 100644 --- a/src/app/admin/layout.tsx +++ b/src/app/admin/layout.tsx @@ -7,6 +7,7 @@ const navItems = [ { href: "/admin/historial", label: "Historial", icon: "📋" }, { href: "/admin/clientes", label: "Clientes", icon: "👥" }, { href: "/admin/servicios", label: "Servicios", icon: "⚙️" }, + { href: "/admin/jefes", label: "Jefes", icon: "🔧" }, ]; export default function AdminLayout({ children }: { children: React.ReactNode }) { diff --git a/src/app/api/admin/jefes/[id]/route.ts b/src/app/api/admin/jefes/[id]/route.ts new file mode 100644 index 0000000..6f3d04a --- /dev/null +++ b/src/app/api/admin/jefes/[id]/route.ts @@ -0,0 +1,41 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { verificarSesion } from "@/lib/auth-admin"; +import bcrypt from "bcryptjs"; + +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 update: { + nombre?: string; + email?: string; + slotsParalelos?: number; + activo?: boolean; + passwordHash?: string; + } = {}; + + if (data.nombre !== undefined) update.nombre = data.nombre; + if (data.email !== undefined) update.email = String(data.email).toLowerCase().trim(); + if (data.slotsParalelos !== undefined) { + const slots = Number(data.slotsParalelos); + if (!Number.isInteger(slots) || slots < 1) + return NextResponse.json({ error: "slotsParalelos debe ser un entero >= 1" }, { status: 400 }); + update.slotsParalelos = slots; + } + if (data.activo !== undefined) update.activo = !!data.activo; + if (data.password) { + update.passwordHash = await bcrypt.hash(data.password, 10); + } + + const jefe = await prisma.jefeMecanico.update({ + where: { id }, + data: update, + select: { id: true, nombre: true, email: true, slotsParalelos: true, activo: true, createdAt: true }, + }); + + return NextResponse.json(jefe); +} diff --git a/src/app/api/admin/jefes/route.ts b/src/app/api/admin/jefes/route.ts new file mode 100644 index 0000000..8ee446c --- /dev/null +++ b/src/app/api/admin/jefes/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { verificarSesion } from "@/lib/auth-admin"; +import bcrypt from "bcryptjs"; + +export async function GET() { + const sesion = await verificarSesion("admin_session"); + if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const jefes = await prisma.jefeMecanico.findMany({ + orderBy: { nombre: "asc" }, + select: { id: true, nombre: true, email: true, slotsParalelos: true, activo: true, createdAt: true }, + }); + return NextResponse.json(jefes); +} + +export async function POST(req: NextRequest) { + const sesion = await verificarSesion("admin_session"); + if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const { nombre, email, password, slotsParalelos } = await req.json(); + if (!nombre || !email || !password) + return NextResponse.json({ error: "nombre, email y password requeridos" }, { status: 400 }); + + const slots = Number(slotsParalelos); + if (!Number.isInteger(slots) || slots < 1) + return NextResponse.json({ error: "slotsParalelos debe ser un entero >= 1" }, { status: 400 }); + + const emailNorm = String(email).toLowerCase().trim(); + const existente = await prisma.jefeMecanico.findUnique({ where: { email: emailNorm } }); + if (existente) return NextResponse.json({ error: "Ya existe un jefe con ese email" }, { status: 409 }); + + const passwordHash = await bcrypt.hash(password, 10); + const jefe = await prisma.jefeMecanico.create({ + data: { nombre, email: emailNorm, passwordHash, slotsParalelos: slots }, + select: { id: true, nombre: true, email: true, slotsParalelos: true, activo: true, createdAt: true }, + }); + + return NextResponse.json(jefe, { status: 201 }); +} diff --git a/src/app/api/auth/jefe-login/route.ts b/src/app/api/auth/jefe-login/route.ts new file mode 100644 index 0000000..c6491b3 --- /dev/null +++ b/src/app/api/auth/jefe-login/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { crearSesionJefe } 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 jefe = await prisma.jefeMecanico.findUnique({ + where: { email: String(email).toLowerCase().trim() }, + }); + if (!jefe || !jefe.activo) + return NextResponse.json({ error: "Credenciales incorrectas" }, { status: 401 }); + + const ok = await bcrypt.compare(password, jefe.passwordHash); + if (!ok) return NextResponse.json({ error: "Credenciales incorrectas" }, { status: 401 }); + + const jwt = await crearSesionJefe(jefe.id); + + const response = NextResponse.json({ ok: true, jefe: { id: jefe.id, nombre: jefe.nombre } }); + response.cookies.set("jefe_session", jwt, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + maxAge: 60 * 60 * 8, + path: "/", + }); + return response; +} diff --git a/src/app/api/auth/jefe-logout/route.ts b/src/app/api/auth/jefe-logout/route.ts new file mode 100644 index 0000000..d2a6808 --- /dev/null +++ b/src/app/api/auth/jefe-logout/route.ts @@ -0,0 +1,7 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + const response = NextResponse.json({ ok: true }); + response.cookies.set("jefe_session", "", { maxAge: 0, path: "/" }); + return response; +} diff --git a/src/app/api/cliente/jefes/route.ts b/src/app/api/cliente/jefes/route.ts new file mode 100644 index 0000000..3c358e0 --- /dev/null +++ b/src/app/api/cliente/jefes/route.ts @@ -0,0 +1,15 @@ +import { 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 jefes = await prisma.jefeMecanico.findMany({ + where: { activo: true }, + orderBy: { nombre: "asc" }, + select: { id: true, nombre: true }, + }); + return NextResponse.json(jefes); +} diff --git a/src/app/api/cliente/slots/route.ts b/src/app/api/cliente/slots/route.ts index f26cba0..3540c1a 100644 --- a/src/app/api/cliente/slots/route.ts +++ b/src/app/api/cliente/slots/route.ts @@ -9,13 +9,16 @@ export async function GET(req: NextRequest) { const { searchParams } = new URL(req.url); const tipoServicioId = searchParams.get("tipoServicioId"); + const jefeMecanicoId = searchParams.get("jefeMecanicoId"); if (!tipoServicioId) return NextResponse.json({ error: "tipoServicioId requerido" }, { status: 400 }); + if (!jefeMecanicoId) + return NextResponse.json({ error: "jefeMecanicoId 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, 60, 500); + const slots = await obtenerSlotsDisponibles(jefeMecanicoId, servicio.duracionHoras, 60, 500); return NextResponse.json(slots); } diff --git a/src/app/api/cliente/turnos/route.ts b/src/app/api/cliente/turnos/route.ts index 478f836..dec5265 100644 --- a/src/app/api/cliente/turnos/route.ts +++ b/src/app/api/cliente/turnos/route.ts @@ -9,7 +9,7 @@ export async function GET() { const turnos = await prisma.turno.findMany({ where: { clienteId: sesion.sub }, - include: { auto: true, tipoServicio: true }, + include: { auto: true, tipoServicio: true, jefeMecanico: { select: { id: true, nombre: true } } }, orderBy: { fechaInicio: "desc" }, }); @@ -20,8 +20,8 @@ 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, kilometros } = await req.json(); - if (!autoId || !tipoServicioId || !fechaInicio || !kilometros) + const { autoId, tipoServicioId, jefeMecanicoId, fechaInicio, kilometros } = await req.json(); + if (!autoId || !tipoServicioId || !jefeMecanicoId || !fechaInicio || !kilometros) return NextResponse.json({ error: "Datos incompletos" }, { status: 400 }); const km = Number(kilometros); @@ -38,16 +38,28 @@ export async function POST(req: NextRequest) { if (!servicio || !servicio.activo) return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 }); + const jefe = await prisma.jefeMecanico.findUnique({ where: { id: jefeMecanicoId } }); + if (!jefe || !jefe.activo) + return NextResponse.json({ error: "Jefe mecánico 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); + const disponible = await slotEstaDisponible(inicio, fin, jefeMecanicoId); 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, kilometros: km }, - include: { auto: true, tipoServicio: true }, + data: { + clienteId: sesion.sub, + autoId, + tipoServicioId, + jefeMecanicoId, + fechaInicio: inicio, + fechaFin: fin, + kilometros: km, + }, + include: { auto: true, tipoServicio: true, jefeMecanico: { select: { id: true, nombre: true } } }, }); return NextResponse.json(turno, { status: 201 }); diff --git a/src/app/api/jefe/turnos/route.ts b/src/app/api/jefe/turnos/route.ts new file mode 100644 index 0000000..a89bfe9 --- /dev/null +++ b/src/app/api/jefe/turnos/route.ts @@ -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("jefe_session"); + if (!sesion || sesion.role !== "jefe") + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const { searchParams } = new URL(req.url); + const fecha = searchParams.get("fecha"); // YYYY-MM-DD + + const where: Record = { jefeMecanicoId: sesion.sub }; + if (fecha) { + where.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); +} diff --git a/src/app/cliente/turnos/page.tsx b/src/app/cliente/turnos/page.tsx index 055474e..fb30a48 100644 --- a/src/app/cliente/turnos/page.tsx +++ b/src/app/cliente/turnos/page.tsx @@ -7,17 +7,20 @@ import Button from "@/components/ui/Button"; import Badge from "@/components/ui/Badge"; interface TipoServicio { id: string; nombre: string; duracionHoras: number; } +interface Jefe { id: string; nombre: string; } 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 }; + tipoServicio: { nombre: string }; + auto: { marca: string; modelo: string; patente: string }; + jefeMecanico?: { nombre: string } | null; } const MESES = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"]; const DIAS_HEADER = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"]; -type Step = "servicio" | "calendario" | "confirmado"; +type Step = "servicio" | "jefe" | "calendario" | "confirmado"; function formatDuracion(h: number) { if (h < 1) return `${Math.round(h * 60)} min`; @@ -66,6 +69,9 @@ function TurnosContent() { const [step, setStep] = useState("servicio"); const [servicios, setServicios] = useState([]); const [servicioSel, setServicioSel] = useState(null); + const [jefes, setJefes] = useState([]); + const [jefeSel, setJefeSel] = useState(null); + const [loadingJefes, setLoadingJefes] = useState(false); const [slots, setSlots] = useState([]); const [loadingSlots, setLoadingSlots] = useState(false); const [misTurnos, setMisTurnos] = useState([]); @@ -106,14 +112,29 @@ function TurnosContent() { async function seleccionarServicio(s: TipoServicio) { setServicioSel(s); + setJefeSel(null); + setSlots([]); + setDiaSeleccionado(null); + setSlotSel(null); + setLoadingJefes(true); + setStep("jefe"); + const res = await fetch("/api/cliente/jefes"); + const data = await res.json(); + setJefes(Array.isArray(data) ? data : []); + setLoadingJefes(false); + } + + async function seleccionarJefe(j: Jefe) { + if (!servicioSel) return; + setJefeSel(j); setLoadingSlots(true); setSlots([]); setDiaSeleccionado(null); setSlotSel(null); setStep("calendario"); - const res = await fetch(`/api/cliente/slots?tipoServicioId=${s.id}`); + const res = await fetch(`/api/cliente/slots?tipoServicioId=${servicioSel.id}&jefeMecanicoId=${j.id}`); const data = await res.json(); - setSlots(data); + setSlots(Array.isArray(data) ? data : []); setLoadingSlots(false); } @@ -138,13 +159,19 @@ function TurnosContent() { } async function reservar() { - if (!slotSel || !servicioSel || !autoId || !kilometros || kmError) return; + if (!slotSel || !servicioSel || !jefeSel || !autoId || !kilometros || kmError) return; setReservando(true); setReservaError(""); const res = await fetch("/api/cliente/turnos", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ autoId, tipoServicioId: servicioSel.id, fechaInicio: slotSel.fechaInicio, kilometros: Number(kilometros) }), + body: JSON.stringify({ + autoId, + tipoServicioId: servicioSel.id, + jefeMecanicoId: jefeSel.id, + fechaInicio: slotSel.fechaInicio, + kilometros: Number(kilometros), + }), }); const data = await res.json(); setReservando(false); @@ -188,6 +215,12 @@ function TurnosContent() {

Servicio

{turnoCreado.tipoServicio.nombre}

+ {turnoCreado.jefeMecanico && ( + <> +

Jefe mecánico

+

{turnoCreado.jefeMecanico.nombre}

+ + )}

Fecha

{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", { timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long" @@ -203,12 +236,16 @@ function TurnosContent() {

{step === "calendario" ? ( + + ) : step === "jefe" ? ( ) : ( )}

- {step === "servicio" ? "Elegí el servicio" : `${servicioSel?.nombre}`} + {step === "servicio" && "Elegí el servicio"} + {step === "jefe" && "Elegí el jefe mecánico"} + {step === "calendario" && `${servicioSel?.nombre}${jefeSel ? ` · ${jefeSel.nombre}` : ""}`}

@@ -251,6 +288,35 @@ function TurnosContent() { )} + {/* STEP: jefe */} + {step === "jefe" && ( +
+ {loadingJefes ? ( +

Cargando jefes...

+ ) : jefes.length === 0 ? ( + +

+ No hay jefes mecánicos disponibles en este momento. Intentá más tarde. +

+
+ ) : ( + jefes.map((j) => ( + + )) + )} +
+ )} + {/* STEP: calendario */} {step === "calendario" && (
diff --git a/src/app/jefe/dashboard/page.tsx b/src/app/jefe/dashboard/page.tsx new file mode 100644 index 0000000..dcc1e10 --- /dev/null +++ b/src/app/jefe/dashboard/page.tsx @@ -0,0 +1,104 @@ +"use client"; +import { useEffect, useState } from "react"; +import Card from "@/components/ui/Card"; +import Badge from "@/components/ui/Badge"; + +interface Turno { + id: string; + fechaInicio: string; + fechaFin: string; + estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO"; + comentario: string | null; + kilometros: number; + tipoServicio: { nombre: string }; + auto: { marca: string; modelo: string; patente: string }; + cliente: { nombre: string; apellido: string; telefono: string }; +} + +function hoyArg() { + return new Date().toLocaleDateString("sv-SE", { timeZone: "America/Argentina/Buenos_Aires" }); +} + +function formatHora(iso: string) { + return new Date(iso).toLocaleTimeString("es-AR", { + timeZone: "America/Argentina/Buenos_Aires", + hour: "2-digit", + minute: "2-digit", + }); +} + +export default function JefeDashboard() { + const [fecha, setFecha] = useState(() => hoyArg()); + const [turnos, setTurnos] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { cargar(); }, [fecha]); + + async function cargar() { + setLoading(true); + const res = await fetch(`/api/jefe/turnos?fecha=${fecha}`); + const data = await res.json(); + setTurnos(Array.isArray(data) ? data : []); + setLoading(false); + } + + 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 w-full sm:w-auto" + /> +
+ + {loading ? ( +
Cargando...
+ ) : turnos.length === 0 ? ( + +

No tenés 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} + Kilometraje + {t.kilometros.toLocaleString("es-AR")} km +
+ + {t.comentario && ( +
+

Comentario

+

{t.comentario}

+
+ )} +
+
+ ))} +
+ )} +
+ ); +} diff --git a/src/app/jefe/layout.tsx b/src/app/jefe/layout.tsx new file mode 100644 index 0000000..266b018 --- /dev/null +++ b/src/app/jefe/layout.tsx @@ -0,0 +1,31 @@ +"use client"; +import { usePathname, useRouter } from "next/navigation"; + +export default function JefeLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const router = useRouter(); + + async function cerrarSesion() { + await fetch("/api/auth/jefe-logout", { method: "POST" }); + router.push("/jefe"); + } + + if (pathname === "/jefe") return <>{children}; + + return ( +
+
+
+

🔧 Mi agenda

+ +
+
{children}
+
+
+ ); +} diff --git a/src/app/jefe/page.tsx b/src/app/jefe/page.tsx new file mode 100644 index 0000000..922d36b --- /dev/null +++ b/src/app/jefe/page.tsx @@ -0,0 +1,51 @@ +"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 JefeLoginPage() { + 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) => + setForm((v) => ({ ...v, [f]: e.target.value })); + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setError(""); + const res = await fetch("/api/auth/jefe-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("/jefe/dashboard"); + } + + return ( +
+ +
+
🔧
+

Agenda del jefe mecánico

+

Ingresá con tus credenciales

+
+
+ + + {error &&

{error}

} + +
+
+
+ ); +} diff --git a/src/lib/auth-admin.ts b/src/lib/auth-admin.ts index cbc0c63..b632d71 100644 --- a/src/lib/auth-admin.ts +++ b/src/lib/auth-admin.ts @@ -19,6 +19,14 @@ export async function crearSesionCliente(clienteId: string): Promise { return token; } +export async function crearSesionJefe(jefeId: string): Promise { + const token = await new SignJWT({ sub: jefeId, role: "jefe" }) + .setProtectedHeader({ alg: "HS256" }) + .setExpirationTime("8h") + .sign(SECRET); + return token; +} + export async function verificarSesion( cookieName: string ): Promise<{ sub: string; role: string } | null> { diff --git a/src/lib/scheduling.ts b/src/lib/scheduling.ts index 65855b0..4a05f0f 100644 --- a/src/lib/scheduling.ts +++ b/src/lib/scheduling.ts @@ -1,6 +1,5 @@ import { prisma } from "./prisma"; -const SLOTS_PARALELOS = 5; const INICIO_H = 8; const INICIO_M = 30; const FIN_H = 17; @@ -51,9 +50,14 @@ function horasDentroDeJornada(inicio: Date, fin: Date): boolean { return mismodia && iMin >= INICIO_MINUTOS && fMin <= FIN_MINUTOS; } -async function turnosSolapados(inicio: Date, fin: Date): Promise { +async function turnosSolapados( + inicio: Date, + fin: Date, + jefeMecanicoId: string +): Promise { const count = await prisma.turno.count({ where: { + jefeMecanicoId, estado: { in: ["PENDIENTE", "CONFIRMADO"] }, AND: [{ fechaInicio: { lt: fin } }, { fechaFin: { gt: inicio } }], }, @@ -61,16 +65,28 @@ async function turnosSolapados(inicio: Date, fin: Date): Promise { return count; } +async function obtenerJefeActivo(jefeMecanicoId: string) { + return prisma.jefeMecanico.findUnique({ + where: { id: jefeMecanicoId }, + select: { slotsParalelos: true, activo: true }, + }); +} + export interface SlotDisponible { fechaInicio: Date; fechaFin: Date; } export async function obtenerSlotsDisponibles( + jefeMecanicoId: string, duracionHoras: number, diasAdelante = 14, limite = 300 ): Promise { + const jefe = await obtenerJefeActivo(jefeMecanicoId); + if (!jefe || !jefe.activo) return []; + const cap = jefe.slotsParalelos; + const slots: SlotDisponible[] = []; const ahora = new Date(); const duracionMs = duracionHoras * 60 * 60 * 1000; @@ -103,8 +119,8 @@ export async function obtenerSlotsDisponibles( continue; } - const ocupados = await turnosSolapados(cursor, fin); - if (ocupados < SLOTS_PARALELOS) { + const ocupados = await turnosSolapados(cursor, fin, jefeMecanicoId); + if (ocupados < cap) { slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) }); } @@ -114,8 +130,14 @@ export async function obtenerSlotsDisponibles( return slots; } -export async function slotEstaDisponible(inicio: Date, fin: Date): Promise { +export async function slotEstaDisponible( + inicio: Date, + fin: Date, + jefeMecanicoId: string +): Promise { if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false; - const ocupados = await turnosSolapados(inicio, fin); - return ocupados < SLOTS_PARALELOS; + const jefe = await obtenerJefeActivo(jefeMecanicoId); + if (!jefe || !jefe.activo) return false; + const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId); + return ocupados < jefe.slotsParalelos; } diff --git a/src/middleware.ts b/src/middleware.ts index 00b3b27..1fd0c06 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -28,14 +28,20 @@ export async function middleware(req: NextRequest) { pathname.startsWith("/admin/turnos") || pathname.startsWith("/admin/historial") || pathname.startsWith("/admin/servicios") || + pathname.startsWith("/admin/jefes") || pathname.startsWith("/admin/clientes")) { const ok = await verifyCookie(req, "admin_session"); if (!ok) return NextResponse.redirect(new URL("/admin", req.url)); } + if (pathname.startsWith("/jefe/dashboard")) { + const ok = await verifyCookie(req, "jefe_session"); + if (!ok) return NextResponse.redirect(new URL("/jefe", req.url)); + } + return NextResponse.next(); } export const config = { - matcher: ["/cliente/:path*", "/admin/:path*"], + matcher: ["/cliente/:path*", "/admin/:path*", "/jefe/:path*"], };