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 approval?: any; // Raw approval data from backend
isCurrentUser?: boolean; isCurrentUser?: boolean;
isInitiator?: boolean; isInitiator?: boolean;
isCurrentLevel?: boolean; // Whether this step is the current active level
onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void; onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void;
onRefresh?: () => void | Promise<void>; // Optional callback to refresh data onRefresh?: () => void | Promise<void>; // Optional callback to refresh data
testId?: string; testId?: string;
@ -66,6 +67,8 @@ const getStepIcon = (status: string, isSkipped?: boolean) => {
return <CheckCircle className="w-4 h-4 sm:w-5 sm:w-5 text-green-600" />; return <CheckCircle className="w-4 h-4 sm:w-5 sm:w-5 text-green-600" />;
case 'rejected': case 'rejected':
return <XCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600" />; return <XCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600" />;
case 'paused':
return <PauseCircle className="w-4 h-4 sm:w-5 sm:h-5 text-yellow-600" />;
case 'pending': case 'pending':
case 'in-review': case 'in-review':
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />; return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />;
@ -82,6 +85,7 @@ export function ApprovalStepCard({
approval, approval,
isCurrentUser = false, isCurrentUser = false,
isInitiator = false, isInitiator = false,
isCurrentLevel = false,
onSkipApprover, onSkipApprover,
onRefresh, onRefresh,
testId = 'approval-step' testId = 'approval-step'
@ -105,6 +109,7 @@ export function ApprovalStepCard({
const isCompleted = step.status === 'approved'; const isCompleted = step.status === 'approved';
const isRejected = step.status === 'rejected'; const isRejected = step.status === 'rejected';
const isWaiting = step.status === 'waiting'; const isWaiting = step.status === 'waiting';
const isPaused = step.status === 'paused';
const tatHours = Number(step.tatHours || 0); const tatHours = Number(step.tatHours || 0);
const actualHours = step.actualHours ?? 0; const actualHours = step.actualHours ?? 0;
@ -180,6 +185,7 @@ export function ApprovalStepCard({
<div className="flex items-start gap-2 sm:gap-3 md:gap-4"> <div className="flex items-start gap-2 sm:gap-3 md:gap-4">
<div className={`p-2 sm:p-2.5 md:p-3 rounded-xl flex-shrink-0 ${ <div className={`p-2 sm:p-2.5 md:p-3 rounded-xl flex-shrink-0 ${
step.isSkipped ? 'bg-orange-100' : step.isSkipped ? 'bg-orange-100' :
isPaused ? 'bg-yellow-100' :
isActive ? 'bg-blue-100' : isActive ? 'bg-blue-100' :
isCompleted ? 'bg-green-100' : isCompleted ? 'bg-green-100' :
isRejected ? 'bg-red-100' : isRejected ? 'bg-red-100' :
@ -333,8 +339,9 @@ export function ApprovalStepCard({
</div> </div>
)} )}
{/* Active Approver - Show Real-time Progress from Backend */} {/* Active Approver (including paused) - Show Real-time Progress from Backend */}
{isActive && approval?.sla && ( {/* Only show SLA for the current level step, not future levels */}
{isCurrentLevel && (isActive || isPaused) && approval?.sla && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-gray-600">Due by:</span> <span className="text-gray-600">Due by:</span>

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,25 +117,39 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return; return;
} }
// PRIORITY 3: Check if this is a logout redirect by checking if all tokens are cleared // PRIORITY 3: Check authentication status
const token = TokenManager.getAccessToken(); const token = TokenManager.getAccessToken();
const refreshToken = TokenManager.getRefreshToken(); const refreshToken = TokenManager.getRefreshToken();
const userData = TokenManager.getUserData(); const userData = TokenManager.getUserData();
const hasAuthData = token || refreshToken || userData; const hasAuthData = token || refreshToken || userData;
// If no auth data exists, we're likely after a logout - set unauthenticated state immediately // Check if we're in production mode (tokens in httpOnly cookies, not accessible to JS)
if (!hasAuthData) { const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
return;
}
// PRIORITY 4: Only check auth status if we have some auth data AND we're not logging out // In production: Always verify with server (cookies are sent automatically)
if (!isLoggingOut) { // In development: Check local auth data first
checkAuthStatus(); if (isProductionMode) {
// Production: Verify session with server via httpOnly cookie
if (!isLoggingOut) {
checkAuthStatus();
} else {
setIsLoading(false);
}
} else { } else {
setIsLoading(false); // Development: If no auth data exists, user is not authenticated
if (!hasAuthData) {
setIsAuthenticated(false);
setUser(null);
setIsLoading(false);
return;
}
// PRIORITY 4: Only check auth status if we have some auth data AND we're not logging out
if (!isLoggingOut) {
checkAuthStatus();
} else {
setIsLoading(false);
}
} }
}, [isLoggingOut]); }, [isLoggingOut]);
@ -143,20 +157,34 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
useEffect(() => { useEffect(() => {
if (!isAuthenticated) return; if (!isAuthenticated) return;
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
const checkAndRefresh = async () => { const checkAndRefresh = async () => {
const token = TokenManager.getAccessToken(); if (isProductionMode) {
if (token && isTokenExpired(token, 5)) { // In production, proactively refresh the session every 10 minutes
// Token expires in less than 5 minutes, refresh it // The httpOnly cookie will be sent automatically
try { try {
await refreshTokenSilently(); await refreshTokenSilently();
} catch (error) { } catch (error) {
console.error('Silent refresh failed:', error); console.error('Silent refresh failed:', error);
} }
} else {
// In development, check token expiration
const token = TokenManager.getAccessToken();
if (token && isTokenExpired(token, 5)) {
// Token expires in less than 5 minutes, refresh it
try {
await refreshTokenSilently();
} catch (error) {
console.error('Silent refresh failed:', error);
}
}
} }
}; };
// Check every 5 minutes // Check every 10 minutes in production, 5 minutes in development
const interval = setInterval(checkAndRefresh, 5 * 60 * 1000); const intervalMs = isProductionMode ? 10 * 60 * 1000 : 5 * 60 * 1000;
const interval = setInterval(checkAndRefresh, intervalMs);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [isAuthenticated]); }, [isAuthenticated]);
@ -232,9 +260,58 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return; return;
} }
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try { try {
setIsLoading(true); setIsLoading(true);
// PRODUCTION MODE: Verify session via httpOnly cookie
// The cookie is sent automatically with the request (withCredentials: true)
if (isProductionMode) {
const storedUser = TokenManager.getUserData();
// Try to get current user from server - this validates the httpOnly cookie
try {
const userData = await getCurrentUser();
setUser(userData);
TokenManager.setUserData(userData);
setIsAuthenticated(true);
} catch (error: any) {
// If 401, try to refresh the token (refresh token is also in httpOnly cookie)
if (error?.response?.status === 401) {
try {
await refreshTokenSilently();
// Retry getting user after refresh
const userData = await getCurrentUser();
setUser(userData);
TokenManager.setUserData(userData);
setIsAuthenticated(true);
} catch {
// Refresh failed - clear user data and show login
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
}
} else if (error?.isConnectionError) {
// Backend not reachable - use stored user data if available
if (storedUser) {
setUser(storedUser);
setIsAuthenticated(true);
} else {
setIsAuthenticated(false);
setUser(null);
}
} else {
// Other error - clear and show login
TokenManager.clearAll();
setIsAuthenticated(false);
setUser(null);
}
}
return;
}
// DEVELOPMENT MODE: Check local token
const token = TokenManager.getAccessToken(); const token = TokenManager.getAccessToken();
const storedUser = TokenManager.getUserData(); const storedUser = TokenManager.getUserData();

View File

@ -262,7 +262,8 @@ export function useRequestDetails(
const approverEmail = (a.approverEmail || '').toLowerCase(); const approverEmail = (a.approverEmail || '').toLowerCase();
const approvalLevelNumber = a.levelNumber || 0; const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level // Only show buttons if user is assigned to the CURRENT active level
return (st === 'PENDING' || st === 'IN_PROGRESS') // Include PAUSED status - paused level is still the current level
return (st === 'PENDING' || st === 'IN_PROGRESS' || st === 'PAUSED')
&& approverEmail === userEmail && approverEmail === userEmail
&& approvalLevelNumber === currentLevel; && approvalLevelNumber === currentLevel;
}); });
@ -325,10 +326,13 @@ export function useRequestDetails(
let displayStatus = statusMap(a.status); let displayStatus = statusMap(a.status);
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') { // If paused, show paused status (don't change it)
if (levelStatus === 'PAUSED') {
displayStatus = 'paused';
} else if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
displayStatus = 'waiting'; displayStatus = 'waiting';
} else if (levelNumber === currentLevel && levelStatus === 'PENDING') { } else if (levelNumber === currentLevel && (levelStatus === 'PENDING' || levelStatus === 'IN_PROGRESS')) {
displayStatus = 'pending'; displayStatus = levelStatus === 'IN_PROGRESS' ? 'in-review' : 'pending';
} }
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId); const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
@ -398,6 +402,16 @@ export function useRequestDetails(
}) })
: []; : [];
// Fetch pause details
let pauseInfo = null;
try {
const { getPauseDetails } = await import('@/services/workflowApi');
pauseInfo = await getPauseDetails(wf.requestId);
} catch (error) {
// Pause info not available or request not paused - ignore
console.debug('Pause details not available:', error);
}
// Build complete request object // Build complete request object
const mapped = { const mapped = {
id: wf.requestNumber || wf.requestId, id: wf.requestNumber || wf.requestId,
@ -428,19 +442,22 @@ export function useRequestDetails(
auditTrail: filteredActivities, auditTrail: filteredActivities,
conclusionRemark: wf.conclusionRemark || null, conclusionRemark: wf.conclusionRemark || null,
closureDate: wf.closureDate || null, closureDate: wf.closureDate || null,
pauseInfo: pauseInfo || null,
}; };
setApiRequest(mapped); setApiRequest(mapped);
// Find current user's approval level // Find current user's approval level
// Only show approve/reject buttons if user is the CURRENT active approver // Only show approve/reject buttons if user is the CURRENT active approver
// Include PAUSED status - when paused, the paused level is still the current level
const userEmail = (user as any)?.email?.toLowerCase(); const userEmail = (user as any)?.email?.toLowerCase();
const userCurrentLevel = approvals.find((a: any) => { const userCurrentLevel = approvals.find((a: any) => {
const status = (a.status || '').toString().toUpperCase(); const status = (a.status || '').toString().toUpperCase();
const approverEmail = (a.approverEmail || '').toLowerCase(); const approverEmail = (a.approverEmail || '').toLowerCase();
const approvalLevelNumber = a.levelNumber || 0; const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level // Only show buttons if user is assigned to the CURRENT active level
return (status === 'PENDING' || status === 'IN_PROGRESS') // Include PAUSED status - paused level is still the current level
return (status === 'PENDING' || status === 'IN_PROGRESS' || status === 'PAUSED')
&& approverEmail === userEmail && approverEmail === userEmail
&& approvalLevelNumber === currentLevel; && approvalLevelNumber === currentLevel;
}); });

View File

@ -1,14 +1,18 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import { AuthProvider } from './contexts/AuthContext'; import { AuthProvider } from './contexts/AuthContext';
import { AuthenticatedApp } from './pages/Auth'; import { AuthenticatedApp } from './pages/Auth';
import { store } from './redux/store';
import './styles/globals.css'; import './styles/globals.css';
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<AuthProvider> <Provider store={store}>
<AuthenticatedApp /> <AuthProvider>
</AuthProvider> <AuthenticatedApp />
</AuthProvider>
</Provider>
</React.StrictMode> </React.StrictMode>
); );

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 { RefreshCw } from 'lucide-react';
import { type DateRange } from '@/services/dashboard.service'; import { type DateRange } from '@/services/dashboard.service';
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext'; import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import { useAppSelector, useAppDispatch } from '@/redux/hooks';
import { setViewAsUser } from './redux/dashboardSlice';
// Custom Hooks // Custom Hooks
import { useDashboardFilters } from './hooks/useDashboardFilters'; import { useDashboardFilters } from './hooks/useDashboardFilters';
@ -36,15 +38,21 @@ interface DashboardProps {
export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
const { user } = useAuth(); const { user } = useAuth();
const dispatch = useAppDispatch();
// Get viewAsUser from Redux store
const viewAsUser = useAppSelector((state) => state.dashboard.viewAsUser);
// Determine user role // Determine user role
const isAdmin = useMemo(() => hasManagementAccess(user), [user]); const isAdmin = useMemo(() => hasManagementAccess(user), [user]);
// Toggle for admin to switch between admin view and personal view
const [viewAsUser, setViewAsUser] = useState(false);
// Effective view mode: if admin and viewAsUser is true, show as normal user // Effective view mode: if admin and viewAsUser is true, show as normal user
const effectiveIsAdmin = isAdmin && !viewAsUser; const effectiveIsAdmin = isAdmin && !viewAsUser;
// Handler to toggle view
const handleToggleView = useCallback((value: boolean) => {
dispatch(setViewAsUser(value));
}, [dispatch]);
// Filters // Filters
const filters = useDashboardFilters(); const filters = useDashboardFilters();
@ -188,7 +196,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
isAdmin={isAdmin} isAdmin={isAdmin}
effectiveIsAdmin={effectiveIsAdmin} effectiveIsAdmin={effectiveIsAdmin}
viewAsUser={viewAsUser} viewAsUser={viewAsUser}
onToggleView={setViewAsUser} onToggleView={handleToggleView}
quickActions={quickActions} quickActions={quickActions}
userDisplayName={(user as any)?.displayName} userDisplayName={(user as any)?.displayName}
userEmail={(user as any)?.email} userEmail={(user as any)?.email}

View File

@ -23,14 +23,6 @@ interface DashboardHeroProps {
export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleView, quickActions, userDisplayName, userEmail }: DashboardHeroProps) { export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleView, quickActions, userDisplayName, userEmail }: DashboardHeroProps) {
// Get user's name for welcome message // Get user's name for welcome message
const userName = userDisplayName || userEmail?.split('@')[0] || 'User'; const userName = userDisplayName || userEmail?.split('@')[0] || 'User';
// Get current time for greeting
const getGreeting = () => {
const hour = new Date().getHours();
if (hour < 12) return 'Good morning';
if (hour < 18) return 'Good afternoon';
return 'Good evening';
};
return ( return (
<Card className="relative overflow-hidden shadow-xl border-0" data-testid="dashboard-hero"> <Card className="relative overflow-hidden shadow-xl border-0" data-testid="dashboard-hero">
<div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"></div> <div className="absolute inset-0 bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900"></div>
@ -39,17 +31,17 @@ export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleV
{/* Toggle for admin to switch between admin and personal view - Top Right Corner */} {/* Toggle for admin to switch between admin and personal view - Top Right Corner */}
{isAdmin && ( {isAdmin && (
<div className="absolute top-4 right-4 sm:top-6 sm:right-6 z-20" data-testid="view-toggle"> <div className="absolute top-4 right-4 sm:top-6 sm:right-6 z-20" data-testid="view-toggle">
<div className="flex items-center gap-2 p-2 bg-white/10 backdrop-blur-sm rounded-lg border border-white/20 shadow-lg"> <div className="flex items-center gap-1.5 sm:gap-2 p-1.5 sm:p-2 bg-white/10 backdrop-blur-sm rounded-lg border border-white/20 shadow-lg">
<div <div
className={`flex items-center gap-1.5 px-2 py-1 rounded transition-all cursor-pointer ${ className={`flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded transition-all cursor-pointer ${
!viewAsUser !viewAsUser
? 'bg-red-600/20 border border-red-600/50' ? 'bg-red-600/20 border border-red-600/50'
: 'opacity-60 hover:opacity-80' : 'opacity-60 hover:opacity-80'
}`} }`}
onClick={() => onToggleView(false)} onClick={() => onToggleView(false)}
> >
<Building2 className={`w-3.5 h-3.5 ${!viewAsUser ? 'text-red-600' : 'text-gray-300'}`} /> <Building2 className={`w-3 h-3 sm:w-3.5 sm:h-3.5 ${!viewAsUser ? 'text-red-600' : 'text-gray-300'}`} />
<Label htmlFor="view-toggle-switch" className={`text-xs font-medium cursor-pointer whitespace-nowrap ${ <Label htmlFor="view-toggle-switch" className={`text-[10px] sm:text-xs font-medium cursor-pointer whitespace-nowrap ${
!viewAsUser ? 'text-red-600' : 'text-gray-300' !viewAsUser ? 'text-red-600' : 'text-gray-300'
}`}> }`}>
Org Org
@ -59,19 +51,19 @@ export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleV
id="view-toggle-switch" id="view-toggle-switch"
checked={viewAsUser} checked={viewAsUser}
onCheckedChange={onToggleView} onCheckedChange={onToggleView}
className="data-[state=checked]:bg-red-600 data-[state=unchecked]:bg-gray-600 shrink-0" className="data-[state=checked]:bg-red-600 data-[state=unchecked]:bg-gray-600 shrink-0 scale-90 sm:scale-100"
data-testid="view-toggle-switch" data-testid="view-toggle-switch"
/> />
<div <div
className={`flex items-center gap-1.5 px-2 py-1 rounded transition-all cursor-pointer ${ className={`flex items-center gap-1 sm:gap-1.5 px-1.5 sm:px-2 py-0.5 sm:py-1 rounded transition-all cursor-pointer ${
viewAsUser viewAsUser
? 'bg-red-600/20 border border-red-600/50' ? 'bg-red-600/20 border border-red-600/50'
: 'opacity-60 hover:opacity-80' : 'opacity-60 hover:opacity-80'
}`} }`}
onClick={() => onToggleView(true)} onClick={() => onToggleView(true)}
> >
<User className={`w-3.5 h-3.5 ${viewAsUser ? 'text-red-600' : 'text-gray-300'}`} /> <User className={`w-3 h-3 sm:w-3.5 sm:h-3.5 ${viewAsUser ? 'text-red-600' : 'text-gray-300'}`} />
<Label htmlFor="view-toggle-switch" className={`text-xs font-medium cursor-pointer whitespace-nowrap ${ <Label htmlFor="view-toggle-switch" className={`text-[10px] sm:text-xs font-medium cursor-pointer whitespace-nowrap ${
viewAsUser ? 'text-red-600' : 'text-gray-300' viewAsUser ? 'text-red-600' : 'text-gray-300'
}`}> }`}>
Personal Personal
@ -82,11 +74,11 @@ export function DashboardHero({ isAdmin, effectiveIsAdmin, viewAsUser, onToggleV
)} )}
<div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 sm:gap-6"> <div className="flex flex-col lg:flex-row items-start lg:items-center justify-between gap-4 sm:gap-6">
<div className="text-white w-full lg:w-auto"> <div className={`text-white w-full lg:w-auto ${isAdmin ? 'pt-12 sm:pt-0' : ''}`}>
<div className="flex items-center gap-3 sm:gap-4 mb-4 sm:mb-6"> <div className="flex items-center gap-3 sm:gap-4 mb-4 sm:mb-6">
<div> <div className="pr-2 sm:pr-0">
<h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold mb-1 sm:mb-2 text-white" data-testid="hero-title"> <h1 className="text-2xl sm:text-3xl lg:text-4xl font-bold mb-1 sm:mb-2 text-white" data-testid="hero-title">
{getGreeting()}, {userName}! Welcome, {userName}!
</h1> </h1>
<p className="text-sm sm:text-lg lg:text-xl text-gray-200" data-testid="hero-subtitle"> <p className="text-sm sm:text-lg lg:text-xl text-gray-200" data-testid="hero-subtitle">
{effectiveIsAdmin ? 'Organization-wide analytics and insights' : 'Track your requests and approvals'} {effectiveIsAdmin ? 'Organization-wide analytics and insights' : 'Track your requests and approvals'}

View File

@ -72,7 +72,7 @@ export function AdminKPICards({
/> />
</div> </div>
{/* Row 2: Pending and Closed */} {/* Row 2: Pending and Closed */}
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2 mb-2">
<StatCard <StatCard
label="Pending" label="Pending"
value={kpis?.requestVolume.openRequests || 0} value={kpis?.requestVolume.openRequests || 0}
@ -96,6 +96,22 @@ export function AdminKPICards({
}} }}
/> />
</div> </div>
{/* Row 3: Paused (if available) */}
{kpis?.requestVolume.pausedRequests !== undefined && (
<div className="grid grid-cols-2 gap-2">
<StatCard
label="Paused"
value={kpis.requestVolume.pausedRequests || 0}
bgColor="bg-orange-100"
textColor="text-orange-700"
testId="stat-paused"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'paused' });
}}
/>
</div>
)}
</KPICard> </KPICard>
{/* SLA Compliance */} {/* SLA Compliance */}

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<{ const [backendStats, setBackendStats] = useState<{
total: number; total: number;
pending: number; pending: number;
paused: number;
approved: number; approved: number;
rejected: number; rejected: number;
draft: number; draft: number;
@ -93,6 +94,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
setBackendStats({ setBackendStats({
total: stats.totalRequests || 0, total: stats.totalRequests || 0,
pending: stats.openRequests || 0, pending: stats.openRequests || 0,
paused: stats.pausedRequests || 0,
approved: stats.approvedRequests || 0, approved: stats.approvedRequests || 0,
rejected: stats.rejectedRequests || 0, rejected: stats.rejectedRequests || 0,
draft: stats.draftRequests || 0, draft: stats.draftRequests || 0,
@ -129,6 +131,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
return { return {
total: backendStats.total || 0, total: backendStats.total || 0,
pending: backendStats.pending || 0, pending: backendStats.pending || 0,
paused: backendStats.paused || 0,
approved: backendStats.approved || 0, approved: backendStats.approved || 0,
rejected: backendStats.rejected || 0, rejected: backendStats.rejected || 0,
draft: backendStats.draft || 0, draft: backendStats.draft || 0,
@ -140,6 +143,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
return { return {
total: 0, total: 0,
pending: 0, pending: 0,
paused: 0,
approved: 0, approved: 0,
rejected: 0, rejected: 0,
draft: 0, draft: 0,

View File

@ -51,6 +51,7 @@ export function MyRequestsFilters({
<SelectItem value="all">All Status</SelectItem> <SelectItem value="all">All Status</SelectItem>
<SelectItem value="draft">Draft</SelectItem> <SelectItem value="draft">Draft</SelectItem>
<SelectItem value="pending">Pending</SelectItem> <SelectItem value="pending">Pending</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem> <SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem> <SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem> <SelectItem value="closed">Closed</SelectItem>

View File

@ -2,7 +2,7 @@
* My Requests Stats Section Component * My Requests Stats Section Component
*/ */
import { FileText, Clock, CheckCircle, XCircle, Edit, Archive } from 'lucide-react'; import { FileText, Clock, Pause, CheckCircle, XCircle, Edit, Archive } from 'lucide-react';
import { StatsCard } from '@/components/dashboard/StatsCard'; import { StatsCard } from '@/components/dashboard/StatsCard';
import { MyRequestsStats } from '../types/myRequests.types'; import { MyRequestsStats } from '../types/myRequests.types';
@ -18,7 +18,7 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
} }
}; };
return ( return (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="my-requests-stats"> <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-7 gap-3 sm:gap-4" data-testid="my-requests-stats">
<StatsCard <StatsCard
label="Total" label="Total"
value={stats.total} value={stats.total}
@ -43,6 +43,18 @@ export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStat
onClick={onStatusFilter ? () => handleCardClick('pending') : undefined} onClick={onStatusFilter ? () => handleCardClick('pending') : undefined}
/> />
<StatsCard
label="Paused"
value={stats.paused}
icon={Pause}
iconColor="text-amber-600"
gradient="bg-gradient-to-br from-amber-50 to-amber-100 border-amber-200"
textColor="text-amber-700"
valueColor="text-amber-900"
testId="stat-paused"
onClick={onStatusFilter ? () => handleCardClick('paused') : undefined}
/>
<StatsCard <StatsCard
label="Approved" label="Approved"
value={stats.approved} value={stats.approved}

View File

@ -5,7 +5,7 @@
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ArrowRight, User, TrendingUp, Clock, FileText } from 'lucide-react'; import { ArrowRight, User, TrendingUp, Clock, FileText, Pause } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { MyRequest } from '../types/myRequests.types'; import { MyRequest } from '../types/myRequests.types';
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers'; import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
@ -79,6 +79,16 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<StatusIcon className="w-3 h-3 mr-1" /> <StatusIcon className="w-3 h-3 mr-1" />
<span className="capitalize">{request.status}</span> <span className="capitalize">{request.status}</span>
</Badge> </Badge>
{(request.pauseInfo?.isPaused || (request as any).isPaused) && (
<Badge
variant="outline"
className="bg-orange-50 text-orange-700 border-orange-300 font-medium text-xs shrink-0"
data-testid="pause-badge"
>
<Pause className="w-3 h-3 mr-1" />
Paused
</Badge>
)}
<Badge <Badge
variant="outline" variant="outline"
className={`${priorityConfig.color} border font-medium text-xs capitalize shrink-0`} className={`${priorityConfig.color} border font-medium text-xs capitalize shrink-0`}

View File

@ -15,6 +15,7 @@ export function useMyRequestsStats({ requests, totalRecords }: UseMyRequestsStat
return { return {
total: totalRecords || requests.length, total: totalRecords || requests.length,
pending: requests.filter((r) => r.status === 'pending').length, pending: requests.filter((r) => r.status === 'pending').length,
paused: requests.filter((r) => r.status === 'paused').length,
approved: requests.filter((r) => r.status === 'approved').length, approved: requests.filter((r) => r.status === 'approved').length,
rejected: requests.filter((r) => r.status === 'rejected').length, rejected: requests.filter((r) => r.status === 'rejected').length,
draft: requests.filter((r) => r.status === 'draft').length, draft: requests.filter((r) => r.status === 'draft').length,

View File

@ -17,11 +17,18 @@ export interface MyRequest {
approverLevel?: string; approverLevel?: string;
templateType?: string; templateType?: string;
templateName?: string; templateName?: string;
pauseInfo?: {
isPaused: boolean;
reason?: string;
pausedAt?: string;
pausedBy?: string;
};
} }
export interface MyRequestsStats { export interface MyRequestsStats {
total: number; total: number;
pending: number; pending: number;
paused: number;
approved: number; approved: number;
rejected: number; rejected: number;
draft: number; draft: number;

View File

@ -49,6 +49,9 @@ import { SummaryTab } from './components/tabs/SummaryTab';
import { QuickActionsSidebar } from './components/QuickActionsSidebar'; import { QuickActionsSidebar } from './components/QuickActionsSidebar';
import { RequestDetailModals } from './components/RequestDetailModals'; import { RequestDetailModals } from './components/RequestDetailModals';
import { RequestDetailProps } from './types/requestDetail.types'; import { RequestDetailProps } from './types/requestDetail.types';
import { PauseModal } from '@/components/workflow/PauseModal';
import { RetriggerPauseModal } from '@/components/workflow/RetriggerPauseModal';
import { resumeWorkflow } from '@/services/workflowApi';
/** /**
* Error Boundary Component * Error Boundary Component
@ -105,6 +108,8 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
const [summaryDetails, setSummaryDetails] = useState<SummaryDetails | null>(null); const [summaryDetails, setSummaryDetails] = useState<SummaryDetails | null>(null);
const [loadingSummary, setLoadingSummary] = useState(false); const [loadingSummary, setLoadingSummary] = useState(false);
const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0); const [sharedRecipientsRefreshTrigger, setSharedRecipientsRefreshTrigger] = useState(0);
const [showPauseModal, setShowPauseModal] = useState(false);
const [showRetriggerModal, setShowRetriggerModal] = useState(false);
const { user } = useAuth(); const { user } = useAuth();
// Custom hooks // Custom hooks
@ -183,6 +188,37 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
refreshDetails(); refreshDetails();
}; };
// Pause handlers
const handlePause = () => {
setShowPauseModal(true);
};
const handleResume = async () => {
if (!apiRequest?.requestId) {
toast.error('Request ID not found');
return;
}
try {
await resumeWorkflow(apiRequest.requestId);
toast.success('Workflow resumed successfully');
refreshDetails();
} catch (error: any) {
toast.error(error?.response?.data?.error || 'Failed to resume workflow');
}
};
const handleRetrigger = () => {
setShowRetriggerModal(true);
};
const handlePauseSuccess = async () => {
refreshDetails();
};
const handleRetriggerSuccess = async () => {
refreshDetails();
};
const handleShareSummary = async () => { const handleShareSummary = async () => {
if (!apiRequest?.requestId) { if (!apiRequest?.requestId) {
toast.error('Request ID not found'); toast.error('Request ID not found');
@ -397,6 +433,12 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
aiGenerated={aiGenerated} aiGenerated={aiGenerated}
handleGenerateConclusion={handleGenerateConclusion} handleGenerateConclusion={handleGenerateConclusion}
handleFinalizeConclusion={handleFinalizeConclusion} handleFinalizeConclusion={handleFinalizeConclusion}
onPause={handlePause}
onResume={handleResume}
onRetrigger={handleRetrigger}
currentUserIsApprover={!!currentApprovalLevel}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId}
/> />
</TabsContent> </TabsContent>
@ -469,8 +511,13 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
onAddSpectator={() => setShowAddSpectatorModal(true)} onAddSpectator={() => setShowAddSpectatorModal(true)}
onApprove={() => setShowApproveModal(true)} onApprove={() => setShowApproveModal(true)}
onReject={() => setShowRejectModal(true)} onReject={() => setShowRejectModal(true)}
onPause={handlePause}
onResume={handleResume}
onRetrigger={handleRetrigger}
summaryId={summaryId} summaryId={summaryId}
refreshTrigger={sharedRecipientsRefreshTrigger} refreshTrigger={sharedRecipientsRefreshTrigger}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId}
/> />
)} )}
</div> </div>
@ -493,6 +540,27 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
/> />
)} )}
{/* Pause Modals */}
{showPauseModal && apiRequest?.requestId && (
<PauseModal
isOpen={showPauseModal}
onClose={() => setShowPauseModal(false)}
requestId={apiRequest.requestId}
levelId={currentApprovalLevel?.levelId || null}
onSuccess={handlePauseSuccess}
/>
)}
{showRetriggerModal && apiRequest?.requestId && (
<RetriggerPauseModal
isOpen={showRetriggerModal}
onClose={() => setShowRetriggerModal(false)}
requestId={apiRequest.requestId}
approverName={request?.pauseInfo?.pausedBy?.name}
onSuccess={handleRetriggerSuccess}
/>
)}
{/* Modals */} {/* Modals */}
<RequestDetailModals <RequestDetailModals
showApproveModal={showApproveModal} showApproveModal={showApproveModal}

View File

@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { UserPlus, Eye, CheckCircle, XCircle, Share2 } from 'lucide-react'; import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react';
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi'; import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
interface QuickActionsSidebarProps { interface QuickActionsSidebarProps {
@ -18,8 +18,13 @@ interface QuickActionsSidebarProps {
onAddSpectator: () => void; onAddSpectator: () => void;
onApprove: () => void; onApprove: () => void;
onReject: () => void; onReject: () => void;
onPause?: () => void;
onResume?: () => void;
onRetrigger?: () => void;
summaryId?: string | null; summaryId?: string | null;
refreshTrigger?: number; // Trigger to refresh shared recipients list refreshTrigger?: number; // Trigger to refresh shared recipients list
pausedByUserId?: string; // User ID of the approver who paused
currentUserId?: string; // Current user's ID
} }
export function QuickActionsSidebar({ export function QuickActionsSidebar({
@ -31,12 +36,23 @@ export function QuickActionsSidebar({
onAddSpectator, onAddSpectator,
onApprove, onApprove,
onReject, onReject,
onPause,
onResume,
onRetrigger,
summaryId, summaryId,
refreshTrigger, refreshTrigger,
pausedByUserId,
currentUserId,
}: QuickActionsSidebarProps) { }: QuickActionsSidebarProps) {
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]); const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
const [loadingRecipients, setLoadingRecipients] = useState(false); const [loadingRecipients, setLoadingRecipients] = useState(false);
const isClosed = request?.status === 'closed'; const isClosed = request?.status === 'closed';
const isPaused = request?.pauseInfo?.isPaused || false;
const canPause = !isPaused && !isClosed && currentApprovalLevel; // Only approver can pause
// Only the approver who paused can resume directly (not initiators)
const canResume = isPaused && onResume && !isInitiator && pausedByUserId === currentUserId;
// Initiators can request resume (retrigger) when workflow is paused
const canRetrigger = isPaused && isInitiator && onRetrigger;
// Fetch shared recipients when request is closed and summaryId is available // Fetch shared recipients when request is closed and summaryId is available
useEffect(() => { useEffect(() => {
@ -96,9 +112,46 @@ export function QuickActionsSidebar({
</Button> </Button>
)} )}
{/* Pause/Resume Button */}
{canPause && onPause && (
<Button
variant="outline"
className="w-full justify-start gap-2 bg-white text-orange-700 border-orange-300 hover:bg-orange-50 hover:text-orange-900 h-9 sm:h-10 text-xs sm:text-sm"
onClick={onPause}
data-testid="pause-workflow-button"
>
<Pause className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
Pause Workflow
</Button>
)}
{canResume && (
<Button
variant="outline"
className="w-full justify-start gap-2 bg-white text-green-700 border-green-300 hover:bg-green-50 hover:text-green-900 h-9 sm:h-10 text-xs sm:text-sm"
onClick={onResume}
data-testid="resume-workflow-button"
>
<Play className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
Resume Workflow
</Button>
)}
{canRetrigger && (
<Button
variant="outline"
className="w-full justify-start gap-2 bg-white text-orange-700 border-orange-300 hover:bg-orange-50 hover:text-orange-900 h-9 sm:h-10 text-xs sm:text-sm"
onClick={onRetrigger}
data-testid="request-resume-button"
>
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
Request Resume
</Button>
)}
{/* Approve/Reject Buttons */} {/* Approve/Reject Buttons */}
<div className="pt-3 sm:pt-4 space-y-2"> <div className="pt-3 sm:pt-4 space-y-2">
{currentApprovalLevel && ( {currentApprovalLevel && !isPaused && (
<> <>
<Button <Button
className="w-full bg-green-600 hover:bg-green-700 text-white h-9 sm:h-10 text-xs sm:text-sm" className="w-full bg-green-600 hover:bg-green-700 text-white h-9 sm:h-10 text-xs sm:text-sm"
@ -119,6 +172,12 @@ export function QuickActionsSidebar({
</Button> </Button>
</> </>
)} )}
{isPaused && (
<div className="bg-orange-50 border border-orange-200 rounded-lg p-3 text-center">
<p className="text-xs text-orange-800 font-medium">Workflow is paused</p>
<p className="text-xs text-orange-600 mt-1">Actions are disabled until resumed</p>
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

View File

@ -7,8 +7,12 @@ import { Button } from '@/components/ui/button';
import { RichTextEditor } from '@/components/ui/rich-text-editor'; import { RichTextEditor } from '@/components/ui/rich-text-editor';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { FormattedDescription } from '@/components/common/FormattedDescription'; import { FormattedDescription } from '@/components/common/FormattedDescription';
import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2 } from 'lucide-react'; import { User, FileText, Mail, Phone, CheckCircle, RefreshCw, Loader2, Pause, Play, AlertCircle } from 'lucide-react';
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter'; import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
dayjs.extend(relativeTime);
interface OverviewTabProps { interface OverviewTabProps {
request: any; request: any;
@ -21,11 +25,17 @@ interface OverviewTabProps {
aiGenerated: boolean; aiGenerated: boolean;
handleGenerateConclusion: () => void; handleGenerateConclusion: () => void;
handleFinalizeConclusion: () => void; handleFinalizeConclusion: () => void;
onPause?: () => void;
onResume?: () => void;
onRetrigger?: () => void;
currentUserIsApprover?: boolean;
pausedByUserId?: string;
currentUserId?: string;
} }
export function OverviewTab({ export function OverviewTab({
request, request,
isInitiator: _isInitiator, isInitiator,
needsClosure, needsClosure,
conclusionRemark, conclusionRemark,
setConclusionRemark, setConclusionRemark,
@ -34,7 +44,21 @@ export function OverviewTab({
aiGenerated, aiGenerated,
handleGenerateConclusion, handleGenerateConclusion,
handleFinalizeConclusion, handleFinalizeConclusion,
onPause: _onPause,
onResume,
onRetrigger,
currentUserIsApprover = false,
pausedByUserId,
currentUserId,
}: OverviewTabProps) { }: OverviewTabProps) {
void _onPause; // Marked as intentionally unused - available for future use
const pauseInfo = request?.pauseInfo;
const isPaused = pauseInfo?.isPaused || false;
// Only the approver who paused can resume directly
// Initiators can only request resume via retrigger
const canResume = isPaused && (currentUserIsApprover && pausedByUserId === currentUserId);
// Initiators can request resume (retrigger) when workflow is paused
const canRetrigger = isPaused && isInitiator;
return ( return (
<div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content"> <div className="space-y-4 sm:space-y-6" data-testid="overview-tab-content">
{/* Request Initiator Card */} {/* Request Initiator Card */}
@ -133,6 +157,83 @@ export function OverviewTab({
</CardContent> </CardContent>
</Card> </Card>
{/* Pause Status Card */}
{isPaused && pauseInfo && (
<Card className="border-orange-300 bg-orange-50/50" data-testid="pause-status-card">
<CardHeader className="pb-3 sm:pb-4">
<CardTitle className="flex items-center gap-2 text-sm sm:text-base text-orange-800">
<Pause className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600" />
Workflow Paused
</CardTitle>
</CardHeader>
<CardContent className="space-y-3 sm:space-y-4">
<div className="bg-white rounded-lg p-3 sm:p-4 border border-orange-200">
<div className="space-y-2 sm:space-y-3">
{pauseInfo.pauseReason && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Reason</label>
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pauseReason}</p>
</div>
)}
{pauseInfo.pausedBy && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused By</label>
<p className="text-sm text-gray-900 mt-1">{pauseInfo.pausedBy.name || pauseInfo.pausedBy.email}</p>
</div>
)}
{pauseInfo.pauseResumeDate && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Auto-Resume Date</label>
<p className="text-sm text-gray-900 mt-1">
{formatDateTime(pauseInfo.pauseResumeDate)}
{dayjs(pauseInfo.pauseResumeDate).isAfter(dayjs()) && (
<span className="ml-2 text-xs text-gray-500">
({dayjs(pauseInfo.pauseResumeDate).fromNow()})
</span>
)}
</p>
</div>
)}
{pauseInfo.pausedAt && (
<div>
<label className="text-xs font-medium text-gray-700 uppercase tracking-wide">Paused At</label>
<p className="text-sm text-gray-900 mt-1">{formatDateTime(pauseInfo.pausedAt)}</p>
</div>
)}
</div>
</div>
{/* Action Buttons */}
<div className="flex flex-wrap gap-2 pt-2 border-t border-orange-200">
{canResume && onResume && (
<Button
onClick={onResume}
className="bg-green-600 hover:bg-green-700 text-white text-xs sm:text-sm h-8 sm:h-9"
size="sm"
>
<Play className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-1.5" />
Resume Now
</Button>
)}
{canRetrigger && onRetrigger && (
<Button
onClick={onRetrigger}
variant="outline"
className="border-orange-300 text-orange-700 hover:bg-orange-50 text-xs sm:text-sm h-8 sm:h-9"
size="sm"
>
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-1.5" />
Request Resume
</Button>
)}
</div>
</CardContent>
</Card>
)}
{/* Claim Management Details */} {/* Claim Management Details */}
{request.claimDetails && ( {request.claimDetails && (
<Card> <Card>

View File

@ -88,6 +88,11 @@ export function WorkflowTab({ request, user, isInitiator, onSkipApprover, onRefr
const currentUserEmail = (user as any)?.email?.toLowerCase(); const currentUserEmail = (user as any)?.email?.toLowerCase();
const approverEmail = step.approverEmail?.toLowerCase(); const approverEmail = step.approverEmail?.toLowerCase();
const isCurrentUser = currentUserEmail && approverEmail && currentUserEmail === approverEmail; const isCurrentUser = currentUserEmail && approverEmail && currentUserEmail === approverEmail;
// Determine if this is the current active level (for SLA display)
// Current level is the one that matches request.currentStep (or currentStepRaw)
const currentStep = request.currentStepRaw !== undefined ? request.currentStepRaw : (request.currentStep || 1);
const isCurrentLevel = step.step === currentStep;
return ( return (
<ApprovalStepCard <ApprovalStepCard
@ -97,6 +102,7 @@ export function WorkflowTab({ request, user, isInitiator, onSkipApprover, onRefr
approval={approval} approval={approval}
isCurrentUser={isCurrentUser} isCurrentUser={isCurrentUser}
isInitiator={isInitiator} isInitiator={isInitiator}
isCurrentLevel={isCurrentLevel}
onSkipApprover={onSkipApprover} onSkipApprover={onSkipApprover}
onRefresh={onRefresh} onRefresh={onRefresh}
testId="workflow-step" testId="workflow-step"

View File

@ -7,6 +7,7 @@
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext'; import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import { useAppSelector } from '@/redux/hooks';
import { Pagination } from '@/components/common/Pagination'; import { Pagination } from '@/components/common/Pagination';
import type { DateRange } from '@/services/dashboard.service'; import type { DateRange } from '@/services/dashboard.service';
import dashboardService from '@/services/dashboard.service'; import dashboardService from '@/services/dashboard.service';
@ -47,7 +48,16 @@ import { format } from 'date-fns';
export function Requests({ onViewRequest }: RequestsProps) { export function Requests({ onViewRequest }: RequestsProps) {
const { user } = useAuth(); const { user } = useAuth();
const isOrgLevel = useMemo(() => hasManagementAccess(user), [user]);
// Get viewAsUser from Redux store (synced with Dashboard toggle)
const viewAsUser = useAppSelector((state) => state.dashboard.viewAsUser);
// Determine if viewing at organization level:
// - If user is admin/management AND not in "Personal" mode (viewAsUser=false) → show all requests
// - If user is admin/management AND in "Personal" mode (viewAsUser=true) → show only their requests
// - If user is not admin/management → always show only their requests
const isAdmin = useMemo(() => hasManagementAccess(user), [user]);
const isOrgLevel = useMemo(() => isAdmin && !viewAsUser, [isAdmin, viewAsUser]);
// Filters hook // Filters hook
const filters = useRequestsFilters(); const filters = useRequestsFilters();
@ -83,6 +93,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Fetch backend stats // Fetch backend stats
// Stats should reflect filters (priority, department, initiator, approver, search, date range) but NOT status // Stats should reflect filters (priority, department, initiator, approver, search, date range) but NOT status
// Status filter should not affect stats - stats should always show all status counts // Status filter should not affect stats - stats should always show all status counts
// For user-level (Personal mode), stats will only include requests where user is involved
const fetchBackendStats = useCallback(async ( const fetchBackendStats = useCallback(async (
statsDateRange?: DateRange, statsDateRange?: DateRange,
statsStartDate?: Date, statsStartDate?: Date,
@ -97,8 +108,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
slaCompliance?: string; slaCompliance?: string;
} }
) => { ) => {
if (!isOrgLevel) return;
try { try {
// For dynamic SLA statuses (on_track, approaching, critical), we need to fetch and enrich // For dynamic SLA statuses (on_track, approaching, critical), we need to fetch and enrich
// because these are calculated dynamically, not stored in DB // because these are calculated dynamically, not stored in DB
@ -131,11 +140,10 @@ export function Requests({ onViewRequest }: RequestsProps) {
if (statsEndDate) backendFilters.endDate = statsEndDate.toISOString(); if (statsEndDate) backendFilters.endDate = statsEndDate.toISOString();
// Fetch up to 1000 requests (backend will enrich and filter by SLA) // Fetch up to 1000 requests (backend will enrich and filter by SLA)
const result = await workflowApi.listWorkflows({ // Use appropriate API based on org/personal mode
page: 1, const result = isOrgLevel
limit: 1000, ? await workflowApi.listWorkflows({ page: 1, limit: 1000, ...backendFilters })
...backendFilters : await workflowApi.listParticipantRequests({ page: 1, limit: 1000, ...backendFilters });
});
const filteredData = Array.isArray(result?.data) ? result.data : []; const filteredData = Array.isArray(result?.data) ? result.data : [];
@ -161,6 +169,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
setBackendStats({ setBackendStats({
total, total,
pending, pending,
paused: 0, // Paused not calculated in dynamic SLA mode
approved, approved,
rejected, rejected,
draft: 0, // Drafts are excluded draft: 0, // Drafts are excluded
@ -169,6 +178,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
} else { } else {
// For breached/compliant or no SLA filter, use dashboard stats API // For breached/compliant or no SLA filter, use dashboard stats API
// Note: status is undefined here because All Requests stats should show all statuses // Note: status is undefined here because All Requests stats should show all statuses
// Pass viewAsUser=true when in Personal mode (not org-level)
const stats = await dashboardService.getRequestStats( const stats = await dashboardService.getRequestStats(
statsDateRange, statsDateRange,
statsStartDate ? statsStartDate.toISOString() : undefined, statsStartDate ? statsStartDate.toISOString() : undefined,
@ -180,12 +190,14 @@ export function Requests({ onViewRequest }: RequestsProps) {
filtersWithoutStatus?.approver, filtersWithoutStatus?.approver,
filtersWithoutStatus?.approverType, filtersWithoutStatus?.approverType,
filtersWithoutStatus?.search, filtersWithoutStatus?.search,
filtersWithoutStatus?.slaCompliance filtersWithoutStatus?.slaCompliance,
!isOrgLevel // viewAsUser: true when in Personal mode
); );
setBackendStats({ setBackendStats({
total: stats.totalRequests || 0, total: stats.totalRequests || 0,
pending: stats.openRequests || 0, pending: stats.openRequests || 0,
paused: stats.pausedRequests || 0,
approved: stats.approvedRequests || 0, approved: stats.approvedRequests || 0,
rejected: stats.rejectedRequests || 0, rejected: stats.rejectedRequests || 0,
draft: stats.draftRequests || 0, draft: stats.draftRequests || 0,
@ -294,32 +306,31 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Stats should reflect priority, department, initiator, approver, search, and date range filters // Stats should reflect priority, department, initiator, approver, search, and date range filters
// But NOT status filter - stats should always show all status counts // But NOT status filter - stats should always show all status counts
// Total changes when other filters are applied, but stays stable when only status changes // Total changes when other filters are applied, but stays stable when only status changes
// Stats are fetched for both org-level AND user-level (Personal mode) views
useEffect(() => { useEffect(() => {
if (isOrgLevel) { const timeoutId = setTimeout(() => {
const timeoutId = setTimeout(() => { const filtersWithoutStatus = {
const filtersWithoutStatus = { priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined, department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined,
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined, initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined, approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined, approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined, search: filters.searchTerm || undefined,
search: filters.searchTerm || undefined, slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined };
}; // All Requests (admin/normal user) should always have a date range
// All Requests (admin/normal user) should always have a date range // Default to 'month' if no date range is selected
// Default to 'month' if no date range is selected const statsDateRange = filters.dateRange || 'month';
const statsDateRange = filters.dateRange || 'month';
fetchBackendStatsRef.current(
fetchBackendStatsRef.current( statsDateRange,
statsDateRange, filters.customStartDate,
filters.customStartDate, filters.customEndDate,
filters.customEndDate, filtersWithoutStatus
filtersWithoutStatus );
); }, filters.searchTerm ? 500 : 0);
}, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
isOrgLevel, isOrgLevel,
@ -337,6 +348,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
]); ]);
// Fetch requests on mount and when filters change // Fetch requests on mount and when filters change
// Also refetch when isOrgLevel changes (when admin toggles between Org/Personal in Dashboard)
useEffect(() => { useEffect(() => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
setCurrentPage(1); setCurrentPage(1);
@ -346,6 +358,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
isOrgLevel, // Re-fetch when org/personal toggle changes
filters.searchTerm, filters.searchTerm,
filters.statusFilter, filters.statusFilter,
filters.priorityFilter, filters.priorityFilter,
@ -371,14 +384,15 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Transform requests // Transform requests
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]); const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
// Calculate stats - Always use backend stats API for overall counts (unfiltered) // Calculate stats - Use backend stats API for both org-level and user-level views
// Stats should always show total counts regardless of any filters applied // Stats should always show total counts regardless of any filters applied
const stats = useMemo(() => { const stats = useMemo(() => {
// For org-level: Use backend stats API (always unfiltered) // Use backend stats if available (for both org-level and user-level)
if (isOrgLevel && backendStats) { if (backendStats) {
return { return {
total: backendStats.total || 0, total: backendStats.total || 0,
pending: backendStats.pending || 0, pending: backendStats.pending || 0,
paused: backendStats.paused || 0,
approved: backendStats.approved || 0, approved: backendStats.approved || 0,
rejected: backendStats.rejected || 0, rejected: backendStats.rejected || 0,
draft: backendStats.draft || 0, draft: backendStats.draft || 0,
@ -387,7 +401,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
} }
// Fallback: Calculate from paginated data (less accurate, but better than nothing) // Fallback: Calculate from paginated data (less accurate, but better than nothing)
// This is for user-level where backend stats might not be available
return calculateStatsFromFilteredData( return calculateStatsFromFilteredData(
[], // Empty - we'll use backendStats or fallback [], // Empty - we'll use backendStats or fallback
isOrgLevel, isOrgLevel,
@ -405,6 +418,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
{/* Header */} {/* Header */}
<RequestsHeader <RequestsHeader
isOrgLevel={isOrgLevel} isOrgLevel={isOrgLevel}
isAdmin={isAdmin}
loading={loading} loading={loading}
exporting={exporting} exporting={exporting}
onExport={handleExportToCSV} onExport={handleExportToCSV}
@ -462,6 +476,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
<SelectContent> <SelectContent>
<SelectItem value="all">All Status</SelectItem> <SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem> <SelectItem value="pending">Pending</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem> <SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem> <SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem> <SelectItem value="closed">Closed</SelectItem>

View File

@ -1,16 +1,16 @@
/** /**
* User All Requests Page - For Regular Users * User All Requests Page - For Regular Users
* *
* This is a SEPARATE screen for regular users' "All Requests" page. * OPTIMIZED: Uses backend pagination (10 records per page) and backend stats API
* Shows requests where the user is EITHER: * Shows requests where the user is EITHER:
* - The initiator (created by the user), OR * - The initiator (created by the user), OR
* - A participant (approver/spectator) * - A participant (approver/spectator)
* Completely separate from AdminAllRequests to avoid interference.
*/ */
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'; import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Pagination } from '@/components/common/Pagination'; import { Pagination } from '@/components/common/Pagination';
import dashboardService from '@/services/dashboard.service'; import dashboardService from '@/services/dashboard.service';
import type { DateRange } from '@/services/dashboard.service';
import userApi from '@/services/userApi'; import userApi from '@/services/userApi';
// Components // Components
@ -30,7 +30,7 @@ import { exportRequestsToCSV } from './utils/csvExports';
import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService'; import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService';
// Types // Types
import type { RequestsProps } from './types/requests.types'; import type { RequestsProps, BackendStats } from './types/requests.types';
// Filter UI components // Filter UI components
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
@ -52,7 +52,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
const [apiRequests, setApiRequests] = useState<any[]>([]); const [apiRequests, setApiRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [allRequestsForStats, setAllRequestsForStats] = useState<any[]>([]); // All requests without status filter for stats const [backendStats, setBackendStats] = useState<BackendStats | null>(null); // Stats from backend API
const [departments, setDepartments] = useState<string[]>([]); const [departments, setDepartments] = useState<string[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false); const [loadingDepartments, setLoadingDepartments] = useState(false);
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]); const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
@ -60,7 +60,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Pagination // Pagination
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1); const [totalPages, setTotalPages] = useState(1);
const [totalRecordsForStats, setTotalRecordsForStats] = useState(0); // For stats (unfiltered - stable) const [totalRecords, setTotalRecords] = useState(0);
const [itemsPerPage] = useState(10); const [itemsPerPage] = useState(10);
// User search hooks // User search hooks
@ -76,38 +76,54 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
onFilterChange: filters.setApproverFilter onFilterChange: filters.setApproverFilter
}); });
// Fetch all requests for stats calculation // Fetch backend stats using dashboard API
// Apply all filters EXCEPT status filter - this way: // OPTIMIZED: Uses backend stats API instead of fetching 100 records
// - Total changes when priority/department/SLA/etc. filters are applied // Stats reflect all filters EXCEPT status - total stays stable when only status changes
// - Total remains stable when only status filter is applied const fetchBackendStats = useCallback(async (
const fetchAllRequestsForStats = useCallback(async () => { statsDateRange?: DateRange,
try { statsStartDate?: Date,
// Get current filters directly from the filters object (not ref) to ensure we have latest values statsEndDate?: Date,
const filterOptions = filters.getFilters(); filtersWithoutStatus?: {
priority?: string;
// Build filters WITHOUT status filter for stats department?: string;
// This ensures total changes with other filters (including SLA) but stays stable with status filter initiator?: string;
const statsFilters = { ...filterOptions }; approver?: string;
delete statsFilters.status; // Remove status filter to get all statuses approverType?: 'current' | 'any';
search?: string;
// Fetch first page with a larger limit to get more data for stats slaCompliance?: string;
const result = await fetchUserParticipantRequestsData({
page: 1,
itemsPerPage: 100, // Fetch more data for accurate stats
filters: statsFilters // Apply all filters except status (includes SLA, priority, department, etc.)
});
setAllRequestsForStats(result.data || []);
// Update totalRecordsForStats from this fetch (with filters except status)
// This total will change when other filters (including SLA) are applied, but stay stable when only status changes
if (result.pagination?.total !== undefined) {
setTotalRecordsForStats(result.pagination.total);
}
} catch (error) {
console.error('Failed to fetch requests for stats:', error);
setAllRequestsForStats([]);
} }
}, [filters]); ) => {
try {
// Use dashboard stats API with viewAsUser=true for user-level stats
const stats = await dashboardService.getRequestStats(
statsDateRange,
statsStartDate ? statsStartDate.toISOString() : undefined,
statsEndDate ? statsEndDate.toISOString() : undefined,
undefined, // status - stats should show all statuses
filtersWithoutStatus?.priority,
filtersWithoutStatus?.department,
filtersWithoutStatus?.initiator,
filtersWithoutStatus?.approver,
filtersWithoutStatus?.approverType,
filtersWithoutStatus?.search,
filtersWithoutStatus?.slaCompliance,
true // viewAsUser: always true for user-level
);
setBackendStats({
total: stats.totalRequests || 0,
pending: stats.openRequests || 0,
paused: stats.pausedRequests || 0,
approved: stats.approvedRequests || 0,
rejected: stats.rejectedRequests || 0,
draft: stats.draftRequests || 0,
closed: stats.closedRequests || 0
});
} catch (error) {
console.error('Failed to fetch backend stats:', error);
// Keep previous stats on error
}
}, []);
// Fetch departments // Fetch departments
const fetchDepartments = useCallback(async () => { const fetchDepartments = useCallback(async () => {
@ -139,15 +155,15 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Use refs to store stable callbacks to prevent infinite loops // Use refs to store stable callbacks to prevent infinite loops
const filtersRef = useRef(filters); const filtersRef = useRef(filters);
const fetchAllRequestsForStatsRef = useRef(fetchAllRequestsForStats); const fetchBackendStatsRef = useRef(fetchBackendStats);
// Update refs on each render // Update refs on each render
useEffect(() => { useEffect(() => {
filtersRef.current = filters; filtersRef.current = filters;
fetchAllRequestsForStatsRef.current = fetchAllRequestsForStats; fetchBackendStatsRef.current = fetchBackendStats;
}, [filters, fetchAllRequestsForStats]); }, [filters, fetchBackendStats]);
// Fetch requests // Fetch requests - OPTIMIZED: Only fetches 10 records per page
const fetchRequests = useCallback(async (page: number = 1) => { const fetchRequests = useCallback(async (page: number = 1) => {
try { try {
if (page === 1) { if (page === 1) {
@ -162,13 +178,12 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
filters: filterOptions filters: filterOptions
}); });
setApiRequests(result.data); // Paginated data WITH status filter (for list display) setApiRequests(result.data); // Paginated data (10 records)
// Update pagination (for list display - includes status filter) // Update pagination
setCurrentPage(result.pagination.page); setCurrentPage(result.pagination.page);
setTotalPages(result.pagination.totalPages); setTotalPages(result.pagination.totalPages);
// Don't update totalRecords here - it should come from stats fetch (without status filter) setTotalRecords(result.pagination.total);
// setTotalRecords(result.pagination.total); // Commented out - use totalRecordsForStats instead
} catch (error) { } catch (error) {
setApiRequests([]); setApiRequests([]);
} finally { } finally {
@ -196,11 +211,27 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
fetchUsers(); fetchUsers();
}, [fetchDepartments, fetchUsers]); }, [fetchDepartments, fetchUsers]);
// Fetch stats when filters change (except status filter) // Fetch backend stats when filters change (except status filter)
// This ensures total changes with other filters but stays stable with status filter // OPTIMIZED: Uses backend stats API instead of fetching 100 records
useEffect(() => { useEffect(() => {
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
fetchAllRequestsForStats(); const filtersWithoutStatus = {
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined,
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
search: filters.searchTerm || undefined,
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
};
const statsDateRange = filters.dateRange || 'month';
fetchBackendStatsRef.current(
statsDateRange,
filters.customStartDate,
filters.customEndDate,
filtersWithoutStatus
);
}, filters.searchTerm ? 500 : 0); }, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId); return () => clearTimeout(timeoutId);
@ -216,7 +247,6 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
filters.dateRange, filters.dateRange,
filters.customStartDate, filters.customStartDate,
filters.customEndDate filters.customEndDate
// fetchAllRequestsForStats excluded to prevent infinite loops
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes // Note: statusFilter is NOT in dependencies - stats don't change when only status changes
]); ]);
@ -254,78 +284,61 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Transform requests // Transform requests
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]); const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
// Transform all requests for stats (without status filter)
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(() => { const stats = useMemo(() => {
// For regular users, calculate stats from allRequestsForStats (fetched without status filter) // Use backend stats if available
// Use totalRecords for total (from backend), and calculate individual status counts from fetched data if (backendStats) {
if (allConvertedRequestsForStats.length > 0) {
// Calculate individual status counts from all fetched requests
const pending = allConvertedRequestsForStats.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'pending' || status === 'in-progress';
}).length;
const approved = allConvertedRequestsForStats.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'approved';
}).length;
const rejected = allConvertedRequestsForStats.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'rejected';
}).length;
const closed = allConvertedRequestsForStats.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'closed';
}).length;
// Use totalRecordsForStats for total - this changes when other filters (priority, department, etc.) are applied
// but stays stable when only status filter changes
return { return {
total: totalRecordsForStats > 0 ? totalRecordsForStats : allConvertedRequestsForStats.length, // Use total from stats fetch (with other filters, without status) total: backendStats.total || 0,
pending, pending: backendStats.pending || 0,
approved, paused: backendStats.paused || 0,
rejected, approved: backendStats.approved || 0,
draft: 0, // Drafts are excluded rejected: backendStats.rejected || 0,
closed draft: backendStats.draft || 0,
}; closed: backendStats.closed || 0
} else {
// Fallback: calculate from convertedRequests (current page only) - less accurate
const pending = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'pending' || status === 'in-progress';
}).length;
const approved = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'approved';
}).length;
const rejected = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'rejected';
}).length;
const closed = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'closed';
}).length;
return {
total: totalRecordsForStats > 0 ? totalRecordsForStats : convertedRequests.length, // Use total from stats fetch if available
pending,
approved,
rejected,
draft: 0,
closed
}; };
} }
}, [totalRecordsForStats, allConvertedRequestsForStats, convertedRequests]);
// Fallback: calculate from current page (less accurate, but works during initial load)
const pending = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'pending' || status === 'in-progress';
}).length;
const paused = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'paused';
}).length;
const approved = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'approved';
}).length;
const rejected = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'rejected';
}).length;
const closed = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'closed';
}).length;
return {
total: totalRecords > 0 ? totalRecords : convertedRequests.length,
pending,
paused,
approved,
rejected,
draft: 0,
closed
};
}, [backendStats, totalRecords, convertedRequests]);
return ( return (
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="user-all-requests-page"> <div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="user-all-requests-page">
{/* Header */} {/* Header */}
<RequestsHeader <RequestsHeader
isOrgLevel={false} isOrgLevel={false}
isAdmin={false}
loading={loading} loading={loading}
exporting={exporting} exporting={exporting}
onExport={handleExportToCSV} onExport={handleExportToCSV}
@ -383,6 +396,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
<SelectContent> <SelectContent>
<SelectItem value="all">All Status</SelectItem> <SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem> <SelectItem value="pending">Pending</SelectItem>
<SelectItem value="paused">Paused</SelectItem>
<SelectItem value="approved">Approved</SelectItem> <SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem> <SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem> <SelectItem value="closed">Closed</SelectItem>
@ -673,7 +687,7 @@ export function UserAllRequests({ onViewRequest }: RequestsProps) {
<Pagination <Pagination
currentPage={currentPage} currentPage={currentPage}
totalPages={totalPages} totalPages={totalPages}
totalRecords={totalRecordsForStats} totalRecords={totalRecords}
itemsPerPage={itemsPerPage} itemsPerPage={itemsPerPage}
onPageChange={handlePageChange} onPageChange={handlePageChange}
loading={loading} loading={loading}

View File

@ -5,7 +5,7 @@
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { User, ArrowRight, TrendingUp, Clock } from 'lucide-react'; import { User, ArrowRight, TrendingUp, Clock, Pause } from 'lucide-react';
import { getPriorityConfig, getStatusConfig } from '../utils/configMappers'; import { getPriorityConfig, getStatusConfig } from '../utils/configMappers';
import type { ConvertedRequest } from '../types/requests.types'; import type { ConvertedRequest } from '../types/requests.types';
import { formatDateDDMMYYYY } from '@/utils/dateFormatter'; import { formatDateDDMMYYYY } from '@/utils/dateFormatter';
@ -78,6 +78,16 @@ export function RequestCard({ request, index, onViewRequest }: RequestCardProps)
<StatusIcon className="w-3 h-3 mr-1" /> <StatusIcon className="w-3 h-3 mr-1" />
<span className="capitalize">{request.status}</span> <span className="capitalize">{request.status}</span>
</Badge> </Badge>
{((request as any).pauseInfo?.isPaused || (request as any).isPaused) && (
<Badge
variant="outline"
className="bg-orange-50 text-orange-700 border-orange-300 font-medium text-xs shrink-0"
data-testid="pause-badge"
>
<Pause className="w-3 h-3 mr-1" />
Paused
</Badge>
)}
<Badge <Badge
variant="outline" variant="outline"
className={`${priorityConfig.color} border font-medium text-xs capitalize shrink-0`} className={`${priorityConfig.color} border font-medium text-xs capitalize shrink-0`}

View File

@ -4,10 +4,12 @@
import { FileText, Download, RefreshCw } from 'lucide-react'; import { FileText, Download, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { PageHeader } from '@/components/common/PageHeader'; import { PageHeader } from '@/components/common/PageHeader';
interface RequestsHeaderProps { interface RequestsHeaderProps {
isOrgLevel: boolean; isOrgLevel: boolean;
isAdmin: boolean;
loading: boolean; loading: boolean;
exporting: boolean; exporting: boolean;
onExport: () => void; onExport: () => void;
@ -15,20 +17,48 @@ interface RequestsHeaderProps {
export function RequestsHeader({ export function RequestsHeader({
isOrgLevel, isOrgLevel,
isAdmin,
loading, loading,
exporting, exporting,
onExport onExport
}: RequestsHeaderProps) { }: RequestsHeaderProps) {
// Determine the title and description based on view mode
const getTitle = () => {
if (isOrgLevel) return "All Requests";
if (isAdmin) return "All Requests"; // Admin viewing as personal
return "All Requests"; // Regular user
};
const getDescription = () => {
if (isOrgLevel) {
return "View and filter all organization-wide workflow requests";
}
if (isAdmin) {
return "Viewing requests where you are initiator, approver, or participant";
}
return "View and filter your workflow requests";
};
return ( return (
<div className="flex items-start justify-between gap-4" data-testid="requests-header-container"> <div className="flex items-start justify-between gap-4" data-testid="requests-header-container">
<PageHeader <div className="flex items-center gap-3">
icon={FileText} <PageHeader
title={isOrgLevel ? "All Requests (Organization)" : "All Requests"} icon={FileText}
description={isOrgLevel title={getTitle()}
? "View and filter all organization-wide workflow requests with advanced filtering options" description={getDescription()}
: "View and filter your workflow requests with advanced filtering options"} testId="requests-header"
testId="requests-header" />
/> {/* View mode badge */}
<Badge
variant="outline"
className={isOrgLevel
? "bg-blue-50 text-blue-700 border-blue-200"
: "bg-green-50 text-green-700 border-green-200"
}
>
{isOrgLevel ? "Organization" : "Personal"}
</Badge>
</div>
<Button <Button
onClick={onExport} onClick={onExport}
disabled={exporting || loading} disabled={exporting || loading}

View File

@ -3,7 +3,7 @@
* Displays statistics cards for requests with click handlers to filter * Displays statistics cards for requests with click handlers to filter
*/ */
import { FileText, Clock, CheckCircle, XCircle, Archive } from 'lucide-react'; import { FileText, Clock, Pause, CheckCircle, XCircle, Archive } from 'lucide-react';
import { StatsCard } from '@/components/dashboard/StatsCard'; import { StatsCard } from '@/components/dashboard/StatsCard';
import type { RequestStats } from '../types/requests.types'; import type { RequestStats } from '../types/requests.types';
@ -20,7 +20,7 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
}; };
return ( return (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 sm:gap-4" data-testid="requests-stats"> <div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="requests-stats">
<StatsCard <StatsCard
label="Total" label="Total"
value={stats.total} value={stats.total}
@ -45,6 +45,18 @@ export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
onClick={onStatusFilter ? () => handleCardClick('pending') : undefined} onClick={onStatusFilter ? () => handleCardClick('pending') : undefined}
/> />
<StatsCard
label="Paused"
value={stats.paused}
icon={Pause}
iconColor="text-amber-600"
gradient="bg-gradient-to-br from-amber-50 to-amber-100 border-amber-200"
textColor="text-amber-700"
valueColor="text-amber-900"
testId="stat-paused"
onClick={onStatusFilter ? () => handleCardClick('paused') : undefined}
/>
<StatsCard <StatsCard
label="Approved" label="Approved"
value={stats.approved} value={stats.approved}

View File

@ -82,7 +82,10 @@ export async function fetchRequestsData({
}; };
} else { } else {
// User-level: Use SEPARATE endpoint for regular users' "All Requests" page // User-level: Use SEPARATE endpoint for regular users' "All Requests" page
// This shows only participant requests (approver/spectator), NOT initiator requests // This shows ALL requests where user is involved:
// - As initiator (created the request)
// - As approver (in any approval level)
// - As participant/spectator
const backendFilters: any = {}; const backendFilters: any = {};
if (filters?.search) backendFilters.search = filters.search; if (filters?.search) backendFilters.search = filters.search;
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status; if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
@ -94,8 +97,8 @@ export async function fetchRequestsData({
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString(); if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString(); if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
// Fetch paginated data using SEPARATE endpoint for regular users // Fetch paginated data using endpoint for regular users
// This endpoint excludes initiator requests automatically // This endpoint includes all requests where user is initiator, approver, or participant
const pageResult = await workflowApi.listParticipantRequests({ const pageResult = await workflowApi.listParticipantRequests({
page, page,
limit: itemsPerPage, limit: itemsPerPage,
@ -143,7 +146,7 @@ export async function fetchAllRequestsForExport(isOrgLevel: boolean): Promise<an
while (hasMore && currentPageNum <= maxPages) { while (hasMore && currentPageNum <= maxPages) {
const pageResult = isOrgLevel const pageResult = isOrgLevel
? await workflowApi.listWorkflows({ page: currentPageNum, limit: EXPORT_FETCH_LIMIT }) // Admin: All org requests ? await workflowApi.listWorkflows({ page: currentPageNum, limit: EXPORT_FETCH_LIMIT }) // Admin: All org requests
: await workflowApi.listParticipantRequests({ page: currentPageNum, limit: EXPORT_FETCH_LIMIT }); // Regular users: Participant requests only : await workflowApi.listParticipantRequests({ page: currentPageNum, limit: EXPORT_FETCH_LIMIT }); // Regular users: All requests where user is involved (initiator, approver, or participant)
let pageData: any[] = []; let pageData: any[] = [];
if (Array.isArray(pageResult?.data)) { if (Array.isArray(pageResult?.data)) {

View File

@ -1,6 +1,6 @@
/** /**
* Service for fetching user requests data (initiator + participant) * Service for fetching user requests data (initiator + participant)
* SEPARATE from admin requests service to avoid interference * OPTIMIZED: Uses single backend endpoint that now includes both initiator and participant requests
* *
* This service is specifically for regular users' "All Requests" page * This service is specifically for regular users' "All Requests" page
* Shows requests where user is EITHER initiator OR participant (approver/spectator) * Shows requests where user is EITHER initiator OR participant (approver/spectator)
@ -9,8 +9,6 @@
import workflowApi from '@/services/workflowApi'; import workflowApi from '@/services/workflowApi';
import type { RequestFilters } from '../types/requests.types'; import type { RequestFilters } from '../types/requests.types';
const EXPORT_FETCH_LIMIT = 100;
interface FetchUserAllRequestsOptions { interface FetchUserAllRequestsOptions {
page: number; page: number;
itemsPerPage: number; itemsPerPage: number;
@ -19,7 +17,8 @@ interface FetchUserAllRequestsOptions {
/** /**
* Fetch all requests for regular users (initiator + participant) * Fetch all requests for regular users (initiator + participant)
* Combines requests where user is initiator AND requests where user is participant * OPTIMIZED: Uses single listParticipantRequests endpoint which now includes initiator requests
* Fetches only the requested page (10 records) for optimal performance
*/ */
export async function fetchUserParticipantRequestsData({ export async function fetchUserParticipantRequestsData({
page, page,
@ -35,134 +34,66 @@ export async function fetchUserParticipantRequestsData({
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator; if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.approver && filters.approver !== 'all') { if (filters?.approver && filters.approver !== 'all') {
backendFilters.approver = filters.approver; backendFilters.approver = filters.approver;
backendFilters.approverType = filters.approverType || 'current'; // Default to 'current' backendFilters.approverType = filters.approverType || 'current';
} }
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance; if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange; if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate; if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate; if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
// To properly merge and paginate, we need to fetch enough data from both endpoints // Use single optimized endpoint - listParticipantRequests now includes initiator requests
// Fetch multiple pages from each endpoint to ensure we have enough data to merge and paginate correctly // Only fetch the requested page (10 records) for optimal performance
const fetchLimit = Math.max(itemsPerPage * 3, 100); // Fetch at least 3 pages worth or 100 items, whichever is larger const result = await workflowApi.listParticipantRequests({
// Fetch from both endpoints in parallel
const [initiatorResult, participantResult] = await Promise.all([
// Fetch requests where user is initiator (fetch more to account for merging)
workflowApi.listMyInitiatedWorkflows({
page: 1,
limit: fetchLimit,
search: backendFilters.search,
status: backendFilters.status,
priority: backendFilters.priority,
department: backendFilters.department,
slaCompliance: backendFilters.slaCompliance,
dateRange: backendFilters.dateRange,
startDate: backendFilters.startDate,
endDate: backendFilters.endDate
}),
// Fetch requests where user is participant (approver/spectator)
workflowApi.listParticipantRequests({
page: 1,
limit: fetchLimit,
...backendFilters
})
]);
// Extract data from both results
let initiatorData: any[] = [];
if (Array.isArray(initiatorResult?.data)) {
initiatorData = initiatorResult.data;
} else if (Array.isArray(initiatorResult)) {
initiatorData = initiatorResult;
}
let participantData: any[] = [];
if (Array.isArray(participantResult?.data)) {
participantData = participantResult.data;
} else if (Array.isArray(participantResult)) {
participantData = participantResult;
}
// Filter out drafts from both
const nonDraftInitiatorData = initiatorData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus !== 'DRAFT';
});
const nonDraftParticipantData = participantData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus !== 'DRAFT';
});
// Merge and deduplicate by requestId
const mergedMap = new Map<string, any>();
// Add initiator requests
nonDraftInitiatorData.forEach((req: any) => {
const requestId = req.requestId || req.id;
if (requestId) {
mergedMap.set(requestId, req);
}
});
// Add participant requests (will overwrite if duplicate, but that's fine)
nonDraftParticipantData.forEach((req: any) => {
const requestId = req.requestId || req.id;
if (requestId) {
mergedMap.set(requestId, req);
}
});
// Convert map to array
const mergedData = Array.from(mergedMap.values());
// Sort by updatedAt or createdAt (most recent first)
mergedData.sort((a: any, b: any) => {
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
return dateB - dateA;
});
// Calculate combined pagination
const initiatorPagination = initiatorResult?.pagination || { page: 1, limit: fetchLimit, total: 0, totalPages: 1 };
const participantPagination = participantResult?.pagination || { page: 1, limit: fetchLimit, total: 0, totalPages: 1 };
// Estimate total: sum of both totals, but account for potential duplicates
// We'll use a conservative estimate: sum of both, but we know there might be overlap
const estimatedTotal = (initiatorPagination.total || 0) + (participantPagination.total || 0);
// The actual merged count might be less due to duplicates, but we use the merged length if we have enough data
const actualTotal = mergedData.length >= fetchLimit ? estimatedTotal : mergedData.length;
// Paginate the merged results
const startIndex = (page - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedData = mergedData.slice(startIndex, endIndex);
const pagination = {
page, page,
limit: itemsPerPage, limit: itemsPerPage,
total: actualTotal, ...backendFilters
totalPages: Math.ceil(actualTotal / itemsPerPage) || 1 });
// Extract data from result
let pageData: any[] = [];
if (Array.isArray(result?.data)) {
pageData = result.data;
} else if (Array.isArray(result)) {
pageData = result;
}
// Filter out drafts (backend should handle this, but double-check)
const nonDraftData = pageData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus !== 'DRAFT';
});
// Get pagination info from backend response
const pagination = result?.pagination || {
page,
limit: itemsPerPage,
total: nonDraftData.length,
totalPages: 1
}; };
return { return {
data: paginatedData, // Paginated merged data for list display data: nonDraftData,
allData: [], // Stats calculated from data allData: [],
filteredData: paginatedData, // Same as data for list filteredData: nonDraftData,
pagination: pagination pagination: {
page: pagination.page,
limit: pagination.limit || itemsPerPage,
total: pagination.total || nonDraftData.length,
totalPages: pagination.totalPages || 1
}
}; };
} }
const EXPORT_FETCH_LIMIT = 100;
/** /**
* Fetch all requests for export (regular users - initiator + participant) * Fetch all requests for export (regular users - initiator + participant)
* Fetches from both endpoints and merges results * OPTIMIZED: Uses single endpoint that includes both initiator and participant requests
*/ */
export async function fetchAllRequestsForExport(filters?: RequestFilters): Promise<any[]> { export async function fetchAllRequestsForExport(filters?: RequestFilters): Promise<any[]> {
const allInitiatorPages: any[] = []; const allPages: any[] = [];
const allParticipantPages: any[] = []; let hasMore = true;
let hasMoreInitiator = true; let currentPage = 1;
let hasMoreParticipant = true;
const maxPages = 100; // Safety limit const maxPages = 100; // Safety limit
// Build filter params for backend API // Build filter params for backend API
@ -181,98 +112,40 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate; if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate; if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
// Fetch initiator requests // Fetch all pages using the single optimized endpoint
const initiatorFetch = async () => { while (hasMore && currentPage <= maxPages) {
let page = 1; const pageResult = await workflowApi.listParticipantRequests({
while (hasMoreInitiator && page <= maxPages) { page: currentPage,
const pageResult = await workflowApi.listMyInitiatedWorkflows({ limit: EXPORT_FETCH_LIMIT,
page, ...backendFilters
limit: EXPORT_FETCH_LIMIT, });
search: backendFilters.search,
status: backendFilters.status,
priority: backendFilters.priority,
department: backendFilters.department,
slaCompliance: backendFilters.slaCompliance,
dateRange: backendFilters.dateRange,
startDate: backendFilters.startDate,
endDate: backendFilters.endDate
});
const pageData = pageResult?.data || []; let pageData: any[] = [];
if (pageData.length === 0) { if (Array.isArray(pageResult?.data)) {
hasMoreInitiator = false; pageData = pageResult.data;
} else if (Array.isArray(pageResult)) {
pageData = pageResult;
}
if (pageData.length === 0) {
hasMore = false;
} else {
// Filter out drafts
const nonDraftData = pageData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus !== 'DRAFT';
});
allPages.push(...nonDraftData);
currentPage++;
if (pageResult?.pagination) {
hasMore = currentPage <= pageResult.pagination.totalPages;
} else { } else {
allInitiatorPages.push(...pageData); hasMore = pageData.length === EXPORT_FETCH_LIMIT;
page++;
if (pageData.length < EXPORT_FETCH_LIMIT) {
hasMoreInitiator = false;
}
} }
} }
}; }
// Fetch participant requests return allPages;
const participantFetch = async () => {
let page = 1;
while (hasMoreParticipant && page <= maxPages) {
const pageResult = await workflowApi.listParticipantRequests({
page,
limit: EXPORT_FETCH_LIMIT,
...backendFilters
});
const pageData = pageResult?.data || [];
if (pageData.length === 0) {
hasMoreParticipant = false;
} else {
allParticipantPages.push(...pageData);
page++;
if (pageData.length < EXPORT_FETCH_LIMIT) {
hasMoreParticipant = false;
}
}
}
};
// Fetch both in parallel
await Promise.all([initiatorFetch(), participantFetch()]);
// Filter out drafts
const nonDraftInitiator = allInitiatorPages.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus !== 'DRAFT';
});
const nonDraftParticipant = allParticipantPages.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus !== 'DRAFT';
});
// Merge and deduplicate by requestId
const mergedMap = new Map<string, any>();
nonDraftInitiator.forEach((req: any) => {
const requestId = req.requestId || req.id;
if (requestId) {
mergedMap.set(requestId, req);
}
});
nonDraftParticipant.forEach((req: any) => {
const requestId = req.requestId || req.id;
if (requestId) {
mergedMap.set(requestId, req);
}
});
// Convert to array and sort by date
const mergedData = Array.from(mergedMap.values());
mergedData.sort((a: any, b: any) => {
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
return dateB - dateA;
});
return mergedData;
} }

View File

@ -25,6 +25,7 @@ export interface RequestFilters {
export interface RequestStats { export interface RequestStats {
total: number; total: number;
pending: number; pending: number;
paused: number;
approved: number; approved: number;
rejected: number; rejected: number;
draft: number; draft: number;
@ -34,6 +35,7 @@ export interface RequestStats {
export interface BackendStats { export interface BackendStats {
total: number; total: number;
pending: number; pending: number;
paused: number;
approved: number; approved: number;
rejected: number; rejected: number;
draft: number; draft: number;

View File

@ -41,9 +41,15 @@ export function calculateStatsFromFilteredData(
return status === 'CLOSED'; return status === 'CLOSED';
}).length; }).length;
const paused = allFilteredRequests.filter((r: any) => {
const status = (r.status || '').toString().toUpperCase();
return status === 'PAUSED';
}).length;
return { return {
total: total, // Total based on other filters (priority, department, etc.) total: total, // Total based on other filters (priority, department, etc.)
pending, pending,
paused,
approved, approved,
rejected, rejected,
draft, draft,
@ -54,6 +60,7 @@ export function calculateStatsFromFilteredData(
return { return {
total: backendStats.total, total: backendStats.total,
pending: backendStats.pending, pending: backendStats.pending,
paused: backendStats.paused || 0,
approved: backendStats.approved, approved: backendStats.approved,
rejected: backendStats.rejected, rejected: backendStats.rejected,
draft: backendStats.draft, draft: backendStats.draft,
@ -67,6 +74,7 @@ export function calculateStatsFromFilteredData(
return { return {
total: total, total: total,
pending: convertedRequests.filter(r => r.status === 'pending' || r.status === 'in-progress').length, pending: convertedRequests.filter(r => r.status === 'pending' || r.status === 'in-progress').length,
paused: convertedRequests.filter(r => r.status === 'paused').length,
approved: convertedRequests.filter(r => r.status === 'approved').length, approved: convertedRequests.filter(r => r.status === 'approved').length,
rejected: convertedRequests.filter(r => r.status === 'rejected').length, rejected: convertedRequests.filter(r => r.status === 'rejected').length,
draft: convertedRequests.filter(r => r.status === 'draft').length, draft: convertedRequests.filter(r => r.status === 'draft').length,

View File

@ -37,16 +37,26 @@ export function applyFilters(data: any[], filters: RequestFilters): any[] {
if (filters.status.toLowerCase() === 'pending') { if (filters.status.toLowerCase() === 'pending') {
filteredData = filteredData.filter((req: any) => { filteredData = filteredData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase(); const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus === 'PENDING' || reqStatus === 'IN_PROGRESS'; const isPaused = req.pauseInfo?.isPaused || req.isPaused || false;
// IN_PROGRESS is treated as PENDING (legacy support during migration)
return (reqStatus === 'PENDING' || reqStatus === 'IN_PROGRESS') && !isPaused;
});
} else if (filters.status.toLowerCase() === 'paused') {
filteredData = filteredData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
const isPaused = req.pauseInfo?.isPaused || req.isPaused || false;
// Check both status enum and isPaused flag for compatibility
return reqStatus === 'PAUSED' || isPaused;
}); });
} else { } else {
const statusUpper = filters.status.toUpperCase().replace('-', '_'); const statusUpper = filters.status.toUpperCase().replace('-', '_');
filteredData = filteredData.filter((req: any) => { filteredData = filteredData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase(); const reqStatus = (req.status || '').toString().toUpperCase();
const isPaused = req.pauseInfo?.isPaused || req.isPaused || false;
if (statusUpper === 'IN_PROGRESS' || statusUpper === 'IN-PROGRESS') { if (statusUpper === 'IN_PROGRESS' || statusUpper === 'IN-PROGRESS') {
return reqStatus === 'IN_PROGRESS'; return reqStatus === 'IN_PROGRESS' && !isPaused;
} }
return reqStatus === statusUpper; return reqStatus === statusUpper && !isPaused; // Exclude paused from other statuses
}); });
} }
} }

View File

@ -1,9 +1,11 @@
import { configureStore } from '@reduxjs/toolkit'; import { configureStore } from '@reduxjs/toolkit';
import authSlice from './slices/authSlice'; import authSlice from './slices/authSlice';
import dashboardSlice from '../pages/Dashboard/redux/dashboardSlice';
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: {
auth: authSlice.reducer, auth: authSlice.reducer,
dashboard: dashboardSlice.reducer,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ getDefaultMiddleware({

View File

@ -18,12 +18,23 @@ const apiClient: AxiosInstance = axios.create({
}); });
// Request interceptor to add access token // Request interceptor to add access token
// In production: No header needed - httpOnly cookies are sent automatically via withCredentials
// In development: Add Authorization header from localStorage
apiClient.interceptors.request.use( apiClient.interceptors.request.use(
(config) => { (config) => {
const token = TokenManager.getAccessToken(); // In production, cookies are sent automatically with withCredentials: true
if (token) { // No need to set Authorization header
config.headers.Authorization = `Bearer ${token}`; const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
if (!isProduction) {
// Development: Get token from localStorage and add to header
const token = TokenManager.getAccessToken();
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
} }
// Production: Cookies handle authentication automatically
return config; return config;
}, },
(error) => { (error) => {
@ -56,26 +67,40 @@ apiClient.interceptors.response.use(
// If error is 401 and we haven't retried yet // If error is 401 and we haven't retried yet
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true; originalRequest._retry = true;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
try { try {
// Attempt to refresh token // Attempt to refresh token
// In production: Cookie is sent automatically via withCredentials
// In development: Send refresh token from localStorage
const refreshToken = TokenManager.getRefreshToken(); const refreshToken = TokenManager.getRefreshToken();
if (!refreshToken) {
// In production, refreshToken will be null but cookie will be sent
// In development, we need the token in body
if (!isProduction && !refreshToken) {
throw new Error('No refresh token available'); throw new Error('No refresh token available');
} }
const response = await axios.post( const response = await axios.post(
`${API_BASE_URL}/auth/refresh`, `${API_BASE_URL}/auth/refresh`,
{ refreshToken }, isProduction ? {} : { refreshToken }, // Empty body in production, cookie is used
{ withCredentials: true } { withCredentials: true }
); );
const { accessToken } = response.data.data || response.data; const responseData = response.data.data || response.data;
const accessToken = responseData.accessToken;
// In production: Backend sets new httpOnly cookie, no token in response
// In development: Token is in response, store it
if (accessToken) { if (accessToken) {
TokenManager.setAccessToken(accessToken); TokenManager.setAccessToken(accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`; originalRequest.headers.Authorization = `Bearer ${accessToken}`;
return apiClient(originalRequest);
} }
// Retry the original request
// In production: Cookie will be sent automatically
return apiClient(originalRequest);
} catch (refreshError) { } catch (refreshError) {
// Refresh failed, clear tokens and redirect to login // Refresh failed, clear tokens and redirect to login
TokenManager.clearAll(); TokenManager.clearAll();
@ -145,18 +170,25 @@ export async function exchangeCodeForTokens(
const data = response.data as any; const data = response.data as any;
const result = data.data || data; const result = data.data || data;
// Tokens are set as httpOnly cookies by backend, but we also store them here for client access // Store user data (always available)
if (result.user) {
TokenManager.setUserData(result.user);
}
// Store ID token if available (needed for Okta logout)
if (result.idToken) {
TokenManager.setIdToken(result.idToken);
}
// SECURITY: In production, tokens are ONLY in httpOnly cookies (not in response body)
// In development, backend returns tokens for cross-port setup
if (result.accessToken && result.refreshToken) { if (result.accessToken && result.refreshToken) {
// Development mode: Backend returned tokens, store them
TokenManager.setAccessToken(result.accessToken); TokenManager.setAccessToken(result.accessToken);
TokenManager.setRefreshToken(result.refreshToken); TokenManager.setRefreshToken(result.refreshToken);
TokenManager.setUserData(result.user);
// Store id_token if available (needed for proper Okta logout)
if (result.idToken) {
TokenManager.setIdToken(result.idToken);
}
} else {
console.warn('⚠️ Tokens missing in response', { result });
} }
// Production mode: No tokens in response - they're in httpOnly cookies
// TokenManager.setAccessToken/setRefreshToken are no-ops in production anyway
return result; return result;
} catch (error: any) { } catch (error: any) {

View File

@ -6,6 +6,7 @@ export interface RequestStats {
approvedRequests: number; approvedRequests: number;
rejectedRequests: number; rejectedRequests: number;
closedRequests: number; closedRequests: number;
pausedRequests?: number;
draftRequests: number; draftRequests: number;
changeFromPrevious: { changeFromPrevious: {
total: string; total: string;
@ -195,7 +196,8 @@ class DashboardService {
approver?: string, approver?: string,
approverType?: 'current' | 'any', approverType?: 'current' | 'any',
search?: string, search?: string,
slaCompliance?: string slaCompliance?: string,
viewAsUser?: boolean
): Promise<RequestStats> { ): Promise<RequestStats> {
try { try {
const params: any = { dateRange }; const params: any = { dateRange };
@ -228,6 +230,10 @@ class DashboardService {
if (slaCompliance && slaCompliance !== 'all') { if (slaCompliance && slaCompliance !== 'all') {
params.slaCompliance = slaCompliance; params.slaCompliance = slaCompliance;
} }
// Pass viewAsUser flag to tell backend to treat admin as normal user
if (viewAsUser) {
params.viewAsUser = 'true';
}
const response = await apiClient.get('/dashboard/stats/requests', { params }); const response = await apiClient.get('/dashboard/stats/requests', { params });
return response.data.data; return response.data.data;
} catch (error) { } catch (error) {

View File

@ -324,6 +324,35 @@ export async function addSpectator(requestId: string, email: string) {
return res.data?.data || res.data; return res.data?.data || res.data;
} }
export async function pauseWorkflow(
requestId: string,
levelId: string | null,
reason: string,
resumeDate: Date
) {
const res = await apiClient.post(`/workflows/${requestId}/pause`, {
levelId,
reason,
resumeDate: resumeDate.toISOString()
});
return res.data?.data || res.data;
}
export async function resumeWorkflow(requestId: string) {
const res = await apiClient.post(`/workflows/${requestId}/resume`);
return res.data?.data || res.data;
}
export async function retriggerPause(requestId: string) {
const res = await apiClient.post(`/workflows/${requestId}/pause/retrigger`);
return res.data?.data || res.data;
}
export async function getPauseDetails(requestId: string) {
const res = await apiClient.get(`/workflows/${requestId}/pause`);
return res.data?.data || res.data;
}
export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string { export function getWorkNoteAttachmentPreviewUrl(attachmentId: string): string {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000'; const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`; return `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/preview`;
@ -336,16 +365,24 @@ export function getDocumentPreviewUrl(documentId: string): string {
export async function downloadDocument(documentId: string): Promise<void> { export async function downloadDocument(documentId: string): Promise<void> {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000'; const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
const token = localStorage.getItem('accessToken');
const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`; const downloadUrl = `${baseURL}/api/v1/workflows/documents/${documentId}/download`;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
try { try {
const response = await fetch(downloadUrl, { // Build fetch options
headers: { const fetchOptions: RequestInit = {
credentials: 'include', // Send cookies in production
};
// In development, add Authorization header from localStorage
if (!isProduction) {
const token = localStorage.getItem('accessToken');
fetchOptions.headers = {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} };
}); }
const response = await fetch(downloadUrl, fetchOptions);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();
@ -373,17 +410,25 @@ export async function downloadDocument(documentId: string): Promise<void> {
} }
export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> { export async function downloadWorkNoteAttachment(attachmentId: string): Promise<void> {
const baseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000'; const downloadBaseURL = import.meta.env.VITE_BASE_URL || 'http://localhost:5000';
const token = localStorage.getItem('accessToken'); const downloadUrl = `${downloadBaseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
const downloadUrl = `${baseURL}/api/v1/workflows/work-notes/attachments/${attachmentId}/download`;
try { try {
const response = await fetch(downloadUrl, { // Build fetch options
headers: { const fetchOptions: RequestInit = {
credentials: 'include', // Send cookies in production
};
// In development, add Authorization header from localStorage
if (!isProduction) {
const token = localStorage.getItem('accessToken');
fetchOptions.headers = {
'Authorization': `Bearer ${token}` 'Authorization': `Bearer ${token}`
} };
}); }
const response = await fetch(downloadUrl, fetchOptions);
if (!response.ok) { if (!response.ok) {
const errorText = await response.text(); const errorText = await response.text();

View File

@ -1,8 +1,8 @@
// Type definitions for Royal Enfield Approval Portal // Type definitions for Royal Enfield Approval Portal
export type Priority = 'express' | 'urgent' | 'standard'; export type Priority = 'express' | 'urgent' | 'standard';
export type Status = 'pending' | 'in-review' | 'approved' | 'rejected' | 'cancelled'; export type Status = 'pending' | 'in-review' | 'approved' | 'rejected' | 'cancelled' | 'paused';
export type ApprovalStatus = 'pending' | 'waiting' | 'approved' | 'rejected' | 'cancelled'; export type ApprovalStatus = 'pending' | 'waiting' | 'approved' | 'rejected' | 'cancelled' | 'paused';
export interface Initiator { export interface Initiator {
name: string; name: string;
@ -56,6 +56,23 @@ export interface AuditTrailItem {
timestamp: string; timestamp: string;
} }
export interface PauseInfo {
isPaused: boolean;
pausedAt?: string;
pausedBy?: {
userId: string;
email: string;
name: string;
};
pauseReason?: string;
pauseResumeDate?: string;
level?: {
levelId: string;
levelNumber: number;
approverName: string;
};
}
export interface BaseRequest { export interface BaseRequest {
id: string; id: string;
title: string; title: string;
@ -81,6 +98,7 @@ export interface BaseRequest {
spectators: Spectator[]; spectators: Spectator[];
auditTrail: AuditTrailItem[]; auditTrail: AuditTrailItem[];
tags: string[]; tags: string[];
pauseInfo?: PauseInfo;
} }
export interface CustomRequest extends BaseRequest { export interface CustomRequest extends BaseRequest {

View File

@ -8,6 +8,11 @@ const REFRESH_TOKEN_KEY = 'refreshToken';
const ID_TOKEN_KEY = 'idToken'; const ID_TOKEN_KEY = 'idToken';
const USER_DATA_KEY = 'userData'; const USER_DATA_KEY = 'userData';
// Check if running in production mode
const isProduction = (): boolean => {
return import.meta.env.PROD || import.meta.env.MODE === 'production';
};
/** /**
* Cookie utility functions * Cookie utility functions
*/ */
@ -72,84 +77,97 @@ export const cookieUtils = {
/** /**
* Token Manager - Handles token storage and retrieval * Token Manager - Handles token storage and retrieval
*
* SECURITY MODES:
* - Production: Tokens stored in httpOnly cookies by backend only
* Frontend does NOT store access/refresh tokens anywhere
* All API requests rely on cookies being sent automatically
*
* - Development: Tokens stored in localStorage for debugging
* Needed because frontend/backend run on different ports
*/ */
export class TokenManager { export class TokenManager {
/** /**
* Store access token * Store access token
* In production: No-op (backend handles via httpOnly cookies)
* In development: Store in localStorage for Authorization header
*/ */
static setAccessToken(token: string): void { static setAccessToken(token: string): void {
if (this.isLocalhost()) { // SECURITY: In production, don't store tokens client-side
// Store in cookie for localhost (backend sets httpOnly cookie, but we also store here for client-side access) // Backend sets httpOnly cookies that are sent automatically
localStorage.setItem(ACCESS_TOKEN_KEY, token); if (isProduction()) {
cookieUtils.set(ACCESS_TOKEN_KEY, token, 1); // 1 day return; // No-op - rely on httpOnly cookies
} else {
localStorage.setItem(ACCESS_TOKEN_KEY, token);
} }
// Development only: Store for debugging and cross-port requests
localStorage.setItem(ACCESS_TOKEN_KEY, token);
} }
/** /**
* Get access token * Get access token
* In production: Returns null (cookies are sent automatically)
* In development: Returns from localStorage
*/ */
static getAccessToken(): string | null { static getAccessToken(): string | null {
if (this.isLocalhost()) { // SECURITY: In production, return null - cookies are used instead
// Try cookie first (set by backend), then localStorage if (isProduction()) {
return cookieUtils.get(ACCESS_TOKEN_KEY) || localStorage.getItem(ACCESS_TOKEN_KEY); return null; // API calls use cookies via withCredentials: true
} }
// Development: Return from localStorage
return localStorage.getItem(ACCESS_TOKEN_KEY); return localStorage.getItem(ACCESS_TOKEN_KEY);
} }
/** /**
* Store refresh token * Store refresh token
* In production: No-op (backend handles via httpOnly cookies)
* In development: Store in localStorage
*/ */
static setRefreshToken(token: string): void { static setRefreshToken(token: string): void {
if (this.isLocalhost()) { // SECURITY: In production, don't store tokens client-side
localStorage.setItem(REFRESH_TOKEN_KEY, token); if (isProduction()) {
cookieUtils.set(REFRESH_TOKEN_KEY, token, 7); // 7 days return; // No-op - rely on httpOnly cookies
} else {
localStorage.setItem(REFRESH_TOKEN_KEY, token);
} }
// Development only
localStorage.setItem(REFRESH_TOKEN_KEY, token);
} }
/** /**
* Get refresh token * Get refresh token
* In production: Returns null (cookies are used)
* In development: Returns from localStorage
*/ */
static getRefreshToken(): string | null { static getRefreshToken(): string | null {
if (this.isLocalhost()) { // SECURITY: In production, return null - backend reads from cookie
// Try cookie first (set by backend), then localStorage if (isProduction()) {
return cookieUtils.get(REFRESH_TOKEN_KEY) || localStorage.getItem(REFRESH_TOKEN_KEY); return null;
} }
return localStorage.getItem(REFRESH_TOKEN_KEY); return localStorage.getItem(REFRESH_TOKEN_KEY);
} }
/** /**
* Store ID token (from Okta) * Store ID token (from Okta) - needed for logout
* Stored in sessionStorage (cleared when tab closes)
*/ */
static setIdToken(token: string): void { static setIdToken(token: string): void {
localStorage.setItem(ID_TOKEN_KEY, token); // ID token is needed for Okta logout, use sessionStorage (more secure than localStorage)
if (this.isLocalhost()) { sessionStorage.setItem(ID_TOKEN_KEY, token);
cookieUtils.set(ID_TOKEN_KEY, token, 1); // 1 day
}
} }
/** /**
* Get ID token * Get ID token
*/ */
static getIdToken(): string | null { static getIdToken(): string | null {
if (this.isLocalhost()) { return sessionStorage.getItem(ID_TOKEN_KEY);
// Try cookie first, then localStorage
return cookieUtils.get(ID_TOKEN_KEY) || localStorage.getItem(ID_TOKEN_KEY);
}
return localStorage.getItem(ID_TOKEN_KEY);
} }
/** /**
* Store user data * Store user data (not sensitive - can be stored in localStorage)
*/ */
static setUserData(user: any): void { static setUserData(user: any): void {
localStorage.setItem(USER_DATA_KEY, JSON.stringify(user)); localStorage.setItem(USER_DATA_KEY, JSON.stringify(user));
if (this.isLocalhost()) {
cookieUtils.set(USER_DATA_KEY, JSON.stringify(user), 7);
}
} }
/** /**
@ -167,9 +185,15 @@ export class TokenManager {
/** /**
* Clear all tokens and user data * Clear all tokens and user data
* This includes localStorage, sessionStorage, cookies, and any auth-related data *
* Uses aggressive clearing to ensure ALL data is removed * PRODUCTION MODE:
* IMPORTANT: This also sets a flag to prevent auto-authentication * - Clears user data from localStorage
* - Clears ID token from sessionStorage
* - Backend logout endpoint clears httpOnly cookies
*
* DEVELOPMENT MODE:
* - Clears all localStorage and sessionStorage
* - Clears client-side cookies
*/ */
static clearAll(): void { static clearAll(): void {
// CRITICAL: Set logout flag in sessionStorage FIRST (before clearing) // CRITICAL: Set logout flag in sessionStorage FIRST (before clearing)
@ -181,7 +205,28 @@ export class TokenManager {
console.warn('Could not set logout flags:', e); console.warn('Could not set logout flags:', e);
} }
// Step 1: Clear specific auth-related localStorage keys // Clear user data (stored in both modes)
try {
localStorage.removeItem(USER_DATA_KEY);
sessionStorage.removeItem(ID_TOKEN_KEY);
} catch (e) {
console.warn('Error clearing user data:', e);
}
// In production, httpOnly cookies are cleared by backend
// Only need to clear user data above
if (isProduction()) {
// Restore logout flags after clearing
try {
sessionStorage.setItem('__logout_in_progress__', 'true');
sessionStorage.setItem('__force_logout__', 'true');
} catch (e) {
// Ignore
}
return;
}
// DEVELOPMENT MODE: Clear everything
const authKeys = [ const authKeys = [
ACCESS_TOKEN_KEY, ACCESS_TOKEN_KEY,
REFRESH_TOKEN_KEY, REFRESH_TOKEN_KEY,
@ -194,11 +239,7 @@ export class TokenManager {
'access_token', 'access_token',
'refresh_token', 'refresh_token',
'id_token', 'id_token',
'idToken',
'token', 'token',
'accessToken',
'refreshToken',
'userData',
'auth', 'auth',
'authentication', 'authentication',
'persist:root', 'persist:root',
@ -215,131 +256,55 @@ export class TokenManager {
} }
}); });
// Step 2: Clear ALL localStorage items by iterating and removing // Clear ALL localStorage
try { try {
const allLocalStorageKeys: string[] = [];
// Get all keys
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key) {
allLocalStorageKeys.push(key);
}
}
// Remove each key explicitly
allLocalStorageKeys.forEach(key => {
try {
localStorage.removeItem(key);
} catch (e) {
console.warn(`Error removing localStorage key ${key}:`, e);
}
});
// Final clear as backup
localStorage.clear(); localStorage.clear();
} catch (e) { } catch (e) {
console.error('Error clearing localStorage:', e); console.error('Error clearing localStorage:', e);
} }
// Step 3: Clear ALL sessionStorage items // Clear ALL sessionStorage except logout flags
try { try {
const allSessionStorageKeys: string[] = []; const keysToKeep = ['__logout_in_progress__', '__force_logout__'];
// Get all keys const allKeys: string[] = [];
for (let i = 0; i < sessionStorage.length; i++) { for (let i = 0; i < sessionStorage.length; i++) {
const key = sessionStorage.key(i); const key = sessionStorage.key(i);
if (key) { if (key && !keysToKeep.includes(key)) {
allSessionStorageKeys.push(key); allKeys.push(key);
} }
} }
allKeys.forEach(key => sessionStorage.removeItem(key));
// Remove each key explicitly
allSessionStorageKeys.forEach(key => {
try {
sessionStorage.removeItem(key);
} catch (e) {
console.warn(`Error removing sessionStorage key ${key}:`, e);
}
});
// Final clear as backup
sessionStorage.clear();
} catch (e) { } catch (e) {
console.error('Error clearing sessionStorage:', e); console.error('Error clearing sessionStorage:', e);
} }
// Step 4: Clear cookies (both client-side and attempt to clear httpOnly cookies) // Clear client-side cookies (development only)
cookieUtils.clearAll(); cookieUtils.clearAll();
// Step 5: Aggressively clear ALL cookies including httpOnly ones
// Note: httpOnly cookies can only be cleared by backend, but we try everything
const cookieNames = [
'accessToken', 'refreshToken', 'userData', 'oktaToken', 'authToken',
'id_token', 'token', 'access_token', 'refresh_token'
];
const hostname = window.location.hostname;
const paths = ['/', '/login', '/login/callback', '/api', '/api/v1'];
const domains = ['', hostname, `.${hostname}`];
// Try every combination of path, domain, and secure flags
cookieNames.forEach(name => {
paths.forEach(path => {
domains.forEach(domain => {
// Try without secure flag
try {
let cookieString = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=Lax;`;
if (domain && domain !== 'localhost' && domain !== '127.0.0.1' && !domain.startsWith('.')) {
cookieString += ` domain=${domain};`;
}
document.cookie = cookieString;
} catch (e) {
// Ignore errors
}
// Try with secure flag
try {
let cookieString = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=None; Secure;`;
if (domain && domain !== 'localhost' && domain !== '127.0.0.1') {
cookieString += ` domain=${domain};`;
}
document.cookie = cookieString;
} catch (e) {
// Ignore errors
}
// Try with SameSite=Strict
try {
let cookieString = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=${path}; SameSite=Strict;`;
if (domain && domain !== 'localhost' && domain !== '127.0.0.1' && !domain.startsWith('.')) {
cookieString += ` domain=${domain};`;
}
document.cookie = cookieString;
} catch (e) {
// Ignore errors
}
});
});
});
// Note: httpOnly cookies can only be cleared by backend - backend logout endpoint should handle this
// Step 6: Verify cleanup
if (localStorage.length > 0 || sessionStorage.length > 0) {
console.warn('WARNING: Storage not fully cleared!');
}
} }
/** /**
* Check if access token exists * Check if access token exists
* In production: Always returns true if user data exists (tokens are in httpOnly cookies)
* In development: Checks localStorage
*/ */
static hasAccessToken(): boolean { static hasAccessToken(): boolean {
if (isProduction()) {
// In production, we can't check httpOnly cookies from JS
// Use presence of user data as proxy for authentication
return !!this.getUserData();
}
return !!this.getAccessToken(); return !!this.getAccessToken();
} }
/** /**
* Check if refresh token exists * Check if refresh token exists
* In production: Always returns true if user data exists
* In development: Checks localStorage
*/ */
static hasRefreshToken(): boolean { static hasRefreshToken(): boolean {
if (isProduction()) {
return !!this.getUserData();
}
return !!this.getRefreshToken(); return !!this.getRefreshToken();
} }
@ -353,6 +318,13 @@ export class TokenManager {
window.location.hostname === '' window.location.hostname === ''
); );
} }
/**
* Check if we're in production mode
*/
static isProduction(): boolean {
return isProduction();
}
} }
/** /**