pause feature added and also all request as normal user for admin

This commit is contained in:
laxmanhalaki 2025-11-27 18:36:55 +05:30
parent 2161cc59ca
commit fe441c8b76
36 changed files with 1334 additions and 593 deletions

View File

@ -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>

View 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>
);
}

View 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>
);
}

View File

@ -117,13 +117,26 @@ 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
// 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';
// 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 {
// Development: If no auth data exists, user is not authenticated
if (!hasAuthData) {
setIsAuthenticated(false);
setUser(null);
@ -137,13 +150,26 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
} else {
setIsLoading(false);
}
}
}, [isLoggingOut]);
// Silent refresh interval
useEffect(() => {
if (!isAuthenticated) return;
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
const checkAndRefresh = async () => {
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
@ -153,10 +179,12 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
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();

View File

@ -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;
});

View File

@ -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>
<Provider store={store}>
<AuthProvider>
<AuthenticatedApp />
</AuthProvider>
</Provider>
</React.StrictMode>
);

View File

@ -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,16 +38,22 @@ 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();
const { dateRange, customStartDate, customEndDate, showCustomDatePicker, handleDateRangeChange, handleApplyCustomDate, resetCustomDates, setCustomStartDate, setCustomEndDate, setShowCustomDatePicker } = filters;
@ -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}

View File

@ -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'}

View File

@ -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 */}

View 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;

View File

@ -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,

View File

@ -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>

View File

@ -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}

View File

@ -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`}

View File

@ -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,

View File

@ -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;

View File

@ -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}

View File

@ -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>

View File

@ -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>

View File

@ -89,6 +89,11 @@ export function WorkflowTab({ request, user, isInitiator, onSkipApprover, onRefr
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
key={index}
@ -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"

View File

@ -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,8 +306,8 @@ 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,
@ -319,7 +331,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
}, filters.searchTerm ? 500 : 0);
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>

View File

@ -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 () => {
// 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;
}
) => {
try {
// Get current filters directly from the filters object (not ref) to ensure we have latest values
const filterOptions = filters.getFilters();
// 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
);
// 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.)
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
});
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([]);
console.error('Failed to fetch backend stats:', error);
// Keep previous stats on error
}
}, [filters]);
}, []);
// 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
]);
@ -255,48 +285,30 @@ 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
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
};
} else {
// Fallback: calculate from convertedRequests (current page only) - less accurate
}
// 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';
@ -311,21 +323,22 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
}).length;
return {
total: totalRecordsForStats > 0 ? totalRecordsForStats : convertedRequests.length, // Use total from stats fetch if available
total: totalRecords > 0 ? totalRecords : convertedRequests.length,
pending,
paused,
approved,
rejected,
draft: 0,
closed
};
}
}, [totalRecordsForStats, allConvertedRequestsForStats, convertedRequests]);
}, [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}

View File

@ -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`}

View File

@ -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">
<div className="flex items-center gap-3">
<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"}
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}

View File

@ -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}

View File

@ -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)) {

View File

@ -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
});
const pageData = pageResult?.data || [];
if (pageData.length === 0) {
hasMoreInitiator = false;
} else {
allInitiatorPages.push(...pageData);
page++;
if (pageData.length < EXPORT_FETCH_LIMIT) {
hasMoreInitiator = false;
}
}
}
};
// Fetch participant requests
const participantFetch = async () => {
let page = 1;
while (hasMoreParticipant && page <= maxPages) {
// Fetch all pages using the single optimized endpoint
while (hasMore && currentPage <= maxPages) {
const pageResult = await workflowApi.listParticipantRequests({
page,
page: currentPage,
limit: EXPORT_FETCH_LIMIT,
...backendFilters
});
const pageData = pageResult?.data || [];
let pageData: any[] = [];
if (Array.isArray(pageResult?.data)) {
pageData = pageResult.data;
} else if (Array.isArray(pageResult)) {
pageData = pageResult;
}
if (pageData.length === 0) {
hasMoreParticipant = false;
hasMore = 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 nonDraftData = pageData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus !== 'DRAFT';
});
allPages.push(...nonDraftData);
currentPage++;
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);
if (pageResult?.pagination) {
hasMore = currentPage <= pageResult.pagination.totalPages;
} else {
hasMore = pageData.length === EXPORT_FETCH_LIMIT;
}
}
});
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;
}

View File

@ -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;

View File

@ -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,

View File

@ -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
});
}
}

View File

@ -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({

View File

@ -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) => {
// 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) => {
@ -57,25 +68,39 @@ apiClient.interceptors.response.use(
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
if (result.accessToken && result.refreshToken) {
TokenManager.setAccessToken(result.accessToken);
TokenManager.setRefreshToken(result.refreshToken);
// Store user data (always available)
if (result.user) {
TokenManager.setUserData(result.user);
// Store id_token if available (needed for proper Okta logout)
}
// Store ID token if available (needed for Okta logout)
if (result.idToken) {
TokenManager.setIdToken(result.idToken);
}
} else {
console.warn('⚠️ Tokens missing in response', { result });
// 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);
}
// 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) {

View File

@ -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) {

View File

@ -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();

View File

@ -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 {

View File

@ -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();
}
}
/**