feat(notifications): Add notification system with service layer and UI integration
This commit is contained in:
parent
d251958a9b
commit
7b0b5099fe
|
|
@ -24,6 +24,7 @@ import AnalyticsRevenuePage from "@/pages/admin/analytics/revenue"
|
||||||
import AnalyticsStoragePage from "@/pages/admin/analytics/storage"
|
import AnalyticsStoragePage from "@/pages/admin/analytics/storage"
|
||||||
import AnalyticsApiPage from "@/pages/admin/analytics/api"
|
import AnalyticsApiPage from "@/pages/admin/analytics/api"
|
||||||
import HealthPage from "@/pages/admin/health"
|
import HealthPage from "@/pages/admin/health"
|
||||||
|
import NotificationsPage from "@/pages/notifications"
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -62,6 +63,7 @@ function App() {
|
||||||
<Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} />
|
<Route path="admin/analytics/storage" element={<AnalyticsStoragePage />} />
|
||||||
<Route path="admin/analytics/api" element={<AnalyticsApiPage />} />
|
<Route path="admin/analytics/api" element={<AnalyticsApiPage />} />
|
||||||
<Route path="admin/health" element={<HealthPage />} />
|
<Route path="admin/health" element={<HealthPage />} />
|
||||||
|
<Route path="notifications" element={<NotificationsPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
<Route path="*" element={<Navigate to="/admin/dashboard" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
|
||||||
|
|
@ -12,15 +12,23 @@ import {
|
||||||
Activity,
|
Activity,
|
||||||
Heart,
|
Heart,
|
||||||
Search,
|
Search,
|
||||||
Mail,
|
|
||||||
Bell,
|
Bell,
|
||||||
LogOut,
|
LogOut,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
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 { cn } from "@/lib/utils"
|
||||||
import { authService } from "@/services"
|
import { authService } from "@/services"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
email: string
|
email: string
|
||||||
|
|
@ -46,6 +54,7 @@ export function AppShell() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const [user, setUser] = useState<User | null>(null)
|
const [user, setUser] = useState<User | null>(null)
|
||||||
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userStr = localStorage.getItem('user')
|
const userStr = localStorage.getItem('user')
|
||||||
|
|
@ -75,6 +84,28 @@ export function AppShell() {
|
||||||
navigate('/login', { replace: true })
|
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<HTMLInputElement>) => {
|
||||||
|
setSearchQuery(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleNotificationClick = () => {
|
||||||
|
console.log('Notification button clicked')
|
||||||
|
navigate('/notifications')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleProfileClick = () => {
|
||||||
|
navigate('/admin/settings')
|
||||||
|
}
|
||||||
|
|
||||||
const getUserInitials = () => {
|
const getUserInitials = () => {
|
||||||
if (user?.firstName && user?.lastName) {
|
if (user?.firstName && user?.lastName) {
|
||||||
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
|
return `${user.firstName[0]}${user.lastName[0]}`.toUpperCase()
|
||||||
|
|
@ -155,24 +186,50 @@ export function AppShell() {
|
||||||
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
|
<header className="h-16 border-b bg-background flex items-center justify-between px-6">
|
||||||
<h1 className="text-2xl font-bold">{getPageTitle()}</h1>
|
<h1 className="text-2xl font-bold">{getPageTitle()}</h1>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="relative">
|
<form onSubmit={handleSearch} className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
<Input
|
<Input
|
||||||
placeholder="Quick Search..."
|
placeholder="Quick Search..."
|
||||||
className="pl-10 w-64"
|
className="pl-10 w-64"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
/>
|
/>
|
||||||
</div>
|
</form>
|
||||||
<Button variant="ghost" size="icon" className="relative">
|
<Button variant="ghost" size="icon" className="relative" onClick={handleNotificationClick}>
|
||||||
<Mail className="w-5 h-5" />
|
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
|
||||||
</Button>
|
|
||||||
<Button variant="ghost" size="icon" className="relative">
|
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full" />
|
<span className="absolute top-1 right-1 w-2 h-2 bg-red-500 rounded-full pointer-events-none" />
|
||||||
</Button>
|
</Button>
|
||||||
<Avatar>
|
<DropdownMenu>
|
||||||
<AvatarFallback>{getUserInitials()}</AvatarFallback>
|
<DropdownMenuTrigger asChild>
|
||||||
</Avatar>
|
<Button variant="ghost" size="icon" className="rounded-full">
|
||||||
|
<Avatar>
|
||||||
|
<AvatarFallback>{getUserInitials()}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuLabel>
|
||||||
|
<div className="flex flex-col space-y-1">
|
||||||
|
<p className="text-sm font-medium">{getUserDisplayName()}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{user?.email}</p>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleProfileClick}>
|
||||||
|
<Settings className="w-4 h-4 mr-2" />
|
||||||
|
Profile Settings
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => navigate('/notifications')}>
|
||||||
|
<Bell className="w-4 h-4 mr-2" />
|
||||||
|
Notifications
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
|
<LogOut className="w-4 h-4 mr-2" />
|
||||||
|
Logout
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,40 @@ export default function DashboardPage() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleExport = () => {
|
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) => {
|
const formatCurrency = (amount: number) => {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,36 @@ export default function DashboardPage() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const handleExport = () => {
|
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) => {
|
const formatCurrency = (amount: number) => {
|
||||||
|
|
|
||||||
|
|
@ -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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
|
|
@ -10,17 +12,161 @@ import {
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} 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() {
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="text-3xl font-bold">Notifications</h2>
|
<div>
|
||||||
<Button>
|
<h2 className="text-3xl font-bold">Notifications</h2>
|
||||||
<Bell className="w-4 h-4 mr-2" />
|
{unreadCount !== undefined && unreadCount > 0 && (
|
||||||
Create Notification
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
</Button>
|
You have {unreadCount} unread notification{unreadCount !== 1 ? 's' : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{unreadCount !== undefined && unreadCount > 0 && (
|
||||||
|
<Button variant="outline" onClick={handleMarkAllAsRead}>
|
||||||
|
<CheckCheck className="w-4 h-4 mr-2" />
|
||||||
|
Mark All as Read
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button>
|
||||||
|
<Bell className="w-4 h-4 mr-2" />
|
||||||
|
Settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|
@ -33,20 +179,32 @@ export default function NotificationsPage() {
|
||||||
<Input
|
<Input
|
||||||
placeholder="Search notification..."
|
placeholder="Search notification..."
|
||||||
className="pl-10 w-64"
|
className="pl-10 w-64"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<select className="px-3 py-2 border rounded-md text-sm">
|
<select
|
||||||
<option>All Types</option>
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
<option>System</option>
|
value={typeFilter}
|
||||||
<option>User</option>
|
onChange={(e) => setTypeFilter(e.target.value)}
|
||||||
<option>Alert</option>
|
>
|
||||||
|
<option value="">All Types</option>
|
||||||
|
<option value="system">System</option>
|
||||||
|
<option value="user">User</option>
|
||||||
|
<option value="alert">Alert</option>
|
||||||
|
<option value="invoice">Invoice</option>
|
||||||
|
<option value="payment">Payment</option>
|
||||||
</select>
|
</select>
|
||||||
<select className="px-3 py-2 border rounded-md text-sm">
|
<select
|
||||||
<option>All Status</option>
|
className="px-3 py-2 border rounded-md text-sm"
|
||||||
<option>Read</option>
|
value={statusFilter}
|
||||||
<option>Unread</option>
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="">All Status</option>
|
||||||
|
<option value="read">Read</option>
|
||||||
|
<option value="unread">Unread</option>
|
||||||
</select>
|
</select>
|
||||||
<Button variant="outline">
|
<Button variant="outline" onClick={handleExport}>
|
||||||
<Download className="w-4 h-4 mr-2" />
|
<Download className="w-4 h-4 mr-2" />
|
||||||
Export
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -54,68 +212,64 @@ export default function NotificationsPage() {
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<Table>
|
{isLoading ? (
|
||||||
<TableHeader>
|
<div className="flex items-center justify-center py-8">
|
||||||
<TableRow>
|
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||||
<TableHead>Notification ID</TableHead>
|
</div>
|
||||||
<TableHead>Title</TableHead>
|
) : filteredNotifications && filteredNotifications.length > 0 ? (
|
||||||
<TableHead>Type</TableHead>
|
<Table>
|
||||||
<TableHead>Recipient</TableHead>
|
<TableHeader>
|
||||||
<TableHead>Status</TableHead>
|
<TableRow>
|
||||||
<TableHead>Created Date</TableHead>
|
<TableHead>Notification ID</TableHead>
|
||||||
<TableHead>Sent Date</TableHead>
|
<TableHead>Title</TableHead>
|
||||||
<TableHead>Action</TableHead>
|
<TableHead>Message</TableHead>
|
||||||
</TableRow>
|
<TableHead>Type</TableHead>
|
||||||
</TableHeader>
|
<TableHead>Status</TableHead>
|
||||||
<TableBody>
|
<TableHead>Created Date</TableHead>
|
||||||
<TableRow>
|
<TableHead>Read Date</TableHead>
|
||||||
<TableCell className="font-medium">NOT001</TableCell>
|
<TableHead>Action</TableHead>
|
||||||
<TableCell>System Update Available</TableCell>
|
</TableRow>
|
||||||
<TableCell>
|
</TableHeader>
|
||||||
<Badge variant="outline">System</Badge>
|
<TableBody>
|
||||||
</TableCell>
|
{filteredNotifications.map((notification) => (
|
||||||
<TableCell>All Users</TableCell>
|
<TableRow key={notification.id} className={!notification.isRead ? 'bg-blue-50' : ''}>
|
||||||
<TableCell>
|
<TableCell className="font-medium">{notification.id}</TableCell>
|
||||||
<Badge className="bg-blue-500">Sent</Badge>
|
<TableCell className="font-medium">{notification.title}</TableCell>
|
||||||
</TableCell>
|
<TableCell className="max-w-xs truncate">{notification.message}</TableCell>
|
||||||
<TableCell>2024-01-15</TableCell>
|
<TableCell>
|
||||||
<TableCell>2024-01-15 10:00</TableCell>
|
<Badge variant="outline" className="capitalize">{notification.type}</Badge>
|
||||||
<TableCell>
|
</TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<TableCell>
|
||||||
<Button variant="ghost" size="icon">
|
<Badge className={getStatusBadge(notification.isRead)}>
|
||||||
<Eye className="w-4 h-4" />
|
{notification.isRead ? 'Read' : 'Unread'}
|
||||||
</Button>
|
</Badge>
|
||||||
<Button variant="ghost" size="icon">
|
</TableCell>
|
||||||
<MoreVertical className="w-4 h-4" />
|
<TableCell>{formatDate(notification.createdAt)}</TableCell>
|
||||||
</Button>
|
<TableCell>{formatDateTime(notification.readAt)}</TableCell>
|
||||||
</div>
|
<TableCell>
|
||||||
</TableCell>
|
{!notification.isRead && (
|
||||||
</TableRow>
|
<Button
|
||||||
<TableRow>
|
variant="ghost"
|
||||||
<TableCell className="font-medium">NOT002</TableCell>
|
size="icon"
|
||||||
<TableCell>Payment Received</TableCell>
|
onClick={() => handleMarkAsRead(notification.id)}
|
||||||
<TableCell>
|
title="Mark as read"
|
||||||
<Badge variant="outline">User</Badge>
|
>
|
||||||
</TableCell>
|
<Eye className="w-4 h-4" />
|
||||||
<TableCell>john@example.com</TableCell>
|
</Button>
|
||||||
<TableCell>
|
)}
|
||||||
<Badge className="bg-green-500">Delivered</Badge>
|
</TableCell>
|
||||||
</TableCell>
|
</TableRow>
|
||||||
<TableCell>2024-01-14</TableCell>
|
))}
|
||||||
<TableCell>2024-01-14 14:30</TableCell>
|
</TableBody>
|
||||||
<TableCell>
|
</Table>
|
||||||
<div className="flex items-center gap-2">
|
) : (
|
||||||
<Button variant="ghost" size="icon">
|
<div className="text-center py-8 text-muted-foreground">
|
||||||
<Eye className="w-4 h-4" />
|
{searchQuery || typeFilter || statusFilter
|
||||||
</Button>
|
? 'No notifications match your filters'
|
||||||
<Button variant="ghost" size="icon">
|
: 'No notifications found'
|
||||||
<MoreVertical className="w-4 h-4" />
|
}
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,16 @@ class AnalyticsService {
|
||||||
const response = await apiClient.get('/admin/analytics/storage')
|
const response = await apiClient.get('/admin/analytics/storage')
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export analytics data
|
||||||
|
*/
|
||||||
|
async exportData(): Promise<Blob> {
|
||||||
|
const response = await apiClient.get('/admin/analytics/export', {
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const analyticsService = new AnalyticsService()
|
export const analyticsService = new AnalyticsService()
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,16 @@ class DashboardService {
|
||||||
})
|
})
|
||||||
return response.data
|
return response.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export user dashboard data
|
||||||
|
*/
|
||||||
|
async exportData(): Promise<Blob> {
|
||||||
|
const response = await apiClient.get('/user/export', {
|
||||||
|
responseType: 'blob',
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dashboardService = new DashboardService()
|
export const dashboardService = new DashboardService()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export { announcementService } from './announcement.service'
|
||||||
export { auditService } from './audit.service'
|
export { auditService } from './audit.service'
|
||||||
export { settingsService } from './settings.service'
|
export { settingsService } from './settings.service'
|
||||||
export { dashboardService } from './dashboard.service'
|
export { dashboardService } from './dashboard.service'
|
||||||
|
export { notificationService } from './notification.service'
|
||||||
|
|
||||||
// Export types
|
// Export types
|
||||||
export type { LoginRequest, LoginResponse } from './auth.service'
|
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 { AuditLog, GetAuditLogsParams, AuditStats } from './audit.service'
|
||||||
export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service'
|
export type { Setting, CreateSettingData, UpdateSettingData } from './settings.service'
|
||||||
export type { UserDashboardStats, UserProfile } from './dashboard.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'
|
||||||
|
|
|
||||||
133
src/services/notification.service.ts
Normal file
133
src/services/notification.service.ts
Normal file
|
|
@ -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<Notification[]> {
|
||||||
|
const response = await apiClient.get<Notification[]>('/notifications', {
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get unread notification count
|
||||||
|
*/
|
||||||
|
async getUnreadCount(): Promise<number> {
|
||||||
|
const response = await apiClient.get<{ count: number }>('/notifications/unread-count')
|
||||||
|
return response.data.count
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark notification as read
|
||||||
|
*/
|
||||||
|
async markAsRead(id: string): Promise<void> {
|
||||||
|
await apiClient.post(`/notifications/${id}/read`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark all notifications as read
|
||||||
|
*/
|
||||||
|
async markAllAsRead(): Promise<void> {
|
||||||
|
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<Notification> {
|
||||||
|
const response = await apiClient.post<Notification>('/notifications/send', data)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscribe to push notifications
|
||||||
|
*/
|
||||||
|
async subscribeToPush(subscription: PushSubscription): Promise<void> {
|
||||||
|
await apiClient.post('/notifications/subscribe', subscription)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unsubscribe from push notifications
|
||||||
|
*/
|
||||||
|
async unsubscribeFromPush(endpoint: string): Promise<void> {
|
||||||
|
await apiClient.delete(`/notifications/unsubscribe/${encodeURIComponent(endpoint)}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get notification settings
|
||||||
|
*/
|
||||||
|
async getSettings(): Promise<NotificationSettings> {
|
||||||
|
const response = await apiClient.get<NotificationSettings>('/notifications/settings')
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update notification settings
|
||||||
|
*/
|
||||||
|
async updateSettings(settings: Partial<NotificationSettings>): Promise<NotificationSettings> {
|
||||||
|
const response = await apiClient.put<NotificationSettings>('/notifications/settings', settings)
|
||||||
|
return response.data
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send invoice reminder
|
||||||
|
*/
|
||||||
|
async sendInvoiceReminder(invoiceId: string): Promise<void> {
|
||||||
|
await apiClient.post(`/notifications/invoice/${invoiceId}/reminder`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Export notifications (creates CSV from current data)
|
||||||
|
*/
|
||||||
|
async exportNotifications(notifications: Notification[]): Promise<Blob> {
|
||||||
|
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()
|
||||||
Loading…
Reference in New Issue
Block a user