diff --git a/prisma/schema.prisma b/prisma/schema.prisma index fad811c..e69b735 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -7,16 +7,18 @@ datasource db { } model Cliente { - id String @id @default(cuid()) - email String @unique - nombre String - apellido String - telefono String - createdAt DateTime @default(now()) + id String @id @default(cuid()) + email String? @unique + nombre String + apellido String + telefono String + telefonoNorm String? @unique // teléfono en formato E.164, usado para matchear al que escribe por WhatsApp + createdAt DateTime @default(now()) - autos Auto[] - turnos Turno[] - magicTokens MagicToken[] + autos Auto[] + turnos Turno[] + magicTokens MagicToken[] + conversaciones ConversacionWhatsapp[] } model Auto { @@ -35,30 +37,32 @@ model Auto { } model TipoServicio { - id String @id @default(cuid()) - nombre String - duracionHoras Float - activo Boolean @default(true) + id String @id @default(cuid()) + nombre String + duracionHoras Float + activo Boolean @default(true) + jefeMecanicoId String? // trabajo cargado por un jefe mecánico puntual; null = servicio global (legacy) - turnos Turno[] - historial HistorialServicio[] + jefeMecanico JefeMecanico? @relation(fields: [jefeMecanicoId], references: [id]) + turnos Turno[] + historial HistorialServicio[] } model Turno { id String @id @default(cuid()) clienteId String - autoId String + autoId String? tipoServicioId String jefeMecanicoId String fechaInicio DateTime fechaFin DateTime - kilometros Int + kilometros Int? estado EstadoTurno @default(PENDIENTE) comentario String? createdAt DateTime @default(now()) 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]) jefeMecanico JefeMecanico @relation(fields: [jefeMecanicoId], references: [id]) } @@ -72,7 +76,8 @@ model JefeMecanico { activo Boolean @default(true) createdAt DateTime @default(now()) - turnos Turno[] + turnos Turno[] + servicios TipoServicio[] } model HistorialServicio { @@ -117,3 +122,17 @@ enum EstadoTurno { CANCELADO COMPLETADO } + +// Estado de la conversación del bot de WhatsApp, por número de teléfono. +// El webhook es stateless: acá se guarda en qué paso está y las selecciones parciales. +model ConversacionWhatsapp { + id String @id @default(cuid()) + telefono String @unique // wa_id del cliente (E.164, sin '+') + paso String @default("INICIO") // paso de la máquina de estados + datos Json @default("{}") // selecciones parciales (mecánico, trabajo, slot, alta en curso, etc.) + clienteId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cliente Cliente? @relation(fields: [clienteId], references: [id]) +} diff --git a/src/app/api/admin/turnos/[id]/route.ts b/src/app/api/admin/turnos/[id]/route.ts index 22ff218..3fba57f 100644 --- a/src/app/api/admin/turnos/[id]/route.ts +++ b/src/app/api/admin/turnos/[id]/route.ts @@ -10,13 +10,28 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id const { id } = await params; const { estado, comentario } = await req.json(); + // Regla: no se puede cerrar (COMPLETADO) un turno sin auto y kilometraje cargados. + // Las reservas por WhatsApp los dejan vacíos; el jefe mecánico debe completarlos antes de cerrar. + if (estado === "COMPLETADO") { + const actual = await prisma.turno.findUnique({ + where: { id }, + select: { autoId: true, kilometros: true }, + }); + if (!actual) return NextResponse.json({ error: "Turno no encontrado" }, { status: 404 }); + if (!actual.autoId || actual.kilometros == null) + return NextResponse.json( + { error: "Cargá el auto y el kilometraje antes de cerrar el turno" }, + { status: 400 } + ); + } + const turno = await prisma.turno.update({ where: { id }, data: { estado, ...(comentario !== undefined && { comentario }) }, include: { auto: true, tipoServicio: true, cliente: true }, }); - if (estado === "COMPLETADO") { + if (estado === "COMPLETADO" && turno.auto && turno.kilometros != null) { try { await agregarFilaSheet({ patente: turno.auto.patente, @@ -26,7 +41,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id anio: turno.auto.anio, nombre: turno.cliente.nombre, apellido: turno.cliente.apellido, - email: turno.cliente.email, + email: turno.cliente.email ?? "", telefono: turno.cliente.telefono, fecha: turno.fechaInicio, trabajo: turno.tipoServicio.nombre, diff --git a/src/app/api/jefe/servicios/[id]/route.ts b/src/app/api/jefe/servicios/[id]/route.ts new file mode 100644 index 0000000..4c159f4 --- /dev/null +++ b/src/app/api/jefe/servicios/[id]/route.ts @@ -0,0 +1,66 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { verificarSesion } from "@/lib/auth-admin"; + +// Edita un trabajo propio del jefe (nombre, duración, activo). Solo si le pertenece. +export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const sesion = await verificarSesion("jefe_session"); + if (!sesion || sesion.role !== "jefe") + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const { id } = await params; + const propio = await prisma.tipoServicio.findFirst({ + where: { id, jefeMecanicoId: sesion.sub }, + }); + if (!propio) return NextResponse.json({ error: "Trabajo no encontrado" }, { status: 404 }); + + const data = await req.json(); + + if (data.duracionHoras !== undefined) { + const dur = Number(data.duracionHoras); + if (!Number.isFinite(dur) || dur <= 0 || dur > 24) + return NextResponse.json( + { error: "La duración debe ser un número de horas entre 0 y 24" }, + { status: 400 } + ); + } + if (data.nombre !== undefined && (typeof data.nombre !== "string" || !data.nombre.trim())) + return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 }); + + const servicio = await prisma.tipoServicio.update({ + where: { id }, + data: { + ...(data.nombre !== undefined && { nombre: data.nombre.trim() }), + ...(data.duracionHoras !== undefined && { duracionHoras: Number(data.duracionHoras) }), + ...(data.activo !== undefined && { activo: Boolean(data.activo) }), + }, + }); + + return NextResponse.json(servicio); +} + +// Elimina un trabajo propio. Si ya tiene turnos asociados, lo desactiva en vez de borrarlo +// (para no romper la integridad referencial de los turnos históricos). +export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const sesion = await verificarSesion("jefe_session"); + if (!sesion || sesion.role !== "jefe") + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const { id } = await params; + const propio = await prisma.tipoServicio.findFirst({ + where: { id, jefeMecanicoId: sesion.sub }, + }); + if (!propio) return NextResponse.json({ error: "Trabajo no encontrado" }, { status: 404 }); + + const enUso = await prisma.turno.count({ where: { tipoServicioId: id } }); + if (enUso > 0) { + const servicio = await prisma.tipoServicio.update({ + where: { id }, + data: { activo: false }, + }); + return NextResponse.json({ ...servicio, desactivado: true }); + } + + await prisma.tipoServicio.delete({ where: { id } }); + return NextResponse.json({ ok: true }); +} diff --git a/src/app/api/jefe/servicios/route.ts b/src/app/api/jefe/servicios/route.ts new file mode 100644 index 0000000..a4c6617 --- /dev/null +++ b/src/app/api/jefe/servicios/route.ts @@ -0,0 +1,40 @@ +import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/prisma"; +import { verificarSesion } from "@/lib/auth-admin"; + +// Lista los trabajos cargados por el jefe logueado (los suyos, no los globales). +export async function GET() { + const sesion = await verificarSesion("jefe_session"); + if (!sesion || sesion.role !== "jefe") + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const servicios = await prisma.tipoServicio.findMany({ + where: { jefeMecanicoId: sesion.sub }, + orderBy: { nombre: "asc" }, + }); + + return NextResponse.json(servicios); +} + +// Crea un trabajo propio del jefe (nombre + duración en horas). +export async function POST(req: NextRequest) { + const sesion = await verificarSesion("jefe_session"); + if (!sesion || sesion.role !== "jefe") + return NextResponse.json({ error: "No autorizado" }, { status: 401 }); + + const { nombre, duracionHoras } = await req.json(); + const dur = Number(duracionHoras); + if (!nombre || typeof nombre !== "string" || !nombre.trim()) + return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 }); + if (!Number.isFinite(dur) || dur <= 0 || dur > 24) + return NextResponse.json( + { error: "La duración debe ser un número de horas entre 0 y 24" }, + { status: 400 } + ); + + const servicio = await prisma.tipoServicio.create({ + data: { nombre: nombre.trim(), duracionHoras: dur, jefeMecanicoId: sesion.sub }, + }); + + return NextResponse.json(servicio, { status: 201 }); +} diff --git a/src/app/jefe/dashboard/page.tsx b/src/app/jefe/dashboard/page.tsx index dcc1e10..eed9af7 100644 --- a/src/app/jefe/dashboard/page.tsx +++ b/src/app/jefe/dashboard/page.tsx @@ -9,9 +9,9 @@ interface Turno { fechaFin: string; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO"; comentario: string | null; - kilometros: number; + kilometros: number | null; tipoServicio: { nombre: string }; - auto: { marca: string; modelo: string; patente: string }; + auto: { marca: string; modelo: string; patente: string } | null; cliente: { nombre: string; apellido: string; telefono: string }; } @@ -81,11 +81,11 @@ export default function JefeDashboard() { Teléfono {t.cliente.telefono} Auto - {t.auto.marca} {t.auto.modelo} + {t.auto ? `${t.auto.marca} ${t.auto.modelo}` : a completar} Patente - {t.auto.patente} + {t.auto?.patente ?? "—"} Kilometraje - {t.kilometros.toLocaleString("es-AR")} km + {t.kilometros != null ? `${t.kilometros.toLocaleString("es-AR")} km` : a completar} {t.comentario && ( diff --git a/src/app/jefe/layout.tsx b/src/app/jefe/layout.tsx index 266b018..c5e3d23 100644 --- a/src/app/jefe/layout.tsx +++ b/src/app/jefe/layout.tsx @@ -12,6 +12,11 @@ export default function JefeLayout({ children }: { children: React.ReactNode }) if (pathname === "/jefe") return <>{children}>; + const tabs = [ + { href: "/jefe/dashboard", label: "Turnos" }, + { href: "/jefe/servicios", label: "Trabajos" }, + ]; + return (
Todavía no cargaste ningún trabajo.
++ {s.nombre} +
++ {s.duracionHoras} {s.duracionHoras === 1 ? "hora" : "horas"} + {!s.activo && " · inactivo"} +
+