diff --git a/src/components/common/Pagination/Pagination.tsx b/src/components/common/Pagination/Pagination.tsx new file mode 100644 index 0000000..2a155b1 --- /dev/null +++ b/src/components/common/Pagination/Pagination.tsx @@ -0,0 +1,141 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { ArrowRight } from 'lucide-react'; + +interface PaginationProps { + currentPage: number; + totalPages: number; + totalRecords: number; + itemsPerPage: number; + onPageChange: (page: number) => void; + loading?: boolean; + itemLabel?: string; // e.g., "requests", "activities", "approvers" + testIdPrefix?: string; +} + +export function Pagination({ + currentPage, + totalPages, + itemsPerPage, + totalRecords, + onPageChange, + loading = false, + itemLabel = 'items', + testIdPrefix = 'pagination' +}: PaginationProps) { + const getPageNumbers = () => { + const pages = []; + const maxPagesToShow = 5; + let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2)); + let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1); + + if (endPage - startPage < maxPagesToShow - 1) { + startPage = Math.max(1, endPage - maxPagesToShow + 1); + } + + for (let i = startPage; i <= endPage; i++) { + pages.push(i); + } + + return pages; + }; + + // Don't show pagination if only 1 page or loading + if (totalPages <= 1 || loading) { + return null; + } + + const startItem = ((currentPage - 1) * itemsPerPage) + 1; + const endItem = Math.min(currentPage * itemsPerPage, totalRecords); + + return ( + + +
+
+ Showing {startItem} to {endItem} of {totalRecords} {itemLabel} +
+ +
+ {/* Previous Button */} + + + {/* First page + ellipsis */} + {currentPage > 3 && totalPages > 5 && ( + <> + + ... + + )} + + {/* Page Numbers */} + {getPageNumbers().map((pageNum) => ( + + ))} + + {/* Last page + ellipsis */} + {currentPage < totalPages - 2 && totalPages > 5 && ( + <> + ... + + + )} + + {/* Next Button */} + +
+
+
+
+ ); +} + diff --git a/src/components/common/Pagination/index.ts b/src/components/common/Pagination/index.ts new file mode 100644 index 0000000..5af4a89 --- /dev/null +++ b/src/components/common/Pagination/index.ts @@ -0,0 +1,2 @@ +export { Pagination } from './Pagination'; + diff --git a/src/components/dashboard/ActivityFeedItem/ActivityFeedItem.tsx b/src/components/dashboard/ActivityFeedItem/ActivityFeedItem.tsx new file mode 100644 index 0000000..1b8b344 --- /dev/null +++ b/src/components/dashboard/ActivityFeedItem/ActivityFeedItem.tsx @@ -0,0 +1,215 @@ +import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; +import { Badge } from '@/components/ui/badge'; +import { + CheckCircle, + AlertTriangle, + MessageSquare, + Flame, + FileText, + Paperclip, + Activity, + ArrowRight +} from 'lucide-react'; +import { differenceInMinutes, differenceInHours, differenceInDays } from 'date-fns'; + +export interface ActivityData { + activityId: string; + requestNumber: string; + requestTitle: string; + action: string; + userId: string; + userName: string; + timestamp: string; + priority: string; +} + +interface ActivityFeedItemProps { + activity: ActivityData; + currentUserId?: string; + currentUserDisplayName?: string; + currentUserEmail?: string; + onNavigate?: (requestNumber: string) => void; + testId?: string; +} + +// Utility functions +const getPriorityColor = (priority: string) => { + const p = priority.toLowerCase(); + switch (p) { + case 'express': return 'bg-orange-100 text-orange-800 border-orange-200'; + case 'standard': return 'bg-blue-100 text-blue-800 border-blue-200'; + case 'high': return 'bg-red-100 text-red-800 border-red-200'; + case 'medium': return 'bg-orange-100 text-orange-800 border-orange-200'; + case 'low': return 'bg-green-100 text-green-800 border-green-200'; + default: return 'bg-gray-100 text-gray-800 border-gray-200'; + } +}; + +const getRelativeTime = (timestamp: string) => { + const now = new Date(); + const time = new Date(timestamp); + const diffMin = differenceInMinutes(now, time); + + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`; + + const diffHrs = differenceInHours(now, time); + if (diffHrs < 24) return `${diffHrs} hour${diffHrs > 1 ? 's' : ''} ago`; + + const diffDay = differenceInDays(now, time); + return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`; +}; + +const cleanActivityDescription = (desc: string) => { + if (!desc) return desc; + + // Remove email addresses in parentheses + let cleaned = desc.replace(/\s*\([^)]*@[^)]*\)/g, ''); + + // Remove "by [user]" at the end - we show user separately + cleaned = cleaned.replace(/\s+by\s+.+$/i, ''); + + // Shorten common phrases + cleaned = cleaned.replace(/has been added as approver/gi, 'added as approver'); + cleaned = cleaned.replace(/has been added as spectator/gi, 'added as spectator'); + cleaned = cleaned.replace(/has been/gi, ''); + + // Make TAT format more compact + cleaned = cleaned.replace(/with TAT of (\d+) hours?/gi, '(TAT: $1h)'); + cleaned = cleaned.replace(/with TAT of (\d+) days?/gi, '(TAT: $1d)'); + + // Replace multiple spaces with single space + cleaned = cleaned.replace(/\s+/g, ' '); + + return cleaned.trim(); +}; + +const getActionIcon = (action: string) => { + const actionLower = action.toLowerCase(); + if (actionLower.includes('approv')) return ; + if (actionLower.includes('reject')) return ; + if (actionLower.includes('comment')) return ; + if (actionLower.includes('escalat')) return ; + if (actionLower.includes('submit')) return ; + if (actionLower.includes('document')) return ; + return ; +}; + +export function ActivityFeedItem({ + activity, + currentUserId, + currentUserDisplayName, + currentUserEmail, + onNavigate, + testId = 'activity-feed-item' +}: ActivityFeedItemProps) { + const isCurrentUser = activity.userId === currentUserId; + const displayName = isCurrentUser ? 'You' : (activity.userName || 'System'); + + const userInitials = isCurrentUser + ? ((currentUserDisplayName || currentUserEmail || 'ME') + .split(' ') + .map((n: string) => n[0]) + .join('') + .toUpperCase() + .substring(0, 2)) + : activity.userName + ? activity.userName + .split(' ') + .map(n => n[0]) + .join('') + .toUpperCase() + .substring(0, 2) + : 'SY'; // System default + + return ( +
onNavigate?.(activity.requestNumber)} + data-testid={`${testId}-${activity.activityId}`} + > +
+ + + + {userInitials} + + +
+ {getActionIcon(activity.action)} +
+
+ +
+ {/* Header with Request Number and Priority Badge */} +
+
+ + {activity.requestNumber} + + + {activity.priority} + +
+
+ + {/* Action Description as Text */} +

