From f022cbf8998739e2749756620bdda6e73b2789dc Mon Sep 17 00:00:00 2001 From: laxmanhalaki Date: Wed, 12 Nov 2025 16:41:29 +0530 Subject: [PATCH] added user tab in the admin and also code refactored --- ROLE_MIGRATION.md | 193 ++ USER_ROLE_MANAGEMENT.md | 339 +++ .../admin/UserRoleManager/UserRoleManager.tsx | 550 +++++ src/components/admin/UserRoleManager/index.ts | 2 + .../common/PageHeader/PageHeader.tsx | 75 + src/components/common/PageHeader/index.ts | 2 + .../dashboard/StatCard/StatCard.tsx | 18 +- .../dashboard/StatsCard/StatsCard.tsx | 55 + src/components/dashboard/StatsCard/index.ts | 2 + .../AddApproverModal/AddApproverModal.tsx | 14 +- .../AddSpectatorModal/AddSpectatorModal.tsx | 14 +- .../sla/SLAProgressBar/SLAProgressBar.tsx | 106 + src/components/sla/SLAProgressBar/index.ts | 3 + .../ApprovalWorkflow/ApprovalStepCard.tsx | 449 ++++ .../workflow/ApprovalWorkflow/index.ts | 3 + .../workflow/DocumentUpload/DocumentCard.tsx | 100 + .../workflow/DocumentUpload/index.ts | 3 + src/contexts/AuthContext.tsx | 30 +- src/hooks/useConclusionRemark.ts | 231 ++ src/hooks/useDocumentUpload.ts | 132 ++ src/hooks/useModalManager.ts | 332 +++ src/hooks/useRequestDetails.ts | 560 +++++ src/hooks/useRequestSocket.ts | 277 +++ src/pages/Auth/AuthenticatedApp.tsx | 2 +- src/pages/Dashboard/Dashboard.tsx | 993 +++----- src/pages/MyRequests/MyRequests.tsx | 305 +-- src/pages/Profile/Profile.tsx | 29 +- src/pages/RequestDetail/RequestDetail.tsx | 2027 +++-------------- src/pages/Settings/Settings.tsx | 20 +- src/services/authApi.ts | 2 +- src/services/userApi.ts | 45 +- src/utils/requestDetailHelpers.tsx | 157 ++ tsconfig.node.json | 3 +- 33 files changed, 4487 insertions(+), 2586 deletions(-) create mode 100644 ROLE_MIGRATION.md create mode 100644 USER_ROLE_MANAGEMENT.md create mode 100644 src/components/admin/UserRoleManager/UserRoleManager.tsx create mode 100644 src/components/admin/UserRoleManager/index.ts create mode 100644 src/components/common/PageHeader/PageHeader.tsx create mode 100644 src/components/common/PageHeader/index.ts create mode 100644 src/components/dashboard/StatsCard/StatsCard.tsx create mode 100644 src/components/dashboard/StatsCard/index.ts create mode 100644 src/components/sla/SLAProgressBar/SLAProgressBar.tsx create mode 100644 src/components/sla/SLAProgressBar/index.ts create mode 100644 src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx create mode 100644 src/components/workflow/ApprovalWorkflow/index.ts create mode 100644 src/components/workflow/DocumentUpload/DocumentCard.tsx create mode 100644 src/components/workflow/DocumentUpload/index.ts create mode 100644 src/hooks/useConclusionRemark.ts create mode 100644 src/hooks/useDocumentUpload.ts create mode 100644 src/hooks/useModalManager.ts create mode 100644 src/hooks/useRequestDetails.ts create mode 100644 src/hooks/useRequestSocket.ts create mode 100644 src/utils/requestDetailHelpers.tsx diff --git a/ROLE_MIGRATION.md b/ROLE_MIGRATION.md new file mode 100644 index 0000000..aa0ebb2 --- /dev/null +++ b/ROLE_MIGRATION.md @@ -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! + diff --git a/USER_ROLE_MANAGEMENT.md b/USER_ROLE_MANAGEMENT.md new file mode 100644 index 0000000..998c50b --- /dev/null +++ b/USER_ROLE_MANAGEMENT.md @@ -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! 🎯 + diff --git a/src/components/admin/UserRoleManager/UserRoleManager.tsx b/src/components/admin/UserRoleManager/UserRoleManager.tsx new file mode 100644 index 0000000..614ee49 --- /dev/null +++ b/src/components/admin/UserRoleManager/UserRoleManager.tsx @@ -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 any>( + func: T, + wait: number +): (...args: Parameters) => void { + let timeout: NodeJS.Timeout | null = null; + return function executedFunction(...args: Parameters) { + 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([]); + const [searching, setSearching] = useState(false); + const [selectedUser, setSelectedUser] = useState(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([]); + 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(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) => { + 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 ; + case 'MANAGEMENT': + return ; + default: + return ; + } + }; + + return ( +
+ {/* Statistics Cards */} +
+ + +
+
+

Administrators

+

{roleStats.admins}

+

Full system access

+
+
+ +
+
+
+
+ + + +
+
+

Management

+

{roleStats.management}

+

Read all data access

+
+
+ +
+
+
+
+ + + +
+
+

Regular Users

+

{roleStats.users}

+

Standard access

+
+
+ +
+
+
+
+
+ + {/* Assign Role Section */} + + +
+
+ +
+
+ Assign User Role + + Search for a user in Okta and assign them a role + +
+
+
+ + {/* Search Input */} +
+ +
+ + + {searching && ( + + )} +
+

Start typing to search across all Okta users

+ + {/* Search Results Dropdown */} + {searchResults.length > 0 && ( +
+
+

+ {searchResults.length} user{searchResults.length > 1 ? 's' : ''} found +

+
+
+ {searchResults.map((user) => ( + + ))} +
+
+ )} +
+ + {/* Selected User */} + {selectedUser && ( +
+
+
+
+ {(selectedUser.displayName || selectedUser.email).charAt(0).toUpperCase()} +
+
+

+ {selectedUser.displayName || selectedUser.email} +

+

{selectedUser.email}

+ {selectedUser.department && ( +

+ {selectedUser.department}{selectedUser.designation ? ` β€’ ${selectedUser.designation}` : ''} +

+ )} +
+
+ +
+
+ )} + + {/* Role Selection */} +
+ + +
+ + {/* Assign Button */} + + + {/* Message */} + {message && ( +
+
+ {message.type === 'success' ? ( + + ) : ( + + )} +

+ {message.text} +

+
+
+ )} +
+
+ + {/* Elevated Users List */} + + +
+
+
+ +
+
+ Users with Elevated Roles + + Administrators and Management team members + +
+
+ + {elevatedUsers.length} user{elevatedUsers.length !== 1 ? 's' : ''} + +
+
+ + {loadingUsers ? ( +
+ +

Loading users...

+
+ ) : elevatedUsers.length === 0 ? ( +
+
+ +
+

No elevated users found

+

Assign ADMIN or MANAGEMENT roles to see users here

+
+ ) : ( +
+ {elevatedUsers.map((user) => ( +
+
+
+
+ {getRoleIcon(user.role)} +
+
+

{user.displayName}

+

{user.email}

+ {user.department && ( +

+ {user.department}{user.designation ? ` β€’ ${user.designation}` : ''} +

+ )} +
+
+ + {user.role} + +
+
+ ))} +
+ )} +
+
+
+ ); +} + diff --git a/src/components/admin/UserRoleManager/index.ts b/src/components/admin/UserRoleManager/index.ts new file mode 100644 index 0000000..368625c --- /dev/null +++ b/src/components/admin/UserRoleManager/index.ts @@ -0,0 +1,2 @@ +export { UserRoleManager } from './UserRoleManager'; + diff --git a/src/components/common/PageHeader/PageHeader.tsx b/src/components/common/PageHeader/PageHeader.tsx new file mode 100644 index 0000000..5e1a0e3 --- /dev/null +++ b/src/components/common/PageHeader/PageHeader.tsx @@ -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 ( +
+
+
+
+ +
+
+

+ {title} +

+

+ {description} +

+
+
+
+ +
+ {badge && ( + + {badge.loading ? 'Loading…' : badge.value} + {badge.label} + + )} + {actions} +
+
+ ); +} + diff --git a/src/components/common/PageHeader/index.ts b/src/components/common/PageHeader/index.ts new file mode 100644 index 0000000..445245c --- /dev/null +++ b/src/components/common/PageHeader/index.ts @@ -0,0 +1,2 @@ +export { PageHeader } from './PageHeader'; + diff --git a/src/components/dashboard/StatCard/StatCard.tsx b/src/components/dashboard/StatCard/StatCard.tsx index baf6f7d..c3c142c 100644 --- a/src/components/dashboard/StatCard/StatCard.tsx +++ b/src/components/dashboard/StatCard/StatCard.tsx @@ -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 (

{label}

{value} diff --git a/src/components/dashboard/StatsCard/StatsCard.tsx b/src/components/dashboard/StatsCard/StatsCard.tsx new file mode 100644 index 0000000..22da5e4 --- /dev/null +++ b/src/components/dashboard/StatsCard/StatsCard.tsx @@ -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 ( + + +

+
+

+ {label} +

+

+ {value} +

+
+ +
+ + + ); +} + diff --git a/src/components/dashboard/StatsCard/index.ts b/src/components/dashboard/StatsCard/index.ts new file mode 100644 index 0000000..8427cd5 --- /dev/null +++ b/src/components/dashboard/StatsCard/index.ts @@ -0,0 +1,2 @@ +export { StatsCard } from './StatsCard'; + diff --git a/src/components/participant/AddApproverModal/AddApproverModal.tsx b/src/components/participant/AddApproverModal/AddApproverModal.tsx index 41bc5b3..a7b27a0 100644 --- a/src/components/participant/AddApproverModal/AddApproverModal.tsx +++ b/src/components/participant/AddApproverModal/AddApproverModal.tsx @@ -43,6 +43,7 @@ export function AddApproverModal({ const [isSearching, setIsSearching] = useState(false); const [selectedUser, setSelectedUser] = useState(null); // Track if user was selected via @ search const searchTimer = useRef(null); + const searchContainerRef = useRef(null); // Ref for auto-scroll // Validation modal state const [validationModal, setValidationModal] = useState<{ @@ -263,6 +264,17 @@ export function AddApproverModal({ return ; }; + // 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({
-
+
{/* Description */}

Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down. diff --git a/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx b/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx index 114bb8c..5ec8397 100644 --- a/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx +++ b/src/components/participant/AddSpectatorModal/AddSpectatorModal.tsx @@ -29,6 +29,7 @@ export function AddSpectatorModal({ const [isSearching, setIsSearching] = useState(false); const [selectedUser, setSelectedUser] = useState(null); // Track if user was selected via @ search const searchTimer = useRef(null); + const searchContainerRef = useRef(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({

-
+
{/* Description */}

Add a spectator to this request. They will receive notifications but cannot approve or reject. diff --git a/src/components/sla/SLAProgressBar/SLAProgressBar.tsx b/src/components/sla/SLAProgressBar/SLAProgressBar.tsx new file mode 100644 index 0000000..b7cc7df --- /dev/null +++ b/src/components/sla/SLAProgressBar/SLAProgressBar.tsx @@ -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 ( +

+ + + {requestStatus === 'closed' ? 'πŸ”’ Request Closed' : + requestStatus === 'approved' ? 'βœ… Request Approved' : + requestStatus === 'rejected' ? '❌ Request Rejected' : 'SLA Not Available'} + +
+ ); + } + + return ( +
+
+
+ + SLA Progress +
+ + {sla.percentageUsed || 0}% elapsed + +
+ + 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`} + /> + +
+ + {sla.elapsedText || `${sla.elapsedHours || 0}h`} elapsed + + + {sla.remainingText || `${sla.remainingHours || 0}h`} remaining + +
+ + {sla.deadline && ( +

+ Due: {new Date(sla.deadline).toLocaleString()} β€’ {sla.percentageUsed || 0}% elapsed +

+ )} + + {sla.status === 'critical' && ( +

+ ⚠️ Approaching Deadline +

+ )} + {sla.status === 'breached' && ( +

+ πŸ”΄ URGENT - Deadline Passed +

+ )} +
+ ); +} + diff --git a/src/components/sla/SLAProgressBar/index.ts b/src/components/sla/SLAProgressBar/index.ts new file mode 100644 index 0000000..7c2ec65 --- /dev/null +++ b/src/components/sla/SLAProgressBar/index.ts @@ -0,0 +1,3 @@ +export { SLAProgressBar } from './SLAProgressBar'; +export type { SLAData } from './SLAProgressBar'; + diff --git a/src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx b/src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx new file mode 100644 index 0000000..6cc4c64 --- /dev/null +++ b/src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx @@ -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 ; + + switch (status) { + case 'approved': + return ; + case 'rejected': + return ; + case 'pending': + case 'in-review': + return ; + case 'waiting': + return ; + default: + return ; + } +}; + +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 ( +
+
+
+ {getStepIcon(step.status, step.isSkipped)} +
+ +
+ {/* Header with Approver Label and Status */} +
+
+
+

+ Approver {index + 1} +

+ + {step.isSkipped ? 'skipped' : step.status} + + {step.isSkipped && step.skipReason && ( + + + + + + +

⏭️ Skip Reason:

+

{step.skipReason}

+
+
+
+ )} + {isCompleted && actualHours && ( + + {actualHours.toFixed(1)} hours + + )} +
+

+ {isCurrentUser ? You : step.approver} +

+

{step.role}

+
+
+

Turnaround Time (TAT)

+

{tatHours} hours

+
+
+ + {/* Completed Approver - Show Completion Details */} + {isCompleted && actualHours !== undefined && ( +
+
+ Completed: + {step.timestamp ? formatDateTime(step.timestamp) : 'N/A'} +
+
+ Completed in: + {actualHours.toFixed(1)} hours +
+ + {/* Progress Bar for Completed - Shows actual time used vs TAT allocated */} +
+ {(() => { + // 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 ( + <> + +
+ + {progressPercentage.toFixed(1)}% of TAT used + + {savedHours > 0 && ( + Saved {savedHours.toFixed(1)} hours + )} +
+ + ); + })()} +
+ + {/* Conclusion Remark */} + {step.comment && ( +
+

πŸ’¬ Conclusion Remark:

+

{step.comment}

+
+ )} +
+ )} + + {/* Active Approver - Show Real-time Progress from Backend */} + {isActive && approval?.sla && ( +
+
+ Due by: + + {approval.sla.deadline ? formatDateTime(approval.sla.deadline) : 'Not set'} + +
+ + {/* Current Approver - Time Tracking */} +
+

+ + Current Approver - Time Tracking +

+ +
+
+ Time elapsed since assigned: + {approval.sla.elapsedText} +
+
+ Time used: + {approval.sla.elapsedText} / {tatHours}h allocated +
+
+ + {/* Progress Bar */} +
+ div]:bg-red-600' : + approval.sla.status === 'critical' ? '[&>div]:bg-orange-600' : + '[&>div]:bg-yellow-600' + }`} + data-testid={`${testId}-sla-progress`} + /> +
+ + Progress: {approval.sla.percentageUsed}% of TAT used + + + {approval.sla.remainingText} remaining + +
+ {approval.sla.status === 'breached' && ( +

+ πŸ”΄ Deadline Breached +

+ )} + {approval.sla.status === 'critical' && ( +

+ ⚠️ Approaching Deadline +

+ )} +
+
+
+ )} + + {/* Waiting Approver - Show Assignment Info */} + {isWaiting && ( +
+
+

⏸️ Awaiting Previous Approval

+

Will be assigned after previous step

+

Allocated {tatHours} hours for approval

+
+
+ )} + + {/* Rejected Status */} + {isRejected && step.comment && ( +
+

❌ Rejection Reason:

+

{step.comment}

+
+ )} + + {/* Skipped Status */} + {step.isSkipped && step.skipReason && ( +
+

⏭️ Skip Reason:

+

{step.skipReason}

+ {step.timestamp && ( +

Skipped on {formatDateTime(step.timestamp)}

+ )} +
+ )} + + {/* TAT Alerts/Reminders */} + {step.tatAlerts && step.tatAlerts.length > 0 && ( +
+ {step.tatAlerts.map((alert: any, alertIndex: number) => ( +
+
+
+ {(alert.thresholdPercentage || 0) === 50 && '⏳'} + {(alert.thresholdPercentage || 0) === 75 && '⚠️'} + {(alert.thresholdPercentage || 0) === 100 && '⏰'} +
+
+
+

+ Reminder {alertIndex + 1} - {alert.thresholdPercentage || 0}% TAT +

+ + {alert.isBreached ? 'BREACHED' : 'WARNING'} + +
+ +

+ {alert.thresholdPercentage || 0}% of SLA breach reminder have been sent +

+ + {/* Time Tracking Details */} +
+
+ Allocated: + + {Number(alert.tatHoursAllocated || 0).toFixed(2)}h + +
+
+ Elapsed: + + {Number(alert.tatHoursElapsed || 0).toFixed(2)}h + {alert.metadata?.tatTestMode && ( + + ({(Number(alert.tatHoursElapsed || 0) * 60).toFixed(0)}m) + + )} + +
+
+ Remaining: + + {Number(alert.tatHoursRemaining || 0).toFixed(2)}h + {alert.metadata?.tatTestMode && ( + + ({(Number(alert.tatHoursRemaining || 0) * 60).toFixed(0)}m) + + )} + +
+
+ Due by: + + {alert.expectedCompletionTime ? formatDateShort(alert.expectedCompletionTime) : 'N/A'} + +
+
+ +
+
+

+ Reminder sent by system automatically +

+ {(alert.metadata?.testMode || alert.metadata?.tatTestMode) && ( + + TEST MODE + + )} +
+

+ Sent at: {alert.alertSentAt ? formatDateTime(alert.alertSentAt) : 'N/A'} +

+ {(alert.metadata?.testMode || alert.metadata?.tatTestMode) && ( +

+ Note: Test mode active (1 hour = 1 minute) +

+ )} +
+
+
+
+ ))} +
+ )} + + {step.timestamp && ( +

+ {isCompleted ? 'Approved' : isRejected ? 'Rejected' : 'Actioned'} on {formatDateTime(step.timestamp)} +

+ )} + + {/* Skip Approver Button - Only show for initiator on pending/in-review levels */} + {isInitiator && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && ( +
+ +

+ Skip if approver is unavailable and move to next level +

+
+ )} +
+
+
+ ); +} + diff --git a/src/components/workflow/ApprovalWorkflow/index.ts b/src/components/workflow/ApprovalWorkflow/index.ts new file mode 100644 index 0000000..b80ab4f --- /dev/null +++ b/src/components/workflow/ApprovalWorkflow/index.ts @@ -0,0 +1,3 @@ +export { ApprovalStepCard } from './ApprovalStepCard'; +export type { ApprovalStep } from './ApprovalStepCard'; + diff --git a/src/components/workflow/DocumentUpload/DocumentCard.tsx b/src/components/workflow/DocumentUpload/DocumentCard.tsx new file mode 100644 index 0000000..d41a4fc --- /dev/null +++ b/src/components/workflow/DocumentUpload/DocumentCard.tsx @@ -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; + 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 ( +
+
+
+ +
+
+

+ {document.name} +

+

+ {document.size} β€’ Uploaded by {document.uploadedBy} on {formatDateTime(document.uploadedAt)} +

+
+
+
+ {/* Preview button for images and PDFs */} + {showPreview && canPreview(document.fileType) && onPreview && ( + + )} + + {/* Download button */} + {onDownload && ( + + )} +
+
+ ); +} + diff --git a/src/components/workflow/DocumentUpload/index.ts b/src/components/workflow/DocumentUpload/index.ts new file mode 100644 index 0000000..66220f2 --- /dev/null +++ b/src/components/workflow/DocumentUpload/index.ts @@ -0,0 +1,3 @@ +export { DocumentCard } from './DocumentCard'; +export type { DocumentData } from './DocumentCard'; + diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 76aac57..3f76f94 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -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'; +} + diff --git a/src/hooks/useConclusionRemark.ts b/src/hooks/useConclusionRemark.ts new file mode 100644 index 0000000..9f465a4 --- /dev/null +++ b/src/hooks/useConclusionRemark.ts @@ -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, + 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 + }; +} + diff --git a/src/hooks/useDocumentUpload.ts b/src/hooks/useDocumentUpload.ts new file mode 100644 index 0000000..aab88b3 --- /dev/null +++ b/src/hooks/useDocumentUpload.ts @@ -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 +) { + // 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) => { + 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 + }; +} + diff --git a/src/hooks/useModalManager.ts b/src/hooks/useModalManager.ts new file mode 100644 index 0000000..880e0dd --- /dev/null +++ b/src/hooks/useModalManager.ts @@ -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 +) { + // 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 + }; +} + diff --git a/src/hooks/useRequestDetails.ts b/src/hooks/useRequestDetails.ts new file mode 100644 index 0000000..6288179 --- /dev/null +++ b/src/hooks/useRequestDetails.ts @@ -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(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(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 + }; +} + diff --git a/src/hooks/useRequestSocket.ts b/src/hooks/useRequestSocket.ts new file mode 100644 index 0000000..6420a1e --- /dev/null +++ b/src/hooks/useRequestSocket.ts @@ -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([]); + + // 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([]); + + /** + * 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 + }; +} + diff --git a/src/pages/Auth/AuthenticatedApp.tsx b/src/pages/Auth/AuthenticatedApp.tsx index 7418511..abbd7b7 100644 --- a/src/pages/Auth/AuthenticatedApp.tsx +++ b/src/pages/Auth/AuthenticatedApp.tsx @@ -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)); diff --git a/src/pages/Dashboard/Dashboard.tsx b/src/pages/Dashboard/Dashboard.tsx index d383fe3..2d6bd65 100644 --- a/src/pages/Dashboard/Dashboard.tsx +++ b/src/pages/Dashboard/Dashboard.tsx @@ -3,7 +3,6 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Progress } from '@/components/ui/progress'; -import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { @@ -13,25 +12,26 @@ import { CheckCircle, Zap, Shield, - ArrowRight, - Star, Activity, Target, Flame, Settings, RefreshCw, MessageSquare, - Paperclip, Filter, Download, Users, PieChart, Calendar } from 'lucide-react'; -import { dashboardService, type DashboardKPIs, type RecentActivity, type CriticalRequest, type DateRange, type AIRemarkUtilization, type ApproverPerformance } from '@/services/dashboard.service'; -import { differenceInMinutes, differenceInHours, differenceInDays } from 'date-fns'; -import { useAuth } from '@/contexts/AuthContext'; +import { dashboardService, type DashboardKPIs, type DateRange, type AIRemarkUtilization, type ApproverPerformance } from '@/services/dashboard.service'; +import { useAuth, isAdmin as checkIsAdmin } from '@/contexts/AuthContext'; import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, PieChart as RechartsPieChart, Pie, Cell, BarChart, Bar } from 'recharts'; +import { KPICard } from '@/components/dashboard/KPICard'; +import { StatCard } from '@/components/dashboard/StatCard'; +import { CriticalAlertCard, CriticalAlertData } from '@/components/dashboard/CriticalAlertCard'; +import { ActivityFeedItem, ActivityData } from '@/components/dashboard/ActivityFeedItem'; +import { Pagination } from '@/components/common/Pagination'; interface DashboardProps { onNavigate?: (page: string) => void; @@ -39,24 +39,11 @@ interface DashboardProps { } -// Utility functions outside component -const getPriorityColor = (priority: string) => { - const p = priority.toLowerCase(); - switch (p) { - case 'express': return 'bg-orange-100 text-orange-800 border-orange-200'; - case 'standard': return 'bg-blue-100 text-blue-800 border-blue-200'; - case 'high': return 'bg-red-100 text-red-800 border-red-200'; - case 'medium': return 'bg-orange-100 text-orange-800 border-orange-200'; - case 'low': return 'bg-green-100 text-green-800 border-green-200'; - default: return 'bg-gray-100 text-gray-800 border-gray-200'; - } -}; - export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { const { user } = useAuth(); const [kpis, setKpis] = useState(null); - const [recentActivity, setRecentActivity] = useState([]); - const [criticalRequests, setCriticalRequests] = useState([]); + const [recentActivity, setRecentActivity] = useState([]); + const [criticalRequests, setCriticalRequests] = useState([]); const [departmentStats, setDepartmentStats] = useState([]); const [priorityDistribution, setPriorityDistribution] = useState([]); const [upcomingDeadlines, setUpcomingDeadlines] = useState([]); @@ -87,7 +74,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { // Determine user role const isAdmin = useMemo(() => { - return (user as any)?.isAdmin || false; + return checkIsAdmin(user); }, [user]); // Fetch recent activities with pagination @@ -238,23 +225,6 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { } }; - const getPageNumbers = (currentPage: number, totalPages: number) => { - 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(() => { fetchDashboardData(false, dateRange); }, [fetchDashboardData, dateRange]); @@ -267,62 +237,6 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { { label: 'Settings', icon: Settings, action: () => onNavigate?.('settings'), color: 'bg-slate-600 hover:bg-slate-700' } ], [onNavigate, onNewRequest]); - // Format relative time - const getRelativeTime = (timestamp: string) => { - const now = new Date(); - const time = new Date(timestamp); - const diffMin = differenceInMinutes(now, time); - - if (diffMin < 1) return 'just now'; - if (diffMin < 60) return `${diffMin} minute${diffMin > 1 ? 's' : ''} ago`; - - const diffHrs = differenceInHours(now, time); - if (diffHrs < 24) return `${diffHrs} hour${diffHrs > 1 ? 's' : ''} ago`; - - const diffDay = differenceInDays(now, time); - return `${diffDay} day${diffDay > 1 ? 's' : ''} ago`; - }; - - // Calculate TAT progress for critical requests (how much time has been used) - const calculateProgress = (request: CriticalRequest) => { - if (!request.originalTATHours || request.originalTATHours === 0) return 0; - - const originalTAT = request.originalTATHours; - const remainingTAT = request.totalTATHours; - - // If breached (negative remaining), show 100% - if (remainingTAT <= 0) return 100; - - // Calculate elapsed time - const elapsedTAT = originalTAT - remainingTAT; - - // Calculate percentage used - const percentageUsed = (elapsedTAT / originalTAT) * 100; - - // Ensure it's between 0 and 100 - return Math.min(100, Math.max(0, Math.round(percentageUsed))); - }; - - // Format remaining time (can be negative if breached) - const formatRemainingTime = (request: CriticalRequest) => { - if (request.totalTATHours === undefined || request.totalTATHours === null) return 'N/A'; - - const hours = request.totalTATHours; - - // If TAT is breached (negative or zero) - if (hours <= 0) { - const overdue = Math.abs(hours); - if (overdue < 1) return `Breached`; - if (overdue < 24) return `${Math.round(overdue)}h overdue`; - return `${Math.round(overdue / 24)}d overdue`; - } - - // If TAT is still remaining - if (hours < 1) return `${Math.round(hours * 60)}min left`; - if (hours < 24) return `${Math.round(hours)}h left`; - return `${Math.round(hours / 24)}d left`; - }; - if (loading) { return (
@@ -451,265 +365,199 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { {isAdmin ? ( <> {/* Admin Stats Cards */} -
+
{/* Total Requests */} - - - - Total Requests - -
- -
-
- -
- {kpis?.requestVolume.totalRequests || 0} -
-
-
-

Approved

-

{kpis?.requestVolume.approvedRequests || 0}

-
-
-

Rejected

-

{kpis?.requestVolume.rejectedRequests || 0}

-
-
-
-
+ +
+ + +
+
{/* Open Requests */} - - - - Open Requests - -
- -
-
- -
- {kpis?.requestVolume.openRequests || 0} -
-
-
-

Pending

-

- {kpis ? kpis.requestVolume.openRequests - criticalRequests.length : 0} -

-
-
-

Critical

-

{criticalRequests.length}

-
-
-
-
+ +
+ + +
+
{/* SLA Compliance */} - - - - SLA Compliance - -
- -
-
- -
- {kpis?.tatEfficiency.avgTATCompliance || 0}% -
- -
-
-

Compliant

-

{kpis?.tatEfficiency.compliantWorkflows || 0}

-
-
-

Breached

-

{kpis?.tatEfficiency.delayedWorkflows || 0}

-
-
-
-
+ + +
+ + +
+
{/* Avg Cycle Time */} - - - - Avg Cycle Time - -
- -
-
- -
- - {kpis?.tatEfficiency.avgCycleTimeHours.toFixed(1) || 0} - - hours -
-
- β‰ˆ {kpis?.tatEfficiency.avgCycleTimeDays.toFixed(1) || 0} working days -
-
-
-

Express

-

- {(() => { - const express = priorityDistribution.find(p => p.priority === 'express'); - const hours = express ? Number(express.avgCycleTimeHours) : 0; - return hours > 0 ? `${hours.toFixed(1)}h` : 'N/A'; - })()} -

-
-
-

Standard

-

- {(() => { - const standard = priorityDistribution.find(p => p.priority === 'standard'); - const hours = standard ? Number(standard.avgCycleTimeHours) : 0; - return hours > 0 ? `${hours.toFixed(1)}h` : 'N/A'; - })()} -

-
-
-
-
+ +
+ { + const express = priorityDistribution.find(p => p.priority === 'express'); + const hours = express ? Number(express.avgCycleTimeHours) : 0; + return hours > 0 ? `${hours.toFixed(1)}h` : 'N/A'; + })()} + bgColor="bg-orange-50" + textColor="text-orange-600" + testId="stat-express-time" + /> + { + const standard = priorityDistribution.find(p => p.priority === 'standard'); + const hours = standard ? Number(standard.avgCycleTimeHours) : 0; + return hours > 0 ? `${hours.toFixed(1)}h` : 'N/A'; + })()} + bgColor="bg-blue-50" + textColor="text-blue-600" + testId="stat-standard-time" + /> +
+
) : ( <> {/* NORMAL USER DASHBOARD - Personal View */} -
+
{/* My Requests Created */} - - - - My Requests (Submitted) - -
- -
-
- -
- {kpis?.requestVolume.totalRequests || 0} -
-
-
-

Approved

-

{kpis?.requestVolume.approvedRequests || 0}

-
-
-

Pending

-

{kpis?.requestVolume.openRequests || 0}

-
-
-

Draft

-

{kpis?.requestVolume.draftRequests || 0}

-
-
-
-
+ +
+ + + +
+
- {/* My Pending Actions (Current Approver) */} - - - - Awaiting My Approval - -
- -
-
- -
- {kpis?.approverLoad.pendingActions || 0} -
-
- at current level -
-
-
-

Approved Today

-

{kpis?.approverLoad.completedToday || 0}

-
-
-

This Week

-

{kpis?.approverLoad.completedThisWeek || 0}

-
-
-
-
+ {/* My Pending Actions */} + +
+ + +
+
{/* Critical Alerts */} - - - - Critical Alerts - -
- + +
+ r.breachCount > 0).length} bgColor="bg-orange-50" textColor="text-red-600" testId="stat-user-breached" /> + r.breachCount === 0).length} bgColor="bg-yellow-50" textColor="text-orange-600" testId="stat-user-warning" />
- - -
- {criticalRequests.length} -
-
-
-

