ai conlusion flow added and TAT triggering got issue resolved enhanced system config for ai setup
This commit is contained in:
parent
7efc5c5d94
commit
d60757ae72
15
src/App.tsx
15
src/App.tsx
@ -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
|
||||||
|
|||||||
@ -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" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
<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 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">
|
||||||
3
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
</Badge>
|
</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" />
|
||||||
|
<p className="text-sm text-gray-500">No notifications yet</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm">
|
) : (
|
||||||
<p className="font-medium">New comment on RE-REQ-003</p>
|
<div className="divide-y">
|
||||||
<p className="text-muted-foreground text-xs">From John Doe - 5 min ago</p>
|
{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>
|
||||||
|
)}
|
||||||
|
</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>
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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,13 +100,17 @@ 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);
|
||||||
|
|
||||||
useEffect(() => {
|
const fetchRequests = async () => {
|
||||||
let mounted = true;
|
|
||||||
(async () => {
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
// Clear old data first
|
||||||
|
setItems([]);
|
||||||
|
|
||||||
const result = await workflowApi.listClosedByMe({ page: 1, limit: 50 });
|
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)
|
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)
|
||||||
@ -97,9 +118,11 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
: Array.isArray(result as any)
|
: Array.isArray(result as any)
|
||||||
? (result as any)
|
? (result as any)
|
||||||
: [];
|
: [];
|
||||||
if (!mounted) return;
|
|
||||||
|
console.log('[ClosedRequests] Parsed data count:', data.length); // Debug log
|
||||||
|
|
||||||
const mapped: Request[] = data
|
const mapped: Request[] = data
|
||||||
.filter((r: any) => ['APPROVED', 'REJECTED'].includes((r.status || '').toString()))
|
.filter((r: any) => ['APPROVED', 'REJECTED', 'CLOSED'].includes((r.status || '').toString()))
|
||||||
.map((r: any) => ({
|
.map((r: any) => ({
|
||||||
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
|
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
|
||||||
requestId: r.requestId, // Keep requestId for reference
|
requestId: r.requestId, // Keep requestId for reference
|
||||||
@ -109,17 +132,30 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
status: (r.status || '').toString().toLowerCase(),
|
status: (r.status || '').toString().toLowerCase(),
|
||||||
priority: (r.priority || '').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() },
|
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 || '—',
|
createdAt: r.submittedAt || r.createdAt || '—',
|
||||||
dueDate: undefined,
|
dueDate: r.closureDate || r.closure_date || undefined,
|
||||||
reason: r.conclusionRemark,
|
reason: r.conclusionRemark || r.conclusion_remark,
|
||||||
department: r.department
|
department: r.department,
|
||||||
|
totalLevels: r.totalLevels || 0,
|
||||||
|
completedLevels: r.summary?.approvedLevels || 0,
|
||||||
}));
|
}));
|
||||||
setItems(mapped);
|
setItems(mapped);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[ClosedRequests] Error fetching requests:', error);
|
||||||
|
setItems([]);
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setLoading(false);
|
setLoading(false);
|
||||||
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
})();
|
};
|
||||||
return () => { mounted = false; };
|
|
||||||
|
const handleRefresh = () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
fetchRequests();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchRequests();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
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,93 +387,85 @@ 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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Center: Main Content */}
|
||||||
|
<div className="flex-1 min-w-0 space-y-3">
|
||||||
|
{/* Header Row */}
|
||||||
|
<div className="flex items-center gap-2.5 flex-wrap">
|
||||||
|
<h3 className="text-base font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
|
||||||
|
{(request as any).displayId || request.id}
|
||||||
|
</h3>
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`text-xs font-medium ${priorityConfig.color} capitalize flex-shrink-0`}
|
className={`${statusConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`}
|
||||||
|
>
|
||||||
|
<statusConfig.icon className="w-3.5 h-3.5 mr-1" />
|
||||||
|
{statusConfig.label}
|
||||||
|
</Badge>
|
||||||
|
{request.department && (
|
||||||
|
<Badge variant="secondary" className="bg-blue-50 text-blue-700 text-xs px-2.5 py-0.5 hidden sm:inline-flex">
|
||||||
|
{request.department}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`${priorityConfig.color} text-xs px-2.5 py-0.5 capitalize hidden md:inline-flex`}
|
||||||
>
|
>
|
||||||
{request.priority}
|
{request.priority}
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content */}
|
{/* Title */}
|
||||||
<div className="flex-1 min-w-0 space-y-3 sm:space-y-4 w-full">
|
<h4 className="text-sm font-semibold text-gray-800 line-clamp-1 leading-relaxed">
|
||||||
{/* Header */}
|
|
||||||
<div className="flex items-start justify-between gap-2 sm:gap-4">
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex flex-wrap items-center gap-1.5 sm:gap-3 mb-2">
|
|
||||||
<h3 className="text-sm sm:text-lg font-semibold text-gray-900 group-hover:text-blue-600 transition-colors">
|
|
||||||
{(request as any).displayId || request.id}
|
|
||||||
</h3>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className={`${statusConfig.color} border font-medium text-xs shrink-0`}
|
|
||||||
>
|
|
||||||
<statusConfig.icon className="w-3 h-3 mr-1" />
|
|
||||||
<span className="capitalize">{request.status}</span>
|
|
||||||
</Badge>
|
|
||||||
{request.department && (
|
|
||||||
<Badge variant="secondary" className="bg-gray-100 text-gray-700 text-xs hidden sm:inline-flex shrink-0">
|
|
||||||
{request.department}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<h4 className="text-base sm:text-xl font-bold text-gray-900 mb-2 line-clamp-2">
|
|
||||||
{request.title}
|
{request.title}
|
||||||
</h4>
|
</h4>
|
||||||
<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">
|
{/* Metadata Row */}
|
||||||
<ArrowRight className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 group-hover:text-blue-600 transition-colors" />
|
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-600">
|
||||||
</div>
|
<div className="flex items-center gap-1.5">
|
||||||
</div>
|
<Avatar className="h-6 w-6 ring-2 ring-white shadow-sm">
|
||||||
|
<AvatarFallback className="bg-gradient-to-br from-slate-700 to-slate-900 text-white text-[10px] font-bold">
|
||||||
{/* Status Info */}
|
|
||||||
<div className="flex items-center gap-2 sm:gap-4 p-3 sm:p-4 bg-gray-50 rounded-lg">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<CheckCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-green-500 flex-shrink-0" />
|
|
||||||
<span className="text-xs sm:text-sm text-gray-700 font-medium truncate">
|
|
||||||
{request.reason}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Participants & Metadata */}
|
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-6">
|
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
|
||||||
<Avatar className="h-7 w-7 sm:h-8 sm:w-8 ring-2 ring-white shadow-sm flex-shrink-0">
|
|
||||||
<AvatarFallback className="bg-slate-700 text-white text-xs sm:text-sm font-semibold">
|
|
||||||
{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>
|
</div>
|
||||||
<p className="text-xs text-gray-500">Initiator</p>
|
|
||||||
|
{(request.totalLevels ?? 0) > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
|
||||||
|
<span className="font-medium">{request.completedLevels || 0}/{request.totalLevels} Approvals</span>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
{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>
|
||||||
|
|
||||||
<div className="text-left sm:text-right">
|
{/* Right: Arrow */}
|
||||||
<div className="flex flex-col gap-1 text-xs text-gray-500">
|
<div className="flex-shrink-0 flex items-center pt-2">
|
||||||
<span className="flex items-center gap-1">
|
<ArrowRight className="w-5 h-5 text-gray-400 group-hover:text-blue-600 group-hover:translate-x-1 transition-all" />
|
||||||
<Calendar className="w-3 h-3 flex-shrink-0" />
|
|
||||||
<span className="truncate">Created {request.createdAt}</span>
|
|
||||||
</span>
|
|
||||||
<span className="truncate">Closed {request.dueDate}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
392
src/pages/Notifications/Notifications.tsx
Normal file
392
src/pages/Notifications/Notifications.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/pages/Notifications/index.ts
Normal file
2
src/pages/Notifications/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Notifications } from './Notifications';
|
||||||
|
|
||||||
@ -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">
|
||||||
|
|||||||
@ -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,6 +375,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 updatedRequest = {
|
const updatedRequest = {
|
||||||
...wf,
|
...wf,
|
||||||
id: wf.requestNumber || wf.requestId,
|
id: wf.requestNumber || 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>
|
||||||
|
|||||||
65
src/services/conclusionApi.ts
Normal file
65
src/services/conclusionApi.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
64
src/services/notificationApi.ts
Normal file
64
src/services/notificationApi.ts
Normal 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;
|
||||||
|
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user