Registro de horas: estado de pago por partida (por pagar / pagada)

Permite separar las horas ya cobradas de las pendientes para que los totales no se
acumulen y poder emitir la nota por pagar al cliente y conservar el recibo de lo pagado.

- Modelo: RegistroHoras.estadoPago ("por_pagar" default | "pagada") + fechaPago
  (se sella al marcar pagada, se limpia al revertir). Migración idempotente.
- API: PATCH /api/cotizaciones/[id]/horas/[registroId] para marcar por pagar/pagada.
- calculators: ESTADOS_PAGO_HORAS + resumenPagoHoras() que suma por separado
  pendiente vs pagado.
- Panel: resumen "Por pagar" vs "Pagado" siempre visible (con IVA si aplica),
  badge/toggle de estado por fila, filtro de estado (Por pagar/Pagadas/Todas) que
  además define el documento: "Nota de horas por pagar", "Recibo de horas pagadas"
  o "Estado de cuenta de horas".

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
This commit is contained in:
urieljareth
2026-07-08 08:33:14 -06:00
co-authored by Claude Opus 4.8
parent 8b1e420d4a
commit d83a80c85a
7 changed files with 282 additions and 16 deletions
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { registroHorasSchema } from "@/lib/schemas";
import { registroHorasSchema, registroHorasEstadoSchema } from "@/lib/schemas";
import { calcularHorasRango, fechaRegistroDesdeISO } from "@/lib/calculators";
// PUT /api/cotizaciones/:id/horas/:registroId
@@ -54,6 +54,46 @@ export async function PUT(
}
}
// PATCH /api/cotizaciones/:id/horas/:registroId
// Marca la partida como "por_pagar" o "pagada" sin tocar el resto. Al marcarla pagada
// se sella la fechaPago; al regresarla a por_pagar se limpia.
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string; registroId: string }> }
) {
try {
const { id, registroId } = await params;
const body = await request.json();
const parsed = registroHorasEstadoSchema.safeParse(body);
if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.issues.map((i) => i.message).join(", ") },
{ status: 400 }
);
}
const { estadoPago } = parsed.data;
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: {
estadoPago,
fechaPago: estadoPago === "pagada" ? (existing.fechaPago ?? new Date()) : null,
},
});
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,