const { useState, useMemo, useEffect, useRef } = React; // Iconos vectoriales limpios (sin emojis) const VacIcon = ({ name, className = "w-4 h-4" }) => { const icons = { 'calendar': , 'users': , 'currency': , 'alert': , 'check': , 'x': , 'plus': , 'printer': , 'search': , 'settings': , 'file-text': , 'chevron-left': , 'chevron-right': , 'cash': }; return icons[name] || null; }; window.AdminVacaciones = function({ globalUsers = [], vacations = [], settings = {}, roles = [], currentUser = null, refreshData, showToast, updateVacationRate, setVacationStatus, revokeEntireVacation, setSelectedUserVacation, isReadOnly }) { const [activeSubTab, setActiveSubTab] = useState(() => { const saved = localStorage.getItem('shift_vacaciones_subtab'); return ['saldos', 'calendario', 'solicitudes'].includes(saved) ? saved : 'saldos'; }); useEffect(() => { try { localStorage.setItem('shift_vacaciones_subtab', activeSubTab); } catch (e) {} }, [activeSubTab]); const [searchFilter, setSearchFilter] = useState(''); const [roleFilter, setRoleFilter] = useState('todos'); // Paginación de aprobadas const [vacPage, setVacPage] = useState(0); // Estados de Kardex y modales const [kardexUser, setKardexUser] = useState(null); const [showRegisterModal, setShowRegisterModal] = useState(false); const [showCashCompModal, setShowCashCompModal] = useState(false); const [selectedSlipVacation, setSelectedSlipVacation] = useState(null); // Formulario de registro manual const [formUserId, setFormUserId] = useState(''); const [formType, setFormType] = useState('range'); // 'range', 'half', 'paid' const [formStartDate, setFormStartDate] = useState(window.getNICDate ? window.getNICDate() : new Date().toISOString().split('T')[0]); const [formEndDate, setFormEndDate] = useState(window.getNICDate ? window.getNICDate() : new Date().toISOString().split('T')[0]); const [formPaidDays, setFormPaidDays] = useState(1); const [formNotes, setFormNotes] = useState(''); const [isSavingVacation, setIsSavingVacation] = useState(false); // Calendario de equipo const todayNIC = window.getNICDate ? window.getNICDate() : new Date().toISOString().split('T')[0]; const [currentCalYear, setCurrentCalYear] = useState(new Date().getFullYear()); const [currentCalMonth, setCurrentCalMonth] = useState(new Date().getMonth()); // 0 a 11 const activeUsers = useMemo(() => { return (globalUsers || []).filter(u => u.status === 'activo'); }, [globalUsers]); // Cálculo consolidado de saldos por colaborador const userBalances = useMemo(() => { const rate = parseFloat(settings?.vacationRate || 2.5); return activeUsers.map(u => { const balanceNum = parseFloat(window.calculateVacationBalance(u, rate, vacations)) || 0; const accruedNum = parseFloat(window.calculateVacationAccrued(u, rate)) || 0; const baseSalary = parseFloat(u.baseSalary) || 0; const dailyRate = baseSalary / 30; const liabilityCords = balanceNum * dailyRate; return { ...u, vacationBalance: balanceNum, accruedTotal: accruedNum, liabilityCords: liabilityCords, dailyRate: dailyRate }; }); }, [activeUsers, settings?.vacationRate, vacations]); // Métricas Financieras y Operativas Globales const metrics = useMemo(() => { let totalLiability = 0; let totalReservedDays = 0; let atRiskCount = 0; userBalances.forEach(u => { totalLiability += u.liabilityCords; totalReservedDays += u.vacationBalance; if (u.vacationBalance >= 30) { atRiskCount++; } }); // Días disfrutados este año const currentYearStr = String(new Date().getFullYear()); const approvedThisYear = (vacations || []) .filter(v => v.status === 'approved' && v.startDate && v.startDate.startsWith(currentYearStr)); const totalTakenDaysThisYear = approvedThisYear.reduce((acc, v) => { if (v.type === 'paid') return acc + (parseFloat(v.paidDays) || 0); if (v.type === 'half') return acc + 0.5; return acc + (window.getDaysDiff ? window.getDaysDiff(v.startDate, v.endDate || v.startDate) : 1); }, 0); return { totalLiability: totalLiability.toFixed(2), totalReservedDays: totalReservedDays.toFixed(2), atRiskCount, totalTakenDaysThisYear: totalTakenDaysThisYear.toFixed(1) }; }, [userBalances, vacations]); // Filtrado de colaboradores const filteredUsers = useMemo(() => { return userBalances.filter(u => { const matchesText = !searchFilter.trim() || (u.name || '').toLowerCase().includes(searchFilter.toLowerCase()) || (u.alias || '').toLowerCase().includes(searchFilter.toLowerCase()) || (u.email || '').toLowerCase().includes(searchFilter.toLowerCase()); const userRole = (u.role_name || u.role || '').toLowerCase(); const matchesRole = roleFilter === 'todos' || userRole === roleFilter.toLowerCase(); return matchesText && matchesRole; }); }, [userBalances, searchFilter, roleFilter]); // Lista de roles únicos para el filtro const availableRoles = useMemo(() => { const setR = new Set(); (globalUsers || []).forEach(u => { const r = u.role_name || u.role; if (r) setR.add(r.trim()); }); return Array.from(setR); }, [globalUsers]); // Solicitudes entrantes y aprobadas const pendingRequests = useMemo(() => { return (vacations || []).filter(v => v.status === 'pending'); }, [vacations]); const approvedVacsHistory = useMemo(() => { return (vacations || []) .filter(v => v.status === 'approved') .sort((a,b) => (b.requestedAt || 0) - (a.requestedAt || 0)); }, [vacations]); const totalVacPages = Math.ceil(approvedVacsHistory.length / 8); const paginatedVacs = approvedVacsHistory.slice(vacPage * 8, (vacPage + 1) * 8); // Validador de colisión de rol para un rango de fechas y usuario const detectTeamClashes = (targetUserId, startDate, endDate) => { if (!targetUserId || !startDate) return []; const targetUser = globalUsers.find(u => Number(u.id) === Number(targetUserId)); if (!targetUser) return []; const targetRole = (targetUser.role_name || targetUser.role || '').toLowerCase().trim(); const end = endDate || startDate; const clashes = []; (vacations || []).forEach(v => { if (v.status !== 'approved' || Number(v.userId) === Number(targetUserId)) return; const otherUser = globalUsers.find(u => Number(u.id) === Number(v.userId)); if (!otherUser) return; const otherRole = (otherUser.role_name || otherUser.role || '').toLowerCase().trim(); if (targetRole && otherRole && targetRole === otherRole) { const vStart = v.startDate; const vEnd = v.endDate || v.startDate; if (vStart <= end && vEnd >= startDate) { clashes.push({ otherUser: otherUser.name, role: targetRole, start: vStart, end: vEnd }); } } }); return clashes; }; // Alerta de colisión para el formulario de registro manual const currentFormClashes = useMemo(() => { if (formType === 'paid' || !formUserId || !formStartDate) return []; return detectTeamClashes(formUserId, formStartDate, formType === 'half' ? formStartDate : formEndDate); }, [formUserId, formStartDate, formEndDate, formType, vacations]); // Apertura del Kardex const handleOpenKardex = (user) => { setKardexUser(user); }; // Registro manual de vacación o compensación const handleRegisterVacationSubmit = async (e) => { e.preventDefault(); if (isReadOnly) return alert("Modo supervisión: sin permisos de edición."); if (!formUserId) { alert("Por favor seleccione un colaborador."); return; } setIsSavingVacation(true); try { const payload = { userId: Number(formUserId), type: formType, startDate: formType === 'paid' ? (window.getNICDate ? window.getNICDate() : new Date().toISOString().split('T')[0]) : formStartDate, endDate: formType === 'paid' ? (window.getNICDate ? window.getNICDate() : new Date().toISOString().split('T')[0]) : (formType === 'half' ? formStartDate : formEndDate), paidDays: formType === 'paid' ? parseFloat(formPaidDays) : (formType === 'half' ? 0.5 : null), status: 'approved', notes: formNotes || (formType === 'paid' ? 'Compensación de vacaciones en dinero' : 'Vacación autorizada por administración'), requestedAt: Date.now() }; const res = await window.apiCall('add_vacation', payload); if (res && res.success) { if (showToast) showToast("Registro vacacional procesado exitosamente."); setShowRegisterModal(false); setShowCashCompModal(false); setFormNotes(''); if (refreshData) await refreshData(); } else { alert("Error al guardar: " + (res?.error || "Respuesta inválida.")); } } catch (err) { alert("Error de conexión al guardar."); } finally { setIsSavingVacation(false); } }; // Apertura rápida de compensación en efectivo para un colaborador específico const handleOpenCashComp = (user) => { setFormUserId(user.id); setFormType('paid'); setFormPaidDays(Math.min(user.vacationBalance > 0 ? Math.floor(user.vacationBalance) : 1, 15)); setFormNotes('Compensación económica de vacaciones acordada con el trabajador'); setShowCashCompModal(true); }; // Calendario de equipo: Generación de matriz mensual const calendarData = useMemo(() => { const year = currentCalYear; const month = currentCalMonth; const firstDay = new Date(year, month, 1); const lastDay = new Date(year, month + 1, 0); // Convertir día de semana de domingo=0 a lunes=0 let startingDayOfWeek = firstDay.getDay() - 1; if (startingDayOfWeek === -1) startingDayOfWeek = 6; const totalDaysInMonth = lastDay.getDate(); const daysArray = []; // Días previos de relleno for (let i = 0; i < startingDayOfWeek; i++) { daysArray.push(null); } // Días del mes for (let d = 1; d <= totalDaysInMonth; d++) { const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`; // Buscar vacaciones aprobadas en este día const dayVacs = approvedVacsHistory.filter(v => { if (v.type === 'paid') return false; // Pagos en dinero no representan ausencia física const s = v.startDate; const e = v.endDate || v.startDate; return dateStr >= s && dateStr <= e && !(v.revokedDates || []).includes(dateStr); }); // Verificar si hay colisión de rol en este día const roleCounts = {}; dayVacs.forEach(v => { const u = globalUsers.find(usr => Number(usr.id) === Number(v.userId)); const r = (u?.role_name || u?.role || 'colaborador').toLowerCase().trim(); roleCounts[r] = (roleCounts[r] || 0) + 1; }); const hasClash = Object.values(roleCounts).some(cnt => cnt >= 2); daysArray.push({ dayNumber: d, dateStr, vacations: dayVacs, hasClash }); } const monthNames = [ 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre' ]; return { monthName: monthNames[month], year, days: daysArray }; }, [currentCalYear, currentCalMonth, approvedVacsHistory, globalUsers]); // Movimiento entre meses const handlePrevMonth = () => { if (currentCalMonth === 0) { setCurrentCalMonth(11); setCurrentCalYear(y => y - 1); } else { setCurrentCalMonth(m => m - 1); } }; const handleNextMonth = () => { if (currentCalMonth === 11) { setCurrentCalMonth(0); setCurrentCalYear(y => y + 1); } else { setCurrentCalMonth(m => m + 1); } }; const handleCurrentMonth = () => { const now = new Date(); setCurrentCalYear(now.getFullYear()); setCurrentCalMonth(now.getMonth()); }; // Construcción del Kardex / Libro Mayor Individual const userLedgerEntries = useMemo(() => { if (!kardexUser) return []; const hiringDate = kardexUser.hiringDate || kardexUser.hiring_date; if (!hiringDate) return []; const rate = parseFloat(settings?.vacationRate || 2.5); const entries = []; // 1. Acumulación periódica mensual desde la fecha de contratación const [hy, hm, hd] = hiringDate.split('-').map(Number); let cur = new Date(hy, hm - 1, hd, 12, 0, 0); const now = new Date(); let accruedCount = 0; while (cur <= now) { cur.setMonth(cur.getMonth() + 1); if (cur <= now) { accruedCount++; const y = cur.getFullYear(); const m = String(cur.getMonth() + 1).padStart(2, '0'); const d = String(cur.getDate()).padStart(2, '0'); entries.push({ date: `${y}-${m}-${d}`, type: 'credit', category: 'Acumulación de Ley', description: `Mes ordinario laborado (+${rate.toFixed(2)} días)`, amount: rate, rawTimestamp: cur.getTime() }); } } // 2. Transacciones aprobadas de este usuario const userApprovedVacs = approvedVacsHistory.filter(v => Number(v.userId) === Number(kardexUser.id)); userApprovedVacs.forEach(v => { const isDeduction = v.logId || v.notes?.includes('Horario') || v.notes?.includes('Auditoría') || v.notes?.includes('Reposo Médico'); const isPaid = v.type === 'paid'; const daysCount = isPaid ? parseFloat(v.paidDays) : (v.type === 'half' ? 0.5 : (window.getDaysDiff ? window.getDaysDiff(v.startDate, v.endDate || v.startDate) : 1)); let category = 'Goce de Vacaciones'; if (isPaid) category = 'Compensación en Dinero'; else if (isDeduction) category = 'Deducción de Horario / Auditoría'; entries.push({ date: v.startDate || window.getNICDate(), type: 'debit', category: category, description: v.notes || (isPaid ? `Liquidación de ${daysCount} día(s) en planilla` : `Período: ${window.formatDateToNIC(v.startDate)} al ${window.formatDateToNIC(v.endDate || v.startDate)}`), amount: -daysCount, rawTimestamp: v.requestedAt || new Date(v.startDate).getTime(), vacationRef: v }); }); // 3. Ajuste manual de inicio si existe vacationDaysUsed en base de datos if (parseFloat(kardexUser.vacationDaysUsed) > 0) { entries.push({ date: hiringDate, type: 'debit', category: 'Ajuste Histórico Anterior', description: 'Días consumidos previos al ingreso al sistema', amount: -parseFloat(kardexUser.vacationDaysUsed), rawTimestamp: new Date(hiringDate).getTime() - 1000 }); } // Ordenar cronológicamente y calcular saldo progresivo entries.sort((a, b) => a.rawTimestamp - b.rawTimestamp); let runningBalance = 0; return entries.map(item => { runningBalance += item.amount; return { ...item, balanceAfter: runningBalance }; }); }, [kardexUser, approvedVacsHistory, settings?.vacationRate]); return (
{/* TARJETAS DE MÉTRICAS FINANCIERAS Y OPERATIVAS */}
Pasivo Vacacional Total
C$ {metrics.totalLiability}

