Compare commits

...

2 Commits

50 changed files with 2877 additions and 2582 deletions

View File

@ -1,6 +1,7 @@
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Star } from 'lucide-react';
import { formatBreachTime } from '@/pages/Dashboard/utils/dashboardCalculations';
export interface CriticalAlertData {
requestId: string;
@ -12,6 +13,8 @@ export interface CriticalAlertData {
breachCount: number;
currentLevel: number;
totalLevels: number;
isActionable?: boolean;
requestRole?: 'APPROVER' | 'INITIATOR' | 'PARTICIPANT';
}
interface CriticalAlertCardProps {
@ -23,112 +26,131 @@ interface CriticalAlertCardProps {
// Utility functions
const calculateProgress = (alert: CriticalAlertData) => {
if (!alert.originalTATHours || alert.originalTATHours === 0) return 0;
const originalTAT = alert.originalTATHours;
const remainingTAT = alert.totalTATHours;
// If breached (negative remaining), show 100%
if (remainingTAT <= 0) return 100;
// Calculate elapsed time
const elapsedTAT = originalTAT - remainingTAT;
// Calculate percentage used
const percentageUsed = (elapsedTAT / originalTAT) * 100;
// Ensure it's between 0 and 100
return Math.min(100, Math.max(0, Math.round(percentageUsed)));
};
const formatRemainingTime = (alert: CriticalAlertData) => {
const formatDisplayTime = (alert: CriticalAlertData) => {
if (alert.totalTATHours === undefined || alert.totalTATHours === null) return 'N/A';
const hours = alert.totalTATHours;
// If TAT is breached (negative or zero)
if (hours <= 0) {
const overdue = Math.abs(hours);
if (overdue < 1) return `Breached`;
if (overdue < 24) return `${Math.round(overdue)}h overdue`;
return `${Math.round(overdue / 24)}d overdue`;
}
// If TAT is still remaining
if (hours < 1) return `${Math.round(hours * 60)}min left`;
if (hours < 24) return `${Math.round(hours)}h left`;
return `${Math.round(hours / 24)}d left`;
const isOverdue = hours <= 0;
const absHours = Math.abs(hours);
const formattedTime = formatBreachTime(absHours);
if (formattedTime === 'Just breached') return 'Breached';
return isOverdue ? `${formattedTime} overdue` : `${formattedTime} left`;
};
export function CriticalAlertCard({
alert,
const getRoleBadge = (role?: string) => {
switch (role) {
case 'APPROVER':
return { label: 'Action Required', className: 'bg-red-100 text-red-700 border-red-200' };
case 'INITIATOR':
return { label: 'My Request', className: 'bg-orange-100 text-orange-700 border-orange-200' };
default:
return { label: 'Monitoring', className: 'bg-blue-100 text-blue-700 border-blue-200' };
}
};
export function CriticalAlertCard({
alert,
onNavigate,
testId = 'critical-alert-card'
}: CriticalAlertCardProps) {
const progress = calculateProgress(alert);
const isActionable = alert.isActionable ?? true; // Default to true if not provided (admin view)
const roleInfo = getRoleBadge(alert.requestRole);
return (
<div
className="p-3 sm:p-4 bg-red-50 rounded-lg sm:rounded-xl border border-red-100 hover:shadow-md transition-all duration-200 cursor-pointer"
<div
className={`p-3 sm:p-4 rounded-lg sm:rounded-xl border hover:shadow-md transition-all duration-200 cursor-pointer ${isActionable
? 'bg-red-50 border-red-100'
: 'bg-orange-50/50 border-orange-100'
}`}
onClick={() => onNavigate?.(alert.requestNumber)}
data-testid={`${testId}-${alert.requestId}`}
>
<div className="flex items-start justify-between gap-2 mb-2 sm:mb-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1 sm:gap-2 mb-1 flex-wrap">
<p
<p
className="font-semibold text-xs sm:text-sm text-gray-900"
data-testid={`${testId}-request-number`}
>
{alert.requestNumber}
</p>
{alert.priority === 'express' && (
<Star
className="h-3 w-3 text-red-500 flex-shrink-0"
<Star
className={`h-3 w-3 flex-shrink-0 ${isActionable ? 'text-red-500' : 'text-orange-500'}`}
data-testid={`${testId}-priority-icon`}
/>
)}
{alert.requestRole && (
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0 h-4 ${roleInfo.className}`}
>
{roleInfo.label}
</Badge>
)}
{alert.breachCount > 0 && (
<Badge
variant="destructive"
className="text-xs"
<Badge
variant="destructive"
className="text-[10px] px-1.5 py-0 h-4"
data-testid={`${testId}-breach-count`}
>
{alert.breachCount}
</Badge>
)}
</div>
<p
<p
className="text-xs sm:text-sm text-gray-700 line-clamp-2"
data-testid={`${testId}-title`}
>
{alert.title}
</p>
</div>
<Badge
variant="outline"
className="text-xs bg-white border-red-200 text-red-700 font-medium whitespace-nowrap"
<Badge
variant="outline"
className={`text-xs bg-white font-medium whitespace-nowrap ${isActionable ? 'border-red-200 text-red-700' : 'border-orange-200 text-orange-700'
}`}
data-testid={`${testId}-remaining-time`}
>
{formatRemainingTime(alert)}
{formatDisplayTime(alert)}
</Badge>
</div>
<div className="space-y-1 sm:space-y-2">
<div className="flex justify-between text-xs text-gray-600">
<span>TAT Used</span>
<span
<span
className="font-medium"
data-testid={`${testId}-progress-percentage`}
>
{progress}%
</span>
</div>
<Progress
value={progress}
className={`h-1.5 sm:h-2 ${
progress >= 80 ? '[&>div]:bg-red-600' :
progress >= 50 ? '[&>div]:bg-orange-500' :
'[&>div]:bg-green-600'
}`}
<Progress
value={progress}
className={`h-1.5 sm:h-2 ${progress >= 80 ? '[&>div]:bg-red-600' :
progress >= 50 ? '[&>div]:bg-orange-500' :
'[&>div]:bg-green-600'
}`}
data-testid={`${testId}-progress-bar`}
/>
</div>

View File

@ -12,12 +12,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Switch } from '../ui/switch';
import { Calendar } from '../ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import {
ArrowLeft,
ArrowRight,
Calendar as CalendarIcon,
Upload,
X,
import {
ArrowLeft,
ArrowRight,
Calendar as CalendarIcon,
Upload,
X,
FileText,
Check,
Users
@ -42,6 +42,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
spectators: [] as any[],
documents: [] as File[]
});
const [isDragging, setIsDragging] = useState(false);
const totalSteps = 5;
@ -78,9 +79,36 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
updateFormData('spectators', formData.spectators.filter(s => s.id !== userId));
};
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
updateFormData('documents', [...formData.documents, ...files]);
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement> | React.DragEvent) => {
let files: File[] = [];
if ('target' in event && event.target instanceof HTMLInputElement && event.target.files) {
files = Array.from(event.target.files);
} else if ('dataTransfer' in event && event.dataTransfer.files) {
files = Array.from(event.dataTransfer.files);
}
if (files.length > 0) {
updateFormData('documents', [...formData.documents, ...files]);
}
};
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
handleFileUpload(e);
};
const removeDocument = (index: number) => {
@ -150,7 +178,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
onChange={(e) => updateFormData('title', e.target.value)}
/>
</div>
<div>
<Label htmlFor="description">Description *</Label>
<Textarea
@ -215,9 +243,9 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<Label htmlFor="workflow-sequential" className="text-sm">Parallel</Label>
</div>
</div>
<div className="p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground">
{formData.workflowType === 'sequential'
{formData.workflowType === 'sequential'
? 'Approvers will review the request one after another in the order you specify.'
: 'All approvers will review the request simultaneously.'
}
@ -311,7 +339,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
</SelectTrigger>
<SelectContent>
{availableUsers
.filter(user =>
.filter(user =>
!formData.spectators.find(s => s.id === user.id) &&
!formData.approvers.find(a => a.id === user.id)
)
@ -375,8 +403,14 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<p className="text-sm text-muted-foreground mb-2">
Attach supporting documents for your request. Maximum 10MB per file.
</p>
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<div
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${isDragging ? 'border-re-green bg-re-green/5' : 'border-border'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<Upload className={`h-8 w-8 mx-auto mb-2 ${isDragging ? 'text-re-green' : 'text-muted-foreground'}`} />
<p className="text-sm text-muted-foreground mb-2">
Drag and drop files here, or click to browse
</p>

File diff suppressed because it is too large Load Diff

View File

@ -1,3 +1,4 @@
import { useState, ChangeEvent, DragEvent, RefObject } from 'react';
import { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@ -20,7 +21,7 @@ interface DocumentsStepProps {
onDocumentsToDeleteChange: (ids: string[]) => void;
onPreviewDocument: (doc: any, isExisting: boolean) => void;
onDocumentErrors?: (errors: Array<{ fileName: string; reason: string }>) => void;
fileInputRef: React.RefObject<HTMLInputElement>;
fileInputRef: RefObject<HTMLInputElement>;
}
/**
@ -47,8 +48,9 @@ export function DocumentsStep({
onDocumentErrors,
fileInputRef
}: DocumentsStepProps) {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
const [isDragging, setIsDragging] = useState(false);
const processFiles = (files: File[]) => {
if (files.length === 0) return;
// Validate files
@ -69,7 +71,7 @@ export function DocumentsStep({
// Check file extension
const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
validationErrors.push({
fileName: file.name,
@ -90,6 +92,11 @@ export function DocumentsStep({
if (validationErrors.length > 0 && onDocumentErrors) {
onDocumentErrors(validationErrors);
}
};
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
processFiles(files);
// Reset file input
if (event.target) {
@ -97,6 +104,27 @@ export function DocumentsStep({
}
};
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(true);
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
};
const handleDrop = (e: DragEvent) => {
e.preventDefault();
e.stopPropagation();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
processFiles(files);
};
const handleRemove = (index: number) => {
const newDocs = documents.filter((_, i) => i !== index);
onDocumentsChange(newDocs);
@ -111,16 +139,16 @@ export function DocumentsStep({
const type = (doc.fileType || doc.file_type || '').toLowerCase();
const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
} else {
const type = (doc.type || '').toLowerCase();
const name = (doc.name || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf');
}
};
@ -156,8 +184,15 @@ export function DocumentsStep({
</CardDescription>
</CardHeader>
<CardContent>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors" data-testid="documents-upload-area">
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
<div
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${isDragging ? 'border-re-green bg-re-green/5' : 'border-gray-300 hover:border-gray-400'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
data-testid="documents-upload-area"
>
<Upload className={`h-12 w-12 mx-auto mb-4 ${isDragging ? 'text-re-green' : 'text-gray-400'}`} />
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
<p className="text-gray-600 mb-4">
Drag and drop files here, or click to browse
@ -172,10 +207,10 @@ export function DocumentsStep({
ref={fileInputRef}
data-testid="documents-file-input"
/>
<Button
variant="outline"
size="lg"
type="button"
<Button
variant="outline"
size="lg"
type="button"
onClick={() => fileInputRef.current?.click()}
data-testid="documents-browse-button"
>
@ -206,7 +241,7 @@ export function DocumentsStep({
const docId = doc.documentId || doc.document_id || '';
const isDeleted = documentsToDelete.includes(docId);
if (isDeleted) return null;
return (
<div key={docId} className="flex items-center justify-between p-4 rounded-lg border bg-gray-50" data-testid={`documents-existing-${docId}`}>
<div className="flex items-center gap-3">
@ -222,9 +257,9 @@ export function DocumentsStep({
</div>
<div className="flex items-center gap-2">
{canPreview(doc, true) && (
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
onClick={() => onPreviewDocument(doc, true)}
data-testid={`documents-existing-${docId}-preview`}
>
@ -276,9 +311,9 @@ export function DocumentsStep({
</div>
<div className="flex items-center gap-2">
{canPreview(file, false) && (
<Button
variant="ghost"
size="sm"
<Button
variant="ghost"
size="sm"
onClick={() => onPreviewDocument(file, false)}
data-testid={`documents-new-${index}-preview`}
>

View File

@ -22,6 +22,7 @@ import { CustomDatePicker } from '@/components/ui/date-picker';
interface StandardUserAllRequestsFiltersProps {
// Filters
searchTerm: string;
lifecycleFilter: string;
statusFilter: string;
priorityFilter: string;
templateTypeFilter: string;
@ -64,6 +65,7 @@ interface StandardUserAllRequestsFiltersProps {
// Actions
onSearchChange: (value: string) => void;
onLifecycleChange: (value: string) => void;
onStatusChange: (value: string) => void;
onPriorityChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void;
@ -85,6 +87,7 @@ interface StandardUserAllRequestsFiltersProps {
export function StandardUserAllRequestsFilters({
searchTerm,
lifecycleFilter,
statusFilter,
priorityFilter,
// templateTypeFilter,
@ -102,6 +105,7 @@ export function StandardUserAllRequestsFilters({
initiatorSearch,
approverSearch,
onSearchChange,
onLifecycleChange,
onStatusChange,
onPriorityChange,
// onTemplateTypeChange,
@ -155,6 +159,17 @@ export function StandardUserAllRequestsFilters({
/>
</div>
<Select value={lifecycleFilter} onValueChange={onLifecycleChange}>
<SelectTrigger className="h-10" data-testid="lifecycle-filter">
<SelectValue placeholder="All Requests" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Requests</SelectItem>
<SelectItem value="open">Open Requests</SelectItem>
<SelectItem value="closed">Closed Requests</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger className="h-10" data-testid="status-filter">
<SelectValue placeholder="All Status" />
@ -240,7 +255,7 @@ export function StandardUserAllRequestsFilters({
) : (
<>
<Input
placeholder="Search initiator..."
placeholder="Use @ to search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => {
@ -310,7 +325,7 @@ export function StandardUserAllRequestsFilters({
) : (
<>
<Input
placeholder="Search approver..."
placeholder="Use @ to search approver..."
value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => {

View File

@ -280,8 +280,9 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
setShowShareSummaryModal(true);
};
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
const isClosed = request?.status === 'closed';
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
const isClosed = apiRequest?.workflowState === 'CLOSED' || requestStatus === 'closed';
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator && !isClosed;
// Fetch summary details if request is closed
useEffect(() => {
@ -295,7 +296,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
try {
setLoadingSummary(true);
const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) {
setSummaryId(summary.summaryId);
try {
@ -356,15 +357,15 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
{accessDenied.message}
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
@ -389,15 +390,15 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
The custom request you're looking for doesn't exist or may have been deleted.
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
@ -419,7 +420,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
refreshing={refreshing}
onBack={onBack || (() => window.history.back())}
onRefresh={handleRefresh}
onShareSummary={handleShareSummary}
onShareSummary={summaryId ? handleShareSummary : undefined}
isInitiator={isInitiator}
// Custom module: Business logic for preparing SLA data
slaData={request?.summary?.sla || request?.sla || null}
@ -516,13 +517,14 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
generationAttempts={generationAttempts}
generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached}
isClosed={isClosed}
/>
</TabsContent>
{isClosed && (
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
<SummaryTab
summary={summaryDetails}
<SummaryTab
summary={summaryDetails}
loading={loadingSummary}
onShare={handleShareSummary}
isInitiator={isInitiator}

View File

@ -34,7 +34,7 @@ interface DealerUserAllRequestsFiltersProps {
customStartDate?: Date;
customEndDate?: Date;
showCustomDatePicker: boolean;
// State for user search
initiatorSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
@ -46,7 +46,7 @@ interface DealerUserAllRequestsFiltersProps {
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
approverSearch: {
selectedUser: { userId: string; email: string; displayName?: string } | null;
searchQuery: string;
@ -57,7 +57,7 @@ interface DealerUserAllRequestsFiltersProps {
handleClear: () => void;
setShowResults: (show: boolean) => void;
};
// Actions
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
@ -70,7 +70,7 @@ interface DealerUserAllRequestsFiltersProps {
onShowCustomDatePickerChange?: (show: boolean) => void;
onApplyCustomDate?: () => void;
onClearFilters: () => void;
// Computed
hasActiveFilters: boolean;
}
@ -172,7 +172,7 @@ export function DealerUserAllRequestsFilters({
) : (
<>
<Input
placeholder="Search initiator..."
placeholder="Use @ to search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => {
@ -242,7 +242,7 @@ export function DealerUserAllRequestsFilters({
) : (
<>
<Input
placeholder="Search approver..."
placeholder="Use @ to search approver..."
value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => {

View File

@ -44,6 +44,7 @@ interface ClaimManagementOverviewTabProps {
generationAttempts?: number;
generationFailed?: boolean;
maxAttemptsReached?: boolean;
isClosed?: boolean;
}
export function ClaimManagementOverviewTab({
@ -64,6 +65,7 @@ export function ClaimManagementOverviewTab({
generationAttempts = 0,
generationFailed = false,
maxAttemptsReached = false,
isClosed = false,
}: ClaimManagementOverviewTabProps) {
// Check if this is a claim management request
if (!isClaimManagementRequest(apiRequest)) {
@ -76,7 +78,7 @@ export function ClaimManagementOverviewTab({
// Map API data to claim management structure
const claimRequest = mapToClaimManagementRequest(apiRequest, currentUserId);
if (!claimRequest) {
console.warn('[ClaimManagementOverviewTab] Failed to map claim data:', {
apiRequest,
@ -96,10 +98,10 @@ export function ClaimManagementOverviewTab({
// Determine user's role
const userRole: RequestRole = determineUserRole(apiRequest, currentUserId);
// Get visibility settings based on role
const visibility = getRoleBasedVisibility(userRole);
// User role and visibility determined
// Extract initiator info from request
@ -118,7 +120,7 @@ export function ClaimManagementOverviewTab({
<div className={`space-y-6 ${className}`}>
{/* Activity Information - Always visible */}
{/* Dealer-claim module: Business logic for preparing timestamp data */}
<ActivityInformationCard
<ActivityInformationCard
activityInfo={claimRequest.activityInfo}
createdAt={apiRequest?.createdAt}
updatedAt={apiRequest?.updatedAt}
@ -136,7 +138,7 @@ export function ClaimManagementOverviewTab({
<RequestInitiatorCard initiatorInfo={initiatorInfo} />
{/* Closed Request Conclusion Remark Display */}
{apiRequest?.status === 'closed' && apiRequest?.conclusionRemark && (
{isClosed && apiRequest?.conclusionRemark && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
@ -147,8 +149,8 @@ export function ClaimManagementOverviewTab({
</CardHeader>
<CardContent className="pt-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<FormattedDescription
content={apiRequest.conclusionRemark || ''}
<FormattedDescription
content={apiRequest.conclusionRemark || ''}
className="text-sm"
/>
</div>
@ -166,23 +168,20 @@ export function ClaimManagementOverviewTab({
{/* Conclusion Remark Section - Closure Setup */}
{needsClosure && (
<Card data-testid="conclusion-remark-card">
<CardHeader className={`bg-gradient-to-r border-b ${
(apiRequest?.status || '').toLowerCase() === 'rejected'
? 'from-red-50 to-rose-50 border-red-200'
: 'from-green-50 to-emerald-50 border-green-200'
}`}>
<CardHeader className={`bg-gradient-to-r border-b ${(apiRequest?.status || '').toLowerCase() === 'rejected'
? 'from-red-50 to-rose-50 border-red-200'
: 'from-green-50 to-emerald-50 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 ${
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-700' : 'text-green-700'
}`}>
<CheckCircle className={`w-5 h-5 ${
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-600' : 'text-green-600'
}`} />
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-700' : 'text-green-700'
}`}>
<CheckCircle className={`w-5 h-5 ${(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-600' : 'text-green-600'
}`} />
Conclusion Remark - Final Step
</CardTitle>
<CardDescription className="mt-1 text-xs sm:text-sm">
{(apiRequest?.status || '').toLowerCase() === 'rejected'
{(apiRequest?.status || '').toLowerCase() === 'rejected'
? 'This request was rejected. Please review the AI-generated closure remark and finalize it to close this request.'
: 'All approvals are complete. Please review and finalize the conclusion to close this request.'}
</CardDescription>
@ -201,7 +200,7 @@ export function ClaimManagementOverviewTab({
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button>
{aiGenerated && !maxAttemptsReached && !generationFailed && (
<span className="text-[10px] text-gray-500 font-medium px-1">
<span className="text-[10px] text-gray-500 font-medium px-1">
{2 - generationAttempts} attempts remaining
</span>
)}

File diff suppressed because it is too large Load Diff

View File

@ -153,7 +153,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
const currentUserId = (user as any)?.userId || '';
// IO tab visibility for dealer claims
// Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO
const showIOTab = isInitiator;
@ -177,7 +177,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// State to temporarily store approval level for modal (used for additional approvers)
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
// Use temporary level if set, otherwise use currentApprovalLevel
const effectiveApprovalLevel = temporaryApprovalLevel || currentApprovalLevel;
@ -219,8 +219,9 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
// Closure functionality - only for initiator when request is approved/rejected
// Check both lowercase and uppercase status values
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
const isClosed = apiRequest?.workflowState === 'CLOSED' || requestStatus === 'closed';
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator && !isClosed;
// Closure check completed
const {
conclusionRemark,
@ -321,7 +322,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setShowShareSummaryModal(true);
};
const isClosed = request?.status === 'closed';
// Summary check already handled by isClosed above
// Fetch summary details if request is closed
useEffect(() => {
@ -335,7 +336,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
try {
setLoadingSummary(true);
const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) {
setSummaryId(summary.summaryId);
try {
@ -376,9 +377,9 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const notifRequestId = notif.requestId || notif.request_id;
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
if (notifRequestId !== apiRequest.requestId &&
notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return;
if (notifRequestId !== apiRequest.requestId &&
notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return;
// Check for credit note metadata
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
@ -427,15 +428,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{accessDenied.message}
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
@ -460,15 +461,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
The dealer claim request you're looking for doesn't exist or may have been deleted.
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
@ -490,7 +491,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
refreshing={refreshing}
onBack={onBack || (() => window.history.back())}
onRefresh={handleRefresh}
onShareSummary={handleShareSummary}
onShareSummary={summaryId ? handleShareSummary : undefined}
isInitiator={isInitiator}
// Dealer-claim module: Business logic for preparing SLA data
slaData={request?.summary?.sla || request?.sla || null}
@ -593,13 +594,14 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
generationAttempts={generationAttempts}
generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached}
isClosed={isClosed}
/>
</TabsContent>
{isClosed && (
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
<SummaryTab
summary={summaryDetails}
<SummaryTab
summary={summaryDetails}
loading={loadingSummary}
onShare={handleShareSummary}
isInitiator={isInitiator}

View File

@ -30,19 +30,19 @@ export function useRequestDetails(
) {
// State: Stores the fetched and transformed request data
const [apiRequest, setApiRequest] = useState<any | null>(null);
// State: Indicates if data is currently being fetched
const [refreshing, setRefreshing] = useState(false);
// State: Loading state for initial fetch
const [loading, setLoading] = useState(true);
// State: Access denied information
const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null);
// State: Stores the current approval level for the logged-in user
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
// State: Indicates if the current user is a spectator (view-only access)
const [isSpectator, setIsSpectator] = useState(false);
@ -103,14 +103,14 @@ export function useRequestDetails(
const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
// Debug: Log TAT alerts for monitoring
if (tatAlerts.length > 0) {
// TAT alerts loaded - logging removed
}
const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
/**
* Transform: Map approval levels to UI format with TAT alerts
* Each approval level includes:
@ -123,10 +123,10 @@ export function useRequestDetails(
const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id;
// Determine display status based on workflow progress
let displayStatus = statusMap(a.status);
// Future levels that haven't been reached yet show as "waiting"
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
displayStatus = 'waiting';
@ -135,10 +135,10 @@ export function useRequestDetails(
else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
displayStatus = 'pending';
}
// Filter: Get TAT alerts that belong to this specific approval level
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
return {
step: levelNumber,
levelId,
@ -152,8 +152,8 @@ export function useRequestDetails(
remainingHours: Number(a.remainingHours || 0),
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
// Calculate actual hours taken if level is completed
actualHours: a.levelEndTime && a.levelStartTime
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
actualHours: a.levelEndTime && a.levelStartTime
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
: undefined,
comment: a.comments || undefined,
timestamp: a.actionDate || undefined,
@ -211,11 +211,11 @@ export function useRequestDetails(
* Filter: Remove TAT breach activities from audit trail
* TAT warnings are already shown in approval steps, no need to duplicate in timeline
*/
const filteredActivities = Array.isArray(details.activities)
const filteredActivities = Array.isArray(details.activities)
? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning';
})
const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning';
})
: [];
/**
@ -224,7 +224,7 @@ export function useRequestDetails(
*/
let pauseInfo = null;
const isPaused = (wf as any).isPaused || false;
if (isPaused) {
try {
pauseInfo = await getPauseDetails(wf.requestId);
@ -240,13 +240,13 @@ export function useRequestDetails(
let proposalDetails = null;
let completionDetails = null;
let internalOrder = null;
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try {
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
const claimData = claimResponse.data?.data || claimResponse.data;
if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details;
proposalDetails = claimData.proposalDetails || claimData.proposal_details;
@ -257,7 +257,7 @@ export function useRequestDetails(
const invoice = claimData.invoice || null;
const creditNote = claimData.creditNote || claimData.credit_note || null;
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
// Store new fields in claimDetails for backward compatibility and easy access
if (claimDetails) {
(claimDetails as any).budgetTracking = budgetTracking;
@ -265,7 +265,7 @@ export function useRequestDetails(
(claimDetails as any).creditNote = creditNote;
(claimDetails as any).completionExpenses = completionExpenses;
}
// Extracted details processed
} else {
console.warn('[useRequestDetails] No claimData found in response');
@ -294,6 +294,7 @@ export function useRequestDetails(
title: wf.title,
description: wf.description,
status: statusMap(wf.status),
workflowState: wf.workflowState,
priority: (wf.priority || '').toString().toLowerCase(),
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
approvalFlow,
@ -334,7 +335,7 @@ export function useRequestDetails(
creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null,
};
setApiRequest(updatedRequest);
/**
@ -352,8 +353,8 @@ export function useRequestDetails(
const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level
// Include PAUSED status - paused level is still the current level
return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED')
&& approverEmail === userEmail
return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED')
&& approverEmail === userEmail
&& approvalLevelNumber === currentLevel;
});
setCurrentApprovalLevel(newCurrentLevel || null);
@ -364,8 +365,8 @@ export function useRequestDetails(
*/
const viewerId = (user as any)?.userId;
if (viewerId) {
const isSpec = participants.some((p: any) =>
(p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' &&
const isSpec = participants.some((p: any) =>
(p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' &&
(p.userId || p.user_id) === viewerId
);
setIsSpectator(isSpec);
@ -389,11 +390,11 @@ export function useRequestDetails(
setLoading(false);
return;
}
let mounted = true;
setLoading(true);
setAccessDenied(null);
(async () => {
try {
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
@ -401,7 +402,7 @@ export function useRequestDetails(
if (mounted) setLoading(false);
return;
}
// Use the same transformation logic as refreshDetails
const wf = details.workflow || {};
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
@ -409,7 +410,7 @@ export function useRequestDetails(
const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
// TAT alerts received - logging removed
const priority = (wf.priority || '').toString().toLowerCase();
@ -420,9 +421,9 @@ export function useRequestDetails(
const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id;
let displayStatus = statusMap(a.status);
// If paused, show paused status (don't change it)
if (levelStatus === 'PAUSED') {
displayStatus = 'paused';
@ -431,9 +432,9 @@ export function useRequestDetails(
} else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) {
displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending';
}
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
return {
step: levelNumber,
levelId,
@ -448,8 +449,8 @@ export function useRequestDetails(
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
// Use backend-calculated elapsedHours (working hours) for completed approvals
// Backend already calculates this correctly using calculateElapsedWorkingHours
actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null
? Number(a.elapsedHours)
actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null
? Number(a.elapsedHours)
: undefined,
comment: a.comments || undefined,
timestamp: a.actionDate || undefined,
@ -457,7 +458,7 @@ export function useRequestDetails(
tatAlerts: levelAlerts,
};
});
// Map spectators
const spectators = participants
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
@ -492,18 +493,18 @@ export function useRequestDetails(
});
// Filter out TAT warnings from activities
const filteredActivities = Array.isArray(details.activities)
const filteredActivities = Array.isArray(details.activities)
? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning';
})
const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning';
})
: [];
// Fetch pause details only if request is actually paused
// Use request-level isPaused field from workflow response
let pauseInfo = null;
const isPaused = (wf as any).isPaused || false;
if (isPaused) {
try {
pauseInfo = await getPauseDetails(wf.requestId);
@ -519,11 +520,11 @@ export function useRequestDetails(
let proposalDetails = null;
let completionDetails = null;
let internalOrder = null;
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try {
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
const claimData = claimResponse.data?.data || claimResponse.data;
if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details;
@ -535,7 +536,7 @@ export function useRequestDetails(
const invoice = claimData.invoice || null;
const creditNote = claimData.creditNote || claimData.credit_note || null;
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
// Store new fields in claimDetails for backward compatibility and easy access
if (claimDetails) {
(claimDetails as any).budgetTracking = budgetTracking;
@ -543,7 +544,7 @@ export function useRequestDetails(
(claimDetails as any).creditNote = creditNote;
(claimDetails as any).completionExpenses = completionExpenses;
}
// Initial load - Extracted details processed
}
} catch (error: any) {
@ -564,6 +565,7 @@ export function useRequestDetails(
description: wf.description,
priority,
status: statusMap(wf.status),
workflowState: wf.workflowState,
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
summary,
initiator: {
@ -599,9 +601,9 @@ export function useRequestDetails(
creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null,
};
setApiRequest(mapped);
// Find current user's approval level
// Only show approve/reject buttons if user is the CURRENT active approver
// Include PAUSED status - when paused, the paused level is still the current level
@ -612,8 +614,8 @@ export function useRequestDetails(
const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level
// Include PAUSED status - paused level is still the current level
return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED')
&& approverEmail === userEmail
return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED')
&& approverEmail === userEmail
&& approvalLevelNumber === currentLevel;
});
setCurrentApprovalLevel(userCurrentLevel || null);
@ -621,7 +623,7 @@ export function useRequestDetails(
// Check spectator status
const viewerId = (user as any)?.userId;
if (viewerId) {
const isSpec = participants.some((p: any) =>
const isSpec = participants.some((p: any) =>
(p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId
);
setIsSpectator(isSpec);
@ -633,7 +635,7 @@ export function useRequestDetails(
if (mounted) {
// Check for 403 Forbidden (Access Denied)
if (error?.response?.status === 403) {
const message = error?.response?.data?.message ||
const message = error?.response?.data?.message ||
'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.';
setAccessDenied({ denied: true, message });
}
@ -645,7 +647,7 @@ export function useRequestDetails(
}
}
})();
return () => { mounted = false; };
}, [requestIdentifier, user]);
@ -656,23 +658,23 @@ export function useRequestDetails(
const request = useMemo(() => {
// Primary source: API data
if (apiRequest) return apiRequest;
// Fallback 1: Static custom request database
const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier];
if (customRequest) return customRequest;
// Fallback 2: Static claim management database
const claimRequest = CLAIM_MANAGEMENT_DATABASE[requestIdentifier];
if (claimRequest) return claimRequest;
// Fallback 3: Dynamic requests passed as props
const dynamicRequest = dynamicRequests.find((req: any) =>
req.id === requestIdentifier ||
const dynamicRequest = dynamicRequests.find((req: any) =>
req.id === requestIdentifier ||
req.requestNumber === requestIdentifier ||
req.request_number === requestIdentifier
);
if (dynamicRequest) return dynamicRequest;
return null;
}, [requestIdentifier, dynamicRequests, apiRequest]);
@ -693,9 +695,9 @@ export function useRequestDetails(
*/
const existingParticipants = useMemo(() => {
if (!request) return [];
const participants: Array<{ email: string; participantType: string; name?: string }> = [];
// Add initiator
if (request.initiator?.email) {
participants.push({
@ -704,7 +706,7 @@ export function useRequestDetails(
name: request.initiator.name
});
}
// Add approvers from approval flow
if (request.approvalFlow && Array.isArray(request.approvalFlow)) {
request.approvalFlow.forEach((approval: any) => {
@ -717,7 +719,7 @@ export function useRequestDetails(
}
});
}
// Add spectators
if (request.spectators && Array.isArray(request.spectators)) {
request.spectators.forEach((spectator: any) => {
@ -730,20 +732,20 @@ export function useRequestDetails(
}
});
}
// Add from participants array
if (request.participants && Array.isArray(request.participants)) {
request.participants.forEach((p: any) => {
const email = (p.userEmail || p.email || '').toLowerCase();
const participantType = (p.participantType || p.participant_type || '').toUpperCase();
const name = p.userName || p.user_name || p.name;
if (email && participantType && !participants.find(x => x.email === email)) {
participants.push({ email, participantType, name });
}
});
}
return participants;
}, [request]);
@ -762,12 +764,12 @@ export function useRequestDetails(
*/
useEffect(() => {
if (!requestIdentifier || !apiRequest) return;
const socket = getSocket();
if (!socket) {
return;
}
/**
* Handler: Request updated by another user
* Silently refresh to show latest changes
@ -779,10 +781,10 @@ export function useRequestDetails(
refreshDetails();
}
};
// Register listener
socket.on('request:updated', handleRequestUpdated);
// Cleanup on unmount
return () => {
socket.off('request:updated', handleRequestUpdated);

View File

@ -8,7 +8,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { ArrowRight, Calendar, CheckCircle } from 'lucide-react';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
import { ClosedRequest } from '../types/closedRequests.types';
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers';
interface ClosedRequestCardProps {
request: ClosedRequest;
@ -18,11 +18,12 @@ interface ClosedRequestCardProps {
export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardProps) {
const priorityConfig = getPriorityConfig(request.priority);
const statusConfig = getStatusConfig(request.status);
const stateConfig = getWorkflowStateConfig(request.workflowState || 'CLOSED');
const PriorityIcon = priorityConfig.icon;
const StatusIcon = statusConfig.icon;
return (
<Card
<Card
className="group hover:shadow-lg transition-all duration-200 cursor-pointer border border-gray-200 hover:border-blue-400 hover:scale-[1.002]"
onClick={() => onViewRequest?.(request.id, request.title)}
data-testid={`closed-request-card-${request.id}`}
@ -43,20 +44,26 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
<h3 className="text-base font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
{request.displayId || request.id}
</h3>
<Badge
variant="outline"
<Badge
variant="outline"
className={`${statusConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`}
>
<StatusIcon className="w-3.5 h-3.5 mr-1" />
{statusConfig.label}
</Badge>
<Badge
variant="outline"
className={`${stateConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`}
>
{stateConfig.label}
</Badge>
{request.department && (
<Badge variant="secondary" className="bg-blue-50 text-blue-700 text-xs px-2.5 py-0.5 hidden sm:inline-flex">
{request.department}
</Badge>
)}
<Badge
variant="outline"
<Badge
variant="outline"
className={`${priorityConfig.color} text-xs px-2.5 py-0.5 capitalize hidden md:inline-flex`}
>
{request.priority}
@ -65,18 +72,18 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
{(() => {
const templateType = request.templateType || '';
const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType
let templateLabel = 'Non-Templatized';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Dealer Claim';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template';
}
return (
<Badge
variant="outline"
@ -104,19 +111,19 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
</Avatar>
<span className="font-medium text-gray-900">{request.initiator.name}</span>
</div>
{(request.totalLevels ?? 0) > 0 && (
<div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-green-600" />
<span className="font-medium">{request.completedLevels || 0}/{request.totalLevels} Approvals</span>
</div>
)}
<div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" />
<span>Created: {request.createdAt !== '—' ? formatDateDDMMYYYY(request.createdAt, true) : '—'}</span>
</div>
{request.dueDate && (
<div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-slate-600" />

View File

@ -8,7 +8,7 @@ export interface ClosedRequest {
displayId?: string;
title: string;
description: string;
status: 'rejected' | 'closed';
status: 'rejected' | 'closed' | 'approved';
priority: 'express' | 'standard';
initiator: { name: string; avatar: string };
createdAt: string;
@ -18,6 +18,7 @@ export interface ClosedRequest {
totalLevels?: number;
completedLevels?: number;
templateType?: string; // Template type for badge display
workflowState?: string;
}
export interface ClosedRequestsProps {

View File

@ -38,6 +38,14 @@ export function getStatusConfig(status: string): StatusConfig {
label: 'Closed',
description: 'Request finalized and archived'
};
case 'approved':
return {
color: 'bg-green-100 text-green-800 border-green-200',
icon: CheckCircle,
iconColor: 'text-green-600',
label: 'Approved',
description: 'Request was approved'
};
case 'rejected':
return {
color: 'bg-red-100 text-red-800 border-red-300',
@ -57,3 +65,25 @@ export function getStatusConfig(status: string): StatusConfig {
}
}
export function getWorkflowStateConfig(state: string) {
const s = (state || '').toUpperCase();
switch (s) {
case 'CLOSED':
return {
color: 'bg-slate-100 text-slate-800 border-slate-200',
label: 'closed'
};
case 'DRAFT':
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'draft'
};
case 'OPEN':
default:
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'open'
};
}
}

View File

@ -11,7 +11,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
displayId: r.requestNumber || r.request_number || r.requestId,
title: r.title,
description: r.description,
status: (r.status || '').toString().toLowerCase() as 'rejected' | 'closed',
status: (r.status || '').toString().toLowerCase() as 'rejected' | 'closed' | 'approved',
priority: (r.priority || '').toString().toLowerCase() as 'express' | 'standard',
initiator: {
name: r.initiator?.displayName || r.initiator?.email || '—',
@ -29,6 +29,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
totalLevels: r.totalLevels || 0,
completedLevels: r.summary?.approvedLevels || 0,
templateType: r.templateType || r.template_type, // Template type for badge display
workflowState: r.workflowState || r.workflow_state,
};
}

View File

@ -139,6 +139,7 @@ export function useCreateRequestSubmission({
user,
documentsToDelete
);
(updatePayload as any).isDraft = true;
await updateWorkflowRequest(
editRequestId,
@ -164,6 +165,7 @@ export function useCreateRequestSubmission({
selectedTemplate,
user
);
(createPayload as any).isDraft = true;
const result = await createWorkflow(createPayload, documents);

View File

@ -59,28 +59,22 @@ export async function submitWorkflowRequest(requestId: string): Promise<void> {
await submitWorkflow(requestId);
}
/**
* Create and submit a workflow in one operation
*/
export async function createAndSubmitWorkflow(
payload: CreateWorkflowPayload,
documents: File[]
): Promise<{ id: string }> {
const result = await createWorkflow(payload, documents);
await submitWorkflowRequest(result.id);
return result;
// Pass isDraft: false (or omit) to trigger backend auto-submit
const res = await createWorkflow({ ...payload, isDraft: false }, documents);
return res;
}
/**
* Update and submit a workflow in one operation
*/
export async function updateAndSubmitWorkflow(
requestId: string,
payload: UpdateWorkflowPayload,
documents: File[],
documentsToDelete: string[]
): Promise<void> {
await updateWorkflowRequest(requestId, payload, documents, documentsToDelete);
await submitWorkflowRequest(requestId);
// Pass isDraft: false (or omit) to trigger backend auto-submit
await updateWorkflowRequest(requestId, { ...payload, isDraft: false }, documents, documentsToDelete);
}

View File

@ -67,6 +67,7 @@ export interface CreateWorkflowPayload {
email: string;
}>;
participants: Participant[];
isDraft?: boolean;
}
export interface UpdateWorkflowPayload {
@ -76,6 +77,7 @@ export interface UpdateWorkflowPayload {
approvalLevels: ApprovalLevel[];
participants: Participant[];
deleteDocumentIds?: string[];
isDraft?: boolean;
}
export interface ValidationModalState {

View File

@ -71,8 +71,8 @@ export function AdminKPICards({
}}
/>
</div>
{/* Row 2: Pending and Closed */}
<div className="grid grid-cols-2 gap-2 mb-2">
{/* Row 2: Pending and Paused */}
<div className="grid grid-cols-2 gap-2">
<StatCard
label="Pending"
value={kpis?.requestVolume.openRequests || 0}
@ -84,21 +84,7 @@ export function AdminKPICards({
onKPIClick({ ...getFilterParams(), status: 'pending' });
}}
/>
<StatCard
label="Closed"
value={kpis?.requestVolume.closedRequests || 0}
bgColor="bg-gray-50"
textColor="text-gray-600"
testId="stat-closed"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'closed' });
}}
/>
</div>
{/* Row 3: Paused (if available) */}
{kpis?.requestVolume.pausedRequests !== undefined && (
<div className="grid grid-cols-2 gap-2">
{kpis?.requestVolume.pausedRequests !== undefined && (
<StatCard
label="Paused"
value={kpis.requestVolume.pausedRequests || 0}
@ -110,8 +96,8 @@ export function AdminKPICards({
onKPIClick({ ...getFilterParams(), status: 'paused' });
}}
/>
</div>
)}
)}
</div>
</KPICard>
{/* SLA Compliance */}

View File

@ -9,7 +9,7 @@ import { Progress } from '@/components/ui/progress';
import { Calendar as CalendarIcon } from 'lucide-react';
import { UpcomingDeadline } from '@/services/dashboard.service';
import { Pagination } from '@/components/common/Pagination';
import { formatHoursMinutes } from '@/utils/slaTracker';
import { formatBreachTime } from '../../utils/dashboardCalculations';
interface UpcomingDeadlinesSectionProps {
isAdmin: boolean;
@ -67,9 +67,8 @@ export function UpcomingDeadlinesSection({
<span className="font-semibold text-xs sm:text-sm">{deadline.requestNumber}</span>
<Badge
variant="outline"
className={`text-xs ${
deadline.priority === 'express' ? 'bg-orange-50 text-orange-700' : 'bg-blue-50 text-blue-700'
}`}
className={`text-xs ${deadline.priority === 'express' ? 'bg-orange-50 text-orange-700' : 'bg-blue-50 text-blue-700'
}`}
>
{deadline.priority}
</Badge>
@ -85,9 +84,8 @@ export function UpcomingDeadlinesSection({
<div className="text-right flex-shrink-0">
<p className="text-xs text-muted-foreground">TAT Used</p>
<p
className={`text-base sm:text-lg font-bold ${
tatPercentage >= 80 ? 'text-red-600' : tatPercentage >= 50 ? 'text-orange-600' : 'text-green-600'
}`}
className={`text-base sm:text-lg font-bold ${tatPercentage >= 80 ? 'text-red-600' : tatPercentage >= 50 ? 'text-orange-600' : 'text-green-600'
}`}
>
{tatPercentage.toFixed(0)}%
</p>
@ -96,13 +94,12 @@ export function UpcomingDeadlinesSection({
<div className="space-y-1">
<Progress
value={tatPercentage}
className={`h-1.5 sm:h-2 ${
tatPercentage >= 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100'
}`}
className={`h-1.5 sm:h-2 ${tatPercentage >= 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100'
}`}
/>
<div className="flex justify-between text-xs text-muted-foreground">
<span>{formatHoursMinutes(elapsedHours)} elapsed</span>
<span>{formatHoursMinutes(remainingHours)} left</span>
<span>{formatBreachTime(elapsedHours)} elapsed</span>
<span>{formatBreachTime(Math.abs(remainingHours))} {remainingHours < 0 ? 'overdue' : 'left'}</span>
</div>
</div>
</div>

View File

@ -54,8 +54,8 @@ export function UserKPICards({
onNavigate?.(`/approver-performance?${params.toString()}`);
};
const successRate = kpis && kpis.requestVolume.totalRequests > 0
? ((kpis.requestVolume.approvedRequests / kpis.requestVolume.totalRequests) * 100)
const successRate = kpis && kpis.requestVolume.totalRequests > 0
? ((kpis.requestVolume.approvedRequests / kpis.requestVolume.totalRequests) * 100)
: 0;
return (
@ -70,7 +70,7 @@ export function UserKPICards({
testId="kpi-my-requests"
onClick={() => onKPIClick(getFilterParams())}
>
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
<div className="grid grid-cols-2 gap-1.5 sm:gap-2">
<StatCard
label="Approved"
value={kpis?.requestVolume.approvedRequests || 0}
@ -115,17 +115,6 @@ export function UserKPICards({
onKPIClick({ ...getFilterParams(), status: 'rejected' });
}}
/>
<StatCard
label="Closed"
value={kpis?.requestVolume.closedRequests || 0}
bgColor="bg-blue-50"
textColor="text-blue-600"
testId="stat-user-closed"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'closed' });
}}
/>
</div>
</KPICard>
@ -218,8 +207,8 @@ export function UserKPICards({
>
<div className="space-y-4 mt-3 flex flex-col flex-1">
<div className="space-y-3">
<Progress
value={successRate}
<Progress
value={successRate}
className="h-4 bg-gray-200 [&>div]:bg-green-600"
data-testid="success-rate-progress"
/>

View File

@ -24,7 +24,7 @@ interface MyRequestsProps {
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
const { user } = useAuth();
// Data fetching hook
const myRequests = useMyRequests({ itemsPerPage: 10 });
@ -38,9 +38,10 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter,
lifecycleFilter: filters.lifecycleFilter,
});
const hasInitialFetchRun = useRef(false);
// Initial fetch on mount - use stored page from Redux
useEffect(() => {
const storedPage = filters.currentPage || 1;
@ -49,46 +50,50 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
});
hasInitialFetchRun.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only on mount
// Track filter changes and refetch
useEffect(() => {
if (!hasInitialFetchRun.current) return;
const prev = prevFiltersRef.current;
const hasChanged =
const hasChanged =
prev.searchTerm !== filters.searchTerm ||
prev.statusFilter !== filters.statusFilter ||
prev.priorityFilter !== filters.priorityFilter ||
prev.templateTypeFilter !== filters.templateTypeFilter;
prev.templateTypeFilter !== filters.templateTypeFilter ||
prev.lifecycleFilter !== filters.lifecycleFilter;
if (!hasChanged) return; // No actual change, skip
// Debounce search
const timeoutId = setTimeout(() => {
filters.setCurrentPage(1); // Reset to page 1 when filters change
fetchRef.current(1, {
fetchRef.current(1, {
search: filters.searchTerm || undefined,
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
});
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
});
// Update previous values
prevFiltersRef.current = {
searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter,
priorityFilter: filters.priorityFilter,
templateTypeFilter: filters.templateTypeFilter,
lifecycleFilter: filters.lifecycleFilter,
};
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter]);
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter]);
// State for backend stats (calculated from entire dataset via SQL queries)
const [backendStats, setBackendStats] = useState<{
@ -111,7 +116,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
try {
setLoadingStats(true);
// Use backend stats API - explicitly filter by user's initiator_id
// This ensures "My Requests" only shows requests where user is the initiator
// Even for admin users, we want to see only their own requests in "My Requests"
@ -131,7 +136,8 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
undefined, // approverType
filters.searchTerm || undefined,
undefined, // slaCompliance
true // viewAsUser - treat as normal user even if admin
true, // viewAsUser - treat as normal user even if admin
filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined // lifecycle
);
setBackendStats({
@ -149,7 +155,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
} finally {
setLoadingStats(false);
}
}, [user?.userId, filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter]); // Exclude statusFilter - stats don't change when only status changes
}, [user?.userId, filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter]); // Exclude statusFilter - stats don't change when only status changes
// Fetch stats when filters change (excluding status filter)
// Stats should reflect priority and search filters, but NOT status filter
@ -160,7 +166,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
}, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId);
}, [filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, fetchBackendStats]); // Exclude statusFilter - stats don't change when only status changes
}, [filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter, fetchBackendStats]); // Exclude statusFilter - stats don't change when only status changes
// Handle dynamic requests (fallback until API loads)
const convertedDynamicRequests = transformRequests(dynamicRequests);
@ -181,7 +187,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
closed: backendStats.closed || 0,
};
}
// Fallback: if stats haven't loaded yet, show zeros
return {
total: 0,
@ -204,6 +210,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
});
}
},
@ -226,8 +233,8 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
/>
{/* Stats Overview */}
<MyRequestsStatsSection
stats={stats}
<MyRequestsStatsSection
stats={stats}
onStatusFilter={(status) => {
filters.setStatusFilter(status);
}}
@ -243,6 +250,8 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
onStatusChange={filters.setStatusFilter}
onPriorityChange={filters.setPriorityFilter}
onTemplateTypeChange={filters.setTemplateTypeFilter}
lifecycleFilter={filters.lifecycleFilter}
onLifecycleChange={filters.setLifecycleFilter}
/>
{/* Requests List */}

View File

@ -12,10 +12,12 @@ interface MyRequestsFiltersProps {
statusFilter: string;
priorityFilter: string;
templateTypeFilter: string;
lifecycleFilter: string;
onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void;
onPriorityChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void;
onLifecycleChange: (value: string) => void;
}
export function MyRequestsFilters({
@ -23,10 +25,12 @@ export function MyRequestsFilters({
statusFilter,
priorityFilter,
// templateTypeFilter,
lifecycleFilter, // Destructure new prop
onSearchChange,
onStatusChange,
onPriorityChange,
// onTemplateTypeChange,
onLifecycleChange, // Destructure new prop
}: MyRequestsFiltersProps) {
return (
<Card className="border-gray-200" data-testid="my-requests-filters">
@ -44,6 +48,21 @@ export function MyRequestsFilters({
</div>
<div className="flex gap-2 sm:gap-3 w-full md:w-auto">
{/* Lifecycle Filter */}
<Select value={lifecycleFilter} onValueChange={onLifecycleChange}>
<SelectTrigger
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
data-testid="lifecycle-filter"
>
<SelectValue placeholder="Lifecycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Requests</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
@ -58,7 +77,6 @@ export function MyRequestsFilters({
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>

View File

@ -2,7 +2,7 @@
* My Requests Stats Section Component
*/
import { FileText, Clock, Pause, CheckCircle, XCircle, Edit, Archive } from 'lucide-react';
import { FileText, Clock, Pause, CheckCircle, XCircle, Edit } from 'lucide-react';
import { StatsCard } from '@/components/dashboard/StatsCard';
import { MyRequestsStats } from '../types/myRequests.types';
@ -18,7 +18,7 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
}
};
return (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3 sm:gap-4" data-testid="my-requests-stats">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="my-requests-stats">
<StatsCard
label="Total"
value={stats.total}
@ -90,18 +90,6 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
testId="stat-draft"
onClick={onStatusFilter ? () => handleCardClick('draft') : undefined}
/>
<StatsCard
label="Closed"
value={stats.closed}
icon={Archive}
iconColor="text-purple-600"
gradient="bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200"
textColor="text-purple-700"
valueColor="text-purple-900"
testId="stat-closed"
onClick={onStatusFilter ? () => handleCardClick('closed') : undefined}
/>
</div>
);
}

View File

@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { ArrowRight, User, TrendingUp, Clock, Pause } from 'lucide-react';
import { motion } from 'framer-motion';
import { MyRequest } from '../types/myRequests.types';
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
/**
@ -16,23 +16,23 @@ import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
*/
const stripHtmlTags = (html: string): string => {
if (!html) return '';
// Check if we're in a browser environment
if (typeof document === 'undefined') {
// Fallback for SSR: use regex to strip HTML tags
return html.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
}
// Create a temporary div to parse HTML
const tempDiv = document.createElement('div');
tempDiv.innerHTML = html;
// Get text content (automatically strips HTML tags)
let text = tempDiv.textContent || tempDiv.innerText || '';
// Clean up extra whitespace
text = text.replace(/\s+/g, ' ').trim();
return text;
};
@ -44,6 +44,7 @@ interface RequestCardProps {
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
const statusConfig = getStatusConfig(request.status);
const stateConfig = getWorkflowStateConfig(request.workflowState || (request.status === 'draft' ? 'DRAFT' : 'OPEN'));
const priorityConfig = getPriorityConfig(request.priority);
const StatusIcon = statusConfig.icon;
const PriorityIcon = priorityConfig.icon;
@ -79,6 +80,15 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<StatusIcon className="w-3 h-3 mr-1" />
<span className="capitalize">{request.status}</span>
</Badge>
{stateConfig.label.toLowerCase() !== request.status.toLowerCase() && (
<Badge
variant="outline"
className={`${stateConfig.color} border font-medium text-xs shrink-0`}
data-testid="state-badge"
>
<span className="capitalize">{stateConfig.label}</span>
</Badge>
)}
{(request.pauseInfo?.isPaused || (request as any).isPaused) && (
<Badge
variant="outline"
@ -101,18 +111,18 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
{(() => {
const templateType = request?.templateType || (request as any)?.template_type || '';
const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType
let templateLabel = 'Non-Templatized';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Dealer Claim';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template';
}
return (
<Badge
variant="outline"

View File

@ -14,6 +14,7 @@ interface UseMyRequestsOptions {
status?: string;
priority?: string;
templateType?: string;
lifecycle?: string;
};
}
@ -29,7 +30,7 @@ export function useMyRequests({ itemsPerPage = 10 }: UseMyRequestsOptions = {})
});
const fetchMyRequests = useCallback(
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string }) => {
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string, lifecycle?: string }) => {
try {
if (page === 1) {
setLoading(true);
@ -43,6 +44,7 @@ export function useMyRequests({ itemsPerPage = 10 }: UseMyRequestsOptions = {})
status: filters?.status,
priority: filters?.priority,
templateType: filters?.templateType,
lifecycle: filters?.lifecycle,
});
// Extract data - workflowApi now returns { data: [], pagination: {} }

View File

@ -11,6 +11,7 @@ import {
setPriorityFilter as setPriorityFilterAction,
setTemplateTypeFilter as setTemplateTypeFilterAction,
setCurrentPage as setCurrentPageAction,
setLifecycleFilter as setLifecycleFilterAction,
clearFilters as clearFiltersAction,
} from '../redux/myRequestsSlice';
@ -23,16 +24,17 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
const dispatch = useAppDispatch();
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const isInitialMount = useRef(true);
// Get filters from Redux
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, currentPage } = useAppSelector((state) => state.myRequests);
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, currentPage, lifecycleFilter } = useAppSelector((state) => state.myRequests);
// Create setters that dispatch Redux actions
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
const setStatusFilter = useCallback((value: string) => dispatch(setStatusFilterAction(value)), [dispatch]);
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]);
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
const setLifecycleFilter = useCallback((value: string) => dispatch(setLifecycleFilterAction(value)), [dispatch]);
const getFilters = useCallback((): MyRequestsFilters => {
return {
@ -40,8 +42,9 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
status: statusFilter,
priority: priorityFilter,
templateType: templateTypeFilter,
lifecycle: lifecycleFilter,
};
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter]);
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, lifecycleFilter]);
// Debounced filter change handler
useEffect(() => {
@ -50,7 +53,7 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
isInitialMount.current = false;
return;
}
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
@ -68,7 +71,7 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
clearTimeout(debounceTimeoutRef.current);
}
};
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, onFiltersChange, getFilters, debounceMs]);
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, lifecycleFilter, onFiltersChange, getFilters, debounceMs]);
const resetFilters = useCallback(() => {
dispatch(clearFiltersAction());
@ -80,11 +83,13 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
priorityFilter,
templateTypeFilter,
currentPage,
lifecycleFilter,
setSearchTerm,
setStatusFilter,
setPriorityFilter,
setTemplateTypeFilter,
setCurrentPage,
setLifecycleFilter,
getFilters,
resetFilters,
};

View File

@ -6,6 +6,7 @@ export interface MyRequestsFiltersState {
priorityFilter: string;
templateTypeFilter: string;
currentPage: number;
lifecycleFilter: string;
}
const initialState: MyRequestsFiltersState = {
@ -14,6 +15,7 @@ const initialState: MyRequestsFiltersState = {
priorityFilter: 'all',
templateTypeFilter: 'all',
currentPage: 1,
lifecycleFilter: 'all',
};
const myRequestsSlice = createSlice({
@ -37,12 +39,16 @@ const myRequestsSlice = createSlice({
setCurrentPage: (state, action: PayloadAction<number>) => {
state.currentPage = action.payload;
},
setLifecycleFilter: (state, action: PayloadAction<string>) => {
state.lifecycleFilter = action.payload;
},
clearFilters: (state) => {
state.searchTerm = '';
state.statusFilter = 'all';
state.priorityFilter = 'all';
state.templateTypeFilter = 'all';
state.currentPage = 1;
state.lifecycleFilter = 'all';
},
},
});
@ -53,6 +59,7 @@ export const {
setPriorityFilter,
setTemplateTypeFilter,
setCurrentPage,
setLifecycleFilter,
clearFilters,
} = myRequestsSlice.actions;

View File

@ -17,6 +17,7 @@ export interface MyRequest {
approverLevel?: string;
templateType?: string;
workflowType?: string;
workflowState?: string;
templateName?: string;
pauseInfo?: {
isPaused: boolean;
@ -41,6 +42,7 @@ export interface MyRequestsFilters {
status: string;
priority: string;
templateType?: string;
lifecycle?: string;
}
export interface PaginationState {

View File

@ -87,3 +87,25 @@ export function getStatusConfig(status: string): StatusConfig {
}
}
export function getWorkflowStateConfig(state: string) {
const s = (state || '').toUpperCase();
switch (s) {
case 'CLOSED':
return {
color: 'bg-slate-100 text-slate-800 border-slate-200',
label: 'closed',
};
case 'DRAFT':
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'draft',
};
case 'OPEN':
default:
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'open',
};
}
}

View File

@ -31,6 +31,7 @@ export function transformRequest(req: any): MyRequest {
: '—',
templateType: req.templateType || req.template_type,
workflowType: req.workflowType || req.workflow_type,
workflowState: req.workflowState || req.workflow_state,
templateName: req.templateName || req.template_name,
};
}

View File

@ -57,20 +57,20 @@ export function QuickActionsSidebar({
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
const [loadingRecipients, setLoadingRecipients] = useState(false);
const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false);
const isClosed = request?.status === 'closed';
const isClosed = apiRequest?.workflowState === 'CLOSED' || 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
@ -80,26 +80,26 @@ export function QuickActionsSidebar({
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: 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
@ -332,7 +332,7 @@ export function QuickActionsSidebar({
.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">

View File

@ -5,7 +5,7 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, FileText, RefreshCw, Share2 } from 'lucide-react';
import { getPriorityConfig, getStatusConfig } from '@/utils/requestDetailHelpers';
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '@/utils/requestDetailHelpers';
import { SLAProgressBar } from '@/components/sla/SLAProgressBar';
interface RequestDetailHeaderProps {
@ -20,18 +20,19 @@ interface RequestDetailHeaderProps {
isPaused?: boolean; // Pass pause status from module
}
export function RequestDetailHeader({
request,
refreshing,
onBack,
onRefresh,
onShareSummary,
export function RequestDetailHeader({
request,
refreshing,
onBack,
onRefresh,
onShareSummary,
isInitiator,
slaData, // Module passes prepared SLA data
isPaused = false // Module passes pause status
}: RequestDetailHeaderProps) {
const priorityConfig = getPriorityConfig(request?.priority || 'standard');
const statusConfig = getStatusConfig(request?.status || 'pending');
const stateConfig = getWorkflowStateConfig(request?.workflowState || (request?.status === 'DRAFT' ? 'DRAFT' : 'OPEN'));
return (
<div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header">
@ -77,31 +78,40 @@ export function RequestDetailHeader({
>
{statusConfig.label}
</Badge>
{stateConfig.label.toLowerCase() !== (request?.status || '').toLowerCase() && (
<Badge
className={`${stateConfig.color} rounded-full px-2 sm:px-3 text-xs capitalize shrink-0`}
variant="outline"
data-testid="state-badge"
>
{stateConfig.label}
</Badge>
)}
{/* Template Type Badge */}
{(() => {
const workflowType = request?.workflowType || request?.workflow_type;
const templateType = request?.templateType || request?.template_type || '';
const templateTypeUpper = templateType?.toUpperCase() || '';
// Check for dealer claim - support multiple formats
const isDealerClaim =
workflowType === 'CLAIM_MANAGEMENT' ||
const isDealerClaim =
workflowType === 'CLAIM_MANAGEMENT' ||
workflowType === 'DEALER_CLAIM' ||
templateType === 'claim-management' ||
templateTypeUpper === 'DEALER CLAIM' ||
templateTypeUpper === 'DEALER_CLAIM';
// Direct mapping from templateType
let templateLabel = 'Non-Templatized';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
if (isDealerClaim) {
templateLabel = 'Dealer Claim';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template';
}
return (
<Badge
className={`${templateColor} rounded-full px-2 sm:px-3 text-xs shrink-0`}
@ -120,7 +130,7 @@ export function RequestDetailHeader({
{/* Action Buttons */}
<div className="flex items-center gap-2">
{/* Share Summary Button - Only show for closed requests if user is initiator */}
{onShareSummary && isInitiator && request?.status?.toLowerCase() === 'closed' && (
{onShareSummary && isInitiator && request?.workflowState?.toLowerCase() === 'closed' && (
<Button
variant="default"
size="sm"
@ -157,14 +167,13 @@ export function RequestDetailHeader({
{/* SLA Progress Section - Plug-and-play: Module passes prepared SLA data */}
{slaData !== undefined && (
<div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${
isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50'
}`} data-testid="sla-section">
<SLAProgressBar
sla={slaData}
requestStatus={request.status}
<div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50'
}`} data-testid="sla-section">
<SLAProgressBar
sla={slaData}
requestStatus={request.status}
isPaused={isPaused}
testId="request-sla"
testId="request-sla"
/>
</div>
)}

View File

@ -35,6 +35,7 @@ interface OverviewTabProps {
generationAttempts?: number;
generationFailed?: boolean;
maxAttemptsReached?: boolean;
isClosed?: boolean;
}
export function OverviewTab({
@ -57,6 +58,7 @@ export function OverviewTab({
generationAttempts = 0,
generationFailed = false,
maxAttemptsReached = false,
isClosed = false,
}: OverviewTabProps) {
void _onPause; // Marked as intentionally unused - available for future use
const { user } = useAuth();
@ -64,10 +66,10 @@ export function OverviewTab({
const isPaused = pauseInfo?.isPaused || false;
const pausedByUserId = pauseInfo?.pausedBy?.userId;
const currentUserId = (user as any)?.userId || '';
// Resume: Can be done by both initiator and approver
const canResume = isPaused && onResume && (currentUserIsApprover || isInitiator);
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
@ -122,8 +124,8 @@ export function OverviewTab({
<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">
<FormattedDescription
content={request.description || ''}
<FormattedDescription
content={request.description || ''}
className="text-xs sm:text-sm"
/>
</div>
@ -187,14 +189,14 @@ export function OverviewTab({
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pauseReason}</p>
</div>
)}
{pauseInfo.pausedBy && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused By</label>
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pausedBy.name || pauseInfo.pausedBy.email}</p>
</div>
)}
{pauseInfo.pauseResumeDate && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Auto-Resume Date</label>
@ -208,7 +210,7 @@ export function OverviewTab({
</p>
</div>
)}
{pauseInfo.pausedAt && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused At</label>
@ -289,8 +291,8 @@ export function OverviewTab({
<div className="pt-4 border-t border-gray-300">
<label className="text-xs font-medium text-gray-500 uppercase tracking-wide">Request Description</label>
<div className="mt-2 bg-gray-50 p-3 rounded-lg">
<FormattedDescription
content={request.claimDetails.requestDescription}
<FormattedDescription
content={request.claimDetails.requestDescription}
className="text-sm"
/>
</div>
@ -301,7 +303,7 @@ export function OverviewTab({
)}
{/* Read-Only Conclusion Remark */}
{request.status === 'closed' && request.conclusionRemark && (
{isClosed && 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">
@ -312,8 +314,8 @@ export function OverviewTab({
</CardHeader>
<CardContent className="pt-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<FormattedDescription
content={request.conclusionRemark || ''}
<FormattedDescription
content={request.conclusionRemark || ''}
className="text-sm"
/>
</div>
@ -331,23 +333,20 @@ export function OverviewTab({
{/* Conclusion Remark Section */}
{needsClosure && (
<Card data-testid="conclusion-remark-card">
<CardHeader className={`bg-gradient-to-r border-b ${
request.status === 'rejected'
? 'from-red-50 to-rose-50 border-red-200'
<CardHeader className={`bg-gradient-to-r border-b ${request.status === 'rejected'
? 'from-red-50 to-rose-50 border-red-200'
: 'from-green-50 to-emerald-50 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 ${
request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
}`}>
<CheckCircle className={`w-5 h-5 ${
request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
}`} />
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
}`}>
<CheckCircle className={`w-5 h-5 ${request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
}`} />
Conclusion Remark - Final Step
</CardTitle>
<CardDescription className="mt-1 text-xs sm:text-sm">
{request.status === 'rejected'
{request.status === 'rejected'
? 'This request was rejected. Please review the AI-generated closure remark and finalize it to close this request.'
: 'All approvals are complete. Please review and finalize the conclusion to close this request.'}
</CardDescription>
@ -365,7 +364,7 @@ export function OverviewTab({
{aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button>
{aiGenerated && !maxAttemptsReached && !generationFailed && (
<span className="text-[10px] text-gray-500 font-medium px-1">
<span className="text-[10px] text-gray-500 font-medium px-1">
{2 - generationAttempts} attempts remaining
</span>
)}

View File

@ -163,7 +163,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
}).length;
const closed = filteredData.filter((r: any) => {
const status = (r.status || '').toString().toUpperCase();
return status === 'CLOSED';
const state = (r.workflowState || '').toString().toUpperCase();
return (status === 'CLOSED' || state === 'CLOSED') && status !== 'APPROVED' && status !== 'REJECTED';
}).length;
setBackendStats({
@ -396,6 +397,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
dateRange: filters.dateRange,
customStartDate: filters.customStartDate,
customEndDate: filters.customEndDate,
lifecycleFilter: filters.lifecycleFilter,
isOrgLevel,
});
const hasInitialFetchRun = useRef(false);
@ -426,6 +428,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
prev.dateRange !== filters.dateRange ||
prev.customStartDate !== filters.customStartDate ||
prev.customEndDate !== filters.customEndDate ||
prev.lifecycleFilter !== filters.lifecycleFilter ||
prev.isOrgLevel !== isOrgLevel;
if (!hasChanged) return;
@ -447,6 +450,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
dateRange: filters.dateRange,
customStartDate: filters.customStartDate,
customEndDate: filters.customEndDate,
lifecycleFilter: filters.lifecycleFilter,
isOrgLevel,
};
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
@ -466,7 +470,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
filters.approverFilterType,
filters.dateRange,
filters.customStartDate,
filters.customEndDate
filters.customEndDate,
filters.lifecycleFilter
]);
// Page change handler
@ -553,8 +558,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
<Separator />
{/* Primary Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
<div className="relative md:col-span-3 lg:col-span-1">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3 sm:gap-4">
<div className="relative md:col-span-2 lg:col-span-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search requests..."
@ -565,6 +570,17 @@ export function Requests({ onViewRequest }: RequestsProps) {
/>
</div>
<Select value={filters.lifecycleFilter} onValueChange={filters.setLifecycleFilter}>
<SelectTrigger className="h-10" data-testid="lifecycle-filter">
<SelectValue placeholder="Lifecycle" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Requests</SelectItem>
<SelectItem value="open">Open</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}>
<SelectTrigger className="h-10" data-testid="status-filter">
<SelectValue placeholder="All Status" />
@ -575,7 +591,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
@ -650,7 +665,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
) : (
<>
<Input
placeholder="Search initiator..."
placeholder="Use @ to search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => {
@ -720,7 +735,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
) : (
<>
<Input
placeholder="Search approver..."
placeholder="Use @ to search approver..."
value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => {

View File

@ -58,7 +58,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Determine once - use this throughout instead of checking repeatedly
const isDealer = userFilterType === 'DEALER';
// Helper to get filters for API - excludes dealer-restricted filters
// Since we know user type initially, this helper uses that knowledge
const getFiltersForApi = useCallback(() => {
@ -70,7 +70,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
}
return filterOptions;
}, [filters, isDealer]);
// Helper to calculate active filters count based on user type
const calculateActiveFiltersCount = useCallback(() => {
if (isDealer) {
@ -120,16 +120,16 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
// Stats reflect all filters EXCEPT status - total stays stable when only status changes
const fetchBackendStats = useCallback(async (
statsDateRange?: DateRange,
statsStartDate?: Date,
statsDateRange?: DateRange,
statsStartDate?: Date,
statsEndDate?: Date,
filtersWithoutStatus?: {
priority?: string;
filtersWithoutStatus?: {
priority?: string;
templateType?: string;
department?: string;
initiator?: string;
approver?: string;
approverType?: 'current' | 'any';
department?: string;
initiator?: string;
approver?: string;
approverType?: 'current' | 'any';
search?: string;
slaCompliance?: string;
}
@ -199,7 +199,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
const filtersRef = useRef(filters);
const fetchBackendStatsRef = useRef(fetchBackendStats);
const getFiltersForApiRef = useRef(getFiltersForApi);
// Update refs on each render
useEffect(() => {
filtersRef.current = filters;
@ -275,7 +275,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
search: filters.searchTerm || undefined,
};
// Only include priority, templateType, department, and slaCompliance if user is not a dealer
if (!isDealer) {
if (filters.priorityFilter !== 'all') filtersWithoutStatus.priority = filters.priorityFilter;
@ -283,13 +283,13 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
if (filters.departmentFilter !== 'all') filtersWithoutStatus.department = filters.departmentFilter;
if (filters.slaComplianceFilter !== 'all') filtersWithoutStatus.slaCompliance = filters.slaComplianceFilter;
}
// Use 'all' if dateRange is 'all', otherwise use the selected dateRange or default to 'month'
const statsDateRange = filters.dateRange === 'all' ? 'all' : (filters.dateRange || 'month');
fetchBackendStatsRef.current(
statsDateRange,
filters.customStartDate,
statsDateRange,
filters.customStartDate,
filters.customEndDate,
filtersWithoutStatus
);
@ -327,9 +327,10 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
dateRange: filters.dateRange,
customStartDate: filters.customStartDate,
customEndDate: filters.customEndDate,
lifecycleFilter: filters.lifecycleFilter,
});
const hasInitialFetchRun = useRef(false);
// Initial fetch on mount - use stored page from Redux
useEffect(() => {
const storedPage = filters.currentPage || 1;
@ -337,13 +338,13 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
hasInitialFetchRun.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Only on mount
// Fetch when filters change
useEffect(() => {
if (!hasInitialFetchRun.current) return;
const prev = prevFiltersRef.current;
const hasChanged =
const hasChanged =
prev.searchTerm !== filters.searchTerm ||
prev.statusFilter !== filters.statusFilter ||
prev.priorityFilter !== filters.priorityFilter ||
@ -355,14 +356,15 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
prev.approverFilterType !== filters.approverFilterType ||
prev.dateRange !== filters.dateRange ||
prev.customStartDate !== filters.customStartDate ||
prev.customEndDate !== filters.customEndDate;
prev.customEndDate !== filters.customEndDate ||
prev.lifecycleFilter !== filters.lifecycleFilter;
if (!hasChanged) return;
const timeoutId = setTimeout(() => {
filters.setCurrentPage(1);
fetchRequests(1);
prevFiltersRef.current = {
searchTerm: filters.searchTerm,
statusFilter: filters.statusFilter,
@ -376,6 +378,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
dateRange: filters.dateRange,
customStartDate: filters.customStartDate,
customEndDate: filters.customEndDate,
lifecycleFilter: filters.lifecycleFilter,
};
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
@ -393,7 +396,9 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
filters.approverFilterType,
filters.dateRange,
filters.customStartDate,
filters.customEndDate
filters.customStartDate,
filters.customEndDate,
filters.lifecycleFilter
]);
// Page change handler
@ -406,7 +411,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Transform requests
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
// Calculate stats - Use backend stats API (OPTIMIZED)
const stats = useMemo(() => {
// Use backend stats if available
@ -421,38 +426,38 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
closed: backendStats.closed || 0
};
}
// Fallback: calculate from current page (less accurate, but works during initial load)
const pending = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'pending' || status === 'in-progress';
}).length;
const pending = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'pending' || status === 'in-progress';
}).length;
const paused = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'paused';
}).length;
const approved = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'approved';
}).length;
const rejected = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'rejected';
}).length;
const closed = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'closed';
}).length;
return {
const approved = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'approved';
}).length;
const rejected = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'rejected';
}).length;
const closed = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'closed';
}).length;
return {
total: totalRecords > 0 ? totalRecords : convertedRequests.length,
pending,
pending,
paused,
approved,
rejected,
draft: 0,
closed
};
approved,
rejected,
draft: 0,
closed
};
}, [backendStats, totalRecords, convertedRequests]);
return (
@ -467,8 +472,8 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
/>
{/* Stats */}
<RequestsStats
stats={stats}
<RequestsStats
stats={stats}
onStatusFilter={(status) => {
filters.setStatusFilter(status);
}}
@ -477,6 +482,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
{/* Filters - Plug-and-play pattern */}
<UserAllRequestsFiltersComponent
searchTerm={filters.searchTerm}
lifecycleFilter={filters.lifecycleFilter}
statusFilter={filters.statusFilter}
priorityFilter={filters.priorityFilter}
templateTypeFilter={filters.templateTypeFilter}
@ -494,6 +500,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
initiatorSearch={initiatorSearch}
approverSearch={approverSearch}
onSearchChange={filters.setSearchTerm}
onLifecycleChange={filters.setLifecycleFilter}
onStatusChange={filters.setStatusFilter}
onPriorityChange={filters.setPriorityFilter}
onTemplateTypeChange={filters.setTemplateTypeFilter}

View File

@ -6,7 +6,7 @@ import { motion } from 'framer-motion';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { User, ArrowRight, TrendingUp, Clock, Pause } from 'lucide-react';
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers';
import type { ConvertedRequest } from '../types/requests.types';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
@ -43,6 +43,7 @@ interface RequestCardProps {
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
const statusConfig = getStatusConfig(request.status);
const stateConfig = getWorkflowStateConfig(request.workflowState || (request.status === 'draft' ? 'DRAFT' : 'OPEN'));
const priorityConfig = getPriorityConfig(request.priority);
const StatusIcon = statusConfig.icon;
const PriorityIcon = priorityConfig.icon;
@ -78,6 +79,15 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<StatusIcon className="w-3 h-3 mr-1" />
<span className="capitalize">{request.status}</span>
</Badge>
{stateConfig.label.toLowerCase() !== (request.status || '').toLowerCase() && (
<Badge
variant="outline"
className={`${stateConfig.color} border font-medium text-xs shrink-0`}
data-testid="state-badge"
>
<span className="capitalize">{stateConfig.label}</span>
</Badge>
)}
{((request as any).pauseInfo?.isPaused || (request as any).isPaused) && (
<Badge
variant="outline"

View File

@ -3,7 +3,7 @@
* Displays statistics cards for requests with click handlers to filter
*/
import { FileText, Clock, Pause, CheckCircle, XCircle, Archive } from 'lucide-react';
import { FileText, Clock, Pause, CheckCircle, XCircle } from 'lucide-react';
import { StatsCard } from '@/components/dashboard/StatsCard';
import type { RequestStats } from '../types/requests.types';
@ -20,7 +20,7 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
};
return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="requests-stats">
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4" data-testid="requests-stats">
<StatsCard
label="Total"
value={stats.total}
@ -80,18 +80,6 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
testId="stat-rejected"
onClick={onStatusFilter ? () => handleCardClick('rejected') : undefined}
/>
<StatsCard
label="Closed"
value={stats.closed}
icon={Archive}
iconColor="text-purple-600"
gradient="bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200"
textColor="text-purple-700"
valueColor="text-purple-900"
testId="stat-closed"
onClick={onStatusFilter ? () => handleCardClick('closed') : undefined}
/>
</div>
);
}

View File

@ -22,6 +22,7 @@ import {
setCustomEndDate as setCustomEndDateAction,
setShowCustomDatePicker as setShowCustomDatePickerAction,
setCurrentPage as setCurrentPageAction,
setLifecycleFilter as setLifecycleFilterAction,
clearFilters as clearFiltersAction,
} from '../redux/requestsSlice';
@ -44,6 +45,7 @@ export function useRequestsFilters() {
customEndDate,
showCustomDatePicker,
currentPage,
lifecycleFilter,
} = useAppSelector((state) => state.requests);
// Create setters that dispatch Redux actions
@ -61,6 +63,7 @@ export function useRequestsFilters() {
const setCustomEndDate = useCallback((value: Date | undefined) => dispatch(setCustomEndDateAction(value)), [dispatch]);
const setShowCustomDatePicker = useCallback((value: boolean) => dispatch(setShowCustomDatePickerAction(value)), [dispatch]);
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
const setLifecycleFilter = useCallback((value: string) => dispatch(setLifecycleFilterAction(value)), [dispatch]);
const getFilters = useCallback((): RequestFilters => {
return {
@ -73,6 +76,7 @@ export function useRequestsFilters() {
initiator: initiatorFilter !== 'all' ? initiatorFilter : undefined,
approver: approverFilter !== 'all' ? approverFilter : undefined,
approverType: approverFilter !== 'all' ? approverFilterType : undefined,
lifecycle: lifecycleFilter !== 'all' ? lifecycleFilter : undefined,
dateRange,
startDate: customStartDate,
endDate: customEndDate
@ -87,6 +91,7 @@ export function useRequestsFilters() {
initiatorFilter,
approverFilter,
approverFilterType,
lifecycleFilter, // Ensure lifecycleFilter is in dependencies
dateRange,
customStartDate,
customEndDate
@ -128,6 +133,7 @@ export function useRequestsFilters() {
departmentFilter !== 'all' ||
initiatorFilter !== 'all' ||
approverFilter !== 'all' ||
lifecycleFilter !== 'all' ||
dateRange !== 'all' ||
customStartDate ||
customEndDate
@ -147,6 +153,7 @@ export function useRequestsFilters() {
dateRange,
customStartDate,
customEndDate,
lifecycleFilter,
showCustomDatePicker,
currentPage,
hasActiveFilters,
@ -165,6 +172,7 @@ export function useRequestsFilters() {
setCustomEndDate,
setShowCustomDatePicker,
setCurrentPage,
setLifecycleFilter,
// Helpers
getFilters,
clearFilters,

View File

@ -45,14 +45,14 @@ export function useUserSearch({ allUsers, filterValue, onFilterChange }: UseUser
clearTimeout(searchTimer.current);
}
if (!query || query.trim().length < 2) {
if (!query || !query.startsWith('@') || query.trim().length < 2) {
setSearchResults([]);
setShowResults(false);
return;
}
searchTimer.current = setTimeout(() => {
const searchLower = query.toLowerCase().trim();
const searchLower = query.slice(1).toLowerCase().trim();
const filtered = allUsers.filter((user) => {
const email = (user.email || '').toLowerCase();
const displayName = (user.displayName || '').toLowerCase();

View File

@ -16,6 +16,7 @@ export interface RequestsFiltersState {
customEndDate?: Date;
showCustomDatePicker: boolean;
currentPage: number;
lifecycleFilter: string;
}
const initialState: RequestsFiltersState = {
@ -33,6 +34,7 @@ const initialState: RequestsFiltersState = {
customEndDate: undefined,
showCustomDatePicker: false,
currentPage: 1,
lifecycleFilter: 'all',
};
const requestsSlice = createSlice({
@ -81,6 +83,9 @@ const requestsSlice = createSlice({
setCurrentPage: (state, action: PayloadAction<number>) => {
state.currentPage = action.payload;
},
setLifecycleFilter: (state, action: PayloadAction<string>) => {
state.lifecycleFilter = action.payload;
},
clearFilters: (state) => {
state.searchTerm = '';
state.statusFilter = 'all';
@ -96,6 +101,7 @@ const requestsSlice = createSlice({
state.customEndDate = undefined;
state.showCustomDatePicker = false;
state.currentPage = 1;
state.lifecycleFilter = 'all';
},
},
});
@ -115,6 +121,7 @@ export const {
setCustomEndDate,
setShowCustomDatePicker,
setCurrentPage,
setLifecycleFilter,
clearFilters,
} = requestsSlice.actions;

View File

@ -36,6 +36,7 @@ export async function fetchRequestsData({
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
// Fetch paginated data for list display (with status filter)
const pageResult = await workflowApi.listWorkflows({
@ -81,60 +82,61 @@ export async function fetchRequestsData({
totalPages: pagination.totalPages || 1
}
};
} else {
// User-level: Use SEPARATE endpoint for regular users' "All Requests" page
// This shows ALL requests where user is involved:
// - As initiator (created the request)
// - As approver (in any approval level)
// - As participant/spectator
const backendFilters: any = {};
if (filters?.search) backendFilters.search = filters.search;
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType;
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
} else {
// User-level: Use SEPARATE endpoint for regular users' "All Requests" page
// This shows ALL requests where user is involved:
// - As initiator (created the request)
// - As approver (in any approval level)
// - As participant/spectator
const backendFilters: any = {};
if (filters?.search) backendFilters.search = filters.search;
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType;
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
// Fetch paginated data using endpoint for regular users
// This endpoint includes all requests where user is initiator, approver, or participant
const pageResult = await workflowApi.listParticipantRequests({
page,
limit: itemsPerPage,
...backendFilters
});
// Fetch paginated data using endpoint for regular users
// This endpoint includes all requests where user is initiator, approver, or participant
const pageResult = await workflowApi.listParticipantRequests({
page,
limit: itemsPerPage,
...backendFilters
});
let pageData: any[] = [];
if (Array.isArray(pageResult?.data)) {
pageData = pageResult.data;
} else if (Array.isArray(pageResult)) {
pageData = pageResult;
}
// Filter out drafts (backend should handle this, but double-check)
const nonDraftData = pageData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus !== 'DRAFT';
});
// Get pagination info from backend response
const pagination = pageResult?.pagination || {
page,
limit: itemsPerPage,
total: nonDraftData.length,
totalPages: 1
};
return {
data: nonDraftData, // Paginated data for list
allData: [], // Stats come from backend stats API for user-level too
filteredData: nonDraftData, // This is the data for the current page, already filtered
pagination: pagination
};
let pageData: any[] = [];
if (Array.isArray(pageResult?.data)) {
pageData = pageResult.data;
} else if (Array.isArray(pageResult)) {
pageData = pageResult;
}
// Filter out drafts (backend should handle this, but double-check)
const nonDraftData = pageData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus !== 'DRAFT';
});
// Get pagination info from backend response
const pagination = pageResult?.pagination || {
page,
limit: itemsPerPage,
total: nonDraftData.length,
totalPages: 1
};
return {
data: nonDraftData, // Paginated data for list
allData: [], // Stats come from backend stats API for user-level too
filteredData: nonDraftData, // This is the data for the current page, already filtered
pagination: pagination
};
}
}
export async function fetchAllRequestsForExport(isOrgLevel: boolean): Promise<any[]> {

View File

@ -41,6 +41,7 @@ export async function fetchUserParticipantRequestsData({
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
// Use single optimized endpoint - listParticipantRequests now includes initiator requests
// Only fetch the requested page (10 records) for optimal performance
@ -113,6 +114,7 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
// Fetch all pages using the single optimized endpoint
while (hasMore && currentPage <= maxPages) {
@ -150,4 +152,3 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
return allPages;
}

View File

@ -21,6 +21,7 @@ export interface RequestFilters {
dateRange?: DateRange;
startDate?: Date;
endDate?: Date;
lifecycle?: string;
}
export interface RequestStats {
@ -64,6 +65,7 @@ export interface ConvertedRequest {
approverLevel: string;
templateType?: string;
workflowType?: string;
workflowState?: string;
templateName?: string;
}

View File

@ -68,3 +68,25 @@ export const getStatusConfig = (status: string) => {
}
};
export const getWorkflowStateConfig = (state: string) => {
const s = (state || '').toUpperCase();
switch (s) {
case 'CLOSED':
return {
color: 'bg-slate-100 text-slate-800 border-slate-200',
label: 'closed'
};
case 'DRAFT':
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'draft'
};
case 'OPEN':
default:
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'open'
};
}
};

View File

@ -8,21 +8,21 @@ export function transformRequest(req: any): ConvertedRequest {
const createdAt = req.submittedAt || req.submitted_at || req.createdAt || req.created_at;
const priority = (req.priority || '').toString().toLowerCase();
const status = (req.status || '').toString().toUpperCase();
// Extract current approver - handle multiple field name variations
let currentApprover = '—';
let approverLevel = '—';
// Try to get current approver from various possible locations
const currentApproverObj = req.currentApprover || req.current_approver || req.currentApproverData;
if (currentApproverObj) {
// Handle object format: { name, email, approverName, approverEmail, etc. }
currentApprover = currentApproverObj.name ||
currentApproverObj.approverName ||
currentApproverObj.displayName ||
currentApproverObj.email ||
currentApproverObj.approverEmail ||
'—';
currentApprover = currentApproverObj.name ||
currentApproverObj.approverName ||
currentApproverObj.displayName ||
currentApproverObj.email ||
currentApproverObj.approverEmail ||
'—';
} else if (req.approvals && Array.isArray(req.approvals) && req.approvals.length > 0) {
// For completed requests, show the last approver (final approver)
// For active requests, find the current pending/in-progress approver
@ -30,15 +30,15 @@ export function transformRequest(req: any): ConvertedRequest {
const aStatus = (a.status || '').toString().toUpperCase();
return aStatus === 'PENDING' || aStatus === 'IN_PROGRESS';
});
if (activeApproval) {
// Active request - show current approver
currentApprover = activeApproval.approverName ||
activeApproval.approver?.name ||
activeApproval.approver?.displayName ||
activeApproval.approverEmail ||
activeApproval.approver?.email ||
'—';
currentApprover = activeApproval.approverName ||
activeApproval.approver?.name ||
activeApproval.approver?.displayName ||
activeApproval.approverEmail ||
activeApproval.approver?.email ||
'—';
} else {
// Completed request - show final approver (last one in the array, or highest level)
const sortedApprovals = [...req.approvals].sort((a: any, b: any) => {
@ -48,20 +48,20 @@ export function transformRequest(req: any): ConvertedRequest {
});
const finalApproval = sortedApprovals[0];
if (finalApproval) {
currentApprover = finalApproval.approverName ||
finalApproval.approver?.name ||
finalApproval.approver?.displayName ||
finalApproval.approverEmail ||
finalApproval.approver?.email ||
'—';
currentApprover = finalApproval.approverName ||
finalApproval.approver?.name ||
finalApproval.approver?.displayName ||
finalApproval.approverEmail ||
finalApproval.approver?.email ||
'—';
}
}
}
// Extract approval level information - handle multiple field name variations
const currentLevel = req.currentLevel || req.current_level || req.currentLevelNumber || req.current_level_number;
const totalLevels = req.totalLevels || req.total_levels || req.totalLevelsCount || req.total_levels_count;
if (currentLevel && totalLevels) {
approverLevel = `${currentLevel} of ${totalLevels}`;
} else if (req.approvals && Array.isArray(req.approvals) && req.approvals.length > 0) {
@ -70,7 +70,7 @@ export function transformRequest(req: any): ConvertedRequest {
const aStatus = (a.status || '').toString().toUpperCase();
return aStatus === 'PENDING' || aStatus === 'IN_PROGRESS';
});
if (activeApproval) {
const levelNum = activeApproval.levelNumber || activeApproval.level_number || 0;
const total = totalLevels || req.approvals.length;
@ -83,14 +83,14 @@ export function transformRequest(req: any): ConvertedRequest {
// Alternative field names
approverLevel = `${req.currentStep} of ${req.totalSteps}`;
}
return {
id: req.requestNumber || req.request_number || req.requestId || req.id || req.request_id,
requestId: req.requestId || req.id || req.request_id,
displayId: req.requestNumber || req.request_number || req.id,
title: req.title,
description: req.description,
status: status.toLowerCase().replace('_','-'),
status: status.toLowerCase().replace('_', '-'),
priority: priority,
department: req.department || req.initiator?.department,
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
@ -99,6 +99,7 @@ export function transformRequest(req: any): ConvertedRequest {
approverLevel: approverLevel,
templateType: req.templateType || req.template_type,
workflowType: req.workflowType || req.workflow_type,
workflowState: req.workflowState || req.workflow_state,
templateName: req.templateName || req.template_name
};
}

View File

@ -102,6 +102,8 @@ export interface CriticalRequest {
isCritical: boolean;
approverId?: string | null;
approverEmail?: string | null;
isActionable?: boolean;
requestRole?: 'APPROVER' | 'INITIATOR' | 'PARTICIPANT';
}
export interface AIRemarkUtilization {
@ -203,7 +205,8 @@ class DashboardService {
approverType?: 'current' | 'any',
search?: string,
slaCompliance?: string,
viewAsUser?: boolean
viewAsUser?: boolean,
lifecycle?: string
): Promise<RequestStats> {
try {
const params: any = { dateRange };
@ -215,6 +218,9 @@ class DashboardService {
if (status && status !== 'all') {
params.status = status;
}
if (lifecycle && lifecycle !== 'all') {
params.lifecycle = lifecycle;
}
if (priority && priority !== 'all') {
params.priority = priority;
}
@ -314,8 +320,8 @@ class DashboardService {
/**
* Get recent activity feed with pagination
*/
async getRecentActivity(page: number = 1, limit: number = 10, viewAsUser?: boolean): Promise<{
activities: RecentActivity[],
async getRecentActivity(page: number = 1, limit: number = 10, viewAsUser?: boolean): Promise<{
activities: RecentActivity[],
pagination: {
currentPage: number,
totalPages: number,
@ -342,8 +348,8 @@ class DashboardService {
/**
* Get critical requests with pagination
*/
async getCriticalRequests(page: number = 1, limit: number = 10, viewAsUser?: boolean): Promise<{
criticalRequests: CriticalRequest[],
async getCriticalRequests(page: number = 1, limit: number = 10, viewAsUser?: boolean): Promise<{
criticalRequests: CriticalRequest[],
pagination: {
currentPage: number,
totalPages: number,
@ -370,8 +376,8 @@ class DashboardService {
/**
* Get upcoming deadlines with pagination
*/
async getUpcomingDeadlines(page: number = 1, limit: number = 10, viewAsUser?: boolean): Promise<{
deadlines: UpcomingDeadline[],
async getUpcomingDeadlines(page: number = 1, limit: number = 10, viewAsUser?: boolean): Promise<{
deadlines: UpcomingDeadline[],
pagination: {
currentPage: number,
totalPages: number,
@ -454,15 +460,15 @@ class DashboardService {
* Supports priority and SLA filters for consistent stats behavior
*/
async getApproverPerformance(
dateRange?: DateRange,
page: number = 1,
limit: number = 10,
startDate?: Date,
dateRange?: DateRange,
page: number = 1,
limit: number = 10,
startDate?: Date,
endDate?: Date,
priority?: string,
slaCompliance?: string
): Promise<{
performance: ApproverPerformance[],
): Promise<{
performance: ApproverPerformance[],
pagination: {
currentPage: number,
totalPages: number,
@ -471,9 +477,9 @@ class DashboardService {
}
}> {
try {
const params: any = {
dateRange,
page,
const params: any = {
dateRange,
page,
limit: limit || 10 // Explicitly set limit (default 10 if not provided)
};
if (dateRange === 'custom' && startDate && endDate) {
@ -486,9 +492,9 @@ class DashboardService {
if (slaCompliance && slaCompliance !== 'all') {
params.slaCompliance = slaCompliance;
}
console.log('[Dashboard Service] Fetching approver performance with params:', params);
const response = await apiClient.get('/dashboard/stats/approver-performance', { params });
return {
performance: response.data.data,
@ -504,13 +510,13 @@ class DashboardService {
* Get Request Lifecycle Report
*/
async getLifecycleReport(
page: number = 1,
page: number = 1,
limit: number = 50,
dateRange?: DateRange,
startDate?: Date,
endDate?: Date
): Promise<{
lifecycleData: any[],
): Promise<{
lifecycleData: any[],
pagination: {
currentPage: number,
totalPages: number,
@ -540,7 +546,7 @@ class DashboardService {
* Get enhanced User Activity Log Report
*/
async getActivityLogReport(
page: number = 1,
page: number = 1,
limit: number = 50,
dateRange?: DateRange,
filterUserId?: string,
@ -549,8 +555,8 @@ class DashboardService {
filterSeverity?: string,
startDate?: Date,
endDate?: Date
): Promise<{
activities: any[],
): Promise<{
activities: any[],
pagination: {
currentPage: number,
totalPages: number,
@ -599,8 +605,8 @@ class DashboardService {
dateRange?: DateRange,
startDate?: Date,
endDate?: Date
): Promise<{
agingData: any[],
): Promise<{
agingData: any[],
pagination: {
currentPage: number,
totalPages: number,
@ -647,7 +653,7 @@ class DashboardService {
}
if (priority && priority !== 'all') params.priority = priority;
if (slaCompliance && slaCompliance !== 'all') params.slaCompliance = slaCompliance;
const response = await apiClient.get('/dashboard/stats/single-approver', { params });
return response.data.data;
} catch (error) {
@ -670,8 +676,8 @@ class DashboardService {
priority?: string,
slaCompliance?: string,
search?: string
): Promise<{
requests: any[],
): Promise<{
requests: any[],
pagination: {
currentPage: number,
totalPages: number,
@ -690,7 +696,7 @@ class DashboardService {
if (priority) params.priority = priority;
if (slaCompliance) params.slaCompliance = slaCompliance;
if (search) params.search = search;
const response = await apiClient.get('/dashboard/requests/by-approver', { params });
return {
requests: response.data.data,

View File

@ -102,6 +102,7 @@ export async function createWorkflowFromForm(form: CreateWorkflowFromFormPayload
priority, // STANDARD | EXPRESS
approvalLevels,
participants: participants.length ? participants : undefined,
isDraft: (form as any).isDraft,
};
const res = await apiClient.post('/workflows', payload);
@ -120,26 +121,27 @@ export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayloa
approvers: Array.from({ length: form.approverCount || 1 }, (_, i) => {
const a = form.approvers[i] || ({} as any);
const tat = typeof a.tat === 'number' ? a.tat : 0;
if (!a.email || !a.email.trim()) {
throw new Error(`Email is required for approver at level ${i + 1}.`);
}
return {
email: a.email,
tat: tat,
tatType: a.tatType || 'hours',
};
}),
isDraft: (form as any).isDraft,
};
// Add spectators if any (simplified - only email required)
if (form.spectators && form.spectators.length > 0) {
payload.spectators = form.spectators
.filter((s: any) => s?.email)
.map((s: any) => ({ email: s.email }));
}
// Note: participants array is auto-generated by backend from approvers and spectators
// No need to build or send it from frontend
@ -155,38 +157,39 @@ export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayloa
return { id: data?.requestId } as any;
}
export async function listWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows', {
params: {
page,
limit,
search,
status,
priority,
export async function listWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; lifecycle?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, slaCompliance, lifecycle, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows', {
params: {
page,
limit,
search,
status,
priority,
templateType,
department,
initiator,
approver,
slaCompliance,
dateRange,
startDate,
endDate
}
department,
initiator,
approver,
slaCompliance,
lifecycle,
dateRange,
startDate,
endDate
}
});
return res.data?.data || res.data;
}
// List requests where user is a participant (not initiator) - for regular users' "All Requests" page
// SEPARATE from listWorkflows (admin) to avoid interference
export async function listParticipantRequests(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; approverType?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/participant-requests', {
params: {
page,
limit,
search,
status,
export async function listParticipantRequests(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; approverType?: string; slaCompliance?: string; lifecycle?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, lifecycle, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/participant-requests', {
params: {
page,
limit,
search,
status,
priority,
templateType,
department,
@ -194,6 +197,7 @@ export async function listParticipantRequests(params: { page?: number; limit?: n
approver,
approverType,
slaCompliance,
lifecycle,
dateRange,
startDate,
endDate
@ -210,12 +214,12 @@ export async function listParticipantRequests(params: { page?: number; limit?: n
// List requests where user is a participant (not initiator) - for "All Requests" page
export async function listMyWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/my', {
params: {
page,
limit,
search,
status,
const res = await apiClient.get('/workflows/my', {
params: {
page,
limit,
search,
status,
priority,
department,
initiator,
@ -224,7 +228,7 @@ export async function listMyWorkflows(params: { page?: number; limit?: number; s
dateRange,
startDate,
endDate
}
}
});
// Response structure: { success, data: { data: [...], pagination: {...} } }
return {
@ -234,22 +238,23 @@ export async function listMyWorkflows(params: { page?: number; limit?: number; s
}
// List requests where user is the initiator - for "My Requests" page
export async function listMyInitiatedWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/my-initiated', {
params: {
page,
limit,
search,
status,
export async function listMyInitiatedWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; slaCompliance?: string; lifecycle?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, templateType, department, slaCompliance, lifecycle, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/my-initiated', {
params: {
page,
limit,
search,
status,
priority,
templateType,
department,
slaCompliance,
lifecycle,
dateRange,
startDate,
endDate
}
}
});
// Response structure: { success, data: { data: [...], pagination: {...} } }
return {
@ -304,22 +309,22 @@ export async function addApprover(requestId: string, email: string) {
}
export async function addApproverAtLevel(
requestId: string,
email: string,
tatHours: number,
requestId: string,
email: string,
tatHours: number,
level: number
) {
const res = await apiClient.post(`/workflows/${requestId}/approvers/at-level`, {
email,
tatHours,
level
const res = await apiClient.post(`/workflows/${requestId}/approvers/at-level`, {
email,
tatHours,
level
});
return res.data?.data || res.data;
}
export async function skipApprover(requestId: string, levelId: string, reason?: string) {
const res = await apiClient.post(`/workflows/${requestId}/approvals/${levelId}/skip`, {
reason
const res = await apiClient.post(`/workflows/${requestId}/approvals/${levelId}/skip`, {
reason
});
return res.data?.data || res.data;
}
@ -376,7 +381,7 @@ function extractFilenameFromContentDisposition(contentDisposition: string | null
if (!contentDisposition) {
return 'download';
}
// Try to extract from filename* first (RFC 5987 encoded) - preferred for non-ASCII
const filenameStarMatch = contentDisposition.match(/filename\*=UTF-8''([^;]+)/);
if (filenameStarMatch && filenameStarMatch[1]) {
@ -386,7 +391,7 @@ function extractFilenameFromContentDisposition(contentDisposition: string | null
// If decoding fails, fall back to regular filename
}
}
// Fallback to regular filename (for ASCII-only filenames)
const filenameMatch = contentDisposition.match(/filename="?([^";]+)"?/);
if (filenameMatch && filenameMatch.length > 1 && filenameMatch[1]) {
@ -396,7 +401,7 @@ function extractFilenameFromContentDisposition(contentDisposition: string | null
const extracted = parts[0]?.trim();
return extracted || 'download';
}
return 'download';
}
@ -404,34 +409,34 @@ export async function downloadDocument(documentId: string): Promise<void> {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
// Build fetch options
const fetchOptions: RequestInit = {
credentials: 'include', // Send cookies in production
};
// In development, add Authorization header from localStorage
if (!isProduction) {
const token = localStorage.getItem('accessToken');
fetchOptions.headers = {
'Authorization': `Bearer ${token}`
};
}
}
const response = await fetch(downloadUrl, fetchOptions);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Download failed: ${response.status} - ${errorText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const contentDisposition = response.headers.get('Content-Disposition');
const filename = extractFilenameFromContentDisposition(contentDisposition);
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = filename;
@ -449,35 +454,35 @@ export async function downloadWorkNoteAttachment(attachmentId: string): Promise<
const downloadBaseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
const downloadUrl = `${downloadBaseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
// Build fetch options
const fetchOptions: RequestInit = {
credentials: 'include', // Send cookies in production
};
// In development, add Authorization header from localStorage
if (!isProduction) {
const token = localStorage.getItem('accessToken');
fetchOptions.headers = {
'Authorization': `Bearer ${token}`
};
}
}
const response = await fetch(downloadUrl, fetchOptions);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Download failed: ${response.status} - ${errorText}`);
}
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
// Get filename from Content-Disposition header or use default
const contentDisposition = response.headers.get('Content-Disposition');
const filename = extractFilenameFromContentDisposition(contentDisposition);
const downloadLink = document.createElement('a');
downloadLink.href = url;
downloadLink.download = filename;
@ -522,14 +527,14 @@ export async function updateWorkflowMultipart(requestId: string, updateData: any
...updateData,
deleteDocumentIds: deleteDocumentIds || []
};
const formData = new FormData();
formData.append('payload', JSON.stringify(payload));
formData.append('category', 'SUPPORTING');
if (files && files.length > 0) {
files.forEach(f => formData.append('files', f));
}
const res = await apiClient.put(`/workflows/${requestId}/multipart`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
@ -560,10 +565,10 @@ export async function updateAndSubmitWorkflow(requestId: string, workflowData: C
description: workflowData.description,
priority: workflowData.priorityUi.toUpperCase() === 'EXPRESS' ? 'EXPRESS' : 'STANDARD',
};
// Update workflow details
await apiClient.put(`/workflows/${requestId}`, payload);
// If files provided, update documents (this would need backend support for updating documents)
// For now, we'll just submit the updated workflow
const res = await apiClient.patch(`/workflows/${requestId}/submit`);
@ -577,7 +582,7 @@ export async function updateBreachReason(levelId: string, breachReason: string):
const response = await apiClient.put(`/tat/breach-reason/${levelId}`, {
breachReason
});
if (!response.data.success) {
throw new Error(response.data.error || 'Failed to update breach reason');
}

View File

@ -1,14 +1,14 @@
import {
CheckCircle,
XCircle,
Clock,
MessageSquare,
RefreshCw,
UserPlus,
FileText,
Paperclip,
AlertTriangle,
Activity
import {
CheckCircle,
XCircle,
Clock,
MessageSquare,
RefreshCw,
UserPlus,
FileText,
Paperclip,
AlertTriangle,
Activity
} from 'lucide-react';
/**
@ -66,7 +66,9 @@ export const getPriorityConfig = (priority: string) => {
* @returns Configuration object with Tailwind CSS classes
*/
export const getStatusConfig = (status: string) => {
switch (status) {
switch (status?.toLowerCase()) {
case 'in-review':
case 'in_progress':
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
@ -77,11 +79,6 @@ export const getStatusConfig = (status: string) => {
color: 'bg-gray-400 text-gray-100 border-gray-500',
label: 'paused'
};
case 'in-review':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'in-review'
};
case 'approved':
return {
color: 'bg-green-100 text-green-800 border-green-200',
@ -110,6 +107,39 @@ export const getStatusConfig = (status: string) => {
}
};
/**
* Utility: getWorkflowStateConfig
*
* Purpose: Get display configuration for workflow lifecycle badges (Open, Closed, Draft)
*
* @param state - workflowState string from backend
* @returns Configuration object with Tailwind CSS classes
*/
export const getWorkflowStateConfig = (state: string) => {
switch (state?.toUpperCase()) {
case 'DRAFT':
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'draft'
};
case 'OPEN':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'open'
};
case 'CLOSED':
return {
color: 'bg-slate-100 text-slate-800 border-slate-200',
label: 'closed'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: state?.toLowerCase() || 'open'
};
}
};
/**
* Utility: getActionTypeIcon
*

View File

@ -16,7 +16,7 @@ let configLoaded = false;
// Lazy initialization of configuration
async function ensureConfigLoaded() {
if (configLoaded) return;
try {
const config = await configService.getConfig();
WORK_START_HOUR = config.workingHours.START_HOUR;
@ -30,7 +30,7 @@ async function ensureConfigLoaded() {
}
// Initialize config on first import (non-blocking)
ensureConfigLoaded().catch(() => {});
ensureConfigLoaded().catch(() => { });
/**
* Check if current time is within working hours
@ -40,7 +40,7 @@ ensureConfigLoaded().catch(() => {});
export function isWorkingTime(date: Date = new Date(), priority: string = 'standard'): boolean {
const day = date.getDay(); // 0 = Sunday, 6 = Saturday
const hour = date.getHours();
// For standard priority: exclude weekends
// For express priority: include weekends (calendar days)
if (priority === 'standard') {
@ -48,14 +48,14 @@ export function isWorkingTime(date: Date = new Date(), priority: string = 'stand
return false;
}
}
// Working hours check (applies to both priorities)
if (hour < WORK_START_HOUR || hour >= WORK_END_HOUR) {
return false;
}
// TODO: Add holiday check if holiday API is available
return true;
}
@ -66,12 +66,12 @@ export function isWorkingTime(date: Date = new Date(), priority: string = 'stand
*/
export function getNextWorkingTime(date: Date = new Date(), priority: string = 'standard'): Date {
const result = new Date(date);
// If already in working time, return as is
if (isWorkingTime(result, priority)) {
return result;
}
// For standard priority: skip weekends
if (priority === 'standard') {
const day = result.getDay();
@ -86,13 +86,13 @@ export function getNextWorkingTime(date: Date = new Date(), priority: string = '
return result;
}
}
// If before work hours, move to work start
if (result.getHours() < WORK_START_HOUR) {
result.setHours(WORK_START_HOUR, 0, 0, 0);
return result;
}
// If after work hours, move to next day work start
if (result.getHours() >= WORK_END_HOUR) {
result.setDate(result.getDate() + 1);
@ -100,7 +100,7 @@ export function getNextWorkingTime(date: Date = new Date(), priority: string = '
// Check if next day is weekend (only for standard priority)
return getNextWorkingTime(result, priority);
}
return result;
}
@ -114,19 +114,19 @@ export function calculateElapsedWorkingHours(startDate: Date, endDate: Date = ne
let current = new Date(startDate);
const end = new Date(endDate);
let elapsedMinutes = 0;
// Move minute by minute and count only working minutes
while (current < end) {
if (isWorkingTime(current, priority)) {
elapsedMinutes++;
}
current.setMinutes(current.getMinutes() + 1);
// Safety: stop if calculating more than 1 year
const hoursSoFar = elapsedMinutes / 60;
if (hoursSoFar > 8760) break;
}
// Convert minutes to hours (with decimal precision)
return elapsedMinutes / 60;
}
@ -140,12 +140,12 @@ export function calculateElapsedWorkingHours(startDate: Date, endDate: Date = ne
export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date = new Date(), priority: string = 'standard'): number {
const deadlineTime = new Date(deadline).getTime();
const currentTime = new Date(fromDate).getTime();
// If deadline has passed
if (deadlineTime <= currentTime) {
return 0;
}
// Calculate remaining working hours
return calculateElapsedWorkingHours(fromDate, deadline, priority);
}
@ -160,9 +160,9 @@ export function calculateRemainingWorkingHours(deadline: Date, fromDate: Date =
export function calculateSLAProgress(startDate: Date, deadline: Date, currentDate: Date = new Date(), priority: string = 'standard'): number {
const totalHours = calculateElapsedWorkingHours(startDate, deadline, priority);
const elapsedHours = calculateElapsedWorkingHours(startDate, currentDate, priority);
if (totalHours === 0) return 0;
const progress = (elapsedHours / totalHours) * 100;
return Math.min(Math.max(progress, 0), 100); // Clamp between 0 and 100
}
@ -185,17 +185,17 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
const start = new Date(startDate);
const end = new Date(deadline);
const now = new Date();
const isWorking = isWorkingTime(now, priority);
const elapsedHours = calculateElapsedWorkingHours(start, now, priority);
const totalHours = calculateElapsedWorkingHours(start, end, priority);
const remainingHours = Math.max(0, totalHours - elapsedHours);
const progress = calculateSLAProgress(start, end, now, priority);
let statusText = '';
if (!isWorking) {
statusText = priority === 'express'
? 'SLA tracking paused (outside working hours)'
statusText = priority === 'express'
? 'SLA tracking paused (outside working hours)'
: 'SLA tracking paused (outside working hours/days)';
} else if (remainingHours === 0) {
statusText = 'SLA deadline reached';
@ -208,7 +208,7 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
} else {
statusText = 'On track';
}
return {
isWorkingTime: isWorking,
progress,
@ -231,43 +231,31 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
export function formatHoursMinutes(hours: number | null | undefined): string {
if (hours === null || hours === undefined || hours < 0) return '0 hours';
if (hours === 0) return '0 hours';
const WORKING_HOURS_PER_DAY = 8;
// If less than 1 hour, show minutes only
if (hours < 1) {
const m = Math.round(hours * 60);
return m > 0 ? `${m}m` : '0 hours';
}
// Calculate days and remaining hours (8 hours = 1 day)
// Match backend formatTime logic from Re_Backend/src/utils/tatTimeUtils.ts
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
const remainingHrs = Math.floor(hours % WORKING_HOURS_PER_DAY);
const minutes = Math.round((hours % 1) * 60);
// If we have days, format with days (matching backend format)
const dayLabel = days === 1 ? 'day' : 'days';
if (days > 0) {
const dayLabel = days === 1 ? 'day' : 'days';
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
const minuteLabel = minutes === 1 ? 'min' : 'm';
if (minutes > 0) {
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
} else {
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel}`;
}
}
// No days, just hours and minutes
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
const minuteLabel = minutes === 1 ? 'min' : 'm';
if (minutes > 0) {
return `${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
} else {
return `${remainingHrs} ${hourLabel}`;
return minutes > 0
? `${days} ${dayLabel} ${remainingHrs}h ${minutes}min`
: `${days} ${dayLabel} ${remainingHrs}h`;
}
return minutes > 0
? `${remainingHrs}h ${minutes}min`
: `${remainingHrs}h`;
}
/**
@ -276,25 +264,25 @@ export function formatHoursMinutes(hours: number | null | undefined): string {
export function formatWorkingHours(hours: number): string {
if (hours === 0) return '0h';
if (hours < 0) return '0h';
const totalMinutes = Math.round(hours * 60);
const days = Math.floor(totalMinutes / (8 * 60)); // 8 working hours per day
const remainingMinutes = totalMinutes % (8 * 60);
const remainingHours = Math.floor(remainingMinutes / 60);
const minutes = remainingMinutes % 60;
if (days > 0 && remainingHours > 0 && minutes > 0) {
return `${days}d ${remainingHours}h ${minutes}m`;
return `${days}d ${remainingHours}h ${minutes}min`;
} else if (days > 0 && remainingHours > 0) {
return `${days}d ${remainingHours}h`;
} else if (days > 0) {
return `${days}d`;
} else if (remainingHours > 0 && minutes > 0) {
return `${remainingHours}h ${minutes}m`;
return `${remainingHours}h ${minutes}min`;
} else if (remainingHours > 0) {
return `${remainingHours}h`;
} else {
return `${minutes}m`;
return `${minutes}min`;
}
}
@ -306,14 +294,14 @@ export function getTimeUntilNextWorking(priority: string = 'standard'): string {
if (isWorkingTime(new Date(), priority)) {
return 'In working hours';
}
const now = new Date();
const next = getNextWorkingTime(now, priority);
const diff = next.getTime() - now.getTime();
const hours = Math.floor(diff / (1000 * 60 * 60));
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
if (hours > 24) {
const days = Math.floor(hours / 24);
return `Resumes in ${days}d ${hours % 24}h`;