reported bugs like preview issue and new requirement all request for normal user,share request is implemented
This commit is contained in:
parent
99a59ac05b
commit
8f3f484dbc
@ -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" />
|
||||
|
||||
44
src/App.tsx
44
src/App.tsx
@ -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>
|
||||
}
|
||||
/>
|
||||
|
||||
BIN
src/assets/images/Re_Logo.png
Normal file
BIN
src/assets/images/Re_Logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
21
src/assets/index.ts
Normal file
21
src/assets/index.ts
Normal 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';
|
||||
|
||||
@ -55,22 +55,45 @@ export function FilePreview({
|
||||
|
||||
try {
|
||||
const token = localStorage.getItem('accessToken');
|
||||
const response = await fetch(fileUrl, {
|
||||
|
||||
// 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}`
|
||||
}
|
||||
'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>
|
||||
)}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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">
|
||||
|
||||
210
src/components/modals/ShareSummaryModal.tsx
Normal file
210
src/components/modals/ShareSummaryModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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] = {
|
||||
|
||||
@ -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
|
||||
}
|
||||
};
|
||||
|
||||
@ -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 ? (
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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'}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,18 +70,34 @@ export function RequestDetailHeader({ request, refreshing, onBack, onRefresh }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Refresh Button */}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1 sm:gap-2 flex-shrink-0 h-8 sm:h-9"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
data-testid="refresh-button"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 sm:w-4 sm:h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<span className="hidden sm:inline">{refreshing ? 'Refreshing...' : 'Refresh'}</span>
|
||||
</Button>
|
||||
{/* 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"
|
||||
size="sm"
|
||||
className="gap-1 sm:gap-2 flex-shrink-0 h-8 sm:h-9"
|
||||
onClick={onRefresh}
|
||||
disabled={refreshing}
|
||||
data-testid="refresh-button"
|
||||
>
|
||||
<RefreshCw className={`w-3.5 h-3.5 sm:w-4 sm:h-4 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
<span className="hidden sm:inline">{refreshing ? 'Refreshing...' : 'Refresh'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Request Title */}
|
||||
|
||||
@ -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,29 +81,120 @@ 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);
|
||||
const stats = await dashboardService.getRequestStats(
|
||||
statsDateRange,
|
||||
statsStartDate ? statsStartDate.toISOString() : undefined,
|
||||
statsEndDate ? statsEndDate.toISOString() : undefined
|
||||
);
|
||||
// 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';
|
||||
|
||||
setBackendStats({
|
||||
total: stats.totalRequests || 0,
|
||||
pending: stats.openRequests || 0,
|
||||
approved: stats.approvedRequests || 0,
|
||||
rejected: stats.rejectedRequests || 0,
|
||||
draft: stats.draftRequests || 0,
|
||||
closed: 0
|
||||
});
|
||||
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,
|
||||
filtersWithoutStatus?.priority,
|
||||
filtersWithoutStatus?.department,
|
||||
filtersWithoutStatus?.initiator,
|
||||
filtersWithoutStatus?.approver,
|
||||
filtersWithoutStatus?.approverType,
|
||||
filtersWithoutStatus?.search,
|
||||
filtersWithoutStatus?.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);
|
||||
// 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>
|
||||
|
||||
684
src/pages/Requests/UserAllRequests.tsx
Normal file
684
src/pages/Requests/UserAllRequests.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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,87 @@ 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) {
|
||||
const pageResult = await workflowApi.listWorkflows({
|
||||
page: currentPageNum,
|
||||
limit: 100
|
||||
// Fetch paginated data for list display (with status filter)
|
||||
const pageResult = await workflowApi.listWorkflows({
|
||||
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
|
||||
};
|
||||
|
||||
// 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 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();
|
||||
|
||||
// Fetch paginated data using SEPARATE endpoint for regular users
|
||||
// This endpoint excludes initiator requests automatically
|
||||
const pageResult = await workflowApi.listParticipantRequests({
|
||||
page,
|
||||
limit: itemsPerPage,
|
||||
...backendFilters
|
||||
});
|
||||
|
||||
let pageData: any[] = [];
|
||||
@ -39,118 +109,32 @@ export async function fetchRequestsData({
|
||||
pageData = pageResult;
|
||||
}
|
||||
|
||||
if (pageData.length > 0) {
|
||||
allPages.push(...pageData);
|
||||
currentPageNum++;
|
||||
|
||||
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: {
|
||||
page,
|
||||
limit: itemsPerPage,
|
||||
total: filteredData.length,
|
||||
totalPages: Math.ceil(filteredData.length / itemsPerPage) || 1
|
||||
}
|
||||
};
|
||||
} else {
|
||||
// User-level: use backend filtering where possible
|
||||
const needsAllDataForDateFilter = filters?.dateRange && filters.dateRange !== 'month';
|
||||
|
||||
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({
|
||||
page,
|
||||
limit: itemsPerPage,
|
||||
search: filters?.search,
|
||||
status: filters?.status !== 'all' ? filters?.status : undefined,
|
||||
priority: filters?.priority !== 'all' ? filters?.priority : undefined
|
||||
// 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';
|
||||
});
|
||||
|
||||
userWorkflowsData = Array.isArray((userResult as any)?.data)
|
||||
? (userResult as any).data
|
||||
: [];
|
||||
}
|
||||
|
||||
// Apply client-side filtering for user-level requests
|
||||
let filteredUserData = filters ? applyFilters(userWorkflowsData, filters) : userWorkflowsData;
|
||||
|
||||
// Apply pagination
|
||||
const startIndex = (page - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedUserData = filteredUserData.slice(startIndex, endIndex);
|
||||
|
||||
return {
|
||||
data: paginatedUserData,
|
||||
allData: userWorkflowsData,
|
||||
filteredData: filteredUserData,
|
||||
pagination: {
|
||||
// Get pagination info from backend response
|
||||
const pagination = pageResult?.pagination || {
|
||||
page,
|
||||
limit: itemsPerPage,
|
||||
total: filteredUserData.length,
|
||||
totalPages: Math.ceil(filteredUserData.length / itemsPerPage) || 1
|
||||
}
|
||||
};
|
||||
}
|
||||
total: nonDraftData.length,
|
||||
totalPages: 1
|
||||
};
|
||||
|
||||
return {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
129
src/pages/Requests/services/userRequestsService.ts
Normal file
129
src/pages/Requests/services/userRequestsService.ts
Normal 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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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];
|
||||
|
||||
|
||||
@ -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.');
|
||||
|
||||
236
src/pages/SharedSummaries/SharedSummaries.tsx
Normal file
236
src/pages/SharedSummaries/SharedSummaries.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
241
src/pages/SharedSummaries/SharedSummaryDetail.tsx
Normal file
241
src/pages/SharedSummaries/SharedSummaryDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
127
src/services/summaryApi.ts
Normal 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 }
|
||||
};
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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 and try again.');
|
||||
}
|
||||
}
|
||||
|
||||
// Final check - permission should be 'granted' at this point
|
||||
if (permission !== 'granted') {
|
||||
throw new Error('Notification permission was denied. Please enable notifications in your browser settings.');
|
||||
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
52
src/vite-env.d.ts
vendored
@ -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;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user