window.AdminOvertime = function({ globalUsers = [], isReadOnly = false, showToast, refreshData, currentUser }) { const [subTab, setSubTab] = React.useState('reentries'); // 'reentries' | 'balances' | 'history' const [loading, setLoading] = React.useState(true); const [overtimeLogs, setOvertimeLogs] = React.useState([]); const [userBalances, setUserBalances] = React.useState([]); const [usagesHistory, setUsagesHistory] = React.useState([]); // Filtros const [statusFilter, setStatusFilter] = React.useState('pending'); // 'pending' | 'approved' | 'rejected' | 'all' const [userFilter, setUserFilter] = React.useState('all'); // Modales const [reviewingLog, setReviewingLog] = React.useState(null); const [reviewHours, setReviewHours] = React.useState(''); const [reviewDestination, setReviewDestination] = React.useState('payroll'); // 'payroll' | 'compensated_time' | 'mixed' | 'rejected' const [reviewPayrollHours, setReviewPayrollHours] = React.useState(''); const [reviewCompensatedHours, setReviewCompensatedHours] = React.useState(''); const [adminNotes, setAdminNotes] = React.useState(''); const [isSavingReview, setIsSavingReview] = React.useState(false); // Modal Otorgar Permiso por Tiempo Compensado const [grantingUser, setGrantingUser] = React.useState(null); const [grantDate, setGrantDate] = React.useState(window.getNICDate ? window.getNICDate() : new Date().toISOString().split('T')[0]); const [grantHours, setGrantHours] = React.useState('8'); const [grantReason, setGrantReason] = React.useState('Permiso Remunerado por Tiempo Compensado'); const [isSavingGrant, setIsSavingGrant] = React.useState(false); // Modal Ajuste Manual de Saldo const [adjustingUser, setAdjustingUser] = React.useState(null); const [newBalanceInput, setNewBalanceInput] = React.useState(''); const [adjustNoteInput, setAdjustNoteInput] = React.useState(''); const [isSavingAdjust, setIsSavingAdjust] = React.useState(false); const showToastRef = React.useRef(showToast); React.useEffect(() => { showToastRef.current = showToast; }, [showToast]); const loadOvertimeData = React.useCallback(async () => { setLoading(true); try { const res = await window.apiCall('get_overtime_logs', {}); if (res && res.success) { setOvertimeLogs(res.overtimeLogs || []); setUserBalances(res.userBalances || []); setUsagesHistory(res.usagesHistory || []); } else { if (showToastRef.current) showToastRef.current(res?.error || "Error al cargar datos de horas extras."); } } catch (e) { if (showToastRef.current) showToastRef.current("Error de conexión al obtener horas extras."); } finally { setLoading(false); } }, []); React.useEffect(() => { loadOvertimeData(); }, [loadOvertimeData]); // Calcular Estadísticas KPIs const stats = React.useMemo(() => { const pendingCount = overtimeLogs.filter(o => o.status === 'pending').length; const pendingHours = overtimeLogs.filter(o => o.status === 'pending').reduce((sum, o) => sum + Number(o.raw_hours || 0), 0); const approvedPayrollHours = overtimeLogs.filter(o => o.status === 'approved').reduce((sum, o) => sum + Number(o.payroll_hours || 0), 0); const totalCompensatedBalance = userBalances.reduce((sum, u) => sum + Number(u.compensated_time_hours || 0), 0); const totalUsagesHours = usagesHistory.reduce((sum, u) => sum + Number(u.hours_used || 0), 0); // Detectar si hay logs con más de 3 horas diarias para alerta legal Art. 62 const excessLogs = overtimeLogs.filter(o => Number(o.raw_hours || o.approved_hours || 0) > 3); return { pendingCount, pendingHours, approvedPayrollHours, totalCompensatedBalance, totalUsagesHours, hasExcess: excessLogs.length > 0, excessLogs }; }, [overtimeLogs, userBalances, usagesHistory]); // Filtrar logs de horas extras const filteredLogs = React.useMemo(() => { return overtimeLogs.filter(log => { if (statusFilter !== 'all' && log.status !== statusFilter) return false; if (userFilter !== 'all' && Number(log.user_id) !== Number(userFilter)) return false; return true; }); }, [overtimeLogs, statusFilter, userFilter]); // Abrir Modal de Revisión const handleOpenReview = (log) => { setReviewingLog(log); const initialApproved = Number(log.approved_hours || log.raw_hours || 0); setReviewHours(initialApproved.toString()); const dest = log.destination !== 'pending' ? log.destination : 'payroll'; setReviewDestination(dest); setReviewPayrollHours(log.payroll_hours ? log.payroll_hours.toString() : (dest === 'payroll' ? initialApproved.toString() : '0')); setReviewCompensatedHours(log.compensated_hours ? log.compensated_hours.toString() : (dest === 'compensated_time' ? initialApproved.toString() : '0')); setAdminNotes(log.admin_notes || ''); }; // Cambiar horas aprobadas y auto-ajustar destinación const handleHoursChange = (val) => { setReviewHours(val); const h = parseFloat(val) || 0; if (reviewDestination === 'payroll') { setReviewPayrollHours(h.toString()); setReviewCompensatedHours('0'); } else if (reviewDestination === 'compensated_time') { setReviewCompensatedHours(h.toString()); setReviewPayrollHours('0'); } else if (reviewDestination === 'mixed') { setReviewPayrollHours((h / 2).toString()); setReviewCompensatedHours((h / 2).toString()); } }; const handleDestinationChange = (dest) => { setReviewDestination(dest); const h = parseFloat(reviewHours) || 0; if (dest === 'payroll') { setReviewPayrollHours(h.toString()); setReviewCompensatedHours('0'); } else if (dest === 'compensated_time') { setReviewCompensatedHours(h.toString()); setReviewPayrollHours('0'); } else if (dest === 'mixed') { setReviewPayrollHours((h / 2).toString()); setReviewCompensatedHours((h / 2).toString()); } else if (dest === 'rejected') { setReviewPayrollHours('0'); setReviewCompensatedHours('0'); } }; // Guardar Aprobación const handleSaveReview = async (isReject = false) => { if (isReadOnly) return alert("Modo supervisión: no tienes permisos para realizar esta acción."); if (!reviewingLog) return; setIsSavingReview(true); try { const finalStatus = isReject ? 'rejected' : 'approved'; const finalDest = isReject ? 'rejected' : reviewDestination; const finalApprHours = isReject ? 0 : (parseFloat(reviewHours) || 0); const finalPayHours = isReject ? 0 : (parseFloat(reviewPayrollHours) || 0); const finalCompHours = isReject ? 0 : (parseFloat(reviewCompensatedHours) || 0); const res = await window.apiCall('approve_overtime_log', { id: reviewingLog.id, status: finalStatus, approved_hours: finalApprHours, destination: finalDest, payroll_hours: finalPayHours, compensated_hours: finalCompHours, admin_notes: adminNotes, admin_user_id: currentUser?.id }); if (res && res.success) { if (res.is_rolled_over) { alert(`ℹ️ Arrastre a Quincena Abierta:\n\n${res.message || 'Las horas extras aprobadas correspondían a un período oficialmente pagado. Se asignaron automáticamente a la quincena abierta actual.'}`); if (showToast) showToast("ℹ️ Horas extras asignadas como arrastre a la quincena actual"); } else if (showToast) { showToast(isReject ? "Registro de horas extras rechazado." : "¡Horas extras aprobadas y distribuidas exitosamente!"); } setReviewingLog(null); await loadOvertimeData(); if (refreshData) await refreshData(); } else { alert("Error al guardar revisión: " + (res?.error || "Error desconocido")); } } catch (e) { alert("Error de conexión al guardar revisión."); } finally { setIsSavingReview(false); } }; // Guardar Permiso por Tiempo Compensado const handleSaveGrantLeave = async () => { if (isReadOnly) return alert("Modo supervisión: no tienes permisos."); if (!grantingUser) return; const hrs = parseFloat(grantHours) || 0; if (hrs <= 0) return alert("Ingresa un número de horas válido."); setIsSavingGrant(true); try { const res = await window.apiCall('grant_compensated_time_leave', { user_id: grantingUser.id, date: grantDate, hours_used: hrs, reason: grantReason, admin_user_id: currentUser?.id }); if (res && res.success) { if (showToast) showToast(`¡Permiso concedido! Se descontaron ${hrs} hrs del saldo de ${grantingUser.name}.`); setGrantingUser(null); await loadOvertimeData(); if (refreshData) await refreshData(); } else { alert("Error al otorgar permiso: " + (res?.error || "Error desconocido")); } } catch (e) { alert("Error de conexión al registrar permiso."); } finally { setIsSavingGrant(false); } }; // Guardar Ajuste Manual de Saldo const handleSaveAdjustBalance = async () => { if (isReadOnly) return alert("Modo supervisión: no tienes permisos."); if (!adjustingUser) return; const val = parseFloat(newBalanceInput); if (isNaN(val) || val < 0) return alert("Ingresa un saldo en horas válido (mínimo 0)."); setIsSavingAdjust(true); try { const res = await window.apiCall('adjust_user_compensated_time', { user_id: adjustingUser.id, new_balance: val, note: adjustNoteInput || 'Ajuste Manual por Administración', admin_user_id: currentUser?.id }); if (res && res.success) { if (showToast) showToast(`Saldo de tiempo compensado actualizado a ${val} hrs.`); setAdjustingUser(null); await loadOvertimeData(); if (refreshData) await refreshData(); } else { alert("Error al ajustar saldo: " + (res?.error || "Error desconocido")); } } catch (e) { alert("Error de conexión al ajustar saldo."); } finally { setIsSavingAdjust(false); } }; return (
{/* ENCABEZADO Y KPI CARDS */}

Horas Extras & Tiempo Compensado

Código del Trabajo Art. 62

Auditoría de reingresos, aprobación flexible a planilla o bolsa de descansos remunerados.

{/* HERO KPIS */}
Pendientes de Revisión

{stats.pendingCount} ({stats.pendingHours.toFixed(1)} hrs)

Reingresos pendientes de aprobación

Aprobadas a Planilla (Efectivo)

{stats.approvedPayrollHours.toFixed(1)} hrs

Liquidables a tarifa doble (100% recargo)

Bolsa Tiempo Compensado Acumulada

{stats.totalCompensatedBalance.toFixed(1)} hrs

~{(stats.totalCompensatedBalance / 8).toFixed(1)} días libres para personal

Descansos Disfrutados

{stats.totalUsagesHours.toFixed(1)} hrs

Otorgados sin afectar vacaciones ni salario

{/* BANNER AVISO LEGAL ART. 62 */} {stats.hasExcess && (

⚠️ Alerta Legal sobre Límite de Horas Extras (Código del Trabajo Art. 62)

Se detectaron {stats.excessLogs.length} registro(s) con más de 3 horas extraordinarias en una jornada diaria. El estándar legal de Nicaragua establece un máximo de 3 horas por día (máx. 9 semanales), salvo acuerdo mutuo voluntario entre el empleador y el colaborador o emergencias operativas. El sistema permite aprobar y pagar la totalidad de las horas registradas si existe dicho acuerdo.

)} {/* BARRA NAVEGACIÓN SUB-TABS */}
{/* CONTENIDO TAB 1: REINGRESOS Y HORAS EXTRAS */} {subTab === 'reentries' && (
{/* FILTROS */}
Estado: {['pending', 'approved', 'rejected', 'all'].map(st => ( ))}
{/* TABLA DE REINGRESOS */} {loading ? (
Cargando registros de horas extras...
) : filteredLogs.length === 0 ? (

No hay registros de horas extras en esta categoría.

Los reingresos extraordinarios y jornadas en días libres aparecerán aquí automáticamente.

) : (
{filteredLogs.map(log => { const rawH = Number(log.raw_hours || 0); const isExcess = rawH > 3; return (
{log.avatar_url ? ( {log.user_name} ) : (
{(log.user_name || 'U')[0].toUpperCase()}
)}

{log.user_name}

{window.formatDateToNIC ? window.formatDateToNIC(log.date_str) : log.date_str} {isExcess && ( ⚠️ > 3 hrs )}

Entrada: {log.entry_time || '---'} • Salida: {log.exit_time || 'En curso...'}

Motivo: {log.justification || 'Reingreso Extraordinario'}

{/* DETALLE Y BOTÓN DE ACCIÓN */}
Horas Calculadas {rawH.toFixed(2)} hrs {log.status === 'approved' && ( Aprobadas: {log.approved_hours}h ({log.destination === 'payroll' ? 'Planilla' : (log.destination === 'compensated_time' ? 'Tiempo Comp.' : 'Mixto')}) )} {log.status === 'rejected' && ( Rechazada )}
); })}
)}
)} {/* CONTENIDO TAB 2: SALDOS Y OTORGAR PERMISOS */} {subTab === 'balances' && (
Bolsa de Tiempo Compensado (Permisos Remunerados): Las horas acumuladas aquí corresponden a trabajo extraordinario acordado para descanso libre. Al otorgar un permiso con este saldo, el colaborador disfrutará su día descansado sin ningún descuento en su salario de planilla ni en sus vacaciones de ley (Art. 76 CT).
{userBalances.map(u => { const hrs = Number(u.compensated_time_hours || 0); const days = (hrs / 8).toFixed(1); return ( ); })}
Colaborador Cargo Saldo Acumulado (Horas) Equivalente Días Libres Acción
{u.avatar_url ? ( {u.name} ) : (
{(u.name || 'U')[0].toUpperCase()}
)}
{u.name}
{u.position_title || 'Colaborador'} 0 ? 'bg-indigo-50 text-indigo-700 border border-indigo-200' : 'bg-slate-100 text-slate-400'}`}> {hrs.toFixed(1)} hrs {days} días
)} {/* CONTENIDO TAB 3: BITÁCORA DE DESCANSOS OTORGADOS */} {subTab === 'history' && (
{usagesHistory.length === 0 ? ( ) : ( usagesHistory.map(u => ( )) )}
Fecha Descanso Colaborador Horas Descontadas Motivo / Justificación Autorizado por
No hay registro de descansos compensados otorgados aún.
{window.formatDateToNIC ? window.formatDateToNIC(u.date_str) : u.date_str} {u.user_name} -{u.hours_used} hrs {u.reason} {u.approved_by_name || 'Administración'}
)} {/* MODAL DE REVISIÓN Y DISTRIBUCIÓN DE HORAS EXTRAS */} {reviewingLog && (
setReviewingLog(null)}>
e.stopPropagation()}>

Revisión de Horas Extras

{reviewingLog.user_name} • {reviewingLog.date_str}

{/* DETALLES DE MARCACIÓN */}
Marcación Entrada: {reviewingLog.entry_time || '---'}
Marcación Salida: {reviewingLog.exit_time || 'En curso...'}
Horas Presenciales Brutas: {Number(reviewingLog.raw_hours || 0).toFixed(2)} hrs
{/* EDICIÓN DE HORAS APROBADAS */}
handleHoursChange(e.target.value)} className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-base text-slate-900" /> {parseFloat(reviewHours) > 3 && ( ⚠️ Excede las 3 horas legales estándar (Art. 62 CT). Permitido bajo acuerdo voluntario de las partes. )}
{/* SELECCIÓN DE DESTINO / DISTRIBUCIÓN */}
{/* OBSERVACIONES ADMIN */}
{/* ACCIONES */}
)} {/* MODAL OTORGAR PERMISO POR TIEMPO COMPENSADO */} {grantingUser && (
setGrantingUser(null)}>
e.stopPropagation()}>

Otorgar Permiso por Tiempo Compensado

{grantingUser.name}

Saldo Disponible Actual: {Number(grantingUser.compensated_time_hours || 0).toFixed(1)} hrs

Este permiso otorgado descontará del saldo de tiempo compensado. NO tocará el salario de planilla ni restará del saldo de vacaciones acumuladas.

setGrantDate(e.target.value)} className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl text-xs font-semibold" />
setGrantReason(e.target.value)} placeholder="Ej: Trámite personal / Descanso acordado por horas extras" className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl text-xs font-medium" />
)} {/* MODAL AJUSTE MANUAL DE SALDO */} {adjustingUser && (
setAdjustingUser(null)}>
e.stopPropagation()}>

Ajuste Manual de Saldo Compensatorio

{adjustingUser.name}

setNewBalanceInput(e.target.value)} className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl font-bold text-base text-slate-900" />
setAdjustNoteInput(e.target.value)} placeholder="Ej: Carga inicial de horas previas / Corrección de saldo" className="w-full p-3 bg-slate-50 border border-slate-200 rounded-xl text-xs font-medium" />
)}
); };