diff --git a/src/App.tsx b/src/App.tsx index 4d35548..1e2452e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWiza import { MyRequests } from '@/pages/MyRequests'; import { Profile } from '@/pages/Profile'; import { Settings } from '@/pages/Settings'; +import { Notifications } from '@/pages/Notifications'; import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal'; import { Toaster } from '@/components/ui/sonner'; import { toast } from 'sonner'; @@ -539,13 +540,13 @@ function AppRoutes({ onLogout }: AppProps) { } /> - {/* Request Detail */} + {/* Request Detail - requestId will be read from URL params */} @@ -613,6 +614,16 @@ function AppRoutes({ onLogout }: AppProps) { } /> + + {/* Notifications */} + + + + } + /> ([]); + const [unreadCount, setUnreadCount] = useState(0); + const [notificationsOpen, setNotificationsOpen] = useState(false); const { user } = useAuth(); // Get user initials for avatar @@ -62,6 +68,107 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on setSidebarOpen(!sidebarOpen); }; + const handleNotificationClick = async (notification: Notification) => { + try { + // Mark as read + if (!notification.isRead) { + await notificationApi.markAsRead(notification.notificationId); + setNotifications(prev => + prev.map(n => n.notificationId === notification.notificationId ? { ...n, isRead: true } : n) + ); + setUnreadCount(prev => Math.max(0, prev - 1)); + } + + // Navigate to the request if URL provided + if (notification.actionUrl && onNavigate) { + // Extract request number from URL (e.g., /request/REQ-2025-12345) + const requestNumber = notification.metadata?.requestNumber; + if (requestNumber) { + // Determine which tab to open based on notification type + let navigationUrl = `request/${requestNumber}`; + + // Work note related notifications should open Work Notes tab + if (notification.notificationType === 'mention' || + notification.notificationType === 'comment' || + notification.notificationType === 'worknote') { + navigationUrl += '?tab=worknotes'; + } + + // Navigate to request detail page + onNavigate(navigationUrl); + console.log('[PageLayout] Navigating to:', navigationUrl); + } + } + + setNotificationsOpen(false); + } catch (error) { + console.error('[PageLayout] Error handling notification click:', error); + } + }; + + const handleMarkAllAsRead = async () => { + try { + await notificationApi.markAllAsRead(); + setNotifications(prev => prev.map(n => ({ ...n, isRead: true }))); + setUnreadCount(0); + } catch (error) { + console.error('[PageLayout] Error marking all as read:', error); + } + }; + + // Fetch notifications and setup real-time updates + useEffect(() => { + const userId = (user as any)?.userId; + if (!userId) return; + + let mounted = true; + + // Fetch initial notifications (only 4 for dropdown preview) + const fetchNotifications = async () => { + try { + const result = await notificationApi.list({ page: 1, limit: 4, unreadOnly: false }); + if (!mounted) return; + + const notifs = result.data?.notifications || []; + setNotifications(notifs); + setUnreadCount(result.data?.unreadCount || 0); + + console.log('[PageLayout] Loaded', notifs.length, 'recent notifications,', result.data?.unreadCount, 'unread'); + } catch (error) { + console.error('[PageLayout] Failed to fetch notifications:', error); + } + }; + + fetchNotifications(); + + // Setup socket for real-time notifications + const baseUrl = import.meta.env.VITE_API_BASE_URL?.replace('/api/v1', '') || 'http://localhost:5000'; + const socket = getSocket(baseUrl); + + if (socket) { + // Join user's personal notification room + joinUserRoom(socket, userId); + + // Listen for new notifications + const handleNewNotification = (data: { notification: Notification }) => { + console.log('[PageLayout] πŸ”” New notification received:', data); + if (!mounted) return; + + setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown + setUnreadCount(prev => prev + 1); + }; + + socket.on('notification:new', handleNewNotification); + + return () => { + mounted = false; + socket.off('notification:new', handleNewNotification); + }; + } + + return () => { mounted = false; }; + }, [user]); + // Handle responsive behavior: sidebar open on desktop, closed on mobile useEffect(() => { const handleResize = () => { @@ -193,29 +300,85 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on New Request - + - -
+ +

Notifications

+ {unreadCount > 0 && ( + + )}
-
-
-

RE-REQ-001 needs approval

-

SLA expires in 2 hours

-
-
-

New comment on RE-REQ-003

-

From John Doe - 5 min ago

