380 lines
16 KiB
TypeScript
380 lines
16 KiB
TypeScript
/**
|
|
* Quick Actions Sidebar Component
|
|
*/
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
|
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react';
|
|
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
|
import { useAuth } from '@/contexts/AuthContext';
|
|
import notificationApi, { type Notification } from '@/services/notificationApi';
|
|
import { ProcessDetailsCard } from '@/dealer-claim/components/request-detail/claim-cards';
|
|
import { isClaimManagementRequest } from '@/utils/claimRequestUtils';
|
|
import { determineUserRole, getRoleBasedVisibility, mapToClaimManagementRequest } from '@/utils/claimDataMapper';
|
|
|
|
interface QuickActionsSidebarProps {
|
|
request: any;
|
|
isInitiator: boolean;
|
|
isSpectator: boolean;
|
|
currentApprovalLevel: any;
|
|
onAddApprover: () => void;
|
|
onAddSpectator: () => void;
|
|
onApprove: () => void;
|
|
onReject: () => void;
|
|
onPause?: () => void;
|
|
onResume?: () => void;
|
|
onRetrigger?: () => void;
|
|
summaryId?: string | null;
|
|
refreshTrigger?: number; // Trigger to refresh shared recipients list
|
|
pausedByUserId?: string; // User ID of the approver who paused (kept for backwards compatibility)
|
|
currentUserId?: string; // Current user's ID (kept for backwards compatibility)
|
|
apiRequest?: any;
|
|
onEditClaimAmount?: () => void;
|
|
}
|
|
|
|
export function QuickActionsSidebar({
|
|
request,
|
|
isInitiator,
|
|
isSpectator,
|
|
currentApprovalLevel,
|
|
onAddApprover,
|
|
onAddSpectator,
|
|
onApprove,
|
|
onReject,
|
|
onPause,
|
|
onResume,
|
|
onRetrigger,
|
|
summaryId,
|
|
refreshTrigger,
|
|
pausedByUserId: pausedByUserIdProp,
|
|
currentUserId: currentUserIdProp,
|
|
apiRequest,
|
|
onEditClaimAmount,
|
|
}: QuickActionsSidebarProps) {
|
|
const { user } = useAuth();
|
|
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
|
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
|
const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false);
|
|
const isClosed = request?.status === 'closed';
|
|
const isPaused = request?.pauseInfo?.isPaused || false;
|
|
const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId;
|
|
const currentUserId = currentUserIdProp || (user as any)?.userId || '';
|
|
|
|
// Both approver AND initiator can pause (when not already paused and not closed)
|
|
const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator);
|
|
|
|
// Resume: Can be done by the person who paused OR by both initiator and approver
|
|
const canResume = isPaused && onResume && (currentApprovalLevel || isInitiator);
|
|
|
|
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
|
|
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
|
|
|
|
// Check for retrigger notification (initiator requested resume)
|
|
// ONLY check when: 1) Request is paused, 2) Current user is an approver
|
|
// This avoids unnecessary API calls for non-paused requests or initiators
|
|
useEffect(() => {
|
|
// Skip check if request is not paused or user is not an approver
|
|
if (!isPaused || !currentApprovalLevel || !request?.requestId) {
|
|
setHasRetriggerNotification(false);
|
|
return;
|
|
}
|
|
|
|
const checkRetriggerNotification = async () => {
|
|
try {
|
|
const response = await notificationApi.list({ page: 1, limit: 50, unreadOnly: true }); // Only unread
|
|
const notifications: Notification[] = response.data?.notifications || [];
|
|
|
|
// Check if there's an UNREAD pause_retrigger_request notification for this request
|
|
const hasRetrigger = notifications.some(
|
|
(notif: Notification) =>
|
|
notif.requestId === request.requestId &&
|
|
notif.notificationType === 'pause_retrigger_request'
|
|
);
|
|
|
|
setHasRetriggerNotification(hasRetrigger);
|
|
} catch (error) {
|
|
console.error('Failed to check retrigger notifications:', error);
|
|
setHasRetriggerNotification(false);
|
|
}
|
|
};
|
|
|
|
checkRetriggerNotification();
|
|
}, [isPaused, currentApprovalLevel, request?.requestId, refreshTrigger]); // Only when paused state or approver status changes
|
|
|
|
// Fetch shared recipients when request is closed and summaryId is available
|
|
useEffect(() => {
|
|
const fetchSharedRecipients = async () => {
|
|
if (!isClosed || !summaryId || !isInitiator) {
|
|
setSharedRecipients([]);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
setLoadingRecipients(true);
|
|
const recipients = await getSharedRecipients(summaryId);
|
|
setSharedRecipients(recipients);
|
|
} catch (error) {
|
|
console.error('Failed to fetch shared recipients:', error);
|
|
setSharedRecipients([]);
|
|
} finally {
|
|
setLoadingRecipients(false);
|
|
}
|
|
};
|
|
|
|
fetchSharedRecipients();
|
|
}, [isClosed, summaryId, isInitiator, refreshTrigger]);
|
|
|
|
// Claim details for sidebar (only for claim management requests)
|
|
const claimSidebarData = useMemo(() => {
|
|
if (!apiRequest || !isClaimManagementRequest(apiRequest)) return null;
|
|
const claimRequest = mapToClaimManagementRequest(apiRequest, currentUserId);
|
|
if (!claimRequest) return null;
|
|
const userRole = determineUserRole(apiRequest, currentUserId);
|
|
const visibility = getRoleBasedVisibility(userRole);
|
|
return { claimRequest, visibility };
|
|
}, [apiRequest, currentUserId]);
|
|
|
|
return (
|
|
<div className="space-y-4 sm:space-y-6">
|
|
{/* Quick Actions Card - Hide entire card for spectators and closed requests */}
|
|
{!isSpectator && request.status !== 'closed' && (
|
|
<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 */}
|
|
{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={onAddApprover}
|
|
data-testid="add-approver-button"
|
|
>
|
|
<UserPlus className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
Add Approver
|
|
</Button>
|
|
)}
|
|
|
|
{/* Add Spectator */}
|
|
{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={onAddSpectator}
|
|
data-testid="add-spectator-button"
|
|
>
|
|
<Eye className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
Add Spectator
|
|
</Button>
|
|
)}
|
|
|
|
{/* Pause/Resume Button */}
|
|
{canPause && onPause && (
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-start gap-2 bg-white text-orange-700 border-orange-300 hover:bg-orange-50 hover:text-orange-900 h-9 sm:h-10 text-xs sm:text-sm"
|
|
onClick={onPause}
|
|
data-testid="pause-workflow-button"
|
|
>
|
|
<Pause className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
Pause Workflow
|
|
</Button>
|
|
)}
|
|
|
|
{canResume && (
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-start gap-2 bg-white text-green-700 border-green-300 hover:bg-green-50 hover:text-green-900 h-9 sm:h-10 text-xs sm:text-sm"
|
|
onClick={onResume}
|
|
data-testid="resume-workflow-button"
|
|
>
|
|
<Play className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
Resume Workflow
|
|
</Button>
|
|
)}
|
|
|
|
{canRetrigger && (
|
|
<Button
|
|
variant="outline"
|
|
className="w-full justify-start gap-2 bg-white text-orange-700 border-orange-300 hover:bg-orange-50 hover:text-orange-900 h-9 sm:h-10 text-xs sm:text-sm"
|
|
onClick={onRetrigger}
|
|
data-testid="request-resume-button"
|
|
>
|
|
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
|
Request Resume
|
|
</Button>
|
|
)}
|
|
|
|
{/* Approve/Reject Buttons */}
|
|
<div className="pt-3 sm:pt-4 space-y-2">
|
|
{currentApprovalLevel && !isPaused && (
|
|
<>
|
|
<Button
|
|
className="w-full bg-green-600 hover:bg-green-700 text-white h-9 sm:h-10 text-xs sm:text-sm"
|
|
onClick={onApprove}
|
|
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={onReject}
|
|
data-testid="reject-request-button"
|
|
>
|
|
<XCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" />
|
|
Reject Request
|
|
</Button>
|
|
</>
|
|
)}
|
|
{isPaused && (
|
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 text-center">
|
|
{/* Different messages based on who paused, who is viewing, and if retrigger was sent */}
|
|
{pausedByUserId === currentUserId ? (
|
|
// User viewing is the one who paused
|
|
<>
|
|
<p className="text-xs text-orange-800 font-medium flex items-center justify-center gap-1.5">
|
|
{hasRetriggerNotification && <AlertCircle className="w-3.5 h-3.5" />}
|
|
{hasRetriggerNotification ? 'Initiator has requested you to resume' : 'You paused this workflow'}
|
|
</p>
|
|
<p className="text-xs text-orange-600 mt-1">
|
|
{hasRetriggerNotification ? 'Please review and resume if appropriate' : 'Click "Resume Workflow" to continue'}
|
|
</p>
|
|
</>
|
|
) : currentApprovalLevel && pausedByUserId !== currentUserId && hasRetriggerNotification ? (
|
|
// Approver viewing, and initiator sent retrigger
|
|
<>
|
|
<p className="text-xs text-orange-800 font-medium flex items-center justify-center gap-1.5">
|
|
<AlertCircle className="w-3.5 h-3.5" />
|
|
Initiator has requested resume
|
|
</p>
|
|
<p className="text-xs text-orange-600 mt-1">Please review and resume if appropriate</p>
|
|
</>
|
|
) : currentApprovalLevel && pausedByUserId !== currentUserId ? (
|
|
// Approver viewing but someone else paused (initiator or another approver)
|
|
<>
|
|
<p className="text-xs text-orange-800 font-medium">Workflow is paused</p>
|
|
<p className="text-xs text-orange-600 mt-1">You can resume to continue approval</p>
|
|
</>
|
|
) : isInitiator && pausedByUserId && pausedByUserId !== currentUserId ? (
|
|
// Initiator viewing, approver paused
|
|
<>
|
|
<p className="text-xs text-orange-800 font-medium">Approver has paused this workflow</p>
|
|
<p className="text-xs text-orange-600 mt-1">
|
|
{canRetrigger ? 'Click "Request Resume" to notify approver' : 'Resume request sent - Waiting for approver'}
|
|
</p>
|
|
</>
|
|
) : (
|
|
// Default message
|
|
<>
|
|
<p className="text-xs text-orange-800 font-medium">Workflow is paused</p>
|
|
<p className="text-xs text-orange-600 mt-1">Actions are disabled until resumed</p>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Spectators Card */}
|
|
<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 && request.spectators.length > 0 ? (
|
|
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>
|
|
))
|
|
) : (
|
|
<div className="py-4 text-center">
|
|
<p className="text-sm text-gray-500">No spectators added</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Shared Recipients Card - Only for closed requests */}
|
|
{isClosed && isInitiator && (
|
|
<Card data-testid="shared-recipients-card">
|
|
<CardHeader className="pb-2">
|
|
<CardTitle className="text-sm sm:text-base flex items-center gap-2">
|
|
<Share2 className="w-4 h-4" />
|
|
Summary Shared With
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{loadingRecipients ? (
|
|
<div className="py-4 text-center">
|
|
<p className="text-sm text-gray-500">Loading...</p>
|
|
</div>
|
|
) : sharedRecipients.length > 0 ? (
|
|
sharedRecipients.map((recipient, index) => {
|
|
const avatar = (recipient.displayName || 'NA')
|
|
.split(' ')
|
|
.map((s: string) => s[0])
|
|
.join('')
|
|
.slice(0, 2)
|
|
.toUpperCase();
|
|
|
|
return (
|
|
<div key={recipient.userId || index} className="flex items-center gap-3" data-testid={`shared-recipient-${index}`}>
|
|
<Avatar className="h-8 w-8">
|
|
<AvatarFallback className="bg-green-100 text-green-800 text-xs font-semibold">
|
|
{avatar}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1 min-w-0">
|
|
<p className="text-sm font-medium text-gray-900">{recipient.displayName}</p>
|
|
<p className="text-xs text-gray-500 truncate">{recipient.email}</p>
|
|
{recipient.isRead && (
|
|
<p className="text-xs text-green-600 mt-0.5">Viewed</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
) : (
|
|
<div className="py-4 text-center">
|
|
<p className="text-sm text-gray-500">Summary not shared yet</p>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Process details anchored at the bottom of the action sidebar for claim workflows */}
|
|
{claimSidebarData && (
|
|
<ProcessDetailsCard
|
|
ioDetails={claimSidebarData.claimRequest.ioDetails}
|
|
dmsDetails={claimSidebarData.claimRequest.dmsDetails}
|
|
claimAmount={{
|
|
amount: claimSidebarData.claimRequest.claimAmount.closed || claimSidebarData.claimRequest.claimAmount.estimated || 0,
|
|
}}
|
|
estimatedBudgetBreakdown={claimSidebarData.claimRequest.proposalDetails?.costBreakup}
|
|
closedExpensesBreakdown={claimSidebarData.claimRequest.activityInfo?.closedExpensesBreakdown}
|
|
visibility={claimSidebarData.visibility}
|
|
onEditClaimAmount={onEditClaimAmount}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|