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
|
||||
isCurrentUser?: boolean;
|
||||
isInitiator?: boolean;
|
||||
isCurrentLevel?: boolean; // Whether this step is the current active level
|
||||
onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void;
|
||||
onRefresh?: () => void | Promise<void>; // Optional callback to refresh data
|
||||
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" />;
|
||||
case 'rejected':
|
||||
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 'in-review':
|
||||
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />;
|
||||
@ -82,6 +85,7 @@ export function ApprovalStepCard({
|
||||
approval,
|
||||
isCurrentUser = false,
|
||||
isInitiator = false,
|
||||
isCurrentLevel = false,
|
||||
onSkipApprover,
|
||||
onRefresh,
|
||||
testId = 'approval-step'
|
||||
@ -105,6 +109,7 @@ export function ApprovalStepCard({
|
||||
const isCompleted = step.status === 'approved';
|
||||
const isRejected = step.status === 'rejected';
|
||||
const isWaiting = step.status === 'waiting';
|
||||
const isPaused = step.status === 'paused';
|
||||
|
||||
const tatHours = Number(step.tatHours || 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={`p-2 sm:p-2.5 md:p-3 rounded-xl flex-shrink-0 ${
|
||||
step.isSkipped ? 'bg-orange-100' :
|
||||
isPaused ? 'bg-yellow-100' :
|
||||
isActive ? 'bg-blue-100' :
|
||||
isCompleted ? 'bg-green-100' :
|
||||
isRejected ? 'bg-red-100' :
|
||||
@ -333,8 +339,9 @@ export function ApprovalStepCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active Approver - Show Real-time Progress from Backend */}
|
||||
{isActive && approval?.sla && (
|
||||
{/* Active Approver (including paused) - Show Real-time Progress from Backend */}
|
||||
{/* Only show SLA for the current level step, not future levels */}
|
||||
{isCurrentLevel && (isActive || isPaused) && approval?.sla && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<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;
|
||||
}
|
||||
|
||||
// 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 refreshToken = TokenManager.getRefreshToken();
|
||||
const userData = TokenManager.getUserData();
|
||||
const hasAuthData = token || refreshToken || userData;
|
||||
|
||||
// If no auth data exists, we're likely after a logout - set unauthenticated state immediately
|
||||
if (!hasAuthData) {
|
||||
setIsAuthenticated(false);
|
||||
setUser(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
// Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
|
||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||
|
||||
// PRIORITY 4: Only check auth status if we have some auth data AND we're not logging out
|
||||
if (!isLoggingOut) {
|
||||
checkAuthStatus();
|
||||
// In production: Always verify with server (cookies are sent automatically)
|
||||
// In development: Check local auth data first
|
||||
if (isProductionMode) {
|
||||
// Production: Verify session with server via httpOnly cookie
|
||||
if (!isLoggingOut) {
|
||||
checkAuthStatus();
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} 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]);
|
||||
|
||||
@ -143,20 +157,34 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated) return;
|
||||
|
||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||
|
||||
const checkAndRefresh = async () => {
|
||||
const token = TokenManager.getAccessToken();
|
||||
if (token && isTokenExpired(token, 5)) {
|
||||
// Token expires in less than 5 minutes, refresh it
|
||||
if (isProductionMode) {
|
||||
// In production, proactively refresh the session every 10 minutes
|
||||
// The httpOnly cookie will be sent automatically
|
||||
try {
|
||||
await refreshTokenSilently();
|
||||
} catch (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
|
||||
const interval = setInterval(checkAndRefresh, 5 * 60 * 1000);
|
||||
// Check every 10 minutes in production, 5 minutes in development
|
||||
const intervalMs = isProductionMode ? 10 * 60 * 1000 : 5 * 60 * 1000;
|
||||
const interval = setInterval(checkAndRefresh, intervalMs);
|
||||
return () => clearInterval(interval);
|
||||
}, [isAuthenticated]);
|
||||
|
||||
@ -232,9 +260,58 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||
|
||||
try {
|
||||
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 storedUser = TokenManager.getUserData();
|
||||
|
||||
|
||||
@ -262,7 +262,8 @@ export function useRequestDetails(
|
||||
const approverEmail = (a.approverEmail || '').toLowerCase();
|
||||
const approvalLevelNumber = a.levelNumber || 0;
|
||||
// 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
|
||||
&& approvalLevelNumber === currentLevel;
|
||||
});
|
||||
@ -325,10 +326,13 @@ export function useRequestDetails(
|
||||
|
||||
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';
|
||||
} else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
|
||||
displayStatus = 'pending';
|
||||
} else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) {
|
||||
displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending';
|
||||
}
|
||||
|
||||
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
||||
@ -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
|
||||
const mapped = {
|
||||
id: wf.requestNumber || wf.requestId,
|
||||
@ -428,19 +442,22 @@ export function useRequestDetails(
|
||||
auditTrail: filteredActivities,
|
||||
conclusionRemark: wf.conclusionRemark || null,
|
||||
closureDate: wf.closureDate || null,
|
||||
pauseInfo: pauseInfo || null,
|
||||
};
|
||||
|
||||
setApiRequest(mapped);
|
||||
|
||||
// Find current user's approval level
|
||||
// Only show approve/reject buttons if user is the CURRENT active approver
|
||||
// Include PAUSED status - when paused, the paused level is still the current level
|
||||
const userEmail = (user as any)?.email?.toLowerCase();
|
||||
const userCurrentLevel = approvals.find((a: any) => {
|
||||
const status = (a.status || '').toString().toUpperCase();
|
||||
const approverEmail = (a.approverEmail || '').toLowerCase();
|
||||
const approvalLevelNumber = a.levelNumber || 0;
|
||||
// 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
|
||||
&& approvalLevelNumber === currentLevel;
|
||||
});
|
||||
|
||||
10
src/main.tsx
10
src/main.tsx
@ -1,14 +1,18 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { AuthProvider } from './contexts/AuthContext';
|
||||
import { AuthenticatedApp } from './pages/Auth';
|
||||
import { store } from './redux/store';
|
||||
import './styles/globals.css';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<AuthProvider>
|
||||
<AuthenticatedApp />
|
||||
</AuthProvider>
|
||||
<Provider store={store}>
|
||||
<AuthProvider>
|
||||
<AuthenticatedApp />
|
||||
</AuthProvider>
|
||||
</Provider>
|
||||
</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 { type DateRange } from '@/services/dashboard.service';
|
||||
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
||||
import { useAppSelector, useAppDispatch } from '@/redux/hooks';
|
||||
import { setViewAsUser } from './redux/dashboardSlice';
|
||||
|
||||
// Custom Hooks
|
||||
import { useDashboardFilters } from './hooks/useDashboardFilters';
|
||||
@ -36,15 +38,21 @@ interface DashboardProps {
|
||||
|
||||
export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
const { user } = useAuth();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Get viewAsUser from Redux store
|
||||
const viewAsUser = useAppSelector((state) => state.dashboard.viewAsUser);
|
||||
|
||||
// Determine user role
|
||||
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
|
||||
const effectiveIsAdmin = isAdmin && !viewAsUser;
|
||||
|
||||
// Handler to toggle view
|
||||
const handleToggleView = useCallback((value: boolean) => {
|
||||
dispatch(setViewAsUser(value));
|
||||
}, [dispatch]);
|
||||
|
||||
// Filters
|
||||
const filters = useDashboardFilters();
|
||||
@ -188,7 +196,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
isAdmin={isAdmin}
|
||||
effectiveIsAdmin={effectiveIsAdmin}
|
||||
viewAsUser={viewAsUser}
|
||||
onToggleView={setViewAsUser}
|
||||
onToggleView={handleToggleView}
|
||||
quickActions={quickActions}
|
||||
userDisplayName={(user as any)?.displayName}
|
||||
userEmail={(user as any)?.email}
|
||||
|
||||
@ -23,14 +23,6 @@ interface DashboardHeroProps {
|
||||
export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleView, quickActions, userDisplayName, userEmail }: DashboardHeroProps) {
|
||||
// Get user's name for welcome message
|
||||
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 (
|
||||
<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>
|
||||
@ -39,17 +31,17 @@ export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleV
|
||||
{/* Toggle for admin to switch between admin and personal view - Top Right Corner */}
|
||||
{isAdmin && (
|
||||
<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
|
||||
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
|
||||
? 'bg-red-600/20 border border-red-600/50'
|
||||
: 'opacity-60 hover:opacity-80'
|
||||
}`}
|
||||
onClick={() => onToggleView(false)}
|
||||
>
|
||||
<Building2 className={`w-3.5 h-3.5 ${!viewAsUser ? 'text-red-600' : 'text-gray-300'}`} />
|
||||
<Label htmlFor="view-toggle-switch" className={`text-xs font-medium cursor-pointer whitespace-nowrap ${
|
||||
<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-[10px] sm:text-xs font-medium cursor-pointer whitespace-nowrap ${
|
||||
!viewAsUser ? 'text-red-600' : 'text-gray-300'
|
||||
}`}>
|
||||
Org
|
||||
@ -59,19 +51,19 @@ export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleV
|
||||
id="view-toggle-switch"
|
||||
checked={viewAsUser}
|
||||
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"
|
||||
/>
|
||||
<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
|
||||
? 'bg-red-600/20 border border-red-600/50'
|
||||
: 'opacity-60 hover:opacity-80'
|
||||
}`}
|
||||
onClick={() => onToggleView(true)}
|
||||
>
|
||||
<User className={`w-3.5 h-3.5 ${viewAsUser ? 'text-red-600' : 'text-gray-300'}`} />
|
||||
<Label htmlFor="view-toggle-switch" className={`text-xs font-medium cursor-pointer whitespace-nowrap ${
|
||||
<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-[10px] sm:text-xs font-medium cursor-pointer whitespace-nowrap ${
|
||||
viewAsUser ? 'text-red-600' : 'text-gray-300'
|
||||
}`}>
|
||||
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="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>
|
||||
<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">
|
||||
{getGreeting()}, {userName}!
|
||||
Welcome, {userName}!
|
||||
</h1>
|
||||
<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'}
|
||||
|
||||
@ -72,7 +72,7 @@ export function AdminKPICards({
|
||||
/>
|
||||
</div>
|
||||
{/* Row 2: Pending and Closed */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="grid grid-cols-2 gap-2 mb-2">
|
||||
<StatCard
|
||||
label="Pending"
|
||||
value={kpis?.requestVolume.openRequests || 0}
|
||||
@ -96,6 +96,22 @@ export function AdminKPICards({
|
||||
}}
|
||||
/>
|
||||
</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>
|
||||
|
||||
{/* 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<{
|
||||
total: number;
|
||||
pending: number;
|
||||
paused: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
draft: number;
|
||||
@ -93,6 +94,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
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,
|
||||
@ -129,6 +131,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
return {
|
||||
total: backendStats.total || 0,
|
||||
pending: backendStats.pending || 0,
|
||||
paused: backendStats.paused || 0,
|
||||
approved: backendStats.approved || 0,
|
||||
rejected: backendStats.rejected || 0,
|
||||
draft: backendStats.draft || 0,
|
||||
@ -140,6 +143,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
return {
|
||||
total: 0,
|
||||
pending: 0,
|
||||
paused: 0,
|
||||
approved: 0,
|
||||
rejected: 0,
|
||||
draft: 0,
|
||||
|
||||
@ -51,6 +51,7 @@ export function MyRequestsFilters({
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
* 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 { MyRequestsStats } from '../types/myRequests.types';
|
||||
|
||||
@ -18,7 +18,7 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-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
|
||||
label="Total"
|
||||
value={stats.total}
|
||||
@ -43,6 +43,18 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
|
||||
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
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
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 { MyRequest } from '../types/myRequests.types';
|
||||
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" />
|
||||
<span className="capitalize">{request.status}</span>
|
||||
</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
|
||||
variant="outline"
|
||||
className={`${priorityConfig.color} border font-medium text-xs capitalize shrink-0`}
|
||||
|
||||
@ -15,6 +15,7 @@ export function useMyRequestsStats({ requests, totalRecords }: UseMyRequestsStat
|
||||
return {
|
||||
total: totalRecords || requests.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,
|
||||
rejected: requests.filter((r) => r.status === 'rejected').length,
|
||||
draft: requests.filter((r) => r.status === 'draft').length,
|
||||
|
||||
@ -17,11 +17,18 @@ export interface MyRequest {
|
||||
approverLevel?: string;
|
||||
templateType?: string;
|
||||
templateName?: string;
|
||||
pauseInfo?: {
|
||||
isPaused: boolean;
|
||||
reason?: string;
|
||||
pausedAt?: string;
|
||||
pausedBy?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface MyRequestsStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
paused: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
draft: number;
|
||||
|
||||
@ -49,6 +49,9 @@ import { SummaryTab } from './components/tabs/SummaryTab';
|
||||
import { QuickActionsSidebar } from './components/QuickActionsSidebar';
|
||||
import { RequestDetailModals } from './components/RequestDetailModals';
|
||||
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
|
||||
@ -105,6 +108,8 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
const [summaryDetails, setSummaryDetails] = useState<SummaryDetails | null>(null);
|
||||
const [loadingSummary, setLoadingSummary] = useState(false);
|
||||
const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0);
|
||||
const [showPauseModal, setShowPauseModal] = useState(false);
|
||||
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
// Custom hooks
|
||||
@ -183,6 +188,37 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
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 () => {
|
||||
if (!apiRequest?.requestId) {
|
||||
toast.error('Request ID not found');
|
||||
@ -397,6 +433,12 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
aiGenerated={aiGenerated}
|
||||
handleGenerateConclusion={handleGenerateConclusion}
|
||||
handleFinalizeConclusion={handleFinalizeConclusion}
|
||||
onPause={handlePause}
|
||||
onResume={handleResume}
|
||||
onRetrigger={handleRetrigger}
|
||||
currentUserIsApprover={!!currentApprovalLevel}
|
||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||
currentUserId={(user as any)?.userId}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
@ -469,8 +511,13 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
onAddSpectator={() => setShowAddSpectatorModal(true)}
|
||||
onApprove={() => setShowApproveModal(true)}
|
||||
onReject={() => setShowRejectModal(true)}
|
||||
onPause={handlePause}
|
||||
onResume={handleResume}
|
||||
onRetrigger={handleRetrigger}
|
||||
summaryId={summaryId}
|
||||
refreshTrigger={sharedRecipientsRefreshTrigger}
|
||||
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
|
||||
currentUserId={(user as any)?.userId}
|
||||
/>
|
||||
)}
|
||||
</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 */}
|
||||
<RequestDetailModals
|
||||
showApproveModal={showApproveModal}
|
||||
|
||||
@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
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';
|
||||
|
||||
interface QuickActionsSidebarProps {
|
||||
@ -18,8 +18,13 @@ interface QuickActionsSidebarProps {
|
||||
onAddSpectator: () => void;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
onPause?: () => void;
|
||||
onResume?: () => void;
|
||||
onRetrigger?: () => void;
|
||||
summaryId?: string | null;
|
||||
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({
|
||||
@ -31,12 +36,23 @@ export function QuickActionsSidebar({
|
||||
onAddSpectator,
|
||||
onApprove,
|
||||
onReject,
|
||||
onPause,
|
||||
onResume,
|
||||
onRetrigger,
|
||||
summaryId,
|
||||
refreshTrigger,
|
||||
pausedByUserId,
|
||||
currentUserId,
|
||||
}: QuickActionsSidebarProps) {
|
||||
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
|
||||
const [loadingRecipients, setLoadingRecipients] = useState(false);
|
||||
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
|
||||
useEffect(() => {
|
||||
@ -96,9 +112,46 @@ export function QuickActionsSidebar({
|
||||
</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 */}
|
||||
<div className="pt-3 sm:pt-4 space-y-2">
|
||||
{currentApprovalLevel && (
|
||||
{currentApprovalLevel && !isPaused && (
|
||||
<>
|
||||
<Button
|
||||
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>
|
||||
</>
|
||||
)}
|
||||
{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>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@ -7,8 +7,12 @@ import { Button } from '@/components/ui/button';
|
||||
import { RichTextEditor } from '@/components/ui/rich-text-editor';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
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 dayjs from 'dayjs';
|
||||
import relativeTime from 'dayjs/plugin/relativeTime';
|
||||
|
||||
dayjs.extend(relativeTime);
|
||||
|
||||
interface OverviewTabProps {
|
||||
request: any;
|
||||
@ -21,11 +25,17 @@ interface OverviewTabProps {
|
||||
aiGenerated: boolean;
|
||||
handleGenerateConclusion: () => void;
|
||||
handleFinalizeConclusion: () => void;
|
||||
onPause?: () => void;
|
||||
onResume?: () => void;
|
||||
onRetrigger?: () => void;
|
||||
currentUserIsApprover?: boolean;
|
||||
pausedByUserId?: string;
|
||||
currentUserId?: string;
|
||||
}
|
||||
|
||||
export function OverviewTab({
|
||||
request,
|
||||
isInitiator: _isInitiator,
|
||||
isInitiator,
|
||||
needsClosure,
|
||||
conclusionRemark,
|
||||
setConclusionRemark,
|
||||
@ -34,7 +44,21 @@ export function OverviewTab({
|
||||
aiGenerated,
|
||||
handleGenerateConclusion,
|
||||
handleFinalizeConclusion,
|
||||
onPause: _onPause,
|
||||
onResume,
|
||||
onRetrigger,
|
||||
currentUserIsApprover = false,
|
||||
pausedByUserId,
|
||||
currentUserId,
|
||||
}: 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 (
|
||||
<div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content">
|
||||
{/* Request Initiator Card */}
|
||||
@ -133,6 +157,83 @@ export function OverviewTab({
|
||||
</CardContent>
|
||||
</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 */}
|
||||
{request.claimDetails && (
|
||||
<Card>
|
||||
|
||||
@ -88,6 +88,11 @@ export function WorkflowTab({ request, user, isInitiator, onSkipApprover, onRefr
|
||||
const currentUserEmail = (user as any)?.email?.toLowerCase();
|
||||
const approverEmail = step.approverEmail?.toLowerCase();
|
||||
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 (
|
||||
<ApprovalStepCard
|
||||
@ -97,6 +102,7 @@ export function WorkflowTab({ request, user, isInitiator, onSkipApprover, onRefr
|
||||
approval={approval}
|
||||
isCurrentUser={isCurrentUser}
|
||||
isInitiator={isInitiator}
|
||||
isCurrentLevel={isCurrentLevel}
|
||||
onSkipApprover={onSkipApprover}
|
||||
onRefresh={onRefresh}
|
||||
testId="workflow-step"
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
||||
import { useAppSelector } from '@/redux/hooks';
|
||||
import { Pagination } from '@/components/common/Pagination';
|
||||
import type { DateRange } from '@/services/dashboard.service';
|
||||
import dashboardService from '@/services/dashboard.service';
|
||||
@ -47,7 +48,16 @@ import { format } from 'date-fns';
|
||||
|
||||
export function Requests({ onViewRequest }: RequestsProps) {
|
||||
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
|
||||
const filters = useRequestsFilters();
|
||||
@ -83,6 +93,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
// Fetch backend stats
|
||||
// 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
|
||||
// For user-level (Personal mode), stats will only include requests where user is involved
|
||||
const fetchBackendStats = useCallback(async (
|
||||
statsDateRange?: DateRange,
|
||||
statsStartDate?: Date,
|
||||
@ -97,8 +108,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
slaCompliance?: string;
|
||||
}
|
||||
) => {
|
||||
if (!isOrgLevel) return;
|
||||
|
||||
try {
|
||||
// For dynamic SLA statuses (on_track, approaching, critical), we need to fetch and enrich
|
||||
// because these are calculated dynamically, not stored in DB
|
||||
@ -131,11 +140,10 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
if (statsEndDate) backendFilters.endDate = statsEndDate.toISOString();
|
||||
|
||||
// Fetch up to 1000 requests (backend will enrich and filter by SLA)
|
||||
const result = await workflowApi.listWorkflows({
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
...backendFilters
|
||||
});
|
||||
// Use appropriate API based on org/personal mode
|
||||
const result = isOrgLevel
|
||||
? await workflowApi.listWorkflows({ page: 1, limit: 1000, ...backendFilters })
|
||||
: await workflowApi.listParticipantRequests({ page: 1, limit: 1000, ...backendFilters });
|
||||
|
||||
const filteredData = Array.isArray(result?.data) ? result.data : [];
|
||||
|
||||
@ -161,6 +169,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
setBackendStats({
|
||||
total,
|
||||
pending,
|
||||
paused: 0, // Paused not calculated in dynamic SLA mode
|
||||
approved,
|
||||
rejected,
|
||||
draft: 0, // Drafts are excluded
|
||||
@ -169,6 +178,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
} else {
|
||||
// For breached/compliant or no SLA filter, use dashboard stats API
|
||||
// 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(
|
||||
statsDateRange,
|
||||
statsStartDate ? statsStartDate.toISOString() : undefined,
|
||||
@ -180,12 +190,14 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
filtersWithoutStatus?.approver,
|
||||
filtersWithoutStatus?.approverType,
|
||||
filtersWithoutStatus?.search,
|
||||
filtersWithoutStatus?.slaCompliance
|
||||
filtersWithoutStatus?.slaCompliance,
|
||||
!isOrgLevel // viewAsUser: true when in Personal mode
|
||||
);
|
||||
|
||||
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,
|
||||
@ -294,32 +306,31 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
// Stats should reflect priority, department, initiator, approver, search, and date range filters
|
||||
// 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
|
||||
// Stats are fetched for both org-level AND user-level (Personal mode) views
|
||||
useEffect(() => {
|
||||
if (isOrgLevel) {
|
||||
const timeoutId = setTimeout(() => {
|
||||
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
|
||||
};
|
||||
// All Requests (admin/normal user) should always have a date range
|
||||
// Default to 'month' if no date range is selected
|
||||
const statsDateRange = filters.dateRange || 'month';
|
||||
|
||||
fetchBackendStatsRef.current(
|
||||
statsDateRange,
|
||||
filters.customStartDate,
|
||||
filters.customEndDate,
|
||||
filtersWithoutStatus
|
||||
);
|
||||
}, filters.searchTerm ? 500 : 0);
|
||||
const timeoutId = setTimeout(() => {
|
||||
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
|
||||
};
|
||||
// All Requests (admin/normal user) should always have a date range
|
||||
// Default to 'month' if no date range is selected
|
||||
const statsDateRange = filters.dateRange || 'month';
|
||||
|
||||
fetchBackendStatsRef.current(
|
||||
statsDateRange,
|
||||
filters.customStartDate,
|
||||
filters.customEndDate,
|
||||
filtersWithoutStatus
|
||||
);
|
||||
}, filters.searchTerm ? 500 : 0);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
return () => clearTimeout(timeoutId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isOrgLevel,
|
||||
@ -337,6 +348,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
]);
|
||||
|
||||
// Fetch requests on mount and when filters change
|
||||
// Also refetch when isOrgLevel changes (when admin toggles between Org/Personal in Dashboard)
|
||||
useEffect(() => {
|
||||
const timeoutId = setTimeout(() => {
|
||||
setCurrentPage(1);
|
||||
@ -346,6 +358,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
return () => clearTimeout(timeoutId);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
isOrgLevel, // Re-fetch when org/personal toggle changes
|
||||
filters.searchTerm,
|
||||
filters.statusFilter,
|
||||
filters.priorityFilter,
|
||||
@ -371,14 +384,15 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
// Transform requests
|
||||
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
|
||||
const stats = useMemo(() => {
|
||||
// For org-level: Use backend stats API (always unfiltered)
|
||||
if (isOrgLevel && backendStats) {
|
||||
// Use backend stats if available (for both org-level and user-level)
|
||||
if (backendStats) {
|
||||
return {
|
||||
total: backendStats.total || 0,
|
||||
pending: backendStats.pending || 0,
|
||||
paused: backendStats.paused || 0,
|
||||
approved: backendStats.approved || 0,
|
||||
rejected: backendStats.rejected || 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)
|
||||
// This is for user-level where backend stats might not be available
|
||||
return calculateStatsFromFilteredData(
|
||||
[], // Empty - we'll use backendStats or fallback
|
||||
isOrgLevel,
|
||||
@ -405,6 +418,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
{/* Header */}
|
||||
<RequestsHeader
|
||||
isOrgLevel={isOrgLevel}
|
||||
isAdmin={isAdmin}
|
||||
loading={loading}
|
||||
exporting={exporting}
|
||||
onExport={handleExportToCSV}
|
||||
@ -462,6 +476,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
/**
|
||||
* 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:
|
||||
* - The initiator (created by the user), OR
|
||||
* - A participant (approver/spectator)
|
||||
* Completely separate from AdminAllRequests to avoid interference.
|
||||
*/
|
||||
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { Pagination } from '@/components/common/Pagination';
|
||||
import dashboardService from '@/services/dashboard.service';
|
||||
import type { DateRange } from '@/services/dashboard.service';
|
||||
import userApi from '@/services/userApi';
|
||||
|
||||
// Components
|
||||
@ -30,7 +30,7 @@ import { exportRequestsToCSV } from './utils/csvExports';
|
||||
import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService';
|
||||
|
||||
// Types
|
||||
import type { RequestsProps } from './types/requests.types';
|
||||
import type { RequestsProps, BackendStats } from './types/requests.types';
|
||||
|
||||
// Filter UI components
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@ -52,7 +52,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
const [apiRequests, setApiRequests] = useState<any[]>([]);
|
||||
const [loading, setLoading] = 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 [loadingDepartments, setLoadingDepartments] = useState(false);
|
||||
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
|
||||
@ -60,7 +60,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
// Pagination
|
||||
const [currentPage, setCurrentPage] = 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);
|
||||
|
||||
// User search hooks
|
||||
@ -76,38 +76,54 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
onFilterChange: filters.setApproverFilter
|
||||
});
|
||||
|
||||
// Fetch all requests for stats calculation
|
||||
// Apply all filters EXCEPT status filter - this way:
|
||||
// - Total changes when priority/department/SLA/etc. filters are applied
|
||||
// - Total remains stable when only status filter is applied
|
||||
const fetchAllRequestsForStats = useCallback(async () => {
|
||||
try {
|
||||
// Get current filters directly from the filters object (not ref) to ensure we have latest values
|
||||
const filterOptions = filters.getFilters();
|
||||
|
||||
// Build filters WITHOUT status filter for stats
|
||||
// This ensures total changes with other filters (including SLA) but stays stable with status filter
|
||||
const statsFilters = { ...filterOptions };
|
||||
delete statsFilters.status; // Remove status filter to get all statuses
|
||||
|
||||
// Fetch first page with a larger limit to get more data for stats
|
||||
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([]);
|
||||
// Fetch backend stats using dashboard API
|
||||
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
|
||||
// Stats reflect all filters EXCEPT status - total stays stable when only status changes
|
||||
const fetchBackendStats = useCallback(async (
|
||||
statsDateRange?: DateRange,
|
||||
statsStartDate?: Date,
|
||||
statsEndDate?: Date,
|
||||
filtersWithoutStatus?: {
|
||||
priority?: string;
|
||||
department?: string;
|
||||
initiator?: string;
|
||||
approver?: string;
|
||||
approverType?: 'current' | 'any';
|
||||
search?: string;
|
||||
slaCompliance?: string;
|
||||
}
|
||||
}, [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
|
||||
const fetchDepartments = useCallback(async () => {
|
||||
@ -139,15 +155,15 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
|
||||
// Use refs to store stable callbacks to prevent infinite loops
|
||||
const filtersRef = useRef(filters);
|
||||
const fetchAllRequestsForStatsRef = useRef(fetchAllRequestsForStats);
|
||||
const fetchBackendStatsRef = useRef(fetchBackendStats);
|
||||
|
||||
// Update refs on each render
|
||||
useEffect(() => {
|
||||
filtersRef.current = filters;
|
||||
fetchAllRequestsForStatsRef.current = fetchAllRequestsForStats;
|
||||
}, [filters, fetchAllRequestsForStats]);
|
||||
fetchBackendStatsRef.current = fetchBackendStats;
|
||||
}, [filters, fetchBackendStats]);
|
||||
|
||||
// Fetch requests
|
||||
// Fetch requests - OPTIMIZED: Only fetches 10 records per page
|
||||
const fetchRequests = useCallback(async (page: number = 1) => {
|
||||
try {
|
||||
if (page === 1) {
|
||||
@ -162,13 +178,12 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
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);
|
||||
setTotalPages(result.pagination.totalPages);
|
||||
// Don't update totalRecords here - it should come from stats fetch (without status filter)
|
||||
// setTotalRecords(result.pagination.total); // Commented out - use totalRecordsForStats instead
|
||||
setTotalRecords(result.pagination.total);
|
||||
} catch (error) {
|
||||
setApiRequests([]);
|
||||
} finally {
|
||||
@ -196,11 +211,27 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
fetchUsers();
|
||||
}, [fetchDepartments, fetchUsers]);
|
||||
|
||||
// Fetch stats when filters change (except status filter)
|
||||
// This ensures total changes with other filters but stays stable with status filter
|
||||
// Fetch backend stats when filters change (except status filter)
|
||||
// OPTIMIZED: Uses backend stats API instead of fetching 100 records
|
||||
useEffect(() => {
|
||||
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);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
@ -216,7 +247,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
filters.dateRange,
|
||||
filters.customStartDate,
|
||||
filters.customEndDate
|
||||
// fetchAllRequestsForStats excluded to prevent infinite loops
|
||||
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
|
||||
]);
|
||||
|
||||
@ -254,78 +284,61 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
|
||||
// Transform requests
|
||||
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
|
||||
|
||||
// Transform all requests for stats (without status filter)
|
||||
const allConvertedRequestsForStats = useMemo(() => transformRequests(allRequestsForStats), [allRequestsForStats]);
|
||||
|
||||
// Calculate stats from all fetched data (without status filter)
|
||||
// Calculate stats - Use backend stats API (OPTIMIZED)
|
||||
const stats = useMemo(() => {
|
||||
// For regular users, calculate stats from allRequestsForStats (fetched without status filter)
|
||||
// Use totalRecords for total (from backend), and calculate individual status counts from fetched data
|
||||
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
|
||||
// Use backend stats if available
|
||||
if (backendStats) {
|
||||
return {
|
||||
total: totalRecordsForStats > 0 ? totalRecordsForStats : allConvertedRequestsForStats.length, // Use total from stats fetch (with other filters, without status)
|
||||
pending,
|
||||
approved,
|
||||
rejected,
|
||||
draft: 0, // Drafts are excluded
|
||||
closed
|
||||
};
|
||||
} 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
|
||||
total: backendStats.total || 0,
|
||||
pending: backendStats.pending || 0,
|
||||
paused: backendStats.paused || 0,
|
||||
approved: backendStats.approved || 0,
|
||||
rejected: backendStats.rejected || 0,
|
||||
draft: backendStats.draft || 0,
|
||||
closed: backendStats.closed || 0
|
||||
};
|
||||
}
|
||||
}, [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 (
|
||||
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="user-all-requests-page">
|
||||
{/* Header */}
|
||||
<RequestsHeader
|
||||
isOrgLevel={false}
|
||||
isAdmin={false}
|
||||
loading={loading}
|
||||
exporting={exporting}
|
||||
onExport={handleExportToCSV}
|
||||
@ -383,6 +396,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="paused">Paused</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
@ -673,7 +687,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
|
||||
<Pagination
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecordsForStats}
|
||||
totalRecords={totalRecords}
|
||||
itemsPerPage={itemsPerPage}
|
||||
onPageChange={handlePageChange}
|
||||
loading={loading}
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
import { motion } from 'framer-motion';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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 type { ConvertedRequest } from '../types/requests.types';
|
||||
import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
|
||||
@ -78,6 +78,16 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
|
||||
<StatusIcon className="w-3 h-3 mr-1" />
|
||||
<span className="capitalize">{request.status}</span>
|
||||
</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
|
||||
variant="outline"
|
||||
className={`${priorityConfig.color} border font-medium text-xs capitalize shrink-0`}
|
||||
|
||||
@ -4,10 +4,12 @@
|
||||
|
||||
import { FileText, Download, RefreshCw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageHeader } from '@/components/common/PageHeader';
|
||||
|
||||
interface RequestsHeaderProps {
|
||||
isOrgLevel: boolean;
|
||||
isAdmin: boolean;
|
||||
loading: boolean;
|
||||
exporting: boolean;
|
||||
onExport: () => void;
|
||||
@ -15,20 +17,48 @@ interface RequestsHeaderProps {
|
||||
|
||||
export function RequestsHeader({
|
||||
isOrgLevel,
|
||||
isAdmin,
|
||||
loading,
|
||||
exporting,
|
||||
onExport
|
||||
}: 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 (
|
||||
<div className="flex items-start justify-between gap-4" data-testid="requests-header-container">
|
||||
<PageHeader
|
||||
icon={FileText}
|
||||
title={isOrgLevel ? "All Requests (Organization)" : "All Requests"}
|
||||
description={isOrgLevel
|
||||
? "View and filter all organization-wide workflow requests with advanced filtering options"
|
||||
: "View and filter your workflow requests with advanced filtering options"}
|
||||
testId="requests-header"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<PageHeader
|
||||
icon={FileText}
|
||||
title={getTitle()}
|
||||
description={getDescription()}
|
||||
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
|
||||
onClick={onExport}
|
||||
disabled={exporting || loading}
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* 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 type { RequestStats } from '../types/requests.types';
|
||||
|
||||
@ -20,7 +20,7 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 md:grid-cols-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
|
||||
label="Total"
|
||||
value={stats.total}
|
||||
@ -45,6 +45,18 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
|
||||
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
|
||||
label="Approved"
|
||||
value={stats.approved}
|
||||
|
||||
@ -82,7 +82,10 @@ export async function fetchRequestsData({
|
||||
};
|
||||
} else {
|
||||
// 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 = {};
|
||||
if (filters?.search) backendFilters.search = filters.search;
|
||||
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?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
|
||||
|
||||
// Fetch paginated data using SEPARATE endpoint for regular users
|
||||
// This endpoint excludes initiator requests automatically
|
||||
// Fetch paginated data using endpoint for regular users
|
||||
// This endpoint includes all requests where user is initiator, approver, or participant
|
||||
const pageResult = await workflowApi.listParticipantRequests({
|
||||
page,
|
||||
limit: itemsPerPage,
|
||||
@ -143,7 +146,7 @@ export async function fetchAllRequestsForExport(isOrgLevel: boolean): Promise<an
|
||||
while (hasMore && currentPageNum <= maxPages) {
|
||||
const pageResult = isOrgLevel
|
||||
? 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[] = [];
|
||||
if (Array.isArray(pageResult?.data)) {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 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
|
||||
* Shows requests where user is EITHER initiator OR participant (approver/spectator)
|
||||
@ -9,8 +9,6 @@
|
||||
import workflowApi from '@/services/workflowApi';
|
||||
import type { RequestFilters } from '../types/requests.types';
|
||||
|
||||
const EXPORT_FETCH_LIMIT = 100;
|
||||
|
||||
interface FetchUserAllRequestsOptions {
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
@ -19,7 +17,8 @@ interface FetchUserAllRequestsOptions {
|
||||
|
||||
/**
|
||||
* 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({
|
||||
page,
|
||||
@ -35,134 +34,66 @@ export async function fetchUserParticipantRequestsData({
|
||||
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
|
||||
if (filters?.approver && filters.approver !== 'all') {
|
||||
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?.dateRange) backendFilters.dateRange = filters.dateRange;
|
||||
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
|
||||
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
||||
|
||||
// To properly merge and paginate, we need to fetch enough data from both endpoints
|
||||
// Fetch multiple pages from each endpoint to ensure we have enough data to merge and paginate correctly
|
||||
const fetchLimit = Math.max(itemsPerPage * 3, 100); // Fetch at least 3 pages worth or 100 items, whichever is larger
|
||||
|
||||
// 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 = {
|
||||
// Use single optimized endpoint - listParticipantRequests now includes initiator requests
|
||||
// Only fetch the requested page (10 records) for optimal performance
|
||||
const result = await workflowApi.listParticipantRequests({
|
||||
page,
|
||||
limit: itemsPerPage,
|
||||
total: actualTotal,
|
||||
totalPages: Math.ceil(actualTotal / itemsPerPage) || 1
|
||||
...backendFilters
|
||||
});
|
||||
|
||||
// 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 {
|
||||
data: paginatedData, // Paginated merged data for list display
|
||||
allData: [], // Stats calculated from data
|
||||
filteredData: paginatedData, // Same as data for list
|
||||
pagination: pagination
|
||||
data: nonDraftData,
|
||||
allData: [],
|
||||
filteredData: nonDraftData,
|
||||
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)
|
||||
* 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[]> {
|
||||
const allInitiatorPages: any[] = [];
|
||||
const allParticipantPages: any[] = [];
|
||||
let hasMoreInitiator = true;
|
||||
let hasMoreParticipant = true;
|
||||
const allPages: any[] = [];
|
||||
let hasMore = true;
|
||||
let currentPage = 1;
|
||||
const maxPages = 100; // Safety limit
|
||||
|
||||
// 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?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
|
||||
|
||||
// Fetch initiator requests
|
||||
const initiatorFetch = async () => {
|
||||
let page = 1;
|
||||
while (hasMoreInitiator && page <= maxPages) {
|
||||
const pageResult = await workflowApi.listMyInitiatedWorkflows({
|
||||
page,
|
||||
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
|
||||
});
|
||||
// Fetch all pages using the single optimized endpoint
|
||||
while (hasMore && currentPage <= maxPages) {
|
||||
const pageResult = await workflowApi.listParticipantRequests({
|
||||
page: currentPage,
|
||||
limit: EXPORT_FETCH_LIMIT,
|
||||
...backendFilters
|
||||
});
|
||||
|
||||
const pageData = pageResult?.data || [];
|
||||
if (pageData.length === 0) {
|
||||
hasMoreInitiator = false;
|
||||
let pageData: any[] = [];
|
||||
if (Array.isArray(pageResult?.data)) {
|
||||
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 {
|
||||
allInitiatorPages.push(...pageData);
|
||||
page++;
|
||||
if (pageData.length < EXPORT_FETCH_LIMIT) {
|
||||
hasMoreInitiator = false;
|
||||
}
|
||||
hasMore = pageData.length === EXPORT_FETCH_LIMIT;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Fetch participant requests
|
||||
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;
|
||||
return allPages;
|
||||
}
|
||||
|
||||
|
||||
@ -25,6 +25,7 @@ export interface RequestFilters {
|
||||
export interface RequestStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
paused: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
draft: number;
|
||||
@ -34,6 +35,7 @@ export interface RequestStats {
|
||||
export interface BackendStats {
|
||||
total: number;
|
||||
pending: number;
|
||||
paused: number;
|
||||
approved: number;
|
||||
rejected: number;
|
||||
draft: number;
|
||||
|
||||
@ -41,9 +41,15 @@ export function calculateStatsFromFilteredData(
|
||||
return status === 'CLOSED';
|
||||
}).length;
|
||||
|
||||
const paused = allFilteredRequests.filter((r: any) => {
|
||||
const status = (r.status || '').toString().toUpperCase();
|
||||
return status === 'PAUSED';
|
||||
}).length;
|
||||
|
||||
return {
|
||||
total: total, // Total based on other filters (priority, department, etc.)
|
||||
pending,
|
||||
paused,
|
||||
approved,
|
||||
rejected,
|
||||
draft,
|
||||
@ -54,6 +60,7 @@ export function calculateStatsFromFilteredData(
|
||||
return {
|
||||
total: backendStats.total,
|
||||
pending: backendStats.pending,
|
||||
paused: backendStats.paused || 0,
|
||||
approved: backendStats.approved,
|
||||
rejected: backendStats.rejected,
|
||||
draft: backendStats.draft,
|
||||
@ -67,6 +74,7 @@ export function calculateStatsFromFilteredData(
|
||||
return {
|
||||
total: total,
|
||||
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,
|
||||
rejected: convertedRequests.filter(r => r.status === 'rejected').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') {
|
||||
filteredData = filteredData.filter((req: any) => {
|
||||
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 {
|
||||
const statusUpper = filters.status.toUpperCase().replace('-', '_');
|
||||
filteredData = filteredData.filter((req: any) => {
|
||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||
const isPaused = req.pauseInfo?.isPaused || req.isPaused || false;
|
||||
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 authSlice from './slices/authSlice';
|
||||
import dashboardSlice from '../pages/Dashboard/redux/dashboardSlice';
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: authSlice.reducer,
|
||||
dashboard: dashboardSlice.reducer,
|
||||
},
|
||||
middleware: (getDefaultMiddleware) =>
|
||||
getDefaultMiddleware({
|
||||
|
||||
@ -18,12 +18,23 @@ const apiClient: AxiosInstance = axios.create({
|
||||
});
|
||||
|
||||
// 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(
|
||||
(config) => {
|
||||
const token = TokenManager.getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
// In production, cookies are sent automatically with withCredentials: true
|
||||
// No need to set Authorization header
|
||||
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;
|
||||
},
|
||||
(error) => {
|
||||
@ -56,26 +67,40 @@ apiClient.interceptors.response.use(
|
||||
// If error is 401 and we haven't retried yet
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||
|
||||
try {
|
||||
// Attempt to refresh token
|
||||
// In production: Cookie is sent automatically via withCredentials
|
||||
// In development: Send refresh token from localStorage
|
||||
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');
|
||||
}
|
||||
|
||||
const response = await axios.post(
|
||||
`${API_BASE_URL}/auth/refresh`,
|
||||
{ refreshToken },
|
||||
isProduction ? {} : { refreshToken }, // Empty body in production, cookie is used
|
||||
{ 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) {
|
||||
TokenManager.setAccessToken(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) {
|
||||
// Refresh failed, clear tokens and redirect to login
|
||||
TokenManager.clearAll();
|
||||
@ -145,18 +170,25 @@ export async function exchangeCodeForTokens(
|
||||
const data = response.data as any;
|
||||
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) {
|
||||
// Development mode: Backend returned tokens, store them
|
||||
TokenManager.setAccessToken(result.accessToken);
|
||||
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;
|
||||
} catch (error: any) {
|
||||
|
||||
@ -6,6 +6,7 @@ export interface RequestStats {
|
||||
approvedRequests: number;
|
||||
rejectedRequests: number;
|
||||
closedRequests: number;
|
||||
pausedRequests?: number;
|
||||
draftRequests: number;
|
||||
changeFromPrevious: {
|
||||
total: string;
|
||||
@ -195,7 +196,8 @@ class DashboardService {
|
||||
approver?: string,
|
||||
approverType?: 'current' | 'any',
|
||||
search?: string,
|
||||
slaCompliance?: string
|
||||
slaCompliance?: string,
|
||||
viewAsUser?: boolean
|
||||
): Promise<RequestStats> {
|
||||
try {
|
||||
const params: any = { dateRange };
|
||||
@ -228,6 +230,10 @@ class DashboardService {
|
||||
if (slaCompliance && slaCompliance !== 'all') {
|
||||
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 });
|
||||
return response.data.data;
|
||||
} catch (error) {
|
||||
|
||||
@ -324,6 +324,35 @@ export async function addSpectator(requestId: string, email: string) {
|
||||
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 {
|
||||
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
||||
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> {
|
||||
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 isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||
|
||||
try {
|
||||
const response = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
// Build fetch options
|
||||
const fetchOptions: RequestInit = {
|
||||
credentials: 'include', // Send cookies in production
|
||||
};
|
||||
|
||||
// In development, add Authorization header from localStorage
|
||||
if (!isProduction) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
fetchOptions.headers = {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
@ -373,17 +410,25 @@ export async function downloadDocument(documentId: string): Promise<void> {
|
||||
}
|
||||
|
||||
export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> {
|
||||
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
||||
const token = localStorage.getItem('accessToken');
|
||||
|
||||
const downloadUrl = `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
|
||||
const downloadBaseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
|
||||
const downloadUrl = `${downloadBaseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
|
||||
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
|
||||
|
||||
try {
|
||||
const response = await fetch(downloadUrl, {
|
||||
headers: {
|
||||
// Build fetch options
|
||||
const fetchOptions: RequestInit = {
|
||||
credentials: 'include', // Send cookies in production
|
||||
};
|
||||
|
||||
// In development, add Authorization header from localStorage
|
||||
if (!isProduction) {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
fetchOptions.headers = {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
const response = await fetch(downloadUrl, fetchOptions);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
// Type definitions for Royal Enfield Approval Portal
|
||||
|
||||
export type Priority = 'express' | 'urgent' | 'standard';
|
||||
export type Status = 'pending' | 'in-review' | 'approved' | 'rejected' | 'cancelled';
|
||||
export type ApprovalStatus = 'pending' | 'waiting' | 'approved' | 'rejected' | 'cancelled';
|
||||
export type Status = 'pending' | 'in-review' | 'approved' | 'rejected' | 'cancelled' | 'paused';
|
||||
export type ApprovalStatus = 'pending' | 'waiting' | 'approved' | 'rejected' | 'cancelled' | 'paused';
|
||||
|
||||
export interface Initiator {
|
||||
name: string;
|
||||
@ -56,6 +56,23 @@ export interface AuditTrailItem {
|
||||
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 {
|
||||
id: string;
|
||||
title: string;
|
||||
@ -81,6 +98,7 @@ export interface BaseRequest {
|
||||
spectators: Spectator[];
|
||||
auditTrail: AuditTrailItem[];
|
||||
tags: string[];
|
||||
pauseInfo?: PauseInfo;
|
||||
}
|
||||
|
||||
export interface CustomRequest extends BaseRequest {
|
||||
|
||||
@ -8,6 +8,11 @@ const REFRESH_TOKEN_KEY = 'refreshToken';
|
||||
const ID_TOKEN_KEY = 'idToken';
|
||||
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
|
||||
*/
|
||||
@ -72,84 +77,97 @@ export const cookieUtils = {
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
/**
|
||||
* 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 {
|
||||
if (this.isLocalhost()) {
|
||||
// Store in cookie for localhost (backend sets httpOnly cookie, but we also store here for client-side access)
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
cookieUtils.set(ACCESS_TOKEN_KEY, token, 1); // 1 day
|
||||
} else {
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
// SECURITY: In production, don't store tokens client-side
|
||||
// Backend sets httpOnly cookies that are sent automatically
|
||||
if (isProduction()) {
|
||||
return; // No-op - rely on httpOnly cookies
|
||||
}
|
||||
|
||||
// Development only: Store for debugging and cross-port requests
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token
|
||||
* In production: Returns null (cookies are sent automatically)
|
||||
* In development: Returns from localStorage
|
||||
*/
|
||||
static getAccessToken(): string | null {
|
||||
if (this.isLocalhost()) {
|
||||
// Try cookie first (set by backend), then localStorage
|
||||
return cookieUtils.get(ACCESS_TOKEN_KEY) || localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
// SECURITY: In production, return null - cookies are used instead
|
||||
if (isProduction()) {
|
||||
return null; // API calls use cookies via withCredentials: true
|
||||
}
|
||||
|
||||
// Development: Return from localStorage
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store refresh token
|
||||
* In production: No-op (backend handles via httpOnly cookies)
|
||||
* In development: Store in localStorage
|
||||
*/
|
||||
static setRefreshToken(token: string): void {
|
||||
if (this.isLocalhost()) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||
cookieUtils.set(REFRESH_TOKEN_KEY, token, 7); // 7 days
|
||||
} else {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||
// SECURITY: In production, don't store tokens client-side
|
||||
if (isProduction()) {
|
||||
return; // No-op - rely on httpOnly cookies
|
||||
}
|
||||
|
||||
// Development only
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get refresh token
|
||||
* In production: Returns null (cookies are used)
|
||||
* In development: Returns from localStorage
|
||||
*/
|
||||
static getRefreshToken(): string | null {
|
||||
if (this.isLocalhost()) {
|
||||
// Try cookie first (set by backend), then localStorage
|
||||
return cookieUtils.get(REFRESH_TOKEN_KEY) || localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
// SECURITY: In production, return null - backend reads from cookie
|
||||
if (isProduction()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
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 {
|
||||
localStorage.setItem(ID_TOKEN_KEY, token);
|
||||
if (this.isLocalhost()) {
|
||||
cookieUtils.set(ID_TOKEN_KEY, token, 1); // 1 day
|
||||
}
|
||||
// ID token is needed for Okta logout, use sessionStorage (more secure than localStorage)
|
||||
sessionStorage.setItem(ID_TOKEN_KEY, token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get ID token
|
||||
*/
|
||||
static getIdToken(): string | null {
|
||||
if (this.isLocalhost()) {
|
||||
// Try cookie first, then localStorage
|
||||
return cookieUtils.get(ID_TOKEN_KEY) || localStorage.getItem(ID_TOKEN_KEY);
|
||||
}
|
||||
return localStorage.getItem(ID_TOKEN_KEY);
|
||||
return sessionStorage.getItem(ID_TOKEN_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Store user data
|
||||
* Store user data (not sensitive - can be stored in localStorage)
|
||||
*/
|
||||
static setUserData(user: any): void {
|
||||
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
|
||||
* This includes localStorage, sessionStorage, cookies, and any auth-related data
|
||||
* Uses aggressive clearing to ensure ALL data is removed
|
||||
* IMPORTANT: This also sets a flag to prevent auto-authentication
|
||||
*
|
||||
* PRODUCTION MODE:
|
||||
* - 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 {
|
||||
// CRITICAL: Set logout flag in sessionStorage FIRST (before clearing)
|
||||
@ -181,7 +205,28 @@ export class TokenManager {
|
||||
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 = [
|
||||
ACCESS_TOKEN_KEY,
|
||||
REFRESH_TOKEN_KEY,
|
||||
@ -194,11 +239,7 @@ export class TokenManager {
|
||||
'access_token',
|
||||
'refresh_token',
|
||||
'id_token',
|
||||
'idToken',
|
||||
'token',
|
||||
'accessToken',
|
||||
'refreshToken',
|
||||
'userData',
|
||||
'auth',
|
||||
'authentication',
|
||||
'persist:root',
|
||||
@ -215,131 +256,55 @@ export class TokenManager {
|
||||
}
|
||||
});
|
||||
|
||||
// Step 2: Clear ALL localStorage items by iterating and removing
|
||||
// Clear ALL localStorage
|
||||
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();
|
||||
} catch (e) {
|
||||
console.error('Error clearing localStorage:', e);
|
||||
}
|
||||
|
||||
// Step 3: Clear ALL sessionStorage items
|
||||
// Clear ALL sessionStorage except logout flags
|
||||
try {
|
||||
const allSessionStorageKeys: string[] = [];
|
||||
// Get all keys
|
||||
const keysToKeep = ['__logout_in_progress__', '__force_logout__'];
|
||||
const allKeys: string[] = [];
|
||||
for (let i = 0; i < sessionStorage.length; i++) {
|
||||
const key = sessionStorage.key(i);
|
||||
if (key) {
|
||||
allSessionStorageKeys.push(key);
|
||||
if (key && !keysToKeep.includes(key)) {
|
||||
allKeys.push(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();
|
||||
allKeys.forEach(key => sessionStorage.removeItem(key));
|
||||
} catch (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();
|
||||
|
||||
// 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
|
||||
* In production: Always returns true if user data exists (tokens are in httpOnly cookies)
|
||||
* In development: Checks localStorage
|
||||
*/
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if refresh token exists
|
||||
* In production: Always returns true if user data exists
|
||||
* In development: Checks localStorage
|
||||
*/
|
||||
static hasRefreshToken(): boolean {
|
||||
if (isProduction()) {
|
||||
return !!this.getUserData();
|
||||
}
|
||||
return !!this.getRefreshToken();
|
||||
}
|
||||
|
||||
@ -353,6 +318,13 @@ export class TokenManager {
|
||||
window.location.hostname === ''
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if we're in production mode
|
||||
*/
|
||||
static isProduction(): boolean {
|
||||
return isProduction();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user