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
+38 -19
View File
@@ -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
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
apellido 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())
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)
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
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?
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
jefeMecanicoId String
fechaInicio 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)
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 '+')
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 { 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({
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) {
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 {
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 ?? "",
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,
fecha: turno.fechaInicio,
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;
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() {
<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>{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="font-mono">{t.auto.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>
<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>
{t.comentario && (
+23
View File
@@ -12,6 +12,11 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
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 (
<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">
@@ -24,6 +29,24 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
Cerrar sesión
</button>
</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>
</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>
);
}