Recuperar feature de registro de horas / cobro por tiempo (RegistroHoras)

La cotización UJ2606AG777 (aprobada, "Agentes IA") se cobra por hora bajo
demanda, pero la rama desplegada (main) no incluía la feature de registro de
horas que sí existía en desarrollo (rama master): el cargo de horas de junio no
se veía en la web y los proyectos por tiempo no mostraban sus cobros en el panel
individual.

Se porta la feature de forma quirúrgica (sin arrastrar cambios no relacionados):

- Modelo RegistroHoras + columnas Cotizacion.incluirIva, Cliente.rfc,
  ServicioCotizado.beneficios (migración idempotente para la web).
- calculators.ts: modelo de cobro "demanda", helpers de horas
  (calcularHorasRango, agrupación por día/semana/mes, notas de pago).
- RegistroHorasPanel: panel para registrar/editar/borrar horas y previsualizar
  la nota de pago con branding. Solo aparece en cotizaciones aprobadas con cobro
  por tiempo (horas/retainer/demanda).
- PreciosEditables: los servicios "demanda" muestran la tarifa/hr en vez de $0.
- API /api/cotizaciones/[id]/horas (+ /[registroId]) para el CRUD de registros.
- Handlers de cotizaciones: persisten incluirIva/rfc/beneficios, "demanda" nunca
  suma al total, y fast-path para el cambio de estado (arregla CambiarEstado que
  fallaba la validación al enviar solo { estado }).
