Spaces:
Running
Running
| import React, { useState, useEffect, useRef } from 'react'; | |
| import { | |
| CContainer, CRow, CCol, CCard, CCardBody, CCardHeader, | |
| CFormInput, CButton, CTable, CNavbar, CNavbarBrand, | |
| CNav, CNavItem, CForm, CBadge, CModal, CModalHeader, | |
| CModalTitle, CModalBody, CModalFooter, CFormSwitch, | |
| COffcanvas, COffcanvasHeader, COffcanvasTitle, COffcanvasBody, CCloseButton, | |
| CTooltip | |
| } from '@coreui/react'; | |
| // --- INLINE ICONS --- | |
| const IconEye = () => (<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M10.5 8a2.5 2.5 0 1 1-5 0 2.5 2.5 0 0 1 5 0z"/><path d="M0 8s3-5.5 8-5.5S16 8 16 8s-3 5.5-8 5.5S0 8 0 8zm8 3.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7z"/></svg>); | |
| const IconEdit = () => (<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M12.146.146a.5.5 0 0 1 .708 0l3 3a.5.5 0 0 1 0 .708l-10 10a.5.5 0 0 1-.168.11l-5 2a.5.5 0 0 1-.65-.65l2-5a.5.5 0 0 1 .11-.168l10-10zM11.207 2.5 13.5 4.793 14.793 3.5 12.5 1.207 11.207 2.5zm1.586 3L10.5 3.207 4 9.707V10h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.293l6.5-6.5zm-9.761 5.175-.106.106-1.528 3.821 3.821-1.528.106-.106A.5.5 0 0 1 5 12.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.468-.325z"/></svg>); | |
| const IconTrash = () => (<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16"><path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/><path fillRule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/></svg>); | |
| const IconDrag = () => (<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 16 16"><path fillRule="evenodd" d="M2.5 12a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h10a.5.5 0 0 1 0 1H3a.5.5 0 0 1-.5-.5z"/></svg>); | |
| function App() { | |
| const [userRole, setUserRole] = useState(null); | |
| const [theme, setTheme] = useState('light'); | |
| // Mobile & Sidebar State | |
| const [isMobile, setIsMobile] = useState(window.innerWidth < 768); | |
| const [sidebarVisible, setSidebarVisible] = useState(false); | |
| const [desktopSidebarVisible, setDesktopSidebarVisible] = useState(true); | |
| // System Alert Modal State | |
| const [alertModalVisible, setAlertModalVisible] = useState(false); | |
| const [alertModalTitle, setAlertModalTitle] = useState(''); | |
| const [alertModalMessage, setAlertModalMessage] = useState(''); | |
| // Login State | |
| const [username, setUsername] = useState(''); | |
| const [password, setPassword] = useState(''); | |
| // Admin User Management State | |
| const [users, setUsers] = useState([]); | |
| const [selectedAdminUserId, setSelectedAdminUserId] = useState(null); // Tracks the active user being edited | |
| const [newUsername, setNewUsername] = useState(''); | |
| const [newPassword, setNewPassword] = useState(''); | |
| const [newReportTitle, setNewReportTitle] = useState(''); | |
| const [newDesktopUrl, setNewDesktopUrl] = useState(''); | |
| const [newMobileUrl, setNewMobileUrl] = useState(''); | |
| // Derived active user for the admin dashboard panel | |
| const activeAdminUser = users.find(u => u.id === selectedAdminUserId) || null; | |
| // Admin Manage Reports State | |
| const [reportModalVisible, setReportModalVisible] = useState(false); | |
| const [reportModalMode, setReportModalMode] = useState('add'); | |
| const [activeUserForModal, setActiveUserForModal] = useState(null); | |
| const [activeReportId, setActiveReportId] = useState(null); | |
| const [modalReportTitle, setModalReportTitle] = useState(''); | |
| const [modalDesktopUrl, setModalDesktopUrl] = useState(''); | |
| const [modalMobileUrl, setModalMobileUrl] = useState(''); | |
| // Admin Reset Password State | |
| const [passwordModalVisible, setPasswordModalVisible] = useState(false); | |
| const [newPasswordInput, setNewPasswordInput] = useState(''); | |
| // Viewer State | |
| const [pages, setPages] = useState([]); | |
| const [selectedPage, setSelectedPage] = useState(null); | |
| // Force Refresh State & Ref | |
| const [refreshKey, setRefreshKey] = useState(0); | |
| const [lastRefresh, setLastRefresh] = useState(''); | |
| const lastAutoRefreshHour = useRef(null); | |
| // Drag and Drop Refs | |
| const dragItem = useRef(); | |
| const dragOverItem = useRef(); | |
| const dragUser = useRef(); | |
| useEffect(() => { | |
| document.documentElement.setAttribute('data-coreui-theme', theme); | |
| }, [theme]); | |
| useEffect(() => { | |
| const handleResize = () => setIsMobile(window.innerWidth < 768); | |
| window.addEventListener('resize', handleResize); | |
| return () => window.removeEventListener('resize', handleResize); | |
| }, []); | |
| // Ensures there is always a selected user in the admin panel if users exist | |
| useEffect(() => { | |
| if (userRole === 'admin' && users.length > 0) { | |
| if (!selectedAdminUserId || !users.find(u => u.id === selectedAdminUserId)) { | |
| setSelectedAdminUserId(users[0].id); | |
| } | |
| } | |
| }, [users, userRole, selectedAdminUserId]); | |
| useEffect(() => { | |
| if (selectedPage && userRole === 'user') { | |
| setLastRefresh(new Date().toLocaleTimeString()); | |
| setRefreshKey(prev => prev + 1); | |
| } | |
| }, [selectedPage, userRole]); | |
| useEffect(() => { | |
| const checkTimeInterval = setInterval(() => { | |
| if (!selectedPage || userRole !== 'user') return; | |
| const now = new Date(); | |
| const currentHour = now.getHours(); | |
| const currentMinute = now.getMinutes(); | |
| if (currentHour >= 7 && currentHour <= 15 && currentMinute === 5) { | |
| if (lastAutoRefreshHour.current !== currentHour) { | |
| setRefreshKey(prev => prev + 1); | |
| setLastRefresh(`Auto-update: ${now.toLocaleTimeString()}`); | |
| lastAutoRefreshHour.current = currentHour; | |
| } | |
| } | |
| }, 30000); | |
| return () => clearInterval(checkTimeInterval); | |
| }, [selectedPage, userRole]); | |
| const showSystemAlert = (title, message) => { | |
| setAlertModalTitle(title); | |
| setAlertModalMessage(message); | |
| setAlertModalVisible(true); | |
| }; | |
| useEffect(() => { | |
| let sessionTimer; | |
| if (userRole) { | |
| const EIGHT_HOURS = 8 * 60 * 60 * 1000; | |
| sessionTimer = setTimeout(() => { | |
| handleLogout(); | |
| showSystemAlert("Session Expired", "Your 8-hour session has automatically ended. Please log in again to continue viewing reports."); | |
| }, EIGHT_HOURS); | |
| } | |
| return () => clearTimeout(sessionTimer); | |
| }, [userRole]); | |
| const toggleTheme = () => setTheme(theme === 'light' ? 'dark' : 'light'); | |
| const handleManualRefresh = () => { | |
| setRefreshKey(prev => prev + 1); | |
| setLastRefresh(new Date().toLocaleTimeString()); | |
| }; | |
| const handleLogin = async (e) => { | |
| e.preventDefault(); | |
| const res = await fetch('/api/login', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ username, password }) | |
| }); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setUserRole(data.role); | |
| if (data.role === 'admin') fetchAdminUsers(); | |
| if (data.role === 'user') loadUserPages(); | |
| } else { | |
| showSystemAlert("Login Failed", "Invalid username or password. Please check your credentials and try again."); | |
| } | |
| }; | |
| const handleLogout = async () => { | |
| await fetch('/api/logout', { method: 'POST' }); | |
| setUserRole(null); | |
| setPages([]); | |
| setSelectedPage(null); | |
| setSelectedAdminUserId(null); | |
| }; | |
| const fetchAdminUsers = async () => { | |
| const res = await fetch('/api/admin/users'); | |
| if (res.status === 401 || res.status === 403) return handleLogout(); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setUsers(data); | |
| } | |
| }; | |
| const handleAddUser = async (e) => { | |
| e.preventDefault(); | |
| const initialPages = []; | |
| if (newReportTitle && newDesktopUrl) { | |
| initialPages.push({ | |
| id: 'rep_' + Date.now(), | |
| title: newReportTitle, | |
| desktopUrl: newDesktopUrl, | |
| mobileUrl: newMobileUrl | |
| }); | |
| } | |
| const newUser = { | |
| username: newUsername, | |
| password: newPassword, | |
| pages: initialPages | |
| }; | |
| const res = await fetch('/api/admin/users', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(newUser) | |
| }); | |
| if (res.status === 401 || res.status === 403) return handleLogout(); | |
| if (res.ok) { | |
| const createdUser = await res.json(); | |
| fetchAdminUsers(); | |
| setSelectedAdminUserId(createdUser.id); // Auto-select the newly created user | |
| setNewUsername(''); | |
| setNewPassword(''); | |
| setNewReportTitle(''); | |
| setNewDesktopUrl(''); | |
| setNewMobileUrl(''); | |
| } | |
| }; | |
| const handleDeleteUser = async (id) => { | |
| const res = await fetch(`/api/admin/users/${id}`, { method: 'DELETE' }); | |
| if (res.status === 401 || res.status === 403) return handleLogout(); | |
| fetchAdminUsers(); // This will auto-select the next available user via the useEffect | |
| }; | |
| const openReportModal = (mode, user, report = null) => { | |
| setReportModalMode(mode); | |
| setActiveUserForModal(user); | |
| if (mode === 'edit' && report) { | |
| setActiveReportId(report.id); | |
| setModalReportTitle(report.title); | |
| setModalDesktopUrl(report.desktopUrl || report.url || ''); | |
| setModalMobileUrl(report.mobileUrl || ''); | |
| } else { | |
| setActiveReportId(null); | |
| setModalReportTitle(''); | |
| setModalDesktopUrl(''); | |
| setModalMobileUrl(''); | |
| } | |
| setReportModalVisible(true); | |
| }; | |
| const handleSaveReport = async () => { | |
| try { | |
| const currentUser = users.find(u => u.id === activeUserForModal.id); | |
| if (!currentUser) return showSystemAlert("Error", "User record not found in active state."); | |
| let updatedPages = [...currentUser.pages]; | |
| if (reportModalMode === 'add') { | |
| updatedPages.push({ | |
| id: 'rep_' + Date.now(), | |
| title: modalReportTitle, | |
| desktopUrl: modalDesktopUrl, | |
| mobileUrl: modalMobileUrl | |
| }); | |
| } else if (reportModalMode === 'edit') { | |
| updatedPages = updatedPages.map(p => | |
| p.id === activeReportId ? { ...p, title: modalReportTitle, desktopUrl: modalDesktopUrl, mobileUrl: modalMobileUrl } : p | |
| ); | |
| } | |
| const res = await fetch(`/api/admin/users/${currentUser.id}/pages`, { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ pages: updatedPages }) | |
| }); | |
| if (res.status === 401 || res.status === 403) return handleLogout(); | |
| if (res.ok) { | |
| setReportModalVisible(false); | |
| fetchAdminUsers(); | |
| } else { | |
| showSystemAlert("Save Failed", "The server failed to save the report updates."); | |
| } | |
| } catch (error) { | |
| showSystemAlert("Error", "An unexpected error occurred during save."); | |
| } | |
| }; | |
| const handleDeleteReport = async (user, reportId) => { | |
| const updatedPages = user.pages.filter(p => p.id !== reportId); | |
| const res = await fetch(`/api/admin/users/${user.id}/pages`, { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ pages: updatedPages }) | |
| }); | |
| if (res.status === 401 || res.status === 403) return handleLogout(); | |
| fetchAdminUsers(); | |
| }; | |
| const openPasswordModal = (user) => { | |
| setActiveUserForModal(user); | |
| setNewPasswordInput(''); | |
| setPasswordModalVisible(true); | |
| }; | |
| const handleSavePassword = async () => { | |
| const res = await fetch(`/api/admin/users/${activeUserForModal.id}/password`, { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ password: newPasswordInput }) | |
| }); | |
| if (res.status === 401 || res.status === 403) return handleLogout(); | |
| if (res.ok) { | |
| setPasswordModalVisible(false); | |
| fetchAdminUsers(); | |
| } | |
| }; | |
| const loadUserPages = async () => { | |
| const res = await fetch('/api/user/pages'); | |
| if (res.status === 401 || res.status === 403) return handleLogout(); | |
| if (res.ok) { | |
| const data = await res.json(); | |
| setPages(data); | |
| if(data.length > 0) setSelectedPage(data[0]); | |
| } | |
| }; | |
| // --- DRAG AND DROP HANDLERS --- | |
| const handleDragStart = (e, userId, index) => { | |
| dragItem.current = index; | |
| dragUser.current = userId; | |
| e.dataTransfer.effectAllowed = 'move'; | |
| setTimeout(() => e.target.style.opacity = '0.5', 0); | |
| }; | |
| const handleDragEnter = (e, userId, index) => { | |
| if (dragUser.current !== userId) return; | |
| dragOverItem.current = index; | |
| }; | |
| const handleDragEnd = async (e, userId) => { | |
| e.target.style.opacity = '1'; | |
| if (dragUser.current !== userId || dragItem.current === null || dragOverItem.current === null) return; | |
| if (dragItem.current === dragOverItem.current) return; | |
| const currentUser = users.find(u => u.id === userId); | |
| if (!currentUser) return; | |
| const newPages = [...currentUser.pages]; | |
| const draggedItemContent = newPages[dragItem.current]; | |
| newPages.splice(dragItem.current, 1); | |
| newPages.splice(dragOverItem.current, 0, draggedItemContent); | |
| // Optimistic UI Update ensures no visual lag | |
| setUsers(users.map(u => u.id === userId ? { ...u, pages: newPages } : u)); | |
| const res = await fetch(`/api/admin/users/${userId}/pages`, { | |
| method: 'PUT', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ pages: newPages }) | |
| }); | |
| if (res.status === 401 || res.status === 403) return handleLogout(); | |
| dragItem.current = null; | |
| dragOverItem.current = null; | |
| dragUser.current = null; | |
| }; | |
| // --- DYNAMIC OVERLAY STYLES --- | |
| const overlayStyleLeft = { | |
| position: 'absolute', bottom: 0, left: 0, | |
| width: '140px', | |
| height: isMobile ? '30px' : '34px', | |
| backgroundColor: '#eaeaea', zIndex: 10 | |
| }; | |
| const overlayStyleRight = { | |
| position: 'absolute', bottom: 0, right: 0, | |
| width: isMobile ? '30px' : '190px', | |
| height: isMobile ? '30px' : '34px', | |
| backgroundColor: '#eaeaea', zIndex: 10 | |
| }; | |
| const getActiveUrl = (page) => { | |
| if (!page) return ''; | |
| if (isMobile && page.mobileUrl) return page.mobileUrl; | |
| return page.desktopUrl || page.url; | |
| }; | |
| const renderAlertModal = () => ( | |
| <CModal alignment="center" visible={alertModalVisible} onClose={() => setAlertModalVisible(false)}> | |
| <CModalHeader onClose={() => setAlertModalVisible(false)}> | |
| <CModalTitle>{alertModalTitle}</CModalTitle> | |
| </CModalHeader> | |
| <CModalBody> | |
| {alertModalMessage} | |
| </CModalBody> | |
| <CModalFooter> | |
| <CButton color="primary" onClick={() => setAlertModalVisible(false)}>OK</CButton> | |
| </CModalFooter> | |
| </CModal> | |
| ); | |
| if (!userRole) { | |
| return ( | |
| <CContainer className="d-flex align-items-center min-vh-100"> | |
| {renderAlertModal()} | |
| <CRow className="justify-content-center w-100"> | |
| <CCol md={6} lg={4}> | |
| <CCard className="p-4 shadow-sm"> | |
| <CCardBody> | |
| <h3 className="text-center mb-4 d-flex justify-content-center align-items-center"> | |
| <img | |
| src="https://upload.wikimedia.org/wikipedia/commons/c/cf/New_Power_BI_Logo.svg" | |
| alt="Power BI" | |
| style={{ height: '32px', marginRight: '10px' }} | |
| /> | |
| LOGIN | |
| </h3> | |
| <CForm onSubmit={handleLogin}> | |
| <CFormInput className="mb-3" type="text" placeholder="Username" value={username} onChange={e => setUsername(e.target.value)} required /> | |
| <CFormInput className="mb-4" type="password" placeholder="Password" value={password} onChange={e => setPassword(e.target.value)} required /> | |
| <CButton color="primary" type="submit" className="w-100 mb-3">Log In</CButton> | |
| </CForm> | |
| <div className="d-flex justify-content-center mt-3"> | |
| <CFormSwitch | |
| size="lg" | |
| id="themeSwitchLogin" | |
| label="Dark Mode" | |
| checked={theme === 'dark'} | |
| onChange={toggleTheme} | |
| /> | |
| </div> | |
| </CCardBody> | |
| </CCard> | |
| </CCol> | |
| </CRow> | |
| </CContainer> | |
| ); | |
| } | |
| return ( | |
| <div className="vh-100 d-flex flex-column bg-body overflow-hidden"> | |
| {renderAlertModal()} | |
| <CNavbar colorScheme={theme} className="bg-body-tertiary px-3 shadow-sm flex-shrink-0 z-1 border-bottom"> | |
| <CNavbarBrand className="d-flex align-items-center"> | |
| {userRole === 'user' && ( | |
| <CButton | |
| variant="ghost" | |
| color="secondary" | |
| className="me-2 p-1" | |
| onClick={() => isMobile ? setSidebarVisible(true) : setDesktopSidebarVisible(!desktopSidebarVisible)} | |
| > | |
| <span className="fs-5">☰</span> | |
| </CButton> | |
| )} | |
| {userRole === 'admin' ? 'Administration Dashboard' : 'BI Viewer'} | |
| </CNavbarBrand> | |
| <CNav className="ms-auto align-items-center flex-row"> | |
| <CNavItem className="me-3 mt-1"> | |
| <CFormSwitch id="themeSwitchNav" label="Dark" checked={theme === 'dark'} onChange={toggleTheme} /> | |
| </CNavItem> | |
| <CNavItem> | |
| <CButton color="danger" size="sm" onClick={handleLogout}>Logout</CButton> | |
| </CNavItem> | |
| </CNav> | |
| </CNavbar> | |
| {/* --- ADMIN VIEW (REFACTORED TO MASTER-DETAIL LAYOUT) --- */} | |
| {userRole === 'admin' && ( | |
| <CContainer fluid className="flex-grow-1 overflow-hidden py-4 d-flex"> | |
| <CRow className="w-100 m-0"> | |
| {/* LEFT COLUMN: Create User & User List */} | |
| <CCol md={4} className="h-100 d-flex flex-column pe-md-3 p-0"> | |
| <CCard className="mb-4 flex-shrink-0"> | |
| <CCardHeader><strong>Create New User</strong></CCardHeader> | |
| <CCardBody> | |
| <CForm onSubmit={handleAddUser}> | |
| <CFormInput className="mb-2" placeholder="New Username" value={newUsername} onChange={e => setNewUsername(e.target.value)} required /> | |
| <CFormInput className="mb-2" type="text" placeholder="Password" value={newPassword} onChange={e => setNewPassword(e.target.value)} required /> | |
| <hr/> | |
| <div className="mb-2 text-muted small">Optional: Assign initial report</div> | |
| <CFormInput className="mb-2" placeholder="Report Title (e.g. Sales Q1)" value={newReportTitle} onChange={e => setNewReportTitle(e.target.value)} /> | |
| <CFormInput className="mb-2" placeholder="Desktop PowerBI URL" value={newDesktopUrl} onChange={e => setNewDesktopUrl(e.target.value)} /> | |
| <CFormInput className="mb-3" placeholder="Mobile PowerBI URL (Optional)" value={newMobileUrl} onChange={e => setNewMobileUrl(e.target.value)} /> | |
| <CButton color="success" type="submit" className="w-100">Create User</CButton> | |
| </CForm> | |
| </CCardBody> | |
| </CCard> | |
| <CCard className="flex-grow-1 overflow-hidden d-flex flex-column mb-3"> | |
| <CCardHeader><strong>Managed Users</strong></CCardHeader> | |
| <CCardBody className="p-2 overflow-auto h-100"> | |
| {users.length === 0 && <p className="text-muted small p-2 text-center">No users created yet.</p>} | |
| {users.map(u => ( | |
| <CButton | |
| key={u.id} | |
| color={selectedAdminUserId === u.id ? 'primary' : 'light'} | |
| className="w-100 mb-2 text-start d-flex justify-content-between align-items-center" | |
| onClick={() => setSelectedAdminUserId(u.id)} | |
| > | |
| <span className="text-truncate me-2">{u.username}</span> | |
| <CBadge color={selectedAdminUserId === u.id ? 'light' : 'secondary'} textColor={selectedAdminUserId === u.id ? 'primary' : 'light'}> | |
| {u.pages.length} {u.pages.length === 1 ? 'Report' : 'Reports'} | |
| </CBadge> | |
| </CButton> | |
| ))} | |
| </CCardBody> | |
| </CCard> | |
| </CCol> | |
| {/* RIGHT COLUMN: Active User Details & Previews */} | |
| <CCol md={8} className="h-100 overflow-auto p-0 pb-4"> | |
| {activeAdminUser ? ( | |
| <CCard className="mb-4"> | |
| <CCardHeader className="d-flex justify-content-between align-items-center"> | |
| <h5 className="m-0">User: <strong>{activeAdminUser.username}</strong></h5> | |
| <div> | |
| <CButton size="sm" color="warning" className="me-2" onClick={() => openPasswordModal(activeAdminUser)}>Reset Pw</CButton> | |
| <CButton size="sm" color="danger" onClick={() => handleDeleteUser(activeAdminUser.id)}>Delete User</CButton> | |
| </div> | |
| </CCardHeader> | |
| <CCardBody> | |
| <div className="mb-4 text-muted small">Current Password: <code>{activeAdminUser.password}</code></div> | |
| <h6 className="mb-3">Assigned Reports</h6> | |
| {activeAdminUser.pages.length === 0 && <p className="text-muted small">This user currently has no reports assigned.</p>} | |
| {activeAdminUser.pages.map((p, index) => ( | |
| <div | |
| key={p.id} | |
| className="mb-2 border rounded bg-body-tertiary d-flex" | |
| draggable | |
| onDragStart={(e) => handleDragStart(e, activeAdminUser.id, index)} | |
| onDragEnter={(e) => handleDragEnter(e, activeAdminUser.id, index)} | |
| onDragEnd={(e) => handleDragEnd(e, activeAdminUser.id)} | |
| onDragOver={(e) => e.preventDefault()} | |
| style={{ cursor: 'grab', transition: 'opacity 0.2s' }} | |
| > | |
| <div className="d-flex align-items-center justify-content-center px-2 text-muted border-end" title="Drag to reorder"> | |
| <IconDrag /> | |
| </div> | |
| <div className="p-2 flex-grow-1 overflow-hidden"> | |
| <div className="d-flex justify-content-between align-items-center mb-2"> | |
| <CBadge color="info">{p.title}</CBadge> | |
| <div> | |
| <CTooltip content="Edit Report Links"> | |
| <CButton size="sm" color="warning" variant="ghost" className="p-1 me-1" onClick={() => openReportModal('edit', activeAdminUser, p)}> | |
| <IconEdit /> | |
| </CButton> | |
| </CTooltip> | |
| <CTooltip content="Delete Entire Report"> | |
| <CButton size="sm" color="danger" variant="ghost" className="p-1" onClick={() => handleDeleteReport(activeAdminUser, p.id)}> | |
| <IconTrash /> | |
| </CButton> | |
| </CTooltip> | |
| </div> | |
| </div> | |
| <div className="small text-muted mb-1 d-flex align-items-center"> | |
| <strong className="me-2" style={{width: '60px'}}>Desktop:</strong> | |
| <span className="text-truncate flex-grow-1">{p.desktopUrl || p.url || 'None'}</span> | |
| {(p.desktopUrl || p.url) && ( | |
| <CTooltip content="Preview Desktop View"> | |
| <CButton size="sm" color="primary" variant="ghost" className="p-0 ms-2 flex-shrink-0" onClick={() => setSelectedPage({ ...p, previewMode: 'desktop' })}> | |
| <IconEye /> | |
| </CButton> | |
| </CTooltip> | |
| )} | |
| </div> | |
| <div className="small text-muted d-flex align-items-center"> | |
| <strong className="me-2" style={{width: '60px'}}>Mobile:</strong> | |
| <span className="text-truncate flex-grow-1">{p.mobileUrl || 'None'}</span> | |
| {p.mobileUrl && ( | |
| <CTooltip content="Preview Mobile View"> | |
| <CButton size="sm" color="primary" variant="ghost" className="p-0 ms-2 flex-shrink-0" onClick={() => setSelectedPage({ ...p, previewMode: 'mobile' })}> | |
| <IconEye /> | |
| </CButton> | |
| </CTooltip> | |
| )} | |
| </div> | |
| </div> | |
| </div> | |
| ))} | |
| <CButton size="sm" color="success" variant="outline" className="mt-2 w-100" onClick={() => openReportModal('add', activeAdminUser)}> | |
| + Add New Report | |
| </CButton> | |
| </CCardBody> | |
| </CCard> | |
| ) : ( | |
| <CCard className="mb-4"> | |
| <CCardBody className="text-center text-muted p-5"> | |
| Select a user from the left panel to manage their reports and settings. | |
| </CCardBody> | |
| </CCard> | |
| )} | |
| {selectedPage && ( | |
| <CCard className="mt-4"> | |
| <CCardHeader className="d-flex justify-content-between align-items-center"> | |
| <strong>Preview: {selectedPage.title} ({selectedPage.previewMode === 'mobile' ? 'Mobile View' : 'Desktop View'})</strong> | |
| <CButton color="secondary" size="sm" onClick={() => setSelectedPage(null)}>Close</CButton> | |
| </CCardHeader> | |
| <CCardBody className="p-0 d-flex justify-content-center bg-dark"> | |
| <div | |
| className="position-relative bg-body" | |
| style={{ | |
| width: selectedPage.previewMode === 'mobile' ? '375px' : '100%', | |
| height: '600px', | |
| boxShadow: selectedPage.previewMode === 'mobile' ? '0 0 20px rgba(0,0,0,0.5)' : 'none', | |
| transition: 'width 0.3s ease-in-out' | |
| }} | |
| > | |
| <iframe | |
| src={selectedPage.previewMode === 'mobile' ? selectedPage.mobileUrl : (selectedPage.desktopUrl || selectedPage.url)} | |
| style={{ width: '100%', height: '100%', border: 'none' }} | |
| title="Preview" | |
| allowFullScreen> | |
| </iframe> | |
| <div style={{ ...overlayStyleLeft, height: selectedPage.previewMode === 'mobile' ? '30px' : '34px' }}></div> | |
| <div style={{ | |
| ...overlayStyleRight, | |
| width: selectedPage.previewMode === 'mobile' ? '30px' : '190px', | |
| height: selectedPage.previewMode === 'mobile' ? '30px' : '34px' | |
| }}></div> | |
| </div> | |
| </CCardBody> | |
| </CCard> | |
| )} | |
| </CCol> | |
| </CRow> | |
| <CModal visible={reportModalVisible} onClose={() => setReportModalVisible(false)}> | |
| <CModalHeader> | |
| <CModalTitle>{reportModalMode === 'add' ? `Add Report for ${activeAdminUser?.username}` : `Edit Report`}</CModalTitle> | |
| </CModalHeader> | |
| <CModalBody> | |
| <CFormInput className="mb-3" label="Report Title" value={modalReportTitle} onChange={e => setModalReportTitle(e.target.value)} /> | |
| <CFormInput className="mb-3" label="Desktop PowerBI URL" value={modalDesktopUrl} onChange={e => setModalDesktopUrl(e.target.value)} /> | |
| <CFormInput label="Mobile PowerBI URL (Optional)" value={modalMobileUrl} onChange={e => setModalMobileUrl(e.target.value)} /> | |
| </CModalBody> | |
| <CModalFooter> | |
| <CButton color="secondary" onClick={() => setReportModalVisible(false)}>Cancel</CButton> | |
| <CButton color="primary" onClick={handleSaveReport}>Save</CButton> | |
| </CModalFooter> | |
| </CModal> | |
| <CModal visible={passwordModalVisible} onClose={() => setPasswordModalVisible(false)}> | |
| <CModalHeader> | |
| <CModalTitle>Reset Password for {activeUserForModal?.username}</CModalTitle> | |
| </CModalHeader> | |
| <CModalBody> | |
| <CFormInput type="text" label="New Password" value={newPasswordInput} onChange={e => setNewPasswordInput(e.target.value)} required /> | |
| </CModalBody> | |
| <CModalFooter> | |
| <CButton color="secondary" onClick={() => setPasswordModalVisible(false)}>Cancel</CButton> | |
| <CButton color="primary" onClick={handleSavePassword}>Save Password</CButton> | |
| </CModalFooter> | |
| </CModal> | |
| </CContainer> | |
| )} | |
| {/* --- USER VIEW --- */} | |
| {userRole === 'user' && ( | |
| <div className="d-flex flex-grow-1 overflow-hidden"> | |
| {desktopSidebarVisible && ( | |
| <div className="d-none d-md-flex flex-column border-end bg-body-tertiary" style={{ width: '250px' }}> | |
| <div className="p-3 overflow-auto flex-grow-1"> | |
| {pages.length === 0 && <p className="text-muted small">No reports assigned.</p>} | |
| {pages.map(page => ( | |
| <CButton | |
| key={page.id} | |
| color={selectedPage?.id === page.id ? 'primary' : 'light'} | |
| className="w-100 mb-2 text-start text-truncate" | |
| onClick={() => setSelectedPage(page)} | |
| > | |
| {page.title} | |
| </CButton> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| <COffcanvas placement="start" visible={sidebarVisible} onHide={() => setSidebarVisible(false)}> | |
| <COffcanvasHeader> | |
| <COffcanvasTitle>Select Report</COffcanvasTitle> | |
| <CCloseButton className="text-reset" onClick={() => setSidebarVisible(false)} /> | |
| </COffcanvasHeader> | |
| <COffcanvasBody> | |
| {pages.map(page => ( | |
| <CButton | |
| key={page.id} | |
| color={selectedPage?.id === page.id ? 'primary' : 'light'} | |
| className="w-100 mb-2 text-start" | |
| onClick={() => { setSelectedPage(page); setSidebarVisible(false); }} | |
| > | |
| {page.title} | |
| </CButton> | |
| ))} | |
| </COffcanvasBody> | |
| </COffcanvas> | |
| <div className="flex-grow-1 d-flex flex-column position-relative"> | |
| {selectedPage ? ( | |
| <> | |
| <div className="bg-body px-3 py-2 d-flex justify-content-between align-items-center border-bottom shadow-sm z-1"> | |
| <h5 className="m-0 text-truncate">{selectedPage.title}</h5> | |
| <div className="d-flex align-items-center text-muted small"> | |
| <span className="d-none d-sm-inline me-2">Last reload: {lastRefresh}</span> | |
| <CButton size="sm" color="info" variant="outline" onClick={handleManualRefresh}> | |
| ↻ Refresh | |
| </CButton> | |
| </div> | |
| </div> | |
| <div className="position-relative w-100 flex-grow-1 bg-body"> | |
| <iframe | |
| key={refreshKey} | |
| src={getActiveUrl(selectedPage)} | |
| style={{ width: '100%', height: '100%', border: 'none', display: 'block' }} | |
| title="Report Viewer" | |
| allowFullScreen | |
| ></iframe> | |
| <div style={overlayStyleLeft}></div> | |
| <div style={overlayStyleRight}></div> | |
| </div> | |
| </> | |
| ) : ( | |
| <div className="p-4 text-center text-muted flex-grow-1 d-flex justify-content-center align-items-center"> | |
| Select a report from the menu to begin viewing. | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| export default App; |