Breached

-

- {criticalRequests.filter(r => r.breachCount > 0).length} -

-
-
-

Warning

-

- {criticalRequests.filter(r => r.breachCount === 0).length} -

-
-
-
- +
{/* My Success Rate */} - - - - Success Rate - -
- -
-
- -
- {kpis && kpis.requestVolume.totalRequests > 0 - ? ((kpis.requestVolume.approvedRequests / kpis.requestVolume.totalRequests) * 100).toFixed(0) - : 0}% -
-
- of {kpis?.requestVolume.totalRequests || 0} requests approved -
-
- Rejected - {kpis?.requestVolume.rejectedRequests || 0} + 0 ? ((kpis.requestVolume.approvedRequests / kpis.requestVolume.totalRequests) * 100).toFixed(0) : 0}%`} + icon={CheckCircle} + iconBgColor="bg-green-50" + iconColor="text-green-600" + subtitle={`of ${kpis?.requestVolume.totalRequests || 0} requests approved`} + testId="kpi-success-rate" + > +
+ Rejected + {kpis?.requestVolume.rejectedRequests || 0}
- - +
)} -
- {/* High Priority Alerts */} - +
+ {/* Critical Alerts */} +
@@ -723,7 +571,11 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
- 0 ? 'animate-pulse' : ''}`}> + 0 ? 'animate-pulse' : ''}`} + data-testid="critical-count-badge" + > {criticalRequests.length}
@@ -731,7 +583,7 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
{criticalRequests.length === 0 ? ( -
+

No critical alerts

All requests are within TAT

@@ -739,42 +591,19 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { ) : ( <> {criticalRequests.slice(0, 3).map((request) => ( -
onNavigate?.(`request/${request.requestNumber}`)}> -
-
-
-

{request.requestNumber}

- {request.priority === 'express' && } - {request.breachCount > 0 && ( - - {request.breachCount} - - )} -
-

{request.title}

-
- - {formatRemainingTime(request)} - -
-
-
- TAT Used - {calculateProgress(request)}% -
- = 80 ? '[&>div]:bg-red-600' : calculateProgress(request) >= 50 ? '[&>div]:bg-orange-500' : '[&>div]:bg-green-600'}`} - /> -
-
+ onNavigate?.(`request/${reqNum}`)} + testId="dashboard-critical-alert" + /> ))} - - {/* Page Numbers */} - {activityPage > 3 && activityTotalPages > 5 && ( - <> - - ... - - )} - - {getPageNumbers(activityPage, activityTotalPages).map((pageNum) => ( - - ))} - - {activityPage < activityTotalPages - 2 && activityTotalPages > 5 && ( - <> - ... - - - )} - - {/* Next Button */} - -
+
+
)}
@@ -1396,7 +1052,12 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { {req.title} - + {req.priority} @@ -1405,7 +1066,13 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { - {formatRemainingTime(req)} + {(() => { + const hours = req.totalTATHours; + if (hours <= 0) return 'Breached'; + if (hours < 1) return `${Math.round(hours * 60)}min left`; + if (hours < 24) return `${Math.round(hours)}h left`; + return `${Math.round(hours / 24)}d left`; + })()} @@ -1417,62 +1084,18 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) {
- {/* Pagination Controls for TAT Breach Report */} - {criticalTotalPages > 1 && ( -
-
- Showing {((criticalPage - 1) * 10) + 1} to {Math.min(criticalPage * 10, criticalTotalRecords)} of {criticalTotalRecords} critical requests -
- -
- - - {criticalPage > 3 && criticalTotalPages > 5 && ( - <> - - ... - - )} - - {getPageNumbers(criticalPage, criticalTotalPages).map((pageNum) => ( - - ))} - - {criticalPage < criticalTotalPages - 2 && criticalTotalPages > 5 && ( - <> - ... - - - )} - - -
-
- )} + {/* Pagination for TAT Breach Report */} +
+ +
)} @@ -1541,62 +1164,18 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { })}
- {/* Pagination Controls for Upcoming Deadlines */} - {deadlinesTotalPages > 1 && ( -
-
- Showing {((deadlinesPage - 1) * 10) + 1} to {Math.min(deadlinesPage * 10, deadlinesTotalRecords)} of {deadlinesTotalRecords} deadlines -
- -
- - - {deadlinesPage > 3 && deadlinesTotalPages > 5 && ( - <> - - ... - - )} - - {getPageNumbers(deadlinesPage, deadlinesTotalPages).map((pageNum) => ( - - ))} - - {deadlinesPage < deadlinesTotalPages - 2 && deadlinesTotalPages > 5 && ( - <> - ... - - - )} - - -
-
- )} + {/* Pagination for Upcoming Deadlines */} +
+ +
)} @@ -1734,62 +1313,18 @@ export function Dashboard({ onNavigate, onNewRequest }: DashboardProps) { })}
- {/* Pagination Controls for Approver Performance */} - {approverTotalPages > 1 && ( -
-
- Showing {((approverPage - 1) * 10) + 1} to {Math.min(approverPage * 10, approverTotalRecords)} of {approverTotalRecords} approvers -
- -
- - - {approverPage > 3 && approverTotalPages > 5 && ( - <> - - ... - - )} - - {getPageNumbers(approverPage, approverTotalPages).map((pageNum) => ( - - ))} - - {approverPage < approverTotalPages - 2 && approverTotalPages > 5 && ( - <> - ... - - - )} - - -
-
- )} + {/* Pagination for Approver Performance */} +
+ +
)} diff --git a/src/pages/MyRequests/MyRequests.tsx b/src/pages/MyRequests/MyRequests.tsx index bae913c..9e87f76 100644 --- a/src/pages/MyRequests/MyRequests.tsx +++ b/src/pages/MyRequests/MyRequests.tsx @@ -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 ( -
- {/* Enhanced Header */} -
-
-
-
- -
-
-

My Requests

-

Track and manage all your submitted requests

-
-
-
- -
- - {loading ? 'Loading…' : `${totalRecords || allRequests.length} total`} - requests - -
-
+
+ {/* Page Header */} + {/* Stats Overview */} -
- - -
-
-

Total

-

{stats.total}

-
- -
-
-
- - - -
-
-

In Progress

-

{stats.pending + stats.inReview}

-
- -
-
-
- - - -
-
-

Approved

-

{stats.approved}

-
- -
-
-
- - - -
-
-

Rejected

-

{stats.rejected}

-
- -
-
-
- - - -
-
-

Draft

-

{stats.draft}

-
- -
-
-
+
+ + + + + + + + +
{/* Filters and Search */} - +
@@ -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" />