From 17c91ccc74e15b223c54b10c411b87cb04444b66 Mon Sep 17 00:00:00 2001 From: Alqku Date: Sat, 30 May 2026 20:01:51 +0000 Subject: [PATCH 1/2] feat: add UserManagementTable with search, filter, pagination, and role management Closes #875 --- .../UserManagement/UserManagementTable.tsx | 355 ++++++++++++++++++ .../pages/admin/UserManagement/index.ts | 1 + 2 files changed, 356 insertions(+) create mode 100644 frontend/package/pages/admin/UserManagement/UserManagementTable.tsx create mode 100644 frontend/package/pages/admin/UserManagement/index.ts diff --git a/frontend/package/pages/admin/UserManagement/UserManagementTable.tsx b/frontend/package/pages/admin/UserManagement/UserManagementTable.tsx new file mode 100644 index 00000000..f92bf0a8 --- /dev/null +++ b/frontend/package/pages/admin/UserManagement/UserManagementTable.tsx @@ -0,0 +1,355 @@ +'use client'; + +import { useState, useCallback, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Input } from '@/components/ui/input'; +import { UserTableRowSkeleton } from '@/components/ui/skeleton'; +import { apiClient } from '@/lib/api/client'; +import type { User, UserRole } from '@/types/auth.types'; + +const ROLE_OPTIONS: { label: string; value: UserRole | 'all' }[] = [ + { label: 'All Roles', value: 'all' }, + { label: 'Shipper', value: 'shipper' }, + { label: 'Carrier', value: 'carrier' }, + { label: 'Admin', value: 'admin' }, +]; + +interface PaginatedUsers { + data: User[]; + total: number; + page: number; + limit: number; + totalPages: number; +} + +interface ConfirmModalProps { + title: string; + message: string; + confirmLabel: string; + variant?: 'default' | 'destructive'; + onConfirm: () => void; + onCancel: () => void; + loading: boolean; +} + +function ConfirmModal({ title, message, confirmLabel, variant = 'destructive', onConfirm, onCancel, loading }: ConfirmModalProps) { + return ( +
+
+

{title}

+

{message}

