ai conlusion flow added and TAT triggering got issue resolved enhanced system config for ai setup

This commit is contained in:
laxmanhalaki 2025-11-11 17:42:50 +05:30
parent 7efc5c5d94
commit d60757ae72
12 changed files with 1205 additions and 156 deletions

View File

@ -11,6 +11,7 @@ import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWiza
import { MyRequests } from '@/pages/MyRequests'; import { MyRequests } from '@/pages/MyRequests';
import { Profile } from '@/pages/Profile'; import { Profile } from '@/pages/Profile';
import { Settings } from '@/pages/Settings'; import { Settings } from '@/pages/Settings';
import { Notifications } from '@/pages/Notifications';
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal'; import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -539,13 +540,13 @@ function AppRoutes({ onLogout }: AppProps) {
} }
/> />
{/* Request Detail */} {/* Request Detail - requestId will be read from URL params */}
<Route <Route
path="/request/:requestId" path="/request/:requestId"
element={ element={
<PageLayout currentPage="request-detail" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}> <PageLayout currentPage="request-detail" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<RequestDetail <RequestDetail
requestId={selectedRequestId || ''} requestId=""
onBack={handleBack} onBack={handleBack}
dynamicRequests={dynamicRequests} dynamicRequests={dynamicRequests}
/> />
@ -613,6 +614,16 @@ function AppRoutes({ onLogout }: AppProps) {
</PageLayout> </PageLayout>
} }
/> />
{/* Notifications */}
<Route
path="/notifications"
element={
<PageLayout currentPage="notifications" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Notifications onNavigate={handleNavigate} />
</PageLayout>
}
/>
</Routes> </Routes>
<Toaster <Toaster

View File

