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,12 +1,14 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { Clock, Plus, Trash2, Pencil, Printer, Eye, X } from "lucide-react";
import { Clock, Plus, Trash2, Pencil, Printer, Eye, X, CheckCircle2, CircleDashed } from "lucide-react";
import {
formatCurrency,
formatFechaRegistro,
periodoAgrupacion,
calcularHorasRango,
resumenPagoHoras,
esPagada,
MODOS_AGRUPACION,
IVA_RATE,
type ModoAgrupacion,
@@ -20,6 +22,8 @@ interface Registro {
horas: number;
tarifaHora: number;
descripcion: string;
estadoPago: string; // "por_pagar" | "pagada"
fechaPago?: string | null;
}
interface Branding {
@@ -41,6 +45,21 @@ interface RegistroHorasPanelProps {
registrosIniciales: Registro[];
}
type FiltroEstado = "todas" | "por_pagar" | "pagada";
const FILTROS_ESTADO: { value: FiltroEstado; label: string }[] = [
{ value: "por_pagar", label: "Por pagar" },
{ value: "pagada", label: "Pagadas" },
{ value: "todas", label: "Todas" },
];
// Titulo del documento segun el filtro de estado activo.
const TITULO_NOTA: Record<FiltroEstado, string> = {
por_pagar: "Nota de horas por pagar",
pagada: "Recibo de horas pagadas",
todas: "Estado de cuenta de horas",
};
function hoyISO(): string {
const d = new Date();
const p2 = (n: number) => String(n).padStart(2, "0");
@@ -72,9 +91,11 @@ export function RegistroHorasPanel({
const [form, setForm] = useState(formVacio(tarifaSugerida));
const [editId, setEditId] = useState<string | null>(null);
const [modo, setModo] = useState<ModoAgrupacion>("detalle");
const [estadoFiltro, setEstadoFiltro] = useState<FiltroEstado>("todas");
const [from, setFrom] = useState("");
const [to, setTo] = useState("");
const [saving, setSaving] = useState(false);
const [togglingId, setTogglingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
// HTML de la nota congelado al abrir la previsualización (null = modal cerrado).
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
@@ -83,7 +104,9 @@ export function RegistroHorasPanel({
// Horas en vivo del rango actual del formulario (feedback al asesor).
const horasPreview = calcularHorasRango(form.horaInicio, form.horaFin);
const filtrados = useMemo(() => {
// Registros acotados solo por fecha (base para el resumen por-pagar / pagado que
// se muestra SIEMPRE, independientemente del filtro de estado en pantalla).
const porFecha = useMemo(() => {
return registros
.filter((r) => {
const d = r.fecha.slice(0, 10);
@@ -98,6 +121,17 @@ export function RegistroHorasPanel({
);
}, [registros, from, to]);
// Resumen separado: pendiente vs pagado (nunca se mezclan).
const resumen = useMemo(() => resumenPagoHoras(porFecha), [porFecha]);
// Lo que se ve en la tabla y entra en la nota: filtro de fecha + estado.
const filtrados = useMemo(() => {
if (estadoFiltro === "todas") return porFecha;
return porFecha.filter((r) =>
estadoFiltro === "pagada" ? esPagada(r.estadoPago) : !esPagada(r.estadoPago)
);
}, [porFecha, estadoFiltro]);
const grupos = useMemo(() => {
if (modo === "detalle") return [];
const map = new Map<string, { etiqueta: string; horas: number; importe: number; count: number }>();
@@ -114,6 +148,9 @@ export function RegistroHorasPanel({
.map(([clave, g]) => ({ clave, ...g }));
}, [filtrados, modo]);
const ivaFactor = incluirIva ? 1 + IVA_RATE : 1;
// Totales de la seleccion actual (lo que se imprime en la nota).
const totalHoras = filtrados.reduce((a, r) => a + r.horas, 0);
const subtotal = filtrados.reduce((a, r) => a + r.horas * r.tarifaHora, 0);
const iva = incluirIva ? subtotal * IVA_RATE : 0;
@@ -190,6 +227,28 @@ export function RegistroHorasPanel({
}
};
// Alterna el estado de cobro de una partida (por_pagar <-> pagada). Optimista.
const handleToggleEstado = async (r: Registro) => {
const nuevo = esPagada(r.estadoPago) ? "por_pagar" : "pagada";
const prev = registros;
setTogglingId(r.id);
setRegistros((rs) => rs.map((x) => (x.id === r.id ? { ...x, estadoPago: nuevo } : x)));
try {
const res = await fetch(`/api/cotizaciones/${cotizacionId}/horas/${r.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ estadoPago: nuevo }),
});
const data = await res.json();
if (!res.ok) setRegistros(prev);
else setRegistros((rs) => rs.map((x) => (x.id === r.id ? data : x)));
} catch {
setRegistros(prev);
} finally {
setTogglingId(null);
}
};
// Congela el HTML de la nota (con branding) y abre el modal de previsualización.
const abrirPreview = () => setPreviewHtml(construirHTMLNota());
@@ -201,6 +260,20 @@ export function RegistroHorasPanel({
w.print();
};
const badgeEstado = (estadoPago: string) => {
const pagada = esPagada(estadoPago);
return (
<span
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
pagada ? "bg-green-100 text-green-800" : "bg-amber-100 text-amber-800"
}`}
>
{pagada ? <CheckCircle2 className="w-3 h-3" /> : <CircleDashed className="w-3 h-3" />}
{pagada ? "Pagada" : "Por pagar"}
</span>
);
};
// ---- HTML autocontenido de la nota de pago (para imprimir / guardar como PDF) ----
const construirHTMLNota = () => {
const PRIMARY = branding.colorPrimario || "#2563eb";
@@ -217,12 +290,17 @@ export function RegistroHorasPanel({
: "Periodo: todos los registros";
const modoLabel = MODOS_AGRUPACION.find((m) => m.value === modo)?.label ?? "";
const titulo = TITULO_NOTA[estadoFiltro];
// Solo el estado de cuenta (todas) muestra la columna Estado por partida.
const mostrarColEstado = estadoFiltro === "todas" && modo === "detalle";
const cuerpo =
modo === "detalle"
? `<thead><tr>
<th>Fecha</th><th>Horario</th><th class="r">Horas</th>
<th class="r">Tarifa</th><th class="r">Importe</th><th>Descripcion</th>
<th class="r">Tarifa</th><th class="r">Importe</th><th>Descripcion</th>${
mostrarColEstado ? "<th>Estado</th>" : ""
}
</tr></thead>
<tbody>${filtrados
.map(
@@ -232,7 +310,11 @@ export function RegistroHorasPanel({
<td class="r">${r.horas.toFixed(2)}</td>
<td class="r">${formatCurrency(r.tarifaHora)}</td>
<td class="r">${formatCurrency(r.horas * r.tarifaHora)}</td>
<td>${escapeHtml(r.descripcion)}</td>
<td>${escapeHtml(r.descripcion)}</td>${
mostrarColEstado
? `<td>${esPagada(r.estadoPago) ? "Pagada" : "Por pagar"}</td>`
: ""
}
</tr>`
)
.join("")}</tbody>`
@@ -251,16 +333,19 @@ export function RegistroHorasPanel({
)
.join("")}</tbody>`;
const etiquetaTotal =
estadoFiltro === "pagada" ? "Total pagado" : estadoFiltro === "todas" ? "Total" : "Total a pagar";
const totalesHTML = `
<table class="tot">
<tr><td>Total de horas</td><td class="r">${totalHoras.toFixed(2)} h</td></tr>
<tr><td>Subtotal</td><td class="r">${formatCurrency(subtotal)}</td></tr>
${incluirIva ? `<tr><td>IVA (16%)</td><td class="r">${formatCurrency(iva)}</td></tr>` : ""}
<tr class="grand"><td>Total a pagar</td><td class="r">${formatCurrency(total)}</td></tr>
<tr class="grand"><td>${etiquetaTotal}</td><td class="r">${formatCurrency(total)}</td></tr>
</table>`;
return `<!doctype html><html lang="es"><head><meta charset="utf-8">
<title>Nota de pago ${escapeHtml(numero)}</title>
<title>${escapeHtml(titulo)} ${escapeHtml(numero)}</title>
<style>
*{box-sizing:border-box} body{font-family:Arial,Helvetica,sans-serif;color:#1f2937;margin:40px;font-size:13px}
h1{font-size:20px;margin:0 0 4px;color:${DARK}} .muted{color:#6b7280}
@@ -282,7 +367,7 @@ export function RegistroHorasPanel({
<div class="brand">
${logoSrc ? `<img src="${logoSrc}" alt="Logo" class="logo">` : ""}
<div>
<h1>Nota de pago</h1>
<h1>${escapeHtml(titulo)}</h1>
<div class="muted">Cotizacion ${escapeHtml(numero)} · ${escapeHtml(proyecto)}</div>
</div>
</div>
@@ -312,10 +397,42 @@ export function RegistroHorasPanel({
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 disabled:opacity-40"
>
<Eye className="w-4 h-4 text-blue-600" />
Previsualizar nota
{estadoFiltro === "pagada"
? "Previsualizar recibo"
: estadoFiltro === "todas"
? "Previsualizar estado de cuenta"
: "Previsualizar nota por pagar"}
</button>
</div>
{/* Resumen separado: pendiente vs pagado (nunca se acumulan). */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
<div className="rounded-lg border border-amber-200 bg-amber-50/60 p-3">
<div className="flex items-center gap-2 text-amber-800 text-sm font-medium">
<CircleDashed className="w-4 h-4" /> Por pagar
</div>
<div className="mt-1 text-2xl font-bold text-amber-900">
{formatCurrency(resumen.porPagar.importe * ivaFactor)}
</div>
<div className="text-xs text-amber-800/80">
{resumen.porPagar.horas.toFixed(2)} h · {resumen.porPagar.count} registro(s)
{incluirIva ? " · IVA incluido" : ""}
</div>
</div>
<div className="rounded-lg border border-green-200 bg-green-50/60 p-3">
<div className="flex items-center gap-2 text-green-800 text-sm font-medium">
<CheckCircle2 className="w-4 h-4" /> Pagado
</div>
<div className="mt-1 text-2xl font-bold text-green-900">
{formatCurrency(resumen.pagada.importe * ivaFactor)}
</div>
<div className="text-xs text-green-800/80">
{resumen.pagada.horas.toFixed(2)} h · {resumen.pagada.count} registro(s)
{incluirIva ? " · IVA incluido" : ""}
</div>
</div>
</div>
{/* Formulario de alta / edicion */}
<form
onSubmit={handleSubmit}
@@ -402,8 +519,22 @@ export function RegistroHorasPanel({
{error && <p className="md:col-span-12 text-sm text-red-600">{error}</p>}
</form>
{/* Filtros y agrupacion */}
{/* Filtros: estado de pago, periodo y agrupacion */}
<div className="flex items-end gap-3 flex-wrap mb-3">
<div>
<label className="block text-xs text-muted mb-1">Estado</label>
<select
value={estadoFiltro}
onChange={(e) => setEstadoFiltro(e.target.value as FiltroEstado)}
className={INPUT}
>
{FILTROS_ESTADO.map((f) => (
<option key={f.value} value={f.value}>
{f.label}
</option>
))}
</select>
</div>
<div>
<label className="block text-xs text-muted mb-1">Desde</label>
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={INPUT} />
@@ -442,7 +573,12 @@ export function RegistroHorasPanel({
{/* Tabla */}
{filtrados.length === 0 ? (
<p className="text-muted text-sm py-6 text-center">
Aún no hay registros de horas{from || to ? " en este periodo" : ""}.
{estadoFiltro === "pagada"
? "No hay horas marcadas como pagadas"
: estadoFiltro === "por_pagar"
? "No hay horas pendientes de pago"
: "Aún no hay registros de horas"}
{from || to ? " en este periodo" : ""}.
</p>
) : (
<div className="overflow-x-auto">
@@ -457,6 +593,7 @@ export function RegistroHorasPanel({
<th className="py-2 pr-3 font-medium text-right">Tarifa</th>
<th className="py-2 pr-3 font-medium text-right">Importe</th>
<th className="py-2 pr-3 font-medium">Descripcion</th>
<th className="py-2 pr-3 font-medium">Estado</th>
<th className="py-2 font-medium"></th>
</>
) : (
@@ -472,7 +609,10 @@ export function RegistroHorasPanel({
<tbody>
{modo === "detalle"
? filtrados.map((r) => (
<tr key={r.id} className="border-b border-border/60">
<tr
key={r.id}
className={`border-b border-border/60 ${esPagada(r.estadoPago) ? "bg-green-50/40" : ""}`}
>
<td className="py-2 pr-3 whitespace-nowrap">{formatFechaRegistro(new Date(r.fecha))}</td>
<td className="py-2 pr-3 whitespace-nowrap">
{r.horaInicio}{r.horaFin}
@@ -481,6 +621,16 @@ export function RegistroHorasPanel({
<td className="py-2 pr-3 text-right">{formatCurrency(r.tarifaHora)}</td>
<td className="py-2 pr-3 text-right">{formatCurrency(r.horas * r.tarifaHora)}</td>
<td className="py-2 pr-3">{r.descripcion}</td>
<td className="py-2 pr-3 whitespace-nowrap">
<button
onClick={() => handleToggleEstado(r)}
disabled={togglingId === r.id}
title={esPagada(r.estadoPago) ? "Marcar como por pagar" : "Marcar como pagada"}
className="disabled:opacity-50"
>
{badgeEstado(r.estadoPago)}
</button>
</td>
<td className="py-2 text-right whitespace-nowrap">
<button
onClick={() => handleEdit(r)}
@@ -512,12 +662,14 @@ export function RegistroHorasPanel({
</div>
)}
{/* Totales */}
{/* Totales de la seleccion actual */}
{filtrados.length > 0 && (
<div className="mt-4 pt-3 border-t border-border flex justify-end">
<div className="w-full max-w-xs space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted">Total de horas</span>
<span className="text-muted">
Total de horas{estadoFiltro !== "todas" ? ` (${FILTROS_ESTADO.find((f) => f.value === estadoFiltro)?.label.toLowerCase()})` : ""}
</span>
<span className="font-medium">{totalHoras.toFixed(2)} h</span>
</div>
<div className="flex justify-between">
@@ -531,7 +683,13 @@ export function RegistroHorasPanel({
</div>
)}
<div className="flex justify-between font-bold pt-2 border-t border-border">
<span>Total a pagar</span>
<span>
{estadoFiltro === "pagada"
? "Total pagado"
: estadoFiltro === "todas"
? "Total general"
: "Total a pagar"}
</span>
<span className="text-primary">{formatCurrency(total)}</span>
</div>
</div>
@@ -548,7 +706,7 @@ export function RegistroHorasPanel({
className="flex items-center justify-between gap-4 px-4 py-3 bg-white border-b border-border"
onClick={(e) => e.stopPropagation()}
>
<h3 className="font-semibold text-sm truncate">Nota de pago {numero}</h3>
<h3 className="font-semibold text-sm truncate">{TITULO_NOTA[estadoFiltro]} {numero}</h3>
<div className="flex items-center gap-2 shrink-0">
<button
onClick={imprimirDesdePreview}