"use client"; import { useMemo, useRef, useState } from "react"; import { Clock, Plus, Trash2, Pencil, Download, Eye, X, CheckCircle2, CircleDashed, RotateCcw } from "lucide-react"; import { formatCurrency, formatFechaRegistro, periodoAgrupacion, calcularHorasRango, resumenPagoHoras, esPagada, 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; estadoPago: string; // "por_pagar" | "pagada" fechaPago?: string | null; } 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[]; } type FiltroEstado = "todas" | "por_pagar" | "pagada"; const FILTROS_ESTADO: { value: FiltroEstado; label: string }[] = [ { value: "por_pagar", label: "Por pagar" }, { value: "pagada", label: "Pagadas" }, { value: "todas", label: "Todas" }, ]; // Titulo del documento segun el filtro de estado activo. const TITULO_NOTA: Record = { por_pagar: "Nota de horas por pagar", pagada: "Recibo de horas pagadas", todas: "Estado de cuenta de horas", }; 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"); // Por defecto "por_pagar": el documento que se emite/manda al cliente nunca debe // incluir horas ya pagadas. "pagada"/"todas" son vistas internas (recibo/estado de cuenta). const [estadoFiltro, setEstadoFiltro] = useState("por_pagar"); const [from, setFrom] = useState(""); const [to, setTo] = useState(""); const [saving, setSaving] = useState(false); const [descargando, setDescargando] = useState(false); const [togglingId, setTogglingId] = useState(null); 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); // Registros acotados solo por fecha (base para el resumen por-pagar / pagado que // se muestra SIEMPRE, independientemente del filtro de estado en pantalla). const porFecha = 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]); // Resumen separado: pendiente vs pagado (nunca se mezclan). const resumen = useMemo(() => resumenPagoHoras(porFecha), [porFecha]); // Lo que se ve en la tabla y entra en la nota: filtro de fecha + estado. const filtrados = useMemo(() => { if (estadoFiltro === "todas") return porFecha; return porFecha.filter((r) => estadoFiltro === "pagada" ? esPagada(r.estadoPago) : !esPagada(r.estadoPago) ); }, [porFecha, estadoFiltro]); 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 ivaFactor = incluirIva ? 1 + IVA_RATE : 1; // Totales de la seleccion actual (lo que se imprime en la nota). 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); } }; // Cambia el estado de cobro de una partida a un valor explícito. Optimista. // Al regresar a "por_pagar" el backend limpia la fechaPago (deja de contar como cobrada). const cambiarEstado = async (r: Registro, nuevo: "por_pagar" | "pagada") => { if (esPagada(r.estadoPago) === (nuevo === "pagada")) return; // ya está en ese estado const prev = registros; setTogglingId(r.id); setRegistros((rs) => rs.map((x) => (x.id === r.id ? { ...x, estadoPago: nuevo } : x))); try { const res = await fetch(`/api/cotizaciones/${cotizacionId}/horas/${r.id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ estadoPago: nuevo }), }); const data = await res.json(); if (!res.ok) setRegistros(prev); else setRegistros((rs) => rs.map((x) => (x.id === r.id ? data : x))); } catch { setRegistros(prev); } finally { setTogglingId(null); } }; // Regresa una partida pagada por error a "Por pagar". Pide confirmación porque se // pierde la marca/fecha de pago y la partida vuelve a entrar en la nota por cobrar. const regresarPorPagar = (r: Registro) => { if (!confirm('¿Regresar esta partida a "Por pagar"? Se quitará la marca de pagada.')) return; cambiarEstado(r, "por_pagar"); }; // Congela el HTML de la nota (con branding) y abre el modal de previsualización. const abrirPreview = () => setPreviewHtml(construirHTMLNota()); // Descarga la nota como PDF generado en el SERVIDOR (pdfkit). A diferencia de imprimir // el HTML desde el navegador, este PDF no lleva encabezados/pies del navegador (URL, // fecha, no. de pagina). Respeta el filtro de estado, periodo y desglose actuales. const descargarNotaPDF = async () => { setDescargando(true); try { const res = await fetch(`/api/cotizaciones/${cotizacionId}/nota-horas`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ estado: estadoFiltro, modo, from, to }), }); if (!res.ok) { alert("No se pudo generar el PDF"); return; } const blob = await res.blob(); // Periodo cobrado para el nombre: fecha minima_maxima de los registros de la nota. const fechas = filtrados.map((r) => r.fecha.slice(0, 10)).sort(); const dd = (iso: string) => { const [y, mo, d] = iso.split("-"); return `${d}-${mo}-${y}`; }; const rango = fechas.length ? `${dd(fechas[0])}_${dd(fechas[fechas.length - 1])}` : ""; const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${TITULO_NOTA[estadoFiltro]} ${numero}${rango ? ` - ${rango}` : ""}.pdf`; document.body.appendChild(a); a.click(); a.remove(); setTimeout(() => URL.revokeObjectURL(url), 2000); } catch { alert("Error de conexion al generar el PDF"); } finally { setDescargando(false); } }; const badgeEstado = (estadoPago: string) => { const pagada = esPagada(estadoPago); return ( {pagada ? : } {pagada ? "Pagada" : "Por pagar"} ); }; // ---- 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 titulo = TITULO_NOTA[estadoFiltro]; // Solo el estado de cuenta (todas) muestra la columna Estado por partida. const mostrarColEstado = estadoFiltro === "todas" && modo === "detalle"; const cuerpo = modo === "detalle" ? ` FechaHorarioHoras TarifaImporteDescripcion${ mostrarColEstado ? "Estado" : "" } ${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)}${ mostrarColEstado ? `${esPagada(r.estadoPago) ? "Pagada" : "Por pagar"}` : "" } ` ) .join("")}` : ` ${modoLabel}Registros HorasImporte ${grupos .map( (g) => ` ${escapeHtml(g.etiqueta)} ${g.count} ${g.horas.toFixed(2)} ${formatCurrency(g.importe)} ` ) .join("")}`; const etiquetaTotal = estadoFiltro === "pagada" ? "Total pagado" : estadoFiltro === "todas" ? "Total" : "Total a pagar"; const totalesHTML = ` ${incluirIva ? `` : ""}
Total de horas${totalHoras.toFixed(2)} h
Subtotal${formatCurrency(subtotal)}
IVA (16%)${formatCurrency(iva)}
${etiquetaTotal}${formatCurrency(total)}
`; return ` ${escapeHtml(titulo)} ${escapeHtml(numero)}
${logoSrc ? `` : ""}

${escapeHtml(titulo)}

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

{/* Resumen separado: pendiente vs pagado (nunca se acumulan). */}
Por pagar
{formatCurrency(resumen.porPagar.importe * ivaFactor)}
{resumen.porPagar.horas.toFixed(2)} h · {resumen.porPagar.count} registro(s) {incluirIva ? " · IVA incluido" : ""}
Pagado
{formatCurrency(resumen.pagada.importe * ivaFactor)}
{resumen.pagada.horas.toFixed(2)} h · {resumen.pagada.count} registro(s) {incluirIva ? " · IVA incluido" : ""}
{/* 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: estado de pago, periodo y agrupacion */}
setFrom(e.target.value)} className={INPUT} />
setTo(e.target.value)} className={INPUT} />
{(from || to) && ( )}
{/* Tabla */} {filtrados.length === 0 ? (

{estadoFiltro === "pagada" ? "No hay horas marcadas como pagadas" : estadoFiltro === "por_pagar" ? "No hay horas pendientes de pago" : "Aún no hay registros de horas"} {from || to ? " en este periodo" : ""}.

) : (
{modo === "detalle" ? ( <> ) : ( <> )} {modo === "detalle" ? filtrados.map((r) => ( )) : grupos.map((g) => ( ))}
Fecha Horario Horas Tarifa Importe Descripcion Estado Periodo Registros Horas Importe
{formatFechaRegistro(new Date(r.fecha))} {r.horaInicio}–{r.horaFin} {r.horas.toFixed(2)} {formatCurrency(r.tarifaHora)} {formatCurrency(r.horas * r.tarifaHora)} {r.descripcion} {badgeEstado(r.estadoPago)} {esPagada(r.estadoPago) ? ( ) : ( )}
{g.etiqueta} {g.count} {g.horas.toFixed(2)} {formatCurrency(g.importe)}
)} {/* Totales de la seleccion actual */} {filtrados.length > 0 && (
Total de horas{estadoFiltro !== "todas" ? ` (${FILTROS_ESTADO.find((f) => f.value === estadoFiltro)?.label.toLowerCase()})` : ""} {totalHoras.toFixed(2)} h
Subtotal {formatCurrency(subtotal)}
{incluirIva && (
IVA (16%) {formatCurrency(iva)}
)}
{estadoFiltro === "pagada" ? "Total pagado" : estadoFiltro === "todas" ? "Total general" : "Total a pagar"} {formatCurrency(total)}
)} {/* Previsualizador de la nota de pago */} {previewHtml !== null && (
setPreviewHtml(null)} >
e.stopPropagation()} >

{TITULO_NOTA[estadoFiltro]} — {numero}

e.stopPropagation()}>