Compare commits
13 Commits
1b903dc56b
...
54462b1658
| Author | SHA1 | Date | |
|---|---|---|---|
| 54462b1658 | |||
| 9281e3deb3 | |||
| 891096a184 | |||
| f022cbf899 | |||
| 231d99ad95 | |||
| 90f29c11bd | |||
| d60757ae72 | |||
| 7efc5c5d94 | |||
| 61ba649ac4 | |||
| 63738c529b | |||
| a030179d3c | |||
| 7d5b93ae50 | |||
| 3c9d7cb620 |
@ -1,537 +0,0 @@
|
|||||||
# 🎛️ Admin Features - Frontend Implementation Guide
|
|
||||||
|
|
||||||
## ✅ What's Been Implemented
|
|
||||||
|
|
||||||
I've successfully integrated the **Admin Configuration & Holiday Management** system into your Settings page!
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 **Features Overview**
|
|
||||||
|
|
||||||
### **1. System Configuration Manager** ⚙️
|
|
||||||
- View and edit all system configurations
|
|
||||||
- Organized by category (TAT Settings, Document Policy, AI Config, etc.)
|
|
||||||
- Different input types: text, number, sliders, toggles
|
|
||||||
- Validation rules applied
|
|
||||||
- Save/Reset to default functionality
|
|
||||||
- Real-time feedback
|
|
||||||
|
|
||||||
### **2. Holiday Calendar Manager** 📅
|
|
||||||
- View holidays by year
|
|
||||||
- Add/edit/delete holidays
|
|
||||||
- Holiday types: National, Regional, Organizational, Optional
|
|
||||||
- Recurring holiday support
|
|
||||||
- Month-wise organized view
|
|
||||||
- Visual calendar interface
|
|
||||||
|
|
||||||
### **3. Access Control** 🔒
|
|
||||||
- Admin-only tabs (System Configuration, Holiday Calendar)
|
|
||||||
- Non-admin users see only User Settings
|
|
||||||
- Graceful degradation for non-admin users
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 **UI Components Created**
|
|
||||||
|
|
||||||
### **New Files:**
|
|
||||||
1. `src/services/adminApi.ts` - API service layer
|
|
||||||
2. `src/components/admin/ConfigurationManager.tsx` - Configuration UI
|
|
||||||
3. `src/components/admin/HolidayManager.tsx` - Holiday management UI
|
|
||||||
4. `src/components/admin/index.ts` - Barrel export
|
|
||||||
|
|
||||||
### **Updated Files:**
|
|
||||||
1. `src/pages/Settings/Settings.tsx` - Integrated admin features
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 **How to Use (As Admin)**
|
|
||||||
|
|
||||||
### **Access Settings:**
|
|
||||||
```
|
|
||||||
Navigate to: Settings (sidebar menu)
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Tabs Available (Admin Users):**
|
|
||||||
1. **User Settings** - Personal preferences (notifications, appearance, etc.)
|
|
||||||
2. **System Configuration** - Admin-only system settings
|
|
||||||
3. **Holiday Calendar** - Admin-only holiday management
|
|
||||||
|
|
||||||
### **Tabs Available (Non-Admin Users):**
|
|
||||||
1. User Settings only
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ⚙️ **System Configuration Tab**
|
|
||||||
|
|
||||||
### **Categories:**
|
|
||||||
|
|
||||||
**TAT Settings:**
|
|
||||||
- Default TAT for Express Priority (hours)
|
|
||||||
- Default TAT for Standard Priority (hours)
|
|
||||||
- First Reminder Threshold (%) - Slider
|
|
||||||
- Second Reminder Threshold (%) - Slider
|
|
||||||
- Working Day Start Hour
|
|
||||||
- Working Day End Hour
|
|
||||||
|
|
||||||
**Document Policy:**
|
|
||||||
- Maximum File Upload Size (MB)
|
|
||||||
- Allowed File Types
|
|
||||||
- Document Retention Period (Days)
|
|
||||||
|
|
||||||
**AI Configuration:**
|
|
||||||
- Enable AI Remark Generation - Toggle
|
|
||||||
- AI Remark Maximum Characters
|
|
||||||
|
|
||||||
### **How to Edit:**
|
|
||||||
1. Navigate to **System Configuration** tab
|
|
||||||
2. Select a category
|
|
||||||
3. Modify the value
|
|
||||||
4. Click **Save**
|
|
||||||
5. Click **Reset to Default** to restore original value
|
|
||||||
|
|
||||||
### **Visual Indicators:**
|
|
||||||
- 🟡 **Modified Badge** - Value has been changed
|
|
||||||
- 🟠 **Requires Restart Badge** - Server restart needed after save
|
|
||||||
- ✅ **Success Message** - Configuration saved
|
|
||||||
- ❌ **Error Message** - Validation failed or save error
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📅 **Holiday Calendar Tab**
|
|
||||||
|
|
||||||
### **Features:**
|
|
||||||
|
|
||||||
**Year Selector:**
|
|
||||||
- View holidays for any year (current year ±2)
|
|
||||||
- Dropdown selection
|
|
||||||
|
|
||||||
**Add Holiday:**
|
|
||||||
1. Click **+ Add Holiday** button
|
|
||||||
2. Fill in form:
|
|
||||||
- **Date** (required)
|
|
||||||
- **Holiday Name** (required)
|
|
||||||
- **Description** (optional)
|
|
||||||
- **Holiday Type**: National/Regional/Organizational/Optional
|
|
||||||
- **Recurring** checkbox (for annual holidays)
|
|
||||||
3. Click **Add Holiday**
|
|
||||||
|
|
||||||
**Edit Holiday:**
|
|
||||||
1. Find holiday in list
|
|
||||||
2. Click **Edit** button
|
|
||||||
3. Modify fields
|
|
||||||
4. Click **Update Holiday**
|
|
||||||
|
|
||||||
**Delete Holiday:**
|
|
||||||
1. Find holiday in list
|
|
||||||
2. Click **Delete** button
|
|
||||||
3. Confirm deletion
|
|
||||||
|
|
||||||
**View:**
|
|
||||||
- Holidays grouped by month
|
|
||||||
- Badges show holiday type
|
|
||||||
- "Recurring" badge for annual holidays
|
|
||||||
- Description shown below holiday name
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 **Configuration Input Types**
|
|
||||||
|
|
||||||
### **Text Input:**
|
|
||||||
```tsx
|
|
||||||
<Input type="text" value={value} onChange={...} />
|
|
||||||
```
|
|
||||||
- Used for: File types, text values
|
|
||||||
|
|
||||||
### **Number Input:**
|
|
||||||
```tsx
|
|
||||||
<Input type="number" min={1} max={100} value={value} onChange={...} />
|
|
||||||
```
|
|
||||||
- Used for: TAT hours, file sizes, retention days
|
|
||||||
- Validation: min/max enforced
|
|
||||||
|
|
||||||
### **Slider:**
|
|
||||||
```tsx
|
|
||||||
<Slider value={[50]} min={0} max={100} step={1} onChange={...} />
|
|
||||||
```
|
|
||||||
- Used for: Percentage thresholds
|
|
||||||
- Visual feedback with current value display
|
|
||||||
|
|
||||||
### **Toggle Switch:**
|
|
||||||
```tsx
|
|
||||||
<Switch checked={true} onCheckedChange={...} />
|
|
||||||
```
|
|
||||||
- Used for: Boolean settings (enable/disable features)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 **Backend Integration**
|
|
||||||
|
|
||||||
### **API Endpoints Used:**
|
|
||||||
|
|
||||||
**Configuration:**
|
|
||||||
- `GET /api/admin/configurations` - Fetch all configs
|
|
||||||
- `GET /api/admin/configurations?category=TAT_SETTINGS` - Filter by category
|
|
||||||
- `PUT /api/admin/configurations/:configKey` - Update value
|
|
||||||
- `POST /api/admin/configurations/:configKey/reset` - Reset to default
|
|
||||||
|
|
||||||
**Holidays:**
|
|
||||||
- `GET /api/admin/holidays?year=2025` - Get holidays for year
|
|
||||||
- `POST /api/admin/holidays` - Create holiday
|
|
||||||
- `PUT /api/admin/holidays/:holidayId` - Update holiday
|
|
||||||
- `DELETE /api/admin/holidays/:holidayId` - Delete holiday
|
|
||||||
- `POST /api/admin/holidays/bulk-import` - Import multiple holidays
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔒 **Security**
|
|
||||||
|
|
||||||
### **Frontend:**
|
|
||||||
- Admin tabs only visible if `user.isAdmin === true`
|
|
||||||
- Uses `useAuth()` context to check admin status
|
|
||||||
|
|
||||||
### **Backend:**
|
|
||||||
- All admin endpoints protected with `authenticateToken`
|
|
||||||
- Additional `requireAdmin` middleware
|
|
||||||
- Non-admin users get 403 Forbidden
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 **UI/UX Features**
|
|
||||||
|
|
||||||
### **Success/Error Messages:**
|
|
||||||
- Green alert for successful operations
|
|
||||||
- Red alert for errors
|
|
||||||
- Auto-dismiss after 3 seconds
|
|
||||||
|
|
||||||
### **Loading States:**
|
|
||||||
- Spinner while fetching data
|
|
||||||
- Disabled buttons during save
|
|
||||||
- "Saving..." button text
|
|
||||||
|
|
||||||
### **Validation:**
|
|
||||||
- Required field checks
|
|
||||||
- Min/max validation for numbers
|
|
||||||
- Visual feedback for invalid input
|
|
||||||
|
|
||||||
### **Responsive Design:**
|
|
||||||
- Grid layout for large screens
|
|
||||||
- Stack layout for mobile
|
|
||||||
- Scrollable content areas
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📱 **Mobile Responsiveness**
|
|
||||||
|
|
||||||
### **Configuration Manager:**
|
|
||||||
- Tabs stack on small screens
|
|
||||||
- Full-width inputs
|
|
||||||
- Touch-friendly buttons
|
|
||||||
|
|
||||||
### **Holiday Manager:**
|
|
||||||
- Year selector and Add button stack vertically
|
|
||||||
- Holiday cards full-width on mobile
|
|
||||||
- Edit/Delete buttons accessible
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧪 **Testing Guide**
|
|
||||||
|
|
||||||
### **Test Configuration Management:**
|
|
||||||
|
|
||||||
1. **Login as Admin:**
|
|
||||||
- Navigate to Settings
|
|
||||||
- Verify 3 tabs visible
|
|
||||||
|
|
||||||
2. **Edit TAT Setting:**
|
|
||||||
- Go to System Configuration → TAT Settings
|
|
||||||
- Change "Default TAT for Express Priority" to 36
|
|
||||||
- Click Save
|
|
||||||
- Verify success message
|
|
||||||
- Check backend: value should be updated in DB
|
|
||||||
|
|
||||||
3. **Use Slider:**
|
|
||||||
- Go to "First Reminder Threshold"
|
|
||||||
- Drag slider to 60%
|
|
||||||
- Click Save
|
|
||||||
- Verify success message
|
|
||||||
|
|
||||||
4. **Toggle AI Feature:**
|
|
||||||
- Go to AI Configuration
|
|
||||||
- Toggle "Enable AI Remark Generation"
|
|
||||||
- Click Save
|
|
||||||
- Verify success message
|
|
||||||
|
|
||||||
5. **Reset to Default:**
|
|
||||||
- Edit any configuration
|
|
||||||
- Click "Reset to Default"
|
|
||||||
- Confirm
|
|
||||||
- Verify value restored
|
|
||||||
|
|
||||||
### **Test Holiday Management:**
|
|
||||||
|
|
||||||
1. **Add Holiday:**
|
|
||||||
- Go to Holiday Calendar tab
|
|
||||||
- Click "+ Add Holiday"
|
|
||||||
- Fill form:
|
|
||||||
- Date: 2025-12-31
|
|
||||||
- Name: New Year's Eve
|
|
||||||
- Type: Organizational
|
|
||||||
- Click "Add Holiday"
|
|
||||||
- Verify appears in December section
|
|
||||||
|
|
||||||
2. **Edit Holiday:**
|
|
||||||
- Find holiday in list
|
|
||||||
- Click "Edit"
|
|
||||||
- Change description
|
|
||||||
- Click "Update Holiday"
|
|
||||||
- Verify changes saved
|
|
||||||
|
|
||||||
3. **Delete Holiday:**
|
|
||||||
- Find holiday
|
|
||||||
- Click "Delete"
|
|
||||||
- Confirm
|
|
||||||
- Verify removed from list
|
|
||||||
|
|
||||||
4. **Change Year:**
|
|
||||||
- Select different year from dropdown
|
|
||||||
- Verify holidays load for that year
|
|
||||||
|
|
||||||
### **Test as Non-Admin:**
|
|
||||||
1. Login as regular user
|
|
||||||
2. Navigate to Settings
|
|
||||||
3. Verify only User Settings visible
|
|
||||||
4. Verify blue info card: "Admin features not accessible"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 **Configuration Categories**
|
|
||||||
|
|
||||||
### **TAT_SETTINGS:**
|
|
||||||
- Default TAT hours
|
|
||||||
- Reminder thresholds
|
|
||||||
- Working hours
|
|
||||||
- **Impact:** Affects all new workflow requests
|
|
||||||
|
|
||||||
### **DOCUMENT_POLICY:**
|
|
||||||
- Max file size
|
|
||||||
- Allowed file types
|
|
||||||
- Retention period
|
|
||||||
- **Impact:** Affects file uploads system-wide
|
|
||||||
|
|
||||||
### **AI_CONFIGURATION:**
|
|
||||||
- Enable/disable AI
|
|
||||||
- Max characters
|
|
||||||
- **Impact:** Affects conclusion remark generation
|
|
||||||
|
|
||||||
### **NOTIFICATION_RULES:** (Future)
|
|
||||||
- Email/SMS preferences
|
|
||||||
- Notification frequency
|
|
||||||
- **Impact:** Affects all notifications
|
|
||||||
|
|
||||||
### **WORKFLOW_SHARING:** (Future)
|
|
||||||
- Spectator permissions
|
|
||||||
- Share link settings
|
|
||||||
- **Impact:** Affects collaboration features
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 **Data Flow**
|
|
||||||
|
|
||||||
```
|
|
||||||
Settings Page (Admin User)
|
|
||||||
↓
|
|
||||||
ConfigurationManager Component
|
|
||||||
↓
|
|
||||||
adminApi.getAllConfigurations()
|
|
||||||
↓
|
|
||||||
Backend: GET /api/admin/configurations
|
|
||||||
↓
|
|
||||||
Fetch from admin_configurations table
|
|
||||||
↓
|
|
||||||
Display by category with appropriate UI components
|
|
||||||
↓
|
|
||||||
User edits value
|
|
||||||
↓
|
|
||||||
adminApi.updateConfiguration(key, value)
|
|
||||||
↓
|
|
||||||
Backend: PUT /api/admin/configurations/:key
|
|
||||||
↓
|
|
||||||
Update database
|
|
||||||
↓
|
|
||||||
Success message + refresh
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 **Styling Reference**
|
|
||||||
|
|
||||||
### **Color Scheme:**
|
|
||||||
- **TAT Settings:** Blue (`bg-blue-100`)
|
|
||||||
- **Document Policy:** Purple (`bg-purple-100`)
|
|
||||||
- **Notification Rules:** Amber (`bg-amber-100`)
|
|
||||||
- **AI Configuration:** Pink (`bg-pink-100`)
|
|
||||||
- **Workflow Sharing:** Emerald (`bg-emerald-100`)
|
|
||||||
|
|
||||||
### **Holiday Types:**
|
|
||||||
- **NATIONAL:** Red (`bg-red-100`)
|
|
||||||
- **REGIONAL:** Blue (`bg-blue-100`)
|
|
||||||
- **ORGANIZATIONAL:** Purple (`bg-purple-100`)
|
|
||||||
- **OPTIONAL:** Gray (`bg-gray-100`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 **Future Enhancements**
|
|
||||||
|
|
||||||
### **Configuration Manager:**
|
|
||||||
1. ✨ Bulk edit mode
|
|
||||||
2. ✨ Search/filter configurations
|
|
||||||
3. ✨ Configuration history (audit trail)
|
|
||||||
4. ✨ Import/export configurations
|
|
||||||
5. ✨ Configuration templates
|
|
||||||
|
|
||||||
### **Holiday Manager:**
|
|
||||||
1. ✨ Visual calendar view (month grid)
|
|
||||||
2. ✨ Drag-and-drop dates
|
|
||||||
3. ✨ Import from Google Calendar
|
|
||||||
4. ✨ Export to CSV/iCal
|
|
||||||
5. ✨ Holiday templates by country
|
|
||||||
6. ✨ Multi-select delete
|
|
||||||
7. ✨ Holiday conflict detection
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🐛 **Troubleshooting**
|
|
||||||
|
|
||||||
### **Admin Tabs Not Showing?**
|
|
||||||
|
|
||||||
**Check:**
|
|
||||||
1. Is user logged in?
|
|
||||||
2. Is `user.isAdmin` true in database?
|
|
||||||
3. Check console for authentication errors
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
```sql
|
|
||||||
-- Make user admin
|
|
||||||
UPDATE users SET is_admin = true WHERE email = 'your-email@example.com';
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Configurations Not Loading?**
|
|
||||||
|
|
||||||
**Check:**
|
|
||||||
1. Backend running?
|
|
||||||
2. Admin auth token valid?
|
|
||||||
3. Check network tab for 403/401 errors
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
- Verify JWT token is valid
|
|
||||||
- Check `requireAdmin` middleware is working
|
|
||||||
|
|
||||||
### **Holidays Not Saving?**
|
|
||||||
|
|
||||||
**Check:**
|
|
||||||
1. Date format correct? (YYYY-MM-DD)
|
|
||||||
2. Holiday name filled?
|
|
||||||
3. Check console for validation errors
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 **Component API**
|
|
||||||
|
|
||||||
### **ConfigurationManager Props:**
|
|
||||||
```typescript
|
|
||||||
interface ConfigurationManagerProps {
|
|
||||||
onConfigUpdate?: () => void; // Callback after config updated
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **HolidayManager Props:**
|
|
||||||
```typescript
|
|
||||||
// No props required - fully self-contained
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ **Sample Screenshots** (Describe UI)
|
|
||||||
|
|
||||||
### **Admin View:**
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ Settings │
|
|
||||||
│ Manage your account settings... │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
|
|
||||||
┌───────────────────────────────────────────┐
|
|
||||||
│ [User Settings] [System Config] [Holidays]│
|
|
||||||
└───────────────────────────────────────────┘
|
|
||||||
|
|
||||||
System Configuration Tab:
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [TAT_SETTINGS] [DOCUMENT_POLICY] [AI] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
|
|
||||||
TAT SETTINGS
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ ⏰ Default TAT for Express Priority │
|
|
||||||
│ Default turnaround time in hours │
|
|
||||||
│ Default: 24 │
|
|
||||||
│ [24] ← input │
|
|
||||||
│ [Save] [Reset to Default] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
|
|
||||||
│ ⏰ First TAT Reminder Threshold (%) │
|
|
||||||
│ Send first reminder at... │
|
|
||||||
│ 50% ━━●━━━━━━━━ Range: 0-100 │
|
|
||||||
│ [Save] [Reset to Default] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 **Key Points**
|
|
||||||
|
|
||||||
1. ✅ **Admin Only:** System Config & Holidays tabs require admin role
|
|
||||||
2. ✅ **Real-time Validation:** Min/max enforced on save
|
|
||||||
3. ✅ **Auto-refresh:** Changes reflect immediately
|
|
||||||
4. ✅ **Holiday TAT Impact:** Holidays automatically excluded from STANDARD priority
|
|
||||||
5. ✅ **Mobile Friendly:** Responsive design for all screen sizes
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 **Next Steps**
|
|
||||||
|
|
||||||
### **Immediate:**
|
|
||||||
1. ✅ **Test as Admin** - Login and verify tabs visible
|
|
||||||
2. ✅ **Add Holidays** - Import Indian holidays or add manually
|
|
||||||
3. ✅ **Configure TAT** - Set organization-specific TAT defaults
|
|
||||||
|
|
||||||
### **Future:**
|
|
||||||
1. 📋 Add visual calendar view for holidays
|
|
||||||
2. 📋 Add configuration audit trail
|
|
||||||
3. 📋 Add bulk configuration import/export
|
|
||||||
4. 📋 Add user role management UI
|
|
||||||
5. 📋 Add notification template editor
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📞 **Support**
|
|
||||||
|
|
||||||
**Common Issues:**
|
|
||||||
- Admin tabs not showing? → Check `user.isAdmin` in database
|
|
||||||
- Configurations not loading? → Check backend logs, verify admin token
|
|
||||||
- Holidays not affecting TAT? → Verify priority is STANDARD, restart backend
|
|
||||||
|
|
||||||
**Documentation:**
|
|
||||||
- Backend Guide: `Re_Backend/HOLIDAY_AND_ADMIN_CONFIG_COMPLETE.md`
|
|
||||||
- Setup Guide: `Re_Backend/SETUP_COMPLETE.md`
|
|
||||||
- API Docs: `Re_Backend/docs/HOLIDAY_CALENDAR_SYSTEM.md`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Status:** ✅ **COMPLETE & READY TO USE!**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Last Updated:** November 4, 2025
|
|
||||||
**Version:** 1.0.0
|
|
||||||
**Team:** Royal Enfield Workflow
|
|
||||||
|
|
||||||
@ -1,248 +0,0 @@
|
|||||||
# Claim Management System - Complete Data Flow
|
|
||||||
|
|
||||||
## 🎯 Overview
|
|
||||||
|
|
||||||
The claim management system now has complete integration with automatic dealer lookup and proper workflow management.
|
|
||||||
|
|
||||||
## 📊 Data Flow Diagram
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 1. User Creates Claim │
|
|
||||||
│ (ClaimManagementWizard) │
|
|
||||||
└────────────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 2. Select Dealer Code (e.g., RE-MH-001) │
|
|
||||||
│ │
|
|
||||||
│ Triggers: getDealerInfo(dealerCode) │
|
|
||||||
│ Returns from dealerDatabase.ts: │
|
|
||||||
│ • Dealer Name: "Royal Motors Mumbai" │
|
|
||||||
│ • Email: "dealer@royalmotorsmumbai.com" │
|
|
||||||
│ • Phone: "+91 98765 12345" │
|
|
||||||
│ • Address: "Shop No. 12-15, Central Avenue..." │
|
|
||||||
└────────────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 3. Complete Form & Submit (formData) │
|
|
||||||
│ │
|
|
||||||
│ Captured Fields: │
|
|
||||||
│ • activityName │
|
|
||||||
│ • activityType │
|
|
||||||
│ • activityDate │
|
|
||||||
│ • location │
|
|
||||||
│ • dealerCode │
|
|
||||||
│ • dealerName ┐ │
|
|
||||||
│ • dealerEmail ├─ Auto-populated from database │
|
|
||||||
│ • dealerPhone │ │
|
|
||||||
│ • dealerAddress ┘ │
|
|
||||||
│ • estimatedBudget │
|
|
||||||
│ • requestDescription │
|
|
||||||
│ • periodStart, periodEnd │
|
|
||||||
└────────────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 4. App.tsx Creates Request Object │
|
|
||||||
│ │
|
|
||||||
│ REQUEST_DATABASE[requestId] = { │
|
|
||||||
│ id: 'RE-REQ-2024-CM-XXX', │
|
|
||||||
│ title: '...', │
|
|
||||||
│ status: 'pending', │
|
|
||||||
│ currentStep: 1, │
|
|
||||||
│ totalSteps: 8, │
|
|
||||||
│ templateType: 'claim-management', │
|
|
||||||
│ claimDetails: { │
|
|
||||||
│ activityName: formData.activityName, │
|
|
||||||
│ dealerEmail: formData.dealerEmail, ← From DB │
|
|
||||||
│ dealerPhone: formData.dealerPhone, ← From DB │
|
|
||||||
│ dealerAddress: formData.dealerAddress, ← From DB │
|
|
||||||
│ estimatedBudget: formData.estimatedBudget, │
|
|
||||||
│ ...all other fields │
|
|
||||||
│ }, │
|
|
||||||
│ approvalFlow: [ 8 steps... ] │
|
|
||||||
│ } │
|
|
||||||
└────────────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 5. MyRequests Shows Request in List │
|
|
||||||
│ │
|
|
||||||
│ RE-REQ-2024-CM-001 │
|
|
||||||
│ Dealer Marketing Activity Claim │
|
|
||||||
│ Status: Pending | Step 1 of 8 │
|
|
||||||
└────────────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 6. User Clicks Request → RequestDetail.tsx │
|
|
||||||
│ │
|
|
||||||
│ Fetches from REQUEST_DATABASE[requestId] │
|
|
||||||
│ Displays all information: │
|
|
||||||
│ │
|
|
||||||
│ ┌─────────────────────────────────────────────────┐ │
|
|
||||||
│ │ Overview Tab │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 📋 Activity Information │ │
|
|
||||||
│ │ • Activity Name: "..." │ │
|
|
||||||
│ │ • Activity Type: "..." │ │
|
|
||||||
│ │ • Date: "..." │ │
|
|
||||||
│ │ • Location: "..." │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 🏢 Dealer Information │ │
|
|
||||||
│ │ • Dealer Code: RE-MH-001 │ │
|
|
||||||
│ │ • Dealer Name: Royal Motors Mumbai │ │
|
|
||||||
│ │ • Email: dealer@royalmotorsmumbai.com ✓ │ │
|
|
||||||
│ │ • Phone: +91 98765 12345 ✓ │ │
|
|
||||||
│ │ • Address: Shop No. 12-15... ✓ │ │
|
|
||||||
│ │ │ │
|
|
||||||
│ │ 💰 Claim Request Details │ │
|
|
||||||
│ │ • Description: "..." │ │
|
|
||||||
│ │ • Estimated Budget: ₹2,45,000 ✓ │ │
|
|
||||||
│ │ • Period: Oct 1 - Oct 10 │ │
|
|
||||||
│ └─────────────────────────────────────────────────┘ │
|
|
||||||
└────────────────────────────┬────────────────────────────────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ 7. Workflow Tab Shows 8-Step Process │
|
|
||||||
│ │
|
|
||||||
│ Step 1: Dealer Document Upload [PENDING] │
|
|
||||||
│ Action: [Upload Proposal Documents] ← Opens modal │
|
|
||||||
│ │
|
|
||||||
│ Step 2: Initiator Evaluation [WAITING] │
|
|
||||||
│ Actions: [Approve] [Request Modifications] │
|
|
||||||
│ │
|
|
||||||
│ Step 3: IO Confirmation (Auto) [WAITING] │
|
|
||||||
│ │
|
|
||||||
│ Step 4: Department Lead Approval [WAITING] │
|
|
||||||
│ Action: [Approve & Lock Budget] │
|
|
||||||
│ │
|
|
||||||
│ Step 5: Dealer Completion Documents [WAITING] │
|
|
||||||
│ Action: [Upload Completion Documents] │
|
|
||||||
│ │
|
|
||||||
│ Step 6: Initiator Verification [WAITING] │
|
|
||||||
│ Action: [Verify & Set Amount] ← Opens modal │
|
|
||||||
│ │
|
|
||||||
│ Step 7: E-Invoice Generation (Auto) [WAITING] │
|
|
||||||
│ │
|
|
||||||
│ Step 8: Credit Note Issuance [WAITING] │
|
|
||||||
│ Action: [Issue Credit Note] │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🔄 Key Features Implemented
|
|
||||||
|
|
||||||
### 1. Dealer Database Auto-Population
|
|
||||||
```typescript
|
|
||||||
// When dealer selected in wizard
|
|
||||||
handleDealerChange('RE-MH-001')
|
|
||||||
↓
|
|
||||||
getDealerInfo('RE-MH-001')
|
|
||||||
↓
|
|
||||||
Returns complete dealer object
|
|
||||||
↓
|
|
||||||
Auto-fills: name, email, phone, address
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Complete Data Capture
|
|
||||||
- ✅ All activity details
|
|
||||||
- ✅ All dealer information (from database)
|
|
||||||
- ✅ Estimated budget
|
|
||||||
- ✅ Request description
|
|
||||||
- ✅ Period dates
|
|
||||||
|
|
||||||
### 3. Step-Specific Actions
|
|
||||||
Each workflow step shows relevant action buttons:
|
|
||||||
- **Upload buttons** for document steps
|
|
||||||
- **Approve/Reject buttons** for approval steps
|
|
||||||
- **Set Amount button** for verification step
|
|
||||||
- **Automatic processing** for system steps
|
|
||||||
|
|
||||||
### 4. Modal Integration
|
|
||||||
- **DealerDocumentModal**: For steps 1 & 5
|
|
||||||
- Upload multiple documents
|
|
||||||
- Add dealer comments
|
|
||||||
- Validation before submit
|
|
||||||
|
|
||||||
- **InitiatorVerificationModal**: For step 6
|
|
||||||
- Review completion documents
|
|
||||||
- Set final approved amount
|
|
||||||
- Add verification comments
|
|
||||||
|
|
||||||
## 🎨 UI Components
|
|
||||||
|
|
||||||
### ClaimManagementWizard
|
|
||||||
```
|
|
||||||
Step 1: Claim Details
|
|
||||||
├── Activity Name & Type
|
|
||||||
├── Dealer Selection → Auto-fills email, phone, address
|
|
||||||
├── Date & Location
|
|
||||||
├── Estimated Budget (new!)
|
|
||||||
└── Request Description
|
|
||||||
|
|
||||||
Step 2: Review & Submit
|
|
||||||
├── Activity Information Card
|
|
||||||
├── Dealer Information Card (with email, phone, address)
|
|
||||||
├── Date & Location Card (with budget)
|
|
||||||
└── Request Details Card
|
|
||||||
```
|
|
||||||
|
|
||||||
### RequestDetail Overview Tab
|
|
||||||
```
|
|
||||||
├── Activity Information
|
|
||||||
│ ├── Activity Name
|
|
||||||
│ ├── Activity Type
|
|
||||||
│ ├── Date
|
|
||||||
│ └── Location
|
|
||||||
│
|
|
||||||
├── Dealer Information
|
|
||||||
│ ├── Dealer Code
|
|
||||||
│ ├── Dealer Name
|
|
||||||
│ ├── Email ← From dealer database
|
|
||||||
│ ├── Phone ← From dealer database
|
|
||||||
│ └── Address ← From dealer database
|
|
||||||
│
|
|
||||||
└── Claim Request Details
|
|
||||||
├── Description
|
|
||||||
├── Estimated Budget ← User input
|
|
||||||
└── Period
|
|
||||||
```
|
|
||||||
|
|
||||||
## 📝 Database Schema
|
|
||||||
|
|
||||||
### dealerDatabase.ts Structure
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
'RE-MH-001': {
|
|
||||||
code: 'RE-MH-001',
|
|
||||||
name: 'Royal Motors Mumbai',
|
|
||||||
email: 'dealer@royalmotorsmumbai.com',
|
|
||||||
phone: '+91 98765 12345',
|
|
||||||
address: 'Shop No. 12-15, Central Avenue, Andheri West',
|
|
||||||
city: 'Mumbai',
|
|
||||||
state: 'Maharashtra',
|
|
||||||
region: 'West',
|
|
||||||
managerName: 'Rahul Deshmukh'
|
|
||||||
},
|
|
||||||
// ... 9 more dealers
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## ✅ Verification Checklist
|
|
||||||
|
|
||||||
- [x] Dealer database created with 10+ dealers
|
|
||||||
- [x] Auto-population works when dealer selected
|
|
||||||
- [x] All fields captured in claimDetails
|
|
||||||
- [x] RequestDetail displays all information
|
|
||||||
- [x] Step-specific action buttons appear
|
|
||||||
- [x] Modals integrate properly
|
|
||||||
- [x] 8-step workflow displays correctly
|
|
||||||
- [x] IDs synchronized across components
|
|
||||||
- [x] Data flows from wizard → app → detail
|
|
||||||
|
|
||||||
## 🚀 Ready for Testing!
|
|
||||||
|
|
||||||
The system is now complete and ready for end-to-end testing. All dealer information is automatically fetched from the database, properly saved in the request, and correctly displayed in the detail view.
|
|
||||||
@ -1,337 +0,0 @@
|
|||||||
# Custom Request Details Page Fix
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
Custom requests created through the NewRequestWizard were not displaying in the detail page. Instead, users only saw a "Go Back" button.
|
|
||||||
|
|
||||||
## Root Cause
|
|
||||||
1. **Database Lookup Issue**: Dynamic requests created through wizards were only stored in `App.tsx` component state (`dynamicRequests`), not in the static `CUSTOM_REQUEST_DATABASE`
|
|
||||||
2. **Component Props**: `RequestDetail` and `ClaimManagementDetail` components weren't receiving the `dynamicRequests` prop
|
|
||||||
3. **Data Flow Gap**: No connection between newly created requests and the detail view components
|
|
||||||
|
|
||||||
## Solution Implemented
|
|
||||||
|
|
||||||
### 1. Enhanced Request Creation (`App.tsx`)
|
|
||||||
|
|
||||||
Updated `handleNewRequestSubmit` to properly create custom request objects:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const newCustomRequest = {
|
|
||||||
id: requestId,
|
|
||||||
title: requestData.title,
|
|
||||||
description: requestData.description,
|
|
||||||
category: requestData.category,
|
|
||||||
subcategory: requestData.subcategory,
|
|
||||||
status: 'pending',
|
|
||||||
priority: requestData.priority,
|
|
||||||
amount: requestData.budget,
|
|
||||||
template: 'custom',
|
|
||||||
initiator: { ... },
|
|
||||||
approvalFlow: [...], // Maps approvers from wizard
|
|
||||||
spectators: [...], // Maps spectators from wizard
|
|
||||||
documents: [],
|
|
||||||
auditTrail: [...],
|
|
||||||
// ... complete request object
|
|
||||||
};
|
|
||||||
|
|
||||||
setDynamicRequests([...dynamicRequests, newCustomRequest]);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Features**:
|
|
||||||
- Generates unique request ID: `RE-REQ-2024-XXX`
|
|
||||||
- Maps wizard data to proper request structure
|
|
||||||
- Creates approval flow from selected approvers
|
|
||||||
- Adds spectators from wizard
|
|
||||||
- Initializes audit trail
|
|
||||||
- Sets proper SLA dates
|
|
||||||
- Navigates to My Requests page after creation
|
|
||||||
|
|
||||||
### 2. Updated Component Props
|
|
||||||
|
|
||||||
#### RequestDetail.tsx
|
|
||||||
```typescript
|
|
||||||
interface RequestDetailProps {
|
|
||||||
requestId: string;
|
|
||||||
onBack?: () => void;
|
|
||||||
onOpenModal?: (modal: string) => void;
|
|
||||||
dynamicRequests?: any[]; // NEW
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
#### ClaimManagementDetail.tsx
|
|
||||||
```typescript
|
|
||||||
interface ClaimManagementDetailProps {
|
|
||||||
requestId: string;
|
|
||||||
onBack?: () => void;
|
|
||||||
onOpenModal?: (modal: string) => void;
|
|
||||||
dynamicRequests?: any[]; // NEW
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Enhanced Request Lookup Logic
|
|
||||||
|
|
||||||
Both detail components now check both static databases AND dynamic requests:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const request = useMemo(() => {
|
|
||||||
// First check static database
|
|
||||||
const staticRequest = CUSTOM_REQUEST_DATABASE[requestId];
|
|
||||||
if (staticRequest) return staticRequest;
|
|
||||||
|
|
||||||
// Then check dynamic requests
|
|
||||||
const dynamicRequest = dynamicRequests.find((req: any) => req.id === requestId);
|
|
||||||
if (dynamicRequest) return dynamicRequest;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [requestId, dynamicRequests]);
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Intelligent Routing (`App.tsx`)
|
|
||||||
|
|
||||||
Updated `renderCurrentPage` for `request-detail` case:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
case 'request-detail':
|
|
||||||
// Check static databases
|
|
||||||
const isClaimRequest = CLAIM_MANAGEMENT_DATABASE[selectedRequestId];
|
|
||||||
const isCustomRequest = CUSTOM_REQUEST_DATABASE[selectedRequestId];
|
|
||||||
|
|
||||||
// Check dynamic requests
|
|
||||||
const dynamicRequest = dynamicRequests.find(...);
|
|
||||||
const isDynamicClaim = dynamicRequest?.templateType === 'claim-management';
|
|
||||||
const isDynamicCustom = dynamicRequest && !isDynamicClaim;
|
|
||||||
|
|
||||||
// Route to appropriate component with dynamicRequests prop
|
|
||||||
if (isClaimRequest || isDynamicClaim) {
|
|
||||||
return <ClaimManagementDetail {...} dynamicRequests={dynamicRequests} />;
|
|
||||||
} else {
|
|
||||||
return <RequestDetail {...} dynamicRequests={dynamicRequests} />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5. Updated handleViewRequest
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
const handleViewRequest = (requestId: string, requestTitle?: string) => {
|
|
||||||
setSelectedRequestId(requestId);
|
|
||||||
|
|
||||||
// Check all sources
|
|
||||||
const isClaimRequest = CLAIM_MANAGEMENT_DATABASE[requestId];
|
|
||||||
const isCustomRequest = CUSTOM_REQUEST_DATABASE[requestId];
|
|
||||||
const dynamicRequest = dynamicRequests.find(...);
|
|
||||||
|
|
||||||
const request = isClaimRequest || isCustomRequest || dynamicRequest;
|
|
||||||
setSelectedRequestTitle(requestTitle || request?.title || 'Unknown Request');
|
|
||||||
setCurrentPage('request-detail');
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Creating a Custom Request
|
|
||||||
|
|
||||||
1. User clicks "Raise New Request" → "Custom Request"
|
|
||||||
2. Fills out NewRequestWizard form:
|
|
||||||
- Title
|
|
||||||
- Description
|
|
||||||
- Category/Subcategory
|
|
||||||
- Budget
|
|
||||||
- Priority
|
|
||||||
- Approvers (multiple)
|
|
||||||
- Spectators (multiple)
|
|
||||||
- Tagged Participants
|
|
||||||
|
|
||||||
3. On Submit:
|
|
||||||
- `handleNewRequestSubmit` creates complete request object
|
|
||||||
- Adds to `dynamicRequests` state
|
|
||||||
- Navigates to My Requests page
|
|
||||||
- Shows success toast
|
|
||||||
|
|
||||||
4. Viewing the Request:
|
|
||||||
- User clicks on request in My Requests
|
|
||||||
- `handleViewRequest` finds request in dynamicRequests
|
|
||||||
- Routes to `request-detail` page
|
|
||||||
- `RequestDetail` component receives `dynamicRequests` prop
|
|
||||||
- Component finds request in dynamicRequests array
|
|
||||||
- Displays complete request details
|
|
||||||
|
|
||||||
## Custom Request Details Page Features
|
|
||||||
|
|
||||||
### Header
|
|
||||||
- Back button
|
|
||||||
- Request ID with file icon
|
|
||||||
- Priority badge (urgent/standard)
|
|
||||||
- Status badge (pending/in-review/approved/rejected)
|
|
||||||
- Refresh button
|
|
||||||
- Title display
|
|
||||||
|
|
||||||
### SLA Progress Bar
|
|
||||||
- Color-coded (green/orange/red based on progress)
|
|
||||||
- Time remaining
|
|
||||||
- Progress percentage
|
|
||||||
- Due date
|
|
||||||
|
|
||||||
### Tabs
|
|
||||||
1. **Overview**
|
|
||||||
- Request Initiator (with avatar, role, email, phone)
|
|
||||||
- Request Details (description, category, subcategory, amount, dates)
|
|
||||||
- Quick Actions sidebar
|
|
||||||
- Spectators list
|
|
||||||
|
|
||||||
2. **Workflow**
|
|
||||||
- Step-by-step approval flow
|
|
||||||
- Color-coded status indicators
|
|
||||||
- TAT and elapsed time
|
|
||||||
- Comments from approvers
|
|
||||||
|
|
||||||
3. **Documents**
|
|
||||||
- List of uploaded documents
|
|
||||||
- Upload new document button
|
|
||||||
- View and download actions
|
|
||||||
|
|
||||||
4. **Activity**
|
|
||||||
- Complete audit trail
|
|
||||||
- Action icons
|
|
||||||
- User and timestamp for each action
|
|
||||||
|
|
||||||
### Quick Actions (Right Sidebar)
|
|
||||||
- Add Work Note (dark green button)
|
|
||||||
- Add Approver
|
|
||||||
- Add Spectator
|
|
||||||
- Modify SLA
|
|
||||||
- Approve Request (green)
|
|
||||||
- Reject Request (red)
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
✅ **Request Creation**
|
|
||||||
- [ ] Create custom request through wizard
|
|
||||||
- [ ] Verify request appears in My Requests
|
|
||||||
- [ ] Check request ID is properly generated
|
|
||||||
- [ ] Verify all wizard data is captured
|
|
||||||
|
|
||||||
✅ **Request Detail Display**
|
|
||||||
- [ ] Click on custom request from My Requests
|
|
||||||
- [ ] Verify detail page loads (not "Go Back" button)
|
|
||||||
- [ ] Check all fields are populated correctly
|
|
||||||
- [ ] Verify initiator information displays
|
|
||||||
- [ ] Check description and category fields
|
|
||||||
|
|
||||||
✅ **Workflow Display**
|
|
||||||
- [ ] Verify approvers from wizard appear in workflow
|
|
||||||
- [ ] Check first approver is marked as "pending"
|
|
||||||
- [ ] Verify other approvers are "waiting"
|
|
||||||
- [ ] Check TAT hours are set
|
|
||||||
|
|
||||||
✅ **Spectators**
|
|
||||||
- [ ] Verify spectators from wizard appear
|
|
||||||
- [ ] Check avatar generation works
|
|
||||||
- [ ] Verify role display
|
|
||||||
|
|
||||||
✅ **Audit Trail**
|
|
||||||
- [ ] Check "Request Created" entry
|
|
||||||
- [ ] Check "Assigned to Approver" entry
|
|
||||||
- [ ] Verify timestamps are correct
|
|
||||||
|
|
||||||
✅ **Quick Actions**
|
|
||||||
- [ ] Test all quick action buttons
|
|
||||||
- [ ] Verify modals/toasts appear
|
|
||||||
- [ ] Check button styling
|
|
||||||
|
|
||||||
✅ **Claim Management Independence**
|
|
||||||
- [ ] Create claim request through ClaimManagementWizard
|
|
||||||
- [ ] Verify it routes to ClaimManagementDetail (purple theme)
|
|
||||||
- [ ] Verify custom requests route to RequestDetail (blue theme)
|
|
||||||
- [ ] Confirm no cross-contamination
|
|
||||||
|
|
||||||
## Sample Custom Request Data Structure
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
id: 'RE-REQ-2024-004',
|
|
||||||
title: 'Marketing Campaign Budget Approval',
|
|
||||||
description: 'Q4 marketing campaign budget request...',
|
|
||||||
category: 'Marketing & Campaigns',
|
|
||||||
subcategory: 'Digital Marketing',
|
|
||||||
status: 'pending',
|
|
||||||
priority: 'express',
|
|
||||||
amount: '₹5,00,000',
|
|
||||||
slaProgress: 0,
|
|
||||||
slaRemaining: '5 days',
|
|
||||||
slaEndDate: 'Oct 20, 2024 5:00 PM',
|
|
||||||
currentStep: 1,
|
|
||||||
totalSteps: 3,
|
|
||||||
template: 'custom',
|
|
||||||
initiator: {
|
|
||||||
name: 'Current User',
|
|
||||||
role: 'Employee',
|
|
||||||
department: 'Marketing',
|
|
||||||
email: 'current.user@royalenfield.com',
|
|
||||||
phone: '+91 98765 43290',
|
|
||||||
avatar: 'CU'
|
|
||||||
},
|
|
||||||
approvalFlow: [
|
|
||||||
{
|
|
||||||
step: 1,
|
|
||||||
approver: 'Rajesh Kumar',
|
|
||||||
role: 'Marketing Director',
|
|
||||||
status: 'pending',
|
|
||||||
tatHours: 48,
|
|
||||||
elapsedHours: 0,
|
|
||||||
assignedAt: '2024-10-15T...',
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
},
|
|
||||||
// ... more approvers
|
|
||||||
],
|
|
||||||
spectators: [
|
|
||||||
{
|
|
||||||
name: 'Finance Team',
|
|
||||||
role: 'Budget Monitoring',
|
|
||||||
avatar: 'FT'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
documents: [],
|
|
||||||
auditTrail: [
|
|
||||||
{
|
|
||||||
type: 'created',
|
|
||||||
action: 'Request Created',
|
|
||||||
details: 'Custom request "..." created',
|
|
||||||
user: 'Current User',
|
|
||||||
timestamp: 'Oct 15, 2024 10:30 AM'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
tags: ['custom-request']
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
1. **Persistence**: Add backend API integration to persist dynamic requests
|
|
||||||
2. **Real-time Updates**: WebSocket for live status updates
|
|
||||||
3. **Document Upload**: Implement actual file upload functionality
|
|
||||||
4. **Notifications**: Email/push notifications for approvers
|
|
||||||
5. **Search**: Add search functionality in My Requests
|
|
||||||
6. **Filters**: Advanced filtering by status, priority, date
|
|
||||||
7. **Export**: Export request details to PDF
|
|
||||||
8. **Comments**: Thread-based commenting system
|
|
||||||
9. **Attachments**: Support for multiple file types
|
|
||||||
10. **Permissions**: Role-based access control
|
|
||||||
|
|
||||||
## Known Limitations
|
|
||||||
|
|
||||||
1. Dynamic requests are in-memory only (lost on refresh)
|
|
||||||
2. No actual file upload (UI only)
|
|
||||||
3. No real approval actions (mocked)
|
|
||||||
4. No email notifications
|
|
||||||
5. No database persistence
|
|
||||||
|
|
||||||
## Next Steps
|
|
||||||
|
|
||||||
1. Implement backend API for request persistence
|
|
||||||
2. Add authentication and authorization
|
|
||||||
3. Implement real approval workflows
|
|
||||||
4. Add document upload functionality
|
|
||||||
5. Create notification system
|
|
||||||
6. Add reporting and analytics
|
|
||||||
7. Mobile responsive improvements
|
|
||||||
8. Accessibility enhancements
|
|
||||||
653
DASHBOARD_FORMULAS.md
Normal file
653
DASHBOARD_FORMULAS.md
Normal file
@ -0,0 +1,653 @@
|
|||||||
|
# Dashboard Formulas & Calculations Documentation
|
||||||
|
|
||||||
|
This document provides a comprehensive breakdown of all formulas and calculations used in the Dashboard for both **Admin/Management** users and **Regular Users**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **1. REQUEST VOLUME STATISTICS**
|
||||||
|
|
||||||
|
### **Admin/Management View**
|
||||||
|
**Scope:** All requests across the organization
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Total Requests = COUNT(*)
|
||||||
|
WHERE submission_date BETWEEN :start AND :end
|
||||||
|
AND is_draft = false
|
||||||
|
AND is_deleted = false
|
||||||
|
AND submission_date IS NOT NULL
|
||||||
|
|
||||||
|
Approved Requests = COUNT(*)
|
||||||
|
WHERE status = 'APPROVED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND is_draft = false
|
||||||
|
|
||||||
|
Rejected Requests = COUNT(*)
|
||||||
|
WHERE status = 'REJECTED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND is_draft = false
|
||||||
|
|
||||||
|
Pending Requests = COUNT(*)
|
||||||
|
WHERE status IN ('PENDING', 'IN_PROGRESS')
|
||||||
|
AND is_draft = false
|
||||||
|
(Note: Includes ALL pending requests regardless of creation date)
|
||||||
|
|
||||||
|
Draft Requests = COUNT(*)
|
||||||
|
WHERE is_draft = true
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Regular User View**
|
||||||
|
**Scope:** Only requests initiated by the user
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Total Requests = COUNT(*)
|
||||||
|
WHERE submission_date BETWEEN :start AND :end
|
||||||
|
AND is_draft = false
|
||||||
|
AND initiator_id = :userId
|
||||||
|
|
||||||
|
Approved Requests = COUNT(*)
|
||||||
|
WHERE status = 'APPROVED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND initiator_id = :userId
|
||||||
|
AND is_draft = false
|
||||||
|
|
||||||
|
Rejected Requests = COUNT(*)
|
||||||
|
WHERE status = 'REJECTED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND initiator_id = :userId
|
||||||
|
AND is_draft = false
|
||||||
|
|
||||||
|
Pending Requests = COUNT(*)
|
||||||
|
WHERE status IN ('PENDING', 'IN_PROGRESS')
|
||||||
|
AND initiator_id = :userId
|
||||||
|
AND is_draft = false
|
||||||
|
|
||||||
|
Draft Requests = COUNT(*)
|
||||||
|
WHERE is_draft = true
|
||||||
|
AND initiator_id = :userId
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⏱️ **2. TAT EFFICIENCY & SLA COMPLIANCE**
|
||||||
|
|
||||||
|
### **Admin/Management View**
|
||||||
|
**Scope:** All completed requests in date range
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Completed Requests = COUNT(*)
|
||||||
|
WHERE status IN ('APPROVED', 'REJECTED')
|
||||||
|
AND is_draft = false
|
||||||
|
AND submission_date IS NOT NULL
|
||||||
|
AND (
|
||||||
|
(closure_date IS NOT NULL AND closure_date BETWEEN :start AND :end)
|
||||||
|
OR (closure_date IS NULL AND updated_at BETWEEN :start AND :end)
|
||||||
|
)
|
||||||
|
|
||||||
|
Breached Requests = COUNT(DISTINCT request_id)
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM tat_alerts ta
|
||||||
|
WHERE ta.request_id = wf.request_id
|
||||||
|
AND ta.is_breached = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Compliant Requests = Total Completed - Breached Requests
|
||||||
|
|
||||||
|
SLA Compliance % = ROUND((Compliant Requests / Total Completed) × 100, 0)
|
||||||
|
If Total Completed = 0, then Compliance = 0%
|
||||||
|
|
||||||
|
Average Cycle Time (Hours) = ROUND(SUM(cycle_times) / COUNT(cycle_times), 1)
|
||||||
|
Where cycle_time = calculateElapsedWorkingHours(submission_date, completion_date, priority)
|
||||||
|
(Respects working hours, weekends, holidays based on priority)
|
||||||
|
|
||||||
|
Average Cycle Time (Days) = ROUND(Average Cycle Time (Hours) / 8, 1)
|
||||||
|
(Assumes 8 working hours per day)
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Regular User View**
|
||||||
|
**Scope:** Only completed requests initiated by the user
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Completed Requests = COUNT(*)
|
||||||
|
WHERE status IN ('APPROVED', 'REJECTED')
|
||||||
|
AND is_draft = false
|
||||||
|
AND submission_date IS NOT NULL
|
||||||
|
AND initiator_id = :userId
|
||||||
|
AND (
|
||||||
|
(closure_date IS NOT NULL AND closure_date BETWEEN :start AND :end)
|
||||||
|
OR (closure_date IS NULL AND updated_at BETWEEN :start AND :end)
|
||||||
|
)
|
||||||
|
|
||||||
|
Breached Requests = COUNT(DISTINCT request_id)
|
||||||
|
WHERE initiator_id = :userId
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM tat_alerts ta
|
||||||
|
WHERE ta.request_id = wf.request_id
|
||||||
|
AND ta.is_breached = true
|
||||||
|
)
|
||||||
|
|
||||||
|
Compliant Requests = Total Completed - Breached Requests
|
||||||
|
|
||||||
|
SLA Compliance % = ROUND((Compliant Requests / Total Completed) × 100, 0)
|
||||||
|
|
||||||
|
Average Cycle Time (Hours) = Same calculation as Admin
|
||||||
|
Average Cycle Time (Days) = Same calculation as Admin
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Breaches are tracked at **approver/level level** but counted at **request level** for SLA compliance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👤 **3. APPROVER LOAD STATISTICS**
|
||||||
|
|
||||||
|
### **Admin/Management View**
|
||||||
|
**Not Applicable** - This metric is user-specific
|
||||||
|
|
||||||
|
### **Regular User View**
|
||||||
|
**Scope:** User's approval workload
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Pending Actions = COUNT(DISTINCT level_id)
|
||||||
|
WHERE approver_id = :userId
|
||||||
|
AND status = 'IN_PROGRESS'
|
||||||
|
AND request.status IN ('PENDING', 'IN_PROGRESS')
|
||||||
|
AND is_draft = false
|
||||||
|
AND level_number = request.current_level
|
||||||
|
(Only counts requests at user's current active level)
|
||||||
|
|
||||||
|
Completed Today = COUNT(*)
|
||||||
|
WHERE approver_id = :userId
|
||||||
|
AND status IN ('APPROVED', 'REJECTED')
|
||||||
|
AND action_date BETWEEN :start AND :end
|
||||||
|
(Date range start/end for "today")
|
||||||
|
|
||||||
|
Completed This Week = COUNT(*)
|
||||||
|
WHERE approver_id = :userId
|
||||||
|
AND status IN ('APPROVED', 'REJECTED')
|
||||||
|
AND action_date >= start_of_week
|
||||||
|
AND action_date BETWEEN :start AND :end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 **4. ENGAGEMENT STATISTICS**
|
||||||
|
|
||||||
|
### **Admin/Management View**
|
||||||
|
**Scope:** All work notes and documents across organization
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Work Notes Added = COUNT(*)
|
||||||
|
FROM work_notes wn
|
||||||
|
WHERE wn.created_at BETWEEN :start AND :end
|
||||||
|
|
||||||
|
Attachments Uploaded = COUNT(*)
|
||||||
|
FROM documents d
|
||||||
|
WHERE d.uploaded_at BETWEEN :start AND :end
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Regular User View**
|
||||||
|
**Scope:** Only from requests initiated by the user
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Work Notes Added = COUNT(*)
|
||||||
|
FROM work_notes wn
|
||||||
|
WHERE wn.created_at BETWEEN :start AND :end
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM workflow_requests wf
|
||||||
|
WHERE wf.request_id = wn.request_id
|
||||||
|
AND wf.initiator_id = :userId
|
||||||
|
AND wf.is_draft = false
|
||||||
|
)
|
||||||
|
|
||||||
|
Attachments Uploaded = COUNT(*)
|
||||||
|
FROM documents d
|
||||||
|
WHERE d.uploaded_at BETWEEN :start AND :end
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM workflow_requests wf
|
||||||
|
WHERE wf.request_id = d.request_id
|
||||||
|
AND wf.initiator_id = :userId
|
||||||
|
AND wf.is_draft = false
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 **5. AI INSIGHTS**
|
||||||
|
|
||||||
|
### **Admin/Management View**
|
||||||
|
**Scope:** All approved requests with conclusion remarks
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Total with Conclusion = COUNT(*)
|
||||||
|
WHERE status = 'APPROVED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND conclusion_remark IS NOT NULL
|
||||||
|
AND is_draft = false
|
||||||
|
|
||||||
|
AI Generated Count = COUNT(*)
|
||||||
|
WHERE status = 'APPROVED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND ai_generated_conclusion IS NOT NULL
|
||||||
|
AND ai_generated_conclusion != ''
|
||||||
|
AND is_draft = false
|
||||||
|
|
||||||
|
Manual Count = COUNT(*)
|
||||||
|
WHERE status = 'APPROVED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND (ai_generated_conclusion IS NULL OR ai_generated_conclusion = '')
|
||||||
|
AND is_draft = false
|
||||||
|
|
||||||
|
AI Adoption % = ROUND((AI Generated Count / Total with Conclusion) × 100, 0)
|
||||||
|
|
||||||
|
Average Remark Length = ROUND(AVG(LENGTH(conclusion_remark)), 0)
|
||||||
|
WHERE status = 'APPROVED'
|
||||||
|
AND conclusion_remark IS NOT NULL
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Regular User View**
|
||||||
|
**Scope:** Only approved requests initiated by the user
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Total with Conclusion = COUNT(*)
|
||||||
|
WHERE status = 'APPROVED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND conclusion_remark IS NOT NULL
|
||||||
|
AND initiator_id = :userId
|
||||||
|
AND is_draft = false
|
||||||
|
|
||||||
|
AI Generated Count = COUNT(*)
|
||||||
|
WHERE status = 'APPROVED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND ai_generated_conclusion IS NOT NULL
|
||||||
|
AND ai_generated_conclusion != ''
|
||||||
|
AND initiator_id = :userId
|
||||||
|
AND is_draft = false
|
||||||
|
|
||||||
|
Manual Count = COUNT(*)
|
||||||
|
WHERE status = 'APPROVED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND (ai_generated_conclusion IS NULL OR ai_generated_conclusion = '')
|
||||||
|
AND initiator_id = :userId
|
||||||
|
AND is_draft = false
|
||||||
|
|
||||||
|
AI Adoption % = ROUND((AI Generated Count / Total with Conclusion) × 100, 0)
|
||||||
|
|
||||||
|
Average Remark Length = Same calculation as Admin (filtered by initiator_id)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📈 **6. SUCCESS RATE (Regular Users Only)**
|
||||||
|
|
||||||
|
**Formula:**
|
||||||
|
```
|
||||||
|
Success Rate % = ROUND((Approved Requests / Total Requests) × 100, 0)
|
||||||
|
If Total Requests = 0, then Success Rate = 0%
|
||||||
|
|
||||||
|
Where:
|
||||||
|
Approved Requests = From Request Volume Statistics
|
||||||
|
Total Requests = From Request Volume Statistics
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🏢 **7. DEPARTMENT STATISTICS (Admin Only)**
|
||||||
|
|
||||||
|
**Scope:** All requests grouped by initiator's department
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Department Stats = GROUP BY initiator.department
|
||||||
|
|
||||||
|
Total Requests per Dept = COUNT(*)
|
||||||
|
WHERE submission_date BETWEEN :start AND :end
|
||||||
|
AND is_draft = false
|
||||||
|
GROUP BY initiator.department
|
||||||
|
|
||||||
|
Approved per Dept = COUNT(*)
|
||||||
|
WHERE status = 'APPROVED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND is_draft = false
|
||||||
|
GROUP BY initiator.department
|
||||||
|
|
||||||
|
Rejected per Dept = COUNT(*)
|
||||||
|
WHERE status = 'REJECTED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND is_draft = false
|
||||||
|
GROUP BY initiator.department
|
||||||
|
|
||||||
|
In Progress per Dept = COUNT(*)
|
||||||
|
WHERE status IN ('PENDING', 'IN_PROGRESS')
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND is_draft = false
|
||||||
|
GROUP BY initiator.department
|
||||||
|
|
||||||
|
Approval Rate per Dept = ROUND((Approved / Total Requests) × 100, 0)
|
||||||
|
If Total Requests = 0, then Approval Rate = 0%
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Limited to top 10 departments by total requests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 **8. PRIORITY DISTRIBUTION (Admin Only)**
|
||||||
|
|
||||||
|
**Scope:** All requests grouped by priority (EXPRESS vs STANDARD)
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Total Count per Priority = COUNT(*)
|
||||||
|
WHERE submission_date BETWEEN :start AND :end
|
||||||
|
AND is_draft = false
|
||||||
|
GROUP BY priority
|
||||||
|
|
||||||
|
Approved Count per Priority = COUNT(*)
|
||||||
|
WHERE status = 'APPROVED'
|
||||||
|
AND submission_date BETWEEN :start AND :end
|
||||||
|
AND is_draft = false
|
||||||
|
GROUP BY priority
|
||||||
|
|
||||||
|
Breached Count per Priority = COUNT(DISTINCT request_id)
|
||||||
|
WHERE EXISTS (
|
||||||
|
SELECT 1 FROM tat_alerts ta
|
||||||
|
WHERE ta.request_id = wf.request_id
|
||||||
|
AND ta.is_breached = true
|
||||||
|
)
|
||||||
|
GROUP BY priority
|
||||||
|
|
||||||
|
Average Cycle Time per Priority = ROUND(SUM(cycle_times) / COUNT(cycle_times), 1)
|
||||||
|
Where cycle_time = calculateElapsedWorkingHours(submission_date, completion_date, priority)
|
||||||
|
Only for COMPLETED requests (status IN ('APPROVED', 'REJECTED'))
|
||||||
|
GROUP BY priority
|
||||||
|
|
||||||
|
Compliance Rate per Priority = ROUND(((Total Count - Breached Count) / Total Count) × 100, 0)
|
||||||
|
If Total Count = 0, then Compliance Rate = 0%
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 👥 **9. APPROVER PERFORMANCE (Admin Only)**
|
||||||
|
|
||||||
|
**Scope:** All approvers who completed approvals in date range
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Total Approved per Approver = COUNT(DISTINCT level_id)
|
||||||
|
WHERE approver_id = :approverId
|
||||||
|
AND action_date BETWEEN :start AND :end
|
||||||
|
AND status IN ('APPROVED', 'REJECTED')
|
||||||
|
AND action_date IS NOT NULL
|
||||||
|
AND level_start_time IS NOT NULL
|
||||||
|
AND tat_hours > 0
|
||||||
|
AND elapsed_hours > 0
|
||||||
|
|
||||||
|
Within TAT Count = COUNT(DISTINCT level_id)
|
||||||
|
WHERE approver_id = :approverId
|
||||||
|
AND action_date BETWEEN :start AND :end
|
||||||
|
AND status IN ('APPROVED', 'REJECTED')
|
||||||
|
AND elapsed_hours IS NOT NULL
|
||||||
|
AND elapsed_hours > 0
|
||||||
|
AND (
|
||||||
|
elapsed_hours < tat_hours
|
||||||
|
OR (elapsed_hours <= tat_hours AND (tat_breached IS NULL OR tat_breached = false))
|
||||||
|
OR (tat_breached IS NOT NULL AND tat_breached = false)
|
||||||
|
)
|
||||||
|
|
||||||
|
Breached Count = COUNT(DISTINCT level_id)
|
||||||
|
WHERE approver_id = :approverId
|
||||||
|
AND action_date BETWEEN :start AND :end
|
||||||
|
AND status IN ('APPROVED', 'REJECTED')
|
||||||
|
AND elapsed_hours IS NOT NULL
|
||||||
|
AND elapsed_hours > 0
|
||||||
|
AND (
|
||||||
|
elapsed_hours > tat_hours
|
||||||
|
OR (tat_breached IS NOT NULL AND tat_breached = true)
|
||||||
|
)
|
||||||
|
|
||||||
|
TAT Compliance % = ROUND((Within TAT Count / Total Approved) × 100, 0)
|
||||||
|
If Total Approved = 0, then TAT Compliance = 0%
|
||||||
|
|
||||||
|
Average Response Hours = ROUND(AVG(elapsed_hours), 1)
|
||||||
|
WHERE approver_id = :approverId
|
||||||
|
AND action_date BETWEEN :start AND :end
|
||||||
|
AND status IN ('APPROVED', 'REJECTED')
|
||||||
|
AND elapsed_hours IS NOT NULL
|
||||||
|
AND elapsed_hours > 0
|
||||||
|
|
||||||
|
Pending Count = COUNT(DISTINCT level_id)
|
||||||
|
WHERE approver_id = :approverId
|
||||||
|
AND status IN ('PENDING', 'IN_PROGRESS')
|
||||||
|
AND request.status IN ('PENDING', 'IN_PROGRESS')
|
||||||
|
AND is_draft = false
|
||||||
|
AND level_number = request.current_level
|
||||||
|
(Only current active level for each request)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Sorting Order:**
|
||||||
|
1. TAT Compliance % (DESC - highest first)
|
||||||
|
2. Average Response Hours (ASC - fastest first)
|
||||||
|
3. Total Approved (DESC - most approvals first)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤖 **10. AI REMARK UTILIZATION (Admin Only)**
|
||||||
|
|
||||||
|
**Scope:** All conclusion remarks generated in date range
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Total Usage = COUNT(*)
|
||||||
|
FROM conclusion_remarks cr
|
||||||
|
WHERE cr.generated_at BETWEEN :start AND :end
|
||||||
|
|
||||||
|
Total Edits = COUNT(*)
|
||||||
|
FROM conclusion_remarks cr
|
||||||
|
WHERE cr.generated_at BETWEEN :start AND :end
|
||||||
|
AND cr.is_edited = true
|
||||||
|
|
||||||
|
Edit Rate % = ROUND((Total Edits / Total Usage) × 100, 0)
|
||||||
|
If Total Usage = 0, then Edit Rate = 0%
|
||||||
|
|
||||||
|
Monthly Trends (Last 7 Months):
|
||||||
|
AI Usage = COUNT(*)
|
||||||
|
WHERE generated_at >= NOW() - INTERVAL '7 months'
|
||||||
|
GROUP BY DATE_TRUNC('month', generated_at)
|
||||||
|
|
||||||
|
Manual Edits = COUNT(*)
|
||||||
|
WHERE generated_at >= NOW() - INTERVAL '7 months'
|
||||||
|
AND is_edited = true
|
||||||
|
GROUP BY DATE_TRUNC('month', generated_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📅 **11. DATE RANGE CALCULATIONS**
|
||||||
|
|
||||||
|
**Date Range Parsing:**
|
||||||
|
```
|
||||||
|
Today:
|
||||||
|
start = start_of_day(today)
|
||||||
|
end = end_of_day(today)
|
||||||
|
|
||||||
|
This Week:
|
||||||
|
start = start_of_week(today)
|
||||||
|
end = end_of_week(today)
|
||||||
|
|
||||||
|
This Month:
|
||||||
|
start = start_of_month(today)
|
||||||
|
end = end_of_month(today)
|
||||||
|
|
||||||
|
This Quarter:
|
||||||
|
start = start_of_quarter(today)
|
||||||
|
end = end_of_quarter(today)
|
||||||
|
|
||||||
|
This Year:
|
||||||
|
start = start_of_year(today)
|
||||||
|
end = end_of_year(today)
|
||||||
|
|
||||||
|
Custom Range:
|
||||||
|
start = start_of_day(custom_start_date)
|
||||||
|
end = end_of_day(custom_end_date)
|
||||||
|
(Capped at current date if future date provided)
|
||||||
|
|
||||||
|
Default (if not specified):
|
||||||
|
start = 30 days ago (start of day)
|
||||||
|
end = today (end of day)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 **12. CRITICAL ALERTS**
|
||||||
|
|
||||||
|
### **Admin/Management View**
|
||||||
|
**Scope:** All requests with critical TAT status
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Critical Requests = Requests where:
|
||||||
|
- TAT percentage used >= 80% (approaching deadline)
|
||||||
|
- OR TAT percentage used >= 100% (breached)
|
||||||
|
- OR has breach alerts (is_breached = true in tat_alerts)
|
||||||
|
|
||||||
|
Breached Count = COUNT(*)
|
||||||
|
WHERE breachCount > 0
|
||||||
|
(From critical requests)
|
||||||
|
|
||||||
|
Warning Count = COUNT(*)
|
||||||
|
WHERE breachCount = 0
|
||||||
|
AND TAT percentage >= 80%
|
||||||
|
(From critical requests)
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Regular User View**
|
||||||
|
**Scope:** Only requests initiated by the user
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Critical Requests = Same logic as Admin, filtered by initiator_id = :userId
|
||||||
|
|
||||||
|
Breached Count = Same calculation as Admin (filtered by user)
|
||||||
|
Warning Count = Same calculation as Admin (filtered by user)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **13. UPCOMING DEADLINES**
|
||||||
|
|
||||||
|
**Scope:** Requests with active levels approaching TAT deadline
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Upcoming Deadlines = Requests where:
|
||||||
|
- Current level is active (status IN ('PENDING', 'IN_PROGRESS'))
|
||||||
|
- remainingHours > 0 (not yet breached)
|
||||||
|
- tatPercentageUsed < 100 (not yet breached)
|
||||||
|
|
||||||
|
TAT Percentage Used = ROUND((elapsedHours / tatHours) × 100, 0)
|
||||||
|
Where elapsedHours = calculateElapsedWorkingHours(level_start_time, current_time, priority)
|
||||||
|
|
||||||
|
Remaining Hours = MAX(0, tatHours - elapsedHours)
|
||||||
|
|
||||||
|
Elapsed Hours = calculateElapsedWorkingHours(level_start_time, current_time, priority)
|
||||||
|
(Respects working hours, weekends, holidays based on priority)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** Only shows requests that are NOT yet breached (remainingHours > 0 and tatPercentage < 100).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📝 **14. RECENT ACTIVITY**
|
||||||
|
|
||||||
|
### **Admin/Management View**
|
||||||
|
**Scope:** All workflow activities across organization
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Recent Activity = All activities from workflow_requests
|
||||||
|
ORDER BY activity.created_at DESC
|
||||||
|
(No user filter)
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Regular User View**
|
||||||
|
**Scope:** Activities from user's requests or where user is a participant
|
||||||
|
|
||||||
|
**Formulas:**
|
||||||
|
```
|
||||||
|
Recent Activity = Activities where:
|
||||||
|
- request.initiator_id = :userId
|
||||||
|
- OR user is a participant in the request
|
||||||
|
ORDER BY activity.created_at DESC
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔑 **KEY DIFFERENCES: ADMIN vs REGULAR USER**
|
||||||
|
|
||||||
|
| Metric | Admin/Management | Regular User |
|
||||||
|
|--------|------------------|--------------|
|
||||||
|
| **Request Volume** | All organization requests | Only user-initiated requests |
|
||||||
|
| **TAT Efficiency** | All completed requests | Only user-initiated completed requests |
|
||||||
|
| **Approver Load** | N/A | User's own approval workload |
|
||||||
|
| **Engagement** | All work notes/documents | Only from user's requests |
|
||||||
|
| **AI Insights** | All approved requests | Only user's approved requests |
|
||||||
|
| **Department Stats** | ✅ Available | ❌ Not available |
|
||||||
|
| **Priority Distribution** | ✅ Available | ❌ Not available |
|
||||||
|
| **Approver Performance** | ✅ Available | ❌ Not available |
|
||||||
|
| **AI Remark Utilization** | ✅ Available | ❌ Not available |
|
||||||
|
| **Success Rate** | ❌ Not shown | ✅ Available |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📌 **IMPORTANT NOTES**
|
||||||
|
|
||||||
|
1. **Date Filtering:**
|
||||||
|
- Most metrics use `submission_date` (when request was submitted), not `created_at`
|
||||||
|
- Completed requests use `closure_date` or `updated_at` for completion date
|
||||||
|
- Pending requests are counted regardless of creation date
|
||||||
|
|
||||||
|
2. **Working Hours Calculation:**
|
||||||
|
- Cycle time uses `calculateElapsedWorkingHours()` which respects:
|
||||||
|
- Working hours (9 AM - 6 PM)
|
||||||
|
- Weekends (for STANDARD priority)
|
||||||
|
- Holidays (configured in system)
|
||||||
|
- Priority type (EXPRESS vs STANDARD)
|
||||||
|
|
||||||
|
3. **Breach Tracking:**
|
||||||
|
- Breaches are tracked at **approver/level level** (each level has its own TAT)
|
||||||
|
- But SLA compliance counts at **request level** (if any level breaches, entire request is non-compliant)
|
||||||
|
|
||||||
|
4. **Rounding:**
|
||||||
|
- Percentages: Rounded to nearest integer (0 decimal places)
|
||||||
|
- Hours: Rounded to 1 decimal place
|
||||||
|
- Days: Rounded to 1 decimal place
|
||||||
|
|
||||||
|
5. **Null Handling:**
|
||||||
|
- All COUNT operations handle NULL values
|
||||||
|
- Division operations use NULLIF to prevent division by zero
|
||||||
|
- Default values are 0 if no data exists
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 **DATA REFRESH**
|
||||||
|
|
||||||
|
All calculations are performed in real-time when:
|
||||||
|
- Dashboard is loaded
|
||||||
|
- Date range filter is changed
|
||||||
|
- Refresh button is clicked
|
||||||
|
- Custom date range is applied
|
||||||
|
|
||||||
|
Data is fetched in parallel for optimal performance.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Last Updated:** Based on codebase as of current date
|
||||||
|
**Version:** 1.0
|
||||||
|
|
||||||
@ -1,216 +0,0 @@
|
|||||||
# Deployment Configuration Guide
|
|
||||||
|
|
||||||
## Issue: Token Exchange Failing in Production
|
|
||||||
|
|
||||||
### Problem Description
|
|
||||||
The OAuth token exchange is working locally but failing in production. This happens because the frontend doesn't know where the backend is deployed.
|
|
||||||
|
|
||||||
### Root Cause
|
|
||||||
In `src/services/authApi.ts`:
|
|
||||||
```typescript
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:5000/api/v1';
|
|
||||||
```
|
|
||||||
|
|
||||||
When `VITE_API_BASE_URL` is not set in production, it defaults to `localhost`, causing the deployed frontend to try calling a non-existent local backend.
|
|
||||||
|
|
||||||
### Solution: Configure Environment Variables
|
|
||||||
|
|
||||||
## 1. Create Environment Files
|
|
||||||
|
|
||||||
Create these files in the `Re_Figma_Code` directory:
|
|
||||||
|
|
||||||
### `.env.example` (template)
|
|
||||||
```env
|
|
||||||
# API Configuration
|
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api/v1
|
|
||||||
VITE_BASE_URL=http://localhost:5000
|
|
||||||
```
|
|
||||||
|
|
||||||
### `.env.local` (local development)
|
|
||||||
```env
|
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api/v1
|
|
||||||
VITE_BASE_URL=http://localhost:5000
|
|
||||||
```
|
|
||||||
|
|
||||||
### `.env.production` (production)
|
|
||||||
```env
|
|
||||||
# Replace with your actual backend URL
|
|
||||||
VITE_API_BASE_URL=https://your-backend-domain.com/api/v1
|
|
||||||
VITE_BASE_URL=https://your-backend-domain.com
|
|
||||||
```
|
|
||||||
|
|
||||||
## 2. Platform-Specific Configuration
|
|
||||||
|
|
||||||
### If deploying on **Vercel**:
|
|
||||||
1. Go to your project settings
|
|
||||||
2. Navigate to "Environment Variables"
|
|
||||||
3. Add:
|
|
||||||
- `VITE_API_BASE_URL` = `https://your-backend-url.com/api/v1`
|
|
||||||
- `VITE_BASE_URL` = `https://your-backend-url.com`
|
|
||||||
4. Select "Production" environment
|
|
||||||
5. Redeploy
|
|
||||||
|
|
||||||
### If deploying on **Netlify**:
|
|
||||||
1. Go to Site settings → Environment variables
|
|
||||||
2. Add:
|
|
||||||
- `VITE_API_BASE_URL` = `https://your-backend-url.com/api/v1`
|
|
||||||
- `VITE_BASE_URL` = `https://your-backend-url.com`
|
|
||||||
3. Redeploy the site
|
|
||||||
|
|
||||||
### If deploying with **Docker**:
|
|
||||||
Add to your `docker-compose.yml` or Dockerfile:
|
|
||||||
```yaml
|
|
||||||
environment:
|
|
||||||
- VITE_API_BASE_URL=https://your-backend-url.com/api/v1
|
|
||||||
- VITE_BASE_URL=https://your-backend-url.com
|
|
||||||
```
|
|
||||||
|
|
||||||
### If using **CI/CD** (GitHub Actions, etc.):
|
|
||||||
Add to your build secrets/variables and pass them during build:
|
|
||||||
```bash
|
|
||||||
VITE_API_BASE_URL=https://your-backend-url.com/api/v1 npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
## 3. Update .gitignore
|
|
||||||
|
|
||||||
Ensure `.env.local` and `.env.production` are in `.gitignore`:
|
|
||||||
```
|
|
||||||
# Environment files
|
|
||||||
.env.local
|
|
||||||
.env.production
|
|
||||||
.env*.local
|
|
||||||
```
|
|
||||||
|
|
||||||
Keep `.env.example` committed for reference.
|
|
||||||
|
|
||||||
## 4. OAuth Flow Validation
|
|
||||||
|
|
||||||
After configuring, verify the flow works:
|
|
||||||
|
|
||||||
### Local Flow:
|
|
||||||
1. Frontend: `http://localhost:3000`
|
|
||||||
2. Backend: `http://localhost:5000`
|
|
||||||
3. Okta redirects to: `http://localhost:3000/login/callback?code=...`
|
|
||||||
4. Frontend calls: `http://localhost:5000/api/v1/auth/token-exchange`
|
|
||||||
5. Backend exchanges code with Okta using `redirect_uri=http://localhost:3000/login/callback`
|
|
||||||
|
|
||||||
### Production Flow:
|
|
||||||
1. Frontend: `https://your-frontend.com`
|
|
||||||
2. Backend: `https://your-backend.com`
|
|
||||||
3. Okta redirects to: `https://your-frontend.com/login/callback?code=...`
|
|
||||||
4. Frontend calls: `https://your-backend.com/api/v1/auth/token-exchange`
|
|
||||||
5. Backend exchanges code with Okta using `redirect_uri=https://your-frontend.com/login/callback`
|
|
||||||
|
|
||||||
## 5. Update Okta Configuration
|
|
||||||
|
|
||||||
Ensure your Okta app has the production callback URL registered:
|
|
||||||
|
|
||||||
1. Log in to Okta Admin Console
|
|
||||||
2. Go to Applications → Your App → General Settings
|
|
||||||
3. Under "Sign-in redirect URIs", add:
|
|
||||||
- `http://localhost:3000/login/callback` (for local dev)
|
|
||||||
- `https://your-frontend-domain.com/login/callback` (for production)
|
|
||||||
4. Under "Sign-out redirect URIs", add:
|
|
||||||
- `http://localhost:3000` (for local dev)
|
|
||||||
- `https://your-frontend-domain.com` (for production)
|
|
||||||
5. Save changes
|
|
||||||
|
|
||||||
## 6. CORS Configuration
|
|
||||||
|
|
||||||
Ensure your backend allows requests from your production frontend:
|
|
||||||
|
|
||||||
In `Re_Backend/src/app.ts` or `server.ts`, update CORS:
|
|
||||||
```typescript
|
|
||||||
app.use(cors({
|
|
||||||
origin: [
|
|
||||||
'http://localhost:3000',
|
|
||||||
'http://localhost:5173',
|
|
||||||
'https://your-frontend-domain.com' // Add your production frontend URL
|
|
||||||
],
|
|
||||||
credentials: true
|
|
||||||
}));
|
|
||||||
```
|
|
||||||
|
|
||||||
## 7. Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Environment variables are set in deployment platform
|
|
||||||
- [ ] Backend URL is reachable from frontend
|
|
||||||
- [ ] Okta callback URLs include production URLs
|
|
||||||
- [ ] CORS allows production frontend origin
|
|
||||||
- [ ] Backend is deployed and running
|
|
||||||
- [ ] Try login flow in production
|
|
||||||
- [ ] Check browser console for API call URLs
|
|
||||||
- [ ] Verify token exchange endpoint is being called correctly
|
|
||||||
|
|
||||||
## 8. Debugging
|
|
||||||
|
|
||||||
If still failing, check:
|
|
||||||
|
|
||||||
### Frontend Console:
|
|
||||||
```javascript
|
|
||||||
// Should show your production backend URL
|
|
||||||
console.log('API_BASE_URL:', import.meta.env.VITE_API_BASE_URL);
|
|
||||||
```
|
|
||||||
|
|
||||||
### Network Tab:
|
|
||||||
- Look for the `/auth/token-exchange` request
|
|
||||||
- Verify it's calling your production backend, not localhost
|
|
||||||
- Check response status and error messages
|
|
||||||
|
|
||||||
### Backend Logs:
|
|
||||||
- Check if token exchange request is reaching the backend
|
|
||||||
- Look for Okta API errors
|
|
||||||
- Verify redirect_uri matches what Okta expects
|
|
||||||
|
|
||||||
## Quick Fix Commands
|
|
||||||
|
|
||||||
### 1. Create environment files:
|
|
||||||
```bash
|
|
||||||
cd Re_Figma_Code
|
|
||||||
|
|
||||||
# Create .env.local
|
|
||||||
echo "VITE_API_BASE_URL=http://localhost:5000/api/v1" > .env.local
|
|
||||||
echo "VITE_BASE_URL=http://localhost:5000" >> .env.local
|
|
||||||
|
|
||||||
# Create .env.production (update URLs!)
|
|
||||||
echo "VITE_API_BASE_URL=https://your-backend-url.com/api/v1" > .env.production
|
|
||||||
echo "VITE_BASE_URL=https://your-backend-url.com" >> .env.production
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Test locally:
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Build for production:
|
|
||||||
```bash
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
The build process will use `.env.production` values.
|
|
||||||
|
|
||||||
## Common Issues
|
|
||||||
|
|
||||||
### Issue 1: "Network Error" or "Failed to fetch"
|
|
||||||
**Cause**: CORS not configured or backend URL wrong
|
|
||||||
**Fix**: Check CORS settings and verify backend URL
|
|
||||||
|
|
||||||
### Issue 2: "Invalid redirect_uri"
|
|
||||||
**Cause**: Okta doesn't have production callback URL
|
|
||||||
**Fix**: Add production URL to Okta app settings
|
|
||||||
|
|
||||||
### Issue 3: Still calling localhost in production
|
|
||||||
**Cause**: Environment variable not loaded during build
|
|
||||||
**Fix**: Ensure variables are set BEFORE building, not at runtime
|
|
||||||
|
|
||||||
### Issue 4: 404 on /api/v1/auth/token-exchange
|
|
||||||
**Cause**: Wrong backend URL or backend not deployed
|
|
||||||
**Fix**: Verify backend is running and URL is correct
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Vite environment variables MUST start with `VITE_` to be exposed to client
|
|
||||||
- Environment variables are embedded at **build time**, not runtime
|
|
||||||
- Changing env vars requires rebuilding the frontend
|
|
||||||
- Never commit `.env.local` or `.env.production` with real URLs to git
|
|
||||||
|
|
||||||
258
DetailedReports_Analysis.md
Normal file
258
DetailedReports_Analysis.md
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
# Detailed Reports Page - Data Availability Analysis
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document analyzes what data is currently available in the backend and what information is missing for implementing the DetailedReports page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Request Lifecycle Report
|
||||||
|
|
||||||
|
### ✅ **Available Data:**
|
||||||
|
- **Request Basic Info:**
|
||||||
|
- `requestNumber` (RE-REQ-2024-XXX)
|
||||||
|
- `title`
|
||||||
|
- `priority` (STANDARD/EXPRESS)
|
||||||
|
- `status` (DRAFT, PENDING, IN_PROGRESS, APPROVED, REJECTED, CLOSED)
|
||||||
|
- `initiatorId` → Can get initiator name via User model
|
||||||
|
- `submissionDate`
|
||||||
|
- `closureDate`
|
||||||
|
- `createdAt`
|
||||||
|
|
||||||
|
- **Current Stage Info:**
|
||||||
|
- `currentLevel` (1-N)
|
||||||
|
- `totalLevels`
|
||||||
|
- Can get current approver from `approval_levels` table
|
||||||
|
|
||||||
|
- **TAT Information:**
|
||||||
|
- `totalTatHours` (cumulative TAT)
|
||||||
|
- Can calculate overall TAT from `submissionDate` to `closureDate` or `updatedAt`
|
||||||
|
- Can get level-wise TAT from `approval_levels.tat_hours`
|
||||||
|
- Can get TAT compliance from `tat_alerts` table
|
||||||
|
|
||||||
|
- **From Existing Services:**
|
||||||
|
- `getCriticalRequests()` - Returns requests with breach info
|
||||||
|
- `getUpcomingDeadlines()` - Returns active level info
|
||||||
|
- `getRecentActivity()` - Returns activity feed
|
||||||
|
|
||||||
|
### ❌ **Missing Data:**
|
||||||
|
1. **Current Stage Name/Description:**
|
||||||
|
- Need to join with `approval_levels` to get `level_name` for current level
|
||||||
|
- Currently only have `currentLevel` number
|
||||||
|
|
||||||
|
2. **Overall TAT Calculation:**
|
||||||
|
- Need API endpoint that calculates total time from submission to current/closure
|
||||||
|
- Currently have `totalTatHours` but need actual elapsed time
|
||||||
|
|
||||||
|
3. **TAT Compliance Status:**
|
||||||
|
- Need to determine if "On Time" or "Delayed" based on TAT vs actual time
|
||||||
|
- Can calculate from `tat_alerts.is_breached` but need endpoint
|
||||||
|
|
||||||
|
4. **Timeline/History:**
|
||||||
|
- Need endpoint to get all approval levels with their start/end times
|
||||||
|
- Need to show progression through levels
|
||||||
|
|
||||||
|
### 🔧 **What Needs to be Built:**
|
||||||
|
- **New API Endpoint:** `/dashboard/reports/lifecycle`
|
||||||
|
- Returns requests with:
|
||||||
|
- Full lifecycle timeline (all levels with dates)
|
||||||
|
- Overall TAT calculation
|
||||||
|
- TAT compliance status (On Time/Delayed)
|
||||||
|
- Current stage name
|
||||||
|
- All approvers in sequence
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. User Activity Log Report
|
||||||
|
|
||||||
|
### ✅ **Available Data:**
|
||||||
|
- **Activity Model Fields:**
|
||||||
|
- `activityId`
|
||||||
|
- `requestId`
|
||||||
|
- `userId` → Can get user name from User model
|
||||||
|
- `userName` (stored directly)
|
||||||
|
- `activityType` (created, assignment, approval, rejection, etc.)
|
||||||
|
- `activityDescription` (details of action)
|
||||||
|
- `ipAddress` (available in model, but may not be logged)
|
||||||
|
- `createdAt` (timestamp)
|
||||||
|
- `metadata` (JSONB - can store additional info)
|
||||||
|
|
||||||
|
- **From Existing Services:**
|
||||||
|
- `getRecentActivity()` - Already returns activity feed with pagination
|
||||||
|
- Returns: `activityId`, `requestId`, `requestNumber`, `requestTitle`, `type`, `action`, `details`, `userId`, `userName`, `timestamp`, `priority`
|
||||||
|
|
||||||
|
### ❌ **Missing Data:**
|
||||||
|
1. **IP Address:**
|
||||||
|
- Field exists in model but may not be populated
|
||||||
|
- Need to ensure IP is captured when logging activities
|
||||||
|
|
||||||
|
2. **User Agent/Device Info:**
|
||||||
|
- Field exists (`userAgent`) but may not be populated
|
||||||
|
- Need to capture browser/device info
|
||||||
|
|
||||||
|
3. **Login Activities:**
|
||||||
|
- Current activity model is request-focused
|
||||||
|
- Need separate user session/login tracking
|
||||||
|
- Can check `users.last_login` but need detailed login history
|
||||||
|
|
||||||
|
4. **Action Categorization:**
|
||||||
|
- Need to map `activityType` to display labels:
|
||||||
|
- "created" → "Created Request"
|
||||||
|
- "approval" → "Approved Request"
|
||||||
|
- "rejection" → "Rejected Request"
|
||||||
|
- "comment" → "Added Comment"
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
5. **Request ID Display:**
|
||||||
|
- Need to show request number when available
|
||||||
|
- Currently `getRecentActivity()` returns `requestNumber` ✅
|
||||||
|
|
||||||
|
### 🔧 **What Needs to be Built:**
|
||||||
|
- **Enhance Activity Logging:**
|
||||||
|
- Capture IP address in activity service
|
||||||
|
- Capture user agent in activity service
|
||||||
|
- Add login activity tracking (separate from request activities)
|
||||||
|
|
||||||
|
- **New/Enhanced API Endpoint:** `/dashboard/reports/activity-log`
|
||||||
|
- Filter by date range
|
||||||
|
- Filter by user
|
||||||
|
- Filter by action type
|
||||||
|
- Include IP address and user agent
|
||||||
|
- Better categorization of actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Workflow Aging Report
|
||||||
|
|
||||||
|
### ✅ **Available Data:**
|
||||||
|
- **Request Basic Info:**
|
||||||
|
- `requestNumber`
|
||||||
|
- `title`
|
||||||
|
- `initiatorId` → Can get initiator name
|
||||||
|
- `priority`
|
||||||
|
- `status`
|
||||||
|
- `createdAt` (can calculate days open)
|
||||||
|
- `submissionDate`
|
||||||
|
|
||||||
|
- **Current Stage Info:**
|
||||||
|
- `currentLevel`
|
||||||
|
- `totalLevels`
|
||||||
|
- Can get current approver from `approval_levels`
|
||||||
|
|
||||||
|
- **From Existing Services:**
|
||||||
|
- `getUpcomingDeadlines()` - Returns active requests with TAT info
|
||||||
|
- Can filter by days open using `createdAt` or `submissionDate`
|
||||||
|
|
||||||
|
### ❌ **Missing Data:**
|
||||||
|
1. **Days Open Calculation:**
|
||||||
|
- Need to calculate from `submissionDate` (not `createdAt`)
|
||||||
|
- Need to exclude weekends/holidays for accurate business days
|
||||||
|
|
||||||
|
2. **Start Date:**
|
||||||
|
- Should use `submissionDate` (when request was submitted, not created)
|
||||||
|
- Currently have this field ✅
|
||||||
|
|
||||||
|
3. **Assigned To:**
|
||||||
|
- Need current approver from `approval_levels` where `level_number = current_level`
|
||||||
|
- Can get from `approval_levels.approver_name` ✅
|
||||||
|
|
||||||
|
4. **Current Stage Name:**
|
||||||
|
- Need `approval_levels.level_name` for current level
|
||||||
|
- Currently only have level number
|
||||||
|
|
||||||
|
5. **Aging Threshold Filtering:**
|
||||||
|
- Need to filter requests where days open > threshold
|
||||||
|
- Need to calculate business days (excluding weekends/holidays)
|
||||||
|
|
||||||
|
### 🔧 **What Needs to be Built:**
|
||||||
|
- **New API Endpoint:** `/dashboard/reports/workflow-aging`
|
||||||
|
- Parameters:
|
||||||
|
- `threshold` (days)
|
||||||
|
- `dateRange` (optional)
|
||||||
|
- `page`, `limit` (pagination)
|
||||||
|
- Returns:
|
||||||
|
- Requests with days open > threshold
|
||||||
|
- Business days calculation
|
||||||
|
- Current stage name
|
||||||
|
- Current approver
|
||||||
|
- Days open (business days)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
### ✅ **Can Show Immediately:**
|
||||||
|
1. **Request Lifecycle Report (Partial):**
|
||||||
|
- Request ID, Title, Priority, Status
|
||||||
|
- Initiator name
|
||||||
|
- Submission date
|
||||||
|
- Current level number
|
||||||
|
- Basic TAT info
|
||||||
|
|
||||||
|
2. **User Activity Log (Partial):**
|
||||||
|
- Timestamp, User, Action, Details
|
||||||
|
- Request ID (when applicable)
|
||||||
|
- Using existing `getRecentActivity()` service
|
||||||
|
|
||||||
|
3. **Workflow Aging (Partial):**
|
||||||
|
- Request ID, Title, Initiator
|
||||||
|
- Days open (calendar days)
|
||||||
|
- Priority, Status
|
||||||
|
- Current approver (with join)
|
||||||
|
|
||||||
|
### ❌ **Missing/Incomplete:**
|
||||||
|
1. **Request Lifecycle:**
|
||||||
|
- Full timeline/history of all levels
|
||||||
|
- Current stage name (not just number)
|
||||||
|
- Overall TAT calculation
|
||||||
|
- TAT compliance status (On Time/Delayed)
|
||||||
|
|
||||||
|
2. **User Activity Log:**
|
||||||
|
- IP Address (field exists but may not be populated)
|
||||||
|
- User Agent (field exists but may not be populated)
|
||||||
|
- Login activities (separate tracking needed)
|
||||||
|
- Better action categorization
|
||||||
|
|
||||||
|
3. **Workflow Aging:**
|
||||||
|
- Business days calculation (excluding weekends/holidays)
|
||||||
|
- Current stage name
|
||||||
|
- Proper threshold filtering
|
||||||
|
|
||||||
|
### 🔧 **Required Backend Work:**
|
||||||
|
1. **New Endpoints:**
|
||||||
|
- `/dashboard/reports/lifecycle` - Full lifecycle with timeline
|
||||||
|
- `/dashboard/reports/activity-log` - Enhanced activity log with filters
|
||||||
|
- `/dashboard/reports/workflow-aging` - Aging report with business days
|
||||||
|
|
||||||
|
2. **Enhancements:**
|
||||||
|
- Capture IP address in activity logging
|
||||||
|
- Capture user agent in activity logging
|
||||||
|
- Add login activity tracking
|
||||||
|
- Add business days calculation utility
|
||||||
|
- Add level name to approval levels response
|
||||||
|
|
||||||
|
3. **Data Joins:**
|
||||||
|
- Join `approval_levels` to get current stage name
|
||||||
|
- Join `users` to get approver names
|
||||||
|
- Join `tat_alerts` to get breach/compliance info
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### Phase 1 (Quick Win - Use Existing Data):
|
||||||
|
- Implement basic reports using existing services
|
||||||
|
- Show available data (request info, basic activity, calendar days)
|
||||||
|
- Add placeholders for missing data
|
||||||
|
|
||||||
|
### Phase 2 (Backend Development):
|
||||||
|
- Build new report endpoints
|
||||||
|
- Enhance activity logging to capture IP/user agent
|
||||||
|
- Add business days calculation
|
||||||
|
- Add level name to responses
|
||||||
|
|
||||||
|
### Phase 3 (Full Implementation):
|
||||||
|
- Complete all three reports with full data
|
||||||
|
- Add filtering, sorting, export functionality
|
||||||
|
- Add date range filters
|
||||||
|
- Add user/role-based filtering
|
||||||
|
|
||||||
188
ERROR_FIX.md
188
ERROR_FIX.md
@ -1,188 +0,0 @@
|
|||||||
# Error Fix: "Objects are not valid as a React child"
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
When creating a custom request through the NewRequestWizard, the application threw an error:
|
|
||||||
```
|
|
||||||
Error: Objects are not valid as a React child (found: object with keys {email, name, level, tat, tatType})
|
|
||||||
```
|
|
||||||
|
|
||||||
## Root Cause
|
|
||||||
The NewRequestWizard stores approvers, spectators, ccList, and invitedUsers as arrays of objects with the structure:
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
email: string,
|
|
||||||
name: string,
|
|
||||||
level: number,
|
|
||||||
tat: number,
|
|
||||||
tatType: 'hours' | 'days'
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
When these objects were passed to `handleNewRequestSubmit` in App.tsx, they were being mapped to the request structure, but the mapping wasn't properly extracting the string values from the objects. Instead, entire objects were being assigned to fields that should contain strings.
|
|
||||||
|
|
||||||
## Solution
|
|
||||||
|
|
||||||
### 1. Enhanced Approver Mapping
|
|
||||||
Updated the `approvalFlow` mapping in `handleNewRequestSubmit` to properly extract values:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
approvalFlow: (requestData.approvers || [])
|
|
||||||
.filter((a: any) => a) // Filter out null/undefined
|
|
||||||
.map((approver: any, index: number) => {
|
|
||||||
// Extract name from email if name is not available
|
|
||||||
const approverName = approver?.name || approver?.email?.split('@')[0] || `Approver ${index + 1}`;
|
|
||||||
const approverEmail = approver?.email || '';
|
|
||||||
|
|
||||||
return {
|
|
||||||
step: index + 1,
|
|
||||||
approver: `${approverName}${approverEmail ? ` (${approverEmail})` : ''}`, // STRING, not object
|
|
||||||
role: approver?.role || `Level ${approver?.level || index + 1} Approver`,
|
|
||||||
status: index === 0 ? 'pending' : 'waiting',
|
|
||||||
tatHours: approver?.tat ? (typeof approver.tat === 'string' ? parseInt(approver.tat) : approver.tat) : 48,
|
|
||||||
elapsedHours: index === 0 ? 0 : 0,
|
|
||||||
assignedAt: index === 0 ? new Date().toISOString() : null,
|
|
||||||
comment: null,
|
|
||||||
timestamp: null
|
|
||||||
};
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key changes:**
|
|
||||||
- Added `.filter((a: any) => a)` to remove null/undefined entries
|
|
||||||
- Properly extract `approverName` from `name` or `email`
|
|
||||||
- Build a display string combining name and email
|
|
||||||
- Convert TAT to number (handles both string and number inputs)
|
|
||||||
|
|
||||||
### 2. Enhanced Spectator Mapping
|
|
||||||
Updated spectators mapping to properly extract values:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
spectators: (requestData.spectators || [])
|
|
||||||
.filter((s: any) => s && (s.name || s.email)) // Filter invalid entries
|
|
||||||
.map((spectator: any) => {
|
|
||||||
const name = spectator?.name || spectator?.email?.split('@')[0] || 'Observer';
|
|
||||||
return {
|
|
||||||
name: name, // STRING, not object
|
|
||||||
role: spectator?.role || spectator?.department || 'Observer',
|
|
||||||
avatar: name.split(' ').map((n: string) => n[0]).join('').toUpperCase().slice(0, 2) || 'OB'
|
|
||||||
};
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key changes:**
|
|
||||||
- Filter out entries without name or email
|
|
||||||
- Extract name from email if needed
|
|
||||||
- Safe avatar generation with fallback
|
|
||||||
|
|
||||||
### 3. Added Missing Fields
|
|
||||||
Added fields required by MyRequests component:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
currentApprover: requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email?.split('@')[0] || 'Pending Assignment',
|
|
||||||
approverLevel: `1 of ${requestData.approvers?.length || 1}`,
|
|
||||||
submittedDate: new Date().toISOString(),
|
|
||||||
estimatedCompletion: new Date(Date.now() + 5 * 24 * 60 * 60 * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. Fixed Audit Trail Message
|
|
||||||
Updated audit trail to safely extract approver name:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
details: `Request assigned to ${requestData.approvers?.[0]?.name || requestData.approvers?.[0]?.email || 'first approver'}`
|
|
||||||
```
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
✅ **Create Custom Request**
|
|
||||||
- [ ] Fill out NewRequestWizard with title, description
|
|
||||||
- [ ] Add 2-3 approvers with emails
|
|
||||||
- [ ] Add spectators (optional)
|
|
||||||
- [ ] Submit request
|
|
||||||
|
|
||||||
✅ **Verify No Errors**
|
|
||||||
- [ ] No console errors about "Objects are not valid as React child"
|
|
||||||
- [ ] Request appears in My Requests
|
|
||||||
- [ ] Can click on request
|
|
||||||
|
|
||||||
✅ **Verify Detail Page**
|
|
||||||
- [ ] Request detail page loads
|
|
||||||
- [ ] Approver names display correctly (not [object Object])
|
|
||||||
- [ ] Workflow tab shows all approvers
|
|
||||||
- [ ] Spectators display correctly (if added)
|
|
||||||
|
|
||||||
✅ **Verify My Requests Display**
|
|
||||||
- [ ] Request shows in list
|
|
||||||
- [ ] Current approver displays as string
|
|
||||||
- [ ] Approver level shows correctly (e.g., "1 of 3")
|
|
||||||
|
|
||||||
## Common Patterns to Avoid
|
|
||||||
|
|
||||||
### ❌ Bad: Rendering Objects Directly
|
|
||||||
```typescript
|
|
||||||
<span>{approver}</span> // If approver is {email: "...", name: "..."}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Good: Extract String First
|
|
||||||
```typescript
|
|
||||||
<span>{approver.name || approver.email}</span>
|
|
||||||
```
|
|
||||||
|
|
||||||
### ❌ Bad: Assigning Object to String Field
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
approver: approverObject // {email: "...", name: "..."}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Good: Extract String Value
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
approver: approverObject.name || approverObject.email
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Related Files Modified
|
|
||||||
|
|
||||||
1. **App.tsx** - `handleNewRequestSubmit` function
|
|
||||||
- Enhanced approver mapping
|
|
||||||
- Enhanced spectator mapping
|
|
||||||
- Added missing fields for MyRequests compatibility
|
|
||||||
- Fixed audit trail messages
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
NewRequestWizard (formData)
|
|
||||||
├── approvers: [{email, name, level, tat, tatType}, ...]
|
|
||||||
├── spectators: [{email, name, role, department}, ...]
|
|
||||||
└── ...other fields
|
|
||||||
↓
|
|
||||||
handleNewRequestSubmit (App.tsx)
|
|
||||||
├── Maps approvers → approvalFlow with STRING values
|
|
||||||
├── Maps spectators → spectators with STRING values
|
|
||||||
└── Creates complete request object
|
|
||||||
↓
|
|
||||||
dynamicRequests state (App.tsx)
|
|
||||||
├── Stored in memory
|
|
||||||
└── Passed to components
|
|
||||||
↓
|
|
||||||
RequestDetail / MyRequests
|
|
||||||
├── Receives proper data structure
|
|
||||||
└── Renders strings (no object errors)
|
|
||||||
```
|
|
||||||
|
|
||||||
## Prevention Tips
|
|
||||||
|
|
||||||
1. **Always validate data types** when mapping from wizard to database
|
|
||||||
2. **Extract primitive values** from objects before assigning to display fields
|
|
||||||
3. **Add TypeScript interfaces** to catch type mismatches early
|
|
||||||
4. **Test with console.log** before rendering to verify data structure
|
|
||||||
5. **Use optional chaining** (`?.`) to safely access nested properties
|
|
||||||
|
|
||||||
## Future Improvements
|
|
||||||
|
|
||||||
1. Add TypeScript interfaces for wizard form data
|
|
||||||
2. Add TypeScript interfaces for request objects
|
|
||||||
3. Create validation functions for data transformation
|
|
||||||
4. Add unit tests for data mapping functions
|
|
||||||
5. Create reusable mapping utilities
|
|
||||||
@ -1,306 +0,0 @@
|
|||||||
# 🔄 Migration Guide - Project Setup Complete
|
|
||||||
|
|
||||||
## ✅ What Has Been Created
|
|
||||||
|
|
||||||
### Configuration Files ✓
|
|
||||||
- ✅ `package.json` - Dependencies and scripts
|
|
||||||
- ✅ `tsconfig.json` - TypeScript configuration
|
|
||||||
- ✅ `tsconfig.node.json` - Node TypeScript config
|
|
||||||
- ✅ `vite.config.ts` - Vite build configuration
|
|
||||||
- ✅ `tailwind.config.ts` - Tailwind CSS configuration
|
|
||||||
- ✅ `postcss.config.js` - PostCSS configuration
|
|
||||||
- ✅ `eslint.config.js` - ESLint configuration
|
|
||||||
- ✅ `.prettierrc` - Prettier configuration
|
|
||||||
- ✅ `.gitignore` - Git ignore rules
|
|
||||||
- ✅ `.env.example` - Environment variables template
|
|
||||||
- ✅ `index.html` - HTML entry point
|
|
||||||
|
|
||||||
### VS Code Configuration ✓
|
|
||||||
- ✅ `.vscode/settings.json` - Editor settings
|
|
||||||
- ✅ `.vscode/extensions.json` - Recommended extensions
|
|
||||||
|
|
||||||
### Documentation ✓
|
|
||||||
- ✅ `README.md` - Comprehensive project documentation
|
|
||||||
- ✅ `MIGRATION_GUIDE.md` - This file
|
|
||||||
|
|
||||||
### Source Files Created ✓
|
|
||||||
- ✅ `src/main.tsx` - Application entry point
|
|
||||||
- ✅ `src/vite-env.d.ts` - Vite environment types
|
|
||||||
- ✅ `src/types/index.ts` - TypeScript type definitions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Next Steps - File Migration
|
|
||||||
|
|
||||||
### Step 1: Install Dependencies
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
npm install
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
This will install all required packages (~5 minutes).
|
|
||||||
|
|
||||||
### Step 2: Migrate Files to src Directory
|
|
||||||
|
|
||||||
You need to manually move the existing files to the `src` directory:
|
|
||||||
|
|
||||||
#### A. Move App.tsx
|
|
||||||
\`\`\`bash
|
|
||||||
# Windows Command Prompt
|
|
||||||
move App.tsx src\\App.tsx
|
|
||||||
|
|
||||||
# Or manually drag and drop in VS Code
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
#### B. Move Components Directory
|
|
||||||
\`\`\`bash
|
|
||||||
# Windows Command Prompt
|
|
||||||
move components src\\components
|
|
||||||
|
|
||||||
# Or manually drag and drop in VS Code
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
#### C. Move Utils Directory
|
|
||||||
\`\`\`bash
|
|
||||||
# Windows Command Prompt
|
|
||||||
move utils src\\utils
|
|
||||||
|
|
||||||
# Or manually drag and drop in VS Code
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
#### D. Move Styles Directory
|
|
||||||
\`\`\`bash
|
|
||||||
# Windows Command Prompt
|
|
||||||
move styles src\\styles
|
|
||||||
|
|
||||||
# Or manually drag and drop in VS Code
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Step 3: Update Import Paths
|
|
||||||
|
|
||||||
After moving files, you'll need to update import statements to use path aliases.
|
|
||||||
|
|
||||||
#### Example Changes:
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
\`\`\`typescript
|
|
||||||
import { Layout } from './components/Layout';
|
|
||||||
import { Dashboard } from './components/Dashboard';
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
\`\`\`typescript
|
|
||||||
import { Layout } from '@/components/Layout';
|
|
||||||
import { Dashboard } from '@/components/Dashboard';
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**Files that need updating:**
|
|
||||||
1. `src/App.tsx` - Update all component imports
|
|
||||||
2. All files in `src/components/` - Update relative imports
|
|
||||||
3. All modal files in `src/components/modals/`
|
|
||||||
|
|
||||||
### Step 4: Fix Sonner Import
|
|
||||||
|
|
||||||
In `src/App.tsx`, update the sonner import:
|
|
||||||
|
|
||||||
**Before:**
|
|
||||||
\`\`\`typescript
|
|
||||||
import { toast } from 'sonner@2.0.3';
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
**After:**
|
|
||||||
\`\`\`typescript
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Step 5: Start Development Server
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
npm run dev
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
The app should open at `http://localhost:3000`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔍 Common Issues & Solutions
|
|
||||||
|
|
||||||
### Issue 1: Module not found errors
|
|
||||||
|
|
||||||
**Problem:** TypeScript can't find modules after migration.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Restart VS Code TypeScript server: `Ctrl+Shift+P` → "TypeScript: Restart TS Server"
|
|
||||||
2. Clear node_modules and reinstall:
|
|
||||||
\`\`\`bash
|
|
||||||
rm -rf node_modules package-lock.json
|
|
||||||
npm install
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### Issue 2: Path alias not working
|
|
||||||
|
|
||||||
**Problem:** `@/` imports show errors.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Check `tsconfig.json` paths configuration
|
|
||||||
2. Check `vite.config.ts` resolve.alias configuration
|
|
||||||
3. Restart VS Code
|
|
||||||
|
|
||||||
### Issue 3: Tailwind classes not applying
|
|
||||||
|
|
||||||
**Problem:** Styles not working after migration.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Ensure `globals.css` is imported in `src/main.tsx`
|
|
||||||
2. Check `tailwind.config.ts` content paths
|
|
||||||
3. Restart dev server: `Ctrl+C` then `npm run dev`
|
|
||||||
|
|
||||||
### Issue 4: Build errors
|
|
||||||
|
|
||||||
**Problem:** TypeScript compilation errors.
|
|
||||||
|
|
||||||
**Solution:**
|
|
||||||
1. Run type check: `npm run type-check`
|
|
||||||
2. Fix any TypeScript errors shown
|
|
||||||
3. Run build again: `npm run build`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📋 Migration Checklist
|
|
||||||
|
|
||||||
Use this checklist to track your migration progress:
|
|
||||||
|
|
||||||
### Files Migration
|
|
||||||
- [ ] Installed dependencies (`npm install`)
|
|
||||||
- [ ] Moved `App.tsx` to `src/`
|
|
||||||
- [ ] Moved `components/` to `src/components/`
|
|
||||||
- [ ] Moved `utils/` to `src/utils/`
|
|
||||||
- [ ] Moved `styles/` to `src/styles/`
|
|
||||||
- [ ] Created `src/main.tsx` (already done)
|
|
||||||
|
|
||||||
### Import Updates
|
|
||||||
- [ ] Updated imports in `src/App.tsx`
|
|
||||||
- [ ] Updated imports in `src/components/Layout.tsx`
|
|
||||||
- [ ] Updated imports in `src/components/Dashboard.tsx`
|
|
||||||
- [ ] Updated imports in all other component files
|
|
||||||
- [ ] Fixed `sonner` import in `App.tsx`
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- [ ] Dev server starts successfully (`npm run dev`)
|
|
||||||
- [ ] Application loads at `http://localhost:3000`
|
|
||||||
- [ ] No console errors
|
|
||||||
- [ ] Dashboard displays correctly
|
|
||||||
- [ ] Navigation works
|
|
||||||
- [ ] New request wizard works
|
|
||||||
- [ ] Claim management wizard works
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
- [ ] ESLint passes (`npm run lint`)
|
|
||||||
- [ ] TypeScript compiles (`npm run type-check`)
|
|
||||||
- [ ] Code formatted (`npm run format`)
|
|
||||||
- [ ] Build succeeds (`npm run build`)
|
|
||||||
|
|
||||||
### Environment
|
|
||||||
- [ ] Created `.env` from `.env.example`
|
|
||||||
- [ ] Updated environment variables if needed
|
|
||||||
- [ ] VS Code extensions installed
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 After Migration
|
|
||||||
|
|
||||||
### 1. Clean Up Old Files
|
|
||||||
|
|
||||||
After confirming everything works in `src/`:
|
|
||||||
\`\`\`bash
|
|
||||||
# Delete old documentation files from root (optional)
|
|
||||||
# Keep only if you want them at root level
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### 2. Commit Changes
|
|
||||||
|
|
||||||
\`\`\`bash
|
|
||||||
git add .
|
|
||||||
git commit -m "feat: migrate to standard React project structure with Vite"
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### 3. Update Team
|
|
||||||
|
|
||||||
Inform team members about:
|
|
||||||
- New project structure
|
|
||||||
- Updated npm scripts
|
|
||||||
- Path alias usage (`@/`)
|
|
||||||
- Required VS Code extensions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Additional Resources
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- [Vite Documentation](https://vitejs.dev/)
|
|
||||||
- [React TypeScript Cheatsheet](https://react-typescript-cheatsheet.netlify.app/)
|
|
||||||
- [Tailwind CSS Docs](https://tailwindcss.com/docs)
|
|
||||||
- [shadcn/ui Components](https://ui.shadcn.com/)
|
|
||||||
|
|
||||||
### Scripts Reference
|
|
||||||
\`\`\`bash
|
|
||||||
npm run dev # Start development server
|
|
||||||
npm run build # Build for production
|
|
||||||
npm run preview # Preview production build
|
|
||||||
npm run lint # Check for linting errors
|
|
||||||
npm run lint:fix # Auto-fix linting errors
|
|
||||||
npm run format # Format code with Prettier
|
|
||||||
npm run type-check # Check TypeScript types
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Tips for Development
|
|
||||||
|
|
||||||
### 1. Use Path Aliases
|
|
||||||
\`\`\`typescript
|
|
||||||
// Good ✓
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { getDealerInfo } from '@/utils/dealerDatabase';
|
|
||||||
|
|
||||||
// Avoid ✗
|
|
||||||
import { Button } from '../../../components/ui/button';
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### 2. Type Safety
|
|
||||||
\`\`\`typescript
|
|
||||||
// Import types
|
|
||||||
import type { Request, DealerInfo } from '@/types';
|
|
||||||
|
|
||||||
// Use them in your components
|
|
||||||
const request: Request = { ... };
|
|
||||||
\`\`\`
|
|
||||||
|
|
||||||
### 3. Code Formatting
|
|
||||||
Set up auto-format on save in VS Code (already configured in `.vscode/settings.json`)
|
|
||||||
|
|
||||||
### 4. Commit Conventions
|
|
||||||
Use conventional commits:
|
|
||||||
- `feat:` for new features
|
|
||||||
- `fix:` for bug fixes
|
|
||||||
- `docs:` for documentation
|
|
||||||
- `style:` for formatting changes
|
|
||||||
- `refactor:` for code refactoring
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ❓ Need Help?
|
|
||||||
|
|
||||||
If you encounter issues:
|
|
||||||
1. Check this migration guide
|
|
||||||
2. Check the main README.md
|
|
||||||
3. Review error messages carefully
|
|
||||||
4. Check VS Code Problems panel
|
|
||||||
5. Restart VS Code TypeScript server
|
|
||||||
6. Clear node_modules and reinstall
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Migration prepared by the Development Team**
|
|
||||||
**Date: 2024**
|
|
||||||
|
|
||||||
193
ROLE_MIGRATION.md
Normal file
193
ROLE_MIGRATION.md
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
# Frontend Role Migration - isAdmin → role
|
||||||
|
|
||||||
|
## 🎯 Overview
|
||||||
|
|
||||||
|
Migrated frontend from `isAdmin: boolean` to `role: 'USER' | 'MANAGEMENT' | 'ADMIN'` to match the new backend RBAC system.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ Files Updated
|
||||||
|
|
||||||
|
### 1. **Type Definitions**
|
||||||
|
|
||||||
|
#### `src/contexts/AuthContext.tsx`
|
||||||
|
- ✅ Updated `User` interface: `isAdmin?: boolean` → `role?: 'USER' | 'MANAGEMENT' | 'ADMIN'`
|
||||||
|
- ✅ Added helper functions:
|
||||||
|
- `isAdmin(user)` - Checks if user is ADMIN
|
||||||
|
- `isManagement(user)` - Checks if user is MANAGEMENT
|
||||||
|
- `hasManagementAccess(user)` - Checks if user is MANAGEMENT or ADMIN
|
||||||
|
- `hasAdminAccess(user)` - Checks if user is ADMIN (same as isAdmin)
|
||||||
|
|
||||||
|
#### `src/services/authApi.ts`
|
||||||
|
- ✅ Updated `TokenExchangeResponse` interface: `isAdmin: boolean` → `role: 'USER' | 'MANAGEMENT' | 'ADMIN'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. **Components Updated**
|
||||||
|
|
||||||
|
#### `src/pages/Dashboard/Dashboard.tsx`
|
||||||
|
**Changes:**
|
||||||
|
- ✅ Imported `isAdmin as checkIsAdmin` from AuthContext
|
||||||
|
- ✅ Updated role check: `(user as any)?.isAdmin || false` → `checkIsAdmin(user)`
|
||||||
|
- ✅ All conditional rendering now uses the helper function
|
||||||
|
|
||||||
|
**Admin Features (shown only for ADMIN role):**
|
||||||
|
- Organization-wide analytics
|
||||||
|
- Admin View badge
|
||||||
|
- Export button
|
||||||
|
- Department-wise workflow summary
|
||||||
|
- Priority distribution report
|
||||||
|
- TAT breach report
|
||||||
|
- AI remark utilization report
|
||||||
|
- Approver performance report
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `src/pages/Settings/Settings.tsx`
|
||||||
|
**Changes:**
|
||||||
|
- ✅ Imported `isAdmin as checkIsAdmin` from AuthContext
|
||||||
|
- ✅ Updated role check: `(user as any)?.isAdmin` → `checkIsAdmin(user)`
|
||||||
|
|
||||||
|
**Admin Features:**
|
||||||
|
- Configuration Manager tab
|
||||||
|
- Holiday Manager tab
|
||||||
|
- System Settings tab
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `src/pages/Profile/Profile.tsx`
|
||||||
|
**Changes:**
|
||||||
|
- ✅ Imported `isAdmin` and `isManagement` helpers from AuthContext
|
||||||
|
- ✅ Added `Users` icon import for Management badge
|
||||||
|
- ✅ Updated all `user?.isAdmin` checks to use `isAdmin(user)`
|
||||||
|
- ✅ Added Management badge display for MANAGEMENT role
|
||||||
|
- ✅ Updated role display to show:
|
||||||
|
- **Administrator** badge (yellow) for ADMIN
|
||||||
|
- **Management** badge (blue) for MANAGEMENT
|
||||||
|
- **User** badge (gray) for USER
|
||||||
|
|
||||||
|
**New Visual Indicators:**
|
||||||
|
- 🟡 Yellow shield icon for ADMIN users
|
||||||
|
- 🔵 Blue users icon for MANAGEMENT users
|
||||||
|
- Role badge on profile card
|
||||||
|
- Role badge in header section
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### `src/pages/Auth/AuthenticatedApp.tsx`
|
||||||
|
**Changes:**
|
||||||
|
- ✅ Updated console log: `'Is Admin:', user.isAdmin` → `'Role:', user.role`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 **Visual Changes**
|
||||||
|
|
||||||
|
### Profile Page Badges
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
```
|
||||||
|
🟡 Administrator (only for admins)
|
||||||
|
```
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
```
|
||||||
|
🟡 Administrator (for ADMIN)
|
||||||
|
🔵 Management (for MANAGEMENT)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role Display
|
||||||
|
|
||||||
|
**Before:**
|
||||||
|
- Administrator / User
|
||||||
|
|
||||||
|
**After:**
|
||||||
|
- Administrator (yellow badge, green checkmark)
|
||||||
|
- Management (blue badge, green checkmark)
|
||||||
|
- User (gray badge, no checkmark)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 **Helper Functions Usage**
|
||||||
|
|
||||||
|
### In Components:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAuth, isAdmin, isManagement, hasManagementAccess } from '@/contexts/AuthContext';
|
||||||
|
|
||||||
|
const { user } = useAuth();
|
||||||
|
|
||||||
|
// Check if user is admin
|
||||||
|
if (isAdmin(user)) {
|
||||||
|
// Show admin-only features
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user is management
|
||||||
|
if (isManagement(user)) {
|
||||||
|
// Show management-only features
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if user has management access (MANAGEMENT or ADMIN)
|
||||||
|
if (hasManagementAccess(user)) {
|
||||||
|
// Show features for both management and admin
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 **Migration Benefits**
|
||||||
|
|
||||||
|
1. **Type Safety** - Role is now a union type, catching errors at compile time
|
||||||
|
2. **Flexibility** - Easy to add more roles (e.g., AUDITOR, VIEWER)
|
||||||
|
3. **Granular Access** - Can differentiate between MANAGEMENT and ADMIN
|
||||||
|
4. **Consistency** - Frontend now matches backend RBAC system
|
||||||
|
5. **Helper Functions** - Cleaner code with reusable role checks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 **Access Levels**
|
||||||
|
|
||||||
|
| Feature | USER | MANAGEMENT | ADMIN |
|
||||||
|
|---------|------|------------|-------|
|
||||||
|
| View own requests | ✅ | ✅ | ✅ |
|
||||||
|
| View own dashboard | ✅ | ✅ | ✅ |
|
||||||
|
| View all requests | ❌ | ✅ | ✅ |
|
||||||
|
| View organization-wide analytics | ❌ | ✅ | ✅ |
|
||||||
|
| Export data | ❌ | ❌ | ✅ |
|
||||||
|
| Manage system configuration | ❌ | ❌ | ✅ |
|
||||||
|
| Manage holidays | ❌ | ❌ | ✅ |
|
||||||
|
| View TAT breach reports | ❌ | ❌ | ✅ |
|
||||||
|
| View approver performance | ❌ | ❌ | ✅ |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ **Testing Checklist**
|
||||||
|
|
||||||
|
- [ ] Login as USER - verify limited access
|
||||||
|
- [ ] Login as MANAGEMENT - verify read access to all data
|
||||||
|
- [ ] Login as ADMIN - verify full access
|
||||||
|
- [ ] Profile page shows correct role badge
|
||||||
|
- [ ] Dashboard shows appropriate views per role
|
||||||
|
- [ ] Settings page shows tabs only for ADMIN
|
||||||
|
- [ ] No console errors related to role checks
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 **Backward Compatibility**
|
||||||
|
|
||||||
|
**None** - This is a breaking change. All users must be assigned a role in the database:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Default all users to USER role
|
||||||
|
UPDATE users SET role = 'USER' WHERE role IS NULL;
|
||||||
|
|
||||||
|
-- Assign specific roles
|
||||||
|
UPDATE users SET role = 'ADMIN' WHERE email = 'admin@royalenfield.com';
|
||||||
|
UPDATE users SET role = 'MANAGEMENT' WHERE email = 'manager@royalenfield.com';
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 **Deployment Ready**
|
||||||
|
|
||||||
|
All changes are complete and linter-clean. Frontend now fully supports the new RBAC system!
|
||||||
|
|
||||||
@ -1,217 +0,0 @@
|
|||||||
# Request Type Separation Implementation
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
Successfully implemented complete separation between **Custom Requests** and **Claim Management Requests** to ensure independent processes, databases, and components.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
### 1. Separate Databases
|
|
||||||
|
|
||||||
#### Custom Request Database
|
|
||||||
- **Location**: `/utils/customRequestDatabase.ts`
|
|
||||||
- **Purpose**: Stores all custom requests created via NewRequestWizard
|
|
||||||
- **Export**: `CUSTOM_REQUEST_DATABASE`
|
|
||||||
- **API Endpoints**: `CUSTOM_REQUEST_API_ENDPOINTS`
|
|
||||||
- **Features**:
|
|
||||||
- User-defined workflow
|
|
||||||
- Custom approvers added during creation
|
|
||||||
- Spectators and tagged participants
|
|
||||||
- Category/subcategory fields
|
|
||||||
- Flexible approval steps
|
|
||||||
|
|
||||||
#### Claim Management Database
|
|
||||||
- **Location**: `/utils/claimManagementDatabase.ts`
|
|
||||||
- **Purpose**: Stores all claim management requests created via ClaimManagementWizard
|
|
||||||
- **Export**: `CLAIM_MANAGEMENT_DATABASE`
|
|
||||||
- **API Endpoints**: `CLAIM_MANAGEMENT_API_ENDPOINTS`
|
|
||||||
- **Features**:
|
|
||||||
- Fixed 8-step workflow process
|
|
||||||
- Dealer information (code, name, contact, address)
|
|
||||||
- Activity details (name, type, location, date)
|
|
||||||
- Budget tracking
|
|
||||||
- Specialized modals (DealerDocumentModal, InitiatorVerificationModal)
|
|
||||||
|
|
||||||
### 2. Separate Detail Components
|
|
||||||
|
|
||||||
#### Request Detail Component
|
|
||||||
- **Location**: `/components/RequestDetail.tsx`
|
|
||||||
- **Purpose**: Display custom/standard requests only
|
|
||||||
- **Database**: Uses `CUSTOM_REQUEST_DATABASE`
|
|
||||||
- **Features**:
|
|
||||||
- Standard initiator information
|
|
||||||
- Category and subcategory display
|
|
||||||
- Approvers shown from request creation
|
|
||||||
- General description and specifications
|
|
||||||
- Flexible workflow steps
|
|
||||||
- Standard action buttons
|
|
||||||
|
|
||||||
#### Claim Management Detail Component
|
|
||||||
- **Location**: `/components/ClaimManagementDetail.tsx`
|
|
||||||
- **Purpose**: Display claim management requests only
|
|
||||||
- **Database**: Uses `CLAIM_MANAGEMENT_DATABASE`
|
|
||||||
- **Features**:
|
|
||||||
- Dealer information prominently displayed
|
|
||||||
- Activity information section
|
|
||||||
- 8-step workflow with specific actions per step
|
|
||||||
- Budget/amount tracking
|
|
||||||
- Claim-specific modals (dealer docs, verification)
|
|
||||||
- Purple theme for claim management branding
|
|
||||||
- Step-specific action buttons (Upload Documents, Verify Amount, etc.)
|
|
||||||
|
|
||||||
### 3. App.tsx Routing Logic
|
|
||||||
|
|
||||||
The main App component now includes intelligent routing:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
case 'request-detail':
|
|
||||||
const isClaimRequest = CLAIM_MANAGEMENT_DATABASE[selectedRequestId];
|
|
||||||
const isCustomRequest = CUSTOM_REQUEST_DATABASE[selectedRequestId];
|
|
||||||
|
|
||||||
if (isClaimRequest) {
|
|
||||||
return <ClaimManagementDetail ... />;
|
|
||||||
} else if (isCustomRequest) {
|
|
||||||
return <RequestDetail ... />;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4. API Endpoint Separation
|
|
||||||
|
|
||||||
Each database file includes its own API endpoint constants:
|
|
||||||
|
|
||||||
#### Custom Request Endpoints
|
|
||||||
- `/api/v1/custom-request/*`
|
|
||||||
- Includes: create, update, get, list, approve, reject, add approver/spectator/tagged, documents, work notes, etc.
|
|
||||||
|
|
||||||
#### Claim Management Endpoints
|
|
||||||
- `/api/v1/claim-management/*`
|
|
||||||
- Includes: create, update, get, list, dealer document upload, initiator evaluate, generate IO, department approval, completion docs, verify, e-invoice, credit note, etc.
|
|
||||||
|
|
||||||
## Benefits
|
|
||||||
|
|
||||||
### 1. Complete Independence
|
|
||||||
- Changes to claim management don't affect custom requests
|
|
||||||
- Changes to custom requests don't affect claim management
|
|
||||||
- Each process has its own data structure and business logic
|
|
||||||
|
|
||||||
### 2. Future-Proof
|
|
||||||
- Easy to add new template types (e.g., Budget Approval, Travel Requests)
|
|
||||||
- Each new template gets its own database and detail component
|
|
||||||
- No cross-contamination between processes
|
|
||||||
|
|
||||||
### 3. API Ready
|
|
||||||
- Separate endpoints allow different backend services
|
|
||||||
- Different authentication/authorization per process type
|
|
||||||
- Different data validation and business rules
|
|
||||||
|
|
||||||
### 4. Maintainability
|
|
||||||
- Clear separation of concerns
|
|
||||||
- Easy to debug issues (know which system to check)
|
|
||||||
- Independent testing for each process type
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Custom Request Flow
|
|
||||||
1. User clicks "Custom Request" in NewRequestWizard
|
|
||||||
2. Fills out form with custom approvers, spectators, etc.
|
|
||||||
3. Submitted to `CUSTOM_REQUEST_DATABASE`
|
|
||||||
4. Clicking on request triggers `RequestDetail` component
|
|
||||||
5. All actions use `CUSTOM_REQUEST_API_ENDPOINTS`
|
|
||||||
|
|
||||||
### Claim Management Flow
|
|
||||||
1. User clicks "Existing Template" → "Claim Management"
|
|
||||||
2. Navigates to ClaimManagementWizard
|
|
||||||
3. Fills out claim-specific form (dealer, activity, budget)
|
|
||||||
4. Submitted to `CLAIM_MANAGEMENT_DATABASE`
|
|
||||||
5. Clicking on claim triggers `ClaimManagementDetail` component
|
|
||||||
6. All actions use `CLAIM_MANAGEMENT_API_ENDPOINTS`
|
|
||||||
|
|
||||||
## Database Schema Differences
|
|
||||||
|
|
||||||
### Custom Request
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
category: string
|
|
||||||
subcategory: string
|
|
||||||
status: string
|
|
||||||
priority: string
|
|
||||||
template: 'custom'
|
|
||||||
approvalFlow: [...] // User-defined
|
|
||||||
// No claimDetails
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Claim Management Request
|
|
||||||
```typescript
|
|
||||||
{
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
template: 'claim-management'
|
|
||||||
claimDetails: {
|
|
||||||
activityName: string
|
|
||||||
activityType: string
|
|
||||||
location: string
|
|
||||||
dealerCode: string
|
|
||||||
dealerName: string
|
|
||||||
dealerEmail: string
|
|
||||||
dealerPhone: string
|
|
||||||
dealerAddress: string
|
|
||||||
estimatedBudget: string
|
|
||||||
// ... more claim-specific fields
|
|
||||||
}
|
|
||||||
approvalFlow: [...] // Fixed 8-step process
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Visual Differences
|
|
||||||
|
|
||||||
### Request Detail (Custom)
|
|
||||||
- Blue theme
|
|
||||||
- Standard file icon
|
|
||||||
- Category/Subcategory displayed
|
|
||||||
- "Request Detail" terminology
|
|
||||||
- Generic approval buttons
|
|
||||||
|
|
||||||
### Claim Management Detail (Purple)
|
|
||||||
- Purple theme throughout
|
|
||||||
- Receipt/claim icon
|
|
||||||
- "Claim Management" badge
|
|
||||||
- Dealer and Activity sections
|
|
||||||
- "Claim Amount" instead of "Total Amount"
|
|
||||||
- Step-specific buttons (Upload Documents, Verify Amount, etc.)
|
|
||||||
- "Claim Activity Timeline" instead of "Activity Timeline"
|
|
||||||
|
|
||||||
## Migration Notes
|
|
||||||
|
|
||||||
### Legacy Database
|
|
||||||
- Old `REQUEST_DATABASE` in App.tsx is now marked as `LEGACY_REQUEST_DATABASE`
|
|
||||||
- Combined export `REQUEST_DATABASE` = custom + claim for backward compatibility
|
|
||||||
- Will be removed in future once all components updated
|
|
||||||
|
|
||||||
### Components to Update (Future)
|
|
||||||
- Dashboard.tsx - update to use both databases
|
|
||||||
- RequestsList.tsx - update to use both databases
|
|
||||||
- MyRequests.tsx - update to use both databases
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
- [ ] Custom request creation works
|
|
||||||
- [ ] Custom request detail page displays correctly
|
|
||||||
- [ ] Custom request actions (approve/reject) work
|
|
||||||
- [ ] Claim management creation works
|
|
||||||
- [ ] Claim management detail page displays correctly
|
|
||||||
- [ ] Claim-specific actions work (dealer upload, verification)
|
|
||||||
- [ ] No cross-contamination between types
|
|
||||||
- [ ] Routing correctly identifies request type
|
|
||||||
- [ ] Proper error handling for missing requests
|
|
||||||
|
|
||||||
## Future Enhancements
|
|
||||||
|
|
||||||
1. Add more template types (Budget Approval, Travel Request, etc.)
|
|
||||||
2. Create utility functions for database operations
|
|
||||||
3. Add TypeScript interfaces for better type safety
|
|
||||||
4. Implement actual API integration
|
|
||||||
5. Add request type indicators in lists
|
|
||||||
6. Create admin panel for template management
|
|
||||||
@ -1,283 +0,0 @@
|
|||||||
# 🎯 Quick Setup Instructions
|
|
||||||
|
|
||||||
## ✅ Project Setup Complete!
|
|
||||||
|
|
||||||
Your Royal Enfield Approval Portal has been configured with industry-standard React development tools and structure.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🚀 Quick Start (5 Minutes)
|
|
||||||
|
|
||||||
### Option 1: Automated Migration (Recommended)
|
|
||||||
|
|
||||||
**For Windows PowerShell:**
|
|
||||||
```powershell
|
|
||||||
# 1. Run the migration script
|
|
||||||
.\migrate-files.ps1
|
|
||||||
|
|
||||||
# 2. Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 3. Start development server
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Option 2: Manual Migration
|
|
||||||
|
|
||||||
**If you prefer manual control:**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create src directories
|
|
||||||
mkdir src\components src\utils src\styles
|
|
||||||
|
|
||||||
# 2. Move files
|
|
||||||
move App.tsx src\
|
|
||||||
move components src\
|
|
||||||
move utils src\
|
|
||||||
move styles src\
|
|
||||||
|
|
||||||
# 3. Install dependencies
|
|
||||||
npm install
|
|
||||||
|
|
||||||
# 4. Start development server
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📦 What Was Created
|
|
||||||
|
|
||||||
### Core Configuration ✅
|
|
||||||
- ✅ `package.json` - 50+ dependencies installed
|
|
||||||
- ✅ `vite.config.ts` - Build tool (fast, modern)
|
|
||||||
- ✅ `tsconfig.json` - TypeScript settings
|
|
||||||
- ✅ `tailwind.config.ts` - Styling configuration
|
|
||||||
- ✅ `eslint.config.js` - Code quality rules
|
|
||||||
- ✅ `.prettierrc` - Code formatting
|
|
||||||
|
|
||||||
### Project Structure ✅
|
|
||||||
```
|
|
||||||
Re_Figma_Code/
|
|
||||||
├── src/ ← NEW! All code goes here
|
|
||||||
│ ├── main.tsx ← Entry point (created)
|
|
||||||
│ ├── App.tsx ← Move here
|
|
||||||
│ ├── components/ ← Move here
|
|
||||||
│ ├── utils/ ← Move here
|
|
||||||
│ ├── styles/ ← Move here
|
|
||||||
│ └── types/ ← Type definitions (created)
|
|
||||||
├── public/ ← Static assets
|
|
||||||
├── index.html ← HTML entry
|
|
||||||
└── [config files] ← All created
|
|
||||||
```
|
|
||||||
|
|
||||||
### VS Code Setup ✅
|
|
||||||
- ✅ `.vscode/settings.json` - Auto-format, Tailwind IntelliSense
|
|
||||||
- ✅ `.vscode/extensions.json` - Recommended extensions
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔧 After Running Setup
|
|
||||||
|
|
||||||
### 1. Fix Imports in App.tsx
|
|
||||||
|
|
||||||
**Update these imports:**
|
|
||||||
```typescript
|
|
||||||
// OLD (relative paths)
|
|
||||||
import { Layout } from './components/Layout';
|
|
||||||
import { Dashboard } from './components/Dashboard';
|
|
||||||
import { toast } from 'sonner@2.0.3';
|
|
||||||
|
|
||||||
// NEW (path aliases)
|
|
||||||
import { Layout } from '@/components/Layout';
|
|
||||||
import { Dashboard } from '@/components/Dashboard';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Update Component Imports
|
|
||||||
|
|
||||||
**In all component files, change:**
|
|
||||||
```typescript
|
|
||||||
// OLD
|
|
||||||
import { Button } from './ui/button';
|
|
||||||
import { Card } from '../ui/card';
|
|
||||||
|
|
||||||
// NEW
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. Verify Everything Works
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Check for TypeScript errors
|
|
||||||
npm run type-check
|
|
||||||
|
|
||||||
# Check for linting issues
|
|
||||||
npm run lint
|
|
||||||
|
|
||||||
# Format code
|
|
||||||
npm run format
|
|
||||||
|
|
||||||
# Build for production (test)
|
|
||||||
npm run build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Development Workflow
|
|
||||||
|
|
||||||
### Daily Development
|
|
||||||
```bash
|
|
||||||
npm run dev # Start dev server (http://localhost:3000)
|
|
||||||
```
|
|
||||||
|
|
||||||
### Code Quality
|
|
||||||
```bash
|
|
||||||
npm run lint # Check for issues
|
|
||||||
npm run lint:fix # Auto-fix issues
|
|
||||||
npm run format # Format code with Prettier
|
|
||||||
```
|
|
||||||
|
|
||||||
### Building
|
|
||||||
```bash
|
|
||||||
npm run build # Production build
|
|
||||||
npm run preview # Preview production build
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🆘 Troubleshooting
|
|
||||||
|
|
||||||
### Issue: "Module not found"
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
# Clear cache and reinstall
|
|
||||||
rm -rf node_modules package-lock.json
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue: "@/ path alias not working"
|
|
||||||
**Solution:**
|
|
||||||
1. Restart VS Code
|
|
||||||
2. Press `Ctrl+Shift+P` → "TypeScript: Restart TS Server"
|
|
||||||
|
|
||||||
### Issue: "Tailwind classes not applying"
|
|
||||||
**Solution:**
|
|
||||||
1. Check `src/main.tsx` imports `'./styles/globals.css'`
|
|
||||||
2. Restart dev server: `Ctrl+C` then `npm run dev`
|
|
||||||
|
|
||||||
### Issue: Build errors
|
|
||||||
**Solution:**
|
|
||||||
```bash
|
|
||||||
npm run type-check # See TypeScript errors
|
|
||||||
npm run lint # See ESLint errors
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Important Files
|
|
||||||
|
|
||||||
### Environment Variables
|
|
||||||
Copy and edit `.env.example`:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
```
|
|
||||||
|
|
||||||
Edit values:
|
|
||||||
```env
|
|
||||||
VITE_API_BASE_URL=http://localhost:5000/api
|
|
||||||
VITE_APP_NAME=Royal Enfield Approval Portal
|
|
||||||
```
|
|
||||||
|
|
||||||
### Type Definitions
|
|
||||||
Use types from `src/types/index.ts`:
|
|
||||||
```typescript
|
|
||||||
import type { Request, DealerInfo, Priority } from '@/types';
|
|
||||||
|
|
||||||
const request: Request = { /* ... */ };
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ New Features Available
|
|
||||||
|
|
||||||
### 1. Path Aliases
|
|
||||||
```typescript
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { getDealerInfo } from '@/utils/dealerDatabase';
|
|
||||||
import type { Request } from '@/types';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. Code Quality Tools
|
|
||||||
- **ESLint** - Catches bugs and enforces best practices
|
|
||||||
- **Prettier** - Consistent code formatting
|
|
||||||
- **TypeScript** - Type safety and IntelliSense
|
|
||||||
|
|
||||||
### 3. Optimized Build
|
|
||||||
- **Code splitting** - Faster load times
|
|
||||||
- **Tree shaking** - Smaller bundle size
|
|
||||||
- **Source maps** - Easy debugging
|
|
||||||
|
|
||||||
### 4. Development Experience
|
|
||||||
- **Hot Module Replacement** - Instant updates
|
|
||||||
- **Fast Refresh** - Preserve component state
|
|
||||||
- **Better Error Messages** - Easier debugging
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Next Steps
|
|
||||||
|
|
||||||
1. ✅ Run migration script or move files manually
|
|
||||||
2. ✅ Install dependencies: `npm install`
|
|
||||||
3. ✅ Update imports to use `@/` aliases
|
|
||||||
4. ✅ Fix sonner import
|
|
||||||
5. ✅ Start dev server: `npm run dev`
|
|
||||||
6. ✅ Test all features work
|
|
||||||
7. ✅ Commit changes to git
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📖 Documentation
|
|
||||||
|
|
||||||
- **README.md** - Comprehensive project documentation
|
|
||||||
- **MIGRATION_GUIDE.md** - Detailed migration steps
|
|
||||||
- **package.json** - All available scripts
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🤝 Team Guidelines
|
|
||||||
|
|
||||||
### Code Style
|
|
||||||
- Use path aliases (`@/`) for all imports
|
|
||||||
- Format code before committing (`npm run format`)
|
|
||||||
- Fix linting issues (`npm run lint:fix`)
|
|
||||||
- Write TypeScript types (avoid `any`)
|
|
||||||
|
|
||||||
### Git Commits
|
|
||||||
```bash
|
|
||||||
git add .
|
|
||||||
git commit -m "feat: add new feature"
|
|
||||||
git commit -m "fix: resolve bug"
|
|
||||||
git commit -m "docs: update documentation"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Success Checklist
|
|
||||||
|
|
||||||
- [ ] Dependencies installed (`npm install`)
|
|
||||||
- [ ] Files migrated to `src/`
|
|
||||||
- [ ] Imports updated to use `@/`
|
|
||||||
- [ ] Sonner import fixed
|
|
||||||
- [ ] Dev server runs (`npm run dev`)
|
|
||||||
- [ ] No console errors
|
|
||||||
- [ ] Application loads correctly
|
|
||||||
- [ ] All features work
|
|
||||||
- [ ] Build succeeds (`npm run build`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**🎉 You're all set! Happy coding!**
|
|
||||||
|
|
||||||
For detailed help, see `MIGRATION_GUIDE.md` or `README.md`
|
|
||||||
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
# SLA Tracking with Working Hours - Implementation Guide
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
The SLA tracking system automatically **pauses during non-working hours** and **resumes during working hours**, ensuring accurate TAT (Turnaround Time) calculations.
|
|
||||||
|
|
||||||
## 🎯 Features
|
|
||||||
|
|
||||||
✅ **Automatic Pause/Resume** - Stops counting during:
|
|
||||||
- Weekends (Saturday & Sunday)
|
|
||||||
- Non-working hours (before 9 AM, after 6 PM)
|
|
||||||
- Holidays (from database)
|
|
||||||
|
|
||||||
✅ **Real-Time Updates** - Progress updates every minute
|
|
||||||
✅ **Visual Indicators** - Shows when paused vs active
|
|
||||||
✅ **Working Hours Display** - Shows elapsed and remaining in working hours (e.g., "2d 3h")
|
|
||||||
✅ **Next Resume Time** - Shows when tracking will resume during paused state
|
|
||||||
|
|
||||||
## 📁 Components Created
|
|
||||||
|
|
||||||
### 1. **Utility: `slaTracker.ts`**
|
|
||||||
Core calculation functions:
|
|
||||||
- `isWorkingTime()` - Check if current time is working hours
|
|
||||||
- `calculateElapsedWorkingHours()` - Count only working hours
|
|
||||||
- `calculateRemainingWorkingHours()` - Working hours until deadline
|
|
||||||
- `getSLAStatus()` - Complete SLA status with pause/resume info
|
|
||||||
- `formatWorkingHours()` - Format hours as "2d 3h"
|
|
||||||
|
|
||||||
### 2. **Hook: `useSLATracking.ts`**
|
|
||||||
React hook for real-time tracking:
|
|
||||||
```typescript
|
|
||||||
const slaStatus = useSLATracking(startDate, deadline);
|
|
||||||
// Returns: { progress, elapsedHours, remainingHours, isPaused, statusText, ... }
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Component: `SLATracker.tsx`**
|
|
||||||
Visual component with pause/resume indicators:
|
|
||||||
```tsx
|
|
||||||
<SLATracker
|
|
||||||
startDate={request.createdAt}
|
|
||||||
deadline={request.dueDate}
|
|
||||||
showDetails={true}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🚀 Usage Examples
|
|
||||||
|
|
||||||
### In MyRequests Page (Already Integrated)
|
|
||||||
```tsx
|
|
||||||
{request.createdAt && request.dueDate &&
|
|
||||||
request.status !== 'approved' && request.status !== 'rejected' && (
|
|
||||||
<SLATracker
|
|
||||||
startDate={request.createdAt}
|
|
||||||
deadline={request.dueDate}
|
|
||||||
showDetails={true}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
### In RequestDetail Page
|
|
||||||
Replace the existing SLA progress bar with:
|
|
||||||
```tsx
|
|
||||||
import { SLATracker } from '@/components/sla/SLATracker';
|
|
||||||
|
|
||||||
// In the SLA Progress section:
|
|
||||||
<SLATracker
|
|
||||||
startDate={request.createdAt}
|
|
||||||
deadline={request.slaEndDate}
|
|
||||||
showDetails={true}
|
|
||||||
className="mt-2"
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
### In OpenRequests Page
|
|
||||||
```tsx
|
|
||||||
{request.createdAt && request.dueDate && (
|
|
||||||
<SLATracker
|
|
||||||
startDate={request.createdAt}
|
|
||||||
deadline={request.dueDate}
|
|
||||||
showDetails={false} // Compact view
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
```
|
|
||||||
|
|
||||||
## 🎨 Visual States
|
|
||||||
|
|
||||||
### Active (During Working Hours)
|
|
||||||
```
|
|
||||||
SLA Progress [▶️ Active] [On track]
|
|
||||||
████████░░░░░░░░ 45.2%
|
|
||||||
Elapsed: 1d 4h Remaining: 1d 4h
|
|
||||||
```
|
|
||||||
|
|
||||||
### Paused (Outside Working Hours)
|
|
||||||
```
|
|
||||||
SLA Progress [⏸️ Paused] [On track]
|
|
||||||
████████⏸️░░░░░░ 45.2%
|
|
||||||
Elapsed: 1d 4h Remaining: 1d 4h
|
|
||||||
⚠️ Resumes in 14h 30m
|
|
||||||
```
|
|
||||||
|
|
||||||
### Critical (>75%)
|
|
||||||
```
|
|
||||||
SLA Progress [▶️ Active] [SLA critical]
|
|
||||||
█████████████████████░ 87.5%
|
|
||||||
Elapsed: 6d 6h Remaining: 1d 2h
|
|
||||||
```
|
|
||||||
|
|
||||||
### Breached (100%)
|
|
||||||
```
|
|
||||||
SLA Progress [⏸️ Paused] [SLA breached]
|
|
||||||
███████████████████████ 100.0%
|
|
||||||
Elapsed: 8d 0h Remaining: 0h
|
|
||||||
⚠️ Resumes in 2h 15m
|
|
||||||
```
|
|
||||||
|
|
||||||
## ⚙️ Configuration
|
|
||||||
|
|
||||||
Working hours are defined in `slaTracker.ts`:
|
|
||||||
```typescript
|
|
||||||
const WORK_START_HOUR = 9; // 9 AM
|
|
||||||
const WORK_END_HOUR = 18; // 6 PM
|
|
||||||
const WORK_START_DAY = 1; // Monday
|
|
||||||
const WORK_END_DAY = 5; // Friday
|
|
||||||
```
|
|
||||||
|
|
||||||
To change these, update the constants in the utility file.
|
|
||||||
|
|
||||||
## 🔄 How It Works
|
|
||||||
|
|
||||||
### Backend (Already Implemented)
|
|
||||||
1. ✅ Calculates deadlines using `addWorkingHours()` (skips weekends, holidays, non-work hours)
|
|
||||||
2. ✅ Stores calculated deadline in database
|
|
||||||
3. ✅ TAT scheduler triggers notifications at 50%, 75%, 100% (accounting for working hours)
|
|
||||||
|
|
||||||
### Frontend (New Implementation)
|
|
||||||
1. **Receives** pre-calculated deadline from backend
|
|
||||||
2. **Calculates** real-time elapsed working hours from start to now
|
|
||||||
3. **Displays** accurate progress that only counts working time
|
|
||||||
4. **Shows** pause indicator when outside working hours
|
|
||||||
5. **Updates** every minute automatically
|
|
||||||
|
|
||||||
### Example Flow:
|
|
||||||
|
|
||||||
**Friday 4:00 PM** (within working hours)
|
|
||||||
- SLA Progress: [▶️ Active] 25%
|
|
||||||
- Shows real-time progress
|
|
||||||
|
|
||||||
**Friday 6:01 PM** (after hours)
|
|
||||||
- SLA Progress: [⏸️ Paused] 25%
|
|
||||||
- Shows "Resumes in 15h" (Monday 9 AM)
|
|
||||||
|
|
||||||
**Monday 9:00 AM** (work resumes)
|
|
||||||
- SLA Progress: [▶️ Active] 25%
|
|
||||||
- Continues from where it left off
|
|
||||||
|
|
||||||
**Monday 10:00 AM** (1 working hour later)
|
|
||||||
- SLA Progress: [▶️ Active] 30%
|
|
||||||
- Progress updates only during working hours
|
|
||||||
|
|
||||||
## 🎯 Benefits
|
|
||||||
|
|
||||||
1. **Accurate SLA Tracking** - Only counts actual working time
|
|
||||||
2. **User Transparency** - Users see when SLA is paused
|
|
||||||
3. **Realistic Deadlines** - No false urgency during weekends
|
|
||||||
4. **Aligned with Backend** - Frontend display matches backend calculations
|
|
||||||
5. **Real-Time Updates** - Live progress without page refresh
|
|
||||||
|
|
||||||
## 🧪 Testing
|
|
||||||
|
|
||||||
Test different scenarios:
|
|
||||||
1. **During working hours** → Should show "Active" badge
|
|
||||||
2. **After 6 PM** → Should show "Paused" and resume time
|
|
||||||
3. **Weekends** → Should show "Paused" and Monday 9 AM resume
|
|
||||||
4. **Progress calculation** → Should only count working hours
|
|
||||||
|
|
||||||
## 📝 Notes
|
|
||||||
|
|
||||||
- The frontend assumes the backend has already calculated the correct deadline
|
|
||||||
- Progress bars update every 60 seconds
|
|
||||||
- Paused state is visual only - actual TAT calculations are on backend
|
|
||||||
- For holidays, consider integrating with backend holiday API in future enhancement
|
|
||||||
|
|
||||||
342
START_HERE.md
342
START_HERE.md
@ -1,342 +0,0 @@
|
|||||||
# 🚀 START HERE - Royal Enfield Approval Portal
|
|
||||||
|
|
||||||
## ✅ Your Project is Now Configured!
|
|
||||||
|
|
||||||
All standard React configuration files have been created. Your project now follows industry best practices.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Quick Start (Choose One Method)
|
|
||||||
|
|
||||||
### Method 1: PowerShell Script (Easiest - Windows)
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
# Run in PowerShell
|
|
||||||
.\migrate-files.ps1
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
### Method 2: Manual (All Platforms)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Create src structure
|
|
||||||
mkdir -p src/components src/utils src/styles
|
|
||||||
|
|
||||||
# 2. Move files
|
|
||||||
mv App.tsx src/
|
|
||||||
mv components src/
|
|
||||||
mv utils src/
|
|
||||||
mv styles src/
|
|
||||||
|
|
||||||
# 3. Install & run
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📁 What Changed
|
|
||||||
|
|
||||||
### ✅ Created Configuration Files
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ package.json - Dependencies & scripts
|
|
||||||
✅ vite.config.ts - Build configuration
|
|
||||||
✅ tsconfig.json - TypeScript settings
|
|
||||||
✅ tailwind.config.ts - Tailwind CSS config
|
|
||||||
✅ eslint.config.js - Code quality rules
|
|
||||||
✅ .prettierrc - Code formatting
|
|
||||||
✅ postcss.config.js - CSS processing
|
|
||||||
✅ .gitignore - Git ignore rules
|
|
||||||
✅ index.html - Entry HTML file
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Created Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
src/
|
|
||||||
├── main.tsx ✅ Created - App entry point
|
|
||||||
├── vite-env.d.ts ✅ Created - Vite types
|
|
||||||
├── types/
|
|
||||||
│ └── index.ts ✅ Created - TypeScript types
|
|
||||||
└── lib/
|
|
||||||
└── utils.ts ✅ Created - Utility functions
|
|
||||||
|
|
||||||
Need to move:
|
|
||||||
├── App.tsx → src/App.tsx
|
|
||||||
├── components/ → src/components/
|
|
||||||
├── utils/ → src/utils/
|
|
||||||
└── styles/ → src/styles/
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ Created Documentation
|
|
||||||
|
|
||||||
```
|
|
||||||
✅ README.md - Full project documentation
|
|
||||||
✅ MIGRATION_GUIDE.md - Detailed migration steps
|
|
||||||
✅ SETUP_INSTRUCTIONS.md - Quick setup guide
|
|
||||||
✅ START_HERE.md - This file
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ VS Code Configuration
|
|
||||||
|
|
||||||
```
|
|
||||||
.vscode/
|
|
||||||
├── settings.json ✅ Auto-format, Tailwind IntelliSense
|
|
||||||
└── extensions.json ✅ Recommended extensions
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Migration Steps
|
|
||||||
|
|
||||||
### Step 1: Move Files (Pick One)
|
|
||||||
|
|
||||||
**Option A - PowerShell Script:**
|
|
||||||
```powershell
|
|
||||||
.\migrate-files.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
**Option B - Manual:**
|
|
||||||
```bash
|
|
||||||
move App.tsx src\
|
|
||||||
move components src\
|
|
||||||
move utils src\
|
|
||||||
move styles src\
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 2: Install Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm install
|
|
||||||
```
|
|
||||||
|
|
||||||
This installs ~50 packages (takes 2-3 minutes).
|
|
||||||
|
|
||||||
### Step 3: Update Imports in src/App.tsx
|
|
||||||
|
|
||||||
**Find and Replace in App.tsx:**
|
|
||||||
|
|
||||||
1. Change all component imports:
|
|
||||||
```typescript
|
|
||||||
// OLD
|
|
||||||
import { Layout } from './components/Layout';
|
|
||||||
import { Dashboard } from './components/Dashboard';
|
|
||||||
// ... all other component imports
|
|
||||||
|
|
||||||
// NEW
|
|
||||||
import { Layout } from '@/components/Layout';
|
|
||||||
import { Dashboard } from '@/components/Dashboard';
|
|
||||||
// ... use @/ for all imports
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Fix sonner import:
|
|
||||||
```typescript
|
|
||||||
// OLD
|
|
||||||
import { toast } from 'sonner@2.0.3';
|
|
||||||
|
|
||||||
// NEW
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 4: Update Component Files
|
|
||||||
|
|
||||||
In all files under `src/components/`, change relative imports:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
// OLD in any component file
|
|
||||||
import { Button } from './ui/button';
|
|
||||||
import { Card } from '../ui/card';
|
|
||||||
|
|
||||||
// NEW
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Card } from '@/components/ui/card';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Step 5: Start Development Server
|
|
||||||
|
|
||||||
```bash
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
Visit: http://localhost:3000
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🛠️ Available Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development
|
|
||||||
npm run dev # Start dev server (port 3000)
|
|
||||||
|
|
||||||
# Build
|
|
||||||
npm run build # Build for production
|
|
||||||
npm run preview # Preview production build
|
|
||||||
|
|
||||||
# Code Quality
|
|
||||||
npm run lint # Check for errors
|
|
||||||
npm run lint:fix # Auto-fix errors
|
|
||||||
npm run format # Format with Prettier
|
|
||||||
npm run type-check # Check TypeScript types
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ New Features You Get
|
|
||||||
|
|
||||||
### 1. **Path Aliases** - Cleaner Imports
|
|
||||||
```typescript
|
|
||||||
// Before
|
|
||||||
import { Button } from '../../../components/ui/button';
|
|
||||||
|
|
||||||
// After
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2. **TypeScript Types** - Better IntelliSense
|
|
||||||
```typescript
|
|
||||||
import type { Request, DealerInfo } from '@/types';
|
|
||||||
|
|
||||||
const request: Request = { /* auto-complete works! */ };
|
|
||||||
```
|
|
||||||
|
|
||||||
### 3. **Code Quality** - Auto-fix on Save
|
|
||||||
- ESLint catches bugs
|
|
||||||
- Prettier formats code
|
|
||||||
- TypeScript ensures type safety
|
|
||||||
|
|
||||||
### 4. **Fast Development**
|
|
||||||
- Hot Module Replacement (HMR)
|
|
||||||
- Instant updates on file save
|
|
||||||
- Optimized build with code splitting
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🆘 Common Issues & Fixes
|
|
||||||
|
|
||||||
### Issue 1: "Cannot find module '@/...'"
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
```bash
|
|
||||||
# Restart TypeScript server
|
|
||||||
# In VS Code: Ctrl+Shift+P → "TypeScript: Restart TS Server"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue 2: "Module not found: sonner@2.0.3"
|
|
||||||
|
|
||||||
**Fix in src/App.tsx:**
|
|
||||||
```typescript
|
|
||||||
// Change this:
|
|
||||||
import { toast } from 'sonner@2.0.3';
|
|
||||||
|
|
||||||
// To this:
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
```
|
|
||||||
|
|
||||||
### Issue 3: Tailwind classes not working
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
1. Ensure `src/main.tsx` has: `import './styles/globals.css';`
|
|
||||||
2. Restart dev server: `Ctrl+C` then `npm run dev`
|
|
||||||
|
|
||||||
### Issue 4: Build fails
|
|
||||||
|
|
||||||
**Fix:**
|
|
||||||
```bash
|
|
||||||
npm run type-check # See what TypeScript errors exist
|
|
||||||
npm run lint # See what ESLint errors exist
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📚 Documentation
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `README.md` | Full project documentation |
|
|
||||||
| `MIGRATION_GUIDE.md` | Step-by-step migration |
|
|
||||||
| `SETUP_INSTRUCTIONS.md` | Quick setup guide |
|
|
||||||
| `START_HERE.md` | This file - quick overview |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Success Checklist
|
|
||||||
|
|
||||||
Track your progress:
|
|
||||||
|
|
||||||
- [ ] Run migration script OR move files manually
|
|
||||||
- [ ] Run `npm install` (2-3 minutes)
|
|
||||||
- [ ] Update imports in `src/App.tsx` to use `@/`
|
|
||||||
- [ ] Fix sonner import in `src/App.tsx`
|
|
||||||
- [ ] Update imports in all component files
|
|
||||||
- [ ] Run `npm run dev`
|
|
||||||
- [ ] Open http://localhost:3000
|
|
||||||
- [ ] Verify app loads without errors
|
|
||||||
- [ ] Test dashboard navigation
|
|
||||||
- [ ] Test creating new request
|
|
||||||
- [ ] Run `npm run build` to verify production build
|
|
||||||
- [ ] Commit changes to git
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎓 Tech Stack Overview
|
|
||||||
|
|
||||||
| Technology | Purpose | Version |
|
|
||||||
|------------|---------|---------|
|
|
||||||
| **React** | UI Framework | 18.3+ |
|
|
||||||
| **TypeScript** | Type Safety | 5.6+ |
|
|
||||||
| **Vite** | Build Tool | 5.4+ |
|
|
||||||
| **Tailwind CSS** | Styling | 3.4+ |
|
|
||||||
| **shadcn/ui** | UI Components | Latest |
|
|
||||||
| **ESLint** | Code Quality | 9.15+ |
|
|
||||||
| **Prettier** | Formatting | 3.3+ |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Next Actions
|
|
||||||
|
|
||||||
1. **Immediate** - Run the migration (5 minutes)
|
|
||||||
2. **Today** - Update imports and test (15 minutes)
|
|
||||||
3. **This Week** - Review new features and documentation
|
|
||||||
4. **Future** - Add backend API, authentication, tests
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 💡 Pro Tips
|
|
||||||
|
|
||||||
### Tip 1: Use VS Code Extensions
|
|
||||||
Install the recommended extensions when VS Code prompts you.
|
|
||||||
|
|
||||||
### Tip 2: Format on Save
|
|
||||||
Already configured! Your code auto-formats when you save.
|
|
||||||
|
|
||||||
### Tip 3: Type Everything
|
|
||||||
Replace `any` types with proper TypeScript types from `@/types`.
|
|
||||||
|
|
||||||
### Tip 4: Use Path Aliases
|
|
||||||
Always use `@/` imports for cleaner code.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎉 You're Ready!
|
|
||||||
|
|
||||||
Your project is now set up with industry-standard React development tools.
|
|
||||||
|
|
||||||
**Next Step:** Run the migration script and start coding!
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\migrate-files.ps1
|
|
||||||
npm install
|
|
||||||
npm run dev
|
|
||||||
```
|
|
||||||
|
|
||||||
**Questions?** Check the detailed guides:
|
|
||||||
- `MIGRATION_GUIDE.md` - Detailed steps
|
|
||||||
- `README.md` - Full documentation
|
|
||||||
- `SETUP_INSTRUCTIONS.md` - Setup help
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**Happy Coding! 🚀**
|
|
||||||
|
|
||||||
@ -1,308 +0,0 @@
|
|||||||
# System Configuration - Frontend Integration Guide
|
|
||||||
|
|
||||||
## 📋 Overview
|
|
||||||
|
|
||||||
The Royal Enfield Workflow Management System now uses **centralized, backend-driven configuration**. All system settings are fetched from the backend API and cached on the frontend.
|
|
||||||
|
|
||||||
## 🚫 **NO MORE HARDCODED VALUES!**
|
|
||||||
|
|
||||||
### ❌ Before (Hardcoded):
|
|
||||||
```typescript
|
|
||||||
const MAX_MESSAGE_LENGTH = 2000;
|
|
||||||
const WORK_START_HOUR = 9;
|
|
||||||
const MAX_APPROVAL_LEVELS = 10;
|
|
||||||
```
|
|
||||||
|
|
||||||
### ✅ After (Backend-Driven):
|
|
||||||
```typescript
|
|
||||||
import { configService, getWorkNotesConfig } from '@/services/configService';
|
|
||||||
|
|
||||||
const config = await getWorkNotesConfig();
|
|
||||||
const maxLength = config.maxMessageLength; // From backend
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 How to Use Configuration
|
|
||||||
|
|
||||||
### **Method 1: Full Configuration Object**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { configService } from '@/services/configService';
|
|
||||||
|
|
||||||
// In component
|
|
||||||
useEffect(() => {
|
|
||||||
const loadConfig = async () => {
|
|
||||||
const config = await configService.getConfig();
|
|
||||||
console.log('Max file size:', config.upload.maxFileSizeMB);
|
|
||||||
console.log('Working hours:', config.workingHours);
|
|
||||||
};
|
|
||||||
loadConfig();
|
|
||||||
}, []);
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Method 2: Helper Functions**
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import {
|
|
||||||
getWorkingHours,
|
|
||||||
getTATThresholds,
|
|
||||||
getUploadLimits,
|
|
||||||
getWorkNotesConfig,
|
|
||||||
getFeatureFlags
|
|
||||||
} from '@/services/configService';
|
|
||||||
|
|
||||||
// Get specific configuration
|
|
||||||
const workingHours = await getWorkingHours();
|
|
||||||
const tatThresholds = await getTATThresholds();
|
|
||||||
const uploadLimits = await getUploadLimits();
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Method 3: React Hook (Recommended)**
|
|
||||||
|
|
||||||
Create a custom hook:
|
|
||||||
```typescript
|
|
||||||
// src/hooks/useSystemConfig.ts
|
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import { configService, SystemConfig } from '@/services/configService';
|
|
||||||
|
|
||||||
export function useSystemConfig() {
|
|
||||||
const [config, setConfig] = useState<SystemConfig | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadConfig = async () => {
|
|
||||||
const cfg = await configService.getConfig();
|
|
||||||
setConfig(cfg);
|
|
||||||
setLoading(false);
|
|
||||||
};
|
|
||||||
loadConfig();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return { config, loading };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Usage in component:
|
|
||||||
function MyComponent() {
|
|
||||||
const { config, loading } = useSystemConfig();
|
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
Max file size: {config.upload.maxFileSizeMB} MB
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Configuration Flow
|
|
||||||
|
|
||||||
```
|
|
||||||
┌─────────────────┐
|
|
||||||
│ Backend (.env) │
|
|
||||||
│ Environment │
|
|
||||||
│ Variables │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌─────────────────┐
|
|
||||||
│ system.config.ts│
|
|
||||||
│ (Centralized) │
|
|
||||||
└────────┬────────┘
|
|
||||||
│
|
|
||||||
├─────► tat.config.ts (TAT settings)
|
|
||||||
├─────► tatTimeUtils.ts (Uses working hours)
|
|
||||||
└─────► config.routes.ts (API endpoint)
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
GET /api/v1/config
|
|
||||||
│
|
|
||||||
▼
|
|
||||||
┌──────────────────────────┐
|
|
||||||
│ Frontend configService │
|
|
||||||
│ (Cached in memory) │
|
|
||||||
└─────────┬────────────────┘
|
|
||||||
│
|
|
||||||
├─────► Components (via hook)
|
|
||||||
├─────► Utils (slaTracker)
|
|
||||||
└─────► Services
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📊 Configuration Values
|
|
||||||
|
|
||||||
### **Working Hours**
|
|
||||||
```typescript
|
|
||||||
const workingHours = await getWorkingHours();
|
|
||||||
// {
|
|
||||||
// START_HOUR: 9,
|
|
||||||
// END_HOUR: 18,
|
|
||||||
// START_DAY: 1, // Monday
|
|
||||||
// END_DAY: 5, // Friday
|
|
||||||
// TIMEZONE: 'Asia/Kolkata'
|
|
||||||
// }
|
|
||||||
```
|
|
||||||
|
|
||||||
### **TAT Thresholds**
|
|
||||||
```typescript
|
|
||||||
const thresholds = await getTATThresholds();
|
|
||||||
// {
|
|
||||||
// warning: 50, // 50% - First reminder
|
|
||||||
// critical: 75, // 75% - Urgent reminder
|
|
||||||
// breach: 100 // 100% - Breach alert
|
|
||||||
// }
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Upload Limits**
|
|
||||||
```typescript
|
|
||||||
const limits = await getUploadLimits();
|
|
||||||
// {
|
|
||||||
// maxFileSizeMB: 10,
|
|
||||||
// allowedFileTypes: ['pdf', 'doc', ...],
|
|
||||||
// maxFilesPerRequest: 10
|
|
||||||
// }
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Feature Flags**
|
|
||||||
```typescript
|
|
||||||
const features = await getFeatureFlags();
|
|
||||||
// {
|
|
||||||
// ENABLE_AI_CONCLUSION: true,
|
|
||||||
// ENABLE_TEMPLATES: false,
|
|
||||||
// ENABLE_ANALYTICS: true,
|
|
||||||
// ENABLE_EXPORT: true
|
|
||||||
// }
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎨 Example Integrations
|
|
||||||
|
|
||||||
### **File Upload Component**
|
|
||||||
```typescript
|
|
||||||
import { getUploadLimits } from '@/services/configService';
|
|
||||||
|
|
||||||
function FileUpload() {
|
|
||||||
const [maxSize, setMaxSize] = useState(10);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadLimits = async () => {
|
|
||||||
const limits = await getUploadLimits();
|
|
||||||
setMaxSize(limits.maxFileSizeMB);
|
|
||||||
};
|
|
||||||
loadLimits();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".pdf,.doc,.docx"
|
|
||||||
max-size={maxSize * 1024 * 1024}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **Work Notes Message Input**
|
|
||||||
```typescript
|
|
||||||
import { getWorkNotesConfig } from '@/services/configService';
|
|
||||||
|
|
||||||
function MessageInput() {
|
|
||||||
const [maxLength, setMaxLength] = useState(2000);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const loadConfig = async () => {
|
|
||||||
const config = await getWorkNotesConfig();
|
|
||||||
setMaxLength(config.maxMessageLength);
|
|
||||||
};
|
|
||||||
loadConfig();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<textarea maxLength={maxLength} />
|
|
||||||
<span>{message.length}/{maxLength}</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### **SLA Tracker** (Already Implemented)
|
|
||||||
```typescript
|
|
||||||
// src/utils/slaTracker.ts
|
|
||||||
import { configService } from '@/services/configService';
|
|
||||||
|
|
||||||
// Loads working hours from backend automatically
|
|
||||||
const config = await configService.getConfig();
|
|
||||||
WORK_START_HOUR = config.workingHours.START_HOUR;
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔄 Auto-Refresh Configuration
|
|
||||||
|
|
||||||
Configuration is **cached** after first fetch. To refresh:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { configService } from '@/services/configService';
|
|
||||||
|
|
||||||
// Force refresh from backend
|
|
||||||
await configService.refreshConfig();
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Benefits
|
|
||||||
|
|
||||||
1. **No Hardcoded Values** - Everything from backend
|
|
||||||
2. **Environment-Specific** - Different configs for dev/prod
|
|
||||||
3. **Easy Updates** - Change .env without code deployment
|
|
||||||
4. **Type-Safe** - TypeScript interfaces prevent errors
|
|
||||||
5. **Cached** - Fast access after first load
|
|
||||||
6. **Fallback Defaults** - Works even if backend unavailable
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🧹 Cleanup Completed
|
|
||||||
|
|
||||||
### **Removed from Frontend:**
|
|
||||||
- ❌ `REQUEST_DATABASE` (hardcoded request data)
|
|
||||||
- ❌ `MOCK_PARTICIPANTS` (dummy participant list)
|
|
||||||
- ❌ `INITIAL_MESSAGES` (sample messages)
|
|
||||||
- ❌ Hardcoded working hours in SLA tracker
|
|
||||||
- ❌ Hardcoded message length limits
|
|
||||||
- ❌ Hardcoded file size limits
|
|
||||||
|
|
||||||
### **Centralized in Backend:**
|
|
||||||
- ✅ `system.config.ts` - Single source of truth
|
|
||||||
- ✅ Environment variables for all settings
|
|
||||||
- ✅ Public API endpoint (`/api/v1/config`)
|
|
||||||
- ✅ Non-sensitive values only exposed to frontend
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 📝 Next Steps
|
|
||||||
|
|
||||||
1. **Create `.env` file** in backend (copy from CONFIGURATION.md)
|
|
||||||
2. **Set your values** for database, JWT secret, etc.
|
|
||||||
3. **Start backend** - Config will be logged on startup
|
|
||||||
4. **Frontend auto-loads** configuration on first API call
|
|
||||||
5. **Use config** in your components via `configService`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🎯 Result
|
|
||||||
|
|
||||||
Your system now has **enterprise-grade configuration management**:
|
|
||||||
|
|
||||||
✅ Centralized configuration
|
|
||||||
✅ Environment-driven values
|
|
||||||
✅ Frontend-backend sync
|
|
||||||
✅ No hardcoded data
|
|
||||||
✅ Type-safe access
|
|
||||||
✅ Easy maintenance
|
|
||||||
|
|
||||||
All dummy data removed, all configuration backend-driven! 🚀
|
|
||||||
|
|
||||||
339
USER_ROLE_MANAGEMENT.md
Normal file
339
USER_ROLE_MANAGEMENT.md
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
# User Role Management Feature
|
||||||
|
|
||||||
|
## 🎯 Overview
|
||||||
|
|
||||||
|
Added a comprehensive User Role Management system for administrators to assign roles to users directly from the Settings page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ What Was Built
|
||||||
|
|
||||||
|
### Frontend Components
|
||||||
|
|
||||||
|
#### 1. **UserRoleManager Component**
|
||||||
|
Location: `src/components/admin/UserRoleManager/UserRoleManager.tsx`
|
||||||
|
|
||||||
|
**Features:**
|
||||||
|
- **Search Users from Okta** - Real-time search with debouncing
|
||||||
|
- **Role Assignment** - Assign USER, MANAGEMENT, or ADMIN roles
|
||||||
|
- **Statistics Dashboard** - Shows count of users in each role
|
||||||
|
- **Elevated Users List** - Displays all ADMIN and MANAGEMENT users
|
||||||
|
- **Auto-create Users** - If user doesn't exist in database, fetches from Okta and creates them
|
||||||
|
- **Self-demotion Prevention** - Admin cannot demote themselves
|
||||||
|
|
||||||
|
**UI Components:**
|
||||||
|
- Statistics cards showing admin/management/user counts
|
||||||
|
- Search input with dropdown results
|
||||||
|
- Selected user card display
|
||||||
|
- Role selector dropdown
|
||||||
|
- Assign button with loading state
|
||||||
|
- Success/error message display
|
||||||
|
- Elevated users list with role badges
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Backend APIs
|
||||||
|
|
||||||
|
#### 2. **New Route: Assign Role by Email**
|
||||||
|
`POST /api/v1/admin/users/assign-role`
|
||||||
|
|
||||||
|
**Purpose:** Assign role to user by email (creates user from Okta if doesn't exist)
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"email": "user@royalenfield.com",
|
||||||
|
"role": "MANAGEMENT" // or "USER" or "ADMIN"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"message": "Successfully assigned MANAGEMENT role to John Doe",
|
||||||
|
"data": {
|
||||||
|
"userId": "abc-123",
|
||||||
|
"email": "user@royalenfield.com",
|
||||||
|
"displayName": "John Doe",
|
||||||
|
"role": "MANAGEMENT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. Check if user exists in database by email
|
||||||
|
2. If not exists → Search Okta API
|
||||||
|
3. If found in Okta → Create user in database with assigned role
|
||||||
|
4. If exists → Update user's role
|
||||||
|
5. Prevent self-demotion (admin demoting themselves)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
#### 3. **Existing Routes (Already Created)**
|
||||||
|
|
||||||
|
**Get Users by Role**
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/users/by-role?role=ADMIN
|
||||||
|
GET /api/v1/admin/users/by-role?role=MANAGEMENT
|
||||||
|
```
|
||||||
|
|
||||||
|
**Get Role Statistics**
|
||||||
|
```
|
||||||
|
GET /api/v1/admin/users/role-statistics
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"data": {
|
||||||
|
"statistics": [
|
||||||
|
{ "role": "ADMIN", "count": 3 },
|
||||||
|
{ "role": "MANAGEMENT", "count": 12 },
|
||||||
|
{ "role": "USER", "count": 145 }
|
||||||
|
],
|
||||||
|
"total": 160
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Update User Role by ID**
|
||||||
|
```
|
||||||
|
PUT /api/v1/admin/users/:userId/role
|
||||||
|
Body: { "role": "MANAGEMENT" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Settings Page Updates
|
||||||
|
|
||||||
|
#### 4. **New Tab: "User Roles"**
|
||||||
|
Location: `src/pages/Settings/Settings.tsx`
|
||||||
|
|
||||||
|
**Changes:**
|
||||||
|
- Added 4th tab to admin settings
|
||||||
|
- Tab layout now responsive: 2 columns on mobile, 4 on desktop
|
||||||
|
- Tab order: User Settings → **User Roles** → Configuration → Holidays
|
||||||
|
- Only visible to ADMIN role users
|
||||||
|
|
||||||
|
**Tab Structure:**
|
||||||
|
```
|
||||||
|
┌─────────────┬────────────┬──────────────┬──────────┐
|
||||||
|
│ User │ User Roles │ Config │ Holidays │
|
||||||
|
│ Settings │ (NEW! ✨) │ │ │
|
||||||
|
└─────────────┴────────────┴──────────────┴──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### API Service Updates
|
||||||
|
|
||||||
|
#### 5. **User API Service**
|
||||||
|
Location: `src/services/userApi.ts`
|
||||||
|
|
||||||
|
**New Functions:**
|
||||||
|
```typescript
|
||||||
|
userApi.assignRole(email, role) // Assign role by email
|
||||||
|
userApi.updateUserRole(userId, role) // Update role by userId
|
||||||
|
userApi.getUsersByRole(role) // Get users filtered by role
|
||||||
|
userApi.getRoleStatistics() // Get role counts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 UI/UX Features
|
||||||
|
|
||||||
|
### Statistics Cards
|
||||||
|
```
|
||||||
|
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
|
||||||
|
│ Administrators │ │ Management │ │ Regular Users │
|
||||||
|
│ 3 │ │ 12 │ │ 145 │
|
||||||
|
│ 👑 ADMIN │ │ 👥 MANAGEMENT │ │ 👤 USER │
|
||||||
|
└──────────────────┘ └──────────────────┘ └──────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### Role Assignment Section
|
||||||
|
1. **Search Input** - Type name or email
|
||||||
|
2. **Results Dropdown** - Shows matching Okta users
|
||||||
|
3. **Selected User Card** - Displays chosen user details
|
||||||
|
4. **Role Selector** - Dropdown with 3 role options
|
||||||
|
5. **Assign Button** - Confirms role assignment
|
||||||
|
|
||||||
|
### Elevated Users List
|
||||||
|
- Shows all ADMIN and MANAGEMENT users
|
||||||
|
- Regular USER role users are not shown (too many)
|
||||||
|
- Each user card shows:
|
||||||
|
- Role icon and badge
|
||||||
|
- Display name
|
||||||
|
- Email
|
||||||
|
- Department and designation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔐 Access Control
|
||||||
|
|
||||||
|
### ADMIN Only
|
||||||
|
- View User Roles tab
|
||||||
|
- Search and assign roles
|
||||||
|
- View all elevated users
|
||||||
|
- Create users from Okta
|
||||||
|
- Demote users (except themselves)
|
||||||
|
|
||||||
|
### MANAGEMENT & USER
|
||||||
|
- Cannot access User Roles tab
|
||||||
|
- See info message about admin features
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 User Creation Flow
|
||||||
|
|
||||||
|
### Scenario 1: User Exists in Database
|
||||||
|
```
|
||||||
|
1. Admin searches "john@royalenfield.com"
|
||||||
|
2. Finds user in search results
|
||||||
|
3. Selects user
|
||||||
|
4. Assigns MANAGEMENT role
|
||||||
|
5. ✅ User role updated
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: User Doesn't Exist in Database
|
||||||
|
```
|
||||||
|
1. Admin searches "new.user@royalenfield.com"
|
||||||
|
2. Finds user in Okta search results
|
||||||
|
3. Selects user
|
||||||
|
4. Assigns MANAGEMENT role
|
||||||
|
5. Backend fetches full details from Okta
|
||||||
|
6. Creates user in database with MANAGEMENT role
|
||||||
|
7. ✅ User created and role assigned
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: User Not in Okta
|
||||||
|
```
|
||||||
|
1. Admin searches "fake@email.com"
|
||||||
|
2. No results found
|
||||||
|
3. If admin types email manually and tries to assign
|
||||||
|
4. ❌ Error: "User not found in Okta. Please ensure the email is correct."
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 Role Badge Colors
|
||||||
|
|
||||||
|
| Role | Badge Color | Icon | Access Level |
|
||||||
|
|------|-------------|------|--------------|
|
||||||
|
| ADMIN | 🟡 Yellow | 👑 Crown | Full system access |
|
||||||
|
| MANAGEMENT | 🔵 Blue | 👥 Users | Read all data, enhanced dashboards |
|
||||||
|
| USER | ⚪ Gray | 👤 User | Own requests and assigned workflows |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📊 Test Scenarios
|
||||||
|
|
||||||
|
### Test 1: Assign MANAGEMENT Role to Existing User
|
||||||
|
```
|
||||||
|
1. Login as ADMIN
|
||||||
|
2. Go to Settings → User Roles tab
|
||||||
|
3. Search for existing user
|
||||||
|
4. Select MANAGEMENT role
|
||||||
|
5. Click Assign Role
|
||||||
|
6. Verify success message
|
||||||
|
7. Check user appears in Elevated Users list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 2: Create New User from Okta
|
||||||
|
```
|
||||||
|
1. Search for user not in database (but in Okta)
|
||||||
|
2. Select ADMIN role
|
||||||
|
3. Click Assign Role
|
||||||
|
4. Verify user is created AND role assigned
|
||||||
|
5. Check statistics update (+1 ADMIN)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 3: Self-Demotion Prevention
|
||||||
|
```
|
||||||
|
1. Login as ADMIN
|
||||||
|
2. Search for your own email
|
||||||
|
3. Try to assign USER or MANAGEMENT role
|
||||||
|
4. Verify error: "You cannot demote yourself from ADMIN role"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Test 4: Role Statistics
|
||||||
|
```
|
||||||
|
1. Check statistics cards show correct counts
|
||||||
|
2. Assign roles to users
|
||||||
|
3. Verify statistics update in real-time
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 Backend Implementation Details
|
||||||
|
|
||||||
|
### Controller: `admin.controller.ts`
|
||||||
|
|
||||||
|
**New Function: `assignRoleByEmail`**
|
||||||
|
```typescript
|
||||||
|
1. Validate email and role
|
||||||
|
2. Check if user exists in database
|
||||||
|
3. If NOT exists:
|
||||||
|
a. Import UserService
|
||||||
|
b. Search Okta by email
|
||||||
|
c. If not found in Okta → return 404
|
||||||
|
d. If found → Create user with assigned role
|
||||||
|
4. If EXISTS:
|
||||||
|
a. Check for self-demotion
|
||||||
|
b. Update user's role
|
||||||
|
5. Return success response
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 Files Modified
|
||||||
|
|
||||||
|
### Frontend (3 new, 2 modified)
|
||||||
|
```
|
||||||
|
✨ src/components/admin/UserRoleManager/UserRoleManager.tsx (NEW)
|
||||||
|
✨ src/components/admin/UserRoleManager/index.ts (NEW)
|
||||||
|
✨ Re_Figma_Code/USER_ROLE_MANAGEMENT.md (NEW - this file)
|
||||||
|
✏️ src/services/userApi.ts (MODIFIED - added 4 functions)
|
||||||
|
✏️ src/pages/Settings/Settings.tsx (MODIFIED - added User Roles tab)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Backend (2 modified)
|
||||||
|
```
|
||||||
|
✏️ src/controllers/admin.controller.ts (MODIFIED - added assignRoleByEmail)
|
||||||
|
✏️ src/routes/admin.routes.ts (MODIFIED - added POST /users/assign-role)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎉 Complete Feature Set
|
||||||
|
|
||||||
|
✅ Search users from Okta
|
||||||
|
✅ Create users from Okta if they don't exist
|
||||||
|
✅ Assign any of 3 roles (USER, MANAGEMENT, ADMIN)
|
||||||
|
✅ View role statistics
|
||||||
|
✅ View all elevated users (ADMIN + MANAGEMENT)
|
||||||
|
✅ Regular users hidden (don't clutter the list)
|
||||||
|
✅ Self-demotion prevention
|
||||||
|
✅ Real-time search with debouncing
|
||||||
|
✅ Beautiful UI with gradient cards
|
||||||
|
✅ Role badges with icons
|
||||||
|
✅ Success/error messaging
|
||||||
|
✅ Loading states
|
||||||
|
✅ Test IDs for testing
|
||||||
|
✅ Mobile responsive
|
||||||
|
✅ Admin-only access
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Ready to Use!
|
||||||
|
|
||||||
|
The feature is fully functional and ready for testing. Admins can now easily manage user roles directly from the Settings page without needing SQL or manual database access!
|
||||||
|
|
||||||
|
**To test:**
|
||||||
|
1. Log in as ADMIN user
|
||||||
|
2. Navigate to Settings
|
||||||
|
3. Click "User Roles" tab
|
||||||
|
4. Start assigning roles! 🎯
|
||||||
|
|
||||||
7
package-lock.json
generated
7
package-lock.json
generated
@ -43,6 +43,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
@ -4060,6 +4061,12 @@
|
|||||||
"url": "https://github.com/sponsors/kossnocorp"
|
"url": "https://github.com/sponsors/kossnocorp"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dayjs": {
|
||||||
|
"version": "1.11.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
|
"integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
|||||||
@ -48,6 +48,7 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"cmdk": "^1.0.0",
|
"cmdk": "^1.0.0",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
|
"dayjs": "^1.11.19",
|
||||||
"embla-carousel-react": "^8.3.0",
|
"embla-carousel-react": "^8.3.0",
|
||||||
"framer-motion": "^12.23.24",
|
"framer-motion": "^12.23.24",
|
||||||
"input-otp": "^1.2.4",
|
"input-otp": "^1.2.4",
|
||||||
|
|||||||
37
src/App.tsx
37
src/App.tsx
@ -11,6 +11,9 @@ import { ClaimManagementWizard } from '@/components/workflow/ClaimManagementWiza
|
|||||||
import { MyRequests } from '@/pages/MyRequests';
|
import { MyRequests } from '@/pages/MyRequests';
|
||||||
import { Profile } from '@/pages/Profile';
|
import { Profile } from '@/pages/Profile';
|
||||||
import { Settings } from '@/pages/Settings';
|
import { Settings } from '@/pages/Settings';
|
||||||
|
import { Notifications } from '@/pages/Notifications';
|
||||||
|
import { DetailedReports } from '@/pages/DetailedReports';
|
||||||
|
import { Admin } from '@/pages/Admin';
|
||||||
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@ -539,13 +542,13 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Request Detail */}
|
{/* Request Detail - requestId will be read from URL params */}
|
||||||
<Route
|
<Route
|
||||||
path="/request/:requestId"
|
path="/request/:requestId"
|
||||||
element={
|
element={
|
||||||
<PageLayout currentPage="request-detail" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
<PageLayout currentPage="request-detail" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
<RequestDetail
|
<RequestDetail
|
||||||
requestId={selectedRequestId || ''}
|
requestId=""
|
||||||
onBack={handleBack}
|
onBack={handleBack}
|
||||||
dynamicRequests={dynamicRequests}
|
dynamicRequests={dynamicRequests}
|
||||||
/>
|
/>
|
||||||
@ -613,6 +616,36 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Notifications */}
|
||||||
|
<Route
|
||||||
|
path="/notifications"
|
||||||
|
element={
|
||||||
|
<PageLayout currentPage="notifications" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
|
<Notifications onNavigate={handleNavigate} />
|
||||||
|
</PageLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Detailed Reports */}
|
||||||
|
<Route
|
||||||
|
path="/detailed-reports"
|
||||||
|
element={
|
||||||
|
<PageLayout currentPage="detailed-reports" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
|
<DetailedReports />
|
||||||
|
</PageLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Admin Control Panel */}
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
|
<Admin />
|
||||||
|
</PageLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
<Toaster
|
<Toaster
|
||||||
|
|||||||
195
src/components/admin/AIConfig/AIConfig.tsx
Normal file
195
src/components/admin/AIConfig/AIConfig.tsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save, Loader2, Sparkles, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { AIProviderSettings } from './AIProviderSettings';
|
||||||
|
import { AIFeatures } from './AIFeatures';
|
||||||
|
import { AIParameters } from './AIParameters';
|
||||||
|
import { getAllConfigurations, updateConfiguration, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface AIConfigData {
|
||||||
|
aiEnabled: boolean;
|
||||||
|
aiProvider: 'claude' | 'openai' | 'gemini';
|
||||||
|
claudeApiKey: string;
|
||||||
|
openaiApiKey: string;
|
||||||
|
geminiApiKey: string;
|
||||||
|
aiRemarkGeneration: boolean;
|
||||||
|
maxRemarkChars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIConfig() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showApiKeys, setShowApiKeys] = useState<Record<string, boolean>>({
|
||||||
|
claude: false,
|
||||||
|
openai: false,
|
||||||
|
gemini: false
|
||||||
|
});
|
||||||
|
const [config, setConfig] = useState<AIConfigData>({
|
||||||
|
aiEnabled: true,
|
||||||
|
aiProvider: 'claude',
|
||||||
|
claudeApiKey: '',
|
||||||
|
openaiApiKey: '',
|
||||||
|
geminiApiKey: '',
|
||||||
|
aiRemarkGeneration: true,
|
||||||
|
maxRemarkChars: 500
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigurations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfigurations = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const configs = await getAllConfigurations('AI_CONFIGURATION');
|
||||||
|
|
||||||
|
// Map configuration values to state
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
setConfig({
|
||||||
|
aiEnabled: configMap['AI_ENABLED'] === 'true',
|
||||||
|
aiProvider: (configMap['AI_PROVIDER'] || 'claude') as 'claude' | 'openai' | 'gemini',
|
||||||
|
claudeApiKey: configMap['CLAUDE_API_KEY'] || '',
|
||||||
|
openaiApiKey: configMap['OPENAI_API_KEY'] || '',
|
||||||
|
geminiApiKey: configMap['GEMINI_API_KEY'] || '',
|
||||||
|
aiRemarkGeneration: configMap['AI_REMARK_GENERATION_ENABLED'] === 'true',
|
||||||
|
maxRemarkChars: parseInt(configMap['AI_REMARK_MAX_CHARACTERS'] || '500')
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load AI configurations:', error);
|
||||||
|
toast.error('Failed to load AI configurations');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
// Save all configurations
|
||||||
|
await Promise.all([
|
||||||
|
updateConfiguration('AI_ENABLED', config.aiEnabled.toString()),
|
||||||
|
updateConfiguration('AI_PROVIDER', config.aiProvider),
|
||||||
|
updateConfiguration('CLAUDE_API_KEY', config.claudeApiKey),
|
||||||
|
updateConfiguration('OPENAI_API_KEY', config.openaiApiKey),
|
||||||
|
updateConfiguration('GEMINI_API_KEY', config.geminiApiKey),
|
||||||
|
updateConfiguration('AI_REMARK_GENERATION_ENABLED', config.aiRemarkGeneration.toString()),
|
||||||
|
updateConfiguration('AI_REMARK_MAX_CHARACTERS', config.maxRemarkChars.toString())
|
||||||
|
]);
|
||||||
|
|
||||||
|
toast.success('AI configuration saved successfully');
|
||||||
|
|
||||||
|
// Reload to get updated values
|
||||||
|
await loadConfigurations();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save AI configuration:', error);
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to save AI configuration');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<AIConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleApiKeyVisibility = (provider: 'claude' | 'openai' | 'gemini') => {
|
||||||
|
setShowApiKeys(prev => ({
|
||||||
|
...prev,
|
||||||
|
[provider]: !prev[provider]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const maskApiKey = (key: string): string => {
|
||||||
|
if (!key || key.length === 0) return '';
|
||||||
|
if (key.length <= 8) return '••••••••';
|
||||||
|
return key.substring(0, 4) + '••••••••' + key.substring(key.length - 4);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-re-green mx-auto mb-4" />
|
||||||
|
<p className="text-sm text-gray-600">Loading AI configuration...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-re-green rounded-md shadow-md">
|
||||||
|
<Sparkles className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold text-gray-900">AI Features Configuration</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-gray-600">
|
||||||
|
Configure AI provider, API keys, and enable/disable AI-powered features
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<AIProviderSettings
|
||||||
|
aiEnabled={config.aiEnabled}
|
||||||
|
aiProvider={config.aiProvider}
|
||||||
|
claudeApiKey={config.claudeApiKey}
|
||||||
|
openaiApiKey={config.openaiApiKey}
|
||||||
|
geminiApiKey={config.geminiApiKey}
|
||||||
|
showApiKeys={showApiKeys}
|
||||||
|
onAiEnabledChange={(enabled) => updateConfig({ aiEnabled: enabled })}
|
||||||
|
onProviderChange={(provider) => updateConfig({ aiProvider: provider })}
|
||||||
|
onClaudeApiKeyChange={(key) => updateConfig({ claudeApiKey: key })}
|
||||||
|
onOpenaiApiKeyChange={(key) => updateConfig({ openaiApiKey: key })}
|
||||||
|
onGeminiApiKeyChange={(key) => updateConfig({ geminiApiKey: key })}
|
||||||
|
onToggleApiKeyVisibility={toggleApiKeyVisibility}
|
||||||
|
maskApiKey={maskApiKey}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<AIFeatures
|
||||||
|
aiRemarkGeneration={config.aiRemarkGeneration}
|
||||||
|
onRemarkGenerationChange={(enabled) => updateConfig({ aiRemarkGeneration: enabled })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<AIParameters
|
||||||
|
maxRemarkChars={config.maxRemarkChars}
|
||||||
|
onMaxRemarkCharsChange={(chars) => updateConfig({ maxRemarkChars: chars })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save AI Configuration
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/admin/AIConfig/AIFeatures.tsx
Normal file
43
src/components/admin/AIConfig/AIFeatures.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AIFeaturesProps {
|
||||||
|
aiRemarkGeneration: boolean;
|
||||||
|
onRemarkGenerationChange: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIFeatures({
|
||||||
|
aiRemarkGeneration,
|
||||||
|
onRemarkGenerationChange
|
||||||
|
}: AIFeaturesProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">AI Features</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Enable/disable specific AI-powered features
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 border border-gray-200 rounded-md hover:bg-gray-100 transition-colors">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">AI Remark Generation</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Automatically generate conclusion remarks for workflow closures
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={aiRemarkGeneration}
|
||||||
|
onCheckedChange={onRemarkGenerationChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/admin/AIConfig/AIParameters.tsx
Normal file
47
src/components/admin/AIConfig/AIParameters.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Sliders } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AIParametersProps {
|
||||||
|
maxRemarkChars: number;
|
||||||
|
onMaxRemarkCharsChange: (chars: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIParameters({
|
||||||
|
maxRemarkChars,
|
||||||
|
onMaxRemarkCharsChange
|
||||||
|
}: AIParametersProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sliders className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">AI Parameters</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Configure AI generation parameters
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max-remark-chars" className="text-sm font-medium">
|
||||||
|
Maximum Remark Characters
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="max-remark-chars"
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
max="2000"
|
||||||
|
value={maxRemarkChars}
|
||||||
|
onChange={(e) => onMaxRemarkCharsChange(parseInt(e.target.value) || 500)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Maximum character limit for AI-generated conclusion remarks (100-2000 characters)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
src/components/admin/AIConfig/AIProviderSettings.tsx
Normal file
240
src/components/admin/AIConfig/AIProviderSettings.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Brain, Eye, EyeOff, Key } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface AIProviderSettingsProps {
|
||||||
|
aiEnabled: boolean;
|
||||||
|
aiProvider: 'claude' | 'openai' | 'gemini';
|
||||||
|
claudeApiKey: string;
|
||||||
|
openaiApiKey: string;
|
||||||
|
geminiApiKey: string;
|
||||||
|
showApiKeys: Record<string, boolean>;
|
||||||
|
onAiEnabledChange: (enabled: boolean) => void;
|
||||||
|
onProviderChange: (provider: 'claude' | 'openai' | 'gemini') => void;
|
||||||
|
onClaudeApiKeyChange: (key: string) => void;
|
||||||
|
onOpenaiApiKeyChange: (key: string) => void;
|
||||||
|
onGeminiApiKeyChange: (key: string) => void;
|
||||||
|
onToggleApiKeyVisibility: (provider: 'claude' | 'openai' | 'gemini') => void;
|
||||||
|
maskApiKey: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDERS = [
|
||||||
|
{ value: 'claude', label: 'Claude (Anthropic)', description: 'Advanced AI by Anthropic' },
|
||||||
|
{ value: 'openai', label: 'OpenAI (GPT-4)', description: 'GPT-4 by OpenAI' },
|
||||||
|
{ value: 'gemini', label: 'Gemini (Google)', description: 'Gemini by Google' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AIProviderSettings({
|
||||||
|
aiEnabled,
|
||||||
|
aiProvider,
|
||||||
|
claudeApiKey,
|
||||||
|
openaiApiKey,
|
||||||
|
geminiApiKey,
|
||||||
|
showApiKeys,
|
||||||
|
onAiEnabledChange,
|
||||||
|
onProviderChange,
|
||||||
|
onClaudeApiKeyChange,
|
||||||
|
onOpenaiApiKeyChange,
|
||||||
|
onGeminiApiKeyChange,
|
||||||
|
onToggleApiKeyVisibility,
|
||||||
|
maskApiKey
|
||||||
|
}: AIProviderSettingsProps) {
|
||||||
|
const getCurrentApiKey = (provider: 'claude' | 'openai' | 'gemini'): string => {
|
||||||
|
switch (provider) {
|
||||||
|
case 'claude':
|
||||||
|
return claudeApiKey;
|
||||||
|
case 'openai':
|
||||||
|
return openaiApiKey;
|
||||||
|
case 'gemini':
|
||||||
|
return geminiApiKey;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApiKeyChangeHandler = (provider: 'claude' | 'openai' | 'gemini') => {
|
||||||
|
switch (provider) {
|
||||||
|
case 'claude':
|
||||||
|
return onClaudeApiKeyChange;
|
||||||
|
case 'openai':
|
||||||
|
return onOpenaiApiKeyChange;
|
||||||
|
case 'gemini':
|
||||||
|
return onGeminiApiKeyChange;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Brain className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">AI Provider & API Keys</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Select your AI provider and configure API keys
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Master Toggle */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Enable AI Features</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Master toggle to enable/disable all AI-powered features
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={aiEnabled}
|
||||||
|
onCheckedChange={onAiEnabledChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiEnabled && (
|
||||||
|
<>
|
||||||
|
{/* Provider Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ai-provider" className="text-sm font-medium">
|
||||||
|
AI Provider
|
||||||
|
</Label>
|
||||||
|
<Select value={aiProvider} onValueChange={(value: any) => onProviderChange(value)}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="ai-provider"
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select AI provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROVIDERS.map((provider) => (
|
||||||
|
<SelectItem key={provider.value} value={provider.value}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{provider.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{provider.description}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Keys for each provider */}
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<Label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
API Keys
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* Claude API Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="claude-key" className="text-xs text-muted-foreground">
|
||||||
|
Claude API Key
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="claude-key"
|
||||||
|
type={showApiKeys.claude ? 'text' : 'password'}
|
||||||
|
value={claudeApiKey}
|
||||||
|
onChange={(e) => onClaudeApiKeyChange(e.target.value)}
|
||||||
|
placeholder={showApiKeys.claude ? "sk-ant-..." : maskApiKey(claudeApiKey) || "sk-ant-..."}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onToggleApiKeyVisibility('claude')}
|
||||||
|
className="border-gray-200"
|
||||||
|
>
|
||||||
|
{showApiKeys.claude ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Get your API key from console.anthropic.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OpenAI API Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="openai-key" className="text-xs text-muted-foreground">
|
||||||
|
OpenAI API Key
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="openai-key"
|
||||||
|
type={showApiKeys.openai ? 'text' : 'password'}
|
||||||
|
value={openaiApiKey}
|
||||||
|
onChange={(e) => onOpenaiApiKeyChange(e.target.value)}
|
||||||
|
placeholder={showApiKeys.openai ? "sk-..." : maskApiKey(openaiApiKey) || "sk-..."}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onToggleApiKeyVisibility('openai')}
|
||||||
|
className="border-gray-200"
|
||||||
|
>
|
||||||
|
{showApiKeys.openai ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Get your API key from platform.openai.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gemini API Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="gemini-key" className="text-xs text-muted-foreground">
|
||||||
|
Gemini API Key
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="gemini-key"
|
||||||
|
type={showApiKeys.gemini ? 'text' : 'password'}
|
||||||
|
value={geminiApiKey}
|
||||||
|
onChange={(e) => onGeminiApiKeyChange(e.target.value)}
|
||||||
|
placeholder={showApiKeys.gemini ? "AIza..." : maskApiKey(geminiApiKey) || "AIza..."}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onToggleApiKeyVisibility('gemini')}
|
||||||
|
className="border-gray-200"
|
||||||
|
>
|
||||||
|
{showApiKeys.gemini ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Get your API key from ai.google.dev
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
5
src/components/admin/AIConfig/index.ts
Normal file
5
src/components/admin/AIConfig/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { AIConfig } from './AIConfig';
|
||||||
|
export { AIProviderSettings } from './AIProviderSettings';
|
||||||
|
export { AIFeatures } from './AIFeatures';
|
||||||
|
export { AIParameters } from './AIParameters';
|
||||||
|
|
||||||
90
src/components/admin/AnalyticsConfig/AnalyticsConfig.tsx
Normal file
90
src/components/admin/AnalyticsConfig/AnalyticsConfig.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
import { AnalyticsSettingsForm } from './AnalyticsSettingsForm';
|
||||||
|
import { DataFeaturesSection } from './DataFeaturesSection';
|
||||||
|
import { ExportFormatsSection } from './ExportFormatsSection';
|
||||||
|
import { DataRetentionSection } from './DataRetentionSection';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface AnalyticsConfigData {
|
||||||
|
defaultPeriod: string;
|
||||||
|
refreshInterval: number;
|
||||||
|
autoRefresh: boolean;
|
||||||
|
realTimeUpdates: boolean;
|
||||||
|
dataExport: boolean;
|
||||||
|
exportFormats: string[];
|
||||||
|
dataRetention: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsConfig() {
|
||||||
|
const [config, setConfig] = useState<AnalyticsConfigData>({
|
||||||
|
defaultPeriod: 'This Month',
|
||||||
|
refreshInterval: 5,
|
||||||
|
autoRefresh: true,
|
||||||
|
realTimeUpdates: true,
|
||||||
|
dataExport: true,
|
||||||
|
exportFormats: ['CSV', 'Excel', 'PDF'],
|
||||||
|
dataRetention: 24
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// TODO: Implement API call to save configuration
|
||||||
|
console.log('Saving analytics configuration:', config);
|
||||||
|
toast.success('Analytics configuration saved successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<AnalyticsConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Analytics & Reporting Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure default reporting periods, auto-refresh, export settings, and data retention
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<AnalyticsSettingsForm
|
||||||
|
defaultPeriod={config.defaultPeriod}
|
||||||
|
refreshInterval={config.refreshInterval}
|
||||||
|
onDefaultPeriodChange={(period) => updateConfig({ defaultPeriod: period })}
|
||||||
|
onRefreshIntervalChange={(interval) => updateConfig({ refreshInterval: interval })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DataFeaturesSection
|
||||||
|
autoRefresh={config.autoRefresh}
|
||||||
|
realTimeUpdates={config.realTimeUpdates}
|
||||||
|
dataExport={config.dataExport}
|
||||||
|
onAutoRefreshChange={(enabled) => updateConfig({ autoRefresh: enabled })}
|
||||||
|
onRealTimeUpdatesChange={(enabled) => updateConfig({ realTimeUpdates: enabled })}
|
||||||
|
onDataExportChange={(enabled) => updateConfig({ dataExport: enabled })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<ExportFormatsSection
|
||||||
|
exportFormats={config.exportFormats}
|
||||||
|
onExportFormatsChange={(formats) => updateConfig({ exportFormats: formats })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataRetentionSection
|
||||||
|
dataRetention={config.dataRetention}
|
||||||
|
onDataRetentionChange={(months) => updateConfig({ dataRetention: months })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} className="bg-re-green hover:bg-re-green/90">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save Analytics Configuration
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
interface AnalyticsSettingsFormProps {
|
||||||
|
defaultPeriod: string;
|
||||||
|
refreshInterval: number;
|
||||||
|
onDefaultPeriodChange: (period: string) => void;
|
||||||
|
onRefreshIntervalChange: (interval: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsSettingsForm({
|
||||||
|
defaultPeriod,
|
||||||
|
refreshInterval,
|
||||||
|
onDefaultPeriodChange,
|
||||||
|
onRefreshIntervalChange
|
||||||
|
}: AnalyticsSettingsFormProps) {
|
||||||
|
const periodOptions = [
|
||||||
|
'Today',
|
||||||
|
'This Week',
|
||||||
|
'This Month',
|
||||||
|
'Last Month',
|
||||||
|
'This Quarter',
|
||||||
|
'This Year',
|
||||||
|
'Custom Range'
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="default-period">Default Reporting Period</Label>
|
||||||
|
<Select value={defaultPeriod} onValueChange={onDefaultPeriodChange}>
|
||||||
|
<SelectTrigger id="default-period">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{periodOptions.map((period) => (
|
||||||
|
<SelectItem key={period} value={period}>
|
||||||
|
{period}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="refresh-interval">Auto-Refresh Interval (minutes)</Label>
|
||||||
|
<Input
|
||||||
|
id="refresh-interval"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="60"
|
||||||
|
value={refreshInterval}
|
||||||
|
onChange={(e) => onRefreshIntervalChange(parseInt(e.target.value) || 5)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
65
src/components/admin/AnalyticsConfig/DataFeaturesSection.tsx
Normal file
65
src/components/admin/AnalyticsConfig/DataFeaturesSection.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
|
interface DataFeaturesSectionProps {
|
||||||
|
autoRefresh: boolean;
|
||||||
|
realTimeUpdates: boolean;
|
||||||
|
dataExport: boolean;
|
||||||
|
onAutoRefreshChange: (enabled: boolean) => void;
|
||||||
|
onRealTimeUpdatesChange: (enabled: boolean) => void;
|
||||||
|
onDataExportChange: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataFeaturesSection({
|
||||||
|
autoRefresh,
|
||||||
|
realTimeUpdates,
|
||||||
|
dataExport,
|
||||||
|
onAutoRefreshChange,
|
||||||
|
onRealTimeUpdatesChange,
|
||||||
|
onDataExportChange
|
||||||
|
}: DataFeaturesSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium">Data Features</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Enable Auto-Refresh</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Automatically refresh dashboard data at set intervals
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoRefresh}
|
||||||
|
onCheckedChange={onAutoRefreshChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Enable Real-time Updates</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Show live updates when data changes occur
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={realTimeUpdates}
|
||||||
|
onCheckedChange={onRealTimeUpdatesChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Enable Data Export</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Allow users to export analytics data and reports
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={dataExport}
|
||||||
|
onCheckedChange={onDataExportChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
interface DataRetentionSectionProps {
|
||||||
|
dataRetention: number;
|
||||||
|
onDataRetentionChange: (months: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataRetentionSection({
|
||||||
|
dataRetention,
|
||||||
|
onDataRetentionChange
|
||||||
|
}: DataRetentionSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="data-retention">Historical Data Retention (months)</Label>
|
||||||
|
<Input
|
||||||
|
id="data-retention"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="120"
|
||||||
|
value={dataRetention}
|
||||||
|
onChange={(e) => onDataRetentionChange(parseInt(e.target.value) || 24)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Analytics data older than this will be archived or deleted
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
|
||||||
|
interface ExportFormatsSectionProps {
|
||||||
|
exportFormats: string[];
|
||||||
|
onExportFormatsChange: (formats: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableFormats = ['CSV', 'Excel', 'PDF', 'JSON'];
|
||||||
|
|
||||||
|
export function ExportFormatsSection({
|
||||||
|
exportFormats,
|
||||||
|
onExportFormatsChange
|
||||||
|
}: ExportFormatsSectionProps) {
|
||||||
|
const handleFormatToggle = (format: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
onExportFormatsChange([...exportFormats, format]);
|
||||||
|
} else {
|
||||||
|
onExportFormatsChange(exportFormats.filter(f => f !== format));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Allowed Export Formats</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{availableFormats.map((format) => (
|
||||||
|
<div
|
||||||
|
key={format}
|
||||||
|
className="flex items-center space-x-2 p-2 bg-muted/50 rounded"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`export-${format}`}
|
||||||
|
checked={exportFormats.includes(format)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleFormatToggle(format, checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`export-${format}`}
|
||||||
|
className="text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
{format}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
6
src/components/admin/AnalyticsConfig/index.ts
Normal file
6
src/components/admin/AnalyticsConfig/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { AnalyticsConfig } from './AnalyticsConfig';
|
||||||
|
export { AnalyticsSettingsForm } from './AnalyticsSettingsForm';
|
||||||
|
export { DataFeaturesSection } from './DataFeaturesSection';
|
||||||
|
export { ExportFormatsSection } from './ExportFormatsSection';
|
||||||
|
export { DataRetentionSection } from './DataRetentionSection';
|
||||||
|
|
||||||
109
src/components/admin/DashboardConfig/DashboardConfig.tsx
Normal file
109
src/components/admin/DashboardConfig/DashboardConfig.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
import { DashboardNote } from './DashboardNote';
|
||||||
|
import { RoleDashboardSection } from './RoleDashboardSection';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export type Role = 'Initiator' | 'Approver' | 'Spectator';
|
||||||
|
|
||||||
|
export type KPICard =
|
||||||
|
| 'Total Requests'
|
||||||
|
| 'Open Requests'
|
||||||
|
| 'Approved Requests'
|
||||||
|
| 'Rejected Requests'
|
||||||
|
| 'My Pending Actions'
|
||||||
|
| 'TAT Compliance'
|
||||||
|
| 'Delayed Workflows'
|
||||||
|
| 'Average Cycle Time';
|
||||||
|
|
||||||
|
interface DashboardConfigData {
|
||||||
|
[key: string]: {
|
||||||
|
[kpi: string]: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardConfig() {
|
||||||
|
const [config, setConfig] = useState<DashboardConfigData>({
|
||||||
|
Initiator: {
|
||||||
|
'Total Requests': true,
|
||||||
|
'Open Requests': true,
|
||||||
|
'Approved Requests': true,
|
||||||
|
'Rejected Requests': true,
|
||||||
|
'My Pending Actions': true,
|
||||||
|
'TAT Compliance': true,
|
||||||
|
'Delayed Workflows': true,
|
||||||
|
'Average Cycle Time': true
|
||||||
|
},
|
||||||
|
Approver: {
|
||||||
|
'Total Requests': true,
|
||||||
|
'Open Requests': true,
|
||||||
|
'Approved Requests': true,
|
||||||
|
'Rejected Requests': true,
|
||||||
|
'My Pending Actions': true,
|
||||||
|
'TAT Compliance': true,
|
||||||
|
'Delayed Workflows': true,
|
||||||
|
'Average Cycle Time': true
|
||||||
|
},
|
||||||
|
Spectator: {
|
||||||
|
'Total Requests': true,
|
||||||
|
'Open Requests': true,
|
||||||
|
'Approved Requests': true,
|
||||||
|
'Rejected Requests': true,
|
||||||
|
'My Pending Actions': true,
|
||||||
|
'TAT Compliance': true,
|
||||||
|
'Delayed Workflows': true,
|
||||||
|
'Average Cycle Time': true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// TODO: Implement API call to save dashboard configuration
|
||||||
|
console.log('Saving dashboard configuration:', config);
|
||||||
|
toast.success('Dashboard layout saved successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKPIToggle = (role: Role, kpi: KPICard, checked: boolean) => {
|
||||||
|
setConfig(prev => ({
|
||||||
|
...prev,
|
||||||
|
[role]: {
|
||||||
|
...prev[role],
|
||||||
|
[kpi]: checked
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const roles: Role[] = ['Initiator', 'Approver', 'Spectator'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dashboard Layout Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Control which KPI cards are visible for each user role
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<DashboardNote />
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<RoleDashboardSection
|
||||||
|
key={role}
|
||||||
|
role={role}
|
||||||
|
kpis={config[role]}
|
||||||
|
onKPIToggle={(kpi, checked) => handleKPIToggle(role, kpi, checked)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} className="bg-re-green hover:bg-re-green/90">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save Dashboard Layout
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
10
src/components/admin/DashboardConfig/DashboardNote.tsx
Normal file
10
src/components/admin/DashboardConfig/DashboardNote.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export function DashboardNote() {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
<strong>Note:</strong> These settings control what information is visible to each role on their dashboard. Admins always have access to all metrics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Role, KPICard } from './DashboardConfig';
|
||||||
|
|
||||||
|
interface RoleDashboardSectionProps {
|
||||||
|
role: Role;
|
||||||
|
kpis: Record<string, boolean>;
|
||||||
|
onKPIToggle: (kpi: KPICard, checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpiCards: KPICard[] = [
|
||||||
|
'Total Requests',
|
||||||
|
'Open Requests',
|
||||||
|
'Approved Requests',
|
||||||
|
'Rejected Requests',
|
||||||
|
'My Pending Actions',
|
||||||
|
'TAT Compliance',
|
||||||
|
'Delayed Workflows',
|
||||||
|
'Average Cycle Time'
|
||||||
|
];
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: Role) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'Initiator':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'Approver':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'Spectator':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoleDashboardSection({ role, kpis, onKPIToggle }: RoleDashboardSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium flex items-center gap-2">
|
||||||
|
<Badge className={getRoleBadgeColor(role)}>
|
||||||
|
{role} Dashboard
|
||||||
|
</Badge>
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 ml-4">
|
||||||
|
{kpiCards.map((kpi) => (
|
||||||
|
<div key={kpi} className="flex items-center space-x-2 p-2 bg-muted/50 rounded">
|
||||||
|
<Checkbox
|
||||||
|
id={`${role.toLowerCase()}-${kpi}`}
|
||||||
|
checked={kpis[kpi] || false}
|
||||||
|
onCheckedChange={(checked) => onKPIToggle(kpi, checked === true)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`${role.toLowerCase()}-${kpi}`}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
{kpi}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
5
src/components/admin/DashboardConfig/index.ts
Normal file
5
src/components/admin/DashboardConfig/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { DashboardConfig } from './DashboardConfig';
|
||||||
|
export type { Role, KPICard } from './DashboardConfig';
|
||||||
|
export { DashboardNote } from './DashboardNote';
|
||||||
|
export { RoleDashboardSection } from './RoleDashboardSection';
|
||||||
|
|
||||||
119
src/components/admin/DocumentConfig/AllowedFileTypes.tsx
Normal file
119
src/components/admin/DocumentConfig/AllowedFileTypes.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { FileCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AllowedFileTypesProps {
|
||||||
|
allowedFileTypes: string;
|
||||||
|
onAllowedFileTypesChange: (types: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common file type mappings
|
||||||
|
const FILE_TYPE_GROUPS = [
|
||||||
|
{ label: 'PDF Documents', extensions: ['pdf'] },
|
||||||
|
{ label: 'Microsoft Word', extensions: ['doc', 'docx'] },
|
||||||
|
{ label: 'Microsoft Excel', extensions: ['xls', 'xlsx'] },
|
||||||
|
{ label: 'Microsoft PowerPoint', extensions: ['ppt', 'pptx'] },
|
||||||
|
{ label: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'] },
|
||||||
|
{ label: 'CSV Files', extensions: ['csv'] },
|
||||||
|
{ label: 'Text Files', extensions: ['txt', 'rtf'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AllowedFileTypes({ allowedFileTypes, onAllowedFileTypesChange }: AllowedFileTypesProps) {
|
||||||
|
const allowedExtensions = allowedFileTypes.split(',').map(ext => ext.trim().toLowerCase());
|
||||||
|
|
||||||
|
const toggleFileType = (extensions: string[], enabled: boolean) => {
|
||||||
|
const currentExts = new Set(allowedExtensions);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
extensions.forEach(ext => currentExts.add(ext));
|
||||||
|
} else {
|
||||||
|
extensions.forEach(ext => currentExts.delete(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
onAllowedFileTypesChange(Array.from(currentExts).join(','));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGroupEnabled = (extensions: string[]) => {
|
||||||
|
return extensions.some(ext => allowedExtensions.includes(ext));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualEdit = (value: string) => {
|
||||||
|
onAllowedFileTypesChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCheck className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">Allowed File Types</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Select which file types are allowed for upload
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{FILE_TYPE_GROUPS.map((group) => {
|
||||||
|
const isEnabled = isGroupEnabled(group.extensions);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.label}
|
||||||
|
onClick={() => toggleFileType(group.extensions, !isEnabled)}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-md border-2 cursor-pointer transition-all ${
|
||||||
|
isEnabled
|
||||||
|
? 'bg-re-green/5 border-re-green/30 hover:border-re-green/50'
|
||||||
|
: 'bg-gray-50 border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
|
||||||
|
isEnabled
|
||||||
|
? 'bg-re-green border-re-green'
|
||||||
|
: 'bg-white border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEnabled && (
|
||||||
|
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-medium ${isEnabled ? 'text-re-green' : 'text-gray-600'}`}>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{group.extensions.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label htmlFor="file-types-manual" className="text-sm font-medium">
|
||||||
|
File Extensions (comma-separated)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="file-types-manual"
|
||||||
|
type="text"
|
||||||
|
value={allowedFileTypes}
|
||||||
|
onChange={(e) => handleManualEdit(e.target.value)}
|
||||||
|
placeholder="pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif"
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Edit directly or use the checkboxes above. Separate extensions with commas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/components/admin/DocumentConfig/DocumentConfig.tsx
Normal file
144
src/components/admin/DocumentConfig/DocumentConfig.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save, Loader2, FileText } from 'lucide-react';
|
||||||
|
import { DocumentUploadSettings } from './DocumentUploadSettings';
|
||||||
|
import { AllowedFileTypes } from './AllowedFileTypes';
|
||||||
|
import { getAllConfigurations, updateConfiguration, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface DocumentConfigData {
|
||||||
|
maxFileSizeMB: number;
|
||||||
|
retentionDays: number;
|
||||||
|
allowedFileTypes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentConfig() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [config, setConfig] = useState<DocumentConfigData>({
|
||||||
|
maxFileSizeMB: 10,
|
||||||
|
retentionDays: 365,
|
||||||
|
allowedFileTypes: 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif'
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigurations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfigurations = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||||
|
|
||||||
|
// Map configuration values to state
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
setConfig({
|
||||||
|
maxFileSizeMB: parseInt(configMap['MAX_FILE_SIZE_MB'] || '10'),
|
||||||
|
retentionDays: parseInt(configMap['DOCUMENT_RETENTION_DAYS'] || '365'),
|
||||||
|
allowedFileTypes: configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load document configurations:', error);
|
||||||
|
toast.error('Failed to load document configurations');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
// Save all configurations
|
||||||
|
await Promise.all([
|
||||||
|
updateConfiguration('MAX_FILE_SIZE_MB', config.maxFileSizeMB.toString()),
|
||||||
|
updateConfiguration('DOCUMENT_RETENTION_DAYS', config.retentionDays.toString()),
|
||||||
|
updateConfiguration('ALLOWED_FILE_TYPES', config.allowedFileTypes)
|
||||||
|
]);
|
||||||
|
|
||||||
|
toast.success('Document policy saved successfully');
|
||||||
|
|
||||||
|
// Reload to get updated values
|
||||||
|
await loadConfigurations();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save document configuration:', error);
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to save document configuration');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<DocumentConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-re-green mx-auto mb-4" />
|
||||||
|
<p className="text-sm text-gray-600">Loading document policy configuration...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-re-green rounded-md shadow-md">
|
||||||
|
<FileText className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold text-gray-900">Document Upload Policy</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-gray-600">
|
||||||
|
Configure file upload limits, allowed types, and retention policies
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<DocumentUploadSettings
|
||||||
|
maxFileSizeMB={config.maxFileSizeMB}
|
||||||
|
retentionDays={config.retentionDays}
|
||||||
|
onMaxFileSizeChange={(size) => updateConfig({ maxFileSizeMB: size })}
|
||||||
|
onRetentionDaysChange={(days) => updateConfig({ retentionDays: days })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<AllowedFileTypes
|
||||||
|
allowedFileTypes={config.allowedFileTypes}
|
||||||
|
onAllowedFileTypesChange={(types) => updateConfig({ allowedFileTypes: types })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save Document Policy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Upload, Archive } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DocumentUploadSettingsProps {
|
||||||
|
maxFileSizeMB: number;
|
||||||
|
retentionDays: number;
|
||||||
|
onMaxFileSizeChange: (size: number) => void;
|
||||||
|
onRetentionDaysChange: (days: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentUploadSettings({
|
||||||
|
maxFileSizeMB,
|
||||||
|
retentionDays,
|
||||||
|
onMaxFileSizeChange,
|
||||||
|
onRetentionDaysChange
|
||||||
|
}: DocumentUploadSettingsProps) {
|
||||||
|
// Convert days to years for display (optional, or keep as days)
|
||||||
|
const retentionYears = Math.round((retentionDays / 365) * 10) / 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Upload className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">Upload & Retention Settings</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Configure file size limits and document retention period
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max-upload" className="text-sm font-medium">
|
||||||
|
Maximum Upload Size (MB)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="max-upload"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={maxFileSizeMB}
|
||||||
|
onChange={(e) => onMaxFileSizeChange(parseInt(e.target.value) || 10)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Upload className="w-3 h-3" />
|
||||||
|
Maximum allowed file size for document uploads
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="retention" className="text-sm font-medium">
|
||||||
|
Retention Period (Days)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="retention"
|
||||||
|
type="number"
|
||||||
|
min="30"
|
||||||
|
max="3650"
|
||||||
|
value={retentionDays}
|
||||||
|
onChange={(e) => onRetentionDaysChange(parseInt(e.target.value) || 365)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Archive className="w-3 h-3" />
|
||||||
|
Days to retain documents after workflow closure ({retentionYears} years)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/components/admin/DocumentConfig/index.ts
Normal file
4
src/components/admin/DocumentConfig/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { DocumentConfig } from './DocumentConfig';
|
||||||
|
export { DocumentUploadSettings } from './DocumentUploadSettings';
|
||||||
|
export { AllowedFileTypes } from './AllowedFileTypes';
|
||||||
|
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
interface EmailTemplateSectionProps {
|
||||||
|
emailTemplate: string;
|
||||||
|
onEmailTemplateChange: (template: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailTemplateSection({
|
||||||
|
emailTemplate,
|
||||||
|
onEmailTemplateChange
|
||||||
|
}: EmailTemplateSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email-template">Email Template Message</Label>
|
||||||
|
<Textarea
|
||||||
|
id="email-template"
|
||||||
|
rows={5}
|
||||||
|
value={emailTemplate}
|
||||||
|
onChange={(e) => onEmailTemplateChange(e.target.value)}
|
||||||
|
className="resize-none"
|
||||||
|
placeholder="Enter email template message..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use placeholders: [Name], [Request ID], [TAT], [Status]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
|
interface NotificationChannelsProps {
|
||||||
|
emailNotifications: boolean;
|
||||||
|
inAppNotifications: boolean;
|
||||||
|
autoReminders: boolean;
|
||||||
|
onEmailNotificationsChange: (enabled: boolean) => void;
|
||||||
|
onInAppNotificationsChange: (enabled: boolean) => void;
|
||||||
|
onAutoRemindersChange: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationChannels({
|
||||||
|
emailNotifications,
|
||||||
|
inAppNotifications,
|
||||||
|
autoReminders,
|
||||||
|
onEmailNotificationsChange,
|
||||||
|
onInAppNotificationsChange,
|
||||||
|
onAutoRemindersChange
|
||||||
|
}: NotificationChannelsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium">Notification Channels</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Email Notifications</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Send notifications via email</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={emailNotifications}
|
||||||
|
onCheckedChange={onEmailNotificationsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">In-App Notifications</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Show notifications in the portal</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={inAppNotifications}
|
||||||
|
onCheckedChange={onInAppNotificationsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Auto-Reminders</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Automatically send reminders for pending approvals</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoReminders}
|
||||||
|
onCheckedChange={onAutoRemindersChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
import { NotificationChannels } from './NotificationChannels';
|
||||||
|
import { NotificationSettings } from './NotificationSettings';
|
||||||
|
import { EmailTemplateSection } from './EmailTemplateSection';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface NotificationConfigData {
|
||||||
|
emailNotifications: boolean;
|
||||||
|
inAppNotifications: boolean;
|
||||||
|
autoReminders: boolean;
|
||||||
|
notificationFrequency: string;
|
||||||
|
reminderFrequency: number;
|
||||||
|
emailTemplate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationConfig() {
|
||||||
|
const [config, setConfig] = useState<NotificationConfigData>({
|
||||||
|
emailNotifications: true,
|
||||||
|
inAppNotifications: true,
|
||||||
|
autoReminders: true,
|
||||||
|
notificationFrequency: 'Immediate',
|
||||||
|
reminderFrequency: 12,
|
||||||
|
emailTemplate: 'Dear [Name], You have a pending approval request [Request ID] that requires your attention. TAT remaining: [TAT]. Please review at your earliest convenience.'
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// TODO: Implement API call to save notification configuration
|
||||||
|
console.log('Saving notification configuration:', config);
|
||||||
|
toast.success('Notification configuration saved successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<NotificationConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Notification Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure notification channels, frequency, and message templates
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<NotificationChannels
|
||||||
|
emailNotifications={config.emailNotifications}
|
||||||
|
inAppNotifications={config.inAppNotifications}
|
||||||
|
autoReminders={config.autoReminders}
|
||||||
|
onEmailNotificationsChange={(enabled) => updateConfig({ emailNotifications: enabled })}
|
||||||
|
onInAppNotificationsChange={(enabled) => updateConfig({ inAppNotifications: enabled })}
|
||||||
|
onAutoRemindersChange={(enabled) => updateConfig({ autoReminders: enabled })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<NotificationSettings
|
||||||
|
notificationFrequency={config.notificationFrequency}
|
||||||
|
reminderFrequency={config.reminderFrequency}
|
||||||
|
onNotificationFrequencyChange={(frequency) => updateConfig({ notificationFrequency: frequency })}
|
||||||
|
onReminderFrequencyChange={(hours) => updateConfig({ reminderFrequency: hours })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EmailTemplateSection
|
||||||
|
emailTemplate={config.emailTemplate}
|
||||||
|
onEmailTemplateChange={(template) => updateConfig({ emailTemplate: template })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} className="bg-re-green hover:bg-re-green/90">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save Notification Settings
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface NotificationSettingsProps {
|
||||||
|
notificationFrequency: string;
|
||||||
|
reminderFrequency: number;
|
||||||
|
onNotificationFrequencyChange: (frequency: string) => void;
|
||||||
|
onReminderFrequencyChange: (hours: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationSettings({
|
||||||
|
notificationFrequency,
|
||||||
|
reminderFrequency,
|
||||||
|
onNotificationFrequencyChange,
|
||||||
|
onReminderFrequencyChange
|
||||||
|
}: NotificationSettingsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="notification-frequency">Notification Frequency</Label>
|
||||||
|
<Select value={notificationFrequency} onValueChange={onNotificationFrequencyChange}>
|
||||||
|
<SelectTrigger id="notification-frequency">
|
||||||
|
<SelectValue placeholder="Select frequency" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Immediate">Immediate</SelectItem>
|
||||||
|
<SelectItem value="Hourly">Hourly</SelectItem>
|
||||||
|
<SelectItem value="Daily">Daily</SelectItem>
|
||||||
|
<SelectItem value="Weekly">Weekly</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reminder-frequency">Reminder Frequency (hours)</Label>
|
||||||
|
<Input
|
||||||
|
id="reminder-frequency"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="168"
|
||||||
|
value={reminderFrequency}
|
||||||
|
onChange={(e) => onReminderFrequencyChange(parseInt(e.target.value) || 12)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
5
src/components/admin/NotificationConfig/index.ts
Normal file
5
src/components/admin/NotificationConfig/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { NotificationConfig } from './NotificationConfig';
|
||||||
|
export { NotificationChannels } from './NotificationChannels';
|
||||||
|
export { NotificationSettings } from './NotificationSettings';
|
||||||
|
export { EmailTemplateSection } from './EmailTemplateSection';
|
||||||
|
|
||||||
68
src/components/admin/SharingConfig/SharingConfig.tsx
Normal file
68
src/components/admin/SharingConfig/SharingConfig.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
import { SharingPermissions } from './SharingPermissions';
|
||||||
|
import { SharingOptions } from './SharingOptions';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface SharingConfigData {
|
||||||
|
spectatorPermission: string;
|
||||||
|
linkSharingPermission: string;
|
||||||
|
requirePassword: boolean;
|
||||||
|
allowExternalSharing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SharingConfig() {
|
||||||
|
const [config, setConfig] = useState<SharingConfigData>({
|
||||||
|
spectatorPermission: 'Initiator & Approver',
|
||||||
|
linkSharingPermission: 'Admin & Initiator',
|
||||||
|
requirePassword: true,
|
||||||
|
allowExternalSharing: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// TODO: Implement API call to save sharing configuration
|
||||||
|
console.log('Saving sharing configuration:', config);
|
||||||
|
toast.success('Sharing policy saved successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<SharingConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Workflow Sharing Policy</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Control who can add spectators and share workflow links
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<SharingPermissions
|
||||||
|
spectatorPermission={config.spectatorPermission}
|
||||||
|
linkSharingPermission={config.linkSharingPermission}
|
||||||
|
onSpectatorPermissionChange={(permission) => updateConfig({ spectatorPermission: permission })}
|
||||||
|
onLinkSharingPermissionChange={(permission) => updateConfig({ linkSharingPermission: permission })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<SharingOptions
|
||||||
|
requirePassword={config.requirePassword}
|
||||||
|
allowExternalSharing={config.allowExternalSharing}
|
||||||
|
onRequirePasswordChange={(enabled) => updateConfig({ requirePassword: enabled })}
|
||||||
|
onAllowExternalSharingChange={(enabled) => updateConfig({ allowExternalSharing: enabled })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} className="bg-re-green hover:bg-re-green/90">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save Sharing Policy
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
42
src/components/admin/SharingConfig/SharingOptions.tsx
Normal file
42
src/components/admin/SharingConfig/SharingOptions.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
|
interface SharingOptionsProps {
|
||||||
|
requirePassword: boolean;
|
||||||
|
allowExternalSharing: boolean;
|
||||||
|
onRequirePasswordChange: (enabled: boolean) => void;
|
||||||
|
onAllowExternalSharingChange: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SharingOptions({
|
||||||
|
requirePassword,
|
||||||
|
allowExternalSharing,
|
||||||
|
onRequirePasswordChange,
|
||||||
|
onAllowExternalSharingChange
|
||||||
|
}: SharingOptionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Require Password for Shared Links</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Add password protection to workflow sharing links</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={requirePassword}
|
||||||
|
onCheckedChange={onRequirePasswordChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Allow External Sharing</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Enable sharing workflows with external users outside the organization</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={allowExternalSharing}
|
||||||
|
onCheckedChange={onAllowExternalSharingChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
63
src/components/admin/SharingConfig/SharingPermissions.tsx
Normal file
63
src/components/admin/SharingConfig/SharingPermissions.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface SharingPermissionsProps {
|
||||||
|
spectatorPermission: string;
|
||||||
|
linkSharingPermission: string;
|
||||||
|
onSpectatorPermissionChange: (permission: string) => void;
|
||||||
|
onLinkSharingPermissionChange: (permission: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionOptions = [
|
||||||
|
'Admin Only',
|
||||||
|
'Initiator Only',
|
||||||
|
'Approver Only',
|
||||||
|
'Initiator & Approver',
|
||||||
|
'Admin & Initiator',
|
||||||
|
'Admin & Approver',
|
||||||
|
'All Roles'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SharingPermissions({
|
||||||
|
spectatorPermission,
|
||||||
|
linkSharingPermission,
|
||||||
|
onSpectatorPermissionChange,
|
||||||
|
onLinkSharingPermissionChange
|
||||||
|
}: SharingPermissionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="spectator-permission">Spectator Addition Permission</Label>
|
||||||
|
<Select value={spectatorPermission} onValueChange={onSpectatorPermissionChange}>
|
||||||
|
<SelectTrigger id="spectator-permission">
|
||||||
|
<SelectValue placeholder="Select permission" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{permissionOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="link-sharing-permission">Link Sharing Permission</Label>
|
||||||
|
<Select value={linkSharingPermission} onValueChange={onLinkSharingPermissionChange}>
|
||||||
|
<SelectTrigger id="link-sharing-permission">
|
||||||
|
<SelectValue placeholder="Select permission" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{permissionOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
4
src/components/admin/SharingConfig/index.ts
Normal file
4
src/components/admin/SharingConfig/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { SharingConfig } from './SharingConfig';
|
||||||
|
export { SharingPermissions } from './SharingPermissions';
|
||||||
|
export { SharingOptions } from './SharingOptions';
|
||||||
|
|
||||||
79
src/components/admin/TATConfig/EscalationSettings.tsx
Normal file
79
src/components/admin/TATConfig/EscalationSettings.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { Bell, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EscalationSettingsProps {
|
||||||
|
reminderThreshold1: number;
|
||||||
|
reminderThreshold2: number;
|
||||||
|
onReminderThreshold1Change: (threshold: number) => void;
|
||||||
|
onReminderThreshold2Change: (threshold: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EscalationSettings({
|
||||||
|
reminderThreshold1,
|
||||||
|
reminderThreshold2,
|
||||||
|
onReminderThreshold1Change,
|
||||||
|
onReminderThreshold2Change
|
||||||
|
}: EscalationSettingsProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">Auto-Reminder & Escalation</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Configure automatic reminder thresholds based on TAT percentage
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="reminder-threshold-1" className="text-sm font-medium">
|
||||||
|
First Reminder Threshold
|
||||||
|
</Label>
|
||||||
|
<span className="text-lg font-semibold text-re-green">{reminderThreshold1}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="reminder-threshold-1"
|
||||||
|
value={[reminderThreshold1]}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
onValueChange={([value]) => onReminderThreshold1Change(value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Bell className="w-3 h-3" />
|
||||||
|
Send first gentle reminder when {reminderThreshold1}% of TAT elapsed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="reminder-threshold-2" className="text-sm font-medium">
|
||||||
|
Second Reminder Threshold (Escalation)
|
||||||
|
</Label>
|
||||||
|
<span className="text-lg font-semibold text-re-green">{reminderThreshold2}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="reminder-threshold-2"
|
||||||
|
value={[reminderThreshold2]}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
onValueChange={([value]) => onReminderThreshold2Change(value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Send escalation warning when {reminderThreshold2}% of TAT elapsed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/admin/TATConfig/PriorityTATSettings.tsx
Normal file
73
src/components/admin/TATConfig/PriorityTATSettings.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Zap, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PriorityTATSettingsProps {
|
||||||
|
expressHours: number;
|
||||||
|
standardHours: number;
|
||||||
|
onExpressChange: (hours: number) => void;
|
||||||
|
onStandardChange: (hours: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriorityTATSettings({
|
||||||
|
expressHours,
|
||||||
|
standardHours,
|
||||||
|
onExpressChange,
|
||||||
|
onStandardChange
|
||||||
|
}: PriorityTATSettingsProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">Priority TAT Settings</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Set default turnaround time in hours for each priority level
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tat-express" className="text-sm font-medium">
|
||||||
|
Express Priority (hours)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tat-express"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="168"
|
||||||
|
value={expressHours}
|
||||||
|
onChange={(e) => onExpressChange(parseInt(e.target.value) || 24)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Critical/Emergency requests (24/7, includes weekends)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tat-standard" className="text-sm font-medium">
|
||||||
|
Standard Priority (hours)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tat-standard"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="720"
|
||||||
|
value={standardHours}
|
||||||
|
onChange={(e) => onStandardChange(parseInt(e.target.value) || 72)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Regular priority requests (working hours only, excludes weekends & holidays)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/components/admin/TATConfig/TATConfig.tsx
Normal file
180
src/components/admin/TATConfig/TATConfig.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save, Loader2, Clock } from 'lucide-react';
|
||||||
|
import { PriorityTATSettings } from './PriorityTATSettings';
|
||||||
|
import { EscalationSettings } from './EscalationSettings';
|
||||||
|
import { WorkingHoursSettings } from './WorkingHoursSettings';
|
||||||
|
import { getAllConfigurations, updateConfiguration, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface TATConfigData {
|
||||||
|
expressHours: number;
|
||||||
|
standardHours: number;
|
||||||
|
reminderThreshold1: number;
|
||||||
|
reminderThreshold2: number;
|
||||||
|
workStartHour: number;
|
||||||
|
workEndHour: number;
|
||||||
|
workStartDay: number;
|
||||||
|
workEndDay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TATConfig() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [config, setConfig] = useState<TATConfigData>({
|
||||||
|
expressHours: 24,
|
||||||
|
standardHours: 72,
|
||||||
|
reminderThreshold1: 50,
|
||||||
|
reminderThreshold2: 75,
|
||||||
|
workStartHour: 9,
|
||||||
|
workEndHour: 18,
|
||||||
|
workStartDay: 1,
|
||||||
|
workEndDay: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigurations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfigurations = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const configs = await getAllConfigurations('TAT_SETTINGS');
|
||||||
|
|
||||||
|
// Map configuration values to state
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
setConfig({
|
||||||
|
expressHours: parseInt(configMap['DEFAULT_TAT_EXPRESS_HOURS'] || '24'),
|
||||||
|
standardHours: parseInt(configMap['DEFAULT_TAT_STANDARD_HOURS'] || '72'),
|
||||||
|
reminderThreshold1: parseInt(configMap['TAT_REMINDER_THRESHOLD_1'] || '50'),
|
||||||
|
reminderThreshold2: parseInt(configMap['TAT_REMINDER_THRESHOLD_2'] || '75'),
|
||||||
|
workStartHour: parseInt(configMap['WORK_START_HOUR'] || '9'),
|
||||||
|
workEndHour: parseInt(configMap['WORK_END_HOUR'] || '18'),
|
||||||
|
workStartDay: parseInt(configMap['WORK_START_DAY'] || '1'),
|
||||||
|
workEndDay: parseInt(configMap['WORK_END_DAY'] || '5')
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load TAT configurations:', error);
|
||||||
|
toast.error('Failed to load TAT configurations');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
// Save all configurations
|
||||||
|
await Promise.all([
|
||||||
|
updateConfiguration('DEFAULT_TAT_EXPRESS_HOURS', config.expressHours.toString()),
|
||||||
|
updateConfiguration('DEFAULT_TAT_STANDARD_HOURS', config.standardHours.toString()),
|
||||||
|
updateConfiguration('TAT_REMINDER_THRESHOLD_1', config.reminderThreshold1.toString()),
|
||||||
|
updateConfiguration('TAT_REMINDER_THRESHOLD_2', config.reminderThreshold2.toString()),
|
||||||
|
updateConfiguration('WORK_START_HOUR', config.workStartHour.toString()),
|
||||||
|
updateConfiguration('WORK_END_HOUR', config.workEndHour.toString()),
|
||||||
|
updateConfiguration('WORK_START_DAY', config.workStartDay.toString()),
|
||||||
|
updateConfiguration('WORK_END_DAY', config.workEndDay.toString())
|
||||||
|
]);
|
||||||
|
|
||||||
|
toast.success('TAT configuration saved successfully');
|
||||||
|
|
||||||
|
// Reload to get updated values
|
||||||
|
await loadConfigurations();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save TAT configuration:', error);
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to save TAT configuration');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<TATConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-re-green mx-auto mb-4" />
|
||||||
|
<p className="text-sm text-gray-600">Loading TAT configuration...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-re-green rounded-md shadow-md">
|
||||||
|
<Clock className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold text-gray-900">Turn Around Time (TAT) Configuration</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-gray-600">
|
||||||
|
Set default TAT hours per priority level, working hours, and configure auto-escalation thresholds
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<PriorityTATSettings
|
||||||
|
expressHours={config.expressHours}
|
||||||
|
standardHours={config.standardHours}
|
||||||
|
onExpressChange={(hours) => updateConfig({ expressHours: hours })}
|
||||||
|
onStandardChange={(hours) => updateConfig({ standardHours: hours })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<WorkingHoursSettings
|
||||||
|
workStartHour={config.workStartHour}
|
||||||
|
workEndHour={config.workEndHour}
|
||||||
|
workStartDay={config.workStartDay}
|
||||||
|
workEndDay={config.workEndDay}
|
||||||
|
onWorkStartHourChange={(hour) => updateConfig({ workStartHour: hour })}
|
||||||
|
onWorkEndHourChange={(hour) => updateConfig({ workEndHour: hour })}
|
||||||
|
onWorkStartDayChange={(day) => updateConfig({ workStartDay: day })}
|
||||||
|
onWorkEndDayChange={(day) => updateConfig({ workEndDay: day })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<EscalationSettings
|
||||||
|
reminderThreshold1={config.reminderThreshold1}
|
||||||
|
reminderThreshold2={config.reminderThreshold2}
|
||||||
|
onReminderThreshold1Change={(threshold) => updateConfig({ reminderThreshold1: threshold })}
|
||||||
|
onReminderThreshold2Change={(threshold) => updateConfig({ reminderThreshold2: threshold })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save TAT Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/components/admin/TATConfig/WorkingHoursSettings.tsx
Normal file
165
src/components/admin/TATConfig/WorkingHoursSettings.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Calendar, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface WorkingHoursSettingsProps {
|
||||||
|
workStartHour: number;
|
||||||
|
workEndHour: number;
|
||||||
|
workStartDay: number;
|
||||||
|
workEndDay: number;
|
||||||
|
onWorkStartHourChange: (hour: number) => void;
|
||||||
|
onWorkEndHourChange: (hour: number) => void;
|
||||||
|
onWorkStartDayChange: (day: number) => void;
|
||||||
|
onWorkEndDayChange: (day: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
{ value: 1, label: 'Monday' },
|
||||||
|
{ value: 2, label: 'Tuesday' },
|
||||||
|
{ value: 3, label: 'Wednesday' },
|
||||||
|
{ value: 4, label: 'Thursday' },
|
||||||
|
{ value: 5, label: 'Friday' },
|
||||||
|
{ value: 6, label: 'Saturday' },
|
||||||
|
{ value: 7, label: 'Sunday' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WorkingHoursSettings({
|
||||||
|
workStartHour,
|
||||||
|
workEndHour,
|
||||||
|
workStartDay,
|
||||||
|
workEndDay,
|
||||||
|
onWorkStartHourChange,
|
||||||
|
onWorkEndHourChange,
|
||||||
|
onWorkStartDayChange,
|
||||||
|
onWorkEndDayChange
|
||||||
|
}: WorkingHoursSettingsProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">Working Hours Configuration</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Define your organization's working hours and days for TAT calculations
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="work-start-hour" className="text-sm font-medium">
|
||||||
|
Working Day Start Hour
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="work-start-hour"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="23"
|
||||||
|
value={workStartHour}
|
||||||
|
onChange={(e) => onWorkStartHourChange(parseInt(e.target.value) || 9)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
24-hour format (0-23). Default: 9 AM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="work-end-hour" className="text-sm font-medium">
|
||||||
|
Working Day End Hour
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="work-end-hour"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="23"
|
||||||
|
value={workEndHour}
|
||||||
|
onChange={(e) => onWorkEndHourChange(parseInt(e.target.value) || 18)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
24-hour format (0-23). Default: 6 PM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="work-start-day" className="text-sm font-medium">
|
||||||
|
Working Week Start Day
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={workStartDay.toString()}
|
||||||
|
onValueChange={(value) => onWorkStartDayChange(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="work-start-day"
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select start day" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DAYS_OF_WEEK.map((day) => (
|
||||||
|
<SelectItem key={day.value} value={day.value.toString()}>
|
||||||
|
{day.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Day when the working week starts (1=Monday, 7=Sunday)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="work-end-day" className="text-sm font-medium">
|
||||||
|
Working Week End Day
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={workEndDay.toString()}
|
||||||
|
onValueChange={(value) => onWorkEndDayChange(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="work-end-day"
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select end day" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DAYS_OF_WEEK.map((day) => (
|
||||||
|
<SelectItem key={day.value} value={day.value.toString()}>
|
||||||
|
{day.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Day when the working week ends (1=Monday, 7=Sunday)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
|
<p className="text-sm text-blue-900">
|
||||||
|
<strong>Current Configuration:</strong> Working hours are from {workStartHour}:00 to {workEndHour}:00,
|
||||||
|
{workStartDay === workEndDay
|
||||||
|
? ` ${DAYS_OF_WEEK.find(d => d.value === workStartDay)?.label} only`
|
||||||
|
: ` ${DAYS_OF_WEEK.find(d => d.value === workStartDay)?.label} to ${DAYS_OF_WEEK.find(d => d.value === workEndDay)?.label}`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
5
src/components/admin/TATConfig/index.ts
Normal file
5
src/components/admin/TATConfig/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { TATConfig } from './TATConfig';
|
||||||
|
export { PriorityTATSettings } from './PriorityTATSettings';
|
||||||
|
export { EscalationSettings } from './EscalationSettings';
|
||||||
|
export { WorkingHoursSettings } from './WorkingHoursSettings';
|
||||||
|
|
||||||
718
src/components/admin/UserManagement/UserManagement.tsx
Normal file
718
src/components/admin/UserManagement/UserManagement.tsx
Normal file
@ -0,0 +1,718 @@
|
|||||||
|
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 {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
Shield,
|
||||||
|
UserCog,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Crown,
|
||||||
|
User as UserIcon,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Power
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { userApi } from '@/services/userApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { UserTable } from './UserTable';
|
||||||
|
import { UserStatsCards } from './UserStatsCards';
|
||||||
|
|
||||||
|
// Simple debounce function
|
||||||
|
function debounce<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
return function executedFunction(...args: Parameters<T>) {
|
||||||
|
const later = () => {
|
||||||
|
timeout = null;
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OktaUser {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
displayName?: string;
|
||||||
|
department?: string;
|
||||||
|
designation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
role: 'USER' | 'MANAGEMENT' | 'ADMIN';
|
||||||
|
department?: string;
|
||||||
|
designation?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserManagement() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<OktaUser[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<OktaUser | null>(null);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<'USER' | 'MANAGEMENT' | 'ADMIN'>('USER');
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [fetchingRole, setFetchingRole] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Users list with filtering and pagination
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
|
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0, total: 0, active: 0, inactive: 0 });
|
||||||
|
|
||||||
|
// Pagination and filtering
|
||||||
|
const [roleFilter, setRoleFilter] = useState<'ELEVATED' | 'ALL' | 'ADMIN' | 'MANAGEMENT' | 'USER'>('ELEVATED');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalUsers, setTotalUsers] = useState(0);
|
||||||
|
const limit = 10;
|
||||||
|
|
||||||
|
// Refs for search container
|
||||||
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Search users from Okta
|
||||||
|
const searchUsers = useCallback(
|
||||||
|
debounce(async (query: string) => {
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await userApi.searchUsers(query, 20);
|
||||||
|
const users = response.data?.data || [];
|
||||||
|
setSearchResults(users);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: error.response?.data?.message || 'Failed to search users'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, 300),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle search input
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const query = e.target.value;
|
||||||
|
setSearchQuery(query);
|
||||||
|
searchUsers(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch user's current role
|
||||||
|
const fetchUserRole = async (email: string): Promise<'USER' | 'MANAGEMENT' | 'ADMIN' | null> => {
|
||||||
|
try {
|
||||||
|
// First check if user exists in current users list
|
||||||
|
const existingUser = users.find(u => u.email.toLowerCase() === email.toLowerCase());
|
||||||
|
if (existingUser) {
|
||||||
|
return existingUser.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found, try to fetch from backend by checking all users
|
||||||
|
// We'll search with a broader filter to find the user
|
||||||
|
const response = await userApi.getUsersByRole('ALL', 1, 1000);
|
||||||
|
const allUsers = response.data?.data?.users || [];
|
||||||
|
const foundUser = allUsers.find((u: any) =>
|
||||||
|
u.email?.toLowerCase() === email.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundUser && foundUser.role) {
|
||||||
|
return foundUser.role as 'USER' | 'MANAGEMENT' | 'ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // User not found in system, no role assigned
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user role:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select user from search results
|
||||||
|
const handleSelectUser = async (user: OktaUser) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setSearchQuery(user.email);
|
||||||
|
setSearchResults([]);
|
||||||
|
setFetchingRole(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch and set the user's current role if they have one
|
||||||
|
const currentRole = await fetchUserRole(user.email);
|
||||||
|
if (currentRole) {
|
||||||
|
setSelectedRole(currentRole);
|
||||||
|
} else {
|
||||||
|
// Default to USER if no role found
|
||||||
|
setSelectedRole('USER');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user role:', error);
|
||||||
|
setSelectedRole('USER'); // Default on error
|
||||||
|
} finally {
|
||||||
|
setFetchingRole(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
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 users list
|
||||||
|
await fetchUsers();
|
||||||
|
await fetchRoleStatistics();
|
||||||
|
|
||||||
|
toast.success(`Role assigned successfully`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Role assignment failed:', error);
|
||||||
|
const errorMsg = error.response?.data?.error || 'Failed to assign role';
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: errorMsg
|
||||||
|
});
|
||||||
|
toast.error(errorMsg);
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch users with filtering and pagination
|
||||||
|
const fetchUsers = async (page: number = currentPage) => {
|
||||||
|
setLoadingUsers(true);
|
||||||
|
try {
|
||||||
|
const response = await userApi.getUsersByRole(roleFilter, page, limit);
|
||||||
|
|
||||||
|
const usersData = response.data?.data?.users || [];
|
||||||
|
const paginationData = response.data?.data?.pagination;
|
||||||
|
const summaryData = response.data?.data?.summary;
|
||||||
|
|
||||||
|
setUsers(usersData.map((u: any) => ({
|
||||||
|
userId: u.userId,
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName || u.email,
|
||||||
|
role: u.role || 'USER',
|
||||||
|
department: u.department,
|
||||||
|
designation: u.designation,
|
||||||
|
isActive: u.isActive !== false // Default to true if not specified
|
||||||
|
})));
|
||||||
|
|
||||||
|
if (paginationData) {
|
||||||
|
setCurrentPage(paginationData.currentPage);
|
||||||
|
setTotalPages(paginationData.totalPages);
|
||||||
|
setTotalUsers(paginationData.totalUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update summary stats if available
|
||||||
|
if (summaryData) {
|
||||||
|
setRoleStats(prev => ({
|
||||||
|
...prev,
|
||||||
|
admins: summaryData.ADMIN || 0,
|
||||||
|
management: summaryData.MANAGEMENT || 0,
|
||||||
|
users: summaryData.USER || 0,
|
||||||
|
total: (summaryData.ADMIN || 0) + (summaryData.MANAGEMENT || 0) + (summaryData.USER || 0)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch users:', error);
|
||||||
|
toast.error('Failed to load users');
|
||||||
|
} finally {
|
||||||
|
setLoadingUsers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch role statistics
|
||||||
|
const fetchRoleStatistics = async () => {
|
||||||
|
try {
|
||||||
|
const response = await userApi.getRoleStatistics();
|
||||||
|
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
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')
|
||||||
|
};
|
||||||
|
|
||||||
|
setRoleStats(prev => ({
|
||||||
|
...prev,
|
||||||
|
...stats,
|
||||||
|
total: stats.admins + stats.management + stats.users,
|
||||||
|
active: prev.active || stats.admins + stats.management + stats.users,
|
||||||
|
inactive: prev.inactive || 0
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch statistics:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load data on mount and when filter changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers(1); // Reset to page 1 when filter changes
|
||||||
|
fetchRoleStatistics();
|
||||||
|
}, [roleFilter]);
|
||||||
|
|
||||||
|
// Handle filter change
|
||||||
|
const handleFilterChange = (value: string) => {
|
||||||
|
setRoleFilter(value as any);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle page change
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
fetchUsers(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle edit user role
|
||||||
|
const handleEditUser = async (userId: string, newRole: 'USER' | 'MANAGEMENT' | 'ADMIN') => {
|
||||||
|
try {
|
||||||
|
await userApi.updateUserRole(userId, newRole);
|
||||||
|
toast.success('User role updated successfully');
|
||||||
|
await fetchUsers();
|
||||||
|
await fetchRoleStatistics();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to update user role:', error);
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to update user role');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle toggle user status (placeholder - needs backend support)
|
||||||
|
const handleToggleUserStatus = async (userId: string) => {
|
||||||
|
const user = users.find(u => u.userId === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
// TODO: Implement backend API for toggling user status
|
||||||
|
toast.info('User status toggle functionality coming soon');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle delete user (placeholder - needs backend support)
|
||||||
|
const handleDeleteUser = async (userId: string) => {
|
||||||
|
const user = users.find(u => u.userId === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
if (user.role === 'ADMIN') {
|
||||||
|
toast.error('Cannot delete admin user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement backend API for deleting users
|
||||||
|
toast.info('User deletion functionality coming soon');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle click outside to close search results
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (searchResults.length > 0) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [searchResults]);
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'ADMIN':
|
||||||
|
return 'bg-yellow-400 text-slate-900';
|
||||||
|
case 'MANAGEMENT':
|
||||||
|
return 'bg-blue-400 text-slate-900';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-400 text-white';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleIcon = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'ADMIN':
|
||||||
|
return <Crown className="w-5 h-5" />;
|
||||||
|
case 'MANAGEMENT':
|
||||||
|
return <Users className="w-5 h-5" />;
|
||||||
|
default:
|
||||||
|
return <UserIcon className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate stats for UserStatsCards
|
||||||
|
const stats = {
|
||||||
|
total: roleStats.total,
|
||||||
|
active: roleStats.active,
|
||||||
|
inactive: roleStats.inactive,
|
||||||
|
admins: roleStats.admins
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<UserStatsCards stats={stats} />
|
||||||
|
|
||||||
|
{/* Assign Role Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Assign User Role</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Search for a user in Okta and assign them a role
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAssignRole} disabled={!selectedUser || updating} className="bg-re-green hover:bg-re-green/90">
|
||||||
|
{updating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Assigning...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Assign Role
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="space-y-2" ref={searchContainerRef}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Type name or email address..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
className="pl-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
{searching && (
|
||||||
|
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-re-green animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Start typing to search across all Okta users</p>
|
||||||
|
|
||||||
|
{/* Search Results Dropdown */}
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="border rounded-lg shadow-lg bg-white max-h-60 overflow-y-auto">
|
||||||
|
<div className="sticky top-0 bg-muted px-4 py-2 border-b">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground">
|
||||||
|
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
{searchResults.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.userId}
|
||||||
|
onClick={() => handleSelectUser(user)}
|
||||||
|
className="w-full text-left p-3 hover:bg-muted rounded-lg transition-colors mb-1 last:mb-0"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-gray-900">{user.displayName || user.email}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||||
|
{user.department && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{user.department}{user.designation ? ` • ${user.designation}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected User */}
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="border-2 border-re-green/20 bg-re-green/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-re-green flex items-center justify-center text-white font-bold shadow-md">
|
||||||
|
{(selectedUser.displayName || selectedUser.email).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{selectedUser.displayName || selectedUser.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{selectedUser.email}</p>
|
||||||
|
{selectedUser.department && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{selectedUser.department}{selectedUser.designation ? ` • ${selectedUser.designation}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{fetchingRole && (
|
||||||
|
<p className="text-xs text-re-green mt-1 flex items-center gap-1">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
Checking current role...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser(null);
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedRole('USER');
|
||||||
|
setFetchingRole(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Select Role</label>
|
||||||
|
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)} disabled={fetchingRole}>
|
||||||
|
<SelectTrigger className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20">
|
||||||
|
<SelectValue placeholder={fetchingRole ? "Loading current role..." : "Select role"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-lg">
|
||||||
|
<SelectItem value="USER" className="p-3 rounded-lg my-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserIcon className="w-4 h-4 text-gray-600" />
|
||||||
|
<span>User - Regular access</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MANAGEMENT" className="p-3 rounded-lg my-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-blue-600" />
|
||||||
|
<span>Management - Read all data</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ADMIN" className="p-3 rounded-lg my-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crown className="w-4 h-4 text-yellow-600" />
|
||||||
|
<span>Administrator - Full access</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{message && (
|
||||||
|
<div className={`border-2 rounded-lg p-4 ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'border-green-200 bg-green-50'
|
||||||
|
: 'border-red-200 bg-red-50'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{message.type === 'success' ? (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
<p className={`text-sm ${message.type === 'success' ? 'text-green-800' : 'text-red-800'}`}>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Users List with Filter and Pagination */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-re-green" />
|
||||||
|
User Management
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
View and manage user accounts and roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={roleFilter} onValueChange={handleFilterChange}>
|
||||||
|
<SelectTrigger className="w-[200px] border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20">
|
||||||
|
<SelectValue placeholder="Filter by role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ELEVATED">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-purple-600" />
|
||||||
|
<span>Elevated ({roleStats.admins + roleStats.management})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ADMIN">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crown className="w-4 h-4 text-yellow-600" />
|
||||||
|
<span>Admins ({roleStats.admins})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MANAGEMENT">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-blue-600" />
|
||||||
|
<span>Management ({roleStats.management})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="USER">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserIcon className="w-4 h-4 text-gray-600" />
|
||||||
|
<span>Users ({roleStats.users})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ALL">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-gray-600" />
|
||||||
|
<span>All Users ({roleStats.admins + roleStats.management + roleStats.users})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
{loadingUsers ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-re-green mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading users...</p>
|
||||||
|
</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Users className="w-6 h-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-gray-700">No users found</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{roleFilter === 'ELEVATED'
|
||||||
|
? 'Assign ADMIN or MANAGEMENT roles to see users here'
|
||||||
|
: 'No users match the selected filter'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserTable
|
||||||
|
users={users.map(u => ({
|
||||||
|
id: u.userId,
|
||||||
|
name: u.displayName,
|
||||||
|
email: u.email,
|
||||||
|
role: u.role, // Keep as ADMIN, MANAGEMENT, or USER
|
||||||
|
department: u.department || 'N/A',
|
||||||
|
status: u.isActive ? 'active' : 'inactive'
|
||||||
|
}))}
|
||||||
|
onEdit={(userId) => {
|
||||||
|
const user = users.find(u => u.userId === userId);
|
||||||
|
if (user) {
|
||||||
|
// Open role selection dialog
|
||||||
|
const newRole = user.role === 'USER' ? 'MANAGEMENT' : user.role === 'MANAGEMENT' ? 'ADMIN' : 'USER';
|
||||||
|
handleEditUser(userId, newRole);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onToggleStatus={handleToggleUserStatus}
|
||||||
|
onDelete={handleDeleteUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t mt-4">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Showing {((currentPage - 1) * limit) + 1} to {Math.min(currentPage * limit, totalUsers)} of {totalUsers} users
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
let pageNum;
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage <= 3) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
pageNum = totalPages - 4 + i;
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 2 + i;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
variant={currentPage === pageNum ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(pageNum)}
|
||||||
|
className={`w-9 h-9 p-0 ${
|
||||||
|
currentPage === pageNum
|
||||||
|
? 'bg-re-green hover:bg-re-green/90'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/admin/UserManagement/UserSearchBar.tsx
Normal file
24
src/components/admin/UserManagement/UserSearchBar.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UserSearchBarProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserSearchBar({ searchQuery, onSearchChange }: UserSearchBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search users by name, email, or department..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
32
src/components/admin/UserManagement/UserStatsCards.tsx
Normal file
32
src/components/admin/UserManagement/UserStatsCards.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
interface UserStatsCardsProps {
|
||||||
|
stats: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
inactive: number;
|
||||||
|
admins: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserStatsCards({ stats }: UserStatsCardsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Total Users</p>
|
||||||
|
<p className="text-2xl font-semibold">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Active</p>
|
||||||
|
<p className="text-2xl font-semibold text-green-600">{stats.active}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-red-50 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Inactive</p>
|
||||||
|
<p className="text-2xl font-semibold text-red-600">{stats.inactive}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-purple-50 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Admins</p>
|
||||||
|
<p className="text-2xl font-semibold text-purple-600">{stats.admins}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
131
src/components/admin/UserManagement/UserTable.tsx
Normal file
131
src/components/admin/UserManagement/UserTable.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { SquarePen, Power, Trash2, CircleCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TableUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: 'ADMIN' | 'MANAGEMENT' | 'USER';
|
||||||
|
department: string;
|
||||||
|
status: 'active' | 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserTableProps {
|
||||||
|
users: TableUser[];
|
||||||
|
onEdit: (userId: string) => void;
|
||||||
|
onToggleStatus: (userId: string) => void;
|
||||||
|
onDelete: (userId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: string) => {
|
||||||
|
switch (role.toUpperCase()) {
|
||||||
|
case 'ADMIN':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||||
|
case 'MANAGEMENT':
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
case 'USER':
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.substring(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserTable({ users, onEdit, onToggleStatus, onDelete }: UserTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Department</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||||
|
No users found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<TableRow key={user.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="size-10">
|
||||||
|
<AvatarFallback className="bg-re-green text-white">
|
||||||
|
{getUserInitials(user.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{user.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={getRoleBadgeColor(user.role)}>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{user.department}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className="bg-green-100 text-green-800 border-green-200">
|
||||||
|
<CircleCheck className="w-3 h-3 mr-1" />
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(user.id)}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<SquarePen className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onToggleStatus(user.id)}
|
||||||
|
disabled={user.role.toUpperCase() === 'ADMIN'}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(user.id)}
|
||||||
|
disabled={user.role.toUpperCase() === 'ADMIN'}
|
||||||
|
className="h-8 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
6
src/components/admin/UserManagement/index.ts
Normal file
6
src/components/admin/UserManagement/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { UserManagement } from './UserManagement';
|
||||||
|
export type { User } from './UserManagement';
|
||||||
|
export { UserSearchBar } from './UserSearchBar';
|
||||||
|
export { UserStatsCards } from './UserStatsCards';
|
||||||
|
export { UserTable } from './UserTable';
|
||||||
|
|
||||||
727
src/components/admin/UserRoleManager/UserRoleManager.tsx
Normal file
727
src/components/admin/UserRoleManager/UserRoleManager.tsx
Normal file
@ -0,0 +1,727 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
Shield,
|
||||||
|
UserCog,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Crown,
|
||||||
|
User as UserIcon
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { userApi } from '@/services/userApi';
|
||||||
|
|
||||||
|
// Simple debounce function
|
||||||
|
function debounce<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
return function executedFunction(...args: Parameters<T>) {
|
||||||
|
const later = () => {
|
||||||
|
timeout = null;
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OktaUser {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
displayName?: string;
|
||||||
|
department?: string;
|
||||||
|
designation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserWithRole {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
role: 'USER' | 'MANAGEMENT' | 'ADMIN';
|
||||||
|
department?: string;
|
||||||
|
designation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserRoleManager() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<OktaUser[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<OktaUser | null>(null);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<'USER' | 'MANAGEMENT' | 'ADMIN'>('USER');
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Users list with filtering and pagination
|
||||||
|
const [users, setUsers] = useState<UserWithRole[]>([]);
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
|
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0 });
|
||||||
|
|
||||||
|
// Pagination and filtering
|
||||||
|
const [roleFilter, setRoleFilter] = useState<'ELEVATED' | 'ALL' | 'ADMIN' | 'MANAGEMENT' | 'USER'>('ELEVATED');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalUsers, setTotalUsers] = useState(0);
|
||||||
|
const limit = 10;
|
||||||
|
|
||||||
|
// Refs for search container and user list
|
||||||
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const userListRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Search users from Okta
|
||||||
|
const searchUsers = useCallback(
|
||||||
|
debounce(async (query: string) => {
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await userApi.searchUsers(query, 20);
|
||||||
|
console.log('Search response:', response);
|
||||||
|
console.log('Response.data:', response.data);
|
||||||
|
|
||||||
|
// Backend returns { success: true, data: [...users], message, timestamp }
|
||||||
|
// Axios response is in response.data, actual user array is in response.data.data
|
||||||
|
const users = response.data?.data || [];
|
||||||
|
console.log('Parsed users:', users);
|
||||||
|
|
||||||
|
setSearchResults(users);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: error.response?.data?.message || 'Failed to search users'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, 300),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle search input
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const query = e.target.value;
|
||||||
|
setSearchQuery(query);
|
||||||
|
searchUsers(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select user from search results
|
||||||
|
const handleSelectUser = (user: OktaUser) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setSearchQuery(user.email);
|
||||||
|
setSearchResults([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign role to user
|
||||||
|
const handleAssignRole = async () => {
|
||||||
|
if (!selectedUser || !selectedRole) {
|
||||||
|
setMessage({ type: 'error', text: 'Please select a user and role' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdating(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Call backend to assign role (will create user if doesn't exist)
|
||||||
|
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 users list
|
||||||
|
await fetchUsers();
|
||||||
|
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 filtering and pagination
|
||||||
|
const fetchUsers = async (page: number = currentPage) => {
|
||||||
|
setLoadingUsers(true);
|
||||||
|
try {
|
||||||
|
const response = await userApi.getUsersByRole(roleFilter, page, limit);
|
||||||
|
|
||||||
|
console.log('Users response:', response);
|
||||||
|
|
||||||
|
// Backend returns { success: true, data: { users: [...], pagination: {...}, summary: {...} } }
|
||||||
|
const usersData = response.data?.data?.users || [];
|
||||||
|
const paginationData = response.data?.data?.pagination;
|
||||||
|
const summaryData = response.data?.data?.summary;
|
||||||
|
|
||||||
|
console.log('Parsed users:', usersData);
|
||||||
|
console.log('Pagination:', paginationData);
|
||||||
|
console.log('Summary:', summaryData);
|
||||||
|
|
||||||
|
setUsers(usersData);
|
||||||
|
|
||||||
|
if (paginationData) {
|
||||||
|
setCurrentPage(paginationData.currentPage);
|
||||||
|
setTotalPages(paginationData.totalPages);
|
||||||
|
setTotalUsers(paginationData.totalUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update summary stats if available
|
||||||
|
if (summaryData) {
|
||||||
|
setRoleStats({
|
||||||
|
admins: summaryData.ADMIN || 0,
|
||||||
|
management: summaryData.MANAGEMENT || 0,
|
||||||
|
users: summaryData.USER || 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} 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 and when filter changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers(1); // Reset to page 1 when filter changes
|
||||||
|
fetchRoleStatistics();
|
||||||
|
}, [roleFilter]);
|
||||||
|
|
||||||
|
// Handle filter change
|
||||||
|
const handleFilterChange = (value: string) => {
|
||||||
|
setRoleFilter(value as any);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle page change
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
fetchUsers(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle statistics card click - filter and scroll to user list
|
||||||
|
const handleStatCardClick = (filter: 'ADMIN' | 'MANAGEMENT' | 'USER') => {
|
||||||
|
setRoleFilter(filter);
|
||||||
|
setCurrentPage(1);
|
||||||
|
|
||||||
|
// Immediate scroll without waiting for data load
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
const element = userListRef.current;
|
||||||
|
if (element) {
|
||||||
|
element.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'start'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle click outside to close search results
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (searchResults.length > 0) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [searchResults]);
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'ADMIN':
|
||||||
|
return 'bg-yellow-400 text-slate-900';
|
||||||
|
case 'MANAGEMENT':
|
||||||
|
return 'bg-blue-400 text-slate-900';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-400 text-white';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleIcon = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'ADMIN':
|
||||||
|
return <Crown className="w-5 h-5" />;
|
||||||
|
case 'MANAGEMENT':
|
||||||
|
return <Users className="w-5 h-5" />;
|
||||||
|
default:
|
||||||
|
return <UserIcon className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 sm:gap-6">
|
||||||
|
<Card
|
||||||
|
className={`border-2 bg-gradient-to-br from-yellow-50 to-yellow-100/50 hover:shadow-lg transition-all rounded-xl cursor-pointer ${
|
||||||
|
roleFilter === 'ADMIN' ? 'border-yellow-400 shadow-lg' : 'border-transparent shadow-md'
|
||||||
|
}`}
|
||||||
|
data-testid="admin-count-card"
|
||||||
|
onClick={() => handleStatCardClick('ADMIN')}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Administrators</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mt-2" data-testid="admin-count">{roleStats.admins}</p>
|
||||||
|
<p className="text-xs text-yellow-700 mt-1 font-semibold">
|
||||||
|
{roleFilter === 'ADMIN' ? '✓ Viewing' : 'Click to view'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gradient-to-br from-yellow-400 to-yellow-500 rounded-xl shadow-md">
|
||||||
|
<Crown className="w-6 h-6 text-slate-900" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={`border-2 bg-gradient-to-br from-blue-50 to-blue-100/50 hover:shadow-lg transition-all rounded-xl cursor-pointer ${
|
||||||
|
roleFilter === 'MANAGEMENT' ? 'border-blue-400 shadow-lg' : 'border-transparent shadow-md'
|
||||||
|
}`}
|
||||||
|
data-testid="management-count-card"
|
||||||
|
onClick={() => handleStatCardClick('MANAGEMENT')}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Management</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mt-2" data-testid="management-count">{roleStats.management}</p>
|
||||||
|
<p className="text-xs text-blue-700 mt-1 font-semibold">
|
||||||
|
{roleFilter === 'MANAGEMENT' ? '✓ Viewing' : 'Click to view'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gradient-to-br from-blue-400 to-blue-500 rounded-xl shadow-md">
|
||||||
|
<Users className="w-6 h-6 text-slate-900" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card
|
||||||
|
className={`border-2 bg-gradient-to-br from-gray-50 to-gray-100/50 hover:shadow-lg transition-all rounded-xl cursor-pointer ${
|
||||||
|
roleFilter === 'USER' ? 'border-gray-400 shadow-lg' : 'border-transparent shadow-md'
|
||||||
|
}`}
|
||||||
|
data-testid="user-count-card"
|
||||||
|
onClick={() => handleStatCardClick('USER')}
|
||||||
|
>
|
||||||
|
<CardContent className="p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold text-gray-600 uppercase tracking-wide">Regular Users</p>
|
||||||
|
<p className="text-3xl font-bold text-gray-900 mt-2" data-testid="user-count">{roleStats.users}</p>
|
||||||
|
<p className="text-xs text-gray-700 mt-1 font-semibold">
|
||||||
|
{roleFilter === 'USER' ? '✓ Viewing' : 'Click to view'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-gradient-to-br from-gray-400 to-gray-500 rounded-xl shadow-md">
|
||||||
|
<UserIcon className="w-6 h-6 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assign Role Section */}
|
||||||
|
<Card className="shadow-lg border">
|
||||||
|
<CardHeader className="border-b pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-gradient-to-br from-purple-500 to-purple-600 rounded-lg shadow-md">
|
||||||
|
<UserCog className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold">Assign User Role</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Search for a user in Okta and assign them a role
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-5 pt-6">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="space-y-2" ref={searchContainerRef}>
|
||||||
|
<label className="text-sm font-medium text-gray-700">Search User</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400 pointer-events-none" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Type name or email address..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
className="pl-10 pr-10 h-12 border rounded-lg border-gray-300 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 transition-all"
|
||||||
|
data-testid="user-search-input"
|
||||||
|
/>
|
||||||
|
{searching && (
|
||||||
|
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-purple-500 animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500">Start typing to search across all Okta users</p>
|
||||||
|
|
||||||
|
{/* Search Results Dropdown */}
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="border border-purple-200 rounded-lg shadow-lg bg-white max-h-60 overflow-y-auto">
|
||||||
|
<div className="sticky top-0 bg-purple-50 px-4 py-2 border-b border-purple-100">
|
||||||
|
<p className="text-xs font-semibold text-purple-700">
|
||||||
|
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
{searchResults.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.userId}
|
||||||
|
onClick={() => handleSelectUser(user)}
|
||||||
|
className="w-full text-left p-3 hover:bg-purple-50 rounded-lg transition-colors mb-1 last:mb-0"
|
||||||
|
data-testid={`user-result-${user.email}`}
|
||||||
|
>
|
||||||
|
<p className="font-medium text-gray-900">{user.displayName || user.email}</p>
|
||||||
|
<p className="text-sm text-gray-600">{user.email}</p>
|
||||||
|
{user.department && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{user.department}{user.designation ? ` • ${user.designation}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected User */}
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="border-2 border-purple-200 bg-purple-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-purple-500 to-purple-600 flex items-center justify-center text-white font-bold shadow-md">
|
||||||
|
{(selectedUser.displayName || selectedUser.email).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{selectedUser.displayName || selectedUser.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-600">{selectedUser.email}</p>
|
||||||
|
{selectedUser.department && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
{selectedUser.department}{selectedUser.designation ? ` • ${selectedUser.designation}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser(null);
|
||||||
|
setSearchQuery('');
|
||||||
|
}}
|
||||||
|
className="hover:bg-purple-100"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-gray-700">Select Role</label>
|
||||||
|
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)}>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-12 border border-gray-300 py-2 rounded-lg focus:border-purple-500 focus:ring-1 focus:ring-purple-200 transition-all"
|
||||||
|
data-testid="role-select"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-lg">
|
||||||
|
<SelectItem value="USER" className="p-3 rounded-lg my-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserIcon className="w-4 h-4 text-gray-600" />
|
||||||
|
<span>User - Regular access</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MANAGEMENT" className="p-3 rounded-lg my-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-blue-600" />
|
||||||
|
<span>Management - Read all data</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ADMIN" className="p-3 rounded-lg my-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crown className="w-4 h-4 text-yellow-600" />
|
||||||
|
<span>Administrator - Full access</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Assign Button */}
|
||||||
|
<Button
|
||||||
|
onClick={handleAssignRole}
|
||||||
|
disabled={!selectedUser || updating}
|
||||||
|
className="w-full h-12 bg-gradient-to-r from-purple-500 to-purple-600 hover:from-purple-600 hover:to-purple-700 text-white font-medium shadow-md hover:shadow-lg transition-all disabled:opacity-50 rounded-lg"
|
||||||
|
data-testid="assign-role-button"
|
||||||
|
>
|
||||||
|
{updating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Assigning Role...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Assign Role
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{message && (
|
||||||
|
<div className={`border-2 rounded-lg p-4 ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'border-green-200 bg-green-50'
|
||||||
|
: 'border-red-200 bg-red-50'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{message.type === 'success' ? (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
<p className={`text-sm ${message.type === 'success' ? 'text-green-800' : 'text-red-800'}`}>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Users List with Filter and Pagination */}
|
||||||
|
<div ref={userListRef}>
|
||||||
|
<Card className="shadow-lg border">
|
||||||
|
<CardHeader className="border-b pb-4">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-gradient-to-br from-indigo-500 to-indigo-600 rounded-lg shadow-md">
|
||||||
|
<Shield className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold">User Management</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
View and manage user roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={roleFilter} onValueChange={handleFilterChange}>
|
||||||
|
<SelectTrigger className="w-[200px] h-10 border rounded-lg border-gray-300">
|
||||||
|
<SelectValue placeholder="Filter by role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ELEVATED">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-purple-600" />
|
||||||
|
<span>Elevated ({roleStats.admins + roleStats.management})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ADMIN">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crown className="w-4 h-4 text-yellow-600" />
|
||||||
|
<span>Admins ({roleStats.admins})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MANAGEMENT">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-blue-600" />
|
||||||
|
<span>Management ({roleStats.management})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="USER">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserIcon className="w-4 h-4 text-gray-600" />
|
||||||
|
<span>Users ({roleStats.users})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ALL">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-gray-600" />
|
||||||
|
<span>All Users ({roleStats.admins + roleStats.management + roleStats.users})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
{loadingUsers ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-purple-500 mb-2" />
|
||||||
|
<p className="text-sm text-gray-500">Loading users...</p>
|
||||||
|
</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Users className="w-6 h-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-gray-700">No users found</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{roleFilter === 'ELEVATED'
|
||||||
|
? 'Assign ADMIN or MANAGEMENT roles to see users here'
|
||||||
|
: 'No users match the selected filter'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2" data-testid="users-list">
|
||||||
|
{users.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.userId}
|
||||||
|
className="border-2 border-gray-100 hover:border-purple-200 hover:shadow-md transition-all rounded-lg bg-white p-4"
|
||||||
|
data-testid={`user-${user.email}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||||
|
<div className={`w-10 h-10 rounded-lg ${getRoleBadgeColor(user.role)} flex items-center justify-center shadow-sm`}>
|
||||||
|
{getRoleIcon(user.role)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="font-semibold text-gray-900 truncate">{user.displayName}</p>
|
||||||
|
<p className="text-sm text-gray-600 truncate">{user.email}</p>
|
||||||
|
{user.department && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1 truncate">
|
||||||
|
{user.department}{user.designation ? ` • ${user.designation}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Badge className={`${getRoleBadgeColor(user.role)} shrink-0`} data-testid={`role-badge-${user.role}`}>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Showing {((currentPage - 1) * limit) + 1} to {Math.min(currentPage * limit, totalUsers)} of {totalUsers} users
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
data-testid="prev-page-button"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
let pageNum;
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage <= 3) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
pageNum = totalPages - 4 + i;
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 2 + i;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
variant={currentPage === pageNum ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(pageNum)}
|
||||||
|
className={`w-9 h-9 p-0 ${
|
||||||
|
currentPage === pageNum
|
||||||
|
? 'bg-purple-500 hover:bg-purple-600'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
data-testid={`page-${pageNum}-button`}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
data-testid="next-page-button"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/admin/UserRoleManager/index.ts
Normal file
2
src/components/admin/UserRoleManager/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { UserRoleManager } from './UserRoleManager';
|
||||||
|
|
||||||
172
src/components/approval/SkipApproverModal/SkipApproverModal.tsx
Normal file
172
src/components/approval/SkipApproverModal/SkipApproverModal.tsx
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { AlertCircle, X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface SkipApproverModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (reason: string) => Promise<void> | void;
|
||||||
|
approverName?: string;
|
||||||
|
levelNumber?: number;
|
||||||
|
requestIdDisplay?: string;
|
||||||
|
requestTitle?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SkipApproverModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
onConfirm,
|
||||||
|
approverName,
|
||||||
|
levelNumber,
|
||||||
|
requestIdDisplay,
|
||||||
|
requestTitle
|
||||||
|
}: SkipApproverModalProps) {
|
||||||
|
const [reason, setReason] = useState('');
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const handleConfirm = async () => {
|
||||||
|
if (!reason.trim()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
await onConfirm(reason.trim());
|
||||||
|
setReason('');
|
||||||
|
onClose();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to skip approver:', error);
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
if (!isSubmitting) {
|
||||||
|
setReason('');
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
|
<DialogContent className="sm:max-w-md max-h-[90vh] flex flex-col p-0">
|
||||||
|
<button
|
||||||
|
onClick={handleClose}
|
||||||
|
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none z-50"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 bg-orange-100 rounded-lg flex items-center justify-center">
|
||||||
|
<AlertCircle className="w-5 h-5 text-orange-600" />
|
||||||
|
</div>
|
||||||
|
<DialogTitle className="text-xl font-bold text-gray-900">Skip Approver</DialogTitle>
|
||||||
|
</div>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 px-6 py-4 overflow-y-auto flex-1">
|
||||||
|
{/* Warning Message */}
|
||||||
|
<div className="bg-orange-50 border border-orange-200 rounded-lg p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-orange-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-semibold text-orange-900 mb-1">Important Notice</p>
|
||||||
|
<p className="text-sm text-orange-800">
|
||||||
|
You are about to skip the current approver. The request will be moved to the next approval level.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Request Information */}
|
||||||
|
{(requestIdDisplay || requestTitle) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold text-gray-700">Request Details</Label>
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3 space-y-1">
|
||||||
|
{requestIdDisplay && (
|
||||||
|
<p className="text-sm text-gray-900">
|
||||||
|
<span className="font-medium">Request ID:</span> {requestIdDisplay}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{requestTitle && (
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
<span className="font-medium">Title:</span> {requestTitle}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Approver Information */}
|
||||||
|
{(approverName || levelNumber) && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-semibold text-gray-700">Approver Being Skipped</Label>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3 space-y-1">
|
||||||
|
{levelNumber && (
|
||||||
|
<p className="text-sm text-blue-900">
|
||||||
|
<span className="font-medium">Level:</span> {levelNumber}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{approverName && (
|
||||||
|
<p className="text-sm text-blue-900">
|
||||||
|
<span className="font-medium">Approver:</span> {approverName}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reason Input */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="skip-reason" className="text-sm font-semibold text-gray-700">
|
||||||
|
Reason for Skipping *
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="skip-reason"
|
||||||
|
placeholder="Please provide a detailed reason for skipping this approver (e.g., 'Approver is on leave until [date]', 'Approver unavailable - escalating to next level')"
|
||||||
|
value={reason}
|
||||||
|
onChange={(e) => setReason(e.target.value)}
|
||||||
|
className="min-h-[100px] border-2 border-gray-300 focus:border-orange-500"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
This reason will be recorded in the activity log and all participants will be notified.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex items-center gap-3 px-6 py-4 border-t flex-shrink-0 bg-white">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleClose}
|
||||||
|
className="flex-1 h-11 border-gray-300"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="flex-1 h-11 bg-orange-600 hover:bg-orange-700 text-white"
|
||||||
|
disabled={isSubmitting || !reason.trim()}
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4 mr-2" />
|
||||||
|
{isSubmitting ? 'Skipping...' : 'Skip Approver'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/approval/SkipApproverModal/index.ts
Normal file
2
src/components/approval/SkipApproverModal/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { SkipApproverModal } from './SkipApproverModal';
|
||||||
|
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ActionStatusModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
success: boolean;
|
||||||
|
title?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionStatusModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
success,
|
||||||
|
title,
|
||||||
|
message
|
||||||
|
}: ActionStatusModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{success ? (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
)}
|
||||||
|
{title || (success ? 'Success' : 'Error')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-6">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
{success ? (
|
||||||
|
<>
|
||||||
|
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mb-4">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
{message || 'Operation completed successfully!'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
||||||
|
<XCircle className="w-8 h-8 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700">
|
||||||
|
{message || 'Operation failed. Please try again.'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={onClose}
|
||||||
|
className={`w-full ${success ? 'bg-green-600 hover:bg-green-700' : 'bg-gray-600 hover:bg-gray-700'}`}
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/common/ActionStatusModal/index.ts
Normal file
2
src/components/common/ActionStatusModal/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { ActionStatusModal } from './ActionStatusModal';
|
||||||
|
|
||||||
75
src/components/common/PageHeader/PageHeader.tsx
Normal file
75
src/components/common/PageHeader/PageHeader.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface PageHeaderProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
badge?: {
|
||||||
|
value: string | number;
|
||||||
|
label: string;
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
actions?: ReactNode;
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PageHeader({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
badge,
|
||||||
|
actions,
|
||||||
|
testId = 'page-header'
|
||||||
|
}: PageHeaderProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col lg:flex-row lg:items-center justify-between gap-3 sm:gap-4 md:gap-6"
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<div className="space-y-1 sm:space-y-2">
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
|
<div
|
||||||
|
className="w-10 h-10 sm:w-12 sm:h-12 bg-gradient-to-br from-slate-800 to-slate-900 rounded-xl flex items-center justify-center shadow-lg"
|
||||||
|
data-testid={`${testId}-icon-container`}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className="w-5 h-5 sm:w-6 sm:h-6 text-white"
|
||||||
|
data-testid={`${testId}-icon`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1
|
||||||
|
className="text-xl sm:text-2xl md:text-3xl font-bold text-gray-900"
|
||||||
|
data-testid={`${testId}-title`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</h1>
|
||||||
|
<p
|
||||||
|
className="text-sm sm:text-base text-gray-600"
|
||||||
|
data-testid={`${testId}-description`}
|
||||||
|
>
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 sm:gap-3">
|
||||||
|
{badge && (
|
||||||
|
<Badge
|
||||||
|
variant="secondary"
|
||||||
|
className="text-xs sm:text-sm md:text-base px-2 sm:px-3 md:px-4 py-1 sm:py-1.5 md:py-2 bg-slate-100 text-slate-800 font-semibold"
|
||||||
|
data-testid={`${testId}-badge`}
|
||||||
|
>
|
||||||
|
{badge.loading ? 'Loading…' : badge.value}
|
||||||
|
<span className="hidden sm:inline ml-1">{badge.label}</span>
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/common/PageHeader/index.ts
Normal file
2
src/components/common/PageHeader/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { PageHeader } from './PageHeader';
|
||||||
|
|
||||||
141
src/components/common/Pagination/Pagination.tsx
Normal file
141
src/components/common/Pagination/Pagination.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PaginationProps {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalRecords: number;
|
||||||
|
itemsPerPage: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
loading?: boolean;
|
||||||
|
itemLabel?: string; // e.g., "requests", "activities", "approvers"
|
||||||
|
testIdPrefix?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({
|
||||||
|
currentPage,
|
||||||
|
totalPages,
|
||||||
|
itemsPerPage,
|
||||||
|
totalRecords,
|
||||||
|
onPageChange,
|
||||||
|
loading = false,
|
||||||
|
itemLabel = 'items',
|
||||||
|
testIdPrefix = 'pagination'
|
||||||
|
}: PaginationProps) {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Don't show pagination if only 1 page or loading
|
||||||
|
if (totalPages <= 1 || loading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startItem = ((currentPage - 1) * itemsPerPage) + 1;
|
||||||
|
const endItem = Math.min(currentPage * itemsPerPage, totalRecords);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="shadow-md" data-testid={`${testIdPrefix}-container`}>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3">
|
||||||
|
<div
|
||||||
|
className="text-xs sm:text-sm text-muted-foreground"
|
||||||
|
data-testid={`${testIdPrefix}-info`}
|
||||||
|
>
|
||||||
|
Showing {startItem} to {endItem} of {totalRecords} {itemLabel}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2" data-testid={`${testIdPrefix}-controls`}>
|
||||||
|
{/* Previous Button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
data-testid={`${testIdPrefix}-prev-btn`}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4 rotate-180" />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{/* First page + ellipsis */}
|
||||||
|
{currentPage > 3 && totalPages > 5 && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
data-testid={`${testIdPrefix}-page-1`}
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</Button>
|
||||||
|
<span className="text-muted-foreground" data-testid={`${testIdPrefix}-ellipsis-start`}>...</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Page Numbers */}
|
||||||
|
{getPageNumbers().map((pageNum) => (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
variant={pageNum === currentPage ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(pageNum)}
|
||||||
|
className={`h-8 w-8 p-0 ${pageNum === currentPage ? 'bg-blue-600 text-white hover:bg-blue-700' : ''}`}
|
||||||
|
data-testid={`${testIdPrefix}-page-${pageNum}`}
|
||||||
|
aria-current={pageNum === currentPage ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Last page + ellipsis */}
|
||||||
|
{currentPage < totalPages - 2 && totalPages > 5 && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground" data-testid={`${testIdPrefix}-ellipsis-end`}>...</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(totalPages)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
data-testid={`${testIdPrefix}-page-${totalPages}`}
|
||||||
|
>
|
||||||
|
{totalPages}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Next Button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
data-testid={`${testIdPrefix}-next-btn`}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<ArrowRight className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/common/Pagination/index.ts
Normal file
2
src/components/common/Pagination/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Pagination } from './Pagination';
|
||||||
|
|
||||||
215
src/components/dashboard/ActivityFeedItem/ActivityFeedItem.tsx
Normal file
215
src/components/dashboard/ActivityFeedItem/ActivityFeedItem.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
CheckCircle,
|
||||||
|
AlertTriangle,
|
||||||
|
MessageSquare,
|
||||||
|
Flame,
|
||||||
|
FileText,
|
||||||
|
Paperclip,
|
||||||
|
Activity,
|
||||||
|
ArrowRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { differenceInMinutes, differenceInHours, differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
|
export interface ActivityData {
|
||||||
|
activityId: string;
|
||||||
|
requestNumber: string;
|
||||||
|
requestTitle: string;
|
||||||
|
action: string;
|
||||||
|
userId: string;
|
||||||
|
userName: string;
|
||||||
|
timestamp: string;
|
||||||
|
priority: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ActivityFeedItemProps {
|
||||||
|
activity: ActivityData;
|
||||||
|
currentUserId?: string;
|
||||||
|
currentUserDisplayName?: string;
|
||||||
|
currentUserEmail?: string;
|
||||||
|
onNavigate?: (requestNumber: string) => void;
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const cleanActivityDescription = (desc: string) => {
|
||||||
|
if (!desc) return desc;
|
||||||
|
|
||||||
|
// Remove email addresses in parentheses
|
||||||
|
let cleaned = desc.replace(/\s*\([^)]*@[^)]*\)/g, '');
|
||||||
|
|
||||||
|
// Remove "by [user]" at the end - we show user separately
|
||||||
|
cleaned = cleaned.replace(/\s+by\s+.+$/i, '');
|
||||||
|
|
||||||
|
// Shorten common phrases
|
||||||
|
cleaned = cleaned.replace(/has been added as approver/gi, 'added as approver');
|
||||||
|
cleaned = cleaned.replace(/has been added as spectator/gi, 'added as spectator');
|
||||||
|
cleaned = cleaned.replace(/has been/gi, '');
|
||||||
|
|
||||||
|
// Make TAT format more compact
|
||||||
|
cleaned = cleaned.replace(/with TAT of (\d+) hours?/gi, '(TAT: $1h)');
|
||||||
|
cleaned = cleaned.replace(/with TAT of (\d+) days?/gi, '(TAT: $1d)');
|
||||||
|
|
||||||
|
// Replace multiple spaces with single space
|
||||||
|
cleaned = cleaned.replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
return cleaned.trim();
|
||||||
|
};
|
||||||
|
|
||||||
|
const getActionIcon = (action: string) => {
|
||||||
|
const actionLower = action.toLowerCase();
|
||||||
|
if (actionLower.includes('approv')) return <CheckCircle className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-green-600" />;
|
||||||
|
if (actionLower.includes('reject')) return <AlertTriangle className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-red-600" />;
|
||||||
|
if (actionLower.includes('comment')) return <MessageSquare className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-blue-600" />;
|
||||||
|
if (actionLower.includes('escalat')) return <Flame className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-orange-600" />;
|
||||||
|
if (actionLower.includes('submit')) return <FileText className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-purple-600" />;
|
||||||
|
if (actionLower.includes('document')) return <Paperclip className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-indigo-600" />;
|
||||||
|
return <Activity className="w-2.5 h-2.5 sm:w-3 sm:h-3 text-gray-600" />;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ActivityFeedItem({
|
||||||
|
activity,
|
||||||
|
currentUserId,
|
||||||
|
currentUserDisplayName,
|
||||||
|
currentUserEmail,
|
||||||
|
onNavigate,
|
||||||
|
testId = 'activity-feed-item'
|
||||||
|
}: ActivityFeedItemProps) {
|
||||||
|
const isCurrentUser = activity.userId === currentUserId;
|
||||||
|
const displayName = isCurrentUser ? 'You' : (activity.userName || 'System');
|
||||||
|
|
||||||
|
const userInitials = isCurrentUser
|
||||||
|
? ((currentUserDisplayName || currentUserEmail || 'ME')
|
||||||
|
.split(' ')
|
||||||
|
.map((n: string) => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.substring(0, 2))
|
||||||
|
: activity.userName
|
||||||
|
? activity.userName
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.substring(0, 2)
|
||||||
|
: 'SY'; // System default
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2 sm:gap-3 p-3 sm:p-4 rounded-lg hover:bg-gray-50 transition-all duration-200 cursor-pointer border border-gray-100 hover:border-gray-300"
|
||||||
|
onClick={() => onNavigate?.(activity.requestNumber)}
|
||||||
|
data-testid={`${testId}-${activity.activityId}`}
|
||||||
|
>
|
||||||
|
<div className="relative flex-shrink-0 mt-0.5">
|
||||||
|
<Avatar
|
||||||
|
className={`h-8 w-8 sm:h-10 sm:w-10 ring-2 shadow-sm ${isCurrentUser ? 'ring-blue-200' : 'ring-white'}`}
|
||||||
|
data-testid={`${testId}-avatar`}
|
||||||
|
>
|
||||||
|
<AvatarImage src="" />
|
||||||
|
<AvatarFallback className={`text-white font-semibold text-xs sm:text-sm ${isCurrentUser ? 'bg-blue-600' : 'bg-slate-700'}`}>
|
||||||
|
{userInitials}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="absolute -bottom-0.5 -right-0.5 sm:-bottom-1 sm:-right-1 w-4 h-4 sm:w-5 sm:h-5 bg-white rounded-full flex items-center justify-center shadow-sm border border-gray-200">
|
||||||
|
{getActionIcon(activity.action)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Header with Request Number and Priority Badge */}
|
||||||
|
<div className="flex items-center justify-between gap-2 mb-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="font-semibold text-xs sm:text-sm text-gray-900"
|
||||||
|
data-testid={`${testId}-request-number`}
|
||||||
|
>
|
||||||
|
{activity.requestNumber}
|
||||||
|
</span>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-xs ${getPriorityColor(activity.priority)} font-medium flex-shrink-0`}
|
||||||
|
data-testid={`${testId}-priority`}
|
||||||
|
>
|
||||||
|
{activity.priority}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Action Description as Text */}
|
||||||
|
<p className="text-xs text-muted-foreground mb-1 line-clamp-2">
|
||||||
|
<span
|
||||||
|
className={`font-medium ${
|
||||||
|
activity.action.toLowerCase().includes('approv') ? 'text-green-600' :
|
||||||
|
activity.action.toLowerCase().includes('reject') ? 'text-red-600' :
|
||||||
|
activity.action.toLowerCase().includes('submit') ? 'text-blue-600' :
|
||||||
|
activity.action.toLowerCase().includes('add') ? 'text-indigo-600' :
|
||||||
|
'text-gray-700'
|
||||||
|
}`}
|
||||||
|
data-testid={`${testId}-action`}
|
||||||
|
>
|
||||||
|
{cleanActivityDescription(activity.action)}
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Request Title */}
|
||||||
|
<p
|
||||||
|
className="text-xs sm:text-sm text-gray-700 line-clamp-1 mb-1"
|
||||||
|
data-testid={`${testId}-request-title`}
|
||||||
|
>
|
||||||
|
{activity.requestTitle}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* User and Time */}
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
className={`font-medium truncate max-w-[150px] sm:max-w-[200px] ${isCurrentUser ? 'text-blue-600' : 'text-gray-900'}`}
|
||||||
|
data-testid={`${testId}-user-name`}
|
||||||
|
>
|
||||||
|
{displayName}
|
||||||
|
</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span
|
||||||
|
className="whitespace-nowrap"
|
||||||
|
data-testid={`${testId}-timestamp`}
|
||||||
|
>
|
||||||
|
{getRelativeTime(activity.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ArrowRight
|
||||||
|
className="h-4 w-4 text-gray-400 hover:text-blue-600 transition-colors flex-shrink-0 hidden sm:block mt-1"
|
||||||
|
data-testid={`${testId}-arrow`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
3
src/components/dashboard/ActivityFeedItem/index.ts
Normal file
3
src/components/dashboard/ActivityFeedItem/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { ActivityFeedItem } from './ActivityFeedItem';
|
||||||
|
export type { ActivityData } from './ActivityFeedItem';
|
||||||
|
|
||||||
138
src/components/dashboard/CriticalAlertCard/CriticalAlertCard.tsx
Normal file
138
src/components/dashboard/CriticalAlertCard/CriticalAlertCard.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Star } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface CriticalAlertData {
|
||||||
|
requestId: string;
|
||||||
|
requestNumber: string;
|
||||||
|
title: string;
|
||||||
|
priority: string;
|
||||||
|
totalTATHours: number;
|
||||||
|
originalTATHours: number;
|
||||||
|
breachCount: number;
|
||||||
|
currentLevel: number;
|
||||||
|
totalLevels: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CriticalAlertCardProps {
|
||||||
|
alert: CriticalAlertData;
|
||||||
|
onNavigate?: (requestNumber: string) => void;
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
const calculateProgress = (alert: CriticalAlertData) => {
|
||||||
|
if (!alert.originalTATHours || alert.originalTATHours === 0) return 0;
|
||||||
|
|
||||||
|
const originalTAT = alert.originalTATHours;
|
||||||
|
const remainingTAT = alert.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)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const formatRemainingTime = (alert: CriticalAlertData) => {
|
||||||
|
if (alert.totalTATHours === undefined || alert.totalTATHours === null) return 'N/A';
|
||||||
|
|
||||||
|
const hours = alert.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`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CriticalAlertCard({
|
||||||
|
alert,
|
||||||
|
onNavigate,
|
||||||
|
testId = 'critical-alert-card'
|
||||||
|
}: CriticalAlertCardProps) {
|
||||||
|
const progress = calculateProgress(alert);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="p-3 sm:p-4 bg-red-50 rounded-lg sm:rounded-xl border border-red-100 hover:shadow-md transition-all duration-200 cursor-pointer"
|
||||||
|
onClick={() => onNavigate?.(alert.requestNumber)}
|
||||||
|
data-testid={`${testId}-${alert.requestId}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-2 mb-2 sm:mb-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1 sm:gap-2 mb-1 flex-wrap">
|
||||||
|
<p
|
||||||
|
className="font-semibold text-xs sm:text-sm text-gray-900"
|
||||||
|
data-testid={`${testId}-request-number`}
|
||||||
|
>
|
||||||
|
{alert.requestNumber}
|
||||||
|
</p>
|
||||||
|
{alert.priority === 'express' && (
|
||||||
|
<Star
|
||||||
|
className="h-3 w-3 text-red-500 flex-shrink-0"
|
||||||
|
data-testid={`${testId}-priority-icon`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{alert.breachCount > 0 && (
|
||||||
|
<Badge
|
||||||
|
variant="destructive"
|
||||||
|
className="text-xs"
|
||||||
|
data-testid={`${testId}-breach-count`}
|
||||||
|
>
|
||||||
|
{alert.breachCount}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p
|
||||||
|
className="text-xs sm:text-sm text-gray-700 line-clamp-2"
|
||||||
|
data-testid={`${testId}-title`}
|
||||||
|
>
|
||||||
|
{alert.title}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className="text-xs bg-white border-red-200 text-red-700 font-medium whitespace-nowrap"
|
||||||
|
data-testid={`${testId}-remaining-time`}
|
||||||
|
>
|
||||||
|
{formatRemainingTime(alert)}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 sm:space-y-2">
|
||||||
|
<div className="flex justify-between text-xs text-gray-600">
|
||||||
|
<span>TAT Used</span>
|
||||||
|
<span
|
||||||
|
className="font-medium"
|
||||||
|
data-testid={`${testId}-progress-percentage`}
|
||||||
|
>
|
||||||
|
{progress}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress
|
||||||
|
value={progress}
|
||||||
|
className={`h-1.5 sm:h-2 ${
|
||||||
|
progress >= 80 ? '[&>div]:bg-red-600' :
|
||||||
|
progress >= 50 ? '[&>div]:bg-orange-500' :
|
||||||
|
'[&>div]:bg-green-600'
|
||||||
|
}`}
|
||||||
|
data-testid={`${testId}-progress-bar`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
3
src/components/dashboard/CriticalAlertCard/index.ts
Normal file
3
src/components/dashboard/CriticalAlertCard/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { CriticalAlertCard } from './CriticalAlertCard';
|
||||||
|
export type { CriticalAlertData } from './CriticalAlertCard';
|
||||||
|
|
||||||
72
src/components/dashboard/KPICard/KPICard.tsx
Normal file
72
src/components/dashboard/KPICard/KPICard.tsx
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface KPICardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: LucideIcon;
|
||||||
|
iconBgColor: string;
|
||||||
|
iconColor: string;
|
||||||
|
subtitle?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
testId?: string;
|
||||||
|
onClick?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function KPICard({
|
||||||
|
title,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
iconBgColor,
|
||||||
|
iconColor,
|
||||||
|
subtitle,
|
||||||
|
children,
|
||||||
|
testId = 'kpi-card',
|
||||||
|
onClick
|
||||||
|
}: KPICardProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className="hover:shadow-lg transition-all duration-200 shadow-md cursor-pointer"
|
||||||
|
onClick={onClick}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-3">
|
||||||
|
<CardTitle
|
||||||
|
className="text-sm font-medium text-muted-foreground"
|
||||||
|
data-testid={`${testId}-title`}
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</CardTitle>
|
||||||
|
<div className={`p-2 sm:p-3 rounded-lg ${iconBgColor}`} data-testid={`${testId}-icon-wrapper`}>
|
||||||
|
<Icon
|
||||||
|
className={`h-4 w-4 sm:h-5 sm:w-5 ${iconColor}`}
|
||||||
|
data-testid={`${testId}-icon`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div
|
||||||
|
className="text-2xl sm:text-3xl font-bold text-gray-900 mb-3"
|
||||||
|
data-testid={`${testId}-value`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</div>
|
||||||
|
{subtitle && (
|
||||||
|
<div
|
||||||
|
className="text-xs text-muted-foreground mb-3"
|
||||||
|
data-testid={`${testId}-subtitle`}
|
||||||
|
>
|
||||||
|
{subtitle}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{children && (
|
||||||
|
<div data-testid={`${testId}-children`}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/dashboard/KPICard/index.ts
Normal file
2
src/components/dashboard/KPICard/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { KPICard } from './KPICard';
|
||||||
|
|
||||||
41
src/components/dashboard/StatCard/StatCard.tsx
Normal file
41
src/components/dashboard/StatCard/StatCard.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
bgColor: string;
|
||||||
|
textColor: string;
|
||||||
|
testId?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
bgColor,
|
||||||
|
textColor,
|
||||||
|
testId = 'stat-card',
|
||||||
|
children
|
||||||
|
}: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`${bgColor} rounded-lg p-2 sm:p-3`}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className="text-xs text-gray-600 mb-1"
|
||||||
|
data-testid={`${testId}-label`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-lg sm:text-xl font-bold ${textColor}`}
|
||||||
|
data-testid={`${testId}-value`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/dashboard/StatCard/index.ts
Normal file
2
src/components/dashboard/StatCard/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { StatCard } from './StatCard';
|
||||||
|
|
||||||
55
src/components/dashboard/StatsCard/StatsCard.tsx
Normal file
55
src/components/dashboard/StatsCard/StatsCard.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
|
import { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface StatsCardProps {
|
||||||
|
label: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: LucideIcon;
|
||||||
|
iconColor: string;
|
||||||
|
gradient: string;
|
||||||
|
textColor: string;
|
||||||
|
valueColor: string;
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatsCard({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
icon: Icon,
|
||||||
|
iconColor,
|
||||||
|
gradient,
|
||||||
|
textColor,
|
||||||
|
valueColor,
|
||||||
|
testId = 'stats-card'
|
||||||
|
}: StatsCardProps) {
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
className={`${gradient} border transition-shadow hover:shadow-md`}
|
||||||
|
data-testid={testId}
|
||||||
|
>
|
||||||
|
<CardContent className="p-3 sm:p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p
|
||||||
|
className={`text-xs sm:text-sm font-medium ${textColor}`}
|
||||||
|
data-testid={`${testId}-label`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className={`text-xl sm:text-2xl font-bold ${valueColor}`}
|
||||||
|
data-testid={`${testId}-value`}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Icon
|
||||||
|
className={`w-6 h-6 sm:w-8 sm:h-8 ${iconColor}`}
|
||||||
|
data-testid={`${testId}-icon`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/dashboard/StatsCard/index.ts
Normal file
2
src/components/dashboard/StatsCard/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { StatsCard } from './StatsCard';
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose } from 'lucide-react';
|
import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, Activity, Shield } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -17,6 +17,9 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
import royalEnfieldLogo from '@/assets/images/royal_enfield_logo.png';
|
import royalEnfieldLogo from '@/assets/images/royal_enfield_logo.png';
|
||||||
|
import notificationApi, { Notification } from '@/services/notificationApi';
|
||||||
|
import { getSocket, joinUserRoom } from '@/utils/socket';
|
||||||
|
import { formatDistanceToNow } from 'date-fns';
|
||||||
|
|
||||||
interface PageLayoutProps {
|
interface PageLayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
@ -29,6 +32,9 @@ interface PageLayoutProps {
|
|||||||
export function PageLayout({ children, currentPage = 'dashboard', onNavigate, onNewRequest, onLogout }: PageLayoutProps) {
|
export function PageLayout({ children, currentPage = 'dashboard', onNavigate, onNewRequest, onLogout }: PageLayoutProps) {
|
||||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||||
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
|
||||||
|
const [notifications, setNotifications] = useState<Notification[]>([]);
|
||||||
|
const [unreadCount, setUnreadCount] = useState(0);
|
||||||
|
const [notificationsOpen, setNotificationsOpen] = useState(false);
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
// Get user initials for avatar
|
// Get user initials for avatar
|
||||||
@ -56,12 +62,114 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
{ id: 'my-requests', label: 'My Requests', icon: User },
|
{ id: 'my-requests', label: 'My Requests', icon: User },
|
||||||
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
|
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
|
||||||
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
||||||
|
{ id: 'admin', label: 'Admin', icon: Shield },
|
||||||
];
|
];
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
setSidebarOpen(!sidebarOpen);
|
setSidebarOpen(!sidebarOpen);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleNotificationClick = async (notification: Notification) => {
|
||||||
|
try {
|
||||||
|
// Mark as read
|
||||||
|
if (!notification.isRead) {
|
||||||
|
await notificationApi.markAsRead(notification.notificationId);
|
||||||
|
setNotifications(prev =>
|
||||||
|
prev.map(n => n.notificationId === notification.notificationId ? { ...n, isRead: true } : n)
|
||||||
|
);
|
||||||
|
setUnreadCount(prev => Math.max(0, prev - 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to the request if URL provided
|
||||||
|
if (notification.actionUrl && onNavigate) {
|
||||||
|
// Extract request number from URL (e.g., /request/REQ-2025-12345)
|
||||||
|
const requestNumber = notification.metadata?.requestNumber;
|
||||||
|
if (requestNumber) {
|
||||||
|
// Determine which tab to open based on notification type
|
||||||
|
let navigationUrl = `request/${requestNumber}`;
|
||||||
|
|
||||||
|
// Work note related notifications should open Work Notes tab
|
||||||
|
if (notification.notificationType === 'mention' ||
|
||||||
|
notification.notificationType === 'comment' ||
|
||||||
|
notification.notificationType === 'worknote') {
|
||||||
|
navigationUrl += '?tab=worknotes';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigate to request detail page
|
||||||
|
onNavigate(navigationUrl);
|
||||||
|
console.log('[PageLayout] Navigating to:', navigationUrl);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setNotificationsOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PageLayout] Error handling notification click:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMarkAllAsRead = async () => {
|
||||||
|
try {
|
||||||
|
await notificationApi.markAllAsRead();
|
||||||
|
setNotifications(prev => prev.map(n => ({ ...n, isRead: true })));
|
||||||
|
setUnreadCount(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PageLayout] Error marking all as read:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch notifications and setup real-time updates
|
||||||
|
useEffect(() => {
|
||||||
|
const userId = (user as any)?.userId;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
// Fetch initial notifications (only 4 for dropdown preview)
|
||||||
|
const fetchNotifications = async () => {
|
||||||
|
try {
|
||||||
|
const result = await notificationApi.list({ page: 1, limit: 4, unreadOnly: false });
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
const notifs = result.data?.notifications || [];
|
||||||
|
setNotifications(notifs);
|
||||||
|
setUnreadCount(result.data?.unreadCount || 0);
|
||||||
|
|
||||||
|
console.log('[PageLayout] Loaded', notifs.length, 'recent notifications,', result.data?.unreadCount, 'unread');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[PageLayout] Failed to fetch notifications:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchNotifications();
|
||||||
|
|
||||||
|
// Setup socket for real-time notifications
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL?.replace('/api/v1', '') || 'http://localhost:5000';
|
||||||
|
const socket = getSocket(baseUrl);
|
||||||
|
|
||||||
|
if (socket) {
|
||||||
|
// Join user's personal notification room
|
||||||
|
joinUserRoom(socket, userId);
|
||||||
|
|
||||||
|
// Listen for new notifications
|
||||||
|
const handleNewNotification = (data: { notification: Notification }) => {
|
||||||
|
console.log('[PageLayout] 🔔 New notification received:', data);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
setNotifications(prev => [data.notification, ...prev].slice(0, 4)); // Keep latest 4 for dropdown
|
||||||
|
setUnreadCount(prev => prev + 1);
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('notification:new', handleNewNotification);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
socket.off('notification:new', handleNewNotification);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => { mounted = false; };
|
||||||
|
}, [user]);
|
||||||
|
|
||||||
// Handle responsive behavior: sidebar open on desktop, closed on mobile
|
// Handle responsive behavior: sidebar open on desktop, closed on mobile
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleResize = () => {
|
const handleResize = () => {
|
||||||
@ -193,29 +301,85 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
New Request
|
New Request
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<DropdownMenu>
|
<DropdownMenu open={notificationsOpen} onOpenChange={setNotificationsOpen}>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<Button variant="ghost" size="icon" className="relative shrink-0 h-10 w-10">
|
<Button variant="ghost" size="icon" className="relative shrink-0 h-10 w-10">
|
||||||
<Bell className="w-5 h-5" />
|
<Bell className="w-5 h-5" />
|
||||||
|
{unreadCount > 0 && (
|
||||||
<Badge className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-destructive text-destructive-foreground text-xs flex items-center justify-center p-0">
|
<Badge className="absolute -top-1 -right-1 w-5 h-5 rounded-full bg-destructive text-destructive-foreground text-xs flex items-center justify-center p-0">
|
||||||
3
|
{unreadCount > 9 ? '9+' : unreadCount}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end" className="w-80">
|
<DropdownMenuContent align="end" className="w-96 max-h-[500px]">
|
||||||
<div className="p-3 border-b">
|
<div className="p-3 border-b flex items-center justify-between sticky top-0 bg-white z-10">
|
||||||
<h4 className="font-semibold text-base">Notifications</h4>
|
<h4 className="font-semibold text-base">Notifications</h4>
|
||||||
|
{unreadCount > 0 && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-700 h-auto p-1"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
handleMarkAllAsRead();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mark all as read
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-3 space-y-2">
|
<div className="max-h-[400px] overflow-y-auto">
|
||||||
<div className="text-sm">
|
{notifications.length === 0 ? (
|
||||||
<p className="font-medium">RE-REQ-001 needs approval</p>
|
<div className="p-6 text-center">
|
||||||
<p className="text-muted-foreground text-xs">SLA expires in 2 hours</p>
|
<Bell className="w-12 h-12 text-gray-300 mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-gray-500">No notifications yet</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm">
|
) : (
|
||||||
<p className="font-medium">New comment on RE-REQ-003</p>
|
<div className="divide-y">
|
||||||
<p className="text-muted-foreground text-xs">From John Doe - 5 min ago</p>
|
{notifications.map((notif) => (
|
||||||
|
<div
|
||||||
|
key={notif.notificationId}
|
||||||
|
className={`p-3 hover:bg-gray-50 cursor-pointer transition-colors ${
|
||||||
|
!notif.isRead ? 'bg-blue-50' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleNotificationClick(notif)}
|
||||||
|
>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{!notif.isRead && (
|
||||||
|
<div className="w-2 h-2 rounded-full bg-blue-600 mt-1.5 shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className={`text-sm ${!notif.isRead ? 'font-semibold' : 'font-medium'}`}>
|
||||||
|
{notif.title}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
|
||||||
|
{notif.message}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-1">
|
||||||
|
{formatDistanceToNow(new Date(notif.createdAt), { addSuffix: true })}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{notifications.length > 0 && (
|
||||||
|
<div className="p-2 border-t">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="w-full text-sm text-blue-600 hover:text-blue-700"
|
||||||
|
onClick={() => {
|
||||||
|
setNotificationsOpen(false);
|
||||||
|
onNavigate?.('notifications'); // Navigate to full notifications page
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
View all notifications
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,13 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Users, X, AtSign, Clock, Shield, CheckCircle, XCircle, AlertCircle } from 'lucide-react';
|
import { Users, X, AtSign, Clock, Shield, CheckCircle, XCircle, AlertCircle, Lightbulb } from 'lucide-react';
|
||||||
import { searchUsers, type UserSummary } from '@/services/userApi';
|
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
||||||
|
|
||||||
interface ApprovalLevelInfo {
|
interface ApprovalLevelInfo {
|
||||||
levelNumber: number;
|
levelNumber: number;
|
||||||
@ -41,7 +41,22 @@ export function AddApproverModal({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null); // Track if user was selected via @ search
|
||||||
const searchTimer = useRef<any>(null);
|
const searchTimer = useRef<any>(null);
|
||||||
|
const searchContainerRef = useRef<HTMLDivElement>(null); // Ref for auto-scroll
|
||||||
|
|
||||||
|
// Validation modal state
|
||||||
|
const [validationModal, setValidationModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
type: 'error' | 'not-found';
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate available levels (after completed levels)
|
// Calculate available levels (after completed levels)
|
||||||
const completedLevels = currentLevels.filter(l =>
|
const completedLevels = currentLevels.filter(l =>
|
||||||
@ -64,36 +79,66 @@ export function AddApproverModal({
|
|||||||
const emailToAdd = email.trim().toLowerCase();
|
const emailToAdd = email.trim().toLowerCase();
|
||||||
|
|
||||||
if (!emailToAdd) {
|
if (!emailToAdd) {
|
||||||
alert('Please enter an email address');
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: 'Please enter an email address'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic email validation
|
// Basic email validation
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(emailToAdd)) {
|
if (!emailRegex.test(emailToAdd)) {
|
||||||
alert('Please enter a valid email address');
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: 'Please enter a valid email address'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate TAT hours
|
// Validate TAT hours
|
||||||
if (!tatHours || tatHours <= 0) {
|
if (!tatHours || tatHours <= 0) {
|
||||||
alert('Please enter valid TAT hours (minimum 1 hour)');
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: 'Please enter valid TAT hours (minimum 1 hour)'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (tatHours > 720) {
|
if (tatHours > 720) {
|
||||||
alert('TAT hours cannot exceed 720 hours (30 days)');
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: 'TAT hours cannot exceed 720 hours (30 days)'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate level
|
// Validate level
|
||||||
if (!selectedLevel) {
|
if (!selectedLevel) {
|
||||||
alert('Please select an approval level');
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: 'Please select an approval level'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedLevel < minLevel) {
|
if (selectedLevel < minLevel) {
|
||||||
alert(`Cannot add approver at level ${selectedLevel}. Minimum allowed level is ${minLevel} (after completed levels)`);
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: `Cannot add approver at level ${selectedLevel}. Minimum allowed level is ${minLevel} (after completed levels)`
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,16 +152,78 @@ export function AddApproverModal({
|
|||||||
const userName = existingParticipant.name || emailToAdd;
|
const userName = existingParticipant.name || emailToAdd;
|
||||||
|
|
||||||
if (participantType === 'INITIATOR') {
|
if (participantType === 'INITIATOR') {
|
||||||
alert(`${userName} is the request initiator and cannot be added as an approver.`);
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: `${userName} is the request initiator and cannot be added as an approver.`
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
} else if (participantType === 'APPROVER') {
|
} else if (participantType === 'APPROVER') {
|
||||||
alert(`${userName} is already an approver on this request.`);
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: `${userName} is already an approver on this request.`
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
} else if (participantType === 'SPECTATOR') {
|
} else if (participantType === 'SPECTATOR') {
|
||||||
alert(`${userName} is currently a spectator on this request and cannot be added as an approver. Please remove them as spectator first.`);
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: `${userName} is currently a spectator on this request and cannot be added as an approver. Please remove them as spectator first.`
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
alert(`${userName} is already a participant on this request.`);
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: `${userName} is already a participant on this request.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user was NOT selected via @ search, validate against Okta
|
||||||
|
if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
|
||||||
|
try {
|
||||||
|
const response = await searchUsers(emailToAdd, 1);
|
||||||
|
// Backend returns { success: true, data: [...users] }
|
||||||
|
const searchOktaResults = response.data?.data || [];
|
||||||
|
|
||||||
|
if (searchOktaResults.length === 0) {
|
||||||
|
// User not found in Okta
|
||||||
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'not-found',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User found - ensure they exist in DB
|
||||||
|
const foundUser = searchOktaResults[0];
|
||||||
|
await ensureUserExists({
|
||||||
|
userId: foundUser.userId,
|
||||||
|
email: foundUser.email,
|
||||||
|
displayName: foundUser.displayName,
|
||||||
|
firstName: foundUser.firstName,
|
||||||
|
lastName: foundUser.lastName,
|
||||||
|
department: foundUser.department
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Validated approver: ${foundUser.displayName} (${foundUser.email})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to validate approver:', error);
|
||||||
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: 'Failed to validate user. Please try again.'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -127,6 +234,7 @@ export function AddApproverModal({
|
|||||||
setEmail('');
|
setEmail('');
|
||||||
setTatHours(24);
|
setTatHours(24);
|
||||||
setSelectedLevel(null);
|
setSelectedLevel(null);
|
||||||
|
setSelectedUser(null);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add approver:', error);
|
console.error('Failed to add approver:', error);
|
||||||
@ -141,6 +249,7 @@ export function AddApproverModal({
|
|||||||
setEmail('');
|
setEmail('');
|
||||||
setTatHours(24);
|
setTatHours(24);
|
||||||
setSelectedLevel(null);
|
setSelectedLevel(null);
|
||||||
|
setSelectedUser(null);
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
onClose();
|
onClose();
|
||||||
@ -157,6 +266,17 @@ export function AddApproverModal({
|
|||||||
return <Clock className="w-4 h-4 text-gray-400" />;
|
return <Clock className="w-4 h-4 text-gray-400" />;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Auto-scroll container when search results appear
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchResults.length > 0 && searchContainerRef.current) {
|
||||||
|
// Scroll to bottom to show the search results dropdown
|
||||||
|
searchContainerRef.current.scrollTo({
|
||||||
|
top: searchContainerRef.current.scrollHeight,
|
||||||
|
behavior: 'smooth'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [searchResults.length]);
|
||||||
|
|
||||||
// Cleanup search timer on unmount
|
// Cleanup search timer on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -170,6 +290,11 @@ export function AddApproverModal({
|
|||||||
const handleEmailChange = (value: string) => {
|
const handleEmailChange = (value: string) => {
|
||||||
setEmail(value);
|
setEmail(value);
|
||||||
|
|
||||||
|
// Clear selectedUser when manually editing (forces revalidation)
|
||||||
|
if (selectedUser && selectedUser.email.toLowerCase() !== value.toLowerCase()) {
|
||||||
|
setSelectedUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
if (searchTimer.current) {
|
if (searchTimer.current) {
|
||||||
clearTimeout(searchTimer.current);
|
clearTimeout(searchTimer.current);
|
||||||
@ -187,7 +312,9 @@ export function AddApproverModal({
|
|||||||
searchTimer.current = setTimeout(async () => {
|
searchTimer.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const term = value.slice(1); // Remove @ prefix
|
const term = value.slice(1); // Remove @ prefix
|
||||||
const results = await searchUsers(term, 10);
|
const response = await searchUsers(term, 10);
|
||||||
|
// Backend returns { success: true, data: [...users] }
|
||||||
|
const results = response.data?.data || [];
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
@ -199,25 +326,47 @@ export function AddApproverModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Select user from search results
|
// Select user from search results
|
||||||
const handleSelectUser = (user: UserSummary) => {
|
const handleSelectUser = async (user: UserSummary) => {
|
||||||
|
// Ensure user exists in DB when selected via @ search
|
||||||
|
try {
|
||||||
|
await ensureUserExists({
|
||||||
|
userId: user.userId,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
department: user.department
|
||||||
|
});
|
||||||
|
|
||||||
setEmail(user.email);
|
setEmail(user.email);
|
||||||
|
setSelectedUser(user); // Track that user was selected via @ search
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
|
console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to ensure user exists:', error);
|
||||||
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: user.email,
|
||||||
|
message: 'Failed to verify user in database. Please try again.'
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md min-h-[60vh] max-h-[90vh] flex flex-col p-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none z-50"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<DialogHeader>
|
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center">
|
||||||
<Users className="w-5 h-5 text-blue-600" />
|
<Users className="w-5 h-5 text-blue-600" />
|
||||||
@ -226,7 +375,7 @@ export function AddApproverModal({
|
|||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div ref={searchContainerRef} className="space-y-4 px-6 py-4 pb-8 overflow-y-auto flex-1">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-sm text-gray-600 leading-relaxed">
|
<p className="text-sm text-gray-600 leading-relaxed">
|
||||||
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
|
Add a new approver at a specific level. Existing approvers at and after the selected level will be shifted down.
|
||||||
@ -398,7 +547,7 @@ export function AddApproverModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center gap-3 pt-4 border-t">
|
<div className="flex items-center gap-3 px-6 py-4 border-t flex-shrink-0 bg-white">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -419,6 +568,75 @@ export function AddApproverModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Validation Error Modal */}
|
||||||
|
<Dialog open={validationModal.open} onOpenChange={(isOpen) => setValidationModal(prev => ({ ...prev, open: isOpen }))}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{validationModal.type === 'not-found' ? (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
User Not Found
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
Validation Error
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{validationModal.type === 'not-found' && (
|
||||||
|
<>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
User <strong>{validationModal.email}</strong> was not found in the organization directory.
|
||||||
|
</p>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 space-y-2">
|
||||||
|
<p className="text-sm text-red-800 font-semibold">Please verify:</p>
|
||||||
|
<ul className="text-sm text-red-700 space-y-1 list-disc list-inside">
|
||||||
|
<li>Email address is spelled correctly</li>
|
||||||
|
<li>User exists in Okta/SSO system</li>
|
||||||
|
<li>User has an active account</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-blue-800 flex items-center gap-1">
|
||||||
|
<Lightbulb className="w-4 h-4" />
|
||||||
|
<strong>Tip:</strong> Use <span className="font-mono bg-blue-100 px-1 rounded">@</span> sign to search users from the directory.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationModal.type === 'error' && (
|
||||||
|
<>
|
||||||
|
{validationModal.email && (
|
||||||
|
<p className="text-gray-700">
|
||||||
|
Failed to validate <strong>{validationModal.email}</strong>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{validationModal.message && (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-gray-700">{validationModal.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setValidationModal(prev => ({ ...prev, open: false }))}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,10 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Eye, X, AtSign } from 'lucide-react';
|
import { Eye, X, AtSign, AlertCircle, Lightbulb } from 'lucide-react';
|
||||||
import { searchUsers, type UserSummary } from '@/services/userApi';
|
import { searchUsers, ensureUserExists, type UserSummary } from '@/services/userApi';
|
||||||
|
|
||||||
interface AddSpectatorModalProps {
|
interface AddSpectatorModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -27,20 +27,45 @@ export function AddSpectatorModal({
|
|||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
|
||||||
const [isSearching, setIsSearching] = useState(false);
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null); // Track if user was selected via @ search
|
||||||
const searchTimer = useRef<any>(null);
|
const searchTimer = useRef<any>(null);
|
||||||
|
const searchContainerRef = useRef<HTMLDivElement>(null); // Ref for auto-scroll
|
||||||
|
|
||||||
|
// Validation modal state
|
||||||
|
const [validationModal, setValidationModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
type: 'error' | 'not-found';
|
||||||
|
email: string;
|
||||||
|
message: string;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
|
||||||
const handleConfirm = async () => {
|
const handleConfirm = async () => {
|
||||||
const emailToAdd = email.trim().toLowerCase();
|
const emailToAdd = email.trim().toLowerCase();
|
||||||
|
|
||||||
if (!emailToAdd) {
|
if (!emailToAdd) {
|
||||||
alert('Please enter an email address');
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: '',
|
||||||
|
message: 'Please enter an email address'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Basic email validation
|
// Basic email validation
|
||||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||||
if (!emailRegex.test(emailToAdd)) {
|
if (!emailRegex.test(emailToAdd)) {
|
||||||
alert('Please enter a valid email address');
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: 'Please enter a valid email address'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,16 +79,78 @@ export function AddSpectatorModal({
|
|||||||
const userName = existingParticipant.name || emailToAdd;
|
const userName = existingParticipant.name || emailToAdd;
|
||||||
|
|
||||||
if (participantType === 'INITIATOR') {
|
if (participantType === 'INITIATOR') {
|
||||||
alert(`${userName} is the request initiator and cannot be added as a spectator.`);
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: `${userName} is the request initiator and cannot be added as a spectator.`
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
} else if (participantType === 'APPROVER') {
|
} else if (participantType === 'APPROVER') {
|
||||||
alert(`${userName} is already an approver on this request and cannot be added as a spectator.`);
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: `${userName} is already an approver on this request and cannot be added as a spectator.`
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
} else if (participantType === 'SPECTATOR') {
|
} else if (participantType === 'SPECTATOR') {
|
||||||
alert(`${userName} is already a spectator on this request.`);
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: `${userName} is already a spectator on this request.`
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
alert(`${userName} is already a participant on this request.`);
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: `${userName} is already a participant on this request.`
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user was NOT selected via @ search, validate against Okta
|
||||||
|
if (!selectedUser || selectedUser.email.toLowerCase() !== emailToAdd) {
|
||||||
|
try {
|
||||||
|
const response = await searchUsers(emailToAdd, 1);
|
||||||
|
// Backend returns { success: true, data: [...users] }
|
||||||
|
const searchOktaResults = response.data?.data || [];
|
||||||
|
|
||||||
|
if (searchOktaResults.length === 0) {
|
||||||
|
// User not found in Okta
|
||||||
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'not-found',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: ''
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// User found - ensure they exist in DB
|
||||||
|
const foundUser = searchOktaResults[0];
|
||||||
|
await ensureUserExists({
|
||||||
|
userId: foundUser.userId,
|
||||||
|
email: foundUser.email,
|
||||||
|
displayName: foundUser.displayName,
|
||||||
|
firstName: foundUser.firstName,
|
||||||
|
lastName: foundUser.lastName,
|
||||||
|
department: foundUser.department
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Validated spectator: ${foundUser.displayName} (${foundUser.email})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to validate spectator:', error);
|
||||||
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: emailToAdd,
|
||||||
|
message: 'Failed to validate user. Please try again.'
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -72,6 +159,7 @@ export function AddSpectatorModal({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
await onConfirm(emailToAdd);
|
await onConfirm(emailToAdd);
|
||||||
setEmail('');
|
setEmail('');
|
||||||
|
setSelectedUser(null);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to add spectator:', error);
|
console.error('Failed to add spectator:', error);
|
||||||
@ -84,12 +172,24 @@ export function AddSpectatorModal({
|
|||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
if (!isSubmitting) {
|
if (!isSubmitting) {
|
||||||
setEmail('');
|
setEmail('');
|
||||||
|
setSelectedUser(null);
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
onClose();
|
onClose();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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
|
// Cleanup search timer on unmount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@ -103,6 +203,11 @@ export function AddSpectatorModal({
|
|||||||
const handleEmailChange = (value: string) => {
|
const handleEmailChange = (value: string) => {
|
||||||
setEmail(value);
|
setEmail(value);
|
||||||
|
|
||||||
|
// Clear selectedUser when manually editing (forces revalidation)
|
||||||
|
if (selectedUser && selectedUser.email.toLowerCase() !== value.toLowerCase()) {
|
||||||
|
setSelectedUser(null);
|
||||||
|
}
|
||||||
|
|
||||||
// Clear existing timer
|
// Clear existing timer
|
||||||
if (searchTimer.current) {
|
if (searchTimer.current) {
|
||||||
clearTimeout(searchTimer.current);
|
clearTimeout(searchTimer.current);
|
||||||
@ -120,7 +225,9 @@ export function AddSpectatorModal({
|
|||||||
searchTimer.current = setTimeout(async () => {
|
searchTimer.current = setTimeout(async () => {
|
||||||
try {
|
try {
|
||||||
const term = value.slice(1); // Remove @ prefix
|
const term = value.slice(1); // Remove @ prefix
|
||||||
const results = await searchUsers(term, 10);
|
const response = await searchUsers(term, 10);
|
||||||
|
// Backend returns { success: true, data: [...users] }
|
||||||
|
const results = response.data?.data || [];
|
||||||
setSearchResults(results);
|
setSearchResults(results);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Search failed:', error);
|
console.error('Search failed:', error);
|
||||||
@ -132,25 +239,47 @@ export function AddSpectatorModal({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Select user from search results
|
// Select user from search results
|
||||||
const handleSelectUser = (user: UserSummary) => {
|
const handleSelectUser = async (user: UserSummary) => {
|
||||||
|
// Ensure user exists in DB when selected via @ search
|
||||||
|
try {
|
||||||
|
await ensureUserExists({
|
||||||
|
userId: user.userId,
|
||||||
|
email: user.email,
|
||||||
|
displayName: user.displayName,
|
||||||
|
firstName: user.firstName,
|
||||||
|
lastName: user.lastName,
|
||||||
|
department: user.department
|
||||||
|
});
|
||||||
|
|
||||||
setEmail(user.email);
|
setEmail(user.email);
|
||||||
|
setSelectedUser(user); // Track that user was selected via @ search
|
||||||
setSearchResults([]);
|
setSearchResults([]);
|
||||||
setIsSearching(false);
|
setIsSearching(false);
|
||||||
|
console.log(`✅ User selected and verified: ${user.displayName} (${user.email})`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to ensure user exists:', error);
|
||||||
|
setValidationModal({
|
||||||
|
open: true,
|
||||||
|
type: 'error',
|
||||||
|
email: user.email,
|
||||||
|
message: 'Failed to verify user in database. Please try again.'
|
||||||
|
});
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={handleClose}>
|
<Dialog open={open} onOpenChange={handleClose}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md min-h-[60vh] max-h-[90vh] flex flex-col p-0">
|
||||||
<button
|
<button
|
||||||
onClick={handleClose}
|
onClick={handleClose}
|
||||||
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none z-50"
|
||||||
disabled={isSubmitting}
|
disabled={isSubmitting}
|
||||||
>
|
>
|
||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
<span className="sr-only">Close</span>
|
<span className="sr-only">Close</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<DialogHeader>
|
<DialogHeader className="px-6 pt-6 pb-4 flex-shrink-0">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
<div className="w-10 h-10 bg-purple-100 rounded-lg flex items-center justify-center">
|
||||||
<Eye className="w-5 h-5 text-purple-600" />
|
<Eye className="w-5 h-5 text-purple-600" />
|
||||||
@ -159,7 +288,7 @@ export function AddSpectatorModal({
|
|||||||
</div>
|
</div>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="space-y-4 py-4">
|
<div ref={searchContainerRef} className="space-y-4 px-6 py-4 pb-8 overflow-y-auto flex-1">
|
||||||
{/* Description */}
|
{/* Description */}
|
||||||
<p className="text-sm text-gray-600 leading-relaxed">
|
<p className="text-sm text-gray-600 leading-relaxed">
|
||||||
Add a spectator to this request. They will receive notifications but cannot approve or reject.
|
Add a spectator to this request. They will receive notifications but cannot approve or reject.
|
||||||
@ -228,7 +357,7 @@ export function AddSpectatorModal({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action Buttons */}
|
{/* Action Buttons */}
|
||||||
<div className="flex items-center gap-3 pt-4 border-t">
|
<div className="flex items-center gap-3 px-6 py-4 border-t flex-shrink-0 bg-white">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -249,6 +378,75 @@ export function AddSpectatorModal({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* Validation Error Modal */}
|
||||||
|
<Dialog open={validationModal.open} onOpenChange={(isOpen) => setValidationModal(prev => ({ ...prev, open: isOpen }))}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
{validationModal.type === 'not-found' ? (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
User Not Found
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
Validation Error
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{validationModal.type === 'not-found' && (
|
||||||
|
<>
|
||||||
|
<p className="text-gray-700">
|
||||||
|
User <strong>{validationModal.email}</strong> was not found in the organization directory.
|
||||||
|
</p>
|
||||||
|
<div className="bg-red-50 border border-red-200 rounded-lg p-3 space-y-2">
|
||||||
|
<p className="text-sm text-red-800 font-semibold">Please verify:</p>
|
||||||
|
<ul className="text-sm text-red-700 space-y-1 list-disc list-inside">
|
||||||
|
<li>Email address is spelled correctly</li>
|
||||||
|
<li>User exists in Okta/SSO system</li>
|
||||||
|
<li>User has an active account</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-blue-800 flex items-center gap-1">
|
||||||
|
<Lightbulb className="w-4 h-4" />
|
||||||
|
<strong>Tip:</strong> Use <span className="font-mono bg-blue-100 px-1 rounded">@</span> sign to search users from the directory.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{validationModal.type === 'error' && (
|
||||||
|
<>
|
||||||
|
{validationModal.email && (
|
||||||
|
<p className="text-gray-700">
|
||||||
|
Failed to validate <strong>{validationModal.email}</strong>.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{validationModal.message && (
|
||||||
|
<div className="bg-gray-50 border border-gray-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-gray-700">{validationModal.message}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setValidationModal(prev => ({ ...prev, open: false }))}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,75 @@
|
|||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Bell, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface NotificationStatusModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
success: boolean;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationStatusModal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
success,
|
||||||
|
message
|
||||||
|
}: NotificationStatusModalProps) {
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onClose}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Bell className="w-5 h-5 text-blue-600" />
|
||||||
|
Push Notifications
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-6">
|
||||||
|
<div className="flex flex-col items-center text-center">
|
||||||
|
{success ? (
|
||||||
|
<>
|
||||||
|
<div className="w-16 h-16 rounded-full bg-green-100 flex items-center justify-center mb-4">
|
||||||
|
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
Notifications Enabled!
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 max-w-sm">
|
||||||
|
{message || 'You will now receive push notifications for workflow updates, approvals, and TAT alerts.'}
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="w-16 h-16 rounded-full bg-red-100 flex items-center justify-center mb-4">
|
||||||
|
<XCircle className="w-8 h-8 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||||
|
Subscription Failed
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-gray-600 max-w-sm mb-4">
|
||||||
|
{message || 'Unable to enable push notifications. Please check your browser settings and try again.'}
|
||||||
|
</p>
|
||||||
|
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-left w-full">
|
||||||
|
<p className="text-xs text-amber-800 font-medium mb-2">💡 Troubleshooting Tips:</p>
|
||||||
|
<ul className="text-xs text-amber-700 space-y-1 list-disc list-inside">
|
||||||
|
<li>Check if notifications are blocked in browser settings</li>
|
||||||
|
<li>Ensure your browser supports push notifications</li>
|
||||||
|
<li>Try refreshing the page and enabling again</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={onClose} className="w-full">
|
||||||
|
{success ? 'Done' : 'Close'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/components/settings/NotificationStatusModal/index.ts
Normal file
2
src/components/settings/NotificationStatusModal/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { NotificationStatusModal } from './NotificationStatusModal';
|
||||||
|
|
||||||
111
src/components/sla/SLAProgressBar/SLAProgressBar.tsx
Normal file
111
src/components/sla/SLAProgressBar/SLAProgressBar.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react';
|
||||||
|
|
||||||
|
export interface SLAData {
|
||||||
|
status: 'normal' | 'approaching' | 'critical' | 'breached';
|
||||||
|
percentageUsed: number;
|
||||||
|
elapsedText: string;
|
||||||
|
elapsedHours: number;
|
||||||
|
remainingText: string;
|
||||||
|
remainingHours: number;
|
||||||
|
deadline?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SLAProgressBarProps {
|
||||||
|
sla: SLAData | null;
|
||||||
|
requestStatus: string;
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SLAProgressBar({
|
||||||
|
sla,
|
||||||
|
requestStatus,
|
||||||
|
testId = 'sla-progress'
|
||||||
|
}: SLAProgressBarProps) {
|
||||||
|
// If request is closed/approved/rejected or no SLA data, show status message
|
||||||
|
if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
|
||||||
|
{requestStatus === 'closed' ? <Lock className="h-4 w-4 text-gray-600" /> :
|
||||||
|
requestStatus === 'approved' ? <CheckCircle className="h-4 w-4 text-green-600" /> :
|
||||||
|
requestStatus === 'rejected' ? <XCircle className="h-4 w-4 text-red-600" /> :
|
||||||
|
<Clock className="h-4 w-4 text-gray-500" />}
|
||||||
|
<span className="text-sm font-medium text-gray-700">
|
||||||
|
{requestStatus === 'closed' ? 'Request Closed' :
|
||||||
|
requestStatus === 'approved' ? 'Request Approved' :
|
||||||
|
requestStatus === 'rejected' ? 'Request Rejected' : 'SLA Not Available'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid={testId}>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-blue-600" />
|
||||||
|
<span className="text-sm font-semibold text-gray-900">SLA Progress</span>
|
||||||
|
</div>
|
||||||
|
<Badge
|
||||||
|
className={`text-xs ${
|
||||||
|
sla.status === 'breached' ? 'bg-red-600 text-white animate-pulse' :
|
||||||
|
sla.status === 'critical' ? 'bg-orange-600 text-white' :
|
||||||
|
sla.status === 'approaching' ? 'bg-yellow-600 text-white' :
|
||||||
|
'bg-green-600 text-white'
|
||||||
|
}`}
|
||||||
|
data-testid={`${testId}-badge`}
|
||||||
|
>
|
||||||
|
{sla.percentageUsed || 0}% elapsed
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Progress
|
||||||
|
value={sla.percentageUsed || 0}
|
||||||
|
className={`h-3 mb-2 ${
|
||||||
|
sla.status === 'breached' ? '[&>div]:bg-red-600' :
|
||||||
|
sla.status === 'critical' ? '[&>div]:bg-orange-600' :
|
||||||
|
sla.status === 'approaching' ? '[&>div]:bg-yellow-600' :
|
||||||
|
'[&>div]:bg-green-600'
|
||||||
|
}`}
|
||||||
|
data-testid={`${testId}-bar`}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between text-xs mb-1">
|
||||||
|
<span className="text-gray-600" data-testid={`${testId}-elapsed`}>
|
||||||
|
{sla.elapsedText || `${sla.elapsedHours || 0}h`} elapsed
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`font-semibold ${
|
||||||
|
sla.status === 'breached' ? 'text-red-600' :
|
||||||
|
sla.status === 'critical' ? 'text-orange-600' :
|
||||||
|
'text-gray-700'
|
||||||
|
}`}
|
||||||
|
data-testid={`${testId}-remaining`}
|
||||||
|
>
|
||||||
|
{sla.remainingText || `${sla.remainingHours || 0}h`} remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{sla.deadline && (
|
||||||
|
<p className="text-xs text-gray-500" data-testid={`${testId}-deadline`}>
|
||||||
|
Due: {new Date(sla.deadline).toLocaleString()} • {sla.percentageUsed || 0}% elapsed
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sla.status === 'critical' && (
|
||||||
|
<p className="text-xs text-orange-600 font-semibold mt-1 flex items-center gap-1.5" data-testid={`${testId}-warning-critical`}>
|
||||||
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
Approaching Deadline
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{sla.status === 'breached' && (
|
||||||
|
<p className="text-xs text-red-600 font-semibold mt-1 flex items-center gap-1.5" data-testid={`${testId}-warning-breached`}>
|
||||||
|
<AlertOctagon className="h-3.5 w-3.5" />
|
||||||
|
URGENT - Deadline Passed
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
3
src/components/sla/SLAProgressBar/index.ts
Normal file
3
src/components/sla/SLAProgressBar/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { SLAProgressBar } from './SLAProgressBar';
|
||||||
|
export type { SLAData } from './SLAProgressBar';
|
||||||
|
|
||||||
@ -7,12 +7,13 @@ import { formatWorkingHours, getTimeUntilNextWorking } from '@/utils/slaTracker'
|
|||||||
interface SLATrackerProps {
|
interface SLATrackerProps {
|
||||||
startDate: string | Date;
|
startDate: string | Date;
|
||||||
deadline: string | Date;
|
deadline: string | Date;
|
||||||
|
priority?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
showDetails?: boolean;
|
showDetails?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SLATracker({ startDate, deadline, className = '', showDetails = true }: SLATrackerProps) {
|
export function SLATracker({ startDate, deadline, priority, className = '', showDetails = true }: SLATrackerProps) {
|
||||||
const slaStatus = useSLATracking(startDate, deadline);
|
const slaStatus = useSLATracking(startDate, deadline, priority);
|
||||||
|
|
||||||
if (!slaStatus) {
|
if (!slaStatus) {
|
||||||
return null;
|
return null;
|
||||||
@ -93,7 +94,7 @@ export function SLATracker({ startDate, deadline, className = '', showDetails =
|
|||||||
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded-md border border-gray-200">
|
<div className="flex items-center gap-2 p-2 bg-gray-50 rounded-md border border-gray-200">
|
||||||
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0" />
|
||||||
<span className="text-xs text-gray-700">
|
<span className="text-xs text-gray-700">
|
||||||
{getTimeUntilNextWorking()}
|
{getTimeUntilNextWorking(priority)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -8,9 +8,10 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border border-gray-400 bg-white px-3 py-1 text-base text-gray-900 transition-all outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:ring-[3px]",
|
"focus-visible:border-re-light-green focus-visible:ring-0 focus-visible:outline-none",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"hover:border-gray-500",
|
||||||
|
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -41,7 +41,10 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"border-gray-400 data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground bg-white text-gray-900 flex w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"hover:border-gray-500",
|
||||||
|
"focus-visible:border-re-light-green focus-visible:ring-0 focus-visible:outline-none",
|
||||||
|
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -7,7 +7,10 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"resize-none border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"resize-none border-gray-400 placeholder:text-muted-foreground bg-white text-gray-900 flex field-sizing-content min-h-16 w-full rounded-md border px-3 py-2 text-base transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"hover:border-gray-500",
|
||||||
|
"focus-visible:border-re-light-green focus-visible:ring-0 focus-visible:outline-none",
|
||||||
|
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator } from '@/services/workflowApi';
|
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
|
||||||
|
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -9,6 +12,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
import { FilePreview } from '@/components/common/FilePreview';
|
import { FilePreview } from '@/components/common/FilePreview';
|
||||||
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
|
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
|
||||||
|
import { AddApproverModal } from '@/components/participant/AddApproverModal';
|
||||||
import { formatDateTime } from '@/utils/dateFormatter';
|
import { formatDateTime } from '@/utils/dateFormatter';
|
||||||
import {
|
import {
|
||||||
Send,
|
Send,
|
||||||
@ -18,20 +22,18 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
MoreHorizontal,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Clock,
|
Clock,
|
||||||
Search,
|
Search,
|
||||||
Hash,
|
|
||||||
AtSign,
|
AtSign,
|
||||||
Archive,
|
|
||||||
Plus,
|
Plus,
|
||||||
Activity,
|
Activity,
|
||||||
Bell,
|
|
||||||
Flag,
|
Flag,
|
||||||
X,
|
X,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Image
|
Image,
|
||||||
|
UserPlus,
|
||||||
|
AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
@ -76,6 +78,9 @@ interface WorkNoteChatProps {
|
|||||||
skipSocketJoin?: boolean; // Set to true when embedded in RequestDetail (to avoid double join)
|
skipSocketJoin?: boolean; // Set to true when embedded in RequestDetail (to avoid double join)
|
||||||
requestTitle?: string; // Optional title for display
|
requestTitle?: string; // Optional title for display
|
||||||
onAttachmentsExtracted?: (attachments: any[]) => void; // Callback to pass attachments to parent
|
onAttachmentsExtracted?: (attachments: any[]) => void; // Callback to pass attachments to parent
|
||||||
|
isInitiator?: boolean; // Whether current user is the initiator
|
||||||
|
currentLevels?: any[]; // Current approval levels for add approver modal
|
||||||
|
onAddApprover?: (email: string, tatHours: number, level: number) => Promise<void>; // Callback to add approver
|
||||||
}
|
}
|
||||||
|
|
||||||
// All data is now fetched from backend - no hardcoded mock data
|
// All data is now fetched from backend - no hardcoded mock data
|
||||||
@ -100,9 +105,10 @@ const getStatusText = (status: string) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const formatMessage = (content: string) => {
|
const formatMessage = (content: string) => {
|
||||||
// Enhanced mention highlighting with better regex
|
// Enhanced mention highlighting - Blue color with extra bold font for high visibility
|
||||||
|
// Matches: @test user11 or @Test User11 (any case, stops before next sentence/punctuation)
|
||||||
return content
|
return content
|
||||||
.replace(/@([\w\s]+)(?=\s|$|[.,!?])/g, '<span class="inline-flex items-center px-2 py-1 rounded-md bg-blue-100 text-blue-800 font-medium text-sm">@$1</span>')
|
.replace(/@([A-Za-z0-9]+(?:\s+[A-Za-z0-9]+)*?)(?=\s+(?:[a-z][a-z\s]*)?(?:[.,!?;:]|$))/g, '<span class="inline-flex items-center px-2.5 py-0.5 rounded-md bg-blue-50 text-blue-800 font-black text-base border-2 border-blue-400 shadow-sm">@$1</span>')
|
||||||
.replace(/\n/g, '<br />');
|
.replace(/\n/g, '<br />');
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -124,7 +130,7 @@ const FileIcon = ({ type }: { type: string }) => {
|
|||||||
return <Paperclip className={`${iconClass} text-gray-600`} />;
|
return <Paperclip className={`${iconClass} text-gray-600`} />;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function WorkNoteChat({ requestId, onBack, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted }: WorkNoteChatProps) {
|
export function WorkNoteChat({ requestId, onBack, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle, onAttachmentsExtracted, isInitiator = false, currentLevels = [], onAddApprover }: WorkNoteChatProps) {
|
||||||
const routeParams = useParams<{ requestId: string }>();
|
const routeParams = useParams<{ requestId: string }>();
|
||||||
const effectiveRequestId = requestId || routeParams.requestId || '';
|
const effectiveRequestId = requestId || routeParams.requestId || '';
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
@ -136,11 +142,30 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||||
const [previewFile, setPreviewFile] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; attachmentId?: string } | null>(null);
|
const [previewFile, setPreviewFile] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; attachmentId?: string } | null>(null);
|
||||||
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
|
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
|
||||||
|
const [showAddApproverModal, setShowAddApproverModal] = useState(false);
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
const socketRef = useRef<any>(null);
|
const socketRef = useRef<any>(null);
|
||||||
const participantsLoadedRef = useRef(false);
|
const participantsLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
// Document policy state
|
||||||
|
const [documentPolicy, setDocumentPolicy] = useState<{
|
||||||
|
maxFileSizeMB: number;
|
||||||
|
allowedFileTypes: string[];
|
||||||
|
}>({
|
||||||
|
maxFileSizeMB: 10,
|
||||||
|
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document validation error modal
|
||||||
|
const [documentErrorModal, setDocumentErrorModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
errors: Array<{ fileName: string; reason: string }>;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
errors: []
|
||||||
|
});
|
||||||
|
|
||||||
console.log('[WorkNoteChat] Component render, participants loaded:', participantsLoadedRef.current);
|
console.log('[WorkNoteChat] Component render, participants loaded:', participantsLoadedRef.current);
|
||||||
|
|
||||||
// Get request info (from props, all data comes from backend now)
|
// Get request info (from props, all data comes from backend now)
|
||||||
@ -377,6 +402,33 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch document policy on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDocumentPolicy = async () => {
|
||||||
|
try {
|
||||||
|
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10');
|
||||||
|
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
|
||||||
|
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
|
||||||
|
|
||||||
|
setDocumentPolicy({
|
||||||
|
maxFileSizeMB,
|
||||||
|
allowedFileTypes
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load document policy:', error);
|
||||||
|
// Use defaults if loading fails
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDocumentPolicy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Realtime updates via Socket.IO (standalone usage OR when embedded in RequestDetail)
|
// Realtime updates via Socket.IO (standalone usage OR when embedded in RequestDetail)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUserId) return; // Wait for currentUserId to be loaded
|
if (!currentUserId) return; // Wait for currentUserId to be loaded
|
||||||
@ -683,6 +735,26 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
|
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
if (message.trim() || selectedFiles.length > 0) {
|
if (message.trim() || selectedFiles.length > 0) {
|
||||||
|
// Extract mentions from message
|
||||||
|
const mentions = extractMentions(message);
|
||||||
|
|
||||||
|
// Find mentioned user IDs from participants
|
||||||
|
const mentionedUserIds = mentions
|
||||||
|
.map(mentionedName => {
|
||||||
|
const participant = participants.find(p =>
|
||||||
|
p.name.toLowerCase().includes(mentionedName.toLowerCase())
|
||||||
|
);
|
||||||
|
console.log('[Mention Match] Looking for:', mentionedName, 'Found participant:', participant ? `${participant.name} (${(participant as any)?.userId})` : 'NOT FOUND');
|
||||||
|
return (participant as any)?.userId;
|
||||||
|
})
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
console.log('[WorkNoteChat] 📝 MESSAGE:', message);
|
||||||
|
console.log('[WorkNoteChat] 👥 ALL PARTICIPANTS:', participants.map(p => ({ name: p.name, userId: (p as any)?.userId })));
|
||||||
|
console.log('[WorkNoteChat] 🎯 MENTIONS EXTRACTED:', mentions);
|
||||||
|
console.log('[WorkNoteChat] 🆔 USER IDS FOUND:', mentionedUserIds);
|
||||||
|
console.log('[WorkNoteChat] 📤 SENDING TO BACKEND:', { message, mentions: mentionedUserIds });
|
||||||
|
|
||||||
const attachments = selectedFiles.map(file => ({
|
const attachments = selectedFiles.map(file => ({
|
||||||
name: file.name,
|
name: file.name,
|
||||||
url: URL.createObjectURL(file),
|
url: URL.createObjectURL(file),
|
||||||
@ -701,19 +773,26 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
minute: 'numeric',
|
minute: 'numeric',
|
||||||
hour12: true
|
hour12: true
|
||||||
}),
|
}),
|
||||||
mentions: extractMentions(message),
|
mentions: mentions,
|
||||||
isHighPriority: message.includes('!important') || message.includes('urgent'),
|
isHighPriority: message.includes('!important') || message.includes('urgent'),
|
||||||
attachments: attachments.length > 0 ? attachments : undefined,
|
attachments: attachments.length > 0 ? attachments : undefined,
|
||||||
isCurrentUser: true
|
isCurrentUser: true
|
||||||
};
|
};
|
||||||
// console.log('new message ->', newMessage, onSend);
|
|
||||||
// If external onSend provided, delegate to caller (RequestDetail will POST and refresh)
|
// If external onSend provided, delegate to caller (RequestDetail will POST and refresh)
|
||||||
if (onSend) {
|
if (onSend) {
|
||||||
try { await onSend(message, selectedFiles); } catch { /* ignore */ }
|
try { await onSend(message, selectedFiles); } catch { /* ignore */ }
|
||||||
} else {
|
} else {
|
||||||
// Fallback: call backend directly
|
// Fallback: call backend directly with mentions
|
||||||
try {
|
try {
|
||||||
await createWorkNoteMultipart(effectiveRequestId, { message }, selectedFiles);
|
await createWorkNoteMultipart(
|
||||||
|
effectiveRequestId,
|
||||||
|
{
|
||||||
|
message,
|
||||||
|
mentions: mentionedUserIds // Send mentioned user IDs to backend
|
||||||
|
},
|
||||||
|
selectedFiles
|
||||||
|
);
|
||||||
const rows = await getWorkNotes(effectiveRequestId);
|
const rows = await getWorkNotes(effectiveRequestId);
|
||||||
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
||||||
const noteUserId = m.userId || m.user_id;
|
const noteUserId = m.userId || m.user_id;
|
||||||
@ -752,7 +831,13 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (externalMessages && Array.isArray(externalMessages)) {
|
if (externalMessages && Array.isArray(externalMessages)) {
|
||||||
try {
|
try {
|
||||||
const mapped: Message[] = externalMessages.map((m: any) => {
|
const mapped: Message[] = externalMessages
|
||||||
|
.filter((m: any) => {
|
||||||
|
// Filter out TAT breach activities (not important for work notes chat)
|
||||||
|
const activityType = (m.type || '').toLowerCase();
|
||||||
|
return activityType !== 'sla_warning';
|
||||||
|
})
|
||||||
|
.map((m: any) => {
|
||||||
// Check if this is an activity (system message) or work note
|
// Check if this is an activity (system message) or work note
|
||||||
const isActivity = m.type || m.activityType || m.isSystem;
|
const isActivity = m.type || m.activityType || m.isSystem;
|
||||||
|
|
||||||
@ -854,10 +939,72 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
}
|
}
|
||||||
}, [externalMessages, effectiveRequestId, participants]);
|
}, [externalMessages, effectiveRequestId, participants]);
|
||||||
|
|
||||||
|
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||||
|
// Check file size
|
||||||
|
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
|
||||||
|
if (file.size > maxSizeBytes) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extension
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
|
||||||
|
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files) {
|
if (!e.target.files || e.target.files.length === 0) return;
|
||||||
|
|
||||||
const filesArray = Array.from(e.target.files);
|
const filesArray = Array.from(e.target.files);
|
||||||
setSelectedFiles(prev => [...prev, ...filesArray]);
|
|
||||||
|
// Validate all files
|
||||||
|
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
|
||||||
|
filesArray.forEach(file => {
|
||||||
|
const validation = validateFile(file);
|
||||||
|
if (!validation.valid) {
|
||||||
|
validationErrors.push({
|
||||||
|
fileName: file.name,
|
||||||
|
reason: validation.reason || 'Unknown validation error'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there are validation errors, show modal
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
setDocumentErrorModal({
|
||||||
|
open: true,
|
||||||
|
errors: validationErrors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add only valid files
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
setSelectedFiles(prev => [...prev, ...validFiles]);
|
||||||
|
if (validFiles.length < filesArray.length) {
|
||||||
|
toast.warning(`${validFiles.length} of ${filesArray.length} file(s) were added. ${validationErrors.length} file(s) were rejected.`);
|
||||||
|
} else {
|
||||||
|
toast.success(`${validFiles.length} file(s) added successfully`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset file input
|
||||||
|
if (e.target) {
|
||||||
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -921,6 +1068,54 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Handler for adding approver
|
||||||
|
const handleAddApproverInternal = async (email: string, tatHours: number, level: number) => {
|
||||||
|
if (onAddApprover) {
|
||||||
|
// Use parent's handler if provided
|
||||||
|
await onAddApprover(email, tatHours, level);
|
||||||
|
setShowAddApproverModal(false);
|
||||||
|
} else {
|
||||||
|
// Fallback: call API directly
|
||||||
|
try {
|
||||||
|
await addApproverAtLevel(effectiveRequestId, email, tatHours, level);
|
||||||
|
// Refresh participants list
|
||||||
|
const details = await getWorkflowDetails(effectiveRequestId);
|
||||||
|
const rows = Array.isArray(details?.participants) ? details.participants : [];
|
||||||
|
if (rows.length) {
|
||||||
|
const mapped: Participant[] = rows.map((p: any) => {
|
||||||
|
const participantType = p.participantType || p.participant_type || 'participant';
|
||||||
|
const userId = p.userId || p.user_id || '';
|
||||||
|
const userName = p.userName || p.user_name || p.userEmail || p.user_email || 'User';
|
||||||
|
const userEmail = p.userEmail || p.user_email || '';
|
||||||
|
const initials = userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase();
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: userName,
|
||||||
|
avatar: initials,
|
||||||
|
role: formatParticipantRole(participantType),
|
||||||
|
status: 'offline' as const,
|
||||||
|
email: userEmail,
|
||||||
|
lastSeen: undefined,
|
||||||
|
permissions: ['read'],
|
||||||
|
userId
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setParticipants(mapped);
|
||||||
|
|
||||||
|
if (socketRef.current && socketRef.current.connected) {
|
||||||
|
socketRef.current.emit('request:online-users', { requestId: effectiveRequestId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowAddApproverModal(false);
|
||||||
|
alert(`Approver added successfully at Level ${level} with ${tatHours}h TAT`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to add approver:', error);
|
||||||
|
alert(error?.response?.data?.error || 'Failed to add approver');
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Emoji picker data - Expanded collection
|
// Emoji picker data - Expanded collection
|
||||||
const emojiList = [
|
const emojiList = [
|
||||||
// Smileys & Emotions
|
// Smileys & Emotions
|
||||||
@ -971,7 +1166,8 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
];
|
];
|
||||||
|
|
||||||
const extractMentions = (text: string): string[] => {
|
const extractMentions = (text: string): string[] => {
|
||||||
const mentionRegex = /@([\w\s]+)(?=\s|$|[.,!?])/g;
|
// Use the SAME regex pattern as formatMessage to ensure consistency
|
||||||
|
const mentionRegex = /@([A-Za-z0-9]+(?:\s+[A-Za-z0-9]+)*?)(?=\s+(?:[a-z][a-z\s]*)?(?:[.,!?;:]|$))/g;
|
||||||
const mentions: string[] = [];
|
const mentions: string[] = [];
|
||||||
let match;
|
let match;
|
||||||
while ((match = mentionRegex.exec(text)) !== null) {
|
while ((match = mentionRegex.exec(text)) !== null) {
|
||||||
@ -979,6 +1175,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
mentions.push(match[1].trim());
|
mentions.push(match[1].trim());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
console.log('[Extract Mentions] Found:', mentions, 'from text:', text);
|
||||||
return mentions;
|
return mentions;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1281,7 +1478,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
multiple
|
multiple
|
||||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
|
accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Selected Files Preview - Scrollable if many files */}
|
{/* Selected Files Preview - Scrollable if many files */}
|
||||||
@ -1307,8 +1504,95 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Textarea with Emoji Picker */}
|
{/* Textarea with Mention Dropdown and Emoji Picker */}
|
||||||
<div className="relative mb-2">
|
<div className="relative mb-2">
|
||||||
|
{/* Mention Suggestions Dropdown - Shows above textarea */}
|
||||||
|
{(() => {
|
||||||
|
const lastAtIndex = message.lastIndexOf('@');
|
||||||
|
const hasAt = lastAtIndex >= 0;
|
||||||
|
const textAfterAt = hasAt ? message.slice(lastAtIndex + 1) : '';
|
||||||
|
|
||||||
|
// Don't show if:
|
||||||
|
// 1. No @ found
|
||||||
|
// 2. Text after @ is too long (>20 chars)
|
||||||
|
// 3. Text after @ ends with a space (completed mention)
|
||||||
|
// 4. Text after @ contains a space (already selected a user)
|
||||||
|
const endsWithSpace = textAfterAt.endsWith(' ');
|
||||||
|
const containsSpace = textAfterAt.trim().includes(' ');
|
||||||
|
const shouldShowDropdown = hasAt &&
|
||||||
|
textAfterAt.length <= 20 &&
|
||||||
|
!endsWithSpace &&
|
||||||
|
!containsSpace;
|
||||||
|
|
||||||
|
console.log('[Mention Debug]', {
|
||||||
|
hasAt,
|
||||||
|
textAfterAt: `"${textAfterAt}"`,
|
||||||
|
endsWithSpace,
|
||||||
|
containsSpace,
|
||||||
|
shouldShowDropdown,
|
||||||
|
participantsCount: participants.length
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!shouldShowDropdown) return null;
|
||||||
|
|
||||||
|
const searchTerm = textAfterAt.toLowerCase();
|
||||||
|
const filteredParticipants = participants.filter(p => {
|
||||||
|
// Exclude current user from mention suggestions
|
||||||
|
const isCurrentUserInList = (p as any).userId === currentUserId;
|
||||||
|
if (isCurrentUserInList) return false;
|
||||||
|
|
||||||
|
// Filter by search term (empty search term shows all)
|
||||||
|
if (searchTerm) {
|
||||||
|
return p.name.toLowerCase().includes(searchTerm);
|
||||||
|
}
|
||||||
|
return true; // Show all if no search term
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('[Mention Debug] Filtered participants:', filteredParticipants.length);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-full left-0 mb-2 bg-white border-2 border-blue-300 rounded-lg shadow-2xl p-3 z-[100] w-full sm:max-w-md">
|
||||||
|
<p className="text-sm font-semibold text-gray-900 mb-2">💬 Mention someone</p>
|
||||||
|
<div className="max-h-60 overflow-y-auto space-y-1">
|
||||||
|
{filteredParticipants.length > 0 ? (
|
||||||
|
filteredParticipants.map((participant, idx) => (
|
||||||
|
<button
|
||||||
|
key={idx}
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const lastAt = message.lastIndexOf('@');
|
||||||
|
const before = message.slice(0, lastAt);
|
||||||
|
setMessage(before + '@' + participant.name + ' ');
|
||||||
|
}}
|
||||||
|
className="w-full flex items-center gap-3 p-3 hover:bg-blue-50 rounded-lg text-left transition-colors border border-transparent hover:border-blue-200"
|
||||||
|
>
|
||||||
|
<Avatar className="h-10 w-10">
|
||||||
|
<AvatarFallback className={`text-white text-sm font-semibold ${
|
||||||
|
participant.role === 'Initiator' ? 'bg-green-600' :
|
||||||
|
participant.role === 'Approver' ? 'bg-purple-600' :
|
||||||
|
'bg-blue-500'
|
||||||
|
}`}>
|
||||||
|
{participant.avatar}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">{participant.name}</p>
|
||||||
|
<p className="text-xs text-gray-600">{participant.role}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500 text-center py-4">
|
||||||
|
{searchTerm ? `No participants found matching "${searchTerm}"` : 'No other participants available'}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
<Textarea
|
<Textarea
|
||||||
placeholder="Type your message... Use @username to mention someone"
|
placeholder="Type your message... Use @username to mention someone"
|
||||||
value={message}
|
value={message}
|
||||||
@ -1373,21 +1657,12 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-gray-500 h-8 w-8 p-0 hidden sm:flex hover:bg-blue-50 hover:text-blue-600 flex-shrink-0"
|
className="text-gray-500 h-8 w-8 p-0 hover:bg-blue-50 hover:text-blue-600 flex-shrink-0"
|
||||||
onClick={() => setMessage(prev => prev + '@')}
|
onClick={() => setMessage(prev => prev + '@')}
|
||||||
title="Mention someone"
|
title="Mention someone"
|
||||||
>
|
>
|
||||||
<AtSign className="h-4 w-4" />
|
<AtSign className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-gray-500 h-8 w-8 p-0 hidden sm:flex hover:bg-blue-50 hover:text-blue-600 flex-shrink-0"
|
|
||||||
onClick={() => setMessage(prev => prev + '#')}
|
|
||||||
title="Add hashtag"
|
|
||||||
>
|
|
||||||
<Hash className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Character count and Send button */}
|
{/* Right side - Character count and Send button */}
|
||||||
@ -1424,8 +1699,8 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
lg:relative lg:translate-x-0 lg:shadow-none
|
lg:relative lg:translate-x-0 lg:shadow-none
|
||||||
${showSidebar ? 'fixed right-0 top-0 bottom-0 z-50 shadow-xl' : 'hidden lg:flex'}
|
${showSidebar ? 'fixed right-0 top-0 bottom-0 z-50 shadow-xl' : 'hidden lg:flex'}
|
||||||
`}>
|
`}>
|
||||||
<div className="p-4 sm:p-6 border-b border-gray-200">
|
<div className="p-4 sm:p-6 border-b border-gray-200 flex-1 flex flex-col min-h-0">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900">Participants</h3>
|
<h3 className="text-base sm:text-lg font-semibold text-gray-900">Participants</h3>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -1436,7 +1711,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4 overflow-y-auto flex-1 pr-2">
|
||||||
{participants.map((participant, index) => {
|
{participants.map((participant, index) => {
|
||||||
const isCurrentUser = (participant as any).userId === currentUserId;
|
const isCurrentUser = (participant as any).userId === currentUserId;
|
||||||
return (
|
return (
|
||||||
@ -1465,18 +1740,27 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
<p className="text-xs text-gray-400">{participant.lastSeen}</p>
|
<p className="text-xs text-gray-400">{participant.lastSeen}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 sm:p-6">
|
<div className="p-4 sm:p-6 flex-shrink-0">
|
||||||
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
|
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
{/* Only initiator can add approvers */}
|
||||||
|
{isInitiator && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full justify-start gap-2 h-9 text-sm"
|
||||||
|
onClick={() => setShowAddApproverModal(true)}
|
||||||
|
>
|
||||||
|
<UserPlus className="h-4 w-4" />
|
||||||
|
Add Approver
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -1486,14 +1770,14 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
<Eye className="h-4 w-4" />
|
<Eye className="h-4 w-4" />
|
||||||
Add Spectator
|
Add Spectator
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
|
{/* <Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
|
||||||
<Bell className="h-4 w-4" />
|
<Bell className="h-4 w-4" />
|
||||||
Manage Notifications
|
Manage Notifications
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
|
<Button variant="outline" size="sm" className="w-full justify-start gap-2 h-9 text-sm">
|
||||||
<Archive className="h-4 w-4" />
|
<Archive className="h-4 w-4" />
|
||||||
Archive Chat
|
Archive Chat
|
||||||
</Button>
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1522,6 +1806,61 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
requestTitle={requestInfo.title}
|
requestTitle={requestInfo.title}
|
||||||
existingParticipants={existingParticipants}
|
existingParticipants={existingParticipants}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Add Approver Modal */}
|
||||||
|
{isInitiator && (
|
||||||
|
<AddApproverModal
|
||||||
|
open={showAddApproverModal}
|
||||||
|
onClose={() => setShowAddApproverModal(false)}
|
||||||
|
onConfirm={handleAddApproverInternal}
|
||||||
|
requestIdDisplay={effectiveRequestId}
|
||||||
|
requestTitle={requestInfo.title}
|
||||||
|
existingParticipants={existingParticipants}
|
||||||
|
currentLevels={currentLevels}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Document Validation Error Modal */}
|
||||||
|
<Dialog open={documentErrorModal.open} onOpenChange={(open) => setDocumentErrorModal(prev => ({ ...prev, open }))}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
Document Upload Policy Violation
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-gray-700">
|
||||||
|
The following file(s) could not be uploaded due to policy violations:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
{documentErrorModal.errors.map((error, index) => (
|
||||||
|
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
|
<p className="font-medium text-red-900 text-sm">{error.fileName}</p>
|
||||||
|
<p className="text-xs text-red-700 mt-1">{error.reason}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-blue-800 font-semibold mb-1">Document Policy:</p>
|
||||||
|
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
|
||||||
|
<li>Maximum file size: {documentPolicy.maxFileSizeMB}MB</li>
|
||||||
|
<li>Allowed file types: {documentPolicy.allowedFileTypes.join(', ')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setDocumentErrorModal({ open: false, errors: [] })}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
486
src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx
Normal file
486
src/components/workflow/ApprovalWorkflow/ApprovalStepCard.tsx
Normal file
@ -0,0 +1,486 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Progress } from '@/components/ui/progress';
|
||||||
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
|
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon } from 'lucide-react';
|
||||||
|
import { 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format working hours as days (8 hours = 1 working day)
|
||||||
|
const formatWorkingHours = (hours: number): string => {
|
||||||
|
const WORKING_HOURS_PER_DAY = 8;
|
||||||
|
if (hours < WORKING_HOURS_PER_DAY) {
|
||||||
|
return `${hours.toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
const days = Math.floor(hours / WORKING_HOURS_PER_DAY);
|
||||||
|
const remainingHours = hours % WORKING_HOURS_PER_DAY;
|
||||||
|
if (remainingHours > 0) {
|
||||||
|
return `${days}d ${remainingHours.toFixed(1)}h`;
|
||||||
|
}
|
||||||
|
return `${days}d`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStepIcon = (status: string, isSkipped?: boolean) => {
|
||||||
|
if (isSkipped) return <AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-orange-600" />;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'approved':
|
||||||
|
return <CheckCircle className="w-4 h-4 sm:w-5 sm:w-5 text-green-600" />;
|
||||||
|
case 'rejected':
|
||||||
|
return <XCircle className="w-4 h-4 sm:w-5 sm:h-5 text-red-600" />;
|
||||||
|
case 'pending':
|
||||||
|
case 'in-review':
|
||||||
|
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-blue-600" />;
|
||||||
|
case 'waiting':
|
||||||
|
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400" />;
|
||||||
|
default:
|
||||||
|
return <Clock className="w-4 h-4 sm:w-5 sm:h-5 text-gray-400" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ApprovalStepCard({
|
||||||
|
step,
|
||||||
|
index,
|
||||||
|
approval,
|
||||||
|
isCurrentUser = false,
|
||||||
|
isInitiator = false,
|
||||||
|
onSkipApprover,
|
||||||
|
testId = 'approval-step'
|
||||||
|
}: ApprovalStepCardProps) {
|
||||||
|
const isActive = step.status === 'pending' || step.status === 'in-review';
|
||||||
|
const isCompleted = step.status === 'approved';
|
||||||
|
const isRejected = step.status === 'rejected';
|
||||||
|
const isWaiting = step.status === 'waiting';
|
||||||
|
|
||||||
|
const tatHours = Number(step.tatHours || 0);
|
||||||
|
const actualHours = step.actualHours;
|
||||||
|
const savedHours = isCompleted && actualHours ? Math.max(0, tatHours - actualHours) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`relative p-3 sm:p-4 md:p-5 rounded-lg border-2 transition-all ${
|
||||||
|
step.isSkipped
|
||||||
|
? 'border-orange-500 bg-orange-50'
|
||||||
|
: isActive
|
||||||
|
? 'border-blue-500 bg-blue-50 shadow-md'
|
||||||
|
: isCompleted
|
||||||
|
? 'border-green-500 bg-green-50'
|
||||||
|
: isRejected
|
||||||
|
? 'border-red-500 bg-red-50'
|
||||||
|
: isWaiting
|
||||||
|
? 'border-gray-300 bg-gray-50'
|
||||||
|
: 'border-gray-200 bg-white'
|
||||||
|
}`}
|
||||||
|
data-testid={`${testId}-${step.step}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 sm:gap-3 md:gap-4">
|
||||||
|
<div className={`p-2 sm:p-2.5 md:p-3 rounded-xl flex-shrink-0 ${
|
||||||
|
step.isSkipped ? 'bg-orange-100' :
|
||||||
|
isActive ? 'bg-blue-100' :
|
||||||
|
isCompleted ? 'bg-green-100' :
|
||||||
|
isRejected ? 'bg-red-100' :
|
||||||
|
isWaiting ? 'bg-gray-200' :
|
||||||
|
'bg-gray-100'
|
||||||
|
}`}>
|
||||||
|
{getStepIcon(step.status, step.isSkipped)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
{/* Header with Approver Label and Status */}
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start sm:justify-between gap-2 sm:gap-4 mb-3">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 sm:gap-2 mb-2">
|
||||||
|
<h4 className="font-semibold text-gray-900 text-base sm:text-lg" data-testid={`${testId}-approver-label`}>
|
||||||
|
Approver {index + 1}
|
||||||
|
</h4>
|
||||||
|
<Badge variant="outline" className={`text-xs shrink-0 capitalize ${
|
||||||
|
step.isSkipped ? 'bg-orange-100 text-orange-800 border-orange-200' :
|
||||||
|
isActive ? 'bg-yellow-100 text-yellow-800 border-yellow-200' :
|
||||||
|
isCompleted ? 'bg-green-100 text-green-800 border-green-200' :
|
||||||
|
isRejected ? 'bg-red-100 text-red-800 border-red-200' :
|
||||||
|
isWaiting ? 'bg-gray-200 text-gray-600 border-gray-300' :
|
||||||
|
'bg-gray-100 text-gray-800 border-gray-200'
|
||||||
|
}`} data-testid={`${testId}-status-badge`}>
|
||||||
|
{step.isSkipped ? 'skipped' : step.status}
|
||||||
|
</Badge>
|
||||||
|
{step.isSkipped && step.skipReason && (
|
||||||
|
<TooltipProvider delayDuration={200}>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center cursor-pointer hover:opacity-80 transition-opacity"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-4 h-4 text-orange-600" />
|
||||||
|
</button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="top" className="max-w-xs bg-orange-50 border-orange-200">
|
||||||
|
<p className="text-xs font-semibold text-orange-900 mb-1 flex items-center gap-1">
|
||||||
|
<FastForward className="w-3 h-3" />
|
||||||
|
Skip Reason:
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-700">{step.skipReason}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
)}
|
||||||
|
{isCompleted && actualHours && (
|
||||||
|
<Badge className="bg-green-600 text-white text-xs" data-testid={`${testId}-completion-time`}>
|
||||||
|
{formatWorkingHours(actualHours)}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm font-semibold text-gray-900" data-testid={`${testId}-approver-name`}>
|
||||||
|
{isCurrentUser ? <span className="text-blue-600">You</span> : step.approver}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-600" data-testid={`${testId}-role`}>{step.role}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-left sm:text-right flex-shrink-0">
|
||||||
|
<p className="text-xs text-gray-500 font-medium">Turnaround Time (TAT)</p>
|
||||||
|
<p className="text-lg font-bold text-gray-900" data-testid={`${testId}-tat-hours`}>{tatHours} hours</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Completed Approver - Show Completion Details */}
|
||||||
|
{isCompleted && actualHours !== undefined && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-600">Completed:</span>
|
||||||
|
<span className="font-medium text-gray-900">{step.timestamp ? formatDateTime(step.timestamp) : 'N/A'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-600">Completed in:</span>
|
||||||
|
<span className="font-medium text-gray-900">{formatWorkingHours(actualHours)}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar for Completed - Shows actual time used vs TAT allocated */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(() => {
|
||||||
|
// Calculate actual progress percentage based on time used
|
||||||
|
// If approved in 1 minute out of 24 hours TAT = (1/60) / 24 * 100 = 0.069%
|
||||||
|
const progressPercentage = tatHours > 0 ? Math.min(100, (actualHours / tatHours) * 100) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Progress
|
||||||
|
value={progressPercentage}
|
||||||
|
className="h-2 bg-gray-200"
|
||||||
|
data-testid={`${testId}-progress-bar`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-green-600 font-semibold">
|
||||||
|
{progressPercentage.toFixed(1)}% of TAT used
|
||||||
|
</span>
|
||||||
|
{savedHours > 0 && (
|
||||||
|
<span className="text-green-600 font-semibold">Saved {savedHours.toFixed(1)} hours</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Conclusion Remark */}
|
||||||
|
{step.comment && (
|
||||||
|
<div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
|
||||||
|
<p className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
|
||||||
|
<MessageSquare className="w-3.5 h-3.5 text-blue-600" />
|
||||||
|
Conclusion Remark:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-700 italic leading-relaxed">{step.comment}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active Approver - Show Real-time Progress from Backend */}
|
||||||
|
{isActive && approval?.sla && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-gray-600">Due by:</span>
|
||||||
|
<span className="font-medium text-gray-900">
|
||||||
|
{approval.sla.deadline ? formatDateTime(approval.sla.deadline) : 'Not set'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Current Approver - Time Tracking */}
|
||||||
|
<div className={`border rounded-lg p-3 ${
|
||||||
|
approval.sla.status === 'critical' ? 'bg-orange-50 border-orange-200' :
|
||||||
|
approval.sla.status === 'breached' ? 'bg-red-50 border-red-200' :
|
||||||
|
'bg-yellow-50 border-yellow-200'
|
||||||
|
}`}>
|
||||||
|
<p className="text-xs font-semibold text-gray-900 mb-3 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
Current Approver - Time Tracking
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2 text-xs mb-3">
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Time elapsed since assigned:</span>
|
||||||
|
<span className="font-medium text-gray-900">{approval.sla.elapsedText}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span className="text-gray-600">Time used:</span>
|
||||||
|
<span className="font-medium text-gray-900">{approval.sla.elapsedText} / {tatHours}h allocated</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress Bar */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Progress
|
||||||
|
value={approval.sla.percentageUsed}
|
||||||
|
className={`h-3 ${
|
||||||
|
approval.sla.status === 'breached' ? '[&>div]:bg-red-600' :
|
||||||
|
approval.sla.status === 'critical' ? '[&>div]:bg-orange-600' :
|
||||||
|
'[&>div]:bg-yellow-600'
|
||||||
|
}`}
|
||||||
|
data-testid={`${testId}-sla-progress`}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className={`text-xs font-semibold ${
|
||||||
|
approval.sla.status === 'breached' ? 'text-red-600' :
|
||||||
|
approval.sla.status === 'critical' ? 'text-orange-600' :
|
||||||
|
'text-yellow-700'
|
||||||
|
}`}>
|
||||||
|
Progress: {approval.sla.percentageUsed}% of TAT used
|
||||||
|
</span>
|
||||||
|
<span className="text-xs font-medium text-gray-700">
|
||||||
|
{approval.sla.remainingText} remaining
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{approval.sla.status === 'breached' && (
|
||||||
|
<p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
|
||||||
|
<AlertOctagon className="w-4 h-4" />
|
||||||
|
Deadline Breached
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{approval.sla.status === 'critical' && (
|
||||||
|
<p className="text-xs font-semibold text-center text-orange-600 flex items-center justify-center gap-1.5">
|
||||||
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
Approaching Deadline
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Waiting Approver - Show Assignment Info */}
|
||||||
|
{isWaiting && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="bg-gray-100 border border-gray-300 rounded-lg p-3">
|
||||||
|
<p className="text-xs text-gray-600 mb-1 flex items-center gap-1.5">
|
||||||
|
<PauseCircle className="w-3.5 h-3.5 text-gray-500" />
|
||||||
|
Awaiting Previous Approval
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-medium text-gray-700">Will be assigned after previous step</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-2">Allocated {tatHours} hours for approval</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rejected Status */}
|
||||||
|
{isRejected && step.comment && (
|
||||||
|
<div className="mt-3 p-3 sm:p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
|
||||||
|
<p className="text-xs font-semibold text-red-700 mb-2 flex items-center gap-1.5">
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
Rejection Reason:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed">{step.comment}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skipped Status */}
|
||||||
|
{step.isSkipped && step.skipReason && (
|
||||||
|
<div className="mt-3 p-3 sm:p-4 bg-orange-50 border-l-4 border-orange-500 rounded-r-lg">
|
||||||
|
<p className="text-xs font-semibold text-orange-700 mb-2 flex items-center gap-1.5">
|
||||||
|
<FastForward className="w-3.5 h-3.5" />
|
||||||
|
Skip Reason:
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-700 leading-relaxed">{step.skipReason}</p>
|
||||||
|
{step.timestamp && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2">Skipped on {formatDateTime(step.timestamp)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* TAT Alerts/Reminders */}
|
||||||
|
{step.tatAlerts && step.tatAlerts.length > 0 && (
|
||||||
|
<div className="mt-2 sm:mt-3 space-y-2">
|
||||||
|
{step.tatAlerts.map((alert: any, alertIndex: number) => (
|
||||||
|
<div
|
||||||
|
key={alertIndex}
|
||||||
|
className={`p-2 sm:p-3 rounded-lg border ${
|
||||||
|
alert.isBreached
|
||||||
|
? 'bg-red-50 border-red-200'
|
||||||
|
: (alert.thresholdPercentage || 0) === 75
|
||||||
|
? 'bg-orange-50 border-orange-200'
|
||||||
|
: 'bg-yellow-50 border-yellow-200'
|
||||||
|
}`}
|
||||||
|
data-testid={`${testId}-tat-alert-${alertIndex}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
|
{(alert.thresholdPercentage || 0) === 50 && (
|
||||||
|
<Hourglass className="w-5 h-5 text-yellow-600" />
|
||||||
|
)}
|
||||||
|
{(alert.thresholdPercentage || 0) === 75 && (
|
||||||
|
<AlertTriangle className="w-5 h-5 text-orange-600" />
|
||||||
|
)}
|
||||||
|
{(alert.thresholdPercentage || 0) === 100 && (
|
||||||
|
<AlertOctagon className="w-5 h-5 text-red-600" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 mb-1">
|
||||||
|
<p className="text-xs sm:text-sm font-semibold text-gray-900">
|
||||||
|
Reminder {alertIndex + 1} - {alert.thresholdPercentage || 0}% TAT
|
||||||
|
</p>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={`text-[10px] sm:text-xs shrink-0 ${
|
||||||
|
alert.isBreached
|
||||||
|
? 'bg-red-100 text-red-800 border-red-300'
|
||||||
|
: 'bg-amber-100 text-amber-800 border-amber-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{alert.isBreached ? 'BREACHED' : 'WARNING'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] sm:text-xs md:text-sm text-gray-700 mt-1">
|
||||||
|
{alert.thresholdPercentage || 0}% of SLA breach reminder have been sent
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Time Tracking Details */}
|
||||||
|
<div className="mt-2 grid grid-cols-2 gap-1.5 sm:gap-2 text-[10px] sm:text-xs">
|
||||||
|
<div className="bg-white/50 rounded px-2 py-1">
|
||||||
|
<span className="text-gray-500">Allocated:</span>
|
||||||
|
<span className="ml-1 font-medium text-gray-900">
|
||||||
|
{Number(alert.tatHoursAllocated || 0).toFixed(2)}h
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/50 rounded px-2 py-1">
|
||||||
|
<span className="text-gray-500">Elapsed:</span>
|
||||||
|
<span className="ml-1 font-medium text-gray-900">
|
||||||
|
{Number(alert.tatHoursElapsed || 0).toFixed(2)}h
|
||||||
|
{alert.metadata?.tatTestMode && (
|
||||||
|
<span className="text-purple-600 ml-1">
|
||||||
|
({(Number(alert.tatHoursElapsed || 0) * 60).toFixed(0)}m)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/50 rounded px-2 py-1">
|
||||||
|
<span className="text-gray-500">Remaining:</span>
|
||||||
|
<span className={`ml-1 font-medium ${
|
||||||
|
(alert.tatHoursRemaining || 0) < 2 ? 'text-red-600' : 'text-gray-900'
|
||||||
|
}`}>
|
||||||
|
{Number(alert.tatHoursRemaining || 0).toFixed(2)}h
|
||||||
|
{alert.metadata?.tatTestMode && (
|
||||||
|
<span className="text-purple-600 ml-1">
|
||||||
|
({(Number(alert.tatHoursRemaining || 0) * 60).toFixed(0)}m)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white/50 rounded px-2 py-1">
|
||||||
|
<span className="text-gray-500">Due by:</span>
|
||||||
|
<span className="ml-1 font-medium text-gray-900">
|
||||||
|
{alert.expectedCompletionTime ? formatDateShort(alert.expectedCompletionTime) : 'N/A'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 pt-2 border-t border-gray-200">
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1">
|
||||||
|
<p className="text-[10px] sm:text-xs text-gray-500">
|
||||||
|
Reminder sent by system automatically
|
||||||
|
</p>
|
||||||
|
{(alert.metadata?.testMode || alert.metadata?.tatTestMode) && (
|
||||||
|
<Badge variant="outline" className="bg-purple-50 text-purple-700 border-purple-300 text-[10px] px-1.5 py-0 shrink-0">
|
||||||
|
TEST MODE
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] sm:text-xs text-gray-600 font-medium mt-0.5">
|
||||||
|
Sent at: {alert.alertSentAt ? formatDateTime(alert.alertSentAt) : 'N/A'}
|
||||||
|
</p>
|
||||||
|
{(alert.metadata?.testMode || alert.metadata?.tatTestMode) && (
|
||||||
|
<p className="text-[10px] text-purple-600 mt-1 italic">
|
||||||
|
Note: Test mode active (1 hour = 1 minute)
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step.timestamp && (
|
||||||
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
|
{isCompleted ? 'Approved' : isRejected ? 'Rejected' : 'Actioned'} on {formatDateTime(step.timestamp)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skip Approver Button - Only show for initiator on pending/in-review levels */}
|
||||||
|
{isInitiator && (isActive || step.status === 'pending') && !isCompleted && !isRejected && step.levelId && onSkipApprover && (
|
||||||
|
<div className="mt-2 sm:mt-3 pt-2 sm:pt-3 border-t border-gray-200">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full border-orange-300 text-orange-700 hover:bg-orange-50 h-9 sm:h-10 text-xs sm:text-sm"
|
||||||
|
onClick={() => onSkipApprover({
|
||||||
|
levelId: step.levelId,
|
||||||
|
approverName: step.approver,
|
||||||
|
levelNumber: step.step
|
||||||
|
})}
|
||||||
|
data-testid={`${testId}-skip-button`}
|
||||||
|
>
|
||||||
|
<AlertCircle className="w-3.5 h-3.5 sm:w-4 sm:h-4 mr-2" />
|
||||||
|
Skip This Approver
|
||||||
|
</Button>
|
||||||
|
<p className="text-[10px] sm:text-xs text-gray-500 mt-1 text-center">
|
||||||
|
Skip if approver is unavailable and move to next level
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
3
src/components/workflow/ApprovalWorkflow/index.ts
Normal file
3
src/components/workflow/ApprovalWorkflow/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { ApprovalStepCard } from './ApprovalStepCard';
|
||||||
|
export type { ApprovalStep } from './ApprovalStepCard';
|
||||||
|
|
||||||
100
src/components/workflow/DocumentUpload/DocumentCard.tsx
Normal file
100
src/components/workflow/DocumentUpload/DocumentCard.tsx
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { FileText, Eye, Download } from 'lucide-react';
|
||||||
|
import { formatDateTime } from '@/utils/dateFormatter';
|
||||||
|
|
||||||
|
export interface DocumentData {
|
||||||
|
documentId: string;
|
||||||
|
name: string;
|
||||||
|
fileType: string;
|
||||||
|
size: string;
|
||||||
|
sizeBytes?: number;
|
||||||
|
uploadedBy?: string;
|
||||||
|
uploadedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DocumentCardProps {
|
||||||
|
document: DocumentData;
|
||||||
|
onPreview?: (doc: { fileName: string; fileType: string; documentId: string; fileSize?: number }) => void;
|
||||||
|
onDownload?: (documentId: string) => Promise<void>;
|
||||||
|
showPreview?: boolean;
|
||||||
|
testId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const canPreview = (fileType: string) => {
|
||||||
|
const type = (fileType || '').toLowerCase();
|
||||||
|
return type.includes('image') || type.includes('pdf') ||
|
||||||
|
type.includes('jpg') || type.includes('jpeg') ||
|
||||||
|
type.includes('png') || type.includes('gif');
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DocumentCard({
|
||||||
|
document,
|
||||||
|
onPreview,
|
||||||
|
onDownload,
|
||||||
|
showPreview = true,
|
||||||
|
testId = 'document-card'
|
||||||
|
}: DocumentCardProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-between p-4 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors"
|
||||||
|
data-testid={`${testId}-${document.documentId}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-blue-100 rounded-lg">
|
||||||
|
<FileText className="w-5 h-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-gray-900" data-testid={`${testId}-name`}>
|
||||||
|
{document.name}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500" data-testid={`${testId}-metadata`}>
|
||||||
|
{document.size} • Uploaded by {document.uploadedBy} on {formatDateTime(document.uploadedAt)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{/* Preview button for images and PDFs */}
|
||||||
|
{showPreview && canPreview(document.fileType) && onPreview && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onPreview({
|
||||||
|
fileName: document.name,
|
||||||
|
fileType: document.fileType,
|
||||||
|
documentId: document.documentId,
|
||||||
|
fileSize: document.sizeBytes
|
||||||
|
})}
|
||||||
|
title="Preview file"
|
||||||
|
data-testid={`${testId}-preview-btn`}
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Download button */}
|
||||||
|
{onDownload && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!document.documentId) {
|
||||||
|
alert('Document ID not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await onDownload(document.documentId);
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to download document');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title="Download file"
|
||||||
|
data-testid={`${testId}-download-btn`}
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
3
src/components/workflow/DocumentUpload/index.ts
Normal file
3
src/components/workflow/DocumentUpload/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { DocumentCard } from './DocumentCard';
|
||||||
|
export type { DocumentData } from './DocumentCard';
|
||||||
|
|
||||||
@ -19,7 +19,7 @@ interface User {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
designation?: string;
|
designation?: string;
|
||||||
isAdmin?: boolean;
|
role?: 'USER' | 'MANAGEMENT' | 'ADMIN';
|
||||||
sub?: string;
|
sub?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
picture?: string;
|
picture?: string;
|
||||||
@ -597,3 +597,31 @@ export function useAuth(): AuthContextType {
|
|||||||
return context;
|
return context;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if user is admin
|
||||||
|
*/
|
||||||
|
export function isAdmin(user: User | null): boolean {
|
||||||
|
return user?.role === 'ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if user is management
|
||||||
|
*/
|
||||||
|
export function isManagement(user: User | null): boolean {
|
||||||
|
return user?.role === 'MANAGEMENT';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if user has management access (MANAGEMENT or ADMIN)
|
||||||
|
*/
|
||||||
|
export function hasManagementAccess(user: User | null): boolean {
|
||||||
|
return user?.role === 'MANAGEMENT' || user?.role === 'ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to check if user has admin access (ADMIN only)
|
||||||
|
*/
|
||||||
|
export function hasAdminAccess(user: User | null): boolean {
|
||||||
|
return user?.role === 'ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
231
src/hooks/useConclusionRemark.ts
Normal file
231
src/hooks/useConclusionRemark.ts
Normal file
@ -0,0 +1,231 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Hook: useConclusionRemark
|
||||||
|
*
|
||||||
|
* Purpose: Manages conclusion remark generation and finalization
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Fetches existing AI-generated conclusion
|
||||||
|
* - Generates new conclusion using AI
|
||||||
|
* - Finalizes conclusion and closes request
|
||||||
|
* - Manages loading and submission states
|
||||||
|
* - Handles navigation after successful closure
|
||||||
|
*
|
||||||
|
* @param request - Current request object
|
||||||
|
* @param requestIdentifier - Request number or UUID
|
||||||
|
* @param isInitiator - Whether current user is the request initiator
|
||||||
|
* @param refreshDetails - Function to refresh request data
|
||||||
|
* @param onBack - Navigation callback
|
||||||
|
* @param setActionStatus - Function to show action status modal
|
||||||
|
* @param setShowActionStatusModal - Function to control action status modal visibility
|
||||||
|
* @returns Object with conclusion state and action handlers
|
||||||
|
*/
|
||||||
|
export function useConclusionRemark(
|
||||||
|
request: any,
|
||||||
|
requestIdentifier: string,
|
||||||
|
isInitiator: boolean,
|
||||||
|
refreshDetails: () => Promise<void>,
|
||||||
|
onBack?: () => void,
|
||||||
|
setActionStatus?: (status: { success: boolean; title: string; message: string }) => void,
|
||||||
|
setShowActionStatusModal?: (show: boolean) => void
|
||||||
|
) {
|
||||||
|
// State: The conclusion remark text (editable by user)
|
||||||
|
const [conclusionRemark, setConclusionRemark] = useState('');
|
||||||
|
|
||||||
|
// State: Indicates if AI is currently generating conclusion
|
||||||
|
const [conclusionLoading, setConclusionLoading] = useState(false);
|
||||||
|
|
||||||
|
// State: Indicates if conclusion is being submitted to backend
|
||||||
|
const [conclusionSubmitting, setConclusionSubmitting] = useState(false);
|
||||||
|
|
||||||
|
// State: Tracks if current conclusion was AI-generated (shows badge in UI)
|
||||||
|
const [aiGenerated, setAiGenerated] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function: fetchExistingConclusion
|
||||||
|
*
|
||||||
|
* Purpose: Load existing AI-generated conclusion from backend
|
||||||
|
*
|
||||||
|
* Use Case: When request is approved, final approver generates conclusion.
|
||||||
|
* Initiator needs to review and finalize it before closing request.
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Dynamically import conclusion API service
|
||||||
|
* 2. Fetch conclusion by request ID
|
||||||
|
* 3. Load into state if exists
|
||||||
|
* 4. Mark as AI-generated if applicable
|
||||||
|
*/
|
||||||
|
const fetchExistingConclusion = async () => {
|
||||||
|
try {
|
||||||
|
// Lazy load: Import conclusion API only when needed
|
||||||
|
const { getConclusion } = await import('@/services/conclusionApi');
|
||||||
|
|
||||||
|
// API Call: Fetch existing conclusion
|
||||||
|
const result = await getConclusion(request.requestId || requestIdentifier);
|
||||||
|
|
||||||
|
if (result && result.aiGeneratedRemark) {
|
||||||
|
// Load: Set the AI-generated or final remark
|
||||||
|
setConclusionRemark(result.finalRemark || result.aiGeneratedRemark);
|
||||||
|
setAiGenerated(!!result.aiGeneratedRemark);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// No conclusion yet - this is expected for newly approved requests
|
||||||
|
console.log('[useConclusionRemark] No existing conclusion found');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function: handleGenerateConclusion
|
||||||
|
*
|
||||||
|
* Purpose: Generate a new conclusion remark using AI
|
||||||
|
*
|
||||||
|
* How it works:
|
||||||
|
* 1. Sends request details to AI service
|
||||||
|
* 2. AI analyzes approval history, comments, and request data
|
||||||
|
* 3. Generates professional conclusion summarizing outcome
|
||||||
|
* 4. User can edit the AI suggestion before finalizing
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Set loading state
|
||||||
|
* 2. Call AI generation API
|
||||||
|
* 3. Load generated text into textarea
|
||||||
|
* 4. Mark as AI-generated (shows badge)
|
||||||
|
* 5. Handle errors silently (user can type manually)
|
||||||
|
*/
|
||||||
|
const handleGenerateConclusion = async () => {
|
||||||
|
try {
|
||||||
|
setConclusionLoading(true);
|
||||||
|
|
||||||
|
// Lazy load: Import conclusion API
|
||||||
|
const { generateConclusion } = await import('@/services/conclusionApi');
|
||||||
|
|
||||||
|
// API Call: Generate AI conclusion based on request data
|
||||||
|
const result = await generateConclusion(request.requestId || requestIdentifier);
|
||||||
|
|
||||||
|
// Success: Load AI-generated remark
|
||||||
|
setConclusionRemark(result.aiGeneratedRemark);
|
||||||
|
setAiGenerated(true);
|
||||||
|
} catch (err) {
|
||||||
|
// Fail silently: User can write conclusion manually
|
||||||
|
console.error('[useConclusionRemark] AI generation failed:', err);
|
||||||
|
setConclusionRemark('');
|
||||||
|
setAiGenerated(false);
|
||||||
|
} finally {
|
||||||
|
setConclusionLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function: handleFinalizeConclusion
|
||||||
|
*
|
||||||
|
* Purpose: Submit conclusion remark and close the request
|
||||||
|
*
|
||||||
|
* Business Logic:
|
||||||
|
* - Only initiators can finalize approved requests
|
||||||
|
* - Conclusion cannot be empty
|
||||||
|
* - After finalization:
|
||||||
|
* → Request status changes to CLOSED
|
||||||
|
* → All participants are notified
|
||||||
|
* → Request moves to Closed Requests
|
||||||
|
* → Conclusion is permanently saved
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Validate conclusion is not empty
|
||||||
|
* 2. Submit to backend
|
||||||
|
* 3. Show success modal
|
||||||
|
* 4. Refresh request data (status will be "closed")
|
||||||
|
* 5. Navigate to Closed Requests after 2 seconds
|
||||||
|
* 6. Handle errors with user-friendly messages
|
||||||
|
*/
|
||||||
|
const handleFinalizeConclusion = async () => {
|
||||||
|
// Validation: Ensure conclusion is not empty
|
||||||
|
if (!conclusionRemark.trim()) {
|
||||||
|
setActionStatus?.({
|
||||||
|
success: false,
|
||||||
|
title: 'Validation Error',
|
||||||
|
message: 'Conclusion remark cannot be empty'
|
||||||
|
});
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setConclusionSubmitting(true);
|
||||||
|
|
||||||
|
// Lazy load: Import conclusion API
|
||||||
|
const { finalizeConclusion } = await import('@/services/conclusionApi');
|
||||||
|
|
||||||
|
// API Call: Submit conclusion and close request
|
||||||
|
// Backend will:
|
||||||
|
// - Update request status to CLOSED
|
||||||
|
// - Save conclusion remark
|
||||||
|
// - Send notifications to all participants
|
||||||
|
// - Record closure timestamp
|
||||||
|
await finalizeConclusion(request.requestId || requestIdentifier, conclusionRemark);
|
||||||
|
|
||||||
|
// Success feedback
|
||||||
|
setActionStatus?.({
|
||||||
|
success: true,
|
||||||
|
title: 'Request Closed with Successful Completion',
|
||||||
|
message: 'The request has been finalized and moved to Closed Requests.'
|
||||||
|
});
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
|
|
||||||
|
// Refresh: Update UI with new "closed" status
|
||||||
|
await refreshDetails();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigate: Redirect to Closed Requests after showing success message
|
||||||
|
* Delay allows user to see the success notification
|
||||||
|
*/
|
||||||
|
setTimeout(() => {
|
||||||
|
if (onBack) {
|
||||||
|
// Use callback navigation if provided
|
||||||
|
onBack();
|
||||||
|
// Then navigate to closed requests
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.hash = '#/closed-requests';
|
||||||
|
}, 100);
|
||||||
|
} else {
|
||||||
|
// Direct navigation
|
||||||
|
window.location.hash = '#/closed-requests';
|
||||||
|
}
|
||||||
|
}, 2000); // 2 second delay
|
||||||
|
|
||||||
|
} catch (err: any) {
|
||||||
|
// Error feedback with backend message
|
||||||
|
setActionStatus?.({
|
||||||
|
success: false,
|
||||||
|
title: 'Error',
|
||||||
|
message: err.response?.data?.error || 'Failed to finalize conclusion'
|
||||||
|
});
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
|
} finally {
|
||||||
|
setConclusionSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effect: Auto-fetch existing conclusion when request becomes approved
|
||||||
|
*
|
||||||
|
* Trigger: When request status changes to "approved" and user is initiator
|
||||||
|
* Purpose: Load any conclusion generated by final approver
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (request?.status === 'approved' && isInitiator && !conclusionRemark) {
|
||||||
|
fetchExistingConclusion();
|
||||||
|
}
|
||||||
|
}, [request?.status, isInitiator]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
conclusionRemark,
|
||||||
|
setConclusionRemark,
|
||||||
|
conclusionLoading,
|
||||||
|
conclusionSubmitting,
|
||||||
|
aiGenerated,
|
||||||
|
handleGenerateConclusion,
|
||||||
|
handleFinalizeConclusion
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
244
src/hooks/useDocumentUpload.ts
Normal file
244
src/hooks/useDocumentUpload.ts
Normal file
@ -0,0 +1,244 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { uploadDocument } from '@/services/documentApi';
|
||||||
|
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Hook: useDocumentUpload
|
||||||
|
*
|
||||||
|
* Purpose: Manages document upload functionality with loading states
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Handles file input change events
|
||||||
|
* - Validates file selection
|
||||||
|
* - Uploads document to backend
|
||||||
|
* - Triggers refresh after successful upload
|
||||||
|
* - Manages upload loading state
|
||||||
|
* - Handles errors with user-friendly messages
|
||||||
|
*
|
||||||
|
* @param apiRequest - Current request object (contains requestId for upload)
|
||||||
|
* @param refreshDetails - Function to refresh request data after upload
|
||||||
|
* @returns Object with upload handler, trigger function, and loading state
|
||||||
|
*/
|
||||||
|
export function useDocumentUpload(
|
||||||
|
apiRequest: any,
|
||||||
|
refreshDetails: () => Promise<void>
|
||||||
|
) {
|
||||||
|
// State: Indicates if document is currently being uploaded
|
||||||
|
const [uploadingDocument, setUploadingDocument] = useState(false);
|
||||||
|
|
||||||
|
// State: Stores document for preview modal
|
||||||
|
const [previewDocument, setPreviewDocument] = useState<{
|
||||||
|
fileName: string;
|
||||||
|
fileType: string;
|
||||||
|
documentId: string;
|
||||||
|
fileSize?: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Document policy state
|
||||||
|
const [documentPolicy, setDocumentPolicy] = useState<{
|
||||||
|
maxFileSizeMB: number;
|
||||||
|
allowedFileTypes: string[];
|
||||||
|
}>({
|
||||||
|
maxFileSizeMB: 10,
|
||||||
|
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document validation error state
|
||||||
|
const [documentError, setDocumentError] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
errors: Array<{ fileName: string; reason: string }>;
|
||||||
|
}>({
|
||||||
|
show: false,
|
||||||
|
errors: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch document policy on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDocumentPolicy = async () => {
|
||||||
|
try {
|
||||||
|
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10');
|
||||||
|
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
|
||||||
|
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
|
||||||
|
|
||||||
|
setDocumentPolicy({
|
||||||
|
maxFileSizeMB,
|
||||||
|
allowedFileTypes
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load document policy:', error);
|
||||||
|
// Use defaults if loading fails
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDocumentPolicy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function: validateFile
|
||||||
|
*
|
||||||
|
* Purpose: Validate file against document policy
|
||||||
|
*
|
||||||
|
* @param file - File to validate
|
||||||
|
* @returns Validation result with reason if invalid
|
||||||
|
*/
|
||||||
|
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||||
|
// Check file size
|
||||||
|
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
|
||||||
|
if (file.size > maxSizeBytes) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extension
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
|
||||||
|
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function: handleDocumentUpload
|
||||||
|
*
|
||||||
|
* Purpose: Process file upload when user selects a file
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Validate file selection
|
||||||
|
* 2. Validate against document policy
|
||||||
|
* 3. Get request UUID (required for backend API)
|
||||||
|
* 4. Upload file to backend
|
||||||
|
* 5. Refresh request details to show new document
|
||||||
|
* 6. Clear file input for next upload
|
||||||
|
* 7. Show success/error messages
|
||||||
|
*
|
||||||
|
* @param event - File input change event
|
||||||
|
*/
|
||||||
|
const handleDocumentUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const files = event.target.files;
|
||||||
|
|
||||||
|
// Validate: Check if file is selected
|
||||||
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
const fileArray = Array.from(files);
|
||||||
|
|
||||||
|
// Validate all files against document policy
|
||||||
|
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
|
||||||
|
fileArray.forEach(file => {
|
||||||
|
const validation = validateFile(file);
|
||||||
|
if (!validation.valid) {
|
||||||
|
validationErrors.push({
|
||||||
|
fileName: file.name,
|
||||||
|
reason: validation.reason || 'Unknown validation error'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there are validation errors, show modal
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
setDocumentError({
|
||||||
|
show: true,
|
||||||
|
errors: validationErrors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no valid files, stop here
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
if (event.target) {
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUploadingDocument(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Upload only the first valid file (backend currently supports single file)
|
||||||
|
const file = validFiles[0];
|
||||||
|
|
||||||
|
// Validate: Ensure request ID is available
|
||||||
|
// Note: Backend requires UUID, not request number
|
||||||
|
const requestId = apiRequest?.requestId;
|
||||||
|
if (!requestId) {
|
||||||
|
toast.error('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
|
||||||
|
if (validFiles.length < fileArray.length) {
|
||||||
|
toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`);
|
||||||
|
} else {
|
||||||
|
toast.success('Document uploaded successfully');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[useDocumentUpload] Upload error:', error);
|
||||||
|
|
||||||
|
// Error feedback with backend error message if available
|
||||||
|
toast.error(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 based on document policy
|
||||||
|
* 3. Attach upload handler
|
||||||
|
* 4. Trigger click to open file picker
|
||||||
|
*/
|
||||||
|
const triggerFileInput = () => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',');
|
||||||
|
input.onchange = handleDocumentUpload as any;
|
||||||
|
input.click();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
uploadingDocument,
|
||||||
|
handleDocumentUpload,
|
||||||
|
triggerFileInput,
|
||||||
|
previewDocument,
|
||||||
|
setPreviewDocument,
|
||||||
|
documentPolicy,
|
||||||
|
documentError,
|
||||||
|
setDocumentError
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
332
src/hooks/useModalManager.ts
Normal file
332
src/hooks/useModalManager.ts
Normal file
@ -0,0 +1,332 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { approveLevel, rejectLevel, addApproverAtLevel, skipApprover, addSpectator } from '@/services/workflowApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Hook: useModalManager
|
||||||
|
*
|
||||||
|
* Purpose: Centralized management of all modals and their actions
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Manages visibility state for all modals (approve, reject, add approver, etc.)
|
||||||
|
* - Handles approval and rejection workflows
|
||||||
|
* - Manages approver and spectator addition
|
||||||
|
* - Handles approver skipping
|
||||||
|
* - Provides action status feedback
|
||||||
|
* - Triggers data refresh after actions
|
||||||
|
*
|
||||||
|
* Modals managed:
|
||||||
|
* - Approval Modal
|
||||||
|
* - Rejection Modal
|
||||||
|
* - Add Approver Modal
|
||||||
|
* - Add Spectator Modal
|
||||||
|
* - Skip Approver Modal
|
||||||
|
* - Action Status Modal (success/error feedback)
|
||||||
|
*
|
||||||
|
* @param requestIdentifier - Request number or UUID
|
||||||
|
* @param currentApprovalLevel - Current user's approval level data
|
||||||
|
* @param refreshDetails - Function to refresh request data
|
||||||
|
* @returns Object with modal states and action handlers
|
||||||
|
*/
|
||||||
|
export function useModalManager(
|
||||||
|
requestIdentifier: string,
|
||||||
|
currentApprovalLevel: any,
|
||||||
|
refreshDetails: () => Promise<void>
|
||||||
|
) {
|
||||||
|
// Modal visibility states
|
||||||
|
const [showApproveModal, setShowApproveModal] = useState(false);
|
||||||
|
const [showRejectModal, setShowRejectModal] = useState(false);
|
||||||
|
const [showAddApproverModal, setShowAddApproverModal] = useState(false);
|
||||||
|
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
|
||||||
|
const [showSkipApproverModal, setShowSkipApproverModal] = useState(false);
|
||||||
|
const [showActionStatusModal, setShowActionStatusModal] = useState(false);
|
||||||
|
|
||||||
|
// State: Data for skip approver modal (which approver to skip)
|
||||||
|
const [skipApproverData, setSkipApproverData] = useState<{
|
||||||
|
levelId: string;
|
||||||
|
approverName: string;
|
||||||
|
levelNumber: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// State: Action status (success or error) to show in modal
|
||||||
|
const [actionStatus, setActionStatus] = useState<{
|
||||||
|
success: boolean;
|
||||||
|
title: string;
|
||||||
|
message: string;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler: handleApproveConfirm
|
||||||
|
*
|
||||||
|
* Purpose: Process approval action when user confirms
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Validate level ID exists
|
||||||
|
* 2. Call backend API to approve level
|
||||||
|
* 3. Refresh request data to show approval
|
||||||
|
* 4. Close modal and show success message
|
||||||
|
*
|
||||||
|
* Backend Actions:
|
||||||
|
* - Updates level status to APPROVED
|
||||||
|
* - Records approval timestamp
|
||||||
|
* - Moves workflow to next level
|
||||||
|
* - Sends notifications to relevant users
|
||||||
|
*
|
||||||
|
* @param description - Optional approval comments/remarks
|
||||||
|
*/
|
||||||
|
const handleApproveConfirm = async (description: string) => {
|
||||||
|
const levelId = currentApprovalLevel?.levelId || currentApprovalLevel?.level_id;
|
||||||
|
|
||||||
|
// Validate: Ensure level ID is available
|
||||||
|
if (!levelId) {
|
||||||
|
alert('Approval level not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Call: Submit approval
|
||||||
|
await approveLevel(requestIdentifier, levelId, description || '');
|
||||||
|
|
||||||
|
// Refresh: Update UI with new approval status
|
||||||
|
await refreshDetails();
|
||||||
|
|
||||||
|
// Legacy: Global handlers (can be replaced with better toast system)
|
||||||
|
(window as any)?.closeModal?.();
|
||||||
|
(window as any)?.toast?.('Approved successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler: handleRejectConfirm
|
||||||
|
*
|
||||||
|
* Purpose: Process rejection action when user confirms
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Validate rejection comments are provided
|
||||||
|
* 2. Call backend API to reject level
|
||||||
|
* 3. Refresh request data to show rejection
|
||||||
|
* 4. Close modal and show success message
|
||||||
|
*
|
||||||
|
* Backend Actions:
|
||||||
|
* - Updates level status to REJECTED
|
||||||
|
* - Records rejection timestamp and reason
|
||||||
|
* - Stops workflow progression
|
||||||
|
* - Sends notifications to initiator and relevant users
|
||||||
|
*
|
||||||
|
* @param description - Required rejection comments/remarks
|
||||||
|
*/
|
||||||
|
const handleRejectConfirm = async (description: string) => {
|
||||||
|
// Validate: Rejection must have comments
|
||||||
|
if (!description?.trim()) {
|
||||||
|
alert('Comments & remarks are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelId = currentApprovalLevel?.levelId || currentApprovalLevel?.level_id;
|
||||||
|
|
||||||
|
// Validate: Ensure level ID is available
|
||||||
|
if (!levelId) {
|
||||||
|
alert('Approval level not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// API Call: Submit rejection
|
||||||
|
// Note: Backend expects both comments and remarks (currently same value)
|
||||||
|
await rejectLevel(requestIdentifier, levelId, description.trim(), description.trim());
|
||||||
|
|
||||||
|
// Refresh: Update UI with rejection status
|
||||||
|
await refreshDetails();
|
||||||
|
|
||||||
|
// Legacy: Global handlers
|
||||||
|
(window as any)?.closeModal?.();
|
||||||
|
(window as any)?.toast?.('Rejected successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler: handleAddApprover
|
||||||
|
*
|
||||||
|
* Purpose: Add a new approver at specific level with TAT
|
||||||
|
*
|
||||||
|
* Use Case: Initiator can add approvers dynamically if workflow needs change
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Validate email, TAT, and level
|
||||||
|
* 2. Call backend to add approver
|
||||||
|
* 3. Refresh request to show new approver
|
||||||
|
* 4. Show success/error modal
|
||||||
|
* 5. Close add approver modal
|
||||||
|
*
|
||||||
|
* Backend Actions:
|
||||||
|
* - Creates new approval level or adds to existing
|
||||||
|
* - Sends notification to new approver
|
||||||
|
* - Updates workflow structure
|
||||||
|
*
|
||||||
|
* @param email - Email of user to add as approver
|
||||||
|
* @param tatHours - Turnaround time allocated for this approver
|
||||||
|
* @param level - Which approval level to add approver to
|
||||||
|
*/
|
||||||
|
const handleAddApprover = async (email: string, tatHours: number, level: number) => {
|
||||||
|
try {
|
||||||
|
// API Call: Add approver at specified level
|
||||||
|
await addApproverAtLevel(requestIdentifier, email, tatHours, level);
|
||||||
|
|
||||||
|
// Refresh: Update workflow to show new approver
|
||||||
|
await refreshDetails();
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
setShowAddApproverModal(false);
|
||||||
|
|
||||||
|
// Success feedback with details
|
||||||
|
setActionStatus?.({
|
||||||
|
success: true,
|
||||||
|
title: 'Approver Added',
|
||||||
|
message: `Approver added successfully at Level ${level} with ${tatHours}h TAT`
|
||||||
|
});
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Error feedback with backend message
|
||||||
|
setActionStatus?.({
|
||||||
|
success: false,
|
||||||
|
title: 'Failed to Add Approver',
|
||||||
|
message: error?.response?.data?.error || 'Failed to add approver. Please try again.'
|
||||||
|
});
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler: handleSkipApprover
|
||||||
|
*
|
||||||
|
* Purpose: Skip an approver who is unavailable or unresponsive
|
||||||
|
*
|
||||||
|
* Use Case: When approver is on leave, workflow can continue without them
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Validate skip data exists
|
||||||
|
* 2. Submit skip reason to backend
|
||||||
|
* 3. Workflow moves to next level automatically
|
||||||
|
* 4. Show success/error feedback
|
||||||
|
* 5. Clear skip data and close modal
|
||||||
|
*
|
||||||
|
* Backend Actions:
|
||||||
|
* - Marks level as SKIPPED
|
||||||
|
* - Records skip reason and timestamp
|
||||||
|
* - Moves workflow to next level
|
||||||
|
* - Sends notifications
|
||||||
|
*
|
||||||
|
* @param reason - Required reason for skipping approver
|
||||||
|
*/
|
||||||
|
const handleSkipApprover = async (reason: string) => {
|
||||||
|
if (!skipApproverData) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// API Call: Skip approver with reason
|
||||||
|
await skipApprover(requestIdentifier, skipApproverData.levelId, reason);
|
||||||
|
|
||||||
|
// Refresh: Update workflow to show skipped status
|
||||||
|
await refreshDetails();
|
||||||
|
|
||||||
|
// Cleanup and close
|
||||||
|
setShowSkipApproverModal(false);
|
||||||
|
setSkipApproverData(null);
|
||||||
|
|
||||||
|
// Success feedback
|
||||||
|
setActionStatus?.({
|
||||||
|
success: true,
|
||||||
|
title: 'Approver Skipped',
|
||||||
|
message: 'Approver skipped successfully. The workflow has moved to the next level.'
|
||||||
|
});
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Error feedback
|
||||||
|
setActionStatus?.({
|
||||||
|
success: false,
|
||||||
|
title: 'Failed to Skip Approver',
|
||||||
|
message: error?.response?.data?.error || 'Failed to skip approver. Please try again.'
|
||||||
|
});
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler: handleAddSpectator
|
||||||
|
*
|
||||||
|
* Purpose: Add a spectator who can view request but cannot approve/reject
|
||||||
|
*
|
||||||
|
* Use Case: Add stakeholders who need visibility but not approval authority
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Validate email
|
||||||
|
* 2. Call backend to add spectator
|
||||||
|
* 3. Refresh to show new spectator
|
||||||
|
* 4. Show success/error feedback
|
||||||
|
* 5. Close add spectator modal
|
||||||
|
*
|
||||||
|
* Backend Actions:
|
||||||
|
* - Adds user as SPECTATOR participant
|
||||||
|
* - Grants view-only access
|
||||||
|
* - Sends notification to spectator
|
||||||
|
*
|
||||||
|
* @param email - Email of user to add as spectator
|
||||||
|
*/
|
||||||
|
const handleAddSpectator = async (email: string) => {
|
||||||
|
try {
|
||||||
|
// API Call: Add spectator
|
||||||
|
await addSpectator(requestIdentifier, email);
|
||||||
|
|
||||||
|
// Refresh: Update participants list
|
||||||
|
await refreshDetails();
|
||||||
|
|
||||||
|
// Close modal
|
||||||
|
setShowAddSpectatorModal(false);
|
||||||
|
|
||||||
|
// Success feedback
|
||||||
|
setActionStatus?.({
|
||||||
|
success: true,
|
||||||
|
title: 'Spectator Added',
|
||||||
|
message: 'Spectator added successfully. They can now view this request.'
|
||||||
|
});
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
|
} catch (error: any) {
|
||||||
|
// Error feedback
|
||||||
|
setActionStatus?.({
|
||||||
|
success: false,
|
||||||
|
title: 'Failed to Add Spectator',
|
||||||
|
message: error?.response?.data?.error || 'Failed to add spectator. Please try again.'
|
||||||
|
});
|
||||||
|
setShowActionStatusModal?.(true);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Modal visibility states
|
||||||
|
showApproveModal,
|
||||||
|
setShowApproveModal,
|
||||||
|
showRejectModal,
|
||||||
|
setShowRejectModal,
|
||||||
|
showAddApproverModal,
|
||||||
|
setShowAddApproverModal,
|
||||||
|
showAddSpectatorModal,
|
||||||
|
setShowAddSpectatorModal,
|
||||||
|
showSkipApproverModal,
|
||||||
|
setShowSkipApproverModal,
|
||||||
|
showActionStatusModal,
|
||||||
|
setShowActionStatusModal,
|
||||||
|
|
||||||
|
// Skip approver data
|
||||||
|
skipApproverData,
|
||||||
|
setSkipApproverData,
|
||||||
|
|
||||||
|
// Action status
|
||||||
|
actionStatus,
|
||||||
|
setActionStatus,
|
||||||
|
|
||||||
|
// Action handlers
|
||||||
|
handleApproveConfirm,
|
||||||
|
handleRejectConfirm,
|
||||||
|
handleAddApprover,
|
||||||
|
handleSkipApprover,
|
||||||
|
handleAddSpectator
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
562
src/hooks/useRequestDetails.ts
Normal file
562
src/hooks/useRequestDetails.ts
Normal file
@ -0,0 +1,562 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import workflowApi from '@/services/workflowApi';
|
||||||
|
import { CUSTOM_REQUEST_DATABASE } from '@/utils/customRequestDatabase';
|
||||||
|
import { CLAIM_MANAGEMENT_DATABASE } from '@/utils/claimManagementDatabase';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Hook: useRequestDetails
|
||||||
|
*
|
||||||
|
* Purpose: Manages request data fetching, transformation, and state management
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Fetches workflow details from API using request identifier (request number or UUID)
|
||||||
|
* - Transforms backend data structure to frontend format
|
||||||
|
* - Maps approval levels with TAT alerts
|
||||||
|
* - Handles spectators and participants
|
||||||
|
* - Provides refresh functionality
|
||||||
|
* - Falls back to static databases when API fails
|
||||||
|
*
|
||||||
|
* @param requestIdentifier - Request number or UUID to fetch
|
||||||
|
* @param dynamicRequests - Optional array of dynamic requests for fallback
|
||||||
|
* @param user - Current authenticated user object
|
||||||
|
* @returns Object containing request data, loading state, refresh function, etc.
|
||||||
|
*/
|
||||||
|
export function useRequestDetails(
|
||||||
|
requestIdentifier: string,
|
||||||
|
dynamicRequests: any[] = [],
|
||||||
|
user: any
|
||||||
|
) {
|
||||||
|
// State: Stores the fetched and transformed request data
|
||||||
|
const [apiRequest, setApiRequest] = useState<any | null>(null);
|
||||||
|
|
||||||
|
// State: Indicates if data is currently being fetched
|
||||||
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
|
|
||||||
|
// State: Stores the current approval level for the logged-in user
|
||||||
|
const [currentApprovalLevel, setCurrentApprovalLevel] = useState<any | null>(null);
|
||||||
|
|
||||||
|
// State: Indicates if the current user is a spectator (view-only access)
|
||||||
|
const [isSpectator, setIsSpectator] = useState(false);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Convert name/email to initials for avatar display
|
||||||
|
* Example: "John Doe" → "JD", "john@email.com" → "JO"
|
||||||
|
*/
|
||||||
|
const toInitials = (name?: string, email?: string) => {
|
||||||
|
const base = (name || email || 'NA').toString();
|
||||||
|
return base.split(' ').map(s => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Map backend status strings to frontend display format
|
||||||
|
* Converts: IN_PROGRESS → in-review, PENDING → pending, etc.
|
||||||
|
*/
|
||||||
|
const statusMap = (s: string) => {
|
||||||
|
const val = (s || '').toUpperCase();
|
||||||
|
if (val === 'IN_PROGRESS') return 'in-review';
|
||||||
|
if (val === 'PENDING') return 'pending';
|
||||||
|
if (val === 'APPROVED') return 'approved';
|
||||||
|
if (val === 'REJECTED') return 'rejected';
|
||||||
|
if (val === 'CLOSED') return 'closed';
|
||||||
|
if (val === 'SKIPPED') return 'skipped';
|
||||||
|
return (s || '').toLowerCase();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function: refreshDetails
|
||||||
|
*
|
||||||
|
* Purpose: Fetch the latest request data from backend and update all state
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Fetch workflow details from API
|
||||||
|
* 2. Extract and validate data arrays (approvals, participants, documents, TAT alerts)
|
||||||
|
* 3. Transform approval levels with TAT alerts
|
||||||
|
* 4. Map spectators and documents
|
||||||
|
* 5. Filter out TAT warning activities from audit trail
|
||||||
|
* 6. Update all state with transformed data
|
||||||
|
* 7. Determine current user's approval level and spectator status
|
||||||
|
*/
|
||||||
|
const refreshDetails = async () => {
|
||||||
|
setRefreshing(true);
|
||||||
|
try {
|
||||||
|
// API Call: Fetch complete workflow details including approvals, documents, participants
|
||||||
|
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
||||||
|
if (!details) {
|
||||||
|
console.warn('[useRequestDetails] No details returned from API');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract: Separate data structures from API response
|
||||||
|
const wf = details.workflow || {};
|
||||||
|
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
|
||||||
|
const participants = Array.isArray(details.participants) ? details.participants : [];
|
||||||
|
const documents = Array.isArray(details.documents) ? details.documents : [];
|
||||||
|
const summary = details.summary || {};
|
||||||
|
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
||||||
|
|
||||||
|
// Debug: Log TAT alerts for monitoring
|
||||||
|
if (tatAlerts.length > 0) {
|
||||||
|
console.log(`[useRequestDetails] Found ${tatAlerts.length} TAT alerts:`, tatAlerts);
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform: Map approval levels to UI format with TAT alerts
|
||||||
|
* Each approval level includes:
|
||||||
|
* - Display status (waiting, pending, in-review, approved, rejected, skipped)
|
||||||
|
* - TAT information (hours, elapsed, remaining, percentage)
|
||||||
|
* - TAT alerts specific to this level
|
||||||
|
* - Approver details
|
||||||
|
*/
|
||||||
|
const approvalFlow = approvals.map((a: any) => {
|
||||||
|
const levelNumber = a.levelNumber || 0;
|
||||||
|
const levelStatus = (a.status || '').toString().toUpperCase();
|
||||||
|
const levelId = a.levelId || a.level_id;
|
||||||
|
|
||||||
|
// Determine display status based on workflow progress
|
||||||
|
let displayStatus = statusMap(a.status);
|
||||||
|
|
||||||
|
// Future levels that haven't been reached yet show as "waiting"
|
||||||
|
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
|
||||||
|
displayStatus = 'waiting';
|
||||||
|
}
|
||||||
|
// Current level with pending status shows as "pending"
|
||||||
|
else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
|
||||||
|
displayStatus = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: Get TAT alerts that belong to this specific approval level
|
||||||
|
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
step: levelNumber,
|
||||||
|
levelId,
|
||||||
|
role: a.levelName || a.approverName || 'Approver',
|
||||||
|
status: displayStatus,
|
||||||
|
approver: a.approverName || a.approverEmail,
|
||||||
|
approverId: a.approverId || a.approver_id,
|
||||||
|
approverEmail: a.approverEmail,
|
||||||
|
tatHours: Number(a.tatHours || 0),
|
||||||
|
elapsedHours: Number(a.elapsedHours || 0),
|
||||||
|
remainingHours: Number(a.remainingHours || 0),
|
||||||
|
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
||||||
|
// Calculate actual hours taken if level is completed
|
||||||
|
actualHours: a.levelEndTime && a.levelStartTime
|
||||||
|
? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60))
|
||||||
|
: undefined,
|
||||||
|
comment: a.comments || undefined,
|
||||||
|
timestamp: a.actionDate || undefined,
|
||||||
|
levelStartTime: a.levelStartTime || a.tatStartTime,
|
||||||
|
tatAlerts: levelAlerts,
|
||||||
|
skipReason: a.skipReason || undefined,
|
||||||
|
isSkipped: levelStatus === 'SKIPPED' || a.isSkipped || false,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform: Map spectators from participants array
|
||||||
|
* Spectators have view-only access to the request
|
||||||
|
*/
|
||||||
|
const spectators = participants
|
||||||
|
.filter((p: any) => (p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR')
|
||||||
|
.map((p: any) => ({
|
||||||
|
name: p.userName || p.user_name || p.userEmail || p.user_email,
|
||||||
|
role: 'Spectator',
|
||||||
|
email: p.userEmail || p.user_email,
|
||||||
|
avatar: toInitials(p.userName || p.user_name, p.userEmail || p.user_email),
|
||||||
|
}));
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: Get participant name by userId
|
||||||
|
* Used for document upload attribution
|
||||||
|
*/
|
||||||
|
const participantNameById = (uid?: string) => {
|
||||||
|
if (!uid) return undefined;
|
||||||
|
const p = participants.find((x: any) => x.userId === uid || x.user_id === uid);
|
||||||
|
if (p?.userName || p?.user_name) return p.userName || p.user_name;
|
||||||
|
if (wf.initiatorId === uid) return wf.initiator?.displayName || wf.initiator?.email;
|
||||||
|
return uid;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform: Map documents with file size conversion and uploader details
|
||||||
|
* Converts bytes to MB for better readability
|
||||||
|
*/
|
||||||
|
const mappedDocuments = documents.map((d: any) => {
|
||||||
|
const sizeBytes = Number(d.fileSize || d.file_size || 0);
|
||||||
|
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
return {
|
||||||
|
documentId: d.documentId || d.document_id,
|
||||||
|
name: d.originalFileName || d.fileName || d.file_name,
|
||||||
|
fileType: d.fileType || d.file_type || '',
|
||||||
|
size: sizeMb,
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
uploadedBy: participantNameById(d.uploadedBy || d.uploaded_by),
|
||||||
|
uploadedAt: d.uploadedAt || d.uploaded_at,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter: Remove TAT breach activities from audit trail
|
||||||
|
* TAT warnings are already shown in approval steps, no need to duplicate in timeline
|
||||||
|
*/
|
||||||
|
const filteredActivities = Array.isArray(details.activities)
|
||||||
|
? details.activities.filter((activity: any) => {
|
||||||
|
const activityType = (activity.type || '').toLowerCase();
|
||||||
|
return activityType !== 'sla_warning';
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build: Complete request object with all transformed data
|
||||||
|
* This object is used throughout the UI
|
||||||
|
*/
|
||||||
|
const updatedRequest = {
|
||||||
|
...wf,
|
||||||
|
id: wf.requestNumber || wf.requestId,
|
||||||
|
requestId: wf.requestId, // UUID for API calls
|
||||||
|
requestNumber: wf.requestNumber, // Human-readable number for display
|
||||||
|
title: wf.title,
|
||||||
|
description: wf.description,
|
||||||
|
status: statusMap(wf.status),
|
||||||
|
priority: (wf.priority || '').toString().toLowerCase(),
|
||||||
|
approvalFlow,
|
||||||
|
approvals, // Raw approvals for SLA calculations
|
||||||
|
participants,
|
||||||
|
documents: mappedDocuments,
|
||||||
|
spectators,
|
||||||
|
summary, // Backend-provided SLA summary
|
||||||
|
initiator: {
|
||||||
|
name: wf.initiator?.displayName || wf.initiator?.email,
|
||||||
|
role: wf.initiator?.designation || undefined,
|
||||||
|
department: wf.initiator?.department || undefined,
|
||||||
|
email: wf.initiator?.email || undefined,
|
||||||
|
phone: wf.initiator?.phone || undefined,
|
||||||
|
avatar: toInitials(wf.initiator?.displayName, wf.initiator?.email),
|
||||||
|
},
|
||||||
|
createdAt: wf.createdAt,
|
||||||
|
updatedAt: wf.updatedAt,
|
||||||
|
totalSteps: wf.totalLevels,
|
||||||
|
currentStep: summary?.currentLevel || wf.currentLevel,
|
||||||
|
auditTrail: filteredActivities,
|
||||||
|
conclusionRemark: wf.conclusionRemark || null,
|
||||||
|
closureDate: wf.closureDate || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setApiRequest(updatedRequest);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine: Find the approval level assigned to current user
|
||||||
|
* Used to show approve/reject buttons only when user has pending approval
|
||||||
|
*/
|
||||||
|
const userEmail = (user as any)?.email?.toLowerCase();
|
||||||
|
const newCurrentLevel = approvals.find((a: any) => {
|
||||||
|
const st = (a.status || '').toString().toUpperCase();
|
||||||
|
const approverEmail = (a.approverEmail || '').toLowerCase();
|
||||||
|
return (st === 'PENDING' || st === 'IN_PROGRESS') && approverEmail === userEmail;
|
||||||
|
});
|
||||||
|
setCurrentApprovalLevel(newCurrentLevel || null);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine: Check if current user is a spectator
|
||||||
|
* Spectators can only view and comment, cannot approve/reject
|
||||||
|
*/
|
||||||
|
const viewerId = (user as any)?.userId;
|
||||||
|
if (viewerId) {
|
||||||
|
const isSpec = participants.some((p: any) =>
|
||||||
|
(p.participantType || p.participant_type || '').toUpperCase() === 'SPECTATOR' &&
|
||||||
|
(p.userId || p.user_id) === viewerId
|
||||||
|
);
|
||||||
|
setIsSpectator(isSpec);
|
||||||
|
} else {
|
||||||
|
setIsSpectator(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useRequestDetails] Error refreshing details:', error);
|
||||||
|
alert('Failed to refresh request details. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setRefreshing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effect: Initial data fetch when component mounts or requestIdentifier changes
|
||||||
|
* This is the primary data loading mechanism
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestIdentifier) return;
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
||||||
|
if (!mounted || !details) return;
|
||||||
|
|
||||||
|
// Use the same transformation logic as refreshDetails
|
||||||
|
const wf = details.workflow || {};
|
||||||
|
const approvals = Array.isArray(details.approvals) ? details.approvals : [];
|
||||||
|
const participants = Array.isArray(details.participants) ? details.participants : [];
|
||||||
|
const documents = Array.isArray(details.documents) ? details.documents : [];
|
||||||
|
const summary = details.summary || {};
|
||||||
|
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
||||||
|
|
||||||
|
console.log('[useRequestDetails] TAT Alerts received:', tatAlerts.length, tatAlerts);
|
||||||
|
|
||||||
|
const priority = (wf.priority || '').toString().toLowerCase();
|
||||||
|
const currentLevel = summary?.currentLevel || wf.currentLevel || 1;
|
||||||
|
|
||||||
|
// Transform approval flow (same logic as refreshDetails)
|
||||||
|
const approvalFlow = approvals.map((a: any) => {
|
||||||
|
const levelNumber = a.levelNumber || 0;
|
||||||
|
const levelStatus = (a.status || '').toString().toUpperCase();
|
||||||
|
const levelId = a.levelId || a.level_id;
|
||||||
|
|
||||||
|
let displayStatus = statusMap(a.status);
|
||||||
|
|
||||||
|
if (levelNumber > currentLevel && levelStatus !== 'APPROVED' && levelStatus !== 'REJECTED') {
|
||||||
|
displayStatus = 'waiting';
|
||||||
|
} else if (levelNumber === currentLevel && levelStatus === 'PENDING') {
|
||||||
|
displayStatus = 'pending';
|
||||||
|
}
|
||||||
|
|
||||||
|
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
||||||
|
|
||||||
|
return {
|
||||||
|
step: levelNumber,
|
||||||
|
levelId,
|
||||||
|
role: a.levelName || a.approverName || 'Approver',
|
||||||
|
status: displayStatus,
|
||||||
|
approver: a.approverName || a.approverEmail,
|
||||||
|
approverId: a.approverId || a.approver_id,
|
||||||
|
approverEmail: a.approverEmail,
|
||||||
|
tatHours: Number(a.tatHours || 0),
|
||||||
|
elapsedHours: Number(a.elapsedHours || 0),
|
||||||
|
remainingHours: Number(a.remainingHours || 0),
|
||||||
|
tatPercentageUsed: Number(a.tatPercentageUsed || 0),
|
||||||
|
// Use backend-calculated elapsedHours (working hours) for completed approvals
|
||||||
|
// Backend already calculates this correctly using calculateElapsedWorkingHours
|
||||||
|
actualHours: a.elapsedHours !== undefined && a.elapsedHours !== null
|
||||||
|
? Number(a.elapsedHours)
|
||||||
|
: undefined,
|
||||||
|
comment: a.comments || undefined,
|
||||||
|
timestamp: a.actionDate || undefined,
|
||||||
|
levelStartTime: a.levelStartTime || a.tatStartTime,
|
||||||
|
tatAlerts: levelAlerts,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Map spectators
|
||||||
|
const spectators = participants
|
||||||
|
.filter((p: any) => (p.participantType || '').toUpperCase() === 'SPECTATOR')
|
||||||
|
.map((p: any) => ({
|
||||||
|
name: p.userName || p.userEmail,
|
||||||
|
role: 'Spectator',
|
||||||
|
avatar: toInitials(p.userName, p.userEmail),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Helper to get participant name by ID
|
||||||
|
const participantNameById = (uid?: string) => {
|
||||||
|
if (!uid) return undefined;
|
||||||
|
const p = participants.find((x: any) => x.userId === uid);
|
||||||
|
if (p?.userName) return p.userName;
|
||||||
|
if (wf.initiatorId === uid) return wf.initiator?.displayName || wf.initiator?.email;
|
||||||
|
return uid;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map documents with size conversion
|
||||||
|
const mappedDocuments = documents.map((d: any) => {
|
||||||
|
const sizeBytes = Number(d.fileSize || 0);
|
||||||
|
const sizeMb = (sizeBytes / (1024 * 1024)).toFixed(2) + ' MB';
|
||||||
|
return {
|
||||||
|
documentId: d.documentId || d.document_id,
|
||||||
|
name: d.originalFileName || d.fileName,
|
||||||
|
fileType: d.fileType || d.file_type || '',
|
||||||
|
size: sizeMb,
|
||||||
|
sizeBytes: sizeBytes,
|
||||||
|
uploadedBy: participantNameById(d.uploadedBy),
|
||||||
|
uploadedAt: d.uploadedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter out TAT warnings from activities
|
||||||
|
const filteredActivities = Array.isArray(details.activities)
|
||||||
|
? details.activities.filter((activity: any) => {
|
||||||
|
const activityType = (activity.type || '').toLowerCase();
|
||||||
|
return activityType !== 'sla_warning';
|
||||||
|
})
|
||||||
|
: [];
|
||||||
|
|
||||||
|
// Build complete request object
|
||||||
|
const mapped = {
|
||||||
|
id: wf.requestNumber || wf.requestId,
|
||||||
|
requestId: wf.requestId,
|
||||||
|
title: wf.title,
|
||||||
|
description: wf.description,
|
||||||
|
priority,
|
||||||
|
status: statusMap(wf.status),
|
||||||
|
summary,
|
||||||
|
initiator: {
|
||||||
|
name: wf.initiator?.displayName || wf.initiator?.email,
|
||||||
|
role: wf.initiator?.designation || undefined,
|
||||||
|
department: wf.initiator?.department || undefined,
|
||||||
|
email: wf.initiator?.email || undefined,
|
||||||
|
phone: wf.initiator?.phone || undefined,
|
||||||
|
avatar: toInitials(wf.initiator?.displayName, wf.initiator?.email),
|
||||||
|
},
|
||||||
|
createdAt: wf.createdAt,
|
||||||
|
updatedAt: wf.updatedAt,
|
||||||
|
totalSteps: wf.totalLevels,
|
||||||
|
currentStep: summary?.currentLevel || wf.currentLevel,
|
||||||
|
approvalFlow,
|
||||||
|
approvals,
|
||||||
|
documents: mappedDocuments,
|
||||||
|
spectators,
|
||||||
|
auditTrail: filteredActivities,
|
||||||
|
conclusionRemark: wf.conclusionRemark || null,
|
||||||
|
closureDate: wf.closureDate || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
setApiRequest(mapped);
|
||||||
|
|
||||||
|
// Find current user's approval level
|
||||||
|
const userEmail = (user as any)?.email?.toLowerCase();
|
||||||
|
const userCurrentLevel = approvals.find((a: any) => {
|
||||||
|
const status = (a.status || '').toString().toUpperCase();
|
||||||
|
const approverEmail = (a.approverEmail || '').toLowerCase();
|
||||||
|
return (status === 'PENDING' || status === 'IN_PROGRESS') && approverEmail === userEmail;
|
||||||
|
});
|
||||||
|
setCurrentApprovalLevel(userCurrentLevel || null);
|
||||||
|
|
||||||
|
// Check spectator status
|
||||||
|
const viewerId = (user as any)?.userId;
|
||||||
|
if (viewerId) {
|
||||||
|
const isSpec = participants.some((p: any) =>
|
||||||
|
(p.participantType || '').toUpperCase() === 'SPECTATOR' && p.userId === viewerId
|
||||||
|
);
|
||||||
|
setIsSpectator(isSpec);
|
||||||
|
} else {
|
||||||
|
setIsSpectator(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useRequestDetails] Error loading request details:', error);
|
||||||
|
if (mounted) {
|
||||||
|
setApiRequest(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { mounted = false; };
|
||||||
|
}, [requestIdentifier, user]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed: Get final request object with fallback to static databases
|
||||||
|
* Priority: API data → Custom DB → Claim DB → Dynamic props → null
|
||||||
|
*/
|
||||||
|
const request = useMemo(() => {
|
||||||
|
// Primary source: API data
|
||||||
|
if (apiRequest) return apiRequest;
|
||||||
|
|
||||||
|
// Fallback 1: Static custom request database
|
||||||
|
const customRequest = CUSTOM_REQUEST_DATABASE[requestIdentifier];
|
||||||
|
if (customRequest) return customRequest;
|
||||||
|
|
||||||
|
// Fallback 2: Static claim management database
|
||||||
|
const claimRequest = CLAIM_MANAGEMENT_DATABASE[requestIdentifier];
|
||||||
|
if (claimRequest) return claimRequest;
|
||||||
|
|
||||||
|
// Fallback 3: Dynamic requests passed as props
|
||||||
|
const dynamicRequest = dynamicRequests.find((req: any) =>
|
||||||
|
req.id === requestIdentifier ||
|
||||||
|
req.requestNumber === requestIdentifier ||
|
||||||
|
req.request_number === requestIdentifier
|
||||||
|
);
|
||||||
|
if (dynamicRequest) return dynamicRequest;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [requestIdentifier, dynamicRequests, apiRequest]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed: Check if current user is the request initiator
|
||||||
|
* Initiators have special permissions (add approvers, skip approvers, close request)
|
||||||
|
*/
|
||||||
|
const isInitiator = useMemo(() => {
|
||||||
|
if (!request || !user) return false;
|
||||||
|
const userEmail = (user as any)?.email?.toLowerCase();
|
||||||
|
const initiatorEmail = request.initiator?.email?.toLowerCase();
|
||||||
|
return userEmail === initiatorEmail;
|
||||||
|
}, [request, user]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed: Get all existing participants for validation
|
||||||
|
* Used when adding new approvers/spectators to prevent duplicates
|
||||||
|
*/
|
||||||
|
const existingParticipants = useMemo(() => {
|
||||||
|
if (!request) return [];
|
||||||
|
|
||||||
|
const participants: Array<{ email: string; participantType: string; name?: string }> = [];
|
||||||
|
|
||||||
|
// Add initiator
|
||||||
|
if (request.initiator?.email) {
|
||||||
|
participants.push({
|
||||||
|
email: request.initiator.email.toLowerCase(),
|
||||||
|
participantType: 'INITIATOR',
|
||||||
|
name: request.initiator.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add approvers from approval flow
|
||||||
|
if (request.approvalFlow && Array.isArray(request.approvalFlow)) {
|
||||||
|
request.approvalFlow.forEach((approval: any) => {
|
||||||
|
if (approval.approverEmail) {
|
||||||
|
participants.push({
|
||||||
|
email: approval.approverEmail.toLowerCase(),
|
||||||
|
participantType: 'APPROVER',
|
||||||
|
name: approval.approver
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add spectators
|
||||||
|
if (request.spectators && Array.isArray(request.spectators)) {
|
||||||
|
request.spectators.forEach((spectator: any) => {
|
||||||
|
if (spectator.email) {
|
||||||
|
participants.push({
|
||||||
|
email: spectator.email.toLowerCase(),
|
||||||
|
participantType: 'SPECTATOR',
|
||||||
|
name: spectator.name
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add from participants array
|
||||||
|
if (request.participants && Array.isArray(request.participants)) {
|
||||||
|
request.participants.forEach((p: any) => {
|
||||||
|
const email = (p.userEmail || p.email || '').toLowerCase();
|
||||||
|
const participantType = (p.participantType || p.participant_type || '').toUpperCase();
|
||||||
|
const name = p.userName || p.user_name || p.name;
|
||||||
|
|
||||||
|
if (email && participantType && !participants.find(x => x.email === email)) {
|
||||||
|
participants.push({ email, participantType, name });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return participants;
|
||||||
|
}, [request]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
request,
|
||||||
|
apiRequest,
|
||||||
|
refreshing,
|
||||||
|
refreshDetails,
|
||||||
|
currentApprovalLevel,
|
||||||
|
isSpectator,
|
||||||
|
isInitiator,
|
||||||
|
existingParticipants
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
277
src/hooks/useRequestSocket.ts
Normal file
277
src/hooks/useRequestSocket.ts
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
||||||
|
import { getWorkNotes } from '@/services/workflowApi';
|
||||||
|
import workflowApi from '@/services/workflowApi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Hook: useRequestSocket
|
||||||
|
*
|
||||||
|
* Purpose: Manages real-time WebSocket connection for request updates
|
||||||
|
*
|
||||||
|
* Responsibilities:
|
||||||
|
* - Establishes socket connection for the request
|
||||||
|
* - Joins/leaves request-specific room
|
||||||
|
* - Listens for new work notes in real-time
|
||||||
|
* - Listens for TAT alerts and updates
|
||||||
|
* - Merges work notes with activity timeline
|
||||||
|
* - Manages unread work notes badge
|
||||||
|
* - Handles socket cleanup on unmount
|
||||||
|
*
|
||||||
|
* @param requestIdentifier - Request number or UUID
|
||||||
|
* @param apiRequest - Current request data object
|
||||||
|
* @param activeTab - Currently active tab
|
||||||
|
* @param user - Current authenticated user
|
||||||
|
* @returns Object with merged messages, unread count, and work note attachments
|
||||||
|
*/
|
||||||
|
export function useRequestSocket(
|
||||||
|
requestIdentifier: string,
|
||||||
|
apiRequest: any,
|
||||||
|
activeTab: string,
|
||||||
|
user: any
|
||||||
|
) {
|
||||||
|
// State: Merged array of work notes and activities, sorted chronologically
|
||||||
|
const [mergedMessages, setMergedMessages] = useState<any[]>([]);
|
||||||
|
|
||||||
|
// State: Count of unread work notes (shows badge on Work Notes tab)
|
||||||
|
const [unreadWorkNotes, setUnreadWorkNotes] = useState(0);
|
||||||
|
|
||||||
|
// State: Attachments extracted from work notes for Documents tab
|
||||||
|
const [workNoteAttachments, setWorkNoteAttachments] = useState<any[]>([]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effect: Establish socket connection and join request room
|
||||||
|
*
|
||||||
|
* Process:
|
||||||
|
* 1. Resolve UUID from request number if needed
|
||||||
|
* 2. Initialize socket connection
|
||||||
|
* 3. Join request-specific room (makes user "online" for this request)
|
||||||
|
* 4. Cleanup on unmount (leave room, remove listeners)
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestIdentifier) {
|
||||||
|
console.warn('[useRequestSocket] No requestIdentifier, cannot join socket room');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[useRequestSocket] Initializing socket connection for:', requestIdentifier);
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
let actualRequestId = requestIdentifier;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// API Call: Fetch UUID if we have request number (socket rooms use UUID)
|
||||||
|
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
||||||
|
if (details?.workflow?.requestId && mounted) {
|
||||||
|
actualRequestId = details.workflow.requestId;
|
||||||
|
console.log('[useRequestSocket] Resolved UUID:', actualRequestId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useRequestSocket] Failed to resolve UUID:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// Initialize: Get socket instance with base URL
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL?.replace('/api/v1', '') || 'http://localhost:5000';
|
||||||
|
const socket = getSocket(baseUrl);
|
||||||
|
|
||||||
|
if (!socket) {
|
||||||
|
console.error('[useRequestSocket] Socket not available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = (user as any)?.userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler: Join request room when socket connects
|
||||||
|
* This makes the user "online" for this specific request
|
||||||
|
*/
|
||||||
|
const handleConnect = () => {
|
||||||
|
console.log('[useRequestSocket] Socket connected, joining room:', actualRequestId);
|
||||||
|
joinRequestRoom(socket, actualRequestId, userId);
|
||||||
|
console.log(`[useRequestSocket] ✅ Joined room: ${actualRequestId} - User is ONLINE`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Join immediately if already connected, otherwise wait for connect event
|
||||||
|
if (socket.connected) {
|
||||||
|
handleConnect();
|
||||||
|
} else {
|
||||||
|
socket.on('connect', handleConnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup: Leave room and remove listeners when component unmounts
|
||||||
|
* This marks user as "offline" for this request
|
||||||
|
*/
|
||||||
|
return () => {
|
||||||
|
if (mounted) {
|
||||||
|
socket.off('connect', handleConnect);
|
||||||
|
leaveRequestRoom(socket, actualRequestId);
|
||||||
|
console.log(`[useRequestSocket] ✅ Left room: ${actualRequestId} - User is OFFLINE`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { mounted = false; };
|
||||||
|
}, [requestIdentifier, user]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effect: Fetch and merge work notes with activities for timeline display
|
||||||
|
*
|
||||||
|
* Purpose: Combine work notes (real-time chat) with audit trail (system events)
|
||||||
|
* to create a unified timeline view
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestIdentifier || !apiRequest) return;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch: Get all work notes for this request
|
||||||
|
const workNotes = await getWorkNotes(requestIdentifier);
|
||||||
|
const activities = apiRequest.auditTrail || [];
|
||||||
|
|
||||||
|
// Merge: Combine work notes and activities
|
||||||
|
const merged = [...workNotes, ...activities];
|
||||||
|
|
||||||
|
// Sort: Order by timestamp (oldest to newest)
|
||||||
|
merged.sort((a, b) => {
|
||||||
|
const timeA = new Date(a.createdAt || a.created_at || a.timestamp || 0).getTime();
|
||||||
|
const timeB = new Date(b.createdAt || b.created_at || b.timestamp || 0).getTime();
|
||||||
|
return timeA - timeB;
|
||||||
|
});
|
||||||
|
|
||||||
|
setMergedMessages(merged);
|
||||||
|
console.log(`[useRequestSocket] Merged ${workNotes.length} work notes with ${activities.length} activities`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useRequestSocket] Failed to fetch and merge messages:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [requestIdentifier, apiRequest]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effect: Listen for real-time work notes and TAT alerts via WebSocket
|
||||||
|
*
|
||||||
|
* Listens for:
|
||||||
|
* 1. 'noteHandler' / 'worknote:new' - New work note added
|
||||||
|
* 2. 'tat:alert' - TAT threshold reached or deadline breached
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestIdentifier) return;
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL?.replace('/api/v1', '') || 'http://localhost:5000';
|
||||||
|
const socket = getSocket(baseUrl);
|
||||||
|
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler: New work note received via WebSocket
|
||||||
|
*
|
||||||
|
* Actions:
|
||||||
|
* 1. Increment unread badge if user is not on Work Notes tab
|
||||||
|
* 2. Refresh merged messages to show new note
|
||||||
|
*/
|
||||||
|
const handleNewWorkNote = (data: any) => {
|
||||||
|
console.log(`[useRequestSocket] 🆕 New work note received:`, data);
|
||||||
|
|
||||||
|
// Update unread badge (only if not viewing work notes)
|
||||||
|
if (activeTab !== 'worknotes') {
|
||||||
|
setUnreadWorkNotes(prev => prev + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh: Re-fetch and merge messages to include new work note
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const workNotes = await getWorkNotes(requestIdentifier);
|
||||||
|
const activities = apiRequest?.auditTrail || [];
|
||||||
|
const merged = [...workNotes, ...activities].sort((a, b) => {
|
||||||
|
const timeA = new Date(a.createdAt || a.created_at || a.timestamp || 0).getTime();
|
||||||
|
const timeB = new Date(b.createdAt || b.created_at || b.timestamp || 0).getTime();
|
||||||
|
return timeA - timeB;
|
||||||
|
});
|
||||||
|
setMergedMessages(merged);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useRequestSocket] Failed to refresh messages:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler: TAT alert received via WebSocket
|
||||||
|
*
|
||||||
|
* Triggered when:
|
||||||
|
* - 50% TAT threshold reached
|
||||||
|
* - 75% TAT threshold reached
|
||||||
|
* - 100% TAT deadline breached
|
||||||
|
*
|
||||||
|
* Actions:
|
||||||
|
* 1. Show console notification with emoji indicator
|
||||||
|
* 2. Refresh request data to get updated TAT alerts
|
||||||
|
* 3. Show browser notification if permission granted
|
||||||
|
*/
|
||||||
|
const handleTatAlert = (data: any) => {
|
||||||
|
console.log(`[useRequestSocket] 🔔 Real-time TAT alert received:`, data);
|
||||||
|
|
||||||
|
// Visual feedback in console with emoji
|
||||||
|
const alertEmoji = data.type === 'breach' ? '⏰' : data.type === 'threshold2' ? '⚠️' : '⏳';
|
||||||
|
console.log(`%c${alertEmoji} TAT Alert: ${data.message}`, 'color: #ff6600; font-size: 14px; font-weight: bold;');
|
||||||
|
|
||||||
|
// Refresh: Get updated TAT alerts from backend
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
||||||
|
|
||||||
|
if (details) {
|
||||||
|
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
||||||
|
console.log(`[useRequestSocket] Refreshed TAT alerts:`, tatAlerts);
|
||||||
|
|
||||||
|
// Browser notification (if user granted permission)
|
||||||
|
if ('Notification' in window && Notification.permission === 'granted') {
|
||||||
|
new Notification(`${alertEmoji} TAT Alert`, {
|
||||||
|
body: data.message,
|
||||||
|
icon: '/favicon.ico',
|
||||||
|
tag: `tat-${data.requestId}-${data.type}`,
|
||||||
|
requireInteraction: false
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[useRequestSocket] Failed to refresh after TAT alert:', error);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Register: Add event listeners for real-time updates
|
||||||
|
socket.on('noteHandler', handleNewWorkNote);
|
||||||
|
socket.on('worknote:new', handleNewWorkNote);
|
||||||
|
socket.on('tat:alert', handleTatAlert);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup: Remove event listeners when component unmounts or dependencies change
|
||||||
|
* Prevents memory leaks and duplicate listeners
|
||||||
|
*/
|
||||||
|
return () => {
|
||||||
|
socket.off('noteHandler', handleNewWorkNote);
|
||||||
|
socket.off('worknote:new', handleNewWorkNote);
|
||||||
|
socket.off('tat:alert', handleTatAlert);
|
||||||
|
};
|
||||||
|
}, [requestIdentifier, activeTab, apiRequest]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Effect: Reset unread count when user switches to Work Notes tab
|
||||||
|
* User has seen the messages, so clear the badge
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'worknotes') {
|
||||||
|
setUnreadWorkNotes(0);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mergedMessages,
|
||||||
|
unreadWorkNotes,
|
||||||
|
workNoteAttachments,
|
||||||
|
setWorkNoteAttachments
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@ -7,12 +7,14 @@ import { getSLAStatus, SLAStatus } from '@/utils/slaTracker';
|
|||||||
*
|
*
|
||||||
* @param startDate - When the SLA tracking started
|
* @param startDate - When the SLA tracking started
|
||||||
* @param deadline - When the SLA should complete
|
* @param deadline - When the SLA should complete
|
||||||
|
* @param priority - Priority type ('express' = calendar hours, 'standard' = working hours)
|
||||||
* @param enabled - Whether tracking is enabled (default: true)
|
* @param enabled - Whether tracking is enabled (default: true)
|
||||||
* @returns SLAStatus object with real-time updates
|
* @returns SLAStatus object with real-time updates
|
||||||
*/
|
*/
|
||||||
export function useSLATracking(
|
export function useSLATracking(
|
||||||
startDate: string | Date | null | undefined,
|
startDate: string | Date | null | undefined,
|
||||||
deadline: string | Date | null | undefined,
|
deadline: string | Date | null | undefined,
|
||||||
|
priority?: string,
|
||||||
enabled: boolean = true
|
enabled: boolean = true
|
||||||
): SLAStatus | null {
|
): SLAStatus | null {
|
||||||
const [slaStatus, setSlaStatus] = useState<SLAStatus | null>(null);
|
const [slaStatus, setSlaStatus] = useState<SLAStatus | null>(null);
|
||||||
@ -26,7 +28,7 @@ export function useSLATracking(
|
|||||||
// Initial calculation
|
// Initial calculation
|
||||||
const updateStatus = () => {
|
const updateStatus = () => {
|
||||||
try {
|
try {
|
||||||
const status = getSLAStatus(startDate, deadline);
|
const status = getSLAStatus(startDate, deadline, priority);
|
||||||
setSlaStatus(status);
|
setSlaStatus(status);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[useSLATracking] Error calculating SLA status:', error);
|
console.error('[useSLATracking] Error calculating SLA status:', error);
|
||||||
@ -39,7 +41,7 @@ export function useSLATracking(
|
|||||||
const interval = setInterval(updateStatus, 60000); // 60 seconds
|
const interval = setInterval(updateStatus, 60000); // 60 seconds
|
||||||
|
|
||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [startDate, deadline, enabled]);
|
}, [startDate, deadline, priority, enabled]);
|
||||||
|
|
||||||
return slaStatus;
|
return slaStatus;
|
||||||
}
|
}
|
||||||
|
|||||||
531
src/pages/Admin/Admin.tsx
Normal file
531
src/pages/Admin/Admin.tsx
Normal file
@ -0,0 +1,531 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
ChartColumn,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
Bell,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
Brain,
|
||||||
|
Share2,
|
||||||
|
Activity,
|
||||||
|
Search,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
CircleAlert
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { AnalyticsConfig } from '@/components/admin/AnalyticsConfig';
|
||||||
|
import { UserManagement } from '@/components/admin/UserManagement';
|
||||||
|
import { TATConfig } from '@/components/admin/TATConfig';
|
||||||
|
import { NotificationConfig } from '@/components/admin/NotificationConfig';
|
||||||
|
import { DocumentConfig } from '@/components/admin/DocumentConfig';
|
||||||
|
import { DashboardConfig } from '@/components/admin/DashboardConfig';
|
||||||
|
import { AIConfig } from '@/components/admin/AIConfig';
|
||||||
|
import { SharingConfig } from '@/components/admin/SharingConfig';
|
||||||
|
|
||||||
|
interface KPICard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
value: string | number;
|
||||||
|
unit?: string;
|
||||||
|
change?: number;
|
||||||
|
changeType?: 'up' | 'down';
|
||||||
|
period: string;
|
||||||
|
visibleTo: string[];
|
||||||
|
category: string;
|
||||||
|
bgColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
thresholdBreached?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KPICategory {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
kpis: KPICard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Admin() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState('sharing');
|
||||||
|
|
||||||
|
// Sample KPI data organized by category
|
||||||
|
const kpiCategories: KPICategory[] = [
|
||||||
|
{
|
||||||
|
name: 'Request Volume & Status',
|
||||||
|
count: 4,
|
||||||
|
kpis: [
|
||||||
|
{
|
||||||
|
id: 'total-requests',
|
||||||
|
title: 'Total Requests Created',
|
||||||
|
description: 'Count of all workflow requests created in a selected period.',
|
||||||
|
value: 5,
|
||||||
|
change: 12,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'Request Volume & Status',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-requests',
|
||||||
|
title: 'Open Requests',
|
||||||
|
description: 'Number of workflows currently in progress with age.',
|
||||||
|
value: 3,
|
||||||
|
change: -5,
|
||||||
|
changeType: 'down',
|
||||||
|
period: 'Today',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'Request Volume & Status',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approved-requests',
|
||||||
|
title: 'Approved Requests',
|
||||||
|
description: 'Requests fully approved and closed.',
|
||||||
|
value: 1,
|
||||||
|
change: 8,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'Request Volume & Status',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rejected-requests',
|
||||||
|
title: 'Rejected Requests',
|
||||||
|
description: 'Requests rejected at any approval stage.',
|
||||||
|
value: 1,
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'Request Volume & Status',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'TAT Efficiency',
|
||||||
|
count: 3,
|
||||||
|
kpis: [
|
||||||
|
{
|
||||||
|
id: 'avg-tat-compliance',
|
||||||
|
title: 'Average TAT Compliance %',
|
||||||
|
description: '% of workflows completed within defined TAT vs breached ones at every level.',
|
||||||
|
value: 100,
|
||||||
|
unit: '%',
|
||||||
|
change: 3,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'TAT Efficiency',
|
||||||
|
bgColor: 'bg-green-50',
|
||||||
|
borderColor: 'border-green-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'avg-approval-cycle',
|
||||||
|
title: 'Avg Approval Cycle Time (Days)',
|
||||||
|
description: 'Average total time from creation to closure.',
|
||||||
|
value: 5.2,
|
||||||
|
unit: ' days',
|
||||||
|
change: -15,
|
||||||
|
changeType: 'down',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'TAT Efficiency',
|
||||||
|
bgColor: 'bg-green-50',
|
||||||
|
borderColor: 'border-green-200',
|
||||||
|
thresholdBreached: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delayed-workflows',
|
||||||
|
title: 'Delayed Workflows',
|
||||||
|
description: 'Requests currently breaching their TAT.',
|
||||||
|
value: 0,
|
||||||
|
change: -2,
|
||||||
|
changeType: 'down',
|
||||||
|
period: 'Today',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'TAT Efficiency',
|
||||||
|
bgColor: 'bg-green-50',
|
||||||
|
borderColor: 'border-green-200'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Approver Load',
|
||||||
|
count: 2,
|
||||||
|
kpis: [
|
||||||
|
{
|
||||||
|
id: 'pending-actions',
|
||||||
|
title: 'Pending Actions (My Queue)',
|
||||||
|
description: 'Requests currently awaiting user\'s approval with age.',
|
||||||
|
value: 3,
|
||||||
|
period: 'Today',
|
||||||
|
visibleTo: ['Management', 'Admin'],
|
||||||
|
category: 'Approver Load',
|
||||||
|
bgColor: 'bg-orange-50',
|
||||||
|
borderColor: 'border-orange-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approvals-completed',
|
||||||
|
title: 'Approvals Completed (Today/Week)',
|
||||||
|
description: 'Count of actions taken within a time frame.',
|
||||||
|
value: 0,
|
||||||
|
change: 22,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Week',
|
||||||
|
visibleTo: ['Management', 'Admin'],
|
||||||
|
category: 'Approver Load',
|
||||||
|
bgColor: 'bg-orange-50',
|
||||||
|
borderColor: 'border-orange-200'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Engagement & Quality',
|
||||||
|
count: 2,
|
||||||
|
kpis: [
|
||||||
|
{
|
||||||
|
id: 'comments-worknotes',
|
||||||
|
title: 'Comments / Work Notes Added',
|
||||||
|
description: 'Measures collaboration activity.',
|
||||||
|
value: 8,
|
||||||
|
change: 18,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Week',
|
||||||
|
visibleTo: ['Management', 'Admin'],
|
||||||
|
category: 'Engagement & Quality',
|
||||||
|
bgColor: 'bg-purple-50',
|
||||||
|
borderColor: 'border-purple-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'attachments-uploaded',
|
||||||
|
title: 'Attachments Uploaded',
|
||||||
|
description: 'Number of documents added to workflows.',
|
||||||
|
value: 16,
|
||||||
|
change: 10,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'Engagement & Quality',
|
||||||
|
bgColor: 'bg-purple-50',
|
||||||
|
borderColor: 'border-purple-200'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'AI & Closure Insights',
|
||||||
|
count: 2,
|
||||||
|
kpis: [
|
||||||
|
{
|
||||||
|
id: 'avg-conclusion-length',
|
||||||
|
title: 'Avg Conclusion Remark Length',
|
||||||
|
description: 'Indicates depth of closure remarks (optional).',
|
||||||
|
value: 85,
|
||||||
|
unit: ' chars',
|
||||||
|
change: 5,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Management', 'Admin'],
|
||||||
|
category: 'AI & Closure Insights',
|
||||||
|
bgColor: 'bg-pink-50',
|
||||||
|
borderColor: 'border-pink-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai-summary-adoption',
|
||||||
|
title: 'AI Summary Adoption %',
|
||||||
|
description: 'How many closures used AI-generated text vs manual edits.',
|
||||||
|
value: 0,
|
||||||
|
unit: '%',
|
||||||
|
change: 25,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Management', 'Admin'],
|
||||||
|
category: 'AI & Closure Insights',
|
||||||
|
bgColor: 'bg-pink-50',
|
||||||
|
borderColor: 'border-pink-200'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter KPIs based on search
|
||||||
|
const filteredCategories = kpiCategories.map(category => ({
|
||||||
|
...category,
|
||||||
|
kpis: category.kpis.filter(kpi =>
|
||||||
|
kpi.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
kpi.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
kpi.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
})).filter(category => category.kpis.length > 0);
|
||||||
|
|
||||||
|
const totalActiveKPIs = kpiCategories.reduce((sum, cat) => sum + cat.count, 0);
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'Initiator':
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
case 'Management':
|
||||||
|
return 'bg-orange-100 text-orange-800 border-orange-200';
|
||||||
|
case 'Admin':
|
||||||
|
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-re-black flex items-center gap-2">
|
||||||
|
<Shield className="w-8 h-8 text-re-green" />
|
||||||
|
Admin Control Panel
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage users, configure system settings, and control portal behavior
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-re-green/10 text-re-green border-re-green/20 px-4 py-2">
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Administrator: {user?.displayName || 'Admin User'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col gap-2 space-y-4">
|
||||||
|
<TabsList className="text-muted-foreground items-center justify-center rounded-xl p-[3px] bg-muted grid w-full grid-cols-9 h-auto">
|
||||||
|
<TabsTrigger value="kpi" className="flex items-center gap-2">
|
||||||
|
<ChartColumn className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">KPI Config</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="analytics" className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Analytics</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Users</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tat" className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">TAT</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="notifications" className="flex items-center gap-2">
|
||||||
|
<Bell className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Notify</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="documents" className="flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Docs</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="dashboard" className="flex items-center gap-2">
|
||||||
|
<LayoutDashboard className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Dashboard</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ai" className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">AI</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sharing" className="flex items-center gap-2">
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Sharing</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* KPI Config Tab */}
|
||||||
|
<TabsContent value="kpi" className="flex-1 outline-none space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>KPI Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure which KPIs are enabled, visible to specific roles, and set alert thresholds
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-blue-50 text-blue-700">
|
||||||
|
{totalActiveKPIs} / {totalActiveKPIs} KPIs Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search KPIs by name, description, or category..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Counts */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
{kpiCategories.map((category) => (
|
||||||
|
<div key={category.name} className="p-4 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-xs text-muted-foreground">{category.name}</p>
|
||||||
|
<p className="text-2xl font-semibold">{category.count}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards by Category */}
|
||||||
|
{filteredCategories.map((category) => (
|
||||||
|
<div key={category.name} className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5 text-re-green" />
|
||||||
|
<h3 className="font-semibold">{category.name}</h3>
|
||||||
|
<Badge variant="outline" className="ml-auto">
|
||||||
|
{category.kpis.length} Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{category.kpis.map((kpi) => (
|
||||||
|
<div
|
||||||
|
key={kpi.id}
|
||||||
|
className={`p-4 border rounded-lg ${kpi.bgColor} ${kpi.borderColor} transition-all hover:shadow-md ${
|
||||||
|
kpi.thresholdBreached ? 'ring-2 ring-orange-500' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-sm mb-1">{kpi.title}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||||
|
{kpi.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-green-100 text-green-800 text-xs">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-3 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-4xl font-bold text-re-black">
|
||||||
|
{kpi.value}
|
||||||
|
</span>
|
||||||
|
{kpi.unit && (
|
||||||
|
<span className="text-lg text-muted-foreground">
|
||||||
|
{kpi.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{kpi.change !== undefined && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
||||||
|
kpi.changeType === 'up'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{kpi.changeType === 'up' ? (
|
||||||
|
<ArrowUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold">{Math.abs(kpi.change)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 pt-2 border-t">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground">Visible:</span>
|
||||||
|
{kpi.visibleTo.map((role) => (
|
||||||
|
<Badge
|
||||||
|
key={role}
|
||||||
|
className={`${getRoleBadgeColor(role)} text-xs px-1.5 py-0`}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Period: {kpi.period}
|
||||||
|
</span>
|
||||||
|
{kpi.thresholdBreached && (
|
||||||
|
<div className="flex items-center gap-1 text-orange-600">
|
||||||
|
<CircleAlert className="w-3 h-3" />
|
||||||
|
<span className="font-medium">Threshold Breached!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Analytics Tab */}
|
||||||
|
<TabsContent value="analytics" className="flex-1 outline-none space-y-4">
|
||||||
|
<AnalyticsConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Users Tab */}
|
||||||
|
<TabsContent value="users" className="flex-1 outline-none space-y-4">
|
||||||
|
<UserManagement />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* TAT Tab */}
|
||||||
|
<TabsContent value="tat" className="flex-1 outline-none space-y-4">
|
||||||
|
<TATConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Notifications Tab */}
|
||||||
|
<TabsContent value="notifications" className="flex-1 outline-none space-y-4">
|
||||||
|
<NotificationConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Documents Tab */}
|
||||||
|
<TabsContent value="documents" className="flex-1 outline-none space-y-4">
|
||||||
|
<DocumentConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Dashboard Tab */}
|
||||||
|
<TabsContent value="dashboard" className="flex-1 outline-none space-y-4">
|
||||||
|
<DashboardConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* AI Tab */}
|
||||||
|
<TabsContent value="ai" className="flex-1 outline-none space-y-4">
|
||||||
|
<AIConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Sharing Tab */}
|
||||||
|
<TabsContent value="sharing" className="flex-1 outline-none space-y-4">
|
||||||
|
<SharingConfig />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user