Costo total de liquidación actual

Días en Reserva
{metrics.totalReservedDays} días

Saldo acumulado del personal activo

Días Disfrutados ({new Date().getFullYear()})
{metrics.totalTakenDaysThisYear} días

Gozados o compensados este período

Acumulación Crítica
0 ? 'bg-amber-50 text-amber-600' : 'bg-slate-50 text-slate-400'}`}>
0 ? 'text-amber-600' : 'text-slate-900'}`}> {metrics.atRiskCount} colaboradores

Saldo mayor a 30 días sin descanso

{/* BARRA SUPERIOR DE ACCIONES Y PESTAÑAS */}
{/* Botones de sub-pestañas */}
{/* Acciones principales */}
{!isReadOnly && ( )}
{/* PESTAÑA 1: SALDOS & KARDEX */} {activeSubTab === 'saldos' && (
{/* Filtros y buscador */}
setSearchFilter(e.target.value)} className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-800 outline-none focus:ring-2 focus:ring-indigo-100" />
Filtrar Rol:
{/* Tabla de Saldos */}
{filteredUsers.length === 0 ? ( ) : ( filteredUsers.map(u => ( )) )}
Colaborador Cargo / Rol Ingreso Saldo Disponible Pasivo Laboral Acciones
No se encontraron colaboradores con los criterios seleccionados.
{(u.name || 'U')[0].toUpperCase()}
{u.name} {u.email || u.alias}
{u.positionTitle || u.role_name || u.role || 'Colaborador'} {window.formatDateToNIC(u.hiringDate || u.hiring_date)} = 30 ? 'bg-amber-100 text-amber-800' : u.vacationBalance > 0 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' : 'bg-slate-100 text-slate-500' }`}> {u.vacationBalance.toFixed(2)} días C$ {u.liabilityCords.toFixed(2)}
{!isReadOnly && u.vacationBalance > 0 && ( )}
)} {/* PESTAÑA 2: CALENDARIO DE EQUIPO */} {activeSubTab === 'calendario' && (
{/* Cabecera del calendario con navegación */}

{calendarData.monthName} {calendarData.year}

{/* Grilla mensual */}
{/* Días de la semana */}
Lun
Mar
Mié
Jue
Vie
Sáb
Dom
{/* Celdas del mes */}
{calendarData.days.map((item, idx) => { if (!item) { return
; } const isToday = item.dateStr === todayNIC; const hasVacations = item.vacations && item.vacations.length > 0; return (
{item.dayNumber} {item.hasClash && ( Alerta )}
{/* Lista de colaboradores ausentes */}
{item.vacations.map(v => (
{v.userName}
))}
); })}
)} {/* PESTAÑA 3: BANDEJA DE SOLICITUDES & APROBADAS */} {activeSubTab === 'solicitudes' && (
{/* SOLICITUDES ENTRANTES */}

Solicitudes Pendientes ({pendingRequests.length})

{pendingRequests.length === 0 ? (

Bandeja limpia. No hay solicitudes pendientes de revisión.

) : (
{pendingRequests.map(v => { const daysCount = v.type === 'half' ? 0.5 : (window.getDaysDiff ? window.getDaysDiff(v.startDate, v.endDate || v.startDate) : 1); const clashes = detectTeamClashes(v.userId, v.startDate, v.endDate || v.startDate); return (

{v.userName}

Solicita: {v.type === 'half' ? 'Medio Día (0.5)' : `${daysCount} Día(s)`}
{window.formatNICTimeFull ? window.formatNICTimeFull(v.requestedAt) : ''}

Período: Del {window.formatDateToNIC(v.startDate)} {v.type === 'range' ? `al ${window.formatDateToNIC(v.endDate)}` : ''}

{v.notes &&

"{v.notes}"

}
{/* Advertencia de colisión de rol */} {clashes.length > 0 && (
Conflicto de cargo: {clashes.map(c => c.otherUser).join(', ')} ya tiene descanso en este período.
)} {!isReadOnly && (
)}
); })}
)}
{/* HISTORIAL GENERAL DE APROBADAS */}

Historial de Vacaciones Aprobadas ({approvedVacsHistory.length})

{vacPage + 1} / {totalVacPages || 1}
{paginatedVacs.length === 0 ? (

Sin registros históricos.

) : (
{paginatedVacs.map(v => { const daysCount = v.type === 'half' ? 0.5 : (v.type === 'paid' ? v.paidDays : (window.getDaysDiff ? window.getDaysDiff(v.startDate, v.endDate || v.startDate) : 1)); const isPaid = v.type === 'paid'; const isDeduction = v.logId || v.notes?.includes('Horario') || v.notes?.includes('Auditoría') || v.notes?.includes('Reposo Médico'); return (

{v.userName}

{isPaid ? ( Compensación en Efectivo ) : isDeduction ? ( Deducción Horario / Auditoría ) : ( Solicitud Vacacional )} {daysCount} Día(s)

{isPaid ? `Liquidado en dinero (${v.paidDays} días)` : `Del ${window.formatDateToNIC(v.startDate)} ${v.type === 'range' ? `al ${window.formatDateToNIC(v.endDate)}` : ''}`}

{v.notes &&

"{v.notes}"

}
{/* Boleta imprimible MITRAB */} {!isPaid && ( )} {/* Revocar */} {!isReadOnly && ( )}
); })}
)}
)} {/* MODAL / DRAWER: KARDEX INDIVIDUAL */} {kardexUser && (
{/* Cabecera del Kardex */}
Libro Mayor de Vacaciones (Kardex)

{kardexUser.name}

Cargo: {kardexUser.positionTitle || kardexUser.role_name || kardexUser.role} • Ingreso: {window.formatDateToNIC(kardexUser.hiringDate || kardexUser.hiring_date)} • Salario Diario: C$ {(parseFloat(kardexUser.baseSalary || 0) / 30).toFixed(2)}
{/* Tarjetas de Saldo del Colaborador */}
Días Acumulados {window.calculateVacationAccrued(kardexUser, settings?.vacationRate || 2.5).toFixed(2)}
Saldo Neto Disponible {window.calculateVacationBalance(kardexUser, settings?.vacationRate || 2.5, vacations)} días
Pasivo Estimado C$ {((parseFloat(window.calculateVacationBalance(kardexUser, settings?.vacationRate || 2.5, vacations)) || 0) * (parseFloat(kardexUser.baseSalary || 0) / 30)).toFixed(2)}
{/* Tabla cronológica del Kardex */}

Movimientos Registrados

{userLedgerEntries.length === 0 ? (

No hay movimientos registrados para este colaborador.

) : (
{userLedgerEntries.map((entry, idx) => ( ))}
Fecha Categoría Detalle Movimiento Saldo
{window.formatDateToNIC(entry.date)} {entry.category} {entry.description} 0 ? 'text-emerald-600' : 'text-slate-800'}`}> {entry.amount > 0 ? `+${entry.amount.toFixed(2)}` : entry.amount.toFixed(2)} {entry.balanceAfter.toFixed(2)}
)}
{/* Pie de acciones del Kardex */}
Cálculo amparado en el Código del Trabajo de Nicaragua.
{!isReadOnly && ( )}
)} {/* MODAL: REGISTRAR VACACIÓN MANUAL */} {showRegisterModal && (
Gestión de RRHH

Registrar Vacación Manual

{ setFormStartDate(e.target.value); if (e.target.value > formEndDate) setFormEndDate(e.target.value); }} className="w-full p-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-800 outline-none focus:ring-2 focus:ring-indigo-100" required />
{formType === 'range' && (
setFormEndDate(e.target.value)} className="w-full p-3 bg-slate-50 border border-slate-200 rounded-2xl text-xs font-bold text-slate-800 outline-none focus:ring-2 focus:ring-indigo-100" required />
)}