pause feature added and also all request as normal user for admin
This commit is contained in:
parent
2161cc59ca
commit
fe441c8b76
@ -39,6 +39,7 @@ interface ApprovalStepCardProps {
|
|||||||
approval?: any; // Raw approval data from backend
|
approval?: any; // Raw approval data from backend
|
||||||
isCurrentUser?: boolean;
|
isCurrentUser?: boolean;
|
||||||
isInitiator?: boolean;
|
isInitiator?: boolean;
|
||||||
|
isCurrentLevel?: boolean; // Whether this step is the current active level
|
||||||
onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void;
|
onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void;
|
||||||
onRefresh?: () => void | Promise<void>; // Optional callback to refresh data
|
onRefresh?: () => void | Promise<void>; // Optional callback to refresh data
|
||||||
testId?: string;
|
testId?: string;
|
||||||
@ -66,6 +67,8 @@ const getStepIcon = (status: string, isSkipped?: boolean) => {
|
|||||||
return <CheckCircle className="w-4 h-4 sm:w-5 sm:w-5 text-green-600" />;
|
return <CheckCircle className="w-4 h-4 sm:w-5 sm:w-5 text-green-600" />;
|
||||||
case 'rejected':
|
case 'rejected':
|
||||||
return <XCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600" />;
|
return <XCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600" />;
|
||||||
|
case 'paused':
|
||||||
|
return <PauseCircle className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600" />;
|
||||||
case 'pending':
|
case 'pending':
|
||||||
case 'in-review':
|
case 'in-review':
|
||||||
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />;
|
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />;
|
||||||
@ -82,6 +85,7 @@ export function ApprovalStepCard({
|
|||||||
approval,
|
approval,
|
||||||
isCurrentUser = false,
|
isCurrentUser = false,
|
||||||
isInitiator = false,
|
isInitiator = false,
|
||||||
|
isCurrentLevel = false,
|
||||||
onSkipApprover,
|
onSkipApprover,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
testId = 'approval-step'
|
testId = 'approval-step'
|
||||||
@ -105,6 +109,7 @@ export function ApprovalStepCard({
|
|||||||
const isCompleted = step.status === 'approved';
|
const isCompleted = step.status === 'approved';
|
||||||
const isRejected = step.status === 'rejected';
|
const isRejected = step.status === 'rejected';
|
||||||
const isWaiting = step.status === 'waiting';
|
const isWaiting = step.status === 'waiting';
|
||||||
|
const isPaused = step.status === 'paused';
|
||||||
|
|
||||||
const tatHours = Number(step.tatHours || 0);
|
const tatHours = Number(step.tatHours || 0);
|
||||||
const actualHours = step.actualHours ?? 0;
|
const actualHours = step.actualHours ?? 0;
|
||||||
@ -180,6 +185,7 @@ export function ApprovalStepCard({
|
|||||||
<div className="flex items-start gap-2 sm:gap-3 md:gap-4">
|
<div className="flex items-start gap-2 sm:gap-3 md:gap-4">
|
||||||
<div className={`p-2 sm:p-2.5 md:p-3 rounded-xl flex-shrink-0 ${
|
<div className={`p-2 sm:p-2.5 md:p-3 rounded-xl flex-shrink-0 ${
|
||||||
step.isSkipped ? 'bg-orange-100' :
|
step.isSkipped ? 'bg-orange-100' :
|
||||||
|
isPaused ? 'bg-yellow-100' :
|
||||||
isActive ? 'bg-blue-100' :
|
isActive ? 'bg-blue-100' :
|
||||||
isCompleted ? 'bg-green-100' :
|
isCompleted ? 'bg-green-100' :
|
||||||
isRejected ? 'bg-red-100' :
|
isRejected ? 'bg-red-100' :
|
||||||
@ -333,8 +339,9 @@ export function ApprovalStepCard({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Active Approver - Show Real-time Progress from Backend */}
|
{/* Active Approver (including paused) - Show Real-time Progress from Backend */}
|
||||||
{isActive && approval?.sla && (
|
{/* Only show SLA for the current level step, not future levels */}
|
||||||
|
{isCurrentLevel && (isActive || isPaused) && approval?.sla && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="text-gray-600">Due by:</span>
|
<span className="text-gray-600">Due by:</span>
|
||||||
|
|||||||
179
src/components/workflow/PauseModal.tsx
Normal file
179
src/components/workflow/PauseModal.tsx
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Loader2, Pause } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { pauseWorkflow } from '@/services/workflowApi';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
interface PauseModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
requestId: string;
|
||||||
|
levelId: string | null;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PauseModal({ isOpen, onClose, requestId, levelId, onSuccess }: PauseModalProps) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [resumeDate, setResumeDate] = useState('');
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// Set default resume date to 1 month from now
|
||||||
|
const getDefaultResumeDate = () => {
|
||||||
|
const maxDate = dayjs().add(1, 'month').format('YYYY-MM-DD');
|
||||||
|
return maxDate;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMaxResumeDate = () => {
|
||||||
|
return dayjs().add(1, 'month').format('YYYY-MM-DD');
|
||||||
|
};
|
||||||
|
|
||||||
|
const getMinResumeDate = () => {
|
||||||
|
return dayjs().add(1, 'day').format('YYYY-MM-DD'); // At least 1 day from now
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize resume date when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && !resumeDate) {
|
||||||
|
setResumeDate(getDefaultResumeDate());
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!reason.trim()) {
|
||||||
|
toast.error('Please provide a reason for pausing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!resumeDate) {
|
||||||
|
toast.error('Please select a resume date');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedDate = dayjs(resumeDate);
|
||||||
|
const maxDate = dayjs().add(1, 'month');
|
||||||
|
const minDate = dayjs().add(1, 'day');
|
||||||
|
|
||||||
|
if (selectedDate.isAfter(maxDate)) {
|
||||||
|
toast.error('Resume date cannot be more than 1 month from now');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedDate.isBefore(minDate, 'day')) {
|
||||||
|
toast.error('Resume date must be at least 1 day from now');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await pauseWorkflow(requestId, levelId, reason.trim(), selectedDate.toDate());
|
||||||
|
toast.success('Workflow paused successfully');
|
||||||
|
setReason('');
|
||||||
|
setResumeDate(getDefaultResumeDate());
|
||||||
|
onSuccess?.();
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to pause workflow:', error);
|
||||||
|
toast.error(error?.response?.data?.error || error?.response?.data?.message || 'Failed to pause workflow');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!submitting) {
|
||||||
|
setReason('');
|
||||||
|
setResumeDate(getDefaultResumeDate());
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Pause className="w-5 h-5 text-orange-600" />
|
||||||
|
Pause Workflow
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-orange-800">
|
||||||
|
<strong>Note:</strong> Pausing will temporarily halt TAT calculations and notifications. The workflow will automatically resume on the selected date.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="pause-reason" className="text-sm font-medium">
|
||||||
|
Reason for Pausing <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="pause-reason"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
placeholder="Enter the reason for pausing this workflow..."
|
||||||
|
className="min-h-[100px] text-sm"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{reason.length} / 1000 characters
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="resume-date" className="text-sm font-medium">
|
||||||
|
Resume Date <span className="text-red-500">*</span>
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="resume-date"
|
||||||
|
type="date"
|
||||||
|
value={resumeDate}
|
||||||
|
onChange={(e) => setResumeDate(e.target.value)}
|
||||||
|
min={getMinResumeDate()}
|
||||||
|
max={getMaxResumeDate()}
|
||||||
|
className="text-sm"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Maximum 1 month from today. The workflow will automatically resume on this date.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting || !reason.trim() || !resumeDate}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700 text-white"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Pausing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Pause className="w-4 h-4 mr-2" />
|
||||||
|
Pause Workflow
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
85
src/components/workflow/RetriggerPauseModal.tsx
Normal file
85
src/components/workflow/RetriggerPauseModal.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Loader2, AlertCircle } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { retriggerPause } from '@/services/workflowApi';
|
||||||
|
|
||||||
|
interface RetriggerPauseModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
requestId: string;
|
||||||
|
approverName?: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RetriggerPauseModal({ isOpen, onClose, requestId, approverName, onSuccess }: RetriggerPauseModalProps) {
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
await retriggerPause(requestId);
|
||||||
|
toast.success('Retrigger request sent to approver');
|
||||||
|
onSuccess?.();
|
||||||
|
onClose();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to retrigger pause:', error);
|
||||||
|
toast.error(error?.response?.data?.error || error?.response?.data?.message || 'Failed to send retrigger request');
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-[450px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-orange-600" />
|
||||||
|
Request Resume
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-4">
|
||||||
|
<p className="text-sm text-gray-700 mb-4">
|
||||||
|
You are requesting the approver{approverName ? ` (${approverName})` : ''} to cancel the pause and resume work on this request.
|
||||||
|
</p>
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-orange-800">
|
||||||
|
A notification will be sent to the approver who paused this workflow, requesting them to resume it.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={submitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleSubmit}
|
||||||
|
disabled={submitting}
|
||||||
|
className="bg-orange-600 hover:bg-orange-700 text-white"
|
||||||
|
>
|
||||||
|
{submitting ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Sending...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-4 h-4 mr-2" />
|
||||||
|
Send Request
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -117,25 +117,39 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PRIORITY 3: Check if this is a logout redirect by checking if all tokens are cleared
|
// PRIORITY 3: Check authentication status
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
const refreshToken = TokenManager.getRefreshToken();
|
const refreshToken = TokenManager.getRefreshToken();
|
||||||
const userData = TokenManager.getUserData();
|
const userData = TokenManager.getUserData();
|
||||||
const hasAuthData = token || refreshToken || userData;
|
const hasAuthData = token || refreshToken || userData;
|
||||||
|
|
||||||
// If no auth data exists, we're likely after a logout - set unauthenticated state immediately
|
// Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
|
||||||
if (!hasAuthData) {
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
setIsAuthenticated(false);
|
|
||||||
setUser(null);
|
|
||||||
setIsLoading(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PRIORITY 4: Only check auth status if we have some auth data AND we're not logging out
|
// In production: Always verify with server (cookies are sent automatically)
|
||||||
if (!isLoggingOut) {
|
// In development: Check local auth data first
|
||||||
checkAuthStatus();
|
if (isProductionMode) {
|
||||||
|
// Production: Verify session with server via httpOnly cookie
|
||||||
|
if (!isLoggingOut) {
|
||||||
|
checkAuthStatus();
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setIsLoading(false);
|
// Development: If no auth data exists, user is not authenticated
|
||||||
|
if (!hasAuthData) {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUser(null);
|
||||||
|
setIsLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// PRIORITY 4: Only check auth status if we have some auth data AND we're not logging out
|
||||||
|
if (!isLoggingOut) {
|
||||||
|
checkAuthStatus();
|
||||||
|
} else {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [isLoggingOut]);
|
}, [isLoggingOut]);
|
||||||
|
|
||||||
@ -143,20 +157,34 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isAuthenticated) return;
|
if (!isAuthenticated) return;
|
||||||
|
|
||||||
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
const checkAndRefresh = async () => {
|
const checkAndRefresh = async () => {
|
||||||
const token = TokenManager.getAccessToken();
|
if (isProductionMode) {
|
||||||
if (token && isTokenExpired(token, 5)) {
|
// In production, proactively refresh the session every 10 minutes
|
||||||
// Token expires in less than 5 minutes, refresh it
|
// The httpOnly cookie will be sent automatically
|
||||||
try {
|
try {
|
||||||
await refreshTokenSilently();
|
await refreshTokenSilently();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Silent refresh failed:', error);
|
console.error('Silent refresh failed:', error);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// In development, check token expiration
|
||||||
|
const token = TokenManager.getAccessToken();
|
||||||
|
if (token && isTokenExpired(token, 5)) {
|
||||||
|
// Token expires in less than 5 minutes, refresh it
|
||||||
|
try {
|
||||||
|
await refreshTokenSilently();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Silent refresh failed:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check every 5 minutes
|
// Check every 10 minutes in production, 5 minutes in development
|
||||||
const interval = setInterval(checkAndRefresh, 5 * 60 * 1000);
|
const intervalMs = isProductionMode ? 10 * 60 * 1000 : 5 * 60 * 1000;
|
||||||
|
const interval = setInterval(checkAndRefresh, intervalMs);
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isAuthenticated]);
|
}, [isAuthenticated]);
|
||||||
|
|
||||||
@ -232,9 +260,58 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// PRODUCTION MODE: Verify session via httpOnly cookie
|
||||||
|
// The cookie is sent automatically with the request (withCredentials: true)
|
||||||
|
if (isProductionMode) {
|
||||||
|
const storedUser = TokenManager.getUserData();
|
||||||
|
|
||||||
|
// Try to get current user from server - this validates the httpOnly cookie
|
||||||
|
try {
|
||||||
|
const userData = await getCurrentUser();
|
||||||
|
setUser(userData);
|
||||||
|
TokenManager.setUserData(userData);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
// If 401, try to refresh the token (refresh token is also in httpOnly cookie)
|
||||||
|
if (error?.response?.status === 401) {
|
||||||
|
try {
|
||||||
|
await refreshTokenSilently();
|
||||||
|
// Retry getting user after refresh
|
||||||
|
const userData = await getCurrentUser();
|
||||||
|
setUser(userData);
|
||||||
|
TokenManager.setUserData(userData);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
} catch {
|
||||||
|
// Refresh failed - clear user data and show login
|
||||||
|
TokenManager.clearAll();
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} else if (error?.isConnectionError) {
|
||||||
|
// Backend not reachable - use stored user data if available
|
||||||
|
if (storedUser) {
|
||||||
|
setUser(storedUser);
|
||||||
|
setIsAuthenticated(true);
|
||||||
|
} else {
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Other error - clear and show login
|
||||||
|
TokenManager.clearAll();
|
||||||
|
setIsAuthenticated(false);
|
||||||
|
setUser(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEVELOPMENT MODE: Check local token
|
||||||
const token = TokenManager.getAccessToken();
|
const token = TokenManager.getAccessToken();
|
||||||
const storedUser = TokenManager.getUserData();
|
const storedUser = TokenManager.getUserData();
|
||||||
|
|
||||||
|
|||||||
@ -262,7 +262,8 @@ export function useRequestDetails(
|
|||||||
const approverEmail = (a.approverEmail || '').toLowerCase();
|
const approverEmail = (a.approverEmail || '').toLowerCase();
|
||||||
const approvalLevelNumber = a.levelNumber || 0;
|
const approvalLevelNumber = a.levelNumber || 0;
|
||||||
// Only show buttons if user is assigned to the CURRENT active level
|
// Only show buttons if user is assigned to the CURRENT active level
|
||||||
return (st === 'PENDING' || st === 'IN_PROGRESS')
|
// Include PAUSED status - paused level is still the current level
|
||||||
|
return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED')
|
||||||
&& approverEmail === userEmail
|
&& approverEmail === userEmail
|
||||||
&& approvalLevelNumber === currentLevel;
|
&& approvalLevelNumber === currentLevel;
|
||||||
});
|
});
|
||||||
@ -325,10 +326,13 @@ export function useRequestDetails(
|
|||||||
|
|
||||||
let displayStatus = statusMap(a.status);
|
let displayStatus = statusMap(a.status);
|
||||||
|
|
||||||
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
|
// If paused, show paused status (don't change it)
|
||||||
|
if (levelStatus === 'PAUSED') {
|
||||||
|
displayStatus = 'paused';
|
||||||
|
} else if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
|
||||||
displayStatus = 'waiting';
|
displayStatus = 'waiting';
|
||||||
} else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
|
} else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) {
|
||||||
displayStatus = 'pending';
|
displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
||||||
@ -398,6 +402,16 @@ export function useRequestDetails(
|
|||||||
})
|
})
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
|
// Fetch pause details
|
||||||
|
let pauseInfo = null;
|
||||||
|
try {
|
||||||
|
const { getPauseDetails } = await import('@/services/workflowApi');
|
||||||
|
pauseInfo = await getPauseDetails(wf.requestId);
|
||||||
|
} catch (error) {
|
||||||
|
// Pause info not available or request not paused - ignore
|
||||||
|
console.debug('Pause details not available:', error);
|
||||||
|
}
|
||||||
|
|
||||||
// Build complete request object
|
// Build complete request object
|
||||||
const mapped = {
|
const mapped = {
|
||||||
id: wf.requestNumber || wf.requestId,
|
id: wf.requestNumber || wf.requestId,
|
||||||
@ -428,19 +442,22 @@ export function useRequestDetails(
|
|||||||
auditTrail: filteredActivities,
|
auditTrail: filteredActivities,
|
||||||
conclusionRemark: wf.conclusionRemark || null,
|
conclusionRemark: wf.conclusionRemark || null,
|
||||||
closureDate: wf.closureDate || null,
|
closureDate: wf.closureDate || null,
|
||||||
|
pauseInfo: pauseInfo || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
setApiRequest(mapped);
|
setApiRequest(mapped);
|
||||||
|
|
||||||
// Find current user's approval level
|
// Find current user's approval level
|
||||||
// Only show approve/reject buttons if user is the CURRENT active approver
|
// Only show approve/reject buttons if user is the CURRENT active approver
|
||||||
|
// Include PAUSED status - when paused, the paused level is still the current level
|
||||||
const userEmail = (user as any)?.email?.toLowerCase();
|
const userEmail = (user as any)?.email?.toLowerCase();
|
||||||
const userCurrentLevel = approvals.find((a: any) => {
|
const userCurrentLevel = approvals.find((a: any) => {
|
||||||
const status = (a.status || '').toString().toUpperCase();
|
const status = (a.status || '').toString().toUpperCase();
|
||||||
const approverEmail = (a.approverEmail || '').toLowerCase();
|
const approverEmail = (a.approverEmail || '').toLowerCase();
|
||||||
const approvalLevelNumber = a.levelNumber || 0;
|
const approvalLevelNumber = a.levelNumber || 0;
|
||||||
// Only show buttons if user is assigned to the CURRENT active level
|
// Only show buttons if user is assigned to the CURRENT active level
|
||||||
return (status === 'PENDING' || status === 'IN_PROGRESS')
|
// Include PAUSED status - paused level is still the current level
|
||||||
|
return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED')
|
||||||
&& approverEmail === userEmail
|
&& approverEmail === userEmail
|
||||||
&& approvalLevelNumber === currentLevel;
|
&& approvalLevelNumber === currentLevel;
|
||||||
});
|
});
|
||||||
|
|||||||
10
src/main.tsx
10
src/main.tsx
@ -1,14 +1,18 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ReactDOM from 'react-dom/client';
|
import ReactDOM from 'react-dom/client';
|
||||||
|
import { Provider } from 'react-redux';
|
||||||
import { AuthProvider } from './contexts/AuthContext';
|
import { AuthProvider } from './contexts/AuthContext';
|
||||||
import { AuthenticatedApp } from './pages/Auth';
|
import { AuthenticatedApp } from './pages/Auth';
|
||||||
|
import { store } from './redux/store';
|
||||||
import './styles/globals.css';
|
import './styles/globals.css';
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<AuthProvider>
|
<Provider store={store}>
|
||||||
<AuthenticatedApp />
|
<AuthProvider>
|
||||||
</AuthProvider>
|
<AuthenticatedApp />
|
||||||
|
</AuthProvider>
|
||||||
|
</Provider>
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { useEffect, useMemo, useCallback, useState } from 'react';
|
import { useEffect, useMemo, useCallback } from 'react';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { type DateRange } from '@/services/dashboard.service';
|
import { type DateRange } from '@/services/dashboard.service';
|
||||||
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
||||||
|
import { useAppSelector, useAppDispatch } from '@/redux/hooks';
|
||||||
|
import { setViewAsUser } from './redux/dashboardSlice';
|
||||||
|
|
||||||
// Custom Hooks
|
// Custom Hooks
|
||||||
import { useDashboardFilters } from './hooks/useDashboardFilters';
|
import { useDashboardFilters } from './hooks/useDashboardFilters';
|
||||||
@ -36,16 +38,22 @@ interface DashboardProps {
|
|||||||
|
|
||||||
export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// Get viewAsUser from Redux store
|
||||||
|
const viewAsUser = useAppSelector((state) => state.dashboard.viewAsUser);
|
||||||
|
|
||||||
// Determine user role
|
// Determine user role
|
||||||
const isAdmin = useMemo(() => hasManagementAccess(user), [user]);
|
const isAdmin = useMemo(() => hasManagementAccess(user), [user]);
|
||||||
|
|
||||||
// Toggle for admin to switch between admin view and personal view
|
|
||||||
const [viewAsUser, setViewAsUser] = useState(false);
|
|
||||||
|
|
||||||
// Effective view mode: if admin and viewAsUser is true, show as normal user
|
// Effective view mode: if admin and viewAsUser is true, show as normal user
|
||||||
const effectiveIsAdmin = isAdmin && !viewAsUser;
|
const effectiveIsAdmin = isAdmin && !viewAsUser;
|
||||||
|
|
||||||
|
// Handler to toggle view
|
||||||
|
const handleToggleView = useCallback((value: boolean) => {
|
||||||
|
dispatch(setViewAsUser(value));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
// Filters
|
// Filters
|
||||||
const filters = useDashboardFilters();
|
const filters = useDashboardFilters();
|
||||||
const { dateRange, customStartDate, customEndDate, showCustomDatePicker, handleDateRangeChange, handleApplyCustomDate, resetCustomDates, setCustomStartDate, setCustomEndDate, setShowCustomDatePicker } = filters;
|
const { dateRange, customStartDate, customEndDate, showCustomDatePicker, handleDateRangeChange, handleApplyCustomDate, resetCustomDates, setCustomStartDate, setCustomEndDate, setShowCustomDatePicker } = filters;
|
||||||
@ -188,7 +196,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
|||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
effectiveIsAdmin={effectiveIsAdmin}
|
effectiveIsAdmin={effectiveIsAdmin}
|
||||||
viewAsUser={viewAsUser}
|
viewAsUser={viewAsUser}
|
||||||
onToggleView={setViewAsUser}
|
onToggleView={handleToggleView}
|
||||||
quickActions={quickActions}
|
quickActions={quickActions}
|
||||||
userDisplayName={(user as any)?.displayName}
|
userDisplayName={(user as any)?.displayName}
|
||||||
userEmail={(user as any)?.email}
|
userEmail={(user as any)?.email}
|
||||||
|
|||||||
@ -23,14 +23,6 @@ interface DashboardHeroProps {
|
|||||||
export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleView, quickActions, userDisplayName, userEmail }: DashboardHeroProps) {
|
export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleView, quickActions, userDisplayName, userEmail }: DashboardHeroProps) {
|
||||||
// Get user's name for welcome message
|
// Get user's name for welcome message
|
||||||
const userName = userDisplayName || userEmail?.split('@')[0] || 'User';
|
const userName = userDisplayName || userEmail?.split('@')[0] || 'User';
|
||||||
|
|
||||||
// Get current time for greeting
|
|
||||||
const getGreeting = () => {
|
|
||||||
const hour = new Date().getHours();
|
|
||||||
if (hour < 12) return 'Good morning';
|
|
||||||
if (hour < 18) return 'Good afternoon';
|
|
||||||
return 'Good evening';
|
|
||||||
};
|
|
||||||
return (
|
return (
|
||||||
<Card className="relative overflow-hidden shadow-xl border-0" data-testid="dashboard-hero">
|
<Card className="relative overflow-hidden shadow-xl border-0" data-testid="dashboard-hero">
|
||||||
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"></div>
|
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"></div>
|
||||||
@ -39,17 +31,17 @@ export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleV
|
|||||||
{/* Toggle for admin to switch between admin and personal view - Top Right Corner */}
|
{/* Toggle for admin to switch between admin and personal view - Top Right Corner */}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<div className="absolute top-4 right-4 sm:top-6 sm:right-6 z-20" data-testid="view-toggle">
|
<div className="absolute top-4 right-4 sm:top-6 sm:right-6 z-20" data-testid="view-toggle">
|
||||||
<div className="flex items-center gap-2 p-2 bg-white/10 backdrop-blur-sm rounded-lg border border-white/20 shadow-lg">
|
<div className="flex items-center gap-1.5 sm:gap-2 p-1.5 sm:p-2 bg-white/10 backdrop-blur-sm rounded-lg border border-white/20 shadow-lg">
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-1.5 px-2 py-1 rounded transition-all cursor-pointer ${
|
className={`flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded transition-all cursor-pointer ${
|
||||||
!viewAsUser
|
!viewAsUser
|
||||||
? 'bg-red-600/20 border border-red-600/50'
|
? 'bg-red-600/20 border border-red-600/50'
|
||||||
: 'opacity-60 hover:opacity-80'
|
: 'opacity-60 hover:opacity-80'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onToggleView(false)}
|
onClick={() => onToggleView(false)}
|
||||||
>
|
>
|
||||||
<Building2 className={`w-3.5 h-3.5 ${!viewAsUser ? 'text-red-600' : 'text-gray-300'}`} />
|
<Building2 className={`w-3 h-3 sm:w-3.5 sm:h-3.5 ${!viewAsUser ? 'text-red-600' : 'text-gray-300'}`} />
|
||||||
<Label htmlFor="view-toggle-switch" className={`text-xs font-medium cursor-pointer whitespace-nowrap ${
|
<Label htmlFor="view-toggle-switch" className={`text-[10px] sm:text-xs font-medium cursor-pointer whitespace-nowrap ${
|
||||||
!viewAsUser ? 'text-red-600' : 'text-gray-300'
|
!viewAsUser ? 'text-red-600' : 'text-gray-300'
|
||||||
}`}>
|
}`}>
|
||||||
Org
|
Org
|
||||||
@ -59,19 +51,19 @@ export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleV
|
|||||||
id="view-toggle-switch"
|
id="view-toggle-switch"
|
||||||
checked={viewAsUser}
|
checked={viewAsUser}
|
||||||
onCheckedChange={onToggleView}
|
onCheckedChange={onToggleView}
|
||||||
className="data-[state=checked]:bg-red-600 data-[state=unchecked]:bg-gray-600 shrink-0"
|
className="data-[state=checked]:bg-red-600 data-[state=unchecked]:bg-gray-600 shrink-0 scale-90 sm:scale-100"
|
||||||
data-testid="view-toggle-switch"
|
data-testid="view-toggle-switch"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center gap-1.5 px-2 py-1 rounded transition-all cursor-pointer ${
|
className={`flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded transition-all cursor-pointer ${
|
||||||
viewAsUser
|
viewAsUser
|
||||||
? 'bg-red-600/20 border border-red-600/50'
|
? 'bg-red-600/20 border border-red-600/50'
|
||||||
: 'opacity-60 hover:opacity-80'
|
: 'opacity-60 hover:opacity-80'
|
||||||
}`}
|
}`}
|
||||||
onClick={() => onToggleView(true)}
|
onClick={() => onToggleView(true)}
|
||||||
>
|
>
|
||||||
<User className={`w-3.5 h-3.5 ${viewAsUser ? 'text-red-600' : 'text-gray-300'}`} />
|
<User className={`w-3 h-3 sm:w-3.5 sm:h-3.5 ${viewAsUser ? 'text-red-600' : 'text-gray-300'}`} />
|
||||||
<Label htmlFor="view-toggle-switch" className={`text-xs font-medium cursor-pointer whitespace-nowrap ${
|
<Label htmlFor="view-toggle-switch" className={`text-[10px] sm:text-xs font-medium cursor-pointer whitespace-nowrap ${
|
||||||
viewAsUser ? 'text-red-600' : 'text-gray-300'
|
viewAsUser ? 'text-red-600' : 'text-gray-300'
|
||||||
}`}>
|
}`}>
|
||||||
Personal
|
Personal
|
||||||
@ -82,11 +74,11 @@ export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleV
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 sm:gap-6">
|
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 sm:gap-6">
|
||||||
<div className="text-white w-full lg:w-auto">
|
<div className={`text-white w-full lg:w-auto ${isAdmin ? 'pt-12 sm:pt-0' : ''}`}>
|
||||||
<div className="flex items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
<div className="flex items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
|
||||||
<div>
|
<div className="pr-2 sm:pr-0">
|
||||||
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold mb-1 sm:mb-2 text-white" data-testid="hero-title">
|
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold mb-1 sm:mb-2 text-white" data-testid="hero-title">
|
||||||
{getGreeting()}, {userName}!
|
Welcome, {userName}!
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm sm:text-lg lg:text-xl text-gray-200" data-testid="hero-subtitle">
|
<p className="text-sm sm:text-lg lg:text-xl text-gray-200" data-testid="hero-subtitle">
|
||||||
{effectiveIsAdmin ? 'Organization-wide analytics and insights' : 'Track your requests and approvals'}
|
{effectiveIsAdmin ? 'Organization-wide analytics and insights' : 'Track your requests and approvals'}
|
||||||
|
|||||||
@ -72,7 +72,7 @@ export function AdminKPICards({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{/* Row 2: Pending and Closed */}
|
{/* Row 2: Pending and Closed */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Pending"
|
label="Pending"
|
||||||
value={kpis?.requestVolume.openRequests || 0}
|
value={kpis?.requestVolume.openRequests || 0}
|
||||||
@ -96,6 +96,22 @@ export function AdminKPICards({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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}
|
||||||
|
bgColor="bg-orange-100"
|
||||||
|
textColor="text-orange-700"
|
||||||
|
testId="stat-paused"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onKPIClick({ ...getFilterParams(), status: 'paused' });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</KPICard>
|
</KPICard>
|
||||||
|
|
||||||
{/* SLA Compliance */}
|
{/* SLA Compliance */}
|
||||||
|
|||||||
35
src/pages/Dashboard/redux/dashboardSlice.ts
Normal file
35
src/pages/Dashboard/redux/dashboardSlice.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
|
|
||||||
|
interface DashboardState {
|
||||||
|
viewAsUser: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial state from localStorage if available
|
||||||
|
const getInitialViewAsUser = (): boolean => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem('dashboard_viewAsUser');
|
||||||
|
return stored ? JSON.parse(stored) : false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialState: DashboardState = {
|
||||||
|
viewAsUser: getInitialViewAsUser(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const dashboardSlice = createSlice({
|
||||||
|
name: 'dashboard',
|
||||||
|
initialState,
|
||||||
|
reducers: {
|
||||||
|
setViewAsUser: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.viewAsUser = action.payload;
|
||||||
|
// Persist to localStorage
|
||||||
|
localStorage.setItem('dashboard_viewAsUser', JSON.stringify(action.payload));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { setViewAsUser } = dashboardSlice.actions;
|
||||||
|
export default dashboardSlice;
|
||||||
|
|
||||||
@ -53,6 +53,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
const [backendStats, setBackendStats] = useState<{
|
const [backendStats, setBackendStats] = useState<{
|
||||||
total: number;
|
total: number;
|
||||||
pending: number;
|
pending: number;
|
||||||
|
paused: number;
|
||||||
approved: number;
|
approved: number;
|
||||||
rejected: number;
|
rejected: number;
|
||||||
draft: number;
|
draft: number;
|
||||||
@ -93,6 +94,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
setBackendStats({
|
setBackendStats({
|
||||||
total: stats.totalRequests || 0,
|
total: stats.totalRequests || 0,
|
||||||
pending: stats.openRequests || 0,
|
pending: stats.openRequests || 0,
|
||||||
|
paused: stats.pausedRequests || 0,
|
||||||
approved: stats.approvedRequests || 0,
|
approved: stats.approvedRequests || 0,
|
||||||
rejected: stats.rejectedRequests || 0,
|
rejected: stats.rejectedRequests || 0,
|
||||||
draft: stats.draftRequests || 0,
|
draft: stats.draftRequests || 0,
|
||||||
@ -129,6 +131,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
return {
|
return {
|
||||||
total: backendStats.total || 0,
|
total: backendStats.total || 0,
|
||||||
pending: backendStats.pending || 0,
|
pending: backendStats.pending || 0,
|
||||||
|
paused: backendStats.paused || 0,
|
||||||
approved: backendStats.approved || 0,
|
approved: backendStats.approved || 0,
|
||||||
rejected: backendStats.rejected || 0,
|
rejected: backendStats.rejected || 0,
|
||||||
draft: backendStats.draft || 0,
|
draft: backendStats.draft || 0,
|
||||||
@ -140,6 +143,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
return {
|
return {
|
||||||
total: 0,
|
total: 0,
|
||||||
pending: 0,
|
pending: 0,
|
||||||
|
paused: 0,
|
||||||
approved: 0,
|
approved: 0,
|
||||||
rejected: 0,
|
rejected: 0,
|
||||||
draft: 0,
|
draft: 0,
|
||||||
|
|||||||
@ -51,6 +51,7 @@ export function MyRequestsFilters({
|
|||||||
<SelectItem value="all">All Status</SelectItem>
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
<SelectItem value="draft">Draft</SelectItem>
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="paused">Paused</SelectItem>
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
<SelectItem value="approved">Approved</SelectItem>
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
<SelectItem value="rejected">Rejected</SelectItem>
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* My Requests Stats Section Component
|
* My Requests Stats Section Component
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FileText, Clock, CheckCircle, XCircle, Edit, Archive } from 'lucide-react';
|
import { FileText, Clock, Pause, CheckCircle, XCircle, Edit, Archive } from 'lucide-react';
|
||||||
import { StatsCard } from '@/components/dashboard/StatsCard';
|
import { StatsCard } from '@/components/dashboard/StatsCard';
|
||||||
import { MyRequestsStats } from '../types/myRequests.types';
|
import { MyRequestsStats } from '../types/myRequests.types';
|
||||||
|
|
||||||
@ -18,7 +18,7 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="my-requests-stats">
|
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3 sm:gap-4" data-testid="my-requests-stats">
|
||||||
<StatsCard
|
<StatsCard
|
||||||
label="Total"
|
label="Total"
|
||||||
value={stats.total}
|
value={stats.total}
|
||||||
@ -43,6 +43,18 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
|
|||||||
onClick={onStatusFilter ? () => handleCardClick('pending') : undefined}
|
onClick={onStatusFilter ? () => handleCardClick('pending') : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StatsCard
|
||||||
|
label="Paused"
|
||||||
|
value={stats.paused}
|
||||||
|
icon={Pause}
|
||||||
|
iconColor="text-amber-600"
|
||||||
|
gradient="bg-gradient-to-br from-amber-50 to-amber-100 border-amber-200"
|
||||||
|
textColor="text-amber-700"
|
||||||
|
valueColor="text-amber-900"
|
||||||
|
testId="stat-paused"
|
||||||
|
onClick={onStatusFilter ? () => handleCardClick('paused') : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
<StatsCard
|
<StatsCard
|
||||||
label="Approved"
|
label="Approved"
|
||||||
value={stats.approved}
|
value={stats.approved}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { ArrowRight, User, TrendingUp, Clock, FileText } from 'lucide-react';
|
import { ArrowRight, User, TrendingUp, Clock, FileText, Pause } from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { MyRequest } from '../types/myRequests.types';
|
import { MyRequest } from '../types/myRequests.types';
|
||||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||||
@ -79,6 +79,16 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
|||||||
<StatusIcon className="w-3 h-3 mr-1" />
|
<StatusIcon className="w-3 h-3 mr-1" />
|
||||||
<span className="capitalize">{request.status}</span>
|
<span className="capitalize">{request.status}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{(request.pauseInfo?.isPaused || (request as any).isPaused) && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-orange-50 text-orange-700 border-orange-300 font-medium text-xs shrink-0"
|
||||||
|
data-testid="pause-badge"
|
||||||
|
>
|
||||||
|
<Pause className="w-3 h-3 mr-1" />
|
||||||
|
Paused
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`${priorityConfig.color} border font-medium text-xs capitalize shrink-0`}
|
className={`${priorityConfig.color} border font-medium text-xs capitalize shrink-0`}
|
||||||
|
|||||||
@ -15,6 +15,7 @@ export function useMyRequestsStats({ requests, totalRecords }: UseMyRequestsStat
|
|||||||
return {
|
return {
|
||||||
total: totalRecords || requests.length,
|
total: totalRecords || requests.length,
|
||||||
pending: requests.filter((r) => r.status === 'pending').length,
|
pending: requests.filter((r) => r.status === 'pending').length,
|
||||||
|
paused: requests.filter((r) => r.status === 'paused').length,
|
||||||
approved: requests.filter((r) => r.status === 'approved').length,
|
approved: requests.filter((r) => r.status === 'approved').length,
|
||||||
rejected: requests.filter((r) => r.status === 'rejected').length,
|
rejected: requests.filter((r) => r.status === 'rejected').length,
|
||||||
draft: requests.filter((r) => r.status === 'draft').length,
|
draft: requests.filter((r) => r.status === 'draft').length,
|
||||||
|
|||||||
@ -17,11 +17,18 @@ export interface MyRequest {
|
|||||||
approverLevel?: string;
|
approverLevel?: string;
|
||||||
templateType?: string;
|
templateType?: string;
|
||||||
templateName?: string;
|
templateName?: string;
|
||||||
|
pauseInfo?: {
|
||||||
|
isPaused: boolean;
|
||||||
|
reason?: string;
|
||||||
|
pausedAt?: string;
|
||||||
|
pausedBy?: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MyRequestsStats {
|
export interface MyRequestsStats {
|
||||||
total: number;
|
total: number;
|
||||||
pending: number;
|
pending: number;
|
||||||
|
paused: number;
|
||||||
approved: number;
|
approved: number;
|
||||||
rejected: number;
|
rejected: number;
|
||||||
draft: number;
|
draft: number;
|
||||||
|
|||||||
@ -49,6 +49,9 @@ import { SummaryTab } from './components/tabs/SummaryTab';
|
|||||||
import { QuickActionsSidebar } from './components/QuickActionsSidebar';
|
import { QuickActionsSidebar } from './components/QuickActionsSidebar';
|
||||||
import { RequestDetailModals } from './components/RequestDetailModals';
|
import { RequestDetailModals } from './components/RequestDetailModals';
|
||||||
import { RequestDetailProps } from './types/requestDetail.types';
|
import { RequestDetailProps } from './types/requestDetail.types';
|
||||||
|
import { PauseModal } from '@/components/workflow/PauseModal';
|
||||||
|
import { RetriggerPauseModal } from '@/components/workflow/RetriggerPauseModal';
|
||||||
|
import { resumeWorkflow } from '@/services/workflowApi';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Error Boundary Component
|
* Error Boundary Component
|
||||||
@ -105,6 +108,8 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
const [summaryDetails, setSummaryDetails] = useState<SummaryDetails | null>(null);
|
const [summaryDetails, setSummaryDetails] = useState<SummaryDetails | null>(null);
|
||||||
const [loadingSummary, setLoadingSummary] = useState(false);
|
const [loadingSummary, setLoadingSummary] = useState(false);
|
||||||
const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0);
|
const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0);
|
||||||
|
const [showPauseModal, setShowPauseModal] = useState(false);
|
||||||
|
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Custom hooks
|
// Custom hooks
|
||||||
@ -183,6 +188,37 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
refreshDetails();
|
refreshDetails();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Pause handlers
|
||||||
|
const handlePause = () => {
|
||||||
|
setShowPauseModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResume = async () => {
|
||||||
|
if (!apiRequest?.requestId) {
|
||||||
|
toast.error('Request ID not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await resumeWorkflow(apiRequest.requestId);
|
||||||
|
toast.success('Workflow resumed successfully');
|
||||||
|
refreshDetails();
|
||||||
|
} catch (error: any) {
|
||||||
|
toast.error(error?.response?.data?.error || 'Failed to resume workflow');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetrigger = () => {
|
||||||
|
setShowRetriggerModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePauseSuccess = async () => {
|
||||||
|
refreshDetails();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRetriggerSuccess = async () => {
|
||||||
|
refreshDetails();
|
||||||
|
};
|
||||||
|
|
||||||
const handleShareSummary = async () => {
|
const handleShareSummary = async () => {
|
||||||
if (!apiRequest?.requestId) {
|
if (!apiRequest?.requestId) {
|
||||||
toast.error('Request ID not found');
|
toast.error('Request ID not found');
|
||||||
@ -397,6 +433,12 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
aiGenerated={aiGenerated}
|
aiGenerated={aiGenerated}
|
||||||
handleGenerateConclusion={handleGenerateConclusion}
|
handleGenerateConclusion={handleGenerateConclusion}
|
||||||
handleFinalizeConclusion={handleFinalizeConclusion}
|
handleFinalizeConclusion={handleFinalizeConclusion}
|
||||||
|
onPause={handlePause}
|
||||||
|
onResume={handleResume}
|
||||||
|
onRetrigger={handleRetrigger}
|
||||||
|
currentUserIsApprover={!!currentApprovalLevel}
|
||||||
|
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||||
|
currentUserId={(user as any)?.userId}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@ -469,8 +511,13 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
onAddSpectator={() => setShowAddSpectatorModal(true)}
|
onAddSpectator={() => setShowAddSpectatorModal(true)}
|
||||||
onApprove={() => setShowApproveModal(true)}
|
onApprove={() => setShowApproveModal(true)}
|
||||||
onReject={() => setShowRejectModal(true)}
|
onReject={() => setShowRejectModal(true)}
|
||||||
|
onPause={handlePause}
|
||||||
|
onResume={handleResume}
|
||||||
|
onRetrigger={handleRetrigger}
|
||||||
summaryId={summaryId}
|
summaryId={summaryId}
|
||||||
refreshTrigger={sharedRecipientsRefreshTrigger}
|
refreshTrigger={sharedRecipientsRefreshTrigger}
|
||||||
|
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||||
|
currentUserId={(user as any)?.userId}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -493,6 +540,27 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pause Modals */}
|
||||||
|
{showPauseModal && apiRequest?.requestId && (
|
||||||
|
<PauseModal
|
||||||
|
isOpen={showPauseModal}
|
||||||
|
onClose={() => setShowPauseModal(false)}
|
||||||
|
requestId={apiRequest.requestId}
|
||||||
|
levelId={currentApprovalLevel?.levelId || null}
|
||||||
|
onSuccess={handlePauseSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showRetriggerModal && apiRequest?.requestId && (
|
||||||
|
<RetriggerPauseModal
|
||||||
|
isOpen={showRetriggerModal}
|
||||||
|
onClose={() => setShowRetriggerModal(false)}
|
||||||
|
requestId={apiRequest.requestId}
|
||||||
|
approverName={request?.pauseInfo?.pausedBy?.name}
|
||||||
|
onSuccess={handleRetriggerSuccess}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Modals */}
|
{/* Modals */}
|
||||||
<RequestDetailModals
|
<RequestDetailModals
|
||||||
showApproveModal={showApproveModal}
|
showApproveModal={showApproveModal}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { UserPlus, Eye, CheckCircle, XCircle, Share2 } from 'lucide-react';
|
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react';
|
||||||
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
|
||||||
|
|
||||||
interface QuickActionsSidebarProps {
|
interface QuickActionsSidebarProps {
|
||||||
@ -18,8 +18,13 @@ interface QuickActionsSidebarProps {
|
|||||||
onAddSpectator: () => void;
|
onAddSpectator: () => void;
|
||||||
onApprove: () => void;
|
onApprove: () => void;
|
||||||
onReject: () => void;
|
onReject: () => void;
|
||||||
|
onPause?: () => void;
|
||||||
|
onResume?: () => void;
|
||||||
|
onRetrigger?: () => void;
|
||||||
summaryId?: string | null;
|
summaryId?: string | null;
|
||||||
refreshTrigger?: number; // Trigger to refresh shared recipients list
|
refreshTrigger?: number; // Trigger to refresh shared recipients list
|
||||||
|
pausedByUserId?: string; // User ID of the approver who paused
|
||||||
|
currentUserId?: string; // Current user's ID
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuickActionsSidebar({
|
export function QuickActionsSidebar({
|
||||||
@ -31,12 +36,23 @@ export function QuickActionsSidebar({
|
|||||||
onAddSpectator,
|
onAddSpectator,
|
||||||
onApprove,
|
onApprove,
|
||||||
onReject,
|
onReject,
|
||||||
|
onPause,
|
||||||
|
onResume,
|
||||||
|
onRetrigger,
|
||||||
summaryId,
|
summaryId,
|
||||||
refreshTrigger,
|
refreshTrigger,
|
||||||
|
pausedByUserId,
|
||||||
|
currentUserId,
|
||||||
}: QuickActionsSidebarProps) {
|
}: QuickActionsSidebarProps) {
|
||||||
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
||||||
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
||||||
const isClosed = request?.status === 'closed';
|
const isClosed = request?.status === 'closed';
|
||||||
|
const isPaused = request?.pauseInfo?.isPaused || false;
|
||||||
|
const canPause = !isPaused && !isClosed && currentApprovalLevel; // Only approver can pause
|
||||||
|
// Only the approver who paused can resume directly (not initiators)
|
||||||
|
const canResume = isPaused && onResume && !isInitiator && pausedByUserId === currentUserId;
|
||||||
|
// Initiators can request resume (retrigger) when workflow is paused
|
||||||
|
const canRetrigger = isPaused && isInitiator && onRetrigger;
|
||||||
|
|
||||||
// Fetch shared recipients when request is closed and summaryId is available
|
// Fetch shared recipients when request is closed and summaryId is available
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -96,9 +112,46 @@ export function QuickActionsSidebar({
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Pause/Resume Button */}
|
||||||
|
{canPause && onPause && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start gap-2 bg-white text-orange-700 border-orange-300 hover:bg-orange-50 hover:text-orange-900 h-9 sm:h-10 text-xs sm:text-sm"
|
||||||
|
onClick={onPause}
|
||||||
|
data-testid="pause-workflow-button"
|
||||||
|
>
|
||||||
|
<Pause className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
|
Pause Workflow
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canResume && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start gap-2 bg-white text-green-700 border-green-300 hover:bg-green-50 hover:text-green-900 h-9 sm:h-10 text-xs sm:text-sm"
|
||||||
|
onClick={onResume}
|
||||||
|
data-testid="resume-workflow-button"
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
|
Resume Workflow
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{canRetrigger && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start gap-2 bg-white text-orange-700 border-orange-300 hover:bg-orange-50 hover:text-orange-900 h-9 sm:h-10 text-xs sm:text-sm"
|
||||||
|
onClick={onRetrigger}
|
||||||
|
data-testid="request-resume-button"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
|
Request Resume
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Approve/Reject Buttons */}
|
{/* Approve/Reject Buttons */}
|
||||||
<div className="pt-3 sm:pt-4 space-y-2">
|
<div className="pt-3 sm:pt-4 space-y-2">
|
||||||
{currentApprovalLevel && (
|
{currentApprovalLevel && !isPaused && (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
className="w-full bg-green-600 hover:bg-green-700 text-white h-9 sm:h-10 text-xs sm:text-sm"
|
className="w-full bg-green-600 hover:bg-green-700 text-white h-9 sm:h-10 text-xs sm:text-sm"
|
||||||
@ -119,6 +172,12 @@ export function QuickActionsSidebar({
|
|||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isPaused && (
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-xs text-orange-800 font-medium">Workflow is paused</p>
|
||||||
|
<p className="text-xs text-orange-600 mt-1">Actions are disabled until resumed</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@ -7,8 +7,12 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
import { FormattedDescription } from '@/components/common/FormattedDescription';
|
||||||
import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2 } from 'lucide-react';
|
import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2, Pause, Play, AlertCircle } from 'lucide-react';
|
||||||
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||||
|
|
||||||
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
interface OverviewTabProps {
|
interface OverviewTabProps {
|
||||||
request: any;
|
request: any;
|
||||||
@ -21,11 +25,17 @@ interface OverviewTabProps {
|
|||||||
aiGenerated: boolean;
|
aiGenerated: boolean;
|
||||||
handleGenerateConclusion: () => void;
|
handleGenerateConclusion: () => void;
|
||||||
handleFinalizeConclusion: () => void;
|
handleFinalizeConclusion: () => void;
|
||||||
|
onPause?: () => void;
|
||||||
|
onResume?: () => void;
|
||||||
|
onRetrigger?: () => void;
|
||||||
|
currentUserIsApprover?: boolean;
|
||||||
|
pausedByUserId?: string;
|
||||||
|
currentUserId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function OverviewTab({
|
export function OverviewTab({
|
||||||
request,
|
request,
|
||||||
isInitiator: _isInitiator,
|
isInitiator,
|
||||||
needsClosure,
|
needsClosure,
|
||||||
conclusionRemark,
|
conclusionRemark,
|
||||||
setConclusionRemark,
|
setConclusionRemark,
|
||||||
@ -34,7 +44,21 @@ export function OverviewTab({
|
|||||||
aiGenerated,
|
aiGenerated,
|
||||||
handleGenerateConclusion,
|
handleGenerateConclusion,
|
||||||
handleFinalizeConclusion,
|
handleFinalizeConclusion,
|
||||||
|
onPause: _onPause,
|
||||||
|
onResume,
|
||||||
|
onRetrigger,
|
||||||
|
currentUserIsApprover = false,
|
||||||
|
pausedByUserId,
|
||||||
|
currentUserId,
|
||||||
}: OverviewTabProps) {
|
}: OverviewTabProps) {
|
||||||
|
void _onPause; // Marked as intentionally unused - available for future use
|
||||||
|
const pauseInfo = request?.pauseInfo;
|
||||||
|
const isPaused = pauseInfo?.isPaused || false;
|
||||||
|
// Only the approver who paused can resume directly
|
||||||
|
// Initiators can only request resume via retrigger
|
||||||
|
const canResume = isPaused && (currentUserIsApprover && pausedByUserId === currentUserId);
|
||||||
|
// Initiators can request resume (retrigger) when workflow is paused
|
||||||
|
const canRetrigger = isPaused && isInitiator;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content">
|
<div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content">
|
||||||
{/* Request Initiator Card */}
|
{/* Request Initiator Card */}
|
||||||
@ -133,6 +157,83 @@ export function OverviewTab({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
{/* Pause Status Card */}
|
||||||
|
{isPaused && pauseInfo && (
|
||||||
|
<Card className="border-orange-300 bg-orange-50/50" data-testid="pause-status-card">
|
||||||
|
<CardHeader className="pb-3 sm:pb-4">
|
||||||
|
<CardTitle className="flex items-center gap-2 text-sm sm:text-base text-orange-800">
|
||||||
|
<Pause className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600" />
|
||||||
|
Workflow Paused
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3 sm:space-y-4">
|
||||||
|
<div className="bg-white rounded-lg p-3 sm:p-4 border border-orange-200">
|
||||||
|
<div className="space-y-2 sm:space-y-3">
|
||||||
|
{pauseInfo.pauseReason && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pauseReason}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pauseInfo.pausedBy && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused By</label>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pausedBy.name || pauseInfo.pausedBy.email}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pauseInfo.pauseResumeDate && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Auto-Resume Date</label>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">
|
||||||
|
{formatDateTime(pauseInfo.pauseResumeDate)}
|
||||||
|
{dayjs(pauseInfo.pauseResumeDate).isAfter(dayjs()) && (
|
||||||
|
<span className="ml-2 text-xs text-gray-500">
|
||||||
|
({dayjs(pauseInfo.pauseResumeDate).fromNow()})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{pauseInfo.pausedAt && (
|
||||||
|
<div>
|
||||||
|
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused At</label>
|
||||||
|
<p className="text-sm text-gray-900 mt-1">{formatDateTime(pauseInfo.pausedAt)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex flex-wrap gap-2 pt-2 border-t border-orange-200">
|
||||||
|
{canResume && onResume && (
|
||||||
|
<Button
|
||||||
|
onClick={onResume}
|
||||||
|
className="bg-green-600 hover:bg-green-700 text-white text-xs sm:text-sm h-8 sm:h-9"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<Play className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-1.5" />
|
||||||
|
Resume Now
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{canRetrigger && onRetrigger && (
|
||||||
|
<Button
|
||||||
|
onClick={onRetrigger}
|
||||||
|
variant="outline"
|
||||||
|
className="border-orange-300 text-orange-700 hover:bg-orange-50 text-xs sm:text-sm h-8 sm:h-9"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-1.5" />
|
||||||
|
Request Resume
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Claim Management Details */}
|
{/* Claim Management Details */}
|
||||||
{request.claimDetails && (
|
{request.claimDetails && (
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@ -89,6 +89,11 @@ export function WorkflowTab({ request, user, isInitiator, onSkipApprover, onRefr
|
|||||||
const approverEmail = step.approverEmail?.toLowerCase();
|
const approverEmail = step.approverEmail?.toLowerCase();
|
||||||
const isCurrentUser = currentUserEmail && approverEmail && currentUserEmail === approverEmail;
|
const isCurrentUser = currentUserEmail && approverEmail && currentUserEmail === approverEmail;
|
||||||
|
|
||||||
|
// Determine if this is the current active level (for SLA display)
|
||||||
|
// Current level is the one that matches request.currentStep (or currentStepRaw)
|
||||||
|
const currentStep = request.currentStepRaw !== undefined ? request.currentStepRaw : (request.currentStep || 1);
|
||||||
|
const isCurrentLevel = step.step === currentStep;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ApprovalStepCard
|
<ApprovalStepCard
|
||||||
key={index}
|
key={index}
|
||||||
@ -97,6 +102,7 @@ export function WorkflowTab({ request, user, isInitiator, onSkipApprover, onRefr
|
|||||||
approval={approval}
|
approval={approval}
|
||||||
isCurrentUser={isCurrentUser}
|
isCurrentUser={isCurrentUser}
|
||||||
isInitiator={isInitiator}
|
isInitiator={isInitiator}
|
||||||
|
isCurrentLevel={isCurrentLevel}
|
||||||
onSkipApprover={onSkipApprover}
|
onSkipApprover={onSkipApprover}
|
||||||
onRefresh={onRefresh}
|
onRefresh={onRefresh}
|
||||||
testId="workflow-step"
|
testId="workflow-step"
|
||||||
|
|||||||
@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
||||||
|
import { useAppSelector } from '@/redux/hooks';
|
||||||
import { Pagination } from '@/components/common/Pagination';
|
import { Pagination } from '@/components/common/Pagination';
|
||||||
import type { DateRange } from '@/services/dashboard.service';
|
import type { DateRange } from '@/services/dashboard.service';
|
||||||
import dashboardService from '@/services/dashboard.service';
|
import dashboardService from '@/services/dashboard.service';
|
||||||
@ -47,7 +48,16 @@ import { format } from 'date-fns';
|
|||||||
|
|
||||||
export function Requests({ onViewRequest }: RequestsProps) {
|
export function Requests({ onViewRequest }: RequestsProps) {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const isOrgLevel = useMemo(() => hasManagementAccess(user), [user]);
|
|
||||||
|
// Get viewAsUser from Redux store (synced with Dashboard toggle)
|
||||||
|
const viewAsUser = useAppSelector((state) => state.dashboard.viewAsUser);
|
||||||
|
|
||||||
|
// Determine if viewing at organization level:
|
||||||
|
// - If user is admin/management AND not in "Personal" mode (viewAsUser=false) → show all requests
|
||||||
|
// - If user is admin/management AND in "Personal" mode (viewAsUser=true) → show only their requests
|
||||||
|
// - If user is not admin/management → always show only their requests
|
||||||
|
const isAdmin = useMemo(() => hasManagementAccess(user), [user]);
|
||||||
|
const isOrgLevel = useMemo(() => isAdmin && !viewAsUser, [isAdmin, viewAsUser]);
|
||||||
|
|
||||||
// Filters hook
|
// Filters hook
|
||||||
const filters = useRequestsFilters();
|
const filters = useRequestsFilters();
|
||||||
@ -83,6 +93,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
// Fetch backend stats
|
// Fetch backend stats
|
||||||
// Stats should reflect filters (priority, department, initiator, approver, search, date range) but NOT status
|
// Stats should reflect filters (priority, department, initiator, approver, search, date range) but NOT status
|
||||||
// Status filter should not affect stats - stats should always show all status counts
|
// Status filter should not affect stats - stats should always show all status counts
|
||||||
|
// For user-level (Personal mode), stats will only include requests where user is involved
|
||||||
const fetchBackendStats = useCallback(async (
|
const fetchBackendStats = useCallback(async (
|
||||||
statsDateRange?: DateRange,
|
statsDateRange?: DateRange,
|
||||||
statsStartDate?: Date,
|
statsStartDate?: Date,
|
||||||
@ -97,8 +108,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
slaCompliance?: string;
|
slaCompliance?: string;
|
||||||
}
|
}
|
||||||
) => {
|
) => {
|
||||||
if (!isOrgLevel) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// For dynamic SLA statuses (on_track, approaching, critical), we need to fetch and enrich
|
// For dynamic SLA statuses (on_track, approaching, critical), we need to fetch and enrich
|
||||||
// because these are calculated dynamically, not stored in DB
|
// because these are calculated dynamically, not stored in DB
|
||||||
@ -131,11 +140,10 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
if (statsEndDate) backendFilters.endDate = statsEndDate.toISOString();
|
if (statsEndDate) backendFilters.endDate = statsEndDate.toISOString();
|
||||||
|
|
||||||
// Fetch up to 1000 requests (backend will enrich and filter by SLA)
|
// Fetch up to 1000 requests (backend will enrich and filter by SLA)
|
||||||
const result = await workflowApi.listWorkflows({
|
// Use appropriate API based on org/personal mode
|
||||||
page: 1,
|
const result = isOrgLevel
|
||||||
limit: 1000,
|
? await workflowApi.listWorkflows({ page: 1, limit: 1000, ...backendFilters })
|
||||||
...backendFilters
|
: await workflowApi.listParticipantRequests({ page: 1, limit: 1000, ...backendFilters });
|
||||||
});
|
|
||||||
|
|
||||||
const filteredData = Array.isArray(result?.data) ? result.data : [];
|
const filteredData = Array.isArray(result?.data) ? result.data : [];
|
||||||
|
|
||||||
@ -161,6 +169,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
setBackendStats({
|
setBackendStats({
|
||||||
total,
|
total,
|
||||||
pending,
|
pending,
|
||||||
|
paused: 0, // Paused not calculated in dynamic SLA mode
|
||||||
approved,
|
approved,
|
||||||
rejected,
|
rejected,
|
||||||
draft: 0, // Drafts are excluded
|
draft: 0, // Drafts are excluded
|
||||||
@ -169,6 +178,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
} else {
|
} else {
|
||||||
// For breached/compliant or no SLA filter, use dashboard stats API
|
// For breached/compliant or no SLA filter, use dashboard stats API
|
||||||
// Note: status is undefined here because All Requests stats should show all statuses
|
// Note: status is undefined here because All Requests stats should show all statuses
|
||||||
|
// Pass viewAsUser=true when in Personal mode (not org-level)
|
||||||
const stats = await dashboardService.getRequestStats(
|
const stats = await dashboardService.getRequestStats(
|
||||||
statsDateRange,
|
statsDateRange,
|
||||||
statsStartDate ? statsStartDate.toISOString() : undefined,
|
statsStartDate ? statsStartDate.toISOString() : undefined,
|
||||||
@ -180,12 +190,14 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
filtersWithoutStatus?.approver,
|
filtersWithoutStatus?.approver,
|
||||||
filtersWithoutStatus?.approverType,
|
filtersWithoutStatus?.approverType,
|
||||||
filtersWithoutStatus?.search,
|
filtersWithoutStatus?.search,
|
||||||
filtersWithoutStatus?.slaCompliance
|
filtersWithoutStatus?.slaCompliance,
|
||||||
|
!isOrgLevel // viewAsUser: true when in Personal mode
|
||||||
);
|
);
|
||||||
|
|
||||||
setBackendStats({
|
setBackendStats({
|
||||||
total: stats.totalRequests || 0,
|
total: stats.totalRequests || 0,
|
||||||
pending: stats.openRequests || 0,
|
pending: stats.openRequests || 0,
|
||||||
|
paused: stats.pausedRequests || 0,
|
||||||
approved: stats.approvedRequests || 0,
|
approved: stats.approvedRequests || 0,
|
||||||
rejected: stats.rejectedRequests || 0,
|
rejected: stats.rejectedRequests || 0,
|
||||||
draft: stats.draftRequests || 0,
|
draft: stats.draftRequests || 0,
|
||||||
@ -294,32 +306,31 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
// Stats should reflect priority, department, initiator, approver, search, and date range filters
|
// Stats should reflect priority, department, initiator, approver, search, and date range filters
|
||||||
// But NOT status filter - stats should always show all status counts
|
// But NOT status filter - stats should always show all status counts
|
||||||
// Total changes when other filters are applied, but stays stable when only status changes
|
// Total changes when other filters are applied, but stays stable when only status changes
|
||||||
|
// Stats are fetched for both org-level AND user-level (Personal mode) views
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOrgLevel) {
|
const timeoutId = setTimeout(() => {
|
||||||
const timeoutId = setTimeout(() => {
|
const filtersWithoutStatus = {
|
||||||
const filtersWithoutStatus = {
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined,
|
||||||
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined,
|
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
|
||||||
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
|
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
|
||||||
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
|
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
|
||||||
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
|
search: filters.searchTerm || undefined,
|
||||||
search: filters.searchTerm || undefined,
|
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
|
||||||
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
|
};
|
||||||
};
|
// All Requests (admin/normal user) should always have a date range
|
||||||
// All Requests (admin/normal user) should always have a date range
|
// Default to 'month' if no date range is selected
|
||||||
// Default to 'month' if no date range is selected
|
const statsDateRange = filters.dateRange || 'month';
|
||||||
const statsDateRange = filters.dateRange || 'month';
|
|
||||||
|
|
||||||
fetchBackendStatsRef.current(
|
fetchBackendStatsRef.current(
|
||||||
statsDateRange,
|
statsDateRange,
|
||||||
filters.customStartDate,
|
filters.customStartDate,
|
||||||
filters.customEndDate,
|
filters.customEndDate,
|
||||||
filtersWithoutStatus
|
filtersWithoutStatus
|
||||||
);
|
);
|
||||||
}, filters.searchTerm ? 500 : 0);
|
}, filters.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
}
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
isOrgLevel,
|
isOrgLevel,
|
||||||
@ -337,6 +348,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Fetch requests on mount and when filters change
|
// Fetch requests on mount and when filters change
|
||||||
|
// Also refetch when isOrgLevel changes (when admin toggles between Org/Personal in Dashboard)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@ -346,6 +358,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [
|
}, [
|
||||||
|
isOrgLevel, // Re-fetch when org/personal toggle changes
|
||||||
filters.searchTerm,
|
filters.searchTerm,
|
||||||
filters.statusFilter,
|
filters.statusFilter,
|
||||||
filters.priorityFilter,
|
filters.priorityFilter,
|
||||||
@ -371,14 +384,15 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
// Transform requests
|
// Transform requests
|
||||||
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
|
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
|
||||||
|
|
||||||
// Calculate stats - Always use backend stats API for overall counts (unfiltered)
|
// Calculate stats - Use backend stats API for both org-level and user-level views
|
||||||
// Stats should always show total counts regardless of any filters applied
|
// Stats should always show total counts regardless of any filters applied
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
// For org-level: Use backend stats API (always unfiltered)
|
// Use backend stats if available (for both org-level and user-level)
|
||||||
if (isOrgLevel && backendStats) {
|
if (backendStats) {
|
||||||
return {
|
return {
|
||||||
total: backendStats.total || 0,
|
total: backendStats.total || 0,
|
||||||
pending: backendStats.pending || 0,
|
pending: backendStats.pending || 0,
|
||||||
|
paused: backendStats.paused || 0,
|
||||||
approved: backendStats.approved || 0,
|
approved: backendStats.approved || 0,
|
||||||
rejected: backendStats.rejected || 0,
|
rejected: backendStats.rejected || 0,
|
||||||
draft: backendStats.draft || 0,
|
draft: backendStats.draft || 0,
|
||||||
@ -387,7 +401,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fallback: Calculate from paginated data (less accurate, but better than nothing)
|
// Fallback: Calculate from paginated data (less accurate, but better than nothing)
|
||||||
// This is for user-level where backend stats might not be available
|
|
||||||
return calculateStatsFromFilteredData(
|
return calculateStatsFromFilteredData(
|
||||||
[], // Empty - we'll use backendStats or fallback
|
[], // Empty - we'll use backendStats or fallback
|
||||||
isOrgLevel,
|
isOrgLevel,
|
||||||
@ -405,6 +418,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<RequestsHeader
|
<RequestsHeader
|
||||||
isOrgLevel={isOrgLevel}
|
isOrgLevel={isOrgLevel}
|
||||||
|
isAdmin={isAdmin}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
exporting={exporting}
|
exporting={exporting}
|
||||||
onExport={handleExportToCSV}
|
onExport={handleExportToCSV}
|
||||||
@ -462,6 +476,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="paused">Paused</SelectItem>
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
<SelectItem value="approved">Approved</SelectItem>
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
<SelectItem value="rejected">Rejected</SelectItem>
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
/**
|
/**
|
||||||
* User All Requests Page - For Regular Users
|
* User All Requests Page - For Regular Users
|
||||||
*
|
*
|
||||||
* This is a SEPARATE screen for regular users' "All Requests" page.
|
* OPTIMIZED: Uses backend pagination (10 records per page) and backend stats API
|
||||||
* Shows requests where the user is EITHER:
|
* Shows requests where the user is EITHER:
|
||||||
* - The initiator (created by the user), OR
|
* - The initiator (created by the user), OR
|
||||||
* - A participant (approver/spectator)
|
* - A participant (approver/spectator)
|
||||||
* Completely separate from AdminAllRequests to avoid interference.
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||||
import { Pagination } from '@/components/common/Pagination';
|
import { Pagination } from '@/components/common/Pagination';
|
||||||
import dashboardService from '@/services/dashboard.service';
|
import dashboardService from '@/services/dashboard.service';
|
||||||
|
import type { DateRange } from '@/services/dashboard.service';
|
||||||
import userApi from '@/services/userApi';
|
import userApi from '@/services/userApi';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
@ -30,7 +30,7 @@ import { exportRequestsToCSV } from './utils/csvExports';
|
|||||||
import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService';
|
import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import type { RequestsProps } from './types/requests.types';
|
import type { RequestsProps, BackendStats } from './types/requests.types';
|
||||||
|
|
||||||
// Filter UI components
|
// Filter UI components
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@ -52,7 +52,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
const [apiRequests, setApiRequests] = useState<any[]>([]);
|
const [apiRequests, setApiRequests] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
const [allRequestsForStats, setAllRequestsForStats] = useState<any[]>([]); // All requests without status filter for stats
|
const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API
|
||||||
const [departments, setDepartments] = useState<string[]>([]);
|
const [departments, setDepartments] = useState<string[]>([]);
|
||||||
const [loadingDepartments, setLoadingDepartments] = useState(false);
|
const [loadingDepartments, setLoadingDepartments] = useState(false);
|
||||||
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
|
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
|
||||||
@ -60,7 +60,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
// Pagination
|
// Pagination
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const [totalPages, setTotalPages] = useState(1);
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
const [totalRecordsForStats, setTotalRecordsForStats] = useState(0); // For stats (unfiltered - stable)
|
const [totalRecords, setTotalRecords] = useState(0);
|
||||||
const [itemsPerPage] = useState(10);
|
const [itemsPerPage] = useState(10);
|
||||||
|
|
||||||
// User search hooks
|
// User search hooks
|
||||||
@ -76,38 +76,54 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
onFilterChange: filters.setApproverFilter
|
onFilterChange: filters.setApproverFilter
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch all requests for stats calculation
|
// Fetch backend stats using dashboard API
|
||||||
// Apply all filters EXCEPT status filter - this way:
|
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
|
||||||
// - Total changes when priority/department/SLA/etc. filters are applied
|
// Stats reflect all filters EXCEPT status - total stays stable when only status changes
|
||||||
// - Total remains stable when only status filter is applied
|
const fetchBackendStats = useCallback(async (
|
||||||
const fetchAllRequestsForStats = useCallback(async () => {
|
statsDateRange?: DateRange,
|
||||||
try {
|
statsStartDate?: Date,
|
||||||
// Get current filters directly from the filters object (not ref) to ensure we have latest values
|
statsEndDate?: Date,
|
||||||
const filterOptions = filters.getFilters();
|
filtersWithoutStatus?: {
|
||||||
|
priority?: string;
|
||||||
// Build filters WITHOUT status filter for stats
|
department?: string;
|
||||||
// This ensures total changes with other filters (including SLA) but stays stable with status filter
|
initiator?: string;
|
||||||
const statsFilters = { ...filterOptions };
|
approver?: string;
|
||||||
delete statsFilters.status; // Remove status filter to get all statuses
|
approverType?: 'current' | 'any';
|
||||||
|
search?: string;
|
||||||
// Fetch first page with a larger limit to get more data for stats
|
slaCompliance?: string;
|
||||||
const result = await fetchUserParticipantRequestsData({
|
|
||||||
page: 1,
|
|
||||||
itemsPerPage: 100, // Fetch more data for accurate stats
|
|
||||||
filters: statsFilters // Apply all filters except status (includes SLA, priority, department, etc.)
|
|
||||||
});
|
|
||||||
|
|
||||||
setAllRequestsForStats(result.data || []);
|
|
||||||
// Update totalRecordsForStats from this fetch (with filters except status)
|
|
||||||
// This total will change when other filters (including SLA) are applied, but stay stable when only status changes
|
|
||||||
if (result.pagination?.total !== undefined) {
|
|
||||||
setTotalRecordsForStats(result.pagination.total);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to fetch requests for stats:', error);
|
|
||||||
setAllRequestsForStats([]);
|
|
||||||
}
|
}
|
||||||
}, [filters]);
|
) => {
|
||||||
|
try {
|
||||||
|
// Use dashboard stats API with viewAsUser=true for user-level stats
|
||||||
|
const stats = await dashboardService.getRequestStats(
|
||||||
|
statsDateRange,
|
||||||
|
statsStartDate ? statsStartDate.toISOString() : undefined,
|
||||||
|
statsEndDate ? statsEndDate.toISOString() : undefined,
|
||||||
|
undefined, // status - stats should show all statuses
|
||||||
|
filtersWithoutStatus?.priority,
|
||||||
|
filtersWithoutStatus?.department,
|
||||||
|
filtersWithoutStatus?.initiator,
|
||||||
|
filtersWithoutStatus?.approver,
|
||||||
|
filtersWithoutStatus?.approverType,
|
||||||
|
filtersWithoutStatus?.search,
|
||||||
|
filtersWithoutStatus?.slaCompliance,
|
||||||
|
true // viewAsUser: always true for user-level
|
||||||
|
);
|
||||||
|
|
||||||
|
setBackendStats({
|
||||||
|
total: stats.totalRequests || 0,
|
||||||
|
pending: stats.openRequests || 0,
|
||||||
|
paused: stats.pausedRequests || 0,
|
||||||
|
approved: stats.approvedRequests || 0,
|
||||||
|
rejected: stats.rejectedRequests || 0,
|
||||||
|
draft: stats.draftRequests || 0,
|
||||||
|
closed: stats.closedRequests || 0
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch backend stats:', error);
|
||||||
|
// Keep previous stats on error
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Fetch departments
|
// Fetch departments
|
||||||
const fetchDepartments = useCallback(async () => {
|
const fetchDepartments = useCallback(async () => {
|
||||||
@ -139,15 +155,15 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
|
|
||||||
// Use refs to store stable callbacks to prevent infinite loops
|
// Use refs to store stable callbacks to prevent infinite loops
|
||||||
const filtersRef = useRef(filters);
|
const filtersRef = useRef(filters);
|
||||||
const fetchAllRequestsForStatsRef = useRef(fetchAllRequestsForStats);
|
const fetchBackendStatsRef = useRef(fetchBackendStats);
|
||||||
|
|
||||||
// Update refs on each render
|
// Update refs on each render
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
filtersRef.current = filters;
|
filtersRef.current = filters;
|
||||||
fetchAllRequestsForStatsRef.current = fetchAllRequestsForStats;
|
fetchBackendStatsRef.current = fetchBackendStats;
|
||||||
}, [filters, fetchAllRequestsForStats]);
|
}, [filters, fetchBackendStats]);
|
||||||
|
|
||||||
// Fetch requests
|
// Fetch requests - OPTIMIZED: Only fetches 10 records per page
|
||||||
const fetchRequests = useCallback(async (page: number = 1) => {
|
const fetchRequests = useCallback(async (page: number = 1) => {
|
||||||
try {
|
try {
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
@ -162,13 +178,12 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
filters: filterOptions
|
filters: filterOptions
|
||||||
});
|
});
|
||||||
|
|
||||||
setApiRequests(result.data); // Paginated data WITH status filter (for list display)
|
setApiRequests(result.data); // Paginated data (10 records)
|
||||||
|
|
||||||
// Update pagination (for list display - includes status filter)
|
// Update pagination
|
||||||
setCurrentPage(result.pagination.page);
|
setCurrentPage(result.pagination.page);
|
||||||
setTotalPages(result.pagination.totalPages);
|
setTotalPages(result.pagination.totalPages);
|
||||||
// Don't update totalRecords here - it should come from stats fetch (without status filter)
|
setTotalRecords(result.pagination.total);
|
||||||
// setTotalRecords(result.pagination.total); // Commented out - use totalRecordsForStats instead
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setApiRequests([]);
|
setApiRequests([]);
|
||||||
} finally {
|
} finally {
|
||||||
@ -196,11 +211,27 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
fetchUsers();
|
fetchUsers();
|
||||||
}, [fetchDepartments, fetchUsers]);
|
}, [fetchDepartments, fetchUsers]);
|
||||||
|
|
||||||
// Fetch stats when filters change (except status filter)
|
// Fetch backend stats when filters change (except status filter)
|
||||||
// This ensures total changes with other filters but stays stable with status filter
|
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const timeoutId = setTimeout(() => {
|
const timeoutId = setTimeout(() => {
|
||||||
fetchAllRequestsForStats();
|
const filtersWithoutStatus = {
|
||||||
|
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
|
||||||
|
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined,
|
||||||
|
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
|
||||||
|
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
|
||||||
|
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
|
||||||
|
search: filters.searchTerm || undefined,
|
||||||
|
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
|
||||||
|
};
|
||||||
|
const statsDateRange = filters.dateRange || 'month';
|
||||||
|
|
||||||
|
fetchBackendStatsRef.current(
|
||||||
|
statsDateRange,
|
||||||
|
filters.customStartDate,
|
||||||
|
filters.customEndDate,
|
||||||
|
filtersWithoutStatus
|
||||||
|
);
|
||||||
}, filters.searchTerm ? 500 : 0);
|
}, filters.searchTerm ? 500 : 0);
|
||||||
|
|
||||||
return () => clearTimeout(timeoutId);
|
return () => clearTimeout(timeoutId);
|
||||||
@ -216,7 +247,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
filters.dateRange,
|
filters.dateRange,
|
||||||
filters.customStartDate,
|
filters.customStartDate,
|
||||||
filters.customEndDate
|
filters.customEndDate
|
||||||
// fetchAllRequestsForStats excluded to prevent infinite loops
|
|
||||||
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
|
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@ -255,77 +285,60 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
// Transform requests
|
// Transform requests
|
||||||
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
|
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
|
||||||
|
|
||||||
// Transform all requests for stats (without status filter)
|
// Calculate stats - Use backend stats API (OPTIMIZED)
|
||||||
const allConvertedRequestsForStats = useMemo(() => transformRequests(allRequestsForStats), [allRequestsForStats]);
|
|
||||||
|
|
||||||
// Calculate stats from all fetched data (without status filter)
|
|
||||||
const stats = useMemo(() => {
|
const stats = useMemo(() => {
|
||||||
// For regular users, calculate stats from allRequestsForStats (fetched without status filter)
|
// Use backend stats if available
|
||||||
// Use totalRecords for total (from backend), and calculate individual status counts from fetched data
|
if (backendStats) {
|
||||||
if (allConvertedRequestsForStats.length > 0) {
|
|
||||||
// Calculate individual status counts from all fetched requests
|
|
||||||
const pending = allConvertedRequestsForStats.filter((r: any) => {
|
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
|
||||||
return status === 'pending' || status === 'in-progress';
|
|
||||||
}).length;
|
|
||||||
const approved = allConvertedRequestsForStats.filter((r: any) => {
|
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
|
||||||
return status === 'approved';
|
|
||||||
}).length;
|
|
||||||
const rejected = allConvertedRequestsForStats.filter((r: any) => {
|
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
|
||||||
return status === 'rejected';
|
|
||||||
}).length;
|
|
||||||
const closed = allConvertedRequestsForStats.filter((r: any) => {
|
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
|
||||||
return status === 'closed';
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
// Use totalRecordsForStats for total - this changes when other filters (priority, department, etc.) are applied
|
|
||||||
// but stays stable when only status filter changes
|
|
||||||
return {
|
return {
|
||||||
total: totalRecordsForStats > 0 ? totalRecordsForStats : allConvertedRequestsForStats.length, // Use total from stats fetch (with other filters, without status)
|
total: backendStats.total || 0,
|
||||||
pending,
|
pending: backendStats.pending || 0,
|
||||||
approved,
|
paused: backendStats.paused || 0,
|
||||||
rejected,
|
approved: backendStats.approved || 0,
|
||||||
draft: 0, // Drafts are excluded
|
rejected: backendStats.rejected || 0,
|
||||||
closed
|
draft: backendStats.draft || 0,
|
||||||
};
|
closed: backendStats.closed || 0
|
||||||
} else {
|
|
||||||
// Fallback: calculate from convertedRequests (current page only) - less accurate
|
|
||||||
const pending = convertedRequests.filter((r: any) => {
|
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
|
||||||
return status === 'pending' || status === 'in-progress';
|
|
||||||
}).length;
|
|
||||||
const approved = convertedRequests.filter((r: any) => {
|
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
|
||||||
return status === 'approved';
|
|
||||||
}).length;
|
|
||||||
const rejected = convertedRequests.filter((r: any) => {
|
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
|
||||||
return status === 'rejected';
|
|
||||||
}).length;
|
|
||||||
const closed = convertedRequests.filter((r: any) => {
|
|
||||||
const status = (r.status || '').toString().toLowerCase();
|
|
||||||
return status === 'closed';
|
|
||||||
}).length;
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: totalRecordsForStats > 0 ? totalRecordsForStats : convertedRequests.length, // Use total from stats fetch if available
|
|
||||||
pending,
|
|
||||||
approved,
|
|
||||||
rejected,
|
|
||||||
draft: 0,
|
|
||||||
closed
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}, [totalRecordsForStats, allConvertedRequestsForStats, convertedRequests]);
|
|
||||||
|
// Fallback: calculate from current page (less accurate, but works during initial load)
|
||||||
|
const pending = convertedRequests.filter((r: any) => {
|
||||||
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
|
return status === 'pending' || status === 'in-progress';
|
||||||
|
}).length;
|
||||||
|
const paused = convertedRequests.filter((r: any) => {
|
||||||
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
|
return status === 'paused';
|
||||||
|
}).length;
|
||||||
|
const approved = convertedRequests.filter((r: any) => {
|
||||||
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
|
return status === 'approved';
|
||||||
|
}).length;
|
||||||
|
const rejected = convertedRequests.filter((r: any) => {
|
||||||
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
|
return status === 'rejected';
|
||||||
|
}).length;
|
||||||
|
const closed = convertedRequests.filter((r: any) => {
|
||||||
|
const status = (r.status || '').toString().toLowerCase();
|
||||||
|
return status === 'closed';
|
||||||
|
}).length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: totalRecords > 0 ? totalRecords : convertedRequests.length,
|
||||||
|
pending,
|
||||||
|
paused,
|
||||||
|
approved,
|
||||||
|
rejected,
|
||||||
|
draft: 0,
|
||||||
|
closed
|
||||||
|
};
|
||||||
|
}, [backendStats, totalRecords, convertedRequests]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="user-all-requests-page">
|
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="user-all-requests-page">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<RequestsHeader
|
<RequestsHeader
|
||||||
isOrgLevel={false}
|
isOrgLevel={false}
|
||||||
|
isAdmin={false}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
exporting={exporting}
|
exporting={exporting}
|
||||||
onExport={handleExportToCSV}
|
onExport={handleExportToCSV}
|
||||||
@ -383,6 +396,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Status</SelectItem>
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
|
<SelectItem value="paused">Paused</SelectItem>
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
<SelectItem value="approved">Approved</SelectItem>
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
<SelectItem value="rejected">Rejected</SelectItem>
|
||||||
<SelectItem value="closed">Closed</SelectItem>
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
@ -673,7 +687,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
|||||||
<Pagination
|
<Pagination
|
||||||
currentPage={currentPage}
|
currentPage={currentPage}
|
||||||
totalPages={totalPages}
|
totalPages={totalPages}
|
||||||
totalRecords={totalRecordsForStats}
|
totalRecords={totalRecords}
|
||||||
itemsPerPage={itemsPerPage}
|
itemsPerPage={itemsPerPage}
|
||||||
onPageChange={handlePageChange}
|
onPageChange={handlePageChange}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { User, ArrowRight, TrendingUp, Clock } from 'lucide-react';
|
import { User, ArrowRight, TrendingUp, Clock, Pause } from 'lucide-react';
|
||||||
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
|
||||||
import type { ConvertedRequest } from '../types/requests.types';
|
import type { ConvertedRequest } from '../types/requests.types';
|
||||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||||
@ -78,6 +78,16 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
|||||||
<StatusIcon className="w-3 h-3 mr-1" />
|
<StatusIcon className="w-3 h-3 mr-1" />
|
||||||
<span className="capitalize">{request.status}</span>
|
<span className="capitalize">{request.status}</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{((request as any).pauseInfo?.isPaused || (request as any).isPaused) && (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="bg-orange-50 text-orange-700 border-orange-300 font-medium text-xs shrink-0"
|
||||||
|
data-testid="pause-badge"
|
||||||
|
>
|
||||||
|
<Pause className="w-3 h-3 mr-1" />
|
||||||
|
Paused
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className={`${priorityConfig.color} border font-medium text-xs capitalize shrink-0`}
|
className={`${priorityConfig.color} border font-medium text-xs capitalize shrink-0`}
|
||||||
|
|||||||
@ -4,10 +4,12 @@
|
|||||||
|
|
||||||
import { FileText, Download, RefreshCw } from 'lucide-react';
|
import { FileText, Download, RefreshCw } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { PageHeader } from '@/components/common/PageHeader';
|
import { PageHeader } from '@/components/common/PageHeader';
|
||||||
|
|
||||||
interface RequestsHeaderProps {
|
interface RequestsHeaderProps {
|
||||||
isOrgLevel: boolean;
|
isOrgLevel: boolean;
|
||||||
|
isAdmin: boolean;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
exporting: boolean;
|
exporting: boolean;
|
||||||
onExport: () => void;
|
onExport: () => void;
|
||||||
@ -15,20 +17,48 @@ interface RequestsHeaderProps {
|
|||||||
|
|
||||||
export function RequestsHeader({
|
export function RequestsHeader({
|
||||||
isOrgLevel,
|
isOrgLevel,
|
||||||
|
isAdmin,
|
||||||
loading,
|
loading,
|
||||||
exporting,
|
exporting,
|
||||||
onExport
|
onExport
|
||||||
}: RequestsHeaderProps) {
|
}: RequestsHeaderProps) {
|
||||||
|
// Determine the title and description based on view mode
|
||||||
|
const getTitle = () => {
|
||||||
|
if (isOrgLevel) return "All Requests";
|
||||||
|
if (isAdmin) return "All Requests"; // Admin viewing as personal
|
||||||
|
return "All Requests"; // Regular user
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDescription = () => {
|
||||||
|
if (isOrgLevel) {
|
||||||
|
return "View and filter all organization-wide workflow requests";
|
||||||
|
}
|
||||||
|
if (isAdmin) {
|
||||||
|
return "Viewing requests where you are initiator, approver, or participant";
|
||||||
|
}
|
||||||
|
return "View and filter your workflow requests";
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start justify-between gap-4" data-testid="requests-header-container">
|
<div className="flex items-start justify-between gap-4" data-testid="requests-header-container">
|
||||||
<PageHeader
|
<div className="flex items-center gap-3">
|
||||||
icon={FileText}
|
<PageHeader
|
||||||
title={isOrgLevel ? "All Requests (Organization)" : "All Requests"}
|
icon={FileText}
|
||||||
description={isOrgLevel
|
title={getTitle()}
|
||||||
? "View and filter all organization-wide workflow requests with advanced filtering options"
|
description={getDescription()}
|
||||||
: "View and filter your workflow requests with advanced filtering options"}
|
testId="requests-header"
|
||||||
testId="requests-header"
|
/>
|
||||||
/>
|
{/* View mode badge */}
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={isOrgLevel
|
||||||
|
? "bg-blue-50 text-blue-700 border-blue-200"
|
||||||
|
: "bg-green-50 text-green-700 border-green-200"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isOrgLevel ? "Organization" : "Personal"}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
<Button
|
<Button
|
||||||
onClick={onExport}
|
onClick={onExport}
|
||||||
disabled={exporting || loading}
|
disabled={exporting || loading}
|
||||||
|
|||||||
@ -3,7 +3,7 @@
|
|||||||
* Displays statistics cards for requests with click handlers to filter
|
* Displays statistics cards for requests with click handlers to filter
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FileText, Clock, CheckCircle, XCircle, Archive } from 'lucide-react';
|
import { FileText, Clock, Pause, CheckCircle, XCircle, Archive } from 'lucide-react';
|
||||||
import { StatsCard } from '@/components/dashboard/StatsCard';
|
import { StatsCard } from '@/components/dashboard/StatsCard';
|
||||||
import type { RequestStats } from '../types/requests.types';
|
import type { RequestStats } from '../types/requests.types';
|
||||||
|
|
||||||
@ -20,7 +20,7 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 sm:gap-4" data-testid="requests-stats">
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="requests-stats">
|
||||||
<StatsCard
|
<StatsCard
|
||||||
label="Total"
|
label="Total"
|
||||||
value={stats.total}
|
value={stats.total}
|
||||||
@ -45,6 +45,18 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
|
|||||||
onClick={onStatusFilter ? () => handleCardClick('pending') : undefined}
|
onClick={onStatusFilter ? () => handleCardClick('pending') : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<StatsCard
|
||||||
|
label="Paused"
|
||||||
|
value={stats.paused}
|
||||||
|
icon={Pause}
|
||||||
|
iconColor="text-amber-600"
|
||||||
|
gradient="bg-gradient-to-br from-amber-50 to-amber-100 border-amber-200"
|
||||||
|
textColor="text-amber-700"
|
||||||
|
valueColor="text-amber-900"
|
||||||
|
testId="stat-paused"
|
||||||
|
onClick={onStatusFilter ? () => handleCardClick('paused') : undefined}
|
||||||
|
/>
|
||||||
|
|
||||||
<StatsCard
|
<StatsCard
|
||||||
label="Approved"
|
label="Approved"
|
||||||
value={stats.approved}
|
value={stats.approved}
|
||||||
|
|||||||
@ -82,7 +82,10 @@ export async function fetchRequestsData({
|
|||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// User-level: Use SEPARATE endpoint for regular users' "All Requests" page
|
// User-level: Use SEPARATE endpoint for regular users' "All Requests" page
|
||||||
// This shows only participant requests (approver/spectator), NOT initiator requests
|
// This shows ALL requests where user is involved:
|
||||||
|
// - As initiator (created the request)
|
||||||
|
// - As approver (in any approval level)
|
||||||
|
// - As participant/spectator
|
||||||
const backendFilters: any = {};
|
const backendFilters: any = {};
|
||||||
if (filters?.search) backendFilters.search = filters.search;
|
if (filters?.search) backendFilters.search = filters.search;
|
||||||
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
|
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
|
||||||
@ -94,8 +97,8 @@ export async function fetchRequestsData({
|
|||||||
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
|
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
|
||||||
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
|
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
|
||||||
|
|
||||||
// Fetch paginated data using SEPARATE endpoint for regular users
|
// Fetch paginated data using endpoint for regular users
|
||||||
// This endpoint excludes initiator requests automatically
|
// This endpoint includes all requests where user is initiator, approver, or participant
|
||||||
const pageResult = await workflowApi.listParticipantRequests({
|
const pageResult = await workflowApi.listParticipantRequests({
|
||||||
page,
|
page,
|
||||||
limit: itemsPerPage,
|
limit: itemsPerPage,
|
||||||
@ -143,7 +146,7 @@ export async function fetchAllRequestsForExport(isOrgLevel: boolean): Promise<an
|
|||||||
while (hasMore && currentPageNum <= maxPages) {
|
while (hasMore && currentPageNum <= maxPages) {
|
||||||
const pageResult = isOrgLevel
|
const pageResult = isOrgLevel
|
||||||
? await workflowApi.listWorkflows({ page: currentPageNum, limit: EXPORT_FETCH_LIMIT }) // Admin: All org requests
|
? await workflowApi.listWorkflows({ page: currentPageNum, limit: EXPORT_FETCH_LIMIT }) // Admin: All org requests
|
||||||
: await workflowApi.listParticipantRequests({ page: currentPageNum, limit: EXPORT_FETCH_LIMIT }); // Regular users: Participant requests only
|
: await workflowApi.listParticipantRequests({ page: currentPageNum, limit: EXPORT_FETCH_LIMIT }); // Regular users: All requests where user is involved (initiator, approver, or participant)
|
||||||
|
|
||||||
let pageData: any[] = [];
|
let pageData: any[] = [];
|
||||||
if (Array.isArray(pageResult?.data)) {
|
if (Array.isArray(pageResult?.data)) {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Service for fetching user requests data (initiator + participant)
|
* Service for fetching user requests data (initiator + participant)
|
||||||
* SEPARATE from admin requests service to avoid interference
|
* OPTIMIZED: Uses single backend endpoint that now includes both initiator and participant requests
|
||||||
*
|
*
|
||||||
* This service is specifically for regular users' "All Requests" page
|
* This service is specifically for regular users' "All Requests" page
|
||||||
* Shows requests where user is EITHER initiator OR participant (approver/spectator)
|
* Shows requests where user is EITHER initiator OR participant (approver/spectator)
|
||||||
@ -9,8 +9,6 @@
|
|||||||
import workflowApi from '@/services/workflowApi';
|
import workflowApi from '@/services/workflowApi';
|
||||||
import type { RequestFilters } from '../types/requests.types';
|
import type { RequestFilters } from '../types/requests.types';
|
||||||
|
|
||||||
const EXPORT_FETCH_LIMIT = 100;
|
|
||||||
|
|
||||||
interface FetchUserAllRequestsOptions {
|
interface FetchUserAllRequestsOptions {
|
||||||
page: number;
|
page: number;
|
||||||
itemsPerPage: number;
|
itemsPerPage: number;
|
||||||
@ -19,7 +17,8 @@ interface FetchUserAllRequestsOptions {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all requests for regular users (initiator + participant)
|
* Fetch all requests for regular users (initiator + participant)
|
||||||
* Combines requests where user is initiator AND requests where user is participant
|
* OPTIMIZED: Uses single listParticipantRequests endpoint which now includes initiator requests
|
||||||
|
* Fetches only the requested page (10 records) for optimal performance
|
||||||
*/
|
*/
|
||||||
export async function fetchUserParticipantRequestsData({
|
export async function fetchUserParticipantRequestsData({
|
||||||
page,
|
page,
|
||||||
@ -35,134 +34,66 @@ export async function fetchUserParticipantRequestsData({
|
|||||||
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
|
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
|
||||||
if (filters?.approver && filters.approver !== 'all') {
|
if (filters?.approver && filters.approver !== 'all') {
|
||||||
backendFilters.approver = filters.approver;
|
backendFilters.approver = filters.approver;
|
||||||
backendFilters.approverType = filters.approverType || 'current'; // Default to 'current'
|
backendFilters.approverType = filters.approverType || 'current';
|
||||||
}
|
}
|
||||||
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
|
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
|
||||||
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
|
||||||
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
||||||
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
||||||
|
|
||||||
// To properly merge and paginate, we need to fetch enough data from both endpoints
|
// Use single optimized endpoint - listParticipantRequests now includes initiator requests
|
||||||
// Fetch multiple pages from each endpoint to ensure we have enough data to merge and paginate correctly
|
// Only fetch the requested page (10 records) for optimal performance
|
||||||
const fetchLimit = Math.max(itemsPerPage * 3, 100); // Fetch at least 3 pages worth or 100 items, whichever is larger
|
const result = await workflowApi.listParticipantRequests({
|
||||||
|
|
||||||
// Fetch from both endpoints in parallel
|
|
||||||
const [initiatorResult, participantResult] = await Promise.all([
|
|
||||||
// Fetch requests where user is initiator (fetch more to account for merging)
|
|
||||||
workflowApi.listMyInitiatedWorkflows({
|
|
||||||
page: 1,
|
|
||||||
limit: fetchLimit,
|
|
||||||
search: backendFilters.search,
|
|
||||||
status: backendFilters.status,
|
|
||||||
priority: backendFilters.priority,
|
|
||||||
department: backendFilters.department,
|
|
||||||
slaCompliance: backendFilters.slaCompliance,
|
|
||||||
dateRange: backendFilters.dateRange,
|
|
||||||
startDate: backendFilters.startDate,
|
|
||||||
endDate: backendFilters.endDate
|
|
||||||
}),
|
|
||||||
// Fetch requests where user is participant (approver/spectator)
|
|
||||||
workflowApi.listParticipantRequests({
|
|
||||||
page: 1,
|
|
||||||
limit: fetchLimit,
|
|
||||||
...backendFilters
|
|
||||||
})
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Extract data from both results
|
|
||||||
let initiatorData: any[] = [];
|
|
||||||
if (Array.isArray(initiatorResult?.data)) {
|
|
||||||
initiatorData = initiatorResult.data;
|
|
||||||
} else if (Array.isArray(initiatorResult)) {
|
|
||||||
initiatorData = initiatorResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
let participantData: any[] = [];
|
|
||||||
if (Array.isArray(participantResult?.data)) {
|
|
||||||
participantData = participantResult.data;
|
|
||||||
} else if (Array.isArray(participantResult)) {
|
|
||||||
participantData = participantResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out drafts from both
|
|
||||||
const nonDraftInitiatorData = initiatorData.filter((req: any) => {
|
|
||||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
|
||||||
return reqStatus !== 'DRAFT';
|
|
||||||
});
|
|
||||||
|
|
||||||
const nonDraftParticipantData = participantData.filter((req: any) => {
|
|
||||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
|
||||||
return reqStatus !== 'DRAFT';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge and deduplicate by requestId
|
|
||||||
const mergedMap = new Map<string, any>();
|
|
||||||
|
|
||||||
// Add initiator requests
|
|
||||||
nonDraftInitiatorData.forEach((req: any) => {
|
|
||||||
const requestId = req.requestId || req.id;
|
|
||||||
if (requestId) {
|
|
||||||
mergedMap.set(requestId, req);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add participant requests (will overwrite if duplicate, but that's fine)
|
|
||||||
nonDraftParticipantData.forEach((req: any) => {
|
|
||||||
const requestId = req.requestId || req.id;
|
|
||||||
if (requestId) {
|
|
||||||
mergedMap.set(requestId, req);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert map to array
|
|
||||||
const mergedData = Array.from(mergedMap.values());
|
|
||||||
|
|
||||||
// Sort by updatedAt or createdAt (most recent first)
|
|
||||||
mergedData.sort((a: any, b: any) => {
|
|
||||||
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
|
||||||
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
|
||||||
return dateB - dateA;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Calculate combined pagination
|
|
||||||
const initiatorPagination = initiatorResult?.pagination || { page: 1, limit: fetchLimit, total: 0, totalPages: 1 };
|
|
||||||
const participantPagination = participantResult?.pagination || { page: 1, limit: fetchLimit, total: 0, totalPages: 1 };
|
|
||||||
|
|
||||||
// Estimate total: sum of both totals, but account for potential duplicates
|
|
||||||
// We'll use a conservative estimate: sum of both, but we know there might be overlap
|
|
||||||
const estimatedTotal = (initiatorPagination.total || 0) + (participantPagination.total || 0);
|
|
||||||
// The actual merged count might be less due to duplicates, but we use the merged length if we have enough data
|
|
||||||
const actualTotal = mergedData.length >= fetchLimit ? estimatedTotal : mergedData.length;
|
|
||||||
|
|
||||||
// Paginate the merged results
|
|
||||||
const startIndex = (page - 1) * itemsPerPage;
|
|
||||||
const endIndex = startIndex + itemsPerPage;
|
|
||||||
const paginatedData = mergedData.slice(startIndex, endIndex);
|
|
||||||
|
|
||||||
const pagination = {
|
|
||||||
page,
|
page,
|
||||||
limit: itemsPerPage,
|
limit: itemsPerPage,
|
||||||
total: actualTotal,
|
...backendFilters
|
||||||
totalPages: Math.ceil(actualTotal / itemsPerPage) || 1
|
});
|
||||||
|
|
||||||
|
// Extract data from result
|
||||||
|
let pageData: any[] = [];
|
||||||
|
if (Array.isArray(result?.data)) {
|
||||||
|
pageData = result.data;
|
||||||
|
} else if (Array.isArray(result)) {
|
||||||
|
pageData = result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out drafts (backend should handle this, but double-check)
|
||||||
|
const nonDraftData = pageData.filter((req: any) => {
|
||||||
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||||
|
return reqStatus !== 'DRAFT';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get pagination info from backend response
|
||||||
|
const pagination = result?.pagination || {
|
||||||
|
page,
|
||||||
|
limit: itemsPerPage,
|
||||||
|
total: nonDraftData.length,
|
||||||
|
totalPages: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
data: paginatedData, // Paginated merged data for list display
|
data: nonDraftData,
|
||||||
allData: [], // Stats calculated from data
|
allData: [],
|
||||||
filteredData: paginatedData, // Same as data for list
|
filteredData: nonDraftData,
|
||||||
pagination: pagination
|
pagination: {
|
||||||
|
page: pagination.page,
|
||||||
|
limit: pagination.limit || itemsPerPage,
|
||||||
|
total: pagination.total || nonDraftData.length,
|
||||||
|
totalPages: pagination.totalPages || 1
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const EXPORT_FETCH_LIMIT = 100;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch all requests for export (regular users - initiator + participant)
|
* Fetch all requests for export (regular users - initiator + participant)
|
||||||
* Fetches from both endpoints and merges results
|
* OPTIMIZED: Uses single endpoint that includes both initiator and participant requests
|
||||||
*/
|
*/
|
||||||
export async function fetchAllRequestsForExport(filters?: RequestFilters): Promise<any[]> {
|
export async function fetchAllRequestsForExport(filters?: RequestFilters): Promise<any[]> {
|
||||||
const allInitiatorPages: any[] = [];
|
const allPages: any[] = [];
|
||||||
const allParticipantPages: any[] = [];
|
let hasMore = true;
|
||||||
let hasMoreInitiator = true;
|
let currentPage = 1;
|
||||||
let hasMoreParticipant = true;
|
|
||||||
const maxPages = 100; // Safety limit
|
const maxPages = 100; // Safety limit
|
||||||
|
|
||||||
// Build filter params for backend API
|
// Build filter params for backend API
|
||||||
@ -181,98 +112,40 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
|
|||||||
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
||||||
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
||||||
|
|
||||||
// Fetch initiator requests
|
// Fetch all pages using the single optimized endpoint
|
||||||
const initiatorFetch = async () => {
|
while (hasMore && currentPage <= maxPages) {
|
||||||
let page = 1;
|
const pageResult = await workflowApi.listParticipantRequests({
|
||||||
while (hasMoreInitiator && page <= maxPages) {
|
page: currentPage,
|
||||||
const pageResult = await workflowApi.listMyInitiatedWorkflows({
|
limit: EXPORT_FETCH_LIMIT,
|
||||||
page,
|
...backendFilters
|
||||||
limit: EXPORT_FETCH_LIMIT,
|
});
|
||||||
search: backendFilters.search,
|
|
||||||
status: backendFilters.status,
|
|
||||||
priority: backendFilters.priority,
|
|
||||||
department: backendFilters.department,
|
|
||||||
slaCompliance: backendFilters.slaCompliance,
|
|
||||||
dateRange: backendFilters.dateRange,
|
|
||||||
startDate: backendFilters.startDate,
|
|
||||||
endDate: backendFilters.endDate
|
|
||||||
});
|
|
||||||
|
|
||||||
const pageData = pageResult?.data || [];
|
let pageData: any[] = [];
|
||||||
if (pageData.length === 0) {
|
if (Array.isArray(pageResult?.data)) {
|
||||||
hasMoreInitiator = false;
|
pageData = pageResult.data;
|
||||||
|
} else if (Array.isArray(pageResult)) {
|
||||||
|
pageData = pageResult;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageData.length === 0) {
|
||||||
|
hasMore = false;
|
||||||
|
} else {
|
||||||
|
// Filter out drafts
|
||||||
|
const nonDraftData = pageData.filter((req: any) => {
|
||||||
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||||
|
return reqStatus !== 'DRAFT';
|
||||||
|
});
|
||||||
|
allPages.push(...nonDraftData);
|
||||||
|
currentPage++;
|
||||||
|
|
||||||
|
if (pageResult?.pagination) {
|
||||||
|
hasMore = currentPage <= pageResult.pagination.totalPages;
|
||||||
} else {
|
} else {
|
||||||
allInitiatorPages.push(...pageData);
|
hasMore = pageData.length === EXPORT_FETCH_LIMIT;
|
||||||
page++;
|
|
||||||
if (pageData.length < EXPORT_FETCH_LIMIT) {
|
|
||||||
hasMoreInitiator = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// Fetch participant requests
|
return allPages;
|
||||||
const participantFetch = async () => {
|
|
||||||
let page = 1;
|
|
||||||
while (hasMoreParticipant && page <= maxPages) {
|
|
||||||
const pageResult = await workflowApi.listParticipantRequests({
|
|
||||||
page,
|
|
||||||
limit: EXPORT_FETCH_LIMIT,
|
|
||||||
...backendFilters
|
|
||||||
});
|
|
||||||
|
|
||||||
const pageData = pageResult?.data || [];
|
|
||||||
if (pageData.length === 0) {
|
|
||||||
hasMoreParticipant = false;
|
|
||||||
} else {
|
|
||||||
allParticipantPages.push(...pageData);
|
|
||||||
page++;
|
|
||||||
if (pageData.length < EXPORT_FETCH_LIMIT) {
|
|
||||||
hasMoreParticipant = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Fetch both in parallel
|
|
||||||
await Promise.all([initiatorFetch(), participantFetch()]);
|
|
||||||
|
|
||||||
// Filter out drafts
|
|
||||||
const nonDraftInitiator = allInitiatorPages.filter((req: any) => {
|
|
||||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
|
||||||
return reqStatus !== 'DRAFT';
|
|
||||||
});
|
|
||||||
|
|
||||||
const nonDraftParticipant = allParticipantPages.filter((req: any) => {
|
|
||||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
|
||||||
return reqStatus !== 'DRAFT';
|
|
||||||
});
|
|
||||||
|
|
||||||
// Merge and deduplicate by requestId
|
|
||||||
const mergedMap = new Map<string, any>();
|
|
||||||
|
|
||||||
nonDraftInitiator.forEach((req: any) => {
|
|
||||||
const requestId = req.requestId || req.id;
|
|
||||||
if (requestId) {
|
|
||||||
mergedMap.set(requestId, req);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
nonDraftParticipant.forEach((req: any) => {
|
|
||||||
const requestId = req.requestId || req.id;
|
|
||||||
if (requestId) {
|
|
||||||
mergedMap.set(requestId, req);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Convert to array and sort by date
|
|
||||||
const mergedData = Array.from(mergedMap.values());
|
|
||||||
mergedData.sort((a: any, b: any) => {
|
|
||||||
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
|
||||||
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
|
||||||
return dateB - dateA;
|
|
||||||
});
|
|
||||||
|
|
||||||
return mergedData;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export interface RequestFilters {
|
|||||||
export interface RequestStats {
|
export interface RequestStats {
|
||||||
total: number;
|
total: number;
|
||||||
pending: number;
|
pending: number;
|
||||||
|
paused: number;
|
||||||
approved: number;
|
approved: number;
|
||||||
rejected: number;
|
rejected: number;
|
||||||
draft: number;
|
draft: number;
|
||||||
@ -34,6 +35,7 @@ export interface RequestStats {
|
|||||||
export interface BackendStats {
|
export interface BackendStats {
|
||||||
total: number;
|
total: number;
|
||||||
pending: number;
|
pending: number;
|
||||||
|
paused: number;
|
||||||
approved: number;
|
approved: number;
|
||||||
rejected: number;
|
rejected: number;
|
||||||
draft: number;
|
draft: number;
|
||||||
|
|||||||
@ -41,9 +41,15 @@ export function calculateStatsFromFilteredData(
|
|||||||
return status === 'CLOSED';
|
return status === 'CLOSED';
|
||||||
}).length;
|
}).length;
|
||||||
|
|
||||||
|
const paused = allFilteredRequests.filter((r: any) => {
|
||||||
|
const status = (r.status || '').toString().toUpperCase();
|
||||||
|
return status === 'PAUSED';
|
||||||
|
}).length;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
total: total, // Total based on other filters (priority, department, etc.)
|
total: total, // Total based on other filters (priority, department, etc.)
|
||||||
pending,
|
pending,
|
||||||
|
paused,
|
||||||
approved,
|
approved,
|
||||||
rejected,
|
rejected,
|
||||||
draft,
|
draft,
|
||||||
@ -54,6 +60,7 @@ export function calculateStatsFromFilteredData(
|
|||||||
return {
|
return {
|
||||||
total: backendStats.total,
|
total: backendStats.total,
|
||||||
pending: backendStats.pending,
|
pending: backendStats.pending,
|
||||||
|
paused: backendStats.paused || 0,
|
||||||
approved: backendStats.approved,
|
approved: backendStats.approved,
|
||||||
rejected: backendStats.rejected,
|
rejected: backendStats.rejected,
|
||||||
draft: backendStats.draft,
|
draft: backendStats.draft,
|
||||||
@ -67,6 +74,7 @@ export function calculateStatsFromFilteredData(
|
|||||||
return {
|
return {
|
||||||
total: total,
|
total: total,
|
||||||
pending: convertedRequests.filter(r => r.status === 'pending' || r.status === 'in-progress').length,
|
pending: convertedRequests.filter(r => r.status === 'pending' || r.status === 'in-progress').length,
|
||||||
|
paused: convertedRequests.filter(r => r.status === 'paused').length,
|
||||||
approved: convertedRequests.filter(r => r.status === 'approved').length,
|
approved: convertedRequests.filter(r => r.status === 'approved').length,
|
||||||
rejected: convertedRequests.filter(r => r.status === 'rejected').length,
|
rejected: convertedRequests.filter(r => r.status === 'rejected').length,
|
||||||
draft: convertedRequests.filter(r => r.status === 'draft').length,
|
draft: convertedRequests.filter(r => r.status === 'draft').length,
|
||||||
|
|||||||
@ -37,16 +37,26 @@ export function applyFilters(data: any[], filters: RequestFilters): any[] {
|
|||||||
if (filters.status.toLowerCase() === 'pending') {
|
if (filters.status.toLowerCase() === 'pending') {
|
||||||
filteredData = filteredData.filter((req: any) => {
|
filteredData = filteredData.filter((req: any) => {
|
||||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||||
return reqStatus === 'PENDING' || reqStatus === 'IN_PROGRESS';
|
const isPaused = req.pauseInfo?.isPaused || req.isPaused || false;
|
||||||
|
// IN_PROGRESS is treated as PENDING (legacy support during migration)
|
||||||
|
return (reqStatus === 'PENDING' || reqStatus === 'IN_PROGRESS') && !isPaused;
|
||||||
|
});
|
||||||
|
} else if (filters.status.toLowerCase() === 'paused') {
|
||||||
|
filteredData = filteredData.filter((req: any) => {
|
||||||
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||||
|
const isPaused = req.pauseInfo?.isPaused || req.isPaused || false;
|
||||||
|
// Check both status enum and isPaused flag for compatibility
|
||||||
|
return reqStatus === 'PAUSED' || isPaused;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const statusUpper = filters.status.toUpperCase().replace('-', '_');
|
const statusUpper = filters.status.toUpperCase().replace('-', '_');
|
||||||
filteredData = filteredData.filter((req: any) => {
|
filteredData = filteredData.filter((req: any) => {
|
||||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||||
|
const isPaused = req.pauseInfo?.isPaused || req.isPaused || false;
|
||||||
if (statusUpper === 'IN_PROGRESS' || statusUpper === 'IN-PROGRESS') {
|
if (statusUpper === 'IN_PROGRESS' || statusUpper === 'IN-PROGRESS') {
|
||||||
return reqStatus === 'IN_PROGRESS';
|
return reqStatus === 'IN_PROGRESS' && !isPaused;
|
||||||
}
|
}
|
||||||
return reqStatus === statusUpper;
|
return reqStatus === statusUpper && !isPaused; // Exclude paused from other statuses
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { configureStore } from '@reduxjs/toolkit';
|
import { configureStore } from '@reduxjs/toolkit';
|
||||||
import authSlice from './slices/authSlice';
|
import authSlice from './slices/authSlice';
|
||||||
|
import dashboardSlice from '../pages/Dashboard/redux/dashboardSlice';
|
||||||
|
|
||||||
export const store = configureStore({
|
export const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
auth: authSlice.reducer,
|
auth: authSlice.reducer,
|
||||||
|
dashboard: dashboardSlice.reducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
|
|||||||
@ -18,12 +18,23 @@ const apiClient: AxiosInstance = axios.create({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Request interceptor to add access token
|
// Request interceptor to add access token
|
||||||
|
// In production: No header needed - httpOnly cookies are sent automatically via withCredentials
|
||||||
|
// In development: Add Authorization header from localStorage
|
||||||
apiClient.interceptors.request.use(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = TokenManager.getAccessToken();
|
// In production, cookies are sent automatically with withCredentials: true
|
||||||
if (token) {
|
// No need to set Authorization header
|
||||||
config.headers.Authorization = `Bearer ${token}`;
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
|
if (!isProduction) {
|
||||||
|
// Development: Get token from localStorage and add to header
|
||||||
|
const token = TokenManager.getAccessToken();
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Production: Cookies handle authentication automatically
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => {
|
||||||
@ -57,25 +68,39 @@ apiClient.interceptors.response.use(
|
|||||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Attempt to refresh token
|
// Attempt to refresh token
|
||||||
|
// In production: Cookie is sent automatically via withCredentials
|
||||||
|
// In development: Send refresh token from localStorage
|
||||||
const refreshToken = TokenManager.getRefreshToken();
|
const refreshToken = TokenManager.getRefreshToken();
|
||||||
if (!refreshToken) {
|
|
||||||
|
// In production, refreshToken will be null but cookie will be sent
|
||||||
|
// In development, we need the token in body
|
||||||
|
if (!isProduction && !refreshToken) {
|
||||||
throw new Error('No refresh token available');
|
throw new Error('No refresh token available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const response = await axios.post(
|
const response = await axios.post(
|
||||||
`${API_BASE_URL}/auth/refresh`,
|
`${API_BASE_URL}/auth/refresh`,
|
||||||
{ refreshToken },
|
isProduction ? {} : { refreshToken }, // Empty body in production, cookie is used
|
||||||
{ withCredentials: true }
|
{ withCredentials: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
const { accessToken } = response.data.data || response.data;
|
const responseData = response.data.data || response.data;
|
||||||
|
const accessToken = responseData.accessToken;
|
||||||
|
|
||||||
|
// In production: Backend sets new httpOnly cookie, no token in response
|
||||||
|
// In development: Token is in response, store it
|
||||||
if (accessToken) {
|
if (accessToken) {
|
||||||
TokenManager.setAccessToken(accessToken);
|
TokenManager.setAccessToken(accessToken);
|
||||||
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
return apiClient(originalRequest);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Retry the original request
|
||||||
|
// In production: Cookie will be sent automatically
|
||||||
|
return apiClient(originalRequest);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
// Refresh failed, clear tokens and redirect to login
|
// Refresh failed, clear tokens and redirect to login
|
||||||
TokenManager.clearAll();
|
TokenManager.clearAll();
|
||||||
@ -145,18 +170,25 @@ export async function exchangeCodeForTokens(
|
|||||||
const data = response.data as any;
|
const data = response.data as any;
|
||||||
const result = data.data || data;
|
const result = data.data || data;
|
||||||
|
|
||||||
// Tokens are set as httpOnly cookies by backend, but we also store them here for client access
|
// Store user data (always available)
|
||||||
|
if (result.user) {
|
||||||
|
TokenManager.setUserData(result.user);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store ID token if available (needed for Okta logout)
|
||||||
|
if (result.idToken) {
|
||||||
|
TokenManager.setIdToken(result.idToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// SECURITY: In production, tokens are ONLY in httpOnly cookies (not in response body)
|
||||||
|
// In development, backend returns tokens for cross-port setup
|
||||||
if (result.accessToken && result.refreshToken) {
|
if (result.accessToken && result.refreshToken) {
|
||||||
|
// Development mode: Backend returned tokens, store them
|
||||||
TokenManager.setAccessToken(result.accessToken);
|
TokenManager.setAccessToken(result.accessToken);
|
||||||
TokenManager.setRefreshToken(result.refreshToken);
|
TokenManager.setRefreshToken(result.refreshToken);
|
||||||
TokenManager.setUserData(result.user);
|
|
||||||
// Store id_token if available (needed for proper Okta logout)
|
|
||||||
if (result.idToken) {
|
|
||||||
TokenManager.setIdToken(result.idToken);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.warn('⚠️ Tokens missing in response', { result });
|
|
||||||
}
|
}
|
||||||
|
// Production mode: No tokens in response - they're in httpOnly cookies
|
||||||
|
// TokenManager.setAccessToken/setRefreshToken are no-ops in production anyway
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@ -6,6 +6,7 @@ export interface RequestStats {
|
|||||||
approvedRequests: number;
|
approvedRequests: number;
|
||||||
rejectedRequests: number;
|
rejectedRequests: number;
|
||||||
closedRequests: number;
|
closedRequests: number;
|
||||||
|
pausedRequests?: number;
|
||||||
draftRequests: number;
|
draftRequests: number;
|
||||||
changeFromPrevious: {
|
changeFromPrevious: {
|
||||||
total: string;
|
total: string;
|
||||||
@ -195,7 +196,8 @@ class DashboardService {
|
|||||||
approver?: string,
|
approver?: string,
|
||||||
approverType?: 'current' | 'any',
|
approverType?: 'current' | 'any',
|
||||||
search?: string,
|
search?: string,
|
||||||
slaCompliance?: string
|
slaCompliance?: string,
|
||||||
|
viewAsUser?: boolean
|
||||||
): Promise<RequestStats> {
|
): Promise<RequestStats> {
|
||||||
try {
|
try {
|
||||||
const params: any = { dateRange };
|
const params: any = { dateRange };
|
||||||
@ -228,6 +230,10 @@ class DashboardService {
|
|||||||
if (slaCompliance && slaCompliance !== 'all') {
|
if (slaCompliance && slaCompliance !== 'all') {
|
||||||
params.slaCompliance = slaCompliance;
|
params.slaCompliance = slaCompliance;
|
||||||
}
|
}
|
||||||
|
// Pass viewAsUser flag to tell backend to treat admin as normal user
|
||||||
|
if (viewAsUser) {
|
||||||
|
params.viewAsUser = 'true';
|
||||||
|
}
|
||||||
const response = await apiClient.get('/dashboard/stats/requests', { params });
|
const response = await apiClient.get('/dashboard/stats/requests', { params });
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -324,6 +324,35 @@ export async function addSpectator(requestId: string, email: string) {
|
|||||||
return res.data?.data || res.data;
|
return res.data?.data || res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function pauseWorkflow(
|
||||||
|
requestId: string,
|
||||||
|
levelId: string | null,
|
||||||
|
reason: string,
|
||||||
|
resumeDate: Date
|
||||||
|
) {
|
||||||
|
const res = await apiClient.post(`/workflows/${requestId}/pause`, {
|
||||||
|
levelId,
|
||||||
|
reason,
|
||||||
|
resumeDate: resumeDate.toISOString()
|
||||||
|
});
|
||||||
|
return res.data?.data || res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function resumeWorkflow(requestId: string) {
|
||||||
|
const res = await apiClient.post(`/workflows/${requestId}/resume`);
|
||||||
|
return res.data?.data || res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function retriggerPause(requestId: string) {
|
||||||
|
const res = await apiClient.post(`/workflows/${requestId}/pause/retrigger`);
|
||||||
|
return res.data?.data || res.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPauseDetails(requestId: string) {
|
||||||
|
const res = await apiClient.get(`/workflows/${requestId}/pause`);
|
||||||
|
return res.data?.data || res.data;
|
||||||
|
}
|
||||||
|
|
||||||
export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string {
|
export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string {
|
||||||
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
||||||
return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`;
|
return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`;
|
||||||
@ -336,16 +365,24 @@ export function getDocumentPreviewUrl(documentId: string): string {
|
|||||||
|
|
||||||
export async function downloadDocument(documentId: string): Promise<void> {
|
export async function downloadDocument(documentId: string): Promise<void> {
|
||||||
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
||||||
const token = localStorage.getItem('accessToken');
|
|
||||||
|
|
||||||
const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`;
|
const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`;
|
||||||
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(downloadUrl, {
|
// Build fetch options
|
||||||
headers: {
|
const fetchOptions: RequestInit = {
|
||||||
|
credentials: 'include', // Send cookies in production
|
||||||
|
};
|
||||||
|
|
||||||
|
// In development, add Authorization header from localStorage
|
||||||
|
if (!isProduction) {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
fetchOptions.headers = {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
}
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const response = await fetch(downloadUrl, fetchOptions);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
@ -373,17 +410,25 @@ export async function downloadDocument(documentId: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> {
|
export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> {
|
||||||
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
const downloadBaseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
||||||
const token = localStorage.getItem('accessToken');
|
const downloadUrl = `${downloadBaseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
|
||||||
|
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
const downloadUrl = `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(downloadUrl, {
|
// Build fetch options
|
||||||
headers: {
|
const fetchOptions: RequestInit = {
|
||||||
|
credentials: 'include', // Send cookies in production
|
||||||
|
};
|
||||||
|
|
||||||
|
// In development, add Authorization header from localStorage
|
||||||
|
if (!isProduction) {
|
||||||
|
const token = localStorage.getItem('accessToken');
|
||||||
|
fetchOptions.headers = {
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
}
|
};
|
||||||
});
|
}
|
||||||
|
|
||||||
|
const response = await fetch(downloadUrl, fetchOptions);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorText = await response.text();
|
const errorText = await response.text();
|
||||||
|
|||||||
@ -1,8 +1,8 @@
|
|||||||
// Type definitions for Royal Enfield Approval Portal
|
// Type definitions for Royal Enfield Approval Portal
|
||||||
|
|
||||||
export type Priority = 'express' | 'urgent' | 'standard';
|
export type Priority = 'express' | 'urgent' | 'standard';
|
||||||
export type Status = 'pending' | 'in-review' | 'approved' | 'rejected' | 'cancelled';
|
export type Status = 'pending' | 'in-review' | 'approved' | 'rejected' | 'cancelled' | 'paused';
|
||||||
export type ApprovalStatus = 'pending' | 'waiting' | 'approved' | 'rejected' | 'cancelled';
|
export type ApprovalStatus = 'pending' | 'waiting' | 'approved' | 'rejected' | 'cancelled' | 'paused';
|
||||||
|
|
||||||
export interface Initiator {
|
export interface Initiator {
|
||||||
name: string;
|
name: string;
|
||||||
@ -56,6 +56,23 @@ export interface AuditTrailItem {
|
|||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PauseInfo {
|
||||||
|
isPaused: boolean;
|
||||||
|
pausedAt?: string;
|
||||||
|
pausedBy?: {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
pauseReason?: string;
|
||||||
|
pauseResumeDate?: string;
|
||||||
|
level?: {
|
||||||
|
levelId: string;
|
||||||
|
levelNumber: number;
|
||||||
|
approverName: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export interface BaseRequest {
|
export interface BaseRequest {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
@ -81,6 +98,7 @@ export interface BaseRequest {
|
|||||||
spectators: Spectator[];
|
spectators: Spectator[];
|
||||||
auditTrail: AuditTrailItem[];
|
auditTrail: AuditTrailItem[];
|
||||||
tags: string[];
|
tags: string[];
|
||||||
|
pauseInfo?: PauseInfo;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CustomRequest extends BaseRequest {
|
export interface CustomRequest extends BaseRequest {
|
||||||
|
|||||||
@ -8,6 +8,11 @@ const REFRESH_TOKEN_KEY = 'refreshToken';
|
|||||||
const ID_TOKEN_KEY = 'idToken';
|
const ID_TOKEN_KEY = 'idToken';
|
||||||
const USER_DATA_KEY = 'userData';
|
const USER_DATA_KEY = 'userData';
|
||||||
|
|
||||||
|
// Check if running in production mode
|
||||||
|
const isProduction = (): boolean => {
|
||||||
|
return import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cookie utility functions
|
* Cookie utility functions
|
||||||
*/
|
*/
|
||||||
@ -72,84 +77,97 @@ export const cookieUtils = {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Token Manager - Handles token storage and retrieval
|
* Token Manager - Handles token storage and retrieval
|
||||||
|
*
|
||||||
|
* SECURITY MODES:
|
||||||
|
* - Production: Tokens stored in httpOnly cookies by backend only
|
||||||
|
* Frontend does NOT store access/refresh tokens anywhere
|
||||||
|
* All API requests rely on cookies being sent automatically
|
||||||
|
*
|
||||||
|
* - Development: Tokens stored in localStorage for debugging
|
||||||
|
* Needed because frontend/backend run on different ports
|
||||||
*/
|
*/
|
||||||
export class TokenManager {
|
export class TokenManager {
|
||||||
/**
|
/**
|
||||||
* Store access token
|
* Store access token
|
||||||
|
* In production: No-op (backend handles via httpOnly cookies)
|
||||||
|
* In development: Store in localStorage for Authorization header
|
||||||
*/
|
*/
|
||||||
static setAccessToken(token: string): void {
|
static setAccessToken(token: string): void {
|
||||||
if (this.isLocalhost()) {
|
// SECURITY: In production, don't store tokens client-side
|
||||||
// Store in cookie for localhost (backend sets httpOnly cookie, but we also store here for client-side access)
|
// Backend sets httpOnly cookies that are sent automatically
|
||||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
if (isProduction()) {
|
||||||
cookieUtils.set(ACCESS_TOKEN_KEY, token, 1); // 1 day
|
return; // No-op - rely on httpOnly cookies
|
||||||
} else {
|
|
||||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Development only: Store for debugging and cross-port requests
|
||||||
|
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get access token
|
* Get access token
|
||||||
|
* In production: Returns null (cookies are sent automatically)
|
||||||
|
* In development: Returns from localStorage
|
||||||
*/
|
*/
|
||||||
static getAccessToken(): string | null {
|
static getAccessToken(): string | null {
|
||||||
if (this.isLocalhost()) {
|
// SECURITY: In production, return null - cookies are used instead
|
||||||
// Try cookie first (set by backend), then localStorage
|
if (isProduction()) {
|
||||||
return cookieUtils.get(ACCESS_TOKEN_KEY) || localStorage.getItem(ACCESS_TOKEN_KEY);
|
return null; // API calls use cookies via withCredentials: true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Development: Return from localStorage
|
||||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store refresh token
|
* Store refresh token
|
||||||
|
* In production: No-op (backend handles via httpOnly cookies)
|
||||||
|
* In development: Store in localStorage
|
||||||
*/
|
*/
|
||||||
static setRefreshToken(token: string): void {
|
static setRefreshToken(token: string): void {
|
||||||
if (this.isLocalhost()) {
|
// SECURITY: In production, don't store tokens client-side
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
if (isProduction()) {
|
||||||
cookieUtils.set(REFRESH_TOKEN_KEY, token, 7); // 7 days
|
return; // No-op - rely on httpOnly cookies
|
||||||
} else {
|
|
||||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Development only
|
||||||
|
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get refresh token
|
* Get refresh token
|
||||||
|
* In production: Returns null (cookies are used)
|
||||||
|
* In development: Returns from localStorage
|
||||||
*/
|
*/
|
||||||
static getRefreshToken(): string | null {
|
static getRefreshToken(): string | null {
|
||||||
if (this.isLocalhost()) {
|
// SECURITY: In production, return null - backend reads from cookie
|
||||||
// Try cookie first (set by backend), then localStorage
|
if (isProduction()) {
|
||||||
return cookieUtils.get(REFRESH_TOKEN_KEY) || localStorage.getItem(REFRESH_TOKEN_KEY);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store ID token (from Okta)
|
* Store ID token (from Okta) - needed for logout
|
||||||
|
* Stored in sessionStorage (cleared when tab closes)
|
||||||
*/
|
*/
|
||||||
static setIdToken(token: string): void {
|
static setIdToken(token: string): void {
|
||||||
localStorage.setItem(ID_TOKEN_KEY, token);
|
// ID token is needed for Okta logout, use sessionStorage (more secure than localStorage)
|
||||||
if (this.isLocalhost()) {
|
sessionStorage.setItem(ID_TOKEN_KEY, token);
|
||||||
cookieUtils.set(ID_TOKEN_KEY, token, 1); // 1 day
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get ID token
|
* Get ID token
|
||||||
*/
|
*/
|
||||||
static getIdToken(): string | null {
|
static getIdToken(): string | null {
|
||||||
if (this.isLocalhost()) {
|
return sessionStorage.getItem(ID_TOKEN_KEY);
|
||||||
// Try cookie first, then localStorage
|
|
||||||
return cookieUtils.get(ID_TOKEN_KEY) || localStorage.getItem(ID_TOKEN_KEY);
|
|
||||||
}
|
|
||||||
return localStorage.getItem(ID_TOKEN_KEY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store user data
|
* Store user data (not sensitive - can be stored in localStorage)
|
||||||
*/
|
*/
|
||||||
static setUserData(user: any): void {
|
static setUserData(user: any): void {
|
||||||
localStorage.setItem(USER_DATA_KEY, JSON.stringify(user));
|
localStorage.setItem(USER_DATA_KEY, JSON.stringify(user));
|
||||||
if (this.isLocalhost()) {
|
|
||||||
cookieUtils.set(USER_DATA_KEY, JSON.stringify(user), 7);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -167,9 +185,15 @@ export class TokenManager {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Clear all tokens and user data
|
* Clear all tokens and user data
|
||||||
* This includes localStorage, sessionStorage, cookies, and any auth-related data
|
*
|
||||||
* Uses aggressive clearing to ensure ALL data is removed
|
* PRODUCTION MODE:
|
||||||
* IMPORTANT: This also sets a flag to prevent auto-authentication
|
* - Clears user data from localStorage
|
||||||
|
* - Clears ID token from sessionStorage
|
||||||
|
* - Backend logout endpoint clears httpOnly cookies
|
||||||
|
*
|
||||||
|
* DEVELOPMENT MODE:
|
||||||
|
* - Clears all localStorage and sessionStorage
|
||||||
|
* - Clears client-side cookies
|
||||||
*/
|
*/
|
||||||
static clearAll(): void {
|
static clearAll(): void {
|
||||||
// CRITICAL: Set logout flag in sessionStorage FIRST (before clearing)
|
// CRITICAL: Set logout flag in sessionStorage FIRST (before clearing)
|
||||||
@ -181,7 +205,28 @@ export class TokenManager {
|
|||||||
console.warn('Could not set logout flags:', e);
|
console.warn('Could not set logout flags:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 1: Clear specific auth-related localStorage keys
|
// Clear user data (stored in both modes)
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(USER_DATA_KEY);
|
||||||
|
sessionStorage.removeItem(ID_TOKEN_KEY);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error clearing user data:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, httpOnly cookies are cleared by backend
|
||||||
|
// Only need to clear user data above
|
||||||
|
if (isProduction()) {
|
||||||
|
// Restore logout flags after clearing
|
||||||
|
try {
|
||||||
|
sessionStorage.setItem('__logout_in_progress__', 'true');
|
||||||
|
sessionStorage.setItem('__force_logout__', 'true');
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DEVELOPMENT MODE: Clear everything
|
||||||
const authKeys = [
|
const authKeys = [
|
||||||
ACCESS_TOKEN_KEY,
|
ACCESS_TOKEN_KEY,
|
||||||
REFRESH_TOKEN_KEY,
|
REFRESH_TOKEN_KEY,
|
||||||
@ -194,11 +239,7 @@ export class TokenManager {
|
|||||||
'access_token',
|
'access_token',
|
||||||
'refresh_token',
|
'refresh_token',
|
||||||
'id_token',
|
'id_token',
|
||||||
'idToken',
|
|
||||||
'token',
|
'token',
|
||||||
'accessToken',
|
|
||||||
'refreshToken',
|
|
||||||
'userData',
|
|
||||||
'auth',
|
'auth',
|
||||||
'authentication',
|
'authentication',
|
||||||
'persist:root',
|
'persist:root',
|
||||||
@ -215,131 +256,55 @@ export class TokenManager {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Step 2: Clear ALL localStorage items by iterating and removing
|
// Clear ALL localStorage
|
||||||
try {
|
try {
|
||||||
const allLocalStorageKeys: string[] = [];
|
|
||||||
// Get all keys
|
|
||||||
for (let i = 0; i < localStorage.length; i++) {
|
|
||||||
const key = localStorage.key(i);
|
|
||||||
if (key) {
|
|
||||||
allLocalStorageKeys.push(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove each key explicitly
|
|
||||||
allLocalStorageKeys.forEach(key => {
|
|
||||||
try {
|
|
||||||
localStorage.removeItem(key);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Error removing localStorage key ${key}:`, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Final clear as backup
|
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error clearing localStorage:', e);
|
console.error('Error clearing localStorage:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 3: Clear ALL sessionStorage items
|
// Clear ALL sessionStorage except logout flags
|
||||||
try {
|
try {
|
||||||
const allSessionStorageKeys: string[] = [];
|
const keysToKeep = ['__logout_in_progress__', '__force_logout__'];
|
||||||
// Get all keys
|
const allKeys: string[] = [];
|
||||||
for (let i = 0; i < sessionStorage.length; i++) {
|
for (let i = 0; i < sessionStorage.length; i++) {
|
||||||
const key = sessionStorage.key(i);
|
const key = sessionStorage.key(i);
|
||||||
if (key) {
|
if (key && !keysToKeep.includes(key)) {
|
||||||
allSessionStorageKeys.push(key);
|
allKeys.push(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
allKeys.forEach(key => sessionStorage.removeItem(key));
|
||||||
// Remove each key explicitly
|
|
||||||
allSessionStorageKeys.forEach(key => {
|
|
||||||
try {
|
|
||||||
sessionStorage.removeItem(key);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(`Error removing sessionStorage key ${key}:`, e);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Final clear as backup
|
|
||||||
sessionStorage.clear();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Error clearing sessionStorage:', e);
|
console.error('Error clearing sessionStorage:', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 4: Clear cookies (both client-side and attempt to clear httpOnly cookies)
|
// Clear client-side cookies (development only)
|
||||||
cookieUtils.clearAll();
|
cookieUtils.clearAll();
|
||||||
|
|
||||||
// Step 5: Aggressively clear ALL cookies including httpOnly ones
|
|
||||||
// Note: httpOnly cookies can only be cleared by backend, but we try everything
|
|
||||||
const cookieNames = [
|
|
||||||
'accessToken', 'refreshToken', 'userData', 'oktaToken', 'authToken',
|
|
||||||
'id_token', 'token', 'access_token', 'refresh_token'
|
|
||||||
];
|
|
||||||
|
|
||||||
const hostname = window.location.hostname;
|
|
||||||
const paths = ['/', '/login', '/login/callback', '/api', '/api/v1'];
|
|
||||||
const domains = ['', hostname, `.${hostname}`];
|
|
||||||
|
|
||||||
// Try every combination of path, domain, and secure flags
|
|
||||||
cookieNames.forEach(name => {
|
|
||||||
paths.forEach(path => {
|
|
||||||
domains.forEach(domain => {
|
|
||||||
// Try without secure flag
|
|
||||||
try {
|
|
||||||
let cookieString = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=Lax;`;
|
|
||||||
if (domain && domain !== 'localhost' && domain !== '127.0.0.1' && !domain.startsWith('.')) {
|
|
||||||
cookieString += ` domain=${domain};`;
|
|
||||||
}
|
|
||||||
document.cookie = cookieString;
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with secure flag
|
|
||||||
try {
|
|
||||||
let cookieString = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=None; Secure;`;
|
|
||||||
if (domain && domain !== 'localhost' && domain !== '127.0.0.1') {
|
|
||||||
cookieString += ` domain=${domain};`;
|
|
||||||
}
|
|
||||||
document.cookie = cookieString;
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try with SameSite=Strict
|
|
||||||
try {
|
|
||||||
let cookieString = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=Strict;`;
|
|
||||||
if (domain && domain !== 'localhost' && domain !== '127.0.0.1' && !domain.startsWith('.')) {
|
|
||||||
cookieString += ` domain=${domain};`;
|
|
||||||
}
|
|
||||||
document.cookie = cookieString;
|
|
||||||
} catch (e) {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Note: httpOnly cookies can only be cleared by backend - backend logout endpoint should handle this
|
|
||||||
|
|
||||||
// Step 6: Verify cleanup
|
|
||||||
if (localStorage.length > 0 || sessionStorage.length > 0) {
|
|
||||||
console.warn('WARNING: Storage not fully cleared!');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if access token exists
|
* Check if access token exists
|
||||||
|
* In production: Always returns true if user data exists (tokens are in httpOnly cookies)
|
||||||
|
* In development: Checks localStorage
|
||||||
*/
|
*/
|
||||||
static hasAccessToken(): boolean {
|
static hasAccessToken(): boolean {
|
||||||
|
if (isProduction()) {
|
||||||
|
// In production, we can't check httpOnly cookies from JS
|
||||||
|
// Use presence of user data as proxy for authentication
|
||||||
|
return !!this.getUserData();
|
||||||
|
}
|
||||||
return !!this.getAccessToken();
|
return !!this.getAccessToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if refresh token exists
|
* Check if refresh token exists
|
||||||
|
* In production: Always returns true if user data exists
|
||||||
|
* In development: Checks localStorage
|
||||||
*/
|
*/
|
||||||
static hasRefreshToken(): boolean {
|
static hasRefreshToken(): boolean {
|
||||||
|
if (isProduction()) {
|
||||||
|
return !!this.getUserData();
|
||||||
|
}
|
||||||
return !!this.getRefreshToken();
|
return !!this.getRefreshToken();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -353,6 +318,13 @@ export class TokenManager {
|
|||||||
window.location.hostname === ''
|
window.location.hostname === ''
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if we're in production mode
|
||||||
|
*/
|
||||||
|
static isProduction(): boolean {
|
||||||
|
return isProduction();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user