-
+
+ {notifications.length === 0 ? ( +
+ +

No notifications yet

+
+ ) : ( +
+ {notifications.map((notif) => ( +
handleNotificationClick(notif)} + > +
+ {!notif.isRead && ( +
+ )} +
+

+ {notif.title} +

+

+ {notif.message} +

+

+ {formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })} +

+
+
+
+ ))} +
+ )}
+ {notifications.length > 0 && ( +
+ +
+ )} diff --git a/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx b/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx index dc42934..cb1ece3 100644 --- a/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx +++ b/src/components/workNote/WorkNoteChat/WorkNoteChat.tsx @@ -786,7 +786,13 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on useEffect(() => { if (externalMessages && Array.isArray(externalMessages)) { try { - const mapped: Message[] = externalMessages.map((m: any) => { + const mapped: Message[] = externalMessages + .filter((m: any) => { + // Filter out TAT breach activities (not important for work notes chat) + const activityType = (m.type || '').toLowerCase(); + return activityType !== 'sla_warning'; + }) + .map((m: any) => { // Check if this is an activity (system message) or work note const isActivity = m.type || m.activityType || m.isSystem; diff --git a/src/pages/ClosedRequests/ClosedRequests.tsx b/src/pages/ClosedRequests/ClosedRequests.tsx index 3f78cbb..f616030 100644 --- a/src/pages/ClosedRequests/ClosedRequests.tsx +++ b/src/pages/ClosedRequests/ClosedRequests.tsx @@ -7,18 +7,21 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Calendar, Filter, Search, FileText, AlertCircle, CheckCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, Settings2, X, XCircle } from 'lucide-react'; import workflowApi from '@/services/workflowApi'; +import { formatDateTime } from '@/utils/dateFormatter'; interface Request { id: string; title: string; description: string; - status: 'approved' | 'rejected'; + status: 'approved' | 'rejected' | 'closed'; priority: 'express' | 'standard'; initiator: { name: string; avatar: string }; createdAt: string; dueDate?: string; reason?: string; department?: string; + totalLevels?: number; + completedLevels?: number; } interface ClosedRequestsProps { @@ -55,21 +58,35 @@ const getStatusConfig = (status: string) => { switch (status) { case 'approved': return { - color: 'bg-green-100 text-green-800 border-green-200', + color: 'bg-emerald-100 text-emerald-800 border-emerald-300', icon: CheckCircle, - iconColor: 'text-green-600' + iconColor: 'text-emerald-600', + label: 'Needs Closure', + description: 'Fully approved, awaiting initiator to finalize' + }; + case 'closed': + return { + color: 'bg-slate-100 text-slate-800 border-slate-300', + icon: CheckCircle, + iconColor: 'text-slate-600', + label: 'Closed', + description: 'Request finalized and archived' }; case 'rejected': return { - color: 'bg-red-100 text-red-800 border-red-200', - icon: AlertCircle, - iconColor: 'text-red-600' + color: 'bg-red-100 text-red-800 border-red-300', + icon: XCircle, + iconColor: 'text-red-600', + label: 'Rejected', + description: 'Request was declined' }; default: return { color: 'bg-gray-100 text-gray-800 border-gray-200', icon: AlertCircle, - iconColor: 'text-gray-600' + iconColor: 'text-gray-600', + label: status, + description: '' }; } }; @@ -83,43 +100,62 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) { const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); const [items, setItems] = useState([]); const [loading, setLoading] = useState(false); + const [refreshing, setRefreshing] = useState(false); + + const fetchRequests = async () => { + try { + setLoading(true); + // Clear old data first + setItems([]); + + const result = await workflowApi.listClosedByMe({ page: 1, limit: 50 }); + console.log('[ClosedRequests] API Response:', result); // Debug log + + const data = Array.isArray((result as any)?.data) + ? (result as any).data + : Array.isArray((result as any)?.data?.data) + ? (result as any).data.data + : Array.isArray(result as any) + ? (result as any) + : []; + + console.log('[ClosedRequests] Parsed data count:', data.length); // Debug log + + const mapped: Request[] = data + .filter((r: any) => ['APPROVED', 'REJECTED', 'CLOSED'].includes((r.status || '').toString())) + .map((r: any) => ({ + id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier + requestId: r.requestId, // Keep requestId for reference + displayId: r.requestNumber || r.request_number || r.requestId, + title: r.title, + description: r.description, + status: (r.status || '').toString().toLowerCase(), + priority: (r.priority || '').toString().toLowerCase(), + initiator: { name: r.initiator?.displayName || r.initiator?.email || 'β€”', avatar: (r.initiator?.displayName || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase() }, + createdAt: r.submittedAt || r.createdAt || 'β€”', + dueDate: r.closureDate || r.closure_date || undefined, + reason: r.conclusionRemark || r.conclusion_remark, + department: r.department, + totalLevels: r.totalLevels || 0, + completedLevels: r.summary?.approvedLevels || 0, + })); + setItems(mapped); + } catch (error) { + console.error('[ClosedRequests] Error fetching requests:', error); + setItems([]); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + const handleRefresh = () => { + setRefreshing(true); + fetchRequests(); + }; useEffect(() => { - let mounted = true; - (async () => { - try { - setLoading(true); - const result = await workflowApi.listClosedByMe({ page: 1, limit: 50 }); - const data = Array.isArray((result as any)?.data) - ? (result as any).data - : Array.isArray((result as any)?.data?.data) - ? (result as any).data.data - : Array.isArray(result as any) - ? (result as any) - : []; - if (!mounted) return; - const mapped: Request[] = data - .filter((r: any) => ['APPROVED', 'REJECTED'].includes((r.status || '').toString())) - .map((r: any) => ({ - id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier - requestId: r.requestId, // Keep requestId for reference - displayId: r.requestNumber || r.request_number || r.requestId, - title: r.title, - description: r.description, - status: (r.status || '').toString().toLowerCase(), - priority: (r.priority || '').toString().toLowerCase(), - initiator: { name: r.initiator?.displayName || r.initiator?.email || 'β€”', avatar: (r.initiator?.displayName || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase() }, - createdAt: r.submittedAt || 'β€”', - dueDate: undefined, - reason: r.conclusionRemark, - department: r.department - })); - setItems(mapped); - } finally { - if (mounted) setLoading(false); - } - })(); - return () => { mounted = false; }; + fetchRequests(); }, []); const filteredAndSortedRequests = useMemo(() => { @@ -145,8 +181,8 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) { bValue = new Date(b.createdAt); break; case 'due': - aValue = new Date(a.dueDate); - bValue = new Date(b.dueDate); + aValue = a.dueDate ? new Date(a.dueDate) : new Date(0); + bValue = b.dueDate ? new Date(b.dueDate) : new Date(0); break; case 'priority': const priorityOrder = { express: 2, standard: 1 }; @@ -200,9 +236,15 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) { {loading ? 'Loading…' : `${filteredAndSortedRequests.length} closed`} requests -
@@ -296,6 +338,12 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) { Approved
+ +
+ + Closed +
+
@@ -330,7 +378,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) { - {/* Requests List */} + {/* Requests List - Balanced Compact View */}
{filteredAndSortedRequests.map((request) => { const priorityConfig = getPriorityConfig(request.priority); @@ -339,94 +387,86 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) { return ( onViewRequest?.(request.id, request.title)} > - -
- {/* Priority Indicator */} -
-
- + +
+ {/* Left: Priority Icon */} +
+
+
- - {request.priority} -
- {/* Main Content */} -
- {/* Header */} -
-
-
-

- {(request as any).displayId || request.id} -

- - - {request.status} - - {request.department && ( - - {request.department} - - )} -
-

- {request.title} -

-

- {request.description} -

-
- -
- -
+ {/* Center: Main Content */} +
+ {/* Header Row */} +
+

+ {(request as any).displayId || request.id} +

+ + + {statusConfig.label} + + {request.department && ( + + {request.department} + + )} +
- {/* Status Info */} -
-
- - - {request.reason} - -
-
+ {/* Title */} +

+ {request.title} +

- {/* Participants & Metadata */} -
-
- - + {/* Metadata Row */} +
+
+ + {request.initiator.avatar} -
-

{request.initiator.name}

-

Initiator

-
+ {request.initiator.name}
-
-
- - - Created {request.createdAt} - - Closed {request.dueDate} + {(request.totalLevels ?? 0) > 0 && ( +
+ + {request.completedLevels || 0}/{request.totalLevels} Approvals
+ )} + +
+ + Created: {request.createdAt !== 'β€”' ? formatDateTime(request.createdAt) : 'β€”'}
+ + {request.dueDate && ( +
+ + Closed: {formatDateTime(request.dueDate)} +
+ )}
+ + {/* Right: Arrow */} +
+ +
diff --git a/src/pages/MyRequests/MyRequests.tsx b/src/pages/MyRequests/MyRequests.tsx index 6e0f8ad..f2a610a 100644 --- a/src/pages/MyRequests/MyRequests.tsx +++ b/src/pages/MyRequests/MyRequests.tsx @@ -97,19 +97,34 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr const [statusFilter, setStatusFilter] = useState('all'); const [apiRequests, setApiRequests] = useState([]); const [loading, setLoading] = useState(false); + const [hasFetchedFromApi, setHasFetchedFromApi] = useState(false); useEffect(() => { let mounted = true; (async () => { try { setLoading(true); + // Clear old data first + setApiRequests([]); + const result = await workflowApi.listMyWorkflows({ page: 1, limit: 20 }); - const items = Array.isArray(result?.data) ? result.data : Array.isArray(result) ? result : []; + console.log('[MyRequests] API Response:', result); // Debug log + + // Handle nested data structure from API + const items = result?.data?.data ? result.data.data : + Array.isArray(result?.data) ? result.data : + Array.isArray(result) ? result : []; + + console.log('[MyRequests] Parsed items:', items); // Debug log + if (!mounted) return; setApiRequests(items); - } catch (_) { + setHasFetchedFromApi(true); // Mark that we've fetched from API + } catch (error) { + console.error('[MyRequests] Error fetching requests:', error); if (!mounted) return; setApiRequests([]); + setHasFetchedFromApi(true); // Still mark as fetched even on error } finally { if (mounted) setLoading(false); } @@ -118,7 +133,8 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr }, []); // Convert API/dynamic requests to the format expected by this component - const sourceRequests = (apiRequests.length ? apiRequests : dynamicRequests); + // Once API has fetched (even if empty), always use API data, never fall back to props + const sourceRequests = hasFetchedFromApi ? apiRequests : dynamicRequests; const convertedDynamicRequests = sourceRequests.map((req: any) => { const createdAt = req.submittedAt || req.submitted_at || req.createdAt || req.created_at; const priority = (req.priority || '').toString().toLowerCase(); diff --git a/src/pages/Notifications/Notifications.tsx b/src/pages/Notifications/Notifications.tsx new file mode 100644 index 0000000..6b69f3f --- /dev/null +++ b/src/pages/Notifications/Notifications.tsx @@ -0,0 +1,392 @@ +import { useState, useEffect } from 'react'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { + Bell, + CheckCheck, + Trash2, + ChevronLeft, + ChevronRight, + RefreshCw, + MessageSquare, + UserPlus, + CheckCircle, + XCircle, + Clock, + AlertTriangle, + AlertCircle, + FileText +} from 'lucide-react'; +import notificationApi, { Notification } from '@/services/notificationApi'; +import { formatDistanceToNow } from 'date-fns'; + +interface NotificationsProps { + onNavigate?: (page: string) => void; +} + +export function Notifications({ onNavigate }: NotificationsProps) { + const [notifications, setNotifications] = useState([]); + const [loading, setLoading] = useState(true); + const [currentPage, setCurrentPage] = useState(1); + const [totalPages, setTotalPages] = useState(1); + const [totalCount, setTotalCount] = useState(0); + const [filter, setFilter] = useState<'all' | 'unread'>('all'); + const [refreshing, setRefreshing] = useState(false); + + const ITEMS_PER_PAGE = 20; + + const fetchNotifications = async (page: number = currentPage, unreadOnly: boolean = filter === 'unread') => { + try { + setLoading(page === 1); + const result = await notificationApi.list({ page, limit: ITEMS_PER_PAGE, unreadOnly }); + + const notifs = result.data?.notifications || []; + const total = result.data?.total || 0; + + setNotifications(notifs); + setTotalCount(total); + setTotalPages(Math.ceil(total / ITEMS_PER_PAGE)); + + console.log(`[Notifications] Loaded page ${page}, ${notifs.length} notifications, ${total} total`); + } catch (error) { + console.error('[Notifications] Failed to fetch:', error); + } finally { + setLoading(false); + setRefreshing(false); + } + }; + + useEffect(() => { + fetchNotifications(1, filter === 'unread'); + }, [filter]); + + const handleNotificationClick = async (notification: Notification) => { + try { + // Mark as read + if (!notification.isRead) { + await notificationApi.markAsRead(notification.notificationId); + setNotifications(prev => + prev.map(n => n.notificationId === notification.notificationId ? { ...n, isRead: true } : n) + ); + } + + // Navigate to the request if URL provided + if (notification.actionUrl && onNavigate) { + const requestNumber = notification.metadata?.requestNumber; + if (requestNumber) { + let navigationUrl = `request/${requestNumber}`; + + // Work note related notifications should open Work Notes tab + if (notification.notificationType === 'mention' || + notification.notificationType === 'comment' || + notification.notificationType === 'worknote') { + navigationUrl += '?tab=worknotes'; + } + + onNavigate(navigationUrl); + } + } + } catch (error) { + console.error('[Notifications] Error handling notification click:', error); + } + }; + + const handleMarkAllAsRead = async () => { + try { + setRefreshing(true); + await notificationApi.markAllAsRead(); + await fetchNotifications(currentPage, filter === 'unread'); + } catch (error) { + console.error('[Notifications] Error marking all as read:', error); + setRefreshing(false); + } + }; + + const handleDelete = async (notificationId: string) => { + try { + await notificationApi.delete(notificationId); + setNotifications(prev => prev.filter(n => n.notificationId !== notificationId)); + setTotalCount(prev => prev - 1); + } catch (error) { + console.error('[Notifications] Error deleting notification:', error); + } + }; + + const handleRefresh = () => { + setRefreshing(true); + fetchNotifications(currentPage, filter === 'unread'); + }; + + const handlePageChange = (newPage: number) => { + if (newPage < 1 || newPage > totalPages) return; + setCurrentPage(newPage); + fetchNotifications(newPage, filter === 'unread'); + }; + + const getNotificationIcon = (type: string) => { + const iconClass = "w-6 h-6"; + + switch (type) { + case 'mention': + case 'comment': + return ; + case 'worknote': + return ; + case 'assignment': + return ; + case 'approval': + return ; + case 'rejection': + return ; + case 'tat_alert': + return ; + case 'tat_breach': + return ; + case 'tat_breach_initiator': + return ; + default: + return ; + } + }; + + const getPriorityColor = (priority?: string) => { + switch (priority?.toUpperCase()) { + case 'URGENT': + return 'bg-red-100 text-red-800 border-red-200'; + case 'HIGH': + return 'bg-orange-100 text-orange-800 border-orange-200'; + case 'MEDIUM': + return 'bg-blue-100 text-blue-800 border-blue-200'; + default: + return 'bg-gray-100 text-gray-800 border-gray-200'; + } + }; + + const unreadCount = notifications.filter(n => !n.isRead).length; + + return ( +
+ {/* Header */} +
+
+

+ + Notifications +

+

+ {totalCount} total notification{totalCount !== 1 ? 's' : ''} + {unreadCount > 0 && ` β€’ ${unreadCount} unread`} +

+
+
+ + {unreadCount > 0 && ( + + )} +
+
+ + {/* Filter Tabs */} +
+ + +
+ + {/* Notifications List */} + + + Recent Activity + + Stay updated with all your workflow activities and mentions + + + + {loading ? ( +
+
+
+ ) : notifications.length === 0 ? ( +
+ +

No notifications

+

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

+
+ ) : ( +
+ {notifications.map((notif) => ( +
handleNotificationClick(notif)} + > +
+ {/* Icon */} +
+ {getNotificationIcon(notif.notificationType)} +
+ + {/* Content */} +
+
+

+ {notif.title} +

+
+ {notif.priority && notif.priority !== 'LOW' && ( + + {notif.priority} + + )} + {!notif.isRead && ( +
+ )} +
+
+ +

+ {notif.message} +

+ +
+

+ {formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })} + {notif.metadata?.requestNumber && ( + β€’ {notif.metadata.requestNumber} + )} +

+ + +
+
+
+
+ ))} +
+ )} +
+
+ + {/* Pagination */} + {!loading && totalPages > 1 && ( +
+

+ Showing {((currentPage - 1) * ITEMS_PER_PAGE) + 1} to {Math.min(currentPage * ITEMS_PER_PAGE, totalCount)} of {totalCount} notifications +

+ +
+ + +
+ {Array.from({ length: Math.min(5, totalPages) }, (_, i) => { + let pageNum; + if (totalPages <= 5) { + pageNum = i + 1; + } else if (currentPage <= 3) { + pageNum = i + 1; + } else if (currentPage >= totalPages - 2) { + pageNum = totalPages - 4 + i; + } else { + pageNum = currentPage - 2 + i; + } + + return ( + + ); + })} +
+ + +
+
+ )} +
+ ); +} + diff --git a/src/pages/Notifications/index.ts b/src/pages/Notifications/index.ts new file mode 100644 index 0000000..b2d14b5 --- /dev/null +++ b/src/pages/Notifications/index.ts @@ -0,0 +1,2 @@ +export { Notifications } from './Notifications'; + diff --git a/src/pages/OpenRequests/OpenRequests.tsx b/src/pages/OpenRequests/OpenRequests.tsx index fc7fe40..fe2e97f 100644 --- a/src/pages/OpenRequests/OpenRequests.tsx +++ b/src/pages/OpenRequests/OpenRequests.tsx @@ -13,7 +13,7 @@ interface Request { id: string; title: string; description: string; - status: 'pending' | 'in-review'; + status: 'pending' | 'in-review' | 'approved'; priority: 'express' | 'standard'; initiator: { name: string; avatar: string }; currentApprover?: { @@ -69,6 +69,13 @@ const getStatusConfig = (status: string) => { icon: Eye, iconColor: 'text-blue-600' }; + case 'approved': + return { + color: 'bg-green-100 text-green-800 border-green-200', + icon: AlertCircle, + iconColor: 'text-green-600', + label: 'Needs Closure' + }; default: return { color: 'bg-gray-100 text-gray-800 border-gray-200', @@ -94,7 +101,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) { const fetchRequests = async () => { try { setLoading(true); + // Clear old data first + setItems([]); + const result = await workflowApi.listOpenForMe({ page: 1, limit: 50 }); + console.log('[OpenRequests] API Response:', result); // Debug log + const data = Array.isArray((result as any)?.data) ? (result as any).data : Array.isArray((result as any)?.data?.data) @@ -103,6 +115,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) { ? (result as any) : []; + console.log('[OpenRequests] Parsed data count:', data.length); // Debug log + const mapped: Request[] = data.map((r: any) => { const createdAt = r.submittedAt || r.submitted_at || r.createdAt || r.created_at; @@ -396,7 +410,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) { className={`${statusConfig.color} border font-medium text-xs shrink-0`} > - {request.status} + {(statusConfig as any).label || request.status} {request.department && ( diff --git a/src/pages/RequestDetail/RequestDetail.tsx b/src/pages/RequestDetail/RequestDetail.tsx index 4d1c9f6..e15067a 100644 --- a/src/pages/RequestDetail/RequestDetail.tsx +++ b/src/pages/RequestDetail/RequestDetail.tsx @@ -7,6 +7,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Progress } from '@/components/ui/progress'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Textarea } from '@/components/ui/textarea'; import { formatDateTime, formatDateShort } from '@/utils/dateFormatter'; import { FilePreview } from '@/components/common/FilePreview'; import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase'; @@ -44,7 +45,8 @@ import { ClipboardList, Paperclip, AlertTriangle, - AlertCircle + AlertCircle, + Loader2 } from 'lucide-react'; // Simple Error Boundary for RequestDetail @@ -139,6 +141,11 @@ const getStatusConfig = (status: string) => { color: 'bg-red-100 text-red-800 border-red-200', label: 'rejected' }; + case 'closed': + return { + color: 'bg-gray-100 text-gray-800 border-gray-300', + label: 'closed' + }; case 'skipped': return { color: 'bg-orange-100 text-orange-800 border-orange-200', @@ -209,7 +216,11 @@ function RequestDetailInner({ // Use requestNumber from URL params (which now contains requestNumber), fallback to prop const requestIdentifier = params.requestId || propRequestId || ''; - const [activeTab, setActiveTab] = useState('overview'); + // Read tab from URL query parameter (e.g., ?tab=worknotes) + const urlParams = new URLSearchParams(window.location.search); + const initialTab = urlParams.get('tab') || 'overview'; + + const [activeTab, setActiveTab] = useState(initialTab); const [apiRequest, setApiRequest] = useState(null); const [isSpectator, setIsSpectator] = useState(false); // approving/rejecting local states are managed inside modals now @@ -228,8 +239,22 @@ function RequestDetailInner({ const [refreshing, setRefreshing] = useState(false); const [showActionStatusModal, setShowActionStatusModal] = useState(false); const [actionStatus, setActionStatus] = useState<{ success: boolean; title: string; message: string } | null>(null); + const [conclusionLoading, setConclusionLoading] = useState(false); + const [conclusionRemark, setConclusionRemark] = useState(''); + const [conclusionSubmitting, setConclusionSubmitting] = useState(false); + const [aiGenerated, setAiGenerated] = useState(false); const { user } = useAuth(); + // Auto-switch tab when URL query parameter changes (e.g., from notifications) + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const tabParam = urlParams.get('tab'); + if (tabParam) { + console.log('[RequestDetail] Auto-switching to tab:', tabParam); + setActiveTab(tabParam); + } + }, [requestIdentifier]); // Re-run when navigating to different request + // Shared refresh routine const refreshDetails = async () => { setRefreshing(true); @@ -264,6 +289,7 @@ function RequestDetailInner({ if (val === 'PENDING') return 'pending'; if (val === 'APPROVED') return 'approved'; if (val === 'REJECTED') return 'rejected'; + if (val === 'CLOSED') return 'closed'; if (val === 'SKIPPED') return 'skipped'; return (s || '').toLowerCase(); }; @@ -349,7 +375,15 @@ function RequestDetailInner({ }; }); - const updatedRequest = { + // Filter out TAT breach activities as they're not important for activity timeline + const filteredActivities = Array.isArray(details.activities) + ? details.activities.filter((activity: any) => { + const activityType = (activity.type || '').toLowerCase(); + return activityType !== 'sla_warning'; // Exclude TAT breach/warning activities + }) + : []; + + const updatedRequest = { ...wf, id: wf.requestNumber || wf.requestId, requestId: wf.requestId, @@ -376,7 +410,9 @@ function RequestDetailInner({ updatedAt: wf.updatedAt, totalSteps: wf.totalLevels, currentStep: summary?.currentLevel || wf.currentLevel, - auditTrail: Array.isArray(details.activities) ? details.activities : [], + auditTrail: filteredActivities, + conclusionRemark: wf.conclusionRemark || null, + closureDate: wf.closureDate || null, }; setApiRequest(updatedRequest); @@ -408,6 +444,87 @@ function RequestDetailInner({ refreshDetails(); }; + const fetchExistingConclusion = async () => { + try { + const { getConclusion } = await import('@/services/conclusionApi'); + const result = await getConclusion(request.requestId || requestIdentifier); + if (result && result.aiGeneratedRemark) { + setConclusionRemark(result.finalRemark || result.aiGeneratedRemark); + setAiGenerated(!!result.aiGeneratedRemark); + } + } catch (err) { + // No conclusion yet - that's okay + console.log('[RequestDetail] No existing conclusion found'); + } + }; + + const handleGenerateConclusion = async () => { + try { + setConclusionLoading(true); + const { generateConclusion } = await import('@/services/conclusionApi'); + const result = await generateConclusion(request.requestId || requestIdentifier); + setConclusionRemark(result.aiGeneratedRemark); + setAiGenerated(true); + } catch (err) { + // Silently fail - user can write manually + setConclusionRemark(''); + setAiGenerated(false); + } finally { + setConclusionLoading(false); + } + }; + + const handleFinalizeConclusion = async () => { + if (!conclusionRemark.trim()) { + setActionStatus({ + success: false, + title: 'Validation Error', + message: 'Conclusion remark cannot be empty' + }); + setShowActionStatusModal(true); + return; + } + + try { + setConclusionSubmitting(true); + const { finalizeConclusion } = await import('@/services/conclusionApi'); + await finalizeConclusion(request.requestId || requestIdentifier, conclusionRemark); + + setActionStatus({ + success: true, + title: 'Request Closed with Successful Completion', + message: 'The request has been finalized and moved to Closed Requests.' + }); + setShowActionStatusModal(true); + + // Refresh to get updated status + await refreshDetails(); + + // Navigate to Closed Requests after a short delay (for user to see the success message) + setTimeout(() => { + // Use onBack if provided, otherwise navigate programmatically + if (onBack) { + onBack(); + // After going back, trigger navigation to closed requests + setTimeout(() => { + window.location.hash = '#/closed-requests'; + }, 100); + } else { + window.location.hash = '#/closed-requests'; + } + }, 2000); // 2 second delay to show success message + } catch (err: any) { + setActionStatus({ + success: false, + title: 'Error', + message: err.response?.data?.error || 'Failed to finalize conclusion' + }); + setShowActionStatusModal(true); + } finally { + setConclusionSubmitting(false); + } + }; + // Work notes load // Approve modal onConfirm @@ -774,6 +891,7 @@ function RequestDetailInner({ if (val === 'PENDING') return 'pending'; if (val === 'APPROVED') return 'approved'; if (val === 'REJECTED') return 'rejected'; + if (val === 'CLOSED') return 'closed'; return (s || '').toLowerCase(); }; @@ -865,6 +983,14 @@ function RequestDetailInner({ }; }); + // Filter out TAT breach activities as they're not important for activity timeline + const filteredActivities = Array.isArray(details.activities) + ? details.activities.filter((activity: any) => { + const activityType = (activity.type || '').toLowerCase(); + return activityType !== 'sla_warning'; // Exclude TAT breach/warning activities + }) + : []; + const mapped = { id: wf.requestNumber || wf.requestId, requestId: wf.requestId, // ← UUID for API calls @@ -889,7 +1015,9 @@ function RequestDetailInner({ approvals, // ← Added: Include raw approvals array with levelStartTime/tatStartTime documents: mappedDocuments, spectators, - auditTrail: Array.isArray(details.activities) ? details.activities : [], + auditTrail: filteredActivities, + conclusionRemark: wf.conclusionRemark || null, + closureDate: wf.closureDate || null, }; setApiRequest(mapped); // Determine viewer role (spectator only means comment-only) @@ -943,6 +1071,13 @@ function RequestDetailInner({ return userEmail === initiatorEmail; }, [request, user]); + // Fetch existing conclusion when request is approved (generated by final approver) + useEffect(() => { + if (request?.status === 'approved' && isInitiator && !conclusionRemark) { + fetchExistingConclusion(); + } + }, [request?.status, isInitiator]); + // Get all existing participants for validation const existingParticipants = useMemo(() => { if (!request) return []; @@ -1030,6 +1165,9 @@ function RequestDetailInner({ const priorityConfig = getPriorityConfig(request.priority || 'standard'); const statusConfig = getStatusConfig(request.status || 'pending'); + // Check if request is approved and needs closure by initiator + const needsClosure = request.status === 'approved' && isInitiator; + return ( <>
@@ -1090,12 +1228,13 @@ function RequestDetailInner({ {(() => { const sla = request.summary?.sla || request.sla; - if (!sla || request.status === 'approved' || request.status === 'rejected') { + if (!sla || request.status === 'approved' || request.status === 'rejected' || request.status === 'closed') { return (
- {request.status === 'approved' ? 'βœ… Request Approved' : + {request.status === 'closed' ? 'πŸ”’ Request Closed' : + request.status === 'approved' ? 'βœ… Request Approved' : request.status === 'rejected' ? '❌ Request Rejected' : 'SLA Not Available'}
@@ -1781,12 +1920,12 @@ function RequestDetailInner({
@@ -2036,8 +2175,8 @@ function RequestDetailInner({ Quick Actions - {/* Only initiator can add approvers */} - {isInitiator && ( + {/* Only initiator can add approvers (not for closed requests) */} + {isInitiator && request.status !== 'closed' && (
+ + {/* Read-Only Conclusion Remark - Shows for closed requests */} + {request.status === 'closed' && request.conclusionRemark && activeTab !== 'worknotes' && ( +
+ + + + + Conclusion Remark + + + Final summary of this closed request + + + +
+

+ {request.conclusionRemark} +

+
+ + {request.closureDate && ( +
+ Request closed on {formatDateTime(request.closureDate)} + By {request.initiator?.name || 'Initiator'} +
+ )} +
+
+
+ )} + + {/* Conclusion Remark Section - Shows below tabs when request is approved */} + {needsClosure && activeTab !== 'worknotes' && ( +
+ + +
+
+ + + Conclusion Remark - Final Step + + + All approvals are complete. Please review and finalize the conclusion to close this request. + +
+ +
+
+ + {conclusionLoading ? ( +
+
+ +

Preparing conclusion remark...

+
+
+ ) : ( +
+
+
+ + {aiGenerated && ( + βœ“ System-generated suggestion (editable) + )} +
+ +