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
+44 -2
View File
@@ -1,5 +1,14 @@
import { prisma } from "@/lib/db";
import { formatDate, formatCurrency, calcularTotalesOpcion, IVA_RATE, type MetaOpcion } from "@/lib/calculators";
import { getConfigBranding } from "@/lib/config-helpers";
import {
formatDate,
formatCurrency,
calcularTotalesOpcion,
esCotizacionPorTiempo,
tarifaHoraSugerida,
IVA_RATE,
type MetaOpcion,
} from "@/lib/calculators";
import Link from "next/link";
import { ArrowLeft, Pencil } from "lucide-react";
import { notFound } from "next/navigation";
@@ -7,6 +16,7 @@ import { ExportExcelButtonSaved, ExportPDFButtonSaved, PreviewPDFButtonSaved } f
import { DeleteCotizacionButton } from "./DeleteButton";
import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
import { PreciosEditables } from "./PreciosEditables";
import { RegistroHorasPanel } from "./RegistroHorasPanel";
export const dynamic = "force-dynamic";
@@ -23,17 +33,25 @@ export default async function CotizacionDetailPage({
asesor: true,
servicios: { include: { servicioCatalogo: true } },
planBucefalo: true,
registrosHoras: { orderBy: [{ fecha: "asc" }, { horaInicio: "asc" }] },
},
});
if (!cot) notFound();
// El registro de horas (notas de pago) solo aplica a cotizaciones aprobadas que se
// cobran por tiempo (horas/retainer/demanda).
const mostrarHoras = cot.estado === "aprobada" && esCotizacionPorTiempo(cot.servicios);
const branding = mostrarHoras ? await getConfigBranding() : null;
const serviciosUnicos = cot.servicios
.filter((s) => s.tipoPago === "unico" && s.seleccionado)
.map((s) => ({
id: s.id,
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
precio: s.precio,
modeloCobro: s.modeloCobro,
tarifaHora: s.tarifaHora,
}));
const serviciosMensuales = cot.servicios
@@ -42,6 +60,8 @@ export default async function CotizacionDetailPage({
id: s.id,
nombre: s.servicioCatalogo?.nombre || s.nombre || "Servicio",
precio: s.precio,
modeloCobro: s.modeloCobro,
tarifaHora: s.tarifaHora,
}));
const opcionesMeta = (cot.opcionesMetadata as { "1"?: MetaOpcion; "2"?: MetaOpcion } | null) ?? {};
@@ -85,7 +105,7 @@ export default async function CotizacionDetailPage({
</div>
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
<h2 className="font-semibold text-lg mb-4">Informacion de la Cotizacion</h2>
<h2 className="font-semibold text-lg mb-4">Información de la Cotización</h2>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div>
<span className="text-muted">Cliente</span>
@@ -180,6 +200,28 @@ export default async function CotizacionDetailPage({
serviciosMensuales={serviciosMensuales}
/>
{mostrarHoras && (
<RegistroHorasPanel
cotizacionId={cot.id}
numero={cot.numero}
clienteNombre={cot.cliente.nombre}
clienteEmpresa={cot.cliente.empresa}
proyecto={cot.proyecto}
tarifaSugerida={tarifaHoraSugerida(cot.servicios)}
incluirIva={cot.incluirIva}
branding={branding ?? {}}
registrosIniciales={cot.registrosHoras.map((r) => ({
id: r.id,
fecha: r.fecha.toISOString(),
horaInicio: r.horaInicio,
horaFin: r.horaFin,
horas: r.horas,
tarifaHora: r.tarifaHora,
descripcion: r.descripcion,
}))}
/>
)}
{cot.observaciones && (
<div className="bg-card-bg rounded-xl border border-border p-5">
<h3 className="font-semibold mb-2">Observaciones</h3>