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
+158
View File
@@ -9,12 +9,64 @@ export function calcularPrecioHoras(horas: number, tarifaHora: number): number {
// Modelos de cobro de una partida. "retainer" = importe minimo mensual fijo +
// tarifa de horas adicionales (las horas extra se facturan aparte, no suman al total).
// "demanda" = tarifa por hora sin horas comprometidas; precio=0, no suma al total
// (se factura a fin de mes segun consumo). Solo muestra la tarifa y sus beneficios.
export const MODELOS_COBRO: Record<string, string> = {
fijo: "Precio fijo",
horas: "Por horas",
retainer: "Retainer (minimo + adicionales)",
demanda: "Bajo demanda / por hora",
};
// Sub-linea de desglose por modelo de cobro (horas / retainer / demanda) para PDF/Excel.
// Reutilizado por ambos backends para mantener paridad.
export function detalleModelo(serv: {
modeloCobro?: string | null;
esPersonalizado?: boolean | null;
horas?: number | null;
tarifaHora?: number | null;
montoMinimo?: number | null;
horasIncluidas?: number | null;
}): string {
if (serv.modeloCobro === "retainer") {
return describirRetainer(serv.montoMinimo ?? 0, serv.horasIncluidas ?? 0, serv.tarifaHora ?? 0);
}
if (serv.modeloCobro === "demanda") {
return `${formatCurrency(serv.tarifaHora ?? 0)}/hr · segun consumo`;
}
if ((serv.modeloCobro === "horas" || serv.esPersonalizado) && serv.horas && serv.tarifaHora) {
return `${serv.horas} h x ${formatCurrency(serv.tarifaHora)}/hr`;
}
return "";
}
// Texto a mostrar en la columna "Precio" de una partida. Para "demanda" muestra la
// tarifa por hora en vez de $0 (el precio real depende del consumo). Reutilizado por
// UI, PDF y Excel para mantener consistencia.
export function precioDisplay(serv: {
modeloCobro?: string | null;
tarifaHora?: number | null;
precio: number;
}): string {
if (serv.modeloCobro === "demanda") {
return `${formatCurrency(serv.tarifaHora || 0)}/hr`;
}
return formatCurrency(serv.precio);
}
// Nota al pie para las partidas "bajo demanda": no suman al total comprometido y se
// facturan segun consumo. Devuelve "" si no hay partidas demanda. Reutilizado por PDF y Excel.
export function notaDemanda(
servicios: Array<{ modeloCobro?: string | null; nombre?: string | null; tarifaHora?: number | null }>
): string {
const dem = servicios.filter((s) => s.modeloCobro === "demanda");
if (dem.length === 0) return "";
const detalle = dem
.map((s) => `${s.nombre || "Servicio"} a ${formatCurrency(s.tarifaHora || 0)}/hr`)
.join(", ");
return `+ Horas facturadas a fin de mes segun consumo (no incluidas en el total): ${detalle}.`;
}
// Texto descriptivo de un retainer, reutilizado por UI, PDF y Excel.
export function describirRetainer(
montoMinimo: number,
@@ -84,6 +136,112 @@ export const PLANES_BUCEFALO = [
export const ESTADOS_COTIZACION = ["borrador", "enviada", "aprobada", "rechazada"] as const;
export type EstadoCotizacion = (typeof ESTADOS_COTIZACION)[number];
// ----- Registro de horas (notas de pago) -----
// Modelos de cobro que implican trabajo por tiempo: para estas cotizaciones se
// habilita el registro de horas trabajadas y la emision de notas de pago.
export const MODELOS_COBRO_TIEMPO = ["horas", "retainer", "demanda"] as const;
// Una cotizacion es "por tiempo" si al menos una de sus partidas se cobra por horas,
// retainer o demanda. Solo en ese caso tiene sentido registrar horas trabajadas.
export function esCotizacionPorTiempo(
servicios: Array<{ modeloCobro?: string | null }>
): boolean {
return servicios.some((s) =>
(MODELOS_COBRO_TIEMPO as readonly string[]).includes(s.modeloCobro ?? "")
);
}
// Tarifa por hora a sugerir al registrar horas: la de la primera partida por tiempo
// con tarifa > 0; si no hay, TARIFA_HORA_DEFAULT.
export function tarifaHoraSugerida(
servicios: Array<{ modeloCobro?: string | null; tarifaHora?: number | null }>
): number {
const serv = servicios.find(
(s) =>
(MODELOS_COBRO_TIEMPO as readonly string[]).includes(s.modeloCobro ?? "") &&
(s.tarifaHora ?? 0) > 0
);
return serv?.tarifaHora ?? TARIFA_HORA_DEFAULT;
}
// "09:00" + "13:30" => 4.5. Devuelve 0 si el formato es invalido o fin <= inicio.
// No cruza medianoche (un registro = un tramo dentro de un dia).
export function calcularHorasRango(horaInicio: string, horaFin: string): number {
const re = /^(\d{1,2}):(\d{2})$/;
const a = re.exec(horaInicio ?? "");
const b = re.exec(horaFin ?? "");
if (!a || !b) return 0;
const ini = parseInt(a[1], 10) * 60 + parseInt(a[2], 10);
const fin = parseInt(b[1], 10) * 60 + parseInt(b[2], 10);
if (fin <= ini) return 0;
return Math.round(((fin - ini) / 60) * 100) / 100;
}
export type ModoAgrupacion = "detalle" | "dia" | "semana" | "mes";
export const MODOS_AGRUPACION: { value: ModoAgrupacion; label: string }[] = [
{ value: "detalle", label: "Detalle" },
{ value: "dia", label: "Por dia" },
{ value: "semana", label: "Por semana" },
{ value: "mes", label: "Por mes" },
];
const MESES_LARGO = [
"enero", "febrero", "marzo", "abril", "mayo", "junio",
"julio", "agosto", "septiembre", "octubre", "noviembre", "diciembre",
];
// La fecha de un registro es una fecha-calendario sin huso horario. Para que el dia
// no se corra entre servidor (UTC) y cliente (MX), se persiste a las 12:00 UTC y se
// lee SIEMPRE con getters UTC. Helper para construir esa fecha desde "YYYY-MM-DD".
export function fechaRegistroDesdeISO(fechaISO: string): Date {
return new Date(`${fechaISO}T12:00:00.000Z`);
}
// Formato corto de fecha de registro (DD/MM/YYYY) leyendo en UTC.
export function formatFechaRegistro(date: Date): string {
return new Intl.DateTimeFormat("es-MX", {
day: "2-digit",
month: "2-digit",
year: "numeric",
timeZone: "UTC",
}).format(date);
}
function lunesDeSemanaUTC(d: Date): Date {
const r = new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
const dia = r.getUTCDay(); // 0 = domingo
const offset = dia === 0 ? -6 : 1 - dia;
r.setUTCDate(r.getUTCDate() + offset);
return r;
}
// Clave + etiqueta del periodo al que pertenece una fecha, segun el modo de agrupacion.
// La clave agrupa; la etiqueta se muestra. Todo en UTC para coherencia de calendario.
export function periodoAgrupacion(
fecha: Date,
modo: ModoAgrupacion
): { clave: string; etiqueta: string } {
const y = fecha.getUTCFullYear();
const m = fecha.getUTCMonth();
const d = fecha.getUTCDate();
const p2 = (n: number) => String(n).padStart(2, "0");
if (modo === "mes") {
return { clave: `${y}-${p2(m + 1)}`, etiqueta: `${MESES_LARGO[m]} ${y}` };
}
if (modo === "semana") {
const lunes = lunesDeSemanaUTC(fecha);
const domingo = new Date(lunes);
domingo.setUTCDate(lunes.getUTCDate() + 6);
const fmt = (x: Date) => `${p2(x.getUTCDate())}/${p2(x.getUTCMonth() + 1)}`;
const clave = `${lunes.getUTCFullYear()}-${p2(lunes.getUTCMonth() + 1)}-${p2(lunes.getUTCDate())}`;
return { clave, etiqueta: `Semana ${fmt(lunes)} - ${fmt(domingo)}` };
}
// "dia" / "detalle"
return { clave: `${y}-${p2(m + 1)}-${p2(d)}`, etiqueta: formatFechaRegistro(fecha) };
}
export function bucefaloPrecio(nivel: string): number {
return PLANES_BUCEFALO.find((p) => p.nivel === nivel)?.precio ?? 0;
}