From 7b0b5099fe231e979fbba5ea1d2ee2296e815520 Mon Sep 17 00:00:00 2001 From: debudebuye Date: Tue, 24 Feb 2026 21:01:08 +0300 Subject: [PATCH] feat(notifications): Add notification system with service layer and UI integration --- src/App.tsx | 2 + src/layouts/app-shell.tsx | 81 +++++-- src/pages/admin/dashboard/index.tsx | 35 ++- src/pages/dashboard/index.tsx | 31 ++- src/pages/notifications/index.tsx | 310 ++++++++++++++++++++------- src/services/analytics.service.ts | 10 + src/services/dashboard.service.ts | 10 + src/services/index.ts | 4 + src/services/notification.service.ts | 133 ++++++++++++ 9 files changed, 524 insertions(+), 92 deletions(-) create mode 100644 src/services/notification.service.ts diff --git a/src/App.tsx b/src/App.tsx index 992802f..7bd9aeb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ import AnalyticsRevenuePage from "@/pages/admin/analytics/revenue" import AnalyticsStoragePage from "@/pages/admin/analytics/storage" import AnalyticsApiPage from "@/pages/admin/analytics/api" import HealthPage from "@/pages/admin/health" +import NotificationsPage from "@/pages/notifications" function App() { return ( @@ -62,6 +63,7 @@ function App() { } /> } /> } /> + } /> } /> diff --git a/src/layouts/app-shell.tsx b/src/layouts/app-shell.tsx index b24e212..bac8caf 100644 --- a/src/layouts/app-shell.tsx +++ b/src/layouts/app-shell.tsx @@ -12,15 +12,23 @@ import { Activity, Heart, Search, - Mail, Bell, LogOut, } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" import { cn } from "@/lib/utils" import { authService } from "@/services" +import { toast } from "sonner" interface User { email: string @@ -46,6 +54,7 @@ export function AppShell() { const location = useLocation() const navigate = useNavigate() const [user, setUser] = useState(null) + const [searchQuery, setSearchQuery] = useState("") useEffect(() => { const userStr = localStorage.getItem('user') @@ -75,6 +84,28 @@ export function AppShell() { navigate('/login', { replace: true }) } + const handleSearch = (e: React.FormEvent) => { + e.preventDefault() + if (searchQuery.trim()) { + const currentPath = location.pathname + navigate(`${currentPath}?search=${encodeURIComponent(searchQuery)}`) + toast.success(`Searching for: ${searchQuery}`) + } + } + + const handleSearchChange = (e: React.ChangeEvent) => { + setSearchQuery(e.target.value) + } + + const handleNotificationClick = () => { + console.log('Notification button clicked') + navigate('/notifications') + } + + const handleProfileClick = () => { + navigate('/admin/settings') + } + const getUserInitials = () => { if (user?.firstName && user?.lastName) { return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase() @@ -155,24 +186,50 @@ export function AppShell() {

{getPageTitle()}

-
+
-
- - - - {getUserInitials()} - + + + + + + +
+

{getUserDisplayName()}

+

{user?.email}

