676 lines
31 KiB
TypeScript
676 lines
31 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Progress } from '@/components/ui/progress';
|
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { Textarea } from '@/components/ui/textarea';
|
|
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon, FileEdit } from 'lucide-react';
|
|
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
|
import { formatHoursMinutes } from '@/utils/slaTracker';
|
|
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
|
import { updateBreachReason as updateBreachReasonApi } from '@/services/workflowApi';
|
|
import { toast } from 'sonner';
|
|
|
|
export interface ApprovalStep {
|
|
step: number;
|
|
levelId: string;
|
|
role: string;
|
|
status: string;
|
|
approver: string;
|
|
approverId?: string;
|
|
approverEmail?: string;
|
|
tatHours: number;
|
|
elapsedHours?: number;
|
|
remainingHours?: number;
|
|
tatPercentageUsed?: number;
|
|
actualHours?: number;
|
|
comment?: string;
|
|
timestamp?: string;
|
|
levelStartTime?: string;
|
|
tatAlerts?: any[];
|
|
skipReason?: string;
|
|
isSkipped?: boolean;
|
|
}
|
|
|
|
interface ApprovalStepCardProps {
|
|
step: ApprovalStep;
|
|
index: number;
|
|
approval?: any; // Raw approval data from backend
|
|
isCurrentUser?: boolean;
|
|
isInitiator?: boolean;
|
|
onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void;
|
|
onRefresh?: () => void | Promise<void>; // Optional callback to refresh data
|
|
testId?: string;
|
|
}
|
|
|
|
// Helper function to format working hours as days (8 hours = 1 working day)
|
|
const formatWorkingHours = (hours: number): string => {
|
|
const WORKING_HOURS_PER_DAY = 8;
|
|
if (hours < WORKING_HOURS_PER_DAY) {
|
|
return formatHoursMinutes(hours);
|
|
}
|
|
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
|
|
const remainingHours = hours % WORKING_HOURS_PER_DAY;
|
|
if (remainingHours > 0) {
|
|
return `${days}d ${formatHoursMinutes(remainingHours)}`;
|
|
}
|
|
return `${days}d`;
|
|
};
|
|
|
|
const getStepIcon = (status: string, isSkipped?: boolean) => {
|
|
if (isSkipped) return <AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600" />;
|
|
|
|
switch (status) {
|
|
case 'approved':
|
|
return <CheckCircle className="w-4 h-4 sm:w-5 sm:w-5 text-green-600" />;
|
|
case 'rejected':
|
|
return <XCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600" />;
|
|
case 'pending':
|
|
case 'in-review':
|
|
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />;
|
|
case 'waiting':
|
|
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400" />;
|
|
default:
|
|
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400" />;
|
|
}
|
|
};
|
|
|
|
export function ApprovalStepCard({
|
|
step,
|
|
index,
|
|
approval,
|
|
isCurrentUser = false,
|
|
isInitiator = false,
|
|
onSkipApprover,
|
|
onRefresh,
|
|
testId = 'approval-step'
|
|
}: ApprovalStepCardProps) {
|
|
const { user } = useAuth();
|
|
const [showBreachReasonModal, setShowBreachReasonModal] = useState(false);
|
|
const [breachReason, setBreachReason] = useState('');
|
|
const [savingReason, setSavingReason] = useState(false);
|
|
|
|
// Get existing breach reason from approval or step data
|
|
const existingBreachReason = (approval as any)?.breachReason || (step as any)?.breachReason || '';
|
|
|
|
// Reset modal state when it closes
|
|
useEffect(() => {
|
|
if (!showBreachReasonModal) {
|
|
setBreachReason('');
|
|
}
|
|
}, [showBreachReasonModal]);
|
|
|
|
const isActive = step.status === 'pending' || step.status === 'in-review';
|
|
const isCompleted = step.status === 'approved';
|
|
const isRejected = step.status === 'rejected';
|
|
const isWaiting = step.status === 'waiting';
|
|
|
|
const tatHours = Number(step.tatHours || 0);
|
|
const actualHours = step.actualHours;
|
|
const savedHours = isCompleted && actualHours ? Math.max(0, tatHours - actualHours) : 0;
|
|
|
|
// Calculate if breached
|
|
const progressPercentage = tatHours > 0 ? (actualHours / tatHours) * 100 : 0;
|
|
const isBreached = progressPercentage >= 100;
|
|
|
|
// Check permissions: ADMIN, MANAGEMENT, or the approver
|
|
const isAdmin = user?.role === 'ADMIN';
|
|
const isManagement = hasManagementAccess(user);
|
|
const isApprover = step.approverId === user?.userId;
|
|
const canEditBreachReason = isAdmin || isManagement || isApprover;
|
|
|
|
const handleSaveBreachReason = async () => {
|
|
if (!breachReason.trim()) {
|
|
toast.error('Breach Reason Required', {
|
|
description: 'Please enter a reason for the breach.',
|
|
});
|
|
return;
|
|
}
|
|
|
|
setSavingReason(true);
|
|
try {
|
|
await updateBreachReasonApi(step.levelId, breachReason.trim());
|
|
setShowBreachReasonModal(false);
|
|
setBreachReason('');
|
|
|
|
toast.success('Breach Reason Updated', {
|
|
description: 'The breach reason has been saved and will appear in the TAT Breach Report.',
|
|
duration: 5000,
|
|
});
|
|
|
|
// Refresh data if callback provided, otherwise reload page
|
|
if (onRefresh) {
|
|
await onRefresh();
|
|
} else {
|
|
// Fallback to page reload if no refresh callback
|
|
setTimeout(() => {
|
|
window.location.reload();
|
|
}, 1000);
|
|
}
|
|
} catch (error: any) {
|
|
console.error('Error updating breach reason:', error);
|
|
const errorMessage = error?.response?.data?.error || error?.message || 'Failed to update breach reason. Please try again.';
|
|
toast.error('Failed to Update Breach Reason', {
|
|
description: errorMessage,
|
|
duration: 5000,
|
|
});
|
|
} finally {
|
|
setSavingReason(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div
|
|
className={`relative p-3 sm:p-4 md:p-5 rounded-lg border-2 transition-all ${
|
|
step.isSkipped
|
|
? 'border-orange-500 bg-orange-50'
|
|
: isActive
|
|
? 'border-blue-500 bg-blue-50 shadow-md'
|
|
: isCompleted
|
|
? 'border-green-500 bg-green-50'
|
|
: isRejected
|
|
? 'border-red-500 bg-red-50'
|
|
: isWaiting
|
|
? 'border-gray-300 bg-gray-50'
|
|
: 'border-gray-200 bg-white'
|
|
}`}
|
|
data-testid={`${testId}-${step.step}`}
|
|
>
|
|
<div className="flex items-start gap-2 sm:gap-3 md:gap-4">
|
|
<div className={`p-2 sm:p-2.5 md:p-3 rounded-xl flex-shrink-0 ${
|
|
step.isSkipped ? 'bg-orange-100' :
|
|
isActive ? 'bg-blue-100' :
|
|
isCompleted ? 'bg-green-100' :
|
|
isRejected ? 'bg-red-100' :
|
|
isWaiting ? 'bg-gray-200' :
|
|
'bg-gray-100'
|
|
}`}>
|
|
{getStepIcon(step.status, step.isSkipped)}
|
|
</div>
|
|
|
|
<div className="flex-1 min-w-0">
|
|
{/* Header with Approver Label and Status */}
|
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 sm:gap-4 mb-3">
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mb-2">
|
|
<h4 className="font-semibold text-gray-900 text-base sm:text-lg" data-testid={`${testId}-approver-label`}>
|
|
Approver {index + 1}
|
|
</h4>
|
|
<Badge variant="outline" className={`text-xs shrink-0 capitalize ${
|
|
step.isSkipped ? 'bg-orange-100 text-orange-800 border-orange-200' :
|
|
isActive ? 'bg-yellow-100 text-yellow-800 border-yellow-200' :
|
|
isCompleted ? 'bg-green-100 text-green-800 border-green-200' :
|
|
isRejected ? 'bg-red-100 text-red-800 border-red-200' :
|
|
isWaiting ? 'bg-gray-200 text-gray-600 border-gray-300' :
|
|
'bg-gray-100 text-gray-800 border-gray-200'
|
|
}`} data-testid={`${testId}-status-badge`}>
|
|
{step.isSkipped ? 'skipped' : step.status}
|
|
</Badge>
|
|
{step.isSkipped && step.skipReason && (
|
|
<TooltipProvider delayDuration={200}>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
type="button"
|
|
className="inline-flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<AlertCircle className="w-4 h-4 text-orange-600" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" className="max-w-xs bg-orange-50 border-orange-200">
|
|
<p className="text-xs font-semibold text-orange-900 mb-1 flex items-center gap-1">
|
|
<FastForward className="w-3 h-3" />
|
|
Skip Reason:
|
|
</p>
|
|
<p className="text-xs text-gray-700">{step.skipReason}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
{isCompleted && actualHours && (
|
|
<Badge className="bg-green-600 text-white text-xs" data-testid={`${testId}-completion-time`}>
|
|
{formatWorkingHours(actualHours)}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-sm font-semibold text-gray-900" data-testid={`${testId}-approver-name`}>
|
|
{isCurrentUser ? <span className="text-blue-600">You</span> : step.approver}
|
|
</p>
|
|
<p className="text-xs text-gray-600" data-testid={`${testId}-role`}>{step.role}</p>
|
|
</div>
|
|
<div className="text-left sm:text-right flex-shrink-0">
|
|
<p className="text-xs text-gray-500 font-medium">Turnaround Time (TAT)</p>
|
|
<p className="text-lg font-bold text-gray-900" data-testid={`${testId}-tat-hours`}>{tatHours} hours</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Completed Approver - Show Completion Details */}
|
|
{isCompleted && actualHours !== undefined && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-gray-600">Completed:</span>
|
|
<span className="font-medium text-gray-900">{step.timestamp ? formatDateTime(step.timestamp) : 'N/A'}</span>
|
|
</div>
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-gray-600">Completed in:</span>
|
|
<span className="font-medium text-gray-900">{formatWorkingHours(actualHours)}</span>
|
|
</div>
|
|
|
|
{/* Progress Bar for Completed - Shows actual time used vs TAT allocated */}
|
|
<div className="space-y-2">
|
|
{(() => {
|
|
// Calculate actual progress percentage based on time used
|
|
// If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069%
|
|
const displayPercentage = Math.min(100, progressPercentage);
|
|
|
|
return (
|
|
<>
|
|
<Progress
|
|
value={displayPercentage}
|
|
className={`h-2 bg-gray-200 ${isBreached ? '[&>div]:bg-red-600' : '[&>div]:bg-green-600'}`}
|
|
data-testid={`${testId}-progress-bar`}
|
|
/>
|
|
<div className="flex items-center justify-between text-xs">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`font-semibold ${isBreached ? 'text-red-600' : 'text-green-600'}`}>
|
|
{Math.round(displayPercentage)}% of TAT used
|
|
</span>
|
|
{isBreached && canEditBreachReason && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
onClick={() => {
|
|
setBreachReason(existingBreachReason);
|
|
setShowBreachReasonModal(true);
|
|
}}
|
|
>
|
|
<FileEdit className="w-3 h-3" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
{savedHours > 0 && (
|
|
<span className="text-green-600 font-semibold">Saved {formatHoursMinutes(savedHours)}</span>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
{/* Breach Reason Display for Completed Approver */}
|
|
{isBreached && existingBreachReason && (
|
|
<div className="mt-4 p-3 sm:p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
|
|
<p className="text-xs font-semibold text-red-700 mb-2 flex items-center gap-1.5">
|
|
<FileEdit className="w-3.5 h-3.5" />
|
|
Breach Reason:
|
|
</p>
|
|
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">{existingBreachReason}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Conclusion Remark */}
|
|
{step.comment && (
|
|
<div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
|
|
<p className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
|
|
<MessageSquare className="w-3.5 h-3.5 text-blue-600" />
|
|
Conclusion Remark:
|
|
</p>
|
|
<p className="text-sm text-gray-700 italic leading-relaxed">{step.comment}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Active Approver - Show Real-time Progress from Backend */}
|
|
{isActive && approval?.sla && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-gray-600">Due by:</span>
|
|
<span className="font-medium text-gray-900">
|
|
{approval.sla.deadline ? formatDateTime(approval.sla.deadline) : 'Not set'}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Current Approver - Time Tracking */}
|
|
<div className={`border rounded-lg p-3 ${
|
|
approval.sla.status === 'critical' ? 'bg-orange-50 border-orange-200' :
|
|
approval.sla.status === 'breached' ? 'bg-red-50 border-red-200' :
|
|
'bg-yellow-50 border-yellow-200'
|
|
}`}>
|
|
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
|
<Clock className="w-4 h-4" />
|
|
Current Approver - Time Tracking
|
|
</p>
|
|
|
|
<div className="space-y-2 text-xs mb-3">
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Time elapsed since assigned:</span>
|
|
<span className="font-medium text-gray-900">{approval.sla.elapsedText}</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-gray-600">Time used:</span>
|
|
<span className="font-medium text-gray-900">{approval.sla.elapsedText} / {formatHoursMinutes(tatHours)} allocated</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress Bar */}
|
|
<div className="space-y-2">
|
|
<Progress
|
|
value={approval.sla.percentageUsed}
|
|
className={`h-3 ${
|
|
approval.sla.status === 'breached' ? '[&>div]:bg-red-600' :
|
|
approval.sla.status === 'critical' ? '[&>div]:bg-orange-600' :
|
|
'[&>div]:bg-yellow-600'
|
|
}`}
|
|
data-testid={`${testId}-sla-progress`}
|
|
/>
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className={`text-xs font-semibold ${
|
|
approval.sla.status === 'breached' ? 'text-red-600' :
|
|
approval.sla.status === 'critical' ? 'text-orange-600' :
|
|
'text-yellow-700'
|
|
}`}>
|
|
Progress: {Math.min(100, approval.sla.percentageUsed)}% of TAT used
|
|
</span>
|
|
{approval.sla.status === 'breached' && canEditBreachReason && (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-5 w-5 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
|
|
onClick={() => {
|
|
setBreachReason(existingBreachReason);
|
|
setShowBreachReasonModal(true);
|
|
}}
|
|
>
|
|
<FileEdit className="w-3 h-3" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>
|
|
<p>{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
)}
|
|
</div>
|
|
<span className="text-xs font-medium text-gray-700">
|
|
{approval.sla.remainingText} remaining
|
|
</span>
|
|
</div>
|
|
{approval.sla.status === 'breached' && (
|
|
<>
|
|
<p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
|
|
<AlertOctagon className="w-4 h-4" />
|
|
Deadline Breached
|
|
</p>
|
|
{existingBreachReason && (
|
|
<div className="mt-3 p-3 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
|
|
<p className="text-xs font-semibold text-red-700 mb-2 flex items-center gap-1.5">
|
|
<FileEdit className="w-3.5 h-3.5" />
|
|
Breach Reason:
|
|
</p>
|
|
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">{existingBreachReason}</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
{approval.sla.status === 'critical' && (
|
|
<p className="text-xs font-semibold text-center text-orange-600 flex items-center justify-center gap-1.5">
|
|
<AlertTriangle className="w-4 h-4" />
|
|
Approaching Deadline
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Waiting Approver - Show Assignment Info */}
|
|
{isWaiting && (
|
|
<div className="space-y-2">
|
|
<div className="bg-gray-100 border border-gray-300 rounded-lg p-3">
|
|
<p className="text-xs text-gray-600 mb-1 flex items-center gap-1.5">
|
|
<PauseCircle className="w-3.5 h-3.5 text-gray-500" />
|
|
Awaiting Previous Approval
|
|
</p>
|
|
<p className="text-sm font-medium text-gray-700">Will be assigned after previous step</p>
|
|
<p className="text-xs text-gray-500 mt-2">Allocated {tatHours} hours for approval</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Rejected Status */}
|
|
{isRejected && step.comment && (
|
|
<div className="mt-3 p-3 sm:p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
|
|
<p className="text-xs font-semibold text-red-700 mb-2 flex items-center gap-1.5">
|
|
<XCircle className="w-3.5 h-3.5" />
|
|
Rejection Reason:
|
|
</p>
|
|
<p className="text-sm text-gray-700 leading-relaxed">{step.comment}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Skipped Status */}
|
|
{step.isSkipped && step.skipReason && (
|
|
<div className="mt-3 p-3 sm:p-4 bg-orange-50 border-l-4 border-orange-500 rounded-r-lg">
|
|
<p className="text-xs font-semibold text-orange-700 mb-2 flex items-center gap-1.5">
|
|
<FastForward className="w-3.5 h-3.5" />
|
|
Skip Reason:
|
|
</p>
|
|
<p className="text-sm text-gray-700 leading-relaxed">{step.skipReason}</p>
|
|
{step.timestamp && (
|
|
<p className="text-xs text-gray-500 mt-2">Skipped on {formatDateTime(step.timestamp)}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* TAT Alerts/Reminders */}
|
|
{step.tatAlerts && step.tatAlerts.length > 0 && (
|
|
<div className="mt-2 sm:mt-3 space-y-2">
|
|
{step.tatAlerts.map((alert: any, alertIndex: number) => (
|
|
<div
|
|
key={alertIndex}
|
|
className={`p-2 sm:p-3 rounded-lg border ${
|
|
alert.isBreached
|
|
? 'bg-red-50 border-red-200'
|
|
: (alert.thresholdPercentage || 0) === 75
|
|
? 'bg-orange-50 border-orange-200'
|
|
: 'bg-yellow-50 border-yellow-200'
|
|
}`}
|
|
data-testid={`${testId}-tat-alert-${alertIndex}`}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<div className="flex-shrink-0 mt-0.5">
|
|
{(alert.thresholdPercentage || 0) === 50 && (
|
|
<Hourglass className="w-5 h-5 text-yellow-600" />
|
|
)}
|
|
{(alert.thresholdPercentage || 0) === 75 && (
|
|
<AlertTriangle className="w-5 h-5 text-orange-600" />
|
|
)}
|
|
{(alert.thresholdPercentage || 0) === 100 && (
|
|
<AlertOctagon className="w-5 h-5 text-red-600" />
|
|
)}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 mb-1">
|
|
<p className="text-xs sm:text-sm font-semibold text-gray-900">
|
|
Reminder {alertIndex + 1} - {alert.thresholdPercentage || 0}% TAT
|
|
</p>
|
|
<Badge
|
|
variant="outline"
|
|
className={`text-[10px] sm:text-xs shrink-0 ${
|
|
alert.isBreached
|
|
? 'bg-red-100 text-red-800 border-red-300'
|
|
: 'bg-amber-100 text-amber-800 border-amber-300'
|
|
}`}
|
|
>
|
|
{alert.isBreached ? 'BREACHED' : 'WARNING'}
|
|
</Badge>
|
|
</div>
|
|
|
|
<p className="text-[10px] sm:text-xs md:text-sm text-gray-700 mt-1">
|
|
{alert.thresholdPercentage || 0}% of SLA breach reminder have been sent
|
|
</p>
|
|
|
|
{/* Time Tracking Details */}
|
|
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:gap-2 text-[10px] sm:text-xs">
|
|
<div className="bg-white/50 rounded px-2 py-1">
|
|
<span className="text-gray-500">Allocated:</span>
|
|
<span className="ml-1 font-medium text-gray-900">
|
|
{Number(alert.tatHoursAllocated || 0).toFixed(2)}h
|
|
</span>
|
|
</div>
|
|
<div className="bg-white/50 rounded px-2 py-1">
|
|
<span className="text-gray-500">Elapsed:</span>
|
|
<span className="ml-1 font-medium text-gray-900">
|
|
{Number(alert.tatHoursElapsed || 0).toFixed(2)}h
|
|
{alert.metadata?.tatTestMode && (
|
|
<span className="text-purple-600 ml-1">
|
|
({(Number(alert.tatHoursElapsed || 0) * 60).toFixed(0)}m)
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="bg-white/50 rounded px-2 py-1">
|
|
<span className="text-gray-500">Remaining:</span>
|
|
<span className={`ml-1 font-medium ${
|
|
(alert.tatHoursRemaining || 0) < 2 ? 'text-red-600' : 'text-gray-900'
|
|
}`}>
|
|
{Number(alert.tatHoursRemaining || 0).toFixed(2)}h
|
|
{alert.metadata?.tatTestMode && (
|
|
<span className="text-purple-600 ml-1">
|
|
({(Number(alert.tatHoursRemaining || 0) * 60).toFixed(0)}m)
|
|
</span>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="bg-white/50 rounded px-2 py-1">
|
|
<span className="text-gray-500">Due by:</span>
|
|
<span className="ml-1 font-medium text-gray-900">
|
|
{alert.expectedCompletionTime ? formatDateShort(alert.expectedCompletionTime) : 'N/A'}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 pt-2 border-t border-gray-200">
|
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1">
|
|
<p className="text-[10px] sm:text-xs text-gray-500">
|
|
Reminder sent by system automatically
|
|
</p>
|
|
{(alert.metadata?.testMode || alert.metadata?.tatTestMode) && (
|
|
<Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-300 text-[10px] px-1.5 py-0 shrink-0">
|
|
TEST MODE
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-[10px] sm:text-xs text-gray-600 font-medium mt-0.5">
|
|
Sent at: {alert.alertSentAt ? formatDateTime(alert.alertSentAt) : 'N/A'}
|
|
</p>
|
|
{(alert.metadata?.testMode || alert.metadata?.tatTestMode) && (
|
|
<p className="text-[10px] text-purple-600 mt-1 italic">
|
|
Note: Test mode active (1 hour = 1 minute)
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{step.timestamp && (
|
|
<p className="text-xs text-gray-500 mt-2">
|
|
{isCompleted ? 'Approved' : isRejected ? 'Rejected' : 'Actioned'} on {formatDateTime(step.timestamp)}
|
|
</p>
|
|
)}
|
|
|
|
{/* Skip Approver Button - Only show for initiator on pending/in-review levels */}
|
|
{isInitiator && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
|
|
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="w-full border-orange-300 text-orange-700 hover:bg-orange-50 h-9 sm:h-10 text-xs sm:text-sm"
|
|
onClick={() => onSkipApprover({
|
|
levelId: step.levelId,
|
|
approverName: step.approver,
|
|
levelNumber: step.step
|
|
})}
|
|
data-testid={`${testId}-skip-button`}
|
|
>
|
|
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" />
|
|
Skip This Approver
|
|
</Button>
|
|
<p className="text-[10px] sm:text-xs text-gray-500 mt-1 text-center">
|
|
Skip if approver is unavailable and move to next level
|
|
</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Breach Reason Modal */}
|
|
<Dialog open={showBreachReasonModal} onOpenChange={setShowBreachReasonModal}>
|
|
<DialogContent className="sm:max-w-[500px]">
|
|
<DialogHeader>
|
|
<DialogTitle>{existingBreachReason ? 'Edit Breach Reason' : 'Add Breach Reason'}</DialogTitle>
|
|
<DialogDescription>
|
|
{existingBreachReason
|
|
? 'Update the reason for the TAT breach. This will be reflected in the TAT Breach Report.'
|
|
: 'Please provide a reason for the TAT breach. This will be reflected in the TAT Breach Report.'}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="py-4">
|
|
<Textarea
|
|
placeholder="Enter the reason for the breach..."
|
|
value={breachReason}
|
|
onChange={(e) => setBreachReason(e.target.value)}
|
|
className="min-h-[100px]"
|
|
maxLength={500}
|
|
/>
|
|
<p className="text-xs text-gray-500 mt-2">
|
|
{breachReason.length}/500 characters
|
|
</p>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowBreachReasonModal(false);
|
|
setBreachReason('');
|
|
}}
|
|
disabled={savingReason}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSaveBreachReason}
|
|
disabled={!breachReason.trim() || savingReason}
|
|
className="bg-red-600 hover:bg-red-700"
|
|
>
|
|
{savingReason ? 'Saving...' : 'Save Reason'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|
|
|