Compare commits
No commits in common. "f883bd34d88866612c3525b734000a1f76ff6284" and "8f3f484dbce52917569cfa19d982f009d19e7d93" have entirely different histories.
f883bd34d8
...
8f3f484dbc
@ -1,6 +1,5 @@
|
||||
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -337,21 +336,7 @@ export function UserRoleManager() {
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="shadow-lg border-0 rounded-md">
|
||||
<CardHeader className="pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
|
||||
<UserCog className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg font-semibold text-gray-900">User Role Management</CardTitle>
|
||||
<CardDescription className="text-sm text-gray-600">
|
||||
Search for users, assign roles, and manage user permissions across the system
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Statistics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
||||
<Card
|
||||
@ -424,17 +409,22 @@ export function UserRoleManager() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Assign Role Section */}
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-1">Assign User Role</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
Search for a user in Okta and assign them a role
|
||||
</p>
|
||||
<Card className="shadow-lg border">
|
||||
<CardHeader className="border-b pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-lg shadow-md">
|
||||
<UserCog className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<CardTitle className="text-lg font-semibold">Assign User Role</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
Search for a user in Okta and assign them a role
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5 pt-6">
|
||||
{/* Search Input */}
|
||||
<div className="space-y-2" ref={searchContainerRef}>
|
||||
<label className="text-sm font-medium text-gray-700">Search User</label>
|
||||
@ -445,11 +435,11 @@ export function UserRoleManager() {
|
||||
placeholder="Type name or email address..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-10 pr-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||
className="pl-10 pr-10 h-12 border rounded-lg border-gray-300 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
|
||||
data-testid="user-search-input"
|
||||
/>
|
||||
{searching && (
|
||||
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-re-green animate-spin" />
|
||||
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-purple-500 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-500">Start typing to search across all Okta users</p>
|
||||
@ -462,18 +452,18 @@ export function UserRoleManager() {
|
||||
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-2">
|
||||
<div className="p-3">
|
||||
{searchResults.map((user) => (
|
||||
<button
|
||||
key={user.userId}
|
||||
onClick={() => handleSelectUser(user)}
|
||||
className="w-full text-left p-2 hover:bg-purple-50 rounded-lg transition-colors mb-1 last:mb-0"
|
||||
className="w-full text-left p-3 hover:bg-purple-50 rounded-lg transition-colors mb-1 last:mb-0"
|
||||
data-testid={`user-result-${user.email}`}
|
||||
>
|
||||
<p className="text-sm font-medium text-gray-900">{user.displayName || user.email}</p>
|
||||
<p className="text-xs text-gray-600">{user.email}</p>
|
||||
<p className="font-medium text-gray-900">{user.displayName || user.email}</p>
|
||||
<p className="text-sm text-gray-600">{user.email}</p>
|
||||
{user.department && (
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{user.department}{user.designation ? ` • ${user.designation}` : ''}
|
||||
</p>
|
||||
)}
|
||||
@ -524,25 +514,25 @@ export function UserRoleManager() {
|
||||
<label className="text-sm font-medium text-gray-700">Select Role</label>
|
||||
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)}>
|
||||
<SelectTrigger
|
||||
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||
className="h-12 border border-gray-300 py-2 rounded-lg focus:border-purple-500 focus:ring-1 focus:ring-purple-200 transition-all"
|
||||
data-testid="role-select"
|
||||
>
|
||||
<SelectValue placeholder="Select role" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="USER">
|
||||
<SelectContent className="rounded-lg">
|
||||
<SelectItem value="USER" className="p-3 rounded-lg my-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<UserIcon className="w-4 h-4 text-gray-600" />
|
||||
<span>User - Regular access</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="MANAGEMENT">
|
||||
<SelectItem value="MANAGEMENT" className="p-3 rounded-lg my-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Users className="w-4 h-4 text-blue-600" />
|
||||
<span>Management - Read all data</span>
|
||||
</div>
|
||||
</SelectItem>
|
||||
<SelectItem value="ADMIN">
|
||||
<SelectItem value="ADMIN" className="p-3 rounded-lg my-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Crown className="w-4 h-4 text-yellow-600" />
|
||||
<span>Administrator - Full access</span>
|
||||
@ -556,7 +546,7 @@ export function UserRoleManager() {
|
||||
<Button
|
||||
onClick={handleAssignRole}
|
||||
disabled={!selectedUser || updating}
|
||||
className="w-full bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full h-12 bg-re-green hover:bg-re-green/90 text-white font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-50 rounded-lg"
|
||||
data-testid="assign-role-button"
|
||||
>
|
||||
{updating ? (
|
||||
@ -591,19 +581,24 @@ export function UserRoleManager() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Users List with Filter and Pagination */}
|
||||
<div ref={userListRef}>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 mb-4">
|
||||
<Card className="shadow-lg border">
|
||||
<CardHeader className="border-b pb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-lg shadow-md">
|
||||
<Shield className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-gray-900 mb-1">User Management</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
<CardTitle className="text-lg font-semibold">User Management</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
View and manage user roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
|
||||
</p>
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={roleFilter} onValueChange={handleFilterChange}>
|
||||
@ -645,7 +640,8 @@ export function UserRoleManager() {
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-2">
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
{loadingUsers ? (
|
||||
<div className="flex flex-col items-center justify-center py-8">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mb-2" />
|
||||
@ -670,7 +666,7 @@ export function UserRoleManager() {
|
||||
{users.map((user) => (
|
||||
<div
|
||||
key={user.userId}
|
||||
className="border border-gray-200 hover:border-re-green hover:shadow-sm transition-all rounded-lg bg-white p-4"
|
||||
className="border-2 border-gray-100 hover:border-purple-200 hover:shadow-md transition-all rounded-lg bg-white p-4"
|
||||
data-testid={`user-${user.email}`}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
@ -756,10 +752,10 @@ export function UserRoleManager() {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -127,24 +127,16 @@ export function ShareSummaryModal({ isOpen, onClose, summaryId, requestTitle, on
|
||||
|
||||
{!searching && users.length > 0 && (
|
||||
<div className="border rounded-lg max-h-[300px] overflow-y-auto">
|
||||
{users.map((user) => {
|
||||
const isSelected = selectedUserIds.has(user.userId);
|
||||
return (
|
||||
{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)}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
className="flex items-center"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
checked={selectedUserIds.has(user.userId)}
|
||||
onCheckedChange={() => handleToggleUser(user.userId)}
|
||||
/>
|
||||
</div>
|
||||
<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" />
|
||||
@ -158,8 +150,7 @@ export function ShareSummaryModal({ isOpen, onClose, summaryId, requestTitle, on
|
||||
<p className="text-xs text-gray-400 truncate">{user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Bell, CheckCircle, XCircle } from 'lucide-react';
|
||||
|
||||
@ -23,11 +23,6 @@ export function NotificationStatusModal({
|
||||
<Bell className="w-5 h-5 text-blue-600" />
|
||||
Push Notifications
|
||||
</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
{success
|
||||
? 'Push notifications have been successfully enabled for your account.'
|
||||
: 'There was an error enabling push notifications. Please review the details below.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-6">
|
||||
|
||||
@ -121,7 +121,7 @@ export function useConclusionRemark(
|
||||
* Purpose: Submit conclusion remark and close the request
|
||||
*
|
||||
* Business Logic:
|
||||
* - Only initiators can finalize approved or rejected requests
|
||||
* - Only initiators can finalize approved requests
|
||||
* - Conclusion cannot be empty
|
||||
* - After finalization:
|
||||
* → Request status changes to CLOSED
|
||||
@ -206,13 +206,13 @@ export function useConclusionRemark(
|
||||
};
|
||||
|
||||
/**
|
||||
* Effect: Auto-fetch existing conclusion when request becomes approved or rejected
|
||||
* Effect: Auto-fetch existing conclusion when request becomes approved
|
||||
*
|
||||
* Trigger: When request status changes to "approved" or "rejected" and user is initiator
|
||||
* Purpose: Load any conclusion generated by final approver (for approved) or AI (for rejected)
|
||||
* Trigger: When request status changes to "approved" and user is initiator
|
||||
* Purpose: Load any conclusion generated by final approver
|
||||
*/
|
||||
useEffect(() => {
|
||||
if ((request?.status === 'approved' || request?.status === 'rejected') && isInitiator && !conclusionRemark) {
|
||||
if (request?.status === 'approved' && isInitiator && !conclusionRemark) {
|
||||
fetchExistingConclusion();
|
||||
}
|
||||
}, [request?.status, isInitiator]);
|
||||
|
||||
@ -1,8 +1,7 @@
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card';
|
||||
import { LogIn } from 'lucide-react';
|
||||
import { ReLogo } from '@/assets';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LogIn, Shield } from 'lucide-react';
|
||||
|
||||
export function Auth() {
|
||||
const { login, isLoading, error } = useAuth();
|
||||
@ -35,14 +34,17 @@ export function Auth() {
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 p-4">
|
||||
<Card className="w-full max-w-md shadow-xl">
|
||||
<CardHeader className="space-y-1 text-center pb-6">
|
||||
<div className="flex flex-col items-center justify-center mb-4">
|
||||
<img
|
||||
src={ReLogo}
|
||||
alt="Royal Enfield Logo"
|
||||
className="h-10 w-auto max-w-[168px] object-contain mb-2"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="w-16 h-16 bg-gradient-to-br from-slate-800 to-slate-900 rounded-2xl flex items-center justify-center shadow-lg">
|
||||
<Shield className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle className="text-3xl font-bold text-gray-900">
|
||||
Royal Enfield
|
||||
</CardTitle>
|
||||
<CardDescription className="text-lg text-gray-600 mt-2">
|
||||
Approval & Request Management Portal
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-4">
|
||||
@ -56,14 +58,12 @@ export function Auth() {
|
||||
<Button
|
||||
onClick={handleSSOLogin}
|
||||
disabled={isLoading}
|
||||
className="w-full h-12 text-base font-semibold bg-re-red hover:bg-re-red/90 text-white"
|
||||
className="w-full h-12 text-base font-semibold bg-slate-900 hover:bg-slate-800"
|
||||
size="lg"
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<div
|
||||
className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent"
|
||||
/>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-background border-t-transparent" />
|
||||
Logging in...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { CheckCircle2, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { ReLogo } from '@/assets';
|
||||
|
||||
export function AuthCallback() {
|
||||
const { isAuthenticated, isLoading, error, user } = useAuth();
|
||||
@ -60,14 +59,24 @@ export function AuthCallback() {
|
||||
<div className="relative z-10 text-center px-4 max-w-md w-full">
|
||||
{/* Logo/Brand Section */}
|
||||
<div className="mb-8">
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
<img
|
||||
src={ReLogo}
|
||||
alt="Royal Enfield Logo"
|
||||
className="h-10 w-auto max-w-[168px] object-contain mb-2"
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 mb-4 rounded-2xl bg-gradient-to-br from-orange-500 to-red-600 shadow-lg shadow-orange-500/20">
|
||||
<svg
|
||||
className="w-12 h-12 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
<p className="text-xs text-gray-400 text-center truncate">Approval Portal</p>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-white mb-2">Royal Enfield</h1>
|
||||
<p className="text-slate-400 text-sm">Approval Portal</p>
|
||||
</div>
|
||||
|
||||
{/* Main Loader Card */}
|
||||
@ -90,10 +99,10 @@ export function AuthCallback() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative">
|
||||
<Loader2 className="w-16 h-16 animate-spin text-re-red" />
|
||||
<Loader2 className="w-16 h-16 text-orange-500 animate-spin" />
|
||||
{/* Outer rotating ring */}
|
||||
<div className="absolute inset-0 border-4 rounded-full border-re-red/20"></div>
|
||||
<div className="absolute inset-0 border-4 border-transparent border-t-re-red rounded-full animate-spin"></div>
|
||||
<div className="absolute inset-0 border-4 border-orange-500/20 rounded-full"></div>
|
||||
<div className="absolute inset-0 border-4 border-transparent border-t-orange-500 rounded-full animate-spin"></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -110,11 +119,11 @@ export function AuthCallback() {
|
||||
{authStep !== 'error' && (
|
||||
<div className="space-y-3 mb-6">
|
||||
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'exchanging' ? 'text-white' : 'text-slate-400'}`}>
|
||||
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'exchanging' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
|
||||
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'exchanging' ? 'bg-orange-500 animate-pulse' : 'bg-slate-600'}`}></div>
|
||||
<span>Validating credentials</span>
|
||||
</div>
|
||||
<div className={`flex items-center gap-3 text-sm transition-all duration-500 ${authStep === 'fetching' ? 'text-white' : 'text-slate-400'}`}>
|
||||
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'fetching' ? 'bg-re-red animate-pulse' : 'bg-slate-600'}`}></div>
|
||||
<div className={`w-2 h-2 rounded-full transition-all duration-500 ${authStep === 'fetching' ? 'bg-orange-500 animate-pulse' : 'bg-slate-600'}`}></div>
|
||||
<span>Loading your profile</span>
|
||||
</div>
|
||||
{authStep === 'complete' && (
|
||||
@ -147,7 +156,7 @@ export function AuthCallback() {
|
||||
<div className="mt-6">
|
||||
<div className="h-1.5 bg-slate-700/50 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-re-red rounded-full animate-pulse"
|
||||
className="h-full bg-gradient-to-r from-orange-500 to-red-600 rounded-full animate-pulse"
|
||||
style={{
|
||||
animation: 'progress 2s ease-in-out infinite',
|
||||
}}
|
||||
@ -171,8 +180,8 @@ export function AuthCallback() {
|
||||
|
||||
{/* Animated Background Elements */}
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-re-red/5 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-orange-500/5 rounded-full blur-3xl animate-pulse"></div>
|
||||
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-red-500/5 rounded-full blur-3xl animate-pulse delay-1000"></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -22,7 +22,6 @@ import {
|
||||
Activity,
|
||||
MessageSquare,
|
||||
AlertTriangle,
|
||||
FileCheck,
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
@ -38,14 +37,13 @@ import { downloadDocument } from '@/services/workflowApi';
|
||||
// Components
|
||||
import { RequestDetailHeader } from './components/RequestDetailHeader';
|
||||
import { ShareSummaryModal } from '@/components/modals/ShareSummaryModal';
|
||||
import { createSummary, getSummaryDetails, type SummaryDetails } from '@/services/summaryApi';
|
||||
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';
|
||||
import { ActivityTab } from './components/tabs/ActivityTab';
|
||||
import { WorkNotesTab } from './components/tabs/WorkNotesTab';
|
||||
import { SummaryTab } from './components/tabs/SummaryTab';
|
||||
import { QuickActionsSidebar } from './components/QuickActionsSidebar';
|
||||
import { RequestDetailModals } from './components/RequestDetailModals';
|
||||
import { RequestDetailProps } from './types/requestDetail.types';
|
||||
@ -102,8 +100,6 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const [showShareSummaryModal, setShowShareSummaryModal] = useState(false);
|
||||
const [summaryId, setSummaryId] = useState<string | null>(null);
|
||||
const [summaryDetails, setSummaryDetails] = useState<SummaryDetails | null>(null);
|
||||
const [loadingSummary, setLoadingSummary] = useState(false);
|
||||
const { user } = useAuth();
|
||||
|
||||
// Custom hooks
|
||||
@ -189,80 +185,26 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
}
|
||||
|
||||
try {
|
||||
// Get or create summary (backend returns existing summary if it exists - idempotent)
|
||||
// Summary should already exist from closure, but create if missing (handles edge cases)
|
||||
// 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);
|
||||
// Refresh summary details after creating
|
||||
try {
|
||||
const details = await getSummaryDetails(currentSummaryId);
|
||||
setSummaryDetails(details);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch summary details after creation:', error);
|
||||
}
|
||||
}
|
||||
// Open share modal with the summary ID (only after we have the ID)
|
||||
if (currentSummaryId) {
|
||||
setShowShareSummaryModal(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create/get summary:', error);
|
||||
const errorMessage = error?.response?.data?.error || error?.response?.data?.message || error?.message;
|
||||
toast.error(errorMessage || 'Failed to prepare summary for sharing');
|
||||
}
|
||||
};
|
||||
|
||||
const needsClosure = (request?.status === 'approved' || request?.status === 'rejected') && isInitiator;
|
||||
|
||||
// Check if request is closed (or needs closure for approved/rejected)
|
||||
const isClosed = request?.status === 'closed' || (request?.status === 'approved' && !isInitiator) || (request?.status === 'rejected' && !isInitiator);
|
||||
|
||||
// Fetch summary details if request is closed
|
||||
// Summary should be automatically created when request is closed, but we'll create it if missing (idempotent)
|
||||
useEffect(() => {
|
||||
const fetchSummaryDetails = async () => {
|
||||
if (!isClosed || !apiRequest?.requestId) {
|
||||
setSummaryDetails(null);
|
||||
setSummaryId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingSummary(true);
|
||||
// Use createSummary which is idempotent - returns existing summary if it exists, creates if missing
|
||||
// This handles cases where summary creation failed during closure or was not created yet
|
||||
const summary = await createSummary(apiRequest.requestId);
|
||||
if (summary?.summaryId) {
|
||||
setSummaryId(summary.summaryId);
|
||||
// Fetch full summary details
|
||||
try {
|
||||
const details = await getSummaryDetails(summary.summaryId);
|
||||
setSummaryDetails(details);
|
||||
} catch (error: any) {
|
||||
// If we can't get details, clear summary
|
||||
console.error('Failed to fetch summary details:', error);
|
||||
setSummaryDetails(null);
|
||||
setSummaryId(null);
|
||||
}
|
||||
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 {
|
||||
setSummaryDetails(null);
|
||||
setSummaryId(null);
|
||||
toast.error(error?.response?.data?.message || 'Failed to prepare summary for sharing');
|
||||
}
|
||||
} catch (error: any) {
|
||||
// If summary creation fails, don't show tab but log error
|
||||
console.error('Summary not available:', error?.message);
|
||||
setSummaryDetails(null);
|
||||
setSummaryId(null);
|
||||
} finally {
|
||||
setLoadingSummary(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSummaryDetails();
|
||||
}, [isClosed, apiRequest?.requestId]);
|
||||
const needsClosure = request?.status === 'approved' && isInitiator;
|
||||
|
||||
// Get current levels for WorkNotesTab
|
||||
const currentLevels = (request?.approvalFlow || [])
|
||||
@ -318,7 +260,7 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full" data-testid="request-detail-tabs">
|
||||
<div className="mb-4 sm:mb-6">
|
||||
<TabsList className="grid grid-cols-3 sm:grid-cols-6 lg:flex lg:flex-row h-auto bg-gray-100 p-1.5 sm:p-1 rounded-lg gap-1.5 sm:gap-1">
|
||||
<TabsList className="grid grid-cols-3 sm:grid-cols-5 lg:flex lg:flex-row h-auto bg-gray-100 p-1.5 sm:p-1 rounded-lg gap-1.5 sm:gap-1">
|
||||
<TabsTrigger
|
||||
value="overview"
|
||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
||||
@ -327,16 +269,6 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
<ClipboardList className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span className="truncate">Overview</span>
|
||||
</TabsTrigger>
|
||||
{isClosed && summaryDetails && (
|
||||
<TabsTrigger
|
||||
value="summary"
|
||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
||||
data-testid="tab-summary"
|
||||
>
|
||||
<FileCheck className="w-3.5 h-3.5 sm:w-4 sm:h-4 flex-shrink-0" />
|
||||
<span className="truncate">Summary</span>
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value="workflow"
|
||||
className="flex items-center justify-center gap-1 sm:gap-1.5 rounded-md px-2 sm:px-3 py-2.5 sm:py-1.5 text-xs sm:text-sm font-medium transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-gray-400 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-white data-[state=active]:text-gray-900 data-[state=active]:shadow-sm text-gray-600 data-[state=active]:text-gray-900"
|
||||
@ -399,17 +331,6 @@ function RequestDetailInner({ requestId: propRequestId, onBack, dynamicRequests
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
{isClosed && (
|
||||
<TabsContent value="summary" className="mt-0" data-testid="summary-tab-content">
|
||||
<SummaryTab
|
||||
summary={summaryDetails}
|
||||
loading={loadingSummary}
|
||||
onShare={handleShareSummary}
|
||||
isInitiator={isInitiator}
|
||||
/>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
<TabsContent value="workflow" className="mt-0">
|
||||
<WorkflowTab
|
||||
request={request}
|
||||
|
||||
@ -30,8 +30,8 @@ export function QuickActionsSidebar({
|
||||
}: QuickActionsSidebarProps) {
|
||||
return (
|
||||
<div className="space-y-4 sm:space-y-6">
|
||||
{/* Quick Actions Card - Hide entire card for spectators and closed requests */}
|
||||
{!isSpectator && request.status !== 'closed' && (
|
||||
{/* Quick Actions Card - Hide entire card for spectators */}
|
||||
{!isSpectator && (
|
||||
<Card data-testid="quick-actions-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm sm:text-base">Quick Actions</CardTitle>
|
||||
@ -92,13 +92,13 @@ export function QuickActionsSidebar({
|
||||
)}
|
||||
|
||||
{/* Spectators Card */}
|
||||
{request.spectators && request.spectators.length > 0 && (
|
||||
<Card data-testid="spectators-card">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm sm:text-base">Spectators</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{request.spectators && request.spectators.length > 0 ? (
|
||||
request.spectators.map((spectator: any, index: number) => (
|
||||
{request.spectators.map((spectator: any, index: number) => (
|
||||
<div key={index} className="flex items-center gap-3" data-testid={`spectator-${index}`}>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback className="bg-blue-100 text-blue-800 text-xs font-semibold">
|
||||
@ -110,14 +110,10 @@ export function QuickActionsSidebar({
|
||||
<p className="text-xs text-gray-500 truncate">{spectator.role}</p>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="py-4 text-center">
|
||||
<p className="text-sm text-gray-500">No spectators added</p>
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -210,25 +210,15 @@ export function OverviewTab({
|
||||
{/* Conclusion Remark Section */}
|
||||
{needsClosure && (
|
||||
<Card data-testid="conclusion-remark-card">
|
||||
<CardHeader className={`bg-gradient-to-r border-b ${
|
||||
request.status === 'rejected'
|
||||
? 'from-red-50 to-rose-50 border-red-200'
|
||||
: 'from-green-50 to-emerald-50 border-green-200'
|
||||
}`}>
|
||||
<CardHeader className="bg-gradient-to-r from-green-50 to-emerald-50 border-b border-green-200">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle className={`flex items-center gap-2 text-base sm:text-lg ${
|
||||
request.status === 'rejected' ? 'text-red-700' : 'text-green-700'
|
||||
}`}>
|
||||
<CheckCircle className={`w-5 h-5 ${
|
||||
request.status === 'rejected' ? 'text-red-600' : 'text-green-600'
|
||||
}`} />
|
||||
<CardTitle className="flex items-center gap-2 text-base sm:text-lg">
|
||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||
Conclusion Remark - Final Step
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1 text-xs sm:text-sm">
|
||||
{request.status === 'rejected'
|
||||
? 'This request was rejected. Please review the AI-generated closure remark and finalize it to close this request.'
|
||||
: 'All approvals are complete. Please review and finalize the conclusion to close this request.'}
|
||||
All approvals are complete. Please review and finalize the conclusion to close this request.
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@ -1,203 +0,0 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileText, CheckCircle, XCircle, Clock, Loader2, Share2 } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import type { SummaryDetails } from '@/services/summaryApi';
|
||||
|
||||
interface SummaryTabProps {
|
||||
summary: SummaryDetails | null;
|
||||
loading: boolean;
|
||||
onShare?: () => void;
|
||||
isInitiator?: boolean;
|
||||
}
|
||||
|
||||
export function SummaryTab({ summary, loading, onShare, isInitiator }: SummaryTabProps) {
|
||||
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="flex items-center justify-center py-12">
|
||||
<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="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
<FileText className="h-12 w-12 text-gray-400 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-bold text-gray-900 mb-2">Summary Not Available</h2>
|
||||
<p className="text-gray-600">Summary has not been generated for this request yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Summary Card */}
|
||||
<div className="bg-white rounded-lg shadow-sm border border-gray-200">
|
||||
<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>
|
||||
{isInitiator && onShare ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onShare}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Share2 className="w-4 h-4" />
|
||||
<span>Share</span>
|
||||
</Button>
|
||||
) : (
|
||||
<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>
|
||||
</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.levelName || `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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,9 +2,7 @@
|
||||
* User All Requests Page - For Regular Users
|
||||
*
|
||||
* This is a SEPARATE screen for regular users' "All Requests" page.
|
||||
* Shows requests where the user is EITHER:
|
||||
* - The initiator (created by the user), OR
|
||||
* - A participant (approver/spectator)
|
||||
* Shows only requests where the user is a participant (approver/spectator), NOT initiator.
|
||||
* Completely separate from AdminAllRequests to avoid interference.
|
||||
*/
|
||||
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
/**
|
||||
* Service for fetching user requests data (initiator + participant)
|
||||
* 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 requests where user is EITHER initiator OR participant (approver/spectator)
|
||||
* Shows only requests where user is a participant (approver/spectator), NOT initiator
|
||||
*/
|
||||
|
||||
import workflowApi from '@/services/workflowApi';
|
||||
@ -11,21 +11,21 @@ import type { RequestFilters } from '../types/requests.types';
|
||||
|
||||
const EXPORT_FETCH_LIMIT = 100;
|
||||
|
||||
interface FetchUserAllRequestsOptions {
|
||||
interface FetchUserParticipantRequestsOptions {
|
||||
page: number;
|
||||
itemsPerPage: number;
|
||||
filters?: RequestFilters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all requests for regular users (initiator + participant)
|
||||
* Combines requests where user is initiator AND requests where user is participant
|
||||
* Fetch participant requests for regular users
|
||||
* Uses /workflows/participant-requests endpoint which excludes initiator requests
|
||||
*/
|
||||
export async function fetchUserParticipantRequestsData({
|
||||
page,
|
||||
itemsPerPage,
|
||||
filters
|
||||
}: FetchUserAllRequestsOptions) {
|
||||
}: FetchUserParticipantRequestsOptions) {
|
||||
// Build filter params for backend API
|
||||
const backendFilters: any = {};
|
||||
if (filters?.search) backendFilters.search = filters.search;
|
||||
@ -42,127 +42,51 @@ export async function fetchUserParticipantRequestsData({
|
||||
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;
|
||||
|
||||
// To properly merge and paginate, we need to fetch enough data from both endpoints
|
||||
// Fetch multiple pages from each endpoint to ensure we have enough data to merge and paginate correctly
|
||||
const fetchLimit = Math.max(itemsPerPage * 3, 100); // Fetch at least 3 pages worth or 100 items, whichever is larger
|
||||
|
||||
// Fetch from both endpoints in parallel
|
||||
const [initiatorResult, participantResult] = await Promise.all([
|
||||
// Fetch requests where user is initiator (fetch more to account for merging)
|
||||
workflowApi.listMyInitiatedWorkflows({
|
||||
page: 1,
|
||||
limit: fetchLimit,
|
||||
search: backendFilters.search,
|
||||
status: backendFilters.status,
|
||||
priority: backendFilters.priority,
|
||||
department: backendFilters.department,
|
||||
slaCompliance: backendFilters.slaCompliance,
|
||||
dateRange: backendFilters.dateRange,
|
||||
startDate: backendFilters.startDate,
|
||||
endDate: backendFilters.endDate
|
||||
}),
|
||||
// Fetch requests where user is participant (approver/spectator)
|
||||
workflowApi.listParticipantRequests({
|
||||
page: 1,
|
||||
limit: fetchLimit,
|
||||
...backendFilters
|
||||
})
|
||||
]);
|
||||
|
||||
// Extract data from both results
|
||||
let initiatorData: any[] = [];
|
||||
if (Array.isArray(initiatorResult?.data)) {
|
||||
initiatorData = initiatorResult.data;
|
||||
} else if (Array.isArray(initiatorResult)) {
|
||||
initiatorData = initiatorResult;
|
||||
}
|
||||
|
||||
let participantData: any[] = [];
|
||||
if (Array.isArray(participantResult?.data)) {
|
||||
participantData = participantResult.data;
|
||||
} else if (Array.isArray(participantResult)) {
|
||||
participantData = participantResult;
|
||||
}
|
||||
|
||||
// Filter out drafts from both
|
||||
const nonDraftInitiatorData = initiatorData.filter((req: any) => {
|
||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||
return reqStatus !== 'DRAFT';
|
||||
});
|
||||
|
||||
const nonDraftParticipantData = participantData.filter((req: any) => {
|
||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||
return reqStatus !== 'DRAFT';
|
||||
});
|
||||
|
||||
// Merge and deduplicate by requestId
|
||||
const mergedMap = new Map<string, any>();
|
||||
|
||||
// Add initiator requests
|
||||
nonDraftInitiatorData.forEach((req: any) => {
|
||||
const requestId = req.requestId || req.id;
|
||||
if (requestId) {
|
||||
mergedMap.set(requestId, req);
|
||||
}
|
||||
});
|
||||
|
||||
// Add participant requests (will overwrite if duplicate, but that's fine)
|
||||
nonDraftParticipantData.forEach((req: any) => {
|
||||
const requestId = req.requestId || req.id;
|
||||
if (requestId) {
|
||||
mergedMap.set(requestId, req);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert map to array
|
||||
const mergedData = Array.from(mergedMap.values());
|
||||
|
||||
// Sort by updatedAt or createdAt (most recent first)
|
||||
mergedData.sort((a: any, b: any) => {
|
||||
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
||||
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
// Calculate combined pagination
|
||||
const initiatorPagination = initiatorResult?.pagination || { page: 1, limit: fetchLimit, total: 0, totalPages: 1 };
|
||||
const participantPagination = participantResult?.pagination || { page: 1, limit: fetchLimit, total: 0, totalPages: 1 };
|
||||
|
||||
// Estimate total: sum of both totals, but account for potential duplicates
|
||||
// We'll use a conservative estimate: sum of both, but we know there might be overlap
|
||||
const estimatedTotal = (initiatorPagination.total || 0) + (participantPagination.total || 0);
|
||||
// The actual merged count might be less due to duplicates, but we use the merged length if we have enough data
|
||||
const actualTotal = mergedData.length >= fetchLimit ? estimatedTotal : mergedData.length;
|
||||
|
||||
// Paginate the merged results
|
||||
const startIndex = (page - 1) * itemsPerPage;
|
||||
const endIndex = startIndex + itemsPerPage;
|
||||
const paginatedData = mergedData.slice(startIndex, endIndex);
|
||||
|
||||
const pagination = {
|
||||
// Fetch paginated data using SEPARATE endpoint for regular users
|
||||
// This endpoint automatically excludes initiator requests
|
||||
const pageResult = await workflowApi.listParticipantRequests({
|
||||
page,
|
||||
limit: itemsPerPage,
|
||||
total: actualTotal,
|
||||
totalPages: Math.ceil(actualTotal / itemsPerPage) || 1
|
||||
...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: paginatedData, // Paginated merged data for list display
|
||||
data: nonDraftData, // Paginated data for list display
|
||||
allData: [], // Stats calculated from data
|
||||
filteredData: paginatedData, // Same as data for list
|
||||
filteredData: nonDraftData, // Same as data for list
|
||||
pagination: pagination
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch all requests for export (regular users - initiator + participant)
|
||||
* Fetches from both endpoints and merges results
|
||||
* 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 allInitiatorPages: any[] = [];
|
||||
const allParticipantPages: any[] = [];
|
||||
let hasMoreInitiator = true;
|
||||
let hasMoreParticipant = true;
|
||||
const allPages: any[] = [];
|
||||
let currentPage = 1;
|
||||
let hasMore = true;
|
||||
const maxPages = 100; // Safety limit
|
||||
|
||||
// Build filter params for backend API
|
||||
@ -181,98 +105,25 @@ export async function fetchAllRequestsForExport(filters?: RequestFilters): Promi
|
||||
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 initiator requests
|
||||
const initiatorFetch = async () => {
|
||||
let page = 1;
|
||||
while (hasMoreInitiator && page <= maxPages) {
|
||||
const pageResult = await workflowApi.listMyInitiatedWorkflows({
|
||||
page,
|
||||
limit: EXPORT_FETCH_LIMIT,
|
||||
search: backendFilters.search,
|
||||
status: backendFilters.status,
|
||||
priority: backendFilters.priority,
|
||||
department: backendFilters.department,
|
||||
slaCompliance: backendFilters.slaCompliance,
|
||||
dateRange: backendFilters.dateRange,
|
||||
startDate: backendFilters.startDate,
|
||||
endDate: backendFilters.endDate
|
||||
});
|
||||
|
||||
const pageData = pageResult?.data || [];
|
||||
if (pageData.length === 0) {
|
||||
hasMoreInitiator = false;
|
||||
} else {
|
||||
allInitiatorPages.push(...pageData);
|
||||
page++;
|
||||
if (pageData.length < EXPORT_FETCH_LIMIT) {
|
||||
hasMoreInitiator = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch participant requests
|
||||
const participantFetch = async () => {
|
||||
let page = 1;
|
||||
while (hasMoreParticipant && page <= maxPages) {
|
||||
while (hasMore && currentPage <= maxPages) {
|
||||
const pageResult = await workflowApi.listParticipantRequests({
|
||||
page,
|
||||
page: currentPage,
|
||||
limit: EXPORT_FETCH_LIMIT,
|
||||
...backendFilters
|
||||
});
|
||||
|
||||
const pageData = pageResult?.data || [];
|
||||
if (pageData.length === 0) {
|
||||
hasMoreParticipant = false;
|
||||
hasMore = false;
|
||||
} else {
|
||||
allParticipantPages.push(...pageData);
|
||||
page++;
|
||||
allPages.push(...pageData);
|
||||
currentPage++;
|
||||
if (pageData.length < EXPORT_FETCH_LIMIT) {
|
||||
hasMoreParticipant = false;
|
||||
hasMore = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Fetch both in parallel
|
||||
await Promise.all([initiatorFetch(), participantFetch()]);
|
||||
|
||||
// Filter out drafts
|
||||
const nonDraftInitiator = allInitiatorPages.filter((req: any) => {
|
||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||
return reqStatus !== 'DRAFT';
|
||||
});
|
||||
|
||||
const nonDraftParticipant = allParticipantPages.filter((req: any) => {
|
||||
const reqStatus = (req.status || '').toString().toUpperCase();
|
||||
return reqStatus !== 'DRAFT';
|
||||
});
|
||||
|
||||
// Merge and deduplicate by requestId
|
||||
const mergedMap = new Map<string, any>();
|
||||
|
||||
nonDraftInitiator.forEach((req: any) => {
|
||||
const requestId = req.requestId || req.id;
|
||||
if (requestId) {
|
||||
mergedMap.set(requestId, req);
|
||||
}
|
||||
});
|
||||
|
||||
nonDraftParticipant.forEach((req: any) => {
|
||||
const requestId = req.requestId || req.id;
|
||||
if (requestId) {
|
||||
mergedMap.set(requestId, req);
|
||||
}
|
||||
});
|
||||
|
||||
// Convert to array and sort by date
|
||||
const mergedData = Array.from(mergedMap.values());
|
||||
mergedData.sort((a: any, b: any) => {
|
||||
const dateA = new Date(a.updatedAt || a.createdAt || 0).getTime();
|
||||
const dateB = new Date(b.updatedAt || b.createdAt || 0).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
|
||||
return mergedData;
|
||||
return allPages;
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,8 @@ import {
|
||||
Palette,
|
||||
Lock,
|
||||
Calendar,
|
||||
Sliders
|
||||
Sliders,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { setupPushNotifications } from '@/utils/pushNotifications';
|
||||
import { useAuth, isAdmin as checkIsAdmin } from '@/contexts/AuthContext';
|
||||
@ -385,6 +386,21 @@ export function Settings() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Info: Admin features not available */}
|
||||
<Card className="shadow-xl border-0 rounded-md bg-gradient-to-br from-blue-50 to-blue-100/50">
|
||||
<CardContent className="p-5 sm:p-6">
|
||||
<div className="flex items-start sm:items-center gap-3 sm:gap-4">
|
||||
<div className="p-2 sm:p-2.5 bg-blue-500 rounded-lg shrink-0">
|
||||
<AlertCircle className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-semibold text-gray-900 mb-1">Admin Features Not Accessible</p>
|
||||
<p className="text-xs text-gray-700">System configuration and holiday management require admin privileges. Contact your administrator for access.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -125,21 +125,3 @@ export async function listMySummaries(params: { page?: number; limit?: number }
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get summary by requestId (checks if summary exists without creating)
|
||||
* Returns null if summary doesn't exist
|
||||
*/
|
||||
export async function getSummaryByRequestId(requestId: string): Promise<RequestSummary | null> {
|
||||
try {
|
||||
const res = await apiClient.get(`/summaries/request/${requestId}`);
|
||||
return res.data.data;
|
||||
} catch (error: any) {
|
||||
// If summary doesn't exist (404), return null
|
||||
if (error?.response?.status === 404) {
|
||||
return null;
|
||||
}
|
||||
// For other errors, also return null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -230,8 +230,8 @@ export async function listMyWorkflows(params: { page?: number; limit?: number; s
|
||||
}
|
||||
|
||||
// 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; slaCompliance?: string; dateRange?: string; startDate?: string; endDate?: string } = {}) {
|
||||
const { page = 1, limit = 20, search, status, priority, department, slaCompliance, dateRange, startDate, endDate } = params;
|
||||
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,
|
||||
@ -240,7 +240,6 @@ export async function listMyInitiatedWorkflows(params: { page?: number; limit?:
|
||||
status,
|
||||
priority,
|
||||
department,
|
||||
slaCompliance,
|
||||
dateRange,
|
||||
startDate,
|
||||
endDate
|
||||
|
||||
@ -47,7 +47,6 @@
|
||||
--re-gold: #c9b037;
|
||||
--re-dark: #1a1a1a;
|
||||
--re-light-green: #8a9b8e;
|
||||
--re-red: #DA281C;
|
||||
}
|
||||
|
||||
.dark {
|
||||
@ -130,7 +129,6 @@
|
||||
--color-re-gold: var(--re-gold);
|
||||
--color-re-dark: var(--re-dark);
|
||||
--color-re-light-green: var(--re-light-green);
|
||||
--color-re-red: var(--re-red);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
|
||||
@ -45,79 +45,23 @@ export async function subscribeUserToPush(register: ServiceWorkerRegistration) {
|
||||
throw new Error('Missing VAPID public key configuration');
|
||||
}
|
||||
|
||||
// Validate VAPID key format (should be base64 URL-safe string)
|
||||
if (!VAPID_PUBLIC_KEY || VAPID_PUBLIC_KEY.trim().length === 0) {
|
||||
throw new Error('VAPID public key is empty. Please configure VITE_PUBLIC_VAPID_KEY in your environment variables.');
|
||||
}
|
||||
|
||||
// Check if pushManager is available
|
||||
if (!register.pushManager) {
|
||||
throw new Error('Push manager is not available. Please ensure your browser supports push notifications and the service worker is properly registered.');
|
||||
}
|
||||
|
||||
// Check if already subscribed
|
||||
let subscription: PushSubscription;
|
||||
try {
|
||||
const existingSubscription = await register.pushManager.getSubscription();
|
||||
|
||||
if (existingSubscription) {
|
||||
// Already subscribed, check if it's still valid by trying to use it
|
||||
try {
|
||||
// Verify the subscription is still valid
|
||||
// Already subscribed, check if it's still valid
|
||||
subscription = existingSubscription;
|
||||
} catch (error) {
|
||||
// Existing subscription is invalid, unsubscribe and create new one
|
||||
console.warn('[Push] Existing subscription is invalid, creating new one...');
|
||||
await existingSubscription.unsubscribe().catch(() => {
|
||||
// Ignore unsubscribe errors
|
||||
});
|
||||
subscription = await register.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Subscribe to push
|
||||
try {
|
||||
subscription = await register.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
||||
});
|
||||
} catch (subscribeError: any) {
|
||||
// If subscription fails, try to clear any invalid subscriptions and retry once
|
||||
console.warn('[Push] Initial subscription failed, attempting to clear and retry...');
|
||||
try {
|
||||
const allSubscriptions = await register.pushManager.getSubscription();
|
||||
if (allSubscriptions) {
|
||||
await allSubscriptions.unsubscribe().catch(() => {
|
||||
// Ignore unsubscribe errors
|
||||
});
|
||||
}
|
||||
// Retry subscription
|
||||
subscription = await register.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
||||
});
|
||||
} catch (retryError: any) {
|
||||
// Provide more specific error messages
|
||||
const errorMsg = subscribeError?.message || retryError?.message || 'Unknown error';
|
||||
if (errorMsg.includes('push service error') || errorMsg.includes('Registration failed')) {
|
||||
throw new Error('Push service error: The browser\'s push service rejected the subscription. This may be due to an invalid VAPID key, network issues, or browser push service problems. Please verify your VAPID key configuration and try again.');
|
||||
}
|
||||
throw new Error(`Failed to subscribe to push notifications: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
// Provide more helpful error messages
|
||||
const errorMsg = error?.message || 'Unknown error';
|
||||
if (errorMsg.includes('push service error') || errorMsg.includes('Registration failed')) {
|
||||
throw new Error('Push service error: The browser\'s push service rejected the subscription. Please verify your VAPID key is correct and matches the backend configuration. If the problem persists, try clearing your browser cache and service workers.');
|
||||
}
|
||||
if (errorMsg.includes('Invalid key')) {
|
||||
throw new Error('Invalid VAPID key format. Please verify that VITE_PUBLIC_VAPID_KEY is correctly set and matches the backend VAPID_PUBLIC_KEY.');
|
||||
}
|
||||
throw new Error(`Failed to subscribe to push notifications: ${errorMsg}`);
|
||||
throw new Error(`Failed to subscribe to push notifications: ${error?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Convert subscription to JSON format for backend
|
||||
|
||||
@ -62,7 +62,6 @@ module.exports = {
|
||||
're-gold': 'var(--re-gold)',
|
||||
're-dark': 'var(--re-dark)',
|
||||
're-light-green': 'var(--re-light-green)',
|
||||
're-red': 'var(--re-red)',
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
|
||||
@ -63,7 +63,6 @@ const config: Config = {
|
||||
're-gold': 'var(--re-gold)',
|
||||
're-dark': 'var(--re-dark)',
|
||||
're-light-green': 'var(--re-light-green)',
|
||||
're-red': 'var(--re-red)',
|
||||
},
|
||||
borderRadius: {
|
||||
lg: 'var(--radius)',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user