El badge de estado deja de ser un toggle (facil de disparar por error) y pasa a ser un indicador. En su lugar, la columna de acciones muestra un boton claro segun el estado: "Marcar como pagada" (pendientes) o "Regresar a Por pagar" (pagadas, con confirmacion). Asi se corrigen los casos donde una partida se marca como pagada por error. El backend ya limpiaba la fechaPago al regresar a por_pagar; solo cambia la UX del panel. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
804 lines
31 KiB
TypeScript
804 lines
31 KiB
TypeScript
"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<FiltroEstado, string> = {
|
||
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<Registro[]>(registrosIniciales);
|
||
const [form, setForm] = useState(formVacio(tarifaSugerida));
|
||
const [editId, setEditId] = useState<string | null>(null);
|
||
const [modo, setModo] = useState<ModoAgrupacion>("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<FiltroEstado>("por_pagar");
|
||
const [from, setFrom] = useState("");
|
||
const [to, setTo] = useState("");
|
||
const [saving, setSaving] = useState(false);
|
||
const [descargando, setDescargando] = useState(false);
|
||
const [togglingId, setTogglingId] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
// HTML de la nota congelado al abrir la previsualización (null = modal cerrado).
|
||
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
||
const iframeRef = useRef<HTMLIFrameElement>(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<string, { etiqueta: string; horas: number; importe: number; count: number }>();
|
||
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 (
|
||
<span
|
||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||
pagada ? "bg-green-100 text-green-800" : "bg-amber-100 text-amber-800"
|
||
}`}
|
||
>
|
||
{pagada ? <CheckCircle2 className="w-3 h-3" /> : <CircleDashed className="w-3 h-3" />}
|
||
{pagada ? "Pagada" : "Por pagar"}
|
||
</span>
|
||
);
|
||
};
|
||
|
||
// ---- 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"
|
||
? `<thead><tr>
|
||
<th>Fecha</th><th>Horario</th><th class="r">Horas</th>
|
||
<th class="r">Tarifa</th><th class="r">Importe</th><th>Descripcion</th>${
|
||
mostrarColEstado ? "<th>Estado</th>" : ""
|
||
}
|
||
</tr></thead>
|
||
<tbody>${filtrados
|
||
.map(
|
||
(r) => `<tr>
|
||
<td>${formatFechaRegistro(new Date(r.fecha))}</td>
|
||
<td>${r.horaInicio}–${r.horaFin}</td>
|
||
<td class="r">${r.horas.toFixed(2)}</td>
|
||
<td class="r">${formatCurrency(r.tarifaHora)}</td>
|
||
<td class="r">${formatCurrency(r.horas * r.tarifaHora)}</td>
|
||
<td>${escapeHtml(r.descripcion)}</td>${
|
||
mostrarColEstado
|
||
? `<td>${esPagada(r.estadoPago) ? "Pagada" : "Por pagar"}</td>`
|
||
: ""
|
||
}
|
||
</tr>`
|
||
)
|
||
.join("")}</tbody>`
|
||
: `<thead><tr>
|
||
<th>${modoLabel}</th><th class="r">Registros</th>
|
||
<th class="r">Horas</th><th class="r">Importe</th>
|
||
</tr></thead>
|
||
<tbody>${grupos
|
||
.map(
|
||
(g) => `<tr>
|
||
<td>${escapeHtml(g.etiqueta)}</td>
|
||
<td class="r">${g.count}</td>
|
||
<td class="r">${g.horas.toFixed(2)}</td>
|
||
<td class="r">${formatCurrency(g.importe)}</td>
|
||
</tr>`
|
||
)
|
||
.join("")}</tbody>`;
|
||
|
||
const etiquetaTotal =
|
||
estadoFiltro === "pagada" ? "Total pagado" : estadoFiltro === "todas" ? "Total" : "Total a pagar";
|
||
|
||
const totalesHTML = `
|
||
<table class="tot">
|
||
<tr><td>Total de horas</td><td class="r">${totalHoras.toFixed(2)} h</td></tr>
|
||
<tr><td>Subtotal</td><td class="r">${formatCurrency(subtotal)}</td></tr>
|
||
${incluirIva ? `<tr><td>IVA (16%)</td><td class="r">${formatCurrency(iva)}</td></tr>` : ""}
|
||
<tr class="grand"><td>${etiquetaTotal}</td><td class="r">${formatCurrency(total)}</td></tr>
|
||
</table>`;
|
||
|
||
return `<!doctype html><html lang="es"><head><meta charset="utf-8">
|
||
<title>${escapeHtml(titulo)} ${escapeHtml(numero)}</title>
|
||
<style>
|
||
*{box-sizing:border-box} body{font-family:Arial,Helvetica,sans-serif;color:#1f2937;margin:40px;font-size:13px}
|
||
h1{font-size:20px;margin:0 0 4px;color:${DARK}} .muted{color:#6b7280}
|
||
.head{display:flex;justify-content:space-between;align-items:flex-start;border-bottom:3px solid ${PRIMARY};padding-bottom:12px;margin-bottom:16px}
|
||
.brand{display:flex;align-items:center;gap:14px}
|
||
.logo{height:52px;width:auto;max-width:220px;object-fit:contain}
|
||
.meta{margin:12px 0;line-height:1.6}
|
||
table{width:100%;border-collapse:collapse;margin-top:8px}
|
||
th,td{border:1px solid #d1d5db;padding:6px 8px;text-align:left;vertical-align:top}
|
||
th{background:#f3f4f6;font-size:11px;text-transform:uppercase;letter-spacing:.03em}
|
||
.r{text-align:right;white-space:nowrap}
|
||
.tot{width:auto;min-width:280px;margin-left:auto;margin-top:16px}
|
||
.tot td{border:none;padding:4px 8px}
|
||
.tot .grand td{border-top:2px solid ${PRIMARY};font-weight:bold;font-size:15px;padding-top:8px;color:${DARK}}
|
||
.foot{margin-top:32px;color:#6b7280;font-size:11px;text-align:center}
|
||
/* margin:0 en @page hace que el navegador omita sus encabezados/pies
|
||
automaticos (URL de la cotizacion, fecha, no. de pagina) al imprimir.
|
||
El margen visual del contenido se pasa a padding del body. */
|
||
@page{margin:0}
|
||
@media print{body{margin:0;padding:14mm}}
|
||
</style></head><body>
|
||
<div class="head">
|
||
<div class="brand">
|
||
${logoSrc ? `<img src="${logoSrc}" alt="Logo" class="logo">` : ""}
|
||
<div>
|
||
<h1>${escapeHtml(titulo)}</h1>
|
||
<div class="muted">Cotizacion ${escapeHtml(numero)} · ${escapeHtml(proyecto)}</div>
|
||
</div>
|
||
</div>
|
||
<div class="muted" style="text-align:right">Emitida: ${formatFechaRegistro(new Date(new Date().toISOString().slice(0, 10) + "T12:00:00Z"))}</div>
|
||
</div>
|
||
<div class="meta">
|
||
<div><b>Cliente:</b> ${escapeHtml(clienteEmpresa || clienteNombre)}</div>
|
||
${clienteEmpresa ? `<div><b>Contacto:</b> ${escapeHtml(clienteNombre)}</div>` : ""}
|
||
<div class="muted">${periodoTexto} · Desglose ${modoLabel.toLowerCase()}</div>
|
||
</div>
|
||
<table>${cuerpo}</table>
|
||
${totalesHTML}
|
||
<div class="foot">Documento generado desde el Cotizador. Conteo de horas trabajadas para cobro del proyecto.</div>
|
||
</body></html>`;
|
||
};
|
||
|
||
return (
|
||
<div className="bg-card-bg rounded-xl border border-border p-5 mb-6">
|
||
<div className="flex items-center justify-between mb-4 gap-3 flex-wrap">
|
||
<h2 className="font-semibold text-lg flex items-center gap-2">
|
||
<Clock className="w-5 h-5 text-primary" />
|
||
Registro de horas
|
||
</h2>
|
||
<button
|
||
onClick={abrirPreview}
|
||
disabled={filtrados.length === 0}
|
||
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 disabled:opacity-40"
|
||
>
|
||
<Eye className="w-4 h-4 text-blue-600" />
|
||
{estadoFiltro === "pagada"
|
||
? "Previsualizar recibo"
|
||
: estadoFiltro === "todas"
|
||
? "Previsualizar estado de cuenta"
|
||
: "Previsualizar nota por pagar"}
|
||
</button>
|
||
</div>
|
||
|
||
{/* Resumen separado: pendiente vs pagado (nunca se acumulan). */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
|
||
<div className="rounded-lg border border-amber-200 bg-amber-50/60 p-3">
|
||
<div className="flex items-center gap-2 text-amber-800 text-sm font-medium">
|
||
<CircleDashed className="w-4 h-4" /> Por pagar
|
||
</div>
|
||
<div className="mt-1 text-2xl font-bold text-amber-900">
|
||
{formatCurrency(resumen.porPagar.importe * ivaFactor)}
|
||
</div>
|
||
<div className="text-xs text-amber-800/80">
|
||
{resumen.porPagar.horas.toFixed(2)} h · {resumen.porPagar.count} registro(s)
|
||
{incluirIva ? " · IVA incluido" : ""}
|
||
</div>
|
||
</div>
|
||
<div className="rounded-lg border border-green-200 bg-green-50/60 p-3">
|
||
<div className="flex items-center gap-2 text-green-800 text-sm font-medium">
|
||
<CheckCircle2 className="w-4 h-4" /> Pagado
|
||
</div>
|
||
<div className="mt-1 text-2xl font-bold text-green-900">
|
||
{formatCurrency(resumen.pagada.importe * ivaFactor)}
|
||
</div>
|
||
<div className="text-xs text-green-800/80">
|
||
{resumen.pagada.horas.toFixed(2)} h · {resumen.pagada.count} registro(s)
|
||
{incluirIva ? " · IVA incluido" : ""}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Formulario de alta / edicion */}
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className="grid grid-cols-1 md:grid-cols-12 gap-2 items-end bg-gray-50 rounded-lg p-3 mb-4"
|
||
>
|
||
<div className="md:col-span-2">
|
||
<label className="block text-xs text-muted mb-1">Fecha</label>
|
||
<input
|
||
type="date"
|
||
value={form.fecha}
|
||
onChange={(e) => setForm({ ...form, fecha: e.target.value })}
|
||
className={`${INPUT} w-full`}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-1">
|
||
<label className="block text-xs text-muted mb-1">Inicio</label>
|
||
<input
|
||
type="time"
|
||
value={form.horaInicio}
|
||
onChange={(e) => setForm({ ...form, horaInicio: e.target.value })}
|
||
className={`${INPUT} w-full`}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-1">
|
||
<label className="block text-xs text-muted mb-1">Fin</label>
|
||
<input
|
||
type="time"
|
||
value={form.horaFin}
|
||
onChange={(e) => setForm({ ...form, horaFin: e.target.value })}
|
||
className={`${INPUT} w-full`}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-1">
|
||
<label className="block text-xs text-muted mb-1">Horas</label>
|
||
<div className="px-2 py-1.5 text-sm font-medium text-center">
|
||
{horasPreview > 0 ? horasPreview.toFixed(2) : "—"}
|
||
</div>
|
||
</div>
|
||
<div className="md:col-span-4">
|
||
<label className="block text-xs text-muted mb-1">Descripcion</label>
|
||
<input
|
||
type="text"
|
||
value={form.descripcion}
|
||
onChange={(e) => setForm({ ...form, descripcion: e.target.value })}
|
||
placeholder="Qué se hizo en este tramo"
|
||
className={`${INPUT} w-full`}
|
||
maxLength={500}
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-1">
|
||
<label className="block text-xs text-muted mb-1">Tarifa/hr</label>
|
||
<input
|
||
type="number"
|
||
min={0}
|
||
step={0.01}
|
||
value={form.tarifaHora}
|
||
onChange={(e) => setForm({ ...form, tarifaHora: parseFloat(e.target.value) || 0 })}
|
||
className={`${INPUT} w-full text-right`}
|
||
/>
|
||
</div>
|
||
<div className="md:col-span-2 flex gap-2">
|
||
<button
|
||
type="submit"
|
||
disabled={saving}
|
||
className="flex-1 flex items-center justify-center gap-1 px-3 py-1.5 bg-primary text-white rounded-lg text-sm hover:opacity-90 disabled:opacity-50"
|
||
>
|
||
{editId ? <Pencil className="w-4 h-4" /> : <Plus className="w-4 h-4" />}
|
||
{editId ? "Guardar" : "Agregar"}
|
||
</button>
|
||
{editId && (
|
||
<button
|
||
type="button"
|
||
onClick={resetForm}
|
||
className="px-2 py-1.5 border border-border rounded-lg text-sm hover:bg-gray-50"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
</div>
|
||
{error && <p className="md:col-span-12 text-sm text-red-600">{error}</p>}
|
||
</form>
|
||
|
||
{/* Filtros: estado de pago, periodo y agrupacion */}
|
||
<div className="flex items-end gap-3 flex-wrap mb-3">
|
||
<div>
|
||
<label className="block text-xs text-muted mb-1">Estado</label>
|
||
<select
|
||
value={estadoFiltro}
|
||
onChange={(e) => setEstadoFiltro(e.target.value as FiltroEstado)}
|
||
className={INPUT}
|
||
>
|
||
{FILTROS_ESTADO.map((f) => (
|
||
<option key={f.value} value={f.value}>
|
||
{f.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-muted mb-1">Desde</label>
|
||
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={INPUT} />
|
||
</div>
|
||
<div>
|
||
<label className="block text-xs text-muted mb-1">Hasta</label>
|
||
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} className={INPUT} />
|
||
</div>
|
||
{(from || to) && (
|
||
<button
|
||
onClick={() => {
|
||
setFrom("");
|
||
setTo("");
|
||
}}
|
||
className="text-xs text-muted underline py-2"
|
||
>
|
||
Limpiar
|
||
</button>
|
||
)}
|
||
<div className="ml-auto">
|
||
<label className="block text-xs text-muted mb-1">Desglose</label>
|
||
<select
|
||
value={modo}
|
||
onChange={(e) => setModo(e.target.value as ModoAgrupacion)}
|
||
className={INPUT}
|
||
>
|
||
{MODOS_AGRUPACION.map((m) => (
|
||
<option key={m.value} value={m.value}>
|
||
{m.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tabla */}
|
||
{filtrados.length === 0 ? (
|
||
<p className="text-muted text-sm py-6 text-center">
|
||
{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" : ""}.
|
||
</p>
|
||
) : (
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="text-left text-muted border-b border-border">
|
||
{modo === "detalle" ? (
|
||
<>
|
||
<th className="py-2 pr-3 font-medium">Fecha</th>
|
||
<th className="py-2 pr-3 font-medium">Horario</th>
|
||
<th className="py-2 pr-3 font-medium text-right">Horas</th>
|
||
<th className="py-2 pr-3 font-medium text-right">Tarifa</th>
|
||
<th className="py-2 pr-3 font-medium text-right">Importe</th>
|
||
<th className="py-2 pr-3 font-medium">Descripcion</th>
|
||
<th className="py-2 pr-3 font-medium">Estado</th>
|
||
<th className="py-2 font-medium"></th>
|
||
</>
|
||
) : (
|
||
<>
|
||
<th className="py-2 pr-3 font-medium">Periodo</th>
|
||
<th className="py-2 pr-3 font-medium text-right">Registros</th>
|
||
<th className="py-2 pr-3 font-medium text-right">Horas</th>
|
||
<th className="py-2 pr-3 font-medium text-right">Importe</th>
|
||
</>
|
||
)}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{modo === "detalle"
|
||
? filtrados.map((r) => (
|
||
<tr
|
||
key={r.id}
|
||
className={`border-b border-border/60 ${esPagada(r.estadoPago) ? "bg-green-50/40" : ""}`}
|
||
>
|
||
<td className="py-2 pr-3 whitespace-nowrap">{formatFechaRegistro(new Date(r.fecha))}</td>
|
||
<td className="py-2 pr-3 whitespace-nowrap">
|
||
{r.horaInicio}–{r.horaFin}
|
||
</td>
|
||
<td className="py-2 pr-3 text-right">{r.horas.toFixed(2)}</td>
|
||
<td className="py-2 pr-3 text-right">{formatCurrency(r.tarifaHora)}</td>
|
||
<td className="py-2 pr-3 text-right">{formatCurrency(r.horas * r.tarifaHora)}</td>
|
||
<td className="py-2 pr-3">{r.descripcion}</td>
|
||
<td className="py-2 pr-3 whitespace-nowrap">{badgeEstado(r.estadoPago)}</td>
|
||
<td className="py-2 text-right whitespace-nowrap">
|
||
{esPagada(r.estadoPago) ? (
|
||
<button
|
||
onClick={() => regresarPorPagar(r)}
|
||
disabled={togglingId === r.id}
|
||
className="p-1 text-muted hover:text-amber-600 disabled:opacity-50"
|
||
title="Regresar a Por pagar"
|
||
>
|
||
<RotateCcw className="w-4 h-4" />
|
||
</button>
|
||
) : (
|
||
<button
|
||
onClick={() => cambiarEstado(r, "pagada")}
|
||
disabled={togglingId === r.id}
|
||
className="p-1 text-muted hover:text-green-600 disabled:opacity-50"
|
||
title="Marcar como pagada"
|
||
>
|
||
<CheckCircle2 className="w-4 h-4" />
|
||
</button>
|
||
)}
|
||
<button
|
||
onClick={() => handleEdit(r)}
|
||
className="p-1 text-muted hover:text-primary"
|
||
title="Editar"
|
||
>
|
||
<Pencil className="w-4 h-4" />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(r.id)}
|
||
className="p-1 text-muted hover:text-red-600"
|
||
title="Eliminar"
|
||
>
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))
|
||
: grupos.map((g) => (
|
||
<tr key={g.clave} className="border-b border-border/60">
|
||
<td className="py-2 pr-3">{g.etiqueta}</td>
|
||
<td className="py-2 pr-3 text-right">{g.count}</td>
|
||
<td className="py-2 pr-3 text-right">{g.horas.toFixed(2)}</td>
|
||
<td className="py-2 pr-3 text-right">{formatCurrency(g.importe)}</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
|
||
{/* Totales de la seleccion actual */}
|
||
{filtrados.length > 0 && (
|
||
<div className="mt-4 pt-3 border-t border-border flex justify-end">
|
||
<div className="w-full max-w-xs space-y-1 text-sm">
|
||
<div className="flex justify-between">
|
||
<span className="text-muted">
|
||
Total de horas{estadoFiltro !== "todas" ? ` (${FILTROS_ESTADO.find((f) => f.value === estadoFiltro)?.label.toLowerCase()})` : ""}
|
||
</span>
|
||
<span className="font-medium">{totalHoras.toFixed(2)} h</span>
|
||
</div>
|
||
<div className="flex justify-between">
|
||
<span className="text-muted">Subtotal</span>
|
||
<span>{formatCurrency(subtotal)}</span>
|
||
</div>
|
||
{incluirIva && (
|
||
<div className="flex justify-between text-muted">
|
||
<span>IVA (16%)</span>
|
||
<span>{formatCurrency(iva)}</span>
|
||
</div>
|
||
)}
|
||
<div className="flex justify-between font-bold pt-2 border-t border-border">
|
||
<span>
|
||
{estadoFiltro === "pagada"
|
||
? "Total pagado"
|
||
: estadoFiltro === "todas"
|
||
? "Total general"
|
||
: "Total a pagar"}
|
||
</span>
|
||
<span className="text-primary">{formatCurrency(total)}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Previsualizador de la nota de pago */}
|
||
{previewHtml !== null && (
|
||
<div
|
||
className="fixed inset-0 z-50 flex flex-col bg-black/60"
|
||
onClick={() => setPreviewHtml(null)}
|
||
>
|
||
<div
|
||
className="flex items-center justify-between gap-4 px-4 py-3 bg-white border-b border-border"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
<h3 className="font-semibold text-sm truncate">{TITULO_NOTA[estadoFiltro]} — {numero}</h3>
|
||
<div className="flex items-center gap-2 shrink-0">
|
||
<button
|
||
onClick={descargarNotaPDF}
|
||
disabled={descargando}
|
||
className="flex items-center gap-2 px-3 py-2 border border-border rounded-lg text-sm hover:bg-gray-50 disabled:opacity-50"
|
||
>
|
||
<Download className="w-4 h-4 text-primary" />
|
||
{descargando ? "Generando…" : "Descargar PDF"}
|
||
</button>
|
||
<button
|
||
onClick={() => setPreviewHtml(null)}
|
||
aria-label="Cerrar previsualización"
|
||
className="p-2 border border-border rounded-lg hover:bg-gray-50"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div className="flex-1 p-4" onClick={(e) => e.stopPropagation()}>
|
||
<iframe
|
||
ref={iframeRef}
|
||
srcDoc={previewHtml}
|
||
title="Previsualización de la nota de pago"
|
||
className="w-full h-full rounded-lg bg-white border border-border"
|
||
/>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function escapeHtml(s: string): string {
|
||
return s
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|