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:
@@ -49,6 +49,7 @@ model Turno {
|
|||||||
clienteId String
|
clienteId String
|
||||||
autoId String
|
autoId String
|
||||||
tipoServicioId String
|
tipoServicioId String
|
||||||
|
jefeMecanicoId String
|
||||||
fechaInicio DateTime
|
fechaInicio DateTime
|
||||||
fechaFin DateTime
|
fechaFin DateTime
|
||||||
kilometros Int
|
kilometros Int
|
||||||
@@ -59,6 +60,19 @@ model Turno {
|
|||||||
cliente Cliente @relation(fields: [clienteId], references: [id])
|
cliente Cliente @relation(fields: [clienteId], references: [id])
|
||||||
auto Auto @relation(fields: [autoId], references: [id])
|
auto Auto @relation(fields: [autoId], references: [id])
|
||||||
tipoServicio TipoServicio @relation(fields: [tipoServicioId], 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 {
|
model HistorialServicio {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ const navItems = [
|
|||||||
{ href: "/admin/historial", label: "Historial", icon: "📋" },
|
{ href: "/admin/historial", label: "Historial", icon: "📋" },
|
||||||
{ href: "/admin/clientes", label: "Clientes", icon: "👥" },
|
{ href: "/admin/clientes", label: "Clientes", icon: "👥" },
|
||||||
{ href: "/admin/servicios", label: "Servicios", icon: "⚙️" },
|
{ href: "/admin/servicios", label: "Servicios", icon: "⚙️" },
|
||||||
|
{ href: "/admin/jefes", label: "Jefes", icon: "🔧" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -9,13 +9,16 @@ export async function GET(req: NextRequest) {
|
|||||||
|
|
||||||
const { searchParams } = new URL(req.url);
|
const { searchParams } = new URL(req.url);
|
||||||
const tipoServicioId = searchParams.get("tipoServicioId");
|
const tipoServicioId = searchParams.get("tipoServicioId");
|
||||||
|
const jefeMecanicoId = searchParams.get("jefeMecanicoId");
|
||||||
if (!tipoServicioId)
|
if (!tipoServicioId)
|
||||||
return NextResponse.json({ error: "tipoServicioId requerido" }, { status: 400 });
|
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 } });
|
const servicio = await prisma.tipoServicio.findUnique({ where: { id: tipoServicioId } });
|
||||||
if (!servicio || !servicio.activo)
|
if (!servicio || !servicio.activo)
|
||||||
return NextResponse.json({ error: "Servicio no encontrado" }, { status: 404 });
|
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);
|
return NextResponse.json(slots);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ export async function GET() {
|
|||||||
|
|
||||||
const turnos = await prisma.turno.findMany({
|
const turnos = await prisma.turno.findMany({
|
||||||
where: { clienteId: sesion.sub },
|
where: { clienteId: sesion.sub },
|
||||||
include: { auto: true, tipoServicio: true },
|
include: { auto: true, tipoServicio: true, jefeMecanico: { select: { id: true, nombre: true } } },
|
||||||
orderBy: { fechaInicio: "desc" },
|
orderBy: { fechaInicio: "desc" },
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@ export async function POST(req: NextRequest) {
|
|||||||
const sesion = await verificarSesion("cliente_session");
|
const sesion = await verificarSesion("cliente_session");
|
||||||
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
if (!sesion) return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
const { autoId, tipoServicioId, fechaInicio, kilometros } = await req.json();
|
const { autoId, tipoServicioId, jefeMecanicoId, fechaInicio, kilometros } = await req.json();
|
||||||
if (!autoId || !tipoServicioId || !fechaInicio || !kilometros)
|
if (!autoId || !tipoServicioId || !jefeMecanicoId || !fechaInicio || !kilometros)
|
||||||
return NextResponse.json({ error: "Datos incompletos" }, { status: 400 });
|
return NextResponse.json({ error: "Datos incompletos" }, { status: 400 });
|
||||||
|
|
||||||
const km = Number(kilometros);
|
const km = Number(kilometros);
|
||||||
@@ -38,16 +38,28 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!servicio || !servicio.activo)
|
if (!servicio || !servicio.activo)
|
||||||
return NextResponse.json({ error: "Servicio no disponible" }, { status: 404 });
|
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 inicio = new Date(fechaInicio);
|
||||||
const fin = new Date(inicio.getTime() + servicio.duracionHoras * 60 * 60 * 1000);
|
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)
|
if (!disponible)
|
||||||
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
|
return NextResponse.json({ error: "El horario ya no está disponible" }, { status: 409 });
|
||||||
|
|
||||||
const turno = await prisma.turno.create({
|
const turno = await prisma.turno.create({
|
||||||
data: { clienteId: sesion.sub, autoId, tipoServicioId, fechaInicio: inicio, fechaFin: fin, kilometros: km },
|
data: {
|
||||||
include: { auto: true, tipoServicio: true },
|
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 });
|
return NextResponse.json(turno, { status: 201 });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -7,17 +7,20 @@ import Button from "@/components/ui/Button";
|
|||||||
import Badge from "@/components/ui/Badge";
|
import Badge from "@/components/ui/Badge";
|
||||||
|
|
||||||
interface TipoServicio { id: string; nombre: string; duracionHoras: number; }
|
interface TipoServicio { id: string; nombre: string; duracionHoras: number; }
|
||||||
|
interface Jefe { id: string; nombre: string; }
|
||||||
interface Slot { fechaInicio: string; fechaFin: string; }
|
interface Slot { fechaInicio: string; fechaFin: string; }
|
||||||
interface Turno {
|
interface Turno {
|
||||||
id: string; fechaInicio: string; fechaFin: string;
|
id: string; fechaInicio: string; fechaFin: string;
|
||||||
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
|
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 MESES = ["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"];
|
||||||
const DIAS_HEADER = ["Lun","Mar","Mié","Jue","Vie","Sáb","Dom"];
|
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) {
|
function formatDuracion(h: number) {
|
||||||
if (h < 1) return `${Math.round(h * 60)} min`;
|
if (h < 1) return `${Math.round(h * 60)} min`;
|
||||||
@@ -66,6 +69,9 @@ function TurnosContent() {
|
|||||||
const [step, setStep] = useState<Step>("servicio");
|
const [step, setStep] = useState<Step>("servicio");
|
||||||
const [servicios, setServicios] = useState<TipoServicio[]>([]);
|
const [servicios, setServicios] = useState<TipoServicio[]>([]);
|
||||||
const [servicioSel, setServicioSel] = useState<TipoServicio | null>(null);
|
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 [slots, setSlots] = useState<Slot[]>([]);
|
||||||
const [loadingSlots, setLoadingSlots] = useState(false);
|
const [loadingSlots, setLoadingSlots] = useState(false);
|
||||||
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
|
const [misTurnos, setMisTurnos] = useState<Turno[]>([]);
|
||||||
@@ -106,14 +112,29 @@ function TurnosContent() {
|
|||||||
|
|
||||||
async function seleccionarServicio(s: TipoServicio) {
|
async function seleccionarServicio(s: TipoServicio) {
|
||||||
setServicioSel(s);
|
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);
|
setLoadingSlots(true);
|
||||||
setSlots([]);
|
setSlots([]);
|
||||||
setDiaSeleccionado(null);
|
setDiaSeleccionado(null);
|
||||||
setSlotSel(null);
|
setSlotSel(null);
|
||||||
setStep("calendario");
|
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();
|
const data = await res.json();
|
||||||
setSlots(data);
|
setSlots(Array.isArray(data) ? data : []);
|
||||||
setLoadingSlots(false);
|
setLoadingSlots(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,13 +159,19 @@ function TurnosContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function reservar() {
|
async function reservar() {
|
||||||
if (!slotSel || !servicioSel || !autoId || !kilometros || kmError) return;
|
if (!slotSel || !servicioSel || !jefeSel || !autoId || !kilometros || kmError) return;
|
||||||
setReservando(true);
|
setReservando(true);
|
||||||
setReservaError("");
|
setReservaError("");
|
||||||
const res = await fetch("/api/cliente/turnos", {
|
const res = await fetch("/api/cliente/turnos", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
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();
|
const data = await res.json();
|
||||||
setReservando(false);
|
setReservando(false);
|
||||||
@@ -188,6 +215,12 @@ function TurnosContent() {
|
|||||||
<Card className="text-left w-full max-w-sm">
|
<Card className="text-left w-full max-w-sm">
|
||||||
<p className="text-sm text-gray-500 mb-1">Servicio</p>
|
<p className="text-sm text-gray-500 mb-1">Servicio</p>
|
||||||
<p className="font-semibold mb-3">{turnoCreado.tipoServicio.nombre}</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="text-sm text-gray-500 mb-1">Fecha</p>
|
||||||
<p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
|
<p className="font-semibold">{new Date(turnoCreado.fechaInicio).toLocaleDateString("es-AR", {
|
||||||
timeZone: "America/Argentina/Buenos_Aires", weekday: "long", day: "numeric", month: "long"
|
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 flex-col gap-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{step === "calendario" ? (
|
{step === "calendario" ? (
|
||||||
|
<Button variant="ghost" className="px-2" onClick={() => setStep("jefe")}>←</Button>
|
||||||
|
) : step === "jefe" ? (
|
||||||
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
|
<Button variant="ghost" className="px-2" onClick={() => setStep("servicio")}>←</Button>
|
||||||
) : (
|
) : (
|
||||||
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
|
<Link href="/cliente/dashboard"><Button variant="ghost" className="px-2">←</Button></Link>
|
||||||
)}
|
)}
|
||||||
<h1 className="text-xl sm:text-2xl font-bold text-white">
|
<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>
|
</h1>
|
||||||
</div>
|
</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 */}
|
||||||
{step === "calendario" && (
|
{step === "calendario" && (
|
||||||
<div className="flex flex-col gap-4">
|
<div className="flex flex-col gap-4">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -19,6 +19,14 @@ export async function crearSesionCliente(clienteId: string): Promise<string> {
|
|||||||
return token;
|
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(
|
export async function verificarSesion(
|
||||||
cookieName: string
|
cookieName: string
|
||||||
): Promise<{ sub: string; role: string } | null> {
|
): Promise<{ sub: string; role: string } | null> {
|
||||||
|
|||||||
+29
-7
@@ -1,6 +1,5 @@
|
|||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
|
|
||||||
const SLOTS_PARALELOS = 5;
|
|
||||||
const INICIO_H = 8;
|
const INICIO_H = 8;
|
||||||
const INICIO_M = 30;
|
const INICIO_M = 30;
|
||||||
const FIN_H = 17;
|
const FIN_H = 17;
|
||||||
@@ -51,9 +50,14 @@ function horasDentroDeJornada(inicio: Date, fin: Date): boolean {
|
|||||||
return mismodia && iMin >= INICIO_MINUTOS && fMin <= FIN_MINUTOS;
|
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({
|
const count = await prisma.turno.count({
|
||||||
where: {
|
where: {
|
||||||
|
jefeMecanicoId,
|
||||||
estado: { in: ["PENDIENTE", "CONFIRMADO"] },
|
estado: { in: ["PENDIENTE", "CONFIRMADO"] },
|
||||||
AND: [{ fechaInicio: { lt: fin } }, { fechaFin: { gt: inicio } }],
|
AND: [{ fechaInicio: { lt: fin } }, { fechaFin: { gt: inicio } }],
|
||||||
},
|
},
|
||||||
@@ -61,16 +65,28 @@ async function turnosSolapados(inicio: Date, fin: Date): Promise<number> {
|
|||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function obtenerJefeActivo(jefeMecanicoId: string) {
|
||||||
|
return prisma.jefeMecanico.findUnique({
|
||||||
|
where: { id: jefeMecanicoId },
|
||||||
|
select: { slotsParalelos: true, activo: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export interface SlotDisponible {
|
export interface SlotDisponible {
|
||||||
fechaInicio: Date;
|
fechaInicio: Date;
|
||||||
fechaFin: Date;
|
fechaFin: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function obtenerSlotsDisponibles(
|
export async function obtenerSlotsDisponibles(
|
||||||
|
jefeMecanicoId: string,
|
||||||
duracionHoras: number,
|
duracionHoras: number,
|
||||||
diasAdelante = 14,
|
diasAdelante = 14,
|
||||||
limite = 300
|
limite = 300
|
||||||
): Promise<SlotDisponible[]> {
|
): Promise<SlotDisponible[]> {
|
||||||
|
const jefe = await obtenerJefeActivo(jefeMecanicoId);
|
||||||
|
if (!jefe || !jefe.activo) return [];
|
||||||
|
const cap = jefe.slotsParalelos;
|
||||||
|
|
||||||
const slots: SlotDisponible[] = [];
|
const slots: SlotDisponible[] = [];
|
||||||
const ahora = new Date();
|
const ahora = new Date();
|
||||||
const duracionMs = duracionHoras * 60 * 60 * 1000;
|
const duracionMs = duracionHoras * 60 * 60 * 1000;
|
||||||
@@ -103,8 +119,8 @@ export async function obtenerSlotsDisponibles(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ocupados = await turnosSolapados(cursor, fin);
|
const ocupados = await turnosSolapados(cursor, fin, jefeMecanicoId);
|
||||||
if (ocupados < SLOTS_PARALELOS) {
|
if (ocupados < cap) {
|
||||||
slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) });
|
slots.push({ fechaInicio: new Date(cursor), fechaFin: new Date(fin) });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -114,8 +130,14 @@ export async function obtenerSlotsDisponibles(
|
|||||||
return slots;
|
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;
|
if (!esDiaHabil(inicio) || !horasDentroDeJornada(inicio, fin)) return false;
|
||||||
const ocupados = await turnosSolapados(inicio, fin);
|
const jefe = await obtenerJefeActivo(jefeMecanicoId);
|
||||||
return ocupados < SLOTS_PARALELOS;
|
if (!jefe || !jefe.activo) return false;
|
||||||
|
const ocupados = await turnosSolapados(inicio, fin, jefeMecanicoId);
|
||||||
|
return ocupados < jefe.slotsParalelos;
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-1
@@ -28,14 +28,20 @@ export async function middleware(req: NextRequest) {
|
|||||||
pathname.startsWith("/admin/turnos") ||
|
pathname.startsWith("/admin/turnos") ||
|
||||||
pathname.startsWith("/admin/historial") ||
|
pathname.startsWith("/admin/historial") ||
|
||||||
pathname.startsWith("/admin/servicios") ||
|
pathname.startsWith("/admin/servicios") ||
|
||||||
|
pathname.startsWith("/admin/jefes") ||
|
||||||
pathname.startsWith("/admin/clientes")) {
|
pathname.startsWith("/admin/clientes")) {
|
||||||
const ok = await verifyCookie(req, "admin_session");
|
const ok = await verifyCookie(req, "admin_session");
|
||||||
if (!ok) return NextResponse.redirect(new URL("/admin", req.url));
|
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();
|
return NextResponse.next();
|
||||||
}
|
}
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/cliente/:path*", "/admin/:path*"],
|
matcher: ["/cliente/:path*", "/admin/:path*", "/jefe/:path*"],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user