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

View File

@ -12,12 +12,12 @@ import { Card, CardContent, CardHeader, CardTitle } from '../ui/card';
import { Switch } from '../ui/switch'; import { Switch } from '../ui/switch';
import { Calendar } from '../ui/calendar'; import { Calendar } from '../ui/calendar';
import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover'; import { Popover, PopoverContent, PopoverTrigger } from '../ui/popover';
import { import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Calendar as CalendarIcon, Calendar as CalendarIcon,
Upload, Upload,
X, X,
FileText, FileText,
Check, Check,
Users Users
@ -42,6 +42,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
spectators: [] as any[], spectators: [] as any[],
documents: [] as File[] documents: [] as File[]
}); });
const [isDragging, setIsDragging] = useState(false);
const totalSteps = 5; const totalSteps = 5;
@ -78,9 +79,36 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
updateFormData('spectators', formData.spectators.filter(s => s.id !== userId)); updateFormData('spectators', formData.spectators.filter(s => s.id !== userId));
}; };
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement> | React.DragEvent) => {
const files = Array.from(event.target.files || []); let files: File[] = [];
updateFormData('documents', [...formData.documents, ...files]); 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) => { const removeDocument = (index: number) => {
@ -150,7 +178,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
onChange={(e) => updateFormData('title', e.target.value)} onChange={(e) => updateFormData('title', e.target.value)}
/> />
</div> </div>
<div> <div>
<Label htmlFor="description">Description *</Label> <Label htmlFor="description">Description *</Label>
<Textarea <Textarea
@ -215,9 +243,9 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
<Label htmlFor="workflow-sequential" className="text-sm">Parallel</Label> <Label htmlFor="workflow-sequential" className="text-sm">Parallel</Label>
</div> </div>
</div> </div>
<div className="p-3 bg-muted/50 rounded-lg text-sm text-muted-foreground"> <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.' ? 'Approvers will review the request one after another in the order you specify.'
: 'All approvers will review the request simultaneously.' : 'All approvers will review the request simultaneously.'
} }
@ -311,7 +339,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{availableUsers {availableUsers
.filter(user => .filter(user =>
!formData.spectators.find(s => s.id === user.id) && !formData.spectators.find(s => s.id === user.id) &&
!formData.approvers.find(a => a.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"> <p className="text-sm text-muted-foreground mb-2">
Attach supporting documents for your request. Maximum 10MB per file. Attach supporting documents for your request. Maximum 10MB per file.
</p> </p>
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center"> <div
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" /> 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"> <p className="text-sm text-muted-foreground mb-2">
Drag and drop files here, or click to browse Drag and drop files here, or click to browse
</p> </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 { motion } from 'framer-motion';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -20,7 +21,7 @@ interface DocumentsStepProps {
onDocumentsToDeleteChange: (ids: string[]) => void; onDocumentsToDeleteChange: (ids: string[]) => void;
onPreviewDocument: (doc: any, isExisting: boolean) => void; onPreviewDocument: (doc: any, isExisting: boolean) => void;
onDocumentErrors?: (errors: Array<{ fileName: string; reason: string }>) => void; onDocumentErrors?: (errors: Array<{ fileName: string; reason: string }>) => void;
fileInputRef: React.RefObject<HTMLInputElement>; fileInputRef: RefObject<HTMLInputElement>;
} }
/** /**
@ -47,8 +48,9 @@ export function DocumentsStep({
onDocumentErrors, onDocumentErrors,
fileInputRef fileInputRef
}: DocumentsStepProps) { }: DocumentsStepProps) {
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => { const [isDragging, setIsDragging] = useState(false);
const files = Array.from(event.target.files || []);
const processFiles = (files: File[]) => {
if (files.length === 0) return; if (files.length === 0) return;
// Validate files // Validate files
@ -69,7 +71,7 @@ export function DocumentsStep({
// Check file extension // Check file extension
const fileName = file.name.toLowerCase(); const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1); const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) { if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
validationErrors.push({ validationErrors.push({
fileName: file.name, fileName: file.name,
@ -90,6 +92,11 @@ export function DocumentsStep({
if (validationErrors.length > 0 && onDocumentErrors) { if (validationErrors.length > 0 && onDocumentErrors) {
onDocumentErrors(validationErrors); onDocumentErrors(validationErrors);
} }
};
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
const files = Array.from(event.target.files || []);
processFiles(files);
// Reset file input // Reset file input
if (event.target) { 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 handleRemove = (index: number) => {
const newDocs = documents.filter((_, i) => i !== index); const newDocs = documents.filter((_, i) => i !== index);
onDocumentsChange(newDocs); onDocumentsChange(newDocs);
@ -111,16 +139,16 @@ export function DocumentsStep({
const type = (doc.fileType || doc.file_type || '').toLowerCase(); const type = (doc.fileType || doc.file_type || '').toLowerCase();
const name = (doc.originalFileName || doc.fileName || '').toLowerCase(); const name = (doc.originalFileName || doc.fileName || '').toLowerCase();
return type.includes('image') || type.includes('pdf') || return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') || name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf'); name.endsWith('.pdf');
} else { } else {
const type = (doc.type || '').toLowerCase(); const type = (doc.type || '').toLowerCase();
const name = (doc.name || '').toLowerCase(); const name = (doc.name || '').toLowerCase();
return type.includes('image') || type.includes('pdf') || return type.includes('image') || type.includes('pdf') ||
name.endsWith('.jpg') || name.endsWith('.jpeg') || name.endsWith('.jpg') || name.endsWith('.jpeg') ||
name.endsWith('.png') || name.endsWith('.gif') || name.endsWith('.png') || name.endsWith('.gif') ||
name.endsWith('.pdf'); name.endsWith('.pdf');
} }
}; };
@ -156,8 +184,15 @@ export function DocumentsStep({
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <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"> <div
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" /> 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> <h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
<p className="text-gray-600 mb-4"> <p className="text-gray-600 mb-4">
Drag and drop files here, or click to browse Drag and drop files here, or click to browse
@ -172,10 +207,10 @@ export function DocumentsStep({
ref={fileInputRef} ref={fileInputRef}
data-testid="documents-file-input" data-testid="documents-file-input"
/> />
<Button <Button
variant="outline" variant="outline"
size="lg" size="lg"
type="button" type="button"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
data-testid="documents-browse-button" data-testid="documents-browse-button"
> >
@ -206,7 +241,7 @@ export function DocumentsStep({
const docId = doc.documentId || doc.document_id || ''; const docId = doc.documentId || doc.document_id || '';
const isDeleted = documentsToDelete.includes(docId); const isDeleted = documentsToDelete.includes(docId);
if (isDeleted) return null; if (isDeleted) return null;
return ( return (
<div key={docId} className="flex items-center justify-between p-4 rounded-lg border bg-gray-50" data-testid={`documents-existing-${docId}`}> <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"> <div className="flex items-center gap-3">
@ -222,9 +257,9 @@ export function DocumentsStep({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canPreview(doc, true) && ( {canPreview(doc, true) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onPreviewDocument(doc, true)} onClick={() => onPreviewDocument(doc, true)}
data-testid={`documents-existing-${docId}-preview`} data-testid={`documents-existing-${docId}-preview`}
> >
@ -276,9 +311,9 @@ export function DocumentsStep({
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{canPreview(file, false) && ( {canPreview(file, false) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => onPreviewDocument(file, false)} onClick={() => onPreviewDocument(file, false)}
data-testid={`documents-new-${index}-preview`} data-testid={`documents-new-${index}-preview`}
> >

View File

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

View File

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

View File

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

View File

@ -44,6 +44,7 @@ interface ClaimManagementOverviewTabProps {
generationAttempts?: number; generationAttempts?: number;
generationFailed?: boolean; generationFailed?: boolean;
maxAttemptsReached?: boolean; maxAttemptsReached?: boolean;
isClosed?: boolean;
} }
export function ClaimManagementOverviewTab({ export function ClaimManagementOverviewTab({
@ -64,6 +65,7 @@ export function ClaimManagementOverviewTab({
generationAttempts = 0, generationAttempts = 0,
generationFailed = false, generationFailed = false,
maxAttemptsReached = false, maxAttemptsReached = false,
isClosed = false,
}: ClaimManagementOverviewTabProps) { }: ClaimManagementOverviewTabProps) {
// Check if this is a claim management request // Check if this is a claim management request
if (!isClaimManagementRequest(apiRequest)) { if (!isClaimManagementRequest(apiRequest)) {
@ -76,7 +78,7 @@ export function ClaimManagementOverviewTab({
// Map API data to claim management structure // Map API data to claim management structure
const claimRequest = mapToClaimManagementRequest(apiRequest, currentUserId); const claimRequest = mapToClaimManagementRequest(apiRequest, currentUserId);
if (!claimRequest) { if (!claimRequest) {
console.warn('[ClaimManagementOverviewTab] Failed to map claim data:', { console.warn('[ClaimManagementOverviewTab] Failed to map claim data:', {
apiRequest, apiRequest,
@ -96,10 +98,10 @@ export function ClaimManagementOverviewTab({
// Determine user's role // Determine user's role
const userRole: RequestRole = determineUserRole(apiRequest, currentUserId); const userRole: RequestRole = determineUserRole(apiRequest, currentUserId);
// Get visibility settings based on role // Get visibility settings based on role
const visibility = getRoleBasedVisibility(userRole); const visibility = getRoleBasedVisibility(userRole);
// User role and visibility determined // User role and visibility determined
// Extract initiator info from request // Extract initiator info from request
@ -118,7 +120,7 @@ export function ClaimManagementOverviewTab({
<div className={`space-y-6 ${className}`}> <div className={`space-y-6 ${className}`}>
{/* Activity Information - Always visible */} {/* Activity Information - Always visible */}
{/* Dealer-claim module: Business logic for preparing timestamp data */} {/* Dealer-claim module: Business logic for preparing timestamp data */}
<ActivityInformationCard <ActivityInformationCard
activityInfo={claimRequest.activityInfo} activityInfo={claimRequest.activityInfo}
createdAt={apiRequest?.createdAt} createdAt={apiRequest?.createdAt}
updatedAt={apiRequest?.updatedAt} updatedAt={apiRequest?.updatedAt}
@ -136,7 +138,7 @@ export function ClaimManagementOverviewTab({
<RequestInitiatorCard initiatorInfo={initiatorInfo} /> <RequestInitiatorCard initiatorInfo={initiatorInfo} />
{/* Closed Request Conclusion Remark Display */} {/* Closed Request Conclusion Remark Display */}
{apiRequest?.status === 'closed' && apiRequest?.conclusionRemark && ( {isClosed && apiRequest?.conclusionRemark && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
@ -147,8 +149,8 @@ export function ClaimManagementOverviewTab({
</CardHeader> </CardHeader>
<CardContent className="pt-4"> <CardContent className="pt-4">
<div className="bg-gray-50 border border-gray-200 rounded-lg p-4"> <div className="bg-gray-50 border border-gray-200 rounded-lg p-4">
<FormattedDescription <FormattedDescription
content={apiRequest.conclusionRemark || ''} content={apiRequest.conclusionRemark || ''}
className="text-sm" className="text-sm"
/> />
</div> </div>
@ -166,23 +168,20 @@ export function ClaimManagementOverviewTab({
{/* Conclusion Remark Section - Closure Setup */} {/* Conclusion Remark Section - Closure Setup */}
{needsClosure && ( {needsClosure && (
<Card data-testid="conclusion-remark-card"> <Card data-testid="conclusion-remark-card">
<CardHeader className={`bg-gradient-to-r border-b ${ <CardHeader className={`bg-gradient-to-r border-b ${(apiRequest?.status || '').toLowerCase() === 'rejected'
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'from-red-50 to-rose-50 border-red-200'
? 'from-red-50 to-rose-50 border-red-200' : 'from-green-50 to-emerald-50 border-green-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 className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div> <div>
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${ <CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-700' : 'text-green-700'
(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'
<CheckCircle className={`w-5 h-5 ${ }`} />
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-600' : 'text-green-600'
}`} />
Conclusion Remark - Final Step Conclusion Remark - Final Step
</CardTitle> </CardTitle>
<CardDescription className="mt-1 text-xs sm:text-sm"> <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.' ? '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.'} : 'All approvals are complete. Please review and finalize the conclusion to close this request.'}
</CardDescription> </CardDescription>
@ -201,7 +200,7 @@ export function ClaimManagementOverviewTab({
{aiGenerated ? 'Regenerate' : 'Generate with AI'} {aiGenerated ? 'Regenerate' : 'Generate with AI'}
</Button> </Button>
{aiGenerated && !maxAttemptsReached && !generationFailed && ( {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 {2 - generationAttempts} attempts remaining
</span> </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) // Determine if user is department lead (find dynamically by levelName, not hardcoded step number)
const currentUserId = (user as any)?.userId || ''; const currentUserId = (user as any)?.userId || '';
// IO tab visibility for dealer claims // IO tab visibility for dealer claims
// Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO // Show IO tab for initiator (Requestor Evaluation level) - initiator can now fetch and block IO
const showIOTab = isInitiator; 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) // State to temporarily store approval level for modal (used for additional approvers)
const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null); const [temporaryApprovalLevel, setTemporaryApprovalLevel] = useState<any>(null);
// Use temporary level if set, otherwise use currentApprovalLevel // Use temporary level if set, otherwise use currentApprovalLevel
const effectiveApprovalLevel = temporaryApprovalLevel || 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 // Closure functionality - only for initiator when request is approved/rejected
// Check both lowercase and uppercase status values // Check both lowercase and uppercase status values
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase(); 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 // Closure check completed
const { const {
conclusionRemark, conclusionRemark,
@ -321,7 +322,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
setShowShareSummaryModal(true); setShowShareSummaryModal(true);
}; };
const isClosed = request?.status === 'closed'; // Summary check already handled by isClosed above
// Fetch summary details if request is closed // Fetch summary details if request is closed
useEffect(() => { useEffect(() => {
@ -335,7 +336,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
try { try {
setLoadingSummary(true); setLoadingSummary(true);
const summary = await getSummaryByRequestId(apiRequest.requestId); const summary = await getSummaryByRequestId(apiRequest.requestId);
if (summary?.summaryId) { if (summary?.summaryId) {
setSummaryId(summary.summaryId); setSummaryId(summary.summaryId);
try { try {
@ -376,9 +377,9 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
const notifRequestId = notif.requestId || notif.request_id; const notifRequestId = notif.requestId || notif.request_id;
const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number; const notifRequestNumber = notif.metadata?.requestNumber || notif.metadata?.request_number;
if (notifRequestId !== apiRequest.requestId && if (notifRequestId !== apiRequest.requestId &&
notifRequestNumber !== requestIdentifier && notifRequestNumber !== requestIdentifier &&
notifRequestNumber !== apiRequest.requestNumber) return; notifRequestNumber !== apiRequest.requestNumber) return;
// Check for credit note metadata // Check for credit note metadata
if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) { if (notif.metadata?.creditNoteNumber || notif.metadata?.credit_note_number) {
@ -427,15 +428,15 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
{accessDenied.message} {accessDenied.message}
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button <Button
variant="outline" variant="outline"
onClick={onBack || (() => window.history.back())} onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={() => window.location.href = '/dashboard'} onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700" 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. The dealer claim request you're looking for doesn't exist or may have been deleted.
</p> </p>
<div className="flex gap-3 justify-center"> <div className="flex gap-3 justify-center">
<Button <Button
variant="outline" variant="outline"
onClick={onBack || (() => window.history.back())} onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<ArrowLeft className="w-4 h-4" /> <ArrowLeft className="w-4 h-4" />
Go Back Go Back
</Button> </Button>
<Button <Button
onClick={() => window.location.href = '/dashboard'} onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
> >
@ -490,7 +491,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
refreshing={refreshing} refreshing={refreshing}
onBack={onBack || (() => window.history.back())} onBack={onBack || (() => window.history.back())}
onRefresh={handleRefresh} onRefresh={handleRefresh}
onShareSummary={handleShareSummary} onShareSummary={summaryId ? handleShareSummary : undefined}
isInitiator={isInitiator} isInitiator={isInitiator}
// Dealer-claim module: Business logic for preparing SLA data // Dealer-claim module: Business logic for preparing SLA data
slaData={request?.summary?.sla || request?.sla || null} slaData={request?.summary?.sla || request?.sla || null}
@ -593,13 +594,14 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
generationAttempts={generationAttempts} generationAttempts={generationAttempts}
generationFailed={generationFailed} generationFailed={generationFailed}
maxAttemptsReached={maxAttemptsReached} maxAttemptsReached={maxAttemptsReached}
isClosed={isClosed}
/> />
</TabsContent> </TabsContent>
{isClosed && ( {isClosed && (
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content"> <TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
<SummaryTab <SummaryTab
summary={summaryDetails} summary={summaryDetails}
loading={loadingSummary} loading={loadingSummary}
onShare={handleShareSummary} onShare={handleShareSummary}
isInitiator={isInitiator} isInitiator={isInitiator}

View File

@ -30,19 +30,19 @@ export function useRequestDetails(
) { ) {
// State: Stores the fetched and transformed request data // State: Stores the fetched and transformed request data
const [apiRequest, setApiRequest] = useState<any | null>(null); const [apiRequest, setApiRequest] = useState<any | null>(null);
// State: Indicates if data is currently being fetched // State: Indicates if data is currently being fetched
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
// State: Loading state for initial fetch // State: Loading state for initial fetch
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// State: Access denied information // State: Access denied information
const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null); const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null);
// State: Stores the current approval level for the logged-in user // State: Stores the current approval level for the logged-in user
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null); const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
// State: Indicates if the current user is a spectator (view-only access) // State: Indicates if the current user is a spectator (view-only access)
const [isSpectator, setIsSpectator] = useState(false); const [isSpectator, setIsSpectator] = useState(false);
@ -103,14 +103,14 @@ export function useRequestDetails(
const documents = Array.isArray(details.documents) ? details.documents : []; const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {}; const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : []; const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
// Debug: Log TAT alerts for monitoring // Debug: Log TAT alerts for monitoring
if (tatAlerts.length > 0) { if (tatAlerts.length > 0) {
// TAT alerts loaded - logging removed // TAT alerts loaded - logging removed
} }
const currentLevel = summary?.currentLevel || wf.currentLevel || 1; const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
/** /**
* Transform: Map approval levels to UI format with TAT alerts * Transform: Map approval levels to UI format with TAT alerts
* Each approval level includes: * Each approval level includes:
@ -123,10 +123,10 @@ export function useRequestDetails(
const levelNumber = a.levelNumber || 0; const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase(); const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id; const levelId = a.levelId || a.level_id;
// Determine display status based on workflow progress // Determine display status based on workflow progress
let displayStatus = statusMap(a.status); let displayStatus = statusMap(a.status);
// Future levels that haven't been reached yet show as "waiting" // Future levels that haven't been reached yet show as "waiting"
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') { if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
displayStatus = 'waiting'; displayStatus = 'waiting';
@ -135,10 +135,10 @@ export function useRequestDetails(
else if (levelNumber === currentLevel && levelStatus === 'PENDING') { else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
displayStatus = 'pending'; displayStatus = 'pending';
} }
// Filter: Get TAT alerts that belong to this specific approval level // Filter: Get TAT alerts that belong to this specific approval level
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId); const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
return { return {
step: levelNumber, step: levelNumber,
levelId, levelId,
@ -152,8 +152,8 @@ export function useRequestDetails(
remainingHours: Number(a.remainingHours || 0), remainingHours: Number(a.remainingHours || 0),
tatPercentageUsed: Number(a.tatPercentageUsed || 0), tatPercentageUsed: Number(a.tatPercentageUsed || 0),
// Calculate actual hours taken if level is completed // Calculate actual hours taken if level is completed
actualHours: a.levelEndTime && a.levelStartTime actualHours: a.levelEndTime && a.levelStartTime
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
: undefined, : undefined,
comment: a.comments || undefined, comment: a.comments || undefined,
timestamp: a.actionDate || undefined, timestamp: a.actionDate || undefined,
@ -211,11 +211,11 @@ export function useRequestDetails(
* Filter: Remove TAT breach activities from audit trail * Filter: Remove TAT breach activities from audit trail
* TAT warnings are already shown in approval steps, no need to duplicate in timeline * 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) => { ? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase(); const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning'; return activityType !== 'sla_warning';
}) })
: []; : [];
/** /**
@ -224,7 +224,7 @@ export function useRequestDetails(
*/ */
let pauseInfo = null; let pauseInfo = null;
const isPaused = (wf as any).isPaused || false; const isPaused = (wf as any).isPaused || false;
if (isPaused) { if (isPaused) {
try { try {
pauseInfo = await getPauseDetails(wf.requestId); pauseInfo = await getPauseDetails(wf.requestId);
@ -240,13 +240,13 @@ export function useRequestDetails(
let proposalDetails = null; let proposalDetails = null;
let completionDetails = null; let completionDetails = null;
let internalOrder = null; let internalOrder = null;
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') { if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try { try {
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`); const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
const claimData = claimResponse.data?.data || claimResponse.data; const claimData = claimResponse.data?.data || claimResponse.data;
if (claimData) { if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details; claimDetails = claimData.claimDetails || claimData.claim_details;
proposalDetails = claimData.proposalDetails || claimData.proposal_details; proposalDetails = claimData.proposalDetails || claimData.proposal_details;
@ -257,7 +257,7 @@ export function useRequestDetails(
const invoice = claimData.invoice || null; const invoice = claimData.invoice || null;
const creditNote = claimData.creditNote || claimData.credit_note || null; const creditNote = claimData.creditNote || claimData.credit_note || null;
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null; const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
// Store new fields in claimDetails for backward compatibility and easy access // Store new fields in claimDetails for backward compatibility and easy access
if (claimDetails) { if (claimDetails) {
(claimDetails as any).budgetTracking = budgetTracking; (claimDetails as any).budgetTracking = budgetTracking;
@ -265,7 +265,7 @@ export function useRequestDetails(
(claimDetails as any).creditNote = creditNote; (claimDetails as any).creditNote = creditNote;
(claimDetails as any).completionExpenses = completionExpenses; (claimDetails as any).completionExpenses = completionExpenses;
} }
// Extracted details processed // Extracted details processed
} else { } else {
console.warn('[useRequestDetails] No claimData found in response'); console.warn('[useRequestDetails] No claimData found in response');
@ -294,6 +294,7 @@ export function useRequestDetails(
title: wf.title, title: wf.title,
description: wf.description, description: wf.description,
status: statusMap(wf.status), status: statusMap(wf.status),
workflowState: wf.workflowState,
priority: (wf.priority || '').toString().toLowerCase(), priority: (wf.priority || '').toString().toLowerCase(),
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'), workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
approvalFlow, approvalFlow,
@ -334,7 +335,7 @@ export function useRequestDetails(
creditNote: (claimDetails as any)?.creditNote || null, creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null, completionExpenses: (claimDetails as any)?.completionExpenses || null,
}; };
setApiRequest(updatedRequest); setApiRequest(updatedRequest);
/** /**
@ -352,8 +353,8 @@ export function useRequestDetails(
const approvalLevelNumber = a.levelNumber || 0; const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level // Only show buttons if user is assigned to the CURRENT active level
// Include PAUSED status - paused level is still the current level // Include PAUSED status - paused level is still the current level
return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED') return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED')
&& approverEmail === userEmail && approverEmail === userEmail
&& approvalLevelNumber === currentLevel; && approvalLevelNumber === currentLevel;
}); });
setCurrentApprovalLevel(newCurrentLevel || null); setCurrentApprovalLevel(newCurrentLevel || null);
@ -364,8 +365,8 @@ export function useRequestDetails(
*/ */
const viewerId = (user as any)?.userId; const viewerId = (user as any)?.userId;
if (viewerId) { if (viewerId) {
const isSpec = participants.some((p: any) => const isSpec = participants.some((p: any) =>
(p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' && (p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' &&
(p.userId || p.user_id) === viewerId (p.userId || p.user_id) === viewerId
); );
setIsSpectator(isSpec); setIsSpectator(isSpec);
@ -389,11 +390,11 @@ export function useRequestDetails(
setLoading(false); setLoading(false);
return; return;
} }
let mounted = true; let mounted = true;
setLoading(true); setLoading(true);
setAccessDenied(null); setAccessDenied(null);
(async () => { (async () => {
try { try {
const details = await workflowApi.getWorkflowDetails(requestIdentifier); const details = await workflowApi.getWorkflowDetails(requestIdentifier);
@ -401,7 +402,7 @@ export function useRequestDetails(
if (mounted) setLoading(false); if (mounted) setLoading(false);
return; return;
} }
// Use the same transformation logic as refreshDetails // Use the same transformation logic as refreshDetails
const wf = details.workflow || {}; const wf = details.workflow || {};
const approvals = Array.isArray(details.approvals) ? details.approvals : []; const approvals = Array.isArray(details.approvals) ? details.approvals : [];
@ -409,7 +410,7 @@ export function useRequestDetails(
const documents = Array.isArray(details.documents) ? details.documents : []; const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {}; const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : []; const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
// TAT alerts received - logging removed // TAT alerts received - logging removed
const priority = (wf.priority || '').toString().toLowerCase(); const priority = (wf.priority || '').toString().toLowerCase();
@ -420,9 +421,9 @@ export function useRequestDetails(
const levelNumber = a.levelNumber || 0; const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase(); const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id; const levelId = a.levelId || a.level_id;
let displayStatus = statusMap(a.status); let displayStatus = statusMap(a.status);
// If paused, show paused status (don't change it) // If paused, show paused status (don't change it)
if (levelStatus === 'PAUSED') { if (levelStatus === 'PAUSED') {
displayStatus = 'paused'; displayStatus = 'paused';
@ -431,9 +432,9 @@ export function useRequestDetails(
} else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) { } else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) {
displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending'; displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending';
} }
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId); const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
return { return {
step: levelNumber, step: levelNumber,
levelId, levelId,
@ -448,8 +449,8 @@ export function useRequestDetails(
tatPercentageUsed: Number(a.tatPercentageUsed || 0), tatPercentageUsed: Number(a.tatPercentageUsed || 0),
// Use backend-calculated elapsedHours (working hours) for completed approvals // Use backend-calculated elapsedHours (working hours) for completed approvals
// Backend already calculates this correctly using calculateElapsedWorkingHours // Backend already calculates this correctly using calculateElapsedWorkingHours
actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null
? Number(a.elapsedHours) ? Number(a.elapsedHours)
: undefined, : undefined,
comment: a.comments || undefined, comment: a.comments || undefined,
timestamp: a.actionDate || undefined, timestamp: a.actionDate || undefined,
@ -457,7 +458,7 @@ export function useRequestDetails(
tatAlerts: levelAlerts, tatAlerts: levelAlerts,
}; };
}); });
// Map spectators // Map spectators
const spectators = participants const spectators = participants
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR') .filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
@ -492,18 +493,18 @@ export function useRequestDetails(
}); });
// Filter out TAT warnings from activities // Filter out TAT warnings from activities
const filteredActivities = Array.isArray(details.activities) const filteredActivities = Array.isArray(details.activities)
? details.activities.filter((activity: any) => { ? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase(); const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning'; return activityType !== 'sla_warning';
}) })
: []; : [];
// Fetch pause details only if request is actually paused // Fetch pause details only if request is actually paused
// Use request-level isPaused field from workflow response // Use request-level isPaused field from workflow response
let pauseInfo = null; let pauseInfo = null;
const isPaused = (wf as any).isPaused || false; const isPaused = (wf as any).isPaused || false;
if (isPaused) { if (isPaused) {
try { try {
pauseInfo = await getPauseDetails(wf.requestId); pauseInfo = await getPauseDetails(wf.requestId);
@ -519,11 +520,11 @@ export function useRequestDetails(
let proposalDetails = null; let proposalDetails = null;
let completionDetails = null; let completionDetails = null;
let internalOrder = null; let internalOrder = null;
if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') { if (wf.workflowType === 'CLAIM_MANAGEMENT' || wf.templateType === 'claim-management') {
try { try {
const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`); const claimResponse = await apiClient.get(`/dealer-claims/${wf.requestId}`);
const claimData = claimResponse.data?.data || claimResponse.data; const claimData = claimResponse.data?.data || claimResponse.data;
if (claimData) { if (claimData) {
claimDetails = claimData.claimDetails || claimData.claim_details; claimDetails = claimData.claimDetails || claimData.claim_details;
@ -535,7 +536,7 @@ export function useRequestDetails(
const invoice = claimData.invoice || null; const invoice = claimData.invoice || null;
const creditNote = claimData.creditNote || claimData.credit_note || null; const creditNote = claimData.creditNote || claimData.credit_note || null;
const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null; const completionExpenses = claimData.completionExpenses || claimData.completion_expenses || null;
// Store new fields in claimDetails for backward compatibility and easy access // Store new fields in claimDetails for backward compatibility and easy access
if (claimDetails) { if (claimDetails) {
(claimDetails as any).budgetTracking = budgetTracking; (claimDetails as any).budgetTracking = budgetTracking;
@ -543,7 +544,7 @@ export function useRequestDetails(
(claimDetails as any).creditNote = creditNote; (claimDetails as any).creditNote = creditNote;
(claimDetails as any).completionExpenses = completionExpenses; (claimDetails as any).completionExpenses = completionExpenses;
} }
// Initial load - Extracted details processed // Initial load - Extracted details processed
} }
} catch (error: any) { } catch (error: any) {
@ -564,6 +565,7 @@ export function useRequestDetails(
description: wf.description, description: wf.description,
priority, priority,
status: statusMap(wf.status), status: statusMap(wf.status),
workflowState: wf.workflowState,
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'), workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
summary, summary,
initiator: { initiator: {
@ -599,9 +601,9 @@ export function useRequestDetails(
creditNote: (claimDetails as any)?.creditNote || null, creditNote: (claimDetails as any)?.creditNote || null,
completionExpenses: (claimDetails as any)?.completionExpenses || null, completionExpenses: (claimDetails as any)?.completionExpenses || null,
}; };
setApiRequest(mapped); setApiRequest(mapped);
// Find current user's approval level // Find current user's approval level
// Only show approve/reject buttons if user is the CURRENT active approver // 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 // 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; const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level // Only show buttons if user is assigned to the CURRENT active level
// Include PAUSED status - paused level is still the current level // Include PAUSED status - paused level is still the current level
return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED') return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED')
&& approverEmail === userEmail && approverEmail === userEmail
&& approvalLevelNumber === currentLevel; && approvalLevelNumber === currentLevel;
}); });
setCurrentApprovalLevel(userCurrentLevel || null); setCurrentApprovalLevel(userCurrentLevel || null);
@ -621,7 +623,7 @@ export function useRequestDetails(
// Check spectator status // Check spectator status
const viewerId = (user as any)?.userId; const viewerId = (user as any)?.userId;
if (viewerId) { if (viewerId) {
const isSpec = participants.some((p: any) => const isSpec = participants.some((p: any) =>
(p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId (p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId
); );
setIsSpectator(isSpec); setIsSpectator(isSpec);
@ -633,7 +635,7 @@ export function useRequestDetails(
if (mounted) { if (mounted) {
// Check for 403 Forbidden (Access Denied) // Check for 403 Forbidden (Access Denied)
if (error?.response?.status === 403) { 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.'; '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 }); setAccessDenied({ denied: true, message });
} }
@ -645,7 +647,7 @@ export function useRequestDetails(
} }
} }
})(); })();
return () => { mounted = false; }; return () => { mounted = false; };
}, [requestIdentifier, user]); }, [requestIdentifier, user]);
@ -656,23 +658,23 @@ export function useRequestDetails(
const request = useMemo(() => { const request = useMemo(() => {
// Primary source: API data // Primary source: API data
if (apiRequest) return apiRequest; if (apiRequest) return apiRequest;
// Fallback 1: Static custom request database // Fallback 1: Static custom request database
const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier]; const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier];
if (customRequest) return customRequest; if (customRequest) return customRequest;
// Fallback 2: Static claim management database // Fallback 2: Static claim management database
const claimRequest = CLAIM_MANAGEMENT_DATABASE[requestIdentifier]; const claimRequest = CLAIM_MANAGEMENT_DATABASE[requestIdentifier];
if (claimRequest) return claimRequest; if (claimRequest) return claimRequest;
// Fallback 3: Dynamic requests passed as props // Fallback 3: Dynamic requests passed as props
const dynamicRequest = dynamicRequests.find((req: any) => const dynamicRequest = dynamicRequests.find((req: any) =>
req.id === requestIdentifier || req.id === requestIdentifier ||
req.requestNumber === requestIdentifier || req.requestNumber === requestIdentifier ||
req.request_number === requestIdentifier req.request_number === requestIdentifier
); );
if (dynamicRequest) return dynamicRequest; if (dynamicRequest) return dynamicRequest;
return null; return null;
}, [requestIdentifier, dynamicRequests, apiRequest]); }, [requestIdentifier, dynamicRequests, apiRequest]);
@ -693,9 +695,9 @@ export function useRequestDetails(
*/ */
const existingParticipants = useMemo(() => { const existingParticipants = useMemo(() => {
if (!request) return []; if (!request) return [];
const participants: Array<{ email: string; participantType: string; name?: string }> = []; const participants: Array<{ email: string; participantType: string; name?: string }> = [];
// Add initiator // Add initiator
if (request.initiator?.email) { if (request.initiator?.email) {
participants.push({ participants.push({
@ -704,7 +706,7 @@ export function useRequestDetails(
name: request.initiator.name name: request.initiator.name
}); });
} }
// Add approvers from approval flow // Add approvers from approval flow
if (request.approvalFlow && Array.isArray(request.approvalFlow)) { if (request.approvalFlow && Array.isArray(request.approvalFlow)) {
request.approvalFlow.forEach((approval: any) => { request.approvalFlow.forEach((approval: any) => {
@ -717,7 +719,7 @@ export function useRequestDetails(
} }
}); });
} }
// Add spectators // Add spectators
if (request.spectators && Array.isArray(request.spectators)) { if (request.spectators && Array.isArray(request.spectators)) {
request.spectators.forEach((spectator: any) => { request.spectators.forEach((spectator: any) => {
@ -730,20 +732,20 @@ export function useRequestDetails(
} }
}); });
} }
// Add from participants array // Add from participants array
if (request.participants && Array.isArray(request.participants)) { if (request.participants && Array.isArray(request.participants)) {
request.participants.forEach((p: any) => { request.participants.forEach((p: any) => {
const email = (p.userEmail || p.email || '').toLowerCase(); const email = (p.userEmail || p.email || '').toLowerCase();
const participantType = (p.participantType || p.participant_type || '').toUpperCase(); const participantType = (p.participantType || p.participant_type || '').toUpperCase();
const name = p.userName || p.user_name || p.name; const name = p.userName || p.user_name || p.name;
if (email && participantType && !participants.find(x => x.email === email)) { if (email && participantType && !participants.find(x => x.email === email)) {
participants.push({ email, participantType, name }); participants.push({ email, participantType, name });
} }
}); });
} }
return participants; return participants;
}, [request]); }, [request]);
@ -762,12 +764,12 @@ export function useRequestDetails(
*/ */
useEffect(() => { useEffect(() => {
if (!requestIdentifier || !apiRequest) return; if (!requestIdentifier || !apiRequest) return;
const socket = getSocket(); const socket = getSocket();
if (!socket) { if (!socket) {
return; return;
} }
/** /**
* Handler: Request updated by another user * Handler: Request updated by another user
* Silently refresh to show latest changes * Silently refresh to show latest changes
@ -779,10 +781,10 @@ export function useRequestDetails(
refreshDetails(); refreshDetails();
} }
}; };
// Register listener // Register listener
socket.on('request:updated', handleRequestUpdated); socket.on('request:updated', handleRequestUpdated);
// Cleanup on unmount // Cleanup on unmount
return () => { return () => {
socket.off('request:updated', handleRequestUpdated); 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 { ArrowRight, Calendar, CheckCircle } from 'lucide-react';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter'; import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
import { ClosedRequest } from '../types/closedRequests.types'; import { ClosedRequest } from '../types/closedRequests.types';
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers'; import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers';
interface ClosedRequestCardProps { interface ClosedRequestCardProps {
request: ClosedRequest; request: ClosedRequest;
@ -18,11 +18,12 @@ interface ClosedRequestCardProps {
export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardProps) { export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardProps) {
const priorityConfig = getPriorityConfig(request.priority); const priorityConfig = getPriorityConfig(request.priority);
const statusConfig = getStatusConfig(request.status); const statusConfig = getStatusConfig(request.status);
const stateConfig = getWorkflowStateConfig(request.workflowState || 'CLOSED');
const PriorityIcon = priorityConfig.icon; const PriorityIcon = priorityConfig.icon;
const StatusIcon = statusConfig.icon; const StatusIcon = statusConfig.icon;
return ( 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]" 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)} onClick={() => onViewRequest?.(request.id, request.title)}
data-testid={`closed-request-card-${request.id}`} 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"> <h3 className="text-base font-bold text-gray-900 group-hover:text-blue-600 transition-colors">
{request.displayId || request.id} {request.displayId || request.id}
</h3> </h3>
<Badge <Badge
variant="outline" variant="outline"
className={`${statusConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`} 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" /> <StatusIcon className="w-3.5 h-3.5 mr-1" />
{statusConfig.label} {statusConfig.label}
</Badge> </Badge>
<Badge
variant="outline"
className={`${stateConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`}
>
{stateConfig.label}
</Badge>
{request.department && ( {request.department && (
<Badge variant="secondary" className="bg-blue-50 text-blue-700 text-xs px-2.5 py-0.5 hidden sm:inline-flex"> <Badge variant="secondary" className="bg-blue-50 text-blue-700 text-xs px-2.5 py-0.5 hidden sm:inline-flex">
{request.department} {request.department}
</Badge> </Badge>
)} )}
<Badge <Badge
variant="outline" variant="outline"
className={`${priorityConfig.color} text-xs px-2.5 py-0.5 capitalize hidden md:inline-flex`} className={`${priorityConfig.color} text-xs px-2.5 py-0.5 capitalize hidden md:inline-flex`}
> >
{request.priority} {request.priority}
@ -65,18 +72,18 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
{(() => { {(() => {
const templateType = request.templateType || ''; const templateType = request.templateType || '';
const templateTypeUpper = templateType?.toUpperCase() || ''; const templateTypeUpper = templateType?.toUpperCase() || '';
// Direct mapping from templateType // Direct mapping from templateType
let templateLabel = 'Non-Templatized'; let templateLabel = 'Non-Templatized';
let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200'; let templateColor = 'bg-purple-100 !text-purple-600 border-purple-200';
if (templateTypeUpper === 'DEALER CLAIM') { if (templateTypeUpper === 'DEALER CLAIM') {
templateLabel = 'Dealer Claim'; templateLabel = 'Dealer Claim';
templateColor = 'bg-blue-100 !text-blue-700 border-blue-200'; templateColor = 'bg-blue-100 !text-blue-700 border-blue-200';
} else if (templateTypeUpper === 'TEMPLATE') { } else if (templateTypeUpper === 'TEMPLATE') {
templateLabel = 'Template'; templateLabel = 'Template';
} }
return ( return (
<Badge <Badge
variant="outline" variant="outline"
@ -104,19 +111,19 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
</Avatar> </Avatar>
<span className="font-medium text-gray-900">{request.initiator.name}</span> <span className="font-medium text-gray-900">{request.initiator.name}</span>
</div> </div>
{(request.totalLevels ?? 0) > 0 && ( {(request.totalLevels ?? 0) > 0 && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-green-600" /> <CheckCircle className="w-3.5 h-3.5 text-green-600" />
<span className="font-medium">{request.completedLevels || 0}/{request.totalLevels} Approvals</span> <span className="font-medium">{request.completedLevels || 0}/{request.totalLevels} Approvals</span>
</div> </div>
)} )}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Calendar className="w-3.5 h-3.5" /> <Calendar className="w-3.5 h-3.5" />
<span>Created: {request.createdAt !== '—' ? formatDateDDMMYYYY(request.createdAt, true) : '—'}</span> <span>Created: {request.createdAt !== '—' ? formatDateDDMMYYYY(request.createdAt, true) : '—'}</span>
</div> </div>
{request.dueDate && ( {request.dueDate && (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<CheckCircle className="w-3.5 h-3.5 text-slate-600" /> <CheckCircle className="w-3.5 h-3.5 text-slate-600" />

View File

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

View File

@ -38,6 +38,14 @@ export function getStatusConfig(status: string): StatusConfig {
label: 'Closed', label: 'Closed',
description: 'Request finalized and archived' 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': case 'rejected':
return { return {
color: 'bg-red-100 text-red-800 border-red-300', 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, displayId: r.requestNumber || r.request_number || r.requestId,
title: r.title, title: r.title,
description: r.description, 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', priority: (r.priority || '').toString().toLowerCase() as 'express' | 'standard',
initiator: { initiator: {
name: r.initiator?.displayName || r.initiator?.email || '—', name: r.initiator?.displayName || r.initiator?.email || '—',
@ -29,6 +29,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
totalLevels: r.totalLevels || 0, totalLevels: r.totalLevels || 0,
completedLevels: r.summary?.approvedLevels || 0, completedLevels: r.summary?.approvedLevels || 0,
templateType: r.templateType || r.template_type, // Template type for badge display 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, user,
documentsToDelete documentsToDelete
); );
(updatePayload as any).isDraft = true;
await updateWorkflowRequest( await updateWorkflowRequest(
editRequestId, editRequestId,
@ -164,6 +165,7 @@ export function useCreateRequestSubmission({
selectedTemplate, selectedTemplate,
user user
); );
(createPayload as any).isDraft = true;
const result = await createWorkflow(createPayload, documents); const result = await createWorkflow(createPayload, documents);

View File

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

View File

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

View File

@ -71,8 +71,8 @@ export function AdminKPICards({
}} }}
/> />
</div> </div>
{/* Row 2: Pending and Closed */} {/* Row 2: Pending and Paused */}
<div className="grid grid-cols-2 gap-2 mb-2"> <div className="grid grid-cols-2 gap-2">
<StatCard <StatCard
label="Pending" label="Pending"
value={kpis?.requestVolume.openRequests || 0} value={kpis?.requestVolume.openRequests || 0}
@ -84,21 +84,7 @@ export function AdminKPICards({
onKPIClick({ ...getFilterParams(), status: 'pending' }); onKPIClick({ ...getFilterParams(), status: 'pending' });
}} }}
/> />
<StatCard {kpis?.requestVolume.pausedRequests !== undefined && (
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">
<StatCard <StatCard
label="Paused" label="Paused"
value={kpis.requestVolume.pausedRequests || 0} value={kpis.requestVolume.pausedRequests || 0}
@ -110,8 +96,8 @@ export function AdminKPICards({
onKPIClick({ ...getFilterParams(), status: 'paused' }); onKPIClick({ ...getFilterParams(), status: 'paused' });
}} }}
/> />
</div> )}
)} </div>
</KPICard> </KPICard>
{/* SLA Compliance */} {/* SLA Compliance */}

View File

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

View File

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

View File

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

View File

@ -12,10 +12,12 @@ interface MyRequestsFiltersProps {
statusFilter: string; statusFilter: string;
priorityFilter: string; priorityFilter: string;
templateTypeFilter: string; templateTypeFilter: string;
lifecycleFilter: string;
onSearchChange: (value: string) => void; onSearchChange: (value: string) => void;
onStatusChange: (value: string) => void; onStatusChange: (value: string) => void;
onPriorityChange: (value: string) => void; onPriorityChange: (value: string) => void;
onTemplateTypeChange: (value: string) => void; onTemplateTypeChange: (value: string) => void;
onLifecycleChange: (value: string) => void;
} }
export function MyRequestsFilters({ export function MyRequestsFilters({
@ -23,10 +25,12 @@ export function MyRequestsFilters({
statusFilter, statusFilter,
priorityFilter, priorityFilter,
// templateTypeFilter, // templateTypeFilter,
lifecycleFilter, // Destructure new prop
onSearchChange, onSearchChange,
onStatusChange, onStatusChange,
onPriorityChange, onPriorityChange,
// onTemplateTypeChange, // onTemplateTypeChange,
onLifecycleChange, // Destructure new prop
}: MyRequestsFiltersProps) { }: MyRequestsFiltersProps) {
return ( return (
<Card className="border-gray-200" data-testid="my-requests-filters"> <Card className="border-gray-200" data-testid="my-requests-filters">
@ -44,6 +48,21 @@ export function MyRequestsFilters({
</div> </div>
<div className="flex gap-2 sm:gap-3 w-full md:w-auto"> <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}> <Select value={statusFilter} onValueChange={onStatusChange}>
<SelectTrigger <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" 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="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem> <SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem> <SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>

View File

@ -2,7 +2,7 @@
* My Requests Stats Section Component * 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 { StatsCard } from '@/components/dashboard/StatsCard';
import { MyRequestsStats } from '../types/myRequests.types'; import { MyRequestsStats } from '../types/myRequests.types';
@ -18,7 +18,7 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
} }
}; };
return ( 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 <StatsCard
label="Total" label="Total"
value={stats.total} value={stats.total}
@ -90,18 +90,6 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
testId="stat-draft" testId="stat-draft"
onClick={onStatusFilter ? () => handleCardClick('draft') : undefined} 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> </div>
); );
} }

View File

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

View File

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

View File

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

View File

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

View File

@ -17,6 +17,7 @@ export interface MyRequest {
approverLevel?: string; approverLevel?: string;
templateType?: string; templateType?: string;
workflowType?: string; workflowType?: string;
workflowState?: string;
templateName?: string; templateName?: string;
pauseInfo?: { pauseInfo?: {
isPaused: boolean; isPaused: boolean;
@ -41,6 +42,7 @@ export interface MyRequestsFilters {
status: string; status: string;
priority: string; priority: string;
templateType?: string; templateType?: string;
lifecycle?: string;
} }
export interface PaginationState { 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, templateType: req.templateType || req.template_type,
workflowType: req.workflowType || req.workflow_type, workflowType: req.workflowType || req.workflow_type,
workflowState: req.workflowState || req.workflow_state,
templateName: req.templateName || req.template_name, templateName: req.templateName || req.template_name,
}; };
} }

View File

@ -57,20 +57,20 @@ export function QuickActionsSidebar({
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]); const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
const [loadingRecipients, setLoadingRecipients] = useState(false); const [loadingRecipients, setLoadingRecipients] = useState(false);
const [hasRetriggerNotification, setHasRetriggerNotification] = 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 isPaused = request?.pauseInfo?.isPaused || false;
const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId; const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId;
const currentUserId = currentUserIdProp || (user as any)?.userId || ''; const currentUserId = currentUserIdProp || (user as any)?.userId || '';
// Both approver AND initiator can pause (when not already paused and not closed) // Both approver AND initiator can pause (when not already paused and not closed)
const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator); const canPause = !isPaused && !isClosed && (currentApprovalLevel || isInitiator);
// Resume: Can be done by the person who paused OR by both initiator and approver // Resume: Can be done by the person who paused OR by both initiator and approver
const canResume = isPaused && onResume && (currentApprovalLevel || isInitiator); const canResume = isPaused && onResume && (currentApprovalLevel || isInitiator);
// Retrigger: Only for initiator when approver paused (initiator asks approver to resume) // Retrigger: Only for initiator when approver paused (initiator asks approver to resume)
const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger; const canRetrigger = isPaused && isInitiator && pausedByUserId && pausedByUserId !== currentUserId && onRetrigger;
// Check for retrigger notification (initiator requested resume) // Check for retrigger notification (initiator requested resume)
// ONLY check when: 1) Request is paused, 2) Current user is an approver // ONLY check when: 1) Request is paused, 2) Current user is an approver
// This avoids unnecessary API calls for non-paused requests or initiators // This avoids unnecessary API calls for non-paused requests or initiators
@ -80,26 +80,26 @@ export function QuickActionsSidebar({
setHasRetriggerNotification(false); setHasRetriggerNotification(false);
return; return;
} }
const checkRetriggerNotification = async () => { const checkRetriggerNotification = async () => {
try { try {
const response = await notificationApi.list({ page: 1, limit: 50, unreadOnly: true }); // Only unread const response = await notificationApi.list({ page: 1, limit: 50, unreadOnly: true }); // Only unread
const notifications: Notification[] = response.data?.notifications || []; const notifications: Notification[] = response.data?.notifications || [];
// Check if there's an UNREAD pause_retrigger_request notification for this request // Check if there's an UNREAD pause_retrigger_request notification for this request
const hasRetrigger = notifications.some( const hasRetrigger = notifications.some(
(notif: Notification) => (notif: Notification) =>
notif.requestId === request.requestId && notif.requestId === request.requestId &&
notif.notificationType === 'pause_retrigger_request' notif.notificationType === 'pause_retrigger_request'
); );
setHasRetriggerNotification(hasRetrigger); setHasRetriggerNotification(hasRetrigger);
} catch (error) { } catch (error) {
console.error('Failed to check retrigger notifications:', error); console.error('Failed to check retrigger notifications:', error);
setHasRetriggerNotification(false); setHasRetriggerNotification(false);
} }
}; };
checkRetriggerNotification(); checkRetriggerNotification();
}, [isPaused, currentApprovalLevel, request?.requestId, refreshTrigger]); // Only when paused state or approver status changes }, [isPaused, currentApprovalLevel, request?.requestId, refreshTrigger]); // Only when paused state or approver status changes
@ -332,7 +332,7 @@ export function QuickActionsSidebar({
.join('') .join('')
.slice(0, 2) .slice(0, 2)
.toUpperCase(); .toUpperCase();
return ( return (
<div key={recipient.userId || index} className="flex items-center gap-3" data-testid={`shared-recipient-${index}`}> <div key={recipient.userId || index} className="flex items-center gap-3" data-testid={`shared-recipient-${index}`}>
<Avatar className="h-8 w-8"> <Avatar className="h-8 w-8">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -3,7 +3,7 @@
* Displays statistics cards for requests with click handlers to filter * 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 { StatsCard } from '@/components/dashboard/StatsCard';
import type { RequestStats } from '../types/requests.types'; import type { RequestStats } from '../types/requests.types';
@ -20,7 +20,7 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
}; };
return ( 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 <StatsCard
label="Total" label="Total"
value={stats.total} value={stats.total}
@ -80,18 +80,6 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
testId="stat-rejected" testId="stat-rejected"
onClick={onStatusFilter ? () => handleCardClick('rejected') : undefined} 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> </div>
); );
} }

View File

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

View File

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

View File

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

View File

@ -36,6 +36,7 @@ export async function fetchRequestsData({
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange; if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString(); if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
if (filters?.endDate) backendFilters.endDate = filters.endDate?.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) // Fetch paginated data for list display (with status filter)
const pageResult = await workflowApi.listWorkflows({ const pageResult = await workflowApi.listWorkflows({
@ -81,60 +82,61 @@ export async function fetchRequestsData({
totalPages: pagination.totalPages || 1 totalPages: pagination.totalPages || 1
} }
}; };
} else { } else {
// User-level: Use SEPARATE endpoint for regular users' "All Requests" page // User-level: Use SEPARATE endpoint for regular users' "All Requests" page
// This shows ALL requests where user is involved: // This shows ALL requests where user is involved:
// - As initiator (created the request) // - As initiator (created the request)
// - As approver (in any approval level) // - As approver (in any approval level)
// - As participant/spectator // - As participant/spectator
const backendFilters: any = {}; const backendFilters: any = {};
if (filters?.search) backendFilters.search = filters.search; if (filters?.search) backendFilters.search = filters.search;
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status; if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority; if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType; if (filters?.templateType && filters.templateType !== 'all') backendFilters.templateType = filters.templateType;
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department; if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator; if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance; if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange; if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString(); if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
if (filters?.endDate) backendFilters.endDate = filters.endDate?.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 // Fetch paginated data using endpoint for regular users
// This endpoint includes all requests where user is initiator, approver, or participant // This endpoint includes all requests where user is initiator, approver, or participant
const pageResult = await workflowApi.listParticipantRequests({ const pageResult = await workflowApi.listParticipantRequests({
page, page,
limit: itemsPerPage, limit: itemsPerPage,
...backendFilters ...backendFilters
}); });
let pageData: any[] = []; let pageData: any[] = [];
if (Array.isArray(pageResult?.data)) { if (Array.isArray(pageResult?.data)) {
pageData = pageResult.data; pageData = pageResult.data;
} else if (Array.isArray(pageResult)) { } else if (Array.isArray(pageResult)) {
pageData = 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
};
} }
// 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[]> { 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?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate; 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?.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 // Use single optimized endpoint - listParticipantRequests now includes initiator requests
// Only fetch the requested page (10 records) for optimal performance // 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?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate; 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?.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 // Fetch all pages using the single optimized endpoint
while (hasMore && currentPage <= maxPages) { while (hasMore && currentPage <= maxPages) {
@ -150,4 +152,3 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
return allPages; return allPages;
} }

View File

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

View File

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

View File

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

View File

@ -1,14 +1,14 @@
import { import {
CheckCircle, CheckCircle,
XCircle, XCircle,
Clock, Clock,
MessageSquare, MessageSquare,
RefreshCw, RefreshCw,
UserPlus, UserPlus,
FileText, FileText,
Paperclip, Paperclip,
AlertTriangle, AlertTriangle,
Activity Activity
} from 'lucide-react'; } from 'lucide-react';
/** /**
@ -66,7 +66,9 @@ export const getPriorityConfig = (priority: string) => {
* @returns Configuration object with Tailwind CSS classes * @returns Configuration object with Tailwind CSS classes
*/ */
export const getStatusConfig = (status: string) => { export const getStatusConfig = (status: string) => {
switch (status) { switch (status?.toLowerCase()) {
case 'in-review':
case 'in_progress':
case 'pending': case 'pending':
return { return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200', 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', color: 'bg-gray-400 text-gray-100 border-gray-500',
label: 'paused' label: 'paused'
}; };
case 'in-review':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'in-review'
};
case 'approved': case 'approved':
return { return {
color: 'bg-green-100 text-green-800 border-green-200', 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 * Utility: getActionTypeIcon
* *

View File

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