added user tab in the admin and also code refactored

This commit is contained in:
laxmanhalaki 2025-11-12 16:41:29 +05:30
parent 231d99ad95
commit f022cbf899
33 changed files with 4487 additions and 2586 deletions

193
ROLE_MIGRATION.md Normal file
View File

@ -0,0 +1,193 @@
# Frontend Role Migration - isAdmin → role
## 🎯 Overview
Migrated frontend from `isAdmin: boolean` to `role: 'USER' | 'MANAGEMENT' | 'ADMIN'` to match the new backend RBAC system.
---
## ✅ Files Updated
### 1. **Type Definitions**
#### `src/contexts/AuthContext.tsx`
- ✅ Updated `User` interface: `isAdmin?: boolean``role?: 'USER' | 'MANAGEMENT' | 'ADMIN'`
- ✅ Added helper functions:
- `isAdmin(user)` - Checks if user is ADMIN
- `isManagement(user)` - Checks if user is MANAGEMENT
- `hasManagementAccess(user)` - Checks if user is MANAGEMENT or ADMIN
- `hasAdminAccess(user)` - Checks if user is ADMIN (same as isAdmin)
#### `src/services/authApi.ts`
- ✅ Updated `TokenExchangeResponse` interface: `isAdmin: boolean``role: 'USER' | 'MANAGEMENT' | 'ADMIN'`
---
### 2. **Components Updated**
#### `src/pages/Dashboard/Dashboard.tsx`
**Changes:**
- ✅ Imported `isAdmin as checkIsAdmin` from AuthContext
- ✅ Updated role check: `(user as any)?.isAdmin || false``checkIsAdmin(user)`
- ✅ All conditional rendering now uses the helper function
**Admin Features (shown only for ADMIN role):**
- Organization-wide analytics
- Admin View badge
- Export button
- Department-wise workflow summary
- Priority distribution report
- TAT breach report
- AI remark utilization report
- Approver performance report
---
#### `src/pages/Settings/Settings.tsx`
**Changes:**
- ✅ Imported `isAdmin as checkIsAdmin` from AuthContext
- ✅ Updated role check: `(user as any)?.isAdmin``checkIsAdmin(user)`
**Admin Features:**
- Configuration Manager tab
- Holiday Manager tab
- System Settings tab
---
#### `src/pages/Profile/Profile.tsx`
**Changes:**
- ✅ Imported `isAdmin` and `isManagement` helpers from AuthContext
- ✅ Added `Users` icon import for Management badge
- ✅ Updated all `user?.isAdmin` checks to use `isAdmin(user)`
- ✅ Added Management badge display for MANAGEMENT role
- ✅ Updated role display to show:
- **Administrator** badge (yellow) for ADMIN
- **Management** badge (blue) for MANAGEMENT
- **User** badge (gray) for USER
**New Visual Indicators:**
- 🟡 Yellow shield icon for ADMIN users
- 🔵 Blue users icon for MANAGEMENT users
- Role badge on profile card
- Role badge in header section
---
#### `src/pages/Auth/AuthenticatedApp.tsx`
**Changes:**
- ✅ Updated console log: `'Is Admin:', user.isAdmin``'Role:', user.role`
---
## 🎨 **Visual Changes**
### Profile Page Badges
**Before:**
```
🟡 Administrator (only for admins)
```
**After:**
```
🟡 Administrator (for ADMIN)
🔵 Management (for MANAGEMENT)
```
### Role Display
**Before:**
- Administrator / User
**After:**
- Administrator (yellow badge, green checkmark)
- Management (blue badge, green checkmark)
- User (gray badge, no checkmark)
---
## 🔧 **Helper Functions Usage**
### In Components:
```typescript
import { useAuth, isAdmin, isManagement, hasManagementAccess } from '@/contexts/AuthContext';
const { user } = useAuth();
// Check if user is admin
if (isAdmin(user)) {
// Show admin-only features
}
// Check if user is management
if (isManagement(user)) {
// Show management-only features
}
// Check if user has management access (MANAGEMENT or ADMIN)
if (hasManagementAccess(user)) {
// Show features for both management and admin
}
```
---
## 🚀 **Migration Benefits**
1. **Type Safety** - Role is now a union type, catching errors at compile time
2. **Flexibility** - Easy to add more roles (e.g., AUDITOR, VIEWER)
3. **Granular Access** - Can differentiate between MANAGEMENT and ADMIN
4. **Consistency** - Frontend now matches backend RBAC system
5. **Helper Functions** - Cleaner code with reusable role checks
---
## 📊 **Access Levels**
| Feature | USER | MANAGEMENT | ADMIN |
|---------|------|------------|-------|
| View own requests | ✅ | ✅ | ✅ |
| View own dashboard | ✅ | ✅ | ✅ |
| View all requests | ❌ | ✅ | ✅ |
| View organization-wide analytics | ❌ | ✅ | ✅ |
| Export data | ❌ | ❌ | ✅ |
| Manage system configuration | ❌ | ❌ | ✅ |
| Manage holidays | ❌ | ❌ | ✅ |
| View TAT breach reports | ❌ | ❌ | ✅ |
| View approver performance | ❌ | ❌ | ✅ |
---
## ✅ **Testing Checklist**
- [ ] Login as USER - verify limited access
- [ ] Login as MANAGEMENT - verify read access to all data
- [ ] Login as ADMIN - verify full access
- [ ] Profile page shows correct role badge
- [ ] Dashboard shows appropriate views per role
- [ ] Settings page shows tabs only for ADMIN
- [ ] No console errors related to role checks
---
## 🔄 **Backward Compatibility**
**None** - This is a breaking change. All users must be assigned a role in the database:
```sql
-- Default all users to USER role
UPDATE users SET role = 'USER' WHERE role IS NULL;
-- Assign specific roles
UPDATE users SET role = 'ADMIN' WHERE email = 'admin@royalenfield.com';
UPDATE users SET role = 'MANAGEMENT' WHERE email = 'manager@royalenfield.com';
```
---
## 🎉 **Deployment Ready**
All changes are complete and linter-clean. Frontend now fully supports the new RBAC system!

339
USER_ROLE_MANAGEMENT.md Normal file
View File

