tat pause resume job logic altered

This commit is contained in:
laxmanhalaki 2025-11-28 19:14:35 +05:30
parent fe441c8b76
commit 91e028fb18
21 changed files with 561 additions and 190 deletions

View File

@ -54,7 +54,8 @@ export function FilePreview({
setError(null);
try {
const token = localStorage.getItem('accessToken');
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
const token = isProduction ? null : localStorage.getItem('accessToken');
// Ensure we have a valid URL - handle relative URLs when served from same origin
let urlToFetch = fileUrl;
@ -63,13 +64,20 @@ export function FilePreview({
urlToFetch = `${window.location.origin}${fileUrl}`;
}
const response = await fetch(urlToFetch, {
headers: {
'Authorization': `Bearer ${token}`,
// Build headers - in production, rely on httpOnly cookies
const headers: HeadersInit = {
'Accept': isPDF ? 'application/pdf' : '*/*'
},
credentials: 'include', // Include credentials for same-origin requests
mode: 'cors' // Explicitly set CORS mode
};
// Only add Authorization header in development mode
if (!isProduction && token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(urlToFetch, {
headers,
credentials: 'include', // Always include credentials for cookie-based auth
mode: 'cors'
});
if (!response.ok) {

View File

@ -22,14 +22,14 @@ export function SLATracker({ startDate, deadline, priority, className = '', show
const getProgressColor = () => {
if (slaStatus.progress >= 100) return 'bg-red-500';
if (slaStatus.progress >= 75) return 'bg-orange-500';
if (slaStatus.progress >= 50) return 'bg-yellow-500';
if (slaStatus.progress >= 50) return 'bg-amber-500'; // Using amber for better visibility
return 'bg-green-500';
};
const getStatusBadgeColor = () => {
if (slaStatus.progress >= 100) return 'bg-red-100 text-red-800 border-red-200';
if (slaStatus.progress >= 75) return 'bg-orange-100 text-orange-800 border-orange-200';
if (slaStatus.progress >= 50) return 'bg-yellow-100 text-yellow-800 border-yellow-200';
if (slaStatus.progress >= 50) return 'bg-amber-100 text-amber-800 border-amber-200'; // Using amber
return 'bg-green-100 text-green-800 border-green-200';
};

View File

@ -11,6 +11,7 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Textarea } from '@/components/ui/textarea';
import { FilePreview } from '@/components/common/FilePreview';
import { ActionStatusModal } from '@/components/common/ActionStatusModal';
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
import { AddApproverModal } from '@/components/participant/AddApproverModal';
import { formatDateTime } from '@/utils/dateFormatter';
@ -154,6 +155,12 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
const [previewFile, setPreviewFile] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; attachmentId?: string } | null>(null);
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
const [showAddApproverModal, setShowAddApproverModal] = useState(false);
const [showActionStatusModal, setShowActionStatusModal] = useState(false);
const [actionStatus, setActionStatus] = useState<{ success: boolean; title: string; message: string }>({
success: true,
title: '',
message: ''
});
const messagesEndRef = useRef<HTMLDivElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const socketRef = useRef<any>(null);
@ -1005,10 +1012,22 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
}
}
setShowAddSpectatorModal(false);
alert('Spectator added successfully');
// Show success modal
setActionStatus({
success: true,
title: 'Spectator Added',
message: 'Spectator added successfully. They can now view this request.'
});
setShowActionStatusModal(true);
} catch (error: any) {
console.error('Failed to add spectator:', error);
alert(error?.response?.data?.error || 'Failed to add spectator');
// Show error modal
setActionStatus({
success: false,
title: 'Failed to Add Spectator',
message: error?.response?.data?.error || 'Failed to add spectator. Please try again.'
});
setShowActionStatusModal(true);
throw error;
}
};
@ -1052,10 +1071,22 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
}
}
setShowAddApproverModal(false);
alert(`Approver added successfully at Level ${level} with ${tatHours}h TAT`);
// Show success modal
setActionStatus({
success: true,
title: 'Approver Added',
message: `Approver added successfully at Level ${level} with ${tatHours}h TAT`
});
setShowActionStatusModal(true);
} catch (error: any) {
console.error('Failed to add approver:', error);
alert(error?.response?.data?.error || 'Failed to add approver');
// Show error modal
setActionStatus({
success: false,
title: 'Failed to Add Approver',
message: error?.response?.data?.error || 'Failed to add approver. Please try again.'
});
setShowActionStatusModal(true);
throw error;
}
}
@ -1353,14 +1384,14 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
e.stopPropagation();
if (!attachmentId) {
alert('Cannot download: Attachment ID missing');
toast.error('Cannot download: Attachment ID missing');
return;
}
try {
await downloadWorkNoteAttachment(attachmentId);
} catch (error) {
alert('Failed to download file');
toast.error('Failed to download file');
}
}}
title="Download file"
@ -1828,6 +1859,15 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
</DialogFooter>
</DialogContent>
</Dialog>
{/* Action Status Modal - Success/Error feedback for adding approver/spectator */}
<ActionStatusModal
open={showActionStatusModal}
onClose={() => setShowActionStatusModal(false)}
success={actionStatus.success}
title={actionStatus.title}
message={actionStatus.message}
/>
</div>
);
}

