Compare commits
2 Commits
main
...
mongo_migr
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bab9c0481 | |||
| 6b4b80c0d4 |
@ -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>
|
||||||
|
|||||||
@ -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
@ -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`}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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={() => {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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={() => {
|
||||||
|
|||||||
@ -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
@ -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}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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" />
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -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 */}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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: {} }
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -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={() => {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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();
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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[]> {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
*
|
*
|
||||||
|
|||||||
@ -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`;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user