Compare commits
2 Commits
main
...
mongo_migr
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bab9c0481 | |||
| 6b4b80c0d4 |
@ -1,6 +1,7 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Star } from 'lucide-react';
|
||||
import { formatBreachTime } from '@/pages/Dashboard/utils/dashboardCalculations';
|
||||
|
||||
export interface CriticalAlertData {
|
||||
requestId: string;
|
||||
@ -12,6 +13,8 @@ export interface CriticalAlertData {
|
||||
breachCount: number;
|
||||
currentLevel: number;
|
||||
totalLevels: number;
|
||||
isActionable?: boolean;
|
||||
requestRole?: 'APPROVER' | 'INITIATOR' | 'PARTICIPANT';
|
||||
}
|
||||
|
||||
interface CriticalAlertCardProps {
|
||||
@ -40,23 +43,29 @@ const calculateProgress = (alert: CriticalAlertData) => {
|
||||
return Math.min(100, Math.max(0, Math.round(percentageUsed)));
|
||||
};
|
||||
|
||||
const formatRemainingTime = (alert: CriticalAlertData) => {
|
||||
const formatDisplayTime = (alert: CriticalAlertData) => {
|
||||
if (alert.totalTATHours === undefined || alert.totalTATHours === null) return 'N/A';
|
||||
|
||||
const hours = alert.totalTATHours;
|
||||
const isOverdue = hours <= 0;
|
||||
const absHours = Math.abs(hours);
|
||||
|
||||
// If TAT is breached (negative or zero)
|
||||
if (hours <= 0) {
|
||||
const overdue = Math.abs(hours);
|
||||
if (overdue < 1) return `Breached`;
|
||||
if (overdue < 24) return `${Math.round(overdue)}h overdue`;
|
||||
return `${Math.round(overdue / 24)}d overdue`;
|
||||
const formattedTime = formatBreachTime(absHours);
|
||||
|
||||
if (formattedTime === 'Just breached') return 'Breached';
|
||||
|
||||
return isOverdue ? `${formattedTime} overdue` : `${formattedTime} left`;
|
||||
};
|
||||
|
||||
const getRoleBadge = (role?: string) => {
|
||||
switch (role) {
|
||||
case 'APPROVER':
|
||||
return { label: 'Action Required', className: 'bg-red-100 text-red-700 border-red-200' };
|
||||
case 'INITIATOR':
|
||||
return { label: 'My Request', className: 'bg-orange-100 text-orange-700 border-orange-200' };
|
||||
default:
|
||||
return { label: 'Monitoring', className: 'bg-blue-100 text-blue-700 border-blue-200' };
|
||||
}
|
||||
|
||||
// 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({
|
||||
@ -65,10 +74,15 @@ export function CriticalAlertCard({
|
||||
testId = 'critical-alert-card'
|
||||
}: CriticalAlertCardProps) {
|
||||
const progress = calculateProgress(alert);
|
||||
const isActionable = alert.isActionable ?? true; // Default to true if not provided (admin view)
|
||||
const roleInfo = getRoleBadge(alert.requestRole);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="p-3 sm:p-4 bg-red-50 rounded-lg sm:rounded-xl border border-red-100 hover:shadow-md transition-all duration-200 cursor-pointer"
|
||||
className={`p-3 sm:p-4 rounded-lg sm:rounded-xl border hover:shadow-md transition-all duration-200 cursor-pointer ${isActionable
|
||||
? 'bg-red-50 border-red-100'
|
||||
: 'bg-orange-50/50 border-orange-100'
|
||||
}`}
|
||||
onClick={() => onNavigate?.(alert.requestNumber)}
|
||||
data-testid={`${testId}-${alert.requestId}`}
|
||||
>
|
||||
@ -83,14 +97,22 @@ export function CriticalAlertCard({
|
||||
</p>
|
||||
{alert.priority === 'express' && (
|
||||
<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`}
|
||||
/>
|
||||
)}
|
||||
{alert.requestRole && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-[10px] px-1.5 py-0 h-4 ${roleInfo.className}`}
|
||||
>
|
||||
{roleInfo.label}
|
||||
</Badge>
|
||||
)}
|
||||
{alert.breachCount > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
className="text-[10px] px-1.5 py-0 h-4"
|
||||
data-testid={`${testId}-breach-count`}
|
||||
>
|
||||
{alert.breachCount}
|
||||
@ -106,10 +128,11 @@ export function CriticalAlertCard({
|
||||
</div>
|
||||
<Badge
|
||||
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`}
|
||||
>
|
||||
{formatRemainingTime(alert)}
|
||||
{formatDisplayTime(alert)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-1 sm:space-y-2">
|
||||
@ -124,8 +147,7 @@ export function CriticalAlertCard({
|
||||
</div>
|
||||
<Progress
|
||||
value={progress}
|
||||
className={`h-1.5 sm:h-2 ${
|
||||
progress >= 80 ? '[&>div]:bg-red-600' :
|
||||
className={`h-1.5 sm:h-2 ${progress >= 80 ? '[&>div]:bg-red-600' :
|
||||
progress >= 50 ? '[&>div]:bg-orange-500' :
|
||||
'[&>div]:bg-green-600'
|
||||
}`}
|
||||
|
||||
@ -42,6 +42,7 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
||||
spectators: [] as any[],
|
||||
documents: [] as File[]
|
||||
});
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const totalSteps = 5;
|
||||
|
||||
@ -78,9 +79,36 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
||||
updateFormData('spectators', formData.spectators.filter(s => s.id !== userId));
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement> | React.DragEvent) => {
|
||||
let files: File[] = [];
|
||||
if ('target' in event && event.target instanceof HTMLInputElement && event.target.files) {
|
||||
files = Array.from(event.target.files);
|
||||
} else if ('dataTransfer' in event && event.dataTransfer.files) {
|
||||
files = Array.from(event.dataTransfer.files);
|
||||
}
|
||||
|
||||
if (files.length > 0) {
|
||||
updateFormData('documents', [...formData.documents, ...files]);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
handleFileUpload(e);
|
||||
};
|
||||
|
||||
const removeDocument = (index: number) => {
|
||||
@ -375,8 +403,14 @@ export function NewRequestModal({ open, onClose, onSubmit }: NewRequestModalProp
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Attach supporting documents for your request. Maximum 10MB per file.
|
||||
</p>
|
||||
<div className="border-2 border-dashed border-border rounded-lg p-6 text-center">
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${isDragging ? 'border-re-green bg-re-green/5' : 'border-border'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className={`h-8 w-8 mx-auto mb-2 ${isDragging ? 'text-re-green' : 'text-muted-foreground'}`} />
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
Drag and drop files here, or click to browse
|
||||
</p>
|
||||
|
||||
@ -240,7 +240,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
id: m.noteId || m.id || String(Math.random()),
|
||||
user: {
|
||||
name: m.userName || 'User',
|
||||
avatar: (m.userName || 'U').slice(0,2).toUpperCase(),
|
||||
avatar: (m.userName || 'U').slice(0, 2).toUpperCase(),
|
||||
role: m.userRole || 'Participant'
|
||||
},
|
||||
content: m.message || '',
|
||||
@ -361,7 +361,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
const userId = p.userId || p.user_id || '';
|
||||
return {
|
||||
name: p.userName || p.user_name || p.user_email || p.userEmail || 'User',
|
||||
avatar: (p.userName || p.user_name || p.user_email || 'U').toString().split(' ').map((s: string)=>s[0]).filter(Boolean).join('').slice(0,2).toUpperCase(),
|
||||
avatar: (p.userName || p.user_name || p.user_email || 'U').toString().split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
|
||||
role: formatParticipantRole(participantType.toString()),
|
||||
status: 'offline', // will be updated by presence events
|
||||
email: p.userEmail || p.user_email || '',
|
||||
@ -464,7 +464,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
if (details?.workflow?.requestId) {
|
||||
joinedId = details.workflow.requestId; // join by UUID to match server emits
|
||||
}
|
||||
} catch {}
|
||||
} catch { }
|
||||
try {
|
||||
// Get socket using helper function (handles VITE_BASE_URL or VITE_API_BASE_URL)
|
||||
const s = getSocket(); // Uses getSocketBaseUrl() helper internally
|
||||
@ -693,10 +693,10 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
// Socket cleanup completed - logging removed
|
||||
};
|
||||
(window as any).__wn_cleanup = cleanup;
|
||||
} catch {}
|
||||
} catch { }
|
||||
})();
|
||||
return () => {
|
||||
try { (window as any).__wn_cleanup?.(); } catch {}
|
||||
try { (window as any).__wn_cleanup?.(); } catch { }
|
||||
};
|
||||
}, [effectiveRequestId, currentUserId, skipSocketJoin]);
|
||||
|
||||
@ -762,7 +762,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
id: m.noteId || m.id || String(Math.random()),
|
||||
user: {
|
||||
name: m.userName || 'User',
|
||||
avatar: (m.userName || 'U').slice(0,2).toUpperCase(),
|
||||
avatar: (m.userName || 'U').slice(0, 2).toUpperCase(),
|
||||
role: m.userRole || 'Participant'
|
||||
},
|
||||
content: m.message || '',
|
||||
@ -779,8 +779,18 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
})) : undefined
|
||||
};
|
||||
}) : [];
|
||||
setMessages(mapped as any);
|
||||
} catch {
|
||||
setMessages(prev => {
|
||||
// Keep system messages (activities) from the previous state
|
||||
const systemMessages = prev.filter(m => m.isSystem);
|
||||
// Combine with the newly fetched work notes
|
||||
const combined = [...mapped, ...systemMessages];
|
||||
// Sort to maintain chronological order
|
||||
return combined.sort((a, b) =>
|
||||
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
|
||||
) as any;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[WorkNoteChat] Failed to send message or fetch notes:', error);
|
||||
setMessages(prev => [...prev, newMessage]);
|
||||
}
|
||||
}
|
||||
@ -1274,8 +1284,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
<div key={msg.id} className={`flex gap-2 sm:gap-3 lg:gap-4 ${msg.isSystem ? 'justify-center' : isCurrentUser ? 'justify-end' : ''}`}>
|
||||
{!msg.isSystem && !isCurrentUser && (
|
||||
<Avatar className="h-8 w-8 sm:h-10 sm:w-10 lg:h-12 lg:w-12 flex-shrink-0 ring-1 sm:ring-2 ring-white shadow-sm">
|
||||
<AvatarFallback className={`text-white font-semibold text-xs sm:text-sm ${
|
||||
msg.user.role === 'Initiator' ? 'bg-green-600' :
|
||||
<AvatarFallback className={`text-white font-semibold text-xs sm:text-sm ${msg.user.role === 'Initiator' ? 'bg-green-600' :
|
||||
msg.user.role === 'Current User' ? 'bg-blue-500' :
|
||||
msg.user.role === 'System' ? 'bg-gray-500' :
|
||||
'bg-slate-600'
|
||||
@ -1414,8 +1423,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => addReaction(msg.id, reaction.emoji)}
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs sm:text-sm transition-colors flex-shrink-0 ${
|
||||
reaction.users.includes('You')
|
||||
className={`flex items-center gap-1 px-2 py-1 rounded-full text-xs sm:text-sm transition-colors flex-shrink-0 ${reaction.users.includes('You')
|
||||
? 'bg-blue-100 text-blue-800 border border-blue-200'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
@ -1564,8 +1572,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
className="w-full flex items-center gap-3 p-3 hover:bg-blue-50 rounded-lg text-left transition-colors border border-transparent hover:border-blue-200"
|
||||
>
|
||||
<Avatar className="h-10 w-10">
|
||||
<AvatarFallback className={`text-white text-sm font-semibold ${
|
||||
participant.role === 'Initiator' ? 'bg-green-600' :
|
||||
<AvatarFallback className={`text-white text-sm font-semibold ${participant.role === 'Initiator' ? 'bg-green-600' :
|
||||
participant.role === 'Approver' ? 'bg-purple-600' :
|
||||
'bg-blue-500'
|
||||
}`}>
|
||||
@ -1713,8 +1720,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
<div key={index} className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Avatar className="h-9 w-9 sm:h-10 sm:w-10">
|
||||
<AvatarFallback className={`text-white font-semibold text-sm ${
|
||||
participant.role === 'Initiator' ? 'bg-green-600' :
|
||||
<AvatarFallback className={`text-white font-semibold text-sm ${participant.role === 'Initiator' ? 'bg-green-600' :
|
||||
isCurrentUser ? 'bg-blue-500' : 'bg-slate-600'
|
||||
}`}>
|
||||
{participant.avatar}
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import { useState, ChangeEvent, DragEvent, RefObject } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -20,7 +21,7 @@ interface DocumentsStepProps {
|
||||
onDocumentsToDeleteChange: (ids: string[]) => void;
|
||||
onPreviewDocument: (doc: any, isExisting: boolean) => void;
|
||||
onDocumentErrors?: (errors: Array<{ fileName: string; reason: string }>) => void;
|
||||
fileInputRef: React.RefObject<HTMLInputElement>;
|
||||
fileInputRef: RefObject<HTMLInputElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -47,8 +48,9 @@ export function DocumentsStep({
|
||||
onDocumentErrors,
|
||||
fileInputRef
|
||||
}: DocumentsStepProps) {
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
const processFiles = (files: File[]) => {
|
||||
if (files.length === 0) return;
|
||||
|
||||
// Validate files
|
||||
@ -90,6 +92,11 @@ export function DocumentsStep({
|
||||
if (validationErrors.length > 0 && onDocumentErrors) {
|
||||
onDocumentErrors(validationErrors);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
processFiles(files);
|
||||
|
||||
// Reset file input
|
||||
if (event.target) {
|
||||
@ -97,6 +104,27 @@ export function DocumentsStep({
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
processFiles(files);
|
||||
};
|
||||
|
||||
const handleRemove = (index: number) => {
|
||||
const newDocs = documents.filter((_, i) => i !== index);
|
||||
onDocumentsChange(newDocs);
|
||||
@ -156,8 +184,15 @@ export function DocumentsStep({
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors" data-testid="documents-upload-area">
|
||||
<Upload className="h-12 w-12 mx-auto mb-4 text-gray-400" />
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${isDragging ? 'border-re-green bg-re-green/5' : 'border-gray-300 hover:border-gray-400'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
data-testid="documents-upload-area"
|
||||
>
|
||||
<Upload className={`h-12 w-12 mx-auto mb-4 ${isDragging ? 'text-re-green' : 'text-gray-400'}`} />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">Upload Files</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
Drag and drop files here, or click to browse
|
||||
|
||||
@ -22,6 +22,7 @@ import { CustomDatePicker } from '@/components/ui/date-picker';
|
||||
interface StandardUserAllRequestsFiltersProps {
|
||||
// Filters
|
||||
searchTerm: string;
|
||||
lifecycleFilter: string;
|
||||
statusFilter: string;
|
||||
priorityFilter: string;
|
||||
templateTypeFilter: string;
|
||||
@ -64,6 +65,7 @@ interface StandardUserAllRequestsFiltersProps {
|
||||
|
||||
// Actions
|
||||
onSearchChange: (value: string) => void;
|
||||
onLifecycleChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onPriorityChange: (value: string) => void;
|
||||
onTemplateTypeChange: (value: string) => void;
|
||||
@ -85,6 +87,7 @@ interface StandardUserAllRequestsFiltersProps {
|
||||
|
||||
export function StandardUserAllRequestsFilters({
|
||||
searchTerm,
|
||||
lifecycleFilter,
|
||||
statusFilter,
|
||||
priorityFilter,
|
||||
// templateTypeFilter,
|
||||
@ -102,6 +105,7 @@ export function StandardUserAllRequestsFilters({
|
||||
initiatorSearch,
|
||||
approverSearch,
|
||||
onSearchChange,
|
||||
onLifecycleChange,
|
||||
onStatusChange,
|
||||
onPriorityChange,
|
||||
// onTemplateTypeChange,
|
||||
@ -155,6 +159,17 @@ export function StandardUserAllRequestsFilters({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={lifecycleFilter} onValueChange={onLifecycleChange}>
|
||||
<SelectTrigger className="h-10" data-testid="lifecycle-filter">
|
||||
<SelectValue placeholder="All Requests" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Requests</SelectItem>
|
||||
<SelectItem value="open">Open Requests</SelectItem>
|
||||
<SelectItem value="closed">Closed Requests</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
||||
<SelectTrigger className="h-10" data-testid="status-filter">
|
||||
<SelectValue placeholder="All Status" />
|
||||
@ -240,7 +255,7 @@ export function StandardUserAllRequestsFilters({
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Search initiator..."
|
||||
placeholder="Use @ to search initiator..."
|
||||
value={initiatorSearch.searchQuery}
|
||||
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
||||
onFocus={() => {
|
||||
@ -310,7 +325,7 @@ export function StandardUserAllRequestsFilters({
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Search approver..."
|
||||
placeholder="Use @ to search approver..."
|
||||
value={approverSearch.searchQuery}
|
||||
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
||||
onFocus={() => {
|
||||
|
||||
@ -280,8 +280,9 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
||||
setShowShareSummaryModal(true);
|
||||
};
|
||||
|
||||
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
|
||||
const isClosed = request?.status === 'closed';
|
||||
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
|
||||
const isClosed = apiRequest?.workflowState === 'CLOSED' || requestStatus === 'closed';
|
||||
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator && !isClosed;
|
||||
|
||||
// Fetch summary details if request is closed
|
||||
useEffect(() => {
|
||||
@ -419,7 +420,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
||||
refreshing={refreshing}
|
||||
onBack={onBack || (() => window.history.back())}
|
||||
onRefresh={handleRefresh}
|
||||
onShareSummary={handleShareSummary}
|
||||
onShareSummary={summaryId ? handleShareSummary : undefined}
|
||||
isInitiator={isInitiator}
|
||||
// Custom module: Business logic for preparing SLA data
|
||||
slaData={request?.summary?.sla || request?.sla || null}
|
||||
@ -516,6 +517,7 @@ function CustomRequestDetailInner({ requestId: propRequestId, onBack, dynamicReq
|
||||
generationAttempts={generationAttempts}
|
||||
generationFailed={generationFailed}
|
||||
maxAttemptsReached={maxAttemptsReached}
|
||||
isClosed={isClosed}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@ -172,7 +172,7 @@ export function DealerUserAllRequestsFilters({
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Search initiator..."
|
||||
placeholder="Use @ to search initiator..."
|
||||
value={initiatorSearch.searchQuery}
|
||||
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
||||
onFocus={() => {
|
||||
@ -242,7 +242,7 @@ export function DealerUserAllRequestsFilters({
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Search approver..."
|
||||
placeholder="Use @ to search approver..."
|
||||
value={approverSearch.searchQuery}
|
||||
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
||||
onFocus={() => {
|
||||
|
||||
@ -44,6 +44,7 @@ interface ClaimManagementOverviewTabProps {
|
||||
generationAttempts?: number;
|
||||
generationFailed?: boolean;
|
||||
maxAttemptsReached?: boolean;
|
||||
isClosed?: boolean;
|
||||
}
|
||||
|
||||
export function ClaimManagementOverviewTab({
|
||||
@ -64,6 +65,7 @@ export function ClaimManagementOverviewTab({
|
||||
generationAttempts = 0,
|
||||
generationFailed = false,
|
||||
maxAttemptsReached = false,
|
||||
isClosed = false,
|
||||
}: ClaimManagementOverviewTabProps) {
|
||||
// Check if this is a claim management request
|
||||
if (!isClaimManagementRequest(apiRequest)) {
|
||||
@ -136,7 +138,7 @@ export function ClaimManagementOverviewTab({
|
||||
<RequestInitiatorCard initiatorInfo={initiatorInfo} />
|
||||
|
||||
{/* Closed Request Conclusion Remark Display */}
|
||||
{apiRequest?.status === 'closed' && apiRequest?.conclusionRemark && (
|
||||
{isClosed && apiRequest?.conclusionRemark && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@ -166,18 +168,15 @@ export function ClaimManagementOverviewTab({
|
||||
{/* Conclusion Remark Section - Closure Setup */}
|
||||
{needsClosure && (
|
||||
<Card data-testid="conclusion-remark-card">
|
||||
<CardHeader className={`bg-gradient-to-r border-b ${
|
||||
(apiRequest?.status || '').toLowerCase() === 'rejected'
|
||||
<CardHeader className={`bg-gradient-to-r border-b ${(apiRequest?.status || '').toLowerCase() === 'rejected'
|
||||
? 'from-red-50 to-rose-50 border-red-200'
|
||||
: 'from-green-50 to-emerald-50 border-green-200'
|
||||
}`}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${
|
||||
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-700' : 'text-green-700'
|
||||
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-700' : 'text-green-700'
|
||||
}`}>
|
||||
<CheckCircle className={`w-5 h-5 ${
|
||||
(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-600' : 'text-green-600'
|
||||
<CheckCircle className={`w-5 h-5 ${(apiRequest?.status || '').toLowerCase() === 'rejected' ? 'text-red-600' : 'text-green-600'
|
||||
}`} />
|
||||
Conclusion Remark - Final Step
|
||||
</CardTitle>
|
||||
|
||||
@ -107,6 +107,8 @@ const getStepIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return <CircleCheckBig className="w-5 h-5 text-green-600" />;
|
||||
case 'in_progress':
|
||||
return <RotateCw className="w-5 h-5 text-purple-600 animate-spin-slow" />;
|
||||
case 'pending':
|
||||
return <Clock className="w-5 h-5 text-blue-600" />;
|
||||
case 'rejected':
|
||||
@ -123,6 +125,8 @@ const getStepBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'in_progress':
|
||||
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||
case 'pending':
|
||||
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||
case 'rejected':
|
||||
@ -155,6 +159,8 @@ const getStepIconBg = (status: string) => {
|
||||
switch (status) {
|
||||
case 'approved':
|
||||
return 'bg-green-100';
|
||||
case 'in_progress':
|
||||
return 'bg-blue-100';
|
||||
case 'pending':
|
||||
return 'bg-purple-100';
|
||||
case 'rejected':
|
||||
@ -186,7 +192,7 @@ export function DealerClaimWorkflowTab({
|
||||
const [versionHistory, setVersionHistory] = useState<any[]>([]);
|
||||
const [showHistory, setShowHistory] = useState(false);
|
||||
const [expandedVersionSteps, setExpandedVersionSteps] = useState<Set<number>>(new Set());
|
||||
const [viewSnapshot, setViewSnapshot] = useState<{data: any, type: 'PROPOSAL' | 'COMPLETION', title?: string} | null>(null);
|
||||
const [viewSnapshot, setViewSnapshot] = useState<{ data: any, type: 'PROPOSAL' | 'COMPLETION', title?: string } | null>(null);
|
||||
|
||||
// Load approval flows from real API
|
||||
const [approvalFlow, setApprovalFlow] = useState<any[]>([]);
|
||||
@ -1720,8 +1726,7 @@ export function DealerClaimWorkflowTab({
|
||||
</div>
|
||||
|
||||
{/* Current Approver - Time Tracking */}
|
||||
<div className={`border rounded-lg p-3 ${
|
||||
isPaused ? 'bg-gray-100 border-gray-300' :
|
||||
<div className={`border rounded-lg p-3 ${isPaused ? 'bg-gray-100 border-gray-300' :
|
||||
(approval.sla.percentageUsed || 0) >= 100 ? 'bg-red-50 border-red-200' :
|
||||
(approval.sla.percentageUsed || 0) >= 75 ? 'bg-orange-50 border-orange-200' :
|
||||
(approval.sla.percentageUsed || 0) >= 50 ? 'bg-amber-50 border-amber-200' :
|
||||
|
||||
@ -219,7 +219,8 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
||||
// Closure functionality - only for initiator when request is approved/rejected
|
||||
// Check both lowercase and uppercase status values
|
||||
const requestStatus = (request?.status || apiRequest?.status || '').toLowerCase();
|
||||
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator;
|
||||
const isClosed = apiRequest?.workflowState === 'CLOSED' || requestStatus === 'closed';
|
||||
const needsClosure = (requestStatus === 'approved' || requestStatus === 'rejected') && isInitiator && !isClosed;
|
||||
|
||||
// Closure check completed
|
||||
const {
|
||||
@ -321,7 +322,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
||||
setShowShareSummaryModal(true);
|
||||
};
|
||||
|
||||
const isClosed = request?.status === 'closed';
|
||||
// Summary check already handled by isClosed above
|
||||
|
||||
// Fetch summary details if request is closed
|
||||
useEffect(() => {
|
||||
@ -490,7 +491,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
||||
refreshing={refreshing}
|
||||
onBack={onBack || (() => window.history.back())}
|
||||
onRefresh={handleRefresh}
|
||||
onShareSummary={handleShareSummary}
|
||||
onShareSummary={summaryId ? handleShareSummary : undefined}
|
||||
isInitiator={isInitiator}
|
||||
// Dealer-claim module: Business logic for preparing SLA data
|
||||
slaData={request?.summary?.sla || request?.sla || null}
|
||||
@ -593,6 +594,7 @@ function DealerClaimRequestDetailInner({ requestId: propRequestId, onBack, dynam
|
||||
generationAttempts={generationAttempts}
|
||||
generationFailed={generationFailed}
|
||||
maxAttemptsReached={maxAttemptsReached}
|
||||
isClosed={isClosed}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
|
||||
@ -294,6 +294,7 @@ export function useRequestDetails(
|
||||
title: wf.title,
|
||||
description: wf.description,
|
||||
status: statusMap(wf.status),
|
||||
workflowState: wf.workflowState,
|
||||
priority: (wf.priority || '').toString().toLowerCase(),
|
||||
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
||||
approvalFlow,
|
||||
@ -564,6 +565,7 @@ export function useRequestDetails(
|
||||
description: wf.description,
|
||||
priority,
|
||||
status: statusMap(wf.status),
|
||||
workflowState: wf.workflowState,
|
||||
workflowType: wf.workflowType || (wf.templateType === 'claim-management' ? 'CLAIM_MANAGEMENT' : 'NON_TEMPLATIZED'),
|
||||
summary,
|
||||
initiator: {
|
||||
|
||||
@ -8,7 +8,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { ArrowRight, Calendar, CheckCircle } from 'lucide-react';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
import { ClosedRequest } from '../types/closedRequests.types';
|
||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers';
|
||||
|
||||
interface ClosedRequestCardProps {
|
||||
request: ClosedRequest;
|
||||
@ -18,6 +18,7 @@ interface ClosedRequestCardProps {
|
||||
export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardProps) {
|
||||
const priorityConfig = getPriorityConfig(request.priority);
|
||||
const statusConfig = getStatusConfig(request.status);
|
||||
const stateConfig = getWorkflowStateConfig(request.workflowState || 'CLOSED');
|
||||
const PriorityIcon = priorityConfig.icon;
|
||||
const StatusIcon = statusConfig.icon;
|
||||
|
||||
@ -50,6 +51,12 @@ export function ClosedRequestCard({ request, onViewRequest }: ClosedRequestCardP
|
||||
<StatusIcon className="w-3.5 h-3.5 mr-1" />
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${stateConfig.color} text-xs px-2.5 py-0.5 font-semibold shrink-0`}
|
||||
>
|
||||
{stateConfig.label}
|
||||
</Badge>
|
||||
{request.department && (
|
||||
<Badge variant="secondary" className="bg-blue-50 text-blue-700 text-xs px-2.5 py-0.5 hidden sm:inline-flex">
|
||||
{request.department}
|
||||
|
||||
@ -8,7 +8,7 @@ export interface ClosedRequest {
|
||||
displayId?: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'rejected' | 'closed';
|
||||
status: 'rejected' | 'closed' | 'approved';
|
||||
priority: 'express' | 'standard';
|
||||
initiator: { name: string; avatar: string };
|
||||
createdAt: string;
|
||||
@ -18,6 +18,7 @@ export interface ClosedRequest {
|
||||
totalLevels?: number;
|
||||
completedLevels?: number;
|
||||
templateType?: string; // Template type for badge display
|
||||
workflowState?: string;
|
||||
}
|
||||
|
||||
export interface ClosedRequestsProps {
|
||||
|
||||
@ -38,6 +38,14 @@ export function getStatusConfig(status: string): StatusConfig {
|
||||
label: 'Closed',
|
||||
description: 'Request finalized and archived'
|
||||
};
|
||||
case 'approved':
|
||||
return {
|
||||
color: 'bg-green-100 text-green-800 border-green-200',
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
label: 'Approved',
|
||||
description: 'Request was approved'
|
||||
};
|
||||
case 'rejected':
|
||||
return {
|
||||
color: 'bg-red-100 text-red-800 border-red-300',
|
||||
@ -57,3 +65,25 @@ export function getStatusConfig(status: string): StatusConfig {
|
||||
}
|
||||
}
|
||||
|
||||
export function getWorkflowStateConfig(state: string) {
|
||||
const s = (state || '').toUpperCase();
|
||||
switch (s) {
|
||||
case 'CLOSED':
|
||||
return {
|
||||
color: 'bg-slate-100 text-slate-800 border-slate-200',
|
||||
label: 'closed'
|
||||
};
|
||||
case 'DRAFT':
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
label: 'draft'
|
||||
};
|
||||
case 'OPEN':
|
||||
default:
|
||||
return {
|
||||
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
label: 'open'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -11,7 +11,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
|
||||
displayId: r.requestNumber || r.request_number || r.requestId,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
status: (r.status || '').toString().toLowerCase() as 'rejected' | 'closed',
|
||||
status: (r.status || '').toString().toLowerCase() as 'rejected' | 'closed' | 'approved',
|
||||
priority: (r.priority || '').toString().toLowerCase() as 'express' | 'standard',
|
||||
initiator: {
|
||||
name: r.initiator?.displayName || r.initiator?.email || '—',
|
||||
@ -29,6 +29,7 @@ export function transformClosedRequest(r: any): ClosedRequest {
|
||||
totalLevels: r.totalLevels || 0,
|
||||
completedLevels: r.summary?.approvedLevels || 0,
|
||||
templateType: r.templateType || r.template_type, // Template type for badge display
|
||||
workflowState: r.workflowState || r.workflow_state,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -139,6 +139,7 @@ export function useCreateRequestSubmission({
|
||||
user,
|
||||
documentsToDelete
|
||||
);
|
||||
(updatePayload as any).isDraft = true;
|
||||
|
||||
await updateWorkflowRequest(
|
||||
editRequestId,
|
||||
@ -164,6 +165,7 @@ export function useCreateRequestSubmission({
|
||||
selectedTemplate,
|
||||
user
|
||||
);
|
||||
(createPayload as any).isDraft = true;
|
||||
|
||||
const result = await createWorkflow(createPayload, documents);
|
||||
|
||||
|
||||
@ -59,28 +59,22 @@ export async function submitWorkflowRequest(requestId: string): Promise<void> {
|
||||
await submitWorkflow(requestId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create and submit a workflow in one operation
|
||||
*/
|
||||
export async function createAndSubmitWorkflow(
|
||||
payload: CreateWorkflowPayload,
|
||||
documents: File[]
|
||||
): Promise<{ id: string }> {
|
||||
const result = await createWorkflow(payload, documents);
|
||||
await submitWorkflowRequest(result.id);
|
||||
return result;
|
||||
// Pass isDraft: false (or omit) to trigger backend auto-submit
|
||||
const res = await createWorkflow({ ...payload, isDraft: false }, documents);
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update and submit a workflow in one operation
|
||||
*/
|
||||
export async function updateAndSubmitWorkflow(
|
||||
requestId: string,
|
||||
payload: UpdateWorkflowPayload,
|
||||
documents: File[],
|
||||
documentsToDelete: string[]
|
||||
): Promise<void> {
|
||||
await updateWorkflowRequest(requestId, payload, documents, documentsToDelete);
|
||||
await submitWorkflowRequest(requestId);
|
||||
// Pass isDraft: false (or omit) to trigger backend auto-submit
|
||||
await updateWorkflowRequest(requestId, { ...payload, isDraft: false }, documents, documentsToDelete);
|
||||
}
|
||||
|
||||
|
||||
@ -67,6 +67,7 @@ export interface CreateWorkflowPayload {
|
||||
email: string;
|
||||
}>;
|
||||
participants: Participant[];
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateWorkflowPayload {
|
||||
@ -76,6 +77,7 @@ export interface UpdateWorkflowPayload {
|
||||
approvalLevels: ApprovalLevel[];
|
||||
participants: Participant[];
|
||||
deleteDocumentIds?: string[];
|
||||
isDraft?: boolean;
|
||||
}
|
||||
|
||||
export interface ValidationModalState {
|
||||
|
||||
@ -71,8 +71,8 @@ export function AdminKPICards({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Row 2: Pending and Closed */}
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
{/* Row 2: Pending and Paused */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatCard
|
||||
label="Pending"
|
||||
value={kpis?.requestVolume.openRequests || 0}
|
||||
@ -84,21 +84,7 @@ export function AdminKPICards({
|
||||
onKPIClick({ ...getFilterParams(), status: 'pending' });
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
label="Closed"
|
||||
value={kpis?.requestVolume.closedRequests || 0}
|
||||
bgColor="bg-gray-50"
|
||||
textColor="text-gray-600"
|
||||
testId="stat-closed"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKPIClick({ ...getFilterParams(), status: 'closed' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Row 3: Paused (if available) */}
|
||||
{kpis?.requestVolume.pausedRequests !== undefined && (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatCard
|
||||
label="Paused"
|
||||
value={kpis.requestVolume.pausedRequests || 0}
|
||||
@ -110,8 +96,8 @@ export function AdminKPICards({
|
||||
onKPIClick({ ...getFilterParams(), status: 'paused' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</KPICard>
|
||||
|
||||
{/* SLA Compliance */}
|
||||
|
||||
@ -9,7 +9,7 @@ import { Progress } from '@/components/ui/progress';
|
||||
import { Calendar as CalendarIcon } from 'lucide-react';
|
||||
import { UpcomingDeadline } from '@/services/dashboard.service';
|
||||
import { Pagination } from '@/components/common/Pagination';
|
||||
import { formatHoursMinutes } from '@/utils/slaTracker';
|
||||
import { formatBreachTime } from '../../utils/dashboardCalculations';
|
||||
|
||||
interface UpcomingDeadlinesSectionProps {
|
||||
isAdmin: boolean;
|
||||
@ -67,8 +67,7 @@ export function UpcomingDeadlinesSection({
|
||||
<span className="font-semibold text-xs sm:text-sm">{deadline.requestNumber}</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`text-xs ${
|
||||
deadline.priority === 'express' ? 'bg-orange-50 text-orange-700' : 'bg-blue-50 text-blue-700'
|
||||
className={`text-xs ${deadline.priority === 'express' ? 'bg-orange-50 text-orange-700' : 'bg-blue-50 text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{deadline.priority}
|
||||
@ -85,8 +84,7 @@ export function UpcomingDeadlinesSection({
|
||||
<div className="text-right flex-shrink-0">
|
||||
<p className="text-xs text-muted-foreground">TAT Used</p>
|
||||
<p
|
||||
className={`text-base sm:text-lg font-bold ${
|
||||
tatPercentage >= 80 ? 'text-red-600' : tatPercentage >= 50 ? 'text-orange-600' : 'text-green-600'
|
||||
className={`text-base sm:text-lg font-bold ${tatPercentage >= 80 ? 'text-red-600' : tatPercentage >= 50 ? 'text-orange-600' : 'text-green-600'
|
||||
}`}
|
||||
>
|
||||
{tatPercentage.toFixed(0)}%
|
||||
@ -96,13 +94,12 @@ export function UpcomingDeadlinesSection({
|
||||
<div className="space-y-1">
|
||||
<Progress
|
||||
value={tatPercentage}
|
||||
className={`h-1.5 sm:h-2 ${
|
||||
tatPercentage >= 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100'
|
||||
className={`h-1.5 sm:h-2 ${tatPercentage >= 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100'
|
||||
}`}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{formatHoursMinutes(elapsedHours)} elapsed</span>
|
||||
<span>{formatHoursMinutes(remainingHours)} left</span>
|
||||
<span>{formatBreachTime(elapsedHours)} elapsed</span>
|
||||
<span>{formatBreachTime(Math.abs(remainingHours))} {remainingHours < 0 ? 'overdue' : 'left'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -70,7 +70,7 @@ export function UserKPICards({
|
||||
testId="kpi-my-requests"
|
||||
onClick={() => onKPIClick(getFilterParams())}
|
||||
>
|
||||
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
|
||||
<div className="grid grid-cols-2 gap-1.5 sm:gap-2">
|
||||
<StatCard
|
||||
label="Approved"
|
||||
value={kpis?.requestVolume.approvedRequests || 0}
|
||||
@ -115,17 +115,6 @@ export function UserKPICards({
|
||||
onKPIClick({ ...getFilterParams(), status: 'rejected' });
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
label="Closed"
|
||||
value={kpis?.requestVolume.closedRequests || 0}
|
||||
bgColor="bg-blue-50"
|
||||
textColor="text-blue-600"
|
||||
testId="stat-user-closed"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onKPIClick({ ...getFilterParams(), status: 'closed' });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</KPICard>
|
||||
|
||||
|
||||
@ -38,6 +38,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
statusFilter: filters.statusFilter,
|
||||
priorityFilter: filters.priorityFilter,
|
||||
templateTypeFilter: filters.templateTypeFilter,
|
||||
lifecycleFilter: filters.lifecycleFilter,
|
||||
});
|
||||
const hasInitialFetchRun = useRef(false);
|
||||
|
||||
@ -49,6 +50,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
|
||||
});
|
||||
hasInitialFetchRun.current = true;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@ -63,7 +65,8 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
prev.searchTerm !== filters.searchTerm ||
|
||||
prev.statusFilter !== filters.statusFilter ||
|
||||
prev.priorityFilter !== filters.priorityFilter ||
|
||||
prev.templateTypeFilter !== filters.templateTypeFilter;
|
||||
prev.templateTypeFilter !== filters.templateTypeFilter ||
|
||||
prev.lifecycleFilter !== filters.lifecycleFilter;
|
||||
|
||||
if (!hasChanged) return; // No actual change, skip
|
||||
|
||||
@ -75,6 +78,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
|
||||
});
|
||||
|
||||
// Update previous values
|
||||
@ -83,12 +87,13 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
statusFilter: filters.statusFilter,
|
||||
priorityFilter: filters.priorityFilter,
|
||||
templateTypeFilter: filters.templateTypeFilter,
|
||||
lifecycleFilter: filters.lifecycleFilter,
|
||||
};
|
||||
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter]);
|
||||
}, [filters.searchTerm, filters.statusFilter, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter]);
|
||||
|
||||
// State for backend stats (calculated from entire dataset via SQL queries)
|
||||
const [backendStats, setBackendStats] = useState<{
|
||||
@ -131,7 +136,8 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
undefined, // approverType
|
||||
filters.searchTerm || undefined,
|
||||
undefined, // slaCompliance
|
||||
true // viewAsUser - treat as normal user even if admin
|
||||
true, // viewAsUser - treat as normal user even if admin
|
||||
filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined // lifecycle
|
||||
);
|
||||
|
||||
setBackendStats({
|
||||
@ -149,7 +155,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
} finally {
|
||||
setLoadingStats(false);
|
||||
}
|
||||
}, [user?.userId, filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter]); // Exclude statusFilter - stats don't change when only status changes
|
||||
}, [user?.userId, filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter]); // Exclude statusFilter - stats don't change when only status changes
|
||||
|
||||
// Fetch stats when filters change (excluding status filter)
|
||||
// Stats should reflect priority and search filters, but NOT status filter
|
||||
@ -160,7 +166,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
}, filters.searchTerm ? 500 : 0);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, fetchBackendStats]); // Exclude statusFilter - stats don't change when only status changes
|
||||
}, [filters.searchTerm, filters.priorityFilter, filters.templateTypeFilter, filters.lifecycleFilter, fetchBackendStats]); // Exclude statusFilter - stats don't change when only status changes
|
||||
|
||||
// Handle dynamic requests (fallback until API loads)
|
||||
const convertedDynamicRequests = transformRequests(dynamicRequests);
|
||||
@ -204,6 +210,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
status: filters.statusFilter !== 'all' ? filters.statusFilter : undefined,
|
||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||
templateType: filters.templateTypeFilter !== 'all' ? filters.templateTypeFilter : undefined,
|
||||
lifecycle: filters.lifecycleFilter !== 'all' ? filters.lifecycleFilter : undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
@ -243,6 +250,8 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
onStatusChange={filters.setStatusFilter}
|
||||
onPriorityChange={filters.setPriorityFilter}
|
||||
onTemplateTypeChange={filters.setTemplateTypeFilter}
|
||||
lifecycleFilter={filters.lifecycleFilter}
|
||||
onLifecycleChange={filters.setLifecycleFilter}
|
||||
/>
|
||||
|
||||
{/* Requests List */}
|
||||
|
||||
@ -12,10 +12,12 @@ interface MyRequestsFiltersProps {
|
||||
statusFilter: string;
|
||||
priorityFilter: string;
|
||||
templateTypeFilter: string;
|
||||
lifecycleFilter: string;
|
||||
onSearchChange: (value: string) => void;
|
||||
onStatusChange: (value: string) => void;
|
||||
onPriorityChange: (value: string) => void;
|
||||
onTemplateTypeChange: (value: string) => void;
|
||||
onLifecycleChange: (value: string) => void;
|
||||
}
|
||||
|
||||
export function MyRequestsFilters({
|
||||
@ -23,10 +25,12 @@ export function MyRequestsFilters({
|
||||
statusFilter,
|
||||
priorityFilter,
|
||||
// templateTypeFilter,
|
||||
lifecycleFilter, // Destructure new prop
|
||||
onSearchChange,
|
||||
onStatusChange,
|
||||
onPriorityChange,
|
||||
// onTemplateTypeChange,
|
||||
onLifecycleChange, // Destructure new prop
|
||||
}: MyRequestsFiltersProps) {
|
||||
return (
|
||||
<Card className="border-gray-200" data-testid="my-requests-filters">
|
||||
@ -44,6 +48,21 @@ export function MyRequestsFilters({
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 sm:gap-3 w-full md:w-auto">
|
||||
{/* Lifecycle Filter */}
|
||||
<Select value={lifecycleFilter} onValueChange={onLifecycleChange}>
|
||||
<SelectTrigger
|
||||
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
||||
data-testid="lifecycle-filter"
|
||||
>
|
||||
<SelectValue placeholder="Lifecycle" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Requests</SelectItem>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={statusFilter} onValueChange={onStatusChange}>
|
||||
<SelectTrigger
|
||||
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
||||
@ -58,7 +77,6 @@ export function MyRequestsFilters({
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* My Requests Stats Section Component
|
||||
*/
|
||||
|
||||
import { FileText, Clock, Pause, CheckCircle, XCircle, Edit, Archive } from 'lucide-react';
|
||||
import { FileText, Clock, Pause, CheckCircle, XCircle, Edit } from 'lucide-react';
|
||||
import { StatsCard } from '@/components/dashboard/StatsCard';
|
||||
import { MyRequestsStats } from '../types/myRequests.types';
|
||||
|
||||
@ -18,7 +18,7 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3 sm:gap-4" data-testid="my-requests-stats">
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="my-requests-stats">
|
||||
<StatsCard
|
||||
label="Total"
|
||||
value={stats.total}
|
||||
@ -90,18 +90,6 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
|
||||
testId="stat-draft"
|
||||
onClick={onStatusFilter ? () => handleCardClick('draft') : undefined}
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
label="Closed"
|
||||
value={stats.closed}
|
||||
icon={Archive}
|
||||
iconColor="text-purple-600"
|
||||
gradient="bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200"
|
||||
textColor="text-purple-700"
|
||||
valueColor="text-purple-900"
|
||||
testId="stat-closed"
|
||||
onClick={onStatusFilter ? () => handleCardClick('closed') : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -8,7 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowRight, User, TrendingUp, Clock, Pause } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { MyRequest } from '../types/myRequests.types';
|
||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
|
||||
/**
|
||||
@ -44,6 +44,7 @@ interface RequestCardProps {
|
||||
|
||||
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
|
||||
const statusConfig = getStatusConfig(request.status);
|
||||
const stateConfig = getWorkflowStateConfig(request.workflowState || (request.status === 'draft' ? 'DRAFT' : 'OPEN'));
|
||||
const priorityConfig = getPriorityConfig(request.priority);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const PriorityIcon = priorityConfig.icon;
|
||||
@ -79,6 +80,15 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
||||
<StatusIcon className="w-3 h-3 mr-1" />
|
||||
<span className="capitalize">{request.status}</span>
|
||||
</Badge>
|
||||
{stateConfig.label.toLowerCase() !== request.status.toLowerCase() && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${stateConfig.color} border font-medium text-xs shrink-0`}
|
||||
data-testid="state-badge"
|
||||
>
|
||||
<span className="capitalize">{stateConfig.label}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{(request.pauseInfo?.isPaused || (request as any).isPaused) && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
||||
@ -14,6 +14,7 @@ interface UseMyRequestsOptions {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
templateType?: string;
|
||||
lifecycle?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@ -29,7 +30,7 @@ export function useMyRequests({ itemsPerPage = 10 }: UseMyRequestsOptions = {})
|
||||
});
|
||||
|
||||
const fetchMyRequests = useCallback(
|
||||
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string }) => {
|
||||
async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; templateType?: string, lifecycle?: string }) => {
|
||||
try {
|
||||
if (page === 1) {
|
||||
setLoading(true);
|
||||
@ -43,6 +44,7 @@ export function useMyRequests({ itemsPerPage = 10 }: UseMyRequestsOptions = {})
|
||||
status: filters?.status,
|
||||
priority: filters?.priority,
|
||||
templateType: filters?.templateType,
|
||||
lifecycle: filters?.lifecycle,
|
||||
});
|
||||
|
||||
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
||||
|
||||
@ -11,6 +11,7 @@ import {
|
||||
setPriorityFilter as setPriorityFilterAction,
|
||||
setTemplateTypeFilter as setTemplateTypeFilterAction,
|
||||
setCurrentPage as setCurrentPageAction,
|
||||
setLifecycleFilter as setLifecycleFilterAction,
|
||||
clearFilters as clearFiltersAction,
|
||||
} from '../redux/myRequestsSlice';
|
||||
|
||||
@ -25,7 +26,7 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
||||
const isInitialMount = useRef(true);
|
||||
|
||||
// Get filters from Redux
|
||||
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, currentPage } = useAppSelector((state) => state.myRequests);
|
||||
const { searchTerm, statusFilter, priorityFilter, templateTypeFilter, currentPage, lifecycleFilter } = useAppSelector((state) => state.myRequests);
|
||||
|
||||
// Create setters that dispatch Redux actions
|
||||
const setSearchTerm = useCallback((value: string) => dispatch(setSearchTermAction(value)), [dispatch]);
|
||||
@ -33,6 +34,7 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
||||
const setPriorityFilter = useCallback((value: string) => dispatch(setPriorityFilterAction(value)), [dispatch]);
|
||||
const setTemplateTypeFilter = useCallback((value: string) => dispatch(setTemplateTypeFilterAction(value)), [dispatch]);
|
||||
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
||||
const setLifecycleFilter = useCallback((value: string) => dispatch(setLifecycleFilterAction(value)), [dispatch]);
|
||||
|
||||
const getFilters = useCallback((): MyRequestsFilters => {
|
||||
return {
|
||||
@ -40,8 +42,9 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
||||
status: statusFilter,
|
||||
priority: priorityFilter,
|
||||
templateType: templateTypeFilter,
|
||||
lifecycle: lifecycleFilter,
|
||||
};
|
||||
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter]);
|
||||
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, lifecycleFilter]);
|
||||
|
||||
// Debounced filter change handler
|
||||
useEffect(() => {
|
||||
@ -68,7 +71,7 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
||||
clearTimeout(debounceTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, onFiltersChange, getFilters, debounceMs]);
|
||||
}, [searchTerm, statusFilter, priorityFilter, templateTypeFilter, lifecycleFilter, onFiltersChange, getFilters, debounceMs]);
|
||||
|
||||
const resetFilters = useCallback(() => {
|
||||
dispatch(clearFiltersAction());
|
||||
@ -80,11 +83,13 @@ export function useMyRequestsFilters({ onFiltersChange, debounceMs = 500 }: UseM
|
||||
priorityFilter,
|
||||
templateTypeFilter,
|
||||
currentPage,
|
||||
lifecycleFilter,
|
||||
setSearchTerm,
|
||||
setStatusFilter,
|
||||
setPriorityFilter,
|
||||
setTemplateTypeFilter,
|
||||
setCurrentPage,
|
||||
setLifecycleFilter,
|
||||
getFilters,
|
||||
resetFilters,
|
||||
};
|
||||
|
||||
@ -6,6 +6,7 @@ export interface MyRequestsFiltersState {
|
||||
priorityFilter: string;
|
||||
templateTypeFilter: string;
|
||||
currentPage: number;
|
||||
lifecycleFilter: string;
|
||||
}
|
||||
|
||||
const initialState: MyRequestsFiltersState = {
|
||||
@ -14,6 +15,7 @@ const initialState: MyRequestsFiltersState = {
|
||||
priorityFilter: 'all',
|
||||
templateTypeFilter: 'all',
|
||||
currentPage: 1,
|
||||
lifecycleFilter: 'all',
|
||||
};
|
||||
|
||||
const myRequestsSlice = createSlice({
|
||||
@ -37,12 +39,16 @@ const myRequestsSlice = createSlice({
|
||||
setCurrentPage: (state, action: PayloadAction<number>) => {
|
||||
state.currentPage = action.payload;
|
||||
},
|
||||
setLifecycleFilter: (state, action: PayloadAction<string>) => {
|
||||
state.lifecycleFilter = action.payload;
|
||||
},
|
||||
clearFilters: (state) => {
|
||||
state.searchTerm = '';
|
||||
state.statusFilter = 'all';
|
||||
state.priorityFilter = 'all';
|
||||
state.templateTypeFilter = 'all';
|
||||
state.currentPage = 1;
|
||||
state.lifecycleFilter = 'all';
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -53,6 +59,7 @@ export const {
|
||||
setPriorityFilter,
|
||||
setTemplateTypeFilter,
|
||||
setCurrentPage,
|
||||
setLifecycleFilter,
|
||||
clearFilters,
|
||||
} = myRequestsSlice.actions;
|
||||
|
||||
|
||||
@ -17,6 +17,7 @@ export interface MyRequest {
|
||||
approverLevel?: string;
|
||||
templateType?: string;
|
||||
workflowType?: string;
|
||||
workflowState?: string;
|
||||
templateName?: string;
|
||||
pauseInfo?: {
|
||||
isPaused: boolean;
|
||||
@ -41,6 +42,7 @@ export interface MyRequestsFilters {
|
||||
status: string;
|
||||
priority: string;
|
||||
templateType?: string;
|
||||
lifecycle?: string;
|
||||
}
|
||||
|
||||
export interface PaginationState {
|
||||
|
||||
@ -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,
|
||||
workflowType: req.workflowType || req.workflow_type,
|
||||
workflowState: req.workflowState || req.workflow_state,
|
||||
templateName: req.templateName || req.template_name,
|
||||
};
|
||||
}
|
||||
|
||||
@ -57,7 +57,7 @@ export function QuickActionsSidebar({
|
||||
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
||||
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
||||
const [hasRetriggerNotification, setHasRetriggerNotification] = useState(false);
|
||||
const isClosed = request?.status === 'closed';
|
||||
const isClosed = apiRequest?.workflowState === 'CLOSED' || request?.status === 'closed';
|
||||
const isPaused = request?.pauseInfo?.isPaused || false;
|
||||
const pausedByUserId = pausedByUserIdProp || request?.pauseInfo?.pausedBy?.userId;
|
||||
const currentUserId = currentUserIdProp || (user as any)?.userId || '';
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowLeft, FileText, RefreshCw, Share2 } from 'lucide-react';
|
||||
import { getPriorityConfig, getStatusConfig } from '@/utils/requestDetailHelpers';
|
||||
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '@/utils/requestDetailHelpers';
|
||||
import { SLAProgressBar } from '@/components/sla/SLAProgressBar';
|
||||
|
||||
interface RequestDetailHeaderProps {
|
||||
@ -32,6 +32,7 @@ export function RequestDetailHeader({
|
||||
}: RequestDetailHeaderProps) {
|
||||
const priorityConfig = getPriorityConfig(request?.priority || 'standard');
|
||||
const statusConfig = getStatusConfig(request?.status || 'pending');
|
||||
const stateConfig = getWorkflowStateConfig(request?.workflowState || (request?.status === 'DRAFT' ? 'DRAFT' : 'OPEN'));
|
||||
|
||||
return (
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-300 mb-4 sm:mb-6" data-testid="request-detail-header">
|
||||
@ -77,6 +78,15 @@ export function RequestDetailHeader({
|
||||
>
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
{stateConfig.label.toLowerCase() !== (request?.status || '').toLowerCase() && (
|
||||
<Badge
|
||||
className={`${stateConfig.color} rounded-full px-2 sm:px-3 text-xs capitalize shrink-0`}
|
||||
variant="outline"
|
||||
data-testid="state-badge"
|
||||
>
|
||||
{stateConfig.label}
|
||||
</Badge>
|
||||
)}
|
||||
{/* Template Type Badge */}
|
||||
{(() => {
|
||||
const workflowType = request?.workflowType || request?.workflow_type;
|
||||
@ -120,7 +130,7 @@ export function RequestDetailHeader({
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Share Summary Button - Only show for closed requests if user is initiator */}
|
||||
{onShareSummary && isInitiator && request?.status?.toLowerCase() === 'closed' && (
|
||||
{onShareSummary && isInitiator && request?.workflowState?.toLowerCase() === 'closed' && (
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
@ -157,8 +167,7 @@ export function RequestDetailHeader({
|
||||
|
||||
{/* SLA Progress Section - Plug-and-play: Module passes prepared SLA data */}
|
||||
{slaData !== undefined && (
|
||||
<div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${
|
||||
isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50'
|
||||
<div className={`px-3 sm:px-4 md:px-6 py-3 sm:py-4 border-b border-gray-200 ${isPaused ? 'bg-gradient-to-r from-gray-100 to-gray-200' : 'bg-gradient-to-r from-blue-50 to-indigo-50'
|
||||
}`} data-testid="sla-section">
|
||||
<SLAProgressBar
|
||||
sla={slaData}
|
||||
|
||||
@ -35,6 +35,7 @@ interface OverviewTabProps {
|
||||
generationAttempts?: number;
|
||||
generationFailed?: boolean;
|
||||
maxAttemptsReached?: boolean;
|
||||
isClosed?: boolean;
|
||||
}
|
||||
|
||||
export function OverviewTab({
|
||||
@ -57,6 +58,7 @@ export function OverviewTab({
|
||||
generationAttempts = 0,
|
||||
generationFailed = false,
|
||||
maxAttemptsReached = false,
|
||||
isClosed = false,
|
||||
}: OverviewTabProps) {
|
||||
void _onPause; // Marked as intentionally unused - available for future use
|
||||
const { user } = useAuth();
|
||||
@ -301,7 +303,7 @@ export function OverviewTab({
|
||||
)}
|
||||
|
||||
{/* Read-Only Conclusion Remark */}
|
||||
{request.status === 'closed' && request.conclusionRemark && (
|
||||
{isClosed && request.conclusionRemark && (
|
||||
<Card>
|
||||
<CardHeader className="bg-gradient-to-r from-gray-50 to-slate-50 border-b border-gray-200">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
@ -331,18 +333,15 @@ export function OverviewTab({
|
||||
{/* Conclusion Remark Section */}
|
||||
{needsClosure && (
|
||||
<Card data-testid="conclusion-remark-card">
|
||||
<CardHeader className={`bg-gradient-to-r border-b ${
|
||||
request.status === 'rejected'
|
||||
<CardHeader className={`bg-gradient-to-r border-b ${request.status === 'rejected'
|
||||
? 'from-red-50 to-rose-50 border-red-200'
|
||||
: 'from-green-50 to-emerald-50 border-green-200'
|
||||
}`}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${
|
||||
request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
|
||||
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
|
||||
}`}>
|
||||
<CheckCircle className={`w-5 h-5 ${
|
||||
request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
|
||||
<CheckCircle className={`w-5 h-5 ${request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
|
||||
}`} />
|
||||
Conclusion Remark - Final Step
|
||||
</CardTitle>
|
||||
|
||||
@ -163,7 +163,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
}).length;
|
||||
const closed = filteredData.filter((r: any) => {
|
||||
const status = (r.status || '').toString().toUpperCase();
|
||||
return status === 'CLOSED';
|
||||
const state = (r.workflowState || '').toString().toUpperCase();
|
||||
return (status === 'CLOSED' || state === 'CLOSED') && status !== 'APPROVED' && status !== 'REJECTED';
|
||||
}).length;
|
||||
|
||||
setBackendStats({
|
||||
@ -396,6 +397,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
dateRange: filters.dateRange,
|
||||
customStartDate: filters.customStartDate,
|
||||
customEndDate: filters.customEndDate,
|
||||
lifecycleFilter: filters.lifecycleFilter,
|
||||
isOrgLevel,
|
||||
});
|
||||
const hasInitialFetchRun = useRef(false);
|
||||
@ -426,6 +428,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
prev.dateRange !== filters.dateRange ||
|
||||
prev.customStartDate !== filters.customStartDate ||
|
||||
prev.customEndDate !== filters.customEndDate ||
|
||||
prev.lifecycleFilter !== filters.lifecycleFilter ||
|
||||
prev.isOrgLevel !== isOrgLevel;
|
||||
|
||||
if (!hasChanged) return;
|
||||
@ -447,6 +450,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
dateRange: filters.dateRange,
|
||||
customStartDate: filters.customStartDate,
|
||||
customEndDate: filters.customEndDate,
|
||||
lifecycleFilter: filters.lifecycleFilter,
|
||||
isOrgLevel,
|
||||
};
|
||||
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
||||
@ -466,7 +470,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
filters.approverFilterType,
|
||||
filters.dateRange,
|
||||
filters.customStartDate,
|
||||
filters.customEndDate
|
||||
filters.customEndDate,
|
||||
filters.lifecycleFilter
|
||||
]);
|
||||
|
||||
// Page change handler
|
||||
@ -553,8 +558,8 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
<Separator />
|
||||
|
||||
{/* Primary Filters */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
|
||||
<div className="relative md:col-span-3 lg:col-span-1">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-3 sm:gap-4">
|
||||
<div className="relative md:col-span-2 lg:col-span-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
|
||||
<Input
|
||||
placeholder="Search requests..."
|
||||
@ -565,6 +570,17 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select value={filters.lifecycleFilter} onValueChange={filters.setLifecycleFilter}>
|
||||
<SelectTrigger className="h-10" data-testid="lifecycle-filter">
|
||||
<SelectValue placeholder="Lifecycle" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Requests</SelectItem>
|
||||
<SelectItem value="open">Open</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}>
|
||||
<SelectTrigger className="h-10" data-testid="status-filter">
|
||||
<SelectValue placeholder="All Status" />
|
||||
@ -575,7 +591,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
@ -650,7 +665,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Search initiator..."
|
||||
placeholder="Use @ to search initiator..."
|
||||
value={initiatorSearch.searchQuery}
|
||||
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
|
||||
onFocus={() => {
|
||||
@ -720,7 +735,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
placeholder="Search approver..."
|
||||
placeholder="Use @ to search approver..."
|
||||
value={approverSearch.searchQuery}
|
||||
onChange={(e) => approverSearch.handleSearch(e.target.value)}
|
||||
onFocus={() => {
|
||||
|
||||
@ -327,6 +327,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
dateRange: filters.dateRange,
|
||||
customStartDate: filters.customStartDate,
|
||||
customEndDate: filters.customEndDate,
|
||||
lifecycleFilter: filters.lifecycleFilter,
|
||||
});
|
||||
const hasInitialFetchRun = useRef(false);
|
||||
|
||||
@ -355,7 +356,8 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
prev.approverFilterType !== filters.approverFilterType ||
|
||||
prev.dateRange !== filters.dateRange ||
|
||||
prev.customStartDate !== filters.customStartDate ||
|
||||
prev.customEndDate !== filters.customEndDate;
|
||||
prev.customEndDate !== filters.customEndDate ||
|
||||
prev.lifecycleFilter !== filters.lifecycleFilter;
|
||||
|
||||
if (!hasChanged) return;
|
||||
|
||||
@ -376,6 +378,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
dateRange: filters.dateRange,
|
||||
customStartDate: filters.customStartDate,
|
||||
customEndDate: filters.customEndDate,
|
||||
lifecycleFilter: filters.lifecycleFilter,
|
||||
};
|
||||
}, filters.searchTerm !== prev.searchTerm ? 500 : 0);
|
||||
|
||||
@ -393,7 +396,9 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
filters.approverFilterType,
|
||||
filters.dateRange,
|
||||
filters.customStartDate,
|
||||
filters.customEndDate
|
||||
filters.customStartDate,
|
||||
filters.customEndDate,
|
||||
filters.lifecycleFilter
|
||||
]);
|
||||
|
||||
// Page change handler
|
||||
@ -477,6 +482,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
{/* Filters - Plug-and-play pattern */}
|
||||
<UserAllRequestsFiltersComponent
|
||||
searchTerm={filters.searchTerm}
|
||||
lifecycleFilter={filters.lifecycleFilter}
|
||||
statusFilter={filters.statusFilter}
|
||||
priorityFilter={filters.priorityFilter}
|
||||
templateTypeFilter={filters.templateTypeFilter}
|
||||
@ -494,6 +500,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
initiatorSearch={initiatorSearch}
|
||||
approverSearch={approverSearch}
|
||||
onSearchChange={filters.setSearchTerm}
|
||||
onLifecycleChange={filters.setLifecycleFilter}
|
||||
onStatusChange={filters.setStatusFilter}
|
||||
onPriorityChange={filters.setPriorityFilter}
|
||||
onTemplateTypeChange={filters.setTemplateTypeFilter}
|
||||
|
||||
@ -6,7 +6,7 @@ import { motion } from 'framer-motion';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { User, ArrowRight, TrendingUp, Clock, Pause } from 'lucide-react';
|
||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||
import { getPriorityConfig, getStatusConfig, getWorkflowStateConfig } from '../utils/configMappers';
|
||||
import type { ConvertedRequest } from '../types/requests.types';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
|
||||
@ -43,6 +43,7 @@ interface RequestCardProps {
|
||||
|
||||
export function RequestCard({ request, index, onViewRequest }: RequestCardProps) {
|
||||
const statusConfig = getStatusConfig(request.status);
|
||||
const stateConfig = getWorkflowStateConfig(request.workflowState || (request.status === 'draft' ? 'DRAFT' : 'OPEN'));
|
||||
const priorityConfig = getPriorityConfig(request.priority);
|
||||
const StatusIcon = statusConfig.icon;
|
||||
const PriorityIcon = priorityConfig.icon;
|
||||
@ -78,6 +79,15 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
||||
<StatusIcon className="w-3 h-3 mr-1" />
|
||||
<span className="capitalize">{request.status}</span>
|
||||
</Badge>
|
||||
{stateConfig.label.toLowerCase() !== (request.status || '').toLowerCase() && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`${stateConfig.color} border font-medium text-xs shrink-0`}
|
||||
data-testid="state-badge"
|
||||
>
|
||||
<span className="capitalize">{stateConfig.label}</span>
|
||||
</Badge>
|
||||
)}
|
||||
{((request as any).pauseInfo?.isPaused || (request as any).isPaused) && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* Displays statistics cards for requests with click handlers to filter
|
||||
*/
|
||||
|
||||
import { FileText, Clock, Pause, CheckCircle, XCircle, Archive } from 'lucide-react';
|
||||
import { FileText, Clock, Pause, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { StatsCard } from '@/components/dashboard/StatsCard';
|
||||
import type { RequestStats } from '../types/requests.types';
|
||||
|
||||
@ -20,7 +20,7 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="requests-stats">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3 sm:gap-4" data-testid="requests-stats">
|
||||
<StatsCard
|
||||
label="Total"
|
||||
value={stats.total}
|
||||
@ -80,18 +80,6 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
|
||||
testId="stat-rejected"
|
||||
onClick={onStatusFilter ? () => handleCardClick('rejected') : undefined}
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
label="Closed"
|
||||
value={stats.closed}
|
||||
icon={Archive}
|
||||
iconColor="text-purple-600"
|
||||
gradient="bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200"
|
||||
textColor="text-purple-700"
|
||||
valueColor="text-purple-900"
|
||||
testId="stat-closed"
|
||||
onClick={onStatusFilter ? () => handleCardClick('closed') : undefined}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -22,6 +22,7 @@ import {
|
||||
setCustomEndDate as setCustomEndDateAction,
|
||||
setShowCustomDatePicker as setShowCustomDatePickerAction,
|
||||
setCurrentPage as setCurrentPageAction,
|
||||
setLifecycleFilter as setLifecycleFilterAction,
|
||||
clearFilters as clearFiltersAction,
|
||||
} from '../redux/requestsSlice';
|
||||
|
||||
@ -44,6 +45,7 @@ export function useRequestsFilters() {
|
||||
customEndDate,
|
||||
showCustomDatePicker,
|
||||
currentPage,
|
||||
lifecycleFilter,
|
||||
} = useAppSelector((state) => state.requests);
|
||||
|
||||
// Create setters that dispatch Redux actions
|
||||
@ -61,6 +63,7 @@ export function useRequestsFilters() {
|
||||
const setCustomEndDate = useCallback((value: Date | undefined) => dispatch(setCustomEndDateAction(value)), [dispatch]);
|
||||
const setShowCustomDatePicker = useCallback((value: boolean) => dispatch(setShowCustomDatePickerAction(value)), [dispatch]);
|
||||
const setCurrentPage = useCallback((value: number) => dispatch(setCurrentPageAction(value)), [dispatch]);
|
||||
const setLifecycleFilter = useCallback((value: string) => dispatch(setLifecycleFilterAction(value)), [dispatch]);
|
||||
|
||||
const getFilters = useCallback((): RequestFilters => {
|
||||
return {
|
||||
@ -73,6 +76,7 @@ export function useRequestsFilters() {
|
||||
initiator: initiatorFilter !== 'all' ? initiatorFilter : undefined,
|
||||
approver: approverFilter !== 'all' ? approverFilter : undefined,
|
||||
approverType: approverFilter !== 'all' ? approverFilterType : undefined,
|
||||
lifecycle: lifecycleFilter !== 'all' ? lifecycleFilter : undefined,
|
||||
dateRange,
|
||||
startDate: customStartDate,
|
||||
endDate: customEndDate
|
||||
@ -87,6 +91,7 @@ export function useRequestsFilters() {
|
||||
initiatorFilter,
|
||||
approverFilter,
|
||||
approverFilterType,
|
||||
lifecycleFilter, // Ensure lifecycleFilter is in dependencies
|
||||
dateRange,
|
||||
customStartDate,
|
||||
customEndDate
|
||||
@ -128,6 +133,7 @@ export function useRequestsFilters() {
|
||||
departmentFilter !== 'all' ||
|
||||
initiatorFilter !== 'all' ||
|
||||
approverFilter !== 'all' ||
|
||||
lifecycleFilter !== 'all' ||
|
||||
dateRange !== 'all' ||
|
||||
customStartDate ||
|
||||
customEndDate
|
||||
@ -147,6 +153,7 @@ export function useRequestsFilters() {
|
||||
dateRange,
|
||||
customStartDate,
|
||||
customEndDate,
|
||||
lifecycleFilter,
|
||||
showCustomDatePicker,
|
||||
currentPage,
|
||||
hasActiveFilters,
|
||||
@ -165,6 +172,7 @@ export function useRequestsFilters() {
|
||||
setCustomEndDate,
|
||||
setShowCustomDatePicker,
|
||||
setCurrentPage,
|
||||
setLifecycleFilter,
|
||||
// Helpers
|
||||
getFilters,
|
||||
clearFilters,
|
||||
|
||||
@ -45,14 +45,14 @@ export function useUserSearch({ allUsers, filterValue, onFilterChange }: UseUser
|
||||
clearTimeout(searchTimer.current);
|
||||
}
|
||||
|
||||
if (!query || query.trim().length < 2) {
|
||||
if (!query || !query.startsWith('@') || query.trim().length < 2) {
|
||||
setSearchResults([]);
|
||||
setShowResults(false);
|
||||
return;
|
||||
}
|
||||
|
||||
searchTimer.current = setTimeout(() => {
|
||||
const searchLower = query.toLowerCase().trim();
|
||||
const searchLower = query.slice(1).toLowerCase().trim();
|
||||
const filtered = allUsers.filter((user) => {
|
||||
const email = (user.email || '').toLowerCase();
|
||||
const displayName = (user.displayName || '').toLowerCase();
|
||||
|
||||
@ -16,6 +16,7 @@ export interface RequestsFiltersState {
|
||||
customEndDate?: Date;
|
||||
showCustomDatePicker: boolean;
|
||||
currentPage: number;
|
||||
lifecycleFilter: string;
|
||||
}
|
||||
|
||||
const initialState: RequestsFiltersState = {
|
||||
@ -33,6 +34,7 @@ const initialState: RequestsFiltersState = {
|
||||
customEndDate: undefined,
|
||||
showCustomDatePicker: false,
|
||||
currentPage: 1,
|
||||
lifecycleFilter: 'all',
|
||||
};
|
||||
|
||||
const requestsSlice = createSlice({
|
||||
@ -81,6 +83,9 @@ const requestsSlice = createSlice({
|
||||
setCurrentPage: (state, action: PayloadAction<number>) => {
|
||||
state.currentPage = action.payload;
|
||||
},
|
||||
setLifecycleFilter: (state, action: PayloadAction<string>) => {
|
||||
state.lifecycleFilter = action.payload;
|
||||
},
|
||||
clearFilters: (state) => {
|
||||
state.searchTerm = '';
|
||||
state.statusFilter = 'all';
|
||||
@ -96,6 +101,7 @@ const requestsSlice = createSlice({
|
||||
state.customEndDate = undefined;
|
||||
state.showCustomDatePicker = false;
|
||||
state.currentPage = 1;
|
||||
state.lifecycleFilter = 'all';
|
||||
},
|
||||
},
|
||||
});
|
||||
@ -115,6 +121,7 @@ export const {
|
||||
setCustomEndDate,
|
||||
setShowCustomDatePicker,
|
||||
setCurrentPage,
|
||||
setLifecycleFilter,
|
||||
clearFilters,
|
||||
} = requestsSlice.actions;
|
||||
|
||||
|
||||
@ -36,6 +36,7 @@ export async function fetchRequestsData({
|
||||
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
||||
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
|
||||
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
|
||||
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
|
||||
|
||||
// Fetch paginated data for list display (with status filter)
|
||||
const pageResult = await workflowApi.listWorkflows({
|
||||
@ -98,6 +99,7 @@ export async function fetchRequestsData({
|
||||
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
||||
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
|
||||
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
|
||||
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
|
||||
|
||||
// Fetch paginated data using endpoint for regular users
|
||||
// This endpoint includes all requests where user is initiator, approver, or participant
|
||||
|
||||
@ -41,6 +41,7 @@ export async function fetchUserParticipantRequestsData({
|
||||
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
||||
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
||||
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
||||
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
|
||||
|
||||
// Use single optimized endpoint - listParticipantRequests now includes initiator requests
|
||||
// Only fetch the requested page (10 records) for optimal performance
|
||||
@ -113,6 +114,7 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
|
||||
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
||||
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
||||
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
||||
if (filters?.lifecycle && filters.lifecycle !== 'all') backendFilters.lifecycle = filters.lifecycle;
|
||||
|
||||
// Fetch all pages using the single optimized endpoint
|
||||
while (hasMore && currentPage <= maxPages) {
|
||||
@ -150,4 +152,3 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
|
||||
|
||||
return allPages;
|
||||
}
|
||||
|
||||
|
||||
@ -21,6 +21,7 @@ export interface RequestFilters {
|
||||
dateRange?: DateRange;
|
||||
startDate?: Date;
|
||||
endDate?: Date;
|
||||
lifecycle?: string;
|
||||
}
|
||||
|
||||
export interface RequestStats {
|
||||
@ -64,6 +65,7 @@ export interface ConvertedRequest {
|
||||
approverLevel: string;
|
||||
templateType?: string;
|
||||
workflowType?: string;
|
||||
workflowState?: string;
|
||||
templateName?: string;
|
||||
}
|
||||
|
||||
|
||||
@ -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'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -90,7 +90,7 @@ export function transformRequest(req: any): ConvertedRequest {
|
||||
displayId: req.requestNumber || req.request_number || req.id,
|
||||
title: req.title,
|
||||
description: req.description,
|
||||
status: status.toLowerCase().replace('_','-'),
|
||||
status: status.toLowerCase().replace('_', '-'),
|
||||
priority: priority,
|
||||
department: req.department || req.initiator?.department,
|
||||
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
|
||||
@ -99,6 +99,7 @@ export function transformRequest(req: any): ConvertedRequest {
|
||||
approverLevel: approverLevel,
|
||||
templateType: req.templateType || req.template_type,
|
||||
workflowType: req.workflowType || req.workflow_type,
|
||||
workflowState: req.workflowState || req.workflow_state,
|
||||
templateName: req.templateName || req.template_name
|
||||
};
|
||||
}
|
||||
|
||||
@ -102,6 +102,8 @@ export interface CriticalRequest {
|
||||
isCritical: boolean;
|
||||
approverId?: string | null;
|
||||
approverEmail?: string | null;
|
||||
isActionable?: boolean;
|
||||
requestRole?: 'APPROVER' | 'INITIATOR' | 'PARTICIPANT';
|
||||
}
|
||||
|
||||
export interface AIRemarkUtilization {
|
||||
@ -203,7 +205,8 @@ class DashboardService {
|
||||
approverType?: 'current' | 'any',
|
||||
search?: string,
|
||||
slaCompliance?: string,
|
||||
viewAsUser?: boolean
|
||||
viewAsUser?: boolean,
|
||||
lifecycle?: string
|
||||
): Promise<RequestStats> {
|
||||
try {
|
||||
const params: any = { dateRange };
|
||||
@ -215,6 +218,9 @@ class DashboardService {
|
||||
if (status && status !== 'all') {
|
||||
params.status = status;
|
||||
}
|
||||
if (lifecycle && lifecycle !== 'all') {
|
||||
params.lifecycle = lifecycle;
|
||||
}
|
||||
if (priority && priority !== 'all') {
|
||||
params.priority = priority;
|
||||
}
|
||||
|
||||
@ -102,6 +102,7 @@ export async function createWorkflowFromForm(form: CreateWorkflowFromFormPayload
|
||||
priority, // STANDARD | EXPRESS
|
||||
approvalLevels,
|
||||
participants: participants.length ? participants : undefined,
|
||||
isDraft: (form as any).isDraft,
|
||||
};
|
||||
|
||||
const res = await apiClient.post('/workflows', payload);
|
||||
@ -131,6 +132,7 @@ export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayloa
|
||||
tatType: a.tatType || 'hours',
|
||||
};
|
||||
}),
|
||||
isDraft: (form as any).isDraft,
|
||||
};
|
||||
|
||||
// Add spectators if any (simplified - only email required)
|
||||
@ -155,8 +157,8 @@ export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayloa
|
||||
return { id: data?.requestId } as any;
|
||||
}
|
||||
|
||||
export async function listWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
|
||||
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params;
|
||||
export async function listWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; lifecycle?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
|
||||
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, slaCompliance, lifecycle, dateRange, startDate, endDate } = params;
|
||||
const res = await apiClient.get('/workflows', {
|
||||
params: {
|
||||
page,
|
||||
@ -169,6 +171,7 @@ export async function listWorkflows(params: { page?: number; limit?: number; sea
|
||||
initiator,
|
||||
approver,
|
||||
slaCompliance,
|
||||
lifecycle,
|
||||
dateRange,
|
||||
startDate,
|
||||
endDate
|
||||
@ -179,8 +182,8 @@ export async function listWorkflows(params: { page?: number; limit?: number; sea
|
||||
|
||||
// List requests where user is a participant (not initiator) - for regular users' "All Requests" page
|
||||
// SEPARATE from listWorkflows (admin) to avoid interference
|
||||
export async function listParticipantRequests(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; approverType?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
|
||||
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate } = params;
|
||||
export async function listParticipantRequests(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; initiator?: string; approver?: string; approverType?: string; slaCompliance?: string; lifecycle?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
|
||||
const { page = 1, limit = 20, search, status, priority, templateType, department, initiator, approver, approverType, slaCompliance, lifecycle, dateRange, startDate, endDate } = params;
|
||||
const res = await apiClient.get('/workflows/participant-requests', {
|
||||
params: {
|
||||
page,
|
||||
@ -194,6 +197,7 @@ export async function listParticipantRequests(params: { page?: number; limit?: n
|
||||
approver,
|
||||
approverType,
|
||||
slaCompliance,
|
||||
lifecycle,
|
||||
dateRange,
|
||||
startDate,
|
||||
endDate
|
||||
@ -234,8 +238,8 @@ export async function listMyWorkflows(params: { page?: number; limit?: number; s
|
||||
}
|
||||
|
||||
// List requests where user is the initiator - for "My Requests" page
|
||||
export async function listMyInitiatedWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
|
||||
const { page = 1, limit = 20, search, status, priority, templateType, department, slaCompliance, dateRange, startDate, endDate } = params;
|
||||
export async function listMyInitiatedWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; templateType?: string; department?: string; slaCompliance?: string; lifecycle?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
|
||||
const { page = 1, limit = 20, search, status, priority, templateType, department, slaCompliance, lifecycle, dateRange, startDate, endDate } = params;
|
||||
const res = await apiClient.get('/workflows/my-initiated', {
|
||||
params: {
|
||||
page,
|
||||
@ -246,6 +250,7 @@ export async function listMyInitiatedWorkflows(params: { page?: number; limit?:
|
||||
templateType,
|
||||
department,
|
||||
slaCompliance,
|
||||
lifecycle,
|
||||
dateRange,
|
||||
startDate,
|
||||
endDate
|
||||
|
||||
@ -66,7 +66,9 @@ export const getPriorityConfig = (priority: string) => {
|
||||
* @returns Configuration object with Tailwind CSS classes
|
||||
*/
|
||||
export const getStatusConfig = (status: string) => {
|
||||
switch (status) {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'in-review':
|
||||
case 'in_progress':
|
||||
case 'pending':
|
||||
return {
|
||||
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
@ -77,11 +79,6 @@ export const getStatusConfig = (status: string) => {
|
||||
color: 'bg-gray-400 text-gray-100 border-gray-500',
|
||||
label: 'paused'
|
||||
};
|
||||
case 'in-review':
|
||||
return {
|
||||
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
label: 'in-review'
|
||||
};
|
||||
case 'approved':
|
||||
return {
|
||||
color: 'bg-green-100 text-green-800 border-green-200',
|
||||
@ -110,6 +107,39 @@ export const getStatusConfig = (status: string) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility: getWorkflowStateConfig
|
||||
*
|
||||
* Purpose: Get display configuration for workflow lifecycle badges (Open, Closed, Draft)
|
||||
*
|
||||
* @param state - workflowState string from backend
|
||||
* @returns Configuration object with Tailwind CSS classes
|
||||
*/
|
||||
export const getWorkflowStateConfig = (state: string) => {
|
||||
switch (state?.toUpperCase()) {
|
||||
case 'DRAFT':
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
label: 'draft'
|
||||
};
|
||||
case 'OPEN':
|
||||
return {
|
||||
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
label: 'open'
|
||||
};
|
||||
case 'CLOSED':
|
||||
return {
|
||||
color: 'bg-slate-100 text-slate-800 border-slate-200',
|
||||
label: 'closed'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
label: state?.toLowerCase() || 'open'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Utility: getActionTypeIcon
|
||||
*
|
||||
|
||||
@ -30,7 +30,7 @@ async function ensureConfigLoaded() {
|
||||
}
|
||||
|
||||
// Initialize config on first import (non-blocking)
|
||||
ensureConfigLoaded().catch(() => {});
|
||||
ensureConfigLoaded().catch(() => { });
|
||||
|
||||
/**
|
||||
* Check if current time is within working hours
|
||||
@ -241,33 +241,21 @@ export function formatHoursMinutes(hours: number | null | undefined): string {
|
||||
}
|
||||
|
||||
// Calculate days and remaining hours (8 hours = 1 day)
|
||||
// Match backend formatTime logic from Re_Backend/src/utils/tatTimeUtils.ts
|
||||
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
|
||||
const remainingHrs = Math.floor(hours % WORKING_HOURS_PER_DAY);
|
||||
const minutes = Math.round((hours % 1) * 60);
|
||||
|
||||
// If we have days, format with days (matching backend format)
|
||||
if (days > 0) {
|
||||
const dayLabel = days === 1 ? 'day' : 'days';
|
||||
const hourLabel = remainingHrs === 1 ? 'hour' : 'hours';
|
||||
const minuteLabel = minutes === 1 ? 'min' : 'm';
|
||||
|
||||
if (minutes > 0) {
|
||||
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel} ${minutes}${minuteLabel}`;
|
||||
} else {
|
||||
return `${days} ${dayLabel} ${remainingHrs} ${hourLabel}`;
|
||||
}
|
||||
if (days > 0) {
|
||||
return minutes > 0
|
||||
? `${days} ${dayLabel} ${remainingHrs}h ${minutes}min`
|
||||
: `${days} ${dayLabel} ${remainingHrs}h`;
|
||||
}
|
||||
|
||||
// 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`;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -284,17 +272,17 @@ export function formatWorkingHours(hours: number): string {
|
||||
const minutes = remainingMinutes % 60;
|
||||
|
||||
if (days > 0 && remainingHours > 0 && minutes > 0) {
|
||||
return `${days}d ${remainingHours}h ${minutes}m`;
|
||||
return `${days}d ${remainingHours}h ${minutes}min`;
|
||||
} else if (days > 0 && remainingHours > 0) {
|
||||
return `${days}d ${remainingHours}h`;
|
||||
} else if (days > 0) {
|
||||
return `${days}d`;
|
||||
} else if (remainingHours > 0 && minutes > 0) {
|
||||
return `${remainingHours}h ${minutes}m`;
|
||||
return `${remainingHours}h ${minutes}min`;
|
||||
} else if (remainingHours > 0) {
|
||||
return `${remainingHours}h`;
|
||||
} else {
|
||||
return `${minutes}m`;
|
||||
return `${minutes}min`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user