+
+
+ + + + Profile Settings + + navigate('/notifications')}> + + Notifications + + + + + Logout + +
+
diff --git a/src/pages/admin/dashboard/index.tsx b/src/pages/admin/dashboard/index.tsx index a6746d3..630d3dd 100644 --- a/src/pages/admin/dashboard/index.tsx +++ b/src/pages/admin/dashboard/index.tsx @@ -33,7 +33,40 @@ export default function DashboardPage() { }) const handleExport = () => { - toast.success("Exporting dashboard data...") + try { + // Create CSV content from current dashboard data + const csvContent = [ + ['Metric', 'Value'], + ['Total Users', overview?.users?.total || 0], + ['Active Users', overview?.users?.active || 0], + ['Inactive Users', overview?.users?.inactive || 0], + ['Total Invoices', overview?.invoices?.total || 0], + ['Total Revenue', overview?.revenue?.total || 0], + ['Storage Used', overview?.storage?.totalSize || 0], + ['Total Documents', overview?.storage?.documents || 0], + ['Error Rate', errorRate?.errorRate || 0], + ['Total Errors', errorRate?.errors || 0], + ['Export Date', new Date().toISOString()], + ] + .map(row => row.join(',')) + .join('\n') + + // Create and download the file + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `admin-dashboard-${new Date().toISOString().split('T')[0]}.csv` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + toast.success("Dashboard data exported successfully!") + } catch (error) { + toast.error("Failed to export data. Please try again.") + console.error('Export error:', error) + } } const formatCurrency = (amount: number) => { diff --git a/src/pages/dashboard/index.tsx b/src/pages/dashboard/index.tsx index 8b72a58..d65e4f5 100644 --- a/src/pages/dashboard/index.tsx +++ b/src/pages/dashboard/index.tsx @@ -17,7 +17,36 @@ export default function DashboardPage() { }) const handleExport = () => { - toast.success("Exporting your data...") + try { + // Create CSV content from current stats + const csvContent = [ + ['Metric', 'Value'], + ['Total Invoices', stats?.totalInvoices || 0], + ['Pending Invoices', stats?.pendingInvoices || 0], + ['Total Transactions', stats?.totalTransactions || 0], + ['Total Revenue', stats?.totalRevenue || 0], + ['Growth Percentage', stats?.growthPercentage || 0], + ['Export Date', new Date().toISOString()], + ] + .map(row => row.join(',')) + .join('\n') + + // Create and download the file + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `dashboard-export-${new Date().toISOString().split('T')[0]}.csv` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + toast.success("Data exported successfully!") + } catch (error) { + toast.error("Failed to export data. Please try again.") + console.error('Export error:', error) + } } const formatCurrency = (amount: number) => { diff --git a/src/pages/notifications/index.tsx b/src/pages/notifications/index.tsx index 533dc43..2d0e591 100644 --- a/src/pages/notifications/index.tsx +++ b/src/pages/notifications/index.tsx @@ -1,3 +1,5 @@ +import { useState, useMemo } from "react" +import { useQuery } from "@tanstack/react-query" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" @@ -10,17 +12,161 @@ import { TableHeader, TableRow, } from "@/components/ui/table" -import { Search, Download, Eye, MoreVertical, Bell } from "lucide-react" +import { Search, Download, Eye, CheckCheck, Bell, Loader2 } from "lucide-react" +import { notificationService } from "@/services/notification.service" +import { toast } from "sonner" export default function NotificationsPage() { + const [searchQuery, setSearchQuery] = useState("") + const [typeFilter, setTypeFilter] = useState("") + const [statusFilter, setStatusFilter] = useState("") + + const { data: notifications, isLoading, refetch } = useQuery({ + queryKey: ['notifications'], + queryFn: () => notificationService.getNotifications(), + }) + + const { data: unreadCount } = useQuery({ + queryKey: ['notifications', 'unread-count'], + queryFn: () => notificationService.getUnreadCount(), + }) + + // Client-side filtering + const filteredNotifications = useMemo(() => { + if (!notifications) return [] + + return notifications.filter((notification) => { + // Type filter + if (typeFilter && notification.type !== typeFilter) return false + + // Status filter + if (statusFilter === 'read' && !notification.isRead) return false + if (statusFilter === 'unread' && notification.isRead) return false + + // Search filter + if (searchQuery) { + const query = searchQuery.toLowerCase() + return ( + notification.title.toLowerCase().includes(query) || + notification.message.toLowerCase().includes(query) || + notification.recipient.toLowerCase().includes(query) + ) + } + + return true + }) + }, [notifications, typeFilter, statusFilter, searchQuery]) + + const handleExport = () => { + try { + if (!filteredNotifications || filteredNotifications.length === 0) { + toast.error("No notifications to export") + return + } + + const csvData = [ + ['Notification ID', 'Title', 'Message', 'Type', 'Recipient', 'Status', 'Created Date', 'Read Date'], + ...filteredNotifications.map(n => [ + n.id, + n.title, + n.message, + n.type, + n.recipient, + n.isRead ? 'Read' : 'Unread', + new Date(n.createdAt).toLocaleString(), + n.readAt ? new Date(n.readAt).toLocaleString() : '-' + ]) + ] + + const csvContent = csvData.map(row => + row.map(cell => `"${String(cell).replace(/"/g, '""')}"`).join(',') + ).join('\n') + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `notifications-${new Date().toISOString().split('T')[0]}.csv` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + toast.success("Notifications exported successfully!") + } catch (error) { + toast.error("Failed to export notifications") + console.error('Export error:', error) + } + } + + const handleMarkAsRead = async (id: string) => { + try { + await notificationService.markAsRead(id) + toast.success("Notification marked as read") + refetch() + } catch (error) { + toast.error("Failed to mark notification as read") + console.error('Mark as read error:', error) + } + } + + const handleMarkAllAsRead = async () => { + try { + await notificationService.markAllAsRead() + toast.success("All notifications marked as read") + refetch() + } catch (error) { + toast.error("Failed to mark all as read") + console.error('Mark all as read error:', error) + } + } + + const formatDate = (dateString: string) => { + return new Date(dateString).toLocaleDateString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + } + + const formatDateTime = (dateString?: string) => { + if (!dateString) return '-' + return new Date(dateString).toLocaleString('en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }) + } + + const getStatusBadge = (isRead: boolean) => { + return isRead ? 'bg-gray-500' : 'bg-orange-500' + } + return (
-

Notifications

- +
+

Notifications

+ {unreadCount !== undefined && unreadCount > 0 && ( +

+ You have {unreadCount} unread notification{unreadCount !== 1 ? 's' : ''} +

+ )} +
+
+ {unreadCount !== undefined && unreadCount > 0 && ( + + )} + +
@@ -33,20 +179,32 @@ export default function NotificationsPage() { setSearchQuery(e.target.value)} />
- setTypeFilter(e.target.value)} + > + + + + + + - setStatusFilter(e.target.value)} + > + + + - @@ -54,68 +212,64 @@ export default function NotificationsPage() { - - - - Notification ID - Title - Type - Recipient - Status - Created Date - Sent Date - Action - - - - - NOT001 - System Update Available - - System - - All Users - - Sent - - 2024-01-15 - 2024-01-15 10:00 - -
- - -
-
-
- - NOT002 - Payment Received - - User - - john@example.com - - Delivered - - 2024-01-14 - 2024-01-14 14:30 - -
- - -
-
-
-
-
+ {isLoading ? ( +
+ +
+ ) : filteredNotifications && filteredNotifications.length > 0 ? ( + + + + Notification ID + Title + Message + Type + Status + Created Date + Read Date + Action + + + + {filteredNotifications.map((notification) => ( + + {notification.id} + {notification.title} + {notification.message} + + {notification.type} + + + + {notification.isRead ? 'Read' : 'Unread'} + + + {formatDate(notification.createdAt)} + {formatDateTime(notification.readAt)} + + {!notification.isRead && ( + + )} + + + ))} + +
+ ) : ( +
+ {searchQuery || typeFilter || statusFilter + ? 'No notifications match your filters' + : 'No notifications found' + } +
+ )}
diff --git a/src/services/analytics.service.ts b/src/services/analytics.service.ts index 243c7e5..6d0f261 100644 --- a/src/services/analytics.service.ts +++ b/src/services/analytics.service.ts @@ -102,6 +102,16 @@ class AnalyticsService { const response = await apiClient.get('/admin/analytics/storage') return response.data } + + /** + * Export analytics data + */ + async exportData(): Promise { + const response = await apiClient.get('/admin/analytics/export', { + responseType: 'blob', + }) + return response.data + } } export const analyticsService = new AnalyticsService() diff --git a/src/services/dashboard.service.ts b/src/services/dashboard.service.ts index c652e79..cee7bae 100644 --- a/src/services/dashboard.service.ts +++ b/src/services/dashboard.service.ts @@ -49,6 +49,16 @@ class DashboardService { }) return response.data } + + /** + * Export user dashboard data + */ + async exportData(): Promise { + const response = await apiClient.get('/user/export', { + responseType: 'blob', + }) + return response.data + } } export const dashboardService = new DashboardService() diff --git a/src/services/index.ts b/src/services/index.ts index c07ede8..6aa04de 100644 --- a/src/services/index.ts +++ b/src/services/index.ts @@ -8,6 +8,7 @@ export { announcementService } from './announcement.service' export { auditService } from './audit.service' export { settingsService } from './settings.service' export { dashboardService } from './dashboard.service' +export { notificationService } from './notification.service' // Export types export type { LoginRequest, LoginResponse } from './auth.service' @@ -19,3 +20,6 @@ export type { Announcement, CreateAnnouncementData, UpdateAnnouncementData } fro export type { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service' export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service' export type { UserDashboardStats, UserProfile } from './dashboard.service' +export type { Notification } from './notification.service' +export type { NotificationSettings } from './notification.service' +export type { NotificationSettings } from './notification.service' diff --git a/src/services/notification.service.ts b/src/services/notification.service.ts new file mode 100644 index 0000000..76a8af7 --- /dev/null +++ b/src/services/notification.service.ts @@ -0,0 +1,133 @@ +import apiClient from './api/client' + +export interface Notification { + id: string + title: string + message: string + type: 'system' | 'user' | 'alert' | 'invoice' | 'payment' + recipient: string + status: 'sent' | 'delivered' | 'read' | 'unread' + isRead: boolean + createdAt: string + sentAt?: string + readAt?: string +} + +export interface NotificationSettings { + emailNotifications: boolean + pushNotifications: boolean + invoiceReminders: boolean + paymentAlerts: boolean + systemUpdates: boolean +} + +class NotificationService { + /** + * Get all notifications for current user + */ + async getNotifications(params?: { + type?: string + status?: string + search?: string + }): Promise { + const response = await apiClient.get('/notifications', { + params, + }) + return response.data + } + + /** + * Get unread notification count + */ + async getUnreadCount(): Promise { + const response = await apiClient.get<{ count: number }>('/notifications/unread-count') + return response.data.count + } + + /** + * Mark notification as read + */ + async markAsRead(id: string): Promise { + await apiClient.post(`/notifications/${id}/read`) + } + + /** + * Mark all notifications as read + */ + async markAllAsRead(): Promise { + await apiClient.post('/notifications/read-all') + } + + /** + * Send notification (ADMIN only) + */ + async sendNotification(data: { + title: string + message: string + type: string + recipient?: string + recipientType?: 'user' | 'all' + }): Promise { + const response = await apiClient.post('/notifications/send', data) + return response.data + } + + /** + * Subscribe to push notifications + */ + async subscribeToPush(subscription: PushSubscription): Promise { + await apiClient.post('/notifications/subscribe', subscription) + } + + /** + * Unsubscribe from push notifications + */ + async unsubscribeFromPush(endpoint: string): Promise { + await apiClient.delete(`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`) + } + + /** + * Get notification settings + */ + async getSettings(): Promise { + const response = await apiClient.get('/notifications/settings') + return response.data + } + + /** + * Update notification settings + */ + async updateSettings(settings: Partial): Promise { + const response = await apiClient.put('/notifications/settings', settings) + return response.data + } + + /** + * Send invoice reminder + */ + async sendInvoiceReminder(invoiceId: string): Promise { + await apiClient.post(`/notifications/invoice/${invoiceId}/reminder`) + } + + /** + * Export notifications (creates CSV from current data) + */ + async exportNotifications(notifications: Notification[]): Promise { + const csvContent = [ + ['ID', 'Title', 'Message', 'Type', 'Status', 'Created Date', 'Read Date'], + ...notifications.map(n => [ + n.id, + n.title, + n.message, + n.type, + n.status, + n.createdAt, + n.readAt || '-' + ]) + ].map(row => row.join(',')).join('\n') + + return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }) + } +} + +export const notificationService = new NotificationService()