@ -17,6 +17,9 @@ import {
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import royalEnfieldLogo from '@/assets/images/royal_enfield_logo.png'; import royalEnfieldLogo from '@/assets/images/royal_enfield_logo.png';
import notificationApi, { Notification } from '@/services/notificationApi';
import { getSocket, joinUserRoom } from '@/utils/socket';
import { formatDistanceToNow } from 'date-fns';
interface PageLayoutProps { interface PageLayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -29,6 +32,9 @@ interface PageLayoutProps {
export function PageLayout({ children, currentPage = 'dashboard', onNavigate, onNewRequest, onLogout }: PageLayoutProps) { export function PageLayout({ children, currentPage = 'dashboard', onNavigate, onNewRequest, onLogout }: PageLayoutProps) {
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [showLogoutDialog, setShowLogoutDialog] = useState(false); const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [notifications, setNotifications] = useState<Notification[]>([]);
const [unreadCount, setUnreadCount] = useState(0);
const [notificationsOpen, setNotificationsOpen] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
// Get user initials for avatar // Get user initials for avatar
@ -62,6 +68,107 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
setSidebarOpen(!sidebarOpen); 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 // Handle responsive behavior: sidebar open on desktop, closed on mobile
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
@ -193,29 +300,85 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
New Request New Request
</Button> </Button>
<DropdownMenu> <DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="relative shrink-0 h-10 w-10"> <Button variant="ghost" size="icon" className="relative shrink-0 h-10 w-10">
<Bell className="w-5 h-5" /> <Bell className="w-5 h-5" />
<Badge className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-destructive text-destructive-foreground text-xs flex items-center justify-center p-0"> {unreadCount > 0 && (
3 <Badge className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-destructive text-destructive-foreground text-xs flex items-center justify-center p-0">
</Badge> {unreadCount > 9 ? '9+' : unreadCount}
</Badge>
)}
</Button> </Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80"> <DropdownMenuContent align="end" className="w-96 max-h-[500px]">
<div className="p-3 border-b"> <div className="p-3 border-b flex items-center justify-between sticky top-0 bg-white z-10">
<h4 className="font-semibold text-base">Notifications</h4> <h4 className="font-semibold text-base">Notifications</h4>
{unreadCount > 0 && (
<Button
variant="ghost"
size="sm"
className="text-xs text-blue-600 hover:text-blue-700 h-auto p-1"
onClick={(e) => {
e.stopPropagation();
handleMarkAllAsRead();
}}
>
Mark all as read
</Button>
)}
</div> </div>
<div className="p-3 space-y-2"> <div className="max-h-[400px] overflow-y-auto">
<div className="text-sm"> {notifications.length === 0 ? (
<p className="font-medium">RE-REQ-001 needs approval</p> <div className="p-6 text-center">
<p className="text-muted-foreground text-xs">SLA expires in 2 hours</p> <Bell className="w-12 h-12 text-gray-300 mx-auto mb-2" />
</div> <p className="text-sm text-gray-500">No notifications yet</p>
<div className="text-sm"> </div>
<p className="font-medium">New comment on RE-REQ-003</p> ) : (
<p className="text-muted-foreground text-xs">From John Doe - 5 min ago</p> <div className="divide-y">
</div> {notifications.map((notif) => (
<div
key={notif.notificationId}
className={`p-3 hover:bg-gray-50 cursor-pointer transition-colors ${
!notif.isRead ? 'bg-blue-50' : ''
}`}
onClick={() => handleNotificationClick(notif)}
>
<div className="flex gap-2">
{!notif.isRead && (
<div className="w-2 h-2 rounded-full bg-blue-600 mt-1.5 shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className={`text-sm ${!notif.isRead ? 'font-semibold' : 'font-medium'}`}>
{notif.title}
</p>
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
{notif.message}
</p>
<p className="text-xs text-gray-400 mt-1">
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
</p>
</div>
</div>
</div>
))}
</div>
)}
</div> </div>
{notifications.length > 0 && (
<div className="p-2 border-t">
<Button
variant="ghost"
className="w-full text-sm text-blue-600 hover:text-blue-700"
onClick={() => {
setNotificationsOpen(false);
onNavigate?.('notifications'); // Navigate to full notifications page
}}
>
View all notifications
</Button>
</div>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>

View File

@ -786,7 +786,13 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
useEffect(() => { useEffect(() => {
if (externalMessages && Array.isArray(externalMessages)) { if (externalMessages && Array.isArray(externalMessages)) {
try { 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 // Check if this is an activity (system message) or work note
const isActivity = m.type || m.activityType || m.isSystem; const isActivity = m.type || m.activityType || m.isSystem;

View File

@ -7,18 +7,21 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; 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 { Calendar, Filter, Search, FileText, AlertCircle, CheckCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, Settings2, X, XCircle } from 'lucide-react';
import workflowApi from '@/services/workflowApi'; import workflowApi from '@/services/workflowApi';
import { formatDateTime } from '@/utils/dateFormatter';
interface Request { interface Request {
id: string; id: string;
title: string; title: string;
description: string; description: string;
status: 'approved' | 'rejected'; status: 'approved' | 'rejected' | 'closed';
priority: 'express' | 'standard'; priority: 'express' | 'standard';
initiator: { name: string; avatar: string }; initiator: { name: string; avatar: string };
createdAt: string; createdAt: string;
dueDate?: string; dueDate?: string;
reason?: string; reason?: string;
department?: string; department?: string;
totalLevels?: number;
completedLevels?: number;
} }
interface ClosedRequestsProps { interface ClosedRequestsProps {
@ -55,21 +58,35 @@ const getStatusConfig = (status: string) => {
switch (status) { switch (status) {
case 'approved': case 'approved':
return { return {
color: 'bg-green-100 text-green-800 border-green-200', color: 'bg-emerald-100 text-emerald-800 border-emerald-300',
icon: CheckCircle, 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': case 'rejected':
return { return {
color: 'bg-red-100 text-red-800 border-red-200', color: 'bg-red-100 text-red-800 border-red-300',
icon: AlertCircle, icon: XCircle,
iconColor: 'text-red-600' iconColor: 'text-red-600',
label: 'Rejected',
description: 'Request was declined'
}; };
default: default:
return { return {
color: 'bg-gray-100 text-gray-800 border-gray-200', color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: AlertCircle, 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 [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [items, setItems] = useState<Request[]>([]); const [items, setItems] = useState<Request[]>([]);
const [loading, setLoading] = useState(false); 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(() => { useEffect(() => {
let mounted = true; fetchRequests();
(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; };
}, []); }, []);
const filteredAndSortedRequests = useMemo(() => { const filteredAndSortedRequests = useMemo(() => {
@ -145,8 +181,8 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
bValue = new Date(b.createdAt); bValue = new Date(b.createdAt);
break; break;
case 'due': case 'due':
aValue = new Date(a.dueDate); aValue = a.dueDate ? new Date(a.dueDate) : new Date(0);
bValue = new Date(b.dueDate); bValue = b.dueDate ? new Date(b.dueDate) : new Date(0);
break; break;
case 'priority': case 'priority':
const priorityOrder = { express: 2, standard: 1 }; const priorityOrder = { express: 2, standard: 1 };
@ -200,9 +236,15 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
{loading ? 'Loading…' : `${filteredAndSortedRequests.length} closed`} {loading ? 'Loading…' : `${filteredAndSortedRequests.length} closed`}
<span className="hidden sm:inline ml-1">requests</span> <span className="hidden sm:inline ml-1">requests</span>
</Badge> </Badge>
<Button variant="outline" size="sm" className="gap-1 sm:gap-2 h-8 sm:h-9"> <Button
<RefreshCw className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> variant="outline"
<span className="hidden sm:inline">Refresh</span> size="sm"
className="gap-1 sm:gap-2 h-8 sm:h-9"
onClick={handleRefresh}
disabled={refreshing}
>
<RefreshCw className={`w-3.5 h-3.5 sm:w-4 sm:h-4 ${refreshing ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline">{refreshing ? 'Refreshing...' : 'Refresh'}</span>
</Button> </Button>
</div> </div>
</div> </div>
@ -296,6 +338,12 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
<span>Approved</span> <span>Approved</span>
</div> </div>
</SelectItem> </SelectItem>
<SelectItem value="closed">
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-gray-600" />
<span>Closed</span>
</div>
</SelectItem>
<SelectItem value="rejected"> <SelectItem value="rejected">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<XCircle className="w-4 h-4 text-red-600" /> <XCircle className="w-4 h-4 text-red-600" />
@ -330,7 +378,7 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
</CardContent> </CardContent>
</Card> </Card>
{/* Requests List */} {/* Requests List - Balanced Compact View */}
<div className="space-y-4"> <div className="space-y-4">
{filteredAndSortedRequests.map((request) => { {filteredAndSortedRequests.map((request) => {
const priorityConfig = getPriorityConfig(request.priority); const priorityConfig = getPriorityConfig(request.priority);
@ -339,94 +387,86 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
return ( return (
<Card <Card
key={request.id} key={request.id}
className="group hover:shadow-xl transition-all duration-300 cursor-pointer border-0 shadow-md hover:scale-[1.01]" className="group hover:shadow-lg transition-all duration-200 cursor-pointer border border-gray-200 hover:border-blue-400 hover:scale-[1.002]"
onClick={() => onViewRequest?.(request.id, request.title)} onClick={() => onViewRequest?.(request.id, request.title)}
> >
<CardContent className="p-3 sm:p-6"> <CardContent className="p-5">
<div className="flex flex-col sm:flex-row items-start gap-3 sm:gap-6"> <div className="flex items-start gap-5">
{/* Priority Indicator */} {/* Left: Priority Icon */}
<div className="flex sm:flex-col items-center gap-2 pt-1 w-full sm:w-auto"> <div className="flex-shrink-0 pt-1">
<div className={`p-2 sm:p-3 rounded-xl ${priorityConfig.color} border flex-shrink-0`}> <div className={`p-2.5 rounded-lg ${priorityConfig.color} border shadow-sm`}>
<priorityConfig.icon className={`w-4 h-4 sm:w-5 sm:h-5 ${priorityConfig.iconColor}`} /> <priorityConfig.icon className={`w-5 h-5 ${priorityConfig.iconColor}`} />
</div> </div>
<Badge
variant="outline"
className={`text-xs font-medium ${priorityConfig.color} capitalize flex-shrink-0`}
>
{request.priority}
</Badge>
</div> </div>
{/* Main Content */} {/* Center: Main Content */}
<div className="flex-1 min-w-0 space-y-3 sm:space-y-4 w-full"> <div className="flex-1 min-w-0 space-y-3">
{/* Header */} {/* Header Row */}
<div className="flex items-start justify-between gap-2 sm:gap-4"> <div className="flex items-center gap-2.5 flex-wrap">
<div className="flex-1 min-w-0"> <h3 className="text-base font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-3 mb-2"> {(request as any).displayId || request.id}
<h3 className="text-sm sm:text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors"> </h3>
{(request as any).displayId || request.id} <Badge
</h3> variant="outline"
<Badge className={`${statusConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`}
variant="outline" >
className={`${statusConfig.color} border font-medium text-xs shrink-0`} <statusConfig.icon className="w-3.5 h-3.5 mr-1" />
> {statusConfig.label}
<statusConfig.icon className="w-3 h-3 mr-1" /> </Badge>
<span className="capitalize">{request.status}</span> {request.department && (
</Badge> <Badge variant="secondary" className="bg-blue-50 text-blue-700 text-xs px-2.5 py-0.5 hidden sm:inline-flex">
{request.department && ( {request.department}
<Badge variant="secondary" className="bg-gray-100 text-gray-700 text-xs hidden sm:inline-flex shrink-0"> </Badge>
{request.department} )}
</Badge> <Badge
)} variant="outline"
</div> className={`${priorityConfig.color} text-xs px-2.5 py-0.5 capitalize hidden md:inline-flex`}
<h4 className="text-base sm:text-xl font-bold text-gray-900 mb-2 line-clamp-2"> >
{request.title} {request.priority}
</h4> </Badge>
<p className="text-xs sm:text-sm text-gray-600 line-clamp-2 leading-relaxed">
{request.description}
</p>
</div>
<div className="flex flex-col items-end gap-2 flex-shrink-0">
<ArrowRight className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 group-hover:text-blue-600 transition-colors" />
</div>
</div> </div>
{/* Status Info */} {/* Title */}
<div className="flex items-center gap-2 sm:gap-4 p-3 sm:p-4 bg-gray-50 rounded-lg"> <h4 className="text-sm font-semibold text-gray-800 line-clamp-1 leading-relaxed">
<div className="flex items-center gap-2 min-w-0"> {request.title}
<CheckCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-green-500 flex-shrink-0" /> </h4>
<span className="text-xs sm:text-sm text-gray-700 font-medium truncate">
{request.reason}
</span>
</div>
</div>
{/* Participants & Metadata */} {/* Metadata Row */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-6"> <div className="flex flex-wrap items-center gap-4 text-xs text-gray-600">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-1.5">
<Avatar className="h-7 w-7 sm:h-8 sm:w-8 ring-2 ring-white shadow-sm flex-shrink-0"> <Avatar className="h-6 w-6 ring-2 ring-white shadow-sm">
<AvatarFallback className="bg-slate-700 text-white text-xs sm:text-sm font-semibold"> <AvatarFallback className="bg-gradient-to-br from-slate-700 to-slate-900 text-white text-[10px] font-bold">
{request.initiator.avatar} {request.initiator.avatar}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
<div className="min-w-0"> <span className="font-medium text-gray-900">{request.initiator.name}</span>
<p className="text-xs sm:text-sm font-medium text-gray-900 truncate">{request.initiator.name}</p>
<p className="text-xs text-gray-500">Initiator</p>
</div>
</div> </div>
<div className="text-left sm:text-right"> {(request.totalLevels ?? 0) > 0 && (
<div className="flex flex-col gap-1 text-xs text-gray-500"> <div className="flex items-center gap-1.5">
<span className="flex items-center gap-1"> <CheckCircle className="w-3.5 h-3.5 text-green-600" />
<Calendar className="w-3 h-3 flex-shrink-0" /> <span className="font-medium">{request.completedLevels || 0}/{request.totalLevels} Approvals</span>
<span className="truncate">Created {request.createdAt}</span>
</span>
<span className="truncate">Closed {request.dueDate}</span>
</div> </div>
)}
<div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
<span>Created: {request.createdAt !== '—' ? formatDateTime(request.createdAt) : '—'}</span>
</div> </div>
{request.dueDate && (
<div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-slate-600" />
<span className="font-medium">Closed: {formatDateTime(request.dueDate)}</span>
</div>
)}
</div> </div>
</div> </div>
{/* Right: Arrow */}
<div className="flex-shrink-0 flex items-center pt-2">
<ArrowRight className="w-5 h-5 text-gray-400 group-hover:text-blue-600 group-hover:translate-x-1 transition-all" />
</div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -97,19 +97,34 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const [apiRequests, setApiRequests] = useState<any[]>([]); const [apiRequests, setApiRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hasFetchedFromApi, setHasFetchedFromApi] = useState(false);
useEffect(() => { useEffect(() => {
let mounted = true; let mounted = true;
(async () => { (async () => {
try { try {
setLoading(true); setLoading(true);
// Clear old data first
setApiRequests([]);
const result = await workflowApi.listMyWorkflows({ page: 1, limit: 20 }); 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; if (!mounted) return;
setApiRequests(items); setApiRequests(items);
} catch (_) { setHasFetchedFromApi(true); // Mark that we've fetched from API
} catch (error) {
console.error('[MyRequests] Error fetching requests:', error);
if (!mounted) return; if (!mounted) return;
setApiRequests([]); setApiRequests([]);
setHasFetchedFromApi(true); // Still mark as fetched even on error
} finally { } finally {
if (mounted) setLoading(false); 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 // 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 convertedDynamicRequests = sourceRequests.map((req: any) => {
const createdAt = req.submittedAt || req.submitted_at || req.createdAt || req.created_at; const createdAt = req.submittedAt || req.submitted_at || req.createdAt || req.created_at;
const priority = (req.priority || '').toString().toLowerCase(); const priority = (req.priority || '').toString().toLowerCase();

View File

@ -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<Notification[]>([]);
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 <MessageSquare className={`${iconClass} text-blue-600`} />;
case 'worknote':
return <FileText className={`${iconClass} text-purple-600`} />;
case 'assignment':
return <UserPlus className={`${iconClass} text-indigo-600`} />;
case 'approval':
return <CheckCircle className={`${iconClass} text-green-600`} />;
case 'rejection':
return <XCircle className={`${iconClass} text-red-600`} />;
case 'tat_alert':
return <Clock className={`${iconClass} text-orange-600`} />;
case 'tat_breach':
return <AlertCircle className={`${iconClass} text-red-600`} />;
case 'tat_breach_initiator':
return <AlertTriangle className={`${iconClass} text-amber-600`} />;
default:
return <Bell className={`${iconClass} text-gray-600`} />;
}
};
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 (
<div className="max-w-6xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
<Bell className="w-8 h-8 text-blue-600" />
Notifications
</h1>
<p className="text-gray-600 mt-1">
{totalCount} total notification{totalCount !== 1 ? 's' : ''}
{unreadCount > 0 && `${unreadCount} unread`}
</p>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
className="gap-2"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
{unreadCount > 0 && (
<Button
variant="outline"
size="sm"
onClick={handleMarkAllAsRead}
disabled={refreshing}
className="gap-2"
>
<CheckCheck className="w-4 h-4" />
Mark All Read
</Button>
)}
</div>
</div>
{/* Filter Tabs */}
<div className="flex gap-2">
<Button
variant={filter === 'all' ? 'default' : 'outline'}
size="sm"
onClick={() => {
setFilter('all');
setCurrentPage(1);
}}
>
All Notifications
</Button>
<Button
variant={filter === 'unread' ? 'default' : 'outline'}
size="sm"
onClick={() => {
setFilter('unread');
setCurrentPage(1);
}}
>
Unread
{unreadCount > 0 && (
<Badge className="ml-2 bg-red-500 text-white">
{unreadCount}
</Badge>
)}
</Button>
</div>
{/* Notifications List */}
<Card>
<CardHeader>
<CardTitle>Recent Activity</CardTitle>
<CardDescription>
Stay updated with all your workflow activities and mentions
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
) : notifications.length === 0 ? (
<div className="text-center py-12">
<Bell className="w-16 h-16 text-gray-300 mx-auto mb-4" />
<p className="text-lg font-medium text-gray-900 mb-2">No notifications</p>
<p className="text-sm text-gray-500">
{filter === 'unread'
? "You're all caught up! No unread notifications."
: "You don't have any notifications yet."}
</p>
</div>
) : (
<div className="space-y-2">
{notifications.map((notif) => (
<div
key={notif.notificationId}
className={`p-4 rounded-lg border transition-all cursor-pointer hover:shadow-md ${
!notif.isRead
? 'bg-blue-50 border-blue-200 hover:bg-blue-100'
: 'bg-white border-gray-200 hover:bg-gray-50'
}`}
onClick={() => handleNotificationClick(notif)}
>
<div className="flex items-start gap-4">
{/* Icon */}
<div className="shrink-0 mt-0.5">
{getNotificationIcon(notif.notificationType)}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-start justify-between gap-2 mb-1">
<h4 className={`text-sm ${!notif.isRead ? 'font-bold' : 'font-semibold'} text-gray-900`}>
{notif.title}
</h4>
<div className="flex items-center gap-2 shrink-0">
{notif.priority && notif.priority !== 'LOW' && (
<Badge
variant="outline"
className={`text-xs ${getPriorityColor(notif.priority)}`}
>
{notif.priority}
</Badge>
)}
{!notif.isRead && (
<div className="w-2 h-2 rounded-full bg-blue-600"></div>
)}
</div>
</div>
<p className="text-sm text-gray-700 mb-2 line-clamp-2">
{notif.message}
</p>
<div className="flex items-center justify-between">
<p className="text-xs text-gray-500">
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
{notif.metadata?.requestNumber && (
<span className="ml-2"> {notif.metadata.requestNumber}</span>
)}
</p>
<Button
variant="ghost"
size="sm"
className="h-7 w-7 p-0 hover:bg-red-100 hover:text-red-600"
onClick={(e) => {
e.stopPropagation();
handleDelete(notif.notificationId);
}}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</div>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Pagination */}
{!loading && totalPages > 1 && (
<div className="flex items-center justify-between">
<p className="text-sm text-gray-600">
Showing {((currentPage - 1) * ITEMS_PER_PAGE) + 1} to {Math.min(currentPage * ITEMS_PER_PAGE, totalCount)} of {totalCount} notifications
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
<ChevronLeft className="w-4 h-4" />
Previous
</Button>
<div className="flex items-center gap-1">
{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 (
<Button
key={pageNum}
variant={currentPage === pageNum ? 'default' : 'outline'}
size="sm"
className="w-9 h-9 p-0"
onClick={() => handlePageChange(pageNum)}
>
{pageNum}
</Button>
);
})}
</div>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
<ChevronRight className="w-4 h-4" />
</Button>
</div>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,2 @@
export { Notifications } from './Notifications';

View File

@ -13,7 +13,7 @@ interface Request {
id: string; id: string;
title: string; title: string;
description: string; description: string;
status: 'pending' | 'in-review'; status: 'pending' | 'in-review' | 'approved';
priority: 'express' | 'standard'; priority: 'express' | 'standard';
initiator: { name: string; avatar: string }; initiator: { name: string; avatar: string };
currentApprover?: { currentApprover?: {
@ -69,6 +69,13 @@ const getStatusConfig = (status: string) => {
icon: Eye, icon: Eye,
iconColor: 'text-blue-600' 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: default:
return { return {
color: 'bg-gray-100 text-gray-800 border-gray-200', color: 'bg-gray-100 text-gray-800 border-gray-200',
@ -94,7 +101,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
const fetchRequests = async () => { const fetchRequests = async () => {
try { try {
setLoading(true); setLoading(true);
// Clear old data first
setItems([]);
const result = await workflowApi.listOpenForMe({ page: 1, limit: 50 }); 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) const data = Array.isArray((result as any)?.data)
? (result as any).data ? (result as any).data
: Array.isArray((result as any)?.data?.data) : Array.isArray((result as any)?.data?.data)
@ -103,6 +115,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
? (result as any) ? (result as any)
: []; : [];
console.log('[OpenRequests] Parsed data count:', data.length); // Debug log
const mapped: Request[] = data.map((r: any) => { const mapped: Request[] = data.map((r: any) => {
const createdAt = r.submittedAt || r.submitted_at || r.createdAt || r.created_at; 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`} className={`${statusConfig.color} border font-medium text-xs shrink-0`}
> >
<statusConfig.icon className="w-3 h-3 mr-1" /> <statusConfig.icon className="w-3 h-3 mr-1" />
<span className="capitalize">{request.status}</span> <span className="capitalize">{(statusConfig as any).label || request.status}</span>
</Badge> </Badge>
{request.department && ( {request.department && (
<Badge variant="secondary" className="bg-gray-100 text-gray-700 text-xs hidden sm:inline-flex shrink-0"> <Badge variant="secondary" className="bg-gray-100 text-gray-700 text-xs hidden sm:inline-flex shrink-0">

View File

@ -7,6 +7,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { Textarea } from '@/components/ui/textarea';
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter'; import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
import { FilePreview } from '@/components/common/FilePreview'; import { FilePreview } from '@/components/common/FilePreview';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase'; import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
@ -44,7 +45,8 @@ import {
ClipboardList, ClipboardList,
Paperclip, Paperclip,
AlertTriangle, AlertTriangle,
AlertCircle AlertCircle,
Loader2
} from 'lucide-react'; } from 'lucide-react';
// Simple Error Boundary for RequestDetail // Simple Error Boundary for RequestDetail
@ -139,6 +141,11 @@ const getStatusConfig = (status: string) => {
color: 'bg-red-100 text-red-800 border-red-200', color: 'bg-red-100 text-red-800 border-red-200',
label: 'rejected' label: 'rejected'
}; };
case 'closed':
return {
color: 'bg-gray-100 text-gray-800 border-gray-300',
label: 'closed'
};
case 'skipped': case 'skipped':
return { return {
color: 'bg-orange-100 text-orange-800 border-orange-200', 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 // Use requestNumber from URL params (which now contains requestNumber), fallback to prop
const requestIdentifier = params.requestId || propRequestId || ''; 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<any | null>(null); const [apiRequest, setApiRequest] = useState<any | null>(null);
const [isSpectator, setIsSpectator] = useState(false); const [isSpectator, setIsSpectator] = useState(false);
// approving/rejecting local states are managed inside modals now // approving/rejecting local states are managed inside modals now
@ -228,8 +239,22 @@ function RequestDetailInner({
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [showActionStatusModal, setShowActionStatusModal] = useState(false); const [showActionStatusModal, setShowActionStatusModal] = useState(false);
const [actionStatus, setActionStatus] = useState<{ success: boolean; title: string; message: string } | null>(null); 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(); 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 // Shared refresh routine
const refreshDetails = async () => { const refreshDetails = async () => {
setRefreshing(true); setRefreshing(true);
@ -264,6 +289,7 @@ function RequestDetailInner({
if (val === 'PENDING') return 'pending'; if (val === 'PENDING') return 'pending';
if (val === 'APPROVED') return 'approved'; if (val === 'APPROVED') return 'approved';
if (val === 'REJECTED') return 'rejected'; if (val === 'REJECTED') return 'rejected';
if (val === 'CLOSED') return 'closed';
if (val === 'SKIPPED') return 'skipped'; if (val === 'SKIPPED') return 'skipped';
return (s || '').toLowerCase(); 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, ...wf,
id: wf.requestNumber || wf.requestId, id: wf.requestNumber || wf.requestId,
requestId: wf.requestId, requestId: wf.requestId,
@ -376,7 +410,9 @@ function RequestDetailInner({
updatedAt: wf.updatedAt, updatedAt: wf.updatedAt,
totalSteps: wf.totalLevels, totalSteps: wf.totalLevels,
currentStep: summary?.currentLevel || wf.currentLevel, currentStep: summary?.currentLevel || wf.currentLevel,
auditTrail: Array.isArray(details.activities) ? details.activities : [], auditTrail: filteredActivities,
conclusionRemark: wf.conclusionRemark || null,
closureDate: wf.closureDate || null,
}; };
setApiRequest(updatedRequest); setApiRequest(updatedRequest);
@ -408,6 +444,87 @@ function RequestDetailInner({
refreshDetails(); 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 // Work notes load
// Approve modal onConfirm // Approve modal onConfirm
@ -774,6 +891,7 @@ function RequestDetailInner({
if (val === 'PENDING') return 'pending'; if (val === 'PENDING') return 'pending';
if (val === 'APPROVED') return 'approved'; if (val === 'APPROVED') return 'approved';
if (val === 'REJECTED') return 'rejected'; if (val === 'REJECTED') return 'rejected';
if (val === 'CLOSED') return 'closed';
return (s || '').toLowerCase(); 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 = { const mapped = {
id: wf.requestNumber || wf.requestId, id: wf.requestNumber || wf.requestId,
requestId: wf.requestId, // ← UUID for API calls requestId: wf.requestId, // ← UUID for API calls
@ -889,7 +1015,9 @@ function RequestDetailInner({
approvals, // ← Added: Include raw approvals array with levelStartTime/tatStartTime approvals, // ← Added: Include raw approvals array with levelStartTime/tatStartTime
documents: mappedDocuments, documents: mappedDocuments,
spectators, spectators,
auditTrail: Array.isArray(details.activities) ? details.activities : [], auditTrail: filteredActivities,
conclusionRemark: wf.conclusionRemark || null,
closureDate: wf.closureDate || null,
}; };
setApiRequest(mapped); setApiRequest(mapped);
// Determine viewer role (spectator only means comment-only) // Determine viewer role (spectator only means comment-only)
@ -943,6 +1071,13 @@ function RequestDetailInner({
return userEmail === initiatorEmail; return userEmail === initiatorEmail;
}, [request, user]); }, [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 // Get all existing participants for validation
const existingParticipants = useMemo(() => { const existingParticipants = useMemo(() => {
if (!request) return []; if (!request) return [];
@ -1030,6 +1165,9 @@ function RequestDetailInner({
const priorityConfig = getPriorityConfig(request.priority || 'standard'); const priorityConfig = getPriorityConfig(request.priority || 'standard');
const statusConfig = getStatusConfig(request.status || 'pending'); const statusConfig = getStatusConfig(request.status || 'pending');
// Check if request is approved and needs closure by initiator
const needsClosure = request.status === 'approved' && isInitiator;
return ( return (
<> <>
<div className="min-h-screen bg-gray-50"> <div className="min-h-screen bg-gray-50">
@ -1090,12 +1228,13 @@ function RequestDetailInner({
{(() => { {(() => {
const sla = request.summary?.sla || request.sla; 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 ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-gray-500" /> <Clock className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-gray-700">
{request.status === 'approved' ? '✅ Request Approved' : {request.status === 'closed' ? '🔒 Request Closed' :
request.status === 'approved' ? '✅ Request Approved' :
request.status === 'rejected' ? '❌ Request Rejected' : 'SLA Not Available'} request.status === 'rejected' ? '❌ Request Rejected' : 'SLA Not Available'}
</span> </span>
</div> </div>
@ -1781,12 +1920,12 @@ function RequestDetailInner({
<Button <Button
size="sm" size="sm"
onClick={triggerFileInput} onClick={triggerFileInput}
disabled={uploadingDocument} disabled={uploadingDocument || request.status === 'closed'}
className="gap-1 sm:gap-2 h-8 sm:h-9 text-xs sm:text-sm shrink-0" className="gap-1 sm:gap-2 h-8 sm:h-9 text-xs sm:text-sm shrink-0"
> >
<Upload className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> <Upload className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
{uploadingDocument ? 'Uploading...' : 'Upload'} {uploadingDocument ? 'Uploading...' : request.status === 'closed' ? 'Closed' : 'Upload'}
<span className="hidden sm:inline">Document</span> <span className="hidden sm:inline">{request.status === 'closed' ? '' : 'Document'}</span>
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
@ -2036,8 +2175,8 @@ function RequestDetailInner({
<CardTitle className="text-sm sm:text-base">Quick Actions</CardTitle> <CardTitle className="text-sm sm:text-base">Quick Actions</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-2"> <CardContent className="space-y-2">
{/* Only initiator can add approvers */} {/* Only initiator can add approvers (not for closed requests) */}
{isInitiator && ( {isInitiator && request.status !== 'closed' && (
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900 h-9 sm:h-10 text-xs sm:text-sm" className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900 h-9 sm:h-10 text-xs sm:text-sm"
@ -2047,8 +2186,8 @@ function RequestDetailInner({
Add Approver Add Approver
</Button> </Button>
)} )}
{/* Non-spectators can add spectators */} {/* Non-spectators can add spectators (not for closed requests) */}
{!isSpectator && ( {!isSpectator && request.status !== 'closed' && (
<Button <Button
variant="outline" variant="outline"
className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900 h-9 sm:h-10 text-xs sm:text-sm" className="w-full justify-start gap-2 bg-white text-gray-700 border-gray-300 hover:bg-gray-50 hover:text-gray-900 h-9 sm:h-10 text-xs sm:text-sm"
@ -2109,6 +2248,138 @@ function RequestDetailInner({
</div> </div>
)} )}
</div> </div>
{/* Read-Only Conclusion Remark - Shows for closed requests */}
{request.status === 'closed' && request.conclusionRemark && activeTab !== 'worknotes' && (
<div className="mt-6 lg:col-span-3">
<Card>
<CardHeader className="bg-gradient-to-r from-gray-50 to-slate-50 border-b border-gray-200">
<CardTitle className="flex items-center gap-2 text-lg">
<CheckCircle className="w-5 h-5 text-gray-600" />
Conclusion Remark
</CardTitle>
<CardDescription className="mt-1">
Final summary of this closed request
</CardDescription>
</CardHeader>
<CardContent className="pt-6">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-6">
<p className="text-sm text-gray-700 whitespace-pre-line leading-relaxed">
{request.conclusionRemark}
</p>
</div>
{request.closureDate && (
<div className="mt-4 flex items-center justify-between text-xs text-gray-500 border-t border-gray-200 pt-4">
<span>Request closed on {formatDateTime(request.closureDate)}</span>
<span>By {request.initiator?.name || 'Initiator'}</span>
</div>
)}
</CardContent>
</Card>
</div>
)}
{/* Conclusion Remark Section - Shows below tabs when request is approved */}
{needsClosure && activeTab !== 'worknotes' && (
<div className="mt-6 lg:col-span-3">
<Card>
<CardHeader className="bg-gradient-to-r from-green-50 to-emerald-50 border-b border-green-200">
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2 text-lg">
<CheckCircle className="w-5 h-5 text-green-600" />
Conclusion Remark - Final Step
</CardTitle>
<CardDescription className="mt-1">
All approvals are complete. Please review and finalize the conclusion to close this request.
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
onClick={handleGenerateConclusion}
disabled={conclusionLoading}
className="gap-2"
>
<RefreshCw className={`w-3.5 h-3.5 ${conclusionLoading ? 'animate-spin' : ''}`} />
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button>
</div>
</CardHeader>
<CardContent className="pt-6">
{conclusionLoading ? (
<div className="flex items-center justify-center py-12">
<div className="text-center">
<Loader2 className="w-10 h-10 text-blue-600 animate-spin mx-auto mb-3" />
<p className="text-sm text-gray-600">Preparing conclusion remark...</p>
</div>
</div>
) : (
<div className="space-y-4">
<div>
<div className="flex items-center justify-between mb-2">
<label className="text-sm font-medium text-gray-700">
Conclusion Remark
</label>
{aiGenerated && (
<span className="text-xs text-blue-600"> System-generated suggestion (editable)</span>
)}
</div>
<Textarea
value={conclusionRemark}
onChange={(e) => setConclusionRemark(e.target.value)}
placeholder="Enter a professional conclusion remark summarizing the request outcome, key decisions, and approvals..."
className="min-h-[200px] text-sm"
maxLength={2000}
/>
<div className="flex items-center justify-between mt-2">
<p className="text-xs text-gray-500">
This will be the final summary for this request
</p>
<p className="text-xs text-gray-500">
{conclusionRemark.length} / 2000 characters
</p>
</div>
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-4">
<p className="text-sm font-semibold text-blue-900 mb-2">Finalizing this request will:</p>
<ul className="text-sm text-blue-800 space-y-1 pl-4">
<li className="list-disc">Change request status to "CLOSED"</li>
<li className="list-disc">Notify all participants of closure</li>
<li className="list-disc">Move request to Closed Requests</li>
<li className="list-disc">Save conclusion remark permanently</li>
</ul>
</div>
<div className="flex gap-3 justify-end pt-4 border-t">
<Button
onClick={handleFinalizeConclusion}
disabled={conclusionSubmitting || !conclusionRemark.trim()}
className="bg-green-600 hover:bg-green-700 text-white"
>
{conclusionSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Finalizing...
</>
) : (
<>
<CheckCircle className="w-4 h-4 mr-2" />
Finalize & Close Request
</>
)}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
</div>
)}
</Tabs> </Tabs>
</div> </div>
</div> </div>

View File

@ -0,0 +1,65 @@
import apiClient from './authApi';
export interface ConclusionRemark {
conclusionId: string;
requestId: string;
aiGeneratedRemark: string | null;
aiModelUsed: string | null;
aiConfidenceScore: number | null;
finalRemark: string | null;
editedBy: string | null;
isEdited: boolean;
editCount: number;
approvalSummary: any;
documentSummary: any;
keyDiscussionPoints: string[];
generatedAt: string | null;
finalizedAt: string | null;
createdAt: string;
updatedAt: string;
}
/**
* Generate AI-powered conclusion remark
*/
export async function generateConclusion(requestId: string): Promise<{
conclusionId: string;
aiGeneratedRemark: string;
keyDiscussionPoints: string[];
confidence: number;
generatedAt: string;
}> {
const response = await apiClient.post(`/conclusions/${requestId}/generate`);
return response.data.data;
}
/**
* Update conclusion remark (edit by initiator)
*/
export async function updateConclusion(requestId: string, finalRemark: string): Promise<ConclusionRemark> {
const response = await apiClient.put(`/conclusions/${requestId}`, { finalRemark });
return response.data.data;
}
/**
* Finalize conclusion and close request
*/
export async function finalizeConclusion(requestId: string, finalRemark: string): Promise<{
conclusionId: string;
requestNumber: string;
status: string;
finalRemark: string;
finalizedAt: string;
}> {
const response = await apiClient.post(`/conclusions/${requestId}/finalize`, { finalRemark });
return response.data.data;
}
/**
* Get conclusion for a request
*/
export async function getConclusion(requestId: string): Promise<ConclusionRemark> {
const response = await apiClient.get(`/conclusions/${requestId}`);
return response.data.data;
}

View File

@ -0,0 +1,64 @@
import apiClient from './authApi';
export interface Notification {
notificationId: string;
userId: string;
requestId?: string;
notificationType: string;
title: string;
message: string;
isRead: boolean;
priority: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
actionUrl?: string;
actionRequired: boolean;
metadata?: any;
sentVia: string[];
readAt?: string;
createdAt: string;
}
class NotificationApi {
/**
* Get user's notifications
*/
async list(params?: { page?: number; limit?: number; unreadOnly?: boolean }) {
const response = await apiClient.get('/notifications', { params });
return response.data;
}
/**
* Get unread count
*/
async getUnreadCount() {
const response = await apiClient.get('/notifications/unread-count');
return response.data.data.unreadCount;
}
/**
* Mark notification as read
*/
async markAsRead(notificationId: string) {
const response = await apiClient.patch(`/notifications/${notificationId}/read`);
return response.data;
}
/**
* Mark all as read
*/
async markAllAsRead() {
const response = await apiClient.post('/notifications/mark-all-read');
return response.data;
}
/**
* Delete notification
*/
async delete(notificationId: string) {
const response = await apiClient.delete(`/notifications/${notificationId}`);
return response.data;
}
}
export const notificationApi = new NotificationApi();
export default notificationApi;

View File

@ -43,4 +43,9 @@ export function leaveRequestRoom(socket: Socket, requestId: string) {
socket.emit('leave:request', requestId); socket.emit('leave:request', requestId);
} }
export function joinUserRoom(socket: Socket, userId: string) {
socket.emit('join:user', { userId });
console.log('[Socket] Joined personal notification room for user:', userId);
}