@ -0,0 +1,339 @@
# User Role Management Feature
## 🎯 Overview
Added a comprehensive User Role Management system for administrators to assign roles to users directly from the Settings page.
---
## ✅ What Was Built
### Frontend Components
#### 1. **UserRoleManager Component**
Location: `src/components/admin/UserRoleManager/UserRoleManager.tsx`
**Features:**
- **Search Users from Okta** - Real-time search with debouncing
- **Role Assignment** - Assign USER, MANAGEMENT, or ADMIN roles
- **Statistics Dashboard** - Shows count of users in each role
- **Elevated Users List** - Displays all ADMIN and MANAGEMENT users
- **Auto-create Users** - If user doesn't exist in database, fetches from Okta and creates them
- **Self-demotion Prevention** - Admin cannot demote themselves
**UI Components:**
- Statistics cards showing admin/management/user counts
- Search input with dropdown results
- Selected user card display
- Role selector dropdown
- Assign button with loading state
- Success/error message display
- Elevated users list with role badges
---
### Backend APIs
#### 2. **New Route: Assign Role by Email**
`POST /api/v1/admin/users/assign-role`
**Purpose:** Assign role to user by email (creates user from Okta if doesn't exist)
**Request:**
```json
{
"email": "user@royalenfield.com",
"role": "MANAGEMENT" // or "USER" or "ADMIN"
}
```
**Response:**
```json
{
"success": true,
"message": "Successfully assigned MANAGEMENT role to John Doe",
"data": {
"userId": "abc-123",
"email": "user@royalenfield.com",
"displayName": "John Doe",
"role": "MANAGEMENT"
}
}
```
**Flow:**
1. Check if user exists in database by email
2. If not exists → Search Okta API
3. If found in Okta → Create user in database with assigned role
4. If exists → Update user's role
5. Prevent self-demotion (admin demoting themselves)
---
#### 3. **Existing Routes (Already Created)**
**Get Users by Role**
```
GET /api/v1/admin/users/by-role?role=ADMIN
GET /api/v1/admin/users/by-role?role=MANAGEMENT
```
**Get Role Statistics**
```
GET /api/v1/admin/users/role-statistics
```
Response:
```json
{
"success": true,
"data": {
"statistics": [
{ "role": "ADMIN", "count": 3 },
{ "role": "MANAGEMENT", "count": 12 },
{ "role": "USER", "count": 145 }
],
"total": 160
}
}
```
**Update User Role by ID**
```
PUT /api/v1/admin/users/:userId/role
Body: { "role": "MANAGEMENT" }
```
---
### Settings Page Updates
#### 4. **New Tab: "User Roles"**
Location: `src/pages/Settings/Settings.tsx`
**Changes:**
- Added 4th tab to admin settings
- Tab layout now responsive: 2 columns on mobile, 4 on desktop
- Tab order: User Settings → **User Roles** → Configuration → Holidays
- Only visible to ADMIN role users
**Tab Structure:**
```
┌─────────────┬────────────┬──────────────┬──────────┐
│ User │ User Roles │ Config │ Holidays │
│ Settings │ (NEW! ✨) │ │ │
└─────────────┴────────────┴──────────────┴──────────┘
```
---
### API Service Updates
#### 5. **User API Service**
Location: `src/services/userApi.ts`
**New Functions:**
```typescript
userApi.assignRole(email, role) // Assign role by email
userApi.updateUserRole(userId, role) // Update role by userId
userApi.getUsersByRole(role) // Get users filtered by role
userApi.getRoleStatistics() // Get role counts
```
---
## 🎨 UI/UX Features
### Statistics Cards
```
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Administrators │ │ Management │ │ Regular Users │
│ 3 │ │ 12 │ │ 145 │
│ 👑 ADMIN │ │ 👥 MANAGEMENT │ │ 👤 USER │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
### Role Assignment Section
1. **Search Input** - Type name or email
2. **Results Dropdown** - Shows matching Okta users
3. **Selected User Card** - Displays chosen user details
4. **Role Selector** - Dropdown with 3 role options
5. **Assign Button** - Confirms role assignment
### Elevated Users List
- Shows all ADMIN and MANAGEMENT users
- Regular USER role users are not shown (too many)
- Each user card shows:
- Role icon and badge
- Display name
- Email
- Department and designation
---
## 🔐 Access Control
### ADMIN Only
- View User Roles tab
- Search and assign roles
- View all elevated users
- Create users from Okta
- Demote users (except themselves)
### MANAGEMENT & USER
- Cannot access User Roles tab
- See info message about admin features
---
## 🔄 User Creation Flow
### Scenario 1: User Exists in Database
```
1. Admin searches "john@royalenfield.com"
2. Finds user in search results
3. Selects user
4. Assigns MANAGEMENT role
5. ✅ User role updated
```
### Scenario 2: User Doesn't Exist in Database
```
1. Admin searches "new.user@royalenfield.com"
2. Finds user in Okta search results
3. Selects user
4. Assigns MANAGEMENT role
5. Backend fetches full details from Okta
6. Creates user in database with MANAGEMENT role
7. ✅ User created and role assigned
```
### Scenario 3: User Not in Okta
```
1. Admin searches "fake@email.com"
2. No results found
3. If admin types email manually and tries to assign
4. ❌ Error: "User not found in Okta. Please ensure the email is correct."
```
---
## 🎯 Role Badge Colors
| Role | Badge Color | Icon | Access Level |
|------|-------------|------|--------------|
| ADMIN | 🟡 Yellow | 👑 Crown | Full system access |
| MANAGEMENT | 🔵 Blue | 👥 Users | Read all data, enhanced dashboards |
| USER | ⚪ Gray | 👤 User | Own requests and assigned workflows |
---
## 📊 Test Scenarios
### Test 1: Assign MANAGEMENT Role to Existing User
```
1. Login as ADMIN
2. Go to Settings → User Roles tab
3. Search for existing user
4. Select MANAGEMENT role
5. Click Assign Role
6. Verify success message
7. Check user appears in Elevated Users list
```
### Test 2: Create New User from Okta
```
1. Search for user not in database (but in Okta)
2. Select ADMIN role
3. Click Assign Role
4. Verify user is created AND role assigned
5. Check statistics update (+1 ADMIN)
```
### Test 3: Self-Demotion Prevention
```
1. Login as ADMIN
2. Search for your own email
3. Try to assign USER or MANAGEMENT role
4. Verify error: "You cannot demote yourself from ADMIN role"
```
### Test 4: Role Statistics
```
1. Check statistics cards show correct counts
2. Assign roles to users
3. Verify statistics update in real-time
```
---
## 🔧 Backend Implementation Details
### Controller: `admin.controller.ts`
**New Function: `assignRoleByEmail`**
```typescript
1. Validate email and role
2. Check if user exists in database
3. If NOT exists:
a. Import UserService
b. Search Okta by email
c. If not found in Okta → return 404
d. If found → Create user with assigned role
4. If EXISTS:
a. Check for self-demotion
b. Update user's role
5. Return success response
```
---
## 📁 Files Modified
### Frontend (3 new, 2 modified)
```
✨ src/components/admin/UserRoleManager/UserRoleManager.tsx (NEW)
✨ src/components/admin/UserRoleManager/index.ts (NEW)
✨ Re_Figma_Code/USER_ROLE_MANAGEMENT.md (NEW - this file)
✏️ src/services/userApi.ts (MODIFIED - added 4 functions)
✏️ src/pages/Settings/Settings.tsx (MODIFIED - added User Roles tab)
```
### Backend (2 modified)
```
✏️ src/controllers/admin.controller.ts (MODIFIED - added assignRoleByEmail)
✏️ src/routes/admin.routes.ts (MODIFIED - added POST /users/assign-role)
```
---
## 🎉 Complete Feature Set
✅ Search users from Okta
✅ Create users from Okta if they don't exist
✅ Assign any of 3 roles (USER, MANAGEMENT, ADMIN)
✅ View role statistics
✅ View all elevated users (ADMIN + MANAGEMENT)
✅ Regular users hidden (don't clutter the list)
✅ Self-demotion prevention
✅ Real-time search with debouncing
✅ Beautiful UI with gradient cards
✅ Role badges with icons
✅ Success/error messaging
✅ Loading states
✅ Test IDs for testing
✅ Mobile responsive
✅ Admin-only access
---
## 🚀 Ready to Use!
The feature is fully functional and ready for testing. Admins can now easily manage user roles directly from the Settings page without needing SQL or manual database access!
**To test:**
1. Log in as ADMIN user
2. Navigate to Settings
3. Click "User Roles" tab
4. Start assigning roles! 🎯

View File

@ -0,0 +1,550 @@
import { useState, useCallback, useEffect, useRef } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Search,
Users,
Shield,
UserCog,
Loader2,
CheckCircle,
AlertCircle,
Crown,
User as UserIcon
} from 'lucide-react';
import { userApi } from '@/services/userApi';
// Simple debounce function
function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout | null = null;
return function executedFunction(...args: Parameters<T>) {
const later = () => {
timeout = null;
func(...args);
};
if (timeout) clearTimeout(timeout);
timeout = setTimeout(later, wait);
};
}
interface OktaUser {
userId: string;
email: string;
firstName?: string;
lastName?: string;
displayName?: string;
department?: string;
designation?: string;
}
interface UserWithRole {
userId: string;
email: string;
displayName: string;
role: 'USER' | 'MANAGEMENT' | 'ADMIN';
department?: string;
designation?: string;
}
export function UserRoleManager() {
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<OktaUser[]>([]);
const [searching, setSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<OktaUser | null>(null);
const [selectedRole, setSelectedRole] = useState<'USER' | 'MANAGEMENT' | 'ADMIN'>('USER');
const [updating, setUpdating] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
// Users with elevated roles
const [elevatedUsers, setElevatedUsers] = useState<UserWithRole[]>([]);
const [loadingUsers, setLoadingUsers] = useState(false);
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0 });
// Ref for search container (click outside to close)
const searchContainerRef = useRef<HTMLDivElement>(null);
// Search users from Okta
const searchUsers = useCallback(
debounce(async (query: string) => {
if (!query || query.length < 2) {
setSearchResults([]);
return;
}
setSearching(true);
try {
const response = await userApi.searchUsers(query, 20);
console.log('Search response:', response);
console.log('Response.data:', response.data);
// Backend returns { success: true, data: [...users], message, timestamp }
// Axios response is in response.data, actual user array is in response.data.data
const users = response.data?.data || [];
console.log('Parsed users:', users);
setSearchResults(users);
} catch (error: any) {
console.error('Search failed:', error);
setMessage({
type: 'error',
text: error.response?.data?.message || 'Failed to search users'
});
} finally {
setSearching(false);
}
}, 300),
[]
);
// Handle search input
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const query = e.target.value;
setSearchQuery(query);
searchUsers(query);
};
// Select user from search results
const handleSelectUser = (user: OktaUser) => {
setSelectedUser(user);
setSearchQuery(user.email);
setSearchResults([]);
};
// Assign role to user
const handleAssignRole = async () => {
if (!selectedUser || !selectedRole) {
setMessage({ type: 'error', text: 'Please select a user and role' });
return;
}
setUpdating(true);
setMessage(null);
try {
// Call backend to assign role (will create user if doesn't exist)
const response = await userApi.assignRole(selectedUser.email, selectedRole);
setMessage({
type: 'success',
text: `Successfully assigned ${selectedRole} role to ${selectedUser.displayName || selectedUser.email}`
});
// Reset form
setSelectedUser(null);
setSearchQuery('');
setSelectedRole('USER');
// Refresh the elevated users list
await fetchElevatedUsers();
await fetchRoleStatistics();
} catch (error: any) {
console.error('Role assignment failed:', error);
setMessage({
type: 'error',
text: error.response?.data?.error || 'Failed to assign role'
});
} finally {
setUpdating(false);
}
};
// Fetch users with ADMIN and MANAGEMENT roles
const fetchElevatedUsers = async () => {
setLoadingUsers(true);
try {
const [adminResponse, managementResponse] = await Promise.all([
userApi.getUsersByRole('ADMIN'),
userApi.getUsersByRole('MANAGEMENT')
]);
console.log('Admin response:', adminResponse);
console.log('Management response:', managementResponse);
// Backend returns { success: true, data: { users: [...], summary: {...} } }
const admins = adminResponse.data?.data?.users || [];
const managers = managementResponse.data?.data?.users || [];
console.log('Parsed admins:', admins);
console.log('Parsed managers:', managers);
setElevatedUsers([...admins, ...managers]);
} catch (error) {
console.error('Failed to fetch users:', error);
} finally {
setLoadingUsers(false);
}
};
// Fetch role statistics
const fetchRoleStatistics = async () => {
try {
const response = await userApi.getRoleStatistics();
console.log('Role statistics response:', response);
// Handle different response formats
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
console.log('Statistics data:', statsData);
setRoleStats({
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
management: parseInt(statsData.find((s: any) => s.role === 'MANAGEMENT')?.count || '0'),
users: parseInt(statsData.find((s: any) => s.role === 'USER')?.count || '0')
});
} catch (error) {
console.error('Failed to fetch statistics:', error);
}
};
// Load data on mount
useEffect(() => {
fetchElevatedUsers();
fetchRoleStatistics();
}, []);
// Handle click outside to close search results
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) {
setSearchResults([]);
}
};
if (searchResults.length > 0) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [searchResults]);
const getRoleBadgeColor = (role: string) => {
switch (role) {
case 'ADMIN':
return 'bg-yellow-400 text-slate-900';
case 'MANAGEMENT':
return 'bg-blue-400 text-slate-900';
default:
return 'bg-gray-400 text-white';
}
};
const getRoleIcon = (role: string) => {
switch (role) {
case 'ADMIN':
return <Crown className="w-5 h-5" />;
case 'MANAGEMENT':
return <Users className="w-5 h-5" />;
default:
return <UserIcon className="w-5 h-5" />;
}
};
return (
<div className="space-y-6">
{/* Statistics Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
<Card className="shadow-lg border-0 bg-gradient-to-br from-yellow-50 to-yellow-100/50 hover:shadow-xl transition-all rounded-xl" data-testid="admin-count-card">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Administrators</p>
<p className="text-3xl font-bold text-gray-900 mt-2" data-testid="admin-count">{roleStats.admins}</p>
<p className="text-xs text-gray-500 mt-1">Full system access</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" />
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-lg border-0 bg-gradient-to-br from-blue-50 to-blue-100/50 hover:shadow-xl transition-all rounded-xl" data-testid="management-count-card">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Management</p>
<p className="text-3xl font-bold text-gray-900 mt-2" data-testid="management-count">{roleStats.management}</p>
<p className="text-xs text-gray-500 mt-1">Read all data access</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" />
</div>
</div>
</CardContent>
</Card>
<Card className="shadow-lg border-0 bg-gradient-to-br from-gray-50 to-gray-100/50 hover:shadow-xl transition-all rounded-xl" data-testid="user-count-card">
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Regular Users</p>
<p className="text-3xl font-bold text-gray-900 mt-2" data-testid="user-count">{roleStats.users}</p>
<p className="text-xs text-gray-500 mt-1">Standard access</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" />
</div>
</div>
</CardContent>
</Card>
</div>
{/* Assign Role Section */}
<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">
<UserCog className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg font-semibold">Assign User Role</CardTitle>
<CardDescription className="text-sm">
Search for a user in Okta and assign them a role
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-5 pt-6">
{/* Search Input */}
<div className="space-y-2" ref={searchContainerRef}>
<label className="text-sm font-medium text-gray-700">Search User</label>
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
<Input
type="text"
placeholder="Type name or email address..."
value={searchQuery}
onChange={handleSearchChange}
className="pl-10 pr-10 h-12 border-2 rounded-lg focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
data-testid="user-search-input"
/>
{searching && (
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-purple-500 animate-spin" />
)}
</div>
<p className="text-xs text-gray-500">Start typing to search across all Okta users</p>
{/* Search Results Dropdown */}
{searchResults.length > 0 && (
<div className="border-2 border-purple-200 rounded-lg shadow-lg bg-white max-h-60 overflow-y-auto">
<div className="sticky top-0 bg-purple-50 px-4 py-2 border-b border-purple-100">
<p className="text-xs font-semibold text-purple-700">
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
</p>
</div>
<div className="p-2">
{searchResults.map((user) => (
<button
key={user.userId}
onClick={() => handleSelectUser(user)}
className="w-full text-left p-3 hover:bg-purple-50 rounded-lg transition-colors mb-1 last:mb-0"
data-testid={`user-result-${user.email}`}
>
<p className="font-medium text-gray-900">{user.displayName || user.email}</p>
<p className="text-sm text-gray-600">{user.email}</p>
{user.department && (
<p className="text-xs text-gray-500 mt-1">
{user.department}{user.designation ? `${user.designation}` : ''}
</p>
)}
</button>
))}
</div>
</div>
)}
</div>
{/* Selected User */}
{selectedUser && (
<div className="border-2 border-purple-200 bg-purple-50 rounded-lg p-4">
<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">
{(selectedUser.displayName || selectedUser.email).charAt(0).toUpperCase()}
</div>
<div>
<p className="font-semibold text-gray-900">
{selectedUser.displayName || selectedUser.email}
</p>
<p className="text-sm text-gray-600">{selectedUser.email}</p>
{selectedUser.department && (
<p className="text-xs text-gray-500 mt-1">
{selectedUser.department}{selectedUser.designation ? `${selectedUser.designation}` : ''}
</p>
)}
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedUser(null);
setSearchQuery('');
}}
className="hover:bg-purple-100"
>
Clear
</Button>
</div>
</div>
)}
{/* Role Selection */}
<div className="space-y-2">
<label className="text-sm font-medium text-gray-700">Select Role</label>
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)}>
<SelectTrigger
className="h-12 border-2 rounded-lg focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
data-testid="role-select"
>
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
<SelectItem value="USER">
<div className="flex items-center gap-2">
<UserIcon className="w-4 h-4 text-gray-600" />
<span>User - Regular access</span>
</div>
</SelectItem>
<SelectItem value="MANAGEMENT">
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-blue-600" />
<span>Management - Read all data</span>
</div>
</SelectItem>
<SelectItem value="ADMIN">
<div className="flex items-center gap-2">
<Crown className="w-4 h-4 text-yellow-600" />
<span>Administrator - Full access</span>
</div>
</SelectItem>
</SelectContent>
</Select>
</div>
{/* Assign Button */}
<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"
data-testid="assign-role-button"
>
{updating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Assigning Role...
</>
) : (
<>
<Shield className="w-4 h-4 mr-2" />
Assign Role
</>
)}
</Button>
{/* Message */}
{message && (
<div className={`border-2 rounded-lg p-4 ${
message.type === 'success'
? 'border-green-200 bg-green-50'
: 'border-red-200 bg-red-50'
}`}>
<div className="flex items-start gap-3">
{message.type === 'success' ? (
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
) : (
<AlertCircle className="w-5 h-5 text-red-600 shrink-0 mt-0.5" />
)}
<p className={`text-sm ${message.type === 'success' ? 'text-green-800' : 'text-red-800'}`}>
{message.text}
</p>
</div>
</div>
)}
</CardContent>
</Card>
{/* Elevated Users List */}
<Card className="shadow-lg border">
<CardHeader className="border-b pb-4">
<div className="flex items-center justify-between">
<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">
<Shield className="w-5 h-5 text-white" />
</div>
<div>
<CardTitle className="text-lg font-semibold">Users with Elevated Roles</CardTitle>
<CardDescription className="text-sm">
Administrators and Management team members
</CardDescription>
</div>
</div>
<Badge variant="outline" className="text-sm">
{elevatedUsers.length} user{elevatedUsers.length !== 1 ? 's' : ''}
</Badge>
</div>
</CardHeader>
<CardContent className="pt-6">
{loadingUsers ? (
<div className="flex flex-col items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mb-2" />
<p className="text-sm text-gray-500">Loading users...</p>
</div>
) : elevatedUsers.length === 0 ? (
<div className="text-center py-8">
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mx-auto mb-3">
<Users className="w-6 h-6 text-gray-400" />
</div>
<p className="font-medium text-gray-700">No elevated users found</p>
<p className="text-sm text-gray-500 mt-1">Assign ADMIN or MANAGEMENT roles to see users here</p>
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto pr-2" data-testid="elevated-users-list">
{elevatedUsers.map((user) => (
<div
key={user.userId}
className="border-2 border-gray-100 hover:border-purple-200 hover:shadow-md transition-all rounded-lg bg-white p-4"
data-testid={`elevated-user-${user.email}`}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
<div className={`w-10 h-10 rounded-lg ${getRoleBadgeColor(user.role)} flex items-center justify-center shadow-sm`}>
{getRoleIcon(user.role)}
</div>
<div className="flex-1 min-w-0">
<p className="font-semibold text-gray-900 truncate">{user.displayName}</p>
<p className="text-sm text-gray-600 truncate">{user.email}</p>
{user.department && (
<p className="text-xs text-gray-500 mt-1 truncate">
{user.department}{user.designation ? `${user.designation}` : ''}
</p>
)}
</div>
</div>
<Badge className={`${getRoleBadgeColor(user.role)} shrink-0`} data-testid={`role-badge-${user.role}`}>
{user.role}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,2 @@
export { UserRoleManager } from './UserRoleManager';

View File

@ -0,0 +1,75 @@
import { Badge } from '@/components/ui/badge';
import { LucideIcon } from 'lucide-react';
import { ReactNode } from 'react';
interface PageHeaderProps {
icon: LucideIcon;
title: string;
description: string;
badge?: {
value: string | number;
label: string;
loading?: boolean;
};
actions?: ReactNode;
testId?: string;
}
export function PageHeader({
icon: Icon,
title,
description,
badge,
actions,
testId = 'page-header'
}: PageHeaderProps) {
return (
<div
className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4 md:gap-6"
data-testid={testId}
>
<div className="space-y-1 sm:space-y-2">
<div className="flex items-center gap-2 sm:gap-3">
<div
className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl flex items-center justify-center shadow-lg"
data-testid={`${testId}-icon-container`}
>
<Icon
className="w-5 h-5 sm:w-6 sm:h-6 text-white"
data-testid={`${testId}-icon`}
/>
</div>
<div>
<h1
className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900"
data-testid={`${testId}-title`}
>
{title}
</h1>
<p
className="text-sm sm:text-base text-gray-600"
data-testid={`${testId}-description`}
>
{description}
</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3">
{badge && (
<Badge
variant="secondary"
className="text-xs sm:text-sm md:text-base px-2 sm:px-3 md:px-4 py-1 sm:py-1.5 md:py-2 bg-slate-100 text-slate-800 font-semibold"
data-testid={`${testId}-badge`}
>
{badge.loading ? 'Loading…' : badge.value}
<span className="hidden sm:inline ml-1">{badge.label}</span>
</Badge>
)}
{actions}
</div>
</div>
);
}

View File

@ -0,0 +1,2 @@
export { PageHeader } from './PageHeader';

View File

@ -2,34 +2,34 @@ import { ReactNode } from 'react';
interface StatCardProps {
label: string;
value: string | number;
value: number | string;
bgColor: string;
textColor: string;
testId?: string;
children?: ReactNode;
}
export function StatCard({
label,
value,
bgColor,
export function StatCard({
label,
value,
bgColor,
textColor,
testId = 'stat-card',
children
children
}: StatCardProps) {
return (
<div
className={`text-center p-2 ${bgColor} rounded`}
className={`${bgColor} rounded-lg p-2 sm:p-3`}
data-testid={testId}
>
<p
className="text-muted-foreground text-xs"
className="text-xs text-gray-600 mb-1"
data-testid={`${testId}-label`}
>
{label}
</p>
<p
className={`font-bold ${textColor}`}
className={`text-lg sm:text-xl font-bold ${textColor}`}
data-testid={`${testId}-value`}
>
{value}

View File

@ -0,0 +1,55 @@
import { Card, CardContent } from '@/components/ui/card';
import { LucideIcon } from 'lucide-react';
interface StatsCardProps {
label: string;
value: string | number;
icon: LucideIcon;
iconColor: string;
gradient: string;
textColor: string;
valueColor: string;
testId?: string;
}
export function StatsCard({
label,
value,
icon: Icon,
iconColor,
gradient,
textColor,
valueColor,
testId = 'stats-card'
}: StatsCardProps) {
return (
<Card
className={`${gradient} border transition-shadow hover:shadow-md`}
data-testid={testId}
>
<CardContent className="p-3 sm:p-4">
<div className="flex items-center justify-between">
<div>
<p
className={`text-xs sm:text-sm font-medium ${textColor}`}
data-testid={`${testId}-label`}
>
{label}
</p>
<p
className={`text-xl sm:text-2xl font-bold ${valueColor}`}
data-testid={`${testId}-value`}
>
{value}
</p>
</div>
<Icon
className={`w-6 h-6 sm:w-8 sm:h-8 ${iconColor}`}
data-testid={`${testId}-icon`}
/>
</div>
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,2 @@
export { StatsCard } from './StatsCard';

View File

@ -43,6 +43,7 @@ export function AddApproverModal({
const [isSearching, setIsSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null); // Track if user was selected via @ search
const searchTimer = useRef<any>(null);
const searchContainerRef = useRef<HTMLDivElement>(null); // Ref for auto-scroll
// Validation modal state
const [validationModal, setValidationModal] = useState<{
@ -263,6 +264,17 @@ export function AddApproverModal({
return <Clock className="w-4 h-4 text-gray-400" />;
};
// Auto-scroll container when search results appear
useEffect(() => {
if (searchResults.length > 0 && searchContainerRef.current) {
// Scroll to bottom to show the search results dropdown
searchContainerRef.current.scrollTo({
top: searchContainerRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, [searchResults.length]);
// Cleanup search timer on unmount
useEffect(() => {
return () => {
@ -359,7 +371,7 @@ export function AddApproverModal({
</div>
</DialogHeader>
<div className="space-y-4 px-6 py-4 overflow-y-auto flex-1">
<div ref={searchContainerRef} className="space-y-4 px-6 py-4 pb-8 overflow-y-auto flex-1">
{/* Description */}
<p className="text-sm text-gray-600 leading-relaxed">
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.

View File

@ -29,6 +29,7 @@ export function AddSpectatorModal({
const [isSearching, setIsSearching] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null); // Track if user was selected via @ search
const searchTimer = useRef<any>(null);
const searchContainerRef = useRef<HTMLDivElement>(null); // Ref for auto-scroll
// Validation modal state
const [validationModal, setValidationModal] = useState<{
@ -176,6 +177,17 @@ export function AddSpectatorModal({
}
};
// Auto-scroll container when search results appear
useEffect(() => {
if (searchResults.length > 0 && searchContainerRef.current) {
// Scroll to bottom to show the search results dropdown
searchContainerRef.current.scrollTo({
top: searchContainerRef.current.scrollHeight,
behavior: 'smooth'
});
}
}, [searchResults.length]);
// Cleanup search timer on unmount
useEffect(() => {
return () => {
@ -272,7 +284,7 @@ export function AddSpectatorModal({
</div>
</DialogHeader>
<div className="space-y-4 px-6 py-4 overflow-y-auto flex-1">
<div ref={searchContainerRef} className="space-y-4 px-6 py-4 pb-8 overflow-y-auto flex-1">
{/* Description */}
<p className="text-sm text-gray-600 leading-relaxed">
Add a spectator to this request. They will receive notifications but cannot approve or reject.

View File

@ -0,0 +1,106 @@
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import { Clock } from 'lucide-react';
export interface SLAData {
status: 'normal' | 'approaching' | 'critical' | 'breached';
percentageUsed: number;
elapsedText: string;
elapsedHours: number;
remainingText: string;
remainingHours: number;
deadline?: string;
}
interface SLAProgressBarProps {
sla: SLAData | null;
requestStatus: string;
testId?: string;
}
export function SLAProgressBar({
sla,
requestStatus,
testId = 'sla-progress'
}: SLAProgressBarProps) {
// If request is closed/approved/rejected or no SLA data, show status message
if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
return (
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
<Clock className="h-4 w-4 text-gray-500" />
<span className="text-sm font-medium text-gray-700">
{requestStatus === 'closed' ? '🔒 Request Closed' :
requestStatus === 'approved' ? '✅ Request Approved' :
requestStatus === 'rejected' ? '❌ Request Rejected' : 'SLA Not Available'}
</span>
</div>
);
}
return (
<div data-testid={testId}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Clock className="h-4 w-4 text-blue-600" />
<span className="text-sm font-semibold text-gray-900">SLA Progress</span>
</div>
<Badge
className={`text-xs ${
sla.status === 'breached' ? 'bg-red-600 text-white animate-pulse' :
sla.status === 'critical' ? 'bg-orange-600 text-white' :
sla.status === 'approaching' ? 'bg-yellow-600 text-white' :
'bg-green-600 text-white'
}`}
data-testid={`${testId}-badge`}
>
{sla.percentageUsed || 0}% elapsed
</Badge>
</div>
<Progress
value={sla.percentageUsed || 0}
className={`h-3 mb-2 ${
sla.status === 'breached' ? '[&>div]:bg-red-600' :
sla.status === 'critical' ? '[&>div]:bg-orange-600' :
sla.status === 'approaching' ? '[&>div]:bg-yellow-600' :
'[&>div]:bg-green-600'
}`}
data-testid={`${testId}-bar`}
/>
<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
</span>
<span
className={`font-semibold ${
sla.status === 'breached' ? 'text-red-600' :
sla.status === 'critical' ? 'text-orange-600' :
'text-gray-700'
}`}
data-testid={`${testId}-remaining`}
>
{sla.remainingText || `${sla.remainingHours || 0}h`} remaining
</span>
</div>
{sla.deadline && (
<p className="text-xs text-gray-500" data-testid={`${testId}-deadline`}>
Due: {new Date(sla.deadline).toLocaleString()} {sla.percentageUsed || 0}% elapsed
</p>
)}
{sla.status === 'critical' && (
<p className="text-xs text-orange-600 font-semibold mt-1" data-testid={`${testId}-warning-critical`}>
Approaching Deadline
</p>
)}
{sla.status === 'breached' && (
<p className="text-xs text-red-600 font-semibold mt-1" data-testid={`${testId}-warning-breached`}>
🔴 URGENT - Deadline Passed
</p>
)}
</div>
);
}

View File

@ -0,0 +1,3 @@
export { SLAProgressBar } from './SLAProgressBar';
export type { SLAData } from './SLAProgressBar';

View File

@ -0,0 +1,449 @@
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 } from 'lucide-react';
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
export interface ApprovalStep {
step: number;
levelId: string;
role: string;
status: string;
approver: string;
approverId?: string;
approverEmail?: string;
tatHours: number;
elapsedHours?: number;
remainingHours?: number;
tatPercentageUsed?: number;
actualHours?: number;
comment?: string;
timestamp?: string;
levelStartTime?: string;
tatAlerts?: any[];
skipReason?: string;
isSkipped?: boolean;
}
interface ApprovalStepCardProps {
step: ApprovalStep;
index: number;
approval?: any; // Raw approval data from backend
isCurrentUser?: boolean;
isInitiator?: boolean;
onSkipApprover?: (data: { levelId: string; approverName: string; levelNumber: number }) => void;
testId?: string;
}
const getStepIcon = (status: string, isSkipped?: boolean) => {
if (isSkipped) return <AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600" />;
switch (status) {
case 'approved':
return <CheckCircle className="w-4 h-4 sm:w-5 sm:w-5 text-green-600" />;
case 'rejected':
return <XCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600" />;
case 'pending':
case 'in-review':
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />;
case 'waiting':
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400" />;
default:
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400" />;
}
};
export function ApprovalStepCard({
step,
index,
approval,
isCurrentUser = false,
isInitiator = false,
onSkipApprover,
testId = 'approval-step'
}: ApprovalStepCardProps) {
const isActive = step.status === 'pending' || step.status === 'in-review';
const isCompleted = step.status === 'approved';
const isRejected = step.status === 'rejected';
const isWaiting = step.status === 'waiting';
const tatHours = Number(step.tatHours || 0);
const actualHours = step.actualHours;
const savedHours = isCompleted && actualHours ? Math.max(0, tatHours - actualHours) : 0;
return (
<div
className={`relative p-3 sm:p-4 md:p-5 rounded-lg border-2 transition-all ${
step.isSkipped
? 'border-orange-500 bg-orange-50'
: isActive
? 'border-blue-500 bg-blue-50 shadow-md'
: isCompleted
? 'border-green-500 bg-green-50'
: isRejected
? 'border-red-500 bg-red-50'
: isWaiting
? 'border-gray-300 bg-gray-50'
: 'border-gray-200 bg-white'
}`}
data-testid={`${testId}-${step.step}`}
>
<div className="flex items-start gap-2 sm:gap-3 md:gap-4">
<div className={`p-2 sm:p-2.5 md:p-3 rounded-xl flex-shrink-0 ${
step.isSkipped ? 'bg-orange-100' :
isActive ? 'bg-blue-100' :
isCompleted ? 'bg-green-100' :
isRejected ? 'bg-red-100' :
isWaiting ? 'bg-gray-200' :
'bg-gray-100'
}`}>
{getStepIcon(step.status, step.isSkipped)}
</div>
<div className="flex-1 min-w-0">
{/* Header with Approver Label and Status */}
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 sm:gap-4 mb-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mb-2">
<h4 className="font-semibold text-gray-900 text-base sm:text-lg" data-testid={`${testId}-approver-label`}>
Approver {index + 1}
</h4>
<Badge variant="outline" className={`text-xs shrink-0 capitalize ${
step.isSkipped ? 'bg-orange-100 text-orange-800 border-orange-200' :
isActive ? 'bg-yellow-100 text-yellow-800 border-yellow-200' :
isCompleted ? 'bg-green-100 text-green-800 border-green-200' :
isRejected ? 'bg-red-100 text-red-800 border-red-200' :
isWaiting ? 'bg-gray-200 text-gray-600 border-gray-300' :
'bg-gray-100 text-gray-800 border-gray-200'
}`} data-testid={`${testId}-status-badge`}>
{step.isSkipped ? 'skipped' : step.status}
</Badge>
{step.isSkipped && step.skipReason && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
onClick={(e) => e.stopPropagation()}
>
<AlertCircle className="w-4 h-4 text-orange-600" />
</button>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs bg-orange-50 border-orange-200">
<p className="text-xs font-semibold text-orange-900 mb-1"> Skip Reason:</p>
<p className="text-xs text-gray-700">{step.skipReason}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{isCompleted && actualHours && (
<Badge className="bg-green-600 text-white text-xs" data-testid={`${testId}-completion-time`}>
{actualHours.toFixed(1)} hours
</Badge>
)}
</div>
<p className="text-sm font-semibold text-gray-900" data-testid={`${testId}-approver-name`}>
{isCurrentUser ? <span className="text-blue-600">You</span> : step.approver}
</p>
<p className="text-xs text-gray-600" data-testid={`${testId}-role`}>{step.role}</p>
</div>
<div className="text-left sm:text-right flex-shrink-0">
<p className="text-xs text-gray-500 font-medium">Turnaround Time (TAT)</p>
<p className="text-lg font-bold text-gray-900" data-testid={`${testId}-tat-hours`}>{tatHours} hours</p>
</div>
</div>
{/* Completed Approver - Show Completion Details */}
{isCompleted && actualHours !== undefined && (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600">Completed:</span>
<span className="font-medium text-gray-900">{step.timestamp ? formatDateTime(step.timestamp) : 'N/A'}</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600">Completed in:</span>
<span className="font-medium text-gray-900">{actualHours.toFixed(1)} hours</span>
</div>
{/* Progress Bar for Completed - Shows actual time used vs TAT allocated */}
<div className="space-y-2">
{(() => {
// 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;
return (
<>
<Progress
value={progressPercentage}
className="h-2 bg-gray-200"
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>
{savedHours > 0 && (
<span className="text-green-600 font-semibold">Saved {savedHours.toFixed(1)} hours</span>
)}
</div>
</>
);
})()}
</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">
<p className="text-xs font-semibold text-gray-700 mb-2">💬 Conclusion Remark:</p>
<p className="text-sm text-gray-700 italic leading-relaxed">{step.comment}</p>
</div>
)}
</div>
)}
{/* Active Approver - Show Real-time Progress from Backend */}
{isActive && approval?.sla && (
<div className="space-y-3">
<div className="flex items-center justify-between text-xs">
<span className="text-gray-600">Due by:</span>
<span className="font-medium text-gray-900">
{approval.sla.deadline ? formatDateTime(approval.sla.deadline) : 'Not set'}
</span>
</div>
{/* Current Approver - Time Tracking */}
<div className={`border rounded-lg p-3 ${
approval.sla.status === 'critical' ? 'bg-orange-50 border-orange-200' :
approval.sla.status === 'breached' ? 'bg-red-50 border-red-200' :
'bg-yellow-50 border-yellow-200'
}`}>
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
<Clock className="w-4 h-4" />
Current Approver - Time Tracking
</p>
<div className="space-y-2 text-xs mb-3">
<div className="flex justify-between">
<span className="text-gray-600">Time elapsed since assigned:</span>
<span className="font-medium text-gray-900">{approval.sla.elapsedText}</span>
</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>
</div>
</div>
{/* Progress Bar */}
<div className="space-y-2">
<Progress
value={approval.sla.percentageUsed}
className={`h-3 ${
approval.sla.status === 'breached' ? '[&>div]:bg-red-600' :
approval.sla.status === 'critical' ? '[&>div]:bg-orange-600' :
'[&>div]:bg-yellow-600'
}`}
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>
<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">
🔴 Deadline Breached
</p>
)}
{approval.sla.status === 'critical' && (
<p className="text-xs font-semibold text-center text-orange-600">
Approaching Deadline
</p>
)}
</div>
</div>
</div>
)}
{/* Waiting Approver - Show Assignment Info */}
{isWaiting && (
<div className="space-y-2">
<div className="bg-gray-100 border border-gray-300 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1"> Awaiting Previous Approval</p>
<p className="text-sm font-medium text-gray-700">Will be assigned after previous step</p>
<p className="text-xs text-gray-500 mt-2">Allocated {tatHours} hours for approval</p>
</div>
</div>
)}
{/* Rejected Status */}
{isRejected && step.comment && (
<div className="mt-3 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"> Rejection Reason:</p>
<p className="text-sm text-gray-700 leading-relaxed">{step.comment}</p>
</div>
)}
{/* Skipped Status */}
{step.isSkipped && step.skipReason && (
<div className="mt-3 p-3 sm:p-4 bg-orange-50 border-l-4 border-orange-500 rounded-r-lg">
<p className="text-xs font-semibold text-orange-700 mb-2"> Skip Reason:</p>
<p className="text-sm text-gray-700 leading-relaxed">{step.skipReason}</p>
{step.timestamp && (
<p className="text-xs text-gray-500 mt-2">Skipped on {formatDateTime(step.timestamp)}</p>
)}
</div>
)}
{/* TAT Alerts/Reminders */}
{step.tatAlerts && step.tatAlerts.length > 0 && (
<div className="mt-2 sm:mt-3 space-y-2">
{step.tatAlerts.map((alert: any, alertIndex: number) => (
<div
key={alertIndex}
className={`p-2 sm:p-3 rounded-lg border ${
alert.isBreached
? 'bg-red-50 border-red-200'
: (alert.thresholdPercentage || 0) === 75
? 'bg-orange-50 border-orange-200'
: 'bg-yellow-50 border-yellow-200'
}`}
data-testid={`${testId}-tat-alert-${alertIndex}`}
>
<div className="flex items-start gap-2">
<div className="text-base sm:text-lg flex-shrink-0">
{(alert.thresholdPercentage || 0) === 50 && '⏳'}
{(alert.thresholdPercentage || 0) === 75 && '⚠️'}
{(alert.thresholdPercentage || 0) === 100 && '⏰'}
</div>
<div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 mb-1">
<p className="text-xs sm:text-sm font-semibold text-gray-900">
Reminder {alertIndex + 1} - {alert.thresholdPercentage || 0}% TAT
</p>
<Badge
variant="outline"
className={`text-[10px] sm:text-xs shrink-0 ${
alert.isBreached
? 'bg-red-100 text-red-800 border-red-300'
: 'bg-amber-100 text-amber-800 border-amber-300'
}`}
>
{alert.isBreached ? 'BREACHED' : 'WARNING'}
</Badge>
</div>
<p className="text-[10px] sm:text-xs md:text-sm text-gray-700 mt-1">
{alert.thresholdPercentage || 0}% of SLA breach reminder have been sent
</p>
{/* Time Tracking Details */}
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:gap-2 text-[10px] sm:text-xs">
<div className="bg-white/50 rounded px-2 py-1">
<span className="text-gray-500">Allocated:</span>
<span className="ml-1 font-medium text-gray-900">
{Number(alert.tatHoursAllocated || 0).toFixed(2)}h
</span>
</div>
<div className="bg-white/50 rounded px-2 py-1">
<span className="text-gray-500">Elapsed:</span>
<span className="ml-1 font-medium text-gray-900">
{Number(alert.tatHoursElapsed || 0).toFixed(2)}h
{alert.metadata?.tatTestMode && (
<span className="text-purple-600 ml-1">
({(Number(alert.tatHoursElapsed || 0) * 60).toFixed(0)}m)
</span>
)}
</span>
</div>
<div className="bg-white/50 rounded px-2 py-1">
<span className="text-gray-500">Remaining:</span>
<span className={`ml-1 font-medium ${
(alert.tatHoursRemaining || 0) < 2 ? 'text-red-600' : 'text-gray-900'
}`}>
{Number(alert.tatHoursRemaining || 0).toFixed(2)}h
{alert.metadata?.tatTestMode && (
<span className="text-purple-600 ml-1">
({(Number(alert.tatHoursRemaining || 0) * 60).toFixed(0)}m)
</span>
)}
</span>
</div>
<div className="bg-white/50 rounded px-2 py-1">
<span className="text-gray-500">Due by:</span>
<span className="ml-1 font-medium text-gray-900">
{alert.expectedCompletionTime ? formatDateShort(alert.expectedCompletionTime) : 'N/A'}
</span>
</div>
</div>
<div className="mt-2 pt-2 border-t border-gray-200">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1">
<p className="text-[10px] sm:text-xs text-gray-500">
Reminder sent by system automatically
</p>
{(alert.metadata?.testMode || alert.metadata?.tatTestMode) && (
<Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-300 text-[10px] px-1.5 py-0 shrink-0">
TEST MODE
</Badge>
)}
</div>
<p className="text-[10px] sm:text-xs text-gray-600 font-medium mt-0.5">
Sent at: {alert.alertSentAt ? formatDateTime(alert.alertSentAt) : 'N/A'}
</p>
{(alert.metadata?.testMode || alert.metadata?.tatTestMode) && (
<p className="text-[10px] text-purple-600 mt-1 italic">
Note: Test mode active (1 hour = 1 minute)
</p>
)}
</div>
</div>
</div>
</div>
))}
</div>
)}
{step.timestamp && (
<p className="text-xs text-gray-500 mt-2">
{isCompleted ? 'Approved' : isRejected ? 'Rejected' : 'Actioned'} on {formatDateTime(step.timestamp)}
</p>
)}
{/* Skip Approver Button - Only show for initiator on pending/in-review levels */}
{isInitiator && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
<Button
variant="outline"
size="sm"
className="w-full border-orange-300 text-orange-700 hover:bg-orange-50 h-9 sm:h-10 text-xs sm:text-sm"
onClick={() => onSkipApprover({
levelId: step.levelId,
approverName: step.approver,
levelNumber: step.step
})}
data-testid={`${testId}-skip-button`}
>
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" />
Skip This Approver
</Button>
<p className="text-[10px] sm:text-xs text-gray-500 mt-1 text-center">
Skip if approver is unavailable and move to next level
</p>
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export { ApprovalStepCard } from './ApprovalStepCard';
export type { ApprovalStep } from './ApprovalStepCard';

View File

@ -0,0 +1,100 @@
import { Button } from '@/components/ui/button';
import { FileText, Eye, Download } from 'lucide-react';
import { formatDateTime } from '@/utils/dateFormatter';
export interface DocumentData {
documentId: string;
name: string;
fileType: string;
size: string;
sizeBytes?: number;
uploadedBy?: string;
uploadedAt: string;
}
interface DocumentCardProps {
document: DocumentData;
onPreview?: (doc: { fileName: string; fileType: string; documentId: string; fileSize?: number }) => void;
onDownload?: (documentId: string) => Promise<void>;
showPreview?: boolean;
testId?: string;
}
const canPreview = (fileType: string) => {
const type = (fileType || '').toLowerCase();
return type.includes('image') || type.includes('pdf') ||
type.includes('jpg') || type.includes('jpeg') ||
type.includes('png') || type.includes('gif');
};
export function DocumentCard({
document,
onPreview,
onDownload,
showPreview = true,
testId = 'document-card'
}: DocumentCardProps) {
return (
<div
className="flex items-center justify-between p-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors"
data-testid={`${testId}-${document.documentId}`}
>
<div className="flex items-center gap-3">
<div className="p-2 bg-blue-100 rounded-lg">
<FileText className="w-5 h-5 text-blue-600" />
</div>
<div>
<p className="font-medium text-gray-900" data-testid={`${testId}-name`}>
{document.name}
</p>
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
{document.size} Uploaded by {document.uploadedBy} on {formatDateTime(document.uploadedAt)}
</p>
</div>
</div>
<div className="flex items-center gap-2">
{/* Preview button for images and PDFs */}
{showPreview && canPreview(document.fileType) && onPreview && (
<Button
variant="ghost"
size="sm"
onClick={() => onPreview({
fileName: document.name,
fileType: document.fileType,
documentId: document.documentId,
fileSize: document.sizeBytes
})}
title="Preview file"
data-testid={`${testId}-preview-btn`}
>
<Eye className="w-4 h-4" />
</Button>
)}
{/* Download button */}
{onDownload && (
<Button
variant="ghost"
size="sm"
onClick={async () => {
if (!document.documentId) {
alert('Document ID not available');
return;
}
try {
await onDownload(document.documentId);
} catch (error) {
alert('Failed to download document');
}
}}
title="Download file"
data-testid={`${testId}-download-btn`}
>
<Download className="w-4 h-4" />
</Button>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,3 @@
export { DocumentCard } from './DocumentCard';
export type { DocumentData } from './DocumentCard';

View File

@ -19,7 +19,7 @@ interface User {
displayName?: string;
department?: string;
designation?: string;
isAdmin?: boolean;
role?: 'USER' | 'MANAGEMENT' | 'ADMIN';
sub?: string;
name?: string;
picture?: string;
@ -597,3 +597,31 @@ export function useAuth(): AuthContextType {
return context;
}
/**
* Helper function to check if user is admin
*/
export function isAdmin(user: User | null): boolean {
return user?.role === 'ADMIN';
}
/**
* Helper function to check if user is management
*/
export function isManagement(user: User | null): boolean {
return user?.role === 'MANAGEMENT';
}
/**
* Helper function to check if user has management access (MANAGEMENT or ADMIN)
*/
export function hasManagementAccess(user: User | null): boolean {
return user?.role === 'MANAGEMENT' || user?.role === 'ADMIN';
}
/**
* Helper function to check if user has admin access (ADMIN only)
*/
export function hasAdminAccess(user: User | null): boolean {
return user?.role === 'ADMIN';
}

View File

@ -0,0 +1,231 @@
import { useState, useEffect } from 'react';
/**
* Custom Hook: useConclusionRemark
*
* Purpose: Manages conclusion remark generation and finalization
*
* Responsibilities:
* - Fetches existing AI-generated conclusion
* - Generates new conclusion using AI
* - Finalizes conclusion and closes request
* - Manages loading and submission states
* - Handles navigation after successful closure
*
* @param request - Current request object
* @param requestIdentifier - Request number or UUID
* @param isInitiator - Whether current user is the request initiator
* @param refreshDetails - Function to refresh request data
* @param onBack - Navigation callback
* @param setActionStatus - Function to show action status modal
* @param setShowActionStatusModal - Function to control action status modal visibility
* @returns Object with conclusion state and action handlers
*/
export function useConclusionRemark(
request: any,
requestIdentifier: string,
isInitiator: boolean,
refreshDetails: () => Promise<void>,
onBack?: () => void,
setActionStatus?: (status: { success: boolean; title: string; message: string }) => void,
setShowActionStatusModal?: (show: boolean) => void
) {
// State: The conclusion remark text (editable by user)
const [conclusionRemark, setConclusionRemark] = useState('');
// State: Indicates if AI is currently generating conclusion
const [conclusionLoading, setConclusionLoading] = useState(false);
// State: Indicates if conclusion is being submitted to backend
const [conclusionSubmitting, setConclusionSubmitting] = useState(false);
// State: Tracks if current conclusion was AI-generated (shows badge in UI)
const [aiGenerated, setAiGenerated] = useState(false);
/**
* Function: fetchExistingConclusion
*
* Purpose: Load existing AI-generated conclusion from backend
*
* Use Case: When request is approved, final approver generates conclusion.
* Initiator needs to review and finalize it before closing request.
*
* Process:
* 1. Dynamically import conclusion API service
* 2. Fetch conclusion by request ID
* 3. Load into state if exists
* 4. Mark as AI-generated if applicable
*/
const fetchExistingConclusion = async () => {
try {
// Lazy load: Import conclusion API only when needed
const { getConclusion } = await import('@/services/conclusionApi');
// API Call: Fetch existing conclusion
const result = await getConclusion(request.requestId || requestIdentifier);
if (result && result.aiGeneratedRemark) {
// Load: Set the AI-generated or final remark
setConclusionRemark(result.finalRemark || result.aiGeneratedRemark);
setAiGenerated(!!result.aiGeneratedRemark);
}
} catch (err) {
// No conclusion yet - this is expected for newly approved requests
console.log('[useConclusionRemark] No existing conclusion found');
}
};
/**
* Function: handleGenerateConclusion
*
* Purpose: Generate a new conclusion remark using AI
*
* How it works:
* 1. Sends request details to AI service
* 2. AI analyzes approval history, comments, and request data
* 3. Generates professional conclusion summarizing outcome
* 4. User can edit the AI suggestion before finalizing
*
* Process:
* 1. Set loading state
* 2. Call AI generation API
* 3. Load generated text into textarea
* 4. Mark as AI-generated (shows badge)
* 5. Handle errors silently (user can type manually)
*/
const handleGenerateConclusion = async () => {
try {
setConclusionLoading(true);
// Lazy load: Import conclusion API
const { generateConclusion } = await import('@/services/conclusionApi');
// API Call: Generate AI conclusion based on request data
const result = await generateConclusion(request.requestId || requestIdentifier);
// Success: Load AI-generated remark
setConclusionRemark(result.aiGeneratedRemark);
setAiGenerated(true);
} catch (err) {
// Fail silently: User can write conclusion manually
console.error('[useConclusionRemark] AI generation failed:', err);
setConclusionRemark('');
setAiGenerated(false);
} finally {
setConclusionLoading(false);
}
};
/**
* Function: handleFinalizeConclusion
*
* Purpose: Submit conclusion remark and close the request
*
* Business Logic:
* - Only initiators can finalize approved requests
* - Conclusion cannot be empty
* - After finalization:
* Request status changes to CLOSED
* All participants are notified
* Request moves to Closed Requests
* Conclusion is permanently saved
*
* Process:
* 1. Validate conclusion is not empty
* 2. Submit to backend
* 3. Show success modal
* 4. Refresh request data (status will be "closed")
* 5. Navigate to Closed Requests after 2 seconds
* 6. Handle errors with user-friendly messages
*/
const handleFinalizeConclusion = async () => {
// Validation: Ensure conclusion is not empty
if (!conclusionRemark.trim()) {
setActionStatus?.({
success: false,
title: 'Validation Error',
message: 'Conclusion remark cannot be empty'
});
setShowActionStatusModal?.(true);
return;
}
try {
setConclusionSubmitting(true);
// Lazy load: Import conclusion API
const { finalizeConclusion } = await import('@/services/conclusionApi');
// API Call: Submit conclusion and close request
// Backend will:
// - Update request status to CLOSED
// - Save conclusion remark
// - Send notifications to all participants
// - Record closure timestamp
await finalizeConclusion(request.requestId || requestIdentifier, conclusionRemark);
// Success feedback
setActionStatus?.({
success: true,
title: 'Request Closed with Successful Completion',
message: 'The request has been finalized and moved to Closed Requests.'
});
setShowActionStatusModal?.(true);
// Refresh: Update UI with new "closed" status
await refreshDetails();
/**
* Navigate: Redirect to Closed Requests after showing success message
* Delay allows user to see the success notification
*/
setTimeout(() => {
if (onBack) {
// Use callback navigation if provided
onBack();
// Then navigate to closed requests
setTimeout(() => {
window.location.hash = '#/closed-requests';
}, 100);
} else {
// Direct navigation
window.location.hash = '#/closed-requests';
}
}, 2000); // 2 second delay
} catch (err: any) {
// Error feedback with backend message
setActionStatus?.({
success: false,
title: 'Error',
message: err.response?.data?.error || 'Failed to finalize conclusion'
});
setShowActionStatusModal?.(true);
} finally {
setConclusionSubmitting(false);
}
};
/**
* Effect: Auto-fetch existing conclusion when request becomes approved
*
* Trigger: When request status changes to "approved" and user is initiator
* Purpose: Load any conclusion generated by final approver
*/
useEffect(() => {
if (request?.status === 'approved' && isInitiator && !conclusionRemark) {
fetchExistingConclusion();
}
}, [request?.status, isInitiator]);
return {
conclusionRemark,
setConclusionRemark,
conclusionLoading,
conclusionSubmitting,
aiGenerated,
handleGenerateConclusion,
handleFinalizeConclusion
};
}

View File

@ -0,0 +1,132 @@
import { useState } from 'react';
import { uploadDocument } from '@/services/documentApi';
/**
* Custom Hook: useDocumentUpload
*
* Purpose: Manages document upload functionality with loading states
*
* Responsibilities:
* - Handles file input change events
* - Validates file selection
* - Uploads document to backend
* - Triggers refresh after successful upload
* - Manages upload loading state
* - Handles errors with user-friendly messages
*
* @param apiRequest - Current request object (contains requestId for upload)
* @param refreshDetails - Function to refresh request data after upload
* @returns Object with upload handler, trigger function, and loading state
*/
export function useDocumentUpload(
apiRequest: any,
refreshDetails: () => Promise<void>
) {
// State: Indicates if document is currently being uploaded
const [uploadingDocument, setUploadingDocument] = useState(false);
// State: Stores document for preview modal
const [previewDocument, setPreviewDocument] = useState<{
fileName: string;
fileType: string;
documentId: string;
fileSize?: number;
} | null>(null);
/**
* Function: handleDocumentUpload
*
* Purpose: Process file upload when user selects a file
*
* Process:
* 1. Validate file selection
* 2. Get request UUID (required for backend API)
* 3. Upload file to backend
* 4. Refresh request details to show new document
* 5. Clear file input for next upload
* 6. Show success/error messages
*
* @param event - File input change event
*/
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
// Validate: Check if file is selected
if (!files || files.length === 0) return;
setUploadingDocument(true);
try {
const file = files[0];
// Validate: Ensure file exists
if (!file) {
alert('No file selected');
return;
}
// Validate: Ensure request ID is available
// Note: Backend requires UUID, not request number
const requestId = apiRequest?.requestId;
if (!requestId) {
alert('Request ID not found');
return;
}
// API Call: Upload document to backend
// Document type is 'SUPPORTING' (as opposed to 'REQUIRED')
await uploadDocument(file, requestId, 'SUPPORTING');
// Refresh: Reload request details to show newly uploaded document
// This also updates the activity timeline
await refreshDetails();
// Success feedback
alert('Document uploaded successfully');
} catch (error: any) {
console.error('[useDocumentUpload] Upload error:', error);
// Error feedback with backend error message if available
alert(error?.response?.data?.error || 'Failed to upload document');
} finally {
setUploadingDocument(false);
// Cleanup: Clear the file input to allow re-uploading same file
if (event.target) {
event.target.value = '';
}
}
};
/**
* Function: triggerFileInput
*
* Purpose: Programmatically open file picker dialog
*
* Process:
* 1. Create temporary file input element
* 2. Configure accepted file types
* 3. Attach upload handler
* 4. Trigger click to open file picker
*
* Accepted formats:
* - Documents: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT
* - Images: JPG, JPEG, PNG, GIF
*/
const triggerFileInput = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.gif';
input.onchange = handleDocumentUpload as any;
input.click();
};
return {
uploadingDocument,
handleDocumentUpload,
triggerFileInput,
previewDocument,
setPreviewDocument
};
}

View File

@ -0,0 +1,332 @@
import { useState } from 'react';
import { approveLevel, rejectLevel, addApproverAtLevel, skipApprover, addSpectator } from '@/services/workflowApi';
/**
* Custom Hook: useModalManager
*
* Purpose: Centralized management of all modals and their actions
*
* Responsibilities:
* - Manages visibility state for all modals (approve, reject, add approver, etc.)
* - Handles approval and rejection workflows
* - Manages approver and spectator addition
* - Handles approver skipping
* - Provides action status feedback
* - Triggers data refresh after actions
*
* Modals managed:
* - Approval Modal
* - Rejection Modal
* - Add Approver Modal
* - Add Spectator Modal
* - Skip Approver Modal
* - Action Status Modal (success/error feedback)
*
* @param requestIdentifier - Request number or UUID
* @param currentApprovalLevel - Current user's approval level data
* @param refreshDetails - Function to refresh request data
* @returns Object with modal states and action handlers
*/
export function useModalManager(
requestIdentifier: string,
currentApprovalLevel: any,
refreshDetails: () => Promise<void>
) {
// Modal visibility states
const [showApproveModal, setShowApproveModal] = useState(false);
const [showRejectModal, setShowRejectModal] = useState(false);
const [showAddApproverModal, setShowAddApproverModal] = useState(false);
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
const [showSkipApproverModal, setShowSkipApproverModal] = useState(false);
const [showActionStatusModal, setShowActionStatusModal] = useState(false);
// State: Data for skip approver modal (which approver to skip)
const [skipApproverData, setSkipApproverData] = useState<{
levelId: string;
approverName: string;
levelNumber: number;
} | null>(null);
// State: Action status (success or error) to show in modal
const [actionStatus, setActionStatus] = useState<{
success: boolean;
title: string;
message: string;
} | null>(null);
/**
* Handler: handleApproveConfirm
*
* Purpose: Process approval action when user confirms
*
* Process:
* 1. Validate level ID exists
* 2. Call backend API to approve level
* 3. Refresh request data to show approval
* 4. Close modal and show success message
*
* Backend Actions:
* - Updates level status to APPROVED
* - Records approval timestamp
* - Moves workflow to next level
* - Sends notifications to relevant users
*
* @param description - Optional approval comments/remarks
*/
const handleApproveConfirm = async (description: string) => {
const levelId = currentApprovalLevel?.levelId || currentApprovalLevel?.level_id;
// Validate: Ensure level ID is available
if (!levelId) {
alert('Approval level not found');
return;
}
// API Call: Submit approval
await approveLevel(requestIdentifier, levelId, description || '');
// Refresh: Update UI with new approval status
await refreshDetails();
// Legacy: Global handlers (can be replaced with better toast system)
(window as any)?.closeModal?.();
(window as any)?.toast?.('Approved successfully');
};
/**
* Handler: handleRejectConfirm
*
* Purpose: Process rejection action when user confirms
*
* Process:
* 1. Validate rejection comments are provided
* 2. Call backend API to reject level
* 3. Refresh request data to show rejection
* 4. Close modal and show success message
*
* Backend Actions:
* - Updates level status to REJECTED
* - Records rejection timestamp and reason
* - Stops workflow progression
* - Sends notifications to initiator and relevant users
*
* @param description - Required rejection comments/remarks
*/
const handleRejectConfirm = async (description: string) => {
// Validate: Rejection must have comments
if (!description?.trim()) {
alert('Comments & remarks are required');
return;
}
const levelId = currentApprovalLevel?.levelId || currentApprovalLevel?.level_id;
// Validate: Ensure level ID is available
if (!levelId) {
alert('Approval level not found');
return;
}
// API Call: Submit rejection
// Note: Backend expects both comments and remarks (currently same value)
await rejectLevel(requestIdentifier, levelId, description.trim(), description.trim());
// Refresh: Update UI with rejection status
await refreshDetails();
// Legacy: Global handlers
(window as any)?.closeModal?.();
(window as any)?.toast?.('Rejected successfully');
};
/**
* Handler: handleAddApprover
*
* Purpose: Add a new approver at specific level with TAT
*
* Use Case: Initiator can add approvers dynamically if workflow needs change
*
* Process:
* 1. Validate email, TAT, and level
* 2. Call backend to add approver
* 3. Refresh request to show new approver
* 4. Show success/error modal
* 5. Close add approver modal
*
* Backend Actions:
* - Creates new approval level or adds to existing
* - Sends notification to new approver
* - Updates workflow structure
*
* @param email - Email of user to add as approver
* @param tatHours - Turnaround time allocated for this approver
* @param level - Which approval level to add approver to
*/
const handleAddApprover = async (email: string, tatHours: number, level: number) => {
try {
// API Call: Add approver at specified level
await addApproverAtLevel(requestIdentifier, email, tatHours, level);
// Refresh: Update workflow to show new approver
await refreshDetails();
// Close modal
setShowAddApproverModal(false);
// Success feedback with details
setActionStatus?.({
success: true,
title: 'Approver Added',
message: `Approver added successfully at Level ${level} with ${tatHours}h TAT`
});
setShowActionStatusModal?.(true);
} catch (error: any) {
// Error feedback with backend message
setActionStatus?.({
success: false,
title: 'Failed to Add Approver',
message: error?.response?.data?.error || 'Failed to add approver. Please try again.'
});
setShowActionStatusModal?.(true);
throw error;
}
};
/**
* Handler: handleSkipApprover
*
* Purpose: Skip an approver who is unavailable or unresponsive
*
* Use Case: When approver is on leave, workflow can continue without them
*
* Process:
* 1. Validate skip data exists
* 2. Submit skip reason to backend
* 3. Workflow moves to next level automatically
* 4. Show success/error feedback
* 5. Clear skip data and close modal
*
* Backend Actions:
* - Marks level as SKIPPED
* - Records skip reason and timestamp
* - Moves workflow to next level
* - Sends notifications
*
* @param reason - Required reason for skipping approver
*/
const handleSkipApprover = async (reason: string) => {
if (!skipApproverData) return;
try {
// API Call: Skip approver with reason
await skipApprover(requestIdentifier, skipApproverData.levelId, reason);
// Refresh: Update workflow to show skipped status
await refreshDetails();
// Cleanup and close
setShowSkipApproverModal(false);
setSkipApproverData(null);
// Success feedback
setActionStatus?.({
success: true,
title: 'Approver Skipped',
message: 'Approver skipped successfully. The workflow has moved to the next level.'
});
setShowActionStatusModal?.(true);
} catch (error: any) {
// Error feedback
setActionStatus?.({
success: false,
title: 'Failed to Skip Approver',
message: error?.response?.data?.error || 'Failed to skip approver. Please try again.'
});
setShowActionStatusModal?.(true);
throw error;
}
};
/**
* Handler: handleAddSpectator
*
* Purpose: Add a spectator who can view request but cannot approve/reject
*
* Use Case: Add stakeholders who need visibility but not approval authority
*
* Process:
* 1. Validate email
* 2. Call backend to add spectator
* 3. Refresh to show new spectator
* 4. Show success/error feedback
* 5. Close add spectator modal
*
* Backend Actions:
* - Adds user as SPECTATOR participant
* - Grants view-only access
* - Sends notification to spectator
*
* @param email - Email of user to add as spectator
*/
const handleAddSpectator = async (email: string) => {
try {
// API Call: Add spectator
await addSpectator(requestIdentifier, email);
// Refresh: Update participants list
await refreshDetails();
// Close modal
setShowAddSpectatorModal(false);
// Success feedback
setActionStatus?.({
success: true,
title: 'Spectator Added',
message: 'Spectator added successfully. They can now view this request.'
});
setShowActionStatusModal?.(true);
} catch (error: any) {
// Error feedback
setActionStatus?.({
success: false,
title: 'Failed to Add Spectator',
message: error?.response?.data?.error || 'Failed to add spectator. Please try again.'
});
setShowActionStatusModal?.(true);
throw error;
}
};
return {
// Modal visibility states
showApproveModal,
setShowApproveModal,
showRejectModal,
setShowRejectModal,
showAddApproverModal,
setShowAddApproverModal,
showAddSpectatorModal,
setShowAddSpectatorModal,
showSkipApproverModal,
setShowSkipApproverModal,
showActionStatusModal,
setShowActionStatusModal,
// Skip approver data
skipApproverData,
setSkipApproverData,
// Action status
actionStatus,
setActionStatus,
// Action handlers
handleApproveConfirm,
handleRejectConfirm,
handleAddApprover,
handleSkipApprover,
handleAddSpectator
};
}

View File

@ -0,0 +1,560 @@
import { useState, useEffect, useMemo } from 'react';
import workflowApi from '@/services/workflowApi';
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
/**
* Custom Hook: useRequestDetails
*
* Purpose: Manages request data fetching, transformation, and state management
*
* Responsibilities:
* - Fetches workflow details from API using request identifier (request number or UUID)
* - Transforms backend data structure to frontend format
* - Maps approval levels with TAT alerts
* - Handles spectators and participants
* - Provides refresh functionality
* - Falls back to static databases when API fails
*
* @param requestIdentifier - Request number or UUID to fetch
* @param dynamicRequests - Optional array of dynamic requests for fallback
* @param user - Current authenticated user object
* @returns Object containing request data, loading state, refresh function, etc.
*/
export function useRequestDetails(
requestIdentifier: string,
dynamicRequests: any[] = [],
user: any
) {
// State: Stores the fetched and transformed request data
const [apiRequest, setApiRequest] = useState<any | null>(null);
// State: Indicates if data is currently being fetched
const [refreshing, setRefreshing] = useState(false);
// State: Stores the current approval level for the logged-in user
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
// State: Indicates if the current user is a spectator (view-only access)
const [isSpectator, setIsSpectator] = useState(false);
/**
* Helper: Convert name/email to initials for avatar display
* Example: "John Doe" "JD", "john@email.com" "JO"
*/
const toInitials = (name?: string, email?: string) => {
const base = (name || email || 'NA').toString();
return base.split(' ').map(s => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase();
};
/**
* Helper: Map backend status strings to frontend display format
* Converts: IN_PROGRESS in-review, PENDING pending, etc.
*/
const statusMap = (s: string) => {
const val = (s || '').toUpperCase();
if (val === 'IN_PROGRESS') return 'in-review';
if (val === 'PENDING') return 'pending';
if (val === 'APPROVED') return 'approved';
if (val === 'REJECTED') return 'rejected';
if (val === 'CLOSED') return 'closed';
if (val === 'SKIPPED') return 'skipped';
return (s || '').toLowerCase();
};
/**
* Function: refreshDetails
*
* Purpose: Fetch the latest request data from backend and update all state
*
* Process:
* 1. Fetch workflow details from API
* 2. Extract and validate data arrays (approvals, participants, documents, TAT alerts)
* 3. Transform approval levels with TAT alerts
* 4. Map spectators and documents
* 5. Filter out TAT warning activities from audit trail
* 6. Update all state with transformed data
* 7. Determine current user's approval level and spectator status
*/
const refreshDetails = async () => {
setRefreshing(true);
try {
// API Call: Fetch complete workflow details including approvals, documents, participants
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
if (!details) {
console.warn('[useRequestDetails] No details returned from API');
return;
}
// Extract: Separate data structures from API response
const wf = details.workflow || {};
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
const participants = Array.isArray(details.participants) ? details.participants : [];
const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
// Debug: Log TAT alerts for monitoring
if (tatAlerts.length > 0) {
console.log(`[useRequestDetails] Found ${tatAlerts.length} TAT alerts:`, tatAlerts);
}
const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
/**
* Transform: Map approval levels to UI format with TAT alerts
* Each approval level includes:
* - Display status (waiting, pending, in-review, approved, rejected, skipped)
* - TAT information (hours, elapsed, remaining, percentage)
* - TAT alerts specific to this level
* - Approver details
*/
const approvalFlow = approvals.map((a: any) => {
const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id;
// Determine display status based on workflow progress
let displayStatus = statusMap(a.status);
// Future levels that haven't been reached yet show as "waiting"
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
displayStatus = 'waiting';
}
// Current level with pending status shows as "pending"
else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
displayStatus = 'pending';
}
// Filter: Get TAT alerts that belong to this specific approval level
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
return {
step: levelNumber,
levelId,
role: a.levelName || a.approverName || 'Approver',
status: displayStatus,
approver: a.approverName || a.approverEmail,
approverId: a.approverId || a.approver_id,
approverEmail: a.approverEmail,
tatHours: Number(a.tatHours || 0),
elapsedHours: Number(a.elapsedHours || 0),
remainingHours: Number(a.remainingHours || 0),
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
// Calculate actual hours taken if level is completed
actualHours: a.levelEndTime && a.levelStartTime
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
: undefined,
comment: a.comments || undefined,
timestamp: a.actionDate || undefined,
levelStartTime: a.levelStartTime || a.tatStartTime,
tatAlerts: levelAlerts,
skipReason: a.skipReason || undefined,
isSkipped: levelStatus === 'SKIPPED' || a.isSkipped || false,
};
});
/**
* Transform: Map spectators from participants array
* Spectators have view-only access to the request
*/
const spectators = participants
.filter((p: any) => (p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR')
.map((p: any) => ({
name: p.userName || p.user_name || p.userEmail || p.user_email,
role: 'Spectator',
email: p.userEmail || p.user_email,
avatar: toInitials(p.userName || p.user_name, p.userEmail || p.user_email),
}));
/**
* Helper: Get participant name by userId
* Used for document upload attribution
*/
const participantNameById = (uid?: string) => {
if (!uid) return undefined;
const p = participants.find((x: any) => x.userId === uid || x.user_id === uid);
if (p?.userName || p?.user_name) return p.userName || p.user_name;
if (wf.initiatorId === uid) return wf.initiator?.displayName || wf.initiator?.email;
return uid;
};
/**
* Transform: Map documents with file size conversion and uploader details
* Converts bytes to MB for better readability
*/
const mappedDocuments = documents.map((d: any) => {
const sizeBytes = Number(d.fileSize || d.file_size || 0);
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2) + ' MB';
return {
documentId: d.documentId || d.document_id,
name: d.originalFileName || d.fileName || d.file_name,
fileType: d.fileType || d.file_type || '',
size: sizeMb,
sizeBytes: sizeBytes,
uploadedBy: participantNameById(d.uploadedBy || d.uploaded_by),
uploadedAt: d.uploadedAt || d.uploaded_at,
};
});
/**
* Filter: Remove TAT breach activities from audit trail
* TAT warnings are already shown in approval steps, no need to duplicate in timeline
*/
const filteredActivities = Array.isArray(details.activities)
? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning';
})
: [];
/**
* Build: Complete request object with all transformed data
* This object is used throughout the UI
*/
const updatedRequest = {
...wf,
id: wf.requestNumber || wf.requestId,
requestId: wf.requestId, // UUID for API calls
requestNumber: wf.requestNumber, // Human-readable number for display
title: wf.title,
description: wf.description,
status: statusMap(wf.status),
priority: (wf.priority || '').toString().toLowerCase(),
approvalFlow,
approvals, // Raw approvals for SLA calculations
participants,
documents: mappedDocuments,
spectators,
summary, // Backend-provided SLA summary
initiator: {
name: wf.initiator?.displayName || wf.initiator?.email,
role: wf.initiator?.designation || undefined,
department: wf.initiator?.department || undefined,
email: wf.initiator?.email || undefined,
phone: wf.initiator?.phone || undefined,
avatar: toInitials(wf.initiator?.displayName, wf.initiator?.email),
},
createdAt: wf.createdAt,
updatedAt: wf.updatedAt,
totalSteps: wf.totalLevels,
currentStep: summary?.currentLevel || wf.currentLevel,
auditTrail: filteredActivities,
conclusionRemark: wf.conclusionRemark || null,
closureDate: wf.closureDate || null,
};
setApiRequest(updatedRequest);
/**
* Determine: Find the approval level assigned to current user
* Used to show approve/reject buttons only when user has pending approval
*/
const userEmail = (user as any)?.email?.toLowerCase();
const newCurrentLevel = approvals.find((a: any) => {
const st = (a.status || '').toString().toUpperCase();
const approverEmail = (a.approverEmail || '').toLowerCase();
return (st === 'PENDING' || st === 'IN_PROGRESS') && approverEmail === userEmail;
});
setCurrentApprovalLevel(newCurrentLevel || null);
/**
* Determine: Check if current user is a spectator
* Spectators can only view and comment, cannot approve/reject
*/
const viewerId = (user as any)?.userId;
if (viewerId) {
const isSpec = participants.some((p: any) =>
(p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' &&
(p.userId || p.user_id) === viewerId
);
setIsSpectator(isSpec);
} else {
setIsSpectator(false);
}
} catch (error) {
console.error('[useRequestDetails] Error refreshing details:', error);
alert('Failed to refresh request details. Please try again.');
} finally {
setRefreshing(false);
}
};
/**
* Effect: Initial data fetch when component mounts or requestIdentifier changes
* This is the primary data loading mechanism
*/
useEffect(() => {
if (!requestIdentifier) return;
let mounted = true;
(async () => {
try {
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
if (!mounted || !details) return;
// Use the same transformation logic as refreshDetails
const wf = details.workflow || {};
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
const participants = Array.isArray(details.participants) ? details.participants : [];
const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
console.log('[useRequestDetails] TAT Alerts received:', tatAlerts.length, tatAlerts);
const priority = (wf.priority || '').toString().toLowerCase();
const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
// Transform approval flow (same logic as refreshDetails)
const approvalFlow = approvals.map((a: any) => {
const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id;
let displayStatus = statusMap(a.status);
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
displayStatus = 'waiting';
} else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
displayStatus = 'pending';
}
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
return {
step: levelNumber,
levelId,
role: a.levelName || a.approverName || 'Approver',
status: displayStatus,
approver: a.approverName || a.approverEmail,
approverId: a.approverId || a.approver_id,
approverEmail: a.approverEmail,
tatHours: Number(a.tatHours || 0),
elapsedHours: Number(a.elapsedHours || 0),
remainingHours: Number(a.remainingHours || 0),
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
actualHours: a.levelEndTime && a.levelStartTime
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
: undefined,
comment: a.comments || undefined,
timestamp: a.actionDate || undefined,
levelStartTime: a.levelStartTime || a.tatStartTime,
tatAlerts: levelAlerts,
};
});
// Map spectators
const spectators = participants
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
.map((p: any) => ({
name: p.userName || p.userEmail,
role: 'Spectator',
avatar: toInitials(p.userName, p.userEmail),
}));
// Helper to get participant name by ID
const participantNameById = (uid?: string) => {
if (!uid) return undefined;
const p = participants.find((x: any) => x.userId === uid);
if (p?.userName) return p.userName;
if (wf.initiatorId === uid) return wf.initiator?.displayName || wf.initiator?.email;
return uid;
};
// Map documents with size conversion
const mappedDocuments = documents.map((d: any) => {
const sizeBytes = Number(d.fileSize || 0);
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2) + ' MB';
return {
documentId: d.documentId || d.document_id,
name: d.originalFileName || d.fileName,
fileType: d.fileType || d.file_type || '',
size: sizeMb,
sizeBytes: sizeBytes,
uploadedBy: participantNameById(d.uploadedBy),
uploadedAt: d.uploadedAt,
};
});
// Filter out TAT warnings from activities
const filteredActivities = Array.isArray(details.activities)
? details.activities.filter((activity: any) => {
const activityType = (activity.type || '').toLowerCase();
return activityType !== 'sla_warning';
})
: [];
// Build complete request object
const mapped = {
id: wf.requestNumber || wf.requestId,
requestId: wf.requestId,
title: wf.title,
description: wf.description,
priority,
status: statusMap(wf.status),
summary,
initiator: {
name: wf.initiator?.displayName || wf.initiator?.email,
role: wf.initiator?.designation || undefined,
department: wf.initiator?.department || undefined,
email: wf.initiator?.email || undefined,
phone: wf.initiator?.phone || undefined,
avatar: toInitials(wf.initiator?.displayName, wf.initiator?.email),
},
createdAt: wf.createdAt,
updatedAt: wf.updatedAt,
totalSteps: wf.totalLevels,
currentStep: summary?.currentLevel || wf.currentLevel,
approvalFlow,
approvals,
documents: mappedDocuments,
spectators,
auditTrail: filteredActivities,
conclusionRemark: wf.conclusionRemark || null,
closureDate: wf.closureDate || null,
};
setApiRequest(mapped);
// Find current user's approval level
const userEmail = (user as any)?.email?.toLowerCase();
const userCurrentLevel = approvals.find((a: any) => {
const status = (a.status || '').toString().toUpperCase();
const approverEmail = (a.approverEmail || '').toLowerCase();
return (status === 'PENDING' || status === 'IN_PROGRESS') && approverEmail === userEmail;
});
setCurrentApprovalLevel(userCurrentLevel || null);
// Check spectator status
const viewerId = (user as any)?.userId;
if (viewerId) {
const isSpec = participants.some((p: any) =>
(p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId
);
setIsSpectator(isSpec);
} else {
setIsSpectator(false);
}
} catch (error) {
console.error('[useRequestDetails] Error loading request details:', error);
if (mounted) {
setApiRequest(null);
}
}
})();
return () => { mounted = false; };
}, [requestIdentifier, user]);
/**
* Computed: Get final request object with fallback to static databases
* Priority: API data Custom DB Claim DB Dynamic props null
*/
const request = useMemo(() => {
// Primary source: API data
if (apiRequest) return apiRequest;
// Fallback 1: Static custom request database
const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier];
if (customRequest) return customRequest;
// Fallback 2: Static claim management database
const claimRequest = CLAIM_MANAGEMENT_DATABASE[requestIdentifier];
if (claimRequest) return claimRequest;
// Fallback 3: Dynamic requests passed as props
const dynamicRequest = dynamicRequests.find((req: any) =>
req.id === requestIdentifier ||
req.requestNumber === requestIdentifier ||
req.request_number === requestIdentifier
);
if (dynamicRequest) return dynamicRequest;
return null;
}, [requestIdentifier, dynamicRequests, apiRequest]);
/**
* Computed: Check if current user is the request initiator
* Initiators have special permissions (add approvers, skip approvers, close request)
*/
const isInitiator = useMemo(() => {
if (!request || !user) return false;
const userEmail = (user as any)?.email?.toLowerCase();
const initiatorEmail = request.initiator?.email?.toLowerCase();
return userEmail === initiatorEmail;
}, [request, user]);
/**
* Computed: Get all existing participants for validation
* Used when adding new approvers/spectators to prevent duplicates
*/
const existingParticipants = useMemo(() => {
if (!request) return [];
const participants: Array<{ email: string; participantType: string; name?: string }> = [];
// Add initiator
if (request.initiator?.email) {
participants.push({
email: request.initiator.email.toLowerCase(),
participantType: 'INITIATOR',
name: request.initiator.name
});
}
// Add approvers from approval flow
if (request.approvalFlow && Array.isArray(request.approvalFlow)) {
request.approvalFlow.forEach((approval: any) => {
if (approval.approverEmail) {
participants.push({
email: approval.approverEmail.toLowerCase(),
participantType: 'APPROVER',
name: approval.approver
});
}
});
}
// Add spectators
if (request.spectators && Array.isArray(request.spectators)) {
request.spectators.forEach((spectator: any) => {
if (spectator.email) {
participants.push({
email: spectator.email.toLowerCase(),
participantType: 'SPECTATOR',
name: spectator.name
});
}
});
}
// Add from participants array
if (request.participants && Array.isArray(request.participants)) {
request.participants.forEach((p: any) => {
const email = (p.userEmail || p.email || '').toLowerCase();
const participantType = (p.participantType || p.participant_type || '').toUpperCase();
const name = p.userName || p.user_name || p.name;
if (email && participantType && !participants.find(x => x.email === email)) {
participants.push({ email, participantType, name });
}
});
}
return participants;
}, [request]);
return {
request,
apiRequest,
refreshing,
refreshDetails,
currentApprovalLevel,
isSpectator,
isInitiator,
existingParticipants
};
}

View File

@ -0,0 +1,277 @@
import { useEffect, useState } from 'react';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
import { getWorkNotes } from '@/services/workflowApi';
import workflowApi from '@/services/workflowApi';
/**
* Custom Hook: useRequestSocket
*
* Purpose: Manages real-time WebSocket connection for request updates
*
* Responsibilities:
* - Establishes socket connection for the request
* - Joins/leaves request-specific room
* - Listens for new work notes in real-time
* - Listens for TAT alerts and updates
* - Merges work notes with activity timeline
* - Manages unread work notes badge
* - Handles socket cleanup on unmount
*
* @param requestIdentifier - Request number or UUID
* @param apiRequest - Current request data object
* @param activeTab - Currently active tab
* @param user - Current authenticated user
* @returns Object with merged messages, unread count, and work note attachments
*/
export function useRequestSocket(
requestIdentifier: string,
apiRequest: any,
activeTab: string,
user: any
) {
// State: Merged array of work notes and activities, sorted chronologically
const [mergedMessages, setMergedMessages] = useState<any[]>([]);
// State: Count of unread work notes (shows badge on Work Notes tab)
const [unreadWorkNotes, setUnreadWorkNotes] = useState(0);
// State: Attachments extracted from work notes for Documents tab
const [workNoteAttachments, setWorkNoteAttachments] = useState<any[]>([]);
/**
* Effect: Establish socket connection and join request room
*
* Process:
* 1. Resolve UUID from request number if needed
* 2. Initialize socket connection
* 3. Join request-specific room (makes user "online" for this request)
* 4. Cleanup on unmount (leave room, remove listeners)
*/
useEffect(() => {
if (!requestIdentifier) {
console.warn('[useRequestSocket] No requestIdentifier, cannot join socket room');
return;
}
console.log('[useRequestSocket] Initializing socket connection for:', requestIdentifier);
let mounted = true;
let actualRequestId = requestIdentifier;
(async () => {
try {
// API Call: Fetch UUID if we have request number (socket rooms use UUID)
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
if (details?.workflow?.requestId && mounted) {
actualRequestId = details.workflow.requestId;
console.log('[useRequestSocket] Resolved UUID:', actualRequestId);
}
} catch (error) {
console.error('[useRequestSocket] Failed to resolve UUID:', error);
}
if (!mounted) return;
// Initialize: Get socket instance with base URL
const baseUrl = import.meta.env.VITE_API_BASE_URL?.replace('/api/v1', '') || 'http://localhost:5000';
const socket = getSocket(baseUrl);
if (!socket) {
console.error('[useRequestSocket] Socket not available');
return;
}
const userId = (user as any)?.userId;
/**
* Handler: Join request room when socket connects
* This makes the user "online" for this specific request
*/
const handleConnect = () => {
console.log('[useRequestSocket] Socket connected, joining room:', actualRequestId);
joinRequestRoom(socket, actualRequestId, userId);
console.log(`[useRequestSocket] ✅ Joined room: ${actualRequestId} - User is ONLINE`);
};
// Join immediately if already connected, otherwise wait for connect event
if (socket.connected) {
handleConnect();
} else {
socket.on('connect', handleConnect);
}
/**
* Cleanup: Leave room and remove listeners when component unmounts
* This marks user as "offline" for this request
*/
return () => {
if (mounted) {
socket.off('connect', handleConnect);
leaveRequestRoom(socket, actualRequestId);
console.log(`[useRequestSocket] ✅ Left room: ${actualRequestId} - User is OFFLINE`);
}
};
})();
return () => { mounted = false; };
}, [requestIdentifier, user]);
/**
* Effect: Fetch and merge work notes with activities for timeline display
*
* Purpose: Combine work notes (real-time chat) with audit trail (system events)
* to create a unified timeline view
*/
useEffect(() => {
if (!requestIdentifier || !apiRequest) return;
(async () => {
try {
// Fetch: Get all work notes for this request
const workNotes = await getWorkNotes(requestIdentifier);
const activities = apiRequest.auditTrail || [];
// Merge: Combine work notes and activities
const merged = [...workNotes, ...activities];
// Sort: Order by timestamp (oldest to newest)
merged.sort((a, b) => {
const timeA = new Date(a.createdAt || a.created_at || a.timestamp || 0).getTime();
const timeB = new Date(b.createdAt || b.created_at || b.timestamp || 0).getTime();
return timeA - timeB;
});
setMergedMessages(merged);
console.log(`[useRequestSocket] Merged ${workNotes.length} work notes with ${activities.length} activities`);
} catch (error) {
console.error('[useRequestSocket] Failed to fetch and merge messages:', error);
}
})();
}, [requestIdentifier, apiRequest]);
/**
* Effect: Listen for real-time work notes and TAT alerts via WebSocket
*
* Listens for:
* 1. 'noteHandler' / 'worknote:new' - New work note added
* 2. 'tat:alert' - TAT threshold reached or deadline breached
*/
useEffect(() => {
if (!requestIdentifier) return;
const baseUrl = import.meta.env.VITE_API_BASE_URL?.replace('/api/v1', '') || 'http://localhost:5000';
const socket = getSocket(baseUrl);
if (!socket) return;
/**
* Handler: New work note received via WebSocket
*
* Actions:
* 1. Increment unread badge if user is not on Work Notes tab
* 2. Refresh merged messages to show new note
*/
const handleNewWorkNote = (data: any) => {
console.log(`[useRequestSocket] 🆕 New work note received:`, data);
// Update unread badge (only if not viewing work notes)
if (activeTab !== 'worknotes') {
setUnreadWorkNotes(prev => prev + 1);
}
// Refresh: Re-fetch and merge messages to include new work note
(async () => {
try {
const workNotes = await getWorkNotes(requestIdentifier);
const activities = apiRequest?.auditTrail || [];
const merged = [...workNotes, ...activities].sort((a, b) => {
const timeA = new Date(a.createdAt || a.created_at || a.timestamp || 0).getTime();
const timeB = new Date(b.createdAt || b.created_at || b.timestamp || 0).getTime();
return timeA - timeB;
});
setMergedMessages(merged);
} catch (error) {
console.error('[useRequestSocket] Failed to refresh messages:', error);
}
})();
};
/**
* Handler: TAT alert received via WebSocket
*
* Triggered when:
* - 50% TAT threshold reached
* - 75% TAT threshold reached
* - 100% TAT deadline breached
*
* Actions:
* 1. Show console notification with emoji indicator
* 2. Refresh request data to get updated TAT alerts
* 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
const alertEmoji = data.type === 'breach' ? '⏰' : data.type === 'threshold2' ? '⚠️' : '⏳';
console.log(`%c${alertEmoji} TAT Alert: ${data.message}`, 'color: #ff6600; font-size: 14px; font-weight: bold;');
// Refresh: Get updated TAT alerts from backend
(async () => {
try {
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
if (details) {
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
console.log(`[useRequestSocket] Refreshed TAT alerts:`, tatAlerts);
// Browser notification (if user granted permission)
if ('Notification' in window && Notification.permission === 'granted') {
new Notification(`${alertEmoji} TAT Alert`, {
body: data.message,
icon: '/favicon.ico',
tag: `tat-${data.requestId}-${data.type}`,
requireInteraction: false
});
}
}
} catch (error) {
console.error('[useRequestSocket] Failed to refresh after TAT alert:', error);
}
})();
};
// Register: Add event listeners for real-time updates
socket.on('noteHandler', handleNewWorkNote);
socket.on('worknote:new', handleNewWorkNote);
socket.on('tat:alert', handleTatAlert);
/**
* Cleanup: Remove event listeners when component unmounts or dependencies change
* Prevents memory leaks and duplicate listeners
*/
return () => {
socket.off('noteHandler', handleNewWorkNote);
socket.off('worknote:new', handleNewWorkNote);
socket.off('tat:alert', handleTatAlert);
};
}, [requestIdentifier, activeTab, apiRequest]);
/**
* Effect: Reset unread count when user switches to Work Notes tab
* User has seen the messages, so clear the badge
*/
useEffect(() => {
if (activeTab === 'worknotes') {
setUnreadWorkNotes(0);
}
}, [activeTab]);
return {
mergedMessages,
unreadWorkNotes,
workNoteAttachments,
setWorkNoteAttachments
};
}

View File

@ -65,7 +65,7 @@ export function AuthenticatedApp() {
console.log('Last Name:', user.lastName);
console.log('Department:', user.department);
console.log('Designation:', user.designation);
console.log('Is Admin:', user.isAdmin);
console.log('Role:', user.role);
console.log('========================================');
console.log('ALL USER DATA:');
console.log(JSON.stringify(user, null, 2));

File diff suppressed because it is too large Load Diff

View File

@ -2,26 +2,27 @@ import { useEffect, useState } from 'react';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
FileText,
Search,
Clock,
CheckCircle,
XCircle,
AlertCircle,
XCircle,
User,
ArrowRight,
TrendingUp,
Eye,
Edit,
Flame,
Target
Target,
Eye,
AlertCircle
} from 'lucide-react';
import { motion } from 'framer-motion';
import workflowApi from '@/services/workflowApi';
// SLATracker removed - not needed on MyRequests (only for OpenRequests where user is approver)
import { PageHeader } from '@/components/common/PageHeader';
import { StatsCard } from '@/components/dashboard/StatsCard';
import { Pagination } from '@/components/common/Pagination';
interface MyRequestsProps {
onViewRequest: (requestId: string, requestTitle?: string, status?: string) => void;
@ -149,23 +150,6 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
}
};
const getPageNumbers = () => {
const pages = [];
const maxPagesToShow = 5;
let startPage = Math.max(1, currentPage - Math.floor(maxPagesToShow / 2));
let endPage = Math.min(totalPages, startPage + maxPagesToShow - 1);
if (endPage - startPage < maxPagesToShow - 1) {
startPage = Math.max(1, endPage - maxPagesToShow + 1);
}
for (let i = startPage; i <= endPage; i++) {
pages.push(i);
}
return pages;
};
useEffect(() => {
fetchMyRequests(1);
}, []);
@ -223,94 +207,80 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
};
return (
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto">
{/* Enhanced Header */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4 md:gap-6">
<div className="space-y-1 sm:space-y-2">
<div className="flex items-center gap-2 sm:gap-3">
<div className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl flex items-center justify-center shadow-lg">
<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">My Requests</h1>
<p className="text-sm sm:text-base text-gray-600">Track and manage all your submitted requests</p>
</div>
</div>
</div>
<div className="flex items-center gap-2 sm:gap-3">
<Badge variant="secondary" className="text-xs sm:text-sm md:text-base px-2 sm:px-3 md:px-4 py-1 sm:py-1.5 md:py-2 bg-slate-100 text-slate-800 font-semibold">
{loading ? 'Loading…' : `${totalRecords || allRequests.length} total`}
<span className="hidden sm:inline ml-1">requests</span>
</Badge>
</div>
</div>
<div className="space-y-4 sm:space-y-6 max-w-7xl mx-auto" data-testid="my-requests-page">
{/* Page Header */}
<PageHeader
icon={FileText}
title="My Requests"
description="Track and manage all your submitted requests"
badge={{
value: `${totalRecords || allRequests.length} total`,
label: 'requests',
loading
}}
testId="my-requests-header"
/>
{/* Stats Overview */}
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 sm:gap-4">
<Card className="bg-gradient-to-br from-blue-50 to-blue-100 border-blue-200">
<CardContent className="p-3 sm:p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs sm:text-sm text-blue-700 font-medium">Total</p>
<p className="text-xl sm:text-2xl font-bold text-blue-900">{stats.total}</p>
</div>
<FileText className="w-6 h-6 sm:w-8 sm:h-8 text-blue-600" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200">
<CardContent className="p-3 sm:p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs sm:text-sm text-orange-700 font-medium">In Progress</p>
<p className="text-xl sm:text-2xl font-bold text-orange-900">{stats.pending + stats.inReview}</p>
</div>
<Clock className="w-6 h-6 sm:w-8 sm:h-8 text-orange-600" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-green-50 to-green-100 border-green-200">
<CardContent className="p-3 sm:p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs sm:text-sm text-green-700 font-medium">Approved</p>
<p className="text-xl sm:text-2xl font-bold text-green-900">{stats.approved}</p>
</div>
<CheckCircle className="w-6 h-6 sm:w-8 sm:h-8 text-green-600" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-red-50 to-red-100 border-red-200">
<CardContent className="p-3 sm:p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs sm:text-sm text-red-700 font-medium">Rejected</p>
<p className="text-xl sm:text-2xl font-bold text-red-900">{stats.rejected}</p>
</div>
<XCircle className="w-6 h-6 sm:w-8 sm:h-8 text-red-600" />
</div>
</CardContent>
</Card>
<Card className="bg-gradient-to-br from-gray-50 to-gray-100 border-gray-200">
<CardContent className="p-3 sm:p-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs sm:text-sm text-gray-700 font-medium">Draft</p>
<p className="text-xl sm:text-2xl font-bold text-gray-900">{stats.draft}</p>
</div>
<Edit className="w-6 h-6 sm:w-8 sm:h-8 text-gray-600" />
</div>
</CardContent>
</Card>
<div className="grid grid-cols-2 md:grid-cols-5 gap-3 sm:gap-4" data-testid="my-requests-stats">
<StatsCard
label="Total"
value={stats.total}
icon={FileText}
iconColor="text-blue-600"
gradient="bg-gradient-to-br from-blue-50 to-blue-100 border-blue-200"
textColor="text-blue-700"
valueColor="text-blue-900"
testId="stat-total"
/>
<StatsCard
label="In Progress"
value={stats.pending + stats.inReview}
icon={Clock}
iconColor="text-orange-600"
gradient="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200"
textColor="text-orange-700"
valueColor="text-orange-900"
testId="stat-in-progress"
/>
<StatsCard
label="Approved"
value={stats.approved}
icon={CheckCircle}
iconColor="text-green-600"
gradient="bg-gradient-to-br from-green-50 to-green-100 border-green-200"
textColor="text-green-700"
valueColor="text-green-900"
testId="stat-approved"
/>
<StatsCard
label="Rejected"
value={stats.rejected}
icon={XCircle}
iconColor="text-red-600"
gradient="bg-gradient-to-br from-red-50 to-red-100 border-red-200"
textColor="text-red-700"
valueColor="text-red-900"
testId="stat-rejected"
/>
<StatsCard
label="Draft"
value={stats.draft}
icon={Edit}
iconColor="text-gray-600"
gradient="bg-gradient-to-br from-gray-50 to-gray-100 border-gray-200"
textColor="text-gray-700"
valueColor="text-gray-900"
testId="stat-draft"
/>
</div>
{/* Filters and Search */}
<Card className="border-gray-200">
<Card className="border-gray-200" data-testid="my-requests-filters">
<CardContent className="p-3 sm:p-4 md:p-6">
<div className="flex flex-col md:flex-row gap-3 sm:gap-4 items-start md:items-center">
<div className="flex-1 relative w-full">
@ -320,12 +290,16 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 text-sm sm:text-base bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10"
data-testid="search-input"
/>
</div>
<div className="flex gap-2 sm:gap-3 w-full md:w-auto">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10">
<SelectTrigger
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10"
data-testid="status-filter"
>
<SelectValue placeholder="Status" />
</SelectTrigger>
<SelectContent>
@ -339,7 +313,10 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
</Select>
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10">
<SelectTrigger
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10"
data-testid="priority-filter"
>
<SelectValue placeholder="Priority" />
</SelectTrigger>
<SelectContent>
@ -354,13 +331,13 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
</Card>
{/* Requests List */}
<div className="space-y-4">
<div className="space-y-4" data-testid="my-requests-list">
{loading ? (
<Card>
<Card data-testid="loading-state">
<CardContent className="p-6 text-sm text-gray-600">Loading your requests</CardContent>
</Card>
) : filteredRequests.length === 0 ? (
<Card>
<Card data-testid="empty-state">
<CardContent className="p-12 text-center">
<FileText className="w-12 h-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No requests found</h3>
@ -382,19 +359,24 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
<Card
className="group hover:shadow-lg transition-all duration-300 cursor-pointer border border-gray-200 shadow-sm hover:shadow-md"
onClick={() => onViewRequest(request.id, request.title, request.status)}
data-testid={`request-card-${request.id}`}
>
<CardContent className="p-3 sm:p-6">
<div className="space-y-3 sm:space-y-4">
{/* Header with Title and Status Badges */}
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<h4 className="text-base sm:text-lg font-semibold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors line-clamp-2">
<h4
className="text-base sm:text-lg font-semibold text-gray-900 mb-2 group-hover:text-blue-600 transition-colors line-clamp-2"
data-testid="request-title"
>
{request.title}
</h4>
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mb-2">
<Badge
variant="outline"
className={`${getStatusConfig(request.status).color} border font-medium text-xs shrink-0`}
data-testid="status-badge"
>
{(() => {
const IconComponent = getStatusConfig(request.status).icon;
@ -405,6 +387,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
<Badge
variant="outline"
className={`${getPriorityConfig(request.priority).color} border font-medium text-xs capitalize shrink-0`}
data-testid="priority-badge"
>
{(() => {
const IconComponent = getPriorityConfig(request.priority).icon;
@ -413,18 +396,29 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
{request.priority}
</Badge>
{(request as any).templateType && (
<Badge variant="secondary" className="bg-purple-100 text-purple-700 text-xs shrink-0 hidden sm:inline-flex">
<Badge
variant="secondary"
className="bg-purple-100 text-purple-700 text-xs shrink-0 hidden sm:inline-flex"
data-testid="template-badge"
>
<FileText className="w-3 h-3 mr-1" />
Template: {(request as any).templateName}
</Badge>
)}
</div>
<p className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2">
<p
className="text-xs sm:text-sm text-gray-600 mb-2 sm:mb-3 line-clamp-2"
data-testid="request-description"
>
{request.description}
</p>
<div className="flex flex-col sm:flex-row sm:items-center gap-1 sm:gap-4 text-xs sm:text-sm text-gray-500">
<span className="truncate"><span className="font-medium">ID:</span> {(request as any).displayId || request.id}</span>
<span className="truncate"><span className="font-medium">Submitted:</span> {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}</span>
<span className="truncate" data-testid="request-id-display">
<span className="font-medium">ID:</span> {(request as any).displayId || request.id}
</span>
<span className="truncate" data-testid="submitted-date">
<span className="font-medium">Submitted:</span> {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}
</span>
</div>
</div>
<ArrowRight className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400 group-hover:text-blue-600 transition-colors flex-shrink-0 mt-1" />
@ -435,20 +429,22 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
<div className="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-4">
<div className="flex items-center gap-2 min-w-0">
<User className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
<span className="text-xs sm:text-sm truncate">
<span className="text-xs sm:text-sm truncate" data-testid="current-approver">
<span className="text-gray-500">Current Approver:</span> <span className="text-gray-900 font-medium">{request.currentApprover}</span>
</span>
</div>
<div className="flex items-center gap-2">
<TrendingUp className="w-3.5 h-3.5 sm:w-4 sm:h-4 text-gray-400 flex-shrink-0" />
<span className="text-xs sm:text-sm">
<span className="text-xs sm:text-sm" data-testid="approval-level">
<span className="text-gray-500">Approval Level:</span> <span className="text-gray-900 font-medium">{request.approverLevel}</span>
</span>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<Clock className="w-3.5 h-3.5 flex-shrink-0" />
<span>Submitted: {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}</span>
<span data-testid="submitted-timestamp">
Submitted: {request.submittedDate ? new Date(request.submittedDate).toLocaleDateString() : 'N/A'}
</span>
</div>
</div>
</div>
@ -459,66 +455,17 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
)}
</div>
{/* Pagination Controls */}
{totalPages > 1 && !loading && (
<Card className="shadow-md border-gray-200">
<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">
Showing {((currentPage - 1) * itemsPerPage) + 1} to {Math.min(currentPage * itemsPerPage, totalRecords)} of {totalRecords} requests
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage - 1)}
disabled={currentPage === 1}
className="h-8 w-8 p-0"
>
<ArrowRight className="h-4 w-4 rotate-180" />
</Button>
{currentPage > 3 && totalPages > 5 && (
<>
<Button variant="outline" size="sm" onClick={() => handlePageChange(1)} className="h-8 w-8 p-0">1</Button>
<span className="text-muted-foreground">...</span>
</>
)}
{getPageNumbers().map((pageNum) => (
<Button
key={pageNum}
variant={pageNum === currentPage ? "default" : "outline"}
size="sm"
onClick={() => handlePageChange(pageNum)}
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}`}
>
{pageNum}
</Button>
))}
{currentPage < totalPages - 2 && totalPages > 5 && (
<>
<span className="text-muted-foreground">...</span>
<Button variant="outline" size="sm" onClick={() => handlePageChange(totalPages)} className="h-8 w-8 p-0">{totalPages}</Button>
</>
)}
<Button
variant="outline"
size="sm"
onClick={() => handlePageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className="h-8 w-8 p-0"
>
<ArrowRight className="h-4 w-4" />
</Button>
</div>
</div>
</CardContent>
</Card>
)}
{/* Pagination */}
<Pagination
currentPage={currentPage}
totalPages={totalPages}
totalRecords={totalRecords}
itemsPerPage={itemsPerPage}
onPageChange={handlePageChange}
loading={loading}
itemLabel="requests"
testIdPrefix="my-requests-pagination"
/>
</div>
);
}

