tab job started implementing add holiday featre added in seetings worknote moved to request detail sceen

This commit is contained in:
laxmanhalaki 2025-11-04 20:34:29 +05:30
parent 3ee174e44e
commit 605ae8d138
9 changed files with 2234 additions and 184 deletions

537
ADMIN_FEATURES_GUIDE.md Normal file
View 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

View 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>
);
}

View 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>
);
}

View File

@ -0,0 +1,3 @@
export { ConfigurationManager } from './ConfigurationManager';
export { HolidayManager } from './HolidayManager';

View File

@ -139,18 +139,18 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
<span className="truncate">{item.label}</span>
</button>
))}
</div>
{/* Quick Action in Sidebar - Right below menu items */}
<div className="mt-6">
<Button
onClick={onNewRequest}
className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm"
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Raise New Request
</Button>
</div>
{/* Quick Action in Sidebar - Right below menu items */}
<div className="mt-6 pt-6 border-t border-gray-800 px-3">
<Button
onClick={onNewRequest}
className="w-full bg-re-green hover:bg-re-green/90 text-white text-sm font-medium"
size="sm"
>
<Plus className="w-4 h-4 mr-2" />
Raise New Request
</Button>
</div>
</div>
</div>

View File

@ -76,6 +76,8 @@ interface WorkNoteChatProps {
onBack?: () => void;
messages?: any[]; // optional external messages
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
@ -267,14 +269,14 @@ const FileIcon = ({ type }: { type: string }) => {
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 effectiveRequestId = requestId || routeParams.requestId || '';
const [message, setMessage] = useState('');
const [activeTab, setActiveTab] = useState('chat');
const [searchTerm, setSearchTerm] = useState('');
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [messages, setMessages] = useState<Message[]>([]);
const [showSidebar, setShowSidebar] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
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 messagesEndRef = useRef<HTMLDivElement>(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
const requestInfo = useMemo(() => {
const data = REQUEST_DATABASE[effectiveRequestId as keyof typeof REQUEST_DATABASE];
return data || {
id: effectiveRequestId,
title: 'Unknown Request',
title: requestTitle || 'Unknown Request',
department: 'Unknown',
priority: 'medium',
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 filteredMessages = messages.filter(msg =>
msg.content.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
const sharedFiles = useMemo(() => {
const files: any[] = [];
@ -369,30 +426,73 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
// Load participants from backend workflow details
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 () => {
try {
console.log('[WorkNoteChat] Fetching participants from backend...');
const details = await getWorkflowDetails(effectiveRequestId);
const rows = Array.isArray(details?.participants) ? details.participants : [];
if (rows.length) {
const mapped: Participant[] = rows.map((p: any) => {
const participantType = p.participantType || p.participant_type || 'participant';
const userId = p.userId || p.user_id || '';
return {
name: p.userName || p.user_name || p.user_email || p.userEmail || 'User',
avatar: (p.userName || p.user_name || p.user_email || 'U').toString().split(' ').map((s: string)=>s[0]).filter(Boolean).join('').slice(0,2).toUpperCase(),
role: formatParticipantRole(participantType.toString()),
status: 'offline', // will be updated by presence events
email: p.userEmail || p.user_email || '',
permissions: ['read', 'write', 'mention'], // default permissions, can be enhanced later
userId: userId // store userId for presence matching
} as any;
});
setParticipants(mapped);
if (rows.length === 0) {
console.log('[WorkNoteChat] No participants found in backend response');
return;
}
} catch {}
const mapped: Participant[] = rows.map((p: any) => {
const participantType = p.participantType || p.participant_type || 'participant';
const userId = p.userId || p.user_id || '';
return {
name: p.userName || p.user_name || p.user_email || p.userEmail || 'User',
avatar: (p.userName || p.user_name || p.user_email || 'U').toString().split(' ').map((s: string)=>s[0]).filter(Boolean).join('').slice(0,2).toUpperCase(),
role: formatParticipantRole(participantType.toString()),
status: 'offline', // will be updated by presence events
email: p.userEmail || p.user_email || '',
permissions: ['read', 'write', 'mention'], // default permissions, can be enhanced later
userId: userId // store userId for presence matching
} as any;
});
console.log('[WorkNoteChat] ✅ Loaded participants:', mapped.map(p => ({ name: p.name, userId: (p as any).userId })));
participantsLoadedRef.current = true;
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);
}
})();
}, [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
useEffect(() => {
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(() => {
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 s = getSocket(base);
// Optimistically mark self as online immediately
setParticipants(prev => prev.map(p =>
(p as any).userId === currentUserId ? { ...p, status: 'online' as const } : p
));
// Only join room if not skipped (standalone mode)
if (!skipSocketJoin) {
// Optimistically mark self as online immediately
setParticipants(prev => prev.map(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
const noteHandler = (payload: any) => {
console.log('[WorkNoteChat] 📨 Received worknote:new event:', 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
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
}
@ -445,8 +560,8 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
const participantRole = getFormattedRole(userRole);
const noteUserId = n.userId || n.user_id;
return [...prev, {
id: n.noteId || String(Date.now()),
const newMessage = {
id: noteId || String(Date.now()),
user: {
name: userName,
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',
fileSize: a.fileSize || a.file_size
})) : undefined
} as any];
} as any;
console.log('[WorkNoteChat] ✅ Adding new message to state:', newMessage.id);
return [...prev, newMessage];
});
};
// Handle presence: user joined
const presenceJoinHandler = (data: { userId: string; requestId: string }) => {
console.log('[WorkNoteChat] User joined:', data);
setParticipants(prev => prev.map(p =>
(p as any).userId === data.userId ? { ...p, status: 'online' as const } : p
));
console.log('[WorkNoteChat] 🟢 User joined:', data);
setParticipants(prev => {
const updated = prev.map(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
const presenceLeaveHandler = (data: { userId: string; requestId: string }) => {
console.log('[WorkNoteChat] User left:', data);
setParticipants(prev => prev.map(p =>
(p as any).userId === data.userId ? { ...p, status: 'offline' as const } : p
));
console.log('[WorkNoteChat] 🔴 User left:', data);
setParticipants(prev => {
const updated = prev.map(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
const presenceOnlineHandler = (data: { requestId: string; userIds: string[] }) => {
console.log('[WorkNoteChat] Online users received:', data);
setParticipants(prev => prev.map(p => {
const pUserId = (p as any).userId || '';
const isOnline = data.userIds.includes(pUserId);
console.log(`[WorkNoteChat] User ${p.name} (${pUserId}): ${isOnline ? 'ONLINE' : 'offline'}`);
return { ...p, status: isOnline ? 'online' as const : 'offline' as const };
}));
console.log('[WorkNoteChat] 📋 Online users list received:', data);
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 isOnline = data.userIds.includes(pUserId);
console.log(`[WorkNoteChat] ${isOnline ? '🟢' : '⚪'} ${p.name} (${pUserId.slice(0, 8)}...): ${isOnline ? 'ONLINE' : 'offline'}`);
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('presence:join', presenceJoinHandler);
s.on('presence:leave', presenceLeaveHandler);
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
const cleanup = () => {
@ -506,7 +654,12 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
s.off('presence:join', presenceJoinHandler);
s.off('presence:leave', presenceLeaveHandler);
s.off('presence:online', presenceOnlineHandler);
leaveRequestRoom(s, joinedId);
// Only leave room if we joined it
if (!skipSocketJoin) {
leaveRequestRoom(s, joinedId);
console.log('[WorkNoteChat] Left request room (standalone mode)');
}
socketRef.current = null;
};
(window as any).__wn_cleanup = cleanup;
} catch {}
@ -514,7 +667,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
return () => {
try { (window as any).__wn_cleanup?.(); } catch {}
};
}, [effectiveRequestId, currentUserId]);
}, [effectiveRequestId, currentUserId, skipSocketJoin]);
const handleSendMessage = async () => {
if (message.trim() || selectedFiles.length > 0) {

View File

@ -1,5 +1,5 @@
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 { Badge } from '@/components/ui/badge';
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 { AddApproverModal } from '@/components/participant/AddApproverModal';
import { AddSpectatorModal } from '@/components/participant/AddSpectatorModal';
import { WorkNoteChat } from '@/components/workNote/WorkNoteChat/WorkNoteChat';
import { useAuth } from '@/contexts/AuthContext';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
import {
ArrowLeft,
Clock,
@ -169,7 +171,6 @@ export function RequestDetail({
dynamicRequests = []
}: RequestDetailProps) {
const params = useParams<{ requestId: string }>();
const navigate = useNavigate();
// Use requestNumber from URL params (which now contains requestNumber), fallback to prop
const requestIdentifier = params.requestId || propRequestId || '';
@ -184,6 +185,7 @@ export function RequestDetail({
const [showAddSpectatorModal, setShowAddSpectatorModal] = useState(false);
const [previewDocument, setPreviewDocument] = useState<{ fileName: string; fileType: string; documentId: string; fileSize?: number } | null>(null);
const [uploadingDocument, setUploadingDocument] = useState(false);
const [unreadWorkNotes, setUnreadWorkNotes] = useState(0);
const fileInputRef = useState<HTMLInputElement | null>(null)[0];
const { user } = useAuth();
@ -232,6 +234,14 @@ export function RequestDetail({
const participants = Array.isArray(details.participants) ? details.participants : [];
const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
// Debug: Log TAT alerts 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 base = (name || email || 'NA').toString();
@ -252,6 +262,7 @@ export function RequestDetail({
const approvalFlow = approvals.map((a: any) => {
const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id;
// Determine display status based on level and current status
let displayStatus = statusMap(a.status);
@ -265,9 +276,17 @@ export function RequestDetail({
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 {
step: levelNumber,
levelId: a.levelId || a.level_id,
levelId,
role: a.levelName || a.approverName || 'Approver',
status: displayStatus,
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,
comment: a.comments || undefined,
timestamp: a.actionDate || undefined,
tatAlerts: levelAlerts,
};
});
@ -471,6 +491,113 @@ export function RequestDetail({
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(() => {
let mounted = true;
(async () => {
@ -484,6 +611,10 @@ export function RequestDetail({
const participants = Array.isArray(details.participants) ? details.participants : [];
const documents = Array.isArray(details.documents) ? details.documents : [];
const summary = details.summary || {};
const tatAlerts = Array.isArray(details.tatAlerts) ? details.tatAlerts : [];
// Debug: Log TAT alerts to console
console.log('[RequestDetail] TAT Alerts received from API:', tatAlerts.length, tatAlerts);
// Map to UI shape without changing UI
const toInitials = (name?: string, email?: string) => {
@ -506,6 +637,7 @@ export function RequestDetail({
const approvalFlow = approvals.map((a: any) => {
const levelNumber = a.levelNumber || 0;
const levelStatus = (a.status || '').toString().toUpperCase();
const levelId = a.levelId || a.level_id;
// Determine display status based on level and current status
let displayStatus = statusMap(a.status);
@ -519,9 +651,17 @@ export function RequestDetail({
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 {
step: levelNumber,
levelId: a.levelId || a.level_id,
levelId,
role: a.levelName || a.approverName || 'Approver',
status: displayStatus,
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,
comment: a.comments || undefined,
timestamp: a.actionDate || undefined,
tatAlerts: levelAlerts,
};
});
@ -791,7 +932,7 @@ export function RequestDetail({
{/* Tabs */}
<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">
<ClipboardList className="w-3 h-3 sm:w-4 sm:h-4" />
Overview
@ -808,12 +949,21 @@ export function RequestDetail({
<Activity className="w-3 h-3 sm:w-4 sm:h-4" />
Activity
</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>
{/* Main Layout with Sidebar for All Tabs */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Tab Content (2/3 width) */}
<div className="lg:col-span-2">
{/* Main Layout - Full width for Work Notes, Grid with sidebar for others */}
<div className={activeTab === 'worknotes' ? '' : 'grid grid-cols-1 lg:grid-cols-3 gap-6'}>
{/* Left Column - Tab Content (2/3 width for most tabs, full width for work notes) */}
<div className={activeTab === 'worknotes' ? '' : 'lg:col-span-2'}>
{/* Overview Tab */}
<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>
)}
{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>
@ -1054,6 +1204,114 @@ export function RequestDetail({
</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 && (
<p className="text-xs text-gray-500 mt-2">
{isCompleted ? 'Approved' : isRejected ? 'Rejected' : 'Actioned'} on {formatDateTime(step.timestamp)}
@ -1216,9 +1474,21 @@ export function RequestDetail({
</Card>
</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>
{/* 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">
{/* Quick Actions */}
<Card>
@ -1226,15 +1496,6 @@ export function RequestDetail({
<CardTitle className="text-base">Quick Actions</CardTitle>
</CardHeader>
<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 && (
<Button
variant="outline"
@ -1304,6 +1565,7 @@ export function RequestDetail({
</Card>
)}
</div>
)}
</div>
</Tabs>
</div>

View File

@ -1,20 +1,23 @@
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Settings as SettingsIcon,
Bell,
Shield,
Palette,
Globe,
Lock,
Database,
Mail,
CheckCircle
Calendar,
Sliders,
AlertCircle
} from 'lucide-react';
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() {
const { user } = useAuth();
return (
<div className="space-y-6 max-w-7xl mx-auto">
{/* Header Card */}
@ -38,109 +41,248 @@ export function Settings() {
</CardContent>
</Card>
{/* Settings Sections */}
<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
</Button>
</div>
</CardContent>
</Card>
{/* 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>
{/* 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>
{/* User Settings Tab */}
<TabsContent value="user" className="space-y-6">
<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
</Button>
</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>
{/* 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>
{/* 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>
{/* 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>
{/* Coming Soon Notice */}
<Card className="shadow-lg border-yellow-200 bg-yellow-50">
<CardContent className="p-6">
<div className="flex items-center gap-3">
<CheckCircle className="w-5 h-5 text-yellow-600 shrink-0" />
<div>
<p className="text-sm font-medium text-gray-900">Settings page is under development</p>
<p className="text-xs text-gray-600 mt-1">Settings and preferences management will be available in a future update.</p>
{/* 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
</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>
</CardContent>
</Card>
{/* Info: Admin features not available */}
<Card className="shadow-lg border-blue-200 bg-blue-50">
<CardContent className="p-6">
<div className="flex items-center gap-3">
<AlertCircle className="w-5 h-5 text-blue-600 shrink-0" />
<div>
<p className="text-sm font-medium text-gray-900">Admin features not accessible</p>
<p className="text-xs text-gray-600 mt-1">System configuration and holiday management require admin privileges.</p>
</div>
</div>
</CardContent>
</Card>
</>
)}
</div>
);
}

117
src/services/adminApi.ts Normal file
View 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;
};