View File

@ -1,5 +1,6 @@
import { useState, useRef, useEffect } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl } from '@/services/workflowApi';
import { toast } from 'sonner';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
import { formatDateTime } from '@/utils/dateFormatter';
import { FilePreview } from '@/components/common/FilePreview';
@ -499,14 +500,14 @@ export function WorkNoteChatSimple({ requestId, messages: externalMessages, onSe
e.stopPropagation();
if (!attachmentId) {
alert('Cannot download: Attachment ID missing');
toast.error('Cannot download: Attachment ID missing');
return;
}
try {
await downloadWorkNoteAttachment(attachmentId);
} catch (error) {
alert('Failed to download file');
toast.error('Failed to download file');
}
}}
title="Download file"

View File

@ -271,16 +271,33 @@ export function ApprovalStepCard({
// If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069%
const displayPercentage = Math.min(100, progressPercentage);
// Determine progress bar color based on percentage
// Green: 0-50%, Yellow: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
const getIndicatorColor = () => {
if (isBreached) return 'bg-red-600';
if (progressPercentage >= 75) return 'bg-orange-500';
if (progressPercentage >= 50) return 'bg-amber-500'; // Using amber instead of yellow for better visibility
return 'bg-green-600';
};
const getProgressTextColor = () => {
if (isBreached) return 'text-red-600';
if (progressPercentage >= 75) return 'text-orange-600';
if (progressPercentage >= 50) return 'text-amber-600';
return 'text-green-600';
};
return (
<>
<Progress
value={displayPercentage}
className={`h-2 bg-gray-200 ${isBreached ? '[&>div]:bg-red-600' : '[&>div]:bg-green-600'}`}
className="h-2 bg-gray-200"
indicatorClassName={getIndicatorColor()}
data-testid={`${testId}-progress-bar`}
/>
<div className="flex items-center justify-between text-xs">
<div className="flex items-center gap-2">
<span className={`font-semibold ${isBreached ? 'text-red-600' : 'text-green-600'}`}>
<span className={`font-semibold ${getProgressTextColor()}`}>
{Math.round(displayPercentage)}% of TAT used
</span>
{isBreached && canEditBreachReason && (
@ -352,9 +369,10 @@ export function ApprovalStepCard({
{/* Current Approver - Time Tracking */}
<div className={`border rounded-lg p-3 ${
approval.sla.status === 'critical' ? 'bg-orange-50 border-orange-200' :
approval.sla.status === 'breached' ? 'bg-red-50 border-red-200' :
'bg-yellow-50 border-yellow-200'
(approval.sla.percentageUsed || 0) >= 100 ? 'bg-red-50 border-red-200' :
(approval.sla.percentageUsed || 0) >= 75 ? 'bg-orange-50 border-orange-200' :
(approval.sla.percentageUsed || 0) >= 50 ? 'bg-amber-50 border-amber-200' :
'bg-green-50 border-green-200'
}`}>
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
<Clock className="w-4 h-4" />
@ -374,22 +392,33 @@ export function ApprovalStepCard({
{/* Progress Bar */}
<div className="space-y-2">
{(() => {
// Determine color based on percentage used
// Green: 0-50%, Amber: 50-75%, Orange: 75-100%, Red: 100%+ (breached)
const percentUsed = approval.sla.percentageUsed || 0;
const getActiveIndicatorColor = () => {
if (percentUsed >= 100) return 'bg-red-600';
if (percentUsed >= 75) return 'bg-orange-500';
if (percentUsed >= 50) return 'bg-amber-500';
return 'bg-green-600';
};
const getActiveTextColor = () => {
if (percentUsed >= 100) return 'text-red-600';
if (percentUsed >= 75) return 'text-orange-600';
if (percentUsed >= 50) return 'text-amber-600';
return 'text-green-600';
};
return (
<>
<Progress
value={approval.sla.percentageUsed}
className={`h-3 ${
approval.sla.status === 'breached' ? '[&>div]:bg-red-600' :
approval.sla.status === 'critical' ? '[&>div]:bg-orange-600' :
'[&>div]:bg-yellow-600'
}`}
className="h-3"
indicatorClassName={getActiveIndicatorColor()}
data-testid={`${testId}-sla-progress`}
/>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className={`text-xs font-semibold ${
approval.sla.status === 'breached' ? 'text-red-600' :
approval.sla.status === 'critical' ? 'text-orange-600' :
'text-yellow-700'
}`}>
<span className={`text-xs font-semibold ${getActiveTextColor()}`}>
Progress: {Math.min(100, approval.sla.percentageUsed)}% of TAT used
</span>
{approval.sla.status === 'breached' && canEditBreachReason && (
@ -419,6 +448,9 @@ export function ApprovalStepCard({
{approval.sla.remainingText} remaining
</span>
</div>
</>
);
})()}
{approval.sla.status === 'breached' && (
<>
<p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
@ -607,8 +639,9 @@ export function ApprovalStepCard({
</p>
)}
{/* Skip Approver Button - Only show for initiator on pending/in-review levels */}
{isInitiator && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
{/* Skip Approver Button - Only show for initiator on pending/in-review/paused levels */}
{/* When paused, initiator can skip the approver which will negate the pause */}
{isInitiator && (isActive || isPaused || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
<Button
variant="outline"
@ -625,7 +658,10 @@ export function ApprovalStepCard({
Skip This Approver
</Button>
<p className="text-[10px] sm:text-xs text-gray-500 mt-1 text-center">
Skip if approver is unavailable and move to next level
{isPaused
? 'Skip this approver to resume the workflow and move to next level'
: 'Skip if approver is unavailable and move to next level'
}
</p>
</div>
)}

View File

@ -14,7 +14,7 @@ interface PauseModalProps {
onClose: () => void;
requestId: string;
levelId: string | null;
onSuccess?: () => void;
onSuccess?: () => void | Promise<void>;
}
export function PauseModal({ isOpen, onClose, requestId, levelId, onSuccess }: PauseModalProps) {
@ -72,9 +72,15 @@ export function PauseModal({ isOpen, onClose, requestId, levelId, onSuccess }: P
setSubmitting(true);
await pauseWorkflow(requestId, levelId, reason.trim(), selectedDate.toDate());
toast.success('Workflow paused successfully');
// Wait for parent to refresh data before closing modal
// This ensures the UI shows updated pause status
if (onSuccess) {
await onSuccess();
}
setReason('');
setResumeDate(getDefaultResumeDate());
onSuccess?.();
onClose();
} catch (error: any) {
console.error('Failed to pause workflow:', error);

View File

@ -10,7 +10,7 @@ interface RetriggerPauseModalProps {
onClose: () => void;
requestId: string;
approverName?: string;
onSuccess?: () => void;
onSuccess?: () => void | Promise<void>;
}
export function RetriggerPauseModal({ isOpen, onClose, requestId, approverName, onSuccess }: RetriggerPauseModalProps) {
@ -21,7 +21,12 @@ export function RetriggerPauseModal({ isOpen, onClose, requestId, approverName,
setSubmitting(true);
await retriggerPause(requestId);
toast.success('Retrigger request sent to approver');
onSuccess?.();
// Wait for parent to refresh data before closing modal
if (onSuccess) {
await onSuccess();
}
onClose();
} catch (error: any) {
console.error('Failed to retrigger pause:', error);

View File

@ -117,7 +117,16 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return;
}
// PRIORITY 3: Check authentication status
// PRIORITY 3: Skip auth check if on callback page - let callback handler process first
// This is critical for production mode where we need to exchange code for tokens
// before we can verify session with server
if (window.location.pathname === '/login/callback') {
// Don't check auth status here - let the callback handler do its job
// The callback handler will set isAuthenticated after successful token exchange
return;
}
// PRIORITY 4: Check authentication status
const token = TokenManager.getAccessToken();
const refreshToken = TokenManager.getRefreshToken();
const userData = TokenManager.getUserData();
@ -144,7 +153,7 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
return;
}
// PRIORITY 4: Only check auth status if we have some auth data AND we're not logging out
// PRIORITY 5: Only check auth status if we have some auth data AND we're not logging out
if (!isLoggingOut) {
checkAuthStatus();
} else {
@ -494,6 +503,27 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
};
const getAccessTokenSilently = async (): Promise<string | null> => {
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
// In production mode, tokens are in httpOnly cookies
// We can't access them directly, but API calls will include them automatically
if (isProductionMode) {
// If user is authenticated, return a placeholder indicating cookies are used
// The actual token is in httpOnly cookie and sent automatically with requests
if (isAuthenticated) {
return 'cookie-based-auth'; // Placeholder - actual auth via cookies
}
// Try to refresh the session
try {
await refreshTokenSilently();
return isAuthenticated ? 'cookie-based-auth' : null;
} catch {
return null;
}
}
// Development mode: tokens in localStorage
const token = TokenManager.getAccessToken();
if (token && !isTokenExpired(token)) {
return token;
@ -509,10 +539,20 @@ function BackendAuthProvider({ children }: { children: ReactNode }) {
};
const refreshTokenSilently = async (): Promise<void> => {
const isProductionMode = import.meta.env.PROD || import.meta.env.MODE === 'production';
try {
const newToken = await refreshAccessToken();
// In production, refresh might not return token (it's in httpOnly cookie)
// but if the call succeeded, the session is valid
if (isProductionMode) {
// Session refreshed via cookies
return;
}
if (newToken) {
// Token refreshed successfully
// Token refreshed successfully (development mode)
return;
}
throw new Error('Failed to refresh token');

View File

@ -32,6 +32,12 @@ export function useRequestDetails(
// State: Indicates if data is currently being fetched
const [refreshing, setRefreshing] = useState(false);
// State: Loading state for initial fetch
const [loading, setLoading] = useState(true);
// State: Access denied information
const [accessDenied, setAccessDenied] = useState<{ denied: boolean; message: string } | null>(null);
// State: Stores the current approval level for the logged-in user
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
@ -208,6 +214,19 @@ export function useRequestDetails(
})
: [];
/**
* Fetch: Get pause details if request is paused
* This is needed to show resume/retrigger buttons correctly
*/
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 with all transformed data
* This object is used throughout the UI
@ -244,6 +263,7 @@ export function useRequestDetails(
auditTrail: filteredActivities,
conclusionRemark: wf.conclusionRemark || null,
closureDate: wf.closureDate || null,
pauseInfo: pauseInfo || null, // Include pause info for resume/retrigger buttons
};
setApiRequest(updatedRequest);
@ -296,14 +316,22 @@ export function useRequestDetails(
* This is the primary data loading mechanism
*/
useEffect(() => {
if (!requestIdentifier) return;
if (!requestIdentifier) {
setLoading(false);
return;
}
let mounted = true;
setLoading(true);
setAccessDenied(null);
(async () => {
try {
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
if (!mounted || !details) return;
if (!mounted || !details) {
if (mounted) setLoading(false);
return;
}
// Use the same transformation logic as refreshDetails
const wf = details.workflow || {};
@ -473,11 +501,21 @@ export function useRequestDetails(
} else {
setIsSpectator(false);
}
} catch (error) {
} catch (error: any) {
console.error('[useRequestDetails] Error loading request details:', error);
if (mounted) {
// Check for 403 Forbidden (Access Denied)
if (error?.response?.status === 403) {
const message = error?.response?.data?.message ||
'You do not have permission to view this request. Access is restricted to the initiator, approvers, and spectators of this request.';
setAccessDenied({ denied: true, message });
}
setApiRequest(null);
}
} finally {
if (mounted) {
setLoading(false);
}
}
})();
@ -585,12 +623,14 @@ export function useRequestDetails(
return {
request,
apiRequest,
loading,
refreshing,
refreshDetails,
currentApprovalLevel,
isSpectator,
isInitiator,
existingParticipants
existingParticipants,
accessDenied
};
}

View File

@ -28,12 +28,9 @@ export function AuthCallback() {
}
} else if (user && isAuthenticated) {
setAuthStep('complete');
// Small delay before redirect for better UX
const timer = setTimeout(() => {
// Use window.location instead of navigate since Router context isn't available yet
window.location.href = '/';
}, 1500);
return () => clearTimeout(timer);
// AuthContext already handles the URL change via replaceState
// No need to do a full page reload which would lose React state
// The AuthenticatedApp will re-render and show the App component
}
}, [isAuthenticated, isLoading, error, user]);
@ -165,7 +162,7 @@ export function AuthCallback() {
{/* Footer Text */}
<p className="mt-6 text-slate-500 text-xs">
{authStep === 'complete' ? 'Redirecting to dashboard...' : 'Please wait while we secure your session'}
{authStep === 'complete' ? 'Loading dashboard...' : 'Please wait while we secure your session'}
</p>
</div>

View File

@ -70,7 +70,7 @@ export function UserKPICards({
testId="kpi-my-requests"
onClick={() => onKPIClick(getFilterParams())}
>
<div className="grid grid-cols-2 gap-1.5 sm:gap-2">
<div className="grid grid-cols-3 gap-1.5 sm:gap-2">
<StatCard
label="Approved"
value={kpis?.requestVolume.approvedRequests || 0}
@ -93,6 +93,17 @@ export function UserKPICards({
onKPIClick({ ...getFilterParams(), status: 'pending' });
}}
/>
<StatCard
label="Paused"
value={kpis?.requestVolume.pausedRequests || 0}
bgColor="bg-amber-50"
textColor="text-amber-600"
testId="stat-user-paused"
onClick={(e) => {
e.stopPropagation();
onKPIClick({ ...getFilterParams(), status: 'paused' });
}}
/>
<StatCard
label="Rejected"
value={kpis?.requestVolume.rejectedRequests || 0}

View File

@ -10,7 +10,7 @@ import type { CriticalAlertData } from '@/components/dashboard/CriticalAlertCard
interface UseDashboardDataOptions {
isAdmin: boolean;
viewAsUser?: boolean; // For admin to view as normal user
userId?: string; // User ID for filtering when viewAsUser is true (not used directly, backend handles it)
userId?: string; // User ID for filtering - needed to fetch user-initiated requests
dateRange: DateRange;
customStartDate?: Date;
customEndDate?: Date;
@ -25,7 +25,7 @@ interface UseDashboardDataOptions {
export function useDashboardData({
isAdmin,
viewAsUser = false,
userId: _userId, // Prefixed with _ to indicate intentionally unused (backend handles userId from auth)
userId, // User ID for filtering user-initiated requests
dateRange,
customStartDate,
customEndDate,
@ -62,6 +62,25 @@ export function useDashboardData({
dashboardService.getUpcomingDeadlines(1, 10, viewAsUser)
];
// For normal users (or admin viewing as user):
// Fetch user-INITIATED requests stats separately for "My Requests" card
// This ensures "My Requests" only shows requests they created, not all requests they're involved in
const userInitiatedPromise = (!isAdmin && userId)
? dashboardService.getRequestStats(
dateRange,
customStartDate?.toISOString(),
customEndDate?.toISOString(),
undefined, // status
undefined, // priority
undefined, // department
userId, // initiator - filter by user's ID to get ONLY their initiated requests
undefined, // approver
undefined, // approverType
undefined, // search
undefined // slaCompliance
)
: null;
// Fetch admin-only data if user is admin
const adminPromises = isAdmin ? [
dashboardService.getDepartmentStats(dateRange, customStartDate, customEndDate),
@ -71,12 +90,23 @@ export function useDashboardData({
] : [];
// Fetch all data in parallel
const results = await Promise.all([...commonPromises, ...adminPromises]);
const [commonResults, userInitiatedStats, adminResults] = await Promise.all([
Promise.all(commonPromises),
userInitiatedPromise,
Promise.all(adminPromises)
]);
const kpisData = results[0] as DashboardKPIs;
const activityResult = results[1] as { activities: RecentActivity[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
const criticalResult = results[2] as { criticalRequests: CriticalRequest[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
const deadlinesResult = results[3] as { deadlines: UpcomingDeadline[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
const kpisData = commonResults[0] as DashboardKPIs;
const activityResult = commonResults[1] as { activities: RecentActivity[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
const criticalResult = commonResults[2] as { criticalRequests: CriticalRequest[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
const deadlinesResult = commonResults[3] as { deadlines: UpcomingDeadline[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
// For normal users: Override requestVolume with user-initiated requests only
// This makes "My Requests" show only requests they created
// Other KPIs (approverLoad, etc.) remain as-is since they need all involved requests
if (!isAdmin && userInitiatedStats) {
kpisData.requestVolume = userInitiatedStats;
}
setKpis(kpisData);
setRecentActivity(activityResult.activities);
@ -101,11 +131,11 @@ export function useDashboardData({
);
// Only set admin-specific data if user is admin
if (isAdmin && results.length >= 8) {
const deptStats = results[4] as DepartmentStats[];
const priorityDist = results[5] as PriorityDistribution[];
const aiUtilization = results[6] as AIRemarkUtilization;
const approverResult = results[7] as { performance: ApproverPerformance[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
if (isAdmin && adminResults.length >= 4) {
const deptStats = adminResults[0] as DepartmentStats[];
const priorityDist = adminResults[1] as PriorityDistribution[];
const aiUtilization = adminResults[2] as AIRemarkUtilization;
const approverResult = adminResults[3] as { performance: ApproverPerformance[]; pagination: { currentPage: number; totalPages: number; totalRecords: number; limit: number } };
setDepartmentStats(deptStats);
setPriorityDistribution(priorityDist);
setAiRemarkUtilization(aiUtilization);
@ -128,7 +158,7 @@ export function useDashboardData({
setLoading(false);
setRefreshing(false);
}
}, [isAdmin, viewAsUser, dateRange, customStartDate, customEndDate]);
}, [isAdmin, viewAsUser, userId, dateRange, customStartDate, customEndDate]);
// Fetch individual data with pagination
const fetchRecentActivities = useCallback(async (page: number = 1) => {

View File

@ -23,6 +23,9 @@ import {
MessageSquare,
AlertTriangle,
FileCheck,
ShieldX,
RefreshCw,
ArrowLeft,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
@ -116,12 +119,14 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
const {
request,
apiRequest,
loading: requestLoading,
refreshing,
refreshDetails,
currentApprovalLevel,
isSpectator,
isInitiator,
existingParticipants,
accessDenied,
} = useRequestDetails(requestIdentifier, dynamicRequests, user);
const {
@ -193,17 +198,23 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
setShowPauseModal(true);
};
const [resuming, setResuming] = useState(false);
const handleResume = async () => {
if (!apiRequest?.requestId) {
toast.error('Request ID not found');
return;
}
try {
setResuming(true);
await resumeWorkflow(apiRequest.requestId);
toast.success('Workflow resumed successfully');
refreshDetails();
// Wait for refresh to complete before clearing loading state
await refreshDetails();
} catch (error: any) {
toast.error(error?.response?.data?.error || 'Failed to resume workflow');
} finally {
setResuming(false);
}
};
@ -212,11 +223,13 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
};
const handlePauseSuccess = async () => {
refreshDetails();
// Wait for refresh to complete to show updated pause status
await refreshDetails();
};
const handleRetriggerSuccess = async () => {
refreshDetails();
// Wait for refresh to complete
await refreshDetails();
};
const handleShareSummary = async () => {
@ -312,27 +325,89 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
}));
// Loading state
if (!request && !apiRequest) {
if (requestLoading && !request && !apiRequest) {
return (
<div className="flex items-center justify-center h-screen" data-testid="loading-state">
<div className="flex items-center justify-center h-screen bg-gray-50" data-testid="loading-state">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mx-auto mb-4"></div>
<RefreshCw className="w-12 h-12 text-blue-600 animate-spin mx-auto mb-4" />
<p className="text-gray-600">Loading request details...</p>
</div>
</div>
);
}
// Error state
if (!request) {
// Access Denied state
if (accessDenied?.denied) {
return (
<div className="flex items-center justify-center h-screen" data-testid="not-found-state">
<div className="text-center">
<h2 className="text-2xl font-bold text-gray-900 mb-2">Request Not Found</h2>
<p className="text-gray-600 mb-4">The request you're looking for doesn't exist.</p>
<Button onClick={onBack}>
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="access-denied-state">
<div className="max-w-lg w-full bg-white rounded-2xl shadow-xl p-8 text-center">
<div className="w-20 h-20 bg-red-100 rounded-full flex items-center justify-center mx-auto mb-6">
<ShieldX className="w-10 h-10 text-red-500" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Access Denied</h2>
<p className="text-gray-600 mb-6 leading-relaxed">
{accessDenied.message}
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4 mb-6 text-left">
<p className="text-sm text-amber-800">
<strong>Who can access this request?</strong>
</p>
<ul className="text-sm text-amber-700 mt-2 space-y-1">
<li> The person who created this request (Initiator)</li>
<li> Designated approvers at any level</li>
<li> Added spectators or participants</li>
<li> Organization administrators</li>
</ul>
</div>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
Go to Dashboard
</Button>
</div>
</div>
</div>
);
}
// Not Found state
if (!request) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center p-6" data-testid="not-found-state">
<div className="max-w-md w-full bg-white rounded-2xl shadow-xl p-8 text-center">
<div className="w-20 h-20 bg-gray-100 rounded-full flex items-center justify-center mx-auto mb-6">
<FileText className="w-10 h-10 text-gray-400" />
</div>
<h2 className="text-2xl font-bold text-gray-900 mb-3">Request Not Found</h2>
<p className="text-gray-600 mb-6">
The request you're looking for doesn't exist or may have been deleted.
</p>
<div className="flex gap-3 justify-center">
<Button
variant="outline"
onClick={onBack || (() => window.history.back())}
className="flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Go Back
</Button>
<Button
onClick={() => window.location.href = '/dashboard'}
className="bg-blue-600 hover:bg-blue-700"
>
Go to Dashboard
</Button>
</div>
</div>
</div>
);
@ -518,6 +593,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
refreshTrigger={sharedRecipientsRefreshTrigger}
pausedByUserId={request?.pauseInfo?.pausedBy?.userId}
currentUserId={(user as any)?.userId}
resuming={resuming}
/>
)}
</div>

View File

@ -6,7 +6,7 @@ import { useEffect, useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle } from 'lucide-react';
import { UserPlus, Eye, CheckCircle, XCircle, Share2, Pause, Play, AlertCircle, Loader2 } from 'lucide-react';
import { getSharedRecipients, type SharedRecipient } from '@/services/summaryApi';
interface QuickActionsSidebarProps {
@ -25,6 +25,7 @@ interface QuickActionsSidebarProps {
refreshTrigger?: number; // Trigger to refresh shared recipients list
pausedByUserId?: string; // User ID of the approver who paused
currentUserId?: string; // Current user's ID
resuming?: boolean; // Loading state for resume action
}
export function QuickActionsSidebar({
@ -43,6 +44,7 @@ export function QuickActionsSidebar({
refreshTrigger,
pausedByUserId,
currentUserId,
resuming = false,
}: QuickActionsSidebarProps) {
const [sharedRecipients, setSharedRecipients] = useState<SharedRecipient[]>([]);
const [loadingRecipients, setLoadingRecipients] = useState(false);
@ -130,10 +132,20 @@ export function QuickActionsSidebar({
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}
disabled={resuming}
data-testid="resume-workflow-button"
>
{resuming ? (
<>
<Loader2 className="w-3.5 h-3.5 sm:w-4 sm:h-4 animate-spin" />
Resuming...
</>
) : (
<>
<Play className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
Resume Workflow
</>
)}
</Button>
)}

View File

@ -1,6 +1,27 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { User } from '@/types/user.types';
// Check if running in production mode
const isProduction = (): boolean => {
try {
return import.meta.env.PROD || import.meta.env.MODE === 'production';
} catch {
return false;
}
};
// Safe localStorage access - returns null in production (cookies used instead)
const getStoredToken = (): string | null => {
if (isProduction()) {
return null; // In production, auth is via httpOnly cookies
}
try {
return localStorage.getItem('token');
} catch {
return null;
}
};
interface AuthState {
user: User | null;
token: string | null;
@ -11,7 +32,7 @@ interface AuthState {
const initialState: AuthState = {
user: null,
token: localStorage.getItem('token'),
token: getStoredToken(),
isAuthenticated: false,
isLoading: false,
error: null,
@ -31,7 +52,14 @@ const authSlice = createSlice({
state.user = action.payload.user;
state.token = action.payload.token;
state.error = null;
// Only store in localStorage in development mode
if (!isProduction()) {
try {
localStorage.setItem('token', action.payload.token);
} catch {
// Ignore storage errors
}
}
},
loginFailure: (state, action: PayloadAction<string>) => {
state.isLoading = false;
@ -39,14 +67,26 @@ const authSlice = createSlice({
state.user = null;
state.token = null;
state.error = action.payload;
if (!isProduction()) {
try {
localStorage.removeItem('token');
} catch {
// Ignore storage errors
}
}
},
logout: (state) => {
state.isAuthenticated = false;
state.user = null;
state.token = null;
state.error = null;
if (!isProduction()) {
try {
localStorage.removeItem('token');
} catch {
// Ignore storage errors
}
}
},
clearError: (state) => {
state.error = null;

View File

@ -92,8 +92,8 @@ apiClient.interceptors.response.use(
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) {
// In development: Token is in response, store it and add to header
if (!isProduction && accessToken) {
TokenManager.setAccessToken(accessToken);
originalRequest.headers.Authorization = `Bearer ${accessToken}`;
}
@ -205,25 +205,42 @@ export async function exchangeCodeForTokens(
/**
* Refresh access token using refresh token
*
* PRODUCTION: Refresh token is in httpOnly cookie, sent automatically
* DEVELOPMENT: Refresh token from localStorage
*/
export async function refreshAccessToken(): Promise<string> {
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
// In development, check for refresh token in localStorage
if (!isProduction) {
const refreshToken = TokenManager.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
}
const response = await apiClient.post<RefreshTokenResponse>('/auth/refresh', {
refreshToken,
});
// In production, httpOnly cookie with refresh token will be sent automatically
// In development, we send the refresh token in the body
const body = isProduction ? {} : { refreshToken: TokenManager.getRefreshToken() };
const response = await apiClient.post<RefreshTokenResponse>('/auth/refresh', body);
const data = response.data as any;
const accessToken = data.data?.accessToken || data.accessToken;
if (accessToken) {
// In development mode, store the token
if (!isProduction && accessToken) {
TokenManager.setAccessToken(accessToken);
return accessToken;
}
// In production mode, token is set via httpOnly cookie by the backend
// Return a placeholder to indicate success
if (isProduction && (data.success !== false)) {
return 'cookie-based-auth';
}
throw new Error('Failed to refresh token');
}

View File

@ -123,22 +123,34 @@ export async function subscribeUserToPush(register: ServiceWorkerRegistration) {
// Convert subscription to JSON format for backend
const subscriptionJson = subscription.toJSON();
// Attach auth token if available
const token = (window as any)?.localStorage?.getItem?.('accessToken') ||
(document?.cookie || '').match(/accessToken=([^;]+)/)?.[1] || '';
// Check if we're in production mode (cookies used for auth)
const isProduction = import.meta.env.PROD || import.meta.env.MODE === 'production';
if (!token) {
// In development, get token from localStorage
// In production, httpOnly cookies are sent automatically with credentials: 'include'
const token = isProduction ? null : (window as any)?.localStorage?.getItem?.('accessToken');
// In development mode, we need a token
if (!isProduction && !token) {
throw new Error('Authentication token not found. Please log in again.');
}
// Send subscription to backend
try {
// Build headers - in production, auth is via httpOnly cookies
const headers: HeadersInit = {
'Content-Type': 'application/json',
};
// Only add Authorization header in development mode
if (!isProduction && token) {
headers['Authorization'] = `Bearer ${token}`;
}
const response = await fetch(`${VITE_BASE_URL}/api/v1/workflows/notifications/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
headers,
credentials: 'include', // Always include credentials for cookie-based auth
body: JSON.stringify(subscriptionJson)
});