View File

@ -1,4 +1,4 @@
import { useAuth } from '@/contexts/AuthContext';
import { useAuth, isAdmin, isManagement } from '@/contexts/AuthContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
@ -12,7 +12,8 @@ import {
Shield,
Calendar,
Edit,
CheckCircle
CheckCircle,
Users
} from 'lucide-react';
export function Profile() {
@ -48,7 +49,7 @@ export function Profile() {
{getUserInitials()}
</AvatarFallback>
</Avatar>
{user?.isAdmin && (
{isAdmin(user) && (
<div className="absolute -bottom-2 -right-2 bg-yellow-400 rounded-full p-1.5 shadow-lg">
<Shield className="w-4 h-4 text-slate-900" />
</div>
@ -65,12 +66,18 @@ export function Profile() {
{user?.email || 'No email provided'}
</p>
<div className="flex flex-wrap gap-2">
{user?.isAdmin && (
{isAdmin(user) && (
<Badge className="bg-yellow-400 text-slate-900 hover:bg-yellow-400 font-semibold">
<Shield className="w-3 h-3 mr-1" />
Administrator
</Badge>
)}
{isManagement(user) && (
<Badge className="bg-blue-400 text-slate-900 hover:bg-blue-400 font-semibold">
<Users className="w-3 h-3 mr-1" />
Management
</Badge>
)}
{user?.employeeId && (
<Badge variant="outline" className="border-white/30 text-white bg-white/10">
ID: {user.employeeId}
@ -212,12 +219,18 @@ export function Profile() {
<p className="text-sm font-medium text-gray-500">Role</p>
<div className="flex items-center gap-2 mt-1">
<Badge
variant={user?.isAdmin ? "default" : "secondary"}
className={user?.isAdmin ? "bg-yellow-400 text-slate-900" : ""}
variant={isAdmin(user) || isManagement(user) ? "default" : "secondary"}
className={
isAdmin(user)
? "bg-yellow-400 text-slate-900"
: isManagement(user)
? "bg-blue-400 text-slate-900"
: ""
}
>
{user?.isAdmin ? 'Administrator' : 'User'}
{isAdmin(user) ? 'Administrator' : isManagement(user) ? 'Management' : 'User'}
</Badge>
{user?.isAdmin && (
{(isAdmin(user) || isManagement(user)) && (
<CheckCircle className="w-4 h-4 text-green-600" />
)}
</div>

File diff suppressed because it is too large Load Diff

View File

@ -12,15 +12,16 @@ import {
AlertCircle
} from 'lucide-react';
import { setupPushNotifications } from '@/utils/pushNotifications';
import { useAuth } from '@/contexts/AuthContext';
import { useAuth, isAdmin as checkIsAdmin } from '@/contexts/AuthContext';
import { ConfigurationManager } from '@/components/admin/ConfigurationManager';
import { HolidayManager } from '@/components/admin/HolidayManager';
import { UserRoleManager } from '@/components/admin/UserRoleManager';
import { NotificationStatusModal } from '@/components/settings/NotificationStatusModal';
import { useState } from 'react';
export function Settings() {
const { user } = useAuth();
const isAdmin = (user as any)?.isAdmin;
const isAdmin = checkIsAdmin(user);
const [showNotificationModal, setShowNotificationModal] = useState(false);
const [notificationSuccess, setNotificationSuccess] = useState(false);
const [notificationMessage, setNotificationMessage] = useState<string>();
@ -62,7 +63,7 @@ export function Settings() {
{/* Tabs for Admin, Cards for Non-Admin */}
{isAdmin ? (
<Tabs defaultValue="user" className="w-full">
<TabsList className="grid w-full grid-cols-3 mb-8 bg-slate-100 p-1 rounded-xl h-auto">
<TabsList className="grid w-full grid-cols-2 lg:grid-cols-4 mb-8 bg-slate-100 p-1 rounded-xl h-auto gap-1">
<TabsTrigger
value="user"
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
@ -71,6 +72,14 @@ export function Settings() {
<span className="hidden sm:inline">User Settings</span>
<span className="sm:hidden">User</span>
</TabsTrigger>
<TabsTrigger
value="roles"
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
>
<Shield className="w-4 h-4" />
<span className="hidden sm:inline">User Roles</span>
<span className="sm:hidden">Roles</span>
</TabsTrigger>
<TabsTrigger
value="system"
className="flex items-center justify-center gap-2 py-3 rounded-lg data-[state=active]:bg-white data-[state=active]:shadow-md transition-all"
@ -188,6 +197,11 @@ export function Settings() {
</div>
</TabsContent>
{/* User Roles Tab (Admin Only) */}
<TabsContent value="roles" className="mt-0">
<UserRoleManager />
</TabsContent>
{/* System Configuration Tab (Admin Only) */}
<TabsContent value="system" className="mt-0">
<ConfigurationManager />

View File

@ -82,7 +82,7 @@ export interface TokenExchangeResponse {
displayName: string;
department?: string;
designation?: string;
isAdmin: boolean;
role: 'USER' | 'MANAGEMENT' | 'ADMIN';
};
accessToken: string;
refreshToken: string;

View File

@ -11,10 +11,10 @@ export interface UserSummary {
isActive?: boolean;
}
export async function searchUsers(query: string, limit: number = 10): Promise<UserSummary[]> {
export async function searchUsers(query: string, limit: number = 10) {
const res = await apiClient.get('/users/search', { params: { q: query, limit } });
const data = (res.data?.data || res.data) as any[];
return data as UserSummary[];
// ResponseHandler.success returns { success: true, data: array }
return res;
}
/**
@ -34,6 +34,43 @@ export async function ensureUserExists(userData: {
return (res.data?.data || res.data) as UserSummary;
}
export default { searchUsers, ensureUserExists };
/**
* Assign role to user (creates user if doesn't exist)
*/
export async function assignRole(email: string, role: 'USER' | 'MANAGEMENT' | 'ADMIN') {
return await apiClient.post('/admin/users/assign-role', { email, role });
}
/**
* Update user role by userId
*/
export async function updateUserRole(userId: string, role: 'USER' | 'MANAGEMENT' | 'ADMIN') {
return await apiClient.put(`/admin/users/${userId}/role`, { role });
}
/**
* Get users by role
*/
export async function getUsersByRole(role: 'USER' | 'MANAGEMENT' | 'ADMIN') {
return await apiClient.get('/admin/users/by-role', { params: { role } });
}
/**
* Get role statistics
*/
export async function getRoleStatistics() {
return await apiClient.get('/admin/users/role-statistics');
}
export const userApi = {
searchUsers,
ensureUserExists,
assignRole,
updateUserRole,
getUsersByRole,
getRoleStatistics
};
export default userApi;

View File

@ -0,0 +1,157 @@
import {
CheckCircle,
XCircle,
Clock,
MessageSquare,
RefreshCw,
UserPlus,
FileText,
Paperclip,
AlertTriangle,
Activity
} from 'lucide-react';
/**
* Utility: getPriorityConfig
*
* Purpose: Get display configuration for priority badges
*
* Returns: Object with color classes and label text
*
* Priority levels:
* - express/urgent: Red background, high visibility
* - standard: Blue background, normal visibility
* - default: Gray background, low visibility
*
* @param priority - Priority string from backend
* @returns Configuration object with Tailwind CSS classes
*/
export const getPriorityConfig = (priority: string) => {
switch (priority) {
case 'express':
case 'urgent':
return {
color: 'bg-red-100 text-red-800 border-red-200',
label: 'urgent priority'
};
case 'standard':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'standard priority'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: 'normal priority'
};
}
};
/**
* Utility: getStatusConfig
*
* Purpose: Get display configuration for status badges
*
* Returns: Object with color classes and label text
*
* Status types:
* - pending: Yellow (waiting for action)
* - in-review: Blue (actively being reviewed)
* - approved: Green (successfully approved)
* - rejected: Red (declined)
* - closed: Gray (finalized and archived)
* - skipped: Orange (bypassed approver)
*
* @param status - Status string from backend
* @returns Configuration object with Tailwind CSS classes
*/
export const getStatusConfig = (status: string) => {
switch (status) {
case 'pending':
return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
label: 'pending'
};
case 'in-review':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
label: 'in-review'
};
case 'approved':
return {
color: 'bg-green-100 text-green-800 border-green-200',
label: 'approved'
};
case 'rejected':
return {
color: 'bg-red-100 text-red-800 border-red-200',
label: 'rejected'
};
case 'closed':
return {
color: 'bg-gray-100 text-gray-800 border-gray-300',
label: 'closed'
};
case 'skipped':
return {
color: 'bg-orange-100 text-orange-800 border-orange-200',
label: 'skipped'
};
default:
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
label: status
};
}
};
/**
* Utility: getActionTypeIcon
*
* Purpose: Get appropriate icon for activity timeline entries
*
* Returns: Lucide icon component for the action type
*
* Action types:
* - approval/approved: Green checkmark
* - rejection/rejected: Red X
* - comment: Blue message bubble
* - status_change/updated: Orange refresh
* - assignment: Purple user plus
* - created: Blue file
* - reminder: Yellow clock
* - document_added: Indigo paperclip
* - sla_warning: Amber warning triangle
* - default: Gray activity pulse
*
* @param type - Activity type from backend
* @returns JSX icon element with appropriate color
*/
export const getActionTypeIcon = (type: string) => {
switch (type) {
case 'approval':
case 'approved':
return <CheckCircle className="w-5 h-5 text-green-600" />;
case 'rejection':
case 'rejected':
return <XCircle className="w-5 h-5 text-red-600" />;
case 'comment':
return <MessageSquare className="w-5 h-5 text-blue-600" />;
case 'status_change':
case 'updated':
return <RefreshCw className="w-5 h-5 text-orange-600" />;
case 'assignment':
return <UserPlus className="w-5 h-5 text-purple-600" />;
case 'created':
return <FileText className="w-5 h-5 text-blue-600" />;
case 'reminder':
return <Clock className="w-5 h-5 text-yellow-600" />;
case 'document_added':
return <Paperclip className="w-5 h-5 text-indigo-600" />;
case 'sla_warning':
return <AlertTriangle className="w-5 h-5 text-amber-600" />;
default:
return <Activity className="w-5 h-5 text-gray-600" />;
}
};

View File

@ -9,7 +9,8 @@
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": true
"composite": true,
"noEmit": false
},
"include": ["vite.config.ts"]
}