reported bugs like preview issue and new requirement all request for normal user,share request is implemented

This commit is contained in:
laxmanhalaki 2025-11-24 21:19:33 +05:30
parent 99a59ac05b
commit 8f3f484dbc
36 changed files with 2686 additions and 294 deletions

View File

@ -2,6 +2,8 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<!-- CSP: Allows blob URLs for file previews and cross-origin API calls during development -->
<meta http-equiv="Content-Security-Policy" content="default-src 'self' blob:; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; script-src 'self'; img-src 'self' data: https: blob:; connect-src 'self' blob: data: http://localhost:5000 http://localhost:3000 ws://localhost:5000 ws://localhost:3000 wss://localhost:5000 wss://localhost:3000; frame-src 'self' blob:; font-src 'self' https://fonts.gstatic.com data:; object-src 'none'; base-uri 'self'; form-action 'self';" />
<link rel="icon" type="image/svg+xml" href="/royal_enfield_logo.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Royal Enfield Approval & Request Management Portal - Streamlined approval workflows for enterprise operations" />

View File

@ -5,11 +5,15 @@ import { Dashboard } from '@/pages/Dashboard';
import { OpenRequests } from '@/pages/OpenRequests';
import { ClosedRequests } from '@/pages/ClosedRequests';
import { RequestDetail } from '@/pages/RequestDetail';
import { SharedSummaries } from '@/pages/SharedSummaries/SharedSummaries';
import { SharedSummaryDetail } from '@/pages/SharedSummaries/SharedSummaryDetail';
import { WorkNotes } from '@/pages/WorkNotes';
import { CreateRequest } from '@/pages/CreateRequest';
import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard';
import { MyRequests } from '@/pages/MyRequests';
import { Requests } from '@/pages/Requests/Requests';
import { UserAllRequests } from '@/pages/Requests/UserAllRequests';
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
import { Profile } from '@/pages/Profile';
import { Settings } from '@/pages/Settings';
@ -34,6 +38,22 @@ interface AppProps {
onLogout?: () => void;
}
// Component to conditionally render Admin or User All Requests screen
// This ensures that when navigating from the sidebar, the correct screen is shown based on user role
function RequestsRoute({ onViewRequest }: { onViewRequest: (requestId: string) => void }) {
const { user } = useAuth();
const isAdmin = hasManagementAccess(user);
// Render separate screens based on user role
// Admin/Management users see all organization requests
// Regular users see only their participant requests (approver/spectator, NOT initiator)
if (isAdmin) {
return <Requests onViewRequest={onViewRequest} />;
} else {
return <UserAllRequests onViewRequest={onViewRequest} />;
}
}
// Main Application Routes Component
function AppRoutes({ onLogout }: AppProps) {
const navigate = useNavigate();
@ -487,6 +507,26 @@ function AppRoutes({ onLogout }: AppProps) {
}
/>
{/* Shared Summaries */}
<Route
path="/shared-summaries"
element={
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<SharedSummaries />
</PageLayout>
}
/>
{/* Shared Summary Detail */}
<Route
path="/shared-summaries/:sharedSummaryId"
element={
<PageLayout currentPage="shared-summaries" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<SharedSummaryDetail />
</PageLayout>
}
/>
{/* My Requests */}
<Route
path="/my-requests"
@ -497,12 +537,12 @@ function AppRoutes({ onLogout }: AppProps) {
}
/>
{/* Requests - Advanced Filtering Screen (Admin/Management) */}
{/* Requests - Separate screens for Admin and Regular Users */}
<Route
path="/requests"
element={
<PageLayout currentPage="requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Requests onViewRequest={handleViewRequest} />
<RequestsRoute onViewRequest={handleViewRequest} />
</PageLayout>
}
/>

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

21
src/assets/index.ts Normal file
View File

@ -0,0 +1,21 @@
/**
* Assets Index
*
* Centralized exports for all assets (images, fonts, icons, etc.)
* This makes it easier to import assets throughout the application.
*/
// Images
export { default as ReLogo } from './images/Re_Logo.png';
export { default as RoyalEnfieldLogo } from './images/royal_enfield_logo.png';
// Fonts
// Add font exports here when fonts are added to the assets/fonts folder
// Example:
// export const FontName = './fonts/FontName.woff2';
// Icons
// Add icon exports here if needed
// Example:
// export { default as IconName } from './icons/icon-name.svg';

View File

@ -55,22 +55,45 @@ export function FilePreview({
try {
const token = localStorage.getItem('accessToken');
const response = await fetch(fileUrl, {
headers: {
'Authorization': `Bearer ${token}`
// Ensure we have a valid URL - handle relative URLs when served from same origin
let urlToFetch = fileUrl;
if (fileUrl.startsWith('/') && !fileUrl.startsWith('//')) {
// Relative URL - construct absolute URL using current origin
urlToFetch = `${window.location.origin}${fileUrl}`;
}
const response = await fetch(urlToFetch, {
headers: {
'Authorization': `Bearer ${token}`,
'Accept': isPDF ? 'application/pdf' : '*/*'
},
credentials: 'include', // Include credentials for same-origin requests
mode: 'cors' // Explicitly set CORS mode
});
if (!response.ok) {
throw new Error('Failed to load file');
const errorText = await response.text().catch(() => '');
throw new Error(`Failed to load file: ${response.status} ${response.statusText}. ${errorText}`);
}
const blob = await response.blob();
// Check if blob is valid
if (blob.size === 0) {
throw new Error('File is empty or could not be loaded');
}
// Verify blob type matches expected type
if (isPDF && !blob.type.includes('pdf') && blob.type !== 'application/octet-stream') {
console.warn(`Expected PDF but got ${blob.type}`);
}
const url = window.URL.createObjectURL(blob);
setBlobUrl(url);
} catch (err) {
console.error('Failed to load file for preview:', err);
setError('Failed to load file for preview');
setError(err instanceof Error ? err.message : 'Failed to load file for preview');
} finally {
setLoading(false);
}
@ -82,9 +105,10 @@ export function FilePreview({
return () => {
if (blobUrl) {
window.URL.revokeObjectURL(blobUrl);
setBlobUrl(null);
}
};
}, [open, fileUrl, canPreview]);
}, [open, fileUrl, canPreview, isPDF]);
const handleDownload = async () => {
if (onDownload && attachmentId) {
@ -218,6 +242,9 @@ export function FilePreview({
minHeight: '70vh',
height: '100%'
}}
onError={() => {
setError('Failed to load PDF preview');
}}
/>
</div>
)}

View File

@ -10,6 +10,7 @@ interface StatsCardProps {
textColor: string;
valueColor: string;
testId?: string;
onClick?: () => void;
}
export function StatsCard({
@ -20,12 +21,14 @@ export function StatsCard({
gradient,
textColor,
valueColor,
testId = 'stats-card'
testId = 'stats-card',
onClick
}: StatsCardProps) {
return (
<Card
className={`${gradient} border transition-shadow hover:shadow-md`}
className={`${gradient} border transition-shadow ${onClick ? 'cursor-pointer hover:shadow-lg' : 'hover:shadow-md'}`}
data-testid={testId}
onClick={onClick}
>
<CardContent className="p-3 sm:p-4">
<div className="flex items-center justify-between">

View File

@ -1,7 +1,6 @@
import { useState, useEffect, useMemo } from 'react';
import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, List } from 'lucide-react';
import { Bell, Settings, User, Plus, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, List, Share2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu';
@ -15,8 +14,8 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import royalEnfieldLogo from '@/assets/images/royal_enfield_logo.png';
import { useAuth } from '@/contexts/AuthContext';
import { ReLogo } from '@/assets';
import notificationApi, { Notification } from '@/services/notificationApi';
import { getSocket, joinUserRoom } from '@/utils/socket';
import { formatDistanceToNow } from 'date-fns';
@ -57,28 +56,23 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
}
};
// Check if user has management access (ADMIN or MANAGEMENT role)
const isManagement = useMemo(() => hasManagementAccess(user), [user]);
const menuItems = useMemo(() => {
const items = [
{ id: 'dashboard', label: 'Dashboard', icon: Home },
// Add "All Requests" for all users (admin sees org-level, regular users see their participant requests)
{ id: 'requests', label: 'All Requests', icon: List },
];
// Add "All Requests" only for ADMIN and MANAGEMENT roles, right after Dashboard
if (isManagement) {
items.push({ id: 'requests', label: 'All Requests', icon: List });
}
// Add remaining menu items
items.push(
{ id: 'my-requests', label: 'My Requests', icon: User },
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle }
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
{ id: 'shared-summaries', label: 'Shared Summary', icon: Share2 }
);
return items;
}, [isManagement]);
}, []);
const toggleSidebar = () => {
setSidebarOpen(!sidebarOpen);
@ -228,16 +222,13 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
`}>
<div className={`w-64 h-full flex flex-col overflow-hidden ${!sidebarOpen ? 'md:hidden' : ''}`}>
<div className="p-4 border-b border-gray-800 flex-shrink-0">
<div className="flex items-center gap-3">
<div className="flex flex-col items-center justify-center">
<img
src={royalEnfieldLogo}
src={ReLogo}
alt="Royal Enfield Logo"
className="w-10 h-10 shrink-0 object-contain"
className="h-10 w-auto max-w-[168px] object-contain"
/>
<div className="min-w-0 flex-1">
<h2 className="text-base font-semibold text-white truncate">Royal Enfield</h2>
<p className="text-sm text-gray-400 truncate">Approval Portal</p>
</div>
<p className="text-xs text-gray-400 text-center mt-1 truncate">Approval Portal</p>
</div>
</div>
<div className="p-3 flex-1 overflow-y-auto">
@ -292,13 +283,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
>
{sidebarOpen ? <PanelLeftClose className="w-5 h-5 text-gray-600" /> : <PanelLeft className="w-5 h-5 text-gray-600" />}
</Button>
<div className="relative max-w-md flex-1">
{/* Search bar commented out */}
{/* <div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search..."
className="pl-10 bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green w-full text-sm h-10"
/>
</div>
</div> */}
</div>
<div className="flex items-center gap-4 shrink-0">

View File

@ -0,0 +1,210 @@
import { useState, useEffect } from 'react';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { Loader2, Search, User, X } from 'lucide-react';
import { toast } from 'sonner';
import { shareSummary } from '@/services/summaryApi';
import { searchUsers } from '@/services/userApi';
interface ShareSummaryModalProps {
isOpen: boolean;
onClose: () => void;
summaryId: string;
requestTitle: string;
onSuccess?: () => void;
}
export function ShareSummaryModal({ isOpen, onClose, summaryId, requestTitle, onSuccess }: ShareSummaryModalProps) {
const [searchTerm, setSearchTerm] = useState('');
const [users, setUsers] = useState<Array<{ userId: string; email: string; displayName?: string; designation?: string; department?: string }>>([]);
const [selectedUserIds, setSelectedUserIds] = useState<Set<string>>(new Set());
const [searching, setSearching] = useState(false);
const [sharing, setSharing] = useState(false);
// Search users
useEffect(() => {
if (!isOpen || !searchTerm.trim()) {
setUsers([]);
return;
}
const searchTimeout = setTimeout(async () => {
try {
setSearching(true);
const response = await searchUsers(searchTerm);
const results = response?.data?.data || response?.data || [];
setUsers(Array.isArray(results) ? results : []);
} catch (error) {
console.error('Failed to search users:', error);
toast.error('Failed to search users');
} finally {
setSearching(false);
}
}, 300);
return () => clearTimeout(searchTimeout);
}, [searchTerm, isOpen]);
const handleToggleUser = (userId: string) => {
setSelectedUserIds(prev => {
const newSet = new Set(prev);
if (newSet.has(userId)) {
newSet.delete(userId);
} else {
newSet.add(userId);
}
return newSet;
});
};
const handleShare = async () => {
if (selectedUserIds.size === 0) {
toast.error('Please select at least one user to share with');
return;
}
try {
setSharing(true);
await shareSummary(summaryId, Array.from(selectedUserIds));
toast.success(`Summary shared with ${selectedUserIds.size} user(s)`);
setSelectedUserIds(new Set());
setSearchTerm('');
setUsers([]);
onSuccess?.();
onClose();
} catch (error: any) {
console.error('Failed to share summary:', error);
toast.error(error?.response?.data?.message || 'Failed to share summary');
} finally {
setSharing(false);
}
};
const handleClose = () => {
setSelectedUserIds(new Set());
setSearchTerm('');
setUsers([]);
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Share Summary</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label className="text-sm font-medium text-gray-700">Request</Label>
<p className="text-sm text-gray-600 mt-1">{requestTitle}</p>
</div>
<div>
<Label htmlFor="user-search" className="text-sm font-medium text-gray-700">
Search Users
</Label>
<div className="relative mt-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
id="user-search"
placeholder="Search by name or email..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{searching && (
<div className="flex items-center justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-blue-600" />
</div>
)}
{!searching && users.length > 0 && (
<div className="border rounded-lg max-h-[300px] overflow-y-auto">
{users.map((user) => (
<div
key={user.userId}
className="flex items-center gap-3 p-3 hover:bg-gray-50 border-b last:border-b-0 cursor-pointer"
onClick={() => handleToggleUser(user.userId)}
>
<Checkbox
checked={selectedUserIds.has(user.userId)}
onCheckedChange={() => handleToggleUser(user.userId)}
/>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<User className="h-4 w-4 text-gray-400 flex-shrink-0" />
<p className="text-sm font-medium text-gray-900 truncate">
{user.displayName || user.email}
</p>
</div>
{(user.designation || user.department) && (
<p className="text-xs text-gray-500 mt-0.5">{user.designation || user.department}</p>
)}
<p className="text-xs text-gray-400 truncate">{user.email}</p>
</div>
</div>
))}
</div>
)}
{!searching && searchTerm && users.length === 0 && (
<div className="text-center py-8 text-gray-500 text-sm">
No users found
</div>
)}
{selectedUserIds.size > 0 && (
<div className="border rounded-lg p-3 bg-blue-50">
<p className="text-sm font-medium text-gray-700 mb-2">
Selected ({selectedUserIds.size})
</p>
<div className="flex flex-wrap gap-2">
{Array.from(selectedUserIds).map((userId) => {
const user = users.find(u => u.userId === userId);
return (
<div
key={userId}
className="flex items-center gap-1 bg-white px-2 py-1 rounded-full text-xs"
>
<span>{user?.displayName || user?.email || userId}</span>
<button
onClick={() => handleToggleUser(userId)}
className="ml-1 hover:text-red-600"
>
<X className="h-3 w-3" />
</button>
</div>
);
})}
</div>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose} disabled={sharing}>
Cancel
</Button>
<Button onClick={handleShare} disabled={sharing || selectedUserIds.size === 0}>
{sharing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Sharing...
</>
) : (
`Share with ${selectedUserIds.size} user(s)`
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -47,7 +47,7 @@ export function NotificationStatusModal({
<h3 className="text-lg font-semibold text-gray-900 mb-2">
Subscription Failed
</h3>
<p className="text-sm text-gray-600 max-w-sm mb-4">
<p className="text-sm text-gray-600 max-w-sm mb-4 whitespace-pre-line">
{message || 'Unable to enable push notifications. Please check your browser settings and try again.'}
</p>
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-left w-full">

View File

@ -1,4 +1,5 @@
import { motion } from 'framer-motion';
import { useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@ -36,6 +37,32 @@ export function ApprovalWorkflowStep({
}: ApprovalWorkflowStepProps) {
const { userSearchResults, userSearchLoading, searchUsersForIndex, clearSearchForIndex } = useMultiUserSearch();
// Initialize approvers array when approverCount changes - moved from render to useEffect
useEffect(() => {
const approverCount = formData.approverCount || 1;
const currentApprovers = formData.approvers || [];
// Ensure we have the correct number of approvers
if (currentApprovers.length < approverCount) {
const newApprovers = [...currentApprovers];
// Fill missing approver slots
for (let i = currentApprovers.length; i < approverCount; i++) {
if (!newApprovers[i]) {
newApprovers[i] = {
email: '',
name: '',
level: i + 1,
tat: '' as any
};
}
}
updateFormData('approvers', newApprovers);
} else if (currentApprovers.length > approverCount) {
// Trim excess approvers if count was reduced
updateFormData('approvers', currentApprovers.slice(0, approverCount));
}
}, [formData.approverCount, updateFormData]);
const handleApproverEmailChange = (index: number, value: string) => {
const newApprovers = [...formData.approvers];
const previousEmail = newApprovers[index]?.email;
@ -61,6 +88,36 @@ export function ApprovalWorkflowStep({
const handleUserSelect = async (index: number, selectedUser: any) => {
try {
// Check for duplicates in other approver slots (excluding current index)
const isDuplicateApprover = formData.approvers?.some(
(approver: any, idx: number) =>
idx !== index &&
(approver.userId === selectedUser.userId || approver.email?.toLowerCase() === selectedUser.email?.toLowerCase())
);
if (isDuplicateApprover) {
onValidationError({
type: 'error',
email: selectedUser.email,
message: 'This user is already added as an approver in another level.'
});
return;
}
// Check for duplicates in spectators
const isDuplicateSpectator = formData.spectators?.some(
(spectator: any) => spectator.userId === selectedUser.userId || spectator.email?.toLowerCase() === selectedUser.email?.toLowerCase()
);
if (isDuplicateSpectator) {
onValidationError({
type: 'error',
email: selectedUser.email,
message: 'This user is already added as a spectator. A user cannot be both an approver and a spectator.'
});
return;
}
const dbUser = await ensureUserExists({
userId: selectedUser.userId,
email: selectedUser.email,
@ -210,11 +267,13 @@ export function ApprovalWorkflowStep({
const level = index + 1;
const isLast = level === (formData.approverCount || 1);
if (!formData.approvers[index]) {
const newApprovers = [...formData.approvers];
newApprovers[index] = { email: '', name: '', level: level, tat: '' as any };
updateFormData('approvers', newApprovers);
}
// Ensure approver exists (should be initialized by useEffect, but provide fallback)
const approver = formData.approvers[index] || {
email: '',
name: '',
level: level,
tat: '' as any
};
return (
<div key={level} className="space-y-3" data-testid={`approval-workflow-approver-level-${level}`}>
@ -223,13 +282,13 @@ export function ApprovalWorkflowStep({
</div>
<div className={`p-4 rounded-lg border-2 transition-all ${
formData.approvers[index]?.email
approver.email
? 'border-green-200 bg-green-50'
: 'border-gray-200 bg-gray-50'
}`}>
<div className="flex items-center gap-3">
<div className={`w-10 h-10 rounded-full flex items-center justify-center ${
formData.approvers[index]?.email
approver.email
? 'bg-green-600'
: 'bg-gray-400'
}`}>
@ -250,7 +309,7 @@ export function ApprovalWorkflowStep({
<Label htmlFor={`approver-${level}`} className="text-sm font-medium">
Email Address *
</Label>
{formData.approvers[index]?.email && formData.approvers[index]?.userId && (
{approver.email && approver.userId && (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-300">
<CheckCircle className="w-3 h-3 mr-1" />
Verified
@ -262,7 +321,7 @@ export function ApprovalWorkflowStep({
id={`approver-${level}`}
type="email"
placeholder="approver@royalenfield.com"
value={formData.approvers[index]?.email || ''}
value={approver.email || ''}
onChange={(e) => handleApproverEmailChange(index, e.target.value)}
className="h-10 border-2 border-gray-300 focus:border-blue-500 mt-1 w-full"
data-testid={`approval-workflow-approver-${level}-email-input`}
@ -300,17 +359,17 @@ export function ApprovalWorkflowStep({
<Input
id={`tat-${level}`}
type="number"
placeholder={formData.approvers[index]?.tatType === 'days' ? '7' : '24'}
placeholder={approver.tatType === 'days' ? '7' : '24'}
min="1"
max={formData.approvers[index]?.tatType === 'days' ? '30' : '720'}
value={formData.approvers[index]?.tat || ''}
max={approver.tatType === 'days' ? '30' : '720'}
value={approver.tat || ''}
onChange={(e) => {
const newApprovers = [...formData.approvers];
newApprovers[index] = {
...newApprovers[index],
tat: parseInt(e.target.value) || '',
level: level,
tatType: formData.approvers[index]?.tatType || 'hours'
tatType: approver.tatType || 'hours'
};
updateFormData('approvers', newApprovers);
}}
@ -318,7 +377,7 @@ export function ApprovalWorkflowStep({
data-testid={`approval-workflow-approver-${level}-tat-input`}
/>
<Select
value={formData.approvers[index]?.tatType || 'hours'}
value={approver.tatType || 'hours'}
onValueChange={(value) => {
const newApprovers = [...formData.approvers];
newApprovers[index] = {

View File

@ -56,6 +56,34 @@ export function ParticipantsStep({
return;
}
// Check for duplicates in spectators
const isDuplicateSpectator = formData.spectators.some(
(s: any) => s.userId === user.userId || s.email?.toLowerCase() === user.email?.toLowerCase()
);
// Check for duplicates in approvers
const isDuplicateApprover = formData.approvers?.some(
(approver: any) => approver.userId === user.userId || approver.email?.toLowerCase() === user.email?.toLowerCase()
);
if (isDuplicateSpectator) {
onValidationError({
type: 'error',
email: user.email,
message: 'This user is already added as a spectator.'
});
return;
}
if (isDuplicateApprover) {
onValidationError({
type: 'error',
email: user.email,
message: 'This user is already added as an approver. A user cannot be both an approver and a spectator.'
});
return;
}
try {
const dbUser = await ensureUser(user);
const spectator = {
@ -90,6 +118,34 @@ export function ParticipantsStep({
});
return;
}
// Check for duplicates in spectators by email
const isDuplicateSpectator = formData.spectators.some(
(s: any) => s.email?.toLowerCase() === emailInput.toLowerCase()
);
// Check for duplicates in approvers by email
const isDuplicateApprover = formData.approvers?.some(
(approver: any) => approver.email?.toLowerCase() === emailInput.toLowerCase()
);
if (isDuplicateSpectator) {
onValidationError({
type: 'error',
email: emailInput,
message: 'This user is already added as a spectator.'
});
return;
}
if (isDuplicateApprover) {
onValidationError({
type: 'error',
email: emailInput,
message: 'This user is already added as an approver. A user cannot be both an approver and a spectator.'
});
return;
}
// This would trigger validation in parent component
}
};

View File

@ -70,10 +70,10 @@ export function WizardFooter({
{savingDraft ? (
<>
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2 animate-spin" />
{isEditing ? 'Updating...' : 'Saving...'}
<span>{isEditing ? 'Updating...' : 'Saving...'}</span>
</>
) : (
isEditing ? 'Update Draft' : 'Save Draft'
<span>{isEditing ? 'Update Draft' : 'Save Draft'}</span>
)}
</Button>
{currentStep === totalSteps ? (

View File

@ -250,13 +250,21 @@ export function useRequestDetails(
/**
* Determine: Find the approval level assigned to current user
* Used to show approve/reject buttons only when user has pending approval
* Used to show approve/reject buttons only when user is the CURRENT active approver
* Conditions:
* 1. User email matches approverEmail
* 2. Status is PENDING or IN_PROGRESS
* 3. Approval level number matches the current active level in workflow
*/
const userEmail = (user as any)?.email?.toLowerCase();
const newCurrentLevel = approvals.find((a: any) => {
const st = (a.status || '').toString().toUpperCase();
const approverEmail = (a.approverEmail || '').toLowerCase();
return (st === 'PENDING' || st === 'IN_PROGRESS') && approverEmail === userEmail;
const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level
return (st === 'PENDING' || st === 'IN_PROGRESS')
&& approverEmail === userEmail
&& approvalLevelNumber === currentLevel;
});
setCurrentApprovalLevel(newCurrentLevel || null);
@ -425,11 +433,16 @@ export function useRequestDetails(
setApiRequest(mapped);
// Find current user's approval level
// Only show approve/reject buttons if user is the CURRENT active approver
const userEmail = (user as any)?.email?.toLowerCase();
const userCurrentLevel = approvals.find((a: any) => {
const status = (a.status || '').toString().toUpperCase();
const approverEmail = (a.approverEmail || '').toLowerCase();
return (status === 'PENDING' || status === 'IN_PROGRESS') && approverEmail === userEmail;
const approvalLevelNumber = a.levelNumber || 0;
// Only show buttons if user is assigned to the CURRENT active level
return (status === 'PENDING' || status === 'IN_PROGRESS')
&& approverEmail === userEmail
&& approvalLevelNumber === currentLevel;
});
setCurrentApprovalLevel(userCurrentLevel || null);

View File

@ -71,12 +71,12 @@ export function TATBreachReport({
<thead>
<tr className="border-b border-gray-200 bg-gray-50">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Request ID</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Title</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 w-[250px]">Title</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Department</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Approver</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Level</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Breach Time</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 min-w-[200px] max-w-[300px]">
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 w-[140px]">Breach Time</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700 w-[300px]">
Reason
</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Priority</th>
@ -95,8 +95,8 @@ export function TATBreachReport({
>
{req.requestNumber}
</td>
<td className="py-3 px-4 text-sm text-gray-900 max-w-xs truncate" title={req.title}>
{req.title}
<td className="py-3 px-4 text-sm text-gray-900 w-[250px]">
<p className="break-words leading-relaxed">{req.title}</p>
</td>
<td
className="py-3 px-4 text-sm text-gray-700 cursor-pointer hover:text-blue-600 hover:underline"
@ -143,12 +143,12 @@ export function TATBreachReport({
<span className="text-gray-400"></span>
)}
</td>
<td className="py-3 px-4">
<span className="bg-red-500 text-white px-2 py-1 rounded text-xs font-medium">
<td className="py-3 px-4 w-[140px]">
<span className="bg-red-500 text-white px-2 py-1 rounded text-xs font-medium whitespace-nowrap">
{formatBreachTime(breachTime)}
</span>
</td>
<td className="py-3 px-4 text-sm text-gray-700 min-w-[200px] max-w-[300px]">
<td className="py-3 px-4 text-sm text-gray-700 w-[300px]">
<div className="max-h-32 overflow-y-auto">
<p className="whitespace-pre-line break-words leading-relaxed">
{req.breachReason || 'TAT Exceeded'}

View File

@ -1,7 +1,9 @@
import { useCallback, useRef } from 'react';
import { useCallback, useRef, useState, useEffect, useMemo } from 'react';
import { FileText } from 'lucide-react';
import { PageHeader } from '@/components/common/PageHeader';
import { Pagination } from '@/components/common/Pagination';
import dashboardService from '@/services/dashboard.service';
import { useAuth } from '@/contexts/AuthContext';
// Components
import { MyRequestsStatsSection } from './components/MyRequestsStats';
@ -11,7 +13,6 @@ import { MyRequestsList } from './components/MyRequestsList';
// Hooks
import { useMyRequests } from './hooks/useMyRequests';
import { useMyRequestsFilters } from './hooks/useMyRequestsFilters';
import { useMyRequestsStats } from './hooks/useMyRequestsStats';
// Utils
import { transformRequests } from './utils/requestTransformers';
@ -25,6 +26,8 @@ interface MyRequestsProps {
}
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
const { user } = useAuth();
// Data fetching hook
const myRequests = useMyRequests({ itemsPerPage: 10 });
@ -46,15 +49,95 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
),
});
// State for backend stats (calculated from entire dataset via SQL queries)
const [backendStats, setBackendStats] = useState<{
total: number;
pending: number;
approved: number;
rejected: number;
draft: number;
closed: number;
} | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
// Fetch stats from backend API (calculates from entire dataset using SQL, not by fetching data)
// Backend automatically filters by userId for non-admin users (initiator_id = userId)
const fetchBackendStats = useCallback(async () => {
if (!user?.userId) return;
try {
setLoadingStats(true);
// Use backend stats API - it automatically filters by userId for non-admin users
// This calculates stats from entire dataset using SQL COUNT queries, not by fetching data
const stats = await dashboardService.getRequestStats(
'month', // Default date range
undefined, // startDate
undefined, // endDate
filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
undefined, // department
undefined, // initiator (already filtered by userId in backend)
undefined, // approver
undefined, // approverType
filters.searchTerm || undefined,
undefined // slaCompliance
);
setBackendStats({
total: stats.totalRequests || 0,
pending: stats.openRequests || 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);
setBackendStats(null);
} finally {
setLoadingStats(false);
}
}, [user?.userId, filters.searchTerm, filters.priorityFilter]); // Exclude statusFilter
// Fetch stats when filters change (except status filter)
// Stats are calculated from entire dataset via backend SQL queries (no data fetching needed)
useEffect(() => {
const timeoutId = setTimeout(() => {
fetchBackendStats();
}, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId);
}, [filters.searchTerm, filters.priorityFilter, fetchBackendStats]); // Exclude statusFilter
// Handle dynamic requests (fallback until API loads)
const convertedDynamicRequests = transformRequests(dynamicRequests);
const sourceRequests = myRequests.hasFetchedFromApi ? myRequests.requests : convertedDynamicRequests;
// Stats calculation
const stats = useMyRequestsStats({
requests: sourceRequests,
totalRecords: myRequests.pagination.totalRecords,
});
// Calculate stats from backend stats API (calculated from entire dataset via SQL queries)
// This is much more efficient - backend uses COUNT queries, no data fetching needed
const stats = useMemo(() => {
if (backendStats) {
// Use backend stats (calculated from entire dataset via SQL COUNT queries)
return {
total: backendStats.total || 0,
pending: backendStats.pending || 0,
approved: backendStats.approved || 0,
rejected: backendStats.rejected || 0,
draft: backendStats.draft || 0,
closed: backendStats.closed || 0,
};
}
// Fallback: if stats haven't loaded yet, show zeros
return {
total: 0,
pending: 0,
approved: 0,
rejected: 0,
draft: 0,
closed: 0,
};
}, [backendStats]);
// Page change handler
const handlePageChange = useCallback(
@ -78,15 +161,20 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
title="My Requests"
description="Track and manage all your submitted requests"
badge={{
value: `${myRequests.pagination.totalRecords || sourceRequests.length} total`,
value: `${stats.total} total`,
label: 'requests',
loading: myRequests.loading,
loading: myRequests.loading || loadingStats,
}}
testId="my-requests-header"
/>
{/* Stats Overview */}
<MyRequestsStatsSection stats={stats} />
<MyRequestsStatsSection
stats={stats}
onStatusFilter={(status) => {
filters.setStatusFilter(status);
}}
/>
{/* Filters and Search */}
<MyRequestsFiltersComponent

View File

@ -2,17 +2,23 @@
* My Requests Stats Section Component
*/
import { FileText, Clock, CheckCircle, XCircle, Edit } from 'lucide-react';
import { FileText, Clock, CheckCircle, XCircle, Edit, Archive } from 'lucide-react';
import { StatsCard } from '@/components/dashboard/StatsCard';
import { MyRequestsStats } from '../types/myRequests.types';
interface MyRequestsStatsProps {
stats: MyRequestsStats;
onStatusFilter?: (status: string) => void;
}
export function MyRequestsStatsSection({ stats }: MyRequestsStatsProps) {
export function MyRequestsStatsSection({ stats, onStatusFilter }: MyRequestsStatsProps) {
const handleCardClick = (status: string) => {
if (onStatusFilter) {
onStatusFilter(status);
}
};
return (
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 sm:gap-4" data-testid="my-requests-stats">
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-3 sm:gap-4" data-testid="my-requests-stats">
<StatsCard
label="Total"
value={stats.total}
@ -22,6 +28,7 @@ export function MyRequestsStatsSection({ stats }: MyRequestsStatsProps) {
textColor="text-blue-700"
valueColor="text-blue-900"
testId="stat-total"
onClick={onStatusFilter ? () => handleCardClick('all') : undefined}
/>
<StatsCard
@ -33,6 +40,7 @@ export function MyRequestsStatsSection({ stats }: MyRequestsStatsProps) {
textColor="text-orange-700"
valueColor="text-orange-900"
testId="stat-pending"
onClick={onStatusFilter ? () => handleCardClick('pending') : undefined}
/>
<StatsCard
@ -44,6 +52,7 @@ export function MyRequestsStatsSection({ stats }: MyRequestsStatsProps) {
textColor="text-green-700"
valueColor="text-green-900"
testId="stat-approved"
onClick={onStatusFilter ? () => handleCardClick('approved') : undefined}
/>
<StatsCard
@ -55,6 +64,7 @@ export function MyRequestsStatsSection({ stats }: MyRequestsStatsProps) {
textColor="text-red-700"
valueColor="text-red-900"
testId="stat-rejected"
onClick={onStatusFilter ? () => handleCardClick('rejected') : undefined}
/>
<StatsCard
@ -66,6 +76,19 @@ export function MyRequestsStatsSection({ stats }: MyRequestsStatsProps) {
textColor="text-gray-700"
valueColor="text-gray-900"
testId="stat-draft"
onClick={onStatusFilter ? () => handleCardClick('draft') : undefined}
/>
<StatsCard
label="Closed"
value={stats.closed}
icon={Archive}
iconColor="text-purple-600"
gradient="bg-gradient-to-br from-purple-50 to-purple-100 border-purple-200"
textColor="text-purple-700"
valueColor="text-purple-900"
testId="stat-closed"
onClick={onStatusFilter ? () => handleCardClick('closed') : undefined}
/>
</div>
);

View File

@ -36,7 +36,7 @@ export function useMyRequests({ itemsPerPage = 10, initialFilters }: UseMyReques
setRequests([]);
}
const result = await workflowApi.listMyWorkflows({
const result = await workflowApi.listMyInitiatedWorkflows({
page,
limit: itemsPerPage,
search: filters?.search,

View File

@ -36,6 +36,9 @@ import { downloadDocument } from '@/services/workflowApi';
// Components
import { RequestDetailHeader } from './components/RequestDetailHeader';
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
import { createSummary } from '@/services/summaryApi';
import { toast } from 'sonner';
import { OverviewTab } from './components/tabs/OverviewTab';
import { WorkflowTab } from './components/tabs/WorkflowTab';
import { DocumentsTab } from './components/tabs/DocumentsTab';
@ -95,6 +98,8 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
const initialTab = urlParams.get('tab') || 'overview';
const [activeTab, setActiveTab] = useState(initialTab);
const [showShareSummaryModal, setShowShareSummaryModal] = useState(false);
const [summaryId, setSummaryId] = useState<string | null>(null);
const { user } = useAuth();
// Custom hooks
@ -173,6 +178,32 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
refreshDetails();
};
const handleShareSummary = async () => {
if (!apiRequest?.requestId) {
toast.error('Request ID not found');
return;
}
try {
// Check if summary already exists, if not create it
let currentSummaryId = summaryId;
if (!currentSummaryId) {
const summary = await createSummary(apiRequest.requestId);
currentSummaryId = summary.summaryId;
setSummaryId(currentSummaryId);
}
setShowShareSummaryModal(true);
} catch (error: any) {
console.error('Failed to create/get summary:', error);
if (error?.response?.status === 400 && error?.response?.data?.message?.includes('already exists')) {
// Summary already exists, try to get it
toast.error('Summary already exists. Please refresh the page.');
} else {
toast.error(error?.response?.data?.message || 'Failed to prepare summary for sharing');
}
}
};
const needsClosure = request?.status === 'approved' && isInitiator;
// Get current levels for WorkNotesTab
@ -222,6 +253,8 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
refreshing={refreshing}
onBack={onBack || (() => window.history.back())}
onRefresh={handleRefresh}
onShareSummary={handleShareSummary}
isInitiator={isInitiator}
/>
{/* Tabs */}
@ -363,6 +396,19 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
</div>
</div>
{/* Share Summary Modal */}
{showShareSummaryModal && summaryId && (
<ShareSummaryModal
isOpen={showShareSummaryModal}
onClose={() => setShowShareSummaryModal(false)}
summaryId={summaryId}
requestTitle={request?.title || 'N/A'}
onSuccess={() => {
refreshDetails();
}}
/>
)}
{/* Modals */}
<RequestDetailModals
showApproveModal={showApproveModal}

View File

@ -4,7 +4,7 @@
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ArrowLeft, FileText, RefreshCw } from 'lucide-react';
import { ArrowLeft, FileText, RefreshCw, Share2 } from 'lucide-react';
import { getPriorityConfig, getStatusConfig } from '@/utils/requestDetailHelpers';
import { SLAProgressBar } from '@/components/sla/SLAProgressBar';
@ -13,9 +13,11 @@ interface RequestDetailHeaderProps {
refreshing: boolean;
onBack: () => void;
onRefresh: () => void;
onShareSummary?: () => void;
isInitiator?: boolean;
}
export function RequestDetailHeader({ request, refreshing, onBack, onRefresh }: RequestDetailHeaderProps) {
export function RequestDetailHeader({ request, refreshing, onBack, onRefresh, onShareSummary, isInitiator }: RequestDetailHeaderProps) {
const priorityConfig = getPriorityConfig(request?.priority || 'standard');
const statusConfig = getStatusConfig(request?.status || 'pending');
@ -68,6 +70,21 @@ export function RequestDetailHeader({ request, refreshing, onBack, onRefresh }:
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center gap-2">
{/* Share Summary Button - Only show for closed requests if user is initiator */}
{onShareSummary && isInitiator && request?.status?.toLowerCase() === 'closed' && (
<Button
variant="default"
size="sm"
className="gap-1 sm:gap-2 flex-shrink-0 h-8 sm:h-9"
onClick={onShareSummary}
data-testid="share-summary-button"
>
<Share2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">Share Summary</span>
</Button>
)}
{/* Refresh Button */}
<Button
variant="outline"
@ -81,6 +98,7 @@ export function RequestDetailHeader({ request, refreshing, onBack, onRefresh }:
<span className="hidden sm:inline">{refreshing ? 'Refreshing...' : 'Refresh'}</span>
</Button>
</div>
</div>
{/* Request Title */}
<div className="mt-3 ml-0 sm:ml-14">

View File

@ -28,6 +28,7 @@ import { exportRequestsToCSV } from './utils/csvExports';
// Services
import { fetchRequestsData, fetchAllRequestsForExport } from './services/requestsService';
import workflowApi from '@/services/workflowApi';
// Types
import type { RequestsProps, BackendStats } from './types/requests.types';
@ -55,9 +56,7 @@ export function Requests({ onViewRequest }: RequestsProps) {
const [apiRequests, setApiRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [allFilteredRequests, setAllFilteredRequests] = useState<any[]>([]);
const [backendStats, setBackendStats] = useState<BackendStats | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const [departments, setDepartments] = useState<string[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false);
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
@ -82,15 +81,104 @@ export function Requests({ onViewRequest }: RequestsProps) {
});
// Fetch backend stats
const fetchBackendStats = useCallback(async (statsDateRange?: DateRange, statsStartDate?: Date, statsEndDate?: Date) => {
// 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
const fetchBackendStats = useCallback(async (
statsDateRange?: DateRange,
statsStartDate?: Date,
statsEndDate?: Date,
filtersWithoutStatus?: {
priority?: string;
department?: string;
initiator?: string;
approver?: string;
approverType?: 'current' | 'any';
search?: string;
slaCompliance?: string;
}
) => {
if (!isOrgLevel) return;
try {
setLoadingStats(true);
// For dynamic SLA statuses (on_track, approaching, critical), we need to fetch and enrich
// because these are calculated dynamically, not stored in DB
const slaCompliance = filtersWithoutStatus?.slaCompliance;
const isDynamicSlaStatus = slaCompliance &&
slaCompliance !== 'all' &&
slaCompliance !== 'breached' &&
slaCompliance !== 'compliant';
if (isDynamicSlaStatus) {
// Fetch a larger sample of requests, enrich them, filter by SLA, then calculate stats
const backendFilters: any = {};
if (filtersWithoutStatus?.search) backendFilters.search = filtersWithoutStatus.search;
if (filtersWithoutStatus?.priority && filtersWithoutStatus.priority !== 'all') {
backendFilters.priority = filtersWithoutStatus.priority;
}
if (filtersWithoutStatus?.department && filtersWithoutStatus.department !== 'all') {
backendFilters.department = filtersWithoutStatus.department;
}
if (filtersWithoutStatus?.initiator && filtersWithoutStatus.initiator !== 'all') {
backendFilters.initiator = filtersWithoutStatus.initiator;
}
if (filtersWithoutStatus?.approver && filtersWithoutStatus.approver !== 'all') {
backendFilters.approver = filtersWithoutStatus.approver;
backendFilters.approverType = filtersWithoutStatus.approverType || 'current';
}
backendFilters.slaCompliance = slaCompliance; // Include SLA filter - backend will enrich and filter
if (statsDateRange) backendFilters.dateRange = statsDateRange;
if (statsStartDate) backendFilters.startDate = statsStartDate.toISOString();
if (statsEndDate) backendFilters.endDate = statsEndDate.toISOString();
// Fetch up to 1000 requests (backend will enrich and filter by SLA)
const result = await workflowApi.listWorkflows({
page: 1,
limit: 1000,
...backendFilters
});
const filteredData = Array.isArray(result?.data) ? result.data : [];
// Calculate stats from filtered data
const total = filteredData.length;
const pending = filteredData.filter((r: any) => {
const status = (r.status || '').toString().toUpperCase();
return status === 'PENDING' || status === 'IN_PROGRESS';
}).length;
const approved = filteredData.filter((r: any) => {
const status = (r.status || '').toString().toUpperCase();
return status === 'APPROVED';
}).length;
const rejected = filteredData.filter((r: any) => {
const status = (r.status || '').toString().toUpperCase();
return status === 'REJECTED';
}).length;
const closed = filteredData.filter((r: any) => {
const status = (r.status || '').toString().toUpperCase();
return status === 'CLOSED';
}).length;
setBackendStats({
total,
pending,
approved,
rejected,
draft: 0, // Drafts are excluded
closed
});
} else {
// For breached/compliant or no SLA filter, use dashboard stats API
const stats = await dashboardService.getRequestStats(
statsDateRange,
statsStartDate ? statsStartDate.toISOString() : undefined,
statsEndDate ? statsEndDate.toISOString() : undefined
statsEndDate ? statsEndDate.toISOString() : undefined,
filtersWithoutStatus?.priority,
filtersWithoutStatus?.department,
filtersWithoutStatus?.initiator,
filtersWithoutStatus?.approver,
filtersWithoutStatus?.approverType,
filtersWithoutStatus?.search,
filtersWithoutStatus?.slaCompliance
);
setBackendStats({
@ -99,12 +187,14 @@ export function Requests({ onViewRequest }: RequestsProps) {
approved: stats.approvedRequests || 0,
rejected: stats.rejectedRequests || 0,
draft: stats.draftRequests || 0,
closed: 0
closed: stats.closedRequests || 0
});
}
} catch (error) {
console.error('Failed to fetch backend stats:', error);
// Keep previous stats on error
} finally {
setLoadingStats(false);
// Stats loading removed - no longer needed
}
}, [isOrgLevel]);
@ -152,7 +242,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
if (page === 1) {
setLoading(true);
setApiRequests([]);
setAllFilteredRequests([]);
}
const filterOptions = filtersRef.current.getFilters();
@ -163,25 +252,15 @@ export function Requests({ onViewRequest }: RequestsProps) {
isOrgLevel
});
setApiRequests(result.data);
setAllFilteredRequests(result.filteredData);
setApiRequests(result.data); // Paginated data WITH status filter (for list display)
// Note: Stats come from backend stats API (always unfiltered), not from allData
// Update pagination
setCurrentPage(result.pagination.page);
setTotalPages(result.pagination.totalPages);
setTotalRecords(result.pagination.total);
// Fetch backend stats for org-level
if (isOrgLevel) {
const closedCount = result.allData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus === 'CLOSED';
}).length;
fetchBackendStatsRef.current(filterOptions.dateRange, filterOptions.startDate, filterOptions.endDate).then(() => {
setBackendStats(prev => prev ? { ...prev, closed: closedCount } : null);
});
}
// Stats are fetched separately via useEffect when filters change
} catch (error) {
setApiRequests([]);
} finally {
@ -209,13 +288,47 @@ export function Requests({ onViewRequest }: RequestsProps) {
fetchUsers();
}, [fetchDepartments, fetchUsers]);
// Fetch backend stats when date range changes
// Fetch backend stats when filters change (excluding status)
// Stats should reflect priority, department, initiator, approver, search, and date range filters
// But NOT status filter - stats should always show all status counts
// Total changes when other filters are applied, but stays stable when only status changes
useEffect(() => {
if (isOrgLevel) {
fetchBackendStatsRef.current(filters.dateRange, filters.customStartDate, filters.customEndDate);
const timeoutId = setTimeout(() => {
const filtersWithoutStatus = {
priority: filters.priorityFilter !== 'all' ? filters.priorityFilter : undefined,
department: filters.departmentFilter !== 'all' ? filters.departmentFilter : undefined,
initiator: filters.initiatorFilter !== 'all' ? filters.initiatorFilter : undefined,
approver: filters.approverFilter !== 'all' ? filters.approverFilter : undefined,
approverType: filters.approverFilter !== 'all' ? filters.approverFilterType : undefined,
search: filters.searchTerm || undefined,
slaCompliance: filters.slaComplianceFilter !== 'all' ? filters.slaComplianceFilter : undefined
};
fetchBackendStatsRef.current(
filters.dateRange,
filters.customStartDate,
filters.customEndDate,
filtersWithoutStatus
);
}, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOrgLevel, filters.dateRange, filters.customStartDate, filters.customEndDate]);
}, [
isOrgLevel,
filters.dateRange,
filters.customStartDate,
filters.customEndDate,
filters.priorityFilter,
filters.departmentFilter,
filters.initiatorFilter,
filters.approverFilter,
filters.approverFilterType,
filters.searchTerm,
filters.slaComplianceFilter
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
]);
// Fetch requests on mount and when filters change
useEffect(() => {
@ -252,34 +365,52 @@ export function Requests({ onViewRequest }: RequestsProps) {
// Transform requests
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
// Calculate stats
// Calculate stats - Always use backend stats API for overall counts (unfiltered)
// Stats should always show total counts regardless of any filters applied
const stats = useMemo(() => {
// For org-level: Use backend stats API (always unfiltered)
if (isOrgLevel && backendStats) {
return {
total: backendStats.total || 0,
pending: backendStats.pending || 0,
approved: backendStats.approved || 0,
rejected: backendStats.rejected || 0,
draft: backendStats.draft || 0,
closed: backendStats.closed || 0
};
}
// 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(
allFilteredRequests,
[], // Empty - we'll use backendStats or fallback
isOrgLevel,
backendStats,
filters.hasActiveFilters,
false, // No filters for stats - always show overall
totalRecords,
convertedRequests
);
}, [allFilteredRequests, isOrgLevel, backendStats, filters.hasActiveFilters, totalRecords, convertedRequests]);
}, [isOrgLevel, backendStats, totalRecords, convertedRequests]);
const totalRequests = isOrgLevel && backendStats ? backendStats.total : (totalRecords || convertedRequests.length);
// Removed totalRequests - no longer displayed in header (shown in stat cards instead)
return (
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="requests-page">
{/* Header */}
<RequestsHeader
isOrgLevel={isOrgLevel}
totalRequests={totalRequests}
loading={loading}
loadingStats={loadingStats}
exporting={exporting}
onExport={handleExportToCSV}
/>
{/* Stats */}
<RequestsStats stats={stats} />
<RequestsStats
stats={stats}
onStatusFilter={(status) => {
filters.setStatusFilter(status);
}}
/>
{/* Filters - TODO: Extract to separate component */}
<Card className="border-gray-200 shadow-md" data-testid="requests-filters">
@ -324,7 +455,6 @@ export function Requests({ onViewRequest }: RequestsProps) {
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="draft">Draft</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>

View File

@ -0,0 +1,684 @@
/**
* User All Requests Page - For Regular Users
*
* This is a SEPARATE screen for regular users' "All Requests" page.
* Shows only requests where the user is a participant (approver/spectator), NOT initiator.
* Completely separate from AdminAllRequests to avoid interference.
*/
import { useEffect, useState, useCallback, useMemo, useRef } from 'react';
import { Pagination } from '@/components/common/Pagination';
import dashboardService from '@/services/dashboard.service';
import userApi from '@/services/userApi';
// Components
import { RequestsHeader } from './components/RequestsHeader';
import { RequestsStats } from './components/RequestsStats';
import { RequestsList } from './components/RequestsList';
// Hooks
import { useRequestsFilters } from './hooks/useRequestsFilters';
import { useUserSearch } from './hooks/useUserSearch';
// Utils
import { transformRequests } from './utils/requestTransformers';
import { exportRequestsToCSV } from './utils/csvExports';
// Services
import { fetchUserParticipantRequestsData, fetchAllRequestsForExport } from './services/userRequestsService';
// Types
import type { RequestsProps } from './types/requests.types';
// Filter UI components
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { X, Search, Filter, RefreshCw, Calendar as CalendarIcon } from 'lucide-react';
import { format } from 'date-fns';
export function UserAllRequests({ onViewRequest }: RequestsProps) {
// Filters hook
const filters = useRequestsFilters();
// State
const [apiRequests, setApiRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [exporting, setExporting] = useState(false);
const [allRequestsForStats, setAllRequestsForStats] = useState<any[]>([]); // All requests without status filter for stats
const [departments, setDepartments] = useState<string[]>([]);
const [loadingDepartments, setLoadingDepartments] = useState(false);
const [allUsers, setAllUsers] = useState<Array<{ userId: string; email: string; displayName?: string }>>([]);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalRecordsForStats, setTotalRecordsForStats] = useState(0); // For stats (unfiltered - stable)
const [itemsPerPage] = useState(10);
// User search hooks
const initiatorSearch = useUserSearch({
allUsers,
filterValue: filters.initiatorFilter,
onFilterChange: filters.setInitiatorFilter
});
const approverSearch = useUserSearch({
allUsers,
filterValue: filters.approverFilter,
onFilterChange: filters.setApproverFilter
});
// Fetch all requests for stats calculation
// Apply all filters EXCEPT status filter - this way:
// - Total changes when priority/department/SLA/etc. filters are applied
// - Total remains stable when only status filter is applied
const fetchAllRequestsForStats = useCallback(async () => {
try {
// Get current filters directly from the filters object (not ref) to ensure we have latest values
const filterOptions = filters.getFilters();
// Build filters WITHOUT status filter for stats
// This ensures total changes with other filters (including SLA) but stays stable with status filter
const statsFilters = { ...filterOptions };
delete statsFilters.status; // Remove status filter to get all statuses
// Fetch first page with a larger limit to get more data for stats
const result = await fetchUserParticipantRequestsData({
page: 1,
itemsPerPage: 100, // Fetch more data for accurate stats
filters: statsFilters // Apply all filters except status (includes SLA, priority, department, etc.)
});
setAllRequestsForStats(result.data || []);
// Update totalRecordsForStats from this fetch (with filters except status)
// This total will change when other filters (including SLA) are applied, but stay stable when only status changes
if (result.pagination?.total !== undefined) {
setTotalRecordsForStats(result.pagination.total);
}
} catch (error) {
console.error('Failed to fetch requests for stats:', error);
setAllRequestsForStats([]);
}
}, [filters]);
// Fetch departments
const fetchDepartments = useCallback(async () => {
try {
setLoadingDepartments(true);
const depts = await dashboardService.getDepartments();
setDepartments(depts);
} catch (error) {
// Leave departments empty on error
} finally {
setLoadingDepartments(false);
}
}, []);
// Fetch users
const fetchUsers = useCallback(async () => {
try {
const usersData = await userApi.getAllUsers();
const usersList = usersData.map((user: any) => ({
userId: user.userId,
email: user.email,
displayName: user.displayName || user.email
}));
setAllUsers(usersList);
} catch (error) {
console.error('Failed to fetch users:', error);
}
}, []);
// Use refs to store stable callbacks to prevent infinite loops
const filtersRef = useRef(filters);
const fetchAllRequestsForStatsRef = useRef(fetchAllRequestsForStats);
// Update refs on each render
useEffect(() => {
filtersRef.current = filters;
fetchAllRequestsForStatsRef.current = fetchAllRequestsForStats;
}, [filters, fetchAllRequestsForStats]);
// Fetch requests
const fetchRequests = useCallback(async (page: number = 1) => {
try {
if (page === 1) {
setLoading(true);
setApiRequests([]);
}
const filterOptions = filtersRef.current.getFilters();
const result = await fetchUserParticipantRequestsData({
page,
itemsPerPage,
filters: filterOptions
});
setApiRequests(result.data); // Paginated data WITH status filter (for list display)
// Update pagination (for list display - includes status filter)
setCurrentPage(result.pagination.page);
setTotalPages(result.pagination.totalPages);
// Don't update totalRecords here - it should come from stats fetch (without status filter)
// setTotalRecords(result.pagination.total); // Commented out - use totalRecordsForStats instead
} catch (error) {
setApiRequests([]);
} finally {
setLoading(false);
}
}, [itemsPerPage]);
// Export to CSV
const handleExportToCSV = useCallback(async () => {
try {
setExporting(true);
const allData = await fetchAllRequestsForExport(filters.getFilters());
await exportRequestsToCSV(allData, filters.getFilters());
} catch (error: any) {
console.error('Failed to export requests:', error);
alert('Failed to export requests. Please try again.');
} finally {
setExporting(false);
}
}, [filters]);
// Initial fetch
useEffect(() => {
fetchDepartments();
fetchUsers();
}, [fetchDepartments, fetchUsers]);
// Fetch stats when filters change (except status filter)
// This ensures total changes with other filters but stays stable with status filter
useEffect(() => {
const timeoutId = setTimeout(() => {
fetchAllRequestsForStats();
}, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filters.searchTerm,
filters.priorityFilter,
filters.slaComplianceFilter,
filters.departmentFilter,
filters.initiatorFilter,
filters.approverFilter,
filters.approverFilterType,
filters.dateRange,
filters.customStartDate,
filters.customEndDate
// fetchAllRequestsForStats excluded to prevent infinite loops
// Note: statusFilter is NOT in dependencies - stats don't change when only status changes
]);
// Fetch requests on mount and when filters change (for list display)
useEffect(() => {
const timeoutId = setTimeout(() => {
setCurrentPage(1);
fetchRequests(1);
}, filters.searchTerm ? 500 : 0);
return () => clearTimeout(timeoutId);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
filters.searchTerm,
filters.statusFilter,
filters.priorityFilter,
filters.slaComplianceFilter,
filters.departmentFilter,
filters.initiatorFilter,
filters.approverFilter,
filters.approverFilterType,
filters.dateRange,
filters.customStartDate,
filters.customEndDate
// fetchRequests excluded to prevent infinite loops
]);
// Page change handler
const handlePageChange = useCallback((newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
setCurrentPage(newPage);
fetchRequests(newPage);
}
}, [totalPages, fetchRequests]);
// Transform requests
const convertedRequests = useMemo(() => transformRequests(apiRequests), [apiRequests]);
// Transform all requests for stats (without status filter)
const allConvertedRequestsForStats = useMemo(() => transformRequests(allRequestsForStats), [allRequestsForStats]);
// Calculate stats from all fetched data (without status filter)
const stats = useMemo(() => {
// For regular users, calculate stats from allRequestsForStats (fetched without status filter)
// Use totalRecords for total (from backend), and calculate individual status counts from fetched data
if (allConvertedRequestsForStats.length > 0) {
// Calculate individual status counts from all fetched requests
const pending = allConvertedRequestsForStats.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'pending' || status === 'in-progress';
}).length;
const approved = allConvertedRequestsForStats.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'approved';
}).length;
const rejected = allConvertedRequestsForStats.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'rejected';
}).length;
const closed = allConvertedRequestsForStats.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'closed';
}).length;
// Use totalRecordsForStats for total - this changes when other filters (priority, department, etc.) are applied
// but stays stable when only status filter changes
return {
total: totalRecordsForStats > 0 ? totalRecordsForStats : allConvertedRequestsForStats.length, // Use total from stats fetch (with other filters, without status)
pending,
approved,
rejected,
draft: 0, // Drafts are excluded
closed
};
} else {
// Fallback: calculate from convertedRequests (current page only) - less accurate
const pending = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'pending' || status === 'in-progress';
}).length;
const approved = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'approved';
}).length;
const rejected = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'rejected';
}).length;
const closed = convertedRequests.filter((r: any) => {
const status = (r.status || '').toString().toLowerCase();
return status === 'closed';
}).length;
return {
total: totalRecordsForStats > 0 ? totalRecordsForStats : convertedRequests.length, // Use total from stats fetch if available
pending,
approved,
rejected,
draft: 0,
closed
};
}
}, [totalRecordsForStats, allConvertedRequestsForStats, convertedRequests]);
return (
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="user-all-requests-page">
{/* Header */}
<RequestsHeader
isOrgLevel={false}
loading={loading}
exporting={exporting}
onExport={handleExportToCSV}
/>
{/* Stats */}
<RequestsStats
stats={stats}
onStatusFilter={(status) => {
filters.setStatusFilter(status);
}}
/>
{/* Filters */}
<Card className="border-gray-200 shadow-md" data-testid="requests-filters">
<CardContent className="p-4 sm:p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-muted-foreground" />
<h3 className="font-semibold text-gray-900">Advanced Filters</h3>
{filters.hasActiveFilters && (
<Badge variant="outline" className="bg-blue-50 text-blue-700 border-blue-200">
Active
</Badge>
)}
</div>
{filters.hasActiveFilters && (
<Button variant="ghost" size="sm" onClick={filters.clearFilters} className="gap-2">
<RefreshCw className="w-4 h-4" />
Clear All
</Button>
)}
</div>
<Separator />
{/* Primary Filters */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3 sm:gap-4">
<div className="relative md:col-span-3 lg:col-span-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4" />
<Input
placeholder="Search requests..."
value={filters.searchTerm}
onChange={(e) => filters.setSearchTerm(e.target.value)}
className="pl-10 h-10"
data-testid="search-input"
/>
</div>
<Select value={filters.statusFilter} onValueChange={filters.setStatusFilter}>
<SelectTrigger className="h-10" data-testid="status-filter">
<SelectValue placeholder="All Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
<Select value={filters.priorityFilter} onValueChange={filters.setPriorityFilter}>
<SelectTrigger className="h-10" data-testid="priority-filter">
<SelectValue placeholder="All Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priority</SelectItem>
<SelectItem value="express">Express</SelectItem>
<SelectItem value="standard">Standard</SelectItem>
</SelectContent>
</Select>
<Select
value={filters.departmentFilter}
onValueChange={filters.setDepartmentFilter}
disabled={loadingDepartments || departments.length === 0}
>
<SelectTrigger className="h-10" data-testid="department-filter">
<SelectValue placeholder={loadingDepartments ? "Loading..." : "All Departments"} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Departments</SelectItem>
{departments.map((dept) => (
<SelectItem key={dept} value={dept}>{dept}</SelectItem>
))}
</SelectContent>
</Select>
<Select value={filters.slaComplianceFilter} onValueChange={filters.setSlaComplianceFilter}>
<SelectTrigger className="h-10" data-testid="sla-compliance-filter">
<SelectValue placeholder="All SLA Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All SLA Status</SelectItem>
<SelectItem value="compliant">Compliant</SelectItem>
<SelectItem value="on-track">On Track</SelectItem>
<SelectItem value="approaching">Approaching</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="breached">Breached</SelectItem>
</SelectContent>
</Select>
</div>
{/* User Filters - Initiator and Approver */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 sm:gap-4">
{/* Initiator Filter */}
<div className="flex flex-col">
<Label className="text-sm font-medium text-gray-700 mb-2">Initiator</Label>
<div className="relative">
{initiatorSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={initiatorSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search initiator..."
value={initiatorSearch.searchQuery}
onChange={(e) => initiatorSearch.handleSearch(e.target.value)}
onFocus={() => {
if (initiatorSearch.searchResults.length > 0) {
initiatorSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => initiatorSearch.setShowResults(false), 200)}
className="h-10"
data-testid="initiator-search-input"
/>
{initiatorSearch.showResults && initiatorSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{initiatorSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => initiatorSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
{/* Approver Filter */}
<div className="flex flex-col">
<div className="flex items-center justify-between mb-2">
<Label className="text-sm font-medium text-gray-700">Approver</Label>
{filters.approverFilter !== 'all' && (
<Select
value={filters.approverFilterType}
onValueChange={(value: 'current' | 'any') => filters.setApproverFilterType(value)}
>
<SelectTrigger className="h-7 w-32 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="current">Current Only</SelectItem>
<SelectItem value="any">Any Approver</SelectItem>
</SelectContent>
</Select>
)}
</div>
<div className="relative">
{approverSearch.selectedUser ? (
<div className="flex items-center gap-2 h-10 px-3 bg-white border border-gray-300 rounded-md">
<span className="flex-1 text-sm text-gray-900 truncate">
{approverSearch.selectedUser.displayName || approverSearch.selectedUser.email}
</span>
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={approverSearch.handleClear}>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<>
<Input
placeholder="Search approver..."
value={approverSearch.searchQuery}
onChange={(e) => approverSearch.handleSearch(e.target.value)}
onFocus={() => {
if (approverSearch.searchResults.length > 0) {
approverSearch.setShowResults(true);
}
}}
onBlur={() => setTimeout(() => approverSearch.setShowResults(false), 200)}
className="h-10"
data-testid="approver-search-input"
/>
{approverSearch.showResults && approverSearch.searchResults.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-white border border-gray-300 rounded-md shadow-lg max-h-60 overflow-y-auto">
{approverSearch.searchResults.map((user) => (
<button
key={user.userId}
type="button"
onClick={() => approverSearch.handleSelect(user)}
className="w-full px-4 py-2 text-left hover:bg-gray-50"
>
<div className="flex flex-col">
<span className="text-sm font-medium text-gray-900">
{user.displayName || user.email}
</span>
{user.displayName && (
<span className="text-xs text-gray-500">{user.email}</span>
)}
</div>
</button>
))}
</div>
)}
</>
)}
</div>
</div>
</div>
{/* Date Range Filter */}
<div className="flex items-center gap-3 flex-wrap">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={filters.dateRange} onValueChange={filters.handleDateRangeChange}>
<SelectTrigger className="w-[160px] h-10">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{filters.dateRange === 'custom' && (
<Popover open={filters.showCustomDatePicker} onOpenChange={filters.setShowCustomDatePicker}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{filters.customStartDate && filters.customEndDate
? `${format(filters.customStartDate, 'MMM d, yyyy')} - ${format(filters.customEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start">
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date">Start Date</Label>
<Input
id="start-date"
type="date"
value={filters.customStartDate ? format(filters.customStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
filters.setCustomStartDate(date);
if (filters.customEndDate && date > filters.customEndDate) {
filters.setCustomEndDate(date);
}
} else {
filters.setCustomStartDate(undefined);
}
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date">End Date</Label>
<Input
id="end-date"
type="date"
value={filters.customEndDate ? format(filters.customEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
filters.setCustomEndDate(date);
if (filters.customStartDate && date < filters.customStartDate) {
filters.setCustomStartDate(date);
}
} else {
filters.setCustomEndDate(undefined);
}
}}
min={filters.customStartDate ? format(filters.customStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={filters.handleApplyCustomDate}
disabled={!filters.customStartDate || !filters.customEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
filters.setShowCustomDatePicker(false);
filters.setCustomStartDate(undefined);
filters.setCustomEndDate(undefined);
filters.setDateRange('month');
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</CardContent>
</Card>
{/* Requests List */}
<RequestsList
requests={convertedRequests}
loading={loading}
hasActiveFilters={filters.hasActiveFilters}
onViewRequest={onViewRequest}
/>
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalRecords={totalRecordsForStats}
itemsPerPage={itemsPerPage}
onPageChange={handlePageChange}
loading={loading}
itemLabel="requests"
testIdPrefix="requests-pagination"
/>
</div>
);
}

View File

@ -8,18 +8,14 @@ import { PageHeader } from '@/components/common/PageHeader';
interface RequestsHeaderProps {
isOrgLevel: boolean;
totalRequests: number;
loading: boolean;
loadingStats: boolean;
exporting: boolean;
onExport: () => void;
}
export function RequestsHeader({
isOrgLevel,
totalRequests,
loading,
loadingStats,
exporting,
onExport
}: RequestsHeaderProps) {
@ -27,15 +23,10 @@ export function RequestsHeader({
<div className="flex items-start justify-between gap-4" data-testid="requests-header-container">
<PageHeader
icon={FileText}
title={isOrgLevel ? "All Requests (Organization)" : "My Requests"}
title={isOrgLevel ? "All Requests (Organization)" : "All Requests"}
description={isOrgLevel
? "View and filter all organization-wide workflow requests with advanced filtering options"
: "View and filter your workflow requests with advanced filtering options"}
badge={{
value: `${totalRequests} total`,
label: 'requests',
loading: loading || loadingStats
}}
testId="requests-header"
/>
<Button

View File

@ -1,18 +1,26 @@
/**
* Requests Stats Overview Component
* Displays statistics cards for requests with click handlers to filter
*/
import { FileText, Clock, CheckCircle, XCircle, Archive, Edit } from 'lucide-react';
import { FileText, Clock, CheckCircle, XCircle, Archive } from 'lucide-react';
import { StatsCard } from '@/components/dashboard/StatsCard';
import type { RequestStats } from '../types/requests.types';
interface RequestsStatsProps {
stats: RequestStats;
onStatusFilter?: (status: string) => void;
}
export function RequestsStats({ stats }: RequestsStatsProps) {
export function RequestsStats({ stats, onStatusFilter }: RequestsStatsProps) {
const handleCardClick = (status: string) => {
if (onStatusFilter) {
onStatusFilter(status);
}
};
return (
<div className="grid grid-cols-2 md:grid-cols-6 gap-3 sm:gap-4" data-testid="requests-stats">
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 sm:gap-4" data-testid="requests-stats">
<StatsCard
label="Total"
value={stats.total}
@ -22,6 +30,7 @@ export function RequestsStats({ stats }: RequestsStatsProps) {
textColor="text-blue-700"
valueColor="text-blue-900"
testId="stat-total"
onClick={onStatusFilter ? () => handleCardClick('all') : undefined}
/>
<StatsCard
@ -33,6 +42,7 @@ export function RequestsStats({ stats }: RequestsStatsProps) {
textColor="text-orange-700"
valueColor="text-orange-900"
testId="stat-pending"
onClick={onStatusFilter ? () => handleCardClick('pending') : undefined}
/>
<StatsCard
@ -44,6 +54,7 @@ export function RequestsStats({ stats }: RequestsStatsProps) {
textColor="text-green-700"
valueColor="text-green-900"
testId="stat-approved"
onClick={onStatusFilter ? () => handleCardClick('approved') : undefined}
/>
<StatsCard
@ -55,6 +66,7 @@ export function RequestsStats({ stats }: RequestsStatsProps) {
textColor="text-red-700"
valueColor="text-red-900"
testId="stat-rejected"
onClick={onStatusFilter ? () => handleCardClick('rejected') : undefined}
/>
<StatsCard
@ -66,17 +78,7 @@ export function RequestsStats({ stats }: RequestsStatsProps) {
textColor="text-purple-700"
valueColor="text-purple-900"
testId="stat-closed"
/>
<StatsCard
label="Draft"
value={stats.draft}
icon={Edit}
iconColor="text-gray-600"
gradient="bg-gradient-to-br from-gray-50 to-gray-100 border-gray-200"
textColor="text-gray-700"
valueColor="text-gray-900"
testId="stat-draft"
onClick={onStatusFilter ? () => handleCardClick('closed') : undefined}
/>
</div>
);

View File

@ -3,7 +3,6 @@
*/
import workflowApi from '@/services/workflowApi';
import { applyFilters } from '../utils/requestFilters';
import type { RequestFilters } from '../types/requests.types';
interface FetchRequestsOptions {
@ -20,16 +19,28 @@ export async function fetchRequestsData({
isOrgLevel
}: FetchRequestsOptions) {
if (isOrgLevel) {
// Organization-level: fetch all pages and filter client-side
const allPages: any[] = [];
let currentPageNum = 1;
let hasMore = true;
const maxPages = 50;
// Organization-level: Use backend pagination with filters for list display
// Build filter params for backend API (including status filter for list)
const backendFilters: any = {};
if (filters?.search) backendFilters.search = filters.search;
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.approver && filters.approver !== 'all') {
backendFilters.approver = filters.approver;
backendFilters.approverType = filters.approverType || 'current'; // Default to 'current'
}
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
while (hasMore && currentPageNum <= maxPages) {
// Fetch paginated data for list display (with status filter)
const pageResult = await workflowApi.listWorkflows({
page: currentPageNum,
limit: 100
page,
limit: itemsPerPage,
...backendFilters
});
let pageData: any[] = [];
@ -39,118 +50,91 @@ export async function fetchRequestsData({
pageData = pageResult;
}
if (pageData.length > 0) {
allPages.push(...pageData);
currentPageNum++;
// 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';
});
if (pageResult?.pagination) {
hasMore = currentPageNum <= pageResult.pagination.totalPages;
} else {
hasMore = pageData.length === 100;
}
} else {
hasMore = false;
}
}
// Apply filters
const filteredData = filters ? applyFilters(allPages, filters) : allPages;
// Apply pagination
const startIndex = (page - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedData = filteredData.slice(startIndex, endIndex);
return {
data: paginatedData,
allData: allPages,
filteredData,
pagination: {
// Get pagination info from backend response
const pagination = pageResult?.pagination || {
page,
limit: itemsPerPage,
total: filteredData.length,
totalPages: Math.ceil(filteredData.length / itemsPerPage) || 1
total: nonDraftData.length,
totalPages: 1
};
// Stats will be fetched separately from backend stats API (always unfiltered)
// No need to fetch all data for stats here - backend stats API handles it
// Return empty array for allData - stats will come from backendStats
const allDataForStats: any[] = [];
return {
data: nonDraftData, // Paginated data for list display (with status filter)
allData: allDataForStats, // All data without status filter for stats calculation
filteredData: nonDraftData, // Same as data for list
pagination: {
page: pagination.page,
limit: pagination.limit || itemsPerPage,
total: pagination.total || nonDraftData.length,
totalPages: pagination.totalPages || 1
}
};
} else {
// User-level: use backend filtering where possible
const needsAllDataForDateFilter = filters?.dateRange && filters.dateRange !== 'month';
// User-level: Use SEPARATE endpoint for regular users' "All Requests" page
// This shows only participant requests (approver/spectator), NOT initiator requests
const backendFilters: any = {};
if (filters?.search) backendFilters.search = filters.search;
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate?.toISOString();
if (filters?.endDate) backendFilters.endDate = filters.endDate?.toISOString();
let userWorkflowsData: any[] = [];
if (needsAllDataForDateFilter || filters?.dateRange === 'custom') {
// Fetch all pages for date filtering
const allPages: any[] = [];
let currentPageNum = 1;
let hasMore = true;
const maxPages = 50;
while (hasMore && currentPageNum <= maxPages) {
const pageResult = await workflowApi.listMyWorkflows({
page: currentPageNum,
limit: 100,
search: filters?.search,
status: filters?.status !== 'all' ? filters?.status : undefined,
priority: filters?.priority !== 'all' ? filters?.priority : undefined
});
const pageData = Array.isArray((pageResult as any)?.data)
? (pageResult as any).data
: [];
if (pageData.length > 0) {
allPages.push(...pageData);
currentPageNum++;
if (pageResult?.pagination) {
hasMore = currentPageNum <= pageResult.pagination.totalPages;
} else {
hasMore = pageData.length === 100;
}
} else {
hasMore = false;
}
}
userWorkflowsData = allPages;
} else {
// Normal pagination
const userResult = await workflowApi.listMyWorkflows({
// Fetch paginated data using SEPARATE endpoint for regular users
// This endpoint excludes initiator requests automatically
const pageResult = await workflowApi.listParticipantRequests({
page,
limit: itemsPerPage,
search: filters?.search,
status: filters?.status !== 'all' ? filters?.status : undefined,
priority: filters?.priority !== 'all' ? filters?.priority : undefined
...backendFilters
});
userWorkflowsData = Array.isArray((userResult as any)?.data)
? (userResult as any).data
: [];
let pageData: any[] = [];
if (Array.isArray(pageResult?.data)) {
pageData = pageResult.data;
} else if (Array.isArray(pageResult)) {
pageData = pageResult;
}
// Apply client-side filtering for user-level requests
let filteredUserData = filters ? applyFilters(userWorkflowsData, filters) : userWorkflowsData;
// 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';
});
// Apply pagination
const startIndex = (page - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedUserData = filteredUserData.slice(startIndex, endIndex);
// Get pagination info from backend response
const pagination = pageResult?.pagination || {
page,
limit: itemsPerPage,
total: nonDraftData.length,
totalPages: 1
};
return {
data: paginatedUserData,
allData: userWorkflowsData,
filteredData: filteredUserData,
pagination: {
page,
limit: itemsPerPage,
total: filteredUserData.length,
totalPages: Math.ceil(filteredUserData.length / itemsPerPage) || 1
}
data: nonDraftData, // Paginated data for list
allData: [], // Stats come from backend stats API for user-level too
filteredData: nonDraftData, // This is the data for the current page, already filtered
pagination: pagination
};
}
}
export async function fetchAllRequestsForExport(isOrgLevel: boolean): Promise<any[]> {
// Use a larger limit (100) to reduce number of API calls when exporting all data
const EXPORT_FETCH_LIMIT = 100;
const allPages: any[] = [];
let currentPageNum = 1;
let hasMore = true;
@ -158,8 +142,8 @@ export async function fetchAllRequestsForExport(isOrgLevel: boolean): Promise<an
while (hasMore && currentPageNum <= maxPages) {
const pageResult = isOrgLevel
? await workflowApi.listWorkflows({ page: currentPageNum, limit: 100 })
: await workflowApi.listMyWorkflows({ page: currentPageNum, limit: 100 });
? 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
let pageData: any[] = [];
if (Array.isArray(pageResult?.data)) {
@ -169,13 +153,18 @@ export async function fetchAllRequestsForExport(isOrgLevel: boolean): Promise<an
}
if (pageData.length > 0) {
allPages.push(...pageData);
// Filter out drafts before adding to allPages
const nonDraftData = pageData.filter((req: any) => {
const reqStatus = (req.status || '').toString().toUpperCase();
return reqStatus !== 'DRAFT';
});
allPages.push(...nonDraftData);
currentPageNum++;
if (pageResult?.pagination) {
hasMore = currentPageNum <= pageResult.pagination.totalPages;
} else {
hasMore = pageData.length === 100;
hasMore = pageData.length === EXPORT_FETCH_LIMIT;
}
} else {
hasMore = false;
@ -184,4 +173,3 @@ export async function fetchAllRequestsForExport(isOrgLevel: boolean): Promise<an
return allPages;
}

View File

@ -0,0 +1,129 @@
/**
* Service for fetching user participant requests data
* SEPARATE from admin requests service to avoid interference
*
* This service is specifically for regular users' "All Requests" page
* Shows only requests where user is a participant (approver/spectator), NOT initiator
*/
import workflowApi from '@/services/workflowApi';
import type { RequestFilters } from '../types/requests.types';
const EXPORT_FETCH_LIMIT = 100;
interface FetchUserParticipantRequestsOptions {
page: number;
itemsPerPage: number;
filters?: RequestFilters;
}
/**
* Fetch participant requests for regular users
* Uses /workflows/participant-requests endpoint which excludes initiator requests
*/
export async function fetchUserParticipantRequestsData({
page,
itemsPerPage,
filters
}: FetchUserParticipantRequestsOptions) {
// Build filter params for backend API
const backendFilters: any = {};
if (filters?.search) backendFilters.search = filters.search;
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.approver && filters.approver !== 'all') {
backendFilters.approver = filters.approver;
backendFilters.approverType = filters.approverType || 'current'; // Default to 'current'
}
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
// Fetch paginated data using SEPARATE endpoint for regular users
// This endpoint automatically excludes initiator requests
const pageResult = await workflowApi.listParticipantRequests({
page,
limit: itemsPerPage,
...backendFilters
});
let pageData: any[] = [];
if (Array.isArray(pageResult?.data)) {
pageData = pageResult.data;
} else if (Array.isArray(pageResult)) {
pageData = pageResult;
}
// 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 = pageResult?.pagination || {
page,
limit: itemsPerPage,
total: nonDraftData.length,
totalPages: 1
};
return {
data: nonDraftData, // Paginated data for list display
allData: [], // Stats calculated from data
filteredData: nonDraftData, // Same as data for list
pagination: pagination
};
}
/**
* Fetch all participant requests for export (regular users)
* Uses the same endpoint but fetches all pages
*/
export async function fetchAllRequestsForExport(filters?: RequestFilters): Promise<any[]> {
const allPages: any[] = [];
let currentPage = 1;
let hasMore = true;
const maxPages = 100; // Safety limit
// Build filter params for backend API
const backendFilters: any = {};
if (filters?.search) backendFilters.search = filters.search;
if (filters?.status && filters.status !== 'all') backendFilters.status = filters.status;
if (filters?.priority && filters.priority !== 'all') backendFilters.priority = filters.priority;
if (filters?.department && filters.department !== 'all') backendFilters.department = filters.department;
if (filters?.initiator && filters.initiator !== 'all') backendFilters.initiator = filters.initiator;
if (filters?.approver && filters.approver !== 'all') {
backendFilters.approver = filters.approver;
backendFilters.approverType = filters.approverType || 'current';
}
if (filters?.slaCompliance && filters.slaCompliance !== 'all') backendFilters.slaCompliance = filters.slaCompliance;
if (filters?.dateRange) backendFilters.dateRange = filters.dateRange;
if (filters?.startDate) backendFilters.startDate = filters.startDate instanceof Date ? filters.startDate.toISOString() : filters.startDate;
if (filters?.endDate) backendFilters.endDate = filters.endDate instanceof Date ? filters.endDate.toISOString() : filters.endDate;
while (hasMore && currentPage <= maxPages) {
const pageResult = await workflowApi.listParticipantRequests({
page: currentPage,
limit: EXPORT_FETCH_LIMIT,
...backendFilters
});
const pageData = pageResult?.data || [];
if (pageData.length === 0) {
hasMore = false;
} else {
allPages.push(...pageData);
currentPage++;
if (pageData.length < EXPORT_FETCH_LIMIT) {
hasMore = false;
}
}
}
return allPages;
}

View File

@ -15,8 +15,10 @@ export function calculateStatsFromFilteredData(
// Check if we have active filters (excluding default date range)
const hasFilters = hasActiveFilters;
// Use allFilteredRequests (all filtered data before pagination) for accurate stats
// Use allFilteredRequests (data filtered by all filters EXCEPT status) for stats calculation
// This ensures stats show all status counts based on other filters (priority, department, etc.)
if (allFilteredRequests.length > 0) {
// Always show all status counts - status filter only affects the list, not the stats
const total = allFilteredRequests.length;
const pending = allFilteredRequests.filter((r: any) => {
const status = (r.status || '').toString().toUpperCase();
@ -40,7 +42,7 @@ export function calculateStatsFromFilteredData(
}).length;
return {
total,
total: total, // Total based on other filters (priority, department, etc.)
pending,
approved,
rejected,
@ -59,8 +61,11 @@ export function calculateStatsFromFilteredData(
};
} else {
// Fallback: calculate from convertedRequests (paginated data - less accurate)
// Note: This fallback should ideally not be used, but if it is, we still show all status counts
const total = totalRecords || convertedRequests.length;
return {
total: totalRecords || convertedRequests.length,
total: total,
pending: convertedRequests.filter(r => r.status === 'pending' || r.status === 'in-progress').length,
approved: convertedRequests.filter(r => r.status === 'approved').length,
rejected: convertedRequests.filter(r => r.status === 'rejected').length,

View File

@ -10,6 +10,14 @@ import type { RequestFilters } from '../types/requests.types';
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
/**
* Apply filters excluding status filter (for stats calculation)
*/
export function applyFiltersWithoutStatus(data: any[], filters: RequestFilters): any[] {
const filtersWithoutStatus = { ...filters, status: undefined };
return applyFilters(data, filtersWithoutStatus);
}
export function applyFilters(data: any[], filters: RequestFilters): any[] {
let filteredData = [...data];

View File

@ -32,6 +32,64 @@ export function Settings() {
setShowNotificationModal(false);
try {
// Check if notifications are supported
if (!('Notification' in window)) {
setNotificationSuccess(false);
setNotificationMessage('Notifications are not supported in this browser. Please use a modern browser like Chrome, Firefox, or Edge.');
setShowNotificationModal(true);
setIsEnablingNotifications(false);
return;
}
// Check current permission status BEFORE attempting to enable
let permission = Notification.permission;
// If permission was previously denied, show user-friendly instructions
if (permission === 'denied') {
setNotificationSuccess(false);
setNotificationMessage(
'Notification permission was previously denied. To enable notifications:\n\n' +
'1. Click the lock icon (🔒) or info icon () in your browser\'s address bar\n' +
'2. Find "Notifications" in the permissions list\n' +
'3. Change it from "Block" to "Allow"\n' +
'4. Refresh this page and try again\n\n' +
'Alternatively, you can enable notifications in your browser\'s site settings.'
);
setShowNotificationModal(true);
setIsEnablingNotifications(false);
return;
}
// If permission is 'default', request it first
if (permission === 'default') {
permission = await Notification.requestPermission();
// If user denied the permission request
if (permission === 'denied') {
setNotificationSuccess(false);
setNotificationMessage(
'Notification permission was denied. To enable notifications:\n\n' +
'1. Click the lock icon (🔒) or info icon () in your browser\'s address bar\n' +
'2. Find "Notifications" in the permissions list\n' +
'3. Change it from "Block" to "Allow"\n' +
'4. Refresh this page and try again'
);
setShowNotificationModal(true);
setIsEnablingNotifications(false);
return;
}
}
// Only proceed if permission is 'granted'
if (permission !== 'granted') {
setNotificationSuccess(false);
setNotificationMessage('Notification permission is required to enable push notifications. Please grant permission and try again.');
setShowNotificationModal(true);
setIsEnablingNotifications(false);
return;
}
// Permission is granted, proceed with setup
await setupPushNotifications();
setNotificationSuccess(true);
setNotificationMessage('Push notifications have been successfully enabled! You will now receive notifications for workflow updates, approvals, and TAT alerts.');

View File

@ -0,0 +1,236 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Loader2, Search, FileText, Calendar, User, Eye, EyeOff } from 'lucide-react';
import { listSharedSummaries, markAsViewed, type SharedSummary } from '@/services/summaryApi';
import { format } from 'date-fns';
import { toast } from 'sonner';
interface SharedSummariesProps {
onViewSummary?: (sharedSummaryId: string) => void;
}
export function SharedSummaries({ onViewSummary }: SharedSummariesProps) {
const navigate = useNavigate();
const [summaries, setSummaries] = useState<SharedSummary[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalRecords, setTotalRecords] = useState(0);
const itemsPerPage = 10;
const fetchSummaries = useCallback(async (page: number = 1) => {
try {
setLoading(true);
const result = await listSharedSummaries({ page, limit: itemsPerPage });
setSummaries(result.data || []);
setTotalPages(result.pagination.totalPages || 1);
setTotalRecords(result.pagination.total || 0);
setCurrentPage(result.pagination.page || 1);
} catch (error: any) {
console.error('Failed to fetch shared summaries:', error);
toast.error('Failed to load shared summaries');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchSummaries(1);
}, [fetchSummaries]);
const handleViewSummary = async (sharedSummaryId: string) => {
try {
// Mark as viewed
await markAsViewed(sharedSummaryId);
// Update local state
setSummaries(prev => prev.map(s =>
s.sharedSummaryId === sharedSummaryId
? { ...s, isRead: true, viewedAt: new Date().toISOString() }
: s
));
// Navigate to detail view
if (onViewSummary) {
onViewSummary(sharedSummaryId);
} else {
navigate(`/shared-summaries/${sharedSummaryId}`);
}
} catch (error: any) {
console.error('Failed to mark as viewed:', error);
// Still navigate even if marking as viewed fails
if (onViewSummary) {
onViewSummary(sharedSummaryId);
} else {
navigate(`/shared-summaries/${sharedSummaryId}`);
}
}
};
const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) {
fetchSummaries(newPage);
}
};
const filteredSummaries = summaries.filter(summary => {
if (!searchTerm) return true;
const search = searchTerm.toLowerCase();
return (
summary.title?.toLowerCase().includes(search) ||
summary.requestNumber?.toLowerCase().includes(search) ||
summary.initiatorName?.toLowerCase().includes(search) ||
summary.sharedByName?.toLowerCase().includes(search)
);
});
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
<div className="max-w-7xl mx-auto">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">Shared Summaries</h1>
<p className="text-sm text-gray-600">View summaries of closed requests shared with you</p>
</div>
{/* Search */}
<div className="mb-6">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search by title, request number, or user..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
</div>
{/* Loading State */}
{loading && (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin text-blue-600" />
</div>
)}
{/* Summaries List */}
{!loading && filteredSummaries.length === 0 && (
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center">
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-semibold text-gray-900 mb-2">No shared summaries</h3>
<p className="text-gray-600">
{searchTerm ? 'No summaries match your search.' : 'You haven\'t received any shared summaries yet.'}
</p>
</div>
)}
{!loading && filteredSummaries.length > 0 && (
<>
<div className="grid gap-4 mb-6">
{filteredSummaries.map((summary) => (
<div
key={summary.sharedSummaryId}
className={`bg-white rounded-lg shadow-sm border-2 transition-all cursor-pointer hover:shadow-md ${
summary.isRead ? 'border-gray-200' : 'border-blue-300 bg-blue-50'
}`}
onClick={() => handleViewSummary(summary.sharedSummaryId)}
>
<div className="p-4 sm:p-6">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-2">
{summary.isRead ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-blue-600" />
)}
<h3 className="text-lg font-semibold text-gray-900 truncate">
{summary.title}
</h3>
{!summary.isRead && (
<span className="px-2 py-0.5 bg-blue-100 text-blue-700 text-xs font-medium rounded-full">
New
</span>
)}
</div>
<p className="text-sm text-gray-600 mb-3">
Request: <span className="font-medium">{summary.requestNumber}</span>
</p>
<div className="flex flex-wrap items-center gap-4 text-sm text-gray-500">
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
<span>Initiator: {summary.initiatorName}</span>
</div>
<div className="flex items-center gap-1">
<User className="h-4 w-4" />
<span>Shared by: {summary.sharedByName}</span>
</div>
<div className="flex items-center gap-1">
<Calendar className="h-4 w-4" />
<span>
Shared: {format(new Date(summary.sharedAt), 'MMM dd, yyyy HH:mm')}
</span>
</div>
{summary.viewedAt && (
<div className="flex items-center gap-1">
<Eye className="h-4 w-4" />
<span>
Viewed: {format(new Date(summary.viewedAt), 'MMM dd, yyyy HH:mm')}
</span>
</div>
)}
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleViewSummary(summary.sharedSummaryId);
}}
>
View
</Button>
</div>
</div>
</div>
))}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-between bg-white rounded-lg shadow-sm border border-gray-200 p-4">
<div className="text-sm text-gray-600">
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, totalRecords)} of {totalRecords} summaries
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</Button>
<span className="text-sm text-gray-600">
Page {currentPage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
</Button>
</div>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,241 @@
import { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, ArrowLeft, FileText, CheckCircle, XCircle, Clock } from 'lucide-react';
import { getSummaryDetails, markAsViewed, type SummaryDetails } from '@/services/summaryApi';
import { format } from 'date-fns';
import { toast } from 'sonner';
export function SharedSummaryDetail() {
const { sharedSummaryId } = useParams<{ sharedSummaryId: string }>();
const navigate = useNavigate();
const [summary, setSummary] = useState<SummaryDetails | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!sharedSummaryId) {
navigate('/shared-summaries');
return;
}
const fetchSummary = async () => {
try {
setLoading(true);
// First, mark as viewed
try {
await markAsViewed(sharedSummaryId);
} catch (error) {
console.warn('Failed to mark as viewed:', error);
}
// Then get the summary details
// Note: We need to get the summaryId from the shared summary first
// For now, we'll use the sharedSummaryId to get details
// The backend should handle this, but we might need to adjust the API
const details = await getSummaryDetails(sharedSummaryId);
setSummary(details);
} catch (error: any) {
console.error('Failed to fetch summary details:', error);
toast.error(error?.response?.data?.message || 'Failed to load summary');
navigate('/shared-summaries');
} finally {
setLoading(false);
}
};
fetchSummary();
}, [sharedSummaryId, navigate]);
const getStatusIcon = (status: string) => {
const statusLower = status.toLowerCase();
if (statusLower === 'approved') return <CheckCircle className="h-4 w-4 text-green-600" />;
if (statusLower === 'rejected') return <XCircle className="h-4 w-4 text-red-600" />;
if (statusLower === 'pending' || statusLower === 'in progress') return <Clock className="h-4 w-4 text-orange-600" />;
return <FileText className="h-4 w-4 text-gray-600" />;
};
const getStatusColor = (status: string) => {
const statusLower = status.toLowerCase();
if (statusLower === 'approved') return 'bg-green-100 text-green-700 border-green-300';
if (statusLower === 'rejected') return 'bg-red-100 text-red-700 border-red-300';
if (statusLower === 'pending' || statusLower === 'in progress') return 'bg-orange-100 text-orange-700 border-orange-300';
return 'bg-gray-100 text-gray-700 border-gray-300';
};
// Helper function to get designation or department (fallback to department if designation is N/A or empty)
const getDesignationOrDepartment = (designation?: string | null, department?: string | null) => {
if (designation && designation.trim() && designation.trim().toUpperCase() !== 'N/A') {
return designation;
}
if (department && department.trim() && department.trim().toUpperCase() !== 'N/A') {
return department;
}
return 'N/A';
};
if (loading) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<Loader2 className="h-12 w-12 animate-spin text-blue-600 mx-auto mb-4" />
<p className="text-gray-600">Loading summary...</p>
</div>
</div>
);
}
if (!summary) {
return (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="text-center">
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-gray-900 mb-2">Summary Not Found</h2>
<p className="text-gray-600 mb-4">The summary you're looking for doesn't exist.</p>
<Button onClick={() => navigate('/shared-summaries')}>Go Back</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-50 p-4 sm:p-6">
<div className="max-w-5xl mx-auto">
{/* Header */}
<div className="mb-6">
<Button
variant="ghost"
size="sm"
onClick={() => navigate('/shared-summaries')}
className="mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Shared Summaries
</Button>
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900 mb-2">Request Summary</h1>
</div>
{/* Summary Card */}
<div className="bg-white rounded-lg shadow-sm border border-gray-200 mb-6">
<div className="p-6 border-b border-gray-200">
<div className="flex items-start justify-between gap-4 mb-4">
<div>
<h2 className="text-xl font-semibold text-gray-900 mb-2">{summary.title}</h2>
<p className="text-sm text-gray-600">Request #{summary.requestNumber}</p>
</div>
<Badge className={getStatusColor(summary.workflow.status)}>
{getStatusIcon(summary.workflow.status)}
<span className="ml-1 capitalize">{summary.workflow.status}</span>
</Badge>
</div>
{summary.description && (
<p className="text-gray-700 mb-4">{summary.description}</p>
)}
</div>
{/* Initiator Section */}
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Initiator</h3>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-xs text-gray-500 mb-1">Name</p>
<p className="text-sm font-medium text-gray-900">{summary.initiator.name}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Designation</p>
<p className="text-sm font-medium text-gray-900">{getDesignationOrDepartment(summary.initiator.designation, summary.initiator.department)}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Status</p>
<p className="text-sm font-medium text-gray-900">{summary.initiator.status}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Time Stamp</p>
<p className="text-sm font-medium text-gray-900">
{format(new Date(summary.initiator.timestamp), 'MMM dd, yy, HH:mm')}
</p>
</div>
</div>
{/* Initiator remarks commented out - remarks won't come while initiating */}
{/* <div className="mt-4">
<p className="text-xs text-gray-500 mb-1">Remarks by Concern</p>
<p className="text-sm text-gray-700">{summary.initiator.remarks}</p>
</div> */}
</div>
{/* Approvers Section */}
{summary.approvers && summary.approvers.length > 0 && (
<div className="p-6 border-b border-gray-200">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Workflow</h3>
{summary.approvers.map((approver, index) => (
<div key={index} className="mb-6 last:mb-0">
<h4 className="text-md font-semibold text-gray-800 mb-3">
Approver {approver.levelNumber}
</h4>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-3">
<div>
<p className="text-xs text-gray-500 mb-1">Name</p>
<p className="text-sm font-medium text-gray-900">{approver.name}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Designation</p>
<p className="text-sm font-medium text-gray-900">{getDesignationOrDepartment(approver.designation, approver.department)}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Status</p>
<div className="flex items-center gap-1">
{getStatusIcon(approver.status)}
<p className="text-sm font-medium text-gray-900">{approver.status}</p>
</div>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Time Stamp</p>
<p className="text-sm font-medium text-gray-900">
{format(new Date(approver.timestamp), 'MMM dd, yy, HH:mm')}
</p>
</div>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Remarks</p>
<p className="text-sm text-gray-700">{approver.remarks}</p>
</div>
</div>
))}
</div>
)}
{/* Closing Remarks Section */}
<div className="p-6">
<h3 className="text-lg font-semibold text-gray-900 mb-4">Closing Remarks (Conclusion)</h3>
<div className="bg-gray-50 rounded-lg p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-4">
<div>
<p className="text-xs text-gray-500 mb-1">Name</p>
<p className="text-sm font-medium text-gray-900">{summary.initiator.name}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Designation</p>
<p className="text-sm font-medium text-gray-900">{getDesignationOrDepartment(summary.initiator.designation, summary.initiator.department)}</p>
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Status</p>
<p className="text-sm font-medium text-gray-900">Concluded</p>
</div>
{summary.isAiGenerated && (
<div>
<p className="text-xs text-gray-500 mb-1">Source</p>
<Badge variant="outline" className="text-xs">AI Generated</Badge>
</div>
)}
</div>
<div>
<p className="text-xs text-gray-500 mb-1">Remarks</p>
<p className="text-sm text-gray-700 whitespace-pre-wrap">{summary.closingRemarks || '—'}</p>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@ -31,12 +31,28 @@ apiClient.interceptors.request.use(
}
);
// Response interceptor to handle token refresh
// Response interceptor to handle token refresh and connection errors
apiClient.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Handle connection errors gracefully in development
if (error.code === 'ERR_NETWORK' || error.code === 'ECONNREFUSED' || error.message?.includes('ERR_CONNECTION_REFUSED')) {
const isDevelopment = import.meta.env.DEV || import.meta.env.MODE === 'development';
if (isDevelopment) {
// In development, log a helpful message instead of spamming console
console.warn(`[API] Backend not reachable at ${API_BASE_URL}. Make sure the backend server is running on port 5000.`);
// Don't throw - let the calling code handle it gracefully
return Promise.reject({
...error,
isConnectionError: true,
message: 'Backend server is not reachable. Please ensure the backend is running on port 5000.'
});
}
}
// If error is 401 and we haven't retried yet
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;

View File

@ -181,13 +181,46 @@ class DashboardService {
/**
* Get request statistics
*/
async getRequestStats(dateRange?: DateRange, startDate?: string, endDate?: string): Promise<RequestStats> {
async getRequestStats(
dateRange?: DateRange,
startDate?: string,
endDate?: string,
priority?: string,
department?: string,
initiator?: string,
approver?: string,
approverType?: 'current' | 'any',
search?: string,
slaCompliance?: string
): Promise<RequestStats> {
try {
const params: any = { dateRange };
if (dateRange === 'custom' && startDate && endDate) {
params.startDate = startDate;
params.endDate = endDate;
}
// Add filters (excluding status - stats should show all statuses)
if (priority && priority !== 'all') {
params.priority = priority;
}
if (department && department !== 'all') {
params.department = department;
}
if (initiator && initiator !== 'all') {
params.initiator = initiator;
}
if (approver && approver !== 'all') {
params.approver = approver;
}
if (approverType) {
params.approverType = approverType;
}
if (search) {
params.search = search;
}
if (slaCompliance && slaCompliance !== 'all') {
params.slaCompliance = slaCompliance;
}
const response = await apiClient.get('/dashboard/stats/requests', { params });
return response.data.data;
} catch (error) {

127
src/services/summaryApi.ts Normal file
View File

@ -0,0 +1,127 @@
import apiClient from './authApi';
export interface RequestSummary {
summaryId: string;
requestId: string;
initiatorId: string;
title: string;
description: string | null;
closingRemarks: string | null;
isAiGenerated: boolean;
conclusionId: string | null;
createdAt: string;
updatedAt: string;
}
export interface SharedSummary {
sharedSummaryId: string;
summaryId: string;
requestId: string;
requestNumber: string;
title: string;
initiatorName: string;
sharedByName: string;
sharedAt: string;
viewedAt: string | null;
isRead: boolean;
closureDate: string | null;
}
export interface SummaryDetails {
summaryId: string;
requestId: string;
requestNumber: string;
title: string;
description: string;
closingRemarks: string;
isAiGenerated: boolean;
createdAt: string;
initiator: {
name: string;
designation: string;
department: string | null;
email: string;
status: string;
timestamp: string;
remarks: string;
};
approvers: Array<{
levelNumber: number;
levelName: string;
name: string;
designation: string;
department: string | null;
email: string;
status: string;
timestamp: string;
remarks: string;
}>;
workflow: {
priority: string;
status: string;
submissionDate: string | null;
closureDate: string | null;
};
}
/**
* Create a summary for a closed request
*/
export async function createSummary(requestId: string): Promise<RequestSummary> {
const res = await apiClient.post('/summaries', { requestId });
return res.data.data;
}
/**
* Get summary details
*/
export async function getSummaryDetails(summaryId: string): Promise<SummaryDetails> {
const res = await apiClient.get(`/summaries/${summaryId}`);
return res.data.data;
}
/**
* Share summary with users
*/
export async function shareSummary(summaryId: string, userIds: string[]): Promise<SharedSummary[]> {
const res = await apiClient.post(`/summaries/${summaryId}/share`, { userIds });
return res.data.data;
}
/**
* List summaries shared with current user
*/
export async function listSharedSummaries(params: { page?: number; limit?: number } = {}): Promise<{
data: SharedSummary[];
pagination: { page: number; limit: number; total: number; totalPages: number };
}> {
const { page = 1, limit = 20 } = params;
const res = await apiClient.get('/summaries/shared', { params: { page, limit } });
return {
data: res.data.data?.data || res.data.data || [],
pagination: res.data.data?.pagination || { page, limit, total: 0, totalPages: 1 }
};
}
/**
* Mark shared summary as viewed
*/
export async function markAsViewed(sharedSummaryId: string): Promise<void> {
await apiClient.patch(`/summaries/shared/${sharedSummaryId}/view`);
}
/**
* List summaries created by current user
*/
export async function listMySummaries(params: { page?: number; limit?: number } = {}): Promise<{
data: RequestSummary[];
pagination: { page: number; limit: number; total: number; totalPages: number };
}> {
const { page = 1, limit = 20 } = params;
const res = await apiClient.get('/summaries/my', { params: { page, limit } });
return {
data: res.data.data?.data || res.data.data || [],
pagination: res.data.data?.pagination || { page, limit, total: 0, totalPages: 1 }
};
}

View File

@ -153,15 +153,98 @@ export async function createWorkflowMultipart(form: CreateWorkflowFromFormPayloa
return { id: data?.requestId } as any;
}
export async function listWorkflows(params: { page?: number; limit?: number } = {}) {
const { page = 1, limit = 20 } = params;
const res = await apiClient.get('/workflows', { params: { page, limit } });
export async function listWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows', {
params: {
page,
limit,
search,
status,
priority,
department,
initiator,
approver,
slaCompliance,
dateRange,
startDate,
endDate
}
});
return res.data?.data || res.data;
}
export async function listMyWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string } = {}) {
const { page = 1, limit = 20, search, status, priority } = params;
const res = await apiClient.get('/workflows/my', { params: { page, limit, search, status, priority } });
// List requests where user is a participant (not initiator) - for regular users' "All Requests" page
// SEPARATE from listWorkflows (admin) to avoid interference
export async function listParticipantRequests(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; approverType?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, department, initiator, approver, approverType, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/participant-requests', {
params: {
page,
limit,
search,
status,
priority,
department,
initiator,
approver,
approverType,
slaCompliance,
dateRange,
startDate,
endDate
}
});
// Response structure: { success, data: { data: [...], pagination: {...} } }
return {
data: res.data?.data?.data || res.data?.data || [],
pagination: res.data?.data?.pagination || { page, limit, total: 0, totalPages: 1 }
};
}
// DEPRECATED: Use listParticipantRequests instead
// List requests where user is a participant (not initiator) - for "All Requests" page
export async function listMyWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; department?: string; initiator?: string; approver?: string; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, department, initiator, approver, slaCompliance, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/my', {
params: {
page,
limit,
search,
status,
priority,
department,
initiator,
approver,
slaCompliance,
dateRange,
startDate,
endDate
}
});
// Response structure: { success, data: { data: [...], pagination: {...} } }
return {
data: res.data?.data?.data || res.data?.data || [],
pagination: res.data?.data?.pagination || { page, limit, total: 0, totalPages: 1 }
};
}
// List requests where user is the initiator - for "My Requests" page
export async function listMyInitiatedWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; department?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
const { page = 1, limit = 20, search, status, priority, department, dateRange, startDate, endDate } = params;
const res = await apiClient.get('/workflows/my-initiated', {
params: {
page,
limit,
search,
status,
priority,
department,
dateRange,
startDate,
endDate
}
});
// Response structure: { success, data: { data: [...], pagination: {...} } }
return {
data: res.data?.data?.data || res.data?.data || [],
@ -330,8 +413,10 @@ export async function downloadWorkNoteAttachment(attachmentId: string): Promise<
export default {
createWorkflowFromForm,
createWorkflowMultipart,
listWorkflows,
listMyWorkflows,
listWorkflows, // Admin: All organization requests
listParticipantRequests, // Regular users: Participant requests only (not initiator)
listMyWorkflows, // DEPRECATED: Use listParticipantRequests
listMyInitiatedWorkflows, // Regular users: Initiator requests only
listOpenForMe,
listClosedByMe,
submitWorkflow,

View File

@ -112,15 +112,24 @@ export async function setupPushNotifications() {
throw new Error('Notifications are not supported in this browser');
}
// Request permission
// Check permission status
let permission = Notification.permission;
if (permission === 'default') {
permission = await Notification.requestPermission();
if (permission === 'denied') {
throw new Error('Notification permission was denied. Please enable notifications in your browser settings and try again.');
}
if (permission === 'default') {
// Request permission if not already requested
permission = await Notification.requestPermission();
if (permission !== 'granted') {
throw new Error('Notification permission was denied. Please enable notifications in your browser settings.');
throw new Error('Notification permission was denied. Please enable notifications in your browser settings and try again.');
}
}
// Final check - permission should be 'granted' at this point
if (permission !== 'granted') {
throw new Error('Notification permission is required. Please grant permission and try again.');
}
// Register service worker (or get existing)

52
src/vite-env.d.ts vendored
View File

@ -13,3 +13,55 @@ interface ImportMeta {
readonly env: ImportMetaEnv;
}
// Image type declarations
declare module '*.png' {
const src: string;
export default src;
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.jpeg' {
const src: string;
export default src;
}
declare module '*.gif' {
const src: string;
export default src;
}
declare module '*.svg' {
const src: string;
export default src;
}
declare module '*.webp' {
const src: string;
export default src;
}
// Font type declarations (for future use)
declare module '*.woff' {
const src: string;
export default src;
}
declare module '*.woff2' {
const src: string;
export default src;
}
declare module '*.ttf' {
const src: string;
export default src;
}
declare module '*.otf' {
const src: string;
export default src;
}