From da107468615170d77bf7bb2b304096caea01bd86 Mon Sep 17 00:00:00 2001 From: Obssa Degefu Date: Mon, 9 Feb 2026 16:46:56 +0300 Subject: [PATCH] Frontend cleanup and Backend improvement (#51) * feat: reorganized the file and folder structure of the frontend * fix: corrected the icon-container( landing page) and icon-links(Footer) style * fix: corrected the accept terms checkbox in the signup page * fix: corrected the displayed error message * refactor: standardized the backend response to be wrapped in data or error * feat: reduced the provider nesting and added dynamic title * feat: made the cookie propertie "sameSite" dependent on env variable * feat: added Guards to admin routes * fix: corrected css property from a non existing "ring" to "outline" * feat: create reducer for news and instead of fetching continously use a context that will update every 5 min * fix: cleaned up the state and separated the hooks and the context and ensured that there is only one global state * fix: made the navigation 1 logo title equal to navigation 2 font weight and size * fix: corrected the code to ensure that user is not authenticated when refresh token expires * refactor: changed the notification page background color and changed the flex direction of setting from row to column * refactor: removed unnecessary wrapping divs and also removed useless import * fix: corrected the officer stats incorrect response rate and replaced calculating average of average by using total value * fix: corrected the officer count in admin manage officers page * feat: implemented filter and positioning in the report excel * feat: migrated from a single materialized view to 3 materialized view to reduce computation when fetching data * feat: implemented a migration_analytics to migrate from a single MV to 3 MV * refactor: removed the migration code after using it * feat: corrected the frontend to include remember me option and also corrected the refresh token storage from the backend * fix: corrected the data mapping and added fallback for excel --------- Co-authored-by: Obssa D --- client/index.html | 4 +- client/public/vite.svg | 1 - client/src/App.jsx | 85 ++- client/src/components/AdminSideBar.jsx | 2 +- client/src/components/Navigation2.jsx | 14 +- client/src/components/NewsSlider.jsx | 27 +- client/src/components/OfficerSideBar.jsx | 2 +- client/src/components/Sidebar1.jsx | 2 +- .../NotificationBell.jsx | 6 +- client/src/components/common/PaymentModal.jsx | 2 +- .../components/layout/AuthenticatedLayout.jsx | 2 +- .../{auth => context}/ApplicationContext.jsx | 25 +- client/src/{auth => context}/AuthContext.jsx | 16 +- client/src/{auth => context}/ChatContext.jsx | 13 +- client/src/context/NewsContext.jsx | 57 ++ .../NotificationsContext.jsx | 14 +- .../src/{auth => context}/PaymentContext.jsx | 23 +- .../ProfileAssetsContext.jsx | 21 +- client/src/{auth => }/guards/AuthGuard.jsx | 12 +- client/src/{auth => }/guards/IDGuard.jsx | 6 +- .../src/{auth => }/guards/PermissionGuard.jsx | 18 +- client/src/{auth => }/guards/RoleGuard.jsx | 4 +- client/src/hooks/useApplication.js | 16 + client/src/hooks/useApplicationState.js | 240 ------- client/src/hooks/useAuth.js | 16 + client/src/hooks/useChat.js | 16 + client/src/hooks/useChatState.js | 169 ----- client/src/hooks/useNews.js | 16 + client/src/hooks/useNotifications.js | 16 + client/src/hooks/usePayment.js | 16 + client/src/hooks/usePaymentState.js | 251 ------- client/src/hooks/usePermissions.js | 6 +- client/src/hooks/useProfileAssets.js | 226 +------ client/src/pages/admin/AdminDashboard.jsx | 4 +- client/src/pages/admin/AdminSettings.jsx | 2 +- client/src/pages/admin/ManageOfficers.jsx | 13 +- .../src/pages/admin/PerformanceMonitoring.jsx | 27 +- client/src/pages/common/Contact.jsx | 4 +- client/src/pages/common/Landing.jsx | 2 +- client/src/pages/common/Login.jsx | 532 ++++++++------- client/src/pages/common/Notifications.jsx | 240 +++---- client/src/pages/common/OAuthCallback.jsx | 4 +- client/src/pages/officer/MessageCenter.jsx | 2 +- client/src/pages/officer/OfficerDashboard.jsx | 18 +- client/src/pages/officer/OfficerSettings.jsx | 6 +- client/src/pages/user/BirthForm.jsx | 6 +- client/src/pages/user/CitizenMessages.jsx | 4 +- client/src/pages/user/MarriageForm.jsx | 6 +- client/src/pages/user/PaymentResult.jsx | 2 +- client/src/pages/user/Settings.jsx | 8 +- client/src/pages/user/TIN.jsx | 4 +- client/src/pages/user/Tracking.jsx | 6 +- client/src/pages/user/UserDashboard.jsx | 6 +- client/src/reducers/authReducer.js | 2 + client/src/routes/AdminRoutes.jsx | 16 +- client/src/routes/CommonRoutes.jsx | 10 - client/src/routes/OfficerRoutes.jsx | 7 +- client/src/routes/UserRoutes.jsx | 8 +- client/src/styles/admin/ManageOfficers.css | 2 +- client/src/styles/common/Landing.css | 5 + client/src/styles/common/Login.css | 537 ++++++++------- client/src/styles/common/Notifications.css | 2 +- client/src/styles/components/Footer.css | 640 +++++++++--------- client/src/styles/components/Navigation1.css | 4 +- .../components}/NotificationBell.css | 0 client/src/styles/officer/OfficerSettings.css | 1 + client/src/utils/api.js | 38 +- server/package.json | 2 +- server/src/controllers/adminController.js | 43 +- server/src/controllers/analyticsController.js | 10 +- server/src/controllers/authController.js | 64 +- server/src/middleware/authMiddleware.js | 27 +- server/src/models/views/GlobalMaxScore.js | 17 + server/src/models/views/OfficerStats.js | 59 -- .../models/views/OfficerStatsCumulative.js | 72 ++ .../src/models/views/OfficerStatsMonthly.js | 77 +++ .../officer_analytics/excelService.js | 231 ++++--- .../officer_analytics/officerStatsService.js | 117 +++- .../officer_analytics/performanceService.js | 354 ++++------ server/tests/admin.test.js | 26 +- 80 files changed, 2098 insertions(+), 2513 deletions(-) delete mode 100644 client/public/vite.svg rename client/src/components/{notifications => common}/NotificationBell.jsx (96%) rename client/src/{auth => context}/ApplicationContext.jsx (81%) rename client/src/{auth => context}/AuthContext.jsx (93%) rename client/src/{auth => context}/ChatContext.jsx (94%) create mode 100644 client/src/context/NewsContext.jsx rename client/src/{auth => context}/NotificationsContext.jsx (93%) rename client/src/{auth => context}/PaymentContext.jsx (81%) rename client/src/{auth => context}/ProfileAssetsContext.jsx (86%) rename client/src/{auth => }/guards/AuthGuard.jsx (77%) rename client/src/{auth => }/guards/IDGuard.jsx (93%) rename client/src/{auth => }/guards/PermissionGuard.jsx (80%) rename client/src/{auth => }/guards/RoleGuard.jsx (92%) create mode 100644 client/src/hooks/useApplication.js delete mode 100644 client/src/hooks/useApplicationState.js create mode 100644 client/src/hooks/useAuth.js create mode 100644 client/src/hooks/useChat.js delete mode 100644 client/src/hooks/useChatState.js create mode 100644 client/src/hooks/useNews.js create mode 100644 client/src/hooks/useNotifications.js create mode 100644 client/src/hooks/usePayment.js delete mode 100644 client/src/hooks/usePaymentState.js rename client/src/{components/notifications => styles/components}/NotificationBell.css (100%) create mode 100644 server/src/models/views/GlobalMaxScore.js delete mode 100644 server/src/models/views/OfficerStats.js create mode 100644 server/src/models/views/OfficerStatsCumulative.js create mode 100644 server/src/models/views/OfficerStatsMonthly.js diff --git a/client/index.html b/client/index.html index f07b3d0..4fe361c 100644 --- a/client/index.html +++ b/client/index.html @@ -2,9 +2,9 @@ - + - client + CiviLink
diff --git a/client/public/vite.svg b/client/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/client/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/client/src/App.jsx b/client/src/App.jsx index 28ed515..1734e66 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,38 +1,83 @@ import { BrowserRouter } from "react-router-dom" import '@fortawesome/fontawesome-free/css/all.min.css'; import './App.css' -import { AuthProvider } from "./auth/AuthContext.jsx"; -import { NotificationsProvider } from "./auth/NotificationsContext.jsx"; -import { ApplicationProvider } from "./auth/ApplicationContext.jsx"; -import { ChatProvider } from "./auth/ChatContext.jsx"; +import { AuthProvider } from "./context/AuthContext.jsx"; +import { NotificationsProvider } from "./context/NotificationsContext.jsx"; +import { ApplicationProvider } from "./context/ApplicationContext.jsx"; +import { ChatProvider } from "./context/ChatContext.jsx"; +import { NewsProvider } from './context/NewsContext.jsx'; import CommonRoutes from "./routes/CommonRoutes"; import UserRoutes from "./routes/UserRoutes"; import OfficerRoutes from "./routes/OfficerRoutes"; import AdminRoutes from "./routes/AdminRoutes"; -import { ProfileAssetsProvider } from './auth/ProfileAssetsContext.jsx'; -import { PaymentProvider } from './auth/PaymentContext.jsx'; +import { ProfileAssetsProvider } from './context/ProfileAssetsContext.jsx'; +import { PaymentProvider } from './context/PaymentContext.jsx'; + +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; + +const titles = { + "/": "Home | CiviLink", + "/login": "Login | CiviLink", + "/about": "About Us | CiviLink", + "/help": "Help Center | CiviLink", + "/contact": "Contact Us | CiviLink", + "/notifications": "Notifications | CiviLink", + "/user/dashboard": "Dashboard | CiviLink", + "/user/applications": "My Applications | CiviLink", + "/user/messages": "Messages | CiviLink", + "/user/settings": "Settings | CiviLink", + "/user/marriage-form": "Marriage Certificate | CiviLink", + "/user/birth-form": "Birth Certificate | CiviLink", + "/user/tin-form": "TIN Application | CiviLink", + "/officer/dashboard": "Officer Dashboard | CiviLink", + "/officer/applications": "Review Requests | CiviLink", + "/officer/messages": "Communications | CiviLink", + "/officer/settings": "Officer Settings | CiviLink", + "/officer/news": "News Management | CiviLink", + "/admin/dashboard": "Admin Dashboard | CiviLink", + "/admin/manage-officers": "Manage Staff | CiviLink", + "/admin/performance": "Performance | CiviLink", + "/admin/security-report": "Security Audit | CiviLink", + "/admin/settings": "Admin Settings | CiviLink", +}; + +function TitleManager() { + const location = useLocation(); + + useEffect(() => { + const currentTitle = titles[location.pathname] || "CiviLink"; + document.title = currentTitle; + }, [location]); + + return null; // This component doesn't render anything +} + function App() { return ( + - - - - -
- - - - -
-
-
-
-
+ + + +
+ + + + + + + + +
+
+
+
diff --git a/client/src/components/AdminSideBar.jsx b/client/src/components/AdminSideBar.jsx index 995c329..918e558 100644 --- a/client/src/components/AdminSideBar.jsx +++ b/client/src/components/AdminSideBar.jsx @@ -1,5 +1,5 @@ import '../styles/components/AdminSideBar.css'; -import { useAuth } from '../auth/AuthContext'; +import { useAuth } from '../hooks/useAuth'; import { NavLink, useNavigate } from 'react-router-dom'; import { useState } from 'react'; import LogoutModal from './common/LogoutModal'; diff --git a/client/src/components/Navigation2.jsx b/client/src/components/Navigation2.jsx index 19d2673..48d04dd 100644 --- a/client/src/components/Navigation2.jsx +++ b/client/src/components/Navigation2.jsx @@ -8,13 +8,13 @@ import Logo from '../assets/logo.png'; import { Link, useNavigate } from 'react-router-dom'; -import { useAuth } from '../auth/AuthContext.jsx'; -import { NotificationBell } from './notifications/NotificationBell.jsx'; +import { useAuth } from '../hooks/useAuth'; +import { NotificationBell } from './common/NotificationBell.jsx'; import LogoutModal from './common/LogoutModal.jsx'; import { useState } from 'react'; import '../styles/components/Navigation2.css'; -function Navigation2(){ +function Navigation2() { const { user, logout, role } = useAuth(); const navigate = useNavigate(); const [showLogoutModal, setShowLogoutModal] = useState(false); @@ -62,7 +62,7 @@ function Navigation2(){ CiviLink Logo CiviLink - +
{/* Notification Bell */} @@ -75,9 +75,9 @@ function Navigation2(){
{user.fullName}
- {role === 'admin' ? 'Administrator' : - role === 'officer' ? `Officer - ${user.department || 'N/A'}` : - `Citizen`} + {role === 'admin' ? 'Administrator' : + role === 'officer' ? `Officer - ${user.department || 'N/A'}` : + `Citizen`}
diff --git a/client/src/components/NewsSlider.jsx b/client/src/components/NewsSlider.jsx index 26a13a8..4e9587d 100644 --- a/client/src/components/NewsSlider.jsx +++ b/client/src/components/NewsSlider.jsx @@ -1,27 +1,17 @@ import React, { useState, useEffect } from 'react'; -import * as newsAPI from '../api/news.api'; +import { useNews } from '../hooks/useNews'; import '../styles/components/NewsSlider.css'; const NewsSlider = () => { - const [news, setNews] = useState([]); + const { news, loading, fetchNews, hasInitialData } = useNews(); const [currentIndex, setCurrentIndex] = useState(0); - const [loading, setLoading] = useState(true); useEffect(() => { - const fetchNews = async () => { - try { - const response = await newsAPI.getLatestNews(); - if (response.success) { - setNews(response.data || []); - } - } catch (err) { - console.error('Failed to fetch news:', err); - } finally { - setLoading(false); - } - }; - fetchNews(); - }, []); + // Only fetch if we don't have data yet (initial load) + if (!hasInitialData) { + fetchNews(); + } + }, [hasInitialData, fetchNews]); useEffect(() => { if (news.length > 1) { @@ -32,7 +22,8 @@ const NewsSlider = () => { } }, [news]); - if (loading) return
Loading latest news...
; + // Only show loading state if it's the VERY FIRST fetch + if (loading && !hasInitialData) return
Loading latest news...
; if (news.length === 0) return null; return ( diff --git a/client/src/components/OfficerSideBar.jsx b/client/src/components/OfficerSideBar.jsx index 1279fcb..b67c2fd 100644 --- a/client/src/components/OfficerSideBar.jsx +++ b/client/src/components/OfficerSideBar.jsx @@ -12,7 +12,7 @@ import '../styles/components/OfficerSideBar.css'; import { NavLink, useNavigate } from 'react-router-dom'; -import { useAuth } from '../auth/AuthContext.jsx'; +import { useAuth } from '../hooks/useAuth'; import { usePermissions } from '../hooks/usePermissions.js'; import LogoutModal from './common/LogoutModal.jsx'; import { useMemo, useState } from 'react'; diff --git a/client/src/components/Sidebar1.jsx b/client/src/components/Sidebar1.jsx index ed048db..d77bb98 100644 --- a/client/src/components/Sidebar1.jsx +++ b/client/src/components/Sidebar1.jsx @@ -7,7 +7,7 @@ import '../styles/components/Sidebar1.css'; import { NavLink, useNavigate } from 'react-router-dom'; -import { useAuth } from '../auth/AuthContext.jsx'; +import { useAuth } from '../hooks/useAuth'; import LogoutModal from './common/LogoutModal.jsx'; import { useMemo, useState } from 'react'; diff --git a/client/src/components/notifications/NotificationBell.jsx b/client/src/components/common/NotificationBell.jsx similarity index 96% rename from client/src/components/notifications/NotificationBell.jsx rename to client/src/components/common/NotificationBell.jsx index fb759fc..779a57e 100644 --- a/client/src/components/notifications/NotificationBell.jsx +++ b/client/src/components/common/NotificationBell.jsx @@ -13,9 +13,9 @@ import React, { useState, useRef, useEffect } from 'react'; import { Link } from 'react-router-dom'; -import { useNotifications } from '../../auth/NotificationsContext.jsx'; -import { useAuth } from '../../auth/AuthContext.jsx'; -import './NotificationBell.css'; +import { useNotifications } from '../../hooks/useNotifications'; +import { useAuth } from '../../hooks/useAuth'; +import '../../styles/components/NotificationBell.css'; export const NotificationBell = () => { const { notifications, unreadCount, markAsRead, isLoading } = useNotifications(); diff --git a/client/src/components/common/PaymentModal.jsx b/client/src/components/common/PaymentModal.jsx index ccbf724..52ce0fc 100644 --- a/client/src/components/common/PaymentModal.jsx +++ b/client/src/components/common/PaymentModal.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { usePayment } from '../../auth/PaymentContext.jsx'; +import { usePayment } from '../../hooks/usePayment'; import '../../styles/components/PaymentModal.css'; const PaymentModal = ({ isOpen, onClose, application }) => { diff --git a/client/src/components/layout/AuthenticatedLayout.jsx b/client/src/components/layout/AuthenticatedLayout.jsx index e3729d1..5f133f3 100644 --- a/client/src/components/layout/AuthenticatedLayout.jsx +++ b/client/src/components/layout/AuthenticatedLayout.jsx @@ -7,7 +7,7 @@ */ import React from 'react'; -import { useAuth } from '../../auth/AuthContext.jsx'; +import { useAuth } from '../../hooks/useAuth'; import Navigation2 from '../Navigation2.jsx'; import SideBar1 from '../Sidebar1.jsx'; import AdminSideBar from '../AdminSideBar.jsx'; diff --git a/client/src/auth/ApplicationContext.jsx b/client/src/context/ApplicationContext.jsx similarity index 81% rename from client/src/auth/ApplicationContext.jsx rename to client/src/context/ApplicationContext.jsx index 12b1f23..d485609 100644 --- a/client/src/auth/ApplicationContext.jsx +++ b/client/src/context/ApplicationContext.jsx @@ -2,15 +2,9 @@ import React, { createContext, useContext, useReducer, useCallback } from 'react import { applicationReducer, applicationActions } from '../reducers/applicationReducer.js'; import * as applicationsAPI from '../api/applications.api.js'; -const ApplicationContext = createContext(null); +export const ApplicationContext = createContext(null); -export const useApplication = () => { - const context = useContext(ApplicationContext); - if (!context) { - throw new Error('useApplication must be used within an ApplicationProvider'); - } - return context; -}; +// Hook removed and moved to src/hooks/useApplication.js export const ApplicationProvider = ({ children }) => { const [state, dispatch] = useReducer(applicationReducer, { @@ -90,11 +84,26 @@ export const ApplicationProvider = ({ children }) => { }, []); const value = { + // State applications: state.applications, selectedApplication: state.selectedApplication, isLoading: state.isLoading, isSubmitting: state.isSubmitting, error: state.error, + + // Derived Logic (Merged from useApplicationState standalone) + hasApplications: state.applications.length > 0, + pendingApplications: state.applications.filter(app => app.status === 'pending'), + approvedApplications: state.applications.filter(app => app.status === 'approved'), + rejectedApplications: state.applications.filter(app => app.status === 'rejected'), + applicationCounts: { + total: state.applications.length, + pending: state.applications.filter(app => app.status === 'pending').length, + approved: state.applications.filter(app => app.status === 'approved').length, + rejected: state.applications.filter(app => app.status === 'rejected').length, + }, + + // Actions fetchApplications, getApplicationDetails, submitApplication, diff --git a/client/src/auth/AuthContext.jsx b/client/src/context/AuthContext.jsx similarity index 93% rename from client/src/auth/AuthContext.jsx rename to client/src/context/AuthContext.jsx index c46d675..3ba1b73 100644 --- a/client/src/auth/AuthContext.jsx +++ b/client/src/context/AuthContext.jsx @@ -17,15 +17,11 @@ import * as authAPI from '../api/auth.api.js'; import * as userAPI from '../api/user.api.js'; import { registerRefreshHandler } from '../utils/api.js'; -const AuthContext = createContext(null); - -export const useAuth = () => { - const context = useContext(AuthContext); - if (!context) { - throw new Error('useAuth must be used within an AuthProvider'); - } - return context; -}; +export const AuthContext = createContext(null); + +// Ignored for now next I'll split it into hooks and controllers +// eslint-disable-next-line react-refresh/only-export-components +// Hook removed and moved to src/hooks/useAuth.js export const AuthProvider = ({ children }) => { const [state, dispatch] = useReducer(authReducer, { @@ -115,7 +111,7 @@ export const AuthProvider = ({ children }) => { dispatch({ type: authActions.REFRESH_TOKEN_SUCCESS, payload: userData }); return { success: true, data: userData }; } catch (error) { - dispatch({ type: authActions.REFRESH_TOKEN_FAILURE, payload: error.message }); + dispatch({ type: authActions.REFRESH_TOKEN_FAILURE }); // I removed the error message from payload to avoid showing token refresh errors to users in login page return { success: false, error: error.message }; } }, []); diff --git a/client/src/auth/ChatContext.jsx b/client/src/context/ChatContext.jsx similarity index 94% rename from client/src/auth/ChatContext.jsx rename to client/src/context/ChatContext.jsx index 03656c0..ff48970 100644 --- a/client/src/auth/ChatContext.jsx +++ b/client/src/context/ChatContext.jsx @@ -2,13 +2,9 @@ import React, { createContext, useContext, useReducer, useCallback } from 'react import { chatReducer, chatActions } from '../reducers/chatReducer.js'; import * as chatAPI from '../api/chat.api.js'; -const ChatContext = createContext(null); +export const ChatContext = createContext(null); -export const useChat = () => { - const context = useContext(ChatContext); - if (!context) throw new Error('useChat must be used within a ChatProvider'); - return context; -}; +// Hook removed and moved to src/hooks/useChat.js export const ChatProvider = ({ children }) => { const [state, dispatch] = useReducer(chatReducer, { @@ -140,6 +136,11 @@ export const ChatProvider = ({ children }) => { const value = { ...state, + // Derived Logic (Merged from useChatState standalone) + hasConversations: state.conversations.length > 0, + unreadConversations: state.conversations.filter(c => !c.read), + + // Actions fetchConversations, fetchCitizenConversations, fetchConversationDetails, diff --git a/client/src/context/NewsContext.jsx b/client/src/context/NewsContext.jsx new file mode 100644 index 0000000..d52229c --- /dev/null +++ b/client/src/context/NewsContext.jsx @@ -0,0 +1,57 @@ +import React, { createContext, useContext, useState, useCallback } from 'react'; +import * as newsAPI from '../api/news.api'; + +export const NewsContext = createContext(null); + +// Hook removed and moved to src/hooks/useNews.js + +export const NewsProvider = ({ children }) => { + const [news, setNews] = useState([]); + const [loading, setLoading] = useState(false); + const [lastFetched, setLastFetched] = useState(null); + const [error, setError] = useState(null); + + const CACHE_TTL = 5 * 60 * 1000; // 5 minutes + + const fetchNews = useCallback(async (force = false) => { + const now = Date.now(); + + // Return cached news if they exist and are not stale (unless forced) + if (!force && news.length > 0 && lastFetched && (now - lastFetched < CACHE_TTL)) { + return { success: true, data: news }; + } + + // Avoid multiple simultaneous fetches + if (loading) return; + + setLoading(true); + setError(null); + try { + const response = await newsAPI.getLatestNews(); + if (response.success) { + const freshNews = response.data || []; + setNews(freshNews); + setLastFetched(now); + return { success: true, data: freshNews }; + } else { + throw new Error(response.message || 'Failed to fetch news'); + } + } catch (err) { + console.error('NewsContext fetch error:', err); + setError(err.message); + return { success: false, error: err.message }; + } finally { + setLoading(false); + } + }, [news, lastFetched, loading]); + + const value = { + news, + loading, + error, + fetchNews, + hasInitialData: lastFetched !== null + }; + + return {children}; +}; diff --git a/client/src/auth/NotificationsContext.jsx b/client/src/context/NotificationsContext.jsx similarity index 93% rename from client/src/auth/NotificationsContext.jsx rename to client/src/context/NotificationsContext.jsx index 389d563..1d1dfc9 100644 --- a/client/src/auth/NotificationsContext.jsx +++ b/client/src/context/NotificationsContext.jsx @@ -15,17 +15,11 @@ import React, { createContext, useContext, useReducer, useCallback, useEffect } from 'react'; import { notificationsReducer, notificationActions } from '../reducers/notificationsReducer.js'; import * as notificationsAPI from '../api/notifications.api.js'; -import { useAuth } from './AuthContext.jsx'; +import { useAuth } from '../hooks/useAuth'; -const NotificationsContext = createContext(null); +export const NotificationsContext = createContext(null); -export const useNotifications = () => { - const context = useContext(NotificationsContext); - if (!context) { - throw new Error('useNotifications must be used within a NotificationsProvider'); - } - return context; -}; +// Hook removed and moved to src/hooks/useNotifications.js export const NotificationsProvider = ({ children }) => { const { isAuthenticated } = useAuth(); @@ -160,7 +154,7 @@ export const NotificationsProvider = ({ children }) => { pagination: state.pagination, isLoading: state.isLoading, error: state.error, - + // Methods fetchNotifications, markAsRead, diff --git a/client/src/auth/PaymentContext.jsx b/client/src/context/PaymentContext.jsx similarity index 81% rename from client/src/auth/PaymentContext.jsx rename to client/src/context/PaymentContext.jsx index 319dcdf..b3b959e 100644 --- a/client/src/auth/PaymentContext.jsx +++ b/client/src/context/PaymentContext.jsx @@ -9,15 +9,9 @@ import React, { createContext, useContext, useReducer, useCallback } from 'react import { paymentReducer, paymentActions } from '../reducers/paymentReducer.js'; import * as paymentAPI from '../api/payment.api.js'; -const PaymentContext = createContext(null); +export const PaymentContext = createContext(null); -export const usePayment = () => { - const context = useContext(PaymentContext); - if (!context) { - throw new Error('usePayment must be used within a PaymentProvider'); - } - return context; -}; +// Hook removed and moved to src/hooks/usePayment.js export const PaymentProvider = ({ children }) => { const [state, dispatch] = useReducer(paymentReducer, { @@ -95,6 +89,19 @@ export const PaymentProvider = ({ children }) => { isVerifying: state.isVerifying, error: state.error, + // Derived Logic (Merged from usePaymentState standalone) + hasPayments: state.payments.length > 0, + successfulPayments: state.payments.filter(p => p.status === 'success'), + pendingPayments: state.payments.filter(p => p.status === 'pending'), + failedPayments: state.payments.filter(p => p.status === 'failed'), + paymentCounts: { + total: state.payments.length, + successful: state.payments.filter(p => p.status === 'success').length, + pending: state.payments.filter(p => p.status === 'pending').length, + failed: state.payments.filter(p => p.status === 'failed').length, + }, + isCurrentPaymentSuccessful: state.currentPayment?.status === 'success', + // Actions fetchPaymentHistory, processPayment, diff --git a/client/src/auth/ProfileAssetsContext.jsx b/client/src/context/ProfileAssetsContext.jsx similarity index 86% rename from client/src/auth/ProfileAssetsContext.jsx rename to client/src/context/ProfileAssetsContext.jsx index 6f26a28..b7d1d0f 100644 --- a/client/src/auth/ProfileAssetsContext.jsx +++ b/client/src/context/ProfileAssetsContext.jsx @@ -9,15 +9,9 @@ import React, { createContext, useContext, useReducer, useCallback } from 'react import { profileAssetsReducer, profileAssetsActions } from '../reducers/profileAssetsReducer.js'; import * as idUploadAPI from '../api/idUpload.api.js'; -const ProfileAssetsContext = createContext(null); +export const ProfileAssetsContext = createContext(null); -export const useProfileAssets = () => { - const context = useContext(ProfileAssetsContext); - if (!context) { - throw new Error('useProfileAssets must be used within a ProfileAssetsProvider'); - } - return context; -}; +// Hook removed and moved to src/hooks/useProfileAssets.js export const ProfileAssetsProvider = ({ children }) => { const [state, dispatch] = useReducer(profileAssetsReducer, { @@ -105,6 +99,17 @@ export const ProfileAssetsProvider = ({ children }) => { error: state.error, idStatus, // derived helper + // Derived Logic (Merged from useProfileAssets standalone) + hasFaydaId: state.faydaId.exists, + hasKebeleId: state.kebeleId.exists, + hasBothIds: state.faydaId.exists && state.kebeleId.exists, + canSubmitApplications: state.faydaId.exists && state.kebeleId.exists, + isUploading: state.faydaId.uploadStatus === 'uploading' || state.kebeleId.uploadStatus === 'uploading', + uploadStatus: { + fayda: state.faydaId.uploadStatus, + kebele: state.kebeleId.uploadStatus, + }, + // Actions fetchIdData, uploadFayda, diff --git a/client/src/auth/guards/AuthGuard.jsx b/client/src/guards/AuthGuard.jsx similarity index 77% rename from client/src/auth/guards/AuthGuard.jsx rename to client/src/guards/AuthGuard.jsx index 1558d6f..ee2e949 100644 --- a/client/src/auth/guards/AuthGuard.jsx +++ b/client/src/guards/AuthGuard.jsx @@ -6,7 +6,7 @@ */ import { Navigate, useLocation } from 'react-router-dom'; -import { useAuth } from '../AuthContext.jsx'; +import { useAuth } from '../hooks/useAuth'; export const AuthGuard = ({ children }) => { const { isAuthenticated, isLoading } = useAuth(); @@ -15,11 +15,11 @@ export const AuthGuard = ({ children }) => { if (isLoading) { // Show loading spinner or skeleton return ( -
Loading...
diff --git a/client/src/auth/guards/IDGuard.jsx b/client/src/guards/IDGuard.jsx similarity index 93% rename from client/src/auth/guards/IDGuard.jsx rename to client/src/guards/IDGuard.jsx index fcf5954..683adb0 100644 --- a/client/src/auth/guards/IDGuard.jsx +++ b/client/src/guards/IDGuard.jsx @@ -1,8 +1,8 @@ import React, { useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../AuthContext.jsx'; -import { useProfileAssets } from '../ProfileAssetsContext.jsx'; -import '../../styles/auth/IDGuard.css'; +import { useAuth } from '../hooks/useAuth'; +import { useProfileAssets } from '../hooks/useProfileAssets'; +import '../styles/auth/IDGuard.css'; export const IDGuard = ({ children }) => { const { isAuthenticated, role } = useAuth(); diff --git a/client/src/auth/guards/PermissionGuard.jsx b/client/src/guards/PermissionGuard.jsx similarity index 80% rename from client/src/auth/guards/PermissionGuard.jsx rename to client/src/guards/PermissionGuard.jsx index 9af1d1c..0ec3f83 100644 --- a/client/src/auth/guards/PermissionGuard.jsx +++ b/client/src/guards/PermissionGuard.jsx @@ -6,8 +6,8 @@ */ import { Navigate } from 'react-router-dom'; -import { useAuth } from '../AuthContext.jsx'; -import { usePermissions } from '../../hooks/usePermissions.js'; +import { useAuth } from '../hooks/useAuth'; +import { usePermissions } from '../hooks/usePermissions.js'; /** * PermissionGuard - Protects routes by permission @@ -16,10 +16,10 @@ import { usePermissions } from '../../hooks/usePermissions.js'; * @param {string|string[]} props.requiredPermissions - Permission(s) required to access * @param {ReactNode} props.fallback - Optional fallback component (default: redirect to officer dashboard) */ -export const PermissionGuard = ({ - children, - requiredPermissions, - fallback = null +export const PermissionGuard = ({ + children, + requiredPermissions, + fallback = null }) => { const { isAuthenticated, isOfficer } = useAuth(); const { hasPermission } = usePermissions(); @@ -33,10 +33,10 @@ export const PermissionGuard = ({ return ; } - const permissionsArray = Array.isArray(requiredPermissions) - ? requiredPermissions + const permissionsArray = Array.isArray(requiredPermissions) + ? requiredPermissions : [requiredPermissions]; - + const hasRequiredPermission = permissionsArray.some(perm => hasPermission(perm)); if (!hasRequiredPermission) { diff --git a/client/src/auth/guards/RoleGuard.jsx b/client/src/guards/RoleGuard.jsx similarity index 92% rename from client/src/auth/guards/RoleGuard.jsx rename to client/src/guards/RoleGuard.jsx index 4074b40..ad56665 100644 --- a/client/src/auth/guards/RoleGuard.jsx +++ b/client/src/guards/RoleGuard.jsx @@ -6,8 +6,8 @@ */ import { Navigate } from 'react-router-dom'; -import { useAuth } from '../AuthContext.jsx'; -import { usePermissions } from '../../hooks/usePermissions.js'; +import { useAuth } from '../hooks/useAuth'; +import { usePermissions } from '../hooks/usePermissions.js'; /** * RoleGuard - Protects routes by role diff --git a/client/src/hooks/useApplication.js b/client/src/hooks/useApplication.js new file mode 100644 index 0000000..00ceebc --- /dev/null +++ b/client/src/hooks/useApplication.js @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { ApplicationContext } from '../context/ApplicationContext.jsx'; + +/** + * useApplication Hook + * + * Consumes the ApplicationContext. + * Provides central access to application list, details, and submission logic. + */ +export const useApplication = () => { + const context = useContext(ApplicationContext); + if (!context) { + throw new Error('useApplication must be used within an ApplicationProvider'); + } + return context; +}; diff --git a/client/src/hooks/useApplicationState.js b/client/src/hooks/useApplicationState.js deleted file mode 100644 index 622a7ee..0000000 --- a/client/src/hooks/useApplicationState.js +++ /dev/null @@ -1,240 +0,0 @@ -/** - * useApplicationState Hook - * - * Custom hook for managing application state using useReducer. - * Provides: - * - Application list and details - * - Submission state - * - Derived values (filtered applications) - * - API integration with useCallback - * - * @example - * const { - * applications, - * isLoading, - * pendingApplications, - * fetchApplications, - * submitApplication - * } = useApplicationState(); - */ - -import { useReducer, useCallback, useMemo } from 'react'; -import { applicationReducer, applicationActions } from '../reducers/applicationReducer.js'; -import { - getAllApplications, - submitTinApplication, - submitVitalApplication, - downloadCertificate, -} from '../api/applications.api.js'; -import { getApplicationDetails } from '../api/officer.api.js'; - -/** - * Custom hook for application state management - * @returns {Object} Application state and actions - */ -export const useApplicationState = () => { - const [state, dispatch] = useReducer(applicationReducer, { - applications: [], - selectedApplication: null, - isLoading: false, - isSubmitting: false, - error: null, - pagination: { - page: 1, - totalPages: 1, - total: 0, - }, - }); - - // ===== Actions ===== - - /** - * Fetch all applications for current user (citizen) - */ - const fetchApplications = useCallback(async (page, limit) => { - dispatch({ type: applicationActions.FETCH_APPLICATIONS_START }); - try { - const data = await getAllApplications(page, limit); - dispatch({ - type: applicationActions.FETCH_APPLICATIONS_SUCCESS, - payload: data - }); - } catch (error) { - dispatch({ - type: applicationActions.FETCH_APPLICATIONS_FAILURE, - payload: error.message || 'Failed to fetch applications' - }); - } - }, []); - - /** - * Fetch application details (officer view) - * @param {string} applicationId - Application ID - */ - const fetchApplicationDetails = useCallback(async (applicationId) => { - dispatch({ type: applicationActions.FETCH_APPLICATION_DETAILS_START }); - try { - const data = await getApplicationDetails(applicationId); - dispatch({ - type: applicationActions.FETCH_APPLICATION_DETAILS_SUCCESS, - payload: data - }); - } catch (error) { - dispatch({ - type: applicationActions.FETCH_APPLICATION_DETAILS_FAILURE, - payload: error.message || 'Failed to fetch application details' - }); - } - }, []); - - /** - * Submit TIN application - * @param {Object} formData - TIN application data - */ - const submitTin = useCallback(async (formData) => { - dispatch({ type: applicationActions.SUBMIT_APPLICATION_START }); - try { - const data = await submitTinApplication(formData); - dispatch({ - type: applicationActions.SUBMIT_APPLICATION_SUCCESS, - payload: data - }); - return data; - } catch (error) { - dispatch({ - type: applicationActions.SUBMIT_APPLICATION_FAILURE, - payload: error.message || 'Failed to submit TIN application' - }); - throw error; - } - }, []); - - /** - * Submit vital record application - * @param {string} type - Type of vital record (birth, marriage, death) - * @param {Object} formData - Vital application data - */ - const submitVital = useCallback(async (type, formData) => { - dispatch({ type: applicationActions.SUBMIT_APPLICATION_START }); - try { - const data = await submitVitalApplication(type, formData); - dispatch({ - type: applicationActions.SUBMIT_APPLICATION_SUCCESS, - payload: data - }); - return data; - } catch (error) { - dispatch({ - type: applicationActions.SUBMIT_APPLICATION_FAILURE, - payload: error.message || 'Failed to submit vital application' - }); - throw error; - } - }, []); - - /** - * Download certificate for approved application - * @param {string} applicationId - Application ID - */ - const downloadCert = useCallback(async (applicationId) => { - try { - downloadCertificate(applicationId); - console.log("Redirect to download page"); - } catch (error) { - dispatch({ - type: applicationActions.FETCH_APPLICATIONS_FAILURE, - payload: error.message || 'Failed to download certificate' - }); - throw error; - } - }, []); - - /** - * Clear error state - */ - const clearError = useCallback(() => { - dispatch({ type: applicationActions.CLEAR_ERROR }); - }, []); - - /** - * Clear selected application - */ - const clearSelected = useCallback(() => { - dispatch({ type: applicationActions.CLEAR_SELECTED }); - }, []); - - // ===== Derived State ===== - - /** - * Check if user has any applications - */ - const hasApplications = useMemo( - () => state.applications.length > 0, - [state.applications] - ); - - /** - * Get pending applications - */ - const pendingApplications = useMemo( - () => state.applications.filter(app => app.status === 'pending'), - [state.applications] - ); - - /** - * Get approved applications - */ - const approvedApplications = useMemo( - () => state.applications.filter(app => app.status === 'approved'), - [state.applications] - ); - - /** - * Get rejected applications - */ - const rejectedApplications = useMemo( - () => state.applications.filter(app => app.status === 'rejected'), - [state.applications] - ); - - /** - * Count applications by status - */ - const applicationCounts = useMemo( - () => ({ - total: state.applications.length, - pending: pendingApplications.length, - approved: approvedApplications.length, - rejected: rejectedApplications.length, - }), - [state.applications, pendingApplications, approvedApplications, rejectedApplications] - ); - - // ===== Return API ===== - - return { - // State - applications: state.applications, - selectedApplication: state.selectedApplication, - isLoading: state.isLoading, - isSubmitting: state.isSubmitting, - error: state.error, - pagination: state.pagination, - - // Derived values - hasApplications, - pendingApplications, - approvedApplications, - rejectedApplications, - applicationCounts, - - // Actions - fetchApplications, - fetchApplicationDetails, - submitTin, - submitVital, - downloadCert, - clearError, - clearSelected, - }; -}; diff --git a/client/src/hooks/useAuth.js b/client/src/hooks/useAuth.js new file mode 100644 index 0000000..9287e95 --- /dev/null +++ b/client/src/hooks/useAuth.js @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { AuthContext } from '../context/AuthContext.jsx'; + +/** + * useAuth Hook + * + * Consumes the AuthContext. + * Provides central access to user authentication state, roles, and session management. + */ +export const useAuth = () => { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used within an AuthProvider'); + } + return context; +}; diff --git a/client/src/hooks/useChat.js b/client/src/hooks/useChat.js new file mode 100644 index 0000000..6968328 --- /dev/null +++ b/client/src/hooks/useChat.js @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { ChatContext } from '../context/ChatContext.jsx'; + +/** + * useChat Hook + * + * Consumes the ChatContext. + * Provides central access to conversations, messaging, and support inquiries. + */ +export const useChat = () => { + const context = useContext(ChatContext); + if (!context) { + throw new Error('useChat must be used within a ChatProvider'); + } + return context; +}; diff --git a/client/src/hooks/useChatState.js b/client/src/hooks/useChatState.js deleted file mode 100644 index c1e0c58..0000000 --- a/client/src/hooks/useChatState.js +++ /dev/null @@ -1,169 +0,0 @@ -/** - * useChatState Hook - * - * Custom hook for managing chat/conversation state using useReducer. - * Provides: - * - Conversation list (officer/citizen) - * - Message handling - * - Pagination support - * - API integration with useCallback - */ - -import { useReducer, useCallback, useMemo } from 'react'; -import { chatReducer, chatActions } from '../reducers/chatReducer.js'; -import { - getConversations, - getCitizenConversations, - getConversationById, - postMessage, - markConversationAsRead as markAsReadAPI, - submitInquiry -} from '../api/chat.api.js'; - -export const useChatState = () => { - const [state, dispatch] = useReducer(chatReducer, { - conversations: [], - selectedConversation: null, - unreadCount: 0, - isLoading: false, - isSending: false, - error: null, - pagination: { - page: 1, - totalPages: 1, - total: 0, - hasNextPage: false, - hasPrevPage: false, - }, - }); - - /** - * Fetch conversations for officer - */ - const fetchConversations = useCallback(async (page, limit) => { - dispatch({ type: chatActions.FETCH_CONVERSATIONS_START }); - try { - const response = await getConversations(page, limit); - dispatch({ - type: chatActions.FETCH_CONVERSATIONS_SUCCESS, - payload: response - }); - } catch (error) { - dispatch({ - type: chatActions.FETCH_CONVERSATIONS_FAILURE, - payload: error.message || 'Failed to fetch conversations' - }); - } - }, []); - - /** - * Fetch conversations for citizen - */ - const fetchCitizenConversations = useCallback(async (page, limit) => { - dispatch({ type: chatActions.FETCH_CONVERSATIONS_START }); - try { - const response = await getCitizenConversations(page, limit); - dispatch({ - type: chatActions.FETCH_CONVERSATIONS_SUCCESS, - payload: response - }); - } catch (error) { - dispatch({ - type: chatActions.FETCH_CONVERSATIONS_FAILURE, - payload: error.message || 'Failed to fetch conversations' - }); - } - }, []); - - /** - * Fetch conversation details - */ - const fetchConversationDetails = useCallback(async (id) => { - dispatch({ type: chatActions.FETCH_CONVERSATION_DETAILS_START }); - try { - const response = await getConversationById(id); - dispatch({ - type: chatActions.FETCH_CONVERSATION_DETAILS_SUCCESS, - payload: response - }); - } catch (error) { - dispatch({ - type: chatActions.FETCH_CONVERSATION_DETAILS_FAILURE, - payload: error.message || 'Failed to fetch conversation' - }); - } - }, []); - - /** - * Send message (Officer) - */ - const sendMessage = useCallback(async (id, content) => { - dispatch({ type: chatActions.SEND_MESSAGE_START }); - try { - const response = await postMessage(id, content); - dispatch({ type: chatActions.SEND_MESSAGE_SUCCESS, payload: response }); - return response; - } catch (error) { - dispatch({ - type: chatActions.SEND_MESSAGE_FAILURE, - payload: error.message || 'Failed to send message' - }); - throw error; - } - }, []); - - /** - * Mark as read - */ - const markAsRead = useCallback(async (id) => { - dispatch({ type: chatActions.MARK_READ_START, payload: id }); - try { - const response = await markAsReadAPI(id); - dispatch({ type: chatActions.MARK_READ_SUCCESS, payload: response }); - } catch (error) { - dispatch({ - type: chatActions.MARK_READ_FAILURE, - payload: { id, error: error.message } - }); - } - }, []); - - /** - * Submit new inquiry (Citizen/Guest) - */ - const submitSupportInquiry = useCallback(async (payload) => { - dispatch({ type: chatActions.SEND_MESSAGE_START }); - try { - const response = await submitInquiry(payload); - dispatch({ type: chatActions.SEND_MESSAGE_SUCCESS, payload: response }); - return response; - } catch (error) { - dispatch({ - type: chatActions.SEND_MESSAGE_FAILURE, - payload: error.message || 'Failed to submit inquiry' - }); - throw error; - } - }, []); - - const clearError = useCallback(() => dispatch({ type: chatActions.CLEAR_ERROR }), []); - const clearSelected = useCallback(() => dispatch({ type: chatActions.CLEAR_SELECTED }), []); - - // Derived values - const hasConversations = useMemo(() => state.conversations.length > 0, [state.conversations]); - const unreadConversations = useMemo(() => state.conversations.filter(c => !c.read), [state.conversations]); - - return { - ...state, - hasConversations, - unreadConversations, - fetchConversations, - fetchCitizenConversations, - fetchConversationDetails, - sendMessage, - markAsRead, - submitSupportInquiry, - clearError, - clearSelected, - }; -}; diff --git a/client/src/hooks/useNews.js b/client/src/hooks/useNews.js new file mode 100644 index 0000000..6fbd34e --- /dev/null +++ b/client/src/hooks/useNews.js @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { NewsContext } from '../context/NewsContext.jsx'; + +/** + * useNews Hook + * + * Consumes the NewsContext. + * Provides central access to the news cache and fetching logic. + */ +export const useNews = () => { + const context = useContext(NewsContext); + if (!context) { + throw new Error('useNews must be used within a NewsProvider'); + } + return context; +}; diff --git a/client/src/hooks/useNotifications.js b/client/src/hooks/useNotifications.js new file mode 100644 index 0000000..1a59310 --- /dev/null +++ b/client/src/hooks/useNotifications.js @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { NotificationsContext } from '../context/NotificationsContext.jsx'; + +/** + * useNotifications Hook + * + * Consumes the NotificationsContext. + * Provides central access to real-time notification state and dismissal logic. + */ +export const useNotifications = () => { + const context = useContext(NotificationsContext); + if (!context) { + throw new Error('useNotifications must be used within a NotificationsProvider'); + } + return context; +}; diff --git a/client/src/hooks/usePayment.js b/client/src/hooks/usePayment.js new file mode 100644 index 0000000..2132059 --- /dev/null +++ b/client/src/hooks/usePayment.js @@ -0,0 +1,16 @@ +import { useContext } from 'react'; +import { PaymentContext } from '../context/PaymentContext.jsx'; + +/** + * usePayment Hook + * + * Consumes the PaymentContext. + * Provides central access to payment history, processing, and status. + */ +export const usePayment = () => { + const context = useContext(PaymentContext); + if (!context) { + throw new Error('usePayment must be used within a PaymentProvider'); + } + return context; +}; diff --git a/client/src/hooks/usePaymentState.js b/client/src/hooks/usePaymentState.js deleted file mode 100644 index 10ec6af..0000000 --- a/client/src/hooks/usePaymentState.js +++ /dev/null @@ -1,251 +0,0 @@ -/** - * usePaymentState Hook - * - * Custom hook for managing payment state using useReducer. - * Provides: - * - Payment history - * - Current payment status - * - Payment processing and verification - * - Derived values (filtered payments) - * - API integration with useCallback - * - * @example - * const { - * payments, - * currentPayment, - * successfulPayments, - * processPayment, - * verifyPayment - * } = usePaymentState(); - */ - -import { useReducer, useCallback, useMemo } from 'react'; -import { paymentReducer, paymentActions } from '../reducers/paymentReducer.js'; -import { - getPaymentHistory, - getPaymentStatus, - processPayment as processPaymentAPI, - verifyPayment as verifyPaymentAPI, - downloadReceipt, -} from '../api/payment.api.js'; - -/** - * Custom hook for payment state management - * @returns {Object} Payment state and actions - */ -export const usePaymentState = () => { - const [state, dispatch] = useReducer(paymentReducer, { - payments: [], - currentPayment: null, - isLoading: false, - isProcessing: false, - isVerifying: false, - error: null, - pagination: { - page: 1, - totalPages: 1, - total: 0, - }, - }); - - // ===== Actions ===== - - /** - * Fetch payment history - */ - const fetchPaymentHistory = useCallback(async (page, limit) => { - dispatch({ type: paymentActions.FETCH_PAYMENT_HISTORY_START }); - try { - const data = await getPaymentHistory(page, limit); - dispatch({ - type: paymentActions.FETCH_PAYMENT_HISTORY_SUCCESS, - payload: data - }); - } catch (error) { - dispatch({ - type: paymentActions.FETCH_PAYMENT_HISTORY_FAILURE, - payload: error.message || 'Failed to fetch payment history' - }); - } - }, []); - - /** - * Fetch payment status by ID - * @param {string} paymentId - Payment ID - */ - const fetchPaymentStatus = useCallback(async (paymentId) => { - dispatch({ type: paymentActions.FETCH_PAYMENT_STATUS_START }); - try { - const data = await getPaymentStatus(paymentId); - dispatch({ - type: paymentActions.FETCH_PAYMENT_STATUS_SUCCESS, - payload: data - }); - } catch (error) { - dispatch({ - type: paymentActions.FETCH_PAYMENT_STATUS_FAILURE, - payload: error.message || 'Failed to fetch payment status' - }); - } - }, []); - - /** - * Process a new payment - * @param {Object} paymentData - Payment data (applicationId, serviceType, phoneNumber, amount) - */ - const processPayment = useCallback(async (paymentData) => { - dispatch({ type: paymentActions.PROCESS_PAYMENT_START }); - try { - const data = await processPaymentAPI(paymentData); - dispatch({ - type: paymentActions.PROCESS_PAYMENT_SUCCESS, - payload: data - }); - return data; - } catch (error) { - dispatch({ - type: paymentActions.PROCESS_PAYMENT_FAILURE, - payload: error.message || 'Failed to process payment' - }); - throw error; - } - }, []); - - /** - * Verify payment with Chapa - * @param {string} txRef - Transaction reference - */ - const verifyPayment = useCallback(async (txRef) => { - dispatch({ type: paymentActions.VERIFY_PAYMENT_START }); - try { - const data = await verifyPaymentAPI(txRef); - dispatch({ - type: paymentActions.VERIFY_PAYMENT_SUCCESS, - payload: data - }); - return data; - } catch (error) { - dispatch({ - type: paymentActions.VERIFY_PAYMENT_FAILURE, - payload: error.message || 'Failed to verify payment' - }); - throw error; - } - }, []); - - /** - * Download payment receipt - * @param {string} paymentId - Payment ID - */ - const downloadPaymentReceipt = useCallback(async (paymentId) => { - try { - const response = await downloadReceipt(paymentId); - return response; - } catch (error) { - dispatch({ - type: paymentActions.FETCH_PAYMENT_HISTORY_FAILURE, - payload: error.message || 'Failed to download receipt' - }); - throw error; - } - }, []); - - /** - * Clear error state - */ - const clearError = useCallback(() => { - dispatch({ type: paymentActions.CLEAR_ERROR }); - }, []); - - /** - * Clear current payment - */ - const clearCurrentPayment = useCallback(() => { - dispatch({ type: paymentActions.CLEAR_CURRENT_PAYMENT }); - }, []); - - // ===== Derived State ===== - - /** - * Check if user has any payments - */ - const hasPayments = useMemo( - () => state.payments.length > 0, - [state.payments] - ); - - /** - * Get successful payments - */ - const successfulPayments = useMemo( - () => state.payments.filter(payment => payment.status === 'success'), - [state.payments] - ); - - /** - * Get pending payments - */ - const pendingPayments = useMemo( - () => state.payments.filter(payment => payment.status === 'pending'), - [state.payments] - ); - - /** - * Get failed payments - */ - const failedPayments = useMemo( - () => state.payments.filter(payment => payment.status === 'failed'), - [state.payments] - ); - - /** - * Count payments by status - */ - const paymentCounts = useMemo( - () => ({ - total: state.payments.length, - successful: successfulPayments.length, - pending: pendingPayments.length, - failed: failedPayments.length, - }), - [state.payments, successfulPayments, pendingPayments, failedPayments] - ); - - /** - * Check if current payment is successful - */ - const isCurrentPaymentSuccessful = useMemo( - () => state.currentPayment?.status === 'success', - [state.currentPayment] - ); - - // ===== Return API ===== - - return { - // State - payments: state.payments, - currentPayment: state.currentPayment, - isLoading: state.isLoading, - isProcessing: state.isProcessing, - isVerifying: state.isVerifying, - error: state.error, - pagination: state.pagination, - - // Derived values - hasPayments, - successfulPayments, - pendingPayments, - failedPayments, - paymentCounts, - isCurrentPaymentSuccessful, - - // Actions - fetchPaymentHistory, - fetchPaymentStatus, - processPayment, - verifyPayment, - downloadPaymentReceipt, - clearError, - clearCurrentPayment, - }; -}; diff --git a/client/src/hooks/usePermissions.js b/client/src/hooks/usePermissions.js index 45d7d20..ae0a2e1 100644 --- a/client/src/hooks/usePermissions.js +++ b/client/src/hooks/usePermissions.js @@ -6,7 +6,7 @@ */ import { useMemo } from 'react'; -import { useAuth } from '../auth/AuthContext.jsx'; +import { useAuth } from './useAuth'; import { PERMISSIONS } from '../constants/roles.js'; /** @@ -41,12 +41,12 @@ export const usePermissions = () => { canApprove, canSupport, canWriteNews, - + // Role checks isCitizen, isOfficer, isAdmin, - + // Raw access permissions, role, diff --git a/client/src/hooks/useProfileAssets.js b/client/src/hooks/useProfileAssets.js index 8997f71..ad42931 100644 --- a/client/src/hooks/useProfileAssets.js +++ b/client/src/hooks/useProfileAssets.js @@ -1,222 +1,16 @@ +import { useContext } from 'react'; +import { ProfileAssetsContext } from '../context/ProfileAssetsContext.jsx'; + /** * useProfileAssets Hook * - * Custom hook for managing ID upload state using useReducer. - * Provides: - * - Fayda and Kebele ID status - * - Upload/delete operations - * - Derived values (ID existence checks) - * - API integration with useCallback - * - * @example - * const { - * hasFaydaId, - * hasKebeleId, - * canSubmitApplications, - * uploadFaydaId, - * deleteId - * } = useProfileAssets(); - */ - -import { useReducer, useCallback, useMemo } from 'react'; -import { profileAssetsReducer, profileAssetsActions } from '../reducers/profileAssetsReducer.js'; -import { - uploadFaydaID, - uploadKebeleID, - deleteIDInfo, -} from '../api/idUpload.api.js'; -import { getIDData } from '../api/user.api.js'; - -/** - * Custom hook for profile assets (ID uploads) state management - * @returns {Object} Profile assets state and actions + * Consumes the ProfileAssetsContext. + * Provides central access to user ID assets, upload status, and operations. */ export const useProfileAssets = () => { - const [state, dispatch] = useReducer(profileAssetsReducer, { - faydaId: { - exists: false, - data: null, - uploadStatus: 'idle', - }, - kebeleId: { - exists: false, - data: null, - uploadStatus: 'idle', - }, - isLoading: false, - error: null, - }); - - // ===== Actions ===== - - /** - * Fetch ID data from backend - */ - const fetchIdData = useCallback(async () => { - dispatch({ type: profileAssetsActions.FETCH_ID_DATA_START }); - try { - const data = await getIDData(); - dispatch({ - type: profileAssetsActions.FETCH_ID_DATA_SUCCESS, - payload: data - }); - } catch (error) { - dispatch({ - type: profileAssetsActions.FETCH_ID_DATA_FAILURE, - payload: error.message || 'Failed to fetch ID data' - }); - } - }, []); - - /** - * Upload Fayda ID - * @param {File} file - ID image file - */ - const uploadFaydaId = useCallback(async (file) => { - dispatch({ type: profileAssetsActions.UPLOAD_FAYDA_START }); - try { - const data = await uploadFaydaID(file); - dispatch({ - type: profileAssetsActions.UPLOAD_FAYDA_SUCCESS, - payload: data - }); - return data; - } catch (error) { - dispatch({ - type: profileAssetsActions.UPLOAD_FAYDA_FAILURE, - payload: error.message || 'Failed to upload Fayda ID' - }); - throw error; - } - }, []); - - /** - * Upload Kebele ID - * @param {File} file - ID image file - */ - const uploadKebeleId = useCallback(async (file) => { - dispatch({ type: profileAssetsActions.UPLOAD_KEBELE_START }); - try { - const data = await uploadKebeleID(file); - dispatch({ - type: profileAssetsActions.UPLOAD_KEBELE_SUCCESS, - payload: data - }); - return data; - } catch (error) { - dispatch({ - type: profileAssetsActions.UPLOAD_KEBELE_FAILURE, - payload: error.message || 'Failed to upload Kebele ID' - }); - throw error; - } - }, []); - - /** - * Delete ID information (Right to be Forgotten) - * @param {string} idType - 'fayda', 'kebele', or 'both' - */ - const deleteId = useCallback(async (idType) => { - dispatch({ type: profileAssetsActions.DELETE_ID_START }); - try { - await deleteIDInfo(idType); - dispatch({ - type: profileAssetsActions.DELETE_ID_SUCCESS, - payload: { idType } - }); - } catch (error) { - dispatch({ - type: profileAssetsActions.DELETE_ID_FAILURE, - payload: error.message || 'Failed to delete ID information' - }); - throw error; - } - }, []); - - /** - * Clear error state - */ - const clearError = useCallback(() => { - dispatch({ type: profileAssetsActions.CLEAR_ERROR }); - }, []); - - // ===== Derived State ===== - - /** - * Check if Fayda ID exists - */ - const hasFaydaId = useMemo( - () => state.faydaId.exists, - [state.faydaId.exists] - ); - - /** - * Check if Kebele ID exists - */ - const hasKebeleId = useMemo( - () => state.kebeleId.exists, - [state.kebeleId.exists] - ); - - /** - * Check if both IDs exist - */ - const hasBothIds = useMemo( - () => state.faydaId.exists && state.kebeleId.exists, - [state.faydaId.exists, state.kebeleId.exists] - ); - - /** - * Check if user can submit applications (based on ID requirements) - * Note: This is derived from backend state, not a UI assumption - */ - const canSubmitApplications = useMemo( - () => state.faydaId.exists && state.kebeleId.exists, - [state.faydaId.exists, state.kebeleId.exists] - ); - - /** - * Check if any upload is in progress - */ - const isUploading = useMemo( - () => state.faydaId.uploadStatus === 'uploading' || state.kebeleId.uploadStatus === 'uploading', - [state.faydaId.uploadStatus, state.kebeleId.uploadStatus] - ); - - /** - * Get upload status summary - */ - const uploadStatus = useMemo( - () => ({ - fayda: state.faydaId.uploadStatus, - kebele: state.kebeleId.uploadStatus, - isUploading, - }), - [state.faydaId.uploadStatus, state.kebeleId.uploadStatus, isUploading] - ); - - // ===== Return API ===== - - return { - // State - faydaId: state.faydaId, - kebeleId: state.kebeleId, - isLoading: state.isLoading, - error: state.error, - - // Derived values - hasFaydaId, - hasKebeleId, - hasBothIds, - canSubmitApplications, - isUploading, - uploadStatus, - - // Actions - fetchIdData, - uploadFaydaId, - uploadKebeleId, - deleteId, - clearError, - }; + const context = useContext(ProfileAssetsContext); + if (!context) { + throw new Error('useProfileAssets must be used within a ProfileAssetsProvider'); + } + return context; }; diff --git a/client/src/pages/admin/AdminDashboard.jsx b/client/src/pages/admin/AdminDashboard.jsx index 8ad6b6b..1bc22b5 100644 --- a/client/src/pages/admin/AdminDashboard.jsx +++ b/client/src/pages/admin/AdminDashboard.jsx @@ -32,7 +32,7 @@ function AdminDashboard() { const dashboardData = { stats: { totalOfficers: officerStats?.counts?.total || officerStats?.data?.totalDocs || 0, - activeRequests: metrics?.data?.summary?.totalRequestsProcessed || 0, + activeRequests: metrics?.data?.summary?.totalTasksAssigned || metrics?.data?.summary?.totalRequestsProcessed || 0, avgResponseTime: metrics?.data?.summary?.averageResponseTimeMs ? formatDuration(metrics.data.summary.averageResponseTimeMs) : 'N/A', securityAlerts: logs?.totalDocs || 0 }, @@ -44,7 +44,7 @@ function AdminDashboard() { performanceMetrics: { requestsProcessed: metrics?.data?.summary?.totalRequestsProcessed || 0, avgResponseTime: metrics?.data?.summary?.averageResponseTimeMs ? formatDuration(metrics.data.summary.averageResponseTimeMs) : 'N/A', - responseRate: metrics?.data?.summary?.communicationResponseRate ? `${(metrics.data.summary.communicationResponseRate * 100).toFixed(2)}%` : 'N/A' + responseRate: metrics?.data?.summary?.combinedResponseRate ? `${(metrics.data.summary.combinedResponseRate * 100).toFixed(1)}%` : 'N/A' }, securityIssues: { failedLogins: 0, diff --git a/client/src/pages/admin/AdminSettings.jsx b/client/src/pages/admin/AdminSettings.jsx index 9c651b0..5ee62f5 100644 --- a/client/src/pages/admin/AdminSettings.jsx +++ b/client/src/pages/admin/AdminSettings.jsx @@ -4,7 +4,7 @@ import '../../styles/admin/AdminSettings.css'; import Navigation2 from '../../components/Navigation2'; import Footer from '../../components/Footer'; import AdminSideBar from '../../components/AdminSideBar'; -import { useAuth } from '../../auth/AuthContext'; +import { useAuth } from '../../hooks/useAuth'; import { changePassword } from '../../api/user.api'; import StatusModal from '../../components/common/StatusModal'; diff --git a/client/src/pages/admin/ManageOfficers.jsx b/client/src/pages/admin/ManageOfficers.jsx index 185042b..0bafb2e 100644 --- a/client/src/pages/admin/ManageOfficers.jsx +++ b/client/src/pages/admin/ManageOfficers.jsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useMemo, useRef } from 'react'; +import React, { useState, useEffect } from 'react'; import { useSearchParams } from 'react-router-dom'; import '../../styles/admin/ManageOfficers.css'; import Navigation2 from '../../components/Navigation2.jsx'; @@ -9,7 +9,7 @@ import LoadingSpinner from '../../components/common/LoadingSpinner'; import { formatDuration } from '../../utils/formatters'; function ManageOfficers() { - const { fetchOfficerList, promoteUser, searchUser, loading, error } = useAdmin(); + const { fetchOfficerList, promoteUser, searchUser } = useAdmin(); // URL Params const [searchParams, setSearchParams] = useSearchParams(); @@ -90,8 +90,8 @@ function ManageOfficers() { hasNextPage: response.hasNextPage, hasPrevPage: response.hasPrevPage })); - if (response.counts) { - setCounts(response.counts); + if (response.counts || response.data?.counts) { + setCounts(response.data?.counts || response.counts); } } else { setOfficers([]); @@ -448,8 +448,8 @@ function PromoteOfficerModal({ departments, onClose, onSave, searchUser }) { try { const results = await searchUser({ name: searchTerm }); // searchUser returns { success: true, count: N, citizens: [] } - if (results && results.citizens) { - setSearchResults(results.citizens); + if (results && results.data?.citizens) { + setSearchResults(results.data?.citizens); } else { setSearchResults([]); } @@ -537,7 +537,6 @@ function PromoteOfficerModal({ departments, onClose, onSave, searchUser }) { }} placeholder="Type to search..." disabled={!!formData.userId} - style={{ paddingRight: formData.userId ? '2.5rem' : '1rem' }} /> {formData.userId && (
@@ -291,7 +300,11 @@ function PerformanceMonitoringContent() { let value = 0; if (selectedMetric === 'requests') value = item.requestsProcessed; else if (selectedMetric === 'time') value = Number((item.averageResponseTimeMs / 3600000).toFixed(1)); - else if (selectedMetric === 'rate') value = Number((((item.communicationResponseRate || 0) + (item.applicationResponseRate || 0)) / 2 * 100).toFixed(2)); + else if (selectedMetric === 'rate') { + // Use the accurate domain-specific rates from backend if available, or fall back to combined + const rate = item.communicationResponseRate || item.applicationResponseRate || 0; + value = Number((rate * 100).toFixed(1)); + } return { label: item.month, value }; }); const maxVal = Math.max(...data.map(d => d.value)) * 1.1 || 10; @@ -365,8 +378,8 @@ function PerformanceMonitoringContent() {
-

{metricsSummary?.communicationResponseRate !== undefined ? `${(metricsSummary.communicationResponseRate <= 1 ? metricsSummary.communicationResponseRate * 100 : metricsSummary.communicationResponseRate).toFixed(2)}%` : 'N/A'}

-

Response Rate

+

{metricsSummary?.combinedResponseRate !== undefined ? `${(metricsSummary.combinedResponseRate <= 1 ? metricsSummary.combinedResponseRate * 100 : metricsSummary.combinedResponseRate).toFixed(1)}%` : 'N/A'}

+

Org. Response Rate

diff --git a/client/src/pages/common/Contact.jsx b/client/src/pages/common/Contact.jsx index d4c5513..b84dd68 100644 --- a/client/src/pages/common/Contact.jsx +++ b/client/src/pages/common/Contact.jsx @@ -4,9 +4,9 @@ import Navigation1 from '../../components/Navigation1'; import Footer from '../../components/Footer'; import Navigation2 from '../../components/Navigation2'; import { useSearchParams } from 'react-router-dom'; -import { useAuth } from '../../auth/AuthContext'; +import { useAuth } from '../../hooks/useAuth'; import * as chatAPI from '../../api/chat.api'; -import { useChat } from '../../auth/ChatContext'; +import { useChat } from '../../hooks/useChat'; function Contact() { const { user } = useAuth(); diff --git a/client/src/pages/common/Landing.jsx b/client/src/pages/common/Landing.jsx index 998b946..7e296ea 100644 --- a/client/src/pages/common/Landing.jsx +++ b/client/src/pages/common/Landing.jsx @@ -6,7 +6,7 @@ import Navigation1 from '../../components/Navigation1'; import { Link, useNavigate } from 'react-router-dom'; import Footer from '../../components/Footer'; -import { useAuth } from '../../auth/AuthContext.jsx'; +import { useAuth } from '../../hooks/useAuth'; function Landing() { const { isAuthenticated, role, isLoading } = useAuth(); diff --git a/client/src/pages/common/Login.jsx b/client/src/pages/common/Login.jsx index 45479c7..4fea640 100644 --- a/client/src/pages/common/Login.jsx +++ b/client/src/pages/common/Login.jsx @@ -4,14 +4,14 @@ import '../../styles/common/Login.css'; import GoogleSvg from '../../assets/uil--google.svg'; import Navigation1 from '../../components/Navigation1'; import Footer from '../../components/Footer'; -import { useAuth } from '../../auth/AuthContext.jsx'; +import { useAuth } from '../../hooks/useAuth'; import { getGoogleAuthUrl } from '../../api/auth.api.js'; function Login() { const navigate = useNavigate(); const location = useLocation(); const { login, register, isAuthenticated, error: authError, clearError } = useAuth(); - + const [isLogin, setIsLogin] = useState(true); const [formData, setFormData] = useState({ name: '', @@ -21,7 +21,8 @@ function Login() { signupPassword: '', confirmPassword: '', idPhoto: null, - acceptTerms: false + acceptTerms: false, + rememberMe: false }); const [showLoginPassword, setShowLoginPassword] = useState(false); const [showSignupPassword, setShowSignupPassword] = useState(false); @@ -46,14 +47,14 @@ function Login() { // Show auth errors useEffect(() => { - if (authError) { + if (authError && authError !== "No refresh token provided") { setError(authError); } }, [authError]); const handleInputChange = (e) => { const { id, value, files, type, checked } = e.target; - + if (id === 'idphoto' && files && files.length > 0) { setFormData(prev => ({ ...prev, idPhoto: files[0] })); setFileName(files[0].name); @@ -69,7 +70,7 @@ function Login() { 'signup-password': 'signupPassword', 'confirm': 'confirmPassword', }; - + const fieldName = fieldMap[id] || id; setFormData(prev => ({ ...prev, [fieldName]: value })); } @@ -78,7 +79,7 @@ function Login() { const handleFileDrop = (e) => { e.preventDefault(); e.stopPropagation(); - + if (e.dataTransfer.files.length > 0) { const file = e.dataTransfer.files[0]; setFormData(prev => ({ ...prev, idPhoto: file })); @@ -104,7 +105,7 @@ function Login() { const result = await login({ email: formData.user, // Backend expects 'email' field password: formData.loginPassword, - rememberMe: false, // You can add a checkbox for this + rememberMe: formData.rememberMe, }); if (result.success) { @@ -177,272 +178,285 @@ function Login() { return ( <> - -
-
-
- - -
- -
- {/* Login Form */} -
-
-

Welcome Back!

- Sign in to continue -
- - {error && ( -
- {error} -
- )} - -
- - -
- -
- -
- - -
-
- - Forgot Password? - -
- - OR CONTINUE WITH -
-
- - {/* Sign Up Form */} -
-
-

Create Your Account

- Sign up to access all CiviLink Services -
- {error && ( -
- {error} -
- )} - -
- - -
- -
- - -
- -
- -
- - -
-
+
+ {/* Login Form */} + +
+

Welcome Back!

+ Sign in to continue +
-
- -
- - -
-
- -
- -
+ {error && ( +
+ {error} +
+ )} + +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
-
- -
Forgot Password? + +
+ + OR CONTINUE WITH + +
+ + + {/* Sign Up Form */} +
-
- +
+

Create Your Account

+ Sign up to access all CiviLink Services
-

Drag & Drop your ID photo here, or click to browse

- Max file size: 5MB - {fileName && ( -
- {fileName} + + {error && ( +
+ {error}
)} - -
-
-
- - OR CONTINUE WITH - +
+ + +
+ +
+ + +
+ +
+ +
+ + +
+
+ +
+ +
+ + +
+
+ +
+ +
+ +
+ +
+
+ +
+

Drag & Drop your ID photo here, or click to browse

+ Max file size: 5MB + {fileName && ( +
+ {fileName} +
+ )} + +
+
+ +
+ + OR CONTINUE WITH + +
+
- +
- -
-
+
); } diff --git a/client/src/pages/common/Notifications.jsx b/client/src/pages/common/Notifications.jsx index dbb30da..b4b85e7 100644 --- a/client/src/pages/common/Notifications.jsx +++ b/client/src/pages/common/Notifications.jsx @@ -11,9 +11,9 @@ */ import React, { useState, useEffect, useCallback } from 'react'; -import { useNotifications } from '../../auth/NotificationsContext.jsx'; -import { useAuth } from '../../auth/AuthContext.jsx'; -import { AuthGuard } from '../../auth/guards/AuthGuard.jsx'; +import { useNotifications } from '../../hooks/useNotifications'; +import { useAuth } from '../../hooks/useAuth'; +import { AuthGuard } from '../../guards/AuthGuard.jsx'; import AuthenticatedLayout from '../../components/layout/AuthenticatedLayout.jsx'; import '../../styles/common/Notifications.css'; @@ -82,133 +82,133 @@ const Notifications = () => {
-
-

Notifications

-
-
- - +
+

Notifications

+
+
+ + +
+ {unreadCount > 0 && filter === 'all' && ( + + )}
- {unreadCount > 0 && filter === 'all' && ( - - )}
-
- {error && ( -
- - {error} -
- )} + {error && ( +
+ + {error} +
+ )} - {isLoading && notifications.length === 0 ? ( -
- -

Loading notifications...

-
- ) : notifications.length === 0 ? ( -
- -

No notifications

-

- {filter === 'unread' - ? "You're all caught up! No unread notifications." - : "You don't have any notifications yet."} -

-
- ) : ( - <> -
- {notifications.map((notification) => ( -
-
-
-

{notification.title}

- {!notification.read && ( - New - )} -
-
- {!notification.read && ( + {isLoading && notifications.length === 0 ? ( +
+ +

Loading notifications...

+
+ ) : notifications.length === 0 ? ( +
+ +

No notifications

+

+ {filter === 'unread' + ? "You're all caught up! No unread notifications." + : "You don't have any notifications yet."} +

+
+ ) : ( + <> +
+ {notifications.map((notification) => ( +
+
+
+

{notification.title}

+ {!notification.read && ( + New + )} +
+
+ {!notification.read && ( + + )} - )} - +
+
+

{notification.message}

+
+ + + {formatDate(notification.createdAt)} +
-

{notification.message}

-
- - - {formatDate(notification.createdAt)} - -
-
- ))} -
- - {/* Pagination */} - {pagination.totalPages > 1 && ( -
- - - Page {pagination.page} of {pagination.totalPages} - - + ))}
- )} - - )} + + {/* Pagination */} + {pagination.totalPages > 1 && ( +
+ + + Page {pagination.page} of {pagination.totalPages} + + +
+ )} + + )}
diff --git a/client/src/pages/common/OAuthCallback.jsx b/client/src/pages/common/OAuthCallback.jsx index 173ce5f..f11611e 100644 --- a/client/src/pages/common/OAuthCallback.jsx +++ b/client/src/pages/common/OAuthCallback.jsx @@ -12,7 +12,7 @@ import React, { useEffect, useState } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; -import { useAuth } from '../../auth/AuthContext.jsx'; +import { useAuth } from '../../hooks/useAuth'; import * as userAPI from '../../api/user.api.js'; import '../../styles/common/OAuthCallback.css'; @@ -38,7 +38,7 @@ const OAuthCallback = () => { // Backend sets cookies, so we need to load user profile // This will verify the cookies and update auth state const result = await loadUser(); - + if (result.success && result.data) { // Auth state is now updated via loadUser // The second useEffect will handle redirect based on auth state diff --git a/client/src/pages/officer/MessageCenter.jsx b/client/src/pages/officer/MessageCenter.jsx index d36d790..2e015d1 100644 --- a/client/src/pages/officer/MessageCenter.jsx +++ b/client/src/pages/officer/MessageCenter.jsx @@ -4,7 +4,7 @@ import '../../styles/officer/MessageCenter.css'; import Footer from '../../components/Footer'; import Navigation2 from '../../components/Navigation2'; import OfficerSideBar from '../../components/OfficerSideBar'; -import { useChat } from '../../auth/ChatContext.jsx'; +import { useChat } from '../../hooks/useChat'; const MessageCenter = () => { const navigate = useNavigate(); diff --git a/client/src/pages/officer/OfficerDashboard.jsx b/client/src/pages/officer/OfficerDashboard.jsx index bd902ab..054cd08 100644 --- a/client/src/pages/officer/OfficerDashboard.jsx +++ b/client/src/pages/officer/OfficerDashboard.jsx @@ -4,10 +4,10 @@ import Navigation2 from '../../components/Navigation2'; import Footer from '../../components/Footer'; import OfficerSideBar from '../../components/OfficerSideBar'; import AuthenticatedLayout from '../../components/layout/AuthenticatedLayout.jsx'; -import { useAuth } from '../../auth/AuthContext.jsx'; +import { useAuth } from '../../hooks/useAuth'; import { usePermissions } from '../../hooks/usePermissions.js'; import * as officerAPI from '../../api/officer.api.js'; -import { useChat } from '../../auth/ChatContext.jsx'; +import { useChat } from '../../hooks/useChat'; import { useNavigate } from 'react-router-dom'; @@ -97,7 +97,7 @@ function OfficerDashboard() { // Handle next application click const handleNextApplication = () => { if (applicationsQueue.length > 0) { - navigate(`/officer/applications/${applicationsQueue[0]._id}`); + navigate(`/ officer / applications / ${applicationsQueue[0]._id} `); } else { alert('No pending applications in queue'); } @@ -106,7 +106,7 @@ function OfficerDashboard() { // Handle next chat click const handleNextChat = () => { if (conversationsQueue.length > 0) { - navigate(`/officer/messages`); + navigate(`/ officer / messages`); } else { alert('No pending chats in queue'); } @@ -115,13 +115,13 @@ function OfficerDashboard() { // Handle view application details const handleViewApplication = (appId) => { - alert(`Viewing application: ${appId}`); + alert(`Viewing application: ${appId} `); // In real app, navigate to application details }; // Handle reassign application const handleReassignApplication = (appId) => { - alert(`Reassigning application: ${appId}`); + alert(`Reassigning application: ${appId} `); // In real app, open reassign modal }; @@ -132,7 +132,7 @@ function OfficerDashboard() { app.id === appId ? { ...app, status: newStatus } : app ) ); - alert(`Application ${appId} status updated to: ${newStatus}`); + alert(`Application ${appId} status updated to: ${newStatus} `); }; // Calculate completion percentage for monthly target @@ -315,7 +315,7 @@ function OfficerDashboard() {
From: {chat.citizenId?.fullName || 'Citizen'}
- @@ -360,7 +360,7 @@ function OfficerDashboard() {
diff --git a/client/src/pages/officer/OfficerSettings.jsx b/client/src/pages/officer/OfficerSettings.jsx index bfd815d..d7249c9 100644 --- a/client/src/pages/officer/OfficerSettings.jsx +++ b/client/src/pages/officer/OfficerSettings.jsx @@ -1,8 +1,8 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../auth/AuthContext.jsx'; -import { AuthGuard } from '../../auth/guards/AuthGuard.jsx'; -import { RoleGuard } from '../../auth/guards/RoleGuard.jsx'; +import { useAuth } from '../../hooks/useAuth'; +import { AuthGuard } from '../../guards/AuthGuard.jsx'; +import { RoleGuard } from '../../guards/RoleGuard.jsx'; import AuthenticatedLayout from '../../components/layout/AuthenticatedLayout.jsx'; import * as userAPI from '../../api/user.api.js'; import '../../styles/officer/OfficerSettings.css'; diff --git a/client/src/pages/user/BirthForm.jsx b/client/src/pages/user/BirthForm.jsx index 705fa72..5252cf6 100644 --- a/client/src/pages/user/BirthForm.jsx +++ b/client/src/pages/user/BirthForm.jsx @@ -4,9 +4,9 @@ import { useNavigate, useSearchParams } from 'react-router-dom'; import FormSideBar from '../../components/FormSideBar'; import AuthenticatedLayout from '../../components/layout/AuthenticatedLayout'; -import { useAuth } from '../../auth/AuthContext'; -import { useProfileAssets } from '../../auth/ProfileAssetsContext'; -import { usePayment } from '../../auth/PaymentContext.jsx'; +import { useAuth } from '../../hooks/useAuth'; +import { useProfileAssets } from '../../hooks/useProfileAssets'; +import { usePayment } from '../../hooks/usePayment'; import PaymentModal from '../../components/common/PaymentModal'; import * as applicationsAPI from '../../api/applications.api'; import * as userAPI from '../../api/user.api'; diff --git a/client/src/pages/user/CitizenMessages.jsx b/client/src/pages/user/CitizenMessages.jsx index 4e73c07..3bd8dfd 100644 --- a/client/src/pages/user/CitizenMessages.jsx +++ b/client/src/pages/user/CitizenMessages.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; -import { useAuth } from '../../auth/AuthContext.jsx'; -import { useChat } from '../../auth/ChatContext.jsx'; +import { useAuth } from '../../hooks/useAuth'; +import { useChat } from '../../hooks/useChat'; import AuthenticatedLayout from '../../components/layout/AuthenticatedLayout.jsx'; import '../../styles/user/CitizenMessages.css'; diff --git a/client/src/pages/user/MarriageForm.jsx b/client/src/pages/user/MarriageForm.jsx index 29ddd03..86ca599 100644 --- a/client/src/pages/user/MarriageForm.jsx +++ b/client/src/pages/user/MarriageForm.jsx @@ -4,9 +4,9 @@ import '../../styles/user/MarriageForm.css'; import { useNavigate, useSearchParams } from "react-router-dom"; import AuthenticatedLayout from "../../components/layout/AuthenticatedLayout"; -import { useAuth } from '../../auth/AuthContext'; -import { useProfileAssets } from '../../auth/ProfileAssetsContext'; -import { usePayment } from '../../auth/PaymentContext'; +import { useAuth } from '../../hooks/useAuth'; +import { useProfileAssets } from '../../hooks/useProfileAssets'; +import { usePayment } from '../../hooks/usePayment'; import PaymentModal from '../../components/common/PaymentModal'; import * as applicationsAPI from '../../api/applications.api'; import * as userAPI from '../../api/user.api'; diff --git a/client/src/pages/user/PaymentResult.jsx b/client/src/pages/user/PaymentResult.jsx index a4ce3bc..4f114cf 100644 --- a/client/src/pages/user/PaymentResult.jsx +++ b/client/src/pages/user/PaymentResult.jsx @@ -7,7 +7,7 @@ import React, { useEffect, useState } from 'react'; import { useSearchParams, useNavigate } from 'react-router-dom'; -import { usePayment } from '../../auth/PaymentContext.jsx'; +import { usePayment } from '../../hooks/usePayment'; import Navigation2 from '../../components/Navigation2'; import Footer from '../../components/Footer'; diff --git a/client/src/pages/user/Settings.jsx b/client/src/pages/user/Settings.jsx index bf9431c..caba9bc 100644 --- a/client/src/pages/user/Settings.jsx +++ b/client/src/pages/user/Settings.jsx @@ -10,10 +10,10 @@ import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { useAuth } from '../../auth/AuthContext.jsx'; -import { AuthGuard } from '../../auth/guards/AuthGuard.jsx'; -import { useProfileAssets } from '../../auth/ProfileAssetsContext.jsx'; -import { RoleGuard } from '../../auth/guards/RoleGuard.jsx'; +import { useAuth } from '../../hooks/useAuth'; +import { useProfileAssets } from '../../hooks/useProfileAssets'; +import { AuthGuard } from '../../guards/AuthGuard.jsx'; +import { RoleGuard } from '../../guards/RoleGuard.jsx'; import AuthenticatedLayout from '../../components/layout/AuthenticatedLayout.jsx'; import LogoutModal from '../../components/common/LogoutModal.jsx'; import * as userAPI from '../../api/user.api.js'; diff --git a/client/src/pages/user/TIN.jsx b/client/src/pages/user/TIN.jsx index 69093ba..893017b 100644 --- a/client/src/pages/user/TIN.jsx +++ b/client/src/pages/user/TIN.jsx @@ -3,8 +3,8 @@ import { useNavigate } from 'react-router-dom'; import '../../styles/user/TIN.css'; import FormSideBar from '../../components/FormSideBar'; import AuthenticatedLayout from '../../components/layout/AuthenticatedLayout'; -import { useAuth } from '../../auth/AuthContext'; -import { useProfileAssets } from '../../auth/ProfileAssetsContext'; +import { useAuth } from '../../hooks/useAuth'; +import { useProfileAssets } from '../../hooks/useProfileAssets'; import PaymentModal from '../../components/common/PaymentModal'; import * as applicationsAPI from '../../api/applications.api'; import * as userAPI from '../../api/user.api'; diff --git a/client/src/pages/user/Tracking.jsx b/client/src/pages/user/Tracking.jsx index 8f3ba99..4d03c08 100644 --- a/client/src/pages/user/Tracking.jsx +++ b/client/src/pages/user/Tracking.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect } from 'react'; -import { useAuth } from '../../auth/AuthContext.jsx'; -import { useApplication } from '../../auth/ApplicationContext.jsx'; -import { AuthGuard } from '../../auth/guards/AuthGuard.jsx'; +import { useAuth } from '../../hooks/useAuth'; +import { useApplication } from '../../hooks/useApplication'; +import { AuthGuard } from '../../guards/AuthGuard.jsx'; import '../../styles/user/Tracking.css'; import Navigation2 from '../../components/Navigation2'; import SideBar1 from '../../components/Sidebar1'; diff --git a/client/src/pages/user/UserDashboard.jsx b/client/src/pages/user/UserDashboard.jsx index 5e9e7ad..ee0799a 100644 --- a/client/src/pages/user/UserDashboard.jsx +++ b/client/src/pages/user/UserDashboard.jsx @@ -10,9 +10,9 @@ */ import React, { useState, useEffect, useCallback } from 'react'; -import { useAuth } from '../../auth/AuthContext.jsx'; -import { AuthGuard } from '../../auth/guards/AuthGuard.jsx'; -import { RoleGuard } from '../../auth/guards/RoleGuard.jsx'; +import { useAuth } from '../../hooks/useAuth'; +import { AuthGuard } from '../../guards/AuthGuard.jsx'; +import { RoleGuard } from '../../guards/RoleGuard.jsx'; import AuthenticatedLayout from '../../components/layout/AuthenticatedLayout.jsx'; import * as applicationsAPI from '../../api/applications.api.js'; import NewsSlider from '../../components/NewsSlider.jsx'; diff --git a/client/src/reducers/authReducer.js b/client/src/reducers/authReducer.js index 33869c1..6bf427d 100644 --- a/client/src/reducers/authReducer.js +++ b/client/src/reducers/authReducer.js @@ -103,6 +103,8 @@ export const authReducer = (state = initialState, action) => { case authActions.REFRESH_TOKEN_FAILURE: return { ...state, + user: null, + isAuthenticated: false, isRefreshing: false, error: action.payload, }; diff --git a/client/src/routes/AdminRoutes.jsx b/client/src/routes/AdminRoutes.jsx index c5f4bc4..fa46b2d 100644 --- a/client/src/routes/AdminRoutes.jsx +++ b/client/src/routes/AdminRoutes.jsx @@ -1,4 +1,6 @@ import{Routes,Route}from'react-router-dom'; +import { AuthGuard } from '../guards/AuthGuard.jsx'; +import { RoleGuard } from '../guards/RoleGuard.jsx'; import AdminDashboard from'../pages/admin/AdminDashboard'; import ManageOfficers from '../pages/admin/ManageOfficers'; import PerformanceMonitoring from '../pages/admin/PerformanceMonitoring'; @@ -6,17 +8,13 @@ import SecurityReport from '../pages/admin/SecurityReport'; import AdminSettings from '../pages/admin/AdminSettings'; function AdminRoutes(){ return( - <> -
- }> - }> - }> - }> - }> + }> + }> + }> + }> + }> -
- ); } diff --git a/client/src/routes/CommonRoutes.jsx b/client/src/routes/CommonRoutes.jsx index 6b624fd..de995b5 100644 --- a/client/src/routes/CommonRoutes.jsx +++ b/client/src/routes/CommonRoutes.jsx @@ -12,9 +12,6 @@ import OAuthCallback from '../pages/common/OAuthCallback'; function CommonRoutes(){ return ( - <> -
- {/*common pages*/} }> @@ -25,13 +22,6 @@ function CommonRoutes(){ }> }> - -
- - - - - ) } diff --git a/client/src/routes/OfficerRoutes.jsx b/client/src/routes/OfficerRoutes.jsx index 2344eda..00c68e4 100644 --- a/client/src/routes/OfficerRoutes.jsx +++ b/client/src/routes/OfficerRoutes.jsx @@ -1,16 +1,14 @@ import { Routes, Route } from 'react-router-dom'; -import { AuthGuard } from '../auth/guards/AuthGuard.jsx'; -import { RoleGuard } from '../auth/guards/RoleGuard.jsx'; +import { AuthGuard } from '../guards/AuthGuard.jsx'; +import { RoleGuard } from '../guards/RoleGuard.jsx'; import OfficerDashboard from '../pages/officer/OfficerDashboard'; import ApplicationRequests from '../pages/officer/ApplicationRequests'; -import ApplicationDetails from '../pages/officer/ApplicationDetails'; import MessageCenter from '../pages/officer/MessageCenter'; import OfficerSettings from '../pages/officer/OfficerSettings'; import OfficerNewsManagement from '../pages/officer/OfficerNewsManagement'; function OfficerRoutes() { return ( -
-
); } diff --git a/client/src/routes/UserRoutes.jsx b/client/src/routes/UserRoutes.jsx index 3ecbc4b..44277d4 100644 --- a/client/src/routes/UserRoutes.jsx +++ b/client/src/routes/UserRoutes.jsx @@ -1,7 +1,7 @@ import { Routes, Route } from 'react-router-dom'; -import { AuthGuard } from '../auth/guards/AuthGuard.jsx'; -import { RoleGuard } from '../auth/guards/RoleGuard.jsx'; -import { IDGuard } from '../auth/guards/IDGuard.jsx'; +import { AuthGuard } from '../guards/AuthGuard.jsx'; +import { RoleGuard } from '../guards/RoleGuard.jsx'; +import { IDGuard } from '../guards/IDGuard.jsx'; import UserDashboard from '../pages/user/UserDashboard'; import Tracking from '../pages/user/Tracking'; import MarriageForm from '../pages/user/MarriageForm'; @@ -13,7 +13,6 @@ import PaymentResult from '../pages/user/PaymentResult'; function UserRoutes() { return ( -
{/* ... existing routes ... */} -
) }; diff --git a/client/src/styles/admin/ManageOfficers.css b/client/src/styles/admin/ManageOfficers.css index f806bd8..ea34e20 100644 --- a/client/src/styles/admin/ManageOfficers.css +++ b/client/src/styles/admin/ManageOfficers.css @@ -595,7 +595,7 @@ .manage-officers .form-group select:focus { outline: none; border-color: #258cf4; - ring: 2px solid rgba(37, 140, 244, 0.1); + outline: 2px solid rgba(37, 140, 244, 0.1); } .manage-officers .permissions-grid { diff --git a/client/src/styles/common/Landing.css b/client/src/styles/common/Landing.css index 407ff11..29235ca 100644 --- a/client/src/styles/common/Landing.css +++ b/client/src/styles/common/Landing.css @@ -214,6 +214,7 @@ .landing-container .card:hover .icon-container { transform: scale(1.1) rotate(5deg); background: linear-gradient(135deg, #258cf4, #0077cc); + } .landing-container .card svg { @@ -222,6 +223,10 @@ transition: all 0.4s ease; } +.landing-container .card:hover svg { + background-color: white; +} + .landing-container .card h3 { font-size: 1.5rem; font-weight: 700; diff --git a/client/src/styles/common/Login.css b/client/src/styles/common/Login.css index b64b566..2c7e977 100644 --- a/client/src/styles/common/Login.css +++ b/client/src/styles/common/Login.css @@ -1,310 +1,321 @@ - .login-container { - font-family: 'Poppins', sans-serif; - background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%); - display: flex; - flex-direction: column; - justify-content: center; - align-items: center; - min-height: 100vh; - padding: 120px; - color: #333; - } +.login-container { + font-family: 'Poppins', sans-serif; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + min-height: 100vh; + margin: 120px auto; + width: 100%; + background-color: white; +} - .login-container .login-signup { - width: 100%; - max-width: 480px; - background: white; - border-radius: 20px; - box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07); - overflow: hidden; - position: relative; - } +.login-container .login-signup { + width: 100%; + max-width: 480px; + background: white; + border-radius: 20px; + box-shadow: 0 15px 35px rgba(50, 50, 93, 0.1), 0 5px 15px rgba(0, 0, 0, 0.07); + overflow: hidden; + position: relative; +} - .login-container .switch-btn { - display: flex; - background-color: #f8f9fa; - border-bottom: 1px solid #eaeaea; - padding: 5px; - margin: 10px; - border-radius: 12px; - } +.login-container .switch-btn { + display: flex; + background-color: #f8f9fa; + border-bottom: 1px solid #eaeaea; + padding: 5px; + margin: 10px; + border-radius: 12px; +} - .login-container .switch-btn button { - flex: 1; - padding: 16px 20px; - border: none; - background: transparent; - font-size: 17px; - font-weight: 600; - color: #666; - cursor: pointer; - border-radius: 10px; - transition: all 0.3s ease; - position: relative; - z-index: 1; - } +.login-container .switch-btn button { + flex: 1; + padding: 16px 20px; + border: none; + background: transparent; + font-size: 17px; + font-weight: 600; + color: #666; + cursor: pointer; + border-radius: 10px; + transition: all 0.3s ease; + position: relative; + z-index: 1; +} - .login-container .switch-btn button.active { - color: #4361ee; - background-color: white; - box-shadow: 0 4px 12px rgba(67, 97, 238, 0.15); - } +.login-container .switch-btn button.active { + color: #4361ee; + background-color: white; + box-shadow: 0 4px 12px rgba(67, 97, 238, 0.15); +} - .login-container .switch-btn button:hover:not(.active) { - color: #4361ee; - background-color: rgba(67, 97, 238, 0.05); - } +.login-container .switch-btn button:hover:not(.active) { + color: #4361ee; + background-color: rgba(67, 97, 238, 0.05); +} - .login-container .form-container { - position: relative; - padding: 30px; - overflow: hidden; - } +.login-container .form-container { + position: relative; + padding: 30px; + overflow: hidden; +} - .login-container .login-form, .login-container .signup-form { - transition: transform 0.4s ease, opacity 0.4s ease; - } +.login-container .login-form, .login-container .signup-form { + transition: transform 0.4s ease, opacity 0.4s ease; +} - .login-container .login-form.hidden, +.login-container .login-form.hidden, .login-container .signup-form.hidden { - display: none; +display: none; } .login-container .login-form:not(.hidden), .login-container .signup-form:not(.hidden) { - display: block; - animation: fadeIn 0.5s ease forwards; +display: block; +animation: fadeIn 0.5s ease forwards; } - .login-container .welcome-text { - text-align: center; - margin-bottom: 30px; - } +.login-container .welcome-text { + text-align: center; + margin-bottom: 30px; +} - .login-container .welcome-text h1 { - font-size: 28px; - font-weight: 700; - color: #2d3748; - margin-bottom: 8px; - } +.login-container .welcome-text h1 { + font-size: 28px; + font-weight: 700; + color: #2d3748; + margin-bottom: 8px; +} - .login-container .welcome-text span { - font-size: 15px; - color: #718096; - font-weight: 400; - } +.login-container .welcome-text span { + font-size: 15px; + color: #718096; + font-weight: 400; +} - .login-container .data { - margin-bottom: 22px; - } +.login-container .data { + margin-bottom: 22px; +} - .login-container .data label { - display: block; - font-size: 14px; - font-weight: 500; - color: #4a5568; - margin-bottom: 8px; - } +.login-container .data label { + display: flex; + font-size: 14px; + font-weight: 500; + color: #4a5568; + margin-bottom: 8px; +} - .login-container .data input { - width: 100%; - padding: 15px 18px; - border: 2px solid #e2e8f0; - border-radius: 10px; - font-size: 16px; - font-family: 'Poppins', sans-serif; - transition: all 0.3s; - outline: none; - } +.login-container .data input { + width: 100%; + padding: 15px 18px; + border: 2px solid #e2e8f0; + border-radius: 10px; + font-size: 16px; + font-family: 'Poppins', sans-serif; + transition: all 0.3s; + outline: none; +} - .login-container .data input:focus { - border-color: #4361ee; - box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.2); - } +.login-container .data input:focus { + border-color: #4361ee; + box-shadow: 0 0 0 3px rgba(67, 97, 238, 0.2); +} - .login-container .data input::placeholder { - color: #a0aec0; - } +.login-container .data input::placeholder { + color: #a0aec0; +} - .login-container .password-container { - position: relative; - } +.login-container .password-container { + position: relative; +} - .login-container .password-toggle { - position: absolute; - right: 15px; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: #a0aec0; - cursor: pointer; - font-size: 18px; - } +.login-container .password-toggle { + position: absolute; + right: 15px; + top: 50%; + transform: translateY(-50%); + background: none; + border: none; + color: #a0aec0; + cursor: pointer; + font-size: 18px; +} - .login-container #forgot { - display: block; - text-align: right; - color: #4361ee; - font-size: 14px; - font-weight: 500; - margin-top: -10px; - margin-bottom: 25px; - text-decoration: none; - transition: color 0.2s; - } +.login-container #forgot { + display: block; + text-align: right; + color: #4361ee; + font-size: 14px; + font-weight: 500; + margin-top: -10px; + margin-bottom: 25px; + text-decoration: none; + transition: color 0.2s; +} - .login-container #forgot:hover { - color: #3a56d4; - text-decoration: underline; - } +.login-container #forgot:hover { + color: #3a56d4; + text-decoration: underline; +} - .login-container .form-foot { - margin-top: 10px; - } +.login-container .form-foot { + margin-top: 10px; +} - .login-container .form-btn { - width: 100%; - padding: 17px; - background: linear-gradient(to right, #4361ee, #3a56d4); - color: white; - border: none; - border-radius: 10px; - font-size: 16px; - font-weight: 600; - cursor: pointer; - transition: all 0.3s; - box-shadow: 0 4px 15px rgba(67, 97, 238, 0.3); - margin-bottom: 25px; - } +.login-container .form-btn { + width: 100%; + padding: 17px; + background: linear-gradient(to right, #4361ee, #3a56d4); + color: white; + border: none; + border-radius: 10px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + box-shadow: 0 4px 15px rgba(67, 97, 238, 0.3); + margin-bottom: 25px; +} - .login-container .form-btn:hover { - background: linear-gradient(to right, #3a56d4, #2e49c4); - transform: translateY(-2px); - box-shadow: 0 6px 20px rgba(67, 97, 238, 0.4); - } +.login-container .form-btn:hover { + background: linear-gradient(to right, #3a56d4, #2e49c4); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(67, 97, 238, 0.4); +} - .login-container .form-btn:active { - transform: translateY(0); - } +.login-container .form-btn:active { + transform: translateY(0); +} - .login-container .form-foot span { - display: block; - text-align: center; - color: #a0aec0; - font-size: 14px; - margin-bottom: 20px; - position: relative; - } +.login-container .form-foot span { + display: block; + text-align: center; + color: #a0aec0; + font-size: 14px; + margin-bottom: 20px; + position: relative; +} - .login-container .form-foot span::before, .login-container .form-foot span::after { - content: ""; - position: absolute; - top: 50%; - width: 40%; - height: 1px; - background-color: #e2e8f0; - } +.login-container .form-foot span::before, .login-container .form-foot span::after { + content: ""; + position: absolute; + top: 50%; + width: 40%; + height: 1px; + background-color: #e2e8f0; +} - .login-container .form-foot span::before { - left: 0; - } +.login-container .form-foot span::before { + left: 0; +} - .login-container .form-foot span::after { - right: 0; - } +.login-container .form-foot span::after { + right: 0; +} - .login-container .google-btn { - width: 100%; - padding: 16px; - background-color: white; - color: #4a5568; - border: 2px solid #e2e8f0; - border-radius: 10px; - font-size: 16px; - font-weight: 500; - cursor: pointer; - transition: all 0.3s; - display: flex; - justify-content: center; - align-items: center; - gap: 12px; - } +.login-container .google-btn { + width: 100%; + padding: 16px; + background-color: white; + color: #4a5568; + border: 2px solid #e2e8f0; + border-radius: 10px; + font-size: 16px; + font-weight: 500; + cursor: pointer; + transition: all 0.3s; + display: flex; + justify-content: center; + align-items: center; + gap: 12px; +} - .login-container .google-btn:hover { - background-color: #f8f9fa; - border-color: #cbd5e0; - transform: translateY(-2px); - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); - } +.login-container .google-btn:hover { + background-color: #f8f9fa; + border-color: #cbd5e0; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05); +} - .login-container .google-btn img { - width: 20px; - height: 20px; - } +.login-container .google-btn img { + width: 20px; + height: 20px; +} - .login-container .filedrop { - border: 2px dashed #cbd5e0; - border-radius: 10px; - padding: 25px 15px; - text-align: center; - cursor: pointer; - transition: all 0.3s; - background-color: #f8f9fa; - } +.login-container .filedrop { + border: 2px dashed #cbd5e0; + border-radius: 10px; + padding: 25px 15px; + text-align: center; + cursor: pointer; + transition: all 0.3s; + background-color: #f8f9fa; +} - .login-container .filedrop:hover { - border-color: #4361ee; - background-color: rgba(67, 97, 238, 0.03); - } +.login-container .filedrop:hover { + border-color: #4361ee; + background-color: rgba(67, 97, 238, 0.03); +} - .login-container .filedrop p { - color: #4a5568; - font-weight: 500; - margin-bottom: 8px; - } +.login-container .filedrop p { + color: #4a5568; + font-weight: 500; + margin-bottom: 8px; +} - .login-container .filedrop small { - color: #a0aec0; - font-size: 13px; - } +.login-container .filedrop small { + color: #a0aec0; + font-size: 13px; +} - .login-container .file-upload-icon { - font-size: 40px; - color: #4361ee; - margin-bottom: 15px; - } +.login-container .file-upload-icon { + font-size: 40px; + color: #4361ee; + margin-bottom: 15px; +} - .login-container .file-name { - font-size: 14px; - color: #4a5568; - margin-top: 10px; - font-weight: 500; - } +.login-container .file-name { + font-size: 14px; + color: #4a5568; + margin-top: 10px; + font-weight: 500; +} - @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } - } +.data label #acceptTerms, .data label #rememberMe { + width: 20px; + margin-right: 10px; + aspect-ratio: 1 / 1; + cursor: pointer; +} - .login-container .login-form, .login-container .signup-form { - animation: fadeIn 0.5s ease forwards; - } +.remember-me-container { + margin-bottom: 20px; +} + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.login-container .login-form, .login-container .signup-form { + animation: fadeIn 0.5s ease forwards; +} - @media (max-width: 576px) { - .login-container .login-signup { - max-width: 100%; - } - - .login-container .form-container { - padding: 20px; - } - - .login-container .switch-btn { - margin: 10px 10px 0; - } - - .login-container .welcome-text h1 { - font-size: 24px; - } +@media (max-width: 576px) { + .login-container .login-signup { + max-width: 90%; + } + + .login-container .form-container { + padding: 20px; + } + + .login-container .switch-btn { + margin: 10px 10px 0; } + + .login-container .welcome-text h1 { + font-size: 24px; + } +} diff --git a/client/src/styles/common/Notifications.css b/client/src/styles/common/Notifications.css index 453813b..1409d6d 100644 --- a/client/src/styles/common/Notifications.css +++ b/client/src/styles/common/Notifications.css @@ -4,7 +4,7 @@ .notifications-page { min-height: 100vh; - background: #f5f7fa; + background: #f8fafc; padding: 2rem; } diff --git a/client/src/styles/components/Footer.css b/client/src/styles/components/Footer.css index 10106f0..64d7581 100644 --- a/client/src/styles/components/Footer.css +++ b/client/src/styles/components/Footer.css @@ -19,323 +19,323 @@ height: 4px; background: linear-gradient(90deg, #258cf4, #0077cc); } - .footer-container { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 3rem; - max-width: 1200px; - margin: 0 auto; - padding: 0 clamp(2rem, 5vw, 4rem); - position: relative; - z-index: 2; - } - - /* Footer sections */ - .footer-section { - animation: fadeInUpFooter 0.6s ease forwards; - opacity: 0; - } - - .footer-section:nth-child(1) { animation-delay: 0.1s; } - .footer-section:nth-child(2) { animation-delay: 0.2s; } - .footer-section:nth-child(3) { animation-delay: 0.3s; } - .footer-section:nth-child(4) { animation-delay: 0.4s; } - - /* Logo section */ - .footer-section.logo-section { - grid-column: span 2; - display: flex; - flex-direction: column; - align-items: flex-start; - } - - .logo-wrapper { - display: flex; - align-items: center; - gap: 1rem; - margin-bottom: 1.5rem; - } - - .logo-wrapper img { - width: 60px; - height: 60px; - border-radius: 12px; - object-fit: contain; - background: white; - padding: 0.5rem; - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); - } - - .logo-text { - font-size: 1.8rem; - font-weight: 700; - background: linear-gradient(to right, #fff, #a6d1ff); - -webkit-background-clip: text; - background-clip: text; - color: transparent; - } - - .tagline { - color: #cbd5e1; - line-height: 1.6; - font-size: 1.1rem; - max-width: 320px; - } - - /* Company and Legal sections */ - .footer-section h4 { - font-size: 1.2rem; - font-weight: 600; - color: white; - margin-bottom: 1.5rem; - position: relative; - padding-bottom: 0.75rem; - } - - .footer-section h4::after { - content: ''; - position: absolute; - bottom: 0; - left: 0; - width: 40px; - height: 2px; - background: #258cf4; - } - - .footer-section ul { - list-style: none; - padding: 0; - margin: 0; - } - - .footer-section li { - margin-bottom: 0.75rem; - } - - .footer-section a { - color: #cbd5e1; - text-decoration: none; - font-size: 1rem; - transition: all 0.3s ease; - display: inline-flex; - align-items: center; - gap: 0.5rem; - } - - .footer-section a:hover { - color: white; - transform: translateX(5px); - } - - .footer-section a:hover::before { - content: '→'; - display: inline-block; - } - - /* Social media section */ - .social-section h4 { - margin-bottom: 1.5rem; - } - - .icon-links { - display: flex; - gap: 1rem; - margin-bottom: 2rem; - } - - .icon-links a { - display: inline-flex; - align-items: center; - justify-content: center; - width: 44px; - height: 44px; - background: rgba(255, 255, 255, 0.1); - border-radius: 12px; - transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); - position: relative; - overflow: hidden; - } - - .icon-links a::before { - content: ''; - position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - background: linear-gradient(135deg, #258cf4, #0077cc); - opacity: 0; - transition: opacity 0.3s ease; - border-radius: 12px; - } - - .icon-links a:hover::before { - opacity: 1; - } - - .icon-links a:hover { - transform: translateY(-3px); - box-shadow: 0 8px 20px rgba(37, 140, 244, 0.3); - } - - .icon-links svg { - width: 24px; - height: 24px; - fill: white; - position: relative; - z-index: 1; - transition: fill 0.3s ease; - } - - .icon-links a:hover svg { - fill: white; - } - - /* Contact info */ - .contact-info { - display: flex; - flex-direction: column; - gap: 1rem; - margin-top: 1.5rem; - } - - .contact-item { - display: flex; - align-items: flex-start; - gap: 0.75rem; - color: #cbd5e1; - font-size: 0.95rem; - } - - .contact-item svg { - width: 18px; - height: 18px; - fill: #258cf4; - flex-shrink: 0; - margin-top: 0.2rem; - } - - /* Footer bottom */ - .footer-wrapper hr { - border: none; - height: 1px; - background: rgba(255, 255, 255, 0.1); - margin: 3rem auto; - max-width: 1200px; - } - - .copyright-section { - display: flex; - justify-content: space-between; - align-items: center; - max-width: 1200px; - margin: 0 auto; - padding: 0 clamp(2rem, 5vw, 4rem) 2rem; - flex-wrap: wrap; - gap: 1rem; - } - - .copyright { - color: #94a3b8; - font-size: 0.95rem; - margin: 0; - } - - .legal-links { - display: flex; - gap: 2rem; - } - - .legal-links a { - color: #94a3b8; - text-decoration: none; - font-size: 0.9rem; - transition: color 0.3s ease; - } - - .legal-links a:hover { - - color: white; - } - - @keyframes fadeInUpFooter { - from { - opacity: 0; - transform: translateY(20px); - } - to { - opacity: 1; - transform: translateY(0); - } - } - - @media (max-width: 1100px) { - /* Footer */ - .footer-container { - grid-template-columns: repeat(2, 1fr); - gap: 2rem; - } - - .footer-section.logo-section { - grid-column: span 2; - } - } - - @media (max-width:768px){ - .footer-container { - grid-template-columns: 1fr; - gap: 2.5rem; - } - - .footer-section.logo-section { - grid-column: span 1; - } - - .copyright-section { - flex-direction: column; - text-align: center; - gap: 1rem; - } - - .legal-links { - justify-content: center; - flex-wrap: wrap; - gap: 1rem 2rem; - } - - .logo-wrapper { - justify-content: center; - } - - .tagline { - text-align: center; - } - } - - @media (max-width:480px){ - /* Footer */ - footer { - padding-top: 3rem; - } - - .footer-container { - padding: 0 1.5rem; - } - - .icon-links { - justify-content: center; - } - - .footer-section h4 { - text-align: center; - } - - .footer-section h4::after { - left: 50%; - transform: translateX(-50%); - } - - .footer-section ul { - text-align: center; - } - } +.footer-container { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 3rem; + max-width: 1200px; + margin: 0 auto; + padding: 0 clamp(2rem, 5vw, 4rem); + position: relative; + z-index: 2; +} + +/* Footer sections */ +.footer-section { + animation: fadeInUpFooter 0.6s ease forwards; + opacity: 0; +} + +.footer-section:nth-child(1) { animation-delay: 0.1s; } +.footer-section:nth-child(2) { animation-delay: 0.2s; } +.footer-section:nth-child(3) { animation-delay: 0.3s; } +.footer-section:nth-child(4) { animation-delay: 0.4s; } + +/* Logo section */ +.footer-section.logo-section { + grid-column: span 2; + display: flex; + flex-direction: column; + align-items: flex-start; +} + +.logo-wrapper { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; +} + +.logo-wrapper img { + width: 60px; + height: 60px; + border-radius: 12px; + object-fit: contain; + background: white; + padding: 0.5rem; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2); +} + +.logo-text { + font-size: 1.8rem; + font-weight: 700; + background: linear-gradient(to right, #fff, #a6d1ff); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.tagline { + color: #cbd5e1; + line-height: 1.6; + font-size: 1.1rem; + max-width: 320px; +} + +/* Company and Legal sections */ +.footer-section h4 { + font-size: 1.2rem; + font-weight: 600; + color: white; + margin-bottom: 1.5rem; + position: relative; + padding-bottom: 0.75rem; +} + +.footer-section h4::after { + content: ''; + position: absolute; + bottom: 0; + left: 0; + width: 40px; + height: 2px; + background: #258cf4; +} + +.footer-section ul { + list-style: none; + padding: 0; + margin: 0; +} + +.footer-section li { + margin-bottom: 0.75rem; +} + +.footer-section a { + color: #cbd5e1; + text-decoration: none; + font-size: 1rem; + transition: all 0.3s ease; + display: inline-flex; + align-items: center; + gap: 0.5rem; +} + +.footer-section a:hover { + color: white; + transform: translateX(5px); +} + +.footer-section a:hover::before { + content: '→'; + display: inline-block; +} + +/* Social media section */ +.social-section h4 { + margin-bottom: 1.5rem; +} + +.icon-links { + display: flex; + gap: 1rem; + margin-bottom: 2rem; +} + +.icon-links a { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + background: rgba(255, 255, 255, 0.1); + border-radius: 12px; + transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1); + position: relative; + overflow: hidden; +} + +.icon-links a::before { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: transparent; + opacity: 0; + transition: opacity 0.3s ease; + border-radius: 12px; +} + +.icon-links a:hover { + transform: translateY(-3px); + box-shadow: 0 8px 20px rgba(37, 140, 244, 0.3); +} + +.icon-links svg { + width: 24px; + height: 24px; + fill: white; + position: relative; + z-index: 1; + transition: fill 0.3s ease; +} + +.icon-links a:hover svg { + fill: white; +} + +/* Contact info */ +.contact-info { + display: flex; + flex-direction: column; + gap: 1rem; + margin-top: 1.5rem; +} + +.contact-item { + display: flex; + align-items: flex-start; + gap: 0.75rem; + color: #cbd5e1; + font-size: 0.95rem; +} + +.contact-item svg { + width: 18px; + height: 18px; + fill: #258cf4; + flex-shrink: 0; + margin-top: 0.2rem; +} + +/* Footer bottom */ +.footer-wrapper hr { + border: none; + height: 1px; + background: rgba(255, 255, 255, 0.1); + margin: 3rem auto; + max-width: 1200px; +} + +.copyright-section { + display: flex; + justify-content: space-between; + align-items: center; + max-width: 1200px; + margin: 0 auto; + padding: 0 clamp(2rem, 5vw, 4rem) 2rem; + flex-wrap: wrap; + gap: 1rem; +} + +.copyright { + color: #94a3b8; + font-size: 0.95rem; + margin: 0; +} + +.legal-links { + display: flex; + gap: 2rem; +} + +.legal-links a { + color: #94a3b8; + text-decoration: none; + font-size: 0.9rem; + transition: color 0.3s ease; +} + +.legal-links a:hover { + + color: white; +} + +@keyframes fadeInUpFooter { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@media (max-width: 1100px) { +/* Footer */ + .footer-container { + grid-template-columns: repeat(2, 1fr); + gap: 2rem; + } + + .footer-section.logo-section { + grid-column: span 2; + } +} + +@media (max-width:768px){ +.footer-container { + grid-template-columns: 1fr; + gap: 2.5rem; + } + + .footer-section.logo-section { + grid-column: span 1; + } + + .copyright-section { + flex-direction: column; + text-align: center; + gap: 1rem; + } + + .legal-links { + justify-content: center; + flex-wrap: wrap; + gap: 1rem 2rem; + } + + .logo-wrapper { + justify-content: center; + } + + .tagline { + text-align: center; + } +} + +@media (max-width:480px){ + /* Footer */ + footer { + padding-top: 3rem; + } + + .footer-container { + padding: 0 1.5rem; + } + + .icon-links { + justify-content: center; + } + + .footer-section h4 { + text-align: center; + } + + .footer-section h4::after { + left: 50%; + transform: translateX(-50%); + } + + .footer-section ul { + text-align: center; + } + + .footer .tagline{ + text-align: left; + } +} diff --git a/client/src/styles/components/Navigation1.css b/client/src/styles/components/Navigation1.css index d79a1e0..3569c23 100644 --- a/client/src/styles/components/Navigation1.css +++ b/client/src/styles/components/Navigation1.css @@ -26,8 +26,8 @@ align-items: center; gap: 10px; text-decoration: none; - font-size: 2rem; - font-weight: 900; + font-size: 1.5rem; + font-weight: 700; color: #258cf4; } diff --git a/client/src/components/notifications/NotificationBell.css b/client/src/styles/components/NotificationBell.css similarity index 100% rename from client/src/components/notifications/NotificationBell.css rename to client/src/styles/components/NotificationBell.css diff --git a/client/src/styles/officer/OfficerSettings.css b/client/src/styles/officer/OfficerSettings.css index 83c7fcc..770b9d9 100644 --- a/client/src/styles/officer/OfficerSettings.css +++ b/client/src/styles/officer/OfficerSettings.css @@ -1,6 +1,7 @@ /* ===== OFFICER SETTINGS MAIN LAYOUT ===== */ .officer-settings { display: flex; + flex-direction: column; min-height: 100vh; background: #f1f5f9; font-family: 'Poppins', sans-serif; diff --git a/client/src/utils/api.js b/client/src/utils/api.js index ba23a44..301395d 100644 --- a/client/src/utils/api.js +++ b/client/src/utils/api.js @@ -6,14 +6,7 @@ import { API_BASE_URL } from '../config/backend.js'; * @returns {Object} Normalized error object */ export const normalizeError = (error) => { - if (error.response) { - // Axios-style error (if we add axios later) - return { - message: error.response.data?.message || error.response.data?.error?.message || 'An error occurred', - status: error.response.status, - data: error.response.data - }; - } + if (error instanceof Response) { // Fetch API error @@ -31,6 +24,15 @@ export const normalizeError = (error) => { }; }; +/** + * Makes an API request with automatic token handling + * @param {string} endpoint - API endpoint (without base URL) + * @param {Object} options - Fetch options + * @returns {Promise} Response data + */ +let refreshHandler = null; +let refreshPromise = null; + /** * Makes an API request with automatic token handling * @param {string} endpoint - API endpoint (without base URL) @@ -56,12 +58,28 @@ export const apiRequest = async (endpoint, options = {}) => { // Skip if this IS the refresh request (prevent infinite loop) if (response.status === 401 && !options._retry && refreshHandler && !endpoint.includes('refresh-token')) { try { - const refreshResult = await refreshHandler(); + // Singleton pattern: If a refresh is already in flight, wait for it + if (!refreshPromise) { + refreshPromise = refreshHandler(); + } + + const refreshResult = await refreshPromise; + + // Reset promise after it completes (success or fail) + // so that future 401s after a NEW session expiration can trigger a new refresh + if (refreshPromise) { + refreshPromise = null; + } + if (refreshResult && refreshResult.success) { // Retry original request with _retry flag return apiRequest(endpoint, { ...options, _retry: true }); + } else { + // If refresh failed, don't retry, just bubble up the error + throw new Error('Session expired. Please log in again.'); } } catch (err) { + refreshPromise = null; console.error('Token refresh failed:', err); throw new Error('Session expired. Please log in again.'); } @@ -92,8 +110,6 @@ export const apiRequest = async (endpoint, options = {}) => { } }; -let refreshHandler = null; - /** * Registers a function to handle token refresh attempts * @param {Function} handler - Function that returns Promise<{success: boolean}> diff --git a/server/package.json b/server/package.json index 8bd1ee5..d7b9741 100644 --- a/server/package.json +++ b/server/package.json @@ -49,4 +49,4 @@ "mongodb-memory-server": "^10.4.1", "supertest": "^7.1.4" } -} +} \ No newline at end of file diff --git a/server/src/controllers/adminController.js b/server/src/controllers/adminController.js index 2457a34..3d5451d 100644 --- a/server/src/controllers/adminController.js +++ b/server/src/controllers/adminController.js @@ -12,7 +12,7 @@ const searchUser = async (req, res) => { if (!name && !email) { return res.status(400).json({ success: false, - message: "Either name or email query parameter is required" + error: {message: "Either name or email query parameter is required"} }); }; @@ -33,14 +33,18 @@ const searchUser = async (req, res) => { res.status(200).json({ success: true, - count: users.length, - citizens: users + data: { + count: users.length, + citizens: users + } }) } catch (err) { res.status(500).json({ success: false, - message: err.message + error:{ + message: err.message + } }) } } @@ -49,11 +53,10 @@ const assignOfficer = async (req, res) => { try { const { userId, department, subcity, adminPassword } = req.body; - /// if (!userId || !department || !subcity || !adminPassword) { return res.status(400).json({ success: false, - message: "Missing required fields" + error: {message: "Missing required fields"} }); }; @@ -61,7 +64,7 @@ const assignOfficer = async (req, res) => { if (!mongoose.Types.ObjectId.isValid(userId)) { return res.status(400).json({ success: false, - message: "Invalid userId" + error: {message: "Invalid userId"} }); }; @@ -70,7 +73,7 @@ const assignOfficer = async (req, res) => { if (!admin || !admin.password) { return res.status(401).json({ success: false, - message: "Admin authentication failed" + error: {message: "Admin authentication failed"} }); } @@ -81,7 +84,7 @@ const assignOfficer = async (req, res) => { .status(401) .json({ success: false, - message: "Invalid admin password" + error: {message: "Invalid admin password"} }); }; @@ -89,7 +92,7 @@ const assignOfficer = async (req, res) => { if (!allowedDepartments.includes(department)) { return res.status(400).json({ success: false, - message: "Invalid department" + error:{message: "Invalid department"} }); } @@ -100,7 +103,7 @@ const assignOfficer = async (req, res) => { if (!user) { return res.status(404).json({ success: false, - message: "User not found" + error:{message: "User not found"} }); }; @@ -108,7 +111,7 @@ const assignOfficer = async (req, res) => { if (user.role !== "citizen") { return res.status(409).json({ success: false, - message: "User is not eligible for officer role" + error:{message: "User is not eligible for officer role"} }); } @@ -132,7 +135,7 @@ const assignOfficer = async (req, res) => { } catch (err) { return res.status(500).json({ success: false, - message: err.message + error:{message: err.message} }); } } @@ -143,7 +146,7 @@ const createAdmin = async (req, res) => { if (existingAdmin) { return res.status(400).json({ success: false, - message: "Admin already exists", + error:{message: "Admin already exists"}, }); }; @@ -152,31 +155,31 @@ const createAdmin = async (req, res) => { if (!acceptTerms) return res .status(400) - .json({ success: false, message: "Terms must be accepted" }); + .json({ success: false, error:{message: "Terms must be accepted"} }); if (password !== confirmPassword) return res .status(400) - .json({ success: false, message: "Passwords do not match" }); + .json({ success: false, error:{message: "Passwords do not match"} }); if (!isValidFullName(fullName)) { return res.status(400).json({ success: false, - message: "Full name is required and must be at least 2 characters", + error:{message: "Full name is required and must be at least 2 characters"}, }); } if (!isValidEmail(email)) { return res .status(400) - .json({ success: false, message: "Invalid email format" }); + .json({ success: false, error:{message: "Invalid email format"} }); } if (!isValidPassword(password)) return res.status(400).json({ success: false, - message: - "Password must be at least 8 characters long and include uppercase, lowercase, number, and special character", + error:{message: + "Password must be at least 8 characters long and include uppercase, lowercase, number, and special character"}, }); const salt = await bcrypt.genSalt(10); diff --git a/server/src/controllers/analyticsController.js b/server/src/controllers/analyticsController.js index b8069d4..edd2e78 100644 --- a/server/src/controllers/analyticsController.js +++ b/server/src/controllers/analyticsController.js @@ -8,8 +8,11 @@ export async function getPerformanceMetrics(req, res) { const stats = data.globalStats[0] || { totalRequestsProcessed: 0, + totalAssigned: 0, avgResponseTimeMs: 0, - communicationResponseRate: 0 + communicationResponseRate: 0, + applicationResponseRate: 0, + combinedResponseRate: 0 }; let officerPerformanceData = []; @@ -39,8 +42,11 @@ export async function getPerformanceMetrics(req, res) { data: { summary: { totalRequestsProcessed: stats.totalRequestsProcessed, + totalTasksAssigned: stats.totalAssigned, averageResponseTimeMs: stats.avgResponseTimeMs || 0, - communicationResponseRate: stats.communicationResponseRate || 0 + communicationResponseRate: stats.communicationResponseRate || 0, + applicationResponseRate: stats.applicationResponseRate || 0, + combinedResponseRate: stats.combinedResponseRate || 0 }, // return copies so further sorting won't mutate the original array topPerformers: [...officerPerformanceData], // Full list sorted desc in service diff --git a/server/src/controllers/authController.js b/server/src/controllers/authController.js index 03562b8..58109da 100644 --- a/server/src/controllers/authController.js +++ b/server/src/controllers/authController.js @@ -37,14 +37,14 @@ const oauthHandler = async (req, res) => { res.cookie("accessToken", accessToken, { httpOnly: true, secure: isProduction, - sameSite: "none", + sameSite: isProduction ? "none" : "lax", maxAge: accessTokenMaxAge, }); res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: isProduction, - sameSite: "none", + sameSite: isProduction ? "none" : "lax", maxAge: refreshTokenMaxAge, }); @@ -58,7 +58,7 @@ const oauthHandler = async (req, res) => { const redirectUrl = `${frontendCallbackUrl}/auth/google/callback?error=${encodeURIComponent(err.message)}`; res.redirect(redirectUrl); } - + } // User Registration @@ -128,13 +128,13 @@ const register = async (req, res) => { res.cookie("accessToken", accessToken, { httpOnly: true, secure: isProduction, - sameSite: "none", + sameSite: isProduction ? "none" : "lax", maxAge: accessTokenMaxAge, }); res.cookie("refreshToken", refreshToken, { httpOnly: true, secure: isProduction, - sameSite: "none", + sameSite: isProduction ? "none" : "lax", maxAge: refreshTokenMaxAge, }); @@ -171,7 +171,7 @@ const login = async (req, res) => { if (!isMatch) return res .status(400) - .json({ success: false, message: "Invalid password" }); + .json({ success: false, message: "Invalid password or Email" }); const accessToken = jwt.sign( { id: user._id, role: user.role }, @@ -179,43 +179,41 @@ const login = async (req, res) => { { expiresIn: process.env.ACCESS_TOKEN_EXPIRES } ); - let refreshToken; - if (rememberMe) { - refreshToken = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { - expiresIn: process.env.REFRESH_TOKEN_EXPIRES, - }); + // Always generate a refresh token, but duration depends on rememberMe + const refreshToken = jwt.sign({ id: user._id }, process.env.JWT_SECRET, { + expiresIn: rememberMe ? process.env.REFRESH_TOKEN_EXPIRES : "24h", + }); - user.refreshToken = refreshToken; - await user.save(); + user.refreshToken = refreshToken; + await user.save(); - res.cookie("refreshToken", refreshToken, { - httpOnly: true, - secure: isProduction, - sameSite: "none", - maxAge: refreshTokenMaxAge, - }); - } + res.cookie("refreshToken", refreshToken, { + httpOnly: true, + secure: isProduction, + sameSite: isProduction ? "none" : "lax", + maxAge: rememberMe ? refreshTokenMaxAge : undefined, // If not rememberMe, it's a session cookie + }); res.cookie("accessToken", accessToken, { httpOnly: true, secure: isProduction, - sameSite: "none", + sameSite: isProduction ? "none" : "lax", maxAge: accessTokenMaxAge, }); const time = new Date(); // Options to make it look cleaner (e.g., "Dec 30, 5:45 PM") - const readableTime = time.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' + const readableTime = time.toLocaleString('en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' }); await makeNotification( - user._id, - "New Login", - `New login detected on ${readableTime}. If this wasn't you, please change your password.` + user._id, + "New Login", + `New login detected on ${readableTime}. If this wasn't you, please change your password.` ); // ✅ Add accessToken in response body for tests @@ -250,13 +248,13 @@ const logout = async (req, res) => { res.clearCookie("accessToken", { httpOnly: true, secure: isProduction, - sameSite: "none", + sameSite: isProduction ? "none" : "lax", }); res.clearCookie("refreshToken", { httpOnly: true, secure: isProduction, - sameSite: "none", + sameSite: isProduction ? "none" : "lax", }); res.status(200).json({ @@ -313,14 +311,14 @@ const refreshToken = async (req, res) => { res.cookie("accessToken", newAccessToken, { httpOnly: true, secure: isProduction, - sameSite: "none", + sameSite: isProduction ? "none" : "lax", maxAge: accessTokenMaxAge, }); res.cookie("refreshToken", newRefreshToken, { httpOnly: true, secure: isProduction, - sameSite: "none", + sameSite: isProduction ? "none" : "lax", maxAge: refreshTokenMaxAge, }); diff --git a/server/src/middleware/authMiddleware.js b/server/src/middleware/authMiddleware.js index e18ae02..600e7a5 100644 --- a/server/src/middleware/authMiddleware.js +++ b/server/src/middleware/authMiddleware.js @@ -2,7 +2,6 @@ import jwt from "jsonwebtoken"; import User from "../models/User.js"; import Officer from "../models/Officer.js"; -const isProduction = process.env.NODE_ENV === "production"; const verifyToken = async (req, res, next) => { try { @@ -19,7 +18,7 @@ const verifyToken = async (req, res, next) => { jwt.verify(token, process.env.JWT_SECRET, async (err, decoded) => { if (err) { return res - .status(403) + .status(401) .json({ success: false, message: "Invalid or expired token" }); } @@ -53,19 +52,19 @@ const authorizeRoles = (...allowedRoles) => { }; const canWriteNews = async (req, res, next) => { - try { - const officer = await Officer.findById(req.user.id); - if (officer && officer.writeNews) { - return next(); - } - - return res.status(403).json({ - success: false, - error: { message: "Unauthorized: You do not have news writing permissions." } - }); - } catch (error) { - return res.status(500).json({ success: false, error: { message: "Auth check failed." } }); + try { + const officer = await Officer.findById(req.user.id); + if (officer && officer.writeNews) { + return next(); } + + return res.status(403).json({ + success: false, + error: { message: "Unauthorized: You do not have news writing permissions." } + }); + } catch (error) { + return res.status(500).json({ success: false, error: { message: "Auth check failed." } }); + } }; export { verifyToken, authorizeRoles, canWriteNews }; diff --git a/server/src/models/views/GlobalMaxScore.js b/server/src/models/views/GlobalMaxScore.js new file mode 100644 index 0000000..39bbd5e --- /dev/null +++ b/server/src/models/views/GlobalMaxScore.js @@ -0,0 +1,17 @@ +import mongoose from 'mongoose'; + +const GlobalMaxScoreSchema = new mongoose.Schema({ + period: { + type: String, + required: true, + unique: true, // One entry per month + }, + maxRankScore: { + type: Number, + default: 0, + } +}, { timestamps: true }); + +const GlobalMaxScore = mongoose.model('GlobalMaxScore', GlobalMaxScoreSchema); + +export default GlobalMaxScore; diff --git a/server/src/models/views/OfficerStats.js b/server/src/models/views/OfficerStats.js deleted file mode 100644 index e9111c2..0000000 --- a/server/src/models/views/OfficerStats.js +++ /dev/null @@ -1,59 +0,0 @@ -import mongoose from "mongoose"; -import aggregatePaginate from "mongoose-aggregate-paginate-v2"; - -const OfficerStatsSchema = new mongoose.Schema({ - officerId: { - type: mongoose.Schema.Types.ObjectId, - ref: 'Officer', - required: true, - }, - period: { - type: String, - required: true, - }, // YYYY-MM format - totalConversations: { - type: Number, - default: 0, - }, - totalApplications: { - type: Number, - default: 0, - }, - averageResponseTimeMs: { - type: Number, - default: 0, - }, - applicationResponseRate: { - type: Number, - default: 0, - min: 0, - max: 1 - }, - communicationResponseRate: { - type: Number, - default: 0, - min: 0, - max: 1 - }, - assignedCount: { - type: Number, - req: true, - default: 0, - }, - score: { - type: Number, - default: 0, - } -}, - { timestamps: true } -); - -// Index for performance -OfficerStatsSchema.index({ officerId: 1, period: 1 }); -OfficerStatsSchema.index({ period: 1, score: -1 }); - -OfficerStatsSchema.plugin(aggregatePaginate); - -const OfficerStats = mongoose.model('OfficerStats', OfficerStatsSchema); - -export default OfficerStats; \ No newline at end of file diff --git a/server/src/models/views/OfficerStatsCumulative.js b/server/src/models/views/OfficerStatsCumulative.js new file mode 100644 index 0000000..cbb3db7 --- /dev/null +++ b/server/src/models/views/OfficerStatsCumulative.js @@ -0,0 +1,72 @@ +import mongoose from 'mongoose'; +import aggregatePaginate from 'mongoose-aggregate-paginate-v2'; + +const OfficerStatsCumulativeSchema = new mongoose.Schema({ + officerId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Officer', + required: true, + }, + department: { + type: String, + required: true, + }, + subcity: { + type: String, + required: true, + }, + totalConversations: { + type: Number, + default: 0, + }, + processedConversations: { + type: Number, + default: 0, + }, + totalApplications: { + type: Number, + default: 0, + }, + processedApplications: { + type: Number, + default: 0, + }, + communicationResponseRate: { + type: Number, + default: 0, + }, + applicationResponseRate: { + type: Number, + default: 0, + }, + averageResponseTimeMs: { + type: Number, + default: 0, + }, + rawScore: { + type: Number, + default: 0, + }, + rankScore: { + type: Number, + default: 0, + }, + requestsProcessed: { + type: Number, + default: 0, + }, + requestsTotal: { + type: Number, + default: 0, + } +}, { timestamps: true }); + +OfficerStatsCumulativeSchema.index({ officerId: 1 }, { unique: true }); +OfficerStatsCumulativeSchema.index({ rankScore: -1 }); +OfficerStatsCumulativeSchema.index({ department: 1, subcity: 1 }); + +OfficerStatsCumulativeSchema.plugin(aggregatePaginate); + +const OfficerStatsCumulative = mongoose.model('OfficerStatsCumulative', OfficerStatsCumulativeSchema); + +export default OfficerStatsCumulative; diff --git a/server/src/models/views/OfficerStatsMonthly.js b/server/src/models/views/OfficerStatsMonthly.js new file mode 100644 index 0000000..26a5b75 --- /dev/null +++ b/server/src/models/views/OfficerStatsMonthly.js @@ -0,0 +1,77 @@ +import mongoose from 'mongoose'; +import aggregatePaginate from 'mongoose-aggregate-paginate-v2'; + +const OfficerStatsMonthlySchema = new mongoose.Schema({ + officerId: { + type: mongoose.Schema.Types.ObjectId, + ref: 'Officer', + required: true, + }, + period: { + type: String, + required: true, + }, // YYYY-MM format + department: { + type: String, + required: true, + }, + subcity: { + type: String, + required: true, + }, + totalConversations: { + type: Number, + default: 0, + }, + processedConversations: { + type: Number, + default: 0, + }, + totalApplications: { + type: Number, + default: 0, + }, + processedApplications: { + type: Number, + default: 0, + }, + communicationResponseRate: { + type: Number, + default: 0, + }, + applicationResponseRate: { + type: Number, + default: 0, + }, + averageResponseTimeMs: { + type: Number, + default: 0, + }, + rawScore: { + type: Number, + default: 0, + }, + rankScore: { + type: Number, + default: 0, + }, + requestsProcessed: { + type: Number, + default: 0, + }, + requestsTotal: { + type: Number, + default: 0, + } +}, { timestamps: true }); + +OfficerStatsMonthlySchema.index({ officerId: 1, period: 1 }, { unique: true }); +OfficerStatsMonthlySchema.index({ period: 1 }); +OfficerStatsMonthlySchema.index({ period: 1, rankScore: -1 }); +OfficerStatsMonthlySchema.index({ department: 1, subcity: 1 }); + +OfficerStatsMonthlySchema.plugin(aggregatePaginate); + +const OfficerStatsMonthly = mongoose.model('OfficerStatsMonthly', OfficerStatsMonthlySchema); + +export default OfficerStatsMonthly; diff --git a/server/src/services/officer_analytics/excelService.js b/server/src/services/officer_analytics/excelService.js index 1d2de50..5c87ccd 100644 --- a/server/src/services/officer_analytics/excelService.js +++ b/server/src/services/officer_analytics/excelService.js @@ -5,123 +5,174 @@ export async function generatePerformanceExcel(data, { from, to, department, sub workbook.creator = 'CiviLink Admin'; workbook.created = new Date(); - // Fallbacks for global stats to prevent "toFixed" crashes const stats = (data.globalStats && data.globalStats[0]) || { totalRequestsProcessed: 0, + totalAssigned: 0, avgResponseTimeMs: 0, - communicationResponseRate: 0 + communicationResponseRate: 0, + applicationResponseRate: 0, + combinedResponseRate: 0 }; const allOfficers = data.officerPerformance || []; const monthlyTrend = data.monthlyTrend || []; + const filterInfo = [ + ['Report Period:', `${from || 'All Time'} to ${to || 'Present'}`], + ['Department:', department || 'All Departments'], + ['Subcity:', subcity || 'All Subcities'], + [] // Empty row spacer + ]; // 1. Overview Sheet - const summarySheet = workbook.addWorksheet('Overview'); - summarySheet.columns = [ - { header: 'Metric', key: 'metric', width: 30 }, - { header: 'Value', key: 'value', width: 25 }, + const overviewSheet = workbook.addWorksheet('Overview'); + overviewSheet.addRows(filterInfo); + + // Add Header for Metrics + const metricHeaderRow = overviewSheet.addRow(['Metric', 'Value']); + metricHeaderRow.font = { bold: true }; + + const overviewData = [ + ['Total Tasks Assigned', Number(stats.totalAssigned)], + ['Total Tasks Processed', Number(stats.totalRequestsProcessed)], + ['Avg Response Time (s)', Number((stats.avgResponseTimeMs || 0) / 1000)], + ['Combined Response Rate', Number(stats.combinedResponseRate)], + ['Comm. Response Rate', Number(stats.communicationResponseRate)], + ['App. Response Rate', Number(stats.applicationResponseRate)] ]; - summarySheet.addRows([ - { metric: 'Report Period', value: `${from || 'All'} to ${to || 'All'}` }, - { metric: 'Department Filter', value: department || 'All' }, - { metric: 'Subcity Filter', value: subcity || 'All' }, - { metric: 'Total Requests Processed', value: stats.totalRequestsProcessed }, - { metric: 'Avg Response Time', value: `${((stats.avgResponseTimeMs || 0) / 1000).toFixed(2)}s` }, - { metric: 'Response Rate', value: `${((stats.communicationResponseRate || 0) * 100).toFixed(1)}%` }, - ]); - - // Formatting: Bold the first column - summarySheet.getColumn(1).font = { bold: true }; - summarySheet.getRow(1).font = { bold: true, color: { argb: 'FFFFFFFF' } }; - summarySheet.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4F81BD' } }; - - // 2. Top Performers (Ranked by normalizedScore descending) - const topSheet = workbook.addWorksheet('Top Performers'); - const sortedTop = [...allOfficers].sort((a, b) => { - const diff = (b.normalizedScore || 0) - (a.normalizedScore || 0); - if (Math.abs(diff) > 0.0001) return diff; - // Tie-breaker: total requests (volume) - return (b.requestsTotal || 0) - (a.requestsTotal || 0); + overviewData.forEach(row => { + const r = overviewSheet.addRow(row); + if (row[0].includes('Rate')) { + r.getCell(2).numFmt = '0.0%'; + } else if (row[0].includes('Time')) { + r.getCell(2).numFmt = '0.00"s"'; + } else { + r.getCell(2).numFmt = '#,##0'; + } }); - setupOfficerSheet(topSheet, sortedTop); - // 3. Worst Performers (Ranked by normalizedScore ascending) + overviewSheet.getColumn(1).width = 30; + overviewSheet.getColumn(2).width = 25; + overviewSheet.getColumn(1).font = { bold: true }; + + // 2. Performance Sheets + const topSheet = workbook.addWorksheet('Top Performers'); const worstSheet = workbook.addWorksheet('Worst Performers'); - const sortedWorst = [...allOfficers].sort((a, b) => { - const diff = (a.normalizedScore || 0) - (b.normalizedScore || 0); - if (Math.abs(diff) > 0.0001) return diff; - // Tie-breaker: total requests (lower volume comes first in worst performers) - return (a.requestsTotal || 0) - (b.requestsTotal || 0); - }); - setupOfficerSheet(worstSheet, sortedWorst); + const allSheet = workbook.addWorksheet('All Officers'); + + const sortedOfficers = [...allOfficers].sort((a, b) => (b.normalizedScore || 0) - (a.normalizedScore || 0)); - // 4. Monthly Report + setupOfficerSheet(topSheet, sortedOfficers, filterInfo, 'Top Performers'); + setupOfficerSheet(worstSheet, [...sortedOfficers].reverse(), filterInfo, 'Bottom Performers'); + setupOfficerSheet(allSheet, allOfficers, filterInfo, 'Full Officer List'); + + // 3. Monthly Trend const trendSheet = workbook.addWorksheet('Monthly Report'); - trendSheet.columns = [ - { header: 'Month', key: 'month', width: 15 }, - { header: 'Requests Processed', key: 'requests', width: 20 }, - { header: 'Avg Response Time (ms)', key: 'time', width: 22 }, - { header: 'Comm. Response Rate', key: 'rate', width: 20 }, - ]; + trendSheet.addRows(filterInfo); - trendSheet.getRow(1).font = { bold: true }; + const trendHeader = trendSheet.addRow(['Month', 'Requests Processed', 'Avg Response Time (ms)', 'Response Rate']); + trendHeader.font = { bold: true }; monthlyTrend.forEach(m => { - trendSheet.addRow({ - month: m.month, - requests: m.requestsProcessed || 0, - time: (m.averageResponseTimeMs || 0).toFixed(0), - // Prefer combined/application rates when available - rate: `${(((m.communicationResponseRate || 0) || (m.applicationResponseRate || 0)) * 100).toFixed(1)}%` - }); + const rate = (m.communicationResponseRate || m.applicationResponseRate || 0); + trendSheet.addRow([ + m.month, + Number(m.requestsProcessed || 0), + Number(m.averageResponseTimeMs || 0), + Number(rate) + ]); }); - // 5. All Officers Database - const allSheet = workbook.addWorksheet('All Officers'); - setupOfficerSheet(allSheet, allOfficers); + trendSheet.getColumn(2).numFmt = '#,##0'; + trendSheet.getColumn(3).numFmt = '#,##0'; + trendSheet.getColumn(4).numFmt = '0.0%'; + trendSheet.getColumn(1).width = 15; + trendSheet.getColumn(2).width = 20; + trendSheet.getColumn(3).width = 22; + trendSheet.getColumn(4).width = 20; return workbook; } -function setupOfficerSheet(sheet, officers) { - sheet.columns = [ - { header: 'Rank', key: 'rank', width: 8 }, - { header: 'Name', key: 'name', width: 30 }, - { header: 'Department', key: 'dept', width: 22 }, - { header: 'Subcity', key: 'subcity', width: 20 }, - { header: 'Tasks Assigned', key: 'assigned', width: 15 }, - { header: 'Tasks Processed', key: 'processed', width: 15 }, - { header: 'Avg Response Time (ms)', key: 'time', width: 22 }, - { header: 'Response Rate', key: 'rate', width: 15 }, - { header: 'Weighted Score', key: 'score', width: 15 }, - { header: 'Performance %', key: 'perf', width: 15 }, +function setupOfficerSheet(sheet, officers, filterInfo, title) { + // Add Title and Filters at the top + sheet.addRow([title]).font = { bold: true, size: 14 }; + sheet.addRows(filterInfo); + + const headers = [ + 'Rank', 'Name', 'Department', 'Subcity', + 'Tasks Assigned', 'Tasks Processed', 'Avg Response Time (ms)', + 'Response Rate', 'Weighted Score', 'Performance %' ]; - // Style the header row - const headerRow = sheet.getRow(1); - headerRow.font = { bold: true }; - headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + const headerRow = sheet.addRow(headers); + headerRow.font = { bold: true, color: { argb: 'FFFFFFFF' } }; + headerRow.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4F81BD' } }; + headerRow.alignment = { horizontal: 'center' }; officers.forEach((o, i) => { - // Nested safety check for officer name/details - const firstName = o.officer?.firstName || o.officer?.fullName || 'Unknown'; - const lastName = o.officer?.lastName || ''; - // Prefer combined metrics if available (created in performanceService) - const rateValue = (typeof o.combinedResponseRate === 'number') ? (o.combinedResponseRate / 100) : ((o.communicationResponseRate || 0) || (o.applicationResponseRate || 0)); - const timeValue = (typeof o.combinedAvgResponseTimeMs === 'number') ? o.combinedAvgResponseTimeMs : (o.avgResponseTimeMs || 0); - - sheet.addRow({ - rank: i + 1, - name: `${firstName} ${lastName}`.trim(), - dept: o.officer?.department || 'N/A', - subcity: o.officer?.subcity || 'N/A', - assigned: o.requestsTotal || 0, - processed: o.requestsProcessed || 0, - time: (timeValue || 0).toFixed(0), - rate: `${((rateValue || 0) * 100).toFixed(1)}%`, - score: (o.rawScore || o.rankScore || 0).toFixed(4), - perf: `${(o.normalizedScore || 0).toFixed(1)}%` - }); + const name = o.name || `${o.officer?.firstName || o.officer?.fullName || 'Unknown'} ${o.officer?.lastName || ''}`.trim(); + + // Unified Response Rate (prefers combinedResponseRate from service which is 0-100) + let rateValue = 0; + if (typeof o.combinedResponseRate === 'number') { + rateValue = o.combinedResponseRate / 100; + } else if (typeof o.responseRate === 'number') { + rateValue = o.responseRate / 100; + } else { + rateValue = (o.communicationResponseRate || 0) || (o.applicationResponseRate || 0); + } + + // Unified Avg Response Time (ms) + const avgTime = Number(o.avgResponseTimeMs || o.combinedAvgResponseTimeMs || o.avgResponseTime || 0); + const perfValue = (o.normalizedScore || o.score || 0) / 100; + + const row = sheet.addRow([ + i + 1, + name, + o.department || o.officer?.department || 'N/A', + o.subcity || o.officer?.subcity || 'N/A', + Number(o.requestsTotal || 0), + Number(o.requestsProcessed || 0), + avgTime, + Number(rateValue), + Number(o.rawScore || 0), + Number(perfValue) + ]); + + // Middle alignment for numbers + row.alignment = { horizontal: 'center' }; + row.getCell(2).alignment = { horizontal: 'left' }; // Name stays left }); -} \ No newline at end of file + + // Column Formats + const colFormats = [ + { col: 5, fmt: '#,##0' }, + { col: 6, fmt: '#,##0' }, + { col: 7, fmt: '#,##0' }, + { col: 8, fmt: '0.0%' }, + { col: 9, fmt: '0.0000' }, + { col: 10, fmt: '0.0%' } + ]; + + colFormats.forEach(cf => { + sheet.getColumn(cf.col).numFmt = cf.fmt; + }); + + // Widths + sheet.getColumn(1).width = 8; + sheet.getColumn(2).width = 30; + sheet.getColumn(3).width = 22; + sheet.getColumn(4).width = 20; + sheet.getColumn(5).width = 15; + sheet.getColumn(6).width = 15; + sheet.getColumn(7).width = 22; + sheet.getColumn(8).width = 15; + sheet.getColumn(9).width = 15; + sheet.getColumn(10).width = 15; + + sheet.autoFilter = { + from: { row: headerRow.number, column: 1 }, + to: { row: headerRow.number, column: 10 } + }; +} diff --git a/server/src/services/officer_analytics/officerStatsService.js b/server/src/services/officer_analytics/officerStatsService.js index d95f14f..3c5fd9c 100644 --- a/server/src/services/officer_analytics/officerStatsService.js +++ b/server/src/services/officer_analytics/officerStatsService.js @@ -1,6 +1,9 @@ import Conversation from '../../models/Conversation.js'; import Application from '../../models/Application.js'; -import OfficerStats from '../../models/views/OfficerStats.js'; +import OfficerStatsMonthly from '../../models/views/OfficerStatsMonthly.js'; +import OfficerStatsCumulative from '../../models/views/OfficerStatsCumulative.js'; +import GlobalMaxScore from '../../models/views/GlobalMaxScore.js'; +import Officer from '../../models/Officer.js'; import { getMonthRange } from '../../utils/date.js'; export async function calculateOfficerStats(month) { @@ -8,13 +11,13 @@ export async function calculateOfficerStats(month) { // Group conversations by officer and the month of the event (updatedAt) const conversationStats = await Conversation.aggregate([ { - $match: { + $match: { // Get conversations that were assigned to an officer and updated within the month officerId: { $ne: null }, updatedAt: { $gte: start, $lte: end } } }, { - $addFields: { + $addFields: { // Create a 'period' field in YYYY-MM format for grouping period: { $dateToString: { format: '%Y-%m', date: '$updatedAt' } } } }, @@ -47,7 +50,7 @@ export async function calculateOfficerStats(month) { } }, { $unwind: '$officer' }, - { $match: { 'officer.department': 'customer_support' } }, + { $match: { 'officer.department': 'customer_support' } }, // Safety nets to ensure we only include relevant officers but it is kinda redundant { $addFields: { communicationResponseRate: { @@ -81,6 +84,8 @@ export async function calculateOfficerStats(month) { _id: 0, officerId: '$_id.officerId', period: '$_id.period', + department: '$officer.department', + subcity: '$officer.subcity', totalConversations: '$assignedCount', totalApplications: { $literal: 0 }, // exact processed counts for conversations @@ -151,7 +156,7 @@ export async function calculateOfficerStats(month) { 0 ] } - , + , averageResponseTimeMs: { $cond: [ { $gt: ['$processedCount', 0] }, @@ -176,6 +181,8 @@ export async function calculateOfficerStats(month) { _id: 0, officerId: '$_id.officerId', period: '$_id.period', + department: '$officer.department', + subcity: '$officer.subcity', totalConversations: { $literal: 0 }, totalApplications: '$assignedCount', // exact processed counts for applications @@ -192,9 +199,35 @@ export async function calculateOfficerStats(month) { const allStats = [...conversationStats, ...applicationStats]; + // Map to the new schema format and ensure all fields are computed + const processedStats = allStats.map(stat => { + const requestsProcessed = (stat.processedConversations || 0) + (stat.processedApplications || 0); + const requestsTotal = (stat.totalConversations || 0) + (stat.totalApplications || 0); + const score = requestsTotal > 0 ? (requestsProcessed / requestsTotal) * Math.log(requestsTotal + 1) : 0; + + return { + officerId: stat.officerId, + period: stat.period, + department: stat.department, + subcity: stat.subcity, + totalConversations: stat.totalConversations || 0, + processedConversations: stat.processedConversations || 0, + totalApplications: stat.totalApplications || 0, + processedApplications: stat.processedApplications || 0, + communicationResponseRate: stat.communicationResponseRate || 0, + applicationResponseRate: stat.applicationResponseRate || 0, + averageResponseTimeMs: stat.averageResponseTimeMs || 0, + requestsProcessed, + requestsTotal, + rawScore: score, + rankScore: score + }; + }); + + // 1. Update OfficerStatsMonthly await Promise.all( - allStats.map(stat => - OfficerStats.findOneAndUpdate( + processedStats.map(stat => + OfficerStatsMonthly.findOneAndUpdate( { officerId: stat.officerId, period: stat.period }, { $set: stat }, { upsert: true, new: true } @@ -202,5 +235,71 @@ export async function calculateOfficerStats(month) { ) ); - return allStats; -} \ No newline at end of file + // 2. Update OfficerStatsCumulative for involved officers + const uniqueOfficerIds = [...new Set(processedStats.map(s => s.officerId))]; + await Promise.all( + uniqueOfficerIds.map(async (officerId) => { + const officerMonthlyData = await OfficerStatsMonthly.find({ officerId }); + const officer = await Officer.findById(officerId); + + if (!officer) return; + + const cumulative = officerMonthlyData.reduce((acc, curr) => { + acc.totalConversations += curr.totalConversations; + acc.processedConversations += curr.processedConversations; + acc.totalApplications += curr.totalApplications; + acc.processedApplications += curr.processedApplications; + acc.requestsProcessed += curr.requestsProcessed; + acc.requestsTotal += curr.requestsTotal; + return acc; + }, { + totalConversations: 0, + processedConversations: 0, + totalApplications: 0, + processedApplications: 0, + requestsProcessed: 0, + requestsTotal: 0 + }); + + // Recalculate weighted rates and score for cumulative + cumulative.communicationResponseRate = cumulative.totalConversations > 0 ? cumulative.processedConversations / cumulative.totalConversations : 0; + cumulative.applicationResponseRate = cumulative.totalApplications > 0 ? cumulative.processedApplications / cumulative.totalApplications : 0; + + const totalTime = officerMonthlyData.reduce((acc, curr) => { + const processed = (curr.processedConversations + curr.processedApplications); + return acc + (curr.averageResponseTimeMs * processed); + }, 0); + cumulative.averageResponseTimeMs = cumulative.requestsProcessed > 0 ? totalTime / cumulative.requestsProcessed : 0; + + cumulative.rawScore = cumulative.requestsTotal > 0 ? (cumulative.requestsProcessed / cumulative.requestsTotal) * Math.log(cumulative.requestsTotal + 1) : 0; + cumulative.rankScore = cumulative.rawScore; + + await OfficerStatsCumulative.findOneAndUpdate( + { officerId }, + { + $set: { + ...cumulative, + department: officer.department, + subcity: officer.subcity + } + }, + { upsert: true } + ); + }) + ); + + // 3. Update GlobalMaxScore for this period + const period = processedStats[0]?.period; + if (period) { + const maxData = await OfficerStatsMonthly.findOne({ period }).sort({ rankScore: -1 }); + if (maxData) { + await GlobalMaxScore.findOneAndUpdate( + { period }, + { $set: { maxRankScore: maxData.rankScore } }, + { upsert: true } + ); + } + } + + return processedStats; +} diff --git a/server/src/services/officer_analytics/performanceService.js b/server/src/services/officer_analytics/performanceService.js index 3644fa0..01b2fb1 100644 --- a/server/src/services/officer_analytics/performanceService.js +++ b/server/src/services/officer_analytics/performanceService.js @@ -1,50 +1,20 @@ -import OfficerStats from "../../models/views/OfficerStats.js"; -import Officer from "../../models/Officer.js" +import OfficerStatsMonthly from "../../models/views/OfficerStatsMonthly.js"; +import OfficerStatsCumulative from "../../models/views/OfficerStatsCumulative.js"; +import GlobalMaxScore from "../../models/views/GlobalMaxScore.js"; +import Officer from "../../models/Officer.js"; import { Types } from "mongoose"; /** - * Calculates a global maximum raw score to use as a consistent benchmark for normalization. + * Fetches the global maximum raw score for normalization. */ -async function getGlobalMaxScore(pipelineBase = []) { - // Accept a base pipeline and compute the maximum per-officer rankScore where - // rankScore = (processedRequests / totalRequests) * ln(totalRequests + 1) - const pipeline = Array.isArray(pipelineBase) ? [...pipelineBase] : []; - - // Sum exact processed counts and totals per officer if available - pipeline.push({ - $group: { - _id: '$officerId', - sumProcessedFromApps: { $sum: { $ifNull: ['$processedApplications', { $multiply: ['$applicationResponseRate', '$totalApplications'] }] } }, - sumProcessedFromConvs: { $sum: { $ifNull: ['$processedConversations', { $multiply: ['$communicationResponseRate', '$totalConversations'] }] } }, - totalApplications: { $sum: '$totalApplications' }, - totalConversations: { $sum: '$totalConversations' } - } - }); - - pipeline.push({ - $project: { - requestsProcessed: { $add: ['$sumProcessedFromApps', '$sumProcessedFromConvs'] }, - totalRequests: { $add: ['$totalApplications', '$totalConversations'] } - } - }); - - pipeline.push({ - $project: { - rankScore: { - $cond: [ - { $gt: ['$totalRequests', 0] }, - { $multiply: [{ $divide: ['$requestsProcessed', '$totalRequests'] }, { $ln: { $add: ['$totalRequests', 1] } }] }, - 0 - ] - } - } - }); - - pipeline.push({ $group: { _id: null, maxRankScore: { $max: '$rankScore' } } }); - - const result = await OfficerStats.aggregate(pipeline); - const max = result[0]?.maxRankScore; - return (max && max > 0) ? max : 1; +async function getGlobalMaxScore(period = null) { + if (period) { + const record = await GlobalMaxScore.findOne({ period }); + return record?.maxRankScore || 1; + } + // For cumulative, get the max from the cumulative collection + const maxRec = await OfficerStatsCumulative.findOne().sort({ rankScore: -1 }); + return maxRec?.rankScore || 1; } export async function getAggregatedPerformance({ from, to, officerId, department, subcity }) { @@ -55,37 +25,47 @@ export async function getAggregatedPerformance({ from, to, officerId, department if (to) match.period.$lte = to; } if (officerId) match.officerId = new Types.ObjectId(officerId); + if (department) match.department = department; + if (subcity) match.subcity = subcity; - const pipeline = [ + // Use monthly collection for ranges and trends + const results = await OfficerStatsMonthly.aggregate([ { $match: match }, - { - $lookup: { - from: 'users', - localField: 'officerId', - foreignField: '_id', - as: 'officer' - } - }, - { $unwind: '$officer' } - ]; - - if (department) pipeline.push({ $match: { 'officer.department': department } }); - if (subcity) pipeline.push({ $match: { 'officer.subcity': subcity } }); - - // Compute global max using the same pipeline so normalization is scoped correctly - const globalMax = await getGlobalMaxScore(pipeline); - const results = await OfficerStats.aggregate([ - ...pipeline, { $facet: { globalStats: [ { $group: { _id: null, - totalRequestsProcessed: { $sum: { $add: ['$totalConversations', '$totalApplications'] } }, - avgResponseTimeMs: { $avg: '$averageResponseTimeMs' }, - communicationResponseRate: { $avg: '$communicationResponseRate' }, - applicationResponseRate: { $avg: '$applicationResponseRate' } + totalAssigned: { $sum: '$requestsTotal' }, + totalRequestsProcessed: { $sum: '$requestsProcessed' }, + // For domain rates, we only average documents that have actual activity in that domain + commDocsCount: { $sum: { $cond: [{ $gt: ['$totalConversations', 0] }, 1, 0] } }, + appDocsCount: { $sum: { $cond: [{ $gt: ['$totalApplications', 0] }, 1, 0] } }, + sumCommRates: { $sum: { $cond: [{ $gt: ['$totalConversations', 0] }, '$communicationResponseRate', 0] } }, + sumAppRates: { $sum: { $cond: [{ $gt: ['$totalApplications', 0] }, '$applicationResponseRate', 0] } }, + // Weighted average response time + sumWeightedTime: { $sum: { $multiply: ['$averageResponseTimeMs', '$requestsProcessed'] } } + } + }, + { + $addFields: { + communicationResponseRate: { + $cond: [{ $gt: ['$commDocsCount', 0] }, { $divide: ['$sumCommRates', '$commDocsCount'] }, 0] + }, + applicationResponseRate: { + $cond: [{ $gt: ['$appDocsCount', 0] }, { $divide: ['$sumAppRates', '$appDocsCount'] }, 0] + }, + avgResponseTimeMs: { + $cond: [{ $gt: ['$totalRequestsProcessed', 0] }, { $divide: ['$sumWeightedTime', '$totalRequestsProcessed'] }, 0] + } + } + }, + { + $addFields: { + combinedResponseRate: { + $cond: [{ $gt: ['$totalAssigned', 0] }, { $divide: ['$totalRequestsProcessed', '$totalAssigned'] }, 0] + } } } ], @@ -93,23 +73,18 @@ export async function getAggregatedPerformance({ from, to, officerId, department { $group: { _id: '$officerId', - officer: { $first: '$officer' }, totalConversations: { $sum: '$totalConversations' }, totalApplications: { $sum: '$totalApplications' }, - // sum processed approximations - sumProcessedFromConvs: { $sum: { $ifNull: ['$processedConversations', { $multiply: ['$communicationResponseRate', '$totalConversations'] }] } }, - sumProcessedFromApps: { $sum: { $ifNull: ['$processedApplications', { $multiply: ['$applicationResponseRate', '$totalApplications'] }] } }, - avgResponseTimeMs: { $avg: '$averageResponseTimeMs' }, - } - }, - { - $addFields: { - requestsProcessed: { $add: ['$sumProcessedFromConvs', '$sumProcessedFromApps'] }, - requestsTotal: { $add: ['$totalConversations', '$totalApplications'] } + requestsProcessed: { $sum: '$requestsProcessed' }, + requestsTotal: { $sum: '$requestsTotal' }, + sumWeightedTime: { $sum: { $multiply: ['$averageResponseTimeMs', '$requestsProcessed'] } } } }, { $addFields: { + avgResponseTimeMs: { + $cond: [{ $gt: ['$requestsProcessed', 0] }, { $divide: ['$sumWeightedTime', '$requestsProcessed'] }, 0] + }, rawScore: { $cond: [ { $gt: ['$requestsTotal', 0] }, @@ -117,15 +92,23 @@ export async function getAggregatedPerformance({ from, to, officerId, department 0 ] }, - // Calculate weighted response rates for each type to avoid average of averages communicationResponseRate: { - $cond: [{ $gt: ['$totalConversations', 0] }, { $divide: ['$sumProcessedFromConvs', '$totalConversations'] }, 0] + $cond: [{ $gt: ['$totalConversations', 0] }, { $divide: ['$processedConversations', '$totalConversations'] }, 0] }, applicationResponseRate: { - $cond: [{ $gt: ['$totalApplications', 0] }, { $divide: ['$sumProcessedFromApps', '$totalApplications'] }, 0] + $cond: [{ $gt: ['$totalApplications', 0] }, { $divide: ['$processedApplications', '$totalApplications'] }, 0] } } }, + { + $lookup: { + from: 'users', + localField: '_id', + foreignField: '_id', + as: 'officer' + } + }, + { $unwind: '$officer' }, { $sort: { rawScore: -1, requestsTotal: -1 } } ], monthlyTrend: [ @@ -134,58 +117,52 @@ export async function getAggregatedPerformance({ from, to, officerId, department _id: '$period', totalConversations: { $sum: '$totalConversations' }, totalApplications: { $sum: '$totalApplications' }, - sumProcessedFromConvs: { $sum: { $ifNull: ['$processedConversations', { $multiply: ['$communicationResponseRate', '$totalConversations'] }] } }, - sumProcessedFromApps: { $sum: { $ifNull: ['$processedApplications', { $multiply: ['$applicationResponseRate', '$totalApplications'] }] } }, - averageResponseTimeMs: { $avg: '$averageResponseTimeMs' }, + requestsProcessed: { $sum: '$requestsProcessed' }, + requestsTotal: { $sum: '$requestsTotal' }, + sumProcessedComm: { $sum: '$processedConversations' }, + sumProcessedApp: { $sum: '$processedApplications' }, + sumWeightedTime: { $sum: { $multiply: ['$averageResponseTimeMs', '$requestsProcessed'] } } } }, { - $addFields: { + $project: { + month: '$_id', + requestsProcessed: '$requestsProcessed', + averageResponseTimeMs: { + $cond: [{ $gt: ['$requestsProcessed', 0] }, { $divide: ['$sumWeightedTime', '$requestsProcessed'] }, 0] + }, communicationResponseRate: { - $cond: [{ $gt: ['$totalConversations', 0] }, { $divide: ['$sumProcessedFromConvs', '$totalConversations'] }, 0] + $cond: [{ $gt: ['$totalConversations', 0] }, { $divide: ['$sumProcessedComm', '$totalConversations'] }, 0] }, applicationResponseRate: { - $cond: [{ $gt: ['$totalApplications', 0] }, { $divide: ['$sumProcessedFromApps', '$totalApplications'] }, 0] - } - } - }, - { $sort: { _id: 1 } }, - { - $project: { - month: '$_id', - requestsProcessed: { $add: ['$totalConversations', '$totalApplications'] }, - averageResponseTimeMs: 1, - communicationResponseRate: 1, - applicationResponseRate: 1, + $cond: [{ $gt: ['$totalApplications', 0] }, { $divide: ['$sumProcessedApp', '$totalApplications'] }, 0] + }, _id: 0 } - } + }, + { $sort: { month: 1 } } ] } } ]); const data = results[0]; + const globalMax = await getGlobalMaxScore(from === to ? from : null); // Apply normalization based on the global distribution and expose rawScore if (data.officerPerformance) { data.officerPerformance = data.officerPerformance.map(o => { - const totalConv = o.totalConversations || 0; - const totalApp = o.totalApplications || 0; - const denom = totalConv + totalApp; - const commRate = o.communicationResponseRate || 0; - const appRate = o.applicationResponseRate || 0; - const combinedResponseRate = denom > 0 ? (o.requestsProcessed / denom) : 0; - // preserve full precision for raw and normalized scores (no premature rounding) const raw = (o.rawScore || 0); const normalizedScore = ((raw / (globalMax || 1)) * 100); return { ...o, + officerId: o._id, rawScore: raw, rankScore: raw, normalizedScore, - combinedResponseRate: Number((combinedResponseRate * 100)), // percentage - combinedAvgResponseTimeMs: Number((o.avgResponseTimeMs || 0)) + combinedResponseRate: o.requestsTotal > 0 ? (o.requestsProcessed / o.requestsTotal * 100) : 0, + combinedAvgResponseTimeMs: o.avgResponseTimeMs || 0, + avgResponseTimeMs: o.avgResponseTimeMs || 0 // Explicitly include it }; }); } @@ -198,80 +175,49 @@ export async function getAggregatedPerformance({ from, to, officerId, department */ export async function getPaginatedOfficerStats({ from, to, department, subcity, search, page = 1, limit = 10 }) { const match = {}; + let isFilteredByPeriod = false; + if (from || to) { match.period = {}; if (from) match.period.$gte = from; if (to) match.period.$lte = to; + isFilteredByPeriod = true; } - // Build the same pipeline used for aggregation so pagination and normalization align - const pipeline = [ - { $match: match }, - { - $lookup: { - from: 'users', - localField: 'officerId', - foreignField: '_id', - as: 'officer' - } - }, - { $unwind: '$officer' } - ]; - - if (search) { - const searchRegex = new RegExp(search, 'i'); - pipeline.push({ - $match: { - $or: [ - { 'officer.fullName': searchRegex }, - { 'officer.email': searchRegex } - ] - } - }); - } + if (department) match.department = department; + if (subcity) match.subcity = subcity; if (search) { - const searchRegex = new RegExp(search, 'i'); - pipeline.push({ - $match: { - $or: [ - { 'officer.fullName': searchRegex }, - { 'officer.email': searchRegex } - ] - } - }); + // Since we need to search by officer name/email, we'll still need a lookup if we didn't store them. + // I'll assume we didn't store name/email in cumulative to keep it lean. + // Actually, let's look them up if needed. } - if (department) pipeline.push({ $match: { 'officer.department': department } }); - if (subcity) pipeline.push({ $match: { 'officer.subcity': subcity } }); + const globalMax = await getGlobalMaxScore(from === to ? from : null); - const globalMax = await getGlobalMaxScore(pipeline); + // Choose Source + const SourceModel = isFilteredByPeriod ? OfficerStatsMonthly : OfficerStatsCumulative; - const aggregate = OfficerStats.aggregate(pipeline); - - aggregate.group({ - _id: '$officerId', - officer: { $first: '$officer' }, - totalRequests: { $sum: { $add: ['$totalConversations', '$totalApplications'] } }, - totalConversations: { $sum: '$totalConversations' }, - totalApplications: { $sum: '$totalApplications' }, - avgResponseTime: { $avg: '$averageResponseTimeMs' }, - communicationResponseRate: { $avg: '$communicationResponseRate' }, - applicationResponseRate: { $avg: '$applicationResponseRate' }, - // compute processed approximations for pagination too - sumProcessedFromConvs: { $sum: { $ifNull: ['$processedConversations', { $multiply: ['$communicationResponseRate', '$totalConversations'] }] } }, - sumProcessedFromApps: { $sum: { $ifNull: ['$processedApplications', { $multiply: ['$applicationResponseRate', '$totalApplications'] }] } } - }); - - // compute per-officer rawScore used for pagination ordering (exact same formula as main aggregation) - aggregate.pipeline().push({ - $addFields: { - requestsProcessed: { $add: ['$sumProcessedFromConvs', '$sumProcessedFromApps'] }, - requestsTotal: { $add: ['$totalConversations', '$totalApplications'] } - } - }); + // Build aggregation for pagination + const aggregate = SourceModel.aggregate([ + { $match: match } + ]); - aggregate.pipeline().push({ - $addFields: { + if (isFilteredByPeriod) { + // If monthly, we must group by officer because there might be multiple months + aggregate.group({ + _id: '$officerId', + totalConversations: { $sum: '$totalConversations' }, + totalApplications: { $sum: '$totalApplications' }, + requestsProcessed: { $sum: '$requestsProcessed' }, + requestsTotal: { $sum: '$requestsTotal' }, + sumWeightedTime: { $sum: { $multiply: ['$averageResponseTimeMs', '$requestsProcessed'] } }, + processedConversations: { $sum: '$processedConversations' }, + processedApplications: { $sum: '$processedApplications' }, + department: { $first: '$department' }, + subcity: { $first: '$subcity' } + }); + aggregate.addFields({ + avgResponseTimeMs: { $cond: [{ $gt: ['$requestsProcessed', 0] }, { $divide: ['$sumWeightedTime', '$requestsProcessed'] }, 0] }, rawScore: { $cond: [ { $gt: ['$requestsTotal', 0] }, @@ -279,19 +225,35 @@ export async function getPaginatedOfficerStats({ from, to, department, subcity, 0 ] } - } - }); + }); + } + // Sort by score aggregate.sort({ rawScore: -1, requestsTotal: -1 }); - // --- Calculate Global Counts (Total, Active, On Leave) --- - // We query the Officer model directly to get accurate current system state counts, - // independent of whether they have performance stats in the selected period. - const countQuery = {}; + // Lookup officer details for search/display + aggregate.lookup({ + from: 'users', + localField: isFilteredByPeriod ? '_id' : 'officerId', + foreignField: '_id', + as: 'officer' + }); + aggregate.unwind('$officer'); + if (search) { + const searchRegex = new RegExp(search, 'i'); + aggregate.match({ + $or: [ + { 'officer.fullName': searchRegex }, + { 'officer.email': searchRegex } + ] + }); + } + + // Counts (Total, Active, On Leave) + const countQuery = {}; if (department) countQuery.department = department; if (subcity) countQuery.subcity = subcity; - if (search) { const searchRegex = new RegExp(search, 'i'); countQuery.$or = [ @@ -300,51 +262,33 @@ export async function getPaginatedOfficerStats({ from, to, department, subcity, ]; } - // Run counts concurrently for performance const [totalCount, activeCount, onLeaveCount] = await Promise.all([ Officer.countDocuments(countQuery), Officer.countDocuments({ ...countQuery, onLeave: { $ne: true } }), Officer.countDocuments({ ...countQuery, onLeave: true }) ]); - const counts = { - total: totalCount, - active: activeCount, - onLeave: onLeaveCount - }; - // --------------------------------------------------------- - const options = { page: parseInt(page), limit: parseInt(limit) }; - const results = await OfficerStats.aggregatePaginate(aggregate, options); + const results = await SourceModel.aggregatePaginate(aggregate, options); - // Attach counts to results - results.counts = { - total: counts.total, - active: counts.active, - onLeave: counts.onLeave - }; + results.counts = { total: totalCount, active: activeCount, onLeave: onLeaveCount }; results.docs = results.docs.map(o => { - const totalConv = o.totalConversations || 0; - const totalApp = o.totalApplications || 0; - const denom = totalConv + totalApp; - const commRate = o.communicationResponseRate || 0; - const appRate = o.applicationResponseRate || 0; - const combinedResponseRate = denom > 0 ? (o.requestsProcessed / denom) : 0; - const raw = (o.rawScore || o.computedRawScore || 0); + const raw = (o.rawScore || 0); + const combinedResponseRate = o.requestsTotal > 0 ? (o.requestsProcessed / o.requestsTotal) : 0; return { - officerId: o._id, - // Use fullName if available, fall back to first/last or ID snippet - name: o.officer?.fullName || `${o.officer?.firstName || ''} ${o.officer?.lastName || ''}`.trim() || (o._id ? `(${o._id.toString().slice(-4)})` : 'Unknown'), - department: o.officer.department, - subcity: o.officer.subcity, + officerId: o.officerId || o._id, + name: o.officer?.fullName || 'Unknown', + department: o.department || o.officer.department, + subcity: o.subcity || o.officer.subcity, requestsTotal: o.requestsTotal, requestsProcessed: o.requestsProcessed, - avgResponseTime: o.avgResponseTime, + avgResponseTimeMs: o.averageResponseTimeMs || o.avgResponseTime || 0, + avgResponseTime: o.averageResponseTimeMs || o.avgResponseTime || 0, // Keep both for safety responseRate: Number((combinedResponseRate * 100)), + combinedResponseRate: Number((combinedResponseRate * 100)), // Map to combinedResponseRate rawScore: raw, rankScore: raw, - // score (percentage) remains for UI backward compatibility score: Number(((raw / (globalMax || 1)) * 100)) }; }); diff --git a/server/tests/admin.test.js b/server/tests/admin.test.js index 2679ff7..79f8f97 100644 --- a/server/tests/admin.test.js +++ b/server/tests/admin.test.js @@ -105,7 +105,7 @@ describe("Admin Routes (Cookie-Based Auth)", () => { expect(res.status).toBe(400); expect(res.body.success).toBe(false); - expect(res.body.message).toBe("Either name or email query parameter is required") + expect(res.body.error.message).toBe("Either name or email query parameter is required") }) it("It should find citizens by name", async () => { @@ -116,8 +116,8 @@ describe("Admin Routes (Cookie-Based Auth)", () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - expect(res.body.citizens.length).toBe(1); - expect(res.body.citizens[0].role).toBe("citizen"); + expect(res.body.data?.citizens.length).toBe(1); + expect(res.body.data?.citizens[0].role).toBe("citizen"); }); it("It should find citizens by email", async () => { @@ -128,8 +128,8 @@ describe("Admin Routes (Cookie-Based Auth)", () => { expect(res.status).toBe(200); expect(res.body.success).toBe(true); - expect(res.body.citizens.length).toBe(1); - expect(res.body.citizens[0].role).toBe("citizen"); + expect(res.body.data?.citizens.length).toBe(1); + expect(res.body.data?.citizens[0].role).toBe("citizen"); }); it("It should never return officers or admins", async () => { @@ -139,8 +139,8 @@ describe("Admin Routes (Cookie-Based Auth)", () => { .query({ name: "one" }); expect(res.status).toBe(200); - expect(res.body.citizens.length).toBe(1); - expect(res.body.citizens[0].role).toBe("citizen"); + expect(res.body.data?.citizens.length).toBe(1); + expect(res.body.data?.citizens[0].role).toBe("citizen"); }); it("It should return a maximum of 5 users", async () => { @@ -150,8 +150,8 @@ describe("Admin Routes (Cookie-Based Auth)", () => { .query({ name: "Citizen" }) expect(res.status).toBe(200); - expect(res.body.citizens.length).toBe(5); - expect(res.body.citizens[0].role).toBe("citizen"); + expect(res.body.data?.citizens.length).toBe(5); + expect(res.body.data?.citizens[0].role).toBe("citizen"); }) }); @@ -185,7 +185,7 @@ describe("Admin Routes (Cookie-Based Auth)", () => { expect(res.status).toBe(401) expect(res.body.success).toBe(false) - expect(res.body.message).toBe("Invalid admin password") + expect(res.body.error.message).toBe("Invalid admin password") }); it("It should return 400 if adminpassword is missing", async () => { @@ -199,7 +199,7 @@ describe("Admin Routes (Cookie-Based Auth)", () => { expect(res.status).toBe(400) expect(res.body.success).toBe(false) - expect(res.body.message).toBe("Missing required fields") + expect(res.body.error.message).toBe("Missing required fields") }); it("It should return 404 if user does not exist", async () => { @@ -216,7 +216,7 @@ describe("Admin Routes (Cookie-Based Auth)", () => { expect(res.status).toBe(404) expect(res.body.success).toBe(false) - expect(res.body.message).toBe("User not found") + expect(res.body.error.message).toBe("User not found") }); it("It should return 409 if user is not a citizen", async () => { @@ -231,7 +231,7 @@ describe("Admin Routes (Cookie-Based Auth)", () => { expect(res.status).toBe(409) expect(res.body.success).toBe(false) - expect(res.body.message).toBe("User is not eligible for officer role") + expect(res.body.error.message).toBe("User is not eligible for officer role") }); it("It should successfully promote a citizen to officer", async () => {