1303 lines
58 KiB
TypeScript
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>
|
|
);
|
|
}
|