Add jefe mecánico to client booking flow
Deploy / deploy (push) Successful in 6m52s

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 <noreply@anthropic.com>
This commit is contained in:
Beldor
2026-06-29 22:28:44 -03:00
parent f79e5ce75c
commit 0792ac3767
18 changed files with 719 additions and 22 deletions
+14
View File
@@ -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 {
+213
View File
@@ -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<Jefe[]>([]);
const [loading, setLoading] = useState(true);
const [mostrarForm, setMostrarForm] = useState(false);
const [form, setForm] = useState<FormState>(FORM_VACIO);
const [guardando, setGuardando] = useState(false);
const [editando, setEditando] = useState<Jefe | null>(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<HTMLInputElement>) =>
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<string, unknown> = {
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 (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-white">Jefes mecánicos</h1>
<Button
onClick={() => { setMostrarForm(true); setEditando(null); setForm(FORM_VACIO); }}
className="text-sm"
>
+ Nuevo jefe
</Button>
</div>
{mostrarForm && (
<Card title={editando ? "Editar jefe" : "Nuevo jefe"}>
<form onSubmit={guardar} className="flex flex-col gap-4">
<Input id="nombre" label="Nombre" placeholder="Juan Pérez" value={form.nombre} onChange={set("nombre")} required />
<Input id="email" type="email" label="Email" placeholder="juan@taller.com" value={form.email} onChange={set("email")} required />
<Input
id="password"
type="password"
label={editando ? "Nueva contraseña (opcional)" : "Contraseña"}
placeholder={editando ? "Dejar vacío para no cambiar" : "Contraseña inicial"}
value={form.password}
onChange={set("password")}
{...(editando ? {} : { required: true })}
/>
<Input
id="slots"
type="number"
label="Turnos en paralelo"
placeholder="1"
step="1"
min="1"
max="20"
value={form.slotsParalelos}
onChange={set("slotsParalelos")}
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-200">Cargando...</div>
) : (
<div className="flex flex-col gap-3">
{jefes.map((j) => (
<div
key={j.id}
className={`flex items-center justify-between gap-3 bg-white border rounded-xl px-4 py-3 ${!j.activo ? "opacity-50" : ""}`}
>
<div>
<p className={`font-medium ${j.activo ? "text-gray-800" : "text-gray-400 line-through"}`}>
{j.nombre}
</p>
<p className="text-sm text-gray-500">
{j.email} · {j.slotsParalelos} {j.slotsParalelos === 1 ? "turno paralelo" : "turnos paralelos"}
</p>
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={() => iniciarEdicion(j)} className="text-sm px-2 py-1">
✏️
</Button>
<Button
variant={j.activo ? "secondary" : "ghost"}
onClick={() => toggleActivo(j)}
className="text-sm px-3 py-1"
>
{j.activo ? "Desactivar" : "Activar"}
</Button>
</div>
</div>
))}
{jefes.length === 0 && (
<p className="text-center text-gray-200 py-4 text-sm">No hay jefes mecánicos cargados.</p>
)}
</div>
)}
</div>
);
}
+1
View File
@@ -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 }) {
+41
View File
@@ -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);
}
+40
View File
@@ -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 });
}
+31
View File
@@ -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;
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from "next/server";
export async function POST() {
const response = NextResponse.json({ ok: true });
response.cookies.set("jefe_session", "", { maxAge: 0, path: "/" });
return response;
}
+15
View File
@@ -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);
}
+4 -1
View File
@@ -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);
}
+18 -6
View File
@@ -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 });
+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("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<string, unknown> = { 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);
}
+73 -7
View File
@@ -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<Step>("servicio");
const [servicios, setServicios] = useState<TipoServicio[]>([]);
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
const [jefes, setJefes] = useState<Jefe[]>([]);
const [jefeSel, setJefeSel] = useState<Jefe | null>(null);
const [loadingJefes, setLoadingJefes] = useState(false);
const [slots, setSlots] = useState<Slot[]>([]);
const [loadingSlots, setLoadingSlots] = useState(false);
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
@@ -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() {
<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>
{turnoCreado.jefeMecanico && (
<>
<p className="text-sm text-gray-500 mb-1">Jefe mecánico</p>
<p className="font-semibold mb-3">{turnoCreado.jefeMecanico.nombre}</p>
</>
)}
<p className="text-sm text-gray-500 mb-1">Fecha</p>
<p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long"
@@ -203,12 +236,16 @@ function TurnosContent() {
<div className="flex flex-col gap-6">
<div className="flex items-center gap-3">
{step === "calendario" ? (
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
) : step === "jefe" ? (
<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-xl sm:text-2xl font-bold text-white">
{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}` : ""}`}
</h1>
</div>
@@ -251,6 +288,35 @@ function TurnosContent() {
</>
)}
{/* STEP: jefe */}
{step === "jefe" && (
<div className="flex flex-col gap-3">
{loadingJefes ? (
<p className="text-center text-sm text-gray-200">Cargando jefes...</p>
) : jefes.length === 0 ? (
<Card>
<p className="text-sm text-gray-600 text-center">
No hay jefes mecánicos disponibles en este momento. Intentá más tarde.
</p>
</Card>
) : (
jefes.map((j) => (
<button
key={j.id}
onClick={() => seleccionarJefe(j)}
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">{j.nombre}</p>
<p className="text-sm text-gray-500 mt-0.5">Ver disponibilidad</p>
</div>
<span className="text-gray-400 text-lg">→</span>
</button>
))
)}
</div>
)}
{/* STEP: calendario */}
{step === "calendario" && (
<div className="flex flex-col gap-4">
+104
View File
@@ -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<string>(() => hoyArg());
const [turnos, setTurnos] = useState<Turno[]>([]);
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 (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between flex-wrap gap-3">
<h2 className="text-xl sm:text-2xl font-bold text-white">Turnos del día</h2>
<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 w-full sm:w-auto"
/>
</div>
{loading ? (
<div className="text-center py-10 text-gray-200">Cargando...</div>
) : turnos.length === 0 ? (
<Card>
<p className="text-center text-gray-400 py-4">No tenés 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>
<span className="text-gray-400">Kilometraje</span>
<span>{t.kilometros.toLocaleString("es-AR")} km</span>
</div>
{t.comentario && (
<div className="text-sm text-gray-600 bg-gray-50 rounded-lg p-3">
<p className="text-gray-400 mb-1">Comentario</p>
<p>{t.comentario}</p>
</div>
)}
</div>
</Card>
))}
</div>
)}
</div>
);
}
+31
View File
@@ -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 (
<div className="min-h-screen flex flex-col">
<div className="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col py-6 gap-4">
<div className="flex items-center justify-between gap-2">
<h1 className="text-lg font-semibold text-white">🔧 Mi agenda</h1>
<button
onClick={cerrarSesion}
className="text-sm text-gray-300 hover:text-red-300 transition px-3 py-1.5 rounded-lg hover:bg-white/10"
>
Cerrar sesión
</button>
</div>
<main className="flex-1">{children}</main>
</div>
</div>
);
}
+51
View File
@@ -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<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/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 (
<div className="flex flex-col items-center justify-center min-h-screen">
<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">Agenda del jefe mecánico</h1>
<p className="text-sm text-gray-500 mt-1">Ingresá con tus credenciales</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>
);
}
+8
View File
@@ -19,6 +19,14 @@ export async function crearSesionCliente(clienteId: string): Promise<string> {
return token;
}
export async function crearSesionJefe(jefeId: string): Promise<string> {
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> {
+29 -7
View File
@@ -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<number> {
async function turnosSolapados(
inicio: Date,
fin: Date,
jefeMecanicoId: string
): Promise<number> {
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<number> {
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<SlotDisponible[]> {
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<boolean> {
export async function slotEstaDisponible(
inicio: Date,
fin: Date,
jefeMecanicoId: string
): Promise<boolean> {
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;
}
+7 -1
View File
@@ -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*"],
};