feat: bot de reservas por WhatsApp (F0) y flujo web jefe -> trabajo #4
@@ -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
|
||||
|
ai-bot
commented
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)
|
||||
|
ai-bot
commented
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?
|
||||
|
ai-bot
commented
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?
|
||||
|
ai-bot
commented
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 '+')
|
||||
|
ai-bot
commented
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.)
|
||||
|
ai-bot
commented
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])
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
ai-bot
commented
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) {
|
||||
|
ai-bot
commented
Redundant check: validation at lines 15-26 already guarantees autoId and kilometros exist when reaching this line. #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 ?? "",
|
||||
|
ai-bot
commented
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,
|
||||
|
||||
@@ -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)
|
||||
|
ai-bot
commented
Duration validation rejects 0 but error message says 'entre 0 y 24'. Should be 'entre 1 y 24' or allow 0 with #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" },
|
||||
|
ai-bot
commented
Error message inconsistent with validation logic (allows 0 vs rejects 0). #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 } });
|
||||
|
ai-bot
commented
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 });
|
||||
|
ai-bot
commented
Response includes custom #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 });
|
||||
}
|
||||
@@ -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({
|
||||
|
ai-bot
commented
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();
|
||||
|
ai-bot
commented
req.json() throws on invalid JSON, resulting in 500. Wrap in try-catch to return 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)
|
||||
|
ai-bot
commented
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. #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
ai-bot
commented
Magic number 24 for max hours. Define as constant for maintainability. #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({
|
||||
|
ai-bot
commented
Prisma create can throw (e.g., unique constraint). Add try-catch to return 400/500 appropriately. #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 });
|
||||
}
|
||||
@@ -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>
|
||||
|
ai-bot
commented
Inconsistent return types in ternary: string vs React element. Use a string for both branches or wrap both in a fragment. #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>
|
||||
|
ai-bot
commented
Inconsistent return types in ternary: string vs React element. Use a string for both branches. #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 && (
|
||||
|
||||
@@ -12,6 +12,11 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
|
||||
|
||||
if (pathname === "/jefe") return <>{children}</>;
|
||||
|
||||
const tabs = [
|
||||
|
ai-bot
commented
Consider moving the tabs array outside the component to avoid recreating it on every render. #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;
|
||||
|
ai-bot
commented
Exact pathname match may not highlight parent tab for nested routes (e.g., /jefe/dashboard/123). Consider using 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)}
|
||||
|
ai-bot
commented
Use Next.js Link component for navigation instead of button with router.push for better accessibility, SEO, and prefetching. #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 ${
|
||||
|
ai-bot
commented
ClassName template literal is hard to read. Extract conditional classes into a variable or use a helper like clsx. #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>
|
||||
|
||||
@@ -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(); }, []);
|
||||
|
ai-bot
commented
useEffect depends on cargar which is recreated each render; wrap cargar in useCallback or add to deps. #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() {
|
||||
|
ai-bot
commented
cargar() doesn't handle network errors or non-ok responses; add error handling. #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 }),
|
||||
|
ai-bot
commented
duracion is a string but duracionHoras expects a number; parse it before sending. #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) {
|
||||
|
ai-bot
commented
toggleActivo doesn't handle errors; UI may become out of sync if request fails. #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 }),
|
||||
|
ai-bot
commented
valor from prompt is a string; parse to number for duracionHoras. #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) {
|
||||
|
ai-bot
commented
eliminar doesn't check res.ok before parsing; may miss errors. #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"
|
||||
|
ai-bot
commented
Input uses type="text" with inputMode="decimal"; consider type="number" for better validation. #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)}>
|
||||
|
ai-bot
commented
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>
|
||||
);
|
||||
}
|
||||
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