+ + {cleanActivityDescription(activity.action)} + +

+ + {/* Request Title */} +

+ {activity.requestTitle} +

+ + {/* User and Time */} +
+ + {displayName} + + β€’ + + {getRelativeTime(activity.timestamp)} + +
+
+ + +
+ ); +} + diff --git a/src/components/dashboard/ActivityFeedItem/index.ts b/src/components/dashboard/ActivityFeedItem/index.ts new file mode 100644 index 0000000..2e46e45 --- /dev/null +++ b/src/components/dashboard/ActivityFeedItem/index.ts @@ -0,0 +1,3 @@ +export { ActivityFeedItem } from './ActivityFeedItem'; +export type { ActivityData } from './ActivityFeedItem'; + diff --git a/src/components/dashboard/CriticalAlertCard/CriticalAlertCard.tsx b/src/components/dashboard/CriticalAlertCard/CriticalAlertCard.tsx new file mode 100644 index 0000000..bdc6c4f --- /dev/null +++ b/src/components/dashboard/CriticalAlertCard/CriticalAlertCard.tsx @@ -0,0 +1,138 @@ +import { Badge } from '@/components/ui/badge'; +import { Progress } from '@/components/ui/progress'; +import { Star } from 'lucide-react'; + +export interface CriticalAlertData { + requestId: string; + requestNumber: string; + title: string; + priority: string; + totalTATHours: number; + originalTATHours: number; + breachCount: number; + currentLevel: number; + totalLevels: number; +} + +interface CriticalAlertCardProps { + alert: CriticalAlertData; + onNavigate?: (requestNumber: string) => void; + testId?: string; +} + +// Utility functions +const calculateProgress = (alert: CriticalAlertData) => { + if (!alert.originalTATHours || alert.originalTATHours === 0) return 0; + + const originalTAT = alert.originalTATHours; + const remainingTAT = alert.totalTATHours; + + // If breached (negative remaining), show 100% + if (remainingTAT <= 0) return 100; + + // Calculate elapsed time + const elapsedTAT = originalTAT - remainingTAT; + + // Calculate percentage used + const percentageUsed = (elapsedTAT / originalTAT) * 100; + + // Ensure it's between 0 and 100 + return Math.min(100, Math.max(0, Math.round(percentageUsed))); +}; + +const formatRemainingTime = (alert: CriticalAlertData) => { + if (alert.totalTATHours === undefined || alert.totalTATHours === null) return 'N/A'; + + const hours = alert.totalTATHours; + + // If TAT is breached (negative or zero) + if (hours <= 0) { + const overdue = Math.abs(hours); + if (overdue < 1) return `Breached`; + if (overdue < 24) return `${Math.round(overdue)}h overdue`; + return `${Math.round(overdue / 24)}d overdue`; + } + + // If TAT is still remaining + if (hours < 1) return `${Math.round(hours * 60)}min left`; + if (hours < 24) return `${Math.round(hours)}h left`; + return `${Math.round(hours / 24)}d left`; +}; + +export function CriticalAlertCard({ + alert, + onNavigate, + testId = 'critical-alert-card' +}: CriticalAlertCardProps) { + const progress = calculateProgress(alert); + + return ( +
onNavigate?.(alert.requestNumber)} + data-testid={`${testId}-${alert.requestId}`} + > +
+
+
+

+ {alert.requestNumber} +

+ {alert.priority === 'express' && ( + + )} + {alert.breachCount > 0 && ( + + {alert.breachCount} + + )} +
+

+ {alert.title} +

