From 8b1e420d4a1035c178bcfb623caf8667d554a02c Mon Sep 17 00:00:00 2001 From: urieljareth Date: Wed, 8 Jul 2026 02:11:57 -0600 Subject: [PATCH] Recuperar feature de registro de horas / cobro por tiempo (RegistroHoras) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .megaignore | 7 + .../migration.sql | 46 ++ prisma/schema.prisma | 26 + .../cotizaciones/[id]/PreciosEditables.tsx | 71 +-- .../cotizaciones/[id]/RegistroHorasPanel.tsx | 589 ++++++++++++++++++ src/app/(app)/cotizaciones/[id]/page.tsx | 46 +- .../[id]/horas/[registroId]/route.ts | 76 +++ src/app/api/cotizaciones/[id]/horas/route.ts | 104 ++++ src/app/api/cotizaciones/[id]/route.ts | 30 +- src/app/api/cotizaciones/route.ts | 7 +- src/lib/calculators.ts | 158 +++++ src/lib/schemas.ts | 24 +- 12 files changed, 1138 insertions(+), 46 deletions(-) create mode 100644 .megaignore create mode 100644 prisma/migrations/20260630000000_add_registro_horas_incluir_iva/migration.sql create mode 100644 src/app/(app)/cotizaciones/[id]/RegistroHorasPanel.tsx create mode 100644 src/app/api/cotizaciones/[id]/horas/[registroId]/route.ts create mode 100644 src/app/api/cotizaciones/[id]/horas/route.ts diff --git a/.megaignore b/.megaignore new file mode 100644 index 0000000..2954863 --- /dev/null +++ b/.megaignore @@ -0,0 +1,7 @@ +# Excluir de la sincronizacion de MegaSync. +# Estas carpetas tienen junction points (symlinks de Windows) y artefactos +# de build que cambian constantemente; sincronizarlas hace que MegaSync pelee +# con Turbopack y provoca el panic "failed to create junction point (os error 145)". +-:.next +-:node_modules +-:.turbo diff --git a/prisma/migrations/20260630000000_add_registro_horas_incluir_iva/migration.sql b/prisma/migrations/20260630000000_add_registro_horas_incluir_iva/migration.sql new file mode 100644 index 0000000..e923d12 --- /dev/null +++ b/prisma/migrations/20260630000000_add_registro_horas_incluir_iva/migration.sql @@ -0,0 +1,46 @@ +-- Feature "registro de horas / cobro por tiempo": recupera el modelo RegistroHoras y +-- las columnas incluirIva / rfc / beneficios que existian en desarrollo pero no habian +-- llegado a la rama desplegada. Idempotente (IF NOT EXISTS) para poder aplicarse tanto +-- en la BD web (limpia) como en una BD que ya tenga estos objetos. + +-- AlterTable: Cliente.rfc +ALTER TABLE "Cliente" ADD COLUMN IF NOT EXISTS "rfc" TEXT; + +-- AlterTable: Cotizacion.incluirIva +ALTER TABLE "Cotizacion" ADD COLUMN IF NOT EXISTS "incluirIva" BOOLEAN NOT NULL DEFAULT true; + +-- AlterTable: ServicioCotizado.beneficios +ALTER TABLE "ServicioCotizado" ADD COLUMN IF NOT EXISTS "beneficios" JSONB NOT NULL DEFAULT '[]'; + +-- CreateTable: RegistroHoras +CREATE TABLE IF NOT EXISTS "RegistroHoras" ( + "id" TEXT NOT NULL, + "cotizacionId" TEXT NOT NULL, + "fecha" TIMESTAMP(3) NOT NULL, + "horaInicio" TEXT NOT NULL, + "horaFin" TEXT NOT NULL, + "horas" DOUBLE PRECISION NOT NULL, + "tarifaHora" DOUBLE PRECISION NOT NULL DEFAULT 0, + "descripcion" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RegistroHoras_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "RegistroHoras_cotizacionId_idx" ON "RegistroHoras"("cotizacionId"); +CREATE INDEX IF NOT EXISTS "RegistroHoras_fecha_idx" ON "RegistroHoras"("fecha"); + +-- AddForeignKey (guarda porque ADD CONSTRAINT no admite IF NOT EXISTS en PostgreSQL) +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_constraint WHERE conname = 'RegistroHoras_cotizacionId_fkey' + ) THEN + ALTER TABLE "RegistroHoras" + ADD CONSTRAINT "RegistroHoras_cotizacionId_fkey" + FOREIGN KEY ("cotizacionId") REFERENCES "Cotizacion"("id") + ON DELETE CASCADE ON UPDATE CASCADE; + END IF; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aeb38c4..f9954a3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -25,6 +25,7 @@ model Cliente { empresa String? email String? telefono String? + rfc String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -45,6 +46,7 @@ model Cotizacion { estado String @default("borrador") incluirBonos Boolean @default(false) incluirFinanciamiento Boolean @default(false) + incluirIva Boolean @default(true) esDoble Boolean @default(false) opcionesMetadata Json? observaciones String? @@ -57,6 +59,7 @@ model Cotizacion { asesor User @relation("AsesorCotizaciones", fields: [asesorId], references: [id]) servicios ServicioCotizado[] planBucefalo PlanBucefaloCotizacion? + registrosHoras RegistroHoras[] @@index([clienteId]) @@index([asesorId]) @@ -158,6 +161,7 @@ model ServicioCotizado { precio Float tiempoEntrega String entregables Json @default("[]") + beneficios Json @default("[]") notas String? seleccionado Boolean @default(true) createdAt DateTime @default(now()) @@ -182,6 +186,28 @@ model PlanBucefaloCotizacion { cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade) } +// Registro de horas trabajadas contra una cotizacion aprobada con cobro por tiempo +// (modeloCobro horas/retainer/demanda). Se usa para emitir notas de pago al cliente: +// cada entrada es un dia con un rango horario, una descripcion breve y una tarifa +// (snapshot). `horas` se calcula del rango (calcularHorasRango) y se persiste. +model RegistroHoras { + id String @id @default(cuid()) + cotizacionId String + fecha DateTime + horaInicio String + horaFin String + horas Float + tarifaHora Float @default(0) + descripcion String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + cotizacion Cotizacion @relation(fields: [cotizacionId], references: [id], onDelete: Cascade) + + @@index([cotizacionId]) + @@index([fecha]) +} + model Configuracion { id String @id @default(cuid()) clave String @unique diff --git a/src/app/(app)/cotizaciones/[id]/PreciosEditables.tsx b/src/app/(app)/cotizaciones/[id]/PreciosEditables.tsx index 66f6719..133cd06 100644 --- a/src/app/(app)/cotizaciones/[id]/PreciosEditables.tsx +++ b/src/app/(app)/cotizaciones/[id]/PreciosEditables.tsx @@ -1,12 +1,14 @@ "use client"; import { useState } from "react"; -import { formatCurrency, IVA_RATE } from "@/lib/calculators"; +import { formatCurrency, precioDisplay, IVA_RATE } from "@/lib/calculators"; interface ServicioItem { id: string; nombre: string; precio: number; + modeloCobro?: string | null; + tarifaHora?: number | null; } interface PreciosEditablesProps { @@ -56,6 +58,33 @@ export function PreciosEditables({ } }; + // Las partidas "bajo demanda" no tienen un precio fijo (se facturan por consumo): + // su monto guardado es 0 y lo relevante es la tarifa por hora cotizada, que se + // muestra como texto en vez de un input editable de precio. + const renderFila = (s: ServicioItem, tipo: "unico" | "mensual") => ( +
+ {s.nombre} +
+ {s.modeloCobro === "demanda" ? ( + {precioDisplay(s)} + ) : ( + <> + $ + + handlePrecioChange(s.id, parseFloat(e.target.value) || 0, tipo) + } + className={INPUT_CLS} + /> + {saving === s.id && ...} + + )} +
+
+ ); + return (
@@ -67,25 +96,7 @@ export function PreciosEditables({

Sin servicios

) : (
- {unicos.map((s) => ( -
- {s.nombre} -
- $ - - handlePrecioChange(s.id, parseFloat(e.target.value) || 0, "unico") - } - className={INPUT_CLS} - /> - {saving === s.id && ( - ... - )} -
-
- ))} + {unicos.map((s) => renderFila(s, "unico"))}
)}
@@ -115,25 +126,7 @@ export function PreciosEditables({

Sin servicios

) : (
- {mensuales.map((s) => ( -
- {s.nombre} -
- $ - - handlePrecioChange(s.id, parseFloat(e.target.value) || 0, "mensual") - } - className={INPUT_CLS} - /> - {saving === s.id && ( - ... - )} -
-
- ))} + {mensuales.map((s) => renderFila(s, "mensual"))}
)}
diff --git a/src/app/(app)/cotizaciones/[id]/RegistroHorasPanel.tsx b/src/app/(app)/cotizaciones/[id]/RegistroHorasPanel.tsx new file mode 100644 index 0000000..8129f3b --- /dev/null +++ b/src/app/(app)/cotizaciones/[id]/RegistroHorasPanel.tsx @@ -0,0 +1,589 @@ +"use client"; + +import { useMemo, useRef, useState } from "react"; +import { Clock, Plus, Trash2, Pencil, Printer, Eye, X } from "lucide-react"; +import { + formatCurrency, + formatFechaRegistro, + periodoAgrupacion, + calcularHorasRango, + MODOS_AGRUPACION, + IVA_RATE, + type ModoAgrupacion, +} from "@/lib/calculators"; + +interface Registro { + id: string; + fecha: string; // ISO (12:00 UTC) + horaInicio: string; + horaFin: string; + horas: number; + tarifaHora: number; + descripcion: string; +} + +interface Branding { + colorPrimario?: string; + colorSecundario?: string; + logoBase64?: string; + logoMime?: string; +} + +interface RegistroHorasPanelProps { + cotizacionId: string; + numero: string; + clienteNombre: string; + clienteEmpresa: string | null; + proyecto: string; + tarifaSugerida: number; + incluirIva: boolean; + branding: Branding; + registrosIniciales: Registro[]; +} + +function hoyISO(): string { + const d = new Date(); + const p2 = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())}`; +} + +const formVacio = (tarifa: number) => ({ + fecha: hoyISO(), + horaInicio: "09:00", + horaFin: "13:00", + descripcion: "", + tarifaHora: tarifa, +}); + +const INPUT = "px-2 py-1.5 border border-border rounded text-sm focus:outline-none focus:ring-1 focus:ring-primary"; + +export function RegistroHorasPanel({ + cotizacionId, + numero, + clienteNombre, + clienteEmpresa, + proyecto, + tarifaSugerida, + incluirIva, + branding, + registrosIniciales, +}: RegistroHorasPanelProps) { + const [registros, setRegistros] = useState(registrosIniciales); + const [form, setForm] = useState(formVacio(tarifaSugerida)); + const [editId, setEditId] = useState(null); + const [modo, setModo] = useState("detalle"); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + // HTML de la nota congelado al abrir la previsualización (null = modal cerrado). + const [previewHtml, setPreviewHtml] = useState(null); + const iframeRef = useRef(null); + + // Horas en vivo del rango actual del formulario (feedback al asesor). + const horasPreview = calcularHorasRango(form.horaInicio, form.horaFin); + + const filtrados = useMemo(() => { + return registros + .filter((r) => { + const d = r.fecha.slice(0, 10); + if (from && d < from) return false; + if (to && d > to) return false; + return true; + }) + .sort((a, b) => + a.fecha === b.fecha + ? a.horaInicio.localeCompare(b.horaInicio) + : a.fecha.localeCompare(b.fecha) + ); + }, [registros, from, to]); + + const grupos = useMemo(() => { + if (modo === "detalle") return []; + const map = new Map(); + for (const r of filtrados) { + const { clave, etiqueta } = periodoAgrupacion(new Date(r.fecha), modo); + const g = map.get(clave) ?? { etiqueta, horas: 0, importe: 0, count: 0 }; + g.horas += r.horas; + g.importe += r.horas * r.tarifaHora; + g.count += 1; + map.set(clave, g); + } + return [...map.entries()] + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([clave, g]) => ({ clave, ...g })); + }, [filtrados, modo]); + + 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; + const total = subtotal + iva; + + const resetForm = () => { + setForm(formVacio(tarifaSugerida)); + setEditId(null); + setError(null); + }; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setError(null); + if (calcularHorasRango(form.horaInicio, form.horaFin) <= 0) { + setError("El rango es invalido: la hora fin debe ser mayor a la de inicio."); + return; + } + if (!form.descripcion.trim()) { + setError("Agrega una descripcion breve de lo realizado."); + return; + } + setSaving(true); + try { + const url = editId + ? `/api/cotizaciones/${cotizacionId}/horas/${editId}` + : `/api/cotizaciones/${cotizacionId}/horas`; + const res = await fetch(url, { + method: editId ? "PUT" : "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(form), + }); + const data = await res.json(); + if (!res.ok) { + setError(data.error || "No se pudo guardar el registro"); + return; + } + setRegistros((prev) => + editId ? prev.map((r) => (r.id === editId ? data : r)) : [...prev, data] + ); + resetForm(); + } catch { + setError("Error de conexion al guardar"); + } finally { + setSaving(false); + } + }; + + const handleEdit = (r: Registro) => { + setEditId(r.id); + setError(null); + setForm({ + fecha: r.fecha.slice(0, 10), + horaInicio: r.horaInicio, + horaFin: r.horaFin, + descripcion: r.descripcion, + tarifaHora: r.tarifaHora, + }); + window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }); + }; + + const handleDelete = async (id: string) => { + if (!confirm("¿Eliminar este registro de horas?")) return; + const prev = registros; + setRegistros((rs) => rs.filter((r) => r.id !== id)); + if (editId === id) resetForm(); + try { + const res = await fetch(`/api/cotizaciones/${cotizacionId}/horas/${id}`, { + method: "DELETE", + }); + if (!res.ok) setRegistros(prev); + } catch { + setRegistros(prev); + } + }; + + // Congela el HTML de la nota (con branding) y abre el modal de previsualización. + const abrirPreview = () => setPreviewHtml(construirHTMLNota()); + + // Imprime / guarda como PDF solo el contenido de la nota previsualizada. + const imprimirDesdePreview = () => { + const w = iframeRef.current?.contentWindow; + if (!w) return; + w.focus(); + w.print(); + }; + + // ---- HTML autocontenido de la nota de pago (para imprimir / guardar como PDF) ---- + const construirHTMLNota = () => { + const PRIMARY = branding.colorPrimario || "#2563eb"; + const DARK = branding.colorSecundario || "#111827"; + const logoSrc = branding.logoBase64 + ? `data:${branding.logoMime || "image/png"};base64,${branding.logoBase64}` + : ""; + + const periodoTexto = + from || to + ? `Periodo: ${from ? formatFechaRegistro(new Date(`${from}T12:00:00Z`)) : "inicio"} al ${ + to ? formatFechaRegistro(new Date(`${to}T12:00:00Z`)) : "hoy" + }` + : "Periodo: todos los registros"; + + const modoLabel = MODOS_AGRUPACION.find((m) => m.value === modo)?.label ?? ""; + + const cuerpo = + modo === "detalle" + ? ` + FechaHorarioHoras + TarifaImporteDescripcion + + ${filtrados + .map( + (r) => ` + ${formatFechaRegistro(new Date(r.fecha))} + ${r.horaInicio}–${r.horaFin} + ${r.horas.toFixed(2)} + ${formatCurrency(r.tarifaHora)} + ${formatCurrency(r.horas * r.tarifaHora)} + ${escapeHtml(r.descripcion)} + ` + ) + .join("")}` + : ` + ${modoLabel}Registros + HorasImporte + + ${grupos + .map( + (g) => ` + ${escapeHtml(g.etiqueta)} + ${g.count} + ${g.horas.toFixed(2)} + ${formatCurrency(g.importe)} + ` + ) + .join("")}`; + + const totalesHTML = ` + + + + ${incluirIva ? `` : ""} + +
Total de horas${totalHoras.toFixed(2)} h
Subtotal${formatCurrency(subtotal)}
IVA (16%)${formatCurrency(iva)}
Total a pagar${formatCurrency(total)}
`; + + return ` + Nota de pago ${escapeHtml(numero)} + +
+
+ ${logoSrc ? `` : ""} +
+

Nota de pago

+
Cotizacion ${escapeHtml(numero)} · ${escapeHtml(proyecto)}
+
+
+
Emitida: ${formatFechaRegistro(new Date(new Date().toISOString().slice(0, 10) + "T12:00:00Z"))}
+
+
+
Cliente: ${escapeHtml(clienteEmpresa || clienteNombre)}
+ ${clienteEmpresa ? `
Contacto: ${escapeHtml(clienteNombre)}
` : ""} +
${periodoTexto} · Desglose ${modoLabel.toLowerCase()}
+
+ ${cuerpo}
+ ${totalesHTML} +
Documento generado desde el Cotizador. Conteo de horas trabajadas para cobro del proyecto.
+ `; + }; + + return ( +
+
+

+ + Registro de horas +

+ +
+ + {/* Formulario de alta / edicion */} +
+
+ + setForm({ ...form, fecha: e.target.value })} + className={`${INPUT} w-full`} + required + /> +
+
+ + setForm({ ...form, horaInicio: e.target.value })} + className={`${INPUT} w-full`} + required + /> +
+
+ + setForm({ ...form, horaFin: e.target.value })} + className={`${INPUT} w-full`} + required + /> +
+
+ +
+ {horasPreview > 0 ? horasPreview.toFixed(2) : "—"} +
+
+
+ + setForm({ ...form, descripcion: e.target.value })} + placeholder="Qué se hizo en este tramo" + className={`${INPUT} w-full`} + maxLength={500} + required + /> +
+
+ + setForm({ ...form, tarifaHora: parseFloat(e.target.value) || 0 })} + className={`${INPUT} w-full text-right`} + /> +
+
+ + {editId && ( + + )} +
+ {error &&

{error}

} +
+ + {/* Filtros y agrupacion */} +
+
+ + setFrom(e.target.value)} className={INPUT} /> +
+
+ + setTo(e.target.value)} className={INPUT} /> +
+ {(from || to) && ( + + )} +
+ + +
+
+ + {/* Tabla */} + {filtrados.length === 0 ? ( +

+ Aún no hay registros de horas{from || to ? " en este periodo" : ""}. +

+ ) : ( +
+ + + + {modo === "detalle" ? ( + <> + + + + + + + + + ) : ( + <> + + + + + + )} + + + + {modo === "detalle" + ? filtrados.map((r) => ( + + + + + + + + + + )) + : grupos.map((g) => ( + + + + + + + ))} + +
FechaHorarioHorasTarifaImporteDescripcionPeriodoRegistrosHorasImporte
{formatFechaRegistro(new Date(r.fecha))} + {r.horaInicio}–{r.horaFin} + {r.horas.toFixed(2)}{formatCurrency(r.tarifaHora)}{formatCurrency(r.horas * r.tarifaHora)}{r.descripcion} + + +
{g.etiqueta}{g.count}{g.horas.toFixed(2)}{formatCurrency(g.importe)}
+
+ )} + + {/* Totales */} + {filtrados.length > 0 && ( +
+
+
+ Total de horas + {totalHoras.toFixed(2)} h +
+
+ Subtotal + {formatCurrency(subtotal)} +
+ {incluirIva && ( +
+ IVA (16%) + {formatCurrency(iva)} +
+ )} +
+ Total a pagar + {formatCurrency(total)} +
+
+
+ )} + + {/* Previsualizador de la nota de pago */} + {previewHtml !== null && ( +
setPreviewHtml(null)} + > +
e.stopPropagation()} + > +

Nota de pago — {numero}

+
+ + +
+
+
e.stopPropagation()}> +