tab job started implementing add holiday featre added in seetings worknote moved to request detail sceen
This commit is contained in:
parent
3ee174e44e
commit
605ae8d138
537
ADMIN_FEATURES_GUIDE.md
Normal file
537
ADMIN_FEATURES_GUIDE.md
Normal file
@ -0,0 +1,537 @@
|
|||||||
|
# 🎛️ 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
|
||||||
|
|
||||||
437
src/components/admin/ConfigurationManager.tsx
Normal file
437
src/components/admin/ConfigurationManager.tsx
Normal file
@ -0,0 +1,437 @@
|
|||||||
|
import { useState, useEffect } 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 { Label } from '@/components/ui/label';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import {
|
||||||
|
Settings,
|
||||||
|
Save,
|
||||||
|
RotateCcw,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
FileText,
|
||||||
|
Bell,
|
||||||
|
Sparkles,
|
||||||
|
Share2,
|
||||||
|
Loader2
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getAllConfigurations, updateConfiguration, resetConfiguration, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
|
||||||
|
interface ConfigurationManagerProps {
|
||||||
|
onConfigUpdate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConfigurationManager({ onConfigUpdate }: ConfigurationManagerProps) {
|
||||||
|
const [configurations, setConfigurations] = useState<AdminConfiguration[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editedValues, setEditedValues] = useState<Record<string, string>>({});
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigurations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfigurations = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const configs = await getAllConfigurations();
|
||||||
|
setConfigurations(configs);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to load configurations');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async (config: AdminConfiguration) => {
|
||||||
|
try {
|
||||||
|
setSaving(config.configKey);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const newValue = editedValues[config.configKey] ?? config.configValue;
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if (config.validationRules) {
|
||||||
|
const numValue = parseFloat(newValue);
|
||||||
|
if (config.valueType === 'NUMBER') {
|
||||||
|
if (config.validationRules.min !== undefined && numValue < config.validationRules.min) {
|
||||||
|
throw new Error(`Value must be at least ${config.validationRules.min}`);
|
||||||
|
}
|
||||||
|
if (config.validationRules.max !== undefined && numValue > config.validationRules.max) {
|
||||||
|
throw new Error(`Value must be at most ${config.validationRules.max}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await updateConfiguration(config.configKey, newValue);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setConfigurations(prev =>
|
||||||
|
prev.map(c =>
|
||||||
|
c.configKey === config.configKey
|
||||||
|
? { ...c, configValue: newValue }
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear edited value
|
||||||
|
setEditedValues(prev => {
|
||||||
|
const newValues = { ...prev };
|
||||||
|
delete newValues[config.configKey];
|
||||||
|
return newValues;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSuccessMessage(`${config.displayName} updated successfully`);
|
||||||
|
setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
|
|
||||||
|
if (onConfigUpdate) {
|
||||||
|
onConfigUpdate();
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message || err.response?.data?.error || 'Failed to save configuration');
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = async (config: AdminConfiguration) => {
|
||||||
|
if (!confirm(`Reset "${config.displayName}" to default value?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(config.configKey);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
await resetConfiguration(config.configKey);
|
||||||
|
|
||||||
|
// Update local state
|
||||||
|
setConfigurations(prev =>
|
||||||
|
prev.map(c =>
|
||||||
|
c.configKey === config.configKey
|
||||||
|
? { ...c, configValue: c.defaultValue || '' }
|
||||||
|
: c
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear edited value
|
||||||
|
setEditedValues(prev => {
|
||||||
|
const newValues = { ...prev };
|
||||||
|
delete newValues[config.configKey];
|
||||||
|
return newValues;
|
||||||
|
});
|
||||||
|
|
||||||
|
setSuccessMessage(`${config.displayName} reset to default`);
|
||||||
|
setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to reset configuration');
|
||||||
|
} finally {
|
||||||
|
setSaving(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleValueChange = (configKey: string, value: string) => {
|
||||||
|
setEditedValues(prev => ({ ...prev, [configKey]: value }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCurrentValue = (config: AdminConfiguration): string => {
|
||||||
|
return editedValues[config.configKey] ?? config.configValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const hasChanges = (config: AdminConfiguration): boolean => {
|
||||||
|
return editedValues[config.configKey] !== undefined &&
|
||||||
|
editedValues[config.configKey] !== config.configValue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderConfigInput = (config: AdminConfiguration) => {
|
||||||
|
const currentValue = getCurrentValue(config);
|
||||||
|
const isChanged = hasChanges(config);
|
||||||
|
const isSaving = saving === config.configKey;
|
||||||
|
|
||||||
|
if (!config.isEditable) {
|
||||||
|
return (
|
||||||
|
<div className="p-3 bg-gray-100 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600 font-mono">{config.configValue}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">This setting cannot be modified</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (config.uiComponent || config.valueType.toLowerCase()) {
|
||||||
|
case 'toggle':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{currentValue === 'true' ? 'Enabled' : 'Disabled'}
|
||||||
|
</span>
|
||||||
|
<Switch
|
||||||
|
checked={currentValue === 'true'}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleValueChange(config.configKey, checked ? 'true' : 'false')
|
||||||
|
}
|
||||||
|
disabled={isSaving}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'slider':
|
||||||
|
const numValue = parseInt(currentValue) || 0;
|
||||||
|
const min = config.validationRules?.min || 0;
|
||||||
|
const max = config.validationRules?.max || 100;
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">{numValue}%</span>
|
||||||
|
<span className="text-xs text-gray-500">Range: {min}-{max}</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
value={[numValue]}
|
||||||
|
min={min}
|
||||||
|
max={max}
|
||||||
|
step={1}
|
||||||
|
onValueChange={([value]) => handleValueChange(config.configKey, value.toString())}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'number':
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => handleValueChange(config.configKey, e.target.value)}
|
||||||
|
disabled={isSaving}
|
||||||
|
min={config.validationRules?.min}
|
||||||
|
max={config.validationRules?.max}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
case 'text':
|
||||||
|
case 'input':
|
||||||
|
default:
|
||||||
|
return (
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => handleValueChange(config.configKey, e.target.value)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="font-mono"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryIcon = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case 'TAT_SETTINGS':
|
||||||
|
return <Clock className="w-5 h-5" />;
|
||||||
|
case 'DOCUMENT_POLICY':
|
||||||
|
return <FileText className="w-5 h-5" />;
|
||||||
|
case 'NOTIFICATION_RULES':
|
||||||
|
return <Bell className="w-5 h-5" />;
|
||||||
|
case 'AI_CONFIGURATION':
|
||||||
|
return <Sparkles className="w-5 h-5" />;
|
||||||
|
case 'WORKFLOW_SHARING':
|
||||||
|
return <Share2 className="w-5 h-5" />;
|
||||||
|
default:
|
||||||
|
return <Settings className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getCategoryColor = (category: string) => {
|
||||||
|
switch (category) {
|
||||||
|
case 'TAT_SETTINGS':
|
||||||
|
return 'bg-blue-100 text-blue-600';
|
||||||
|
case 'DOCUMENT_POLICY':
|
||||||
|
return 'bg-purple-100 text-purple-600';
|
||||||
|
case 'NOTIFICATION_RULES':
|
||||||
|
return 'bg-amber-100 text-amber-600';
|
||||||
|
case 'AI_CONFIGURATION':
|
||||||
|
return 'bg-pink-100 text-pink-600';
|
||||||
|
case 'WORKFLOW_SHARING':
|
||||||
|
return 'bg-emerald-100 text-emerald-600';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-600';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const groupedConfigs = configurations.reduce((acc, config) => {
|
||||||
|
if (!acc[config.configCategory]) {
|
||||||
|
acc[config.configCategory] = [];
|
||||||
|
}
|
||||||
|
acc[config.configCategory].push(config);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, AdminConfiguration[]>);
|
||||||
|
|
||||||
|
// Sort configs within each category by sortOrder
|
||||||
|
Object.keys(groupedConfigs).forEach(category => {
|
||||||
|
groupedConfigs[category].sort((a, b) => a.sortOrder - b.sortOrder);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (configurations.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Settings className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600">No configurations found</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-2">
|
||||||
|
System configurations will appear here once they are initialized
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const categories = Object.keys(groupedConfigs);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Success Message */}
|
||||||
|
{successMessage && (
|
||||||
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600 shrink-0" />
|
||||||
|
<p className="text-sm text-green-800">{successMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 shrink-0" />
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Configurations by Category */}
|
||||||
|
<Tabs defaultValue={categories[0]} className="w-full">
|
||||||
|
<TabsList className="grid w-full" style={{ gridTemplateColumns: `repeat(${Math.min(categories.length, 5)}, 1fr)` }}>
|
||||||
|
{categories.map(category => (
|
||||||
|
<TabsTrigger key={category} value={category} className="text-xs sm:text-sm">
|
||||||
|
{category.replace(/_/g, ' ')}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{categories.map(category => (
|
||||||
|
<TabsContent key={category} value={category} className="space-y-4 mt-6">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-3 rounded-lg ${getCategoryColor(category)}`}>
|
||||||
|
{getCategoryIcon(category)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg">
|
||||||
|
{category.replace(/_/g, ' ')}
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{groupedConfigs[category].length} setting{groupedConfigs[category].length !== 1 ? 's' : ''}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{groupedConfigs[category].map(config => (
|
||||||
|
<div key={config.configKey} className="space-y-3 pb-6 border-b last:border-b-0 last:pb-0">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<Label className="text-sm font-semibold text-gray-900">
|
||||||
|
{config.displayName}
|
||||||
|
</Label>
|
||||||
|
{hasChanges(config) && (
|
||||||
|
<Badge variant="outline" className="text-xs bg-yellow-50 text-yellow-700 border-yellow-300">
|
||||||
|
Modified
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{config.requiresRestart && (
|
||||||
|
<Badge variant="outline" className="text-xs bg-orange-50 text-orange-700 border-orange-300">
|
||||||
|
Requires Restart
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{config.description && (
|
||||||
|
<p className="text-xs text-gray-600 mt-1">{config.description}</p>
|
||||||
|
)}
|
||||||
|
{config.defaultValue && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Default: <code className="px-1.5 py-0.5 bg-gray-100 rounded">{config.defaultValue}</code>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{renderConfigInput(config)}
|
||||||
|
|
||||||
|
{config.isEditable && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleSave(config)}
|
||||||
|
disabled={!hasChanges(config) || saving === config.configKey}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
{saving === config.configKey ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4" />
|
||||||
|
Save
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{config.defaultValue && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => handleReset(config)}
|
||||||
|
disabled={saving === config.configKey}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-4 h-4" />
|
||||||
|
Reset to Default
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
))}
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
399
src/components/admin/HolidayManager.tsx
Normal file
399
src/components/admin/HolidayManager.tsx
Normal file
@ -0,0 +1,399 @@
|
|||||||
|
import { useState, useEffect } 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 { Label } from '@/components/ui/label';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Calendar,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Edit2,
|
||||||
|
Loader2,
|
||||||
|
AlertCircle,
|
||||||
|
CheckCircle,
|
||||||
|
Upload
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { getAllHolidays, createHoliday, updateHoliday, deleteHoliday, Holiday } from '@/services/adminApi';
|
||||||
|
import { formatDateShort } from '@/utils/dateFormatter';
|
||||||
|
|
||||||
|
export function HolidayManager() {
|
||||||
|
const [holidays, setHolidays] = useState<Holiday[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
|
||||||
|
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||||
|
const [editingHoliday, setEditingHoliday] = useState<Holiday | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
holidayDate: '',
|
||||||
|
holidayName: '',
|
||||||
|
description: '',
|
||||||
|
holidayType: 'ORGANIZATIONAL' as Holiday['holidayType'],
|
||||||
|
isRecurring: false
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadHolidays();
|
||||||
|
}, [selectedYear]);
|
||||||
|
|
||||||
|
const loadHolidays = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await getAllHolidays(selectedYear);
|
||||||
|
setHolidays(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to load holidays');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = () => {
|
||||||
|
setFormData({
|
||||||
|
holidayDate: '',
|
||||||
|
holidayName: '',
|
||||||
|
description: '',
|
||||||
|
holidayType: 'ORGANIZATIONAL',
|
||||||
|
isRecurring: false
|
||||||
|
});
|
||||||
|
setEditingHoliday(null);
|
||||||
|
setShowAddDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = (holiday: Holiday) => {
|
||||||
|
setFormData({
|
||||||
|
holidayDate: holiday.holidayDate,
|
||||||
|
holidayName: holiday.holidayName,
|
||||||
|
description: holiday.description || '',
|
||||||
|
holidayType: holiday.holidayType,
|
||||||
|
isRecurring: holiday.isRecurring
|
||||||
|
});
|
||||||
|
setEditingHoliday(holiday);
|
||||||
|
setShowAddDialog(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (!formData.holidayDate || !formData.holidayName) {
|
||||||
|
setError('Holiday date and name are required');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingHoliday) {
|
||||||
|
// Update existing
|
||||||
|
await updateHoliday(editingHoliday.holidayId, formData);
|
||||||
|
setSuccessMessage('Holiday updated successfully');
|
||||||
|
} else {
|
||||||
|
// Create new
|
||||||
|
await createHoliday(formData);
|
||||||
|
setSuccessMessage('Holiday created successfully');
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadHolidays();
|
||||||
|
setShowAddDialog(false);
|
||||||
|
setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to save holiday');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (holiday: Holiday) => {
|
||||||
|
if (!confirm(`Delete "${holiday.holidayName}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setError(null);
|
||||||
|
await deleteHoliday(holiday.holidayId);
|
||||||
|
setSuccessMessage('Holiday deleted successfully');
|
||||||
|
await loadHolidays();
|
||||||
|
setTimeout(() => setSuccessMessage(null), 3000);
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.response?.data?.error || 'Failed to delete holiday');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTypeColor = (type: Holiday['holidayType']) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'NATIONAL':
|
||||||
|
return 'bg-red-100 text-red-800 border-red-200';
|
||||||
|
case 'REGIONAL':
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
case 'ORGANIZATIONAL':
|
||||||
|
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||||
|
case 'OPTIONAL':
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const years = Array.from({ length: 5 }, (_, i) => new Date().getFullYear() - 1 + i);
|
||||||
|
|
||||||
|
// Group holidays by month
|
||||||
|
const holidaysByMonth = holidays.reduce((acc, holiday) => {
|
||||||
|
const month = new Date(holiday.holidayDate).toLocaleString('default', { month: 'long' });
|
||||||
|
if (!acc[month]) {
|
||||||
|
acc[month] = [];
|
||||||
|
}
|
||||||
|
acc[month].push(holiday);
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, Holiday[]>);
|
||||||
|
|
||||||
|
// Sort months chronologically
|
||||||
|
const sortedMonths = Object.keys(holidaysByMonth).sort((a, b) => {
|
||||||
|
const monthA = new Date(Date.parse(a + " 1, 2000")).getMonth();
|
||||||
|
const monthB = new Date(Date.parse(b + " 1, 2000")).getMonth();
|
||||||
|
return monthA - monthB;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Success Message */}
|
||||||
|
{successMessage && (
|
||||||
|
<div className="p-4 bg-green-50 border border-green-200 rounded-lg flex items-center gap-3">
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600 shrink-0" />
|
||||||
|
<p className="text-sm text-green-800">{successMessage}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Error Message */}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 shrink-0" />
|
||||||
|
<p className="text-sm text-red-800">{error}</p>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setError(null)}
|
||||||
|
className="ml-auto"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Header */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-blue-100 rounded-lg">
|
||||||
|
<Calendar className="w-6 h-6 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-xl">Holiday Calendar</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Manage organization holidays for TAT calculations
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={selectedYear.toString()} onValueChange={(v) => setSelectedYear(parseInt(v))}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{years.map(year => (
|
||||||
|
<SelectItem key={year} value={year.toString()}>
|
||||||
|
{year}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button onClick={handleAdd} className="gap-2">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add Holiday
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Holidays List */}
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
|
||||||
|
</div>
|
||||||
|
) : holidays.length === 0 ? (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Calendar className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||||
|
<p className="text-gray-600">No holidays found for {selectedYear}</p>
|
||||||
|
<Button onClick={handleAdd} variant="outline" className="mt-4 gap-2">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
Add First Holiday
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{sortedMonths.map(month => (
|
||||||
|
<Card key={month}>
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<CardTitle className="text-lg">{month} {selectedYear}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{holidaysByMonth[month].length} holiday{holidaysByMonth[month].length !== 1 ? 's' : ''}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{holidaysByMonth[month].map(holiday => (
|
||||||
|
<div
|
||||||
|
key={holiday.holidayId}
|
||||||
|
className="flex items-center justify-between p-4 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<p className="font-semibold text-gray-900">{holiday.holidayName}</p>
|
||||||
|
<Badge variant="outline" className={getTypeColor(holiday.holidayType)}>
|
||||||
|
{holiday.holidayType}
|
||||||
|
</Badge>
|
||||||
|
{holiday.isRecurring && (
|
||||||
|
<Badge variant="outline" className="bg-indigo-50 text-indigo-700 border-indigo-200">
|
||||||
|
Recurring
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-600">
|
||||||
|
{formatDateShort(holiday.holidayDate)}
|
||||||
|
</p>
|
||||||
|
{holiday.description && (
|
||||||
|
<p className="text-xs text-gray-500 mt-1">{holiday.description}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleEdit(holiday)}
|
||||||
|
className="gap-2"
|
||||||
|
>
|
||||||
|
<Edit2 className="w-4 h-4" />
|
||||||
|
Edit
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => handleDelete(holiday)}
|
||||||
|
className="gap-2 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add/Edit Dialog */}
|
||||||
|
<Dialog open={showAddDialog} onOpenChange={setShowAddDialog}>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{editingHoliday ? 'Edit Holiday' : 'Add New Holiday'}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{editingHoliday ? 'Update holiday information' : 'Add a new holiday to the calendar'}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="date">Date *</Label>
|
||||||
|
<Input
|
||||||
|
id="date"
|
||||||
|
type="date"
|
||||||
|
value={formData.holidayDate}
|
||||||
|
onChange={(e) => setFormData({ ...formData, holidayDate: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">Holiday Name *</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
placeholder="e.g., Diwali, Republic Day"
|
||||||
|
value={formData.holidayName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, holidayName: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">Description</Label>
|
||||||
|
<Input
|
||||||
|
id="description"
|
||||||
|
placeholder="Optional description"
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="type">Holiday Type</Label>
|
||||||
|
<Select
|
||||||
|
value={formData.holidayType}
|
||||||
|
onValueChange={(value: Holiday['holidayType']) =>
|
||||||
|
setFormData({ ...formData, holidayType: value })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="type">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="NATIONAL">National</SelectItem>
|
||||||
|
<SelectItem value="REGIONAL">Regional</SelectItem>
|
||||||
|
<SelectItem value="ORGANIZATIONAL">Organizational</SelectItem>
|
||||||
|
<SelectItem value="OPTIONAL">Optional</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="recurring"
|
||||||
|
checked={formData.isRecurring}
|
||||||
|
onChange={(e) => setFormData({ ...formData, isRecurring: e.target.checked })}
|
||||||
|
className="rounded border-gray-300"
|
||||||
|
/>
|
||||||
|
<Label htmlFor="recurring" className="font-normal cursor-pointer">
|
||||||
|
This holiday recurs annually
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setShowAddDialog(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleSave}>
|
||||||
|
{editingHoliday ? 'Update' : 'Add'} Holiday
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
3
src/components/admin/index.ts
Normal file
3
src/components/admin/index.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
export { ConfigurationManager } from './ConfigurationManager';
|
||||||
|
export { HolidayManager } from './HolidayManager';
|
||||||
|
|
||||||
@ -139,12 +139,13 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
<span className="truncate">{item.label}</span>
|
<span className="truncate">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Quick Action in Sidebar - Right below menu items */}
|
{/* Quick Action in Sidebar - Right below menu items */}
|
||||||
<div className="mt-6">
|
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
|
||||||
<Button
|
<Button
|
||||||
onClick={onNewRequest}
|
onClick={onNewRequest}
|
||||||
className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm"
|
className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm font-medium"
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
@ -153,7 +154,6 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* Main Content Area */}
|
{/* Main Content Area */}
|
||||||
|
|||||||
@ -76,6 +76,8 @@ interface WorkNoteChatProps {
|
|||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
messages?: any[]; // optional external messages
|
messages?: any[]; // optional external messages
|
||||||
onSend?: (messageHtml: string, files: File[]) => Promise<void> | void;
|
onSend?: (messageHtml: string, files: File[]) => Promise<void> | void;
|
||||||
|
skipSocketJoin?: boolean; // Set to true when embedded in RequestDetail (to avoid double join)
|
||||||
|
requestTitle?: string; // Optional title for display
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get request data from the same source as RequestDetail
|
// Get request data from the same source as RequestDetail
|
||||||
@ -267,14 +269,14 @@ 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 }: WorkNoteChatProps) {
|
export function WorkNoteChat({ requestId, onBack, messages: externalMessages, onSend, skipSocketJoin = false, requestTitle }: 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('');
|
||||||
const [activeTab, setActiveTab] = useState('chat');
|
const [activeTab, setActiveTab] = useState('chat');
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||||
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
const [showSidebar, setShowSidebar] = useState(false);
|
const [showSidebar, setShowSidebar] = useState(false);
|
||||||
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
|
||||||
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
const [currentUserId, setCurrentUserId] = useState<string | null>(null);
|
||||||
@ -282,26 +284,81 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
|
const [showAddSpectatorModal, setShowAddSpectatorModal] = 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 participantsLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
console.log('[WorkNoteChat] Component render, participants loaded:', participantsLoadedRef.current);
|
||||||
|
|
||||||
// Get request info
|
// Get request info
|
||||||
const requestInfo = useMemo(() => {
|
const requestInfo = useMemo(() => {
|
||||||
const data = REQUEST_DATABASE[effectiveRequestId as keyof typeof REQUEST_DATABASE];
|
const data = REQUEST_DATABASE[effectiveRequestId as keyof typeof REQUEST_DATABASE];
|
||||||
return data || {
|
return data || {
|
||||||
id: effectiveRequestId,
|
id: effectiveRequestId,
|
||||||
title: 'Unknown Request',
|
title: requestTitle || 'Unknown Request',
|
||||||
department: 'Unknown',
|
department: 'Unknown',
|
||||||
priority: 'medium',
|
priority: 'medium',
|
||||||
status: 'pending'
|
status: 'pending'
|
||||||
};
|
};
|
||||||
}, [effectiveRequestId]);
|
}, [effectiveRequestId, requestTitle]);
|
||||||
|
|
||||||
const [participants, setParticipants] = useState<Participant[]>(MOCK_PARTICIPANTS);
|
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||||
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
const onlineParticipants = participants.filter(p => p.status === 'online');
|
const onlineParticipants = participants.filter(p => p.status === 'online');
|
||||||
const filteredMessages = messages.filter(msg =>
|
const filteredMessages = messages.filter(msg =>
|
||||||
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
msg.content.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
|
msg.user.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Log when participants change
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[WorkNoteChat] Participants state changed:', {
|
||||||
|
total: participants.length,
|
||||||
|
online: participants.filter(p => p.status === 'online').length,
|
||||||
|
participants: participants.map(p => ({ name: p.name, status: p.status, userId: (p as any).userId }))
|
||||||
|
});
|
||||||
|
}, [participants]);
|
||||||
|
|
||||||
|
// Load initial messages from backend
|
||||||
|
useEffect(() => {
|
||||||
|
if (!effectiveRequestId || !currentUserId) return;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
setLoadingMessages(true);
|
||||||
|
const rows = await getWorkNotes(effectiveRequestId);
|
||||||
|
const mapped = Array.isArray(rows) ? rows.map((m: any) => {
|
||||||
|
const noteUserId = m.userId || m.user_id;
|
||||||
|
return {
|
||||||
|
id: m.noteId || m.id || String(Math.random()),
|
||||||
|
user: {
|
||||||
|
name: m.userName || 'User',
|
||||||
|
avatar: (m.userName || 'U').slice(0,2).toUpperCase(),
|
||||||
|
role: m.userRole || 'Participant'
|
||||||
|
},
|
||||||
|
content: m.message || '',
|
||||||
|
timestamp: m.createdAt || new Date().toISOString(),
|
||||||
|
isCurrentUser: noteUserId === currentUserId,
|
||||||
|
attachments: Array.isArray(m.attachments) ? m.attachments.map((a: any) => ({
|
||||||
|
attachmentId: a.attachmentId || a.attachment_id,
|
||||||
|
name: a.fileName || a.file_name || a.name,
|
||||||
|
fileName: a.fileName || a.file_name || a.name,
|
||||||
|
url: a.storageUrl || a.storage_url || a.url || '#',
|
||||||
|
type: a.fileType || a.file_type || a.type || 'file',
|
||||||
|
fileType: a.fileType || a.file_type || a.type || 'file',
|
||||||
|
fileSize: a.fileSize || a.file_size
|
||||||
|
})) : undefined
|
||||||
|
};
|
||||||
|
}) : [];
|
||||||
|
setMessages(mapped as any);
|
||||||
|
console.log(`[WorkNoteChat] Loaded ${mapped.length} messages from backend`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WorkNoteChat] Failed to load messages:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingMessages(false);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}, [effectiveRequestId, currentUserId]);
|
||||||
|
|
||||||
// Extract all shared files from messages with attachments
|
// Extract all shared files from messages with attachments
|
||||||
const sharedFiles = useMemo(() => {
|
const sharedFiles = useMemo(() => {
|
||||||
const files: any[] = [];
|
const files: any[] = [];
|
||||||
@ -369,11 +426,28 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
|
|
||||||
// Load participants from backend workflow details
|
// Load participants from backend workflow details
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Skip if participants are already loaded (prevents resetting on tab switch)
|
||||||
|
if (participantsLoadedRef.current) {
|
||||||
|
console.log('[WorkNoteChat] Participants already loaded, skipping reload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!effectiveRequestId) {
|
||||||
|
console.log('[WorkNoteChat] No requestId, skipping participants load');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
|
console.log('[WorkNoteChat] Fetching participants from backend...');
|
||||||
const details = await getWorkflowDetails(effectiveRequestId);
|
const details = await getWorkflowDetails(effectiveRequestId);
|
||||||
const rows = Array.isArray(details?.participants) ? details.participants : [];
|
const rows = Array.isArray(details?.participants) ? details.participants : [];
|
||||||
if (rows.length) {
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log('[WorkNoteChat] No participants found in backend response');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const mapped: Participant[] = rows.map((p: any) => {
|
const mapped: Participant[] = rows.map((p: any) => {
|
||||||
const participantType = p.participantType || p.participant_type || 'participant';
|
const participantType = p.participantType || p.participant_type || 'participant';
|
||||||
const userId = p.userId || p.user_id || '';
|
const userId = p.userId || p.user_id || '';
|
||||||
@ -387,12 +461,38 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
userId: userId // store userId for presence matching
|
userId: userId // store userId for presence matching
|
||||||
} as any;
|
} as any;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log('[WorkNoteChat] ✅ Loaded participants:', mapped.map(p => ({ name: p.name, userId: (p as any).userId })));
|
||||||
|
participantsLoadedRef.current = true;
|
||||||
setParticipants(mapped);
|
setParticipants(mapped);
|
||||||
|
|
||||||
|
// Request online users immediately after setting participants
|
||||||
|
setTimeout(() => {
|
||||||
|
if (socketRef.current && socketRef.current.connected) {
|
||||||
|
console.log('[WorkNoteChat] 📡 Requesting online users list...');
|
||||||
|
socketRef.current.emit('request:online-users', { requestId: effectiveRequestId });
|
||||||
|
} else {
|
||||||
|
console.log('[WorkNoteChat] ⚠️ Socket not ready, will request online users when socket connects');
|
||||||
|
}
|
||||||
|
}, 100); // Small delay to ensure state is updated
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[WorkNoteChat] ❌ Failed to load participants:', error);
|
||||||
}
|
}
|
||||||
} catch {}
|
|
||||||
})();
|
})();
|
||||||
}, [effectiveRequestId]);
|
}, [effectiveRequestId]);
|
||||||
|
|
||||||
|
// Reset participants loaded flag when request changes
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Don't reset on unmount, only on request change
|
||||||
|
if (effectiveRequestId) {
|
||||||
|
console.log('[WorkNoteChat] Request changed, will reload participants on next mount');
|
||||||
|
participantsLoadedRef.current = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [effectiveRequestId]);
|
||||||
|
|
||||||
// Load current user ID on mount
|
// Load current user ID on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const userDataStr = localStorage.getItem('userData');
|
const userDataStr = localStorage.getItem('userData');
|
||||||
@ -407,7 +507,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Realtime updates via Socket.IO (standalone usage)
|
// 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
|
||||||
|
|
||||||
@ -423,20 +523,35 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
const base = (import.meta as any).env.VITE_BASE_URL || window.location.origin;
|
const base = (import.meta as any).env.VITE_BASE_URL || window.location.origin;
|
||||||
const s = getSocket(base);
|
const s = getSocket(base);
|
||||||
|
|
||||||
|
// Only join room if not skipped (standalone mode)
|
||||||
|
if (!skipSocketJoin) {
|
||||||
// Optimistically mark self as online immediately
|
// Optimistically mark self as online immediately
|
||||||
setParticipants(prev => prev.map(p =>
|
setParticipants(prev => prev.map(p =>
|
||||||
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p
|
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p
|
||||||
));
|
));
|
||||||
|
|
||||||
joinRequestRoom(s, joinedId, currentUserId);
|
joinRequestRoom(s, joinedId, currentUserId);
|
||||||
|
console.log('[WorkNoteChat] Joined request room (standalone mode)');
|
||||||
|
} else {
|
||||||
|
console.log('[WorkNoteChat] Skipping socket join - parent component handling connection');
|
||||||
|
}
|
||||||
|
|
||||||
// Handle new work notes
|
// Handle new work notes
|
||||||
const noteHandler = (payload: any) => {
|
const noteHandler = (payload: any) => {
|
||||||
|
console.log('[WorkNoteChat] 📨 Received worknote:new event:', payload);
|
||||||
const n = payload?.note || payload;
|
const n = payload?.note || payload;
|
||||||
if (!n) return;
|
if (!n) {
|
||||||
|
console.log('[WorkNoteChat] ⚠️ No note data in payload');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const noteId = n.noteId || n.id;
|
||||||
|
console.log('[WorkNoteChat] Processing note:', noteId, 'from:', n.userName || n.user_name);
|
||||||
|
|
||||||
// Prevent duplicates: check if message with same noteId already exists
|
// Prevent duplicates: check if message with same noteId already exists
|
||||||
setMessages(prev => {
|
setMessages(prev => {
|
||||||
if (prev.some(m => m.id === (n.noteId || n.id))) {
|
if (prev.some(m => m.id === noteId)) {
|
||||||
|
console.log('[WorkNoteChat] ⏭️ Duplicate note, skipping:', noteId);
|
||||||
return prev; // Already exists, don't add
|
return prev; // Already exists, don't add
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -445,8 +560,8 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
const participantRole = getFormattedRole(userRole);
|
const participantRole = getFormattedRole(userRole);
|
||||||
const noteUserId = n.userId || n.user_id;
|
const noteUserId = n.userId || n.user_id;
|
||||||
|
|
||||||
return [...prev, {
|
const newMessage = {
|
||||||
id: n.noteId || String(Date.now()),
|
id: noteId || String(Date.now()),
|
||||||
user: {
|
user: {
|
||||||
name: userName,
|
name: userName,
|
||||||
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
|
avatar: userName.split(' ').map((s: string) => s[0]).filter(Boolean).join('').slice(0, 2).toUpperCase(),
|
||||||
@ -464,41 +579,74 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
fileType: a.fileType || a.file_type || a.type || 'file',
|
fileType: a.fileType || a.file_type || a.type || 'file',
|
||||||
fileSize: a.fileSize || a.file_size
|
fileSize: a.fileSize || a.file_size
|
||||||
})) : undefined
|
})) : undefined
|
||||||
} as any];
|
} as any;
|
||||||
|
|
||||||
|
console.log('[WorkNoteChat] ✅ Adding new message to state:', newMessage.id);
|
||||||
|
return [...prev, newMessage];
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle presence: user joined
|
// Handle presence: user joined
|
||||||
const presenceJoinHandler = (data: { userId: string; requestId: string }) => {
|
const presenceJoinHandler = (data: { userId: string; requestId: string }) => {
|
||||||
console.log('[WorkNoteChat] User joined:', data);
|
console.log('[WorkNoteChat] 🟢 User joined:', data);
|
||||||
setParticipants(prev => prev.map(p =>
|
setParticipants(prev => {
|
||||||
|
const updated = prev.map(p =>
|
||||||
(p as any).userId === data.userId ? { ...p, status: 'online' as const } : p
|
(p as any).userId === data.userId ? { ...p, status: 'online' as const } : p
|
||||||
));
|
);
|
||||||
|
console.log('[WorkNoteChat] Updated participants after join:', updated.filter(p => p.status === 'online').length, 'online');
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle presence: user left
|
// Handle presence: user left
|
||||||
const presenceLeaveHandler = (data: { userId: string; requestId: string }) => {
|
const presenceLeaveHandler = (data: { userId: string; requestId: string }) => {
|
||||||
console.log('[WorkNoteChat] User left:', data);
|
console.log('[WorkNoteChat] 🔴 User left:', data);
|
||||||
setParticipants(prev => prev.map(p =>
|
setParticipants(prev => {
|
||||||
|
const updated = prev.map(p =>
|
||||||
(p as any).userId === data.userId ? { ...p, status: 'offline' as const } : p
|
(p as any).userId === data.userId ? { ...p, status: 'offline' as const } : p
|
||||||
));
|
);
|
||||||
|
console.log('[WorkNoteChat] Updated participants after leave:', updated.filter(p => p.status === 'online').length, 'online');
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle initial online users list
|
// Handle initial online users list
|
||||||
const presenceOnlineHandler = (data: { requestId: string; userIds: string[] }) => {
|
const presenceOnlineHandler = (data: { requestId: string; userIds: string[] }) => {
|
||||||
console.log('[WorkNoteChat] Online users received:', data);
|
console.log('[WorkNoteChat] 📋 Online users list received:', data);
|
||||||
setParticipants(prev => prev.map(p => {
|
setParticipants(prev => {
|
||||||
|
if (prev.length === 0) {
|
||||||
|
console.log('[WorkNoteChat] ⚠️ No participants loaded yet, cannot update online status');
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const updated = prev.map(p => {
|
||||||
const pUserId = (p as any).userId || '';
|
const pUserId = (p as any).userId || '';
|
||||||
const isOnline = data.userIds.includes(pUserId);
|
const isOnline = data.userIds.includes(pUserId);
|
||||||
console.log(`[WorkNoteChat] User ${p.name} (${pUserId}): ${isOnline ? 'ONLINE' : 'offline'}`);
|
console.log(`[WorkNoteChat] ${isOnline ? '🟢' : '⚪'} ${p.name} (${pUserId.slice(0, 8)}...): ${isOnline ? 'ONLINE' : 'offline'}`);
|
||||||
return { ...p, status: isOnline ? 'online' as const : 'offline' as const };
|
return { ...p, status: isOnline ? 'online' as const : 'offline' as const };
|
||||||
}));
|
});
|
||||||
|
console.log('[WorkNoteChat] ✅ Total online:', updated.filter(p => p.status === 'online').length, '/', updated.length);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
console.log('[WorkNoteChat] 🔌 Attaching socket listeners for request:', joinedId);
|
||||||
s.on('worknote:new', noteHandler);
|
s.on('worknote:new', noteHandler);
|
||||||
s.on('presence:join', presenceJoinHandler);
|
s.on('presence:join', presenceJoinHandler);
|
||||||
s.on('presence:leave', presenceLeaveHandler);
|
s.on('presence:leave', presenceLeaveHandler);
|
||||||
s.on('presence:online', presenceOnlineHandler);
|
s.on('presence:online', presenceOnlineHandler);
|
||||||
|
console.log('[WorkNoteChat] ✅ All socket listeners attached');
|
||||||
|
|
||||||
|
// Store socket in ref for coordination with participants loading
|
||||||
|
socketRef.current = s;
|
||||||
|
|
||||||
|
// Always request online users after socket is ready
|
||||||
|
console.log('[WorkNoteChat] 🔌 Socket ready and listeners attached');
|
||||||
|
if (participantsLoadedRef.current) {
|
||||||
|
console.log('[WorkNoteChat] 📡 Participants already loaded, requesting online users now');
|
||||||
|
s.emit('request:online-users', { requestId: joinedId });
|
||||||
|
} else {
|
||||||
|
console.log('[WorkNoteChat] ⏳ Waiting for participants to load first...');
|
||||||
|
}
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
const cleanup = () => {
|
const cleanup = () => {
|
||||||
@ -506,7 +654,12 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
s.off('presence:join', presenceJoinHandler);
|
s.off('presence:join', presenceJoinHandler);
|
||||||
s.off('presence:leave', presenceLeaveHandler);
|
s.off('presence:leave', presenceLeaveHandler);
|
||||||
s.off('presence:online', presenceOnlineHandler);
|
s.off('presence:online', presenceOnlineHandler);
|
||||||
|
// Only leave room if we joined it
|
||||||
|
if (!skipSocketJoin) {
|
||||||
leaveRequestRoom(s, joinedId);
|
leaveRequestRoom(s, joinedId);
|
||||||
|
console.log('[WorkNoteChat] Left request room (standalone mode)');
|
||||||
|
}
|
||||||
|
socketRef.current = null;
|
||||||
};
|
};
|
||||||
(window as any).__wn_cleanup = cleanup;
|
(window as any).__wn_cleanup = cleanup;
|
||||||
} catch {}
|
} catch {}
|
||||||
@ -514,7 +667,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
return () => {
|
return () => {
|
||||||
try { (window as any).__wn_cleanup?.(); } catch {}
|
try { (window as any).__wn_cleanup?.(); } catch {}
|
||||||
};
|
};
|
||||||
}, [effectiveRequestId, currentUserId]);
|
}, [effectiveRequestId, currentUserId, skipSocketJoin]);
|
||||||
|
|
||||||
const handleSendMessage = async () => {
|
const handleSendMessage = async () => {
|
||||||
if (message.trim() || selectedFiles.length > 0) {
|
if (message.trim() || selectedFiles.length > 0) {
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -16,7 +16,9 @@ import { ApprovalModal } from '@/components/approval/ApprovalModal/ApprovalModal
|
|||||||
import { RejectionModal } from '@/components/approval/RejectionModal/RejectionModal';
|
import { RejectionModal } from '@/components/approval/RejectionModal/RejectionModal';
|
||||||
import { AddApproverModal } from '@/components/participant/AddApproverModal';
|
import { AddApproverModal } from '@/components/participant/AddApproverModal';
|
||||||
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
|
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
|
||||||
|
import { WorkNoteChat } from '@/components/workNote/WorkNoteChat/WorkNoteChat';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Clock,
|
Clock,
|
||||||
@ -169,7 +171,6 @@ export function RequestDetail({
|
|||||||
dynamicRequests = []
|
dynamicRequests = []
|
||||||
}: RequestDetailProps) {
|
}: RequestDetailProps) {
|
||||||
const params = useParams<{ requestId: string }>();
|
const params = useParams<{ requestId: string }>();
|
||||||
const navigate = useNavigate();
|
|
||||||
// Use requestNumber from URL params (which now contains requestNumber), fallback to prop
|
// Use requestNumber from URL params (which now contains requestNumber), fallback to prop
|
||||||
const requestIdentifier = params.requestId || propRequestId || '';
|
const requestIdentifier = params.requestId || propRequestId || '';
|
||||||
|
|
||||||
@ -184,6 +185,7 @@ export function RequestDetail({
|
|||||||
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
|
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
|
||||||
const [previewDocument, setPreviewDocument] = useState<{ fileName: string; fileType: string; documentId: string; fileSize?: number } | null>(null);
|
const [previewDocument, setPreviewDocument] = useState<{ fileName: string; fileType: string; documentId: string; fileSize?: number } | null>(null);
|
||||||
const [uploadingDocument, setUploadingDocument] = useState(false);
|
const [uploadingDocument, setUploadingDocument] = useState(false);
|
||||||
|
const [unreadWorkNotes, setUnreadWorkNotes] = useState(0);
|
||||||
const fileInputRef = useState<HTMLInputElement | null>(null)[0];
|
const fileInputRef = useState<HTMLInputElement | null>(null)[0];
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
|
|
||||||
@ -232,6 +234,14 @@ export function RequestDetail({
|
|||||||
const participants = Array.isArray(details.participants) ? details.participants : [];
|
const participants = Array.isArray(details.participants) ? details.participants : [];
|
||||||
const documents = Array.isArray(details.documents) ? details.documents : [];
|
const documents = Array.isArray(details.documents) ? details.documents : [];
|
||||||
const summary = details.summary || {};
|
const summary = details.summary || {};
|
||||||
|
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
||||||
|
|
||||||
|
// Debug: Log TAT alerts to console
|
||||||
|
if (tatAlerts.length > 0) {
|
||||||
|
console.log(`[RequestDetail] Found ${tatAlerts.length} TAT alerts for request:`, tatAlerts);
|
||||||
|
} else {
|
||||||
|
console.log('[RequestDetail] No TAT alerts found for this request');
|
||||||
|
}
|
||||||
|
|
||||||
const toInitials = (name?: string, email?: string) => {
|
const toInitials = (name?: string, email?: string) => {
|
||||||
const base = (name || email || 'NA').toString();
|
const base = (name || email || 'NA').toString();
|
||||||
@ -252,6 +262,7 @@ export function RequestDetail({
|
|||||||
const approvalFlow = approvals.map((a: any) => {
|
const approvalFlow = approvals.map((a: any) => {
|
||||||
const levelNumber = a.levelNumber || 0;
|
const levelNumber = a.levelNumber || 0;
|
||||||
const levelStatus = (a.status || '').toString().toUpperCase();
|
const levelStatus = (a.status || '').toString().toUpperCase();
|
||||||
|
const levelId = a.levelId || a.level_id;
|
||||||
|
|
||||||
// Determine display status based on level and current status
|
// Determine display status based on level and current status
|
||||||
let displayStatus = statusMap(a.status);
|
let displayStatus = statusMap(a.status);
|
||||||
@ -265,9 +276,17 @@ export function RequestDetail({
|
|||||||
displayStatus = 'pending';
|
displayStatus = 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get TAT alerts for this level
|
||||||
|
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
||||||
|
|
||||||
|
// Debug log
|
||||||
|
if (levelAlerts.length > 0) {
|
||||||
|
console.log(`[RequestDetail] Level ${levelNumber} (${levelId}) has ${levelAlerts.length} TAT alerts:`, levelAlerts);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
step: levelNumber,
|
step: levelNumber,
|
||||||
levelId: a.levelId || a.level_id,
|
levelId,
|
||||||
role: a.levelName || a.approverName || 'Approver',
|
role: a.levelName || a.approverName || 'Approver',
|
||||||
status: displayStatus,
|
status: displayStatus,
|
||||||
approver: a.approverName || a.approverEmail,
|
approver: a.approverName || a.approverEmail,
|
||||||
@ -278,6 +297,7 @@ export function RequestDetail({
|
|||||||
actualHours: a.levelEndTime && a.levelStartTime ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) : undefined,
|
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,
|
comment: a.comments || undefined,
|
||||||
timestamp: a.actionDate || undefined,
|
timestamp: a.actionDate || undefined,
|
||||||
|
tatAlerts: levelAlerts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -471,6 +491,113 @@ export function RequestDetail({
|
|||||||
input.click();
|
input.click();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Establish socket connection and join request room when component mounts
|
||||||
|
useEffect(() => {
|
||||||
|
if (!requestIdentifier) {
|
||||||
|
console.warn('[RequestDetail] No requestIdentifier, cannot join socket room');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[RequestDetail] Initializing socket connection for:', requestIdentifier);
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
let actualRequestId = requestIdentifier;
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
// Fetch UUID if we have request number
|
||||||
|
const details = await workflowApi.getWorkflowDetails(requestIdentifier);
|
||||||
|
if (details?.workflow?.requestId && mounted) {
|
||||||
|
actualRequestId = details.workflow.requestId; // Use UUID for room
|
||||||
|
console.log('[RequestDetail] Resolved UUID:', actualRequestId);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[RequestDetail] Failed to resolve UUID:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
// 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('[RequestDetail] Socket not available after getSocket()');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[RequestDetail] Socket instance obtained, connected:', socket.connected);
|
||||||
|
|
||||||
|
const userId = (user as any)?.userId;
|
||||||
|
console.log('[RequestDetail] Current userId:', userId);
|
||||||
|
|
||||||
|
// Wait for socket to connect before joining room
|
||||||
|
const handleConnect = () => {
|
||||||
|
console.log('[RequestDetail] Socket connected, joining room with UUID:', actualRequestId);
|
||||||
|
joinRequestRoom(socket, actualRequestId, userId);
|
||||||
|
console.log(`[RequestDetail] ✅ Joined request room: ${actualRequestId} - User is now ONLINE`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// If already connected, join immediately
|
||||||
|
if (socket.connected) {
|
||||||
|
console.log('[RequestDetail] Socket already connected, joining immediately');
|
||||||
|
handleConnect();
|
||||||
|
} else {
|
||||||
|
console.log('[RequestDetail] Socket not connected yet, waiting for connect event');
|
||||||
|
socket.on('connect', handleConnect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup - only runs when component unmounts or requestIdentifier changes
|
||||||
|
return () => {
|
||||||
|
if (mounted) {
|
||||||
|
console.log('[RequestDetail] Cleaning up socket listeners and leaving room');
|
||||||
|
socket.off('connect', handleConnect);
|
||||||
|
leaveRequestRoom(socket, actualRequestId);
|
||||||
|
console.log(`[RequestDetail] ✅ Left request room: ${actualRequestId} - User is now OFFLINE`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [requestIdentifier, user]);
|
||||||
|
|
||||||
|
// Separate effect to listen for new work notes and update badge
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Listen for new work notes to update badge count when not on work notes tab
|
||||||
|
const handleNewWorkNote = (data: any) => {
|
||||||
|
console.log(`[RequestDetail] New work note received:`, data);
|
||||||
|
// Only increment badge if not currently viewing work notes tab
|
||||||
|
if (activeTab !== 'worknotes') {
|
||||||
|
setUnreadWorkNotes(prev => prev + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on('noteHandler', handleNewWorkNote);
|
||||||
|
socket.on('worknote:new', handleNewWorkNote); // Also listen to worknote:new
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
return () => {
|
||||||
|
socket.off('noteHandler', handleNewWorkNote);
|
||||||
|
socket.off('worknote:new', handleNewWorkNote);
|
||||||
|
};
|
||||||
|
}, [requestIdentifier, activeTab]);
|
||||||
|
|
||||||
|
// Clear unread count when switching to work notes tab
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeTab === 'worknotes') {
|
||||||
|
setUnreadWorkNotes(0);
|
||||||
|
}
|
||||||
|
}, [activeTab]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
(async () => {
|
(async () => {
|
||||||
@ -484,6 +611,10 @@ export function RequestDetail({
|
|||||||
const participants = Array.isArray(details.participants) ? details.participants : [];
|
const participants = Array.isArray(details.participants) ? details.participants : [];
|
||||||
const documents = Array.isArray(details.documents) ? details.documents : [];
|
const documents = Array.isArray(details.documents) ? details.documents : [];
|
||||||
const summary = details.summary || {};
|
const summary = details.summary || {};
|
||||||
|
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
|
||||||
|
|
||||||
|
// Debug: Log TAT alerts to console
|
||||||
|
console.log('[RequestDetail] TAT Alerts received from API:', tatAlerts.length, tatAlerts);
|
||||||
|
|
||||||
// Map to UI shape without changing UI
|
// Map to UI shape without changing UI
|
||||||
const toInitials = (name?: string, email?: string) => {
|
const toInitials = (name?: string, email?: string) => {
|
||||||
@ -506,6 +637,7 @@ export function RequestDetail({
|
|||||||
const approvalFlow = approvals.map((a: any) => {
|
const approvalFlow = approvals.map((a: any) => {
|
||||||
const levelNumber = a.levelNumber || 0;
|
const levelNumber = a.levelNumber || 0;
|
||||||
const levelStatus = (a.status || '').toString().toUpperCase();
|
const levelStatus = (a.status || '').toString().toUpperCase();
|
||||||
|
const levelId = a.levelId || a.level_id;
|
||||||
|
|
||||||
// Determine display status based on level and current status
|
// Determine display status based on level and current status
|
||||||
let displayStatus = statusMap(a.status);
|
let displayStatus = statusMap(a.status);
|
||||||
@ -519,9 +651,17 @@ export function RequestDetail({
|
|||||||
displayStatus = 'pending';
|
displayStatus = 'pending';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get TAT alerts for this level
|
||||||
|
const levelAlerts = tatAlerts.filter((alert: any) => alert.levelId === levelId);
|
||||||
|
|
||||||
|
// Debug log
|
||||||
|
if (levelAlerts.length > 0) {
|
||||||
|
console.log(`[RequestDetail useEffect] Level ${levelNumber} (${levelId}) has ${levelAlerts.length} TAT alerts:`, levelAlerts);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
step: levelNumber,
|
step: levelNumber,
|
||||||
levelId: a.levelId || a.level_id,
|
levelId,
|
||||||
role: a.levelName || a.approverName || 'Approver',
|
role: a.levelName || a.approverName || 'Approver',
|
||||||
status: displayStatus,
|
status: displayStatus,
|
||||||
approver: a.approverName || a.approverEmail,
|
approver: a.approverName || a.approverEmail,
|
||||||
@ -532,6 +672,7 @@ export function RequestDetail({
|
|||||||
actualHours: a.levelEndTime && a.levelStartTime ? Math.max(0, (new Date(a.levelEndTime).getTime() - new Date(a.levelStartTime).getTime()) / (1000 * 60 * 60)) : undefined,
|
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,
|
comment: a.comments || undefined,
|
||||||
timestamp: a.actionDate || undefined,
|
timestamp: a.actionDate || undefined,
|
||||||
|
tatAlerts: levelAlerts,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -791,7 +932,7 @@ export function RequestDetail({
|
|||||||
|
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||||
<TabsList className="grid w-full grid-cols-4 bg-gray-100 h-10 mb-6">
|
<TabsList className="grid w-full grid-cols-5 bg-gray-100 h-10 mb-6">
|
||||||
<TabsTrigger value="overview" className="flex items-center gap-2 text-xs sm:text-sm px-2">
|
<TabsTrigger value="overview" className="flex items-center gap-2 text-xs sm:text-sm px-2">
|
||||||
<ClipboardList className="w-3 h-3 sm:w-4 sm:h-4" />
|
<ClipboardList className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
Overview
|
Overview
|
||||||
@ -808,12 +949,21 @@ export function RequestDetail({
|
|||||||
<Activity className="w-3 h-3 sm:w-4 sm:h-4" />
|
<Activity className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
Activity
|
Activity
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="worknotes" className="flex items-center gap-2 text-xs sm:text-sm px-2 relative">
|
||||||
|
<MessageSquare className="w-3 h-3 sm:w-4 sm:h-4" />
|
||||||
|
Work Notes
|
||||||
|
{unreadWorkNotes > 0 && (
|
||||||
|
<Badge className="absolute -top-1 -right-1 h-5 w-5 rounded-full bg-red-500 text-white text-[10px] flex items-center justify-center p-0">
|
||||||
|
{unreadWorkNotes > 9 ? '9+' : unreadWorkNotes}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
{/* Main Layout with Sidebar for All Tabs */}
|
{/* Main Layout - Full width for Work Notes, Grid with sidebar for others */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className={activeTab === 'worknotes' ? '' : 'grid grid-cols-1 lg:grid-cols-3 gap-6'}>
|
||||||
{/* Left Column - Tab Content (2/3 width) */}
|
{/* Left Column - Tab Content (2/3 width for most tabs, full width for work notes) */}
|
||||||
<div className="lg:col-span-2">
|
<div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
|
||||||
|
|
||||||
{/* Overview Tab */}
|
{/* Overview Tab */}
|
||||||
<TabsContent value="overview" className="mt-0">
|
<TabsContent value="overview" className="mt-0">
|
||||||
@ -1043,7 +1193,7 @@ export function RequestDetail({
|
|||||||
<p className="text-xs text-gray-600 font-medium">Elapsed: {step.elapsedHours}h</p>
|
<p className="text-xs text-gray-600 font-medium">Elapsed: {step.elapsedHours}h</p>
|
||||||
)}
|
)}
|
||||||
{step.actualHours !== undefined && (
|
{step.actualHours !== undefined && (
|
||||||
<p className="text-xs text-gray-600 font-medium">Completed in: {step.actualHours}h</p>
|
<p className="text-xs text-gray-600 font-medium">Completed in: {step.actualHours.toFixed(2)}h</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -1054,6 +1204,114 @@ export function RequestDetail({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* TAT Alerts/Reminders */}
|
||||||
|
{step.tatAlerts && step.tatAlerts.length > 0 && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{step.tatAlerts.map((alert: any, alertIndex: number) => (
|
||||||
|
<div
|
||||||
|
key={alertIndex}
|
||||||
|
className={`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'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<div className="text-lg">
|
||||||
|
{(alert.thresholdPercentage || 0) === 50 && '⏳'}
|
||||||
|
{(alert.thresholdPercentage || 0) === 75 && '⚠️'}
|
||||||
|
{(alert.thresholdPercentage || 0) === 100 && '⏰'}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<p className="text-sm font-semibold text-gray-900">
|
||||||
|
Reminder {alertIndex + 1} - {alert.thresholdPercentage || 0}% TAT Threshold
|
||||||
|
</p>
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={
|
||||||
|
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-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-2 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 items-center justify-between">
|
||||||
|
<p className="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">
|
||||||
|
TEST MODE
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="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 && (
|
{step.timestamp && (
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
{isCompleted ? 'Approved' : isRejected ? 'Rejected' : 'Actioned'} on {formatDateTime(step.timestamp)}
|
{isCompleted ? 'Approved' : isRejected ? 'Rejected' : 'Actioned'} on {formatDateTime(step.timestamp)}
|
||||||
@ -1216,9 +1474,21 @@ export function RequestDetail({
|
|||||||
</Card>
|
</Card>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Work Notes Tab - Full Width (Last Tab) */}
|
||||||
|
<TabsContent value="worknotes" className="mt-0" forceMount={true} hidden={activeTab !== 'worknotes'}>
|
||||||
|
<div className="h-[calc(100vh-300px)] min-h-[600px]">
|
||||||
|
<WorkNoteChat
|
||||||
|
requestId={requestIdentifier}
|
||||||
|
requestTitle={request.title}
|
||||||
|
skipSocketJoin={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column - Quick Actions Sidebar (1/3 width) - Visible for All Tabs */}
|
{/* Right Column - Quick Actions Sidebar (1/3 width) - Hidden for Work Notes Tab */}
|
||||||
|
{activeTab !== 'worknotes' && (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Quick Actions */}
|
{/* Quick Actions */}
|
||||||
<Card>
|
<Card>
|
||||||
@ -1226,15 +1496,6 @@ export function RequestDetail({
|
|||||||
<CardTitle className="text-base">Quick Actions</CardTitle>
|
<CardTitle className="text-base">Quick Actions</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-2">
|
<CardContent className="space-y-2">
|
||||||
{/* Work Notes - Opens dedicated full-screen page */}
|
|
||||||
<Button
|
|
||||||
className="w-full justify-start gap-2"
|
|
||||||
onClick={() => navigate(`/work-notes/${requestIdentifier}`)}
|
|
||||||
>
|
|
||||||
<MessageSquare className="w-4 h-4" />
|
|
||||||
Add Work Note
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{!isSpectator && (
|
{!isSpectator && (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -1304,6 +1565,7 @@ export function RequestDetail({
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,20 +1,23 @@
|
|||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import {
|
import {
|
||||||
Settings as SettingsIcon,
|
Settings as SettingsIcon,
|
||||||
Bell,
|
Bell,
|
||||||
Shield,
|
Shield,
|
||||||
Palette,
|
Palette,
|
||||||
Globe,
|
|
||||||
Lock,
|
Lock,
|
||||||
Database,
|
Calendar,
|
||||||
Mail,
|
Sliders,
|
||||||
CheckCircle
|
AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { setupPushNotifications } from '@/utils/pushNotifications';
|
import { setupPushNotifications } from '@/utils/pushNotifications';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { ConfigurationManager } from '@/components/admin/ConfigurationManager';
|
||||||
|
import { HolidayManager } from '@/components/admin/HolidayManager';
|
||||||
|
|
||||||
export function Settings() {
|
export function Settings() {
|
||||||
|
const { user } = useAuth();
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-7xl mx-auto">
|
<div className="space-y-6 max-w-7xl mx-auto">
|
||||||
{/* Header Card */}
|
{/* Header Card */}
|
||||||
@ -38,7 +41,26 @@ export function Settings() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Settings Sections */}
|
{/* Check if user is admin */}
|
||||||
|
{(user as any)?.isAdmin ? (
|
||||||
|
<Tabs defaultValue="user" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-3 mb-6">
|
||||||
|
<TabsTrigger value="user" className="flex items-center gap-2">
|
||||||
|
<SettingsIcon className="w-4 h-4" />
|
||||||
|
User Settings
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="system" className="flex items-center gap-2">
|
||||||
|
<Sliders className="w-4 h-4" />
|
||||||
|
System Configuration
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="holidays" className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
Holiday Calendar
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* User Settings Tab */}
|
||||||
|
<TabsContent value="user" className="space-y-6">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Notification Settings */}
|
{/* Notification Settings */}
|
||||||
<Card className="shadow-lg hover:shadow-xl transition-shadow">
|
<Card className="shadow-lg hover:shadow-xl transition-shadow">
|
||||||
@ -55,7 +77,125 @@ export function Settings() {
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Button onClick={async () => { try { await setupPushNotifications(); alert('Notifications enabled'); } catch (e) { alert('Failed to enable notifications'); } }}>
|
<Button onClick={async () => {
|
||||||
|
try {
|
||||||
|
await setupPushNotifications();
|
||||||
|
alert('Notifications enabled');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to enable notifications');
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
Enable Push Notifications
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Security Settings */}
|
||||||
|
<Card className="shadow-lg hover:shadow-xl transition-shadow">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-red-100 rounded-lg">
|
||||||
|
<Lock className="h-5 w-5 text-red-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg text-gray-900">Security</CardTitle>
|
||||||
|
<CardDescription className="text-gray-600">Password and security settings</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600">Security settings will be available soon</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Appearance Settings */}
|
||||||
|
<Card className="shadow-lg hover:shadow-xl transition-shadow">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-purple-100 rounded-lg">
|
||||||
|
<Palette className="h-5 w-5 text-purple-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg text-gray-900">Appearance</CardTitle>
|
||||||
|
<CardDescription className="text-gray-600">Theme and display preferences</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600">Appearance settings will be available soon</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Preferences */}
|
||||||
|
<Card className="shadow-lg hover:shadow-xl transition-shadow">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-emerald-100 rounded-lg">
|
||||||
|
<Shield className="h-5 w-5 text-emerald-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg text-gray-900">Preferences</CardTitle>
|
||||||
|
<CardDescription className="text-gray-600">Application preferences</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="p-4 bg-gray-50 rounded-lg">
|
||||||
|
<p className="text-sm text-gray-600">User preferences will be available soon</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* System Configuration Tab (Admin Only) */}
|
||||||
|
<TabsContent value="system">
|
||||||
|
<ConfigurationManager />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Holiday Calendar Tab (Admin Only) */}
|
||||||
|
<TabsContent value="holidays">
|
||||||
|
<HolidayManager />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Non-Admin User Settings Only */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
{/* Notification Settings */}
|
||||||
|
<Card className="shadow-lg hover:shadow-xl transition-shadow">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-blue-100 rounded-lg">
|
||||||
|
<Bell className="h-5 w-5 text-blue-600" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg text-gray-900">Notifications</CardTitle>
|
||||||
|
<CardDescription className="text-gray-600">Manage notification preferences</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Button onClick={async () => {
|
||||||
|
try {
|
||||||
|
await setupPushNotifications();
|
||||||
|
alert('Notifications enabled');
|
||||||
|
} catch (e) {
|
||||||
|
alert('Failed to enable notifications');
|
||||||
|
}
|
||||||
|
}}>
|
||||||
Enable Push Notifications
|
Enable Push Notifications
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -129,18 +269,20 @@ export function Settings() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Coming Soon Notice */}
|
{/* Info: Admin features not available */}
|
||||||
<Card className="shadow-lg border-yellow-200 bg-yellow-50">
|
<Card className="shadow-lg border-blue-200 bg-blue-50">
|
||||||
<CardContent className="p-6">
|
<CardContent className="p-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<CheckCircle className="w-5 h-5 text-yellow-600 shrink-0" />
|
<AlertCircle className="w-5 h-5 text-blue-600 shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900">Settings page is under development</p>
|
<p className="text-sm font-medium text-gray-900">Admin features not accessible</p>
|
||||||
<p className="text-xs text-gray-600 mt-1">Settings and preferences management will be available in a future update.</p>
|
<p className="text-xs text-gray-600 mt-1">System configuration and holiday management require admin privileges.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
117
src/services/adminApi.ts
Normal file
117
src/services/adminApi.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import apiClient from './authApi';
|
||||||
|
|
||||||
|
export interface AdminConfiguration {
|
||||||
|
configId: string;
|
||||||
|
configKey: string;
|
||||||
|
configCategory: string;
|
||||||
|
configValue: string;
|
||||||
|
valueType: 'STRING' | 'NUMBER' | 'BOOLEAN' | 'JSON' | 'ARRAY';
|
||||||
|
displayName: string;
|
||||||
|
description?: string;
|
||||||
|
defaultValue?: string;
|
||||||
|
isEditable: boolean;
|
||||||
|
isSensitive: boolean;
|
||||||
|
validationRules?: {
|
||||||
|
min?: number;
|
||||||
|
max?: number;
|
||||||
|
regex?: string;
|
||||||
|
required?: boolean;
|
||||||
|
};
|
||||||
|
uiComponent?: string;
|
||||||
|
options?: any;
|
||||||
|
sortOrder: number;
|
||||||
|
requiresRestart: boolean;
|
||||||
|
lastModifiedBy?: string;
|
||||||
|
lastModifiedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Holiday {
|
||||||
|
holidayId: string;
|
||||||
|
holidayDate: string;
|
||||||
|
holidayName: string;
|
||||||
|
description?: string;
|
||||||
|
holidayType: 'NATIONAL' | 'REGIONAL' | 'ORGANIZATIONAL' | 'OPTIONAL';
|
||||||
|
isRecurring: boolean;
|
||||||
|
recurrenceRule?: string;
|
||||||
|
isActive: boolean;
|
||||||
|
appliesToDepartments?: string[];
|
||||||
|
appliesToLocations?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all admin configurations
|
||||||
|
*/
|
||||||
|
export const getAllConfigurations = async (category?: string): Promise<AdminConfiguration[]> => {
|
||||||
|
const params = category ? { category } : {};
|
||||||
|
const response = await apiClient.get('/admin/configurations', { params });
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a configuration value
|
||||||
|
*/
|
||||||
|
export const updateConfiguration = async (
|
||||||
|
configKey: string,
|
||||||
|
configValue: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await apiClient.put(`/admin/configurations/${configKey}`, { configValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset configuration to default
|
||||||
|
*/
|
||||||
|
export const resetConfiguration = async (configKey: string): Promise<void> => {
|
||||||
|
await apiClient.post(`/admin/configurations/${configKey}/reset`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all holidays
|
||||||
|
*/
|
||||||
|
export const getAllHolidays = async (year?: number): Promise<Holiday[]> => {
|
||||||
|
const params = year ? { year } : {};
|
||||||
|
const response = await apiClient.get('/admin/holidays', { params });
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get holiday calendar for a year
|
||||||
|
*/
|
||||||
|
export const getHolidayCalendar = async (year: number): Promise<any> => {
|
||||||
|
const response = await apiClient.get(`/admin/holidays/calendar/${year}`);
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new holiday
|
||||||
|
*/
|
||||||
|
export const createHoliday = async (holiday: Partial<Holiday>): Promise<Holiday> => {
|
||||||
|
const response = await apiClient.post('/admin/holidays', holiday);
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a holiday
|
||||||
|
*/
|
||||||
|
export const updateHoliday = async (
|
||||||
|
holidayId: string,
|
||||||
|
updates: Partial<Holiday>
|
||||||
|
): Promise<Holiday> => {
|
||||||
|
const response = await apiClient.put(`/admin/holidays/${holidayId}`, updates);
|
||||||
|
return response.data.data;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a holiday
|
||||||
|
*/
|
||||||
|
export const deleteHoliday = async (holidayId: string): Promise<void> => {
|
||||||
|
await apiClient.delete(`/admin/holidays/${holidayId}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bulk import holidays
|
||||||
|
*/
|
||||||
|
export const bulkImportHolidays = async (holidays: Partial<Holiday>[]): Promise<any> => {
|
||||||
|
const response = await apiClient.post('/admin/holidays/bulk-import', { holidays });
|
||||||
|
return response.data;
|
||||||
|
};
|
||||||
|
|
||||||
Loading…
Reference in New Issue
Block a user