From 2095cb542a4bf38599afe4705422f8ffeba7aa43 Mon Sep 17 00:00:00 2001 From: Beldor Date: Mon, 27 Jul 2026 19:01:54 -0300 Subject: [PATCH] feat(whatsapp): schema del bot de reservas + panel de trabajos por jefe (F1+F3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FASE 1 — schema: - Cliente.email opcional; nuevo telefonoNorm (E.164, único) para matchear por WhatsApp - TipoServicio.jefeMecanicoId opcional (trabajos per-jefe; globales legacy quedan null) - Turno.autoId/kilometros opcionales (reservas por WhatsApp sin auto/km) - Nuevo modelo ConversacionWhatsapp (estado del bot por teléfono) - admin/turnos/[id]: no permite COMPLETADO sin auto+km; Sheets tolera email nulo FASE 3 — panel de trabajos del jefe: - API jefe/servicios (GET/POST) y jefe/servicios/[id] (PATCH/DELETE), scoped al jefe - DELETE desactiva si el trabajo ya tiene turnos asociados - Página /jefe/servicios (alta/listado/activar/editar/eliminar) + nav Turnos|Trabajos - Dashboard del jefe resiliente a turnos sin auto/km ("a completar") Co-Authored-By: Claude Opus 4.8 --- prisma/schema.prisma | 57 +++++--- src/app/api/admin/turnos/[id]/route.ts | 19 ++- src/app/api/jefe/servicios/[id]/route.ts | 66 ++++++++++ src/app/api/jefe/servicios/route.ts | 40 ++++++ src/app/jefe/dashboard/page.tsx | 10 +- src/app/jefe/layout.tsx | 23 ++++ src/app/jefe/servicios/page.tsx | 159 +++++++++++++++++++++++ 7 files changed, 348 insertions(+), 26 deletions(-) create mode 100644 src/app/api/jefe/servicios/[id]/route.ts create mode 100644 src/app/api/jefe/servicios/route.ts create mode 100644 src/app/jefe/servicios/page.tsx 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 (
@@ -24,6 +29,24 @@ export default function JefeLayout({ children }: { children: React.ReactNode }) Cerrar sesión
+
{children}
diff --git a/src/app/jefe/servicios/page.tsx b/src/app/jefe/servicios/page.tsx new file mode 100644 index 0000000..2ff6cea --- /dev/null +++ b/src/app/jefe/servicios/page.tsx @@ -0,0 +1,159 @@ +"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 Servicio { + id: string; + nombre: string; + duracionHoras: number; + activo: boolean; +} + +export default function JefeServicios() { + const [servicios, setServicios] = useState([]); + const [loading, setLoading] = useState(true); + const [nombre, setNombre] = useState(""); + const [duracion, setDuracion] = useState(""); + const [error, setError] = useState(null); + const [guardando, setGuardando] = useState(false); + + useEffect(() => { cargar(); }, []); + + async function cargar() { + setLoading(true); + const res = await fetch("/api/jefe/servicios"); + const data = await res.json(); + setServicios(Array.isArray(data) ? data : []); + setLoading(false); + } + + async function agregar(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setGuardando(true); + const res = await fetch("/api/jefe/servicios", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ nombre, duracionHoras: duracion }), + }); + setGuardando(false); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + setError(d.error ?? "No se pudo guardar el trabajo"); + return; + } + setNombre(""); + setDuracion(""); + cargar(); + } + + async function toggleActivo(s: Servicio) { + await fetch(`/api/jefe/servicios/${s.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ activo: !s.activo }), + }); + cargar(); + } + + async function editarDuracion(s: Servicio) { + const valor = prompt(`Duración de "${s.nombre}" en horas:`, String(s.duracionHoras)); + if (valor == null) return; + const res = await fetch(`/api/jefe/servicios/${s.id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ duracionHoras: valor }), + }); + if (!res.ok) { + const d = await res.json().catch(() => ({})); + alert(d.error ?? "No se pudo actualizar"); + return; + } + cargar(); + } + + async function eliminar(s: Servicio) { + if (!confirm(`¿Eliminar el trabajo "${s.nombre}"?`)) return; + const res = await fetch(`/api/jefe/servicios/${s.id}`, { method: "DELETE" }); + const d = await res.json().catch(() => ({})); + if (d.desactivado) + alert("El trabajo ya tenía turnos asociados, así que se desactivó en vez de borrarse."); + cargar(); + } + + return ( +
+

Mis trabajos y tiempos

+ + +
+

+ Cargá los trabajos que ofrecés y cuánto tardan. Son los que verá el cliente al reservar por WhatsApp. +

+
+ setNombre(e.target.value)} + required + /> + setDuracion(e.target.value)} + className="sm:w-40" + required + /> +
+ {error &&

{error}

} +
+ +
+
+
+ + {loading ? ( +
Cargando...
+ ) : servicios.length === 0 ? ( + +

Todavía no cargaste ningún trabajo.

+
+ ) : ( +
+ {servicios.map((s) => ( + +
+
+

+ {s.nombre} +

+

+ {s.duracionHoras} {s.duracionHoras === 1 ? "hora" : "horas"} + {!s.activo && " · inactivo"} +

+
+
+ + + +
+
+
+ ))} +
+ )} +
+ ); +}