feat(whatsapp): schema del bot de reservas + panel de trabajos por jefe (F1+F3)
FASE 1 — schema:
- Cliente.email opcional; nuevo telefonoNorm (E.164, único) para matchear por WhatsApp
- TipoServicio.jefeMecanicoId opcional (trabajos per-jefe; globales legacy quedan null)
- Turno.autoId/kilometros opcionales (reservas por WhatsApp sin auto/km)
- Nuevo modelo ConversacionWhatsapp (estado del bot por teléfono)
- admin/turnos/[id]: no permite COMPLETADO sin auto+km; Sheets tolera email nulo
FASE 3 — panel de trabajos del jefe:
- API jefe/servicios (GET/POST) y jefe/servicios/[id] (PATCH/DELETE), scoped al jefe
- DELETE desactiva si el trabajo ya tiene turnos asociados
- Página /jefe/servicios (alta/listado/activar/editar/eliminar) + nav Turnos|Trabajos
- Dashboard del jefe resiliente a turnos sin auto/km ("a completar")
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+23
-4
@@ -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
|
||||||
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
|
||||||
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)
|
||||||
|
|
||||||
|
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?
|
||||||
tipoServicioId String
|
tipoServicioId String
|
||||||
jefeMecanicoId String
|
jefeMecanicoId String
|
||||||
fechaInicio DateTime
|
fechaInicio DateTime
|
||||||
fechaFin DateTime
|
fechaFin DateTime
|
||||||
kilometros Int
|
kilometros Int?
|
||||||
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 '+')
|
||||||
|
paso String @default("INICIO") // paso de la máquina de estados
|
||||||
|
datos Json @default("{}") // selecciones parciales (mecánico, trabajo, slot, alta en curso, etc.)
|
||||||
|
clienteId String?
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
cliente Cliente? @relation(fields: [clienteId], references: [id])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
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) {
|
||||||
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 ?? "",
|
||||||
telefono: turno.cliente.telefono,
|
telefono: turno.cliente.telefono,
|
||||||
fecha: turno.fechaInicio,
|
fecha: turno.fechaInicio,
|
||||||
trabajo: turno.tipoServicio.nombre,
|
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)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "La duración debe ser un número de horas entre 0 y 24" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (data.nombre !== undefined && (typeof data.nombre !== "string" || !data.nombre.trim()))
|
||||||
|
return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 });
|
||||||
|
|
||||||
|
const servicio = await prisma.tipoServicio.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(data.nombre !== undefined && { nombre: data.nombre.trim() }),
|
||||||
|
...(data.duracionHoras !== undefined && { duracionHoras: Number(data.duracionHoras) }),
|
||||||
|
...(data.activo !== undefined && { activo: Boolean(data.activo) }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(servicio);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Elimina un trabajo propio. Si ya tiene turnos asociados, lo desactiva en vez de borrarlo
|
||||||
|
// (para no romper la integridad referencial de los turnos históricos).
|
||||||
|
export async function DELETE(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const sesion = await verificarSesion("jefe_session");
|
||||||
|
if (!sesion || sesion.role !== "jefe")
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { id } = await params;
|
||||||
|
const propio = await prisma.tipoServicio.findFirst({
|
||||||
|
where: { id, jefeMecanicoId: sesion.sub },
|
||||||
|
});
|
||||||
|
if (!propio) return NextResponse.json({ error: "Trabajo no encontrado" }, { status: 404 });
|
||||||
|
|
||||||
|
const enUso = await prisma.turno.count({ where: { tipoServicioId: id } });
|
||||||
|
if (enUso > 0) {
|
||||||
|
const servicio = await prisma.tipoServicio.update({
|
||||||
|
where: { id },
|
||||||
|
data: { activo: false },
|
||||||
|
});
|
||||||
|
return NextResponse.json({ ...servicio, desactivado: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.tipoServicio.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { prisma } from "@/lib/prisma";
|
||||||
|
import { verificarSesion } from "@/lib/auth-admin";
|
||||||
|
|
||||||
|
// Lista los trabajos cargados por el jefe logueado (los suyos, no los globales).
|
||||||
|
export async function GET() {
|
||||||
|
const sesion = await verificarSesion("jefe_session");
|
||||||
|
if (!sesion || sesion.role !== "jefe")
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const servicios = await prisma.tipoServicio.findMany({
|
||||||
|
where: { jefeMecanicoId: sesion.sub },
|
||||||
|
orderBy: { nombre: "asc" },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(servicios);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crea un trabajo propio del jefe (nombre + duración en horas).
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const sesion = await verificarSesion("jefe_session");
|
||||||
|
if (!sesion || sesion.role !== "jefe")
|
||||||
|
return NextResponse.json({ error: "No autorizado" }, { status: 401 });
|
||||||
|
|
||||||
|
const { nombre, duracionHoras } = await req.json();
|
||||||
|
const dur = Number(duracionHoras);
|
||||||
|
if (!nombre || typeof nombre !== "string" || !nombre.trim())
|
||||||
|
return NextResponse.json({ error: "El nombre del trabajo es obligatorio" }, { status: 400 });
|
||||||
|
if (!Number.isFinite(dur) || dur <= 0 || dur > 24)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "La duración debe ser un número de horas entre 0 y 24" },
|
||||||
|
{ status: 400 }
|
||||||
|
);
|
||||||
|
|
||||||
|
const servicio = await prisma.tipoServicio.create({
|
||||||
|
data: { nombre: nombre.trim(), duracionHoras: dur, jefeMecanicoId: sesion.sub },
|
||||||
|
});
|
||||||
|
|
||||||
|
return NextResponse.json(servicio, { status: 201 });
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{t.comentario && (
|
{t.comentario && (
|
||||||
|
|||||||
@@ -12,6 +12,11 @@ export default function JefeLayout({ children }: { children: React.ReactNode })
|
|||||||
|
|
||||||
if (pathname === "/jefe") return <>{children}</>;
|
if (pathname === "/jefe") return <>{children}</>;
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ 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;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={t.href}
|
||||||
|
onClick={() => router.push(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}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
<main className="flex-1">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
</div>
|
</div>
|
||||||
</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(); }, []);
|
||||||
|
|
||||||
|
async function cargar() {
|
||||||
|
setLoading(true);
|
||||||
|
const res = await fetch("/api/jefe/servicios");
|
||||||
|
const data = await res.json();
|
||||||
|
setServicios(Array.isArray(data) ? data : []);
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function agregar(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
setGuardando(true);
|
||||||
|
const res = await fetch("/api/jefe/servicios", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ nombre, duracionHoras: duracion }),
|
||||||
|
});
|
||||||
|
setGuardando(false);
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
setError(d.error ?? "No se pudo guardar el trabajo");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setNombre("");
|
||||||
|
setDuracion("");
|
||||||
|
cargar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleActivo(s: Servicio) {
|
||||||
|
await fetch(`/api/jefe/servicios/${s.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ activo: !s.activo }),
|
||||||
|
});
|
||||||
|
cargar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editarDuracion(s: Servicio) {
|
||||||
|
const valor = prompt(`Duración de "${s.nombre}" en horas:`, String(s.duracionHoras));
|
||||||
|
if (valor == null) return;
|
||||||
|
const res = await fetch(`/api/jefe/servicios/${s.id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ duracionHoras: valor }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
alert(d.error ?? "No se pudo actualizar");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cargar();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function eliminar(s: Servicio) {
|
||||||
|
if (!confirm(`¿Eliminar el trabajo "${s.nombre}"?`)) return;
|
||||||
|
const res = await fetch(`/api/jefe/servicios/${s.id}`, { method: "DELETE" });
|
||||||
|
const d = await res.json().catch(() => ({}));
|
||||||
|
if (d.desactivado)
|
||||||
|
alert("El trabajo ya tenía turnos asociados, así que se desactivó en vez de borrarse.");
|
||||||
|
cargar();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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"
|
||||||
|
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)}>
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user