dashboard enhanced and and created all requests screen

This commit is contained in:
laxmanhalaki 2025-11-18 20:30:18 +05:30
parent e193b60083
commit ea1cc43cb6
31 changed files with 5473 additions and 334 deletions

View File

@ -9,6 +9,8 @@ import { WorkNotes } from '@/pages/WorkNotes';
import { CreateRequest } from '@/pages/CreateRequest'; import { CreateRequest } from '@/pages/CreateRequest';
import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard'; import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard';
import { MyRequests } from '@/pages/MyRequests'; import { MyRequests } from '@/pages/MyRequests';
import { Requests } from '@/pages/Requests/Requests';
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
import { Profile } from '@/pages/Profile'; import { Profile } from '@/pages/Profile';
import { Settings } from '@/pages/Settings'; import { Settings } from '@/pages/Settings';
import { Notifications } from '@/pages/Notifications'; import { Notifications } from '@/pages/Notifications';
@ -70,7 +72,13 @@ function AppRoutes({ onLogout }: AppProps) {
navigate('/settings'); navigate('/settings');
return; return;
} }
navigate(`/${page}`); // If page already starts with '/', use it directly (e.g., '/requests?status=approved')
// Otherwise, add leading slash (e.g., 'open-requests' -> '/open-requests')
if (page.startsWith('/')) {
navigate(page);
} else {
navigate(`/${page}`);
}
}; };
const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string) => { const handleViewRequest = async (requestId: string, requestTitle?: string, status?: string) => {
@ -491,6 +499,26 @@ function AppRoutes({ onLogout }: AppProps) {
} }
/> />
{/* Requests - Advanced Filtering Screen (Admin/Management) */}
<Route
path="/requests"
element={
<PageLayout currentPage="requests" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Requests onViewRequest={handleViewRequest} />
</PageLayout>
}
/>
{/* Approver Performance - Detailed Performance Analysis */}
<Route
path="/approver-performance"
element={
<PageLayout currentPage="approver-performance" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<ApproverPerformance onViewRequest={handleViewRequest} />
</PageLayout>
}
/>
{/* Request Detail - requestId will be read from URL params */} {/* Request Detail - requestId will be read from URL params */}
<Route <Route
path="/request/:requestId" path="/request/:requestId"

View File

@ -128,7 +128,7 @@ export function AIConfig() {
<Card className="shadow-lg border-0 rounded-md"> <Card className="shadow-lg border-0 rounded-md">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-re-green rounded-md shadow-md"> <div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<Sparkles className="h-5 w-5 text-white" /> <Sparkles className="h-5 w-5 text-white" />
</div> </div>
<div> <div>

View File

@ -259,23 +259,17 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
}; };
const getCategoryColor = (category: string) => { const getCategoryColor = (category: string) => {
switch (category) { // Use uniform slate color for all category icons
case 'TAT_SETTINGS': return 'bg-gradient-to-br from-slate-600 to-slate-700 text-white';
return 'bg-blue-100 text-blue-600';
case 'DOCUMENT_POLICY':
return 'bg-purple-100 text-purple-600';
case 'NOTIFICATION_RULES':
return 'bg-amber-100 text-amber-600';
case 'AI_CONFIGURATION':
return 'bg-pink-100 text-pink-600';
case 'WORKFLOW_SHARING':
return 'bg-emerald-100 text-emerald-600';
default:
return 'bg-gray-100 text-gray-600';
}
}; };
const groupedConfigs = configurations.reduce((acc, config) => { // Filter out notification rules and dashboard layout categories
const excludedCategories = ['NOTIFICATION_RULES', 'DASHBOARD_LAYOUT'];
const filteredConfigurations = configurations.filter(
config => !excludedCategories.includes(config.configCategory)
);
const groupedConfigs = filteredConfigurations.reduce((acc, config) => {
if (!acc[config.configCategory]) { if (!acc[config.configCategory]) {
acc[config.configCategory] = []; acc[config.configCategory] = [];
} }
@ -299,7 +293,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
); );
} }
if (configurations.length === 0) { if (filteredConfigurations.length === 0) {
return ( return (
<Card className="shadow-lg border-0 rounded-md"> <Card className="shadow-lg border-0 rounded-md">
<CardContent className="p-12 text-center"> <CardContent className="p-12 text-center">
@ -368,8 +362,10 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
<Card className="shadow-lg border-0 rounded-md"> <Card className="shadow-lg border-0 rounded-md">
<CardHeader className="pb-4 border-b border-slate-100"> <CardHeader className="pb-4 border-b border-slate-100">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className={`p-2.5 rounded-md shadow-sm ${getCategoryColor(category)}`}> <div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<div className="text-white">
{getCategoryIcon(category)} {getCategoryIcon(category)}
</div>
</div> </div>
<div> <div>
<CardTitle className="text-lg font-semibold text-slate-900"> <CardTitle className="text-lg font-semibold text-slate-900">
@ -420,7 +416,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
size="sm" size="sm"
onClick={() => handleSave(config)} onClick={() => handleSave(config)}
disabled={!hasChanges(config) || saving === config.configKey} disabled={!hasChanges(config) || saving === config.configKey}
className="gap-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 shadow-sm disabled:opacity-50 disabled:cursor-not-allowed" className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
> >
{saving === config.configKey ? ( {saving === config.configKey ? (
<> <>

View File

@ -93,7 +93,7 @@ export function DocumentConfig() {
<Card className="shadow-lg border-0 rounded-md"> <Card className="shadow-lg border-0 rounded-md">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-re-green rounded-md shadow-md"> <div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<FileText className="h-5 w-5 text-white" /> <FileText className="h-5 w-5 text-white" />
</div> </div>
<div> <div>

View File

@ -198,7 +198,7 @@ export function HolidayManager() {
<CardHeader className="border-b border-slate-100 py-4 sm:py-5"> <CardHeader className="border-b border-slate-100 py-4 sm:py-5">
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4"> <div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-3 sm:gap-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2.5 bg-gradient-to-br from-blue-500 to-blue-600 rounded-md shadow-md"> <div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<Calendar className="w-5 h-5 text-white" /> <Calendar className="w-5 h-5 text-white" />
</div> </div>
<div> <div>
@ -223,7 +223,7 @@ export function HolidayManager() {
</Select> </Select>
<Button <Button
onClick={handleAdd} onClick={handleAdd}
className="gap-2 bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 shadow-sm flex-1 sm:flex-initial" className="gap-2 bg-re-green hover:bg-re-green/90 text-white shadow-sm flex-1 sm:flex-initial"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
<span className="hidden xs:inline">Add Holiday</span> <span className="hidden xs:inline">Add Holiday</span>
@ -329,97 +329,148 @@ export function HolidayManager() {
{/* Add/Edit Dialog */} {/* Add/Edit Dialog */}
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}> <Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
<DialogContent className="sm:max-w-[500px] rounded-md"> <DialogContent className="sm:max-w-[550px] max-h-[90vh] rounded-lg flex flex-col p-0">
<DialogHeader> <DialogHeader className="pb-4 border-b border-slate-100 px-6 pt-6 flex-shrink-0">
<DialogTitle className="text-lg sm:text-xl font-semibold text-slate-900"> <div className="flex items-center gap-3">
{editingHoliday ? 'Edit Holiday' : 'Add New Holiday'} <div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-lg shadow-md">
</DialogTitle> <Calendar className="w-5 h-5 text-white" />
<DialogDescription className="text-sm text-slate-600"> </div>
{editingHoliday ? 'Update holiday information' : 'Add a new holiday to the calendar'} <div className="flex-1">
</DialogDescription> <DialogTitle className="text-xl font-semibold text-slate-900">
{editingHoliday ? 'Edit Holiday' : 'Add New Holiday'}
</DialogTitle>
<DialogDescription className="text-sm text-slate-600 mt-1">
{editingHoliday ? 'Update holiday information' : 'Add a new holiday to the calendar for TAT calculations'}
</DialogDescription>
</div>
</div>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-5 py-6 px-6 overflow-y-auto flex-1 min-h-0">
{/* Date Field */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="date" className="text-sm font-medium text-slate-900">Date *</Label> <Label htmlFor="date" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
Date <span className="text-red-500">*</span>
</Label>
<Input <Input
id="date" id="date"
type="date" type="date"
value={formData.holidayDate} value={formData.holidayDate}
onChange={(e) => setFormData({ ...formData, holidayDate: e.target.value })} onChange={(e) => setFormData({ ...formData, holidayDate: e.target.value })}
className="border-slate-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 rounded-md transition-all shadow-sm" className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
/> />
<p className="text-xs text-slate-500">Select the holiday date</p>
</div> </div>
{/* Holiday Name Field */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium text-slate-900">Holiday Name *</Label> <Label htmlFor="name" className="text-sm font-semibold text-slate-900 flex items-center gap-1">
Holiday Name <span className="text-red-500">*</span>
</Label>
<Input <Input
id="name" id="name"
placeholder="e.g., Diwali, Republic Day" placeholder="e.g., Diwali, Republic Day, Christmas"
value={formData.holidayName} value={formData.holidayName}
onChange={(e) => setFormData({ ...formData, holidayName: e.target.value })} onChange={(e) => setFormData({ ...formData, holidayName: e.target.value })}
className="border-slate-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 rounded-md transition-all shadow-sm" className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
/> />
<p className="text-xs text-slate-500">Enter the official name of the holiday</p>
</div> </div>
{/* Description Field */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description" className="text-sm font-medium text-slate-900">Description</Label> <Label htmlFor="description" className="text-sm font-semibold text-slate-900">
Description <span className="text-slate-400 font-normal text-xs">(Optional)</span>
</Label>
<Input <Input
id="description" id="description"
placeholder="Optional description" placeholder="Add additional details about this holiday..."
value={formData.description} value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })} onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="border-slate-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 rounded-md transition-all shadow-sm" className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm"
/> />
<p className="text-xs text-slate-500">Optional description or notes about the holiday</p>
</div> </div>
{/* Holiday Type Field */}
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="type" className="text-sm font-medium text-slate-900">Holiday Type</Label> <Label htmlFor="type" className="text-sm font-semibold text-slate-900">
Holiday Type
</Label>
<Select <Select
value={formData.holidayType} value={formData.holidayType}
onValueChange={(value: Holiday['holidayType']) => onValueChange={(value: Holiday['holidayType']) =>
setFormData({ ...formData, holidayType: value }) setFormData({ ...formData, holidayType: value })
} }
> >
<SelectTrigger id="type" className="border-slate-300 focus:border-blue-500 focus:ring-2 focus:ring-blue-500/20 rounded-md transition-all shadow-sm"> <SelectTrigger id="type" className="h-11 border-slate-300 focus:border-re-green focus:ring-2 focus:ring-re-green/20 rounded-lg transition-all shadow-sm">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent className="rounded-md"> <SelectContent className="rounded-lg">
<SelectItem value="NATIONAL">National</SelectItem> <SelectItem value="NATIONAL" className="p-3">
<SelectItem value="REGIONAL">Regional</SelectItem> <div className="flex items-center gap-2">
<SelectItem value="ORGANIZATIONAL">Organizational</SelectItem> <div className="w-2 h-2 rounded-full bg-red-500"></div>
<SelectItem value="OPTIONAL">Optional</SelectItem> <span>National</span>
</div>
</SelectItem>
<SelectItem value="REGIONAL" className="p-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-blue-500"></div>
<span>Regional</span>
</div>
</SelectItem>
<SelectItem value="ORGANIZATIONAL" className="p-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-purple-500"></div>
<span>Organizational</span>
</div>
</SelectItem>
<SelectItem value="OPTIONAL" className="p-3">
<div className="flex items-center gap-2">
<div className="w-2 h-2 rounded-full bg-slate-500"></div>
<span>Optional</span>
</div>
</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-slate-500">Select the category of this holiday</p>
</div> </div>
<div className="flex items-center gap-2 p-3 bg-slate-50 border border-slate-200 rounded-md hover:bg-slate-100 transition-colors"> {/* Recurring Checkbox */}
<div className="flex items-start gap-3 p-4 bg-gradient-to-br from-slate-50 to-slate-100/50 border-2 border-slate-200 rounded-lg hover:border-slate-300 hover:bg-slate-100 transition-all cursor-pointer group" onClick={() => setFormData({ ...formData, isRecurring: !formData.isRecurring })}>
<input <input
type="checkbox" type="checkbox"
id="recurring" id="recurring"
checked={formData.isRecurring} checked={formData.isRecurring}
onChange={(e) => setFormData({ ...formData, isRecurring: e.target.checked })} onChange={(e) => setFormData({ ...formData, isRecurring: e.target.checked })}
className="rounded border-slate-300 text-blue-600 focus:ring-2 focus:ring-blue-500/20 focus:ring-offset-0 w-4 h-4 cursor-pointer" className="mt-0.5 rounded border-slate-300 text-re-green focus:ring-2 focus:ring-re-green/20 focus:ring-offset-0 w-4 h-4 cursor-pointer"
/> />
<Label htmlFor="recurring" className="font-normal cursor-pointer text-sm text-slate-700"> <div className="flex-1">
This holiday recurs annually <Label htmlFor="recurring" className="font-semibold cursor-pointer text-sm text-slate-900 block mb-1">
</Label> Recurring Holiday
</Label>
<p className="text-xs text-slate-600">
This holiday will automatically repeat every year on the same date
</p>
</div>
</div> </div>
</div> </div>
<DialogFooter className="gap-2"> <DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0">
<Button <Button
variant="outline" variant="outline"
onClick={() => setShowAddDialog(false)} onClick={() => setShowAddDialog(false)}
className="border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm" className="h-11 border-slate-300 hover:bg-slate-50 hover:border-slate-400 shadow-sm"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={handleSave} onClick={handleSave}
className="bg-gradient-to-r from-blue-600 to-blue-700 hover:from-blue-700 hover:to-blue-800 shadow-sm" disabled={!formData.holidayDate || !formData.holidayName}
className="h-11 bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
{editingHoliday ? 'Update' : 'Add'} Holiday <Calendar className="w-4 h-4 mr-2" />
{editingHoliday ? 'Update Holiday' : 'Add Holiday'}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@ -114,7 +114,7 @@ export function TATConfig() {
<Card className="shadow-lg border-0 rounded-md"> <Card className="shadow-lg border-0 rounded-md">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-re-green rounded-md shadow-md"> <div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md">
<Clock className="h-5 w-5 text-white" /> <Clock className="h-5 w-5 text-white" />
</div> </div>
<div> <div>

View File

@ -46,7 +46,20 @@ interface OktaUser {
lastName?: string; lastName?: string;
displayName?: string; displayName?: string;
department?: string; department?: string;
phone?: string;
mobilePhone?: string;
designation?: string; designation?: string;
jobTitle?: string;
manager?: string;
employeeId?: string;
employeeNumber?: string;
secondEmail?: string;
location?: {
state?: string;
city?: string;
country?: string;
office?: string;
};
} }
interface UserWithRole { interface UserWithRole {
@ -124,10 +137,41 @@ export function UserRoleManager() {
}; };
// Select user from search results // Select user from search results
const handleSelectUser = (user: OktaUser) => { const handleSelectUser = async (user: OktaUser) => {
setSelectedUser(user); setSelectedUser(user);
setSearchQuery(user.email); setSearchQuery(user.email);
setSearchResults([]); setSearchResults([]);
// Check if user already exists in the current users list and has a role assigned
const existingUser = users.find(u =>
u.email.toLowerCase() === user.email.toLowerCase() ||
u.userId === user.userId
);
if (existingUser && existingUser.role) {
// Pre-select the user's current role
setSelectedRole(existingUser.role);
} else {
// If user doesn't exist in current list, check all users in database
try {
const allUsers = await userApi.getAllUsers();
const foundUser = allUsers.find((u: any) =>
(u.email && u.email.toLowerCase() === user.email.toLowerCase()) ||
(u.userId && u.userId === user.userId)
);
if (foundUser && foundUser.role) {
setSelectedRole(foundUser.role);
} else {
// Default to USER if user doesn't exist
setSelectedRole('USER');
}
} catch (error) {
console.error('Failed to check user role:', error);
// Default to USER on error
setSelectedRole('USER');
}
}
}; };
// Assign role to user // Assign role to user
@ -142,7 +186,8 @@ export function UserRoleManager() {
try { try {
// Call backend to assign role (will create user if doesn't exist) // Call backend to assign role (will create user if doesn't exist)
await userApi.assignRole(selectedUser.email, selectedRole); // Pass full user data so backend can capture all Okta fields
await userApi.assignRole(selectedUser.email, selectedRole, selectedUser);
setMessage({ setMessage({
type: 'success', type: 'success',
@ -282,22 +327,22 @@ export function UserRoleManager() {
const getRoleBadgeColor = (role: string) => { const getRoleBadgeColor = (role: string) => {
switch (role) { switch (role) {
case 'ADMIN': case 'ADMIN':
return 'bg-yellow-400 text-slate-900'; return 'bg-yellow-400 text-slate-800';
case 'MANAGEMENT': case 'MANAGEMENT':
return 'bg-blue-400 text-slate-900'; return 'bg-blue-400 text-slate-800';
default: default:
return 'bg-gray-400 text-white'; return 'bg-gray-400 text-slate-800';
} }
}; };
const getRoleIcon = (role: string) => { const getRoleIcon = (role: string) => {
switch (role) { switch (role) {
case 'ADMIN': case 'ADMIN':
return <Crown className="w-5 h-5" />; return <Crown className="w-5 h-5 text-slate-800" />;
case 'MANAGEMENT': case 'MANAGEMENT':
return <Users className="w-5 h-5" />; return <Users className="w-5 h-5 text-slate-800" />;
default: default:
return <UserIcon className="w-5 h-5" />; return <UserIcon className="w-5 h-5 text-slate-800" />;
} }
}; };
@ -322,7 +367,7 @@ export function UserRoleManager() {
</p> </p>
</div> </div>
<div className="p-3 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-xl shadow-md"> <div className="p-3 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-xl shadow-md">
<Crown className="w-6 h-6 text-slate-900" /> <Crown className="w-6 h-6 text-slate-800" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -345,7 +390,7 @@ export function UserRoleManager() {
</p> </p>
</div> </div>
<div className="p-3 bg-gradient-to-br from-blue-400 to-blue-500 rounded-xl shadow-md"> <div className="p-3 bg-gradient-to-br from-blue-400 to-blue-500 rounded-xl shadow-md">
<Users className="w-6 h-6 text-slate-900" /> <Users className="w-6 h-6 text-slate-800" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -368,7 +413,7 @@ export function UserRoleManager() {
</p> </p>
</div> </div>
<div className="p-3 bg-gradient-to-br from-gray-400 to-gray-500 rounded-xl shadow-md"> <div className="p-3 bg-gradient-to-br from-gray-400 to-gray-500 rounded-xl shadow-md">
<UserIcon className="w-6 h-6 text-white" /> <UserIcon className="w-6 h-6 text-slate-800" />
</div> </div>
</div> </div>
</CardContent> </CardContent>
@ -379,7 +424,7 @@ export function UserRoleManager() {
<Card className="shadow-lg border"> <Card className="shadow-lg border">
<CardHeader className="border-b pb-4"> <CardHeader className="border-b pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg shadow-md"> <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" /> <UserCog className="w-5 h-5 text-white" />
</div> </div>
<div> <div>
@ -442,19 +487,19 @@ export function UserRoleManager() {
{/* Selected User */} {/* Selected User */}
{selectedUser && ( {selectedUser && (
<div className="border-2 border-purple-200 bg-purple-50 rounded-lg p-4"> <div className="border-2 border-slate-300 bg-gradient-to-br from-slate-100 to-slate-50 rounded-lg p-4 shadow-sm">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center text-white font-bold shadow-md"> <div className="w-10 h-10 rounded-lg bg-gradient-to-br from-slate-700 to-slate-500 flex items-center justify-center text-white font-bold shadow-md">
{(selectedUser.displayName || selectedUser.email).charAt(0).toUpperCase()} {(selectedUser.displayName || selectedUser.email).charAt(0).toUpperCase()}
</div> </div>
<div> <div>
<p className="font-semibold text-gray-900"> <p className="font-semibold text-slate-900">
{selectedUser.displayName || selectedUser.email} {selectedUser.displayName || selectedUser.email}
</p> </p>
<p className="text-sm text-gray-600">{selectedUser.email}</p> <p className="text-sm text-slate-600">{selectedUser.email}</p>
{selectedUser.department && ( {selectedUser.department && (
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-slate-500 mt-1">
{selectedUser.department}{selectedUser.designation ? `${selectedUser.designation}` : ''} {selectedUser.department}{selectedUser.designation ? `${selectedUser.designation}` : ''}
</p> </p>
)} )}
@ -467,7 +512,7 @@ export function UserRoleManager() {
setSelectedUser(null); setSelectedUser(null);
setSearchQuery(''); setSearchQuery('');
}} }}
className="hover:bg-purple-100" className="hover:bg-slate-200"
> >
Clear Clear
</Button> </Button>
@ -512,7 +557,7 @@ export function UserRoleManager() {
<Button <Button
onClick={handleAssignRole} onClick={handleAssignRole}
disabled={!selectedUser || updating} disabled={!selectedUser || updating}
className="w-full h-12 bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-50 rounded-lg" 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" data-testid="assign-role-button"
> >
{updating ? ( {updating ? (
@ -556,7 +601,7 @@ export function UserRoleManager() {
<CardHeader className="border-b pb-4"> <CardHeader className="border-b pb-4">
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-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="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-lg shadow-md"> <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" /> <Shield className="w-5 h-5 text-white" />
</div> </div>
<div> <div>
@ -694,7 +739,7 @@ export function UserRoleManager() {
onClick={() => handlePageChange(pageNum)} onClick={() => handlePageChange(pageNum)}
className={`w-9 h-9 p-0 ${ className={`w-9 h-9 p-0 ${
currentPage === pageNum currentPage === pageNum
? 'bg-purple-500 hover:bg-purple-600' ? 'bg-re-green hover:bg-re-green/90 text-white'
: '' : ''
}`} }`}
data-testid={`page-${pageNum}-button`} data-testid={`page-${pageNum}-button`}

View File

@ -40,20 +40,45 @@ export function Pagination({
return pages; return pages;
}; };
// Don't show pagination if only 1 page or loading // Calculate display values
if (totalPages <= 1 || loading) { const startItem = totalRecords > 0 ? ((currentPage - 1) * itemsPerPage) + 1 : 0;
return null;
}
const startItem = ((currentPage - 1) * itemsPerPage) + 1;
const endItem = Math.min(currentPage * itemsPerPage, totalRecords); const endItem = Math.min(currentPage * itemsPerPage, totalRecords);
// Always show the count info, even if there's only 1 page
// If only 1 page or loading, only show the count info without pagination controls
if (totalPages <= 1) {
return (
<Card className="shadow-md border-gray-200" data-testid={`${testIdPrefix}-container`}>
<CardContent className="p-4">
<div className="flex items-center justify-center">
<div
className="text-sm sm:text-base font-medium text-gray-700"
data-testid={`${testIdPrefix}-info`}
>
{loading ? (
`Loading ${itemLabel}...`
) : totalRecords === 0 ? (
`No ${itemLabel} found`
) : totalRecords === 1 ? (
`Showing 1 ${itemLabel.slice(0, -1)}`
) : startItem === endItem ? (
`Showing ${startItem} of ${totalRecords} ${itemLabel}`
) : (
`Showing ${startItem} to ${endItem} of ${totalRecords} ${itemLabel}`
)}
</div>
</div>
</CardContent>
</Card>
);
}
return ( return (
<Card className="shadow-md" data-testid={`${testIdPrefix}-container`}> <Card className="shadow-md border-gray-200" data-testid={`${testIdPrefix}-container`}>
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex flex-col sm:flex-row items-center justify-between gap-3"> <div className="flex flex-col sm:flex-row items-center justify-between gap-3">
<div <div
className="text-xs sm:text-sm text-muted-foreground" className="text-sm sm:text-base font-medium text-gray-700"
data-testid={`${testIdPrefix}-info`} data-testid={`${testIdPrefix}-info`}
> >
Showing {startItem} to {endItem} of {totalRecords} {itemLabel} Showing {startItem} to {endItem} of {totalRecords} {itemLabel}

View File

@ -7,6 +7,7 @@ interface StatCardProps {
textColor: string; textColor: string;
testId?: string; testId?: string;
children?: ReactNode; children?: ReactNode;
onClick?: (e: React.MouseEvent) => void;
} }
export function StatCard({ export function StatCard({
@ -15,12 +16,14 @@ export function StatCard({
bgColor, bgColor,
textColor, textColor,
testId = 'stat-card', testId = 'stat-card',
children children,
onClick
}: StatCardProps) { }: StatCardProps) {
return ( return (
<div <div
className={`${bgColor} rounded-lg p-2 sm:p-3`} className={`${bgColor} rounded-lg p-2 sm:p-3 ${onClick ? 'cursor-pointer hover:shadow-md transition-shadow' : ''}`}
data-testid={testId} data-testid={testId}
onClick={onClick}
> >
<p <p
className="text-xs text-gray-600 mb-1" className="text-xs text-gray-600 mb-1"

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useMemo } from 'react';
import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, Shield } from 'lucide-react'; import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, List } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -15,7 +15,7 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog'; } from '@/components/ui/alert-dialog';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import royalEnfieldLogo from '@/assets/images/royal_enfield_logo.png'; import royalEnfieldLogo from '@/assets/images/royal_enfield_logo.png';
import notificationApi, { Notification } from '@/services/notificationApi'; import notificationApi, { Notification } from '@/services/notificationApi';
import { getSocket, joinUserRoom } from '@/utils/socket'; import { getSocket, joinUserRoom } from '@/utils/socket';
@ -57,13 +57,28 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
} }
}; };
const menuItems = [ // Check if user has management access (ADMIN or MANAGEMENT role)
{ id: 'dashboard', label: 'Dashboard', icon: Home }, const isManagement = useMemo(() => hasManagementAccess(user), [user]);
{ id: 'my-requests', label: 'My Requests', icon: User },
{ id: 'open-requests', label: 'Open Requests', icon: FileText }, const menuItems = useMemo(() => {
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle }, const items = [
{ id: 'admin', label: 'Admin', icon: Shield }, { id: 'dashboard', label: 'Dashboard', icon: Home },
]; ];
// 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 }
);
return items;
}, [isManagement]);
const toggleSidebar = () => { const toggleSidebar = () => {
setSidebarOpen(!sidebarOpen); setSidebarOpen(!sidebarOpen);

View File

@ -0,0 +1,88 @@
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertCircle } from 'lucide-react';
interface PolicyViolationModalProps {
open: boolean;
onClose: () => void;
violations: Array<{
type: string;
message: string;
currentValue?: number;
maxValue?: number;
}>;
policyDetails?: {
maxApprovalLevels?: number;
maxParticipants?: number;
allowSpectators?: boolean;
maxSpectators?: number;
};
}
export function PolicyViolationModal({
open,
onClose,
violations,
policyDetails
}: PolicyViolationModalProps) {
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
Policy Violation
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<p className="text-gray-700">
The following policy violations were detected:
</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{violations.map((violation, index) => (
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="font-medium text-red-900 text-sm">{violation.type}</p>
<p className="text-xs text-red-700 mt-1">{violation.message}</p>
{violation.currentValue !== undefined && violation.maxValue !== undefined && (
<p className="text-xs text-red-600 mt-1 font-semibold">
Current: {violation.currentValue} / Maximum: {violation.maxValue}
</p>
)}
</div>
))}
</div>
{policyDetails && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-800 font-semibold mb-1">System Policy:</p>
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
{policyDetails.maxApprovalLevels !== undefined && (
<li>Maximum approval levels: {policyDetails.maxApprovalLevels}</li>
)}
{policyDetails.maxParticipants !== undefined && (
<li>Maximum participants per request: {policyDetails.maxParticipants}</li>
)}
{policyDetails.allowSpectators !== undefined && (
<li>Allow adding spectators: {policyDetails.allowSpectators ? 'Yes' : 'No'}</li>
)}
{policyDetails.maxSpectators !== undefined && (
<li>Maximum spectators per request: {policyDetails.maxSpectators}</li>
)}
</ul>
</div>
)}
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={onClose}
className="w-full sm:w-auto"
>
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@ -210,7 +210,16 @@ export function AddApproverModal({
displayName: foundUser.displayName, displayName: foundUser.displayName,
firstName: foundUser.firstName, firstName: foundUser.firstName,
lastName: foundUser.lastName, lastName: foundUser.lastName,
department: foundUser.department department: foundUser.department,
phone: foundUser.phone,
mobilePhone: foundUser.mobilePhone,
designation: foundUser.designation,
jobTitle: foundUser.jobTitle,
manager: foundUser.manager,
employeeId: foundUser.employeeId,
employeeNumber: foundUser.employeeNumber,
secondEmail: foundUser.secondEmail,
location: foundUser.location
}); });
console.log(`✅ Validated approver: ${foundUser.displayName} (${foundUser.email})`); console.log(`✅ Validated approver: ${foundUser.displayName} (${foundUser.email})`);
@ -333,7 +342,16 @@ export function AddApproverModal({
displayName: user.displayName, displayName: user.displayName,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
department: user.department department: user.department,
phone: user.phone,
mobilePhone: user.mobilePhone,
designation: user.designation,
jobTitle: user.jobTitle,
manager: user.manager,
employeeId: user.employeeId,
employeeNumber: user.employeeNumber,
secondEmail: user.secondEmail,
location: user.location
}); });
setEmail(user.email); setEmail(user.email);

View File

@ -5,6 +5,8 @@ import { Input } from '@/components/ui/input';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react'; import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react';
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi'; import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
interface AddSpectatorModalProps { interface AddSpectatorModalProps {
open: boolean; open: boolean;
@ -42,6 +44,57 @@ export function AddSpectatorModal({
message: '' message: ''
}); });
// Policy violation modal state
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
}>({
open: false,
violations: []
});
// System policy configuration state
const [systemPolicy, setSystemPolicy] = useState<{
maxApprovalLevels: number;
maxParticipants: number;
allowSpectators: boolean;
maxSpectators: number;
}>({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
// Fetch system policy on mount
useEffect(() => {
const loadSystemPolicy = async () => {
try {
const workflowConfigs = await getAllConfigurations('WORKFLOW_SHARING');
const tatConfigs = await getAllConfigurations('TAT_SETTINGS');
const allConfigs = [...workflowConfigs, ...tatConfigs];
const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
setSystemPolicy({
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
});
} catch (error) {
console.error('Failed to load system policy:', error);
// Use defaults if loading fails
}
};
if (open) {
loadSystemPolicy();
}
}, [open]);
const handleConfirm = async () => { const handleConfirm = async () => {
const emailToAdd = email.trim().toLowerCase(); const emailToAdd = email.trim().toLowerCase();
@ -111,6 +164,58 @@ export function AddSpectatorModal({
} }
} }
// Policy validation before adding spectator
const violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }> = [];
// Check if spectators are allowed
if (!systemPolicy.allowSpectators) {
violations.push({
type: 'Spectators Not Allowed',
message: `Adding spectators is not allowed by system policy.`,
});
}
// Count existing spectators
const existingSpectators = existingParticipants.filter(
p => (p.participantType || '').toUpperCase() === 'SPECTATOR'
);
const currentSpectatorCount = existingSpectators.length;
// Check maximum spectators
if (currentSpectatorCount >= systemPolicy.maxSpectators) {
violations.push({
type: 'Maximum Spectators Exceeded',
message: `This request has reached the maximum number of spectators allowed.`,
currentValue: currentSpectatorCount,
maxValue: systemPolicy.maxSpectators
});
}
// Count existing participants (initiator + approvers + spectators)
const existingApprovers = existingParticipants.filter(
p => (p.participantType || '').toUpperCase() === 'APPROVER'
);
const totalParticipants = existingParticipants.length + 1; // +1 for the new spectator
// Check maximum participants
if (totalParticipants > systemPolicy.maxParticipants) {
violations.push({
type: 'Maximum Participants Exceeded',
message: `Adding this spectator would exceed the maximum participants limit.`,
currentValue: totalParticipants,
maxValue: systemPolicy.maxParticipants
});
}
// If there are policy violations, show modal and return
if (violations.length > 0) {
setPolicyViolationModal({
open: true,
violations
});
return;
}
// If user was NOT selected via @ search, validate against Okta // If user was NOT selected via @ search, validate against Okta
if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) { if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
try { try {
@ -137,7 +242,16 @@ export function AddSpectatorModal({
displayName: foundUser.displayName, displayName: foundUser.displayName,
firstName: foundUser.firstName, firstName: foundUser.firstName,
lastName: foundUser.lastName, lastName: foundUser.lastName,
department: foundUser.department department: foundUser.department,
phone: foundUser.phone,
mobilePhone: foundUser.mobilePhone,
designation: foundUser.designation,
jobTitle: foundUser.jobTitle,
manager: foundUser.manager,
employeeId: foundUser.employeeId,
employeeNumber: foundUser.employeeNumber,
secondEmail: foundUser.secondEmail,
location: foundUser.location
}); });
console.log(`✅ Validated spectator: ${foundUser.displayName} (${foundUser.email})`); console.log(`✅ Validated spectator: ${foundUser.displayName} (${foundUser.email})`);
@ -246,7 +360,16 @@ export function AddSpectatorModal({
displayName: user.displayName, displayName: user.displayName,
firstName: user.firstName, firstName: user.firstName,
lastName: user.lastName, lastName: user.lastName,
department: user.department department: user.department,
phone: user.phone,
mobilePhone: user.mobilePhone,
designation: user.designation,
jobTitle: user.jobTitle,
manager: user.manager,
employeeId: user.employeeId,
employeeNumber: user.employeeNumber,
secondEmail: user.secondEmail,
location: user.location
}); });
setEmail(user.email); setEmail(user.email);
@ -445,6 +568,19 @@ export function AddSpectatorModal({
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Policy Violation Modal */}
<PolicyViolationModal
open={policyViolationModal.open}
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
violations={policyViolationModal.violations}
policyDetails={{
maxApprovalLevels: systemPolicy.maxApprovalLevels,
maxParticipants: systemPolicy.maxParticipants,
allowSpectators: systemPolicy.allowSpectators,
maxSpectators: systemPolicy.maxSpectators
}}
/>
</Dialog> </Dialog>
); );
} }

View File

@ -1,6 +1,7 @@
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react'; import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react';
import { formatHoursMinutes } from '@/utils/slaTracker';
export interface SLAData { export interface SLAData {
status: 'normal' | 'approaching' | 'critical' | 'breached'; status: 'normal' | 'approaching' | 'critical' | 'breached';
@ -73,7 +74,7 @@ export function SLAProgressBar({
<div className="flex items-center justify-between text-xs mb-1"> <div className="flex items-center justify-between text-xs mb-1">
<span className="text-gray-600" data-testid={`${testId}-elapsed`}> <span className="text-gray-600" data-testid={`${testId}-elapsed`}>
{sla.elapsedText || `${sla.elapsedHours || 0}h`} elapsed {sla.elapsedText || formatHoursMinutes(sla.elapsedHours || 0)} elapsed
</span> </span>
<span <span
className={`font-semibold ${ className={`font-semibold ${
@ -83,7 +84,7 @@ export function SLAProgressBar({
}`} }`}
data-testid={`${testId}-remaining`} data-testid={`${testId}-remaining`}
> >
{sla.remainingText || `${sla.remainingHours || 0}h`} remaining {sla.remainingText || formatHoursMinutes(sla.remainingHours || 0)} remaining
</span> </span>
</div> </div>

View File

@ -105,9 +105,20 @@ const getStatusText = (status: string) => {
const formatMessage = (content: string) => { const formatMessage = (content: string) => {
// Enhanced mention highlighting - Blue color with extra bold font for high visibility // Enhanced mention highlighting - Blue color with extra bold font for high visibility
// Matches: @test user11 or @Test User11 (any case, stops before next sentence/punctuation) // Matches: @username or @FirstName LastName (only one space allowed for first name + last name)
// Pattern: @word or @word word (stops after second word)
return content return content
.replace(/@([A-Za-z0-9]+(?:\s+[A-Za-z0-9]+)*?)(?=\s+(?:[a-z][a-z\s]*)?(?:[.,!?;:]|$))/g, '<span class="inline-flex items-center px-2.5 py-0.5 rounded-md bg-blue-50 text-blue-800 font-black text-base border-2 border-blue-400 shadow-sm">@$1</span>') .replace(/@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g, (match, mention, offset, string) => {
const afterPos = offset + match.length;
const afterChar = string[afterPos];
// Valid mention if followed by: space, punctuation, @ (another mention), or end of string
if (!afterChar || /\s|[.,!?;:]|@/.test(afterChar)) {
return '<span class="inline-flex items-center px-2.5 py-0.5 rounded-md bg-blue-50 text-blue-800 font-black text-base border-2 border-blue-400 shadow-sm">@' + mention + '</span>';
}
return match;
})
.replace(/\n/g, '<br />'); .replace(/\n/g, '<br />');
}; };
@ -165,7 +176,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
errors: [] errors: []
}); });
console.log('[WorkNoteChat] Component render, participants loaded:', participantsLoadedRef.current); // Component render - logging removed for performance
// Get request info (from props, all data comes from backend now) // Get request info (from props, all data comes from backend now)
const requestInfo = useMemo(() => { const requestInfo = useMemo(() => {
@ -182,13 +193,9 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase()) msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
); );
// Log when participants change // Log when participants change - logging removed for performance
useEffect(() => { useEffect(() => {
console.log('[WorkNoteChat] Participants state changed:', { // Participants state changed - logging removed
total: participants.length,
online: participants.filter(p => p.status === 'online').length,
participants: participants.map(p => ({ name: p.name, status: p.status, userId: (p as any).userId }))
});
}, [participants]); }, [participants]);
// Load initial messages from backend (only if not provided by parent) // Load initial messages from backend (only if not provided by parent)
@ -340,7 +347,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
} as any; } as any;
}); });
console.log('[WorkNoteChat] ✅ Loaded participants:', mapped.map(p => ({ name: p.name, userId: (p as any).userId }))); // Participants loaded - logging removed
participantsLoadedRef.current = true; participantsLoadedRef.current = true;
setParticipants(mapped); setParticipants(mapped);
@ -349,7 +356,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
const maxRetries = 3; const maxRetries = 3;
const requestOnlineUsers = () => { const requestOnlineUsers = () => {
if (socketRef.current && socketRef.current.connected) { if (socketRef.current && socketRef.current.connected) {
console.log('[WorkNoteChat] 📡 Requesting online users list (attempt', retryCount + 1, ')...'); // Requesting online users - logging removed
socketRef.current.emit('request:online-users', { requestId: effectiveRequestId }); socketRef.current.emit('request:online-users', { requestId: effectiveRequestId });
retryCount++; retryCount++;
// Retry a few times to ensure we get the list // Retry a few times to ensure we get the list
@ -357,7 +364,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
setTimeout(requestOnlineUsers, 500); setTimeout(requestOnlineUsers, 500);
} }
} else { } else {
console.log('[WorkNoteChat] ⚠️ Socket not ready, will retry in 200ms... (attempt', retryCount + 1, ')'); // Socket not ready - retrying silently
retryCount++; retryCount++;
if (retryCount < maxRetries) { if (retryCount < maxRetries) {
setTimeout(requestOnlineUsers, 200); setTimeout(requestOnlineUsers, 200);
@ -574,31 +581,27 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
// Handle initial online users list // Handle initial online users list
const presenceOnlineHandler = (data: { requestId: string; userIds: string[] }) => { const presenceOnlineHandler = (data: { requestId: string; userIds: string[] }) => {
console.log('[WorkNoteChat] 📋 presence:online received - requestId:', data.requestId, 'onlineUserIds:', data.userIds, 'count:', data.userIds.length); // Presence update received - logging removed
setParticipants(prev => { setParticipants(prev => {
if (prev.length === 0) { if (prev.length === 0) {
console.log('[WorkNoteChat] ⚠️ No participants loaded yet, cannot update online status. Response will be ignored.'); console.log('[WorkNoteChat] ⚠️ No participants loaded yet, cannot update online status. Response will be ignored.');
return prev; return prev;
} }
console.log('[WorkNoteChat] 📊 Updating online status for', prev.length, 'participants'); // Updating online status - logging removed
const updated = prev.map(p => { const updated = prev.map(p => {
const pUserId = (p as any).userId || ''; const pUserId = (p as any).userId || '';
const isCurrentUserSelf = pUserId === currentUserId; const isCurrentUserSelf = pUserId === currentUserId;
// Always keep self as online in own browser // Always keep self as online in own browser
if (isCurrentUserSelf) { if (isCurrentUserSelf) {
console.log(`[WorkNoteChat] 🟢 ${p.name} (YOU - always online in own view)`);
return { ...p, status: 'online' as const }; return { ...p, status: 'online' as const };
} }
const isOnline = data.userIds.includes(pUserId); const isOnline = data.userIds.includes(pUserId);
console.log(`[WorkNoteChat] ${isOnline ? '🟢' : '⚪'} ${p.name} (userId: ${pUserId.slice(0, 8)}...): ${isOnline ? 'ONLINE' : 'offline'}`);
return { ...p, status: isOnline ? 'online' as const : 'offline' as const }; return { ...p, status: isOnline ? 'online' as const : 'offline' as const };
}); });
const onlineCount = updated.filter(p => p.status === 'online').length; // Online status updated - logging removed
console.log('[WorkNoteChat] ✅ Online status updated: ', onlineCount, '/', updated.length, 'participants online');
console.log('[WorkNoteChat] 📋 Online participants:', updated.filter(p => p.status === 'online').map(p => p.name).join(', '));
return updated; return updated;
}); });
}; };
@ -652,13 +655,13 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
}; };
// Debug: Log ALL events received from server for this request // Debug: Log ALL events received from server for this request
const anyEventHandler = (eventName: string, ...args: any[]) => { const anyEventHandler = (eventName: string) => {
if (eventName.includes('presence') || eventName.includes('worknote') || eventName.includes('request')) { if (eventName.includes('presence') || eventName.includes('worknote') || eventName.includes('request')) {
console.log('[WorkNoteChat] 📨 Event received:', eventName, args); // Socket event received - logging removed
} }
}; };
console.log('[WorkNoteChat] 🔌 Attaching socket listeners for request:', joinedId); // Attaching socket listeners - logging removed
s.on('connect', connectHandler); s.on('connect', connectHandler);
s.on('disconnect', disconnectHandler); s.on('disconnect', disconnectHandler);
s.on('error', errorHandler); s.on('error', errorHandler);
@ -667,35 +670,26 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
s.on('presence:leave', presenceLeaveHandler); s.on('presence:leave', presenceLeaveHandler);
s.on('presence:online', presenceOnlineHandler); s.on('presence:online', presenceOnlineHandler);
s.onAny(anyEventHandler); // Debug: catch all events s.onAny(anyEventHandler); // Debug: catch all events
console.log('[WorkNoteChat] ✅ All socket listeners attached (including error handlers)'); // Socket listeners attached - logging removed
// Store socket in ref for coordination with participants loading // Store socket in ref for coordination with participants loading
socketRef.current = s; socketRef.current = s;
// Always request online users after socket is ready // Always request online users after socket is ready
console.log('[WorkNoteChat] 🔌 Socket ready and listeners attached, socket.connected:', s.connected);
if (s.connected) { if (s.connected) {
if (participantsLoadedRef.current) { if (participantsLoadedRef.current) {
console.log('[WorkNoteChat] 📡 Participants already loaded, requesting online users now (with retries)'); // Requesting online users with retries - logging removed
// Send multiple requests to ensure we get the response
s.emit('request:online-users', { requestId: joinedId }); s.emit('request:online-users', { requestId: joinedId });
setTimeout(() => { setTimeout(() => {
console.log('[WorkNoteChat] 📡 Retry 1: Requesting online users...');
s.emit('request:online-users', { requestId: joinedId }); s.emit('request:online-users', { requestId: joinedId });
}, 300); }, 300);
setTimeout(() => { setTimeout(() => {
console.log('[WorkNoteChat] 📡 Retry 2: Requesting online users...');
s.emit('request:online-users', { requestId: joinedId }); s.emit('request:online-users', { requestId: joinedId });
}, 800); }, 800);
setTimeout(() => { setTimeout(() => {
console.log('[WorkNoteChat] 📡 Final retry: Requesting online users...');
s.emit('request:online-users', { requestId: joinedId }); s.emit('request:online-users', { requestId: joinedId });
}, 1500); }, 1500);
} else {
console.log('[WorkNoteChat] ⏳ Waiting for participants to load first...');
} }
} else {
console.log('[WorkNoteChat] ⏳ Socket not connected yet, will request online users on connect event');
} }
// cleanup // cleanup
@ -714,7 +708,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
console.log('[WorkNoteChat] 🚪 Emitting leave:request for room (standalone mode)'); console.log('[WorkNoteChat] 🚪 Emitting leave:request for room (standalone mode)');
} }
socketRef.current = null; socketRef.current = null;
console.log('[WorkNoteChat] 🧹 Cleaned up all socket listeners and left room'); // Socket cleanup completed - logging removed
}; };
(window as any).__wn_cleanup = cleanup; (window as any).__wn_cleanup = cleanup;
} catch {} } catch {}
@ -740,11 +734,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
}) })
.filter(Boolean); .filter(Boolean);
console.log('[WorkNoteChat] 📝 MESSAGE:', message); // Message sending - logging removed
console.log('[WorkNoteChat] 👥 ALL PARTICIPANTS:', participants.map(p => ({ name: p.name, userId: (p as any)?.userId })));
console.log('[WorkNoteChat] 🎯 MENTIONS EXTRACTED:', mentions);
console.log('[WorkNoteChat] 🆔 USER IDS FOUND:', mentionedUserIds);
console.log('[WorkNoteChat] 📤 SENDING TO BACKEND:', { message, mentions: mentionedUserIds });
const attachments = selectedFiles.map(file => ({ const attachments = selectedFiles.map(file => ({
name: file.name, name: file.name,
@ -878,7 +868,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
); );
console.log('[WorkNoteChat] Mapped and sorted messages:', sorted.length, 'total'); // Messages mapped - logging removed
setMessages(sorted); setMessages(sorted);
} catch (err) { } catch (err) {
console.error('[WorkNoteChat] Error mapping messages:', err); console.error('[WorkNoteChat] Error mapping messages:', err);
@ -1158,12 +1148,21 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
const extractMentions = (text: string): string[] => { const extractMentions = (text: string): string[] => {
// Use the SAME regex pattern as formatMessage to ensure consistency // Use the SAME regex pattern as formatMessage to ensure consistency
const mentionRegex = /@([A-Za-z0-9]+(?:\s+[A-Za-z0-9]+)*?)(?=\s+(?:[a-z][a-z\s]*)?(?:[.,!?;:]|$))/g; // Only one space allowed: @word or @word word (first name + last name)
const mentionRegex = /@(\w+(?:\s+\w+)?)(?=\s|$|[.,!?;:]|@)/g;
const mentions: string[] = []; const mentions: string[] = [];
let match; let match;
while ((match = mentionRegex.exec(text)) !== null) { while ((match = mentionRegex.exec(text)) !== null) {
if (match[1]) { if (match[1]) {
mentions.push(match[1].trim()); // Check if this is a valid mention (followed by space, punctuation, @, or end)
const afterPos = match.index + match[0].length;
const afterText = text.slice(afterPos);
const afterChar = text[afterPos];
// Valid if followed by: @ (another mention), space, punctuation, or end
if (afterText.startsWith('@') || !afterChar || /\s|[.,!?;:]|@/.test(afterChar)) {
mentions.push(match[1].trim());
}
} }
} }
console.log('[Extract Mentions] Found:', mentions, 'from text:', text); console.log('[Extract Mentions] Found:', mentions, 'from text:', text);
@ -1499,34 +1498,43 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
<div className="relative mb-2"> <div className="relative mb-2">
{/* Mention Suggestions Dropdown - Shows above textarea */} {/* Mention Suggestions Dropdown - Shows above textarea */}
{(() => { {(() => {
// Find the last @ symbol that hasn't been completed (doesn't have a space after a name)
const lastAtIndex = message.lastIndexOf('@'); const lastAtIndex = message.lastIndexOf('@');
const hasAt = lastAtIndex >= 0; const hasAt = lastAtIndex >= 0;
const textAfterAt = hasAt ? message.slice(lastAtIndex + 1) : '';
// Don't show if: if (!hasAt) return null;
// 1. No @ found
// 2. Text after @ is too long (>20 chars) // Get text after the last @
// 3. Text after @ ends with a space (completed mention) const textAfterAt = message.slice(lastAtIndex + 1);
// 4. Text after @ contains a space (already selected a user)
// Check if this mention is already completed
// A completed mention looks like: "@username " (ends with space after name)
// An incomplete mention looks like: "@" or "@user" (no space after, or just typed @)
const trimmedAfterAt = textAfterAt.trim();
const endsWithSpace = textAfterAt.endsWith(' '); const endsWithSpace = textAfterAt.endsWith(' ');
const containsSpace = textAfterAt.trim().includes(' '); const hasNonSpaceChars = trimmedAfterAt.length > 0;
// Don't show dropdown if:
// 1. Text after @ is too long (>20 chars) - probably not a mention
// 2. Text after @ ends with space AND has characters (completed mention like "@user ")
// 3. Text after @ contains space in the middle (like "@user name" - multi-word name already typed)
const containsSpaceInMiddle = trimmedAfterAt.includes(' ') && !endsWithSpace;
const isCompletedMention = endsWithSpace && hasNonSpaceChars;
// Show dropdown if:
// - Has @ symbol
// - Text after @ is not too long
// - Mention is not completed (doesn't end with space after a name)
// - Doesn't contain space in middle (not a multi-word name being typed)
const shouldShowDropdown = hasAt && const shouldShowDropdown = hasAt &&
textAfterAt.length <= 20 && textAfterAt.length <= 20 &&
!endsWithSpace && !containsSpaceInMiddle &&
!containsSpace; !isCompletedMention;
console.log('[Mention Debug]', {
hasAt,
textAfterAt: `"${textAfterAt}"`,
endsWithSpace,
containsSpace,
shouldShowDropdown,
participantsCount: participants.length
});
if (!shouldShowDropdown) return null; if (!shouldShowDropdown) return null;
const searchTerm = textAfterAt.toLowerCase(); // Use trimmed text for search (ignore trailing spaces)
const searchTerm = trimmedAfterAt.toLowerCase();
const filteredParticipants = participants.filter(p => { const filteredParticipants = participants.filter(p => {
// Exclude current user from mention suggestions // Exclude current user from mention suggestions
const isCurrentUserInList = (p as any).userId === currentUserId; const isCurrentUserInList = (p as any).userId === currentUserId;
@ -1539,8 +1547,6 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
return true; // Show all if no search term return true; // Show all if no search term
}); });
console.log('[Mention Debug] Filtered participants:', filteredParticipants.length);
return ( return (
<div className="absolute bottom-full left-0 mb-2 bg-white border-2 border-blue-300 rounded-lg shadow-2xl p-3 z-[100] w-full sm:max-w-md"> <div className="absolute bottom-full left-0 mb-2 bg-white border-2 border-blue-300 rounded-lg shadow-2xl p-3 z-[100] w-full sm:max-w-md">
<p className="text-sm font-semibold text-gray-900 mb-2">💬 Mention someone</p> <p className="text-sm font-semibold text-gray-900 mb-2">💬 Mention someone</p>
@ -1553,8 +1559,10 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
// Find the last @ and replace everything from @ to end with the new mention
const lastAt = message.lastIndexOf('@'); const lastAt = message.lastIndexOf('@');
const before = message.slice(0, lastAt); const before = message.slice(0, lastAt);
// Add the mention with a space after for easy continuation
setMessage(before + '@' + participant.name + ' '); setMessage(before + '@' + participant.name + ' ');
}} }}
className="w-full flex items-center gap-3 p-3 hover:bg-blue-50 rounded-lg text-left transition-colors border border-transparent hover:border-blue-200" className="w-full flex items-center gap-3 p-3 hover:bg-blue-50 rounded-lg text-left transition-colors border border-transparent hover:border-blue-200"

View File

@ -1,9 +1,16 @@
import { useState, useEffect } from 'react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon } from 'lucide-react'; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon, FileEdit } from 'lucide-react';
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter'; import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
import { formatHoursMinutes } from '@/utils/slaTracker';
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
import { updateBreachReason as updateBreachReasonApi } from '@/services/workflowApi';
import { toast } from 'sonner';
export interface ApprovalStep { export interface ApprovalStep {
step: number; step: number;
@ -33,6 +40,7 @@ interface ApprovalStepCardProps {
isCurrentUser?: boolean; isCurrentUser?: boolean;
isInitiator?: boolean; isInitiator?: boolean;
onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void; onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void;
onRefresh?: () => void | Promise<void>; // Optional callback to refresh data
testId?: string; testId?: string;
} }
@ -40,12 +48,12 @@ interface ApprovalStepCardProps {
const formatWorkingHours = (hours: number): string => { const formatWorkingHours = (hours: number): string => {
const WORKING_HOURS_PER_DAY = 8; const WORKING_HOURS_PER_DAY = 8;
if (hours < WORKING_HOURS_PER_DAY) { if (hours < WORKING_HOURS_PER_DAY) {
return `${hours.toFixed(1)}h`; return formatHoursMinutes(hours);
} }
const days = Math.floor(hours / WORKING_HOURS_PER_DAY); const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
const remainingHours = hours % WORKING_HOURS_PER_DAY; const remainingHours = hours % WORKING_HOURS_PER_DAY;
if (remainingHours > 0) { if (remainingHours > 0) {
return `${days}d ${remainingHours.toFixed(1)}h`; return `${days}d ${formatHoursMinutes(remainingHours)}`;
} }
return `${days}d`; return `${days}d`;
}; };
@ -75,8 +83,24 @@ export function ApprovalStepCard({
isCurrentUser = false, isCurrentUser = false,
isInitiator = false, isInitiator = false,
onSkipApprover, onSkipApprover,
onRefresh,
testId = 'approval-step' testId = 'approval-step'
}: ApprovalStepCardProps) { }: ApprovalStepCardProps) {
const { user } = useAuth();
const [showBreachReasonModal, setShowBreachReasonModal] = useState(false);
const [breachReason, setBreachReason] = useState('');
const [savingReason, setSavingReason] = useState(false);
// Get existing breach reason from approval or step data
const existingBreachReason = (approval as any)?.breachReason || (step as any)?.breachReason || '';
// Reset modal state when it closes
useEffect(() => {
if (!showBreachReasonModal) {
setBreachReason('');
}
}, [showBreachReasonModal]);
const isActive = step.status === 'pending' || step.status === 'in-review'; const isActive = step.status === 'pending' || step.status === 'in-review';
const isCompleted = step.status === 'approved'; const isCompleted = step.status === 'approved';
const isRejected = step.status === 'rejected'; const isRejected = step.status === 'rejected';
@ -85,6 +109,56 @@ export function ApprovalStepCard({
const tatHours = Number(step.tatHours || 0); const tatHours = Number(step.tatHours || 0);
const actualHours = step.actualHours; const actualHours = step.actualHours;
const savedHours = isCompleted && actualHours ? Math.max(0, tatHours - actualHours) : 0; const savedHours = isCompleted && actualHours ? Math.max(0, tatHours - actualHours) : 0;
// Calculate if breached
const progressPercentage = tatHours > 0 ? (actualHours / tatHours) * 100 : 0;
const isBreached = progressPercentage >= 100;
// Check permissions: ADMIN, MANAGEMENT, or the approver
const isAdmin = user?.role === 'ADMIN';
const isManagement = hasManagementAccess(user);
const isApprover = step.approverId === user?.userId;
const canEditBreachReason = isAdmin || isManagement || isApprover;
const handleSaveBreachReason = async () => {
if (!breachReason.trim()) {
toast.error('Breach Reason Required', {
description: 'Please enter a reason for the breach.',
});
return;
}
setSavingReason(true);
try {
await updateBreachReasonApi(step.levelId, breachReason.trim());
setShowBreachReasonModal(false);
setBreachReason('');
toast.success('Breach Reason Updated', {
description: 'The breach reason has been saved and will appear in the TAT Breach Report.',
duration: 5000,
});
// Refresh data if callback provided, otherwise reload page
if (onRefresh) {
await onRefresh();
} else {
// Fallback to page reload if no refresh callback
setTimeout(() => {
window.location.reload();
}, 1000);
}
} catch (error: any) {
console.error('Error updating breach reason:', error);
const errorMessage = error?.response?.data?.error || error?.message || 'Failed to update breach reason. Please try again.';
toast.error('Failed to Update Breach Reason', {
description: errorMessage,
duration: 5000,
});
} finally {
setSavingReason(false);
}
};
return ( return (
<div <div
@ -189,21 +263,45 @@ export function ApprovalStepCard({
{(() => { {(() => {
// Calculate actual progress percentage based on time used // Calculate actual progress percentage based on time used
// If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069% // If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069%
const progressPercentage = tatHours > 0 ? Math.min(100, (actualHours / tatHours) * 100) : 0; const displayPercentage = Math.min(100, progressPercentage);
return ( return (
<> <>
<Progress <Progress
value={progressPercentage} value={displayPercentage}
className="h-2 bg-gray-200" className={`h-2 bg-gray-200 ${isBreached ? '[&>div]:bg-red-600' : '[&>div]:bg-green-600'}`}
data-testid={`${testId}-progress-bar`} data-testid={`${testId}-progress-bar`}
/> />
<div className="flex items-center justify-between text-xs"> <div className="flex items-center justify-between text-xs">
<span className="text-green-600 font-semibold"> <div className="flex items-center gap-2">
{progressPercentage.toFixed(1)}% of TAT used <span className={`font-semibold ${isBreached ? 'text-red-600' : 'text-green-600'}`}>
</span> {Math.round(displayPercentage)}% of TAT used
</span>
{isBreached && canEditBreachReason && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => {
setBreachReason(existingBreachReason);
setShowBreachReasonModal(true);
}}
>
<FileEdit className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
{savedHours > 0 && ( {savedHours > 0 && (
<span className="text-green-600 font-semibold">Saved {savedHours.toFixed(1)} hours</span> <span className="text-green-600 font-semibold">Saved {formatHoursMinutes(savedHours)}</span>
)} )}
</div> </div>
</> </>
@ -211,6 +309,17 @@ export function ApprovalStepCard({
})()} })()}
</div> </div>
{/* Breach Reason Display for Completed Approver */}
{isBreached && existingBreachReason && (
<div className="mt-4 p-3 sm:p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
<p className="text-xs font-semibold text-red-700 mb-2 flex items-center gap-1.5">
<FileEdit className="w-3.5 h-3.5" />
Breach Reason:
</p>
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">{existingBreachReason}</p>
</div>
)}
{/* Conclusion Remark */} {/* Conclusion Remark */}
{step.comment && ( {step.comment && (
<div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg"> <div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
@ -252,7 +361,7 @@ export function ApprovalStepCard({
</div> </div>
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-600">Time used:</span> <span className="text-gray-600">Time used:</span>
<span className="font-medium text-gray-900">{approval.sla.elapsedText} / {tatHours}h allocated</span> <span className="font-medium text-gray-900">{approval.sla.elapsedText} / {formatHoursMinutes(tatHours)} allocated</span>
</div> </div>
</div> </div>
@ -268,22 +377,57 @@ export function ApprovalStepCard({
data-testid={`${testId}-sla-progress`} data-testid={`${testId}-sla-progress`}
/> />
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className={`text-xs font-semibold ${ <div className="flex items-center gap-2">
approval.sla.status === 'breached' ? 'text-red-600' : <span className={`text-xs font-semibold ${
approval.sla.status === 'critical' ? 'text-orange-600' : approval.sla.status === 'breached' ? 'text-red-600' :
'text-yellow-700' approval.sla.status === 'critical' ? 'text-orange-600' :
}`}> 'text-yellow-700'
Progress: {approval.sla.percentageUsed}% of TAT used }`}>
</span> Progress: {Math.min(100, approval.sla.percentageUsed)}% of TAT used
</span>
{approval.sla.status === 'breached' && canEditBreachReason && (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-5 w-5 p-0 text-red-600 hover:text-red-700 hover:bg-red-50"
onClick={() => {
setBreachReason(existingBreachReason);
setShowBreachReasonModal(true);
}}
>
<FileEdit className="w-3 h-3" />
</Button>
</TooltipTrigger>
<TooltipContent>
<p>{existingBreachReason ? 'Edit breach reason' : 'Add breach reason'}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
<span className="text-xs font-medium text-gray-700"> <span className="text-xs font-medium text-gray-700">
{approval.sla.remainingText} remaining {approval.sla.remainingText} remaining
</span> </span>
</div> </div>
{approval.sla.status === 'breached' && ( {approval.sla.status === 'breached' && (
<p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5"> <>
<AlertOctagon className="w-4 h-4" /> <p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
Deadline Breached <AlertOctagon className="w-4 h-4" />
</p> Deadline Breached
</p>
{existingBreachReason && (
<div className="mt-3 p-3 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
<p className="text-xs font-semibold text-red-700 mb-2 flex items-center gap-1.5">
<FileEdit className="w-3.5 h-3.5" />
Breach Reason:
</p>
<p className="text-sm text-gray-700 leading-relaxed whitespace-pre-line">{existingBreachReason}</p>
</div>
)}
</>
)} )}
{approval.sla.status === 'critical' && ( {approval.sla.status === 'critical' && (
<p className="text-xs font-semibold text-center text-orange-600 flex items-center justify-center gap-1.5"> <p className="text-xs font-semibold text-center text-orange-600 flex items-center justify-center gap-1.5">
@ -480,6 +624,51 @@ export function ApprovalStepCard({
)} )}
</div> </div>
</div> </div>
{/* Breach Reason Modal */}
<Dialog open={showBreachReasonModal} onOpenChange={setShowBreachReasonModal}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>{existingBreachReason ? 'Edit Breach Reason' : 'Add Breach Reason'}</DialogTitle>
<DialogDescription>
{existingBreachReason
? 'Update the reason for the TAT breach. This will be reflected in the TAT Breach Report.'
: 'Please provide a reason for the TAT breach. This will be reflected in the TAT Breach Report.'}
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Textarea
placeholder="Enter the reason for the breach..."
value={breachReason}
onChange={(e) => setBreachReason(e.target.value)}
className="min-h-[100px]"
maxLength={500}
/>
<p className="text-xs text-gray-500 mt-2">
{breachReason.length}/500 characters
</p>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowBreachReasonModal(false);
setBreachReason('');
}}
disabled={savingReason}
>
Cancel
</Button>
<Button
onClick={handleSaveBreachReason}
disabled={!breachReason.trim() || savingReason}
className="bg-red-600 hover:bg-red-700"
>
{savingReason ? 'Saving...' : 'Save Reason'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -96,7 +96,7 @@ export function useRequestDetails(
// Debug: Log TAT alerts for monitoring // Debug: Log TAT alerts for monitoring
if (tatAlerts.length > 0) { if (tatAlerts.length > 0) {
console.log(`[useRequestDetails] Found ${tatAlerts.length} TAT alerts:`, tatAlerts); // TAT alerts loaded - logging removed
} }
const currentLevel = summary?.currentLevel || wf.currentLevel || 1; const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
@ -302,7 +302,7 @@ export function useRequestDetails(
const summary = details.summary || {}; const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : []; const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
console.log('[useRequestDetails] TAT Alerts received:', tatAlerts.length, tatAlerts); // TAT alerts received - logging removed
const priority = (wf.priority || '').toString().toLowerCase(); const priority = (wf.priority || '').toString().toLowerCase();
const currentLevel = summary?.currentLevel || wf.currentLevel || 1; const currentLevel = summary?.currentLevel || wf.currentLevel || 1;

View File

@ -53,7 +53,7 @@ export function useRequestSocket(
return; return;
} }
console.log('[useRequestSocket] Initializing socket connection for:', requestIdentifier); // Socket connection initialized - logging removed
let mounted = true; let mounted = true;
let actualRequestId = requestIdentifier; let actualRequestId = requestIdentifier;
@ -64,7 +64,7 @@ export function useRequestSocket(
const details = await workflowApi.getWorkflowDetails(requestIdentifier); const details = await workflowApi.getWorkflowDetails(requestIdentifier);
if (details?.workflow?.requestId && mounted) { if (details?.workflow?.requestId && mounted) {
actualRequestId = details.workflow.requestId; actualRequestId = details.workflow.requestId;
console.log('[useRequestSocket] Resolved UUID:', actualRequestId); // UUID resolved - logging removed
} }
} catch (error) { } catch (error) {
console.error('[useRequestSocket] Failed to resolve UUID:', error); console.error('[useRequestSocket] Failed to resolve UUID:', error);
@ -87,9 +87,8 @@ export function useRequestSocket(
* This makes the user "online" for this specific request * This makes the user "online" for this specific request
*/ */
const handleConnect = () => { const handleConnect = () => {
console.log('[useRequestSocket] Socket connected, joining room:', actualRequestId); // Socket connected - joining room
joinRequestRoom(socket, actualRequestId, userId); joinRequestRoom(socket, actualRequestId, userId);
console.log(`[useRequestSocket] ✅ Joined room: ${actualRequestId} - User is ONLINE`);
}; };
// Join immediately if already connected, otherwise wait for connect event // Join immediately if already connected, otherwise wait for connect event
@ -107,7 +106,7 @@ export function useRequestSocket(
if (mounted) { if (mounted) {
socket.off('connect', handleConnect); socket.off('connect', handleConnect);
leaveRequestRoom(socket, actualRequestId); leaveRequestRoom(socket, actualRequestId);
console.log(`[useRequestSocket] ✅ Left room: ${actualRequestId} - User is OFFLINE`); // Left room - logging removed
} }
}; };
})(); })();
@ -141,7 +140,7 @@ export function useRequestSocket(
}); });
setMergedMessages(merged); setMergedMessages(merged);
console.log(`[useRequestSocket] Merged ${workNotes.length} work notes with ${activities.length} activities`); // Messages merged - logging removed
} catch (error) { } catch (error) {
console.error('[useRequestSocket] Failed to fetch and merge messages:', error); console.error('[useRequestSocket] Failed to fetch and merge messages:', error);
} }
@ -171,7 +170,7 @@ export function useRequestSocket(
* 2. Refresh merged messages to show new note * 2. Refresh merged messages to show new note
*/ */
const handleNewWorkNote = (data: any) => { const handleNewWorkNote = (data: any) => {
console.log(`[useRequestSocket] 🆕 New work note received:`, data); // New work note received - logging removed
// Update unread badge (only if not viewing work notes) // Update unread badge (only if not viewing work notes)
if (activeTab !== 'worknotes') { if (activeTab !== 'worknotes') {
@ -209,11 +208,9 @@ export function useRequestSocket(
* 3. Show browser notification if permission granted * 3. Show browser notification if permission granted
*/ */
const handleTatAlert = (data: any) => { const handleTatAlert = (data: any) => {
console.log(`[useRequestSocket] 🔔 Real-time TAT alert received:`, data); // TAT alert received - single line log only
// Visual feedback in console with emoji
const alertEmoji = data.type === 'breach' ? '⏰' : data.type === 'threshold2' ? '⚠️' : '⏳'; const alertEmoji = data.type === 'breach' ? '⏰' : data.type === 'threshold2' ? '⚠️' : '⏳';
console.log(`%c${alertEmoji} TAT Alert: ${data.message}`, 'color: #ff6600; font-size: 14px; font-weight: bold;'); console.log(`TAT Alert: ${data.message}`);
// Refresh: Get updated TAT alerts from backend // Refresh: Get updated TAT alerts from backend
(async () => { (async () => {

View File

@ -0,0 +1,994 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Button } from '@/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import {
FileText,
Search,
Clock,
CheckCircle,
XCircle,
User,
ArrowLeft,
ArrowRight,
TrendingUp,
Target,
AlertCircle,
Calendar as CalendarIcon,
RefreshCw,
Users,
BarChart3,
Timer,
Award
} from 'lucide-react';
import { format } from 'date-fns';
import dayjs from 'dayjs';
import isSameOrAfter from 'dayjs/plugin/isSameOrAfter';
import isSameOrBefore from 'dayjs/plugin/isSameOrBefore';
dayjs.extend(isSameOrAfter);
dayjs.extend(isSameOrBefore);
import { Pagination } from '@/components/common/Pagination';
import type { DateRange } from '@/services/dashboard.service';
import dashboardService, { type ApproverPerformance } from '@/services/dashboard.service';
import { formatHoursMinutes } from '@/utils/slaTracker';
const getPriorityConfig = (priority: string) => {
switch (priority) {
case 'express':
return {
color: 'bg-red-100 text-red-800 border-red-200',
icon: TrendingUp,
iconColor: 'text-red-600'
};
case 'standard':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
icon: Target,
iconColor: 'text-blue-600'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: Target,
iconColor: 'text-gray-600'
};
}
};
const getStatusConfig = (status: string) => {
switch (status) {
case 'approved':
return {
color: 'bg-green-100 text-green-800 border-green-200',
icon: CheckCircle,
iconColor: 'text-green-600'
};
case 'rejected':
return {
color: 'bg-red-100 text-red-800 border-red-200',
icon: XCircle,
iconColor: 'text-red-600'
};
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
icon: Clock,
iconColor: 'text-yellow-600'
};
case 'in-progress':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
icon: Clock,
iconColor: 'text-blue-600'
};
case 'closed':
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: CheckCircle,
iconColor: 'text-gray-600'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: FileText,
iconColor: 'text-gray-600'
};
}
};
const getSLAConfig = (status: string) => {
switch (status) {
case 'breached':
return {
color: 'bg-red-100 text-red-800 border-red-200',
label: 'Breached'
};
case 'critical':
return {
color: 'bg-orange-100 text-orange-800 border-orange-200',
label: 'Critical'
};
case 'approaching':
return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
label: 'Approaching'
};
case 'on_track':
case 'on-track':
return {
color: 'bg-green-100 text-green-800 border-green-200',
label: 'On Track'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'N/A'
};
}
};
export function ApproverPerformance() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// Get approver ID and name from URL params
const approverId = searchParams.get('approverId') || '';
const approverName = searchParams.get('approverName') || 'Unknown Approver';
// Filter states
const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState<string>(searchParams.get('status') || 'all');
const [priorityFilter, setPriorityFilter] = useState<string>(searchParams.get('priority') || 'all');
const [slaComplianceFilter, setSlaComplianceFilter] = useState<string>(searchParams.get('slaCompliance') || 'all');
const [dateRange, setDateRange] = useState<DateRange>((searchParams.get('dateRange') as DateRange) || 'month');
const [customStartDate, setCustomStartDate] = useState<Date | undefined>(
searchParams.get('startDate') ? new Date(searchParams.get('startDate')!) : undefined
);
const [customEndDate, setCustomEndDate] = useState<Date | undefined>(
searchParams.get('endDate') ? new Date(searchParams.get('endDate')!) : undefined
);
const [showCustomDatePicker, setShowCustomDatePicker] = useState(false);
const [tempCustomStartDate, setTempCustomStartDate] = useState<Date | undefined>(undefined);
const [tempCustomEndDate, setTempCustomEndDate] = useState<Date | undefined>(undefined);
// Data states
const [requests, setRequests] = useState<any[]>([]);
const [approverStats, setApproverStats] = useState<ApproverPerformance | null>(null);
const [allFilteredRequests, setAllFilteredRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [totalRecords, setTotalRecords] = useState(0);
const itemsPerPage = 10;
// Fetch approver performance stats
const fetchApproverStats = useCallback(async () => {
if (!approverId) return;
try {
const params: any = { dateRange, page: 1, limit: 100 }; // Get all to find this approver
if (dateRange === 'custom' && customStartDate && customEndDate) {
params.startDate = customStartDate;
params.endDate = customEndDate;
}
const result = await dashboardService.getApproverPerformance(
dateRange,
1,
100,
customStartDate,
customEndDate
);
const approver = result.performance.find((p: ApproverPerformance) => p.approverId === approverId);
if (approver) {
setApproverStats(approver);
}
} catch (error) {
console.error('Failed to fetch approver stats:', error);
}
}, [approverId, dateRange, customStartDate, customEndDate]);
// Fetch requests for this approver - server-side pagination
const fetchRequests = useCallback(async (page: number = 1) => {
if (!approverId) {
setLoading(false);
return;
}
try {
setLoading(true);
// Fetch current page only (server-side pagination)
const result = await dashboardService.getRequestsByApprover(
approverId,
page,
itemsPerPage, // Use itemsPerPage (10) for server-side pagination
dateRange,
customStartDate,
customEndDate,
statusFilter !== 'all' ? statusFilter : undefined,
priorityFilter !== 'all' ? priorityFilter : undefined,
slaComplianceFilter !== 'all' ? slaComplianceFilter : undefined,
searchTerm || undefined
);
setRequests(result.requests);
setTotalRecords(result.pagination.totalRecords);
setTotalPages(result.pagination.totalPages);
setCurrentPage(page);
// For stats calculation, fetch ALL data (without pagination)
// This ensures stats are calculated based on all filtered data
const statsResult = await dashboardService.getRequestsByApprover(
approverId,
1,
10000, // Fetch all records for stats calculation
dateRange,
customStartDate,
customEndDate,
statusFilter !== 'all' ? statusFilter : undefined,
priorityFilter !== 'all' ? priorityFilter : undefined,
slaComplianceFilter !== 'all' ? slaComplianceFilter : undefined,
searchTerm || undefined
);
// Store all filtered requests for stats calculation
setAllFilteredRequests(statsResult.requests);
} catch (error) {
console.error('Failed to fetch requests:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, [approverId, dateRange, customStartDate, customEndDate, statusFilter, priorityFilter, slaComplianceFilter, searchTerm, itemsPerPage]);
// Calculate stats from filtered data - based on approver's actions
const calculatedStats = useMemo(() => {
const filtered = allFilteredRequests;
// Count by approver's approval status (what the approver actually did)
const approvedByApprover = filtered.filter(r => {
const status = (r.approvalStatus || '').toLowerCase();
return status === 'approved';
}).length;
const rejectedByApprover = filtered.filter(r => {
const status = (r.approvalStatus || '').toLowerCase();
return status === 'rejected';
}).length;
const pendingByApprover = filtered.filter(r => {
const status = (r.approvalStatus || '').toLowerCase();
return ['pending', 'in_progress', 'in-progress'].includes(status);
}).length;
// TAT compliance stats - check isBreached flag OR slaStatus === 'breached'
// This includes pending requests that have breached their TAT
const breached = filtered.filter(r => {
const isBreached = r.isBreached === true || r.isBreached === 1;
const slaStatusBreached = (r.slaStatus || '').toLowerCase() === 'breached';
return isBreached || slaStatusBreached;
}).length;
// Compliant: completed actions (approved/rejected) that are NOT breached
// OR pending requests that haven't breached yet
const compliant = filtered.filter(r => {
const status = (r.approvalStatus || '').toLowerCase();
const isCompleted = status === 'approved' || status === 'rejected';
const isPending = ['pending', 'in_progress', 'in-progress'].includes(status);
const isNotBreached = !(r.isBreached === true || r.isBreached === 1) &&
(r.slaStatus || '').toLowerCase() !== 'breached';
// Completed and not breached = compliant
// Pending and not breached = also counts as compliant (hasn't breached yet)
return (isCompleted && isNotBreached) || (isPending && isNotBreached);
}).length;
// Calculate approval rate (based on completed actions)
const completedActions = approvedByApprover + rejectedByApprover;
const approvalRate = completedActions > 0
? Math.round((approvedByApprover / completedActions) * 100)
: 0;
// Calculate rejection rate
const rejectionRate = completedActions > 0
? Math.round((rejectedByApprover / completedActions) * 100)
: 0;
// Calculate TAT compliance rate
// Use total requests (not just completed) to include pending requests in compliance calculation
// Use Math.floor to ensure consistent rounding with dashboard (prevents 79.5% vs 80% discrepancy)
const totalForCompliance = filtered.length;
const tatComplianceRate = totalForCompliance > 0
? Math.floor((compliant / totalForCompliance) * 100)
: 0;
return {
total: filtered.length,
// Approver's actions
approvedByApprover,
rejectedByApprover,
pendingByApprover,
// TAT stats
breached,
compliant,
// Rates
approvalRate,
rejectionRate,
tatComplianceRate,
completedActions
};
}, [allFilteredRequests]);
useEffect(() => {
fetchApproverStats();
}, [fetchApproverStats]);
useEffect(() => {
fetchRequests(1);
}, [fetchRequests]);
const handleClearFilters = () => {
setSearchTerm('');
setStatusFilter('all');
setPriorityFilter('all');
setSlaComplianceFilter('all');
setDateRange('month');
setCustomStartDate(undefined);
setCustomEndDate(undefined);
setTempCustomStartDate(undefined);
setTempCustomEndDate(undefined);
setShowCustomDatePicker(false);
};
const handleDateRangeChange = (value: string) => {
const newRange = value as DateRange;
setDateRange(newRange);
if (newRange !== 'custom') {
setCustomStartDate(undefined);
setCustomEndDate(undefined);
setTempCustomStartDate(undefined);
setTempCustomEndDate(undefined);
setShowCustomDatePicker(false);
} else {
setTempCustomStartDate(customStartDate);
setTempCustomEndDate(customEndDate);
setShowCustomDatePicker(true);
}
};
const handleApplyCustomDate = () => {
if (tempCustomStartDate && tempCustomEndDate) {
if (tempCustomStartDate > tempCustomEndDate) {
const temp = tempCustomStartDate;
setCustomStartDate(tempCustomEndDate);
setCustomEndDate(temp);
setTempCustomStartDate(tempCustomEndDate);
setTempCustomEndDate(temp);
} else {
setCustomStartDate(tempCustomStartDate);
setCustomEndDate(tempCustomEndDate);
}
setShowCustomDatePicker(false);
}
};
const handleRefresh = () => {
setRefreshing(true);
fetchApproverStats();
fetchRequests(1);
};
const handlePageChange = (page: number) => {
setCurrentPage(page);
// Since we have all data in memory, just update the paginated slice
const startIdx = (page - 1) * itemsPerPage;
const endIdx = startIdx + itemsPerPage;
const paginatedRequests = allFilteredRequests.slice(startIdx, endIdx);
setRequests(paginatedRequests);
};
// Update paginated data when allFilteredRequests changes
useEffect(() => {
const startIdx = (currentPage - 1) * itemsPerPage;
const endIdx = startIdx + itemsPerPage;
const paginatedRequests = allFilteredRequests.slice(startIdx, endIdx);
setRequests(paginatedRequests);
setTotalPages(Math.ceil(allFilteredRequests.length / itemsPerPage));
if (currentPage > Math.ceil(allFilteredRequests.length / itemsPerPage) && allFilteredRequests.length > 0) {
setCurrentPage(1);
}
}, [allFilteredRequests, currentPage]);
const formatDate = (date: string | Date | null | undefined): string => {
if (!date) return 'N/A';
try {
return format(new Date(date), 'MMM d, yyyy');
} catch {
return 'N/A';
}
};
const formatDateTime = (date: string | Date | null | undefined): string => {
if (!date) return 'N/A';
try {
return format(new Date(date), 'MMM d, yyyy HH:mm');
} catch {
return 'N/A';
}
};
if (!approverId) {
return (
<div className="flex-1 p-3 lg:p-6 overflow-auto min-w-0">
<div className="max-w-7xl mx-auto p-4">
<Card>
<CardContent className="p-8 text-center">
<AlertCircle className="w-12 h-12 text-yellow-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">Approver ID Required</h2>
<p className="text-gray-600">Please select an approver to view their performance details.</p>
<Button onClick={() => navigate(-1)} className="mt-4">
<ArrowLeft className="w-4 h-4 mr-2" />
Back
</Button>
</CardContent>
</Card>
</div>
</div>
);
}
return (
<div className="flex-1 p-3 lg:p-6 overflow-auto min-w-0">
<div className="max-w-7xl mx-auto p-4 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button
variant="outline"
onClick={() => navigate(-1)}
className="inline-flex items-center gap-2"
>
<ArrowLeft className="w-4 h-4" />
Back
</Button>
<div className="flex items-center gap-3">
<div className="p-3 bg-yellow-100 rounded-lg">
<Users className="h-6 w-6 text-yellow-600" />
</div>
<div>
<h1 className="text-2xl font-bold text-gray-900">Approver Performance Report</h1>
<p className="text-sm text-gray-600">{approverName}</p>
</div>
</div>
</div>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={refreshing}
className="gap-2"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Approver Performance Overview Cards */}
{approverStats && (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-gray-600">TAT Compliance</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-3xl font-bold text-gray-900">
{approverStats?.tatCompliancePercent ?? calculatedStats.tatComplianceRate}%
</div>
<div className={`p-2 rounded-lg ${
(approverStats?.tatCompliancePercent ?? calculatedStats.tatComplianceRate) >= 95 ? 'bg-green-100' :
(approverStats?.tatCompliancePercent ?? calculatedStats.tatComplianceRate) >= 90 ? 'bg-blue-100' :
(approverStats?.tatCompliancePercent ?? calculatedStats.tatComplianceRate) >= 85 ? 'bg-orange-100' : 'bg-red-100'
}`}>
<Target className={`w-5 h-5 ${
(approverStats?.tatCompliancePercent ?? calculatedStats.tatComplianceRate) >= 95 ? 'text-green-600' :
(approverStats?.tatCompliancePercent ?? calculatedStats.tatComplianceRate) >= 90 ? 'text-blue-600' :
(approverStats?.tatCompliancePercent ?? calculatedStats.tatComplianceRate) >= 85 ? 'text-orange-600' : 'text-red-600'
}`} />
</div>
</div>
<Progress
value={approverStats?.tatCompliancePercent ?? calculatedStats.tatComplianceRate}
className="mt-2 h-2"
/>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-gray-600">Total Approved</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-3xl font-bold text-gray-900">
{approverStats.totalApproved}
</div>
<div className="p-2 bg-blue-100 rounded-lg">
<CheckCircle className="w-5 h-5 text-blue-600" />
</div>
</div>
<p className="text-xs text-gray-500 mt-1">Requests handled</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-gray-600">Avg Response Time</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-3xl font-bold text-gray-900">
{approverStats.avgResponseHours.toFixed(1)}h
</div>
<div className="p-2 bg-purple-100 rounded-lg">
<Timer className="w-5 h-5 text-purple-600" />
</div>
</div>
<p className="text-xs text-gray-500 mt-1">
{approverStats.avgResponseHours < 24
? `${(approverStats.avgResponseHours / 8).toFixed(1)} working days`
: `${(approverStats.avgResponseHours / 24).toFixed(1)} days`
}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium text-gray-600">Pending Actions</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<div className="text-3xl font-bold text-gray-900">
{approverStats.pendingCount}
</div>
<div className="p-2 bg-orange-100 rounded-lg">
<Clock className="w-5 h-5 text-orange-600" />
</div>
</div>
<p className="text-xs text-gray-500 mt-1">Awaiting approval</p>
</CardContent>
</Card>
</div>
)}
{/* Filtered Request Stats - Approver's Actions */}
<Card>
<CardHeader>
<CardTitle>Approver's Actions (Filtered)</CardTitle>
<CardDescription>
Statistics based on {approverName}'s actions with current filters applied
</CardDescription>
</CardHeader>
<CardContent>
{/* Approver's Actions - What the approver actually did */}
<div className="mb-6">
<h4 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Users className="w-4 h-4" />
Approver's Actions
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-3">
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center justify-between mb-2">
<CheckCircle className="w-5 h-5 text-green-600" />
<span className="text-xs text-green-600 font-medium">
{calculatedStats.completedActions > 0 ? `${calculatedStats.approvalRate}%` : '0%'}
</span>
</div>
<div className="text-2xl font-bold text-green-700">{calculatedStats.approvedByApprover}</div>
<div className="text-xs text-gray-600 mt-1">Approved by Approver</div>
</div>
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
<div className="flex items-center justify-between mb-2">
<XCircle className="w-5 h-5 text-red-600" />
<span className="text-xs text-red-600 font-medium">
{calculatedStats.completedActions > 0 ? `${calculatedStats.rejectionRate}%` : '0%'}
</span>
</div>
<div className="text-2xl font-bold text-red-700">{calculatedStats.rejectedByApprover}</div>
<div className="text-xs text-gray-600 mt-1">Rejected by Approver</div>
</div>
<div className="p-4 bg-yellow-50 rounded-lg border border-yellow-200">
<div className="flex items-center justify-between mb-2">
<Clock className="w-5 h-5 text-yellow-600" />
</div>
<div className="text-2xl font-bold text-yellow-700">{calculatedStats.pendingByApprover}</div>
<div className="text-xs text-gray-600 mt-1">Pending Actions</div>
</div>
<div className="p-4 bg-blue-50 rounded-lg border border-blue-200">
<div className="flex items-center justify-between mb-2">
<FileText className="w-5 h-5 text-blue-600" />
</div>
<div className="text-2xl font-bold text-blue-700">{calculatedStats.total}</div>
<div className="text-xs text-gray-600 mt-1">Total Requests</div>
</div>
</div>
</div>
{/* TAT Compliance Stats */}
<div className="mb-6 pt-4 border-t">
<h4 className="text-sm font-semibold text-gray-700 mb-3 flex items-center gap-2">
<Target className="w-4 h-4" />
TAT Compliance
</h4>
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
<div className="p-4 bg-green-50 rounded-lg border border-green-200">
<div className="flex items-center justify-between mb-2">
<Award className="w-5 h-5 text-green-600" />
<span className="text-xs text-green-600 font-medium">
{approverStats?.tatCompliancePercent !== undefined ? `${approverStats.tatCompliancePercent}%` : (calculatedStats.completedActions > 0 ? `${calculatedStats.tatComplianceRate}%` : 'N/A')}
</span>
</div>
<div className="text-2xl font-bold text-green-700">{calculatedStats.compliant}</div>
<div className="text-xs text-gray-600 mt-1">TAT Compliant</div>
</div>
<div className="p-4 bg-red-50 rounded-lg border border-red-200">
<div className="flex items-center justify-between mb-2">
<AlertCircle className="w-5 h-5 text-red-600" />
</div>
<div className="text-2xl font-bold text-red-700">{calculatedStats.breached}</div>
<div className="text-xs text-gray-600 mt-1">TAT Breached</div>
</div>
<div className="p-4 bg-purple-50 rounded-lg border border-purple-200">
<div className="flex items-center justify-between mb-2">
<BarChart3 className="w-5 h-5 text-purple-600" />
</div>
<div className="text-2xl font-bold text-purple-700">{calculatedStats.completedActions}</div>
<div className="text-xs text-gray-600 mt-1">Completed Actions</div>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Filters */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Filters</CardTitle>
<Button
variant="ghost"
size="sm"
onClick={handleClearFilters}
className="text-xs"
>
Clear All
</Button>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-6 gap-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="Search requests..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
{/* Status Filter */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger>
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Status</SelectItem>
<SelectItem value="pending">Pending</SelectItem>
<SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent>
</Select>
{/* Priority Filter */}
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger>
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Priority</SelectItem>
<SelectItem value="express">Express</SelectItem>
<SelectItem value="standard">Standard</SelectItem>
</SelectContent>
</Select>
{/* SLA Compliance Filter */}
<Select value={slaComplianceFilter} onValueChange={setSlaComplianceFilter}>
<SelectTrigger>
<SelectValue placeholder="SLA Compliance" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All SLA</SelectItem>
<SelectItem value="compliant">Compliant</SelectItem>
<SelectItem value="on-track">On Track</SelectItem>
<SelectItem value="approaching">Approaching</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="breached">Breached</SelectItem>
</SelectContent>
</Select>
{/* Date Range Filter */}
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={dateRange} onValueChange={handleDateRangeChange}>
<SelectTrigger className="flex-1">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{/* Custom Date Range Picker */}
{dateRange === 'custom' && (
<Popover open={showCustomDatePicker} onOpenChange={setShowCustomDatePicker}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{tempCustomStartDate && tempCustomEndDate
? `${format(tempCustomStartDate, 'MMM d')} - ${format(tempCustomEndDate, 'MMM d')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start" sideOffset={8}>
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="start-date" className="text-sm font-medium">Start Date</Label>
<Input
id="start-date"
type="date"
value={tempCustomStartDate ? format(tempCustomStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
setTempCustomStartDate(date);
if (tempCustomEndDate && date > tempCustomEndDate) {
setTempCustomEndDate(date);
}
} else {
setTempCustomStartDate(undefined);
}
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="end-date" className="text-sm font-medium">End Date</Label>
<Input
id="end-date"
type="date"
value={tempCustomEndDate ? format(tempCustomEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
setTempCustomEndDate(date);
if (tempCustomStartDate && date < tempCustomStartDate) {
setTempCustomStartDate(date);
}
} else {
setTempCustomEndDate(undefined);
}
}}
min={tempCustomStartDate ? format(tempCustomStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={handleApplyCustomDate}
disabled={!tempCustomStartDate || !tempCustomEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowCustomDatePicker(false);
setTempCustomStartDate(customStartDate);
setTempCustomEndDate(customEndDate);
if (!customStartDate || !customEndDate) {
setCustomStartDate(undefined);
setCustomEndDate(undefined);
setDateRange('month');
}
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
</CardContent>
</Card>
{/* Requests List */}
<Card>
<CardHeader>
<CardTitle>Request Details</CardTitle>
<CardDescription>
All requests handled by {approverName} with applied filters
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex items-center justify-center py-12">
<RefreshCw className="w-6 h-6 animate-spin text-gray-400" />
<span className="ml-2 text-sm text-gray-600">Loading requests...</span>
</div>
) : requests.length === 0 ? (
<div className="text-center py-12 text-gray-500">
<FileText className="w-12 h-12 mx-auto mb-4 text-gray-400" />
<p className="text-sm">No requests found for this approver</p>
</div>
) : (
<div className="space-y-4">
{requests.map((request) => {
const priorityConfig = getPriorityConfig(request.priority);
const statusConfig = getStatusConfig(request.status);
const slaConfig = getSLAConfig(request.slaStatus || '');
return (
<Card
key={request.requestId}
className="hover:shadow-md transition-shadow cursor-pointer"
onClick={() => navigate(`/request/${request.requestId}`)}
>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-3 mb-2 flex-wrap">
<span className="font-semibold text-sm text-blue-600 hover:underline">
{request.requestNumber}
</span>
<Badge className={priorityConfig.color}>
<priorityConfig.icon className={`w-3 h-3 mr-1 ${priorityConfig.iconColor}`} />
{request.priority}
</Badge>
<Badge className={statusConfig.color}>
<statusConfig.icon className={`w-3 h-3 mr-1 ${statusConfig.iconColor}`} />
{request.status}
</Badge>
{/* Show approver's action status */}
{request.approvalStatus && (
<Badge className={
request.approvalStatus === 'approved' || request.approvalStatus === 'APPROVED'
? 'bg-green-100 text-green-800 border-green-200'
: request.approvalStatus === 'rejected' || request.approvalStatus === 'REJECTED'
? 'bg-red-100 text-red-800 border-red-200'
: 'bg-yellow-100 text-yellow-800 border-yellow-200'
}>
{request.approvalStatus === 'approved' || request.approvalStatus === 'APPROVED' ? (
<>
<CheckCircle className="w-3 h-3 mr-1" />
Approved
</>
) : request.approvalStatus === 'rejected' || request.approvalStatus === 'REJECTED' ? (
<>
<XCircle className="w-3 h-3 mr-1" />
Rejected
</>
) : (
<>
<Clock className="w-3 h-3 mr-1" />
Pending
</>
)}
</Badge>
)}
{request.slaStatus && (
<Badge className={slaConfig.color}>
{slaConfig.label}
</Badge>
)}
</div>
<h3 className="font-medium text-gray-900 mb-1 truncate">{request.title}</h3>
<div className="flex flex-wrap items-center gap-4 text-xs text-gray-500 mt-2">
<span className="flex items-center gap-1">
<User className="w-3 h-3" />
{request.initiatorName}
{request.initiatorDepartment && (
<span className="ml-1">({request.initiatorDepartment})</span>
)}
</span>
<span className="flex items-center gap-1">
<Clock className="w-3 h-3" />
Submitted: {formatDate(request.submissionDate)}
</span>
{request.approvalActionDate && (
<span className="flex items-center gap-1">
<CheckCircle className="w-3 h-3" />
Action: {formatDateTime(request.approvalActionDate)}
</span>
)}
<span className="flex items-center gap-1">
<Target className="w-3 h-3" />
Level {request.levelNumber} of {request.totalLevels}
</span>
{request.levelElapsedHours > 0 && (
<span className="flex items-center gap-1">
<Timer className="w-3 h-3" />
{formatHoursMinutes(request.levelElapsedHours)} / {formatHoursMinutes(request.levelTatHours)} TAT
</span>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
navigate(`/request/${request.requestId}`);
}}
>
<ArrowRight className="w-4 h-4" />
</Button>
</div>
</CardContent>
</Card>
);
})}
</div>
)}
{/* Pagination */}
{totalPages > 0 && (
<div className="mt-6">
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalRecords={totalRecords}
itemsPerPage={itemsPerPage}
onPageChange={handlePageChange}
itemLabel="requests"
testIdPrefix="approver-performance"
/>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -107,6 +107,13 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
const [totalRecords, setTotalRecords] = useState(0); const [totalRecords, setTotalRecords] = useState(0);
const [itemsPerPage] = useState(10); const [itemsPerPage] = useState(10);
// Fetch closed requests for the current user only (user-scoped, not organization-wide)
// Note: This endpoint returns only requests where the user is:
// - An approver (for APPROVED, REJECTED, CLOSED requests)
// - A spectator (for APPROVED, REJECTED, CLOSED requests)
// - An initiator (for REJECTED or CLOSED requests only, not APPROVED - those are in Open Requests)
// This applies to ALL users including regular users, MANAGEMENT, and ADMIN roles
// For organization-wide view, users should use the "All Requests" screen (/requests)
const fetchRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => { const fetchRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
try { try {
if (page === 1) { if (page === 1) {
@ -116,6 +123,9 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
console.log('[ClosedRequests] Fetching with filters:', { page, filters }); // Debug log console.log('[ClosedRequests] Fetching with filters:', { page, filters }); // Debug log
// Always use user-scoped endpoint (not organization-wide)
// Backend filters by userId regardless of user role (ADMIN/MANAGEMENT/regular user)
// For organization-wide requests, use the "All Requests" screen (/requests)
const result = await workflowApi.listClosedByMe({ const result = await workflowApi.listClosedByMe({
page, page,
limit: itemsPerPage, limit: itemsPerPage,
@ -260,8 +270,8 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
<FileText className="w-5 h-5 sm:w-6 sm:h-6 text-white" /> <FileText className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
</div> </div>
<div> <div>
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900">Closed Requests</h1> <h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900">My Closed Requests</h1>
<p className="text-sm sm:text-base text-gray-600">Review completed and archived requests</p> <p className="text-sm sm:text-base text-gray-600">Review your completed and archived requests</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -46,6 +46,7 @@ import {
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi'; import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface CreateRequestProps { interface CreateRequestProps {
@ -247,7 +248,7 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
open: false, open: false,
errors: [] errors: []
}); });
// Validation modal states // Validation modal states
const [validationModal, setValidationModal] = useState<{ const [validationModal, setValidationModal] = useState<{
open: boolean; open: boolean;
@ -261,31 +262,70 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
message: '' message: ''
}); });
// Fetch document policy on mount // Policy violation modal state
const [policyViolationModal, setPolicyViolationModal] = useState<{
open: boolean;
violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }>;
}>({
open: false,
violations: []
});
// System policy configuration state
const [systemPolicy, setSystemPolicy] = useState<{
maxApprovalLevels: number;
maxParticipants: number;
allowSpectators: boolean;
maxSpectators: number;
}>({
maxApprovalLevels: 10,
maxParticipants: 50,
allowSpectators: true,
maxSpectators: 20
});
// Fetch document policy and system policy on mount
useEffect(() => { useEffect(() => {
const loadDocumentPolicy = async () => { const loadPolicies = async () => {
try { try {
const configs = await getAllConfigurations('DOCUMENT_POLICY'); // Load document policy
const configMap: Record<string, string> = {}; const docConfigs = await getAllConfigurations('DOCUMENT_POLICY');
configs.forEach((c: AdminConfiguration) => { const docConfigMap: Record<string, string> = {};
configMap[c.configKey] = c.configValue; docConfigs.forEach((c: AdminConfiguration) => {
docConfigMap[c.configKey] = c.configValue;
}); });
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10'); const maxFileSizeMB = parseInt(docConfigMap['MAX_FILE_SIZE_MB'] || '10');
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif'; const allowedFileTypesStr = docConfigMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase()); const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
setDocumentPolicy({ setDocumentPolicy({
maxFileSizeMB, maxFileSizeMB,
allowedFileTypes allowedFileTypes
}); });
// Load system policy (WORKFLOW_SHARING and TAT_SETTINGS)
const workflowConfigs = await getAllConfigurations('WORKFLOW_SHARING');
const tatConfigs = await getAllConfigurations('TAT_SETTINGS');
const allConfigs = [...workflowConfigs, ...tatConfigs];
const configMap: Record<string, string> = {};
allConfigs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
setSystemPolicy({
maxApprovalLevels: parseInt(configMap['MAX_APPROVAL_LEVELS'] || '10'),
maxParticipants: parseInt(configMap['MAX_PARTICIPANTS_PER_REQUEST'] || '50'),
allowSpectators: configMap['ALLOW_ADD_SPECTATOR']?.toLowerCase() === 'true',
maxSpectators: parseInt(configMap['MAX_SPECTATORS_PER_REQUEST'] || '20')
});
} catch (error) { } catch (error) {
console.error('Failed to load document policy:', error); console.error('Failed to load policies:', error);
// Use defaults if loading fails // Use defaults if loading fails
} }
}; };
loadDocumentPolicy(); loadPolicies();
}, []); }, []);
// Fetch draft data when in edit mode // Fetch draft data when in edit mode
@ -559,7 +599,16 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
displayName: foundUser.displayName, displayName: foundUser.displayName,
firstName: foundUser.firstName, firstName: foundUser.firstName,
lastName: foundUser.lastName, lastName: foundUser.lastName,
department: foundUser.department department: foundUser.department,
phone: foundUser.phone,
mobilePhone: foundUser.mobilePhone,
designation: foundUser.designation,
jobTitle: foundUser.jobTitle,
manager: foundUser.manager,
employeeId: foundUser.employeeId,
employeeNumber: foundUser.employeeNumber,
secondEmail: foundUser.secondEmail,
location: foundUser.location
}); });
// Update approver with DB userId and full details // Update approver with DB userId and full details
@ -685,15 +734,82 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
return; return;
} }
} }
// Policy validation before adding
const violations: Array<{ type: string; message: string; currentValue?: number; maxValue?: number }> = [];
if (type === 'approvers') {
// Check maximum approval levels
const updatedApprovers = [...currentList, user];
const maxLevel = Math.max(...updatedApprovers.map((a: any) => a.level || 1), 1);
if (maxLevel > systemPolicy.maxApprovalLevels) {
violations.push({
type: 'Maximum Approval Levels Exceeded',
message: `Adding this approver would exceed the maximum approval levels limit.`,
currentValue: maxLevel,
maxValue: systemPolicy.maxApprovalLevels
});
}
// Check maximum participants (approvers + spectators + initiator)
const totalParticipants = 1 + updatedApprovers.length + formData.spectators.length; // 1 for initiator
if (totalParticipants > systemPolicy.maxParticipants) {
violations.push({
type: 'Maximum Participants Exceeded',
message: `Adding this approver would exceed the maximum participants limit.`,
currentValue: totalParticipants,
maxValue: systemPolicy.maxParticipants
});
}
} else if (type === 'spectators') {
// Check if spectators are allowed
if (!systemPolicy.allowSpectators) {
violations.push({
type: 'Spectators Not Allowed',
message: `Adding spectators is not allowed by system policy.`,
});
}
// Check maximum spectators
const updatedSpectators = [...currentList, user];
if (updatedSpectators.length > systemPolicy.maxSpectators) {
violations.push({
type: 'Maximum Spectators Exceeded',
message: `Adding this spectator would exceed the maximum spectators limit.`,
currentValue: updatedSpectators.length,
maxValue: systemPolicy.maxSpectators
});
}
// Check maximum participants (approvers + spectators + initiator)
const totalParticipants = 1 + formData.approvers.length + updatedSpectators.length; // 1 for initiator
if (totalParticipants > systemPolicy.maxParticipants) {
violations.push({
type: 'Maximum Participants Exceeded',
message: `Adding this spectator would exceed the maximum participants limit.`,
currentValue: totalParticipants,
maxValue: systemPolicy.maxParticipants
});
}
}
// If there are violations, show policy violation modal
if (violations.length > 0) {
setPolicyViolationModal({
open: true,
violations
});
return;
}
// Add user to the list // Add user to the list
const updatedList = [...currentList, user]; const updatedList = [...currentList, user];
updateFormData(type, updatedList); updateFormData(type, updatedList);
// Update max level if adding approver // Update max level if adding approver
if (type === 'approvers') { if (type === 'approvers') {
const maxApproverLevel = Math.max(...updatedList.map((a: any) => a.level), 0); const maxApproverLevel = Math.max(...updatedList.map((a: any) => a.level || 1), 1);
updateFormData('maxLevel', maxApproverLevel); updateFormData('maxLevel', maxApproverLevel);
} }
}; };
@ -786,7 +902,16 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
displayName: foundUser.displayName, displayName: foundUser.displayName,
firstName: foundUser.firstName, firstName: foundUser.firstName,
lastName: foundUser.lastName, lastName: foundUser.lastName,
department: foundUser.department department: foundUser.department,
phone: foundUser.phone,
mobilePhone: foundUser.mobilePhone,
designation: foundUser.designation,
jobTitle: foundUser.jobTitle,
manager: foundUser.manager,
employeeId: foundUser.employeeId,
employeeNumber: foundUser.employeeNumber,
secondEmail: foundUser.secondEmail,
location: foundUser.location
}); });
// Create spectator object with verified data // Create spectator object with verified data
@ -1896,7 +2021,16 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
displayName: u.displayName, displayName: u.displayName,
firstName: u.firstName, firstName: u.firstName,
lastName: u.lastName, lastName: u.lastName,
department: u.department department: u.department,
phone: u.phone,
mobilePhone: u.mobilePhone,
designation: u.designation,
jobTitle: u.jobTitle,
manager: u.manager,
employeeId: u.employeeId,
employeeNumber: u.employeeNumber,
secondEmail: u.secondEmail,
location: u.location
}); });
// Use the database userId (UUID) instead of Okta ID // Use the database userId (UUID) instead of Okta ID
dbUserId = dbUser.userId; dbUserId = dbUser.userId;
@ -2290,7 +2424,16 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
displayName: u.displayName, displayName: u.displayName,
firstName: u.firstName, firstName: u.firstName,
lastName: u.lastName, lastName: u.lastName,
department: u.department department: u.department,
phone: u.phone,
mobilePhone: u.mobilePhone,
designation: u.designation,
jobTitle: u.jobTitle,
manager: u.manager,
employeeId: u.employeeId,
employeeNumber: u.employeeNumber,
secondEmail: u.secondEmail,
location: u.location
}); });
// Use the database userId (UUID) instead of Okta ID // Use the database userId (UUID) instead of Okta ID
dbUserId = dbUser.userId; dbUserId = dbUser.userId;
@ -3340,6 +3483,19 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Policy Violation Modal */}
<PolicyViolationModal
open={policyViolationModal.open}
onClose={() => setPolicyViolationModal({ open: false, violations: [] })}
violations={policyViolationModal.violations}
policyDetails={{
maxApprovalLevels: systemPolicy.maxApprovalLevels,
maxParticipants: systemPolicy.maxParticipants,
allowSpectators: systemPolicy.allowSpectators,
maxSpectators: systemPolicy.maxSpectators
}}
/>
</div> </div>
); );
} }

View File

@ -35,8 +35,10 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Responsi
import { KPICard } from '@/components/dashboard/KPICard'; import { KPICard } from '@/components/dashboard/KPICard';
import { StatCard } from '@/components/dashboard/StatCard'; import { StatCard } from '@/components/dashboard/StatCard';
import { CriticalAlertCard, CriticalAlertData } from '@/components/dashboard/CriticalAlertCard'; import { CriticalAlertCard, CriticalAlertData } from '@/components/dashboard/CriticalAlertCard';
import type { CriticalRequest } from '@/services/dashboard.service';
import { ActivityFeedItem, ActivityData } from '@/components/dashboard/ActivityFeedItem'; import { ActivityFeedItem, ActivityData } from '@/components/dashboard/ActivityFeedItem';
import { Pagination } from '@/components/common/Pagination'; import { Pagination } from '@/components/common/Pagination';
import { formatHoursMinutes } from '@/utils/slaTracker';
interface DashboardProps { interface DashboardProps {
onNavigate?: (page: string) => void; onNavigate?: (page: string) => void;
@ -48,7 +50,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
const { user } = useAuth(); const { user } = useAuth();
const [kpis, setKpis] = useState<DashboardKPIs | null>(null); const [kpis, setKpis] = useState<DashboardKPIs | null>(null);
const [recentActivity, setRecentActivity] = useState<ActivityData[]>([]); const [recentActivity, setRecentActivity] = useState<ActivityData[]>([]);
const [criticalRequests, setCriticalRequests] = useState<CriticalAlertData[]>([]); const [criticalRequests, setCriticalRequests] = useState<(CriticalRequest | CriticalAlertData)[]>([]);
const [departmentStats, setDepartmentStats] = useState<any[]>([]); const [departmentStats, setDepartmentStats] = useState<any[]>([]);
const [priorityDistribution, setPriorityDistribution] = useState<any[]>([]); const [priorityDistribution, setPriorityDistribution] = useState<any[]>([]);
const [upcomingDeadlines, setUpcomingDeadlines] = useState<any[]>([]); const [upcomingDeadlines, setUpcomingDeadlines] = useState<any[]>([]);
@ -57,6 +59,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [exportingDeptStats, setExportingDeptStats] = useState(false); const [exportingDeptStats, setExportingDeptStats] = useState(false);
const [exportingApproverPerformance, setExportingApproverPerformance] = useState(false);
// Filter states // Filter states
const [dateRange, setDateRange] = useState<DateRange>('month'); const [dateRange, setDateRange] = useState<DateRange>('month');
@ -86,10 +89,33 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
return hasManagementAccess(user); return hasManagementAccess(user);
}, [user]); }, [user]);
// Backend now returns only breached requests for TAT Breach Report // Backend returns critical requests including pending requests that have breached TAT
// But we still filter to ensure data integrity // Backend already filters for breached requests (is_breached = true) and includes PENDING/IN_PROGRESS statuses
// The backend query explicitly includes: WHERE wf.status IN ('PENDING', 'IN_PROGRESS') AND ta.is_breached = true
// So all critical requests returned are already pending/in-progress and breached - no additional filtering needed
// However, we still verify breachCount > 0 or isCritical to ensure data integrity
const breachedRequests = useMemo(() => { const breachedRequests = useMemo(() => {
return criticalRequests.filter(r => r.breachCount > 0); return criticalRequests.filter(r => {
// Include all requests that are breached (breachCount > 0) or marked as critical
// Backend already ensures these are pending/in-progress requests that have breached TAT
const isBreached = (r.breachCount || 0) > 0;
const isCritical = (r as any).isCritical === true;
const status = (r as any).status;
// If status is available, ensure it's pending/in-progress (backend should already filter this)
// This is a safety check to ensure we only show pending/in-progress requests that have breached
if (status) {
const isPendingOrInProgress = status === 'pending' || status === 'in-progress' ||
status === 'PENDING' || status === 'IN_PROGRESS';
// Include if (breached or critical) AND status is pending/in-progress
// This ensures pending requests that have breached are included
return (isBreached || isCritical) && isPendingOrInProgress;
}
// Fallback: if no status info, include all breached or critical requests
// (Backend should always provide status, but this is a safety fallback)
return isBreached || isCritical;
});
}, [criticalRequests]); }, [criticalRequests]);
// Filter upcoming deadlines to show only requests about to breach (not yet breached) // Filter upcoming deadlines to show only requests about to breach (not yet breached)
@ -292,6 +318,71 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
} }
}; };
// Export Approver Performance to CSV
const exportApproverPerformanceToCSV = async () => {
try {
setExportingApproverPerformance(true);
// Fetch all approver performance data (no pagination)
// Use a large limit to get all data
const allData: ApproverPerformance[] = [];
let currentPage = 1;
let hasMore = true;
const maxPages = 100; // Safety limit
while (hasMore && currentPage <= maxPages) {
const result = await dashboardService.getApproverPerformance(
dateRange,
currentPage,
100, // Large page size
customStartDate,
customEndDate
);
if (result.performance && result.performance.length > 0) {
allData.push(...result.performance);
currentPage++;
hasMore = currentPage <= result.pagination.totalPages;
} else {
hasMore = false;
}
}
// Convert to CSV format
const csvRows = [
['Approver Name', 'Total Approved', 'TAT Compliance (%)', 'Avg Response Time', 'Pending Count'].join(',')
];
allData.forEach((approver) => {
const row = [
`"${(approver.approverName || 'Unknown').replace(/"/g, '""')}"`,
approver.totalApproved || 0,
approver.tatCompliancePercent || 0,
formatHoursMinutes(approver.avgResponseHours),
approver.pendingCount || 0
];
csvRows.push(row.join(','));
});
const csvContent = csvRows.join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `approver-performance-report-${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
} catch (error: any) {
console.error('Failed to export approver performance:', error);
alert('Failed to export approver performance data. Please try again.');
} finally {
setExportingApproverPerformance(false);
}
};
// Export Department Stats to CSV // Export Department Stats to CSV
const exportDepartmentStatsToCSV = async () => { const exportDepartmentStatsToCSV = async () => {
try { try {
@ -371,6 +462,43 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
{ label: 'Settings', icon: Settings, action: () => onNavigate?.('settings'), color: 'bg-slate-600 hover:bg-slate-700' } { label: 'Settings', icon: Settings, action: () => onNavigate?.('settings'), color: 'bg-slate-600 hover:bg-slate-700' }
], [onNavigate, onNewRequest]); ], [onNavigate, onNewRequest]);
// Helper function to build filter URL params
const buildFilterUrl = useCallback((filters: {
status?: string;
priority?: string;
slaCompliance?: string;
department?: string;
dateRange?: DateRange;
startDate?: Date;
endDate?: Date;
}) => {
const params = new URLSearchParams();
if (filters.status) params.set('status', filters.status);
if (filters.priority) params.set('priority', filters.priority);
if (filters.slaCompliance) params.set('slaCompliance', filters.slaCompliance);
if (filters.department) params.set('department', filters.department);
if (filters.dateRange) params.set('dateRange', filters.dateRange);
if (filters.startDate) params.set('startDate', filters.startDate.toISOString());
if (filters.endDate) params.set('endDate', filters.endDate.toISOString());
const queryString = params.toString();
// Navigate to new Requests screen instead of My Requests
return queryString ? `/requests?${queryString}` : '/requests';
}, []);
// Handler for KPI clicks - navigates to Requests screen with filters
const handleKPIClick = useCallback((filters: {
status?: string;
priority?: string;
slaCompliance?: string;
department?: string;
dateRange?: DateRange;
startDate?: Date;
endDate?: Date;
}) => {
const url = buildFilterUrl(filters);
onNavigate?.(url);
}, [buildFilterUrl, onNavigate]);
if (loading) { if (loading) {
return ( return (
<div className="flex items-center justify-center h-screen"> <div className="flex items-center justify-center h-screen">
@ -601,14 +729,29 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
iconBgColor="bg-blue-50" iconBgColor="bg-blue-50"
iconColor="text-blue-600" iconColor="text-blue-600"
testId="kpi-total-requests" testId="kpi-total-requests"
onClick={() => handleKPIClick({
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
})}
> >
<div className="grid grid-cols-3 gap-2"> {/* Row 1: Approved and Rejected */}
<div className="grid grid-cols-2 gap-2 mb-2">
<StatCard <StatCard
label="Approved" label="Approved"
value={kpis?.requestVolume.approvedRequests || 0} value={kpis?.requestVolume.approvedRequests || 0}
bgColor="bg-green-50" bgColor="bg-green-50"
textColor="text-green-600" textColor="text-green-600"
testId="stat-approved" testId="stat-approved"
onClick={(e) => {
e.stopPropagation();
handleKPIClick({
status: 'approved',
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
/> />
<StatCard <StatCard
label="Rejected" label="Rejected"
@ -616,13 +759,50 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
bgColor="bg-red-50" bgColor="bg-red-50"
textColor="text-red-600" textColor="text-red-600"
testId="stat-rejected" testId="stat-rejected"
onClick={(e) => {
e.stopPropagation();
handleKPIClick({
status: 'rejected',
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
/> />
</div>
{/* Row 2: Pending and Closed */}
<div className="grid grid-cols-2 gap-2">
<StatCard <StatCard
label="Pending" label="Pending"
value={kpis?.requestVolume.openRequests || 0} value={kpis?.requestVolume.openRequests || 0}
bgColor="bg-orange-50" bgColor="bg-orange-50"
textColor="text-orange-600" textColor="text-orange-600"
testId="stat-pending" testId="stat-pending"
onClick={(e) => {
e.stopPropagation();
handleKPIClick({
status: 'pending',
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
/>
<StatCard
label="Closed"
value={kpis?.requestVolume.closedRequests || 0}
bgColor="bg-gray-50"
textColor="text-gray-600"
testId="stat-closed"
onClick={(e) => {
e.stopPropagation();
handleKPIClick({
status: 'closed',
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
/> />
</div> </div>
</KPICard> </KPICard>
@ -635,8 +815,13 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
iconBgColor="bg-green-50" iconBgColor="bg-green-50"
iconColor="text-green-600" iconColor="text-green-600"
testId="kpi-sla-compliance" testId="kpi-sla-compliance"
onClick={() => handleKPIClick({
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
})}
> >
<Progress value={kpis?.tatEfficiency.avgTATCompliance || 0} className="h-2 mb-3" data-testid="sla-progress-bar" /> <Progress value={kpis?.tatEfficiency.avgTATCompliance || 0} className="h-2 mb-4" data-testid="sla-progress-bar" />
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<StatCard <StatCard
label="Compliant" label="Compliant"
@ -644,6 +829,15 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
bgColor="bg-green-50" bgColor="bg-green-50"
textColor="text-green-600" textColor="text-green-600"
testId="stat-compliant" testId="stat-compliant"
onClick={(e) => {
e.stopPropagation();
handleKPIClick({
slaCompliance: 'compliant',
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
/> />
<StatCard <StatCard
label="Breached" label="Breached"
@ -651,6 +845,15 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
bgColor="bg-red-50" bgColor="bg-red-50"
textColor="text-red-600" textColor="text-red-600"
testId="stat-breached" testId="stat-breached"
onClick={(e) => {
e.stopPropagation();
handleKPIClick({
slaCompliance: 'breached',
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
/> />
</div> </div>
</KPICard> </KPICard>
@ -658,12 +861,17 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
{/* Avg Cycle Time */} {/* Avg Cycle Time */}
<KPICard <KPICard
title="Avg Cycle Time" title="Avg Cycle Time"
value={`${kpis?.tatEfficiency.avgCycleTimeHours.toFixed(1) || 0}`} value={kpis?.tatEfficiency.avgCycleTimeHours ? formatHoursMinutes(kpis.tatEfficiency.avgCycleTimeHours) : '0h'}
icon={Clock} icon={Clock}
iconBgColor="bg-purple-50" iconBgColor="bg-purple-50"
iconColor="text-purple-600" iconColor="text-purple-600"
subtitle={`${kpis?.tatEfficiency.avgCycleTimeDays.toFixed(1) || 0} working days`} subtitle={`${kpis?.tatEfficiency.avgCycleTimeDays.toFixed(1) || 0} working days`}
testId="kpi-avg-cycle-time" testId="kpi-avg-cycle-time"
onClick={() => handleKPIClick({
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
})}
> >
<div className="grid grid-cols-2 gap-2"> <div className="grid grid-cols-2 gap-2">
<StatCard <StatCard
@ -671,22 +879,40 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
value={(() => { value={(() => {
const express = priorityDistribution.find(p => p.priority === 'express'); const express = priorityDistribution.find(p => p.priority === 'express');
const hours = express ? Number(express.avgCycleTimeHours) : 0; const hours = express ? Number(express.avgCycleTimeHours) : 0;
return hours > 0 ? `${hours.toFixed(1)}h` : 'N/A'; return hours > 0 ? formatHoursMinutes(hours) : 'N/A';
})()} })()}
bgColor="bg-orange-50" bgColor="bg-orange-50"
textColor="text-orange-600" textColor="text-orange-600"
testId="stat-express-time" testId="stat-express-time"
onClick={(e) => {
e.stopPropagation();
handleKPIClick({
priority: 'express',
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
/> />
<StatCard <StatCard
label="Standard" label="Standard"
value={(() => { value={(() => {
const standard = priorityDistribution.find(p => p.priority === 'standard'); const standard = priorityDistribution.find(p => p.priority === 'standard');
const hours = standard ? Number(standard.avgCycleTimeHours) : 0; const hours = standard ? Number(standard.avgCycleTimeHours) : 0;
return hours > 0 ? `${hours.toFixed(1)}h` : 'N/A'; return hours > 0 ? formatHoursMinutes(hours) : 'N/A';
})()} })()}
bgColor="bg-blue-50" bgColor="bg-blue-50"
textColor="text-blue-600" textColor="text-blue-600"
testId="stat-standard-time" testId="stat-standard-time"
onClick={(e) => {
e.stopPropagation();
handleKPIClick({
priority: 'standard',
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
/> />
</div> </div>
</KPICard> </KPICard>
@ -704,11 +930,61 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
iconBgColor="bg-blue-50" iconBgColor="bg-blue-50"
iconColor="text-blue-600" iconColor="text-blue-600"
testId="kpi-my-requests" testId="kpi-my-requests"
onClick={() => handleKPIClick({
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
})}
> >
<div className="grid grid-cols-3 gap-1.5"> <div className="grid grid-cols-3 gap-1.5">
<StatCard label="Approved" value={kpis?.requestVolume.approvedRequests || 0} bgColor="bg-green-50" textColor="text-green-600" testId="stat-user-approved" /> <StatCard
<StatCard label="Pending" value={kpis?.requestVolume.openRequests || 0} bgColor="bg-orange-50" textColor="text-orange-600" testId="stat-user-pending" /> label="Approved"
<StatCard label="Draft" value={kpis?.requestVolume.draftRequests || 0} bgColor="bg-gray-50" textColor="text-gray-600" testId="stat-user-draft" /> value={kpis?.requestVolume.approvedRequests || 0}
bgColor="bg-green-50"
textColor="text-green-600"
testId="stat-user-approved"
onClick={(e) => {
e.stopPropagation();
handleKPIClick({
status: 'approved',
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
/>
<StatCard
label="Pending"
value={kpis?.requestVolume.openRequests || 0}
bgColor="bg-orange-50"
textColor="text-orange-600"
testId="stat-user-pending"
onClick={(e) => {
e.stopPropagation();
handleKPIClick({
status: 'pending',
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
/>
<StatCard
label="Draft"
value={kpis?.requestVolume.draftRequests || 0}
bgColor="bg-gray-50"
textColor="text-gray-600"
testId="stat-user-draft"
onClick={(e) => {
e.stopPropagation();
handleKPIClick({
status: 'draft',
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
/>
</div> </div>
</KPICard> </KPICard>
@ -1079,7 +1355,32 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
<XAxis <XAxis
dataKey="department" dataKey="department"
stroke="#999" stroke="#999"
tick={{ fontSize: 11 }} tick={(props) => {
const { x, y, payload } = props;
return (
<g transform={`translate(${x},${y})`}>
<text
x={0}
y={0}
dy={16}
textAnchor="middle"
fill="#999"
fontSize={11}
className="cursor-pointer hover:text-blue-600 hover:underline"
onClick={() => {
handleKPIClick({
department: payload.value,
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}}
>
{payload.value}
</text>
</g>
);
}}
/> />
<YAxis <YAxis
stroke="#999" stroke="#999"
@ -1153,9 +1454,8 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-baseline gap-2"> <div className="flex items-baseline gap-2">
<span className="text-2xl sm:text-3xl font-bold text-purple-600"> <span className="text-2xl sm:text-3xl font-bold text-purple-600">
{kpis.tatEfficiency.avgCycleTimeHours.toFixed(1)} {formatHoursMinutes(kpis.tatEfficiency.avgCycleTimeHours)}
</span> </span>
<span className="text-sm text-muted-foreground">hours</span>
</div> </div>
<Separator /> <Separator />
<div className="text-xs text-muted-foreground text-center"> <div className="text-xs text-muted-foreground text-center">
@ -1201,7 +1501,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
{priority.totalCount} {priority.totalCount}
</div> </div>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
Avg: {avgCycleTime.toFixed(1)}h cycle Avg: {formatHoursMinutes(avgCycleTime)} cycle
</div> </div>
</div> </div>
); );
@ -1216,6 +1516,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
data={priorityDistribution.map(p => ({ data={priorityDistribution.map(p => ({
name: p.priority.charAt(0).toUpperCase() + p.priority.slice(1), name: p.priority.charAt(0).toUpperCase() + p.priority.slice(1),
value: p.totalCount, value: p.totalCount,
priority: p.priority,
percentage: Math.round((p.totalCount / priorityDistribution.reduce((sum, item) => sum + item.totalCount, 0)) * 100) percentage: Math.round((p.totalCount / priorityDistribution.reduce((sum, item) => sum + item.totalCount, 0)) * 100)
}))} }))}
cx="50%" cx="50%"
@ -1248,9 +1549,20 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
outerRadius={90} outerRadius={90}
fill="#8884d8" fill="#8884d8"
dataKey="value" dataKey="value"
onClick={(data: any) => {
if (data && data.priority && onNavigate) {
// Navigate to requests page with priority filter pre-filled
onNavigate(`requests?priority=${data.priority}`);
}
}}
style={{ cursor: 'pointer' }}
> >
{priorityDistribution.map((priority, index) => ( {priorityDistribution.map((priority, index) => (
<Cell key={`cell-${index}`} fill={priority.priority === 'express' ? '#ef4444' : '#3b82f6'} /> <Cell
key={`cell-${index}`}
fill={priority.priority === 'express' ? '#ef4444' : '#3b82f6'}
style={{ cursor: 'pointer' }}
/>
))} ))}
</Pie> </Pie>
<Tooltip <Tooltip
@ -1260,6 +1572,13 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
borderRadius: '6px', borderRadius: '6px',
fontSize: '12px' fontSize: '12px'
}} }}
formatter={(value: any, _name: any, props: any) => {
const priority = props.payload?.priority || '';
return [
`${value} requests`,
`Click for ${priority}`
];
}}
/> />
</RechartsPieChart> </RechartsPieChart>
</ResponsiveContainer> </ResponsiveContainer>
@ -1298,8 +1617,9 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Title</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Title</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Department</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Department</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Approver</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Approver</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Level</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Breach Time</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Breach Time</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Reason</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700 min-w-[200px] max-w-[300px]">Reason</th>
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Priority</th> <th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Priority</th>
</tr> </tr>
</thead> </thead>
@ -1309,10 +1629,10 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
const formatBreachTime = (hours: number) => { const formatBreachTime = (hours: number) => {
if (hours <= 0) return 'Just breached'; if (hours <= 0) return 'Just breached';
if (hours < 1) return `${Math.round(hours * 60)} min`; if (hours < 1) return `${Math.round(hours * 60)} min`;
if (hours < 24) return `${hours.toFixed(1)} hours`; if (hours < 24) return formatHoursMinutes(hours);
const days = Math.floor(hours / 24); const days = Math.floor(hours / 24);
const remainingHours = hours % 24; const remainingHours = hours % 24;
return remainingHours > 0 ? `${days}d ${remainingHours.toFixed(1)}h` : `${days}d`; return remainingHours > 0 ? `${days}d ${formatHoursMinutes(remainingHours)}` : `${days}d`;
}; };
return ( return (
@ -1324,19 +1644,67 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
<td className="py-3 px-4 text-sm text-gray-900 max-w-xs truncate" title={req.title}> <td className="py-3 px-4 text-sm text-gray-900 max-w-xs truncate" title={req.title}>
{req.title} {req.title}
</td> </td>
<td className="py-3 px-4 text-sm text-gray-700"> <td
className="py-3 px-4 text-sm text-gray-700 cursor-pointer hover:text-blue-600 hover:underline"
onClick={() => {
if (req.department && req.department !== 'Unknown') {
handleKPIClick({
department: req.department,
dateRange: dateRange,
startDate: customStartDate,
endDate: customEndDate
});
}
}}
>
{req.department || 'Unknown'} {req.department || 'Unknown'}
</td> </td>
<td
className="py-3 px-4 text-sm text-gray-700"
>
{req.approverId ? (
<span
className="cursor-pointer hover:text-blue-600 hover:underline"
onClick={() => {
if (onNavigate) {
// Navigate to requests page with approver filter pre-filled
const params = new URLSearchParams();
params.set('approver', req.approverId!);
params.set('approverType', 'current'); // Since these are current approvers
params.set('slaCompliance', 'breached'); // Filter for breached requests
onNavigate(`requests?${params.toString()}`);
}
}}
title="Click to view all requests for this approver"
>
{req.approver || 'N/A'}
</span>
) : (
req.approver || 'N/A'
)}
</td>
<td className="py-3 px-4 text-sm text-gray-700"> <td className="py-3 px-4 text-sm text-gray-700">
{req.approver || 'N/A'} {req.currentLevel && req.totalLevels ? (
<span className="font-medium">
{req.currentLevel}/{req.totalLevels}
</span>
) : req.currentLevel ? (
<span className="font-medium">{req.currentLevel}/</span>
) : (
<span className="text-gray-400"></span>
)}
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<span className="bg-red-500 text-white px-2 py-1 rounded text-xs font-medium"> <span className="bg-red-500 text-white px-2 py-1 rounded text-xs font-medium">
{formatBreachTime(breachTime)} {formatBreachTime(breachTime)}
</span> </span>
</td> </td>
<td className="py-3 px-4 text-sm text-gray-700"> <td className="py-3 px-4 text-sm text-gray-700 min-w-[200px] max-w-[300px]">
{req.breachReason || 'TAT Exceeded'} <div className="max-h-32 overflow-y-auto">
<p className="whitespace-pre-line break-words leading-relaxed">
{req.breachReason || 'TAT Exceeded'}
</p>
</div>
</td> </td>
<td className="py-3 px-4"> <td className="py-3 px-4">
<Badge <Badge
@ -1426,8 +1794,8 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
className={`h-1.5 sm:h-2 ${tatPercentage >= 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100'}`} className={`h-1.5 sm:h-2 ${tatPercentage >= 80 ? 'bg-red-100' : tatPercentage >= 50 ? 'bg-orange-100' : 'bg-green-100'}`}
/> />
<div className="flex justify-between text-xs text-muted-foreground"> <div className="flex justify-between text-xs text-muted-foreground">
<span>{elapsedHours.toFixed(1)}h elapsed</span> <span>{formatHoursMinutes(elapsedHours)} elapsed</span>
<span>{remainingHours.toFixed(1)}h left</span> <span>{formatHoursMinutes(remainingHours)} left</span>
</div> </div>
</div> </div>
</div> </div>
@ -1530,14 +1898,34 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
{isAdmin && approverPerformance.length > 0 && ( {isAdmin && approverPerformance.length > 0 && (
<Card className="shadow-md hover:shadow-lg transition-shadow"> <Card className="shadow-md hover:shadow-lg transition-shadow">
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<div className="flex items-center gap-3"> <div className="flex items-center justify-between gap-3">
<div className="bg-yellow-50 p-2 sm:p-3 rounded-lg"> <div className="flex items-center gap-3">
<Users className="h-5 w-5 sm:h-6 sm:w-6 text-yellow-600" /> <div className="bg-yellow-50 p-2 sm:p-3 rounded-lg">
</div> <Users className="h-5 w-5 sm:h-6 sm:w-6 text-yellow-600" />
<div> </div>
<CardTitle className="text-base sm:text-lg lg:text-xl">Approver Performance Report</CardTitle> <div>
<CardDescription className="text-xs sm:text-sm">Response time & TAT compliance tracking</CardDescription> <CardTitle className="text-base sm:text-lg lg:text-xl">Approver Performance Report</CardTitle>
<CardDescription className="text-xs sm:text-sm">Response time & TAT compliance tracking</CardDescription>
</div>
</div> </div>
<Button
onClick={exportApproverPerformanceToCSV}
disabled={exportingApproverPerformance || loading}
className="bg-re-green hover:bg-re-green/90 text-white shrink-0"
size="sm"
>
{exportingApproverPerformance ? (
<>
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
Exporting...
</>
) : (
<>
<Download className="w-4 h-4 mr-2" />
Export
</>
)}
</Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -1552,7 +1940,21 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
}; };
return ( return (
<div key={idx} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"> <div
key={idx}
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
onClick={() => {
const params = new URLSearchParams();
params.set('approverId', approver.approverId);
params.set('approverName', approver.approverName);
params.set('dateRange', dateRange);
if (dateRange === 'custom' && customStartDate && customEndDate) {
params.set('startDate', customStartDate.toISOString());
params.set('endDate', customEndDate.toISOString());
}
onNavigate?.(`/approver-performance?${params.toString()}`);
}}
>
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 bg-orange-500 rounded-full flex items-center justify-center text-white font-semibold text-sm flex-shrink-0"> <div className="w-8 h-8 bg-orange-500 rounded-full flex items-center justify-center text-white font-semibold text-sm flex-shrink-0">
@ -1570,7 +1972,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
<div className="grid grid-cols-2 gap-4 mt-3"> <div className="grid grid-cols-2 gap-4 mt-3">
<div> <div>
<div className="text-xs text-gray-500">Avg Response</div> <div className="text-xs text-gray-500">Avg Response</div>
<div className="text-sm font-medium text-gray-900">{approver.avgResponseHours.toFixed(1)}h</div> <div className="text-sm font-medium text-gray-900">{formatHoursMinutes(approver.avgResponseHours)}</div>
</div> </div>
<div> <div>
<div className="text-xs text-gray-500">Pending</div> <div className="text-xs text-gray-500">Pending</div>

View File

@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { Label } from '@/components/ui/label';
import { import {
ArrowLeft, ArrowLeft,
FileText, FileText,
@ -16,9 +18,12 @@ import {
ChevronRight, ChevronRight,
Search, Search,
Loader2, Loader2,
AlertCircle AlertCircle,
Calendar as CalendarIcon
} from 'lucide-react'; } from 'lucide-react';
import { dashboardService } from '@/services/dashboard.service'; import { format } from 'date-fns';
import { dashboardService, type DateRange } from '@/services/dashboard.service';
import { formatHoursMinutes } from '@/utils/slaTracker';
interface DetailedReportsProps { interface DetailedReportsProps {
onBack?: () => void; onBack?: () => void;
@ -27,8 +32,34 @@ interface DetailedReportsProps {
export function DetailedReports({ onBack }: DetailedReportsProps) { export function DetailedReports({ onBack }: DetailedReportsProps) {
const navigate = useNavigate(); const navigate = useNavigate();
const [threshold, setThreshold] = useState('7'); const [threshold, setThreshold] = useState('7');
const [thresholdError, setThresholdError] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
// Date range filters - shared across all reports
const [lifecycleDateRange, setLifecycleDateRange] = useState<DateRange>('month');
const [lifecycleCustomStartDate, setLifecycleCustomStartDate] = useState<Date | undefined>(undefined);
const [lifecycleCustomEndDate, setLifecycleCustomEndDate] = useState<Date | undefined>(undefined);
const [showLifecycleCustomDatePicker, setShowLifecycleCustomDatePicker] = useState(false);
// Temporary state for date picker inputs (only applied when "Apply" is clicked)
const [tempLifecycleCustomStartDate, setTempLifecycleCustomStartDate] = useState<Date | undefined>(undefined);
const [tempLifecycleCustomEndDate, setTempLifecycleCustomEndDate] = useState<Date | undefined>(undefined);
const [activityDateRange, setActivityDateRange] = useState<DateRange>('month');
const [activityCustomStartDate, setActivityCustomStartDate] = useState<Date | undefined>(undefined);
const [activityCustomEndDate, setActivityCustomEndDate] = useState<Date | undefined>(undefined);
const [showActivityCustomDatePicker, setShowActivityCustomDatePicker] = useState(false);
// Temporary state for date picker inputs (only applied when "Apply" is clicked)
const [tempActivityCustomStartDate, setTempActivityCustomStartDate] = useState<Date | undefined>(undefined);
const [tempActivityCustomEndDate, setTempActivityCustomEndDate] = useState<Date | undefined>(undefined);
const [agingDateRange, setAgingDateRange] = useState<DateRange>('month');
const [agingCustomStartDate, setAgingCustomStartDate] = useState<Date | undefined>(undefined);
const [agingCustomEndDate, setAgingCustomEndDate] = useState<Date | undefined>(undefined);
const [showAgingCustomDatePicker, setShowAgingCustomDatePicker] = useState(false);
// Temporary state for date picker inputs (only applied when "Apply" is clicked)
const [tempAgingCustomStartDate, setTempAgingCustomStartDate] = useState<Date | undefined>(undefined);
const [tempAgingCustomEndDate, setTempAgingCustomEndDate] = useState<Date | undefined>(undefined);
// Activity log filters // Activity log filters
const [filterCategory, setFilterCategory] = useState<string>('all'); const [filterCategory, setFilterCategory] = useState<string>('all');
const [filterSeverity, setFilterSeverity] = useState<string>('all'); const [filterSeverity, setFilterSeverity] = useState<string>('all');
@ -92,10 +123,10 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
const formatTAT = (hours: number | null | undefined): string => { const formatTAT = (hours: number | null | undefined): string => {
if (!hours && hours !== 0) return 'N/A'; if (!hours && hours !== 0) return 'N/A';
const WORKING_HOURS_PER_DAY = 8; const WORKING_HOURS_PER_DAY = 8;
if (hours < WORKING_HOURS_PER_DAY) return `${hours.toFixed(1)}h`; if (hours < WORKING_HOURS_PER_DAY) return formatHoursMinutes(hours);
const days = Math.floor(hours / WORKING_HOURS_PER_DAY); const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
const remainingHours = hours % WORKING_HOURS_PER_DAY; const remainingHours = hours % WORKING_HOURS_PER_DAY;
return remainingHours > 0 ? `${days}d ${remainingHours.toFixed(1)}h` : `${days}d`; return remainingHours > 0 ? `${days}d ${formatHoursMinutes(remainingHours)}` : `${days}d`;
}; };
// Helper function to format date // Helper function to format date
@ -156,7 +187,13 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
setLoadingLifecyclePage(true); setLoadingLifecyclePage(true);
} }
setErrorLifecycle(null); setErrorLifecycle(null);
const result = await dashboardService.getLifecycleReport(page, LIFECYCLE_RECORDS_PER_PAGE); const result = await dashboardService.getLifecycleReport(
page,
LIFECYCLE_RECORDS_PER_PAGE,
lifecycleDateRange,
lifecycleCustomStartDate,
lifecycleCustomEndDate
);
const mapped = result.lifecycleData.map((req: any) => { const mapped = result.lifecycleData.map((req: any) => {
const overallTAT = formatTAT(req.overallTATHours); const overallTAT = formatTAT(req.overallTATHours);
@ -191,7 +228,7 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
setLoadingLifecyclePage(false); setLoadingLifecyclePage(false);
} }
} }
}, [lifecycleRequests.length]); }, [lifecycleRequests.length, lifecycleDateRange, lifecycleCustomStartDate, lifecycleCustomEndDate]);
// Fetch User Activity Log data // Fetch User Activity Log data
const fetchActivityData = useCallback(async (page: number = 1) => { const fetchActivityData = useCallback(async (page: number = 1) => {
@ -207,11 +244,13 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
const result = await dashboardService.getActivityLogReport( const result = await dashboardService.getActivityLogReport(
page, page,
RECORDS_PER_PAGE, RECORDS_PER_PAGE,
undefined, // dateRange activityDateRange, // dateRange
undefined, // filterUserId undefined, // filterUserId
undefined, // filterType undefined, // filterType
filterCategory && filterCategory !== 'all' ? filterCategory : undefined, filterCategory && filterCategory !== 'all' ? filterCategory : undefined,
filterSeverity && filterSeverity !== 'all' ? filterSeverity : undefined filterSeverity && filterSeverity !== 'all' ? filterSeverity : undefined,
activityCustomStartDate,
activityCustomEndDate
); );
const mapped = result.activities.map((activity: any) => { const mapped = result.activities.map((activity: any) => {
@ -252,7 +291,7 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
setLoadingActivityPage(false); setLoadingActivityPage(false);
} }
} }
}, [activityLog.length, filterCategory, filterSeverity]); }, [activityLog.length, filterCategory, filterSeverity, activityDateRange, activityCustomStartDate, activityCustomEndDate]);
// Fetch Workflow Aging Report data // Fetch Workflow Aging Report data
const fetchAgingData = useCallback(async (page: number = 1) => { const fetchAgingData = useCallback(async (page: number = 1) => {
@ -266,7 +305,21 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
} }
setErrorAging(null); setErrorAging(null);
const result = await dashboardService.getWorkflowAgingReport(parseInt(threshold), page, RECORDS_PER_PAGE); // Validate threshold before fetching
const thresholdValue = parseInt(threshold, 10);
if (isNaN(thresholdValue) || thresholdValue < 1) {
setErrorAging('Please enter a valid threshold (minimum 1 day)');
return;
}
const result = await dashboardService.getWorkflowAgingReport(
thresholdValue,
page,
RECORDS_PER_PAGE,
agingDateRange,
agingCustomStartDate,
agingCustomEndDate
);
const mapped = result.agingData.map((req: any) => { const mapped = result.agingData.map((req: any) => {
return { return {
@ -297,7 +350,7 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
setLoadingAgingPage(false); setLoadingAgingPage(false);
} }
} }
}, [threshold, agingWorkflows.length]); }, [threshold, agingWorkflows.length, agingDateRange, agingCustomStartDate, agingCustomEndDate]);
// Fetch all data on mount // Fetch all data on mount
useEffect(() => { useEffect(() => {
@ -306,22 +359,148 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
fetchAgingData(1); fetchAgingData(1);
}, [fetchLifecycleData, fetchActivityData, fetchAgingData]); }, [fetchLifecycleData, fetchActivityData, fetchAgingData]);
// Refetch aging data when threshold changes // Refetch lifecycle data when date range changes
useEffect(() => { useEffect(() => {
fetchAgingData(1); // If custom range is selected but dates are not set, don't fetch yet
}, [threshold, fetchAgingData]); if (lifecycleDateRange === 'custom' && (!lifecycleCustomStartDate || !lifecycleCustomEndDate)) {
return;
}
fetchLifecycleData(1);
}, [lifecycleDateRange, lifecycleCustomStartDate, lifecycleCustomEndDate, fetchLifecycleData]);
// Refetch activity data when filters change // Refetch aging data when threshold or date range changes
useEffect(() => { useEffect(() => {
// If custom range is selected but dates are not set, don't fetch yet
if (agingDateRange === 'custom' && (!agingCustomStartDate || !agingCustomEndDate)) {
return;
}
fetchAgingData(1);
}, [threshold, agingDateRange, agingCustomStartDate, agingCustomEndDate, fetchAgingData]);
// Refetch activity data when filters or date range changes
useEffect(() => {
// If custom range is selected but dates are not set, don't fetch yet
if (activityDateRange === 'custom' && (!activityCustomStartDate || !activityCustomEndDate)) {
return;
}
fetchActivityData(1); fetchActivityData(1);
}, [filterCategory, filterSeverity, fetchActivityData]); }, [filterCategory, filterSeverity, activityDateRange, activityCustomStartDate, activityCustomEndDate, fetchActivityData]);
// Date range change handlers
const handleLifecycleDateRangeChange = (value: string) => {
const newRange = value as DateRange;
setLifecycleDateRange(newRange);
if (newRange !== 'custom') {
setLifecycleCustomStartDate(undefined);
setLifecycleCustomEndDate(undefined);
setTempLifecycleCustomStartDate(undefined);
setTempLifecycleCustomEndDate(undefined);
setShowLifecycleCustomDatePicker(false);
} else {
// Initialize temp state from actual state when opening
setTempLifecycleCustomStartDate(lifecycleCustomStartDate);
setTempLifecycleCustomEndDate(lifecycleCustomEndDate);
setShowLifecycleCustomDatePicker(true);
}
};
const handleActivityDateRangeChange = (value: string) => {
const newRange = value as DateRange;
setActivityDateRange(newRange);
if (newRange !== 'custom') {
setActivityCustomStartDate(undefined);
setActivityCustomEndDate(undefined);
setTempActivityCustomStartDate(undefined);
setTempActivityCustomEndDate(undefined);
setShowActivityCustomDatePicker(false);
} else {
// Initialize temp state from actual state when opening
setTempActivityCustomStartDate(activityCustomStartDate);
setTempActivityCustomEndDate(activityCustomEndDate);
setShowActivityCustomDatePicker(true);
}
};
const handleAgingDateRangeChange = (value: string) => {
const newRange = value as DateRange;
setAgingDateRange(newRange);
if (newRange !== 'custom') {
setAgingCustomStartDate(undefined);
setAgingCustomEndDate(undefined);
setTempAgingCustomStartDate(undefined);
setTempAgingCustomEndDate(undefined);
setShowAgingCustomDatePicker(false);
} else {
// Initialize temp state from actual state when opening
setTempAgingCustomStartDate(agingCustomStartDate);
setTempAgingCustomEndDate(agingCustomEndDate);
setShowAgingCustomDatePicker(true);
}
};
const handleApplyLifecycleCustomDate = () => {
if (tempLifecycleCustomStartDate && tempLifecycleCustomEndDate) {
// Swap dates if start > end
if (tempLifecycleCustomStartDate > tempLifecycleCustomEndDate) {
const temp = tempLifecycleCustomStartDate;
setLifecycleCustomStartDate(tempLifecycleCustomEndDate);
setLifecycleCustomEndDate(temp);
setTempLifecycleCustomStartDate(tempLifecycleCustomEndDate);
setTempLifecycleCustomEndDate(temp);
} else {
setLifecycleCustomStartDate(tempLifecycleCustomStartDate);
setLifecycleCustomEndDate(tempLifecycleCustomEndDate);
}
setShowLifecycleCustomDatePicker(false);
}
};
const handleApplyActivityCustomDate = () => {
if (tempActivityCustomStartDate && tempActivityCustomEndDate) {
// Swap dates if start > end
if (tempActivityCustomStartDate > tempActivityCustomEndDate) {
const temp = tempActivityCustomStartDate;
setActivityCustomStartDate(tempActivityCustomEndDate);
setActivityCustomEndDate(temp);
setTempActivityCustomStartDate(tempActivityCustomEndDate);
setTempActivityCustomEndDate(temp);
} else {
setActivityCustomStartDate(tempActivityCustomStartDate);
setActivityCustomEndDate(tempActivityCustomEndDate);
}
setShowActivityCustomDatePicker(false);
}
};
const handleApplyAgingCustomDate = () => {
if (tempAgingCustomStartDate && tempAgingCustomEndDate) {
// Swap dates if start > end
if (tempAgingCustomStartDate > tempAgingCustomEndDate) {
const temp = tempAgingCustomStartDate;
setAgingCustomStartDate(tempAgingCustomEndDate);
setAgingCustomEndDate(temp);
setTempAgingCustomStartDate(tempAgingCustomEndDate);
setTempAgingCustomEndDate(temp);
} else {
setAgingCustomStartDate(tempAgingCustomStartDate);
setAgingCustomEndDate(tempAgingCustomEndDate);
}
setShowAgingCustomDatePicker(false);
}
};
// CSV Export Functions - Fetch all data for export // CSV Export Functions - Fetch all data for export
const exportLifecycleToCSV = async () => { const exportLifecycleToCSV = async () => {
try { try {
setExportingLifecycle(true); setExportingLifecycle(true);
// Fetch all data with a very large limit // Fetch all data with a very large limit
const result = await dashboardService.getLifecycleReport(1, 10000); const result = await dashboardService.getLifecycleReport(
1,
10000,
lifecycleDateRange,
lifecycleCustomStartDate,
lifecycleCustomEndDate
);
// Use the same mapping logic as the display to ensure consistency // Use the same mapping logic as the display to ensure consistency
const csvRows = [ const csvRows = [
@ -386,7 +565,17 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
try { try {
setExportingActivity(true); setExportingActivity(true);
// Fetch all data with a very large limit // Fetch all data with a very large limit
const result = await dashboardService.getActivityLogReport(1, 10000); const result = await dashboardService.getActivityLogReport(
1,
10000,
activityDateRange,
undefined, // filterUserId
undefined, // filterType
filterCategory && filterCategory !== 'all' ? filterCategory : undefined,
filterSeverity && filterSeverity !== 'all' ? filterSeverity : undefined,
activityCustomStartDate,
activityCustomEndDate
);
const csvRows = [ const csvRows = [
['Timestamp', 'User', 'Action', 'Details', 'IP Address', 'User Agent', 'Request ID'].join(',') ['Timestamp', 'User', 'Action', 'Details', 'IP Address', 'User Agent', 'Request ID'].join(',')
@ -430,7 +619,14 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
try { try {
setExportingAging(true); setExportingAging(true);
// Fetch all data with a very large limit // Fetch all data with a very large limit
const result = await dashboardService.getWorkflowAgingReport(parseInt(threshold), 1, 10000); const result = await dashboardService.getWorkflowAgingReport(
parseInt(threshold),
1,
10000,
agingDateRange,
agingCustomStartDate,
agingCustomEndDate
);
const csvRows = [ const csvRows = [
['Request ID', 'Title', 'Initiator', 'Start Date', 'Days Open (Business)', 'Current Stage', 'Assigned To', 'Priority', 'Status'].join(',') ['Request ID', 'Title', 'Initiator', 'Start Date', 'Days Open (Business)', 'Current Stage', 'Assigned To', 'Priority', 'Status'].join(',')
@ -590,6 +786,109 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
{exportingLifecycle ? 'Exporting...' : 'Download CSV'} {exportingLifecycle ? 'Exporting...' : 'Download CSV'}
</Button> </Button>
</div> </div>
<div className="mt-4 flex items-center gap-3 flex-wrap">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={lifecycleDateRange} onValueChange={handleLifecycleDateRangeChange}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{/* Custom Date Range Picker for Lifecycle */}
{lifecycleDateRange === 'custom' && (
<Popover open={showLifecycleCustomDatePicker} onOpenChange={setShowLifecycleCustomDatePicker}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{lifecycleCustomStartDate && lifecycleCustomEndDate
? `${format(lifecycleCustomStartDate, 'MMM d, yyyy')} - ${format(lifecycleCustomEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start" sideOffset={8}>
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="lifecycle-start-date" className="text-sm font-medium">Start Date</Label>
<Input
id="lifecycle-start-date"
type="date"
value={tempLifecycleCustomStartDate ? format(tempLifecycleCustomStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
setTempLifecycleCustomStartDate(date);
if (tempLifecycleCustomEndDate && date > tempLifecycleCustomEndDate) {
setTempLifecycleCustomEndDate(date);
}
} else {
setTempLifecycleCustomStartDate(undefined);
}
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="lifecycle-end-date" className="text-sm font-medium">End Date</Label>
<Input
id="lifecycle-end-date"
type="date"
value={tempLifecycleCustomEndDate ? format(tempLifecycleCustomEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
setTempLifecycleCustomEndDate(date);
if (tempLifecycleCustomStartDate && date < tempLifecycleCustomStartDate) {
setTempLifecycleCustomStartDate(date);
}
} else {
setTempLifecycleCustomEndDate(undefined);
}
}}
min={tempLifecycleCustomStartDate ? format(tempLifecycleCustomStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={handleApplyLifecycleCustomDate}
disabled={!tempLifecycleCustomStartDate || !tempLifecycleCustomEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowLifecycleCustomDatePicker(false);
setTempLifecycleCustomStartDate(lifecycleCustomStartDate);
setTempLifecycleCustomEndDate(lifecycleCustomEndDate);
if (!lifecycleCustomStartDate || !lifecycleCustomEndDate) {
setLifecycleCustomStartDate(undefined);
setLifecycleCustomEndDate(undefined);
setLifecycleDateRange('month');
}
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
{loadingLifecycle ? ( {loadingLifecycle ? (
@ -610,12 +909,23 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
<div className="space-y-4"> <div className="space-y-4">
{lifecycleRequests.map((request) => ( {lifecycleRequests.map((request) => (
<div key={request.id} className="border rounded-xl overflow-hidden"> <div key={request.id} className="border rounded-xl overflow-hidden">
<div className="p-4 bg-gradient-to-r from-gray-50 to-white hover:bg-gray-100 cursor-pointer transition-all"> <div
className="p-4 bg-gradient-to-r from-gray-50 to-white hover:bg-gray-100 cursor-pointer transition-all"
onClick={() => handleViewRequest(request.requestId || request.id)}
>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-2 mb-1"> <div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-sm text-gray-900">{request.id}</span> <button
onClick={(e) => {
e.stopPropagation();
handleViewRequest(request.requestId || request.id);
}}
className="font-semibold text-sm text-blue-600 hover:text-blue-800 hover:underline cursor-pointer transition-colors"
>
{request.id}
</button>
<Badge className={getPriorityColor(request.priority)}> <Badge className={getPriorityColor(request.priority)}>
{request.priority} {request.priority}
</Badge> </Badge>
@ -719,7 +1029,110 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
{exportingActivity ? 'Exporting...' : 'Download CSV'} {exportingActivity ? 'Exporting...' : 'Download CSV'}
</Button> </Button>
</div> </div>
<div className="mt-4 flex items-center gap-4"> <div className="mt-4 flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={activityDateRange} onValueChange={handleActivityDateRangeChange}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{/* Custom Date Range Picker for Activity */}
{activityDateRange === 'custom' && (
<Popover open={showActivityCustomDatePicker} onOpenChange={setShowActivityCustomDatePicker}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{activityCustomStartDate && activityCustomEndDate
? `${format(activityCustomStartDate, 'MMM d, yyyy')} - ${format(activityCustomEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start" sideOffset={8}>
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="activity-start-date" className="text-sm font-medium">Start Date</Label>
<Input
id="activity-start-date"
type="date"
value={tempActivityCustomStartDate ? format(tempActivityCustomStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
setTempActivityCustomStartDate(date);
if (tempActivityCustomEndDate && date > tempActivityCustomEndDate) {
setTempActivityCustomEndDate(date);
}
} else {
setTempActivityCustomStartDate(undefined);
}
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="activity-end-date" className="text-sm font-medium">End Date</Label>
<Input
id="activity-end-date"
type="date"
value={tempActivityCustomEndDate ? format(tempActivityCustomEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
setTempActivityCustomEndDate(date);
if (tempActivityCustomStartDate && date < tempActivityCustomStartDate) {
setTempActivityCustomStartDate(date);
}
} else {
setTempActivityCustomEndDate(undefined);
}
}}
min={tempActivityCustomStartDate ? format(tempActivityCustomStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={handleApplyActivityCustomDate}
disabled={!tempActivityCustomStartDate || !tempActivityCustomEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowActivityCustomDatePicker(false);
setTempActivityCustomStartDate(activityCustomStartDate);
setTempActivityCustomEndDate(activityCustomEndDate);
if (!activityCustomStartDate || !activityCustomEndDate) {
setActivityCustomStartDate(undefined);
setActivityCustomEndDate(undefined);
setActivityDateRange('month');
}
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm text-gray-600 whitespace-nowrap">Category:</label> <label className="text-sm text-gray-600 whitespace-nowrap">Category:</label>
<Select value={filterCategory} onValueChange={setFilterCategory}> <Select value={filterCategory} onValueChange={setFilterCategory}>
@ -752,13 +1165,17 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{(filterCategory !== 'all' || filterSeverity !== 'all') && ( {(filterCategory !== 'all' || filterSeverity !== 'all' || activityDateRange !== 'month' || activityCustomStartDate || activityCustomEndDate) && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { onClick={() => {
setFilterCategory('all'); setFilterCategory('all');
setFilterSeverity('all'); setFilterSeverity('all');
setActivityDateRange('month');
setActivityCustomStartDate(undefined);
setActivityCustomEndDate(undefined);
setShowActivityCustomDatePicker(false);
}} }}
className="text-xs" className="text-xs"
> >
@ -814,10 +1231,21 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
{activity.requestId !== '-' ? ( {activity.requestId !== '-' && activity.requestId !== 'System Login' ? (
<Badge variant="outline" className="text-xs"> <button
{activity.requestId} onClick={() => {
</Badge> // Extract request number from requestId (could be requestNumber or requestId)
const requestIdentifier = activity.requestId;
if (requestIdentifier) {
handleViewRequest(requestIdentifier);
}
}}
className="hover:underline"
>
<Badge variant="outline" className="text-xs cursor-pointer hover:bg-blue-50 hover:border-blue-300 transition-colors">
{activity.requestId}
</Badge>
</button>
) : ( ) : (
<span className="text-xs text-gray-400">-</span> <span className="text-xs text-gray-400">-</span>
)} )}
@ -876,19 +1304,51 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3 flex-wrap">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Threshold:</label> <label className="text-sm text-gray-600 whitespace-nowrap">Threshold:</label>
<Select value={threshold} onValueChange={setThreshold}> <div className="flex flex-col gap-1">
<SelectTrigger className="w-32"> <div className="flex items-center gap-2">
<SelectValue /> <Input
</SelectTrigger> type="number"
<SelectContent> min="1"
<SelectItem value="7">&gt; 7 days</SelectItem> value={threshold}
<SelectItem value="14">&gt; 14 days</SelectItem> onChange={(e) => {
<SelectItem value="30">&gt; 30 days</SelectItem> const value = e.target.value;
</SelectContent> // Allow empty string while typing
</Select> if (value === '') {
setThreshold('');
setThresholdError(null);
return;
}
// Parse as integer
const numValue = parseInt(value, 10);
// Validate: must be a positive integer >= 1
if (isNaN(numValue) || numValue < 1) {
setThresholdError('Minimum threshold is 1 day');
setThreshold(value); // Keep the invalid value for user to see
} else {
setThresholdError(null);
setThreshold(value);
}
}}
onBlur={(e) => {
const value = e.target.value;
// If empty or invalid on blur, reset to minimum (1)
if (value === '' || isNaN(parseInt(value, 10)) || parseInt(value, 10) < 1) {
setThreshold('1');
setThresholdError(null);
}
}}
className={`w-24 ${thresholdError ? 'border-red-500 focus:border-red-500 focus:ring-red-500' : ''}`}
placeholder="Days"
/>
<span className="text-sm text-gray-600 whitespace-nowrap">days</span>
</div>
{thresholdError && (
<span className="text-xs text-red-600">{thresholdError}</span>
)}
</div>
</div> </div>
<Badge variant="outline" className="font-semibold"> <Badge variant="outline" className="font-semibold">
{agingTotalRecords} workflows {agingTotalRecords} workflows
@ -905,8 +1365,111 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
</Button> </Button>
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4 flex items-center gap-4 flex-wrap">
<div className="relative"> <div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-muted-foreground" />
<Select value={agingDateRange} onValueChange={handleAgingDateRangeChange}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="Date Range" />
</SelectTrigger>
<SelectContent>
<SelectItem value="today">Today</SelectItem>
<SelectItem value="week">This Week</SelectItem>
<SelectItem value="month">This Month</SelectItem>
<SelectItem value="custom">Custom Range</SelectItem>
</SelectContent>
</Select>
{/* Custom Date Range Picker for Aging */}
{agingDateRange === 'custom' && (
<Popover open={showAgingCustomDatePicker} onOpenChange={setShowAgingCustomDatePicker}>
<PopoverTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<CalendarIcon className="w-4 h-4" />
{agingCustomStartDate && agingCustomEndDate
? `${format(agingCustomStartDate, 'MMM d, yyyy')} - ${format(agingCustomEndDate, 'MMM d, yyyy')}`
: 'Select dates'}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-4" align="start" sideOffset={8}>
<div className="space-y-4">
<div className="space-y-3">
<div className="space-y-2">
<Label htmlFor="aging-start-date" className="text-sm font-medium">Start Date</Label>
<Input
id="aging-start-date"
type="date"
value={tempAgingCustomStartDate ? format(tempAgingCustomStartDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
setTempAgingCustomStartDate(date);
if (tempAgingCustomEndDate && date > tempAgingCustomEndDate) {
setTempAgingCustomEndDate(date);
}
} else {
setTempAgingCustomStartDate(undefined);
}
}}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
<div className="space-y-2">
<Label htmlFor="aging-end-date" className="text-sm font-medium">End Date</Label>
<Input
id="aging-end-date"
type="date"
value={tempAgingCustomEndDate ? format(tempAgingCustomEndDate, 'yyyy-MM-dd') : ''}
onChange={(e) => {
const date = e.target.value ? new Date(e.target.value) : undefined;
if (date) {
setTempAgingCustomEndDate(date);
if (tempAgingCustomStartDate && date < tempAgingCustomStartDate) {
setTempAgingCustomStartDate(date);
}
} else {
setTempAgingCustomEndDate(undefined);
}
}}
min={tempAgingCustomStartDate ? format(tempAgingCustomStartDate, 'yyyy-MM-dd') : undefined}
max={format(new Date(), 'yyyy-MM-dd')}
className="w-full"
/>
</div>
</div>
<div className="flex gap-2 pt-2 border-t">
<Button
size="sm"
onClick={handleApplyAgingCustomDate}
disabled={!tempAgingCustomStartDate || !tempAgingCustomEndDate}
className="flex-1 bg-re-green hover:bg-re-green/90"
>
Apply
</Button>
<Button
size="sm"
variant="outline"
onClick={() => {
setShowAgingCustomDatePicker(false);
setTempAgingCustomStartDate(agingCustomStartDate);
setTempAgingCustomEndDate(agingCustomEndDate);
if (!agingCustomStartDate || !agingCustomEndDate) {
setAgingCustomStartDate(undefined);
setAgingCustomEndDate(undefined);
setAgingDateRange('month');
}
}}
>
Cancel
</Button>
</div>
</div>
</PopoverContent>
</Popover>
)}
</div>
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" /> <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input <Input
placeholder="Search by Request ID, Title, or Initiator..." placeholder="Search by Request ID, Title, or Initiator..."
@ -951,7 +1514,14 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
<TableBody> <TableBody>
{filteredAgingWorkflows.map((workflow) => ( {filteredAgingWorkflows.map((workflow) => (
<TableRow key={workflow.id} className="hover:bg-gray-50"> <TableRow key={workflow.id} className="hover:bg-gray-50">
<TableCell className="font-medium text-sm">{workflow.id}</TableCell> <TableCell className="font-medium text-sm">
<button
onClick={() => handleViewRequest(workflow.requestId || workflow.id)}
className="text-blue-600 hover:text-blue-800 hover:underline cursor-pointer transition-colors"
>
{workflow.id}
</button>
</TableCell>
<TableCell className="text-sm">{workflow.title}</TableCell> <TableCell className="text-sm">{workflow.title}</TableCell>
<TableCell className="text-sm"> <TableCell className="text-sm">
{workflow.initiator} {workflow.initiator}

View File

@ -114,6 +114,13 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
const [totalRecords, setTotalRecords] = useState(0); const [totalRecords, setTotalRecords] = useState(0);
const [itemsPerPage] = useState(10); const [itemsPerPage] = useState(10);
// Fetch open requests for the current user only (user-scoped, not organization-wide)
// Note: This endpoint returns only requests where the user is:
// - An approver (with pending/in-progress status)
// - A spectator
// - An initiator (for approved requests awaiting closure)
// This applies to ALL users including regular users, MANAGEMENT, and ADMIN roles
// For organization-wide view, users should use the "All Requests" screen (/requests)
const fetchRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => { const fetchRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
try { try {
if (page === 1) { if (page === 1) {
@ -121,6 +128,9 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
setItems([]); setItems([]);
} }
// Always use user-scoped endpoint (not organization-wide)
// Backend filters by userId regardless of user role (ADMIN/MANAGEMENT/regular user)
// For organization-wide requests, use the "All Requests" screen (/requests)
const result = await workflowApi.listOpenForMe({ const result = await workflowApi.listOpenForMe({
page, page,
limit: itemsPerPage, limit: itemsPerPage,
@ -277,8 +287,8 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
<FileText className="w-5 h-5 sm:w-6 sm:h-6 text-white" /> <FileText className="w-5 h-5 sm:w-6 sm:h-6 text-white" />
</div> </div>
<div> <div>
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900">Open Requests</h1> <h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900">My Open Requests</h1>
<p className="text-sm sm:text-base text-gray-600">Manage and track active approval requests</p> <p className="text-sm sm:text-base text-gray-600">Manage and track your active approval requests</p>
</div> </div>
</div> </div>
</div> </div>

View File

@ -870,6 +870,7 @@ function RequestDetailInner({
setSkipApproverData(data); setSkipApproverData(data);
setShowSkipApproverModal(true); setShowSkipApproverModal(true);
}} }}
onRefresh={refreshDetails}
testId="workflow-step" testId="workflow-step"
/> />
); );

File diff suppressed because it is too large Load Diff

View File

@ -124,7 +124,7 @@ export function Settings() {
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group"> <Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-blue-500 to-blue-600 rounded-md shadow-md group-hover:shadow-lg transition-shadow"> <div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md group-hover:shadow-lg transition-shadow">
<Bell className="h-5 w-5 text-white" /> <Bell className="h-5 w-5 text-white" />
</div> </div>
<div> <div>
@ -138,7 +138,7 @@ export function Settings() {
<Button <Button
onClick={handleEnableNotifications} onClick={handleEnableNotifications}
disabled={isEnablingNotifications} disabled={isEnablingNotifications}
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed" 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"
> >
<Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} /> <Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
{isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'} {isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'}
@ -151,7 +151,7 @@ export function Settings() {
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group"> <Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-red-500 to-red-600 rounded-md shadow-md group-hover:shadow-lg transition-shadow"> <div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md group-hover:shadow-lg transition-shadow">
<Lock className="h-5 w-5 text-white" /> <Lock className="h-5 w-5 text-white" />
</div> </div>
<div> <div>
@ -173,7 +173,7 @@ export function Settings() {
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group"> <Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-purple-500 to-purple-600 rounded-md shadow-md group-hover:shadow-lg transition-shadow"> <div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md group-hover:shadow-lg transition-shadow">
<Palette className="h-5 w-5 text-white" /> <Palette className="h-5 w-5 text-white" />
</div> </div>
<div> <div>
@ -195,7 +195,7 @@ export function Settings() {
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group"> <Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-md shadow-md group-hover:shadow-lg transition-shadow"> <div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md group-hover:shadow-lg transition-shadow">
<Shield className="h-5 w-5 text-white" /> <Shield className="h-5 w-5 text-white" />
</div> </div>
<div> <div>
@ -239,7 +239,7 @@ export function Settings() {
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group"> <Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-blue-500 to-blue-600 rounded-md shadow-md group-hover:shadow-lg transition-shadow"> <div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md group-hover:shadow-lg transition-shadow">
<Bell className="h-5 w-5 text-white" /> <Bell className="h-5 w-5 text-white" />
</div> </div>
<div> <div>
@ -253,7 +253,7 @@ export function Settings() {
<Button <Button
onClick={handleEnableNotifications} onClick={handleEnableNotifications}
disabled={isEnablingNotifications} disabled={isEnablingNotifications}
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed" 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"
> >
<Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} /> <Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
{isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'} {isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'}
@ -266,7 +266,7 @@ export function Settings() {
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group"> <Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-red-500 to-red-600 rounded-md shadow-md group-hover:shadow-lg transition-shadow"> <div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md group-hover:shadow-lg transition-shadow">
<Lock className="h-5 w-5 text-white" /> <Lock className="h-5 w-5 text-white" />
</div> </div>
<div> <div>
@ -288,7 +288,7 @@ export function Settings() {
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group"> <Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-purple-500 to-purple-600 rounded-md shadow-md group-hover:shadow-lg transition-shadow"> <div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md group-hover:shadow-lg transition-shadow">
<Palette className="h-5 w-5 text-white" /> <Palette className="h-5 w-5 text-white" />
</div> </div>
<div> <div>
@ -310,7 +310,7 @@ export function Settings() {
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group"> <Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
<CardHeader className="pb-4"> <CardHeader className="pb-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-gradient-to-br from-emerald-500 to-emerald-600 rounded-md shadow-md group-hover:shadow-lg transition-shadow"> <div className="p-3 bg-gradient-to-br from-slate-600 to-slate-700 rounded-md shadow-md group-hover:shadow-lg transition-shadow">
<Shield className="h-5 w-5 text-white" /> <Shield className="h-5 w-5 text-white" />
</div> </div>
<div> <div>

View File

@ -5,6 +5,7 @@ export interface RequestStats {
openRequests: number; openRequests: number;
approvedRequests: number; approvedRequests: number;
rejectedRequests: number; rejectedRequests: number;
closedRequests: number;
draftRequests: number; draftRequests: number;
changeFromPrevious: { changeFromPrevious: {
total: string; total: string;
@ -98,6 +99,8 @@ export interface CriticalRequest {
originalTATHours: number; originalTATHours: number;
breachCount: number; breachCount: number;
isCritical: boolean; isCritical: boolean;
approverId?: string | null;
approverEmail?: string | null;
} }
export interface AIRemarkUtilization { export interface AIRemarkUtilization {
@ -178,11 +181,14 @@ class DashboardService {
/** /**
* Get request statistics * Get request statistics
*/ */
async getRequestStats(dateRange?: DateRange): Promise<RequestStats> { async getRequestStats(dateRange?: DateRange, startDate?: string, endDate?: string): Promise<RequestStats> {
try { try {
const response = await apiClient.get('/dashboard/stats/requests', { const params: any = { dateRange };
params: { dateRange } if (dateRange === 'custom' && startDate && endDate) {
}); params.startDate = startDate;
params.endDate = endDate;
}
const response = await apiClient.get('/dashboard/stats/requests', { params });
return response.data.data; return response.data.data;
} catch (error) { } catch (error) {
console.error('Failed to fetch request stats:', error); console.error('Failed to fetch request stats:', error);
@ -414,7 +420,13 @@ class DashboardService {
/** /**
* Get Request Lifecycle Report * Get Request Lifecycle Report
*/ */
async getLifecycleReport(page: number = 1, limit: number = 50): Promise<{ async getLifecycleReport(
page: number = 1,
limit: number = 50,
dateRange?: DateRange,
startDate?: Date,
endDate?: Date
): Promise<{
lifecycleData: any[], lifecycleData: any[],
pagination: { pagination: {
currentPage: number, currentPage: number,
@ -424,9 +436,13 @@ class DashboardService {
} }
}> { }> {
try { try {
const response = await apiClient.get('/dashboard/reports/lifecycle', { const params: any = { page, limit };
params: { page, limit } if (dateRange) params.dateRange = dateRange;
}); if (dateRange === 'custom' && startDate && endDate) {
params.startDate = startDate.toISOString();
params.endDate = endDate.toISOString();
}
const response = await apiClient.get('/dashboard/reports/lifecycle', { params });
return { return {
lifecycleData: response.data.data, lifecycleData: response.data.data,
pagination: response.data.pagination pagination: response.data.pagination
@ -447,7 +463,9 @@ class DashboardService {
filterUserId?: string, filterUserId?: string,
filterType?: string, filterType?: string,
filterCategory?: string, filterCategory?: string,
filterSeverity?: string filterSeverity?: string,
startDate?: Date,
endDate?: Date
): Promise<{ ): Promise<{
activities: any[], activities: any[],
pagination: { pagination: {
@ -458,9 +476,13 @@ class DashboardService {
} }
}> { }> {
try { try {
const response = await apiClient.get('/dashboard/reports/activity-log', { const params: any = { page, limit, filterUserId, filterType, filterCategory, filterSeverity };
params: { page, limit, dateRange, filterUserId, filterType, filterCategory, filterSeverity } if (dateRange) params.dateRange = dateRange;
}); if (dateRange === 'custom' && startDate && endDate) {
params.startDate = startDate.toISOString();
params.endDate = endDate.toISOString();
}
const response = await apiClient.get('/dashboard/reports/activity-log', { params });
return { return {
activities: response.data.data, activities: response.data.data,
pagination: response.data.pagination pagination: response.data.pagination
@ -471,6 +493,19 @@ class DashboardService {
} }
} }
/**
* Get list of departments (metadata for filtering)
*/
async getDepartments(): Promise<string[]> {
try {
const response = await apiClient.get('/dashboard/metadata/departments');
return response.data.data.departments || [];
} catch (error) {
console.error('Failed to fetch departments:', error);
throw error;
}
}
/** /**
* Get Workflow Aging Report * Get Workflow Aging Report
*/ */
@ -478,7 +513,9 @@ class DashboardService {
threshold: number = 7, threshold: number = 7,
page: number = 1, page: number = 1,
limit: number = 50, limit: number = 50,
dateRange?: DateRange dateRange?: DateRange,
startDate?: Date,
endDate?: Date
): Promise<{ ): Promise<{
agingData: any[], agingData: any[],
pagination: { pagination: {
@ -489,9 +526,13 @@ class DashboardService {
} }
}> { }> {
try { try {
const response = await apiClient.get('/dashboard/reports/workflow-aging', { const params: any = { threshold, page, limit };
params: { threshold, page, limit, dateRange } if (dateRange) params.dateRange = dateRange;
}); if (dateRange === 'custom' && startDate && endDate) {
params.startDate = startDate.toISOString();
params.endDate = endDate.toISOString();
}
const response = await apiClient.get('/dashboard/reports/workflow-aging', { params });
return { return {
agingData: response.data.data, agingData: response.data.data,
pagination: response.data.pagination pagination: response.data.pagination
@ -501,6 +542,52 @@ class DashboardService {
throw error; throw error;
} }
} }
/**
* Get requests filtered by approver ID for detailed performance analysis
*/
async getRequestsByApprover(
approverId: string,
page: number = 1,
limit: number = 50,
dateRange?: DateRange,
startDate?: Date,
endDate?: Date,
status?: string,
priority?: string,
slaCompliance?: string,
search?: string
): Promise<{
requests: any[],
pagination: {
currentPage: number,
totalPages: number,
totalRecords: number,
limit: number
}
}> {
try {
const params: any = { approverId, page, limit };
if (dateRange) params.dateRange = dateRange;
if (dateRange === 'custom' && startDate && endDate) {
params.startDate = startDate.toISOString();
params.endDate = endDate.toISOString();
}
if (status) params.status = status;
if (priority) params.priority = priority;
if (slaCompliance) params.slaCompliance = slaCompliance;
if (search) params.search = search;
const response = await apiClient.get('/dashboard/requests/by-approver', { params });
return {
requests: response.data.data,
pagination: response.data.pagination
};
} catch (error) {
console.error('Failed to fetch requests by approver:', error);
throw error;
}
}
} }
export const dashboardService = new DashboardService(); export const dashboardService = new DashboardService();

View File

@ -7,7 +7,20 @@ export interface UserSummary {
firstName?: string; firstName?: string;
lastName?: string; lastName?: string;
department?: string; department?: string;
phone?: string;
mobilePhone?: string;
designation?: string; designation?: string;
jobTitle?: string;
manager?: string;
employeeId?: string;
employeeNumber?: string;
secondEmail?: string;
location?: {
state?: string;
city?: string;
country?: string;
office?: string;
};
isActive?: boolean; isActive?: boolean;
} }
@ -29,6 +42,19 @@ export async function ensureUserExists(userData: {
lastName?: string; lastName?: string;
department?: string; department?: string;
phone?: string; phone?: string;
mobilePhone?: string;
designation?: string;
jobTitle?: string;
manager?: string;
employeeId?: string;
employeeNumber?: string;
secondEmail?: string;
location?: {
state?: string;
city?: string;
country?: string;
office?: string;
};
}): Promise<UserSummary> { }): Promise<UserSummary> {
const res = await apiClient.post('/users/ensure', userData); const res = await apiClient.post('/users/ensure', userData);
return (res.data?.data || res.data) as UserSummary; return (res.data?.data || res.data) as UserSummary;
@ -36,9 +62,36 @@ export async function ensureUserExists(userData: {
/** /**
* Assign role to user (creates user if doesn't exist) * Assign role to user (creates user if doesn't exist)
* @param email - User email
* @param role - Role to assign
* @param userData - Optional full user data from Okta search (to capture all fields)
*/ */
export async function assignRole(email: string, role: 'USER' | 'MANAGEMENT' | 'ADMIN') { export async function assignRole(
return await apiClient.post('/admin/users/assign-role', { email, role }); email: string,
role: 'USER' | 'MANAGEMENT' | 'ADMIN',
userData?: UserSummary
) {
return await apiClient.post('/admin/users/assign-role', {
email,
role,
userData: userData ? {
userId: userData.userId,
email: userData.email,
displayName: userData.displayName,
firstName: userData.firstName,
lastName: userData.lastName,
department: userData.department,
phone: userData.phone,
mobilePhone: userData.mobilePhone,
designation: userData.designation,
jobTitle: userData.jobTitle,
manager: userData.manager,
employeeId: userData.employeeId,
employeeNumber: userData.employeeNumber,
secondEmail: userData.secondEmail,
location: userData.location
} : undefined
});
} }
/** /**
@ -68,13 +121,23 @@ export async function getRoleStatistics() {
return await apiClient.get('/admin/users/role-statistics'); return await apiClient.get('/admin/users/role-statistics');
} }
/**
* Get all users from database (for filtering purposes)
*/
export async function getAllUsers() {
const res = await apiClient.get('/users');
// Response format: { success: true, data: { users: [...], total: number } }
return res.data?.data?.users || [];
}
export const userApi = { export const userApi = {
searchUsers, searchUsers,
ensureUserExists, ensureUserExists,
assignRole, assignRole,
updateUserRole, updateUserRole,
getUsersByRole, getUsersByRole,
getRoleStatistics getRoleStatistics,
getAllUsers
}; };
export default userApi; export default userApi;

View File

@ -403,6 +403,19 @@ export async function updateAndSubmitWorkflow(requestId: string, workflowData: C
return res.data?.data || res.data; return res.data?.data || res.data;
} }
/**
* Update breach reason for a TAT alert
*/
export async function updateBreachReason(levelId: string, breachReason: string): Promise<void> {
const response = await apiClient.put(`/tat/breach-reason/${levelId}`, {
breachReason
});
if (!response.data.success) {
throw new Error(response.data.error || 'Failed to update breach reason');
}
}
// Also export in default for convenience // Also export in default for convenience
// Note: keeping separate named export above for tree-shaking // Note: keeping separate named export above for tree-shaking

View File

@ -222,6 +222,27 @@ export function getSLAStatus(startDate: string | Date, deadline: string | Date,
}; };
} }
/**
* Format decimal hours to hours and minutes format (e.g., 2.6 -> "2h 23m")
* Simple format without considering working days
*/
export function formatHoursMinutes(hours: number | null | undefined): string {
if (hours === null || hours === undefined || hours < 0) return '0h';
if (hours === 0) return '0h';
const totalMinutes = Math.round(hours * 60);
const h = Math.floor(totalMinutes / 60);
const m = totalMinutes % 60;
if (h > 0 && m > 0) {
return `${h}h ${m}m`;
} else if (h > 0) {
return `${h}h`;
} else {
return `${m}m`;
}
}
/** /**
* Format working hours for display * Format working hours for display
*/ */