diff --git a/index.html b/index.html index 0488ca4..5846836 100644 --- a/index.html +++ b/index.html @@ -2,6 +2,8 @@ + + diff --git a/src/App.tsx b/src/App.tsx index 8d816b5..4bce3ae 100644 --- a/src/App.tsx +++ b/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 ; + } else { + return ; + } +} + // Main Application Routes Component function AppRoutes({ onLogout }: AppProps) { const navigate = useNavigate(); @@ -487,6 +507,26 @@ function AppRoutes({ onLogout }: AppProps) { } /> + {/* Shared Summaries */} + + + + } + /> + + {/* Shared Summary Detail */} + + + + } + /> + {/* My Requests */} - {/* Requests - Advanced Filtering Screen (Admin/Management) */} + {/* Requests - Separate screens for Admin and Regular Users */} - + } /> diff --git a/src/assets/images/Re_Logo.png b/src/assets/images/Re_Logo.png new file mode 100644 index 0000000..6ceec94 Binary files /dev/null and b/src/assets/images/Re_Logo.png differ diff --git a/src/assets/index.ts b/src/assets/index.ts new file mode 100644 index 0000000..487bec9 --- /dev/null +++ b/src/assets/index.ts @@ -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'; + diff --git a/src/components/common/FilePreview/FilePreview.tsx b/src/components/common/FilePreview/FilePreview.tsx index 342b7c8..104eb31 100644 --- a/src/components/common/FilePreview/FilePreview.tsx +++ b/src/components/common/FilePreview/FilePreview.tsx @@ -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'); + }} /> )} diff --git a/src/components/dashboard/StatsCard/StatsCard.tsx b/src/components/dashboard/StatsCard/StatsCard.tsx index 22da5e4..1d2ddf2 100644 --- a/src/components/dashboard/StatsCard/StatsCard.tsx +++ b/src/components/dashboard/StatsCard/StatsCard.tsx @@ -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 (
diff --git a/src/components/layout/PageLayout/PageLayout.tsx b/src/components/layout/PageLayout/PageLayout.tsx index 4309408..4f7c135 100644 --- a/src/components/layout/PageLayout/PageLayout.tsx +++ b/src/components/layout/PageLayout/PageLayout.tsx @@ -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 `}>
-
+
Royal Enfield Logo -
-

Royal Enfield

-

Approval Portal

-
+

Approval Portal

@@ -292,13 +283,14 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on > {sidebarOpen ? : } -
+ {/* Search bar commented out */} + {/*
-
+
*/}
diff --git a/src/components/modals/ShareSummaryModal.tsx b/src/components/modals/ShareSummaryModal.tsx new file mode 100644 index 0000000..0c2de64 --- /dev/null +++ b/src/components/modals/ShareSummaryModal.tsx @@ -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>([]); + const [selectedUserIds, setSelectedUserIds] = useState>(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 ( + + + + Share Summary + + +
+
+ +

{requestTitle}

+
+ +
+ +
+ + setSearchTerm(e.target.value)} + className="pl-10" + /> +
+
+ + {searching && ( +
+ +
+ )} + + {!searching && users.length > 0 && ( +
+ {users.map((user) => ( +
handleToggleUser(user.userId)} + > + handleToggleUser(user.userId)} + /> +
+
+ +

+ {user.displayName || user.email} +

+
+ {(user.designation || user.department) && ( +

{user.designation || user.department}

+ )} +

{user.email}

+
+
+ ))} +
+ )} + + {!searching && searchTerm && users.length === 0 && ( +
+ No users found +
+ )} + + {selectedUserIds.size > 0 && ( +
+

+ Selected ({selectedUserIds.size}) +

+
+ {Array.from(selectedUserIds).map((userId) => { + const user = users.find(u => u.userId === userId); + return ( +
+ {user?.displayName || user?.email || userId} + +
+ ); + })} +
+
+ )} +
+ + + + + +
+
+ ); +} + diff --git a/src/components/settings/NotificationStatusModal/NotificationStatusModal.tsx b/src/components/settings/NotificationStatusModal/NotificationStatusModal.tsx index d55e749..6cf46f9 100644 --- a/src/components/settings/NotificationStatusModal/NotificationStatusModal.tsx +++ b/src/components/settings/NotificationStatusModal/NotificationStatusModal.tsx @@ -47,7 +47,7 @@ export function NotificationStatusModal({

Subscription Failed

-

+

{message || 'Unable to enable push notifications. Please check your browser settings and try again.'}

diff --git a/src/components/workflow/CreateRequest/ApprovalWorkflowStep.tsx b/src/components/workflow/CreateRequest/ApprovalWorkflowStep.tsx index b3f160e..8247d32 100644 --- a/src/components/workflow/CreateRequest/ApprovalWorkflowStep.tsx +++ b/src/components/workflow/CreateRequest/ApprovalWorkflowStep.tsx @@ -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 (
@@ -223,13 +282,13 @@ export function ApprovalWorkflowStep({
@@ -250,7 +309,7 @@ export function ApprovalWorkflowStep({ - {formData.approvers[index]?.email && formData.approvers[index]?.userId && ( + {approver.email && approver.userId && ( 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({ { 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`} /> filters.setSearchTerm(e.target.value)} + className="pl-10 h-10" + data-testid="search-input" + /> +
+ + + + + + + + +
+ + {/* User Filters - Initiator and Approver */} +
+ {/* Initiator Filter */} +
+ +
+ {initiatorSearch.selectedUser ? ( +
+ + {initiatorSearch.selectedUser.displayName || initiatorSearch.selectedUser.email} + + +
+ ) : ( + <> + 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 && ( +
+ {initiatorSearch.searchResults.map((user) => ( + + ))} +
+ )} + + )} +
+
+ + {/* Approver Filter */} +
+
+ + {filters.approverFilter !== 'all' && ( + + )} +
+
+ {approverSearch.selectedUser ? ( +
+ + {approverSearch.selectedUser.displayName || approverSearch.selectedUser.email} + + +
+ ) : ( + <> + 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 && ( +
+ {approverSearch.searchResults.map((user) => ( + + ))} +
+ )} + + )} +
+
+
+ + {/* Date Range Filter */} +
+ + + + {filters.dateRange === 'custom' && ( + + + + + +
+
+
+ + { + 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" + /> +
+
+ + { + 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" + /> +
+
+
+ + +
+
+
+
+ )} +
+
+ + + + {/* Requests List */} + + + {/* Pagination */} + +
+ ); +} + diff --git a/src/pages/Requests/components/RequestsHeader.tsx b/src/pages/Requests/components/RequestsHeader.tsx index 1ff0648..b51f3be 100644 --- a/src/pages/Requests/components/RequestsHeader.tsx +++ b/src/pages/Requests/components/RequestsHeader.tsx @@ -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({
+
+
+
+ ))} +
+ + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, totalRecords)} of {totalRecords} summaries +
+
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} + + )} +
+ + ); +} + diff --git a/src/pages/SharedSummaries/SharedSummaryDetail.tsx b/src/pages/SharedSummaries/SharedSummaryDetail.tsx new file mode 100644 index 0000000..f153c5c --- /dev/null +++ b/src/pages/SharedSummaries/SharedSummaryDetail.tsx @@ -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(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 ; + if (statusLower === 'rejected') return ; + if (statusLower === 'pending' || statusLower === 'in progress') return ; + return ; + }; + + 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 ( +
+
+ +

Loading summary...

+
+
+ ); + } + + if (!summary) { + return ( +
+
+ +

Summary Not Found

+

The summary you're looking for doesn't exist.

+ +
+
+ ); + } + + return ( +
+
+ {/* Header */} +
+ +

Request Summary

+
+ + {/* Summary Card */} +
+
+
+
+

{summary.title}

+

Request #{summary.requestNumber}

+
+ + {getStatusIcon(summary.workflow.status)} + {summary.workflow.status} + +
+ {summary.description && ( +

{summary.description}

+ )} +
+ + {/* Initiator Section */} +
+

Initiator

+
+
+

Name

+

{summary.initiator.name}

+
+
+

Designation

+

{getDesignationOrDepartment(summary.initiator.designation, summary.initiator.department)}

+
+
+

Status

+

{summary.initiator.status}

+
+
+

Time Stamp

+

+ {format(new Date(summary.initiator.timestamp), 'MMM dd, yy, HH:mm')} +

+
+
+ {/* Initiator remarks commented out - remarks won't come while initiating */} + {/*
+

Remarks by Concern

+

{summary.initiator.remarks}

+
*/} +
+ + {/* Approvers Section */} + {summary.approvers && summary.approvers.length > 0 && ( +
+

Workflow

+ {summary.approvers.map((approver, index) => ( +
+

+ Approver {approver.levelNumber} +

+
+
+

Name

+

{approver.name}

+
+
+

Designation

+

{getDesignationOrDepartment(approver.designation, approver.department)}

+
+
+

Status

+
+ {getStatusIcon(approver.status)} +

{approver.status}

+
+
+
+

Time Stamp

+

+ {format(new Date(approver.timestamp), 'MMM dd, yy, HH:mm')} +

+
+
+
+

Remarks

+

{approver.remarks}

+
+
+ ))} +
+ )} + + {/* Closing Remarks Section */} +
+

Closing Remarks (Conclusion)

+
+
+
+

Name

+

{summary.initiator.name}

+
+
+

Designation

+

{getDesignationOrDepartment(summary.initiator.designation, summary.initiator.department)}

+
+
+

Status

+

Concluded

+
+ {summary.isAiGenerated && ( +
+

Source

+ AI Generated +
+ )} +
+
+

Remarks

+

{summary.closingRemarks || '—'}

+
+
+
+
+
+
+ ); +} + diff --git a/src/services/authApi.ts b/src/services/authApi.ts index 3e13da8..e37f09e 100644 --- a/src/services/authApi.ts +++ b/src/services/authApi.ts @@ -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; diff --git a/src/services/dashboard.service.ts b/src/services/dashboard.service.ts index 3f58011..158cb98 100644 --- a/src/services/dashboard.service.ts +++ b/src/services/dashboard.service.ts @@ -181,13 +181,46 @@ class DashboardService { /** * Get request statistics */ - async getRequestStats(dateRange?: DateRange, startDate?: string, endDate?: string): Promise { + async getRequestStats( + dateRange?: DateRange, + startDate?: string, + endDate?: string, + priority?: string, + department?: string, + initiator?: string, + approver?: string, + approverType?: 'current' | 'any', + search?: string, + slaCompliance?: string + ): Promise { 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) { diff --git a/src/services/summaryApi.ts b/src/services/summaryApi.ts new file mode 100644 index 0000000..fe57c3f --- /dev/null +++ b/src/services/summaryApi.ts @@ -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 { + const res = await apiClient.post('/summaries', { requestId }); + return res.data.data; +} + +/** + * Get summary details + */ +export async function getSummaryDetails(summaryId: string): Promise { + const res = await apiClient.get(`/summaries/${summaryId}`); + return res.data.data; +} + +/** + * Share summary with users + */ +export async function shareSummary(summaryId: string, userIds: string[]): Promise { + 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 { + 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 } + }; +} + diff --git a/src/services/workflowApi.ts b/src/services/workflowApi.ts index 2fe1b93..a6e51cc 100644 --- a/src/services/workflowApi.ts +++ b/src/services/workflowApi.ts @@ -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, diff --git a/src/utils/pushNotifications.ts b/src/utils/pushNotifications.ts index 4da3e54..5d3e625 100644 --- a/src/utils/pushNotifications.ts +++ b/src/utils/pushNotifications.ts @@ -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) diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index fc29386..d8abad8 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -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; +} +