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()}> +