dashboard enhanced and and created all requests screen
This commit is contained in:
parent
e193b60083
commit
ea1cc43cb6
30
src/App.tsx
30
src/App.tsx
@ -9,6 +9,8 @@ import { WorkNotes } from '@/pages/WorkNotes';
|
||||
import { CreateRequest } from '@/pages/CreateRequest';
|
||||
import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWizard';
|
||||
import { MyRequests } from '@/pages/MyRequests';
|
||||
import { Requests } from '@/pages/Requests/Requests';
|
||||
import { ApproverPerformance } from '@/pages/ApproverPerformance/ApproverPerformance';
|
||||
import { Profile } from '@/pages/Profile';
|
||||
import { Settings } from '@/pages/Settings';
|
||||
import { Notifications } from '@/pages/Notifications';
|
||||
@ -70,7 +72,13 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
navigate('/settings');
|
||||
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) => {
|
||||
@ -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 */}
|
||||
<Route
|
||||
path="/request/:requestId"
|
||||
|
||||
@ -128,7 +128,7 @@ export function AIConfig() {
|
||||
<Card className="shadow-lg border-0 rounded-md">
|
||||
<CardHeader className="pb-4">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -259,23 +259,17 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
||||
};
|
||||
|
||||
const getCategoryColor = (category: string) => {
|
||||
switch (category) {
|
||||
case 'TAT_SETTINGS':
|
||||
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';
|
||||
}
|
||||
// Use uniform slate color for all category icons
|
||||
return 'bg-gradient-to-br from-slate-600 to-slate-700 text-white';
|
||||
};
|
||||
|
||||
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]) {
|
||||
acc[config.configCategory] = [];
|
||||
}
|
||||
@ -299,7 +293,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
||||
);
|
||||
}
|
||||
|
||||
if (configurations.length === 0) {
|
||||
if (filteredConfigurations.length === 0) {
|
||||
return (
|
||||
<Card className="shadow-lg border-0 rounded-md">
|
||||
<CardContent className="p-12 text-center">
|
||||
@ -368,8 +362,10 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
||||
<Card className="shadow-lg border-0 rounded-md">
|
||||
<CardHeader className="pb-4 border-b border-slate-100">
|
||||
<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)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg font-semibold text-slate-900">
|
||||
@ -420,7 +416,7 @@ export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerPro
|
||||
size="sm"
|
||||
onClick={() => handleSave(config)}
|
||||
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 ? (
|
||||
<>
|
||||
|
||||
@ -93,7 +93,7 @@ export function DocumentConfig() {
|
||||
<Card className="shadow-lg border-0 rounded-md">
|
||||
<CardHeader className="pb-4">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -198,7 +198,7 @@ export function HolidayManager() {
|
||||
<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 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" />
|
||||
</div>
|
||||
<div>
|
||||
@ -223,7 +223,7 @@ export function HolidayManager() {
|
||||
</Select>
|
||||
<Button
|
||||
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" />
|
||||
<span className="hidden xs:inline">Add Holiday</span>
|
||||
@ -329,97 +329,148 @@ export function HolidayManager() {
|
||||
|
||||
{/* Add/Edit Dialog */}
|
||||
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||
<DialogContent className="sm:max-w-[500px] rounded-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg sm:text-xl font-semibold text-slate-900">
|
||||
{editingHoliday ? 'Edit Holiday' : 'Add New Holiday'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-sm text-slate-600">
|
||||
{editingHoliday ? 'Update holiday information' : 'Add a new holiday to the calendar'}
|
||||
</DialogDescription>
|
||||
<DialogContent className="sm:max-w-[550px] max-h-[90vh] rounded-lg flex flex-col p-0">
|
||||
<DialogHeader className="pb-4 border-b border-slate-100 px-6 pt-6 flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 bg-gradient-to-br from-slate-600 to-slate-700 rounded-lg shadow-md">
|
||||
<Calendar className="w-5 h-5 text-white" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<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>
|
||||
|
||||
<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">
|
||||
<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
|
||||
id="date"
|
||||
type="date"
|
||||
value={formData.holidayDate}
|
||||
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>
|
||||
|
||||
{/* Holiday Name Field */}
|
||||
<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
|
||||
id="name"
|
||||
placeholder="e.g., Diwali, Republic Day"
|
||||
placeholder="e.g., Diwali, Republic Day, Christmas"
|
||||
value={formData.holidayName}
|
||||
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>
|
||||
|
||||
{/* Description Field */}
|
||||
<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
|
||||
id="description"
|
||||
placeholder="Optional description"
|
||||
placeholder="Add additional details about this holiday..."
|
||||
value={formData.description}
|
||||
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>
|
||||
|
||||
{/* Holiday Type Field */}
|
||||
<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
|
||||
value={formData.holidayType}
|
||||
onValueChange={(value: Holiday['holidayType']) =>
|
||||
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 />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="rounded-md">
|
||||
<SelectItem value="NATIONAL">National</SelectItem>
|
||||
<SelectItem value="REGIONAL">Regional</SelectItem>
|
||||
<SelectItem value="ORGANIZATIONAL">Organizational</SelectItem>
|
||||
<SelectItem value="OPTIONAL">Optional</SelectItem>
|
||||
<SelectContent className="rounded-lg">
|
||||
<SelectItem value="NATIONAL" className="p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500"></div>
|
||||
<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>
|
||||
</Select>
|
||||
<p className="text-xs text-slate-500">Select the category of this holiday</p>
|
||||
</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
|
||||
type="checkbox"
|
||||
id="recurring"
|
||||
checked={formData.isRecurring}
|
||||
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">
|
||||
This holiday recurs annually
|
||||
</Label>
|
||||
<div className="flex-1">
|
||||
<Label htmlFor="recurring" className="font-semibold cursor-pointer text-sm text-slate-900 block mb-1">
|
||||
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>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<DialogFooter className="gap-3 pt-4 border-t border-slate-100 px-6 pb-6 flex-shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
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
|
||||
</Button>
|
||||
<Button
|
||||
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>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@ -114,7 +114,7 @@ export function TATConfig() {
|
||||
<Card className="shadow-lg border-0 rounded-md">
|
||||
<CardHeader className="pb-4">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -46,7 +46,20 @@ interface OktaUser {
|
||||
lastName?: string;
|
||||
displayName?: string;
|
||||
department?: 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;
|
||||
};
|
||||
}
|
||||
|
||||
interface UserWithRole {
|
||||
@ -124,10 +137,41 @@ export function UserRoleManager() {
|
||||
};
|
||||
|
||||
// Select user from search results
|
||||
const handleSelectUser = (user: OktaUser) => {
|
||||
const handleSelectUser = async (user: OktaUser) => {
|
||||
setSelectedUser(user);
|
||||
setSearchQuery(user.email);
|
||||
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
|
||||
@ -142,7 +186,8 @@ export function UserRoleManager() {
|
||||
|
||||
try {
|
||||
// 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({
|
||||
type: 'success',
|
||||
@ -282,22 +327,22 @@ export function UserRoleManager() {
|
||||
const getRoleBadgeColor = (role: string) => {
|
||||
switch (role) {
|
||||
case 'ADMIN':
|
||||
return 'bg-yellow-400 text-slate-900';
|
||||
return 'bg-yellow-400 text-slate-800';
|
||||
case 'MANAGEMENT':
|
||||
return 'bg-blue-400 text-slate-900';
|
||||
return 'bg-blue-400 text-slate-800';
|
||||
default:
|
||||
return 'bg-gray-400 text-white';
|
||||
return 'bg-gray-400 text-slate-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getRoleIcon = (role: string) => {
|
||||
switch (role) {
|
||||
case 'ADMIN':
|
||||
return <Crown className="w-5 h-5" />;
|
||||
return <Crown className="w-5 h-5 text-slate-800" />;
|
||||
case 'MANAGEMENT':
|
||||
return <Users className="w-5 h-5" />;
|
||||
return <Users className="w-5 h-5 text-slate-800" />;
|
||||
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>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
@ -345,7 +390,7 @@ export function UserRoleManager() {
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
@ -368,7 +413,7 @@ export function UserRoleManager() {
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</CardContent>
|
||||
@ -379,7 +424,7 @@ export function UserRoleManager() {
|
||||
<Card className="shadow-lg border">
|
||||
<CardHeader className="border-b pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-gradient-to-br from-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" />
|
||||
</div>
|
||||
<div>
|
||||
@ -442,19 +487,19 @@ export function UserRoleManager() {
|
||||
|
||||
{/* Selected User */}
|
||||
{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 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()}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-gray-900">
|
||||
<p className="font-semibold text-slate-900">
|
||||
{selectedUser.displayName || selectedUser.email}
|
||||
</p>
|
||||
<p className="text-sm text-gray-600">{selectedUser.email}</p>
|
||||
<p className="text-sm text-slate-600">{selectedUser.email}</p>
|
||||
{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}` : ''}
|
||||
</p>
|
||||
)}
|
||||
@ -467,7 +512,7 @@ export function UserRoleManager() {
|
||||
setSelectedUser(null);
|
||||
setSearchQuery('');
|
||||
}}
|
||||
className="hover:bg-purple-100"
|
||||
className="hover:bg-slate-200"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
@ -512,7 +557,7 @@ export function UserRoleManager() {
|
||||
<Button
|
||||
onClick={handleAssignRole}
|
||||
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"
|
||||
>
|
||||
{updating ? (
|
||||
@ -556,7 +601,7 @@ export function UserRoleManager() {
|
||||
<CardHeader className="border-b pb-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-3 bg-gradient-to-br from-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" />
|
||||
</div>
|
||||
<div>
|
||||
@ -694,7 +739,7 @@ export function UserRoleManager() {
|
||||
onClick={() => handlePageChange(pageNum)}
|
||||
className={`w-9 h-9 p-0 ${
|
||||
currentPage === pageNum
|
||||
? 'bg-purple-500 hover:bg-purple-600'
|
||||
? 'bg-re-green hover:bg-re-green/90 text-white'
|
||||
: ''
|
||||
}`}
|
||||
data-testid={`page-${pageNum}-button`}
|
||||
|
||||
@ -40,20 +40,45 @@ export function Pagination({
|
||||
return pages;
|
||||
};
|
||||
|
||||
// Don't show pagination if only 1 page or loading
|
||||
if (totalPages <= 1 || loading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startItem = ((currentPage - 1) * itemsPerPage) + 1;
|
||||
// Calculate display values
|
||||
const startItem = totalRecords > 0 ? ((currentPage - 1) * itemsPerPage) + 1 : 0;
|
||||
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 (
|
||||
<Card className="shadow-md" data-testid={`${testIdPrefix}-container`}>
|
||||
<Card className="shadow-md border-gray-200" data-testid={`${testIdPrefix}-container`}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||
<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`}
|
||||
>
|
||||
Showing {startItem} to {endItem} of {totalRecords} {itemLabel}
|
||||
|
||||
@ -7,6 +7,7 @@ interface StatCardProps {
|
||||
textColor: string;
|
||||
testId?: string;
|
||||
children?: ReactNode;
|
||||
onClick?: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
@ -15,12 +16,14 @@ export function StatCard({
|
||||
bgColor,
|
||||
textColor,
|
||||
testId = 'stat-card',
|
||||
children
|
||||
children,
|
||||
onClick
|
||||
}: StatCardProps) {
|
||||
return (
|
||||
<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}
|
||||
onClick={onClick}
|
||||
>
|
||||
<p
|
||||
className="text-xs text-gray-600 mb-1"
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, Shield } from 'lucide-react';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -15,7 +15,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} 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 notificationApi, { Notification } from '@/services/notificationApi';
|
||||
import { getSocket, joinUserRoom } from '@/utils/socket';
|
||||
@ -57,13 +57,28 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
||||
}
|
||||
};
|
||||
|
||||
const menuItems = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: Home },
|
||||
{ id: 'my-requests', label: 'My Requests', icon: User },
|
||||
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
|
||||
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
||||
{ id: 'admin', label: 'Admin', icon: Shield },
|
||||
];
|
||||
// Check if user has management access (ADMIN or MANAGEMENT role)
|
||||
const isManagement = useMemo(() => hasManagementAccess(user), [user]);
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items = [
|
||||
{ id: 'dashboard', label: 'Dashboard', icon: Home },
|
||||
];
|
||||
|
||||
// Add "All Requests" 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 = () => {
|
||||
setSidebarOpen(!sidebarOpen);
|
||||
|
||||
88
src/components/modals/PolicyViolationModal.tsx
Normal file
88
src/components/modals/PolicyViolationModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -210,7 +210,16 @@ export function AddApproverModal({
|
||||
displayName: foundUser.displayName,
|
||||
firstName: foundUser.firstName,
|
||||
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})`);
|
||||
@ -333,7 +342,16 @@ export function AddApproverModal({
|
||||
displayName: user.displayName,
|
||||
firstName: user.firstName,
|
||||
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);
|
||||
|
||||
@ -5,6 +5,8 @@ import { Input } from '@/components/ui/input';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react';
|
||||
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
||||
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
||||
|
||||
interface AddSpectatorModalProps {
|
||||
open: boolean;
|
||||
@ -42,6 +44,57 @@ export function AddSpectatorModal({
|
||||
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 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 (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
|
||||
try {
|
||||
@ -137,7 +242,16 @@ export function AddSpectatorModal({
|
||||
displayName: foundUser.displayName,
|
||||
firstName: foundUser.firstName,
|
||||
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})`);
|
||||
@ -246,7 +360,16 @@ export function AddSpectatorModal({
|
||||
displayName: user.displayName,
|
||||
firstName: user.firstName,
|
||||
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);
|
||||
@ -445,6 +568,19 @@ export function AddSpectatorModal({
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react';
|
||||
import { formatHoursMinutes } from '@/utils/slaTracker';
|
||||
|
||||
export interface SLAData {
|
||||
status: 'normal' | 'approaching' | 'critical' | 'breached';
|
||||
@ -73,7 +74,7 @@ export function SLAProgressBar({
|
||||
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<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
|
||||
className={`font-semibold ${
|
||||
@ -83,7 +84,7 @@ export function SLAProgressBar({
|
||||
}`}
|
||||
data-testid={`${testId}-remaining`}
|
||||
>
|
||||
{sla.remainingText || `${sla.remainingHours || 0}h`} remaining
|
||||
{sla.remainingText || formatHoursMinutes(sla.remainingHours || 0)} remaining
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@ -105,9 +105,20 @@ const getStatusText = (status: string) => {
|
||||
|
||||
const formatMessage = (content: string) => {
|
||||
// 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
|
||||
.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 />');
|
||||
};
|
||||
|
||||
@ -165,7 +176,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
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)
|
||||
const requestInfo = useMemo(() => {
|
||||
@ -182,13 +193,9 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
// Log when participants change
|
||||
// Log when participants change - logging removed for performance
|
||||
useEffect(() => {
|
||||
console.log('[WorkNoteChat] Participants state changed:', {
|
||||
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 state changed - logging removed
|
||||
}, [participants]);
|
||||
|
||||
// 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;
|
||||
});
|
||||
|
||||
console.log('[WorkNoteChat] ✅ Loaded participants:', mapped.map(p => ({ name: p.name, userId: (p as any).userId })));
|
||||
// Participants loaded - logging removed
|
||||
participantsLoadedRef.current = true;
|
||||
setParticipants(mapped);
|
||||
|
||||
@ -349,7 +356,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
const maxRetries = 3;
|
||||
const requestOnlineUsers = () => {
|
||||
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 });
|
||||
retryCount++;
|
||||
// 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);
|
||||
}
|
||||
} else {
|
||||
console.log('[WorkNoteChat] ⚠️ Socket not ready, will retry in 200ms... (attempt', retryCount + 1, ')');
|
||||
// Socket not ready - retrying silently
|
||||
retryCount++;
|
||||
if (retryCount < maxRetries) {
|
||||
setTimeout(requestOnlineUsers, 200);
|
||||
@ -574,31 +581,27 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
|
||||
// Handle initial online users list
|
||||
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 => {
|
||||
if (prev.length === 0) {
|
||||
console.log('[WorkNoteChat] ⚠️ No participants loaded yet, cannot update online status. Response will be ignored.');
|
||||
return prev;
|
||||
}
|
||||
|
||||
console.log('[WorkNoteChat] 📊 Updating online status for', prev.length, 'participants');
|
||||
// Updating online status - logging removed
|
||||
const updated = prev.map(p => {
|
||||
const pUserId = (p as any).userId || '';
|
||||
const isCurrentUserSelf = pUserId === currentUserId;
|
||||
|
||||
// Always keep self as online in own browser
|
||||
if (isCurrentUserSelf) {
|
||||
console.log(`[WorkNoteChat] 🟢 ${p.name} (YOU - always online in own view)`);
|
||||
return { ...p, status: 'online' as const };
|
||||
}
|
||||
|
||||
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 };
|
||||
});
|
||||
const onlineCount = updated.filter(p => p.status === 'online').length;
|
||||
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(', '));
|
||||
// Online status updated - logging removed
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
@ -652,13 +655,13 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
};
|
||||
|
||||
// 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')) {
|
||||
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('disconnect', disconnectHandler);
|
||||
s.on('error', errorHandler);
|
||||
@ -667,35 +670,26 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
s.on('presence:leave', presenceLeaveHandler);
|
||||
s.on('presence:online', presenceOnlineHandler);
|
||||
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
|
||||
socketRef.current = s;
|
||||
|
||||
// Always request online users after socket is ready
|
||||
console.log('[WorkNoteChat] 🔌 Socket ready and listeners attached, socket.connected:', s.connected);
|
||||
if (s.connected) {
|
||||
if (participantsLoadedRef.current) {
|
||||
console.log('[WorkNoteChat] 📡 Participants already loaded, requesting online users now (with retries)');
|
||||
// Send multiple requests to ensure we get the response
|
||||
// Requesting online users with retries - logging removed
|
||||
s.emit('request:online-users', { requestId: joinedId });
|
||||
setTimeout(() => {
|
||||
console.log('[WorkNoteChat] 📡 Retry 1: Requesting online users...');
|
||||
s.emit('request:online-users', { requestId: joinedId });
|
||||
}, 300);
|
||||
setTimeout(() => {
|
||||
console.log('[WorkNoteChat] 📡 Retry 2: Requesting online users...');
|
||||
s.emit('request:online-users', { requestId: joinedId });
|
||||
}, 800);
|
||||
setTimeout(() => {
|
||||
console.log('[WorkNoteChat] 📡 Final retry: Requesting online users...');
|
||||
s.emit('request:online-users', { requestId: joinedId });
|
||||
}, 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
|
||||
@ -714,7 +708,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
console.log('[WorkNoteChat] 🚪 Emitting leave:request for room (standalone mode)');
|
||||
}
|
||||
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;
|
||||
} catch {}
|
||||
@ -740,11 +734,7 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
console.log('[WorkNoteChat] 📝 MESSAGE:', message);
|
||||
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 });
|
||||
// Message sending - logging removed
|
||||
|
||||
const attachments = selectedFiles.map(file => ({
|
||||
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()
|
||||
);
|
||||
|
||||
console.log('[WorkNoteChat] Mapped and sorted messages:', sorted.length, 'total');
|
||||
// Messages mapped - logging removed
|
||||
setMessages(sorted);
|
||||
} catch (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[] => {
|
||||
// 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[] = [];
|
||||
let match;
|
||||
while ((match = mentionRegex.exec(text)) !== null) {
|
||||
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);
|
||||
@ -1499,34 +1498,43 @@ export function WorkNoteChat({ requestId, messages: externalMessages, onSend, sk
|
||||
<div className="relative mb-2">
|
||||
{/* 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 hasAt = lastAtIndex >= 0;
|
||||
const textAfterAt = hasAt ? message.slice(lastAtIndex + 1) : '';
|
||||
|
||||
// Don't show if:
|
||||
// 1. No @ found
|
||||
// 2. Text after @ is too long (>20 chars)
|
||||
// 3. Text after @ ends with a space (completed mention)
|
||||
// 4. Text after @ contains a space (already selected a user)
|
||||
if (!hasAt) return null;
|
||||
|
||||
// Get text after the last @
|
||||
const textAfterAt = message.slice(lastAtIndex + 1);
|
||||
|
||||
// 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 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 &&
|
||||
textAfterAt.length <= 20 &&
|
||||
!endsWithSpace &&
|
||||
!containsSpace;
|
||||
|
||||
console.log('[Mention Debug]', {
|
||||
hasAt,
|
||||
textAfterAt: `"${textAfterAt}"`,
|
||||
endsWithSpace,
|
||||
containsSpace,
|
||||
shouldShowDropdown,
|
||||
participantsCount: participants.length
|
||||
});
|
||||
!containsSpaceInMiddle &&
|
||||
!isCompletedMention;
|
||||
|
||||
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 => {
|
||||
// Exclude current user from mention suggestions
|
||||
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
|
||||
});
|
||||
|
||||
console.log('[Mention Debug] Filtered participants:', filteredParticipants.length);
|
||||
|
||||
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">
|
||||
<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) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
// Find the last @ and replace everything from @ to end with the new mention
|
||||
const lastAt = message.lastIndexOf('@');
|
||||
const before = message.slice(0, lastAt);
|
||||
// Add the mention with a space after for easy continuation
|
||||
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"
|
||||
|
||||
@ -1,9 +1,16 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
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 { formatHoursMinutes } from '@/utils/slaTracker';
|
||||
import { useAuth, hasManagementAccess } from '@/contexts/AuthContext';
|
||||
import { updateBreachReason as updateBreachReasonApi } from '@/services/workflowApi';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface ApprovalStep {
|
||||
step: number;
|
||||
@ -33,6 +40,7 @@ interface ApprovalStepCardProps {
|
||||
isCurrentUser?: boolean;
|
||||
isInitiator?: boolean;
|
||||
onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void;
|
||||
onRefresh?: () => void | Promise<void>; // Optional callback to refresh data
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
@ -40,12 +48,12 @@ interface ApprovalStepCardProps {
|
||||
const formatWorkingHours = (hours: number): string => {
|
||||
const WORKING_HOURS_PER_DAY = 8;
|
||||
if (hours < WORKING_HOURS_PER_DAY) {
|
||||
return `${hours.toFixed(1)}h`;
|
||||
return formatHoursMinutes(hours);
|
||||
}
|
||||
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
|
||||
const remainingHours = hours % WORKING_HOURS_PER_DAY;
|
||||
if (remainingHours > 0) {
|
||||
return `${days}d ${remainingHours.toFixed(1)}h`;
|
||||
return `${days}d ${formatHoursMinutes(remainingHours)}`;
|
||||
}
|
||||
return `${days}d`;
|
||||
};
|
||||
@ -75,8 +83,24 @@ export function ApprovalStepCard({
|
||||
isCurrentUser = false,
|
||||
isInitiator = false,
|
||||
onSkipApprover,
|
||||
onRefresh,
|
||||
testId = 'approval-step'
|
||||
}: 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 isCompleted = step.status === 'approved';
|
||||
const isRejected = step.status === 'rejected';
|
||||
@ -85,6 +109,56 @@ export function ApprovalStepCard({
|
||||
const tatHours = Number(step.tatHours || 0);
|
||||
const actualHours = step.actualHours;
|
||||
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 (
|
||||
<div
|
||||
@ -189,21 +263,45 @@ export function ApprovalStepCard({
|
||||
{(() => {
|
||||
// Calculate actual progress percentage based on time used
|
||||
// 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 (
|
||||
<>
|
||||
<Progress
|
||||
value={progressPercentage}
|
||||
className="h-2 bg-gray-200"
|
||||
value={displayPercentage}
|
||||
className={`h-2 bg-gray-200 ${isBreached ? '[&>div]:bg-red-600' : '[&>div]:bg-green-600'}`}
|
||||
data-testid={`${testId}-progress-bar`}
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-green-600 font-semibold">
|
||||
{progressPercentage.toFixed(1)}% of TAT used
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`font-semibold ${isBreached ? 'text-red-600' : 'text-green-600'}`}>
|
||||
{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 && (
|
||||
<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>
|
||||
</>
|
||||
@ -211,6 +309,17 @@ export function ApprovalStepCard({
|
||||
})()}
|
||||
</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 */}
|
||||
{step.comment && (
|
||||
<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 className="flex justify-between">
|
||||
<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>
|
||||
|
||||
@ -268,22 +377,57 @@ export function ApprovalStepCard({
|
||||
data-testid={`${testId}-sla-progress`}
|
||||
/>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className={`text-xs font-semibold ${
|
||||
approval.sla.status === 'breached' ? 'text-red-600' :
|
||||
approval.sla.status === 'critical' ? 'text-orange-600' :
|
||||
'text-yellow-700'
|
||||
}`}>
|
||||
Progress: {approval.sla.percentageUsed}% of TAT used
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs font-semibold ${
|
||||
approval.sla.status === 'breached' ? 'text-red-600' :
|
||||
approval.sla.status === 'critical' ? 'text-orange-600' :
|
||||
'text-yellow-700'
|
||||
}`}>
|
||||
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">
|
||||
{approval.sla.remainingText} remaining
|
||||
</span>
|
||||
</div>
|
||||
{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" />
|
||||
Deadline Breached
|
||||
</p>
|
||||
<>
|
||||
<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" />
|
||||
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' && (
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -96,7 +96,7 @@ export function useRequestDetails(
|
||||
|
||||
// Debug: Log TAT alerts for monitoring
|
||||
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;
|
||||
@ -302,7 +302,7 @@ export function useRequestDetails(
|
||||
const summary = details.summary || {};
|
||||
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 currentLevel = summary?.currentLevel || wf.currentLevel || 1;
|
||||
|
||||
@ -53,7 +53,7 @@ export function useRequestSocket(
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[useRequestSocket] Initializing socket connection for:', requestIdentifier);
|
||||
// Socket connection initialized - logging removed
|
||||
|
||||
let mounted = true;
|
||||
let actualRequestId = requestIdentifier;
|
||||
@ -64,7 +64,7 @@ export function useRequestSocket(
|
||||
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
||||
if (details?.workflow?.requestId && mounted) {
|
||||
actualRequestId = details.workflow.requestId;
|
||||
console.log('[useRequestSocket] Resolved UUID:', actualRequestId);
|
||||
// UUID resolved - logging removed
|
||||
}
|
||||
} catch (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
|
||||
*/
|
||||
const handleConnect = () => {
|
||||
console.log('[useRequestSocket] Socket connected, joining room:', actualRequestId);
|
||||
// Socket connected - joining room
|
||||
joinRequestRoom(socket, actualRequestId, userId);
|
||||
console.log(`[useRequestSocket] ✅ Joined room: ${actualRequestId} - User is ONLINE`);
|
||||
};
|
||||
|
||||
// Join immediately if already connected, otherwise wait for connect event
|
||||
@ -107,7 +106,7 @@ export function useRequestSocket(
|
||||
if (mounted) {
|
||||
socket.off('connect', handleConnect);
|
||||
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);
|
||||
console.log(`[useRequestSocket] Merged ${workNotes.length} work notes with ${activities.length} activities`);
|
||||
// Messages merged - logging removed
|
||||
} catch (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
|
||||
*/
|
||||
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)
|
||||
if (activeTab !== 'worknotes') {
|
||||
@ -209,11 +208,9 @@ export function useRequestSocket(
|
||||
* 3. Show browser notification if permission granted
|
||||
*/
|
||||
const handleTatAlert = (data: any) => {
|
||||
console.log(`[useRequestSocket] 🔔 Real-time TAT alert received:`, data);
|
||||
|
||||
// Visual feedback in console with emoji
|
||||
// TAT alert received - single line log only
|
||||
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
|
||||
(async () => {
|
||||
|
||||
994
src/pages/ApproverPerformance/ApproverPerformance.tsx
Normal file
994
src/pages/ApproverPerformance/ApproverPerformance.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -107,6 +107,13 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||
const [totalRecords, setTotalRecords] = useState(0);
|
||||
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 }) => {
|
||||
try {
|
||||
if (page === 1) {
|
||||
@ -116,6 +123,9 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||
|
||||
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({
|
||||
page,
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900">Closed Requests</h1>
|
||||
<p className="text-sm sm:text-base text-gray-600">Review completed and archived requests</p>
|
||||
<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 your completed and archived requests</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -46,6 +46,7 @@ import {
|
||||
import { format } from 'date-fns';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
import { PolicyViolationModal } from '@/components/modals/PolicyViolationModal';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CreateRequestProps {
|
||||
@ -247,7 +248,7 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
open: false,
|
||||
errors: []
|
||||
});
|
||||
|
||||
|
||||
// Validation modal states
|
||||
const [validationModal, setValidationModal] = useState<{
|
||||
open: boolean;
|
||||
@ -261,31 +262,70 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
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(() => {
|
||||
const loadDocumentPolicy = async () => {
|
||||
const loadPolicies = async () => {
|
||||
try {
|
||||
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||
const configMap: Record<string, string> = {};
|
||||
configs.forEach((c: AdminConfiguration) => {
|
||||
configMap[c.configKey] = c.configValue;
|
||||
// Load document policy
|
||||
const docConfigs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||
const docConfigMap: Record<string, string> = {};
|
||||
docConfigs.forEach((c: AdminConfiguration) => {
|
||||
docConfigMap[c.configKey] = c.configValue;
|
||||
});
|
||||
|
||||
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10');
|
||||
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
|
||||
const maxFileSizeMB = parseInt(docConfigMap['MAX_FILE_SIZE_MB'] || '10');
|
||||
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());
|
||||
|
||||
setDocumentPolicy({
|
||||
maxFileSizeMB,
|
||||
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) {
|
||||
console.error('Failed to load document policy:', error);
|
||||
console.error('Failed to load policies:', error);
|
||||
// Use defaults if loading fails
|
||||
}
|
||||
};
|
||||
|
||||
loadDocumentPolicy();
|
||||
loadPolicies();
|
||||
}, []);
|
||||
|
||||
// Fetch draft data when in edit mode
|
||||
@ -559,7 +599,16 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
displayName: foundUser.displayName,
|
||||
firstName: foundUser.firstName,
|
||||
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
|
||||
@ -685,15 +734,82 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
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
|
||||
const updatedList = [...currentList, user];
|
||||
updateFormData(type, updatedList);
|
||||
|
||||
// Update max level if adding approver
|
||||
if (type === 'approvers') {
|
||||
const maxApproverLevel = Math.max(...updatedList.map((a: any) => a.level), 0);
|
||||
updateFormData('maxLevel', maxApproverLevel);
|
||||
const updatedList = [...currentList, user];
|
||||
updateFormData(type, updatedList);
|
||||
|
||||
// Update max level if adding approver
|
||||
if (type === 'approvers') {
|
||||
const maxApproverLevel = Math.max(...updatedList.map((a: any) => a.level || 1), 1);
|
||||
updateFormData('maxLevel', maxApproverLevel);
|
||||
}
|
||||
};
|
||||
|
||||
@ -786,7 +902,16 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
displayName: foundUser.displayName,
|
||||
firstName: foundUser.firstName,
|
||||
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
|
||||
@ -1896,7 +2021,16 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
displayName: u.displayName,
|
||||
firstName: u.firstName,
|
||||
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
|
||||
dbUserId = dbUser.userId;
|
||||
@ -2290,7 +2424,16 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
displayName: u.displayName,
|
||||
firstName: u.firstName,
|
||||
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
|
||||
dbUserId = dbUser.userId;
|
||||
@ -3340,6 +3483,19 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -35,8 +35,10 @@ import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, Responsi
|
||||
import { KPICard } from '@/components/dashboard/KPICard';
|
||||
import { StatCard } from '@/components/dashboard/StatCard';
|
||||
import { CriticalAlertCard, CriticalAlertData } from '@/components/dashboard/CriticalAlertCard';
|
||||
import type { CriticalRequest } from '@/services/dashboard.service';
|
||||
import { ActivityFeedItem, ActivityData } from '@/components/dashboard/ActivityFeedItem';
|
||||
import { Pagination } from '@/components/common/Pagination';
|
||||
import { formatHoursMinutes } from '@/utils/slaTracker';
|
||||
|
||||
interface DashboardProps {
|
||||
onNavigate?: (page: string) => void;
|
||||
@ -48,7 +50,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
const { user } = useAuth();
|
||||
const [kpis, setKpis] = useState<DashboardKPIs | null>(null);
|
||||
const [recentActivity, setRecentActivity] = useState<ActivityData[]>([]);
|
||||
const [criticalRequests, setCriticalRequests] = useState<CriticalAlertData[]>([]);
|
||||
const [criticalRequests, setCriticalRequests] = useState<(CriticalRequest | CriticalAlertData)[]>([]);
|
||||
const [departmentStats, setDepartmentStats] = useState<any[]>([]);
|
||||
const [priorityDistribution, setPriorityDistribution] = useState<any[]>([]);
|
||||
const [upcomingDeadlines, setUpcomingDeadlines] = useState<any[]>([]);
|
||||
@ -57,6 +59,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [exportingDeptStats, setExportingDeptStats] = useState(false);
|
||||
const [exportingApproverPerformance, setExportingApproverPerformance] = useState(false);
|
||||
|
||||
// Filter states
|
||||
const [dateRange, setDateRange] = useState<DateRange>('month');
|
||||
@ -86,10 +89,33 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
return hasManagementAccess(user);
|
||||
}, [user]);
|
||||
|
||||
// Backend now returns only breached requests for TAT Breach Report
|
||||
// But we still filter to ensure data integrity
|
||||
// Backend returns critical requests including pending requests that have breached TAT
|
||||
// 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(() => {
|
||||
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]);
|
||||
|
||||
// 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
|
||||
const exportDepartmentStatsToCSV = async () => {
|
||||
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' }
|
||||
], [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) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-screen">
|
||||
@ -601,14 +729,29 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
iconBgColor="bg-blue-50"
|
||||
iconColor="text-blue-600"
|
||||
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
|
||||
label="Approved"
|
||||
value={kpis?.requestVolume.approvedRequests || 0}
|
||||
bgColor="bg-green-50"
|
||||
textColor="text-green-600"
|
||||
testId="stat-approved"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleKPIClick({
|
||||
status: 'approved',
|
||||
dateRange: dateRange,
|
||||
startDate: customStartDate,
|
||||
endDate: customEndDate
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
label="Rejected"
|
||||
@ -616,13 +759,50 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
bgColor="bg-red-50"
|
||||
textColor="text-red-600"
|
||||
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
|
||||
label="Pending"
|
||||
value={kpis?.requestVolume.openRequests || 0}
|
||||
bgColor="bg-orange-50"
|
||||
textColor="text-orange-600"
|
||||
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>
|
||||
</KPICard>
|
||||
@ -635,8 +815,13 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
iconBgColor="bg-green-50"
|
||||
iconColor="text-green-600"
|
||||
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">
|
||||
<StatCard
|
||||
label="Compliant"
|
||||
@ -644,6 +829,15 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
bgColor="bg-green-50"
|
||||
textColor="text-green-600"
|
||||
testId="stat-compliant"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleKPIClick({
|
||||
slaCompliance: 'compliant',
|
||||
dateRange: dateRange,
|
||||
startDate: customStartDate,
|
||||
endDate: customEndDate
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
label="Breached"
|
||||
@ -651,6 +845,15 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
bgColor="bg-red-50"
|
||||
textColor="text-red-600"
|
||||
testId="stat-breached"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleKPIClick({
|
||||
slaCompliance: 'breached',
|
||||
dateRange: dateRange,
|
||||
startDate: customStartDate,
|
||||
endDate: customEndDate
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</KPICard>
|
||||
@ -658,12 +861,17 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
{/* Avg Cycle Time */}
|
||||
<KPICard
|
||||
title="Avg Cycle Time"
|
||||
value={`${kpis?.tatEfficiency.avgCycleTimeHours.toFixed(1) || 0}`}
|
||||
value={kpis?.tatEfficiency.avgCycleTimeHours ? formatHoursMinutes(kpis.tatEfficiency.avgCycleTimeHours) : '0h'}
|
||||
icon={Clock}
|
||||
iconBgColor="bg-purple-50"
|
||||
iconColor="text-purple-600"
|
||||
subtitle={`≈ ${kpis?.tatEfficiency.avgCycleTimeDays.toFixed(1) || 0} working days`}
|
||||
testId="kpi-avg-cycle-time"
|
||||
onClick={() => handleKPIClick({
|
||||
dateRange: dateRange,
|
||||
startDate: customStartDate,
|
||||
endDate: customEndDate
|
||||
})}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<StatCard
|
||||
@ -671,22 +879,40 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
value={(() => {
|
||||
const express = priorityDistribution.find(p => p.priority === 'express');
|
||||
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"
|
||||
textColor="text-orange-600"
|
||||
testId="stat-express-time"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleKPIClick({
|
||||
priority: 'express',
|
||||
dateRange: dateRange,
|
||||
startDate: customStartDate,
|
||||
endDate: customEndDate
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<StatCard
|
||||
label="Standard"
|
||||
value={(() => {
|
||||
const standard = priorityDistribution.find(p => p.priority === 'standard');
|
||||
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"
|
||||
textColor="text-blue-600"
|
||||
testId="stat-standard-time"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleKPIClick({
|
||||
priority: 'standard',
|
||||
dateRange: dateRange,
|
||||
startDate: customStartDate,
|
||||
endDate: customEndDate
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</KPICard>
|
||||
@ -704,11 +930,61 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
iconBgColor="bg-blue-50"
|
||||
iconColor="text-blue-600"
|
||||
testId="kpi-my-requests"
|
||||
onClick={() => handleKPIClick({
|
||||
dateRange: dateRange,
|
||||
startDate: customStartDate,
|
||||
endDate: customEndDate
|
||||
})}
|
||||
>
|
||||
<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 label="Pending" value={kpis?.requestVolume.openRequests || 0} bgColor="bg-orange-50" textColor="text-orange-600" testId="stat-user-pending" />
|
||||
<StatCard label="Draft" value={kpis?.requestVolume.draftRequests || 0} bgColor="bg-gray-50" textColor="text-gray-600" testId="stat-user-draft" />
|
||||
<StatCard
|
||||
label="Approved"
|
||||
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>
|
||||
</KPICard>
|
||||
|
||||
@ -1079,7 +1355,32 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
<XAxis
|
||||
dataKey="department"
|
||||
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
|
||||
stroke="#999"
|
||||
@ -1153,9 +1454,8 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-2xl sm:text-3xl font-bold text-purple-600">
|
||||
{kpis.tatEfficiency.avgCycleTimeHours.toFixed(1)}
|
||||
{formatHoursMinutes(kpis.tatEfficiency.avgCycleTimeHours)}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">hours</span>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="text-xs text-muted-foreground text-center">
|
||||
@ -1201,7 +1501,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
{priority.totalCount}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
Avg: {avgCycleTime.toFixed(1)}h cycle
|
||||
Avg: {formatHoursMinutes(avgCycleTime)} cycle
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@ -1216,6 +1516,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
data={priorityDistribution.map(p => ({
|
||||
name: p.priority.charAt(0).toUpperCase() + p.priority.slice(1),
|
||||
value: p.totalCount,
|
||||
priority: p.priority,
|
||||
percentage: Math.round((p.totalCount / priorityDistribution.reduce((sum, item) => sum + item.totalCount, 0)) * 100)
|
||||
}))}
|
||||
cx="50%"
|
||||
@ -1248,9 +1549,20 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
outerRadius={90}
|
||||
fill="#8884d8"
|
||||
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) => (
|
||||
<Cell key={`cell-${index}`} fill={priority.priority === 'express' ? '#ef4444' : '#3b82f6'} />
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={priority.priority === 'express' ? '#ef4444' : '#3b82f6'}
|
||||
style={{ cursor: 'pointer' }}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
@ -1260,6 +1572,13 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
borderRadius: '6px',
|
||||
fontSize: '12px'
|
||||
}}
|
||||
formatter={(value: any, _name: any, props: any) => {
|
||||
const priority = props.payload?.priority || '';
|
||||
return [
|
||||
`${value} requests`,
|
||||
`Click for ${priority}`
|
||||
];
|
||||
}}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</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">Department</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Approver</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Level</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">Breach Time</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-700">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>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -1309,10 +1629,10 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
const formatBreachTime = (hours: number) => {
|
||||
if (hours <= 0) return 'Just breached';
|
||||
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 remainingHours = hours % 24;
|
||||
return remainingHours > 0 ? `${days}d ${remainingHours.toFixed(1)}h` : `${days}d`;
|
||||
return remainingHours > 0 ? `${days}d ${formatHoursMinutes(remainingHours)}` : `${days}d`;
|
||||
};
|
||||
|
||||
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}>
|
||||
{req.title}
|
||||
</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'}
|
||||
</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">
|
||||
{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 className="py-3 px-4">
|
||||
<span className="bg-red-500 text-white px-2 py-1 rounded text-xs font-medium">
|
||||
{formatBreachTime(breachTime)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-3 px-4 text-sm text-gray-700">
|
||||
{req.breachReason || 'TAT Exceeded'}
|
||||
<td className="py-3 px-4 text-sm text-gray-700 min-w-[200px] max-w-[300px]">
|
||||
<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 className="py-3 px-4">
|
||||
<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'}`}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{elapsedHours.toFixed(1)}h elapsed</span>
|
||||
<span>{remainingHours.toFixed(1)}h left</span>
|
||||
<span>{formatHoursMinutes(elapsedHours)} elapsed</span>
|
||||
<span>{formatHoursMinutes(remainingHours)} left</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -1530,14 +1898,34 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
{isAdmin && approverPerformance.length > 0 && (
|
||||
<Card className="shadow-md hover:shadow-lg transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-yellow-50 p-2 sm:p-3 rounded-lg">
|
||||
<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>
|
||||
<CardDescription className="text-xs sm:text-sm">Response time & TAT compliance tracking</CardDescription>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-yellow-50 p-2 sm:p-3 rounded-lg">
|
||||
<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>
|
||||
<CardDescription className="text-xs sm:text-sm">Response time & TAT compliance tracking</CardDescription>
|
||||
</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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -1552,7 +1940,21 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
};
|
||||
|
||||
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 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">
|
||||
@ -1570,7 +1972,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
|
||||
<div className="grid grid-cols-2 gap-4 mt-3">
|
||||
<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 className="text-xs text-gray-500">Pending</div>
|
||||
|
||||
@ -6,6 +6,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
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 {
|
||||
ArrowLeft,
|
||||
FileText,
|
||||
@ -16,9 +18,12 @@ import {
|
||||
ChevronRight,
|
||||
Search,
|
||||
Loader2,
|
||||
AlertCircle
|
||||
AlertCircle,
|
||||
Calendar as CalendarIcon
|
||||
} 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 {
|
||||
onBack?: () => void;
|
||||
@ -27,8 +32,34 @@ interface DetailedReportsProps {
|
||||
export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
const navigate = useNavigate();
|
||||
const [threshold, setThreshold] = useState('7');
|
||||
const [thresholdError, setThresholdError] = useState<string | null>(null);
|
||||
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
|
||||
const [filterCategory, setFilterCategory] = 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 => {
|
||||
if (!hours && hours !== 0) return 'N/A';
|
||||
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 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
|
||||
@ -156,7 +187,13 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
setLoadingLifecyclePage(true);
|
||||
}
|
||||
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 overallTAT = formatTAT(req.overallTATHours);
|
||||
@ -191,7 +228,7 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
setLoadingLifecyclePage(false);
|
||||
}
|
||||
}
|
||||
}, [lifecycleRequests.length]);
|
||||
}, [lifecycleRequests.length, lifecycleDateRange, lifecycleCustomStartDate, lifecycleCustomEndDate]);
|
||||
|
||||
// Fetch User Activity Log data
|
||||
const fetchActivityData = useCallback(async (page: number = 1) => {
|
||||
@ -207,11 +244,13 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
const result = await dashboardService.getActivityLogReport(
|
||||
page,
|
||||
RECORDS_PER_PAGE,
|
||||
undefined, // dateRange
|
||||
activityDateRange, // dateRange
|
||||
undefined, // filterUserId
|
||||
undefined, // filterType
|
||||
filterCategory && filterCategory !== 'all' ? filterCategory : undefined,
|
||||
filterSeverity && filterSeverity !== 'all' ? filterSeverity : undefined
|
||||
filterSeverity && filterSeverity !== 'all' ? filterSeverity : undefined,
|
||||
activityCustomStartDate,
|
||||
activityCustomEndDate
|
||||
);
|
||||
|
||||
const mapped = result.activities.map((activity: any) => {
|
||||
@ -252,7 +291,7 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
setLoadingActivityPage(false);
|
||||
}
|
||||
}
|
||||
}, [activityLog.length, filterCategory, filterSeverity]);
|
||||
}, [activityLog.length, filterCategory, filterSeverity, activityDateRange, activityCustomStartDate, activityCustomEndDate]);
|
||||
|
||||
// Fetch Workflow Aging Report data
|
||||
const fetchAgingData = useCallback(async (page: number = 1) => {
|
||||
@ -266,7 +305,21 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
}
|
||||
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) => {
|
||||
return {
|
||||
@ -297,7 +350,7 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
setLoadingAgingPage(false);
|
||||
}
|
||||
}
|
||||
}, [threshold, agingWorkflows.length]);
|
||||
}, [threshold, agingWorkflows.length, agingDateRange, agingCustomStartDate, agingCustomEndDate]);
|
||||
|
||||
// Fetch all data on mount
|
||||
useEffect(() => {
|
||||
@ -306,22 +359,148 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
fetchAgingData(1);
|
||||
}, [fetchLifecycleData, fetchActivityData, fetchAgingData]);
|
||||
|
||||
// Refetch aging data when threshold changes
|
||||
// Refetch lifecycle data when date range changes
|
||||
useEffect(() => {
|
||||
fetchAgingData(1);
|
||||
}, [threshold, fetchAgingData]);
|
||||
// If custom range is selected but dates are not set, don't fetch yet
|
||||
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(() => {
|
||||
// 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);
|
||||
}, [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
|
||||
const exportLifecycleToCSV = async () => {
|
||||
try {
|
||||
setExportingLifecycle(true);
|
||||
// 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
|
||||
const csvRows = [
|
||||
@ -386,7 +565,17 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
try {
|
||||
setExportingActivity(true);
|
||||
// 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 = [
|
||||
['Timestamp', 'User', 'Action', 'Details', 'IP Address', 'User Agent', 'Request ID'].join(',')
|
||||
@ -430,7 +619,14 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
try {
|
||||
setExportingAging(true);
|
||||
// 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 = [
|
||||
['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'}
|
||||
</Button>
|
||||
</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>
|
||||
<CardContent>
|
||||
{loadingLifecycle ? (
|
||||
@ -610,12 +909,23 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
<div className="space-y-4">
|
||||
{lifecycleRequests.map((request) => (
|
||||
<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 gap-4">
|
||||
<div className="flex flex-col">
|
||||
<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)}>
|
||||
{request.priority}
|
||||
</Badge>
|
||||
@ -719,7 +1029,110 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
{exportingActivity ? 'Exporting...' : 'Download CSV'}
|
||||
</Button>
|
||||
</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">
|
||||
<label className="text-sm text-gray-600 whitespace-nowrap">Category:</label>
|
||||
<Select value={filterCategory} onValueChange={setFilterCategory}>
|
||||
@ -752,13 +1165,17 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{(filterCategory !== 'all' || filterSeverity !== 'all') && (
|
||||
{(filterCategory !== 'all' || filterSeverity !== 'all' || activityDateRange !== 'month' || activityCustomStartDate || activityCustomEndDate) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setFilterCategory('all');
|
||||
setFilterSeverity('all');
|
||||
setActivityDateRange('month');
|
||||
setActivityCustomStartDate(undefined);
|
||||
setActivityCustomEndDate(undefined);
|
||||
setShowActivityCustomDatePicker(false);
|
||||
}}
|
||||
className="text-xs"
|
||||
>
|
||||
@ -814,10 +1231,21 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{activity.requestId !== '-' ? (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{activity.requestId}
|
||||
</Badge>
|
||||
{activity.requestId !== '-' && activity.requestId !== 'System Login' ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
// 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>
|
||||
)}
|
||||
@ -876,19 +1304,51 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
</CardDescription>
|
||||
</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">
|
||||
<label className="text-sm text-gray-600">Threshold:</label>
|
||||
<Select value={threshold} onValueChange={setThreshold}>
|
||||
<SelectTrigger className="w-32">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">> 7 days</SelectItem>
|
||||
<SelectItem value="14">> 14 days</SelectItem>
|
||||
<SelectItem value="30">> 30 days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<label className="text-sm text-gray-600 whitespace-nowrap">Threshold:</label>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
value={threshold}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
// Allow empty string while typing
|
||||
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>
|
||||
<Badge variant="outline" className="font-semibold">
|
||||
{agingTotalRecords} workflows
|
||||
@ -905,8 +1365,111 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<div className="relative">
|
||||
<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={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" />
|
||||
<Input
|
||||
placeholder="Search by Request ID, Title, or Initiator..."
|
||||
@ -951,7 +1514,14 @@ export function DetailedReports({ onBack }: DetailedReportsProps) {
|
||||
<TableBody>
|
||||
{filteredAgingWorkflows.map((workflow) => (
|
||||
<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.initiator}
|
||||
|
||||
@ -114,6 +114,13 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
const [totalRecords, setTotalRecords] = useState(0);
|
||||
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 }) => {
|
||||
try {
|
||||
if (page === 1) {
|
||||
@ -121,6 +128,9 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
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({
|
||||
page,
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900">Open Requests</h1>
|
||||
<p className="text-sm sm:text-base text-gray-600">Manage and track active approval requests</p>
|
||||
<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 your active approval requests</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -870,6 +870,7 @@ function RequestDetailInner({
|
||||
setSkipApproverData(data);
|
||||
setShowSkipApproverModal(true);
|
||||
}}
|
||||
onRefresh={refreshDetails}
|
||||
testId="workflow-step"
|
||||
/>
|
||||
);
|
||||
|
||||
2212
src/pages/Requests/Requests.tsx
Normal file
2212
src/pages/Requests/Requests.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@ -124,7 +124,7 @@ export function Settings() {
|
||||
<Card className="shadow-lg hover:shadow-xl transition-all duration-300 border-0 rounded-md group">
|
||||
<CardHeader className="pb-4">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
@ -138,7 +138,7 @@ export function Settings() {
|
||||
<Button
|
||||
onClick={handleEnableNotifications}
|
||||
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' : ''}`} />
|
||||
{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">
|
||||
<CardHeader className="pb-4">
|
||||
<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" />
|
||||
</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">
|
||||
<CardHeader className="pb-4">
|
||||
<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" />
|
||||
</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">
|
||||
<CardHeader className="pb-4">
|
||||
<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" />
|
||||
</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">
|
||||
<CardHeader className="pb-4">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
@ -253,7 +253,7 @@ export function Settings() {
|
||||
<Button
|
||||
onClick={handleEnableNotifications}
|
||||
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' : ''}`} />
|
||||
{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">
|
||||
<CardHeader className="pb-4">
|
||||
<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" />
|
||||
</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">
|
||||
<CardHeader className="pb-4">
|
||||
<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" />
|
||||
</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">
|
||||
<CardHeader className="pb-4">
|
||||
<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" />
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@ -5,6 +5,7 @@ export interface RequestStats {
|
||||
openRequests: number;
|
||||
approvedRequests: number;
|
||||
rejectedRequests: number;
|
||||
closedRequests: number;
|
||||
draftRequests: number;
|
||||
changeFromPrevious: {
|
||||
total: string;
|
||||
@ -98,6 +99,8 @@ export interface CriticalRequest {
|
||||
originalTATHours: number;
|
||||
breachCount: number;
|
||||
isCritical: boolean;
|
||||
approverId?: string | null;
|
||||
approverEmail?: string | null;
|
||||
}
|
||||
|
||||
export interface AIRemarkUtilization {
|
||||
@ -178,11 +181,14 @@ class DashboardService {
|
||||
/**
|
||||
* Get request statistics
|
||||
*/
|
||||
async getRequestStats(dateRange?: DateRange): Promise<RequestStats> {
|
||||
async getRequestStats(dateRange?: DateRange, startDate?: string, endDate?: string): Promise<RequestStats> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/requests', {
|
||||
params: { dateRange }
|
||||
});
|
||||
const params: any = { 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;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch request stats:', error);
|
||||
@ -414,7 +420,13 @@ class DashboardService {
|
||||
/**
|
||||
* 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[],
|
||||
pagination: {
|
||||
currentPage: number,
|
||||
@ -424,9 +436,13 @@ class DashboardService {
|
||||
}
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/reports/lifecycle', {
|
||||
params: { page, limit }
|
||||
});
|
||||
const params: any = { 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 {
|
||||
lifecycleData: response.data.data,
|
||||
pagination: response.data.pagination
|
||||
@ -447,7 +463,9 @@ class DashboardService {
|
||||
filterUserId?: string,
|
||||
filterType?: string,
|
||||
filterCategory?: string,
|
||||
filterSeverity?: string
|
||||
filterSeverity?: string,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
): Promise<{
|
||||
activities: any[],
|
||||
pagination: {
|
||||
@ -458,9 +476,13 @@ class DashboardService {
|
||||
}
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/reports/activity-log', {
|
||||
params: { page, limit, dateRange, filterUserId, filterType, filterCategory, filterSeverity }
|
||||
});
|
||||
const params: any = { page, limit, 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 {
|
||||
activities: response.data.data,
|
||||
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
|
||||
*/
|
||||
@ -478,7 +513,9 @@ class DashboardService {
|
||||
threshold: number = 7,
|
||||
page: number = 1,
|
||||
limit: number = 50,
|
||||
dateRange?: DateRange
|
||||
dateRange?: DateRange,
|
||||
startDate?: Date,
|
||||
endDate?: Date
|
||||
): Promise<{
|
||||
agingData: any[],
|
||||
pagination: {
|
||||
@ -489,9 +526,13 @@ class DashboardService {
|
||||
}
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/reports/workflow-aging', {
|
||||
params: { threshold, page, limit, dateRange }
|
||||
});
|
||||
const params: any = { threshold, 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/workflow-aging', { params });
|
||||
return {
|
||||
agingData: response.data.data,
|
||||
pagination: response.data.pagination
|
||||
@ -501,6 +542,52 @@ class DashboardService {
|
||||
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();
|
||||
|
||||
@ -7,7 +7,20 @@ export interface UserSummary {
|
||||
firstName?: string;
|
||||
lastName?: string;
|
||||
department?: 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;
|
||||
};
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
@ -29,6 +42,19 @@ export async function ensureUserExists(userData: {
|
||||
lastName?: string;
|
||||
department?: 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> {
|
||||
const res = await apiClient.post('/users/ensure', userData);
|
||||
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)
|
||||
* @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') {
|
||||
return await apiClient.post('/admin/users/assign-role', { email, role });
|
||||
export async function assignRole(
|
||||
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');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 = {
|
||||
searchUsers,
|
||||
ensureUserExists,
|
||||
assignRole,
|
||||
updateUserRole,
|
||||
getUsersByRole,
|
||||
getRoleStatistics
|
||||
getRoleStatistics,
|
||||
getAllUsers
|
||||
};
|
||||
|
||||
export default userApi;
|
||||
|
||||
@ -403,6 +403,19 @@ export async function updateAndSubmitWorkflow(requestId: string, workflowData: C
|
||||
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
|
||||
// Note: keeping separate named export above for tree-shaking
|
||||
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
Loading…
Reference in New Issue
Block a user