/* ═══════════════════════════════════════════════ ADMIN VIEW v2 — User Management + Audit Log ═══════════════════════════════════════════════ */ /* ── Audit View ── */ const AuditView = () => { const [, setTick] = React.useState(0); React.useEffect(() => { const handler = () => setTick(t => t + 1); window.addEventListener('vencidos_data_changed', handler); return () => window.removeEventListener('vencidos_data_changed', handler); }, []); const log = window.MOCK.AUDIT_LOG; const [filterUser, setFilterUser] = React.useState('Todos'); const [filterAction, setFilterAction] = React.useState('Todos'); const [search, setSearch] = React.useState(''); const [dateFrom, setDateFrom] = React.useState(''); const [dateTo, setDateTo] = React.useState(''); const users = ['Todos', ...new Set(log.map(l => l.user))]; const actions = ['Todos', ...new Set(log.map(l => l.action))]; const filtered = log.filter(l => { if (filterUser !== 'Todos' && l.user !== filterUser) return false; if (filterAction !== 'Todos' && l.action !== filterAction) return false; if (dateFrom && l.timestamp.split('T')[0] < dateFrom) return false; if (dateTo && l.timestamp.split('T')[0] > dateTo) return false; if (search && !l.detail.toLowerCase().includes(search.toLowerCase()) && !l.user.toLowerCase().includes(search.toLowerCase())) return false; return true; }); const roleColors = { admin: { bg: 'var(--status-danger-bg)', color: 'var(--status-danger)' }, supervisor: { bg: 'var(--status-info-bg)', color: 'var(--status-info)' }, sala: { bg: 'var(--status-success-bg)', color: 'var(--status-success)' }, }; const actionIcons = { 'Registró producto': 'M12 4v16m8-8H4', 'Cambió estado': 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15', 'Asignó acción': 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2', 'Exportó reporte': 'M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', 'Completó registro': 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', 'Eliminó producto': 'M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16', 'Editó producto': 'M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z', 'Inicio sesión': 'M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1', 'Aprobó traspaso': 'M5 13l4 4L19 7', 'Generó consolidado': 'M9 17v-2m3 2v-4m3 4v-6m2 10H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z', 'Creó usuario': 'M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z', 'Asignó sala': 'M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5', }; const as = { container: { padding: '24px', maxWidth: 1000, margin: '0 auto' }, header: { display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap', alignItems: 'flex-end', }, searchField: { flex: '1 1 200px' }, filterField: { flex: '0 0 170px' }, dateField: { flex: '0 0 150px' }, filterLabel: { fontSize: 11, fontWeight: 600, color: 'var(--text-tertiary)', marginBottom: 4, display: 'block', textTransform: 'uppercase', letterSpacing: '0.05em', }, timeline: { position: 'relative', paddingLeft: 28 }, timelineLine: { position: 'absolute', left: 13, top: 0, bottom: 0, width: 2, background: 'var(--border-color)', borderRadius: 1, }, entry: { position: 'relative', paddingBottom: 2, marginBottom: 4, animation: 'slideRight 0.35s cubic-bezier(0.16,1,0.3,1) both', }, dot: { position: 'absolute', left: -22, top: 14, width: 12, height: 12, borderRadius: '50%', background: 'var(--accent)', border: '2px solid var(--bg-primary)', }, card: { background: 'var(--card-bg)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-md)', padding: '8px 12px', transition: 'all var(--transition-fast)', }, cardTop: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', }, userRow: { display: 'flex', alignItems: 'center', gap: 8 }, avatar: (role) => ({ width: 24, height: 24, borderRadius: 6, fontSize: 9, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', background: roleColors[role]?.bg || 'var(--bg-tertiary)', color: roleColors[role]?.color || 'var(--text-secondary)', }), userName: { fontSize: 13, fontWeight: 600 }, roleBadge: (role) => ({ fontSize: 10, fontWeight: 600, padding: '1px 6px', borderRadius: 'var(--radius-full)', background: roleColors[role]?.bg || 'var(--bg-tertiary)', color: roleColors[role]?.color || 'var(--text-secondary)', textTransform: 'capitalize', }), timestamp: { fontSize: 11, color: 'var(--text-tertiary)' }, actionIconMini: { width: 18, height: 18, borderRadius: 4, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', background: 'var(--bg-tertiary)', color: 'var(--text-secondary)', flexShrink: 0, }, actionText: { fontSize: 12, fontWeight: 700, color: 'var(--text-primary)' }, detailRow: { paddingLeft: 32, marginTop: 2 }, detailText: { fontSize: 12, color: 'var(--text-secondary)' }, count: { fontSize: 13, color: 'var(--text-tertiary)', marginBottom: 16, fontWeight: 500, }, }; return (
Buscar
setSearch(e.target.value)} style={{ fontSize: 13 }} />
Usuario
({ label: u, value: u }))} />
Acción
({ label: a, value: a }))} />
Desde
setDateFrom(e.target.value)} style={{ fontSize: 13 }} />
Hasta
setDateTo(e.target.value)} style={{ fontSize: 13 }} />
{filtered.length} registros de auditoría
{filtered.length === 0 && (
Sin registros
No hay actividad registrada aún.
)} {filtered.slice(0, 50).map((entry, i) => (
e.currentTarget.style.boxShadow = 'var(--shadow-sm)'} onMouseLeave={e => e.currentTarget.style.boxShadow = 'none'}>
{entry.avatar}
{entry.user} {entry.role} ·
{entry.action}
{entry.date} · {entry.time}
{entry.detail && (
{entry.detail}
)}
))}
); }; /* ═══════════════════════════════════════════════ USERS VIEW — Full user management (Admin only) ═══════════════════════════════════════════════ */ const UsersView = () => { const [users, setUsers] = React.useState(() => [...window.MOCK.USERS]); const [saving, setSaving] = React.useState(false); const currentUserData = (() => { try { return JSON.parse(localStorage.getItem('vencidos_user')) || {}; } catch { return {}; } })(); const currentUserRole = currentUserData.role || 'admin'; const currentUserId = currentUserData.id || null; const isSupervisor = currentUserRole === 'supervisor'; React.useEffect(() => { const handler = () => setUsers([...window.MOCK.USERS]); window.addEventListener('vencidos_data_changed', handler); return () => window.removeEventListener('vencidos_data_changed', handler); }, []); const [showModal, setShowModal] = React.useState(false); const [editUser, setEditUser] = React.useState(null); const [searchTerm, setSearchTerm] = React.useState(''); const [filterRole, setFilterRole] = React.useState('Todos'); const [filterSala, setFilterSala] = React.useState('Todos'); const [confirmDelete, setConfirmDelete] = React.useState(null); // Form state const [form, setForm] = React.useState({ name: '', username: '', role: 'sala', sucursal: '', active: true, password: '123456' }); const resetForm = () => setForm({ name: '', username: '', role: 'sala', sucursal: '', active: true, password: '123456' }); const openCreate = () => { resetForm(); setEditUser(null); setShowModal(true); }; const openEdit = (u) => { setForm({ name: u.name, username: u.username || '', role: u.role, sucursal: u.sucursal || '', active: u.active, password: u.password || '123456' }); setEditUser(u); setShowModal(true); }; const handleSave = async () => { if (!form.name.trim() || !form.username.trim()) return; setSaving(true); const avatar = form.name.split(' ').map(w => w[0]).join('').toUpperCase().slice(0, 2); try { let result; if (editUser) { result = await window.MOCK.updateUser(editUser.id, { ...form, avatar, sucursal: form.role === 'sala' ? form.sucursal : null, }); } else { result = await window.MOCK.addUser({ ...form, avatar, sucursal: form.role === 'sala' ? form.sucursal : null, password: form.password || '123456', created_by_id: currentUserId, }); } const actionDesc = editUser ? 'Editó usuario' : 'Creó usuario'; const detailDesc = editUser ? `Usuario ${form.name} modificado` : `Nuevo usuario: ${form.name} → ${form.role === 'sala' ? form.sucursal : form.role}`; try { const savedUser = localStorage.getItem('vencidos_user'); const cu = savedUser ? JSON.parse(savedUser) : { name: 'Administrador', role: 'admin', avatar: 'AD' }; window.MOCK.addAuditEntry(actionDesc, detailDesc, cu.name, cu.role, cu.avatar); } catch (e) {} setShowModal(false); resetForm(); setEditUser(null); } catch (e) { alert('Error al guardar usuario: ' + e.message); } finally { setSaving(false); } }; const handleDelete = async (id) => { const target = users.find(u => u.id === id); try { await window.MOCK.deleteUser(id); if (target) { try { const savedUser = localStorage.getItem('vencidos_user'); const cu = savedUser ? JSON.parse(savedUser) : { name: 'Administrador', role: 'admin', avatar: 'AD' }; window.MOCK.addAuditEntry('Eliminó usuario', `Eliminó a ${target.name}`, cu.name, cu.role, cu.avatar); } catch (e) {} } } catch (e) { alert('Error: ' + e.message); } setConfirmDelete(null); }; const handleToggleActive = async (id) => { const target = users.find(u => u.id === id); if (!target) return; try { await window.MOCK.updateUser(id, { active: !target.active }); try { const savedUser = localStorage.getItem('vencidos_user'); const cu = savedUser ? JSON.parse(savedUser) : { name: 'Administrador', role: 'admin', avatar: 'AD' }; const actText = target.active ? 'Desactivó usuario' : 'Activó usuario'; window.MOCK.addAuditEntry(actText, `${actText}: ${target.name}`, cu.name, cu.role, cu.avatar); } catch (e) {} } catch (e) { alert('Error: ' + e.message); } }; const filtered = users.filter(u => { if (filterRole !== 'Todos' && u.role !== filterRole) return false; if (filterSala !== 'Todos' && (u.sucursal || '') !== filterSala) return false; if (searchTerm) { const s = searchTerm.toLowerCase(); return u.name.toLowerCase().includes(s) || (u.username || '').toLowerCase().includes(s); } return true; }); const salaUsers = users.filter(u => u.role === 'sala'); const sucursalesWithUsers = window.MOCK.SUCURSALES.map(s => ({ ...s, users: salaUsers.filter(u => u.sucursal === s.id), })); const roleLabels = { admin: 'Administrador', supervisor: 'Supervisor', sala: 'Personal de sala' }; const roleColors = { admin: { bg: 'var(--status-danger-bg)', color: 'var(--status-danger)' }, supervisor: { bg: 'var(--status-info-bg)', color: 'var(--status-info)' }, sala: { bg: 'var(--status-success-bg)', color: 'var(--status-success)' }, }; const us = { container: { padding: '24px', maxWidth: 1100, margin: '0 auto' }, header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20, flexWrap: 'wrap', gap: 12, }, stats: { display: 'flex', gap: 16 }, stat: (color) => ({ display: 'flex', flexDirection: 'column', alignItems: 'center', padding: '8px 16px', borderRadius: 'var(--radius-md)', background: 'var(--bg-tertiary)', }), statNum: { fontSize: 22, fontWeight: 800, lineHeight: 1 }, statLabel: { fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }, toolbar: { display: 'flex', gap: 10, marginBottom: 20, flexWrap: 'wrap', alignItems: 'flex-end', }, searchWrap: { flex: '1 1 200px' }, filterWrap: { flex: '0 0 160px' }, filterLabel: { fontSize: 10, fontWeight: 600, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.05em', marginBottom: 3, display: 'block', }, table: { width: '100%', borderCollapse: 'collapse', fontSize: 13, background: 'var(--card-bg)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-lg)', overflow: 'hidden', }, th: { textAlign: 'left', padding: '12px 16px', fontWeight: 600, color: 'var(--text-secondary)', borderBottom: '1px solid var(--border-color)', background: 'var(--bg-tertiary)', fontSize: 12, whiteSpace: 'nowrap', }, td: { padding: '12px 16px', borderBottom: '1px solid var(--border-subtle)', verticalAlign: 'middle', }, row: { transition: 'background var(--transition-fast)' }, avatar: (role) => ({ width: 36, height: 36, borderRadius: 10, fontSize: 13, fontWeight: 700, display: 'flex', alignItems: 'center', justifyContent: 'center', background: roleColors[role]?.bg || 'var(--bg-tertiary)', color: roleColors[role]?.color || 'var(--text-secondary)', flexShrink: 0, }), userCell: { display: 'flex', alignItems: 'center', gap: 10 }, userName: { fontSize: 14, fontWeight: 600 }, userEmail: { fontSize: 12, color: 'var(--text-tertiary)' }, roleBadge: (role) => ({ display: 'inline-flex', padding: '3px 10px', borderRadius: 'var(--radius-full)', fontSize: 11, fontWeight: 600, background: roleColors[role]?.bg, color: roleColors[role]?.color, }), statusBadge: (active) => ({ display: 'inline-flex', alignItems: 'center', gap: 4, padding: '3px 10px', borderRadius: 'var(--radius-full)', fontSize: 11, fontWeight: 600, background: active ? 'var(--status-success-bg)' : 'var(--bg-tertiary)', color: active ? 'var(--status-success)' : 'var(--text-tertiary)', }), statusDot: (active) => ({ width: 6, height: 6, borderRadius: '50%', background: active ? 'var(--status-success)' : 'var(--text-tertiary)', }), actions: { display: 'flex', gap: 4 }, // Modal overlay: { position: 'fixed', inset: 0, zIndex: 500, background: 'rgba(0,0,0,0.5)', backdropFilter: 'blur(4px)', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 20, animation: 'fadeIn 0.2s ease both', }, modal: { background: 'var(--bg-secondary)', borderRadius: 'var(--radius-xl)', border: '1px solid var(--border-color)', boxShadow: 'var(--shadow-xl)', width: '100%', maxWidth: 480, maxHeight: '90vh', overflow: 'auto', animation: 'scaleIn 0.3s cubic-bezier(0.16,1,0.3,1) both', }, modalHeader: { padding: '20px 24px 0', display: 'flex', justifyContent: 'space-between', alignItems: 'center', }, modalTitle: { fontSize: 18, fontWeight: 700 }, modalBody: { padding: '20px 24px' }, modalField: { marginBottom: 16 }, modalLabel: { display: 'block', fontSize: 13, fontWeight: 600, color: 'var(--text-secondary)', marginBottom: 6, }, modalFooter: { padding: '16px 24px', borderTop: '1px solid var(--border-color)', display: 'flex', gap: 10, justifyContent: 'flex-end', }, roleOption: (active) => ({ flex: 1, padding: '12px', textAlign: 'center', borderRadius: 'var(--radius-md)', border: `1.5px solid ${active ? 'var(--accent)' : 'var(--border-color)'}`, background: active ? 'var(--accent-subtle)' : 'var(--bg-secondary)', cursor: 'pointer', transition: 'all var(--transition-fast)', }), roleOptionLabel: { fontSize: 13, fontWeight: 600, marginTop: 4 }, roleOptionDesc: { fontSize: 11, color: 'var(--text-tertiary)', marginTop: 2 }, // Sucursal map section mapSection: { marginTop: 24, padding: '20px', background: 'var(--card-bg)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-lg)', }, mapTitle: { fontSize: 15, fontWeight: 700, marginBottom: 16 }, mapGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))', gap: 10, }, mapCard: { padding: '12px 14px', borderRadius: 'var(--radius-md)', border: '1px solid var(--border-color)', background: 'var(--bg-secondary)', }, mapSucName: { fontSize: 13, fontWeight: 700, marginBottom: 6 }, mapCity: { fontSize: 11, color: 'var(--text-tertiary)', marginBottom: 8 }, mapUserChip: { display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 'var(--radius-full)', background: 'var(--status-success-bg)', color: 'var(--status-success)', fontSize: 11, fontWeight: 600, marginRight: 4, marginBottom: 4, }, mapEmpty: { fontSize: 11, color: 'var(--text-tertiary)', fontStyle: 'italic' }, }; const RoleIcon = ({ role, size = 16 }) => { const paths = { admin: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z', supervisor: 'M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z', sala: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z', }; return ( ); }; return (
{/* Header */}
{users.length}
Total
{users.filter(u => u.role === 'sala').length}
Sala
{users.filter(u => u.role === 'supervisor').length}
Supervisores
{users.filter(u => u.role === 'admin').length}
Admin
{/* Toolbar */}
setSearchTerm(e.target.value)} style={{ fontSize: 13 }} />
Rol
Sala
({ value: s.id, label: s.name })), ]} />
{/* Users table */}
{filtered.map((u, i) => ( e.currentTarget.style.background = 'var(--bg-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'}> ))}
Usuario Rol Sala asignada Estado Acciones
{u.avatar}
{u.name}
{roleLabels[u.role]} {u.sucursal ? ( {window.MOCK.SUCURSALES.find(s => s.id === u.sucursal)?.name || u.sucursal} ) : ( )} !isSupervisor && handleToggleActive(u.id)} title={isSupervisor ? undefined : 'Clic para cambiar estado'}> {u.active ? 'Activo' : 'Inactivo'}
{(!isSupervisor || u.createdById === currentUserId) && ( )}
{/* Sucursal assignment map */}
Asignación por sucursal
{sucursalesWithUsers.map(suc => (
{suc.name}
{suc.city}
{suc.users.length > 0 ? (
{suc.users.map(u => ( {u.name.split(' ')[0]} ))}
) : (
Sin asignar
)}
))}
{/* Create/Edit Modal */} {showModal && (
setShowModal(false)}>
e.stopPropagation()}>
{editUser ? 'Editar usuario' : 'Crear nuevo usuario'}
{/* Role selector */}
Rol
{[ { value: 'sala', label: 'Sala', desc: 'Registra productos' }, { value: 'supervisor', label: 'Supervisor', desc: 'Dashboard y reportes' }, { value: 'admin', label: 'Admin', desc: 'Acceso total' }, ].filter(r => !isSupervisor || r.value !== 'admin').map(r => (
setForm(f => ({...f, role: r.value}))}>
{r.label}
{r.desc}
))}
setForm(f => ({...f, name: e.target.value}))} placeholder="Nombre Apellido" />
setForm(f => ({...f, username: e.target.value.toLowerCase().replace(/\s/g,'')}))} placeholder="ej: jorge.alvarez" />
{form.role === 'sala' && (
setForm(f => ({...f, sucursal: val === '' ? '' : val}))} placeholder="Seleccionar sucursal..." options={[ { value: '', label: 'Seleccionar sucursal...' }, ...window.MOCK.SUCURSALES.map(s => ({ value: s.id, label: `${s.name} — ${s.city}` })), ]} /> {form.sucursal && (
{sucursalesWithUsers.find(s => s.id === form.sucursal)?.users.length || 0} usuario(s) ya asignados a esta sala
)}
)}
setForm(f => ({...f, password: e.target.value}))} placeholder="Contraseña..." />
)} {/* Delete confirmation */} {confirmDelete && (
setConfirmDelete(null)}>
e.stopPropagation()}>
¿Eliminar usuario?
{users.find(u => u.id === confirmDelete)?.name} será eliminado permanentemente del sistema.
)}
); }; /* ═══════════════════════════════════════════════ CONSOLIDATED VIEW ═══════════════════════════════════════════════ */ const escapeExcelXml = (value) => String(value ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const EXCEL_TEXT_ENCODER = new TextEncoder(); const toExcelColumnName = (index) => { let value = ''; let current = index + 1; while (current > 0) { const remainder = (current - 1) % 26; value = String.fromCharCode(65 + remainder) + value; current = Math.floor((current - 1) / 26); } return value; }; const normalizeWorksheetRows = (rows = [], header = []) => { const normalizedRows = []; if (header.length) normalizedRows.push({ values: header, styleId: 1 }); rows.forEach((row) => { if (Array.isArray(row)) { normalizedRows.push({ values: row, styleId: 0 }); return; } normalizedRows.push({ values: row.values || [], styleId: row.styleId ?? 0, }); }); return normalizedRows; }; const buildXlsxCell = (rowIndex, colIndex, value, styleId = 0) => { if (value === undefined || value === null || value === '') return ''; const ref = `${toExcelColumnName(colIndex)}${rowIndex}`; if (typeof value === 'number' && Number.isFinite(value)) { return `${value}`; } return `${escapeExcelXml(value)}`; }; const buildWorksheetXml = ({ header = [], rows = [], autoFilterRef = '', columnWidths = [] }) => { const normalizedRows = normalizeWorksheetRows(rows, header); const maxColumns = normalizedRows.reduce((max, row) => Math.max(max, row.values.length), 0); const dimension = normalizedRows.length && maxColumns ? `A1:${toExcelColumnName(maxColumns - 1)}${normalizedRows.length}` : 'A1'; const colsXml = columnWidths.length ? `${columnWidths.map((width, index) => `` ).join('')}` : ''; const rowXml = normalizedRows.map((row, rowIndex) => { const cells = row.values.map((value, colIndex) => buildXlsxCell(rowIndex + 1, colIndex, value, row.styleId) ).filter(Boolean).join(''); return `${cells}`; }).join(''); return ` ${colsXml} ${rowXml} ${autoFilterRef ? `` : ''} `; }; const buildWorkbookXml = (sheetNames) => ` ${sheetNames.map((name, index) => `` ).join('')} `; const buildWorkbookRelsXml = (sheetCount) => ` ${Array.from({ length: sheetCount }, (_, index) => `` ).join('')} `; const buildStylesXml = () => ` `; const buildContentTypesXml = (sheetCount) => ` ${Array.from({ length: sheetCount }, (_, index) => `` ).join('')} `; const buildRootRelsXml = () => ` `; const buildAppPropsXml = (sheetNames) => ` Codex Worksheets ${sheetNames.length} ${sheetNames.map((name) => `${escapeExcelXml(name)}`).join('')} 16.0300 `; const buildCorePropsXml = (generatedAt) => ` Codex Codex ${generatedAt.toISOString()} ${generatedAt.toISOString()} `; const buildCrc32Table = () => { const table = new Uint32Array(256); for (let index = 0; index < 256; index += 1) { let crc = index; for (let bit = 0; bit < 8; bit += 1) { crc = (crc & 1) ? (0xEDB88320 ^ (crc >>> 1)) : (crc >>> 1); } table[index] = crc >>> 0; } return table; }; const EXCEL_CRC32_TABLE = buildCrc32Table(); const calculateCrc32 = (bytes) => { let crc = 0xFFFFFFFF; for (let index = 0; index < bytes.length; index += 1) { crc = EXCEL_CRC32_TABLE[(crc ^ bytes[index]) & 0xFF] ^ (crc >>> 8); } return (crc ^ 0xFFFFFFFF) >>> 0; }; const getDosDateTime = (date) => { const safeYear = Math.max(date.getFullYear(), 1980); return { date: ((safeYear - 1980) << 9) | ((date.getMonth() + 1) << 5) | date.getDate(), time: (date.getHours() << 11) | (date.getMinutes() << 5) | Math.floor(date.getSeconds() / 2), }; }; const concatBytes = (chunks) => { const totalLength = chunks.reduce((total, chunk) => total + chunk.length, 0); const result = new Uint8Array(totalLength); let offset = 0; chunks.forEach((chunk) => { result.set(chunk, offset); offset += chunk.length; }); return result; }; const createStoredZipBlob = (files) => { const timestamp = getDosDateTime(new Date()); const localFiles = []; const centralFiles = []; let offset = 0; files.forEach((file) => { const nameBytes = EXCEL_TEXT_ENCODER.encode(file.name); const dataBytes = file.data instanceof Uint8Array ? file.data : EXCEL_TEXT_ENCODER.encode(file.data); const crc32 = calculateCrc32(dataBytes); const localFile = new Uint8Array(30 + nameBytes.length + dataBytes.length); const localView = new DataView(localFile.buffer); localView.setUint32(0, 0x04034B50, true); localView.setUint16(4, 20, true); localView.setUint16(6, 0, true); localView.setUint16(8, 0, true); localView.setUint16(10, timestamp.time, true); localView.setUint16(12, timestamp.date, true); localView.setUint32(14, crc32, true); localView.setUint32(18, dataBytes.length, true); localView.setUint32(22, dataBytes.length, true); localView.setUint16(26, nameBytes.length, true); localView.setUint16(28, 0, true); localFile.set(nameBytes, 30); localFile.set(dataBytes, 30 + nameBytes.length); localFiles.push(localFile); const centralFile = new Uint8Array(46 + nameBytes.length); const centralView = new DataView(centralFile.buffer); centralView.setUint32(0, 0x02014B50, true); centralView.setUint16(4, 20, true); centralView.setUint16(6, 20, true); centralView.setUint16(8, 0, true); centralView.setUint16(10, 0, true); centralView.setUint16(12, timestamp.time, true); centralView.setUint16(14, timestamp.date, true); centralView.setUint32(16, crc32, true); centralView.setUint32(20, dataBytes.length, true); centralView.setUint32(24, dataBytes.length, true); centralView.setUint16(28, nameBytes.length, true); centralView.setUint16(30, 0, true); centralView.setUint16(32, 0, true); centralView.setUint16(34, 0, true); centralView.setUint16(36, 0, true); centralView.setUint32(38, 0, true); centralView.setUint32(42, offset, true); centralFile.set(nameBytes, 46); centralFiles.push(centralFile); offset += localFile.length; }); const localBytes = concatBytes(localFiles); const centralBytes = concatBytes(centralFiles); const endRecord = new Uint8Array(22); const endView = new DataView(endRecord.buffer); endView.setUint32(0, 0x06054B50, true); endView.setUint16(4, 0, true); endView.setUint16(6, 0, true); endView.setUint16(8, files.length, true); endView.setUint16(10, files.length, true); endView.setUint32(12, centralBytes.length, true); endView.setUint32(16, localBytes.length, true); endView.setUint16(20, 0, true); return new Blob([localBytes, centralBytes, endRecord], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', }); }; const downloadExcelWorkbook = (sheets, fileName, generatedAt = new Date()) => { const sheetNames = sheets.map((sheet) => sheet.name); const files = [ { name: '[Content_Types].xml', data: buildContentTypesXml(sheets.length) }, { name: '_rels/.rels', data: buildRootRelsXml() }, { name: 'docProps/app.xml', data: buildAppPropsXml(sheetNames) }, { name: 'docProps/core.xml', data: buildCorePropsXml(generatedAt) }, { name: 'xl/workbook.xml', data: buildWorkbookXml(sheetNames) }, { name: 'xl/_rels/workbook.xml.rels', data: buildWorkbookRelsXml(sheets.length) }, { name: 'xl/styles.xml', data: buildStylesXml() }, ...sheets.map((sheet, index) => ({ name: `xl/worksheets/sheet${index + 1}.xml`, data: buildWorksheetXml(sheet), })), ]; const blob = createStoredZipBlob(files); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = fileName; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const ConsolidadoView = () => { const [, setTick] = React.useState(0); React.useEffect(() => { const handler = () => setTick(t => t + 1); window.addEventListener('vencidos_data_changed', handler); return () => window.removeEventListener('vencidos_data_changed', handler); }, []); const products = (window.MOCK.MOCK_PRODUCTS && window.MOCK.MOCK_PRODUCTS.length) ? window.MOCK.MOCK_PRODUCTS : ((window.MOCK.getBootstrapProducts && window.MOCK.getBootstrapProducts()) || []); const [selectedSucursales, setSelectedSucursales] = React.useState( window.MOCK.SUCURSALES.map(s => s.id) ); const [lastExport, setLastExport] = React.useState(null); const [exportError, setExportError] = React.useState(''); const [isExporting, setIsExporting] = React.useState(false); const [exportStatus, setExportStatus] = React.useState(''); const toggleSuc = (id) => { setSelectedSucursales(prev => prev.includes(id) ? prev.filter(s => s !== id) : [...prev, id] ); }; const ubicMap = window.MOCK.UBICACION_TO_CATALOG || {}; const sucNameMap = Object.fromEntries(window.MOCK.SUCURSALES.map(s => [s.id, s.name])); const resolveSalaId = (odooCode) => ubicMap[odooCode] || odooCode; const resolveSalaCode = (odooCode) => resolveSalaId(odooCode) || odooCode || ''; const resolveUbicacionCode = (odooCode) => { if (window.MOCK.resolveSalaDisplayCode) return window.MOCK.resolveSalaDisplayCode(odooCode); const rawCode = String(odooCode || '').trim().toUpperCase(); return rawCode || resolveSalaCode(odooCode) || ''; }; const resolveSalaName = (odooCode) => sucNameMap[resolveSalaId(odooCode)] || odooCode; const normalizeBarcode = (value) => { if (window.MOCK.normalizeBarcode) return window.MOCK.normalizeBarcode(value); const text = String(value || '').trim(); if (!text) return ''; const stripped = text.replace(/^0+/, ''); return stripped || text; }; const getCatalogData = (product, lookup = window.MOCK.CATALOG_LOOKUP || {}) => { const sucursalId = resolveSalaId(product && product.sucursal); const barcode = String((product && product.codigo) || '').trim(); const normalized = normalizeBarcode(barcode); return ( lookup[`${barcode}_${sucursalId}`] || lookup[`${normalized}_${sucursalId}`] || null ); }; const filteredProducts = products.filter(p => selectedSucursales.includes(resolveSalaId(p.sucursal))); const estadoLabels = Object.fromEntries(window.MOCK.ESTADOS.map((estado) => [estado.id, estado.label])); const visibleSucursalIds = new Set(window.MOCK.SUCURSALES.map((sucursal) => sucursal.id)); const isMappedSucursal = (product) => visibleSucursalIds.has(resolveSalaId(product.sucursal)); const unmappedProducts = products.filter((product) => !isMappedSucursal(product)); const unmappedSummary = Object.entries( unmappedProducts.reduce((acc, product) => { const key = resolveUbicacionCode(product.sucursal) || product.sucursalName || 'Sin sucursal'; acc[key] = (acc[key] || 0) + 1; return acc; }, {}) ).sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])); const unmappedSummaryText = unmappedSummary.map(([label, count]) => `${label} (${count})`).join(', '); const yieldToBrowser = () => new Promise((resolve) => { if (typeof requestAnimationFrame === 'function') { requestAnimationFrame(() => requestAnimationFrame(resolve)); return; } setTimeout(resolve, 16); }); React.useEffect(() => { setLastExport(null); setExportError(''); }, [selectedSucursales]); const sucStats = window.MOCK.SUCURSALES.map(suc => { const prods = products.filter(p => resolveSalaId(p.sucursal) === suc.id); const urgentes = prods.filter(p => p.estado === 'vence_menos_3m' || p.estado === 'devoluciones' || p.estado === 'ya_vencido').length; return { ...suc, total: prods.length, pendientes: urgentes, completado: urgentes === 0 && prods.length > 0, }; }); const cs = { container: { padding: '24px', maxWidth: 1200, margin: '0 auto' }, intro: { marginBottom: 24, padding: '24px', background: 'var(--card-bg)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-lg)', }, introTitle: { fontSize: 18, fontWeight: 700, marginBottom: 8 }, introText: { fontSize: 14, color: 'var(--text-secondary)', lineHeight: 1.6, marginBottom: 16 }, sucGrid: { display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))', gap: 8, marginBottom: 16, }, sucCard: (selected) => ({ padding: '12px', borderRadius: 'var(--radius-md)', border: `1.5px solid ${selected ? 'var(--accent)' : 'var(--border-color)'}`, background: selected ? 'var(--accent-subtle)' : 'var(--bg-secondary)', cursor: 'pointer', transition: 'all var(--transition-fast)', textAlign: 'center', }), sucName: { fontSize: 13, fontWeight: 600, marginBottom: 2 }, sucCount: { fontSize: 20, fontWeight: 800, color: 'var(--accent)' }, sucStatus: (done) => ({ fontSize: 10, fontWeight: 600, marginTop: 4, color: done ? 'var(--status-success)' : 'var(--status-warning)', }), summary: { display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: 12, marginTop: 20, }, summaryCard: { padding: '16px', borderRadius: 'var(--radius-md)', background: 'var(--bg-tertiary)', textAlign: 'center', }, summaryNum: { fontSize: 28, fontWeight: 800, color: 'var(--accent)' }, summaryLabel: { fontSize: 12, color: 'var(--text-tertiary)', marginTop: 4 }, infoBanner: { padding: '14px 16px', borderRadius: 'var(--radius-md)', background: 'var(--status-warning-bg)', border: '1px solid var(--status-warning)', color: 'var(--text-secondary)', fontSize: 13, lineHeight: 1.5, marginTop: 16, }, consolidateBtn: { width: '100%', padding: '14px', fontSize: 15, fontWeight: 700, marginTop: 20, }, successBanner: { padding: '16px 20px', borderRadius: 'var(--radius-md)', background: 'var(--status-success-bg)', border: '1px solid var(--status-success)', color: 'var(--status-success)', fontWeight: 600, fontSize: 14, display: 'flex', alignItems: 'center', gap: 10, marginTop: 16, }, loadingBanner: { padding: '16px 20px', borderRadius: 'var(--radius-md)', background: 'var(--accent-subtle)', border: '1px solid var(--accent)', color: 'var(--text-primary)', fontWeight: 600, fontSize: 14, display: 'flex', alignItems: 'center', gap: 12, marginTop: 16, }, errorBanner: { padding: '16px 20px', borderRadius: 'var(--radius-md)', background: 'var(--status-warning-bg)', border: '1px solid var(--status-warning)', color: 'var(--status-warning)', fontWeight: 600, fontSize: 14, display: 'flex', alignItems: 'center', gap: 10, marginTop: 16, }, }; const handleExport = async () => { if (isExporting) return; if (!selectedSucursales.length) { setExportError('Selecciona al menos una sucursal para generar el consolidado.'); setLastExport(null); return; } if (!filteredProducts.length) { setExportError('No hay productos en las sucursales seleccionadas para exportar.'); setLastExport(null); return; } setExportError(''); setLastExport(null); setIsExporting(true); setExportStatus('Preparando productos...'); await yieldToBrowser(); try { const generatedAt = new Date(); const fileStamp = generatedAt.toISOString().replace(/[:]/g, '-').split('.')[0]; const fileName = `consolidado_vencidos_${fileStamp}.xlsx`; const sucursalCodes = [...selectedSucursales]; const selectedDisplayCodes = sucursalCodes.map((sucursalId) => resolveUbicacionCode(sucursalId)); const sortedProducts = [...filteredProducts].sort((a, b) => { const bySucursal = resolveUbicacionCode(a.sucursal).localeCompare(resolveUbicacionCode(b.sucursal)); if (bySucursal !== 0) return bySucursal; return (a.nombre || '').localeCompare(b.nombre || ''); }); setExportStatus('Buscando categorías y costos...'); await yieldToBrowser(); if (window.MOCK.syncCatalogLookup) { try { await window.MOCK.syncCatalogLookup(); } catch (_) {} } const catalogLookup = window.MOCK.CATALOG_LOOKUP || {}; const dataReportHeader = [ 'UBICACION', 'Asesora', 'CODIGO DE BARRA', 'DESCRIPCION', 'PROVEEDOR', 'CAT1', 'CAT2', 'CAT3', 'CAT4', 'cambio', 'cantidad', 'coste', 'subtotal', 'FECHA VENCIMIENTO', 'Estado', 'DIAS PARA VENCER A FECHA ACTUAL', 'Fecha de reporte', 'UNIDAD DE NEGOCIO', ]; const dataReportRows = sortedProducts.map((product) => { const catalogData = getCatalogData(product, catalogLookup); const costNumber = Number(catalogData && catalogData.costPrice); const costValue = Number.isFinite(costNumber) && costNumber > 0 ? costNumber : ''; const subtotalValue = costValue === '' ? '' : Number((Number(product.cantidad || 0) * costValue).toFixed(2)); return [ resolveUbicacionCode(product.sucursal), product.reportadoPor, product.codigo, product.nombre, product.proveedor, product.cat1 || (catalogData && catalogData.cat1) || '', product.cat2 || (catalogData && catalogData.cat2) || '', product.cat3 || (catalogData && catalogData.cat3) || '', (catalogData && catalogData.cat4) || '', product.proveedorCambio ? 'SI' : 'NO', product.cantidad, costValue, subtotalValue, product.tipo === 'dañado' ? 'Dañados' : product.fechaVencimiento, product.tipo === 'dañado' ? (product.accion || 'Tratamiento dañados') : product.estadoVencimiento, product.tipo === 'dañado' ? '' : product.diasRestantes, product.fechaRegistro, 'VENCIDOS', ]; }); const reportStates = [ 'Dañados', 'Próximo a vencer', 'Vence en más de 3 meses', 'Vence en menos de 3 meses', 'Vencido', 'Ya vencido', ]; const providerSummary = {}; sortedProducts.forEach((product) => { const provider = product.proveedor || 'Sin proveedor'; const state = product.tipo === 'dañado' ? 'Dañados' : (product.estadoVencimiento || 'Sin estado'); if (!providerSummary[provider]) { providerSummary[provider] = Object.fromEntries(reportStates.map((label) => [label, 0])); providerSummary[provider].total = 0; } if (providerSummary[provider][state] !== undefined) { providerSummary[provider][state] += product.cantidad; } providerSummary[provider].total += product.cantidad; }); const providerRows = Object.entries(providerSummary) .sort(([, left], [, right]) => right.total - left.total) .map(([provider, counters]) => [ provider, ...reportStates.map((label) => counters[label]), counters.total, ]); const sucursalRows = sucursalCodes.map((sucursalId, index) => { const sucursalCode = selectedDisplayCodes[index]; const sucursalProducts = sortedProducts.filter((product) => resolveSalaId(product.sucursal) === sucursalId); return [ sucursalCode, sucursalProducts.length, sucursalProducts.filter((product) => product.estado === 'proximo_vencer').length, sucursalProducts.filter((product) => product.estado === 'vence_mas_3m').length, sucursalProducts.filter((product) => product.estado === 'venta_impulso').length, sucursalProducts.filter((product) => product.estado === 'vence_menos_3m').length, sucursalProducts.filter((product) => product.estado === 'devoluciones').length, sucursalProducts.filter((product) => product.estado === 'ya_vencido').length, ]; }); setExportStatus('Armando archivo Excel...'); await yieldToBrowser(); downloadExcelWorkbook([ { name: 'data reportes', header: dataReportHeader, autoFilterRef: `A1:${toExcelColumnName(dataReportHeader.length - 1)}${dataReportRows.length + 1}`, columnWidths: [18, 18, 18, 42, 28, 16, 16, 18, 16, 10, 12, 12, 12, 18, 24, 18, 18, 18], rows: dataReportRows, }, { name: 'reporte general', columnWidths: [34, 18, 18, 18, 22, 18, 18, 18], rows: [ { values: ['Fecha de reporte', generatedAt.toLocaleString('es-BO')], styleId: 2 }, { values: ['Sucursales incluidas', selectedDisplayCodes.join(', ')], styleId: 2 }, { values: ['Cantidad de sucursales', selectedSucursales.length], styleId: 2 }, { values: ['Productos cargados', products.length], styleId: 2 }, { values: ['Productos exportados', sortedProducts.length], styleId: 2 }, { values: ['Fuera de sucursales visibles', unmappedProducts.length], styleId: 2 }, ...(unmappedProducts.length ? [{ values: ['Ubicaciones fuera de sucursal', unmappedSummaryText], styleId: 2 }] : []), { values: ['Próximo a vencer', sortedProducts.filter((product) => product.estado === 'proximo_vencer').length], styleId: 2 }, { values: ['Vence en más de 3 meses', sortedProducts.filter((product) => product.estado === 'vence_mas_3m').length], styleId: 2 }, { values: ['Venta Impulso', sortedProducts.filter((product) => product.estado === 'venta_impulso').length], styleId: 2 }, { values: ['Vence en menos de 3 meses', sortedProducts.filter((product) => product.estado === 'vence_menos_3m').length], styleId: 2 }, { values: ['Devoluciones', sortedProducts.filter((product) => product.estado === 'devoluciones').length], styleId: 2 }, { values: ['Ya vencido', sortedProducts.filter((product) => product.estado === 'ya_vencido').length], styleId: 2 }, [''], { values: ['Proveedor', ...reportStates, 'Total general'], styleId: 1 }, ...providerRows, [''], { values: ['Sucursal', 'Productos', 'Próximo a vencer', 'Vence >3m', 'Venta Impulso', 'Vence <3m', 'Devoluciones', 'Ya vencido'], styleId: 1 }, ...sucursalRows, ], }, ], fileName, generatedAt); setLastExport({ count: sortedProducts.length, fileName, generatedAt: generatedAt.toLocaleTimeString('es-BO', { hour: '2-digit', minute: '2-digit' }), }); } catch (error) { setExportError(`Error al exportar: ${error && error.message ? error.message : 'falló la generación del Excel.'}`); } finally { setIsExporting(false); setExportStatus(''); } }; return (
Consolidar sucursales
Selecciona las sucursales que deseas incluir en el consolidado. La exportacion genera un archivo XLSX con dos hojas: data reportes y reporte general.
{sucStats.map(suc => (
toggleSuc(suc.id)}>
{resolveUbicacionCode(suc.id)}
{suc.total}
{suc.completado ? '✓ Completo' : '● Pendiente'}
))}
{selectedSucursales.length}
Sucursales seleccionadas
{products.length}
Productos cargados
{filteredProducts.length}
Productos exportables
{filteredProducts.filter(p => p.estado === 'vence_menos_3m').length}
Vence <3 meses
{filteredProducts.filter(p => p.diasRestantes < 0).length}
Ya vencidos
{unmappedProducts.length > 0 && (
{unmappedProducts.length} productos quedan fuera del consolidado porque no pertenecen a las {window.MOCK.SUCURSALES.length} sucursales visibles: {unmappedSummaryText}.
)} {isExporting && (
{exportStatus || 'Generando archivo Excel...'}
)} {lastExport && (
Excel exportado — {lastExport.count} productos en {lastExport.fileName} a las {lastExport.generatedAt}
)} {exportError && (
{exportError}
)}
); }; /* ═══════════════════════════════════════════════ SYNC STATUS VIEW — Odoo catalog sync info ═══════════════════════════════════════════════ */ const SyncStatusView = () => { const [status, setStatus] = React.useState(null); const [loading, setLoading] = React.useState(true); const load = async () => { setLoading(true); try { const r = await fetch(`${window.MOCK.getApiBase()}/sync/status`); setStatus(await r.json()); } catch { setStatus({ ok: false, error: 'Sin conexión' }); } setLoading(false); }; React.useEffect(() => { load(); }, []); const fmt = (iso) => { if (!iso) return '—'; try { const d = new Date(iso); return d.toLocaleString('es-BO', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); } catch { return iso; } }; const ss = { wrap: { padding: 24, maxWidth: 700, margin: '0 auto' }, title: { fontSize: 20, fontWeight: 800, marginBottom: 4, color: 'var(--text-primary)' }, sub: { fontSize: 13, color: 'var(--text-tertiary)', marginBottom: 24 }, card: { background: 'var(--card-bg)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-lg)', padding: 24, marginBottom: 16, }, row: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '10px 0', borderBottom: '1px solid var(--border-subtle)' }, label: { fontSize: 13, color: 'var(--text-secondary)' }, value: { fontSize: 13, fontWeight: 700, color: 'var(--text-primary)' }, badge: (ok) => ({ display: 'inline-flex', alignItems: 'center', gap: 6, padding: '4px 12px', borderRadius: 'var(--radius-full)', fontSize: 12, fontWeight: 700, background: ok ? 'var(--status-success-bg)' : 'var(--status-danger-bg)', color: ok ? 'var(--status-success)' : 'var(--status-danger)', }), dot: (ok) => ({ width: 7, height: 7, borderRadius: '50%', background: ok ? 'var(--status-success)' : 'var(--status-danger)', }), infoBox: { background: 'var(--accent-subtle)', border: '1px solid var(--accent)', borderRadius: 'var(--radius-md)', padding: '12px 16px', fontSize: 13, color: 'var(--text-secondary)', marginTop: 16, lineHeight: 1.6, }, }; return (
Sincronización de catálogo
Estado de la sincronización automática con Odoo (cada 30 minutos)
{loading ? (
Cargando...
) : !status?.ok ? (
⚠ Error al leer el estado
{status?.error}
) : (
Estado del sistema Activo
{[ ['Última sincronización', fmt(status.lastSync)], ['Próxima sincronización', fmt(status.nextSync)], ['Productos en catálogo', status.productCount?.toLocaleString('es-BO') + ' productos'], ['Tamaño del cache', status.cacheSize + ' MB'], ['Fuente', 'Odoo (Sistema NUBA / Andy\'s)'], ].map(([label, value]) => (
{label} {value}
))}
La sincronización es automática — el sistema Quiebra actualiza el catálogo de Odoo cada 30 minutos. Stock y cobertura se calculan por sucursal al momento del registro. No se requiere intervención manual.
)}
); }; /* ═══════════════════════════════════════════════ CATALOGO VIEW — Admin: explora catálogo por sala ═══════════════════════════════════════════════ */ const CatalogoView = (props) => { const isSala = props.user?.role === 'sala'; const [sucursal, setSucursal] = React.useState(isSala ? (props.user?.sucursal || 'SG') : 'SG'); const [term, setTerm] = React.useState(''); const [results, setResults] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(''); const [showAll, setShowAll] = React.useState(false); const [fStock, setFStock] = React.useState('all'); const [fVtas, setFVtas] = React.useState('all'); const [fCob, setFCob] = React.useState('all'); const [fAbc, setFAbc] = React.useState([]); const [page, setPage] = React.useState(0); const [sortCol, setSortCol] = React.useState(null); // 'nombre'|'stock'|'sales30d'|'coverageDays'|'abc' const [sortDir, setSortDir] = React.useState(1); // 1=asc -1=desc const PAGE_SIZE = 100; const timerRef = React.useRef(null); React.useEffect(() => { if (timerRef.current) clearTimeout(timerRef.current); setLoading(true); let cancelled = false; timerRef.current = setTimeout(async () => { const baseUrl = `${window.MOCK.getApiBase()}/catalog/search?q=${encodeURIComponent(term)}&sucursal=${sucursal}&show_all=${showAll}`; try { // Phase 1: fast fetch of first 200 to render UI immediately const fastRes = await fetch(`${baseUrl}&limit=200`); if (cancelled) return; if (!fastRes.ok) throw new Error('HTTP ' + fastRes.status); const fastItems = await fastRes.json(); if (cancelled) return; setResults(fastItems); setError(''); setLoading(false); // Phase 2: fetch the full list in background if more may exist if (fastItems.length >= 200) { const fullRes = await fetch(`${baseUrl}&limit=0`); if (cancelled) return; if (fullRes.ok) { const allItems = await fullRes.json(); if (!cancelled) setResults(allItems); } } } catch (e) { if (cancelled) return; setError('Error: ' + e.message); setResults([]); setLoading(false); } }, term ? 250 : 0); return () => { cancelled = true; if (timerRef.current) clearTimeout(timerRef.current); }; }, [term, sucursal, showAll]); const sucName = window.MOCK.SUCURSALES.find(s => s.id === sucursal)?.name || sucursal; const fallbackCount = results.filter(r => r.fallback).length; const filtered = results.filter(r => { if (fStock === 'con' && r.stock <= 0) return false; if (fStock === 'sin' && r.stock > 0) return false; if (fVtas === 'con' && r.sales30d <= 0) return false; if (fVtas === 'sin' && r.sales30d > 0) return false; if (fCob !== 'all') { if (fCob === 'sd' && (r.coverageDays > 0 || r.sales30d > 0)) return false; if (fCob === 'sin-ventas' && r.sales30d !== 0) return false; if (fCob === '>60' && r.cobertura !== '>60' && r.cobertura !== '>180') return false; if (fCob !== 'sd' && fCob !== 'sin-ventas' && fCob !== '>60' && r.cobertura !== fCob) return false; } if (fAbc.length > 0) { const abc = r.abcSucursal || r.abc || ''; if (!fAbc.includes(abc)) return false; } return true; }); const hasFilters = fStock !== 'all' || fVtas !== 'all' || fCob !== 'all' || fAbc.length > 0; React.useEffect(() => { setPage(0); }, [fStock, fVtas, fCob, fAbc, term, sucursal, showAll, sortCol, sortDir]); const ABC_ORDER = {'A':0,'B':1,'C':2,'D':3,'E':4,'':5}; const sorted = React.useMemo(() => { if (!sortCol) return filtered; return [...filtered].sort((a, b) => { let va = a[sortCol], vb = b[sortCol]; if (sortCol === 'abc') { va = ABC_ORDER[a.abcSucursal || a.abc || '']; vb = ABC_ORDER[b.abcSucursal || b.abc || '']; } if (typeof va === 'string') return sortDir * va.localeCompare(vb); return sortDir * ((va ?? -1) - (vb ?? -1)); }); }, [filtered, sortCol, sortDir]); const toggleSort = (col) => { if (sortCol === col && sortDir === -1) { setSortCol(null); setSortDir(1); } else if (sortCol === col) setSortDir(-1); else { setSortCol(col); setSortDir(1); } }; const totalPages = Math.ceil(sorted.length / PAGE_SIZE); const paginated = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE); const cv = { wrap: { padding: 24, maxWidth: 1200, margin: '0 auto' }, head: { display: 'flex', alignItems: 'center', gap: 16, flexWrap: 'wrap', marginBottom: 8, }, title: { fontSize: 22, fontWeight: 800, color: 'var(--text-primary)', width: '100%', marginBottom: 4 }, select: { minWidth: 180 }, inputWrap: { position: 'relative', flex: '1 1 280px', minWidth: 240 }, input: { width: '100%', boxSizing: 'border-box', height: 38, background: 'var(--bg-tertiary)', border: '1px solid var(--border-color)', borderRadius: 'var(--radius-md)', padding: '0 12px 0 34px', fontSize: 13, color: 'var(--text-primary)', }, inputIcon: { position: 'absolute', left: 11, top: '50%', transform: 'translateY(-50%)', color: 'var(--text-tertiary)', pointerEvents: 'none', }, sub: { fontSize: 12, color: 'var(--text-tertiary)', marginBottom: 14, display: 'flex', gap: 12, alignItems: 'center', flexWrap: 'wrap', }, warnPill: { padding: '2px 8px', borderRadius: 'var(--radius-full)', background: 'var(--status-warning-bg)', color: 'var(--status-warning)', fontSize: 11, fontWeight: 700, }, table: { width: '100%', borderCollapse: 'collapse', fontSize: 13 }, th: (col) => ({ textAlign: 'left', padding: '10px 12px', fontSize: 11, fontWeight: 700, color: sortCol === col ? '#60a5fa' : 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.04em', borderBottom: `2px solid ${sortCol === col ? '#3b82f6' : 'var(--border-color)'}`, background: sortCol === col ? '#1e3a5f33' : 'var(--bg-secondary)', position: 'sticky', top: 0, cursor: col ? 'pointer' : 'default', userSelect: 'none', whiteSpace: 'nowrap', }), td: { padding: '9px 12px', borderBottom: '1px solid var(--border-subtle)' }, rowEven: { background: 'transparent' }, rowOdd: { background: '#ffffff08' }, rowFallback: { background: '#f59e0b11' }, empty: { padding: 40, textAlign: 'center', color: 'var(--text-tertiary)' }, fallbackTag: { display: 'inline-block', padding: '1px 6px', borderRadius: 'var(--radius-full)', background: 'var(--status-warning)', color: '#000', fontSize: 9, fontWeight: 800, marginLeft: 6, letterSpacing: '0.04em', verticalAlign: 'middle', }, sortIcon: (col, dir) => ({ marginLeft: 4, opacity: sortCol === col ? 1 : 0.3, fontSize: 10, }), stockHigh: { color: '#4ade80', fontWeight: 700 }, stockLow: { color: '#fb923c', fontWeight: 700 }, stockZero: { color: '#f87171', fontWeight: 700 }, cobBadge: (rng) => ({ display: 'inline-block', padding: '2px 7px', borderRadius: 'var(--radius-full)', fontSize: 10, fontWeight: 700, background: rng === '0-7' ? '#fee2e2' : rng === '7-14' ? '#fef3c7' : rng === '14-30' ? '#dbeafe' : rng === '30-60' ? '#dcfce7' : rng === '>60' ? '#f0fdf4' : '#1f2937', color: rng === '0-7' ? '#991b1b' : rng === '7-14' ? '#92400e' : rng === '14-30' ? '#1e40af' : rng === '30-60' ? '#166534' : rng === '>60' ? '#15803d' : '#6b7280', }), abcBadge: (abc) => ({ display: 'inline-block', padding: '2px 8px', borderRadius: 'var(--radius-full)', fontSize: 11, fontWeight: 800, background: abc==='A'?'#dcfce7':abc==='B'?'#dbeafe':abc==='C'?'#fef9c3':abc==='D'?'#f3e8ff':abc==='E'?'#ffe4e6':'#f3f4f6', color: abc==='A'?'#166534':abc==='B'?'#1d4ed8':abc==='C'?'#854d0e':abc==='D'?'#7e22ce':abc==='E'?'#9f1239':'#6b7280', }), toggleBtn: { height: 38, padding: '0 16px', borderRadius: 'var(--radius-md)', fontSize: 12, fontWeight: 800, cursor: 'pointer', border: !showAll ? '1.5px solid #f59e0b' : '1.5px solid #3b82f6', background: !showAll ? '#f59e0b22' : '#3b82f622', color: !showAll ? '#f59e0b' : '#60a5fa', whiteSpace: 'nowrap', letterSpacing: '0.02em', }, filterBar: { display: 'flex', gap: 8, flexWrap: 'wrap', alignItems: 'center', marginBottom: 10, padding: '8px 0', borderBottom: '1px solid var(--border-subtle)', }, filterGroup: { display: 'flex', gap: 4, alignItems: 'center' }, filterLabel: { fontSize: 10, fontWeight: 700, color: 'var(--text-tertiary)', textTransform: 'uppercase', letterSpacing: '0.06em', marginRight: 2 }, pill: (active) => ({ padding: '4px 11px', borderRadius: 'var(--radius-full)', fontSize: 11, fontWeight: 700, cursor: 'pointer', border: `1.5px solid ${active ? '#3b82f6' : 'var(--border-color)'}`, background: active ? '#3b82f6' : 'var(--bg-tertiary)', color: active ? '#fff' : 'var(--text-secondary)', userSelect: 'none', transition: 'all 0.12s', }), pillAbc: (active) => ({ padding: '4px 9px', borderRadius: 'var(--radius-full)', fontSize: 11, fontWeight: 800, cursor: 'pointer', border: `1.5px solid ${active ? '#8b5cf6' : 'var(--border-color)'}`, background: active ? '#8b5cf6' : 'var(--bg-tertiary)', color: active ? '#fff' : 'var(--text-secondary)', userSelect: 'none', transition: 'all 0.12s', }), clearBtn: { padding: '4px 11px', borderRadius: 'var(--radius-full)', fontSize: 11, fontWeight: 700, cursor: 'pointer', border: '1.5px solid #ef4444', background: '#ef444422', color: '#ef4444', userSelect: 'none', }, pagBar: { display: 'flex', alignItems: 'center', gap: 6, padding: '10px 12px', borderTop: '1px solid var(--border-subtle)', background: 'var(--bg-secondary)', }, pagBtn: (active, disabled) => ({ padding: '4px 10px', borderRadius: 'var(--radius-sm)', fontSize: 12, fontWeight: 700, cursor: disabled ? 'default' : 'pointer', border: `1px solid ${active ? '#3b82f6' : 'var(--border-color)'}`, background: active ? '#3b82f6' : 'var(--bg-tertiary)', color: active ? '#fff' : disabled ? 'var(--text-tertiary)' : 'var(--text-secondary)', opacity: disabled ? 0.4 : 1, }), pagInfo: { fontSize: 12, color: 'var(--text-tertiary)', margin: '0 4px' }, }; const toggleAbc = (cat) => setFAbc(prev => prev.includes(cat) ? prev.filter(c => c !== cat) : [...prev, cat] ); return (
Catálogo
{isSala ? (
{sucursal} — {sucName}
) : ( ({ value: s.id, label: `${s.id} — ${s.name}` }))} /> )} {!isSala && ( )}
setTerm(e.target.value)} />
Stock {[['all','Todos'],['con','Con stock'],['sin','Sin stock']].map(([v,l]) => ( setFStock(v)}>{l} ))}
Ventas {[['all','Todos'],['con','Con ventas'],['sin','Sin ventas']].map(([v,l]) => ( setFVtas(v)}>{l} ))}
Cobertura {[['all','Todos'],['sd','S/D'],['0-7','0-7d'],['7-14','7-14d'],['14-30','14-30d'],['30-60','30-60d'],['>60','>60d']].map(([v,l]) => ( setFCob(v)}>{l} ))}
ABC {['A','B','C','D','E'].map(cat => ( toggleAbc(cat)}>{cat} ))}
{hasFilters && ( { setFStock('all'); setFVtas('all'); setFCob('all'); setFAbc([]); }}>✕ Limpiar )}
Página {page + 1} de {totalPages || 1} —{' '} {filtered.length}{filtered.length !== results.length ? ` de ${results.length}` : ''} productos en {sucName} {fallbackCount > 0 && ( ⚠ {fallbackCount} sin datos por sala )}
{error &&
{error}
}
{loading && results.length === 0 ? ( ) : paginated.length === 0 ? ( ) : paginated.map((r, i) => { const abc = r.abcSucursal || r.abc || ''; const rowStyle = r.fallback ? cv.rowFallback : i % 2 === 0 ? cv.rowEven : cv.rowOdd; const stockStyle = r.stock <= 0 ? cv.stockZero : r.stock <= 5 ? cv.stockLow : cv.stockHigh; return ( ); })}
toggleSort('nombre')}> Producto {sortCol==='nombre'?(sortDir>0?'▲':'▼'):'⇅'} Código toggleSort('proveedor')}> Proveedor {sortCol==='proveedor'?(sortDir>0?'▲':'▼'):'⇅'} toggleSort('stock')}> Stock {sortCol==='stock'?(sortDir>0?'▲':'▼'):'⇅'} toggleSort('sales30d')}> Ventas 30d {sortCol==='sales30d'?(sortDir>0?'▲':'▼'):'⇅'} toggleSort('coverageDays')}> Cobertura {sortCol==='coverageDays'?(sortDir>0?'▲':'▼'):'⇅'} toggleSort('abc')}> ABC {sortCol==='abc'?(sortDir>0?'▲':'▼'):'⇅'}
Cargando…
Sin resultados.
{r.nombre} {r.fallback && GENERAL} {r.barcode || '—'} {r.proveedor || '—'} {r.stock} u 0 ? '#a78bfa' : 'var(--text-tertiary)', fontWeight: r.sales30d > 0 ? 700 : 400}}>{r.sales30d} u {r.sales30d === 0 ? Sin ventas : r.cobertura ? {r.coverageDays}d : S/D} {abc ? {abc} : }
{totalPages > 1 && (
page > 0 && setPage(0)}>« page > 0 && setPage(p => p - 1)}>‹ Ant {page * PAGE_SIZE + 1}–{Math.min((page + 1) * PAGE_SIZE, filtered.length)} de {filtered.length} = totalPages - 1)} onClick={() => page < totalPages - 1 && setPage(p => p + 1)}>Sig › = totalPages - 1)} onClick={() => page < totalPages - 1 && setPage(totalPages - 1)}>» | {Array.from({length: totalPages}, (_, i) => ( setPage(i)}>{i + 1} ))}
)}
); }; window.AuditView = AuditView; window.UsersView = UsersView; window.ConsolidadoView = ConsolidadoView; window.SyncStatusView = SyncStatusView; window.CatalogoView = CatalogoView;