Files
cotizador/src/app/(app)/cotizaciones/[id]/page.tsx
T
urieljarethandClaude Opus 4.8 8b1e420d4a 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]>
2026-07-08 02:11:57 -06:00

234 lines
8.5 KiB
TypeScript

import { prisma } from "@/lib/db";
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";
import { ExportExcelButtonSaved, ExportPDFButtonSaved, PreviewPDFButtonSaved } from "@/components/ExportButtons";
import { DeleteCotizacionButton } from "./DeleteButton";
import { CambiarEstadoButtons } from "./CambiarEstadoButtons";
import { PreciosEditables } from "./PreciosEditables";
import { RegistroHorasPanel } from "./RegistroHorasPanel";
export const dynamic = "force-dynamic";
export default async function CotizacionDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
const cot = await prisma.cotizacion.findUnique({
where: { id },
include: {
cliente: true,
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
.filter((s) => s.tipoPago === "mensual" && s.seleccionado)
.map((s) => ({
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) ?? {};
const totalesOpcion = cot.esDoble
? {
"1": calcularTotalesOpcion(cot.servicios, "1"),
"2": calcularTotalesOpcion(cot.servicios, "2"),
}
: null;
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-4">
<Link
href="/cotizaciones"
className="p-2 hover:bg-gray-100 rounded-lg"
>
<ArrowLeft className="w-5 h-5" />
</Link>
<div>
<h1 className="text-2xl font-bold">{cot.numero}</h1>
<p className="text-muted text-sm">
{cot.cliente.empresa || cot.cliente.nombre} | {cot.proyecto}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<PreviewPDFButtonSaved cotizacionId={cot.id} />
<ExportPDFButtonSaved cotizacionId={cot.id} />
<ExportExcelButtonSaved cotizacionId={cot.id} />
<Link
href={`/cotizaciones/${cot.id}/editar`}
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50"
>
<Pencil className="w-4 h-4" />
Editar
</Link>
<DeleteCotizacionButton cotizacionId={cot.id} cotizacionNumero={cot.numero} />
</div>
</div>
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
<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>
<p className="font-medium">{cot.cliente.nombre}</p>
</div>
<div>
<span className="text-muted">Empresa</span>
<p className="font-medium">{cot.cliente.empresa || "-"}</p>
</div>
<div>
<span className="text-muted">Asesor</span>
<p className="font-medium">{cot.asesor.name}</p>
</div>
<div>
<span className="text-muted">Fecha</span>
<p className="font-medium">{formatDate(cot.fecha)}</p>
</div>
<div>
<span className="text-muted">Vigencia</span>
<p className="font-medium">{formatDate(cot.vigencia)}</p>
</div>
<div>
<span className="text-muted">Moneda</span>
<p className="font-medium">{cot.moneda}</p>
</div>
<div>
<span className="text-muted">Esquema</span>
<p className="font-medium">{cot.esquemaPago}</p>
</div>
<div>
<span className="text-muted">Estado</span>
<div className="mt-0.5">
<CambiarEstadoButtons cotizacionId={cot.id} estado={cot.estado} />
</div>
</div>
</div>
</div>
{cot.esDoble && totalesOpcion && (
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
<h2 className="font-semibold text-lg mb-4">
Doble propuesta comparativa
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{(["1", "2"] as const).map((op) => {
const t = totalesOpcion[op];
const meta = opcionesMeta[op];
return (
<div key={op} className="border border-primary/30 rounded-lg p-4 bg-primary-light/10">
<h3 className="font-semibold text-primary">
Opcion {op}
{meta?.titulo ? `: ${meta.titulo}` : ""}
</h3>
{meta?.descripcion && (
<p className="text-sm text-muted mt-1 whitespace-pre-wrap">{meta.descripcion}</p>
)}
{meta?.noIncluye && (
<p className="text-xs text-muted mt-2">
<span className="font-medium">No incluye:</span>{" "}
<span className="whitespace-pre-wrap">{meta.noIncluye}</span>
</p>
)}
<div className="mt-3 pt-3 border-t border-border space-y-1 text-sm">
<div className="flex justify-between">
<span>Total unico (c/IVA)</span>
<span className="font-medium">{formatCurrency(t.totalUnico * (1 + IVA_RATE))}</span>
</div>
<div className="flex justify-between">
<span>Total mensual (c/IVA)</span>
<span className="font-medium">{formatCurrency(t.totalMensual * (1 + IVA_RATE))}</span>
</div>
{t.horas > 0 && (
<div className="flex justify-between text-muted text-xs">
<span>Horas estimadas</span>
<span>{t.horas} h</span>
</div>
)}
</div>
</div>
);
})}
</div>
<p className="text-xs text-muted mt-3">
Las partidas marcadas <b>Ambas</b> suman en las dos opciones. Edita la asignacion de cada partida desde <b>Editar</b>.
</p>
</div>
)}
<PreciosEditables
cotizacionId={cot.id}
serviciosUnicos={serviciosUnicos}
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>
<p className="text-sm text-muted whitespace-pre-wrap">{cot.observaciones}</p>
</div>
)}
</div>
);
}