- .megaignore para evitar que MegaSync corrompa node_modules/.next.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-08 02:11:57 -06:00
co-authored by Claude Opus 4.8
parent 4d550c5963
commit 8b1e420d4a
12 changed files with 1138 additions and 46 deletions
@@ -0,0 +1,76 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { registroHorasSchema } from "@/lib/schemas";
import { calcularHorasRango, fechaRegistroDesdeISO } from "@/lib/calculators";
// PUT /api/cotizaciones/:id/horas/:registroId
// Edita un registro de horas. Recalcula `horas` del rango.
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string; registroId: string }> }
) {
try {
const { id, registroId } = await params;
const body = await request.json();
const parsed = registroHorasSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const { fecha, horaInicio, horaFin, descripcion, tarifaHora } = parsed.data;
const horas = calcularHorasRango(horaInicio, horaFin);
if (horas <= 0) {
return NextResponse.json(
{ error: "El rango de horas es invalido (la hora fin debe ser mayor a la de inicio)" },
{ status: 400 }
);
}
const existing = await prisma.registroHoras.findUnique({ where: { id: registroId } });
if (!existing || existing.cotizacionId !== id) {
return NextResponse.json({ error: "Registro no encontrado" }, { status: 404 });
}
const registro = await prisma.registroHoras.update({
where: { id: registroId },
data: {
fecha: fechaRegistroDesdeISO(fecha),
horaInicio,
horaFin,
horas,
descripcion: descripcion.trim(),
...(tarifaHora !== undefined && { tarifaHora }),
},
});
return NextResponse.json(registro);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
// DELETE /api/cotizaciones/:id/horas/:registroId
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string; registroId: string }> }
) {
try {
const { id, registroId } = await params;
const existing = await prisma.registroHoras.findUnique({ where: { id: registroId } });
if (!existing || existing.cotizacionId !== id) {
return NextResponse.json({ error: "Registro no encontrado" }, { status: 404 });
}
await prisma.registroHoras.delete({ where: { id: registroId } });
return NextResponse.json({ ok: true });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
@@ -0,0 +1,104 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { registroHorasSchema } from "@/lib/schemas";
import {
calcularHorasRango,
esCotizacionPorTiempo,
fechaRegistroDesdeISO,
tarifaHoraSugerida,
} from "@/lib/calculators";
// GET /api/cotizaciones/:id/horas?from=YYYY-MM-DD&to=YYYY-MM-DD
// Lista los registros de horas de la cotizacion (orden cronologico). Filtros from/to
// opcionales para acotar el periodo de una nota de pago.
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const sp = request.nextUrl.searchParams;
const from = sp.get("from");
const to = sp.get("to");
const fechaFilter: { gte?: Date; lte?: Date } = {};
if (from && /^\d{4}-\d{2}-\d{2}$/.test(from)) fechaFilter.gte = fechaRegistroDesdeISO(from);
if (to && /^\d{4}-\d{2}-\d{2}$/.test(to)) fechaFilter.lte = fechaRegistroDesdeISO(to);
const registros = await prisma.registroHoras.findMany({
where: {
cotizacionId: id,
...(Object.keys(fechaFilter).length > 0 && { fecha: fechaFilter }),
},
orderBy: [{ fecha: "asc" }, { horaInicio: "asc" }],
});
return NextResponse.json(registros);
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
// POST /api/cotizaciones/:id/horas
// Crea un registro de horas. Solo para cotizaciones con cobro por tiempo. `horas` se
// calcula del rango en el servidor; si la tarifa no viene, se usa la sugerida.
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await request.json();
const parsed = registroHorasSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const { fecha, horaInicio, horaFin, descripcion, tarifaHora } = parsed.data;
const horas = calcularHorasRango(horaInicio, horaFin);
if (horas <= 0) {
return NextResponse.json(
{ error: "El rango de horas es invalido (la hora fin debe ser mayor a la de inicio)" },
{ status: 400 }
);
}
const cot = await prisma.cotizacion.findUnique({
where: { id },
include: { servicios: true },
});
if (!cot) {
return NextResponse.json({ error: "Cotizacion no encontrada" }, { status: 404 });
}
if (!esCotizacionPorTiempo(cot.servicios)) {
return NextResponse.json(
{ error: "Esta cotizacion no se cobra por tiempo; no admite registro de horas" },
{ status: 400 }
);
}
const tarifa = tarifaHora ?? tarifaHoraSugerida(cot.servicios);
const registro = await prisma.registroHoras.create({
data: {
cotizacionId: id,
fecha: fechaRegistroDesdeISO(fecha),
horaInicio,
horaFin,
horas,
tarifaHora: tarifa,
descripcion: descripcion.trim(),
},
});
return NextResponse.json(registro, { status: 201 });
} catch (error: unknown) {
const msg = error instanceof Error ? error.message : "Error interno";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
+29 -1
View File
@@ -58,6 +58,28 @@ export async function PUT(
try {
const { id } = await params;
const body = await request.json();
// Camino rápido: cambio de estado puntual (CambiarEstadoButtons envía solo
// { estado }). El schema completo exige todos los campos de la cotización,
// así que un PUT con solo el estado fallaría la validación; lo tratamos
// como un patch parcial.
const bodyKeys = Object.keys(body);
if (bodyKeys.length === 1 && bodyKeys[0] === "estado") {
const nuevoEstado = body.estado;
if (!ESTADOS_COTIZACION.includes(nuevoEstado as typeof ESTADOS_COTIZACION[number])) {
return NextResponse.json({ error: "Estado invalido" }, { status: 400 });
}
const existing = await prisma.cotizacion.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "No encontrada" }, { status: 404 });
}
const updated = await prisma.cotizacion.update({
where: { id },
data: { estado: nuevoEstado },
});
return NextResponse.json(updated);
}
const parsed = cotizacionPutSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
@@ -74,6 +96,7 @@ export async function PUT(
esquemaPago,
incluirBonos,
incluirFinanciamiento,
incluirIva,
esDoble,
opciones,
observaciones,
@@ -100,6 +123,7 @@ export async function PUT(
data: {
email: cliente.email || existingCliente.email,
telefono: cliente.telefono || existingCliente.telefono,
rfc: cliente.rfc || existingCliente.rfc,
},
});
} else {
@@ -109,6 +133,7 @@ export async function PUT(
empresa: cliente.empresa || null,
email: cliente.email || null,
telefono: cliente.telefono || null,
rfc: cliente.rfc || null,
},
});
clienteIdFinal = newCliente.id;
@@ -129,6 +154,7 @@ export async function PUT(
...(esquemaPago && { esquemaPago }),
...(incluirBonos !== undefined && { incluirBonos }),
...(incluirFinanciamiento !== undefined && { incluirFinanciamiento }),
...(incluirIva !== undefined && { incluirIva }),
...(esDoble !== undefined && { esDoble }),
...(esDoble !== undefined && { opcionesMetadata: esDoble ? opciones ?? {} : undefined }),
...(observaciones !== undefined && { observaciones }),
@@ -174,9 +200,11 @@ export async function PUT(
opcion: esDobleFinal ? serv.opcion ?? "ambas" : null,
fase: serv.fase,
tipoPago: serv.tipoPago,
precio: serv.precio,
// "demanda": tarifa por hora sin compromiso; nunca suma al total.
precio: serv.modeloCobro === "demanda" ? 0 : serv.precio,
tiempoEntrega: serv.tiempoEntrega,
entregables: serv.entregables,
beneficios: serv.beneficios ?? [],
seleccionado: true,
};
}),
+6 -1
View File
@@ -22,6 +22,7 @@ export async function POST(request: NextRequest) {
esquemaPago,
incluirBonos,
incluirFinanciamiento,
incluirIva,
esDoble,
opciones,
observaciones,
@@ -45,6 +46,7 @@ export async function POST(request: NextRequest) {
empresa: cliente.empresa || null,
email: cliente.email || null,
telefono: cliente.telefono || null,
rfc: cliente.rfc || null,
},
});
}
@@ -75,6 +77,7 @@ export async function POST(request: NextRequest) {
esquemaPago,
incluirBonos,
incluirFinanciamiento,
incluirIva: incluirIva ?? true,
esDoble: esDoble ?? false,
opcionesMetadata: esDoble ? opciones ?? {} : undefined,
observaciones: observaciones || null,
@@ -107,9 +110,11 @@ export async function POST(request: NextRequest) {
opcion: esDoble ? servicio.opcion ?? "ambas" : null,
fase: servicio.fase,
tipoPago: servicio.tipoPago,
precio: servicio.precio,
// "demanda": tarifa por hora sin compromiso; nunca suma al total.
precio: servicio.modeloCobro === "demanda" ? 0 : servicio.precio,
tiempoEntrega: servicio.tiempoEntrega,
entregables: servicio.entregables,
beneficios: servicio.beneficios ?? [],
seleccionado: true,
};
}),