// ============================================================ // Dashboard estadístico conectado al servidor // ============================================================ const Dashboard = ({ onSessionExpired }) => { const [state, setState] = React.useState({ loading: true, error: '', data: null }); const loadDashboard = React.useCallback(async () => { setState(previous => ({ ...previous, loading: true, error: '' })); try { const data = await window.dashboardService.getDashboard(); setState({ loading: false, error: '', data }); } catch (error) { if (error?.status === 401 && typeof onSessionExpired === 'function') { onSessionExpired(); return; } setState({ loading: false, error: error?.message || 'No fue posible cargar el dashboard.', data: null }); } }, [onSessionExpired]); React.useEffect(() => { loadDashboard(); }, [loadDashboard]); if (state.loading) { return (
Consultando datos reales del servidor…
); } if (state.error || !state.data) { return (
No se pudo cargar el dashboard
{state.error}
); } const data = state.data; const summary = data.summary || {}; const stages = data.stage_progress || data.stages || {}; const statuses = data.statuses || {}; const project = data.project || {}; const total = Math.max(0, Number(summary.total_books) || 0); const metaBooks = Math.max(total, Number(APP_DATA?.PROYECTO?.metaLibros) || total || 1); const metaImages = Number(APP_DATA?.PROYECTO?.metaImagenes) || 70000; const finalized = Number(summary.finalized) || 0; const processedImages = Number(summary.processed_images) || 0; const endDate = project.end_date || APP_DATA?.PROYECTO?.deadline || null; const daysRemaining = endDate ? Math.max(0, Math.ceil((new Date(`${endDate}T23:59:59`) - new Date()) / 86400000)) : null; const qualityTotal = (Number(statuses.approved) || 0) + (Number(statuses.rejected) || 0); const acceptanceRate = qualityTotal > 0 ? ((Number(statuses.approved) || 0) / qualityTotal) * 100 : 0; const daily = Array.isArray(data.daily) ? data.daily : []; const dailyMax = Math.max(1, ...daily.map(item => Number(item.digitalized) || 0)); const productivity = Array.isArray(data.productivity) ? data.productivity : []; const pipeline = data.pipeline || {}; // Proyección calculada con datos reales del servidor y la meta configurada del proyecto. const projectionStartDate = project.start_date || APP_DATA?.PROYECTO?.fechaInicio || null; const projectionEndDate = project.end_date || APP_DATA?.PROYECTO?.deadline || null; const projectionTarget = Math.max(total, Number(APP_DATA?.PROYECTO?.metaLibros) || total || 1); const todayAtMidnight = new Date(); todayAtMidnight.setHours(0, 0, 0, 0); const startAtMidnight = projectionStartDate ? new Date(`${projectionStartDate}T00:00:00`) : null; const endAtMidnight = projectionEndDate ? new Date(`${projectionEndDate}T00:00:00`) : null; const hasValidProjectionDates = startAtMidnight instanceof Date && !Number.isNaN(startAtMidnight.getTime()) && endAtMidnight instanceof Date && !Number.isNaN(endAtMidnight.getTime()); const elapsedProjectionDays = hasValidProjectionDates ? Math.max(1, Math.floor((todayAtMidnight - startAtMidnight) / 86400000) + 1) : 1; const remainingProjectionDays = hasValidProjectionDates ? Math.max(0, Math.ceil((endAtMidnight - todayAtMidnight) / 86400000)) : 0; const currentProjectionRate = finalized / elapsedProjectionDays; const missingProjectionBooks = Math.max(0, projectionTarget - finalized); const projectedBooksAtDeadline = Math.min( projectionTarget, Math.max(finalized, Math.round(finalized + (currentProjectionRate * remainingProjectionDays))) ); const requiredProjectionRate = remainingProjectionDays > 0 ? missingProjectionBooks / remainingProjectionDays : (missingProjectionBooks > 0 ? missingProjectionBooks : 0); const projectionOnTrack = missingProjectionBooks === 0 || projectedBooksAtDeadline >= projectionTarget; const projectionExpired = hasValidProjectionDates && remainingProjectionDays === 0 && missingProjectionBooks > 0; const deadlineLabel = hasValidProjectionDates ? endAtMidnight.toLocaleDateString('es-MX', { day: 'numeric', month: 'long' }) : 'la fecha objetivo'; const stageRows = [ { key: 'catalog', label: 'Catálogo / pendientes', color: '#64748b' }, { key: 'digitization', label: 'Digitalización', color: '#0f766e' }, { key: 'editing', label: 'Edición', color: '#7e22ce' }, { key: 'quality_control', label: 'Control de calidad', color: '#b45309' }, { key: 'conversion', label: 'Conversión', color: '#1e40af' }, { key: 'publication', label: 'Publicación / finalizados', color: '#15803d' }, ]; const stageLabel = (stage) => ({ catalog: 'Catálogo', digitization: 'Digitalización', editing: 'Edición', quality_control: 'Control de calidad', conversion: 'Conversión', publication: 'Publicación', published: 'Publicado' }[stage] || stage || 'Sin etapa'); const statusLabel = (status) => ({ pending: 'Pendiente', assigned: 'Asignado', in_progress: 'En proceso', completed: 'Completado', approved: 'Aprobado', rejected: 'Rechazado', correction_required: 'Requiere corrección', cancelled: 'Cancelado' }[status] || status || 'Sin estado'); return (
{project.name || 'Proyecto de digitalización'}
Datos consultados directamente desde MySQL · actualización {new Date(data.generated_at).toLocaleString('es-MX')}
0 ? (((Number(summary.pending_assignments) || 0) + (Number(summary.active_assignments) || 0)) / total) * 100 : 0} tone="amber" /> 0 ? ((finalized / metaBooks) * 100).toFixed(1) : '0.0'}% de la meta`} progress={metaBooks > 0 ? (finalized / metaBooks) * 100 : 0} tone="success" /> 0 ? `${((processedImages / metaImages) * 100).toFixed(1)}% de ${fmtNum(metaImages)}` : 'Se actualizará con la producción registrada'} progress={metaImages > 0 ? (processedImages / metaImages) * 100 : 0} tone="primary" />
Proyección al deadline
¿Vamos a llegar al {deadlineLabel}?
PROYECCIÓN AL DEADLINE
{fmtNum(projectedBooksAtDeadline)} libros
al ritmo actual ({currentProjectionRate.toFixed(2)} libros/día)
RITMO NECESARIO
{requiredProjectionRate.toFixed(1)}
libros/día para llegar
{projectionExpired ? ( <>⚠ La fecha objetivo terminó y todavía faltan {fmtNum(missingProjectionBooks)} libros. ) : projectionOnTrack ? ( <>✓ Va en línea: al ritmo actual se proyectan {fmtNum(projectedBooksAtDeadline)} libros para la fecha objetivo. ) : ( <>⚠ Necesita acelerar: Faltan {fmtNum(missingProjectionBooks)} libros en {fmtNum(remainingProjectionDays)} días. Se requiere aumentar producción. )}
Avance acumulado por etapa
Libros que han alcanzado cada etapa · datos reales de MySQL
{stageRows.map(item => { const value = Number(stages[item.key]) || 0; const pct = total > 0 ? (value / total) * 100 : 0; return (
{item.label}
{fmtNum(value)} {pct.toFixed(1)}%
); })}
Indicadores operativos
Calculados con libros, asignaciones e historial
Pendientes en catálogo {fmtNum(Number(statuses.pending) || 0)}
En proceso {fmtNum(Number(statuses.in_progress) || 0)}
Completados {fmtNum(Number(statuses.completed) || 0)}
Rechazados / corrección {fmtNum(Number(statuses.rejected) || 0)}
Tasa de aceptación CC {acceptanceRate.toFixed(0)}%
Días restantes {daysRemaining === null ? '—' : daysRemaining}
Producción real de los últimos 45 días
Libros cuya digitalización fue completada y registrada en el historial
{daily.map((item, index) => { const value = Number(item.digitalized) || 0; return (
); })}
Hace 45 días Hoy
{daily.every(item => Number(item.digitalized) === 0) && (
Aún no hay eventos reales de digitalización completada durante este periodo.
)}
Productividad por usuario
Libros trabajados en el periodo
{productivity.length === 0 ? ( ) : productivity.map((user, index) => { const initials = String(user.name || user.username || '?') .split(/\s+/).filter(Boolean).slice(0, 2).map(part => part[0]).join('').toUpperCase(); const avatarColors = ['#4338ca', '#b45309', '#0f766e', '#1d4ed8', '#15803d', '#7e22ce', '#c2410c', '#be185d']; return ( ); })}
Usuario Digitalizados Editados Imágenes
Aún no hay productividad real registrada.
{initials} {user.name}
{Number(user.digitalized) ? fmtNum(user.digitalized) : '—'} {Number(user.edited) ? fmtNum(user.edited) : '—'} {fmtNum(Number(user.images) || 0)}
Estado actual del pipeline
Libros en cada etapa · datos reales de MySQL
{[ ['nuevo', 'Nuevo'], ['en_digitalizacion', 'En digitalización'], ['digitalizado', 'Digitalizado'], ['en_edicion', 'En edición'], ['editado', 'Editado'], ['en_cc', 'En control calidad'], ['aceptado', 'Aceptado'], ['rechazado', 'Rechazado'], ['en_conversion', 'En conversión'], ['finalizado', 'Finalizado'] ].map(([key, label]) => { const count = Number(pipeline[key]) || 0; return (
{fmtNum(count)} libro{count === 1 ? '' : 's'}
); })}
); }; Object.assign(window, { Dashboard });