+
+ + +
+
+
+ ); +} + +export function UserManagementTable() { + const queryClient = useQueryClient(); + const [search, setSearch] = useState(''); + const [debouncedSearch, setDebouncedSearch] = useState(''); + const [roleFilter, setRoleFilter] = useState('all'); + const [page, setPage] = useState(1); + const [confirmAction, setConfirmAction] = useState<{ + user: User; + action: 'activate' | 'deactivate' | 'changeRole'; + newRole?: UserRole; + } | null>(null); + + // Debounce search + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedSearch(search); + setPage(1); + }, 300); + return () => clearTimeout(timer); + }, [search]); + + const { data: result, isLoading } = useQuery({ + queryKey: ['user-management', debouncedSearch, roleFilter, page], + queryFn: () => + apiClient( + `/users?page=${page}&limit=20${roleFilter !== 'all' ? `&role=${roleFilter}` : ''}${debouncedSearch ? `&search=${encodeURIComponent(debouncedSearch)}` : ''}`, + ), + }); + + const toggleActiveMutation = useMutation({ + mutationFn: ({ userId, isActive }: { userId: string; isActive: boolean }) => + apiClient(`/users/${userId}/${isActive ? 'deactivate' : 'activate'}`, { + method: 'PATCH', + }), + onSuccess: () => { + toast.success('User status updated'); + queryClient.invalidateQueries({ queryKey: ['user-management'] }); + }, + onError: () => toast.error('Failed to update user status'), + onSettled: () => setConfirmAction(null), + }); + + const changeRoleMutation = useMutation({ + mutationFn: ({ userId, role }: { userId: string; role: UserRole }) => + apiClient(`/users/${userId}/role`, { + method: 'PATCH', + body: JSON.stringify({ role }), + }), + onSuccess: () => { + toast.success('User role updated'); + queryClient.invalidateQueries({ queryKey: ['user-management'] }); + }, + onError: () => toast.error('Failed to change user role'), + onSettled: () => setConfirmAction(null), + }); + + const handleConfirmAction = () => { + if (!confirmAction) return; + const { user, action, newRole } = confirmAction; + + if (action === 'activate' || action === 'deactivate') { + toggleActiveMutation.mutate({ userId: user.id, isActive: user.isActive }); + } else if (action === 'changeRole' && newRole) { + changeRoleMutation.mutate({ userId: user.id, role: newRole }); + } + }; + + const handleRoleChange = (user: User, newRole: UserRole) => { + setConfirmAction({ user, action: 'changeRole', newRole }); + }; + + const handleToggleActive = (user: User) => { + setConfirmAction({ + user, + action: user.isActive ? 'deactivate' : 'activate', + }); + }; + + return ( + <> + {confirmAction && ( + setConfirmAction(null)} + loading={toggleActiveMutation.isPending || changeRoleMutation.isPending} + /> + )} + +
+
+

User Management

+

+ {result ? `${result.total} total users` : 'Loading...'} +

+
+ + {/* Search & Filters */} +
+
+ setSearch(e.target.value)} + /> +
+
+ {ROLE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {/* Table */} + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : !result || result.data.length === 0 ? ( + + + {debouncedSearch + ? 'No users match your search criteria.' + : 'No users found.'} + + + ) : ( + + + Users + + +
+ + + + + + + + + + + + + {result.data.map((user) => ( + + + + + + + + + + ))} + +
NameEmailRoleVerifiedStatusJoined +
+
+
+ {user.firstName[0]}{user.lastName[0]} +
+ + {user.firstName} {user.lastName} + +
+
+ {user.email} + + + + {user.emailVerified ? ( + + โœ“ Verified + + ) : ( + + Unverified + + )} + + + {user.isActive ? 'Active' : 'Inactive'} + + + {new Date(user.createdAt).toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + year: 'numeric', + })} + +
+ + +
+
+
+
+
+ )} + + {/* Pagination */} + {result && result.totalPages > 1 && ( +
+

+ Page {result.page} of {result.totalPages} ({result.total} total) +

+
+ + +
+
+ )} +
+ + ); +} diff --git a/frontend/package/pages/admin/UserManagement/index.ts b/frontend/package/pages/admin/UserManagement/index.ts new file mode 100644 index 00000000..6ca506a5 --- /dev/null +++ b/frontend/package/pages/admin/UserManagement/index.ts @@ -0,0 +1 @@ +export { UserManagementTable } from './UserManagementTable'; From dd895acfd22ff1eef99458e063c547f9cf4f3bf2 Mon Sep 17 00:00:00 2001 From: Alqku Date: Sat, 30 May 2026 20:01:51 +0000 Subject: [PATCH 2/2] feat: add NotificationBell component with unread count badge, dropdown, and mark-as-read Closes #874 --- .../NotificationBell/NotificationBell.tsx | 178 ++++++++++++++++++ .../components/NotificationBell/index.ts | 2 + 2 files changed, 180 insertions(+) create mode 100644 frontend/package/components/NotificationBell/NotificationBell.tsx create mode 100644 frontend/package/components/NotificationBell/index.ts diff --git a/frontend/package/components/NotificationBell/NotificationBell.tsx b/frontend/package/components/NotificationBell/NotificationBell.tsx new file mode 100644 index 00000000..5c1ea88c --- /dev/null +++ b/frontend/package/components/NotificationBell/NotificationBell.tsx @@ -0,0 +1,178 @@ +'use client'; + +import { useState, useRef, useEffect } from 'react'; +import Link from 'next/link'; +import { useNotificationStore, type ShipmentNotification } from '@/stores/notification.store'; + +const EVENT_LABELS: Record = { + 'shipment:created': 'Created', + 'shipment:accepted': 'Accepted', + 'shipment:in_transit': 'In Transit', + 'shipment:delivered': 'Delivered', + 'shipment:completed': 'Completed', + 'shipment:cancelled': 'Cancelled', + 'shipment:disputed': 'Disputed', + 'shipment:dispute_resolved': 'Dispute Resolved', +}; + +const EVENT_ICONS: Record = { + 'shipment:created': '๐Ÿ“ฆ', + 'shipment:accepted': 'โœ…', + 'shipment:in_transit': '๐Ÿš›', + 'shipment:delivered': '๐Ÿ“ฌ', + 'shipment:completed': '๐ŸŽ‰', + 'shipment:cancelled': 'โŒ', + 'shipment:disputed': 'โš ๏ธ', + 'shipment:dispute_resolved': '๐Ÿ›ก๏ธ', +}; + +const EVENT_COLORS: Record = { + 'shipment:disputed': 'text-red-600', + 'shipment:cancelled': 'text-red-600', + 'shipment:completed': 'text-green-600', + 'shipment:dispute_resolved': 'text-green-600', +}; + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime(); + const mins = Math.floor(diff / 60_000); + if (mins < 1) return 'just now'; + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +} + +function NotificationItem({ n }: { n: ShipmentNotification }) { + const label = EVENT_LABELS[n.event] ?? 'Updated'; + const icon = EVENT_ICONS[n.event] ?? '๐Ÿ””'; + const color = EVENT_COLORS[n.event] ?? 'text-foreground'; + + return ( + + +
+

{label}

+

+ {n.trackingNumber} ยท {n.origin} โ†’ {n.destination} +

+
+ + {timeAgo(n.updatedAt)} + + + ); +} + +export interface NotificationBellProps { + maxDisplay?: number; +} + +export function NotificationBell({ maxDisplay = 10 }: NotificationBellProps) { + const [open, setOpen] = useState(false); + const { notifications, unreadCount, markAllRead, clearAll } = useNotificationStore(); + const ref = useRef(null); + + // Close on outside click and Escape key + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) { + setOpen(false); + } + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setOpen(false); + }; + + document.addEventListener('mousedown', handleClick); + document.addEventListener('keydown', handleKeyDown); + return () => { + document.removeEventListener('mousedown', handleClick); + document.removeEventListener('keydown', handleKeyDown); + }; + }, []); + + const handleToggle = () => { + setOpen((prev) => !prev); + if (!open && unreadCount > 0) markAllRead(); + }; + + const displayedNotifications = notifications.slice(0, maxDisplay); + + return ( +
+ + + {open && ( +
+
+

Notifications

+ {notifications.length > 0 && ( +
+ +
+ )} +
+ +
+ {displayedNotifications.length === 0 ? ( +

+ No notifications yet. +

+ ) : ( + displayedNotifications.map((n) => ( + + )) + )} +
+ + {notifications.length > maxDisplay && ( +
+

+ +{notifications.length - maxDisplay} more notification + {notifications.length - maxDisplay !== 1 ? 's' : ''} +

+
+ )} +
+ )} +
+ ); +} diff --git a/frontend/package/components/NotificationBell/index.ts b/frontend/package/components/NotificationBell/index.ts new file mode 100644 index 00000000..0dc212f3 --- /dev/null +++ b/frontend/package/components/NotificationBell/index.ts @@ -0,0 +1,2 @@ +export { NotificationBell } from './NotificationBell'; +export type { NotificationBellProps } from './NotificationBell';