feat: bot de reservas por WhatsApp (F0) y flujo web jefe -> trabajo #4

Merged
jpbeldomenico merged 6 commits from feat/whatsapp-bot into main 2026-08-05 12:24:55 -03:00
7 changed files with 348 additions and 26 deletions
Showing only changes of commit c53e5bb5aa - Show all commits
+23 -4
View File
@@ -8,15 +8,17 @@ datasource db {
model Cliente { model Cliente {
id String @id @default(cuid()) id String @id @default(cuid())
email String @unique email String? @unique
Review

Making email optional with @unique allows multiple NULL values in some databases (PostgreSQL allows multiple NULLs with unique constraint). Consider if multiple clients without email is intended.

#ai-review-inline

Making email optional with @unique allows multiple NULL values in some databases (PostgreSQL allows multiple NULLs with unique constraint). Consider if multiple clients without email is intended. #ai-review-inline
nombre String nombre String
apellido String apellido String
telefono String telefono String
telefonoNorm String? @unique // teléfono en formato E.164, usado para matchear al que escribe por WhatsApp
Review

telefonoNorm has @unique but is optional. Multiple clients could have NULL telefonoNorm. Consider if this is intended or if it should be required when provided.

#ai-review-inline

telefonoNorm has @unique but is optional. Multiple clients could have NULL telefonoNorm. Consider if this is intended or if it should be required when provided. #ai-review-inline
createdAt DateTime @default(now()) createdAt DateTime @default(now())
autos Auto[] autos Auto[]
turnos Turno[] turnos Turno[]
magicTokens MagicToken[] magicTokens MagicToken[]
conversaciones ConversacionWhatsapp[]
} }
model Auto { model Auto {
@@ -39,7 +41,9 @@ model TipoServicio {
nombre String nombre String
duracionHoras Float duracionHoras Float
activo Boolean @default(true) activo Boolean @default(true)
jefeMecanicoId String? // trabajo cargado por un jefe mecánico puntual; null = servicio global (legacy)
Review

jefeMecanicoId is optional on TipoServicio. Ensure the relation JefeMecanico? handles null correctly in application code when jefeMecanicoId is null.

#ai-review-inline

jefeMecanicoId is optional on TipoServicio. Ensure the relation JefeMecanico? handles null correctly in application code when jefeMecanicoId is null. #ai-review-inline
jefeMecanico JefeMecanico? @relation(fields: [jefeMecanicoId], references: [id])
turnos Turno[] turnos Turno[]
historial HistorialServicio[] historial HistorialServicio[]
} }
@@ -47,18 +51,18 @@ model TipoServicio {
model Turno { model Turno {
id String @id @default(cuid()) id String @id @default(cuid())
clienteId String clienteId String
autoId String autoId String?
Review

autoId is now optional but Turno still requires tipoServicioId and jefeMecanicoId. Ensure business logic handles appointments without a vehicle correctly.

#ai-review-inline

autoId is now optional but Turno still requires tipoServicioId and jefeMecanicoId. Ensure business logic handles appointments without a vehicle correctly. #ai-review-inline
tipoServicioId String tipoServicioId String
jefeMecanicoId String jefeMecanicoId String
fechaInicio DateTime fechaInicio DateTime
fechaFin DateTime fechaFin DateTime
kilometros Int kilometros Int?
Review

kilometros is now optional. Consider if appointments without mileage tracking are valid for your business logic.

#ai-review-inline

kilometros is now optional. Consider if appointments without mileage tracking are valid for your business logic. #ai-review-inline
estado EstadoTurno @default(PENDIENTE) estado EstadoTurno @default(PENDIENTE)
comentario String? comentario String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
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]) jefeMecanico JefeMecanico @relation(fields: [jefeMecanicoId], references: [id])
} }
@@ -73,6 +77,7 @@ model JefeMecanico {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
turnos Turno[] turnos Turno[]
servicios TipoServicio[]
} }
model HistorialServicio { model HistorialServicio {
@@ -117,3 +122,17 @@ enum EstadoTurno {
CANCELADO CANCELADO
COMPLETADO 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 '+')
Review

telefono field uses wa_id (E.164 without '+'). Consider adding a comment about the exact format expected (e.g., '5491123456789') for clarity.

#ai-review-inline

telefono field uses wa_id (E.164 without '+'). Consider adding a comment about the exact format expected (e.g., '5491123456789') for clarity. #ai-review-inline
paso String @default("INICIO") // paso de la máquina de estados
datos Json @default("{}") // selecciones parciales (mecánico, trabajo, slot, alta en curso, etc.)
Review

datos field uses Json with default '{}'. Consider defining a TypeScript interface for the expected JSON structure to improve type safety.

#ai-review-inline

datos field uses Json with default '{}'. Consider defining a TypeScript interface for the expected JSON structure to improve type safety. #ai-review-inline
clienteId String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
cliente Cliente? @relation(fields: [clienteId], references: [id])
}
+17 -2
View File
@@ -10,13 +10,28 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
const { id } = await params; const { id } = await params;
const { estado, comentario } = await req.json(); 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({
Review

Potential race condition: another request could modify autoId/kilometros between this read and the update at line 28.

#ai-review-inline

Potential race condition: another request could modify autoId/kilometros between this read and the update at line 28. #ai-review-inline
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({ const turno = await prisma.turno.update({
where: { id }, where: { id },
data: { estado, ...(comentario !== undefined && { comentario }) }, data: { estado, ...(comentario !== undefined && { comentario }) },
include: { auto: true, tipoServicio: true, cliente: true }, include: { auto: true, tipoServicio: true, cliente: true },
}); });
if (estado === "COMPLETADO") { if (estado === "COMPLETADO" && turno.auto && turno.kilometros != null) {
Review

Redundant check: validation at lines 15-26 already guarantees autoId and kilometros exist when reaching this line.

if (estado === "COMPLETADO") {

#ai-review-inline

Redundant check: validation at lines 15-26 already guarantees autoId and kilometros exist when reaching this line. ```suggestion if (estado === "COMPLETADO") { ``` #ai-review-inline
try { try {
await agregarFilaSheet({ await agregarFilaSheet({
patente: turno.auto.patente, patente: turno.auto.patente,
@@ -26,7 +41,7 @@ export async function PATCH(req: NextRequest, { params }: { params: Promise<{ id
anio: turno.auto.anio, anio: turno.auto.anio,
nombre: turno.cliente.nombre, nombre: turno.cliente.nombre,
apellido: turno.cliente.apellido, apellido: turno.cliente.apellido,
email: turno.cliente.email, email: turno.cliente.email ?? "",
Review

Good: nullish coalescing handles missing email gracefully for sheet export.

#ai-review-inline

Good: nullish coalescing handles missing email gracefully for sheet export. #ai-review-inline
telefono: turno.cliente.telefono, telefono: turno.cliente.telefono,
fecha: turno.fechaInicio, fecha: turno.fechaInicio,
trabajo: turno.tipoServicio.nombre, trabajo: turno.tipoServicio.nombre,
+66
View File
@@ -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)
Review

Duration validation rejects 0 but error message says 'entre 0 y 24'. Should be 'entre 1 y 24' or allow 0 with dur < 0.

if (!Number.isFinite(dur) || dur < 0 || dur > 24)

#ai-review-inline

Duration validation rejects 0 but error message says 'entre 0 y 24'. Should be 'entre 1 y 24' or allow 0 with `dur < 0`. ```suggestion if (!Number.isFinite(dur) || dur < 0 || dur > 24) ``` #ai-review-inline
return NextResponse.json(
{ error: "La duración debe ser un número de horas entre 0 y 24" },
Outdated
Review

Error message inconsistent with validation logic (allows 0 vs rejects 0).

{ error: "La duración debe ser un número de horas entre 1 y 24" }

#ai-review-inline

Error message inconsistent with validation logic (allows 0 vs rejects 0). ```suggestion { error: "La duración debe ser un número de horas entre 1 y 24" } ``` #ai-review-inline
{ 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 } });
Review

Potential race condition: another request could create a turno between count and delete/update. Consider using a transaction or handling FK constraint error.

#ai-review-inline

Potential race condition: another request could create a turno between count and delete/update. Consider using a transaction or handling FK constraint error. #ai-review-inline
if (enUso > 0) {
const servicio = await prisma.tipoServicio.update({
where: { id },
data: { activo: false },
});
return NextResponse.json({ ...servicio, desactivado: true });
Review

Response includes custom desactivado property not in Prisma model. Ensure frontend handles this.

#ai-review-inline

Response includes custom `desactivado` property not in Prisma model. Ensure frontend handles this. #ai-review-inline
}
await prisma.tipoServicio.delete({ where: { id } });
return NextResponse.json({ ok: true });
}
+40
View File
@@ -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({
Review

No pagination on findMany; could return large dataset. Consider adding take/skip or cursor pagination.

#ai-review-inline

No pagination on findMany; could return large dataset. Consider adding take/skip or cursor pagination. #ai-review-inline
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();
Review

req.json() throws on invalid JSON, resulting in 500. Wrap in try-catch to return 400.

  let nombre, duracionHoras;
  try {
    ({ nombre, duracionHoras } = await req.json());
  } catch {
    return NextResponse.json({ error: "Cuerpo de solicitud inválido" }, { status: 400 });
  }

#ai-review-inline

req.json() throws on invalid JSON, resulting in 500. Wrap in try-catch to return 400. ```suggestion let nombre, duracionHoras; try { ({ nombre, duracionHoras } = await req.json()); } catch { return NextResponse.json({ error: "Cuerpo de solicitud inválido" }, { status: 400 }); } ``` #ai-review-inline
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)
Review

Error message says 'entre 0 y 24' but validation rejects 0 (dur <= 0). Update message to 'entre 1 y 24' or adjust validation to allow 0.

      { error: "La duración debe ser un número de horas entre 1 y 24" },

#ai-review-inline

Error message says 'entre 0 y 24' but validation rejects 0 (dur <= 0). Update message to 'entre 1 y 24' or adjust validation to allow 0. ```suggestion { error: "La duración debe ser un número de horas entre 1 y 24" }, ``` #ai-review-inline
Review

Magic number 24 for max hours. Define as constant for maintainability.

  const MAX_HOURS = 24;
  if (!Number.isFinite(dur) || dur <= 0 || dur > MAX_HOURS)

#ai-review-inline

Magic number 24 for max hours. Define as constant for maintainability. ```suggestion const MAX_HOURS = 24; if (!Number.isFinite(dur) || dur <= 0 || dur > MAX_HOURS) ``` #ai-review-inline
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({
Review

Prisma create can throw (e.g., unique constraint). Add try-catch to return 400/500 appropriately.

  try {
    const servicio = await prisma.tipoServicio.create({
      data: { nombre: nombre.trim(), duracionHoras: dur, jefeMecanicoId: sesion.sub },
    });
    return NextResponse.json(servicio, { status: 201 });
  } catch (error) {
    return NextResponse.json({ error: "Error al crear el servicio" }, { status: 500 });
  }

#ai-review-inline

Prisma create can throw (e.g., unique constraint). Add try-catch to return 400/500 appropriately. ```suggestion try { const servicio = await prisma.tipoServicio.create({ data: { nombre: nombre.trim(), duracionHoras: dur, jefeMecanicoId: sesion.sub }, }); return NextResponse.json(servicio, { status: 201 }); } catch (error) { return NextResponse.json({ error: "Error al crear el servicio" }, { status: 500 }); } ``` #ai-review-inline
data: { nombre: nombre.trim(), duracionHoras: dur, jefeMecanicoId: sesion.sub },
});
return NextResponse.json(servicio, { status: 201 });
}
+5 -5
View File
@@ -9,9 +9,9 @@ interface Turno {
fechaFin: string; fechaFin: string;
estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO"; estado: "PENDIENTE" | "CONFIRMADO" | "CANCELADO" | "COMPLETADO";
comentario: string | null; comentario: string | null;
kilometros: number; kilometros: number | null;
tipoServicio: { nombre: string }; 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 }; cliente: { nombre: string; apellido: string; telefono: string };
} }
@@ -81,11 +81,11 @@ export default function JefeDashboard() {
<span className="text-gray-400">Teléfono</span> <span className="text-gray-400">Teléfono</span>
<span>{t.cliente.telefono}</span> <span>{t.cliente.telefono}</span>
<span className="text-gray-400">Auto</span> <span className="text-gray-400">Auto</span>
<span>{t.auto.marca} {t.auto.modelo}</span> <span>{t.auto ? `${t.auto.marca} ${t.auto.modelo}` : <em className="text-amber-600">a completar</em>}</span>
Review

Inconsistent return types in ternary: string vs React element. Use a string for both branches or wrap both in a fragment.

{t.auto ? `${t.auto.marca} ${t.auto.modelo}` : "a completar"}

#ai-review-inline

Inconsistent return types in ternary: string vs React element. Use a string for both branches or wrap both in a fragment. ```suggestion {t.auto ? `${t.auto.marca} ${t.auto.modelo}` : "a completar"} ``` #ai-review-inline
<span className="text-gray-400">Patente</span> <span className="text-gray-400">Patente</span>
<span className="font-mono">{t.auto.patente}</span> <span className="font-mono">{t.auto?.patente ?? "—"}</span>
<span className="text-gray-400">Kilometraje</span> <span className="text-gray-400">Kilometraje</span>
<span>{t.kilometros.toLocaleString("es-AR")} km</span> <span>{t.kilometros != null ? `${t.kilometros.toLocaleString("es-AR")} km` : <em className="text-amber-600">a completar</em>}</span>
Review

Inconsistent return types in ternary: string vs React element. Use a string for both branches.

{t.kilometros != null ? `${t.kilometros.toLocaleString("es-AR")} km` : "a completar"}

#ai-review-inline

Inconsistent return types in ternary: string vs React element. Use a string for both branches. ```suggestion {t.kilometros != null ? `${t.kilometros.toLocaleString("es-AR")} km` : "a completar"} ``` #ai-review-inline
</div> </div>
{t.comentario && ( {t.comentario && (
+23
View File
@@ -12,6 +12,11 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
if (pathname === "/jefe") return <>{children}</>; if (pathname === "/jefe") return <>{children}</>;
const tabs = [
Review

Consider moving the tabs array outside the component to avoid recreating it on every render.

const tabs = [
  { href: '/jefe/dashboard', label: 'Turnos' },
  { href: '/jefe/servicios', label: 'Trabajos' },
];

export default function Layout({ children }: { children: React.ReactNode }) {

#ai-review-inline

Consider moving the tabs array outside the component to avoid recreating it on every render. ```suggestion const tabs = [ { href: '/jefe/dashboard', label: 'Turnos' }, { href: '/jefe/servicios', label: 'Trabajos' }, ]; export default function Layout({ children }: { children: React.ReactNode }) { ``` #ai-review-inline
{ href: "/jefe/dashboard", label: "Turnos" },
{ href: "/jefe/servicios", label: "Trabajos" },
];
return ( return (
<div className="min-h-screen flex flex-col"> <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="max-w-5xl mx-auto w-full px-4 flex-1 flex flex-col py-6 gap-4">
@@ -24,6 +29,24 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
Cerrar sesión Cerrar sesión
</button> </button>
</div> </div>
<nav className="flex gap-1">
{tabs.map((t) => {
const activo = pathname === t.href;
Review

Exact pathname match may not highlight parent tab for nested routes (e.g., /jefe/dashboard/123). Consider using pathname.startsWith(t.href).

const activo = pathname === t.href || pathname.startsWith(t.href + '/');

#ai-review-inline

Exact pathname match may not highlight parent tab for nested routes (e.g., /jefe/dashboard/123). Consider using pathname.startsWith(t.href). ```suggestion const activo = pathname === t.href || pathname.startsWith(t.href + '/'); ``` #ai-review-inline
return (
<button
key={t.href}
onClick={() => router.push(t.href)}
Review

Use Next.js Link component for navigation instead of button with router.push for better accessibility, SEO, and prefetching.

<Link
  key={t.href}
  href={t.href}
  className={`text-sm px-4 py-2 rounded-lg transition $
    activo
      ? 'bg-white/15 text-white font-medium'
      : 'text-gray-300 hover:text-white hover:bg-white/10'
  }`}
>
  {t.label}
</Link>

#ai-review-inline

Use Next.js Link component for navigation instead of button with router.push for better accessibility, SEO, and prefetching. ```suggestion <Link key={t.href} href={t.href} className={`text-sm px-4 py-2 rounded-lg transition $ activo ? 'bg-white/15 text-white font-medium' : 'text-gray-300 hover:text-white hover:bg-white/10' }`} > {t.label} </Link> ``` #ai-review-inline
className={`text-sm px-4 py-2 rounded-lg transition ${
Review

ClassName template literal is hard to read. Extract conditional classes into a variable or use a helper like clsx.

const base = 'text-sm px-4 py-2 rounded-lg transition';
const active = 'bg-white/15 text-white font-medium';
const inactive = 'text-gray-300 hover:text-white hover:bg-white/10';
className={`${base} ${activo ? active : inactive}`}

#ai-review-inline

ClassName template literal is hard to read. Extract conditional classes into a variable or use a helper like clsx. ```suggestion const base = 'text-sm px-4 py-2 rounded-lg transition'; const active = 'bg-white/15 text-white font-medium'; const inactive = 'text-gray-300 hover:text-white hover:bg-white/10'; className={`${base} ${activo ? active : inactive}`} ``` #ai-review-inline
activo
? "bg-white/15 text-white font-medium"
: "text-gray-300 hover:text-white hover:bg-white/10"
}`}
>
{t.label}
</button>
);
})}
</nav>
<main className="flex-1">{children}</main> <main className="flex-1">{children}</main>
</div> </div>
</div> </div>
+159
View File
@@ -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<Servicio[]>([]);
const [loading, setLoading] = useState(true);
const [nombre, setNombre] = useState("");
const [duracion, setDuracion] = useState("");
const [error, setError] = useState<string | null>(null);
const [guardando, setGuardando] = useState(false);
useEffect(() => { cargar(); }, []);
Review

useEffect depends on cargar which is recreated each render; wrap cargar in useCallback or add to deps.

const cargar = useCallback(async () => {
    setLoading(true);
    try {
      const res = await fetch("/api/jefe/servicios");
      if (!res.ok) throw new Error("Failed to fetch");
      const data = await res.json();
      setServicios(Array.isArray(data) ? data : []);
    } catch {
      setServicios([]);
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => { cargar(); }, [cargar]);

#ai-review-inline

useEffect depends on cargar which is recreated each render; wrap cargar in useCallback or add to deps. ```suggestion const cargar = useCallback(async () => { setLoading(true); try { const res = await fetch("/api/jefe/servicios"); if (!res.ok) throw new Error("Failed to fetch"); const data = await res.json(); setServicios(Array.isArray(data) ? data : []); } catch { setServicios([]); } finally { setLoading(false); } }, []); useEffect(() => { cargar(); }, [cargar]); ``` #ai-review-inline
async function cargar() {
Review

cargar() doesn't handle network errors or non-ok responses; add error handling.

async function cargar() {
    setLoading(true);
    try {
      const res = await fetch("/api/jefe/servicios");
      if (!res.ok) throw new Error("Failed to fetch");
      const data = await res.json();
      setServicios(Array.isArray(data) ? data : []);
    } catch {
      setServicios([]);
    } finally {
      setLoading(false);
    }
  }

#ai-review-inline

cargar() doesn't handle network errors or non-ok responses; add error handling. ```suggestion async function cargar() { setLoading(true); try { const res = await fetch("/api/jefe/servicios"); if (!res.ok) throw new Error("Failed to fetch"); const data = await res.json(); setServicios(Array.isArray(data) ? data : []); } catch { setServicios([]); } finally { setLoading(false); } } ``` #ai-review-inline
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 }),
Review

duracion is a string but duracionHoras expects a number; parse it before sending.

body: JSON.stringify({ nombre, duracionHoras: parseFloat(duracion) || 0 }),

#ai-review-inline

duracion is a string but duracionHoras expects a number; parse it before sending. ```suggestion body: JSON.stringify({ nombre, duracionHoras: parseFloat(duracion) || 0 }), ``` #ai-review-inline
});
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) {
Review

toggleActivo doesn't handle errors; UI may become out of sync if request fails.

async function toggleActivo(s: Servicio) {
    try {
      const res = await fetch(`/api/jefe/servicios/${s.id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ activo: !s.activo }),
      });
      if (!res.ok) throw new Error("Failed to toggle");
      cargar();
    } catch {
      alert("No se pudo cambiar el estado");
    }
  }

#ai-review-inline

toggleActivo doesn't handle errors; UI may become out of sync if request fails. ```suggestion async function toggleActivo(s: Servicio) { try { const res = await fetch(`/api/jefe/servicios/${s.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ activo: !s.activo }), }); if (!res.ok) throw new Error("Failed to toggle"); cargar(); } catch { alert("No se pudo cambiar el estado"); } } ``` #ai-review-inline
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 }),
Review

valor from prompt is a string; parse to number for duracionHoras.

body: JSON.stringify({ duracionHoras: parseFloat(valor) }),

#ai-review-inline

valor from prompt is a string; parse to number for duracionHoras. ```suggestion body: JSON.stringify({ duracionHoras: parseFloat(valor) }), ``` #ai-review-inline
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
alert(d.error ?? "No se pudo actualizar");
return;
}
cargar();
}
async function eliminar(s: Servicio) {
Review

eliminar doesn't check res.ok before parsing; may miss errors.

async function eliminar(s: Servicio) {
    if (!confirm(`¿Eliminar el trabajo "${s.nombre}"?`)) return;
    try {
      const res = await fetch(`/api/jefe/servicios/${s.id}`, { method: "DELETE" });
      const d = await res.json().catch(() => ({}));
      if (!res.ok) throw new Error(d.error ?? "No se pudo eliminar");
      if (d.desactivado) alert("El trabajo ya tenía turnos asociados, así que se desactivó en vez de borrarse.");
      cargar();
    } catch (e) {
      alert(e instanceof Error ? e.message : "Error desconocido");
    }
  }

#ai-review-inline

eliminar doesn't check res.ok before parsing; may miss errors. ```suggestion async function eliminar(s: Servicio) { if (!confirm(`¿Eliminar el trabajo "${s.nombre}"?`)) return; try { const res = await fetch(`/api/jefe/servicios/${s.id}`, { method: "DELETE" }); const d = await res.json().catch(() => ({})); if (!res.ok) throw new Error(d.error ?? "No se pudo eliminar"); if (d.desactivado) alert("El trabajo ya tenía turnos asociados, así que se desactivó en vez de borrarse."); cargar(); } catch (e) { alert(e instanceof Error ? e.message : "Error desconocido"); } } ``` #ai-review-inline
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 (
<div className="flex flex-col gap-6">
<h2 className="text-xl sm:text-2xl font-bold text-white">Mis trabajos y tiempos</h2>
<Card>
<form onSubmit={agregar} className="flex flex-col gap-3">
<p className="text-sm text-gray-600">
Cargá los trabajos que ofrecés y cuánto tardan. Son los que verá el cliente al reservar por WhatsApp.
</p>
<div className="grid grid-cols-1 sm:grid-cols-[1fr_auto] gap-3 items-end">
<Input
label="Trabajo"
placeholder="Ej: Cambio de aceite"
value={nombre}
onChange={(e) => setNombre(e.target.value)}
required
/>
<Input
label="Duración (horas)"
type="text"
Review

Input uses type="text" with inputMode="decimal"; consider type="number" for better validation.

type="number"

#ai-review-inline

Input uses type="text" with inputMode="decimal"; consider type="number" for better validation. ```suggestion type="number" ``` #ai-review-inline
inputMode="decimal"
placeholder="Ej: 1.5"
value={duracion}
onChange={(e) => setDuracion(e.target.value)}
className="sm:w-40"
required
/>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
<div>
<Button type="submit" loading={guardando}>Agregar trabajo</Button>
</div>
</form>
</Card>
{loading ? (
<div className="text-center py-10 text-gray-200">Cargando...</div>
) : servicios.length === 0 ? (
<Card>
<p className="text-center text-gray-400 py-4">Todavía no cargaste ningún trabajo.</p>
</Card>
) : (
<div className="flex flex-col gap-3">
{servicios.map((s) => (
<Card key={s.id}>
<div className="flex items-center justify-between flex-wrap gap-3">
<div>
<p className={`font-semibold ${s.activo ? "text-gray-800" : "text-gray-400 line-through"}`}>
{s.nombre}
</p>
<p className="text-sm text-gray-500 mt-0.5">
{s.duracionHoras} {s.duracionHoras === 1 ? "hora" : "horas"}
{!s.activo && " · inactivo"}
</p>
</div>
<div className="flex gap-2">
<Button variant="secondary" className="text-sm" onClick={() => editarDuracion(s)}>
Review

Action buttons lack loading/disabled states; users may trigger multiple requests.

#ai-review-inline

Action buttons lack loading/disabled states; users may trigger multiple requests. #ai-review-inline
Duración
</Button>
<Button variant="secondary" className="text-sm" onClick={() => toggleActivo(s)}>
{s.activo ? "Desactivar" : "Activar"}
</Button>
<Button variant="danger" className="text-sm" onClick={() => eliminar(s)}>
Eliminar
</Button>
</div>
</div>
</Card>
))}
</div>
)}
</div>
);
}