+
+ + {formatRemainingTime(alert)} + +
+
+
+ TAT Used + + {progress}% + +
+ = 80 ? '[&>div]:bg-red-600' : + progress >= 50 ? '[&>div]:bg-orange-500' : + '[&>div]:bg-green-600' + }`} + data-testid={`${testId}-progress-bar`} + /> +
+
+ ); +} + diff --git a/src/components/dashboard/CriticalAlertCard/index.ts b/src/components/dashboard/CriticalAlertCard/index.ts new file mode 100644 index 0000000..e7984a2 --- /dev/null +++ b/src/components/dashboard/CriticalAlertCard/index.ts @@ -0,0 +1,3 @@ +export { CriticalAlertCard } from './CriticalAlertCard'; +export type { CriticalAlertData } from './CriticalAlertCard'; + diff --git a/src/components/dashboard/KPICard/KPICard.tsx b/src/components/dashboard/KPICard/KPICard.tsx new file mode 100644 index 0000000..efa74e4 --- /dev/null +++ b/src/components/dashboard/KPICard/KPICard.tsx @@ -0,0 +1,72 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { LucideIcon } from 'lucide-react'; +import { ReactNode } from 'react'; + +interface KPICardProps { + title: string; + value: string | number; + icon: LucideIcon; + iconBgColor: string; + iconColor: string; + subtitle?: string; + children?: ReactNode; + testId?: string; + onClick?: () => void; +} + +export function KPICard({ + title, + value, + icon: Icon, + iconBgColor, + iconColor, + subtitle, + children, + testId = 'kpi-card', + onClick +}: KPICardProps) { + return ( + + + + {title} + +
+ +
+
+ +
+ {value} +
+ {subtitle && ( +
+ {subtitle} +
+ )} + {children && ( +
+ {children} +
+ )} +
+
+ ); +} + diff --git a/src/components/dashboard/KPICard/index.ts b/src/components/dashboard/KPICard/index.ts new file mode 100644 index 0000000..b418b34 --- /dev/null +++ b/src/components/dashboard/KPICard/index.ts @@ -0,0 +1,2 @@ +export { KPICard } from './KPICard'; + diff --git a/src/components/dashboard/StatCard/StatCard.tsx b/src/components/dashboard/StatCard/StatCard.tsx new file mode 100644 index 0000000..baf6f7d --- /dev/null +++ b/src/components/dashboard/StatCard/StatCard.tsx @@ -0,0 +1,41 @@ +import { ReactNode } from 'react'; + +interface StatCardProps { + label: string; + value: string | number; + bgColor: string; + textColor: string; + testId?: string; + children?: ReactNode; +} + +export function StatCard({ + label, + value, + bgColor, + textColor, + testId = 'stat-card', + children +}: StatCardProps) { + return ( +
+

+ {label} +

+

+ {value} +

+ {children} +
+ ); +} + diff --git a/src/components/dashboard/StatCard/index.ts b/src/components/dashboard/StatCard/index.ts new file mode 100644 index 0000000..b25baf4 --- /dev/null +++ b/src/components/dashboard/StatCard/index.ts @@ -0,0 +1,2 @@ +export { StatCard } from './StatCard'; + diff --git a/src/pages/RequestDetail/RequestDetail.tsx b/src/pages/RequestDetail/RequestDetail.tsx new file mode 100644 index 0000000..27ba6ba --- /dev/null +++ b/src/pages/RequestDetail/RequestDetail.tsx @@ -0,0 +1,2518 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; +import { Progress } from '@/components/ui/progress'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { Textarea } from '@/components/ui/textarea'; +import { formatDateTime, formatDateShort } from '@/utils/dateFormatter'; +import { FilePreview } from '@/components/common/FilePreview'; +import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase'; +import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase'; +import workflowApi, { approveLevel, rejectLevel, addApproverAtLevel, skipApprover, addSpectator, downloadDocument, getDocumentPreviewUrl } from '@/services/workflowApi'; +import { uploadDocument } from '@/services/documentApi'; +import { ApprovalModal } from '@/components/approval/ApprovalModal/ApprovalModal'; +import { RejectionModal } from '@/components/approval/RejectionModal/RejectionModal'; +import { SkipApproverModal } from '@/components/approval/SkipApproverModal'; +import { AddApproverModal } from '@/components/participant/AddApproverModal'; +import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal'; +import { ActionStatusModal } from '@/components/common/ActionStatusModal'; +import { WorkNoteChat } from '@/components/workNote/WorkNoteChat/WorkNoteChat'; +import { useAuth } from '@/contexts/AuthContext'; +import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket'; +import { getWorkNotes } from '@/services/workflowApi'; +import { Component, ErrorInfo, ReactNode } from 'react'; +import { + ArrowLeft, + Clock, + User, + FileText, + MessageSquare, + CheckCircle, + XCircle, + Download, + Eye, + TrendingUp, + RefreshCw, + Activity, + Mail, + Phone, + Upload, + UserPlus, + ClipboardList, + Paperclip, + AlertTriangle, + AlertCircle, + Loader2 +} from 'lucide-react'; + +// Simple Error Boundary for RequestDetail +class RequestDetailErrorBoundary extends Component< + { children: ReactNode }, + { hasError: boolean; error: Error | null } +> { + constructor(props: { children: ReactNode }) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error) { + return { hasError: true, error }; + } + + override componentDidCatch(error: Error, errorInfo: ErrorInfo) { + console.error('RequestDetail Error:', error, errorInfo); + } + + override render() { + if (this.state.hasError) { + return ( +
+
+ +

Error Loading Request

+

+ {this.state.error?.message || 'An unexpected error occurred'} +

+ + +
+
+ ); + } + return this.props.children; + } +} + +interface RequestDetailProps { + requestId: string; + onBack?: () => void; + dynamicRequests?: any[]; +} + +// Utility functions +const getPriorityConfig = (priority: string) => { + switch (priority) { + case 'express': + case 'urgent': + return { + color: 'bg-red-100 text-red-800 border-red-200', + label: 'urgent priority' + }; + case 'standard': + return { + color: 'bg-blue-100 text-blue-800 border-blue-200', + label: 'standard priority' + }; + default: + return { + color: 'bg-gray-100 text-gray-800 border-gray-200', + label: 'normal priority' + }; + } +}; + +const getStatusConfig = (status: string) => { + switch (status) { + case 'pending': + return { + color: 'bg-yellow-100 text-yellow-800 border-yellow-200', + label: 'pending' + }; + case 'in-review': + return { + color: 'bg-blue-100 text-blue-800 border-blue-200', + label: 'in-review' + }; + case 'approved': + return { + color: 'bg-green-100 text-green-800 border-green-200', + label: 'approved' + }; + case 'rejected': + return { + color: 'bg-red-100 text-red-800 border-red-200', + label: 'rejected' + }; + case 'closed': + return { + color: 'bg-gray-100 text-gray-800 border-gray-300', + label: 'closed' + }; + case 'skipped': + return { + color: 'bg-orange-100 text-orange-800 border-orange-200', + label: 'skipped' + }; + default: + return { + color: 'bg-gray-100 text-gray-800 border-gray-200', + label: status + }; + } +}; + +const getStepIcon = (status: string, isSkipped?: boolean) => { + if (isSkipped) { + return ; + } + + switch (status) { + case 'approved': + return ; + case 'rejected': + return ; + case 'pending': + case 'in-review': + return ; + case 'waiting': + return ; + default: + return ; + } +}; + +const getActionTypeIcon = (type: string) => { + switch (type) { + case 'approval': + case 'approved': + return ; + case 'rejection': + case 'rejected': + return ; + case 'comment': + return ; + case 'status_change': + case 'updated': + return ; + case 'assignment': + return ; + case 'created': + return ; + case 'reminder': + return ; + case 'document_added': + return ; + case 'sla_warning': + return ; + default: + return ; + } +}; + +function RequestDetailInner({ + requestId: propRequestId, + onBack, + dynamicRequests = [] +}: RequestDetailProps) { + const params = useParams<{ requestId: string }>(); + // Use requestNumber from URL params (which now contains requestNumber), fallback to prop + const requestIdentifier = params.requestId || propRequestId || ''; + + // Read tab from URL query parameter (e.g., ?tab=worknotes) + const urlParams = new URLSearchParams(window.location.search); + const initialTab = urlParams.get('tab') || 'overview'; + + const [activeTab, setActiveTab] = useState(initialTab); + const [apiRequest, setApiRequest] = useState(null); + const [isSpectator, setIsSpectator] = useState(false); + // approving/rejecting local states are managed inside modals now + const [currentApprovalLevel, setCurrentApprovalLevel] = useState(null); + const [showApproveModal, setShowApproveModal] = useState(false); + const [showRejectModal, setShowRejectModal] = useState(false); + const [showAddApproverModal, setShowAddApproverModal] = useState(false); + const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false); + const [showSkipApproverModal, setShowSkipApproverModal] = useState(false); + const [skipApproverData, setSkipApproverData] = useState<{ levelId: string; approverName: string; levelNumber: number } | null>(null); + const [previewDocument, setPreviewDocument] = useState<{ fileName: string; fileType: string; documentId: string; fileSize?: number } | null>(null); + const [uploadingDocument, setUploadingDocument] = useState(false); + const [unreadWorkNotes, setUnreadWorkNotes] = useState(0); + const [mergedMessages, setMergedMessages] = useState([]); + const [workNoteAttachments, setWorkNoteAttachments] = useState([]); + const [refreshing, setRefreshing] = useState(false); + const [showActionStatusModal, setShowActionStatusModal] = useState(false); + const [actionStatus, setActionStatus] = useState<{ success: boolean; title: string; message: string } | null>(null); + const [conclusionLoading, setConclusionLoading] = useState(false); + const [conclusionRemark, setConclusionRemark] = useState(''); + const [conclusionSubmitting, setConclusionSubmitting] = useState(false); + const [aiGenerated, setAiGenerated] = useState(false); + const { user } = useAuth(); + + // Auto-switch tab when URL query parameter changes (e.g., from notifications) + useEffect(() => { + const urlParams = new URLSearchParams(window.location.search); + const tabParam = urlParams.get('tab'); + if (tabParam) { + console.log('[RequestDetail] Auto-switching to tab:', tabParam); + setActiveTab(tabParam); + } + }, [requestIdentifier]); // Re-run when navigating to different request + + // Shared refresh routine + const refreshDetails = async () => { + setRefreshing(true); + try { + const details = await workflowApi.getWorkflowDetails(requestIdentifier); + if (!details) { + console.warn('[RequestDetail] No details returned from API'); + return; + } + const wf = details.workflow || {}; + const approvals = Array.isArray(details.approvals) ? details.approvals : []; + const participants = Array.isArray(details.participants) ? details.participants : []; + const documents = Array.isArray(details.documents) ? details.documents : []; + const summary = details.summary || {}; + const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : []; + + // Debug: Log TAT alerts to console + if (tatAlerts.length > 0) { + console.log(`[RequestDetail] Found ${tatAlerts.length} TAT alerts for request:`, tatAlerts); + } else { + console.log('[RequestDetail] No TAT alerts found for this request'); + } + + const toInitials = (name?: string, email?: string) => { + const base = (name || email || 'NA').toString(); + return base.split(' ').map(s => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(); + }; + + const statusMap = (s: string) => { + const val = (s || '').toUpperCase(); + if (val === 'IN_PROGRESS') return 'in-review'; + if (val === 'PENDING') return 'pending'; + if (val === 'APPROVED') return 'approved'; + if (val === 'REJECTED') return 'rejected'; + if (val === 'CLOSED') return 'closed'; + if (val === 'SKIPPED') return 'skipped'; + return (s || '').toLowerCase(); + }; + + const currentLevel = summary?.currentLevel || wf.currentLevel || 1; + + const approvalFlow = approvals.map((a: any) => { + const levelNumber = a.levelNumber || 0; + const levelStatus = (a.status || '').toString().toUpperCase(); + const levelId = a.levelId || a.level_id; + + // Determine display status based on level and current status + let displayStatus = statusMap(a.status); + + // If level hasn't been reached yet and status is not completed/rejected + if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') { + displayStatus = 'waiting'; + } + // If it's the current level and status is PENDING + else if (levelNumber === currentLevel && levelStatus === 'PENDING') { + displayStatus = 'pending'; + } + + // Get TAT alerts for this level + const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId); + + // Debug log + if (levelAlerts.length > 0) { + console.log(`[RequestDetail] Level ${levelNumber} (${levelId}) has ${levelAlerts.length} TAT alerts:`, levelAlerts); + } + + return { + step: levelNumber, + levelId, + role: a.levelName || a.approverName || 'Approver', + status: displayStatus, + approver: a.approverName || a.approverEmail, + approverId: a.approverId || a.approver_id, + approverEmail: a.approverEmail, + tatHours: Number(a.tatHours || 0), + elapsedHours: Number(a.elapsedHours || 0), + remainingHours: Number(a.remainingHours || 0), + tatPercentageUsed: Number(a.tatPercentageUsed || 0), + actualHours: a.levelEndTime && a.levelStartTime ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) : undefined, + comment: a.comments || undefined, + timestamp: a.actionDate || undefined, + levelStartTime: a.levelStartTime || a.tatStartTime, + tatAlerts: levelAlerts, + skipReason: a.skipReason || undefined, + isSkipped: levelStatus === 'SKIPPED' || a.isSkipped || false, + }; + }); + + // Map spectators + const spectators = participants + .filter((p: any) => (p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR') + .map((p: any) => ({ + name: p.userName || p.user_name || p.userEmail || p.user_email, + role: 'Spectator', + email: p.userEmail || p.user_email, + avatar: toInitials(p.userName || p.user_name, p.userEmail || p.user_email), + })); + + const participantNameById = (uid?: string) => { + if (!uid) return undefined; + const p = participants.find((x: any) => x.userId === uid || x.user_id === uid); + if (p?.userName || p?.user_name) return p.userName || p.user_name; + if (wf.initiatorId === uid) return wf.initiator?.displayName || wf.initiator?.email; + return uid; + }; + + const mappedDocuments = documents.map((d: any) => { + const sizeBytes = Number(d.fileSize || d.file_size || 0); + const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2) + ' MB'; + return { + documentId: d.documentId || d.document_id, + name: d.originalFileName || d.fileName || d.file_name, + fileType: d.fileType || d.file_type || '', + size: sizeMb, + sizeBytes: sizeBytes, + uploadedBy: participantNameById(d.uploadedBy || d.uploaded_by), + uploadedAt: d.uploadedAt || d.uploaded_at, + }; + }); + + // Filter out TAT breach activities as they're not important for activity timeline + const filteredActivities = Array.isArray(details.activities) + ? details.activities.filter((activity: any) => { + const activityType = (activity.type || '').toLowerCase(); + return activityType !== 'sla_warning'; // Exclude TAT breach/warning activities + }) + : []; + + const updatedRequest = { + ...wf, + id: wf.requestNumber || wf.requestId, + requestId: wf.requestId, + requestNumber: wf.requestNumber, + title: wf.title, + description: wf.description, + status: statusMap(wf.status), + priority: (wf.priority || '').toString().toLowerCase(), + approvalFlow, + approvals, + participants, + documents: mappedDocuments, + spectators, + summary, // ← Backend provides SLA in summary.sla + initiator: { + name: wf.initiator?.displayName || wf.initiator?.email, + role: wf.initiator?.designation || undefined, + department: wf.initiator?.department || undefined, + email: wf.initiator?.email || undefined, + phone: wf.initiator?.phone || undefined, + avatar: toInitials(wf.initiator?.displayName, wf.initiator?.email), + }, + createdAt: wf.createdAt, + updatedAt: wf.updatedAt, + totalSteps: wf.totalLevels, + currentStep: summary?.currentLevel || wf.currentLevel, + auditTrail: filteredActivities, + conclusionRemark: wf.conclusionRemark || null, + closureDate: wf.closureDate || null, + }; + setApiRequest(updatedRequest); + + const userEmail = (user as any)?.email?.toLowerCase(); + const newCurrentLevel = approvals.find((a: any) => { + const st = (a.status || '').toString().toUpperCase(); + const approverEmail = (a.approverEmail || '').toLowerCase(); + return (st === 'PENDING' || st === 'IN_PROGRESS') && approverEmail === userEmail; + }); + setCurrentApprovalLevel(newCurrentLevel || null); + + // Update viewer role + const viewerId = (user as any)?.userId; + if (viewerId) { + const isSpec = participants.some((p: any) => (p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' && (p.userId || p.user_id) === viewerId); + setIsSpectator(isSpec); + } else { + setIsSpectator(false); + } + } catch (error) { + console.error('[RequestDetail] Error refreshing details:', error); + alert('Failed to refresh request details. Please try again.'); + } finally { + setRefreshing(false); + } + }; + + const handleRefresh = () => { + refreshDetails(); + }; + + const fetchExistingConclusion = async () => { + try { + const { getConclusion } = await import('@/services/conclusionApi'); + const result = await getConclusion(request.requestId || requestIdentifier); + if (result && result.aiGeneratedRemark) { + setConclusionRemark(result.finalRemark || result.aiGeneratedRemark); + setAiGenerated(!!result.aiGeneratedRemark); + } + } catch (err) { + // No conclusion yet - that's okay + console.log('[RequestDetail] No existing conclusion found'); + } + }; + + const handleGenerateConclusion = async () => { + try { + setConclusionLoading(true); + const { generateConclusion } = await import('@/services/conclusionApi'); + const result = await generateConclusion(request.requestId || requestIdentifier); + setConclusionRemark(result.aiGeneratedRemark); + setAiGenerated(true); + } catch (err) { + // Silently fail - user can write manually + setConclusionRemark(''); + setAiGenerated(false); + } finally { + setConclusionLoading(false); + } + }; + + const handleFinalizeConclusion = async () => { + if (!conclusionRemark.trim()) { + setActionStatus({ + success: false, + title: 'Validation Error', + message: 'Conclusion remark cannot be empty' + }); + setShowActionStatusModal(true); + return; + } + + try { + setConclusionSubmitting(true); + const { finalizeConclusion } = await import('@/services/conclusionApi'); + await finalizeConclusion(request.requestId || requestIdentifier, conclusionRemark); + + setActionStatus({ + success: true, + title: 'Request Closed with Successful Completion', + message: 'The request has been finalized and moved to Closed Requests.' + }); + setShowActionStatusModal(true); + + // Refresh to get updated status + await refreshDetails(); + + // Navigate to Closed Requests after a short delay (for user to see the success message) + setTimeout(() => { + // Use onBack if provided, otherwise navigate programmatically + if (onBack) { + onBack(); + // After going back, trigger navigation to closed requests + setTimeout(() => { + window.location.hash = '#/closed-requests'; + }, 100); + } else { + window.location.hash = '#/closed-requests'; + } + }, 2000); // 2 second delay to show success message + } catch (err: any) { + setActionStatus({ + success: false, + title: 'Error', + message: err.response?.data?.error || 'Failed to finalize conclusion' + }); + setShowActionStatusModal(true); + } finally { + setConclusionSubmitting(false); + } + }; + + // Work notes load + + // Approve modal onConfirm + async function handleApproveConfirm(description: string) { + const levelId = currentApprovalLevel?.levelId || currentApprovalLevel?.level_id; + if (!levelId) { alert('Approval level not found'); return; } + + await approveLevel(requestIdentifier, levelId, description || ''); + await refreshDetails(); + // Close modal + notify (assumes global handlers, replace as needed) + (window as any)?.closeModal?.(); + (window as any)?.toast?.('Approved successfully'); + } + + // Reject modal onConfirm (UI uses only comments/remarks; map it to both fields) + async function handleRejectConfirm(description: string) { + if (!description?.trim()) { alert('Comments & remarks are required'); return; } + + const levelId = currentApprovalLevel?.levelId || currentApprovalLevel?.level_id; + if (!levelId) { alert('Approval level not found'); return; } + + await rejectLevel(requestIdentifier, levelId, description.trim(), description.trim()); + await refreshDetails(); + // Close modal + notify (assumes global handlers, replace as needed) + (window as any)?.closeModal?.(); + (window as any)?.toast?.('Rejected successfully'); + } + + // Add approver modal handler (enhanced with level and TAT) + async function handleAddApprover(email: string, tatHours: number, level: number) { + try { + await addApproverAtLevel(requestIdentifier, email, tatHours, level); + await refreshDetails(); + setShowAddApproverModal(false); + setActionStatus({ + success: true, + title: 'Approver Added', + message: `Approver added successfully at Level ${level} with ${tatHours}h TAT` + }); + setShowActionStatusModal(true); + } catch (error: any) { + setActionStatus({ + success: false, + title: 'Failed to Add Approver', + message: error?.response?.data?.error || 'Failed to add approver. Please try again.' + }); + setShowActionStatusModal(true); + throw error; + } + } + + // Skip approver handler + async function handleSkipApprover(reason: string) { + if (!skipApproverData) return; + + try { + await skipApprover(requestIdentifier, skipApproverData.levelId, reason); + await refreshDetails(); + setShowSkipApproverModal(false); + setSkipApproverData(null); + setActionStatus({ + success: true, + title: 'Approver Skipped', + message: 'Approver skipped successfully. The workflow has moved to the next level.' + }); + setShowActionStatusModal(true); + } catch (error: any) { + setActionStatus({ + success: false, + title: 'Failed to Skip Approver', + message: error?.response?.data?.error || 'Failed to skip approver. Please try again.' + }); + setShowActionStatusModal(true); + throw error; + } + } + + // Add spectator modal handler + async function handleAddSpectator(email: string) { + try { + await addSpectator(requestIdentifier, email); + await refreshDetails(); + setShowAddSpectatorModal(false); + setActionStatus({ + success: true, + title: 'Spectator Added', + message: 'Spectator added successfully. They can now view this request.' + }); + setShowActionStatusModal(true); + } catch (error: any) { + setActionStatus({ + success: false, + title: 'Failed to Add Spectator', + message: error?.response?.data?.error || 'Failed to add spectator. Please try again.' + }); + setShowActionStatusModal(true); + throw error; + } + } + + // Document upload handler + const handleDocumentUpload = async (event: React.ChangeEvent) => { + const files = event.target.files; + if (!files || files.length === 0) return; + + setUploadingDocument(true); + try { + const file = files[0]; + if (!file) { + alert('No file selected'); + return; + } + + // Get UUID requestId (not request number) from current request + const requestId = apiRequest?.requestId; + if (!requestId) { + alert('Request ID not found'); + return; + } + + await uploadDocument(file, requestId, 'SUPPORTING'); + + // Refresh the details to show new document and activity + await refreshDetails(); + + alert('Document uploaded successfully'); + } catch (error: any) { + console.error('Upload error:', error); + alert(error?.response?.data?.error || 'Failed to upload document'); + } finally { + setUploadingDocument(false); + // Clear the input + if (event.target) { + event.target.value = ''; + } + } + }; + + // Trigger file input + const triggerFileInput = () => { + const input = document.createElement('input'); + input.type = 'file'; + input.accept = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.gif'; + input.onchange = handleDocumentUpload as any; + input.click(); + }; + + // Establish socket connection and join request room when component mounts + useEffect(() => { + if (!requestIdentifier) { + console.warn('[RequestDetail] No requestIdentifier, cannot join socket room'); + return; + } + + console.log('[RequestDetail] Initializing socket connection for:', requestIdentifier); + + let mounted = true; + let actualRequestId = requestIdentifier; + + (async () => { + try { + // Fetch UUID if we have request number + const details = await workflowApi.getWorkflowDetails(requestIdentifier); + if (details?.workflow?.requestId && mounted) { + actualRequestId = details.workflow.requestId; // Use UUID for room + console.log('[RequestDetail] Resolved UUID:', actualRequestId); + } + } catch (error) { + console.error('[RequestDetail] Failed to resolve UUID:', error); + } + + if (!mounted) return; + + // Get socket instance with base URL + const baseUrl = import.meta.env.VITE_API_BASE_URL?.replace('/api/v1', '') || 'http://localhost:5000'; + const socket = getSocket(baseUrl); + + if (!socket) { + console.error('[RequestDetail] Socket not available after getSocket()'); + return; + } + + console.log('[RequestDetail] Socket instance obtained, connected:', socket.connected); + + const userId = (user as any)?.userId; + console.log('[RequestDetail] Current userId:', userId); + + // Wait for socket to connect before joining room + const handleConnect = () => { + console.log('[RequestDetail] Socket connected, joining room with UUID:', actualRequestId); + joinRequestRoom(socket, actualRequestId, userId); + console.log(`[RequestDetail] βœ… Joined request room: ${actualRequestId} - User is now ONLINE`); + }; + + // If already connected, join immediately + if (socket.connected) { + console.log('[RequestDetail] Socket already connected, joining immediately'); + handleConnect(); + } else { + console.log('[RequestDetail] Socket not connected yet, waiting for connect event'); + socket.on('connect', handleConnect); + } + + // Cleanup - only runs when component unmounts or requestIdentifier changes + return () => { + if (mounted) { + console.log('[RequestDetail] Cleaning up socket listeners and leaving room'); + socket.off('connect', handleConnect); + leaveRequestRoom(socket, actualRequestId); + console.log(`[RequestDetail] βœ… Left request room: ${actualRequestId} - User is now OFFLINE`); + } + }; + })(); + + return () => { + mounted = false; + }; + }, [requestIdentifier, user]); + + // Fetch and merge work notes with activities + useEffect(() => { + if (!requestIdentifier || !apiRequest) return; + + (async () => { + try { + const workNotes = await getWorkNotes(requestIdentifier); + const activities = apiRequest.auditTrail || []; + + // Merge work notes and activities + const merged = [...workNotes, ...activities]; + + // Sort by timestamp + merged.sort((a, b) => { + const timeA = new Date(a.createdAt || a.created_at || a.timestamp || 0).getTime(); + const timeB = new Date(b.createdAt || b.created_at || b.timestamp || 0).getTime(); + return timeA - timeB; + }); + + setMergedMessages(merged); + console.log(`[RequestDetail] Merged ${workNotes.length} work notes with ${activities.length} activities`); + } catch (error) { + console.error('[RequestDetail] Failed to fetch and merge messages:', error); + } + })(); + }, [requestIdentifier, apiRequest]); + + // Separate effect to listen for new work notes and update badge + useEffect(() => { + if (!requestIdentifier) return; + + const baseUrl = import.meta.env.VITE_API_BASE_URL?.replace('/api/v1', '') || 'http://localhost:5000'; + const socket = getSocket(baseUrl); + + if (!socket) return; + + // Listen for new work notes to update badge count when not on work notes tab + const handleNewWorkNote = (data: any) => { + console.log(`[RequestDetail] New work note received:`, data); + // Only increment badge if not currently viewing work notes tab + if (activeTab !== 'worknotes') { + setUnreadWorkNotes(prev => prev + 1); + } + + // Refresh merged messages when new work note arrives + (async () => { + try { + const workNotes = await getWorkNotes(requestIdentifier); + const activities = apiRequest?.auditTrail || []; + const merged = [...workNotes, ...activities].sort((a, b) => { + const timeA = new Date(a.createdAt || a.created_at || a.timestamp || 0).getTime(); + const timeB = new Date(b.createdAt || b.created_at || b.timestamp || 0).getTime(); + return timeA - timeB; + }); + setMergedMessages(merged); + } catch (error) { + console.error('[RequestDetail] Failed to refresh messages:', error); + } + })(); + }; + + socket.on('noteHandler', handleNewWorkNote); + socket.on('worknote:new', handleNewWorkNote); // Also listen to worknote:new + + // Listen for real-time TAT alerts + const handleTatAlert = (data: any) => { + console.log(`[RequestDetail] πŸ”” Real-time TAT alert received:`, data); + + // Show visual feedback (you can replace with a toast notification) + const alertEmoji = data.type === 'breach' ? '⏰' : data.type === 'threshold2' ? '⚠️' : '⏳'; + console.log(`%c${alertEmoji} TAT Alert: ${data.message}`, 'color: #ff6600; font-size: 14px; font-weight: bold;'); + + // Refresh the request to get updated TAT alerts + (async () => { + try { + const details = await workflowApi.getWorkflowDetails(requestIdentifier); + + if (details) { + setApiRequest(details); + + // Update approval steps with new TAT alerts + const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : []; + console.log(`[RequestDetail] Refreshed TAT alerts after real-time update:`, tatAlerts); + + // Optional: Show browser notification if user granted permission + if ('Notification' in window && Notification.permission === 'granted') { + new Notification(`${alertEmoji} TAT Alert`, { + body: data.message, + icon: '/favicon.ico', + tag: `tat-${data.requestId}-${data.type}`, + requireInteraction: false + }); + } + } + } catch (error) { + console.error('[RequestDetail] Failed to refresh after TAT alert:', error); + } + })(); + }; + + socket.on('tat:alert', handleTatAlert); + + // Cleanup + return () => { + socket.off('noteHandler', handleNewWorkNote); + socket.off('worknote:new', handleNewWorkNote); + socket.off('tat:alert', handleTatAlert); + }; + }, [requestIdentifier, activeTab, apiRequest]); + + // Clear unread count when switching to work notes tab + useEffect(() => { + if (activeTab === 'worknotes') { + setUnreadWorkNotes(0); + } + }, [activeTab]); + + useEffect(() => { + let mounted = true; + (async () => { + try { + + // Use requestIdentifier (which should now be requestNumber) for API call + const details = await workflowApi.getWorkflowDetails(requestIdentifier); + if (!mounted || !details) return; + const wf = details.workflow || {}; + const approvals = Array.isArray(details.approvals) ? details.approvals : []; + const participants = Array.isArray(details.participants) ? details.participants : []; + const documents = Array.isArray(details.documents) ? details.documents : []; + const summary = details.summary || {}; + const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : []; + + // Debug: Log TAT alerts to console + console.log('[RequestDetail] TAT Alerts received from API:', tatAlerts.length, tatAlerts); + + // Map to UI shape without changing UI + const toInitials = (name?: string, email?: string) => { + const base = (name || email || 'NA').toString(); + return base.split(' ').map(s => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(); + }; + + const statusMap = (s: string) => { + const val = (s || '').toUpperCase(); + if (val === 'IN_PROGRESS') return 'in-review'; + if (val === 'PENDING') return 'pending'; + if (val === 'APPROVED') return 'approved'; + if (val === 'REJECTED') return 'rejected'; + if (val === 'CLOSED') return 'closed'; + return (s || '').toLowerCase(); + }; + + const priority = (wf.priority || '').toString().toLowerCase(); + const currentLevel = summary?.currentLevel || wf.currentLevel || 1; + + const approvalFlow = approvals.map((a: any) => { + const levelNumber = a.levelNumber || 0; + const levelStatus = (a.status || '').toString().toUpperCase(); + const levelId = a.levelId || a.level_id; + + // Determine display status based on level and current status + let displayStatus = statusMap(a.status); + + // If level hasn't been reached yet and status is not completed/rejected + if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') { + displayStatus = 'waiting'; + } + // If it's the current level and status is PENDING + else if (levelNumber === currentLevel && levelStatus === 'PENDING') { + displayStatus = 'pending'; + } + + // Get TAT alerts for this level + const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId); + + // Debug log + if (levelAlerts.length > 0) { + console.log(`[RequestDetail useEffect] Level ${levelNumber} (${levelId}) has ${levelAlerts.length} TAT alerts:`, levelAlerts); + } + + return { + step: levelNumber, + levelId, + role: a.levelName || a.approverName || 'Approver', + status: displayStatus, + approver: a.approverName || a.approverEmail, + approverId: a.approverId || a.approver_id, + approverEmail: a.approverEmail, + tatHours: Number(a.tatHours || 0), + elapsedHours: Number(a.elapsedHours || 0), + remainingHours: Number(a.remainingHours || 0), + tatPercentageUsed: Number(a.tatPercentageUsed || 0), + actualHours: a.levelEndTime && a.levelStartTime ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) : undefined, + comment: a.comments || undefined, + timestamp: a.actionDate || undefined, + levelStartTime: a.levelStartTime || a.tatStartTime, + tatAlerts: levelAlerts, + }; + }); + + // Find current approval level for logged-in user + const userEmail = (user as any)?.email?.toLowerCase(); + const userCurrentLevel = approvals.find((a: any) => { + const status = (a.status || '').toString().toUpperCase(); + const approverEmail = (a.approverEmail || '').toLowerCase(); + return (status === 'PENDING' || status === 'IN_PROGRESS') && + approverEmail === userEmail; + }); + setCurrentApprovalLevel(userCurrentLevel || null); + + const spectators = participants + .filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR') + .map((p: any) => ({ + name: p.userName || p.userEmail, + role: 'Spectator', + avatar: toInitials(p.userName, p.userEmail), + })); + + const participantNameById = (uid?: string) => { + if (!uid) return undefined; + const p = participants.find((x: any) => x.userId === uid); + if (p?.userName) return p.userName; + if (wf.initiatorId === uid) return wf.initiator?.displayName || wf.initiator?.email; + return uid; + }; + + const mappedDocuments = documents.map((d: any) => { + const sizeBytes = Number(d.fileSize || 0); + const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2) + ' MB'; + return { + documentId: d.documentId || d.document_id, + name: d.originalFileName || d.fileName, + fileType: d.fileType || d.file_type || '', + size: sizeMb, + sizeBytes: sizeBytes, + uploadedBy: participantNameById(d.uploadedBy), + uploadedAt: d.uploadedAt, + }; + }); + + // Filter out TAT breach activities as they're not important for activity timeline + const filteredActivities = Array.isArray(details.activities) + ? details.activities.filter((activity: any) => { + const activityType = (activity.type || '').toLowerCase(); + return activityType !== 'sla_warning'; // Exclude TAT breach/warning activities + }) + : []; + + const mapped = { + id: wf.requestNumber || wf.requestId, + requestId: wf.requestId, // ← UUID for API calls + title: wf.title, + description: wf.description, + priority, + status: statusMap(wf.status), + summary, // ← Backend provides comprehensive SLA in summary.sla + initiator: { + name: wf.initiator?.displayName || wf.initiator?.email, + role: wf.initiator?.designation || undefined, + department: wf.initiator?.department || undefined, + email: wf.initiator?.email || undefined, + phone: wf.initiator?.phone || undefined, + avatar: toInitials(wf.initiator?.displayName, wf.initiator?.email), + }, + createdAt: wf.createdAt, + updatedAt: wf.updatedAt, + totalSteps: wf.totalLevels, + currentStep: summary?.currentLevel || wf.currentLevel, + approvalFlow, + approvals, // ← Added: Include raw approvals array with levelStartTime/tatStartTime + documents: mappedDocuments, + spectators, + auditTrail: filteredActivities, + conclusionRemark: wf.conclusionRemark || null, + closureDate: wf.closureDate || null, + }; + setApiRequest(mapped); + // Determine viewer role (spectator only means comment-only) + const viewerId = (user as any)?.userId; + if (viewerId) { + const isSpec = participants.some((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId); + setIsSpectator(isSpec); + } else { + setIsSpectator(false); + } + } catch (error) { + console.error('[RequestDetail] Error loading request details:', error); + if (mounted) { + // Set a minimal request object to prevent complete failure + setApiRequest(null); + } + } finally { + + } + })(); + return () => { mounted = false; }; + }, [requestIdentifier]); + + // Get request from any database or dynamic requests + const request = useMemo(() => { + if (apiRequest) return apiRequest; + // First check custom request database (by requestNumber or requestId) + const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier]; + if (customRequest) return customRequest; + + // Then check claim management database + const claimRequest = CLAIM_MANAGEMENT_DATABASE[requestIdentifier]; + if (claimRequest) return claimRequest; + + // Then check dynamic requests (match by requestNumber or id) + const dynamicRequest = dynamicRequests.find((req: any) => + req.id === requestIdentifier || + req.requestNumber === requestIdentifier || + req.request_number === requestIdentifier + ); + if (dynamicRequest) return dynamicRequest; + + return null; + }, [requestIdentifier, dynamicRequests, apiRequest]); + + // Check if current user is the initiator + const isInitiator = useMemo(() => { + if (!request || !user) return false; + const userEmail = (user as any)?.email?.toLowerCase(); + const initiatorEmail = request.initiator?.email?.toLowerCase(); + return userEmail === initiatorEmail; + }, [request, user]); + + // Fetch existing conclusion when request is approved (generated by final approver) + useEffect(() => { + if (request?.status === 'approved' && isInitiator && !conclusionRemark) { + fetchExistingConclusion(); + } + }, [request?.status, isInitiator]); + + // Get all existing participants for validation + const existingParticipants = useMemo(() => { + if (!request) return []; + + const participants: Array<{ email: string; participantType: string; name?: string }> = []; + + // Add initiator + if (request.initiator?.email) { + participants.push({ + email: request.initiator.email.toLowerCase(), + participantType: 'INITIATOR', + name: request.initiator.name + }); + } + + // Add approvers from approval flow + if (request.approvalFlow && Array.isArray(request.approvalFlow)) { + request.approvalFlow.forEach((approval: any) => { + if (approval.approverEmail) { + participants.push({ + email: approval.approverEmail.toLowerCase(), + participantType: 'APPROVER', + name: approval.approver + }); + } + }); + } + + // Add spectators + if (request.spectators && Array.isArray(request.spectators)) { + request.spectators.forEach((spectator: any) => { + if (spectator.email) { + participants.push({ + email: spectator.email.toLowerCase(), + participantType: 'SPECTATOR', + name: spectator.name + }); + } + }); + } + + // Add from participants array if available + if (request.participants && Array.isArray(request.participants)) { + request.participants.forEach((p: any) => { + const email = (p.userEmail || p.email || '').toLowerCase(); + const participantType = (p.participantType || p.participant_type || '').toUpperCase(); + const name = p.userName || p.user_name || p.name; + + if (email && participantType && !participants.find(x => x.email === email)) { + participants.push({ email, participantType, name }); + } + }); + } + + return participants; + }, [request]); + + // Loading state + if (!request && !apiRequest) { + return ( +
+
+
+

Loading request details...

+
+
+ ); + } + + if (!request) { + return ( +
+
+

Request Not Found

+

The request you're looking for doesn't exist.

+ +
+
+ ); + } + + const priorityConfig = getPriorityConfig(request.priority || 'standard'); + const statusConfig = getStatusConfig(request.status || 'pending'); + + // Check if request is approved and needs closure by initiator + const needsClosure = request.status === 'approved' && isInitiator; + + return ( + <> +
+
+ {/* Header Section */} +
+ {/* Top Header */} +
+
+
+ + +
+
+ +
+ +
+

+ {request.id || 'N/A'} +

+
+ + {priorityConfig.label} + + + {statusConfig.label} + +
+
+
+
+ + +
+ +
+

+ {request.title} +

+
+
+ + {/* SLA Progress Section - Shows OVERALL request SLA from backend */} +
+ {(() => { + const sla = request.summary?.sla || request.sla; + + if (!sla || request.status === 'approved' || request.status === 'rejected' || request.status === 'closed') { + return ( +
+ + + {request.status === 'closed' ? 'πŸ”’ Request Closed' : + request.status === 'approved' ? 'βœ… Request Approved' : + request.status === 'rejected' ? '❌ Request Rejected' : 'SLA Not Available'} + +
+ ); + } + + return ( +
+
+
+ + SLA Progress +
+ + {sla.percentageUsed || 0}% elapsed + +
+ + div]:bg-red-600' : + sla.status === 'critical' ? '[&>div]:bg-orange-600' : + sla.status === 'approaching' ? '[&>div]:bg-yellow-600' : + '[&>div]:bg-green-600' + }`} + /> + +
+ + {sla.elapsedText || `${sla.elapsedHours || 0}h`} elapsed + + + {sla.remainingText || `${sla.remainingHours || 0}h`} remaining + +
+ + {sla.deadline && ( +

+ Due: {new Date(sla.deadline).toLocaleString()} β€’ {sla.percentageUsed || 0}% elapsed +

+ )} + + {sla.status === 'critical' && ( +

⚠️ Approaching Deadline

+ )} + {sla.status === 'breached' && ( +

πŸ”΄ URGENT - Deadline Passed

+ )} +
+ ); + })()} +
+
+ + {/* Tabs */} + + + + + Overview + + + + Workflow + + + + Docs + + + + Activity + + + + Work Notes + {unreadWorkNotes > 0 && ( + + {unreadWorkNotes > 9 ? '9+' : unreadWorkNotes} + + )} + + + + {/* Main Layout - Full width for Work Notes, Grid with sidebar for others */} +
+ {/* Left Column - Tab Content (2/3 width for most tabs, full width for work notes) */} +
+ + {/* Overview Tab */} + +
+ {/* Request Initiator */} + + + + + Request Initiator + + + +
+ + + {request.initiator?.avatar || 'U'} + + +
+

