added user tab in the admin and also code refactored
This commit is contained in:
parent
231d99ad95
commit
f022cbf899
193
ROLE_MIGRATION.md
Normal file
193
ROLE_MIGRATION.md
Normal 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
339
USER_ROLE_MANAGEMENT.md
Normal 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! 🎯
|
||||
|
||||
550
src/components/admin/UserRoleManager/UserRoleManager.tsx
Normal file
550
src/components/admin/UserRoleManager/UserRoleManager.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
2
src/components/admin/UserRoleManager/index.ts
Normal file
2
src/components/admin/UserRoleManager/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { UserRoleManager } from './UserRoleManager';
|
||||
|
||||
75
src/components/common/PageHeader/PageHeader.tsx
Normal file
75
src/components/common/PageHeader/PageHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
2
src/components/common/PageHeader/index.ts
Normal file
2
src/components/common/PageHeader/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { PageHeader } from './PageHeader';
|
||||
|
||||
@ -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}
|
||||
|
||||
55
src/components/dashboard/StatsCard/StatsCard.tsx
Normal file
55
src/components/dashboard/StatsCard/StatsCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
2
src/components/dashboard/StatsCard/index.ts
Normal file
2
src/components/dashboard/StatsCard/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { StatsCard } from './StatsCard';
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
106
src/components/sla/SLAProgressBar/SLAProgressBar.tsx
Normal file
106
src/components/sla/SLAProgressBar/SLAProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
3
src/components/sla/SLAProgressBar/index.ts
Normal file
3
src/components/sla/SLAProgressBar/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { SLAProgressBar } from './SLAProgressBar';
|
||||
export type { SLAData } from './SLAProgressBar';
|
||||
|
||||
449
src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx
Normal file
449
src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
3
src/components/workflow/ApprovalWorkflow/index.ts
Normal file
3
src/components/workflow/ApprovalWorkflow/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { ApprovalStepCard } from './ApprovalStepCard';
|
||||
export type { ApprovalStep } from './ApprovalStepCard';
|
||||
|
||||
100
src/components/workflow/DocumentUpload/DocumentCard.tsx
Normal file
100
src/components/workflow/DocumentUpload/DocumentCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
3
src/components/workflow/DocumentUpload/index.ts
Normal file
3
src/components/workflow/DocumentUpload/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export { DocumentCard } from './DocumentCard';
|
||||
export type { DocumentData } from './DocumentCard';
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
|
||||
231
src/hooks/useConclusionRemark.ts
Normal file
231
src/hooks/useConclusionRemark.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
132
src/hooks/useDocumentUpload.ts
Normal file
132
src/hooks/useDocumentUpload.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
332
src/hooks/useModalManager.ts
Normal file
332
src/hooks/useModalManager.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
560
src/hooks/useRequestDetails.ts
Normal file
560
src/hooks/useRequestDetails.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
277
src/hooks/useRequestSocket.ts
Normal file
277
src/hooks/useRequestSocket.ts
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
@ -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
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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
@ -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 />
|
||||
|
||||
@ -82,7 +82,7 @@ export interface TokenExchangeResponse {
|
||||
displayName: string;
|
||||
department?: string;
|
||||
designation?: string;
|
||||
isAdmin: boolean;
|
||||
role: 'USER' | 'MANAGEMENT' | 'ADMIN';
|
||||
};
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
|
||||
@ -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;
|
||||
|
||||
|
||||
|
||||
157
src/utils/requestDetailHelpers.tsx
Normal file
157
src/utils/requestDetailHelpers.tsx
Normal 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" />;
|
||||
}
|
||||
};
|
||||
|
||||
@ -9,7 +9,8 @@
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"noEmit": true
|
||||
"composite": true,
|
||||
"noEmit": false
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user