diff --git a/prisma/migrations/20260708000000_registro_horas_estado_pago/migration.sql b/prisma/migrations/20260708000000_registro_horas_estado_pago/migration.sql new file mode 100644 index 0000000..459a98d --- /dev/null +++ b/prisma/migrations/20260708000000_registro_horas_estado_pago/migration.sql @@ -0,0 +1,10 @@ +-- Estado de cobro por partida de horas: separa lo pendiente ("por_pagar") de lo ya +-- cobrado ("pagada") para poder emitir notas por pagar y conservar recibos pagados sin +-- que los totales se mezclen. Idempotente (IF NOT EXISTS). + +-- AlterTable: RegistroHoras.estadoPago / fechaPago +ALTER TABLE "RegistroHoras" ADD COLUMN IF NOT EXISTS "estadoPago" TEXT NOT NULL DEFAULT 'por_pagar'; +ALTER TABLE "RegistroHoras" ADD COLUMN IF NOT EXISTS "fechaPago" TIMESTAMP(3); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "RegistroHoras_estadoPago_idx" ON "RegistroHoras"("estadoPago"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f9954a3..2539dd7 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -199,6 +199,11 @@ model RegistroHoras { horas Float tarifaHora Float @default(0) descripcion String + // Estado de cobro de la partida de horas: "por_pagar" (default) o "pagada". + // Permite separar lo pendiente (nota por pagar al cliente) de lo ya cobrado + // (recibo/historial) sin que los totales se mezclen. fechaPago = cuando se marco pagada. + estadoPago String @default("por_pagar") + fechaPago DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -206,6 +211,7 @@ model RegistroHoras { @@index([cotizacionId]) @@index([fecha]) + @@index([estadoPago]) } model Configuracion { diff --git a/src/app/(app)/cotizaciones/[id]/RegistroHorasPanel.tsx b/src/app/(app)/cotizaciones/[id]/RegistroHorasPanel.tsx index 8129f3b..f509fdb 100644 --- a/src/app/(app)/cotizaciones/[id]/RegistroHorasPanel.tsx +++ b/src/app/(app)/cotizaciones/[id]/RegistroHorasPanel.tsx @@ -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 = { + 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(null); const [modo, setModo] = useState("detalle"); + const [estadoFiltro, setEstadoFiltro] = useState("todas"); const [from, setFrom] = useState(""); const [to, setTo] = useState(""); const [saving, setSaving] = useState(false); + const [togglingId, setTogglingId] = useState(null); const [error, setError] = useState(null); // HTML de la nota congelado al abrir la previsualización (null = modal cerrado). const [previewHtml, setPreviewHtml] = useState(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(); @@ -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 ( + + {pagada ? : } + {pagada ? "Pagada" : "Por pagar"} + + ); + }; + // ---- 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" ? ` FechaHorarioHoras - TarifaImporteDescripcion + TarifaImporteDescripcion${ + mostrarColEstado ? "Estado" : "" + } ${filtrados .map( @@ -232,7 +310,11 @@ export function RegistroHorasPanel({ ${r.horas.toFixed(2)} ${formatCurrency(r.tarifaHora)} ${formatCurrency(r.horas * r.tarifaHora)} - ${escapeHtml(r.descripcion)} + ${escapeHtml(r.descripcion)}${ + mostrarColEstado + ? `${esPagada(r.estadoPago) ? "Pagada" : "Por pagar"}` + : "" + } ` ) .join("")}` @@ -251,16 +333,19 @@ export function RegistroHorasPanel({ ) .join("")}`; + const etiquetaTotal = + estadoFiltro === "pagada" ? "Total pagado" : estadoFiltro === "todas" ? "Total" : "Total a pagar"; + const totalesHTML = ` ${incluirIva ? `` : ""} - +
Total de horas${totalHoras.toFixed(2)} h
Subtotal${formatCurrency(subtotal)}
IVA (16%)${formatCurrency(iva)}
Total a pagar${formatCurrency(total)}
${etiquetaTotal}${formatCurrency(total)}
`; return ` - Nota de pago ${escapeHtml(numero)} + ${escapeHtml(titulo)} ${escapeHtml(numero)}