{request.initiator?.name || 'N/A'}

+

{request.initiator?.role || 'N/A'}

+

{request.initiator?.department || 'N/A'}

+ +
+
+ + {request.initiator?.email || 'N/A'} +
+
+ + {request.initiator?.phone || 'N/A'} +
+
+
+
+
+
+ + {/* Request Details */} + + + + + Request Details + + + +
+ +
+

+ {request.description} +

+
+
+ + {/* Additional Details */} + {(request.category || request.subcategory) && ( +
+ {request.category && ( +
+ +

{request.category}

+
+ )} + {request.subcategory && ( +
+ +

{request.subcategory}

+
+ )} +
+ )} + + {request.amount && ( +
+ +

{request.amount}

+
+ )} + +
+
+ +

{formatDateTime(request.createdAt)}

+
+
+ +

{formatDateTime(request.updatedAt)}

+
+
+
+
+ + {/* Claim Management Details - Show only for claim management requests */} + {request.claimDetails && ( + + + + + Claim Management Details + + + +
+
+ +

{request.claimDetails.activityName || 'N/A'}

+
+
+ +

{request.claimDetails.activityType || 'N/A'}

+
+
+ +

{request.claimDetails.location || 'N/A'}

+
+
+ +

{request.claimDetails.activityDate ? formatDateShort(request.claimDetails.activityDate) : 'N/A'}

+
+
+ +

{request.claimDetails.dealerCode || 'N/A'}

+
+
+ +

{request.claimDetails.dealerName || 'N/A'}

+
+
+ + {request.claimDetails.requestDescription && ( +
+ +

+ {request.claimDetails.requestDescription} +

+
+ )} +
+
+ )} + + {/* Read-Only Conclusion Remark - Shows for closed requests in Overview tab only */} + {request.status === 'closed' && request.conclusionRemark && ( + + + + + Conclusion Remark + + + Final summary of this closed request + + + +
+

+ {request.conclusionRemark} +

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

Preparing conclusion remark...

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