Re_Figma_Code/src/pages/RequestDetail/RequestDetail.tsx

1303 lines
58 KiB
TypeScript

/**
* RequestDetail Component
*
* Purpose: Display and manage detailed view of a workflow request
*
* Features:
* - View request details and approval workflow
* - Approve/Reject requests (if user is assigned approver)
* - Add/Skip approvers (if user is initiator)
* - Upload and view documents
* - Real-time work notes chat
* - Activity timeline with audit trail
* - TAT/SLA tracking with alerts
* - Conclusion remark generation and finalization
*
* Architecture:
* - Custom hooks for complex logic (useRequestDetails, useRequestSocket, etc.)
* - Reusable components (ApprovalStepCard, DocumentCard, SLAProgressBar)
* - Error boundary for graceful error handling
* - Real-time WebSocket integration
*/
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Component, ErrorInfo, ReactNode } from 'react';
// UI Components
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
// Utility imports
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
import { getPriorityConfig, getStatusConfig, getActionTypeIcon } from '@/utils/requestDetailHelpers.tsx';
// Service imports
import { downloadDocument, getDocumentPreviewUrl } from '@/services/workflowApi';
// Component imports
import { FilePreview } from '@/components/common/FilePreview';
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 { ApprovalStepCard } from '@/components/workflow/ApprovalWorkflow';
import { DocumentCard } from '@/components/workflow/DocumentUpload';
import { SLAProgressBar } from '@/components/sla/SLAProgressBar';
// Context and hooks
import { useAuth } from '@/contexts/AuthContext';
import { useRequestDetails } from '@/hooks/useRequestDetails';
import { useRequestSocket } from '@/hooks/useRequestSocket';
import { useDocumentUpload } from '@/hooks/useDocumentUpload';
import { useConclusionRemark } from '@/hooks/useConclusionRemark';
import { useModalManager } from '@/hooks/useModalManager';
// Icon imports
import {
ArrowLeft,
User,
FileText,
MessageSquare,
CheckCircle,
XCircle,
Eye,
TrendingUp,
RefreshCw,
Activity,
Mail,
Phone,
Upload,
UserPlus,
ClipboardList,
AlertTriangle,
Loader2,
AlertCircle
} from 'lucide-react';
/**
* Error Boundary Component
*
* Purpose: Catch and handle React errors gracefully
*
* Benefits:
* - Prevents entire app crash if RequestDetail has an error
* - Shows user-friendly error message
* - Provides recovery options (reload or go back)
* - Logs errors for debugging
*
* Catches:
* - Runtime errors in component tree
* - Rendering errors
* - Lifecycle method errors
*/
class RequestDetailErrorBoundary extends Component<
{ children: ReactNode },
{ hasError: boolean; error: Error | null }
> {
constructor(props: { children: ReactNode }) {
super(props);
this.state = { hasError: false, error: null };
}
// Static method: Update state when error is caught
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
// Lifecycle: Log error details for debugging
override componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('RequestDetail Error:', error, errorInfo);
}
// Render: Show error UI or normal children
override render() {
if (this.state.hasError) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6">
<div className="max-w-md w-full bg-white rounded-lg shadow-lg p-8 text-center">
<AlertTriangle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-2xl font-bold mb-2">Error Loading Request</h2>
<p className="text-gray-600 mb-4">
{this.state.error?.message || 'An unexpected error occurred'}
</p>
<Button onClick={() => window.location.reload()} className="mr-2">
Reload Page
</Button>
<Button variant="outline" onClick={() => window.history.back()}>
Go Back
</Button>
</div>
</div>
);
}
return this.props.children;
}
}
/**
* RequestDetailProps Interface
*
* Props:
* @param requestId - Request number or UUID to load
* @param onBack - Optional callback for back button navigation
* @param dynamicRequests - Optional array of requests for fallback data
*/
interface RequestDetailProps {
requestId: string;
onBack?: () => void;
dynamicRequests?: any[];
}
/**
* RequestDetailInner Component
*
* Purpose: Main component logic for request detail view
*
* Architecture:
* - Uses custom hooks for complex logic (data fetching, socket, document upload, etc.)
* - Delegates state management to specialized hooks
* - Focuses on UI rendering and user interactions
* - All heavy lifting is done by hooks, keeping this component clean
*/
function RequestDetailInner({
requestId: propRequestId,
onBack,
dynamicRequests = []
}: RequestDetailProps) {
// Route params: Get request identifier from URL
const params = useParams<{ requestId: string }>();
const requestIdentifier = params.requestId || propRequestId || '';
// URL query params: Read initial tab from URL (e.g., ?tab=worknotes)
const urlParams = new URLSearchParams(window.location.search);
const initialTab = urlParams.get('tab') || 'overview';
// State: Currently active tab
const [activeTab, setActiveTab] = useState(initialTab);
// Auth: Get current logged-in user
const { user } = useAuth();
/**
* Custom Hook: useRequestDetails
*
* Handles:
* - Request data fetching and transformation
* - Approval flow mapping with TAT alerts
* - Spectator and participant management
* - Current user's role determination (initiator, approver, spectator)
* - Data refresh functionality
*/
const {
request,
apiRequest,
refreshing,
refreshDetails,
currentApprovalLevel,
isSpectator,
isInitiator,
existingParticipants
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
/**
* Custom Hook: useRequestSocket
*
* Handles:
* - WebSocket connection management
* - Real-time work notes updates
* - TAT alerts listening
* - Merged timeline of work notes and activities
* - Unread work notes badge
*/
const {
mergedMessages,
unreadWorkNotes,
workNoteAttachments,
setWorkNoteAttachments
} = useRequestSocket(requestIdentifier, apiRequest, activeTab, user);
/**
* Custom Hook: useDocumentUpload
*
* Handles:
* - Document upload functionality
* - File input triggering
* - Upload loading state
* - Document preview modal
*/
const {
uploadingDocument,
triggerFileInput,
previewDocument,
setPreviewDocument,
documentPolicy,
documentError,
setDocumentError
} = useDocumentUpload(apiRequest, refreshDetails);
/**
* Custom Hook: useModalManager
*
* Handles:
* - All modal visibility states
* - Approve/Reject/Skip actions
* - Add approver/spectator actions
* - Action status feedback
*/
const {
showApproveModal,
setShowApproveModal,
showRejectModal,
setShowRejectModal,
showAddApproverModal,
setShowAddApproverModal,
showAddSpectatorModal,
setShowAddSpectatorModal,
showSkipApproverModal,
setShowSkipApproverModal,
showActionStatusModal,
setShowActionStatusModal,
skipApproverData,
setSkipApproverData,
actionStatus,
setActionStatus,
handleApproveConfirm,
handleRejectConfirm,
handleAddApprover,
handleSkipApprover,
handleAddSpectator
} = useModalManager(requestIdentifier, currentApprovalLevel, refreshDetails);
/**
* Custom Hook: useConclusionRemark
*
* Handles:
* - AI-generated conclusion fetching
* - Conclusion generation
* - Conclusion finalization and request closure
* - Navigation after closure
*/
const {
conclusionRemark,
setConclusionRemark,
conclusionLoading,
conclusionSubmitting,
aiGenerated,
handleGenerateConclusion,
handleFinalizeConclusion
} = useConclusionRemark(
request,
requestIdentifier,
isInitiator,
refreshDetails,
onBack,
setActionStatus,
setShowActionStatusModal
);
/**
* Effect: Auto-switch tab when URL query parameter changes
*
* Use Case: When user clicks notification, URL includes ?tab=worknotes
* This effect automatically switches to the specified tab
*
* Trigger: When requestIdentifier changes (navigating to different request)
*/
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]);
/**
* Handler: handleRefresh
*
* Purpose: Trigger manual refresh of request data
* Delegates to useRequestDetails hook's refreshDetails function
*/
const handleRefresh = () => {
refreshDetails();
};
/**
* Computed: Get display configuration for priority and status badges
* Uses helper functions from requestDetailHelpers utility
*/
const priorityConfig = getPriorityConfig(request?.priority || 'standard');
const statusConfig = getStatusConfig(request?.status || 'pending');
/**
* Computed: Determine if request needs conclusion from initiator
* TRUE when: Request is approved AND current user is the initiator
* Purpose: Show conclusion remark form to close the request
*/
const needsClosure = request?.status === 'approved' && isInitiator;
/**
* Early return: Show loading state while request data is being fetched
*/
if (!request && !apiRequest) {
return (
<div className="flex items-center justify-center h-screen" data-testid="loading-state">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<p className="text-gray-600">Loading request details...</p>
</div>
</div>
);
}
/**
* Early return: Show error state if request not found
*/
if (!request) {
return (
<div className="flex items-center justify-center h-screen" data-testid="not-found-state">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Request Not Found</h2>
<p className="text-gray-600 mb-4">The request you're looking for doesn't exist.</p>
<Button onClick={onBack}>
<ArrowLeft className="w-4 h-4 mr-2" />
Go Back
</Button>
</div>
</div>
);
}
/**
* Main Render: Request detail UI with tabs
*
* Layout structure:
* - Header: Request ID, title, priority, status, refresh button
* - SLA Progress Bar: Overall request SLA/TAT tracking
* - Tabs: Overview, Workflow, Documents, Activity, Work Notes
* - Sidebar: Quick actions (approve, reject, add participants)
* - Modals: All action modals rendered at bottom
*/
return (
<>
<div className="min-h-screen bg-gray-50" data-testid="request-detail-page">
<div className="max-w-7xl mx-auto">
{/* Header Section: Request ID, title, badges, refresh button */}
<div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header">
{/* Top Header */}
<div className="p-3 sm:p-4 md:p-6 border-b border-gray-300">
<div className="flex items-start sm:items-center justify-between gap-2 sm:gap-4">
<div className="flex items-start sm:items-center gap-2 sm:gap-4 min-w-0 flex-1">
{/* Back Button: Navigate to previous page */}
<Button
variant="ghost"
size="icon"
onClick={onBack}
className="rounded-lg flex-shrink-0 h-8 w-8 sm:h-10 sm:w-10"
data-testid="back-button"
>
<ArrowLeft className="h-4 w-4 sm:h-5 sm:h-5" />
</Button>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-3 min-w-0 flex-1">
{/* File Icon */}
<div className="w-8 h-8 sm:w-10 sm:h-10 rounded-full bg-blue-100 flex items-center justify-center flex-shrink-0">
<FileText className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
</div>
<div className="flex flex-col sm:flex-row sm:items-center gap-2 min-w-0 flex-1">
{/* Request ID/Number */}
<h1 className="text-sm sm:text-base md:text-lg font-bold text-gray-900 truncate" data-testid="request-id">
{request.id || 'N/A'}
</h1>
{/* Priority and Status Badges */}
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2">
<Badge
className={`${priorityConfig.color} rounded-full px-2 sm:px-3 text-xs capitalize shrink-0`}
variant="outline"
data-testid="priority-badge"
>
{priorityConfig.label}
</Badge>
<Badge
className={`${statusConfig.color} rounded-full px-2 sm:px-3 text-xs capitalize shrink-0`}
variant="outline"
data-testid="status-badge"
>
{statusConfig.label}
</Badge>
</div>
</div>
</div>
</div>
{/* Refresh Button: Manually reload request data */}
<Button
variant="outline"
size="sm"
className="gap-1 sm:gap-2 flex-shrink-0 h-8 sm:h-9"
onClick={handleRefresh}
disabled={refreshing}
data-testid="refresh-button"
>
<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>
</div>
{/* Request Title */}
<div className="mt-3 ml-0 sm:ml-14">
<h2 className="text-base sm:text-lg md:text-xl font-semibold text-gray-900 line-clamp-2" data-testid="request-title">
{request.title}
</h2>
</div>
</div>
{/* SLA Progress Section: Shows overall request SLA/TAT from backend */}
<div className="px-3 sm:px-4 md:px-6 py-3 sm:py-4 bg-gradient-to-r from-blue-50 to-indigo-50 border-b border-gray-200" data-testid="sla-section">
<SLAProgressBar
sla={request.summary?.sla || request.sla}
requestStatus={request.status}
testId="request-sla"
/>
</div>
</div>
{/* Tabs: Overview, Workflow, Documents, Activity, Work Notes */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full" data-testid="request-detail-tabs">
<TabsList className="inline-flex h-10 sm:h-11 items-center justify-start rounded-lg bg-gray-100 p-1 text-gray-500 w-full mb-4 sm:mb-6">
<TabsTrigger
value="overview"
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-xs sm:text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm gap-1.5"
data-testid="tab-overview"
>
<ClipboardList className="w-4 h-4" />
<span>Overview</span>
</TabsTrigger>
<TabsTrigger
value="workflow"
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-xs sm:text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm gap-1.5"
data-testid="tab-workflow"
>
<TrendingUp className="w-4 h-4" />
<span>Workflow</span>
</TabsTrigger>
<TabsTrigger
value="documents"
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-xs sm:text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm gap-1.5"
data-testid="tab-documents"
>
<FileText className="w-4 h-4" />
<span>Docs</span>
</TabsTrigger>
<TabsTrigger
value="activity"
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-xs sm:text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm gap-1.5"
data-testid="tab-activity"
>
<Activity className="w-4 h-4" />
<span>Activity</span>
</TabsTrigger>
<TabsTrigger
value="worknotes"
className="inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1.5 text-xs sm:text-sm font-medium ring-offset-white transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm gap-1.5 relative"
data-testid="tab-worknotes"
>
<MessageSquare className="w-4 h-4" />
<span>Work Notes</span>
{/* Unread Work Notes Badge */}
{unreadWorkNotes > 0 && (
<Badge
className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-[10px] flex items-center justify-center p-0"
data-testid="worknotes-unread-badge"
>
{unreadWorkNotes > 9 ? '9+' : unreadWorkNotes}
</Badge>
)}
</TabsTrigger>
</TabsList>
{/* Main Layout: Full width for Work Notes, Grid with sidebar for other tabs */}
<div className={activeTab === 'worknotes' ? '' : 'grid grid-cols-1 lg:grid-cols-3 gap-6'}>
{/* Left Column: Tab content (2/3 width normally, full width for work notes) */}
<div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
{/* Overview Tab: Request initiator, details, and conclusion */}
<TabsContent value="overview" className="mt-0" data-testid="overview-tab-content">
<div className="space-y-4 sm:space-y-6">
{/* Request Initiator Card */}
<Card data-testid="initiator-card">
<CardHeader className="pb-3 sm:pb-4">
<CardTitle className="flex items-center gap-2 text-sm sm:text-base">
<User className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
Request Initiator
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-start gap-3 sm:gap-4">
<Avatar className="h-10 w-10 sm:h-12 sm:w-12 ring-2 ring-white shadow-sm flex-shrink-0">
<AvatarFallback className="bg-gray-700 text-white font-semibold text-sm">
{request.initiator?.avatar || 'U'}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<h3 className="font-semibold text-gray-900 text-sm sm:text-base truncate">{request.initiator?.name || 'N/A'}</h3>
<p className="text-xs sm:text-sm text-gray-600 truncate">{request.initiator?.role || 'N/A'}</p>
<p className="text-xs sm:text-sm text-gray-500 truncate">{request.initiator?.department || 'N/A'}</p>
<div className="mt-2 sm:mt-3 space-y-1.5 sm:space-y-2">
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-600 min-w-0">
<Mail className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span className="truncate">{request.initiator?.email || 'N/A'}</span>
</div>
<div className="flex items-center gap-2 text-xs sm:text-sm text-gray-600">
<Phone className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
<span>{request.initiator?.phone || 'N/A'}</span>
</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Request Details Card */}
<Card data-testid="request-details-card">
<CardHeader className="pb-3 sm:pb-4">
<CardTitle className="flex items-center gap-2 text-sm sm:text-base">
<FileText className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
Request Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div>
<label className="text-xs sm:text-sm font-medium text-gray-700 block mb-2">Description</label>
<div className="bg-gray-50 rounded-lg p-3 sm:p-4 border border-gray-300">
<p className="text-xs sm:text-sm text-gray-700 whitespace-pre-line leading-relaxed break-words">
{request.description}
</p>
</div>
</div>
{/* Additional Details: Category, Subcategory (if applicable) */}
{(request.category || request.subcategory) && (
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-300">
{request.category && (
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Category</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.category}</p>
</div>
)}
{request.subcategory && (
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Subcategory</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.subcategory}</p>
</div>
)}
</div>
)}
{/* Amount (if applicable) */}
{request.amount && (
<div className="pt-4 border-t border-gray-300">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Amount</label>
<p className="text-lg font-bold text-gray-900 mt-1">{request.amount}</p>
</div>
)}
{/* Timestamps: Created and Last Updated */}
<div className="grid grid-cols-2 gap-4 pt-4 border-t border-gray-300">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Created</label>
<p className="text-sm text-gray-900 font-medium mt-1">{formatDateTime(request.createdAt)}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Last Updated</label>
<p className="text-sm text-gray-900 font-medium mt-1">{formatDateTime(request.updatedAt)}</p>
</div>
</div>
</CardContent>
</Card>
{/* Claim Management Details: Show only for claim management requests */}
{request.claimDetails && (
<Card>
<CardHeader className="pb-4">
<CardTitle className="flex items-center gap-2 text-base">
<FileText className="w-5 h-5 text-purple-600" />
Claim Management Details
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Activity Name</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.claimDetails.activityName || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Activity Type</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.claimDetails.activityType || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Location</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.claimDetails.location || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Activity Date</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.claimDetails.activityDate ? formatDateShort(request.claimDetails.activityDate) : 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Dealer Code</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.claimDetails.dealerCode || 'N/A'}</p>
</div>
<div>
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Dealer Name</label>
<p className="text-sm text-gray-900 font-medium mt-1">{request.claimDetails.dealerName || 'N/A'}</p>
</div>
</div>
{request.claimDetails.requestDescription && (
<div className="pt-4 border-t border-gray-300">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Request Description</label>
<p className="text-sm text-gray-700 mt-2 bg-gray-50 p-3 rounded-lg whitespace-pre-line">
{request.claimDetails.requestDescription}
</p>
</div>
)}
</CardContent>
</Card>
)}
{/* Read-Only Conclusion Remark: Shows for closed requests */}
{request.status === 'closed' && request.conclusionRemark && (
<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-base">
<CheckCircle className="w-5 h-5 text-gray-600" />
Conclusion Remark
</CardTitle>
<CardDescription className="mt-1 text-xs sm:text-sm">
Final summary of this closed request
</CardDescription>
</CardHeader>
<CardContent className="pt-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<p className="text-sm text-gray-700 whitespace-pre-line leading-relaxed">
{request.conclusionRemark}
</p>
</div>
{request.closureDate && (
<div className="mt-3 flex items-center justify-between text-xs text-gray-500 border-t border-gray-200 pt-3">
<span>Request closed on {formatDateTime(request.closureDate)}</span>
<span>By {request.initiator?.name || 'Initiator'}</span>
</div>
)}
</CardContent>
</Card>
)}
{/* Conclusion Remark Section: Shows when request is approved (initiator finalizes) */}
{needsClosure && (
<Card data-testid="conclusion-remark-card">
<CardHeader className="bg-gradient-to-r from-green-50 to-emerald-50 border-b border-green-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
<CheckCircle className="w-5 h-5 text-green-600" />
Conclusion Remark - Final Step
</CardTitle>
<CardDescription className="mt-1 text-xs sm:text-sm">
All approvals are complete. Please review and finalize the conclusion to close this request.
</CardDescription>
</div>
{/* AI Generation Button */}
<Button
variant="outline"
size="sm"
onClick={handleGenerateConclusion}
disabled={conclusionLoading}
className="gap-2 shrink-0"
data-testid="generate-ai-conclusion-button"
>
<RefreshCw className={`w-3.5 h-3.5 ${conclusionLoading ? 'animate-spin' : ''}`} />
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button>
</div>
</CardHeader>
<CardContent className="pt-4">
{conclusionLoading ? (
<div className="flex items-center justify-center py-8" data-testid="conclusion-loading">
<div className="text-center">
<Loader2 className="w-8 h-8 text-blue-600 animate-spin mx-auto mb-2" />
<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>
{/* AI Generated Badge */}
{aiGenerated && (
<span className="text-xs text-blue-600" data-testid="ai-generated-label">
✠System-generated suggestion (editable)
</span>
)}
</div>
{/* Conclusion Textarea */}
<Textarea
value={conclusionRemark}
onChange={(e) => setConclusionRemark(e.target.value)}
placeholder="Enter a professional conclusion remark summarizing the request outcome, key decisions, and approvals..."
className="text-sm resize-none"
style={{ height: '160px' }}
maxLength={2000}
data-testid="conclusion-remark-textarea"
/>
<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" data-testid="character-count">
{conclusionRemark.length} / 2000 characters
</p>
</div>
</div>
{/* Info Box: What happens when finalizing */}
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-xs sm:text-sm font-semibold text-blue-900 mb-1.5">Finalizing this request will:</p>
<ul className="text-xs sm:text-sm text-blue-800 space-y-0.5 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>
{/* Finalize Button */}
<div className="flex gap-3 justify-end pt-3 border-t">
<Button
onClick={handleFinalizeConclusion}
disabled={conclusionSubmitting || !conclusionRemark.trim()}
className="bg-green-600 hover:bg-green-700 text-white"
data-testid="finalize-close-button"
>
{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>
</TabsContent>
{/* Workflow Tab: Approval steps with TAT tracking */}
<TabsContent value="workflow" className="mt-0">
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<CardTitle className="flex items-center gap-2 text-sm sm:text-base">
<TrendingUp className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
Approval Workflow
</CardTitle>
<CardDescription className="mt-1 sm:mt-2 text-xs sm:text-sm">
Track the approval progress through each step
</CardDescription>
</div>
{/* Progress Badge: Current step and completion count */}
{request.totalSteps && (() => {
const completedCount = request.approvalFlow?.filter((s: any) => s.status === 'approved').length || 0;
return (
<Badge variant="outline" className="font-medium text-xs sm:text-sm shrink-0">
Step {request.currentStep} of {request.totalSteps} - {completedCount} completed
</Badge>
);
})()}
</div>
</CardHeader>
<CardContent>
{request.approvalFlow && request.approvalFlow.length > 0 ? (
<div className="space-y-4 sm:space-y-6">
{/* Map each approval step to ApprovalStepCard component */}
{request.approvalFlow.map((step: any, index: number) => {
// Get approval details with backend-calculated SLA
const approval = request.approvals?.find((a: any) => a.levelId === step.levelId);
// Check if this approver is the current user
const currentUserEmail = (user as any)?.email?.toLowerCase();
const approverEmail = step.approverEmail?.toLowerCase();
const isCurrentUser = currentUserEmail && approverEmail && currentUserEmail === approverEmail;
return (
<ApprovalStepCard
key={index}
step={step}
index={index}
approval={approval}
isCurrentUser={isCurrentUser}
isInitiator={isInitiator}
onSkipApprover={(data) => {
if (!data.levelId) {
alert('Level ID not available');
return;
}
setSkipApproverData(data);
setShowSkipApproverModal(true);
}}
onRefresh={refreshDetails}
testId="workflow-step"
/>
);
})}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-8" data-testid="no-workflow-steps">No workflow steps defined</p>
)}
</CardContent>
</Card>
</TabsContent>
{/* Documents Tab: Request documents and work note attachments */}
<TabsContent value="documents" className="mt-0">
<div className="space-y-4 sm:space-y-6">
{/* Section 1: Request Documents */}
<Card>
<CardHeader>
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3 sm:gap-4">
<div>
<CardTitle className="flex items-center gap-2 text-sm sm:text-base">
<FileText className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />
Request Documents
</CardTitle>
<CardDescription className="text-xs sm:text-sm mt-1">Documents attached while creating the request</CardDescription>
</div>
{/* Upload Document Button */}
<div className="flex flex-col items-end gap-1">
<Button
size="sm"
onClick={triggerFileInput}
disabled={uploadingDocument || request.status === 'closed'}
className="gap-1 sm:gap-2 h-8 sm:h-9 text-xs sm:text-sm shrink-0"
data-testid="upload-document-btn"
>
<Upload className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
{uploadingDocument ? 'Uploading...' : request.status === 'closed' ? 'Closed' : 'Upload'}
<span className="hidden sm:inline">{request.status === 'closed' ? '' : 'Document'}</span>
</Button>
<p className="text-xs text-gray-500 whitespace-nowrap">
Max {documentPolicy.maxFileSizeMB}MB
</p>
</div>
</div>
</CardHeader>
<CardContent>
{request.documents && request.documents.length > 0 ? (
<div className="space-y-3">
{/* Map documents to DocumentCard component */}
{request.documents.map((doc: any, index: number) => (
<DocumentCard
key={index}
document={doc}
onPreview={(previewDoc) => setPreviewDocument(previewDoc)}
onDownload={downloadDocument}
testId="request-document"
/>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-8" data-testid="no-documents">No documents uploaded yet</p>
)}
</CardContent>
</Card>
{/* Section 2: Work Note Attachments */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm sm:text-base">
<MessageSquare className="w-4 h-4 sm:w-5 sm:h-5 text-purple-600" />
Work Note Attachments
</CardTitle>
<CardDescription className="text-xs sm:text-sm">Files shared in work notes discussions</CardDescription>
</CardHeader>
<CardContent>
{workNoteAttachments && workNoteAttachments.length > 0 ? (
<div className="space-y-3">
{/* Map work note attachments to DocumentCard component */}
{workNoteAttachments.map((file: any, index: number) => (
<DocumentCard
key={file.attachmentId || index}
document={{
documentId: file.attachmentId || '',
name: file.name,
fileType: file.type || '',
size: file.size ? `${(file.size / 1024).toFixed(1)} KB` : 'Unknown size',
sizeBytes: file.size,
uploadedBy: file.uploadedBy,
uploadedAt: file.uploadedAt
}}
onPreview={(previewDoc) => setPreviewDocument(previewDoc)}
onDownload={async (attachmentId) => {
const { downloadWorkNoteAttachment } = require('@/services/workflowApi');
await downloadWorkNoteAttachment(attachmentId);
}}
testId="worknote-attachment"
/>
))}
</div>
) : (
<p className="text-sm text-gray-500 text-center py-8" data-testid="no-attachments">No files shared in work notes yet</p>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Activity Tab: Complete audit trail of all actions */}
<TabsContent value="activity" className="mt-0">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-sm sm:text-base">
<Activity className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600" />
Activity Timeline
</CardTitle>
<CardDescription className="text-xs sm:text-sm">
Complete audit trail of all request activities
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4 sm:space-y-6">
{/* Map activity entries to timeline items */}
{request.auditTrail && request.auditTrail.length > 0 ? request.auditTrail.map((entry: any, index: number) => (
<div key={index} className="flex items-start gap-4" data-testid={`activity-item-${index}`}>
{/* Icon based on activity type */}
<div className="flex-shrink-0">
<div className="w-10 h-10 rounded-full bg-gray-100 flex items-center justify-center">
{getActionTypeIcon(entry.type)}
</div>
</div>
{/* Activity Content */}
<div className="flex-1 min-w-0">
<div className="bg-white rounded-lg border border-gray-200 p-4 shadow-sm">
{/* Header with action title and timestamp */}
<div className="flex items-center justify-between mb-2">
<h4 className="font-semibold text-gray-900">{entry.action}</h4>
<span className="text-xs text-gray-500 whitespace-nowrap ml-4">{formatDateTime(entry.timestamp)}</span>
</div>
{/* Details */}
<div className="text-sm text-gray-600 leading-relaxed">
<p className="whitespace-pre-line break-words">{entry.details}</p>
</div>
</div>
</div>
</div>
)) : (
<div className="text-center py-12" data-testid="no-activity">
<Activity className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<p className="text-sm text-gray-500">No activity recorded yet</p>
<p className="text-xs text-gray-400 mt-2">Actions and updates will appear here</p>
</div>
)}
</div>
</CardContent>
</Card>
</TabsContent>
{/* Work Notes Tab: Real-time chat (Full Width) */}
<TabsContent value="worknotes" className="mt-0" forceMount={true} hidden={activeTab !== 'worknotes'}>
<div className="h-[calc(100vh-300px)] min-h-[600px]">
<WorkNoteChat
requestId={requestIdentifier}
requestTitle={request.title}
skipSocketJoin={true}
messages={mergedMessages}
onAttachmentsExtracted={setWorkNoteAttachments}
isInitiator={isInitiator}
currentLevels={(request.approvalFlow || [])
.filter((flow: any) => flow && typeof flow.step === 'number')
.map((flow: any) => ({
levelNumber: flow.step || 0,
approverName: flow.approver || 'Unknown',
status: flow.status || 'pending',
tatHours: flow.tatHours || 24
}))
}
onAddApprover={handleAddApprover}
/>
</div>
</TabsContent>
</div>
{/* Right Column: Quick Actions Sidebar (1/3 width, hidden for Work Notes) */}
{activeTab !== 'worknotes' && (
<div className="space-y-4 sm:space-y-6">
{/* Quick Actions Card */}
<Card data-testid="quick-actions-card">
<CardHeader className="pb-2">
<CardTitle className="text-sm sm:text-base">Quick Actions</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{/* Add Approver: Only initiator can add (not for closed requests) */}
{isInitiator && request.status !== 'closed' && (
<Button
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"
onClick={() => setShowAddApproverModal(true)}
data-testid="add-approver-button"
>
<UserPlus className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
Add Approver
</Button>
)}
{/* Add Spectator: Non-spectators can add (not for closed requests) */}
{!isSpectator && request.status !== 'closed' && (
<Button
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"
onClick={() => setShowAddSpectatorModal(true)}
data-testid="add-spectator-button"
>
<Eye className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
Add Spectator
</Button>
)}
{/* Approve/Reject Buttons: Only show for assigned approvers */}
<div className="pt-3 sm:pt-4 space-y-2">
{!isSpectator && currentApprovalLevel && (
<>
<Button
className="w-full bg-green-600 hover:bg-green-700 text-white h-9 sm:h-10 text-xs sm:text-sm"
onClick={() => setShowApproveModal(true)}
data-testid="approve-request-button"
>
<CheckCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" />
Approve Request
</Button>
<Button
variant="destructive"
className="w-full h-9 sm:h-10 text-xs sm:text-sm"
onClick={() => setShowRejectModal(true)}
data-testid="reject-request-button"
>
<XCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" />
Reject Request
</Button>
</>
)}
</div>
</CardContent>
</Card>
{/* Spectators Card: List of users with view-only access */}
{request.spectators && request.spectators.length > 0 && (
<Card data-testid="spectators-card">
<CardHeader className="pb-2">
<CardTitle className="text-sm sm:text-base">Spectators</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{request.spectators.map((spectator: any, index: number) => (
<div key={index} className="flex items-center gap-3" data-testid={`spectator-${index}`}>
<Avatar className="h-8 w-8">
<AvatarFallback className="bg-blue-100 text-blue-800 text-xs font-semibold">
{spectator.avatar}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900">{spectator.name}</p>
<p className="text-xs text-gray-500 truncate">{spectator.role}</p>
</div>
</div>
))}
</CardContent>
</Card>
)}
</div>
)}
</div>
</Tabs>
</div>
</div>
{/* Modals: All action modals rendered here */}
{/* Approval Modal */}
<ApprovalModal
open={showApproveModal}
onClose={() => setShowApproveModal(false)}
onConfirm={handleApproveConfirm}
requestIdDisplay={request.id}
requestTitle={request.title}
/>
{/* Rejection Modal */}
<RejectionModal
open={showRejectModal}
onClose={() => setShowRejectModal(false)}
onConfirm={handleRejectConfirm}
requestIdDisplay={request.id}
requestTitle={request.title}
/>
{/* Add Approver Modal */}
<AddApproverModal
open={showAddApproverModal}
onClose={() => setShowAddApproverModal(false)}
onConfirm={handleAddApprover}
requestIdDisplay={request.id}
requestTitle={request.title}
existingParticipants={existingParticipants}
currentLevels={(request.approvalFlow || [])
.filter((flow: any) => flow && typeof flow.step === 'number')
.map((flow: any) => ({
levelNumber: flow.step || 0,
approverName: flow.approver || 'Unknown',
status: flow.status || 'pending',
tatHours: flow.tatHours || 24
}))
}
/>
{/* Add Spectator Modal */}
<AddSpectatorModal
open={showAddSpectatorModal}
onClose={() => setShowAddSpectatorModal(false)}
onConfirm={handleAddSpectator}
requestIdDisplay={request.id}
requestTitle={request.title}
existingParticipants={existingParticipants}
/>
{/* Skip Approver Modal */}
<SkipApproverModal
open={showSkipApproverModal}
onClose={() => {
setShowSkipApproverModal(false);
setSkipApproverData(null);
}}
onConfirm={handleSkipApprover}
approverName={skipApproverData?.approverName}
levelNumber={skipApproverData?.levelNumber}
requestIdDisplay={request.id}
requestTitle={request.title}
/>
{/* File Preview Modal */}
{previewDocument && (
<FilePreview
fileName={previewDocument.fileName}
fileType={previewDocument.fileType}
fileUrl={getDocumentPreviewUrl(previewDocument.documentId)}
fileSize={previewDocument.fileSize}
attachmentId={previewDocument.documentId}
onDownload={downloadDocument}
open={!!previewDocument}
onClose={() => setPreviewDocument(null)}
/>
)}
{/* Action Status Modal: Success/Error feedback */}
{actionStatus && (
<ActionStatusModal
open={showActionStatusModal}
onClose={() => {
setShowActionStatusModal(false);
setActionStatus(null);
}}
success={actionStatus.success}
title={actionStatus.title}
message={actionStatus.message}
/>
)}
{/* Document Validation Error Modal */}
<Dialog open={documentError.show} onOpenChange={(open) => setDocumentError(prev => ({ ...prev, show: open }))}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
Document Upload Policy Violation
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<p className="text-gray-700">
The following file(s) could not be uploaded due to policy violations:
</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{documentError.errors.map((error, index) => (
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="font-medium text-red-900 text-sm">{error.fileName}</p>
<p className="text-xs text-red-700 mt-1">{error.reason}</p>
</div>
))}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-800 font-semibold mb-1">Document Policy:</p>
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
<li>Maximum file size: {documentPolicy.maxFileSizeMB}MB</li>
<li>Allowed file types: {documentPolicy.allowedFileTypes.join(', ')}</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={() => setDocumentError({ show: false, errors: [] })}
className="w-full sm:w-auto"
>
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
/**
* RequestDetail Component (Exported)
*
* Purpose: Wrap RequestDetailInner with Error Boundary
*
* Benefits:
* - Prevents app crash if errors occur
* - Shows graceful error UI
* - Provides error recovery options
*/
export function RequestDetail(props: RequestDetailProps) {
return (
<RequestDetailErrorBoundary>
<RequestDetailInner {...props} />
</RequestDetailErrorBoundary>
);
}