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
|
||||
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 {
|
||||
|
||||
@@ -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/clientes", label: "Clientes", icon: "👥" },
|
||||
{ href: "/admin/servicios", label: "Servicios", icon: "⚙️" },
|
||||
{ href: "/admin/jefes", label: "Jefes", icon: "🔧" },
|
||||
];
|
||||
|
||||
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 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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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";
|
||||
|
||||
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">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
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
@@ -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
@@ -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*"],
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user