bug fixes and admin screen added
This commit is contained in:
parent
891096a184
commit
9281e3deb3
258
DetailedReports_Analysis.md
Normal file
258
DetailedReports_Analysis.md
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
# Detailed Reports Page - Data Availability Analysis
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This document analyzes what data is currently available in the backend and what information is missing for implementing the DetailedReports page.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Request Lifecycle Report
|
||||||
|
|
||||||
|
### ✅ **Available Data:**
|
||||||
|
- **Request Basic Info:**
|
||||||
|
- `requestNumber` (RE-REQ-2024-XXX)
|
||||||
|
- `title`
|
||||||
|
- `priority` (STANDARD/EXPRESS)
|
||||||
|
- `status` (DRAFT, PENDING, IN_PROGRESS, APPROVED, REJECTED, CLOSED)
|
||||||
|
- `initiatorId` → Can get initiator name via User model
|
||||||
|
- `submissionDate`
|
||||||
|
- `closureDate`
|
||||||
|
- `createdAt`
|
||||||
|
|
||||||
|
- **Current Stage Info:**
|
||||||
|
- `currentLevel` (1-N)
|
||||||
|
- `totalLevels`
|
||||||
|
- Can get current approver from `approval_levels` table
|
||||||
|
|
||||||
|
- **TAT Information:**
|
||||||
|
- `totalTatHours` (cumulative TAT)
|
||||||
|
- Can calculate overall TAT from `submissionDate` to `closureDate` or `updatedAt`
|
||||||
|
- Can get level-wise TAT from `approval_levels.tat_hours`
|
||||||
|
- Can get TAT compliance from `tat_alerts` table
|
||||||
|
|
||||||
|
- **From Existing Services:**
|
||||||
|
- `getCriticalRequests()` - Returns requests with breach info
|
||||||
|
- `getUpcomingDeadlines()` - Returns active level info
|
||||||
|
- `getRecentActivity()` - Returns activity feed
|
||||||
|
|
||||||
|
### ❌ **Missing Data:**
|
||||||
|
1. **Current Stage Name/Description:**
|
||||||
|
- Need to join with `approval_levels` to get `level_name` for current level
|
||||||
|
- Currently only have `currentLevel` number
|
||||||
|
|
||||||
|
2. **Overall TAT Calculation:**
|
||||||
|
- Need API endpoint that calculates total time from submission to current/closure
|
||||||
|
- Currently have `totalTatHours` but need actual elapsed time
|
||||||
|
|
||||||
|
3. **TAT Compliance Status:**
|
||||||
|
- Need to determine if "On Time" or "Delayed" based on TAT vs actual time
|
||||||
|
- Can calculate from `tat_alerts.is_breached` but need endpoint
|
||||||
|
|
||||||
|
4. **Timeline/History:**
|
||||||
|
- Need endpoint to get all approval levels with their start/end times
|
||||||
|
- Need to show progression through levels
|
||||||
|
|
||||||
|
### 🔧 **What Needs to be Built:**
|
||||||
|
- **New API Endpoint:** `/dashboard/reports/lifecycle`
|
||||||
|
- Returns requests with:
|
||||||
|
- Full lifecycle timeline (all levels with dates)
|
||||||
|
- Overall TAT calculation
|
||||||
|
- TAT compliance status (On Time/Delayed)
|
||||||
|
- Current stage name
|
||||||
|
- All approvers in sequence
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. User Activity Log Report
|
||||||
|
|
||||||
|
### ✅ **Available Data:**
|
||||||
|
- **Activity Model Fields:**
|
||||||
|
- `activityId`
|
||||||
|
- `requestId`
|
||||||
|
- `userId` → Can get user name from User model
|
||||||
|
- `userName` (stored directly)
|
||||||
|
- `activityType` (created, assignment, approval, rejection, etc.)
|
||||||
|
- `activityDescription` (details of action)
|
||||||
|
- `ipAddress` (available in model, but may not be logged)
|
||||||
|
- `createdAt` (timestamp)
|
||||||
|
- `metadata` (JSONB - can store additional info)
|
||||||
|
|
||||||
|
- **From Existing Services:**
|
||||||
|
- `getRecentActivity()` - Already returns activity feed with pagination
|
||||||
|
- Returns: `activityId`, `requestId`, `requestNumber`, `requestTitle`, `type`, `action`, `details`, `userId`, `userName`, `timestamp`, `priority`
|
||||||
|
|
||||||
|
### ❌ **Missing Data:**
|
||||||
|
1. **IP Address:**
|
||||||
|
- Field exists in model but may not be populated
|
||||||
|
- Need to ensure IP is captured when logging activities
|
||||||
|
|
||||||
|
2. **User Agent/Device Info:**
|
||||||
|
- Field exists (`userAgent`) but may not be populated
|
||||||
|
- Need to capture browser/device info
|
||||||
|
|
||||||
|
3. **Login Activities:**
|
||||||
|
- Current activity model is request-focused
|
||||||
|
- Need separate user session/login tracking
|
||||||
|
- Can check `users.last_login` but need detailed login history
|
||||||
|
|
||||||
|
4. **Action Categorization:**
|
||||||
|
- Need to map `activityType` to display labels:
|
||||||
|
- "created" → "Created Request"
|
||||||
|
- "approval" → "Approved Request"
|
||||||
|
- "rejection" → "Rejected Request"
|
||||||
|
- "comment" → "Added Comment"
|
||||||
|
- etc.
|
||||||
|
|
||||||
|
5. **Request ID Display:**
|
||||||
|
- Need to show request number when available
|
||||||
|
- Currently `getRecentActivity()` returns `requestNumber` ✅
|
||||||
|
|
||||||
|
### 🔧 **What Needs to be Built:**
|
||||||
|
- **Enhance Activity Logging:**
|
||||||
|
- Capture IP address in activity service
|
||||||
|
- Capture user agent in activity service
|
||||||
|
- Add login activity tracking (separate from request activities)
|
||||||
|
|
||||||
|
- **New/Enhanced API Endpoint:** `/dashboard/reports/activity-log`
|
||||||
|
- Filter by date range
|
||||||
|
- Filter by user
|
||||||
|
- Filter by action type
|
||||||
|
- Include IP address and user agent
|
||||||
|
- Better categorization of actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Workflow Aging Report
|
||||||
|
|
||||||
|
### ✅ **Available Data:**
|
||||||
|
- **Request Basic Info:**
|
||||||
|
- `requestNumber`
|
||||||
|
- `title`
|
||||||
|
- `initiatorId` → Can get initiator name
|
||||||
|
- `priority`
|
||||||
|
- `status`
|
||||||
|
- `createdAt` (can calculate days open)
|
||||||
|
- `submissionDate`
|
||||||
|
|
||||||
|
- **Current Stage Info:**
|
||||||
|
- `currentLevel`
|
||||||
|
- `totalLevels`
|
||||||
|
- Can get current approver from `approval_levels`
|
||||||
|
|
||||||
|
- **From Existing Services:**
|
||||||
|
- `getUpcomingDeadlines()` - Returns active requests with TAT info
|
||||||
|
- Can filter by days open using `createdAt` or `submissionDate`
|
||||||
|
|
||||||
|
### ❌ **Missing Data:**
|
||||||
|
1. **Days Open Calculation:**
|
||||||
|
- Need to calculate from `submissionDate` (not `createdAt`)
|
||||||
|
- Need to exclude weekends/holidays for accurate business days
|
||||||
|
|
||||||
|
2. **Start Date:**
|
||||||
|
- Should use `submissionDate` (when request was submitted, not created)
|
||||||
|
- Currently have this field ✅
|
||||||
|
|
||||||
|
3. **Assigned To:**
|
||||||
|
- Need current approver from `approval_levels` where `level_number = current_level`
|
||||||
|
- Can get from `approval_levels.approver_name` ✅
|
||||||
|
|
||||||
|
4. **Current Stage Name:**
|
||||||
|
- Need `approval_levels.level_name` for current level
|
||||||
|
- Currently only have level number
|
||||||
|
|
||||||
|
5. **Aging Threshold Filtering:**
|
||||||
|
- Need to filter requests where days open > threshold
|
||||||
|
- Need to calculate business days (excluding weekends/holidays)
|
||||||
|
|
||||||
|
### 🔧 **What Needs to be Built:**
|
||||||
|
- **New API Endpoint:** `/dashboard/reports/workflow-aging`
|
||||||
|
- Parameters:
|
||||||
|
- `threshold` (days)
|
||||||
|
- `dateRange` (optional)
|
||||||
|
- `page`, `limit` (pagination)
|
||||||
|
- Returns:
|
||||||
|
- Requests with days open > threshold
|
||||||
|
- Business days calculation
|
||||||
|
- Current stage name
|
||||||
|
- Current approver
|
||||||
|
- Days open (business days)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
### ✅ **Can Show Immediately:**
|
||||||
|
1. **Request Lifecycle Report (Partial):**
|
||||||
|
- Request ID, Title, Priority, Status
|
||||||
|
- Initiator name
|
||||||
|
- Submission date
|
||||||
|
- Current level number
|
||||||
|
- Basic TAT info
|
||||||
|
|
||||||
|
2. **User Activity Log (Partial):**
|
||||||
|
- Timestamp, User, Action, Details
|
||||||
|
- Request ID (when applicable)
|
||||||
|
- Using existing `getRecentActivity()` service
|
||||||
|
|
||||||
|
3. **Workflow Aging (Partial):**
|
||||||
|
- Request ID, Title, Initiator
|
||||||
|
- Days open (calendar days)
|
||||||
|
- Priority, Status
|
||||||
|
- Current approver (with join)
|
||||||
|
|
||||||
|
### ❌ **Missing/Incomplete:**
|
||||||
|
1. **Request Lifecycle:**
|
||||||
|
- Full timeline/history of all levels
|
||||||
|
- Current stage name (not just number)
|
||||||
|
- Overall TAT calculation
|
||||||
|
- TAT compliance status (On Time/Delayed)
|
||||||
|
|
||||||
|
2. **User Activity Log:**
|
||||||
|
- IP Address (field exists but may not be populated)
|
||||||
|
- User Agent (field exists but may not be populated)
|
||||||
|
- Login activities (separate tracking needed)
|
||||||
|
- Better action categorization
|
||||||
|
|
||||||
|
3. **Workflow Aging:**
|
||||||
|
- Business days calculation (excluding weekends/holidays)
|
||||||
|
- Current stage name
|
||||||
|
- Proper threshold filtering
|
||||||
|
|
||||||
|
### 🔧 **Required Backend Work:**
|
||||||
|
1. **New Endpoints:**
|
||||||
|
- `/dashboard/reports/lifecycle` - Full lifecycle with timeline
|
||||||
|
- `/dashboard/reports/activity-log` - Enhanced activity log with filters
|
||||||
|
- `/dashboard/reports/workflow-aging` - Aging report with business days
|
||||||
|
|
||||||
|
2. **Enhancements:**
|
||||||
|
- Capture IP address in activity logging
|
||||||
|
- Capture user agent in activity logging
|
||||||
|
- Add login activity tracking
|
||||||
|
- Add business days calculation utility
|
||||||
|
- Add level name to approval levels response
|
||||||
|
|
||||||
|
3. **Data Joins:**
|
||||||
|
- Join `approval_levels` to get current stage name
|
||||||
|
- Join `users` to get approver names
|
||||||
|
- Join `tat_alerts` to get breach/compliance info
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### Phase 1 (Quick Win - Use Existing Data):
|
||||||
|
- Implement basic reports using existing services
|
||||||
|
- Show available data (request info, basic activity, calendar days)
|
||||||
|
- Add placeholders for missing data
|
||||||
|
|
||||||
|
### Phase 2 (Backend Development):
|
||||||
|
- Build new report endpoints
|
||||||
|
- Enhance activity logging to capture IP/user agent
|
||||||
|
- Add business days calculation
|
||||||
|
- Add level name to responses
|
||||||
|
|
||||||
|
### Phase 3 (Full Implementation):
|
||||||
|
- Complete all three reports with full data
|
||||||
|
- Add filtering, sorting, export functionality
|
||||||
|
- Add date range filters
|
||||||
|
- Add user/role-based filtering
|
||||||
|
|
||||||
22
src/App.tsx
22
src/App.tsx
@ -12,6 +12,8 @@ import { MyRequests } from '@/pages/MyRequests';
|
|||||||
import { Profile } from '@/pages/Profile';
|
import { Profile } from '@/pages/Profile';
|
||||||
import { Settings } from '@/pages/Settings';
|
import { Settings } from '@/pages/Settings';
|
||||||
import { Notifications } from '@/pages/Notifications';
|
import { Notifications } from '@/pages/Notifications';
|
||||||
|
import { DetailedReports } from '@/pages/DetailedReports';
|
||||||
|
import { Admin } from '@/pages/Admin';
|
||||||
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
||||||
import { Toaster } from '@/components/ui/sonner';
|
import { Toaster } from '@/components/ui/sonner';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
@ -624,6 +626,26 @@ function AppRoutes({ onLogout }: AppProps) {
|
|||||||
</PageLayout>
|
</PageLayout>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Detailed Reports */}
|
||||||
|
<Route
|
||||||
|
path="/detailed-reports"
|
||||||
|
element={
|
||||||
|
<PageLayout currentPage="detailed-reports" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
|
<DetailedReports />
|
||||||
|
</PageLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Admin Control Panel */}
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
|
||||||
|
<Admin />
|
||||||
|
</PageLayout>
|
||||||
|
}
|
||||||
|
/>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
<Toaster
|
<Toaster
|
||||||
|
|||||||
195
src/components/admin/AIConfig/AIConfig.tsx
Normal file
195
src/components/admin/AIConfig/AIConfig.tsx
Normal file
@ -0,0 +1,195 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save, Loader2, Sparkles, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { AIProviderSettings } from './AIProviderSettings';
|
||||||
|
import { AIFeatures } from './AIFeatures';
|
||||||
|
import { AIParameters } from './AIParameters';
|
||||||
|
import { getAllConfigurations, updateConfiguration, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface AIConfigData {
|
||||||
|
aiEnabled: boolean;
|
||||||
|
aiProvider: 'claude' | 'openai' | 'gemini';
|
||||||
|
claudeApiKey: string;
|
||||||
|
openaiApiKey: string;
|
||||||
|
geminiApiKey: string;
|
||||||
|
aiRemarkGeneration: boolean;
|
||||||
|
maxRemarkChars: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIConfig() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [showApiKeys, setShowApiKeys] = useState<Record<string, boolean>>({
|
||||||
|
claude: false,
|
||||||
|
openai: false,
|
||||||
|
gemini: false
|
||||||
|
});
|
||||||
|
const [config, setConfig] = useState<AIConfigData>({
|
||||||
|
aiEnabled: true,
|
||||||
|
aiProvider: 'claude',
|
||||||
|
claudeApiKey: '',
|
||||||
|
openaiApiKey: '',
|
||||||
|
geminiApiKey: '',
|
||||||
|
aiRemarkGeneration: true,
|
||||||
|
maxRemarkChars: 500
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigurations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfigurations = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const configs = await getAllConfigurations('AI_CONFIGURATION');
|
||||||
|
|
||||||
|
// Map configuration values to state
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
setConfig({
|
||||||
|
aiEnabled: configMap['AI_ENABLED'] === 'true',
|
||||||
|
aiProvider: (configMap['AI_PROVIDER'] || 'claude') as 'claude' | 'openai' | 'gemini',
|
||||||
|
claudeApiKey: configMap['CLAUDE_API_KEY'] || '',
|
||||||
|
openaiApiKey: configMap['OPENAI_API_KEY'] || '',
|
||||||
|
geminiApiKey: configMap['GEMINI_API_KEY'] || '',
|
||||||
|
aiRemarkGeneration: configMap['AI_REMARK_GENERATION_ENABLED'] === 'true',
|
||||||
|
maxRemarkChars: parseInt(configMap['AI_REMARK_MAX_CHARACTERS'] || '500')
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load AI configurations:', error);
|
||||||
|
toast.error('Failed to load AI configurations');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
// Save all configurations
|
||||||
|
await Promise.all([
|
||||||
|
updateConfiguration('AI_ENABLED', config.aiEnabled.toString()),
|
||||||
|
updateConfiguration('AI_PROVIDER', config.aiProvider),
|
||||||
|
updateConfiguration('CLAUDE_API_KEY', config.claudeApiKey),
|
||||||
|
updateConfiguration('OPENAI_API_KEY', config.openaiApiKey),
|
||||||
|
updateConfiguration('GEMINI_API_KEY', config.geminiApiKey),
|
||||||
|
updateConfiguration('AI_REMARK_GENERATION_ENABLED', config.aiRemarkGeneration.toString()),
|
||||||
|
updateConfiguration('AI_REMARK_MAX_CHARACTERS', config.maxRemarkChars.toString())
|
||||||
|
]);
|
||||||
|
|
||||||
|
toast.success('AI configuration saved successfully');
|
||||||
|
|
||||||
|
// Reload to get updated values
|
||||||
|
await loadConfigurations();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save AI configuration:', error);
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to save AI configuration');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<AIConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleApiKeyVisibility = (provider: 'claude' | 'openai' | 'gemini') => {
|
||||||
|
setShowApiKeys(prev => ({
|
||||||
|
...prev,
|
||||||
|
[provider]: !prev[provider]
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const maskApiKey = (key: string): string => {
|
||||||
|
if (!key || key.length === 0) return '';
|
||||||
|
if (key.length <= 8) return '••••••••';
|
||||||
|
return key.substring(0, 4) + '••••••••' + key.substring(key.length - 4);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-re-green mx-auto mb-4" />
|
||||||
|
<p className="text-sm text-gray-600">Loading AI configuration...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-re-green rounded-md shadow-md">
|
||||||
|
<Sparkles className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold text-gray-900">AI Features Configuration</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-gray-600">
|
||||||
|
Configure AI provider, API keys, and enable/disable AI-powered features
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<AIProviderSettings
|
||||||
|
aiEnabled={config.aiEnabled}
|
||||||
|
aiProvider={config.aiProvider}
|
||||||
|
claudeApiKey={config.claudeApiKey}
|
||||||
|
openaiApiKey={config.openaiApiKey}
|
||||||
|
geminiApiKey={config.geminiApiKey}
|
||||||
|
showApiKeys={showApiKeys}
|
||||||
|
onAiEnabledChange={(enabled) => updateConfig({ aiEnabled: enabled })}
|
||||||
|
onProviderChange={(provider) => updateConfig({ aiProvider: provider })}
|
||||||
|
onClaudeApiKeyChange={(key) => updateConfig({ claudeApiKey: key })}
|
||||||
|
onOpenaiApiKeyChange={(key) => updateConfig({ openaiApiKey: key })}
|
||||||
|
onGeminiApiKeyChange={(key) => updateConfig({ geminiApiKey: key })}
|
||||||
|
onToggleApiKeyVisibility={toggleApiKeyVisibility}
|
||||||
|
maskApiKey={maskApiKey}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<AIFeatures
|
||||||
|
aiRemarkGeneration={config.aiRemarkGeneration}
|
||||||
|
onRemarkGenerationChange={(enabled) => updateConfig({ aiRemarkGeneration: enabled })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<AIParameters
|
||||||
|
maxRemarkChars={config.maxRemarkChars}
|
||||||
|
onMaxRemarkCharsChange={(chars) => updateConfig({ maxRemarkChars: chars })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save AI Configuration
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
src/components/admin/AIConfig/AIFeatures.tsx
Normal file
43
src/components/admin/AIConfig/AIFeatures.tsx
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Sparkles } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AIFeaturesProps {
|
||||||
|
aiRemarkGeneration: boolean;
|
||||||
|
onRemarkGenerationChange: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIFeatures({
|
||||||
|
aiRemarkGeneration,
|
||||||
|
onRemarkGenerationChange
|
||||||
|
}: AIFeaturesProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sparkles className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">AI Features</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Enable/disable specific AI-powered features
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 border border-gray-200 rounded-md hover:bg-gray-100 transition-colors">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">AI Remark Generation</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Automatically generate conclusion remarks for workflow closures
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={aiRemarkGeneration}
|
||||||
|
onCheckedChange={onRemarkGenerationChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
src/components/admin/AIConfig/AIParameters.tsx
Normal file
47
src/components/admin/AIConfig/AIParameters.tsx
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Sliders } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AIParametersProps {
|
||||||
|
maxRemarkChars: number;
|
||||||
|
onMaxRemarkCharsChange: (chars: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AIParameters({
|
||||||
|
maxRemarkChars,
|
||||||
|
onMaxRemarkCharsChange
|
||||||
|
}: AIParametersProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Sliders className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">AI Parameters</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Configure AI generation parameters
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max-remark-chars" className="text-sm font-medium">
|
||||||
|
Maximum Remark Characters
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="max-remark-chars"
|
||||||
|
type="number"
|
||||||
|
min="100"
|
||||||
|
max="2000"
|
||||||
|
value={maxRemarkChars}
|
||||||
|
onChange={(e) => onMaxRemarkCharsChange(parseInt(e.target.value) || 500)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Maximum character limit for AI-generated conclusion remarks (100-2000 characters)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
240
src/components/admin/AIConfig/AIProviderSettings.tsx
Normal file
240
src/components/admin/AIConfig/AIProviderSettings.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Brain, Eye, EyeOff, Key } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
|
interface AIProviderSettingsProps {
|
||||||
|
aiEnabled: boolean;
|
||||||
|
aiProvider: 'claude' | 'openai' | 'gemini';
|
||||||
|
claudeApiKey: string;
|
||||||
|
openaiApiKey: string;
|
||||||
|
geminiApiKey: string;
|
||||||
|
showApiKeys: Record<string, boolean>;
|
||||||
|
onAiEnabledChange: (enabled: boolean) => void;
|
||||||
|
onProviderChange: (provider: 'claude' | 'openai' | 'gemini') => void;
|
||||||
|
onClaudeApiKeyChange: (key: string) => void;
|
||||||
|
onOpenaiApiKeyChange: (key: string) => void;
|
||||||
|
onGeminiApiKeyChange: (key: string) => void;
|
||||||
|
onToggleApiKeyVisibility: (provider: 'claude' | 'openai' | 'gemini') => void;
|
||||||
|
maskApiKey: (key: string) => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROVIDERS = [
|
||||||
|
{ value: 'claude', label: 'Claude (Anthropic)', description: 'Advanced AI by Anthropic' },
|
||||||
|
{ value: 'openai', label: 'OpenAI (GPT-4)', description: 'GPT-4 by OpenAI' },
|
||||||
|
{ value: 'gemini', label: 'Gemini (Google)', description: 'Gemini by Google' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AIProviderSettings({
|
||||||
|
aiEnabled,
|
||||||
|
aiProvider,
|
||||||
|
claudeApiKey,
|
||||||
|
openaiApiKey,
|
||||||
|
geminiApiKey,
|
||||||
|
showApiKeys,
|
||||||
|
onAiEnabledChange,
|
||||||
|
onProviderChange,
|
||||||
|
onClaudeApiKeyChange,
|
||||||
|
onOpenaiApiKeyChange,
|
||||||
|
onGeminiApiKeyChange,
|
||||||
|
onToggleApiKeyVisibility,
|
||||||
|
maskApiKey
|
||||||
|
}: AIProviderSettingsProps) {
|
||||||
|
const getCurrentApiKey = (provider: 'claude' | 'openai' | 'gemini'): string => {
|
||||||
|
switch (provider) {
|
||||||
|
case 'claude':
|
||||||
|
return claudeApiKey;
|
||||||
|
case 'openai':
|
||||||
|
return openaiApiKey;
|
||||||
|
case 'gemini':
|
||||||
|
return geminiApiKey;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getApiKeyChangeHandler = (provider: 'claude' | 'openai' | 'gemini') => {
|
||||||
|
switch (provider) {
|
||||||
|
case 'claude':
|
||||||
|
return onClaudeApiKeyChange;
|
||||||
|
case 'openai':
|
||||||
|
return onOpenaiApiKeyChange;
|
||||||
|
case 'gemini':
|
||||||
|
return onGeminiApiKeyChange;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Brain className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">AI Provider & API Keys</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Select your AI provider and configure API keys
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Master Toggle */}
|
||||||
|
<div className="flex items-center justify-between p-4 bg-gray-50 border border-gray-200 rounded-md">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Enable AI Features</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Master toggle to enable/disable all AI-powered features
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={aiEnabled}
|
||||||
|
onCheckedChange={onAiEnabledChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{aiEnabled && (
|
||||||
|
<>
|
||||||
|
{/* Provider Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="ai-provider" className="text-sm font-medium">
|
||||||
|
AI Provider
|
||||||
|
</Label>
|
||||||
|
<Select value={aiProvider} onValueChange={(value: any) => onProviderChange(value)}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="ai-provider"
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select AI provider" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{PROVIDERS.map((provider) => (
|
||||||
|
<SelectItem key={provider.value} value={provider.value}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-medium">{provider.label}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">-</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{provider.description}</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* API Keys for each provider */}
|
||||||
|
<div className="space-y-4 pt-2">
|
||||||
|
<Label className="text-sm font-medium flex items-center gap-2">
|
||||||
|
<Key className="w-4 h-4" />
|
||||||
|
API Keys
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
{/* Claude API Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="claude-key" className="text-xs text-muted-foreground">
|
||||||
|
Claude API Key
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="claude-key"
|
||||||
|
type={showApiKeys.claude ? 'text' : 'password'}
|
||||||
|
value={claudeApiKey}
|
||||||
|
onChange={(e) => onClaudeApiKeyChange(e.target.value)}
|
||||||
|
placeholder={showApiKeys.claude ? "sk-ant-..." : maskApiKey(claudeApiKey) || "sk-ant-..."}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onToggleApiKeyVisibility('claude')}
|
||||||
|
className="border-gray-200"
|
||||||
|
>
|
||||||
|
{showApiKeys.claude ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Get your API key from console.anthropic.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* OpenAI API Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="openai-key" className="text-xs text-muted-foreground">
|
||||||
|
OpenAI API Key
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="openai-key"
|
||||||
|
type={showApiKeys.openai ? 'text' : 'password'}
|
||||||
|
value={openaiApiKey}
|
||||||
|
onChange={(e) => onOpenaiApiKeyChange(e.target.value)}
|
||||||
|
placeholder={showApiKeys.openai ? "sk-..." : maskApiKey(openaiApiKey) || "sk-..."}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onToggleApiKeyVisibility('openai')}
|
||||||
|
className="border-gray-200"
|
||||||
|
>
|
||||||
|
{showApiKeys.openai ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Get your API key from platform.openai.com
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Gemini API Key */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="gemini-key" className="text-xs text-muted-foreground">
|
||||||
|
Gemini API Key
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
id="gemini-key"
|
||||||
|
type={showApiKeys.gemini ? 'text' : 'password'}
|
||||||
|
value={geminiApiKey}
|
||||||
|
onChange={(e) => onGeminiApiKeyChange(e.target.value)}
|
||||||
|
placeholder={showApiKeys.gemini ? "AIza..." : maskApiKey(geminiApiKey) || "AIza..."}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => onToggleApiKeyVisibility('gemini')}
|
||||||
|
className="border-gray-200"
|
||||||
|
>
|
||||||
|
{showApiKeys.gemini ? (
|
||||||
|
<EyeOff className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Eye className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Get your API key from ai.google.dev
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
5
src/components/admin/AIConfig/index.ts
Normal file
5
src/components/admin/AIConfig/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { AIConfig } from './AIConfig';
|
||||||
|
export { AIProviderSettings } from './AIProviderSettings';
|
||||||
|
export { AIFeatures } from './AIFeatures';
|
||||||
|
export { AIParameters } from './AIParameters';
|
||||||
|
|
||||||
90
src/components/admin/AnalyticsConfig/AnalyticsConfig.tsx
Normal file
90
src/components/admin/AnalyticsConfig/AnalyticsConfig.tsx
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
import { AnalyticsSettingsForm } from './AnalyticsSettingsForm';
|
||||||
|
import { DataFeaturesSection } from './DataFeaturesSection';
|
||||||
|
import { ExportFormatsSection } from './ExportFormatsSection';
|
||||||
|
import { DataRetentionSection } from './DataRetentionSection';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface AnalyticsConfigData {
|
||||||
|
defaultPeriod: string;
|
||||||
|
refreshInterval: number;
|
||||||
|
autoRefresh: boolean;
|
||||||
|
realTimeUpdates: boolean;
|
||||||
|
dataExport: boolean;
|
||||||
|
exportFormats: string[];
|
||||||
|
dataRetention: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsConfig() {
|
||||||
|
const [config, setConfig] = useState<AnalyticsConfigData>({
|
||||||
|
defaultPeriod: 'This Month',
|
||||||
|
refreshInterval: 5,
|
||||||
|
autoRefresh: true,
|
||||||
|
realTimeUpdates: true,
|
||||||
|
dataExport: true,
|
||||||
|
exportFormats: ['CSV', 'Excel', 'PDF'],
|
||||||
|
dataRetention: 24
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// TODO: Implement API call to save configuration
|
||||||
|
console.log('Saving analytics configuration:', config);
|
||||||
|
toast.success('Analytics configuration saved successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<AnalyticsConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Analytics & Reporting Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure default reporting periods, auto-refresh, export settings, and data retention
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<AnalyticsSettingsForm
|
||||||
|
defaultPeriod={config.defaultPeriod}
|
||||||
|
refreshInterval={config.refreshInterval}
|
||||||
|
onDefaultPeriodChange={(period) => updateConfig({ defaultPeriod: period })}
|
||||||
|
onRefreshIntervalChange={(interval) => updateConfig({ refreshInterval: interval })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DataFeaturesSection
|
||||||
|
autoRefresh={config.autoRefresh}
|
||||||
|
realTimeUpdates={config.realTimeUpdates}
|
||||||
|
dataExport={config.dataExport}
|
||||||
|
onAutoRefreshChange={(enabled) => updateConfig({ autoRefresh: enabled })}
|
||||||
|
onRealTimeUpdatesChange={(enabled) => updateConfig({ realTimeUpdates: enabled })}
|
||||||
|
onDataExportChange={(enabled) => updateConfig({ dataExport: enabled })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<ExportFormatsSection
|
||||||
|
exportFormats={config.exportFormats}
|
||||||
|
onExportFormatsChange={(formats) => updateConfig({ exportFormats: formats })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DataRetentionSection
|
||||||
|
dataRetention={config.dataRetention}
|
||||||
|
onDataRetentionChange={(months) => updateConfig({ dataRetention: months })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} className="bg-re-green hover:bg-re-green/90">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save Analytics Configuration
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
interface AnalyticsSettingsFormProps {
|
||||||
|
defaultPeriod: string;
|
||||||
|
refreshInterval: number;
|
||||||
|
onDefaultPeriodChange: (period: string) => void;
|
||||||
|
onRefreshIntervalChange: (interval: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AnalyticsSettingsForm({
|
||||||
|
defaultPeriod,
|
||||||
|
refreshInterval,
|
||||||
|
onDefaultPeriodChange,
|
||||||
|
onRefreshIntervalChange
|
||||||
|
}: AnalyticsSettingsFormProps) {
|
||||||
|
const periodOptions = [
|
||||||
|
'Today',
|
||||||
|
'This Week',
|
||||||
|
'This Month',
|
||||||
|
'Last Month',
|
||||||
|
'This Quarter',
|
||||||
|
'This Year',
|
||||||
|
'Custom Range'
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="default-period">Default Reporting Period</Label>
|
||||||
|
<Select value={defaultPeriod} onValueChange={onDefaultPeriodChange}>
|
||||||
|
<SelectTrigger id="default-period">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{periodOptions.map((period) => (
|
||||||
|
<SelectItem key={period} value={period}>
|
||||||
|
{period}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="refresh-interval">Auto-Refresh Interval (minutes)</Label>
|
||||||
|
<Input
|
||||||
|
id="refresh-interval"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="60"
|
||||||
|
value={refreshInterval}
|
||||||
|
onChange={(e) => onRefreshIntervalChange(parseInt(e.target.value) || 5)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
65
src/components/admin/AnalyticsConfig/DataFeaturesSection.tsx
Normal file
65
src/components/admin/AnalyticsConfig/DataFeaturesSection.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
|
interface DataFeaturesSectionProps {
|
||||||
|
autoRefresh: boolean;
|
||||||
|
realTimeUpdates: boolean;
|
||||||
|
dataExport: boolean;
|
||||||
|
onAutoRefreshChange: (enabled: boolean) => void;
|
||||||
|
onRealTimeUpdatesChange: (enabled: boolean) => void;
|
||||||
|
onDataExportChange: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataFeaturesSection({
|
||||||
|
autoRefresh,
|
||||||
|
realTimeUpdates,
|
||||||
|
dataExport,
|
||||||
|
onAutoRefreshChange,
|
||||||
|
onRealTimeUpdatesChange,
|
||||||
|
onDataExportChange
|
||||||
|
}: DataFeaturesSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium">Data Features</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Enable Auto-Refresh</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Automatically refresh dashboard data at set intervals
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoRefresh}
|
||||||
|
onCheckedChange={onAutoRefreshChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Enable Real-time Updates</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Show live updates when data changes occur
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={realTimeUpdates}
|
||||||
|
onCheckedChange={onRealTimeUpdatesChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Enable Data Export</p>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Allow users to export analytics data and reports
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={dataExport}
|
||||||
|
onCheckedChange={onDataExportChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
|
||||||
|
interface DataRetentionSectionProps {
|
||||||
|
dataRetention: number;
|
||||||
|
onDataRetentionChange: (months: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DataRetentionSection({
|
||||||
|
dataRetention,
|
||||||
|
onDataRetentionChange
|
||||||
|
}: DataRetentionSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="data-retention">Historical Data Retention (months)</Label>
|
||||||
|
<Input
|
||||||
|
id="data-retention"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="120"
|
||||||
|
value={dataRetention}
|
||||||
|
onChange={(e) => onDataRetentionChange(parseInt(e.target.value) || 24)}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Analytics data older than this will be archived or deleted
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
|
||||||
|
interface ExportFormatsSectionProps {
|
||||||
|
exportFormats: string[];
|
||||||
|
onExportFormatsChange: (formats: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableFormats = ['CSV', 'Excel', 'PDF', 'JSON'];
|
||||||
|
|
||||||
|
export function ExportFormatsSection({
|
||||||
|
exportFormats,
|
||||||
|
onExportFormatsChange
|
||||||
|
}: ExportFormatsSectionProps) {
|
||||||
|
const handleFormatToggle = (format: string, checked: boolean) => {
|
||||||
|
if (checked) {
|
||||||
|
onExportFormatsChange([...exportFormats, format]);
|
||||||
|
} else {
|
||||||
|
onExportFormatsChange(exportFormats.filter(f => f !== format));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>Allowed Export Formats</Label>
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{availableFormats.map((format) => (
|
||||||
|
<div
|
||||||
|
key={format}
|
||||||
|
className="flex items-center space-x-2 p-2 bg-muted/50 rounded"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`export-${format}`}
|
||||||
|
checked={exportFormats.includes(format)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
handleFormatToggle(format, checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`export-${format}`}
|
||||||
|
className="text-sm cursor-pointer"
|
||||||
|
>
|
||||||
|
{format}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
6
src/components/admin/AnalyticsConfig/index.ts
Normal file
6
src/components/admin/AnalyticsConfig/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { AnalyticsConfig } from './AnalyticsConfig';
|
||||||
|
export { AnalyticsSettingsForm } from './AnalyticsSettingsForm';
|
||||||
|
export { DataFeaturesSection } from './DataFeaturesSection';
|
||||||
|
export { ExportFormatsSection } from './ExportFormatsSection';
|
||||||
|
export { DataRetentionSection } from './DataRetentionSection';
|
||||||
|
|
||||||
109
src/components/admin/DashboardConfig/DashboardConfig.tsx
Normal file
109
src/components/admin/DashboardConfig/DashboardConfig.tsx
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
import { DashboardNote } from './DashboardNote';
|
||||||
|
import { RoleDashboardSection } from './RoleDashboardSection';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export type Role = 'Initiator' | 'Approver' | 'Spectator';
|
||||||
|
|
||||||
|
export type KPICard =
|
||||||
|
| 'Total Requests'
|
||||||
|
| 'Open Requests'
|
||||||
|
| 'Approved Requests'
|
||||||
|
| 'Rejected Requests'
|
||||||
|
| 'My Pending Actions'
|
||||||
|
| 'TAT Compliance'
|
||||||
|
| 'Delayed Workflows'
|
||||||
|
| 'Average Cycle Time';
|
||||||
|
|
||||||
|
interface DashboardConfigData {
|
||||||
|
[key: string]: {
|
||||||
|
[kpi: string]: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardConfig() {
|
||||||
|
const [config, setConfig] = useState<DashboardConfigData>({
|
||||||
|
Initiator: {
|
||||||
|
'Total Requests': true,
|
||||||
|
'Open Requests': true,
|
||||||
|
'Approved Requests': true,
|
||||||
|
'Rejected Requests': true,
|
||||||
|
'My Pending Actions': true,
|
||||||
|
'TAT Compliance': true,
|
||||||
|
'Delayed Workflows': true,
|
||||||
|
'Average Cycle Time': true
|
||||||
|
},
|
||||||
|
Approver: {
|
||||||
|
'Total Requests': true,
|
||||||
|
'Open Requests': true,
|
||||||
|
'Approved Requests': true,
|
||||||
|
'Rejected Requests': true,
|
||||||
|
'My Pending Actions': true,
|
||||||
|
'TAT Compliance': true,
|
||||||
|
'Delayed Workflows': true,
|
||||||
|
'Average Cycle Time': true
|
||||||
|
},
|
||||||
|
Spectator: {
|
||||||
|
'Total Requests': true,
|
||||||
|
'Open Requests': true,
|
||||||
|
'Approved Requests': true,
|
||||||
|
'Rejected Requests': true,
|
||||||
|
'My Pending Actions': true,
|
||||||
|
'TAT Compliance': true,
|
||||||
|
'Delayed Workflows': true,
|
||||||
|
'Average Cycle Time': true
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// TODO: Implement API call to save dashboard configuration
|
||||||
|
console.log('Saving dashboard configuration:', config);
|
||||||
|
toast.success('Dashboard layout saved successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKPIToggle = (role: Role, kpi: KPICard, checked: boolean) => {
|
||||||
|
setConfig(prev => ({
|
||||||
|
...prev,
|
||||||
|
[role]: {
|
||||||
|
...prev[role],
|
||||||
|
[kpi]: checked
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const roles: Role[] = ['Initiator', 'Approver', 'Spectator'];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Dashboard Layout Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Control which KPI cards are visible for each user role
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<DashboardNote />
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
{roles.map((role) => (
|
||||||
|
<RoleDashboardSection
|
||||||
|
key={role}
|
||||||
|
role={role}
|
||||||
|
kpis={config[role]}
|
||||||
|
onKPIToggle={(kpi, checked) => handleKPIToggle(role, kpi, checked)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} className="bg-re-green hover:bg-re-green/90">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save Dashboard Layout
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
10
src/components/admin/DashboardConfig/DashboardNote.tsx
Normal file
10
src/components/admin/DashboardConfig/DashboardNote.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export function DashboardNote() {
|
||||||
|
return (
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<p className="text-sm text-blue-800">
|
||||||
|
<strong>Note:</strong> These settings control what information is visible to each role on their dashboard. Admins always have access to all metrics.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
|
import { Role, KPICard } from './DashboardConfig';
|
||||||
|
|
||||||
|
interface RoleDashboardSectionProps {
|
||||||
|
role: Role;
|
||||||
|
kpis: Record<string, boolean>;
|
||||||
|
onKPIToggle: (kpi: KPICard, checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const kpiCards: KPICard[] = [
|
||||||
|
'Total Requests',
|
||||||
|
'Open Requests',
|
||||||
|
'Approved Requests',
|
||||||
|
'Rejected Requests',
|
||||||
|
'My Pending Actions',
|
||||||
|
'TAT Compliance',
|
||||||
|
'Delayed Workflows',
|
||||||
|
'Average Cycle Time'
|
||||||
|
];
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: Role) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'Initiator':
|
||||||
|
return 'bg-blue-100 text-blue-800';
|
||||||
|
case 'Approver':
|
||||||
|
return 'bg-green-100 text-green-800';
|
||||||
|
case 'Spectator':
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function RoleDashboardSection({ role, kpis, onKPIToggle }: RoleDashboardSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium flex items-center gap-2">
|
||||||
|
<Badge className={getRoleBadgeColor(role)}>
|
||||||
|
{role} Dashboard
|
||||||
|
</Badge>
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 gap-2 ml-4">
|
||||||
|
{kpiCards.map((kpi) => (
|
||||||
|
<div key={kpi} className="flex items-center space-x-2 p-2 bg-muted/50 rounded">
|
||||||
|
<Checkbox
|
||||||
|
id={`${role.toLowerCase()}-${kpi}`}
|
||||||
|
checked={kpis[kpi] || false}
|
||||||
|
onCheckedChange={(checked) => onKPIToggle(kpi, checked === true)}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={`${role.toLowerCase()}-${kpi}`}
|
||||||
|
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
|
||||||
|
>
|
||||||
|
{kpi}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
5
src/components/admin/DashboardConfig/index.ts
Normal file
5
src/components/admin/DashboardConfig/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { DashboardConfig } from './DashboardConfig';
|
||||||
|
export type { Role, KPICard } from './DashboardConfig';
|
||||||
|
export { DashboardNote } from './DashboardNote';
|
||||||
|
export { RoleDashboardSection } from './RoleDashboardSection';
|
||||||
|
|
||||||
119
src/components/admin/DocumentConfig/AllowedFileTypes.tsx
Normal file
119
src/components/admin/DocumentConfig/AllowedFileTypes.tsx
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { FileCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
interface AllowedFileTypesProps {
|
||||||
|
allowedFileTypes: string;
|
||||||
|
onAllowedFileTypesChange: (types: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Common file type mappings
|
||||||
|
const FILE_TYPE_GROUPS = [
|
||||||
|
{ label: 'PDF Documents', extensions: ['pdf'] },
|
||||||
|
{ label: 'Microsoft Word', extensions: ['doc', 'docx'] },
|
||||||
|
{ label: 'Microsoft Excel', extensions: ['xls', 'xlsx'] },
|
||||||
|
{ label: 'Microsoft PowerPoint', extensions: ['ppt', 'pptx'] },
|
||||||
|
{ label: 'Images', extensions: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'] },
|
||||||
|
{ label: 'CSV Files', extensions: ['csv'] },
|
||||||
|
{ label: 'Text Files', extensions: ['txt', 'rtf'] }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function AllowedFileTypes({ allowedFileTypes, onAllowedFileTypesChange }: AllowedFileTypesProps) {
|
||||||
|
const allowedExtensions = allowedFileTypes.split(',').map(ext => ext.trim().toLowerCase());
|
||||||
|
|
||||||
|
const toggleFileType = (extensions: string[], enabled: boolean) => {
|
||||||
|
const currentExts = new Set(allowedExtensions);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
extensions.forEach(ext => currentExts.add(ext));
|
||||||
|
} else {
|
||||||
|
extensions.forEach(ext => currentExts.delete(ext));
|
||||||
|
}
|
||||||
|
|
||||||
|
onAllowedFileTypesChange(Array.from(currentExts).join(','));
|
||||||
|
};
|
||||||
|
|
||||||
|
const isGroupEnabled = (extensions: string[]) => {
|
||||||
|
return extensions.some(ext => allowedExtensions.includes(ext));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleManualEdit = (value: string) => {
|
||||||
|
onAllowedFileTypesChange(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileCheck className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">Allowed File Types</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Select which file types are allowed for upload
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{FILE_TYPE_GROUPS.map((group) => {
|
||||||
|
const isEnabled = isGroupEnabled(group.extensions);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={group.label}
|
||||||
|
onClick={() => toggleFileType(group.extensions, !isEnabled)}
|
||||||
|
className={`flex items-center justify-between p-3 rounded-md border-2 cursor-pointer transition-all ${
|
||||||
|
isEnabled
|
||||||
|
? 'bg-re-green/5 border-re-green/30 hover:border-re-green/50'
|
||||||
|
: 'bg-gray-50 border-gray-200 hover:border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className={`w-4 h-4 rounded border-2 flex items-center justify-center ${
|
||||||
|
isEnabled
|
||||||
|
? 'bg-re-green border-re-green'
|
||||||
|
: 'bg-white border-gray-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isEnabled && (
|
||||||
|
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
fillRule="evenodd"
|
||||||
|
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||||
|
clipRule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className={`text-sm font-medium ${isEnabled ? 'text-re-green' : 'text-gray-600'}`}>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{group.extensions.join(', ')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 pt-2">
|
||||||
|
<Label htmlFor="file-types-manual" className="text-sm font-medium">
|
||||||
|
File Extensions (comma-separated)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="file-types-manual"
|
||||||
|
type="text"
|
||||||
|
value={allowedFileTypes}
|
||||||
|
onChange={(e) => handleManualEdit(e.target.value)}
|
||||||
|
placeholder="pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif"
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20 font-mono text-sm"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Edit directly or use the checkboxes above. Separate extensions with commas.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
144
src/components/admin/DocumentConfig/DocumentConfig.tsx
Normal file
144
src/components/admin/DocumentConfig/DocumentConfig.tsx
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save, Loader2, FileText } from 'lucide-react';
|
||||||
|
import { DocumentUploadSettings } from './DocumentUploadSettings';
|
||||||
|
import { AllowedFileTypes } from './AllowedFileTypes';
|
||||||
|
import { getAllConfigurations, updateConfiguration, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface DocumentConfigData {
|
||||||
|
maxFileSizeMB: number;
|
||||||
|
retentionDays: number;
|
||||||
|
allowedFileTypes: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentConfig() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [config, setConfig] = useState<DocumentConfigData>({
|
||||||
|
maxFileSizeMB: 10,
|
||||||
|
retentionDays: 365,
|
||||||
|
allowedFileTypes: 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif'
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigurations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfigurations = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||||
|
|
||||||
|
// Map configuration values to state
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
setConfig({
|
||||||
|
maxFileSizeMB: parseInt(configMap['MAX_FILE_SIZE_MB'] || '10'),
|
||||||
|
retentionDays: parseInt(configMap['DOCUMENT_RETENTION_DAYS'] || '365'),
|
||||||
|
allowedFileTypes: configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif'
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load document configurations:', error);
|
||||||
|
toast.error('Failed to load document configurations');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
// Save all configurations
|
||||||
|
await Promise.all([
|
||||||
|
updateConfiguration('MAX_FILE_SIZE_MB', config.maxFileSizeMB.toString()),
|
||||||
|
updateConfiguration('DOCUMENT_RETENTION_DAYS', config.retentionDays.toString()),
|
||||||
|
updateConfiguration('ALLOWED_FILE_TYPES', config.allowedFileTypes)
|
||||||
|
]);
|
||||||
|
|
||||||
|
toast.success('Document policy saved successfully');
|
||||||
|
|
||||||
|
// Reload to get updated values
|
||||||
|
await loadConfigurations();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save document configuration:', error);
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to save document configuration');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<DocumentConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-re-green mx-auto mb-4" />
|
||||||
|
<p className="text-sm text-gray-600">Loading document policy configuration...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-re-green rounded-md shadow-md">
|
||||||
|
<FileText className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold text-gray-900">Document Upload Policy</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-gray-600">
|
||||||
|
Configure file upload limits, allowed types, and retention policies
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<DocumentUploadSettings
|
||||||
|
maxFileSizeMB={config.maxFileSizeMB}
|
||||||
|
retentionDays={config.retentionDays}
|
||||||
|
onMaxFileSizeChange={(size) => updateConfig({ maxFileSizeMB: size })}
|
||||||
|
onRetentionDaysChange={(days) => updateConfig({ retentionDays: days })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<AllowedFileTypes
|
||||||
|
allowedFileTypes={config.allowedFileTypes}
|
||||||
|
onAllowedFileTypesChange={(types) => updateConfig({ allowedFileTypes: types })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save Document Policy
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Upload, Archive } from 'lucide-react';
|
||||||
|
|
||||||
|
interface DocumentUploadSettingsProps {
|
||||||
|
maxFileSizeMB: number;
|
||||||
|
retentionDays: number;
|
||||||
|
onMaxFileSizeChange: (size: number) => void;
|
||||||
|
onRetentionDaysChange: (days: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DocumentUploadSettings({
|
||||||
|
maxFileSizeMB,
|
||||||
|
retentionDays,
|
||||||
|
onMaxFileSizeChange,
|
||||||
|
onRetentionDaysChange
|
||||||
|
}: DocumentUploadSettingsProps) {
|
||||||
|
// Convert days to years for display (optional, or keep as days)
|
||||||
|
const retentionYears = Math.round((retentionDays / 365) * 10) / 10;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Upload className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">Upload & Retention Settings</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Configure file size limits and document retention period
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max-upload" className="text-sm font-medium">
|
||||||
|
Maximum Upload Size (MB)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="max-upload"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="100"
|
||||||
|
value={maxFileSizeMB}
|
||||||
|
onChange={(e) => onMaxFileSizeChange(parseInt(e.target.value) || 10)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Upload className="w-3 h-3" />
|
||||||
|
Maximum allowed file size for document uploads
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="retention" className="text-sm font-medium">
|
||||||
|
Retention Period (Days)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="retention"
|
||||||
|
type="number"
|
||||||
|
min="30"
|
||||||
|
max="3650"
|
||||||
|
value={retentionDays}
|
||||||
|
onChange={(e) => onRetentionDaysChange(parseInt(e.target.value) || 365)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Archive className="w-3 h-3" />
|
||||||
|
Days to retain documents after workflow closure ({retentionYears} years)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
4
src/components/admin/DocumentConfig/index.ts
Normal file
4
src/components/admin/DocumentConfig/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { DocumentConfig } from './DocumentConfig';
|
||||||
|
export { DocumentUploadSettings } from './DocumentUploadSettings';
|
||||||
|
export { AllowedFileTypes } from './AllowedFileTypes';
|
||||||
|
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
|
||||||
|
interface EmailTemplateSectionProps {
|
||||||
|
emailTemplate: string;
|
||||||
|
onEmailTemplateChange: (template: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmailTemplateSection({
|
||||||
|
emailTemplate,
|
||||||
|
onEmailTemplateChange
|
||||||
|
}: EmailTemplateSectionProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="email-template">Email Template Message</Label>
|
||||||
|
<Textarea
|
||||||
|
id="email-template"
|
||||||
|
rows={5}
|
||||||
|
value={emailTemplate}
|
||||||
|
onChange={(e) => onEmailTemplateChange(e.target.value)}
|
||||||
|
className="resize-none"
|
||||||
|
placeholder="Enter email template message..."
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Use placeholders: [Name], [Request ID], [TAT], [Status]
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,59 @@
|
|||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
|
interface NotificationChannelsProps {
|
||||||
|
emailNotifications: boolean;
|
||||||
|
inAppNotifications: boolean;
|
||||||
|
autoReminders: boolean;
|
||||||
|
onEmailNotificationsChange: (enabled: boolean) => void;
|
||||||
|
onInAppNotificationsChange: (enabled: boolean) => void;
|
||||||
|
onAutoRemindersChange: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationChannels({
|
||||||
|
emailNotifications,
|
||||||
|
inAppNotifications,
|
||||||
|
autoReminders,
|
||||||
|
onEmailNotificationsChange,
|
||||||
|
onInAppNotificationsChange,
|
||||||
|
onAutoRemindersChange
|
||||||
|
}: NotificationChannelsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<h4 className="font-medium">Notification Channels</h4>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Email Notifications</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Send notifications via email</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={emailNotifications}
|
||||||
|
onCheckedChange={onEmailNotificationsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">In-App Notifications</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Show notifications in the portal</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={inAppNotifications}
|
||||||
|
onCheckedChange={onInAppNotificationsChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Auto-Reminders</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Automatically send reminders for pending approvals</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={autoReminders}
|
||||||
|
onCheckedChange={onAutoRemindersChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
import { NotificationChannels } from './NotificationChannels';
|
||||||
|
import { NotificationSettings } from './NotificationSettings';
|
||||||
|
import { EmailTemplateSection } from './EmailTemplateSection';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface NotificationConfigData {
|
||||||
|
emailNotifications: boolean;
|
||||||
|
inAppNotifications: boolean;
|
||||||
|
autoReminders: boolean;
|
||||||
|
notificationFrequency: string;
|
||||||
|
reminderFrequency: number;
|
||||||
|
emailTemplate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationConfig() {
|
||||||
|
const [config, setConfig] = useState<NotificationConfigData>({
|
||||||
|
emailNotifications: true,
|
||||||
|
inAppNotifications: true,
|
||||||
|
autoReminders: true,
|
||||||
|
notificationFrequency: 'Immediate',
|
||||||
|
reminderFrequency: 12,
|
||||||
|
emailTemplate: 'Dear [Name], You have a pending approval request [Request ID] that requires your attention. TAT remaining: [TAT]. Please review at your earliest convenience.'
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// TODO: Implement API call to save notification configuration
|
||||||
|
console.log('Saving notification configuration:', config);
|
||||||
|
toast.success('Notification configuration saved successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<NotificationConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Notification Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure notification channels, frequency, and message templates
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<NotificationChannels
|
||||||
|
emailNotifications={config.emailNotifications}
|
||||||
|
inAppNotifications={config.inAppNotifications}
|
||||||
|
autoReminders={config.autoReminders}
|
||||||
|
onEmailNotificationsChange={(enabled) => updateConfig({ emailNotifications: enabled })}
|
||||||
|
onInAppNotificationsChange={(enabled) => updateConfig({ inAppNotifications: enabled })}
|
||||||
|
onAutoRemindersChange={(enabled) => updateConfig({ autoReminders: enabled })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<NotificationSettings
|
||||||
|
notificationFrequency={config.notificationFrequency}
|
||||||
|
reminderFrequency={config.reminderFrequency}
|
||||||
|
onNotificationFrequencyChange={(frequency) => updateConfig({ notificationFrequency: frequency })}
|
||||||
|
onReminderFrequencyChange={(hours) => updateConfig({ reminderFrequency: hours })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EmailTemplateSection
|
||||||
|
emailTemplate={config.emailTemplate}
|
||||||
|
onEmailTemplateChange={(template) => updateConfig({ emailTemplate: template })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} className="bg-re-green hover:bg-re-green/90">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save Notification Settings
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface NotificationSettingsProps {
|
||||||
|
notificationFrequency: string;
|
||||||
|
reminderFrequency: number;
|
||||||
|
onNotificationFrequencyChange: (frequency: string) => void;
|
||||||
|
onReminderFrequencyChange: (hours: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NotificationSettings({
|
||||||
|
notificationFrequency,
|
||||||
|
reminderFrequency,
|
||||||
|
onNotificationFrequencyChange,
|
||||||
|
onReminderFrequencyChange
|
||||||
|
}: NotificationSettingsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="notification-frequency">Notification Frequency</Label>
|
||||||
|
<Select value={notificationFrequency} onValueChange={onNotificationFrequencyChange}>
|
||||||
|
<SelectTrigger id="notification-frequency">
|
||||||
|
<SelectValue placeholder="Select frequency" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="Immediate">Immediate</SelectItem>
|
||||||
|
<SelectItem value="Hourly">Hourly</SelectItem>
|
||||||
|
<SelectItem value="Daily">Daily</SelectItem>
|
||||||
|
<SelectItem value="Weekly">Weekly</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="reminder-frequency">Reminder Frequency (hours)</Label>
|
||||||
|
<Input
|
||||||
|
id="reminder-frequency"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="168"
|
||||||
|
value={reminderFrequency}
|
||||||
|
onChange={(e) => onReminderFrequencyChange(parseInt(e.target.value) || 12)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
5
src/components/admin/NotificationConfig/index.ts
Normal file
5
src/components/admin/NotificationConfig/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { NotificationConfig } from './NotificationConfig';
|
||||||
|
export { NotificationChannels } from './NotificationChannels';
|
||||||
|
export { NotificationSettings } from './NotificationSettings';
|
||||||
|
export { EmailTemplateSection } from './EmailTemplateSection';
|
||||||
|
|
||||||
68
src/components/admin/SharingConfig/SharingConfig.tsx
Normal file
68
src/components/admin/SharingConfig/SharingConfig.tsx
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save } from 'lucide-react';
|
||||||
|
import { SharingPermissions } from './SharingPermissions';
|
||||||
|
import { SharingOptions } from './SharingOptions';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface SharingConfigData {
|
||||||
|
spectatorPermission: string;
|
||||||
|
linkSharingPermission: string;
|
||||||
|
requirePassword: boolean;
|
||||||
|
allowExternalSharing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SharingConfig() {
|
||||||
|
const [config, setConfig] = useState<SharingConfigData>({
|
||||||
|
spectatorPermission: 'Initiator & Approver',
|
||||||
|
linkSharingPermission: 'Admin & Initiator',
|
||||||
|
requirePassword: true,
|
||||||
|
allowExternalSharing: false
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
// TODO: Implement API call to save sharing configuration
|
||||||
|
console.log('Saving sharing configuration:', config);
|
||||||
|
toast.success('Sharing policy saved successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<SharingConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Workflow Sharing Policy</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Control who can add spectators and share workflow links
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<SharingPermissions
|
||||||
|
spectatorPermission={config.spectatorPermission}
|
||||||
|
linkSharingPermission={config.linkSharingPermission}
|
||||||
|
onSpectatorPermissionChange={(permission) => updateConfig({ spectatorPermission: permission })}
|
||||||
|
onLinkSharingPermissionChange={(permission) => updateConfig({ linkSharingPermission: permission })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<SharingOptions
|
||||||
|
requirePassword={config.requirePassword}
|
||||||
|
allowExternalSharing={config.allowExternalSharing}
|
||||||
|
onRequirePasswordChange={(enabled) => updateConfig({ requirePassword: enabled })}
|
||||||
|
onAllowExternalSharingChange={(enabled) => updateConfig({ allowExternalSharing: enabled })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button onClick={handleSave} className="bg-re-green hover:bg-re-green/90">
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save Sharing Policy
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
42
src/components/admin/SharingConfig/SharingOptions.tsx
Normal file
42
src/components/admin/SharingConfig/SharingOptions.tsx
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
|
|
||||||
|
interface SharingOptionsProps {
|
||||||
|
requirePassword: boolean;
|
||||||
|
allowExternalSharing: boolean;
|
||||||
|
onRequirePasswordChange: (enabled: boolean) => void;
|
||||||
|
onAllowExternalSharingChange: (enabled: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SharingOptions({
|
||||||
|
requirePassword,
|
||||||
|
allowExternalSharing,
|
||||||
|
onRequirePasswordChange,
|
||||||
|
onAllowExternalSharingChange
|
||||||
|
}: SharingOptionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Require Password for Shared Links</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Add password protection to workflow sharing links</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={requirePassword}
|
||||||
|
onCheckedChange={onRequirePasswordChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-4 bg-muted/50 rounded-lg">
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">Allow External Sharing</p>
|
||||||
|
<p className="text-sm text-muted-foreground">Enable sharing workflows with external users outside the organization</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={allowExternalSharing}
|
||||||
|
onCheckedChange={onAllowExternalSharingChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
63
src/components/admin/SharingConfig/SharingPermissions.tsx
Normal file
63
src/components/admin/SharingConfig/SharingPermissions.tsx
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface SharingPermissionsProps {
|
||||||
|
spectatorPermission: string;
|
||||||
|
linkSharingPermission: string;
|
||||||
|
onSpectatorPermissionChange: (permission: string) => void;
|
||||||
|
onLinkSharingPermissionChange: (permission: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissionOptions = [
|
||||||
|
'Admin Only',
|
||||||
|
'Initiator Only',
|
||||||
|
'Approver Only',
|
||||||
|
'Initiator & Approver',
|
||||||
|
'Admin & Initiator',
|
||||||
|
'Admin & Approver',
|
||||||
|
'All Roles'
|
||||||
|
];
|
||||||
|
|
||||||
|
export function SharingPermissions({
|
||||||
|
spectatorPermission,
|
||||||
|
linkSharingPermission,
|
||||||
|
onSpectatorPermissionChange,
|
||||||
|
onLinkSharingPermissionChange
|
||||||
|
}: SharingPermissionsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="spectator-permission">Spectator Addition Permission</Label>
|
||||||
|
<Select value={spectatorPermission} onValueChange={onSpectatorPermissionChange}>
|
||||||
|
<SelectTrigger id="spectator-permission">
|
||||||
|
<SelectValue placeholder="Select permission" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{permissionOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="link-sharing-permission">Link Sharing Permission</Label>
|
||||||
|
<Select value={linkSharingPermission} onValueChange={onLinkSharingPermissionChange}>
|
||||||
|
<SelectTrigger id="link-sharing-permission">
|
||||||
|
<SelectValue placeholder="Select permission" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{permissionOptions.map((option) => (
|
||||||
|
<SelectItem key={option} value={option}>
|
||||||
|
{option}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
4
src/components/admin/SharingConfig/index.ts
Normal file
4
src/components/admin/SharingConfig/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { SharingConfig } from './SharingConfig';
|
||||||
|
export { SharingPermissions } from './SharingPermissions';
|
||||||
|
export { SharingOptions } from './SharingOptions';
|
||||||
|
|
||||||
79
src/components/admin/TATConfig/EscalationSettings.tsx
Normal file
79
src/components/admin/TATConfig/EscalationSettings.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Slider } from '@/components/ui/slider';
|
||||||
|
import { Bell, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EscalationSettingsProps {
|
||||||
|
reminderThreshold1: number;
|
||||||
|
reminderThreshold2: number;
|
||||||
|
onReminderThreshold1Change: (threshold: number) => void;
|
||||||
|
onReminderThreshold2Change: (threshold: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EscalationSettings({
|
||||||
|
reminderThreshold1,
|
||||||
|
reminderThreshold2,
|
||||||
|
onReminderThreshold1Change,
|
||||||
|
onReminderThreshold2Change
|
||||||
|
}: EscalationSettingsProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bell className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">Auto-Reminder & Escalation</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Configure automatic reminder thresholds based on TAT percentage
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="reminder-threshold-1" className="text-sm font-medium">
|
||||||
|
First Reminder Threshold
|
||||||
|
</Label>
|
||||||
|
<span className="text-lg font-semibold text-re-green">{reminderThreshold1}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="reminder-threshold-1"
|
||||||
|
value={[reminderThreshold1]}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
onValueChange={([value]) => onReminderThreshold1Change(value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Bell className="w-3 h-3" />
|
||||||
|
Send first gentle reminder when {reminderThreshold1}% of TAT elapsed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label htmlFor="reminder-threshold-2" className="text-sm font-medium">
|
||||||
|
Second Reminder Threshold (Escalation)
|
||||||
|
</Label>
|
||||||
|
<span className="text-lg font-semibold text-re-green">{reminderThreshold2}%</span>
|
||||||
|
</div>
|
||||||
|
<Slider
|
||||||
|
id="reminder-threshold-2"
|
||||||
|
value={[reminderThreshold2]}
|
||||||
|
min={1}
|
||||||
|
max={100}
|
||||||
|
step={1}
|
||||||
|
onValueChange={([value]) => onReminderThreshold2Change(value)}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Send escalation warning when {reminderThreshold2}% of TAT elapsed
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
src/components/admin/TATConfig/PriorityTATSettings.tsx
Normal file
73
src/components/admin/TATConfig/PriorityTATSettings.tsx
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Zap, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface PriorityTATSettingsProps {
|
||||||
|
expressHours: number;
|
||||||
|
standardHours: number;
|
||||||
|
onExpressChange: (hours: number) => void;
|
||||||
|
onStandardChange: (hours: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PriorityTATSettings({
|
||||||
|
expressHours,
|
||||||
|
standardHours,
|
||||||
|
onExpressChange,
|
||||||
|
onStandardChange
|
||||||
|
}: PriorityTATSettingsProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">Priority TAT Settings</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Set default turnaround time in hours for each priority level
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tat-express" className="text-sm font-medium">
|
||||||
|
Express Priority (hours)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tat-express"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="168"
|
||||||
|
value={expressHours}
|
||||||
|
onChange={(e) => onExpressChange(parseInt(e.target.value) || 24)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Critical/Emergency requests (24/7, includes weekends)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="tat-standard" className="text-sm font-medium">
|
||||||
|
Standard Priority (hours)
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="tat-standard"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="720"
|
||||||
|
value={standardHours}
|
||||||
|
onChange={(e) => onStandardChange(parseInt(e.target.value) || 72)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
Regular priority requests (working hours only, excludes weekends & holidays)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
180
src/components/admin/TATConfig/TATConfig.tsx
Normal file
180
src/components/admin/TATConfig/TATConfig.tsx
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import { Save, Loader2, Clock } from 'lucide-react';
|
||||||
|
import { PriorityTATSettings } from './PriorityTATSettings';
|
||||||
|
import { EscalationSettings } from './EscalationSettings';
|
||||||
|
import { WorkingHoursSettings } from './WorkingHoursSettings';
|
||||||
|
import { getAllConfigurations, updateConfiguration, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
interface TATConfigData {
|
||||||
|
expressHours: number;
|
||||||
|
standardHours: number;
|
||||||
|
reminderThreshold1: number;
|
||||||
|
reminderThreshold2: number;
|
||||||
|
workStartHour: number;
|
||||||
|
workEndHour: number;
|
||||||
|
workStartDay: number;
|
||||||
|
workEndDay: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TATConfig() {
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [config, setConfig] = useState<TATConfigData>({
|
||||||
|
expressHours: 24,
|
||||||
|
standardHours: 72,
|
||||||
|
reminderThreshold1: 50,
|
||||||
|
reminderThreshold2: 75,
|
||||||
|
workStartHour: 9,
|
||||||
|
workEndHour: 18,
|
||||||
|
workStartDay: 1,
|
||||||
|
workEndDay: 5
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfigurations();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const loadConfigurations = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const configs = await getAllConfigurations('TAT_SETTINGS');
|
||||||
|
|
||||||
|
// Map configuration values to state
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
setConfig({
|
||||||
|
expressHours: parseInt(configMap['DEFAULT_TAT_EXPRESS_HOURS'] || '24'),
|
||||||
|
standardHours: parseInt(configMap['DEFAULT_TAT_STANDARD_HOURS'] || '72'),
|
||||||
|
reminderThreshold1: parseInt(configMap['TAT_REMINDER_THRESHOLD_1'] || '50'),
|
||||||
|
reminderThreshold2: parseInt(configMap['TAT_REMINDER_THRESHOLD_2'] || '75'),
|
||||||
|
workStartHour: parseInt(configMap['WORK_START_HOUR'] || '9'),
|
||||||
|
workEndHour: parseInt(configMap['WORK_END_HOUR'] || '18'),
|
||||||
|
workStartDay: parseInt(configMap['WORK_START_DAY'] || '1'),
|
||||||
|
workEndDay: parseInt(configMap['WORK_END_DAY'] || '5')
|
||||||
|
});
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to load TAT configurations:', error);
|
||||||
|
toast.error('Failed to load TAT configurations');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
|
||||||
|
// Save all configurations
|
||||||
|
await Promise.all([
|
||||||
|
updateConfiguration('DEFAULT_TAT_EXPRESS_HOURS', config.expressHours.toString()),
|
||||||
|
updateConfiguration('DEFAULT_TAT_STANDARD_HOURS', config.standardHours.toString()),
|
||||||
|
updateConfiguration('TAT_REMINDER_THRESHOLD_1', config.reminderThreshold1.toString()),
|
||||||
|
updateConfiguration('TAT_REMINDER_THRESHOLD_2', config.reminderThreshold2.toString()),
|
||||||
|
updateConfiguration('WORK_START_HOUR', config.workStartHour.toString()),
|
||||||
|
updateConfiguration('WORK_END_HOUR', config.workEndHour.toString()),
|
||||||
|
updateConfiguration('WORK_START_DAY', config.workStartDay.toString()),
|
||||||
|
updateConfiguration('WORK_END_DAY', config.workEndDay.toString())
|
||||||
|
]);
|
||||||
|
|
||||||
|
toast.success('TAT configuration saved successfully');
|
||||||
|
|
||||||
|
// Reload to get updated values
|
||||||
|
await loadConfigurations();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to save TAT configuration:', error);
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to save TAT configuration');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateConfig = (updates: Partial<TATConfigData>) => {
|
||||||
|
setConfig(prev => ({ ...prev, ...updates }));
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardContent className="p-12 text-center">
|
||||||
|
<Loader2 className="w-8 h-8 animate-spin text-re-green mx-auto mb-4" />
|
||||||
|
<p className="text-sm text-gray-600">Loading TAT configuration...</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="shadow-lg border-0 rounded-md">
|
||||||
|
<CardHeader className="pb-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-3 bg-re-green rounded-md shadow-md">
|
||||||
|
<Clock className="h-5 w-5 text-white" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-lg font-semibold text-gray-900">Turn Around Time (TAT) Configuration</CardTitle>
|
||||||
|
<CardDescription className="text-sm text-gray-600">
|
||||||
|
Set default TAT hours per priority level, working hours, and configure auto-escalation thresholds
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
<PriorityTATSettings
|
||||||
|
expressHours={config.expressHours}
|
||||||
|
standardHours={config.standardHours}
|
||||||
|
onExpressChange={(hours) => updateConfig({ expressHours: hours })}
|
||||||
|
onStandardChange={(hours) => updateConfig({ standardHours: hours })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<WorkingHoursSettings
|
||||||
|
workStartHour={config.workStartHour}
|
||||||
|
workEndHour={config.workEndHour}
|
||||||
|
workStartDay={config.workStartDay}
|
||||||
|
workEndDay={config.workEndDay}
|
||||||
|
onWorkStartHourChange={(hour) => updateConfig({ workStartHour: hour })}
|
||||||
|
onWorkEndHourChange={(hour) => updateConfig({ workEndHour: hour })}
|
||||||
|
onWorkStartDayChange={(day) => updateConfig({ workStartDay: day })}
|
||||||
|
onWorkEndDayChange={(day) => updateConfig({ workEndDay: day })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<EscalationSettings
|
||||||
|
reminderThreshold1={config.reminderThreshold1}
|
||||||
|
reminderThreshold2={config.reminderThreshold2}
|
||||||
|
onReminderThreshold1Change={(threshold) => updateConfig({ reminderThreshold1: threshold })}
|
||||||
|
onReminderThreshold2Change={(threshold) => updateConfig({ reminderThreshold2: threshold })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex justify-end pt-4">
|
||||||
|
<Button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-re-green hover:bg-re-green/90 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Saving...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
Save TAT Settings
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
165
src/components/admin/TATConfig/WorkingHoursSettings.tsx
Normal file
165
src/components/admin/TATConfig/WorkingHoursSettings.tsx
Normal file
@ -0,0 +1,165 @@
|
|||||||
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { Calendar, Clock } from 'lucide-react';
|
||||||
|
|
||||||
|
interface WorkingHoursSettingsProps {
|
||||||
|
workStartHour: number;
|
||||||
|
workEndHour: number;
|
||||||
|
workStartDay: number;
|
||||||
|
workEndDay: number;
|
||||||
|
onWorkStartHourChange: (hour: number) => void;
|
||||||
|
onWorkEndHourChange: (hour: number) => void;
|
||||||
|
onWorkStartDayChange: (day: number) => void;
|
||||||
|
onWorkEndDayChange: (day: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DAYS_OF_WEEK = [
|
||||||
|
{ value: 1, label: 'Monday' },
|
||||||
|
{ value: 2, label: 'Tuesday' },
|
||||||
|
{ value: 3, label: 'Wednesday' },
|
||||||
|
{ value: 4, label: 'Thursday' },
|
||||||
|
{ value: 5, label: 'Friday' },
|
||||||
|
{ value: 6, label: 'Saturday' },
|
||||||
|
{ value: 7, label: 'Sunday' }
|
||||||
|
];
|
||||||
|
|
||||||
|
export function WorkingHoursSettings({
|
||||||
|
workStartHour,
|
||||||
|
workEndHour,
|
||||||
|
workStartDay,
|
||||||
|
workEndDay,
|
||||||
|
onWorkStartHourChange,
|
||||||
|
onWorkEndHourChange,
|
||||||
|
onWorkStartDayChange,
|
||||||
|
onWorkEndDayChange
|
||||||
|
}: WorkingHoursSettingsProps) {
|
||||||
|
return (
|
||||||
|
<Card className="border-0 shadow-sm">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-5 h-5 text-re-green" />
|
||||||
|
<CardTitle className="text-base font-semibold">Working Hours Configuration</CardTitle>
|
||||||
|
</div>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Define your organization's working hours and days for TAT calculations
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="work-start-hour" className="text-sm font-medium">
|
||||||
|
Working Day Start Hour
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="work-start-hour"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="23"
|
||||||
|
value={workStartHour}
|
||||||
|
onChange={(e) => onWorkStartHourChange(parseInt(e.target.value) || 9)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
24-hour format (0-23). Default: 9 AM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="work-end-hour" className="text-sm font-medium">
|
||||||
|
Working Day End Hour
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="work-end-hour"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="23"
|
||||||
|
value={workEndHour}
|
||||||
|
onChange={(e) => onWorkEndHourChange(parseInt(e.target.value) || 18)}
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
24-hour format (0-23). Default: 6 PM
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="work-start-day" className="text-sm font-medium">
|
||||||
|
Working Week Start Day
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={workStartDay.toString()}
|
||||||
|
onValueChange={(value) => onWorkStartDayChange(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="work-start-day"
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select start day" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DAYS_OF_WEEK.map((day) => (
|
||||||
|
<SelectItem key={day.value} value={day.value.toString()}>
|
||||||
|
{day.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Day when the working week starts (1=Monday, 7=Sunday)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="work-end-day" className="text-sm font-medium">
|
||||||
|
Working Week End Day
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={workEndDay.toString()}
|
||||||
|
onValueChange={(value) => onWorkEndDayChange(parseInt(value))}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="work-end-day"
|
||||||
|
className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
>
|
||||||
|
<SelectValue placeholder="Select end day" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{DAYS_OF_WEEK.map((day) => (
|
||||||
|
<SelectItem key={day.value} value={day.value.toString()}>
|
||||||
|
{day.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Day when the working week ends (1=Monday, 7=Sunday)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-md">
|
||||||
|
<p className="text-sm text-blue-900">
|
||||||
|
<strong>Current Configuration:</strong> Working hours are from {workStartHour}:00 to {workEndHour}:00,
|
||||||
|
{workStartDay === workEndDay
|
||||||
|
? ` ${DAYS_OF_WEEK.find(d => d.value === workStartDay)?.label} only`
|
||||||
|
: ` ${DAYS_OF_WEEK.find(d => d.value === workStartDay)?.label} to ${DAYS_OF_WEEK.find(d => d.value === workEndDay)?.label}`
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
5
src/components/admin/TATConfig/index.ts
Normal file
5
src/components/admin/TATConfig/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
export { TATConfig } from './TATConfig';
|
||||||
|
export { PriorityTATSettings } from './PriorityTATSettings';
|
||||||
|
export { EscalationSettings } from './EscalationSettings';
|
||||||
|
export { WorkingHoursSettings } from './WorkingHoursSettings';
|
||||||
|
|
||||||
718
src/components/admin/UserManagement/UserManagement.tsx
Normal file
718
src/components/admin/UserManagement/UserManagement.tsx
Normal file
@ -0,0 +1,718 @@
|
|||||||
|
import { useState, useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import {
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
Users,
|
||||||
|
Shield,
|
||||||
|
UserCog,
|
||||||
|
Loader2,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
Crown,
|
||||||
|
User as UserIcon,
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Power
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { userApi } from '@/services/userApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { UserTable } from './UserTable';
|
||||||
|
import { UserStatsCards } from './UserStatsCards';
|
||||||
|
|
||||||
|
// Simple debounce function
|
||||||
|
function debounce<T extends (...args: any[]) => any>(
|
||||||
|
func: T,
|
||||||
|
wait: number
|
||||||
|
): (...args: Parameters<T>) => void {
|
||||||
|
let timeout: NodeJS.Timeout | null = null;
|
||||||
|
return function executedFunction(...args: Parameters<T>) {
|
||||||
|
const later = () => {
|
||||||
|
timeout = null;
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
if (timeout) clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OktaUser {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
firstName?: string;
|
||||||
|
lastName?: string;
|
||||||
|
displayName?: string;
|
||||||
|
department?: string;
|
||||||
|
designation?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
userId: string;
|
||||||
|
email: string;
|
||||||
|
displayName: string;
|
||||||
|
role: 'USER' | 'MANAGEMENT' | 'ADMIN';
|
||||||
|
department?: string;
|
||||||
|
designation?: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserManagement() {
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [searchResults, setSearchResults] = useState<OktaUser[]>([]);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [selectedUser, setSelectedUser] = useState<OktaUser | null>(null);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<'USER' | 'MANAGEMENT' | 'ADMIN'>('USER');
|
||||||
|
const [updating, setUpdating] = useState(false);
|
||||||
|
const [fetchingRole, setFetchingRole] = useState(false);
|
||||||
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
|
||||||
|
// Users list with filtering and pagination
|
||||||
|
const [users, setUsers] = useState<User[]>([]);
|
||||||
|
const [loadingUsers, setLoadingUsers] = useState(false);
|
||||||
|
const [roleStats, setRoleStats] = useState({ admins: 0, management: 0, users: 0, total: 0, active: 0, inactive: 0 });
|
||||||
|
|
||||||
|
// Pagination and filtering
|
||||||
|
const [roleFilter, setRoleFilter] = useState<'ELEVATED' | 'ALL' | 'ADMIN' | 'MANAGEMENT' | 'USER'>('ELEVATED');
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [totalPages, setTotalPages] = useState(1);
|
||||||
|
const [totalUsers, setTotalUsers] = useState(0);
|
||||||
|
const limit = 10;
|
||||||
|
|
||||||
|
// Refs for search container
|
||||||
|
const searchContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Search users from Okta
|
||||||
|
const searchUsers = useCallback(
|
||||||
|
debounce(async (query: string) => {
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
setSearchResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
const response = await userApi.searchUsers(query, 20);
|
||||||
|
const users = response.data?.data || [];
|
||||||
|
setSearchResults(users);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Search failed:', error);
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: error.response?.data?.message || 'Failed to search users'
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
}, 300),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle search input
|
||||||
|
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const query = e.target.value;
|
||||||
|
setSearchQuery(query);
|
||||||
|
searchUsers(query);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch user's current role
|
||||||
|
const fetchUserRole = async (email: string): Promise<'USER' | 'MANAGEMENT' | 'ADMIN' | null> => {
|
||||||
|
try {
|
||||||
|
// First check if user exists in current users list
|
||||||
|
const existingUser = users.find(u => u.email.toLowerCase() === email.toLowerCase());
|
||||||
|
if (existingUser) {
|
||||||
|
return existingUser.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not found, try to fetch from backend by checking all users
|
||||||
|
// We'll search with a broader filter to find the user
|
||||||
|
const response = await userApi.getUsersByRole('ALL', 1, 1000);
|
||||||
|
const allUsers = response.data?.data?.users || [];
|
||||||
|
const foundUser = allUsers.find((u: any) =>
|
||||||
|
u.email?.toLowerCase() === email.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (foundUser && foundUser.role) {
|
||||||
|
return foundUser.role as 'USER' | 'MANAGEMENT' | 'ADMIN';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null; // User not found in system, no role assigned
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user role:', error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Select user from search results
|
||||||
|
const handleSelectUser = async (user: OktaUser) => {
|
||||||
|
setSelectedUser(user);
|
||||||
|
setSearchQuery(user.email);
|
||||||
|
setSearchResults([]);
|
||||||
|
setFetchingRole(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch and set the user's current role if they have one
|
||||||
|
const currentRole = await fetchUserRole(user.email);
|
||||||
|
if (currentRole) {
|
||||||
|
setSelectedRole(currentRole);
|
||||||
|
} else {
|
||||||
|
// Default to USER if no role found
|
||||||
|
setSelectedRole('USER');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch user role:', error);
|
||||||
|
setSelectedRole('USER'); // Default on error
|
||||||
|
} finally {
|
||||||
|
setFetchingRole(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Assign role to user
|
||||||
|
const handleAssignRole = async () => {
|
||||||
|
if (!selectedUser || !selectedRole) {
|
||||||
|
setMessage({ type: 'error', text: 'Please select a user and role' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setUpdating(true);
|
||||||
|
setMessage(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await userApi.assignRole(selectedUser.email, selectedRole);
|
||||||
|
|
||||||
|
setMessage({
|
||||||
|
type: 'success',
|
||||||
|
text: `Successfully assigned ${selectedRole} role to ${selectedUser.displayName || selectedUser.email}`
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setSelectedUser(null);
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedRole('USER');
|
||||||
|
|
||||||
|
// Refresh the users list
|
||||||
|
await fetchUsers();
|
||||||
|
await fetchRoleStatistics();
|
||||||
|
|
||||||
|
toast.success(`Role assigned successfully`);
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Role assignment failed:', error);
|
||||||
|
const errorMsg = error.response?.data?.error || 'Failed to assign role';
|
||||||
|
setMessage({
|
||||||
|
type: 'error',
|
||||||
|
text: errorMsg
|
||||||
|
});
|
||||||
|
toast.error(errorMsg);
|
||||||
|
} finally {
|
||||||
|
setUpdating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch users with filtering and pagination
|
||||||
|
const fetchUsers = async (page: number = currentPage) => {
|
||||||
|
setLoadingUsers(true);
|
||||||
|
try {
|
||||||
|
const response = await userApi.getUsersByRole(roleFilter, page, limit);
|
||||||
|
|
||||||
|
const usersData = response.data?.data?.users || [];
|
||||||
|
const paginationData = response.data?.data?.pagination;
|
||||||
|
const summaryData = response.data?.data?.summary;
|
||||||
|
|
||||||
|
setUsers(usersData.map((u: any) => ({
|
||||||
|
userId: u.userId,
|
||||||
|
email: u.email,
|
||||||
|
displayName: u.displayName || u.email,
|
||||||
|
role: u.role || 'USER',
|
||||||
|
department: u.department,
|
||||||
|
designation: u.designation,
|
||||||
|
isActive: u.isActive !== false // Default to true if not specified
|
||||||
|
})));
|
||||||
|
|
||||||
|
if (paginationData) {
|
||||||
|
setCurrentPage(paginationData.currentPage);
|
||||||
|
setTotalPages(paginationData.totalPages);
|
||||||
|
setTotalUsers(paginationData.totalUsers);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update summary stats if available
|
||||||
|
if (summaryData) {
|
||||||
|
setRoleStats(prev => ({
|
||||||
|
...prev,
|
||||||
|
admins: summaryData.ADMIN || 0,
|
||||||
|
management: summaryData.MANAGEMENT || 0,
|
||||||
|
users: summaryData.USER || 0,
|
||||||
|
total: (summaryData.ADMIN || 0) + (summaryData.MANAGEMENT || 0) + (summaryData.USER || 0)
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch users:', error);
|
||||||
|
toast.error('Failed to load users');
|
||||||
|
} finally {
|
||||||
|
setLoadingUsers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fetch role statistics
|
||||||
|
const fetchRoleStatistics = async () => {
|
||||||
|
try {
|
||||||
|
const response = await userApi.getRoleStatistics();
|
||||||
|
const statsData = response.data?.data?.statistics || response.data?.statistics || [];
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
admins: parseInt(statsData.find((s: any) => s.role === 'ADMIN')?.count || '0'),
|
||||||
|
management: parseInt(statsData.find((s: any) => s.role === 'MANAGEMENT')?.count || '0'),
|
||||||
|
users: parseInt(statsData.find((s: any) => s.role === 'USER')?.count || '0')
|
||||||
|
};
|
||||||
|
|
||||||
|
setRoleStats(prev => ({
|
||||||
|
...prev,
|
||||||
|
...stats,
|
||||||
|
total: stats.admins + stats.management + stats.users,
|
||||||
|
active: prev.active || stats.admins + stats.management + stats.users,
|
||||||
|
inactive: prev.inactive || 0
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch statistics:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load data on mount and when filter changes
|
||||||
|
useEffect(() => {
|
||||||
|
fetchUsers(1); // Reset to page 1 when filter changes
|
||||||
|
fetchRoleStatistics();
|
||||||
|
}, [roleFilter]);
|
||||||
|
|
||||||
|
// Handle filter change
|
||||||
|
const handleFilterChange = (value: string) => {
|
||||||
|
setRoleFilter(value as any);
|
||||||
|
setCurrentPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle page change
|
||||||
|
const handlePageChange = (page: number) => {
|
||||||
|
fetchUsers(page);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle edit user role
|
||||||
|
const handleEditUser = async (userId: string, newRole: 'USER' | 'MANAGEMENT' | 'ADMIN') => {
|
||||||
|
try {
|
||||||
|
await userApi.updateUserRole(userId, newRole);
|
||||||
|
toast.success('User role updated successfully');
|
||||||
|
await fetchUsers();
|
||||||
|
await fetchRoleStatistics();
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('Failed to update user role:', error);
|
||||||
|
toast.error(error.response?.data?.error || 'Failed to update user role');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle toggle user status (placeholder - needs backend support)
|
||||||
|
const handleToggleUserStatus = async (userId: string) => {
|
||||||
|
const user = users.find(u => u.userId === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
// TODO: Implement backend API for toggling user status
|
||||||
|
toast.info('User status toggle functionality coming soon');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle delete user (placeholder - needs backend support)
|
||||||
|
const handleDeleteUser = async (userId: string) => {
|
||||||
|
const user = users.find(u => u.userId === userId);
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
|
if (user.role === 'ADMIN') {
|
||||||
|
toast.error('Cannot delete admin user');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Implement backend API for deleting users
|
||||||
|
toast.info('User deletion functionality coming soon');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle click outside to close search results
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (searchContainerRef.current && !searchContainerRef.current.contains(event.target as Node)) {
|
||||||
|
setSearchResults([]);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (searchResults.length > 0) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [searchResults]);
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'ADMIN':
|
||||||
|
return 'bg-yellow-400 text-slate-900';
|
||||||
|
case 'MANAGEMENT':
|
||||||
|
return 'bg-blue-400 text-slate-900';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-400 text-white';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getRoleIcon = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'ADMIN':
|
||||||
|
return <Crown className="w-5 h-5" />;
|
||||||
|
case 'MANAGEMENT':
|
||||||
|
return <Users className="w-5 h-5" />;
|
||||||
|
default:
|
||||||
|
return <UserIcon className="w-5 h-5" />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Calculate stats for UserStatsCards
|
||||||
|
const stats = {
|
||||||
|
total: roleStats.total,
|
||||||
|
active: roleStats.active,
|
||||||
|
inactive: roleStats.inactive,
|
||||||
|
admins: roleStats.admins
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Statistics Cards */}
|
||||||
|
<UserStatsCards stats={stats} />
|
||||||
|
|
||||||
|
{/* Assign Role Section */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>Assign User Role</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Search for a user in Okta and assign them a role
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button onClick={handleAssignRole} disabled={!selectedUser || updating} className="bg-re-green hover:bg-re-green/90">
|
||||||
|
{updating ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||||
|
Assigning...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
Assign Role
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="space-y-2" ref={searchContainerRef}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
placeholder="Type name or email address..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
className="pl-10 border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20"
|
||||||
|
/>
|
||||||
|
{searching && (
|
||||||
|
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-re-green animate-spin" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">Start typing to search across all Okta users</p>
|
||||||
|
|
||||||
|
{/* Search Results Dropdown */}
|
||||||
|
{searchResults.length > 0 && (
|
||||||
|
<div className="border rounded-lg shadow-lg bg-white max-h-60 overflow-y-auto">
|
||||||
|
<div className="sticky top-0 bg-muted px-4 py-2 border-b">
|
||||||
|
<p className="text-xs font-semibold text-muted-foreground">
|
||||||
|
{searchResults.length} user{searchResults.length > 1 ? 's' : ''} found
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-3">
|
||||||
|
{searchResults.map((user) => (
|
||||||
|
<button
|
||||||
|
key={user.userId}
|
||||||
|
onClick={() => handleSelectUser(user)}
|
||||||
|
className="w-full text-left p-3 hover:bg-muted rounded-lg transition-colors mb-1 last:mb-0"
|
||||||
|
>
|
||||||
|
<p className="font-medium text-gray-900">{user.displayName || user.email}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{user.email}</p>
|
||||||
|
{user.department && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{user.department}{user.designation ? ` • ${user.designation}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected User */}
|
||||||
|
{selectedUser && (
|
||||||
|
<div className="border-2 border-re-green/20 bg-re-green/5 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-re-green flex items-center justify-center text-white font-bold shadow-md">
|
||||||
|
{(selectedUser.displayName || selectedUser.email).charAt(0).toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">
|
||||||
|
{selectedUser.displayName || selectedUser.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{selectedUser.email}</p>
|
||||||
|
{selectedUser.department && (
|
||||||
|
<p className="text-xs text-muted-foreground mt-1">
|
||||||
|
{selectedUser.department}{selectedUser.designation ? ` • ${selectedUser.designation}` : ''}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{fetchingRole && (
|
||||||
|
<p className="text-xs text-re-green mt-1 flex items-center gap-1">
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
Checking current role...
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedUser(null);
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedRole('USER');
|
||||||
|
setFetchingRole(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Role Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">Select Role</label>
|
||||||
|
<Select value={selectedRole} onValueChange={(value: any) => setSelectedRole(value)} disabled={fetchingRole}>
|
||||||
|
<SelectTrigger className="border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20">
|
||||||
|
<SelectValue placeholder={fetchingRole ? "Loading current role..." : "Select role"} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent className="rounded-lg">
|
||||||
|
<SelectItem value="USER" className="p-3 rounded-lg my-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserIcon className="w-4 h-4 text-gray-600" />
|
||||||
|
<span>User - Regular access</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MANAGEMENT" className="p-3 rounded-lg my-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-blue-600" />
|
||||||
|
<span>Management - Read all data</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ADMIN" className="p-3 rounded-lg my-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crown className="w-4 h-4 text-yellow-600" />
|
||||||
|
<span>Administrator - Full access</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message */}
|
||||||
|
{message && (
|
||||||
|
<div className={`border-2 rounded-lg p-4 ${
|
||||||
|
message.type === 'success'
|
||||||
|
? 'border-green-200 bg-green-50'
|
||||||
|
: 'border-red-200 bg-red-50'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
{message.type === 'success' ? (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600 shrink-0 mt-0.5" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600 shrink-0 mt-0.5" />
|
||||||
|
)}
|
||||||
|
<p className={`text-sm ${message.type === 'success' ? 'text-green-800' : 'text-red-800'}`}>
|
||||||
|
{message.text}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Users List with Filter and Pagination */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Users className="w-5 h-5 text-re-green" />
|
||||||
|
User Management
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
View and manage user accounts and roles ({totalUsers} {roleFilter !== 'ALL' && roleFilter !== 'ELEVATED' ? roleFilter.toLowerCase() : ''} users)
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Select value={roleFilter} onValueChange={handleFilterChange}>
|
||||||
|
<SelectTrigger className="w-[200px] border-gray-200 focus:border-re-green focus:ring-2 focus:ring-re-green/20">
|
||||||
|
<SelectValue placeholder="Filter by role" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="ELEVATED">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Shield className="w-4 h-4 text-purple-600" />
|
||||||
|
<span>Elevated ({roleStats.admins + roleStats.management})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ADMIN">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Crown className="w-4 h-4 text-yellow-600" />
|
||||||
|
<span>Admins ({roleStats.admins})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="MANAGEMENT">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-blue-600" />
|
||||||
|
<span>Management ({roleStats.management})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="USER">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<UserIcon className="w-4 h-4 text-gray-600" />
|
||||||
|
<span>Users ({roleStats.users})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="ALL">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4 text-gray-600" />
|
||||||
|
<span>All Users ({roleStats.admins + roleStats.management + roleStats.users})</span>
|
||||||
|
</div>
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-6">
|
||||||
|
{loadingUsers ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-8">
|
||||||
|
<Loader2 className="w-6 h-6 animate-spin text-re-green mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">Loading users...</p>
|
||||||
|
</div>
|
||||||
|
) : users.length === 0 ? (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center mx-auto mb-3">
|
||||||
|
<Users className="w-6 h-6 text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium text-gray-700">No users found</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
|
{roleFilter === 'ELEVATED'
|
||||||
|
? 'Assign ADMIN or MANAGEMENT roles to see users here'
|
||||||
|
: 'No users match the selected filter'
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<UserTable
|
||||||
|
users={users.map(u => ({
|
||||||
|
id: u.userId,
|
||||||
|
name: u.displayName,
|
||||||
|
email: u.email,
|
||||||
|
role: u.role, // Keep as ADMIN, MANAGEMENT, or USER
|
||||||
|
department: u.department || 'N/A',
|
||||||
|
status: u.isActive ? 'active' : 'inactive'
|
||||||
|
}))}
|
||||||
|
onEdit={(userId) => {
|
||||||
|
const user = users.find(u => u.userId === userId);
|
||||||
|
if (user) {
|
||||||
|
// Open role selection dialog
|
||||||
|
const newRole = user.role === 'USER' ? 'MANAGEMENT' : user.role === 'MANAGEMENT' ? 'ADMIN' : 'USER';
|
||||||
|
handleEditUser(userId, newRole);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onToggleStatus={handleToggleUserStatus}
|
||||||
|
onDelete={handleDeleteUser}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Pagination Controls */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-between pt-4 border-t mt-4">
|
||||||
|
<div className="text-sm text-gray-600">
|
||||||
|
Showing {((currentPage - 1) * limit) + 1} to {Math.min(currentPage * limit, totalUsers)} of {totalUsers} users
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||||
|
let pageNum;
|
||||||
|
if (totalPages <= 5) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage <= 3) {
|
||||||
|
pageNum = i + 1;
|
||||||
|
} else if (currentPage >= totalPages - 2) {
|
||||||
|
pageNum = totalPages - 4 + i;
|
||||||
|
} else {
|
||||||
|
pageNum = currentPage - 2 + i;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={pageNum}
|
||||||
|
variant={currentPage === pageNum ? "default" : "outline"}
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(pageNum)}
|
||||||
|
className={`w-9 h-9 p-0 ${
|
||||||
|
currentPage === pageNum
|
||||||
|
? 'bg-re-green hover:bg-re-green/90'
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageNum}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handlePageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
24
src/components/admin/UserManagement/UserSearchBar.tsx
Normal file
24
src/components/admin/UserManagement/UserSearchBar.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
|
||||||
|
interface UserSearchBarProps {
|
||||||
|
searchQuery: string;
|
||||||
|
onSearchChange: (query: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserSearchBar({ searchQuery, onSearchChange }: UserSearchBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search users by name, email, or department..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
32
src/components/admin/UserManagement/UserStatsCards.tsx
Normal file
32
src/components/admin/UserManagement/UserStatsCards.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
interface UserStatsCardsProps {
|
||||||
|
stats: {
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
inactive: number;
|
||||||
|
admins: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserStatsCards({ stats }: UserStatsCardsProps) {
|
||||||
|
return (
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||||
|
<div className="p-4 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Total Users</p>
|
||||||
|
<p className="text-2xl font-semibold">{stats.total}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-green-50 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Active</p>
|
||||||
|
<p className="text-2xl font-semibold text-green-600">{stats.active}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-red-50 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Inactive</p>
|
||||||
|
<p className="text-2xl font-semibold text-red-600">{stats.inactive}</p>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 bg-purple-50 rounded-lg">
|
||||||
|
<p className="text-sm text-muted-foreground">Admins</p>
|
||||||
|
<p className="text-2xl font-semibold text-purple-600">{stats.admins}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
131
src/components/admin/UserManagement/UserTable.tsx
Normal file
131
src/components/admin/UserManagement/UserTable.tsx
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
|
import { SquarePen, Power, Trash2, CircleCheck } from 'lucide-react';
|
||||||
|
|
||||||
|
interface TableUser {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
role: 'ADMIN' | 'MANAGEMENT' | 'USER';
|
||||||
|
department: string;
|
||||||
|
status: 'active' | 'inactive';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserTableProps {
|
||||||
|
users: TableUser[];
|
||||||
|
onEdit: (userId: string) => void;
|
||||||
|
onToggleStatus: (userId: string) => void;
|
||||||
|
onDelete: (userId: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: string) => {
|
||||||
|
switch (role.toUpperCase()) {
|
||||||
|
case 'ADMIN':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||||
|
case 'MANAGEMENT':
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
case 'USER':
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getUserInitials = (name: string) => {
|
||||||
|
return name
|
||||||
|
.split(' ')
|
||||||
|
.map(n => n[0])
|
||||||
|
.join('')
|
||||||
|
.toUpperCase()
|
||||||
|
.substring(0, 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function UserTable({ users, onEdit, onToggleStatus, onDelete }: UserTableProps) {
|
||||||
|
return (
|
||||||
|
<div className="border rounded-lg overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>User</TableHead>
|
||||||
|
<TableHead>Role</TableHead>
|
||||||
|
<TableHead>Department</TableHead>
|
||||||
|
<TableHead>Status</TableHead>
|
||||||
|
<TableHead className="text-right">Actions</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{users.length === 0 ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={5} className="text-center py-8 text-muted-foreground">
|
||||||
|
No users found
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
) : (
|
||||||
|
users.map((user) => (
|
||||||
|
<TableRow key={user.id} className="hover:bg-muted/50">
|
||||||
|
<TableCell>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Avatar className="size-10">
|
||||||
|
<AvatarFallback className="bg-re-green text-white">
|
||||||
|
{getUserInitials(user.name)}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{user.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={getRoleBadgeColor(user.role)}>
|
||||||
|
{user.role}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{user.department}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className="bg-green-100 text-green-800 border-green-200">
|
||||||
|
<CircleCheck className="w-3 h-3 mr-1" />
|
||||||
|
{user.status}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit(user.id)}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<SquarePen className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onToggleStatus(user.id)}
|
||||||
|
disabled={user.role.toUpperCase() === 'ADMIN'}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
<Power className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete(user.id)}
|
||||||
|
disabled={user.role.toUpperCase() === 'ADMIN'}
|
||||||
|
className="h-8 text-destructive hover:text-destructive"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
6
src/components/admin/UserManagement/index.ts
Normal file
6
src/components/admin/UserManagement/index.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export { UserManagement } from './UserManagement';
|
||||||
|
export type { User } from './UserManagement';
|
||||||
|
export { UserSearchBar } from './UserSearchBar';
|
||||||
|
export { UserStatsCards } from './UserStatsCards';
|
||||||
|
export { UserTable } from './UserTable';
|
||||||
|
|
||||||
@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose } from 'lucide-react';
|
import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, Activity, Shield } from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -62,6 +62,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
|
|||||||
{ id: 'my-requests', label: 'My Requests', icon: User },
|
{ id: 'my-requests', label: 'My Requests', icon: User },
|
||||||
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
|
{ id: 'open-requests', label: 'Open Requests', icon: FileText },
|
||||||
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
||||||
|
{ id: 'admin', label: 'Admin', icon: Shield },
|
||||||
];
|
];
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
const toggleSidebar = () => {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Clock } from 'lucide-react';
|
import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react';
|
||||||
|
|
||||||
export interface SLAData {
|
export interface SLAData {
|
||||||
status: 'normal' | 'approaching' | 'critical' | 'breached';
|
status: 'normal' | 'approaching' | 'critical' | 'breached';
|
||||||
@ -27,11 +27,14 @@ export function SLAProgressBar({
|
|||||||
if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
|
if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
|
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
|
||||||
<Clock className="h-4 w-4 text-gray-500" />
|
{requestStatus === 'closed' ? <Lock className="h-4 w-4 text-gray-600" /> :
|
||||||
|
requestStatus === 'approved' ? <CheckCircle className="h-4 w-4 text-green-600" /> :
|
||||||
|
requestStatus === 'rejected' ? <XCircle className="h-4 w-4 text-red-600" /> :
|
||||||
|
<Clock className="h-4 w-4 text-gray-500" />}
|
||||||
<span className="text-sm font-medium text-gray-700">
|
<span className="text-sm font-medium text-gray-700">
|
||||||
{requestStatus === 'closed' ? '🔒 Request Closed' :
|
{requestStatus === 'closed' ? 'Request Closed' :
|
||||||
requestStatus === 'approved' ? '✅ Request Approved' :
|
requestStatus === 'approved' ? 'Request Approved' :
|
||||||
requestStatus === 'rejected' ? '❌ Request Rejected' : 'SLA Not Available'}
|
requestStatus === 'rejected' ? 'Request Rejected' : 'SLA Not Available'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -91,13 +94,15 @@ export function SLAProgressBar({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{sla.status === 'critical' && (
|
{sla.status === 'critical' && (
|
||||||
<p className="text-xs text-orange-600 font-semibold mt-1" data-testid={`${testId}-warning-critical`}>
|
<p className="text-xs text-orange-600 font-semibold mt-1 flex items-center gap-1.5" data-testid={`${testId}-warning-critical`}>
|
||||||
⚠️ Approaching Deadline
|
<AlertTriangle className="h-3.5 w-3.5" />
|
||||||
|
Approaching Deadline
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{sla.status === 'breached' && (
|
{sla.status === 'breached' && (
|
||||||
<p className="text-xs text-red-600 font-semibold mt-1" data-testid={`${testId}-warning-breached`}>
|
<p className="text-xs text-red-600 font-semibold mt-1 flex items-center gap-1.5" data-testid={`${testId}-warning-breached`}>
|
||||||
🔴 URGENT - Deadline Passed
|
<AlertOctagon className="h-3.5 w-3.5" />
|
||||||
|
URGENT - Deadline Passed
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -8,9 +8,10 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
|||||||
type={type}
|
type={type}
|
||||||
data-slot="input"
|
data-slot="input"
|
||||||
className={cn(
|
className={cn(
|
||||||
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border border-gray-400 bg-white px-3 py-1 text-base text-gray-900 transition-all outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
"focus-visible:ring-[3px]",
|
"focus-visible:border-re-light-green focus-visible:ring-0 focus-visible:outline-none",
|
||||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
"hover:border-gray-500",
|
||||||
|
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -41,7 +41,10 @@ function SelectTrigger({
|
|||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"border-gray-400 data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground bg-white text-gray-900 flex w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
"hover:border-gray-500",
|
||||||
|
"focus-visible:border-re-light-green focus-visible:ring-0 focus-visible:outline-none",
|
||||||
|
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -7,7 +7,10 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
|||||||
<textarea
|
<textarea
|
||||||
data-slot="textarea"
|
data-slot="textarea"
|
||||||
className={cn(
|
className={cn(
|
||||||
"resize-none border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
"resize-none border-gray-400 placeholder:text-muted-foreground bg-white text-gray-900 flex field-sizing-content min-h-16 w-full rounded-md border px-3 py-2 text-base transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||||
|
"hover:border-gray-500",
|
||||||
|
"focus-visible:border-re-light-green focus-visible:ring-0 focus-visible:outline-none",
|
||||||
|
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
|
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
|
||||||
|
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@ -19,21 +22,18 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Download,
|
Download,
|
||||||
Eye,
|
Eye,
|
||||||
MoreHorizontal,
|
|
||||||
MessageSquare,
|
MessageSquare,
|
||||||
Clock,
|
Clock,
|
||||||
Search,
|
Search,
|
||||||
Hash,
|
|
||||||
AtSign,
|
AtSign,
|
||||||
Archive,
|
|
||||||
Plus,
|
Plus,
|
||||||
Activity,
|
Activity,
|
||||||
Bell,
|
|
||||||
Flag,
|
Flag,
|
||||||
X,
|
X,
|
||||||
FileSpreadsheet,
|
FileSpreadsheet,
|
||||||
Image,
|
Image,
|
||||||
UserPlus
|
UserPlus,
|
||||||
|
AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
interface Message {
|
interface Message {
|
||||||
@ -148,6 +148,24 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
const socketRef = useRef<any>(null);
|
const socketRef = useRef<any>(null);
|
||||||
const participantsLoadedRef = useRef(false);
|
const participantsLoadedRef = useRef(false);
|
||||||
|
|
||||||
|
// Document policy state
|
||||||
|
const [documentPolicy, setDocumentPolicy] = useState<{
|
||||||
|
maxFileSizeMB: number;
|
||||||
|
allowedFileTypes: string[];
|
||||||
|
}>({
|
||||||
|
maxFileSizeMB: 10,
|
||||||
|
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document validation error modal
|
||||||
|
const [documentErrorModal, setDocumentErrorModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
errors: Array<{ fileName: string; reason: string }>;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
errors: []
|
||||||
|
});
|
||||||
|
|
||||||
console.log('[WorkNoteChat] Component render, participants loaded:', participantsLoadedRef.current);
|
console.log('[WorkNoteChat] Component render, participants loaded:', participantsLoadedRef.current);
|
||||||
|
|
||||||
// Get request info (from props, all data comes from backend now)
|
// Get request info (from props, all data comes from backend now)
|
||||||
@ -384,6 +402,33 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Fetch document policy on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDocumentPolicy = async () => {
|
||||||
|
try {
|
||||||
|
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10');
|
||||||
|
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
|
||||||
|
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
|
||||||
|
|
||||||
|
setDocumentPolicy({
|
||||||
|
maxFileSizeMB,
|
||||||
|
allowedFileTypes
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load document policy:', error);
|
||||||
|
// Use defaults if loading fails
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDocumentPolicy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Realtime updates via Socket.IO (standalone usage OR when embedded in RequestDetail)
|
// Realtime updates via Socket.IO (standalone usage OR when embedded in RequestDetail)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!currentUserId) return; // Wait for currentUserId to be loaded
|
if (!currentUserId) return; // Wait for currentUserId to be loaded
|
||||||
@ -894,10 +939,72 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
}
|
}
|
||||||
}, [externalMessages, effectiveRequestId, participants]);
|
}, [externalMessages, effectiveRequestId, participants]);
|
||||||
|
|
||||||
|
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||||
|
// Check file size
|
||||||
|
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
|
||||||
|
if (file.size > maxSizeBytes) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extension
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
|
||||||
|
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files) {
|
if (!e.target.files || e.target.files.length === 0) return;
|
||||||
const filesArray = Array.from(e.target.files);
|
|
||||||
setSelectedFiles(prev => [...prev, ...filesArray]);
|
const filesArray = Array.from(e.target.files);
|
||||||
|
|
||||||
|
// Validate all files
|
||||||
|
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
|
||||||
|
filesArray.forEach(file => {
|
||||||
|
const validation = validateFile(file);
|
||||||
|
if (!validation.valid) {
|
||||||
|
validationErrors.push({
|
||||||
|
fileName: file.name,
|
||||||
|
reason: validation.reason || 'Unknown validation error'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there are validation errors, show modal
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
setDocumentErrorModal({
|
||||||
|
open: true,
|
||||||
|
errors: validationErrors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add only valid files
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
setSelectedFiles(prev => [...prev, ...validFiles]);
|
||||||
|
if (validFiles.length < filesArray.length) {
|
||||||
|
toast.warning(`${validFiles.length} of ${filesArray.length} file(s) were added. ${validationErrors.length} file(s) were rejected.`);
|
||||||
|
} else {
|
||||||
|
toast.success(`${validFiles.length} file(s) added successfully`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset file input
|
||||||
|
if (e.target) {
|
||||||
|
e.target.value = '';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -1371,7 +1478,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
onChange={handleFileSelect}
|
onChange={handleFileSelect}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
multiple
|
multiple
|
||||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt"
|
accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Selected Files Preview - Scrollable if many files */}
|
{/* Selected Files Preview - Scrollable if many files */}
|
||||||
@ -1556,15 +1663,6 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
>
|
>
|
||||||
<AtSign className="h-4 w-4" />
|
<AtSign className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-gray-500 h-8 w-8 p-0 hidden sm:flex hover:bg-blue-50 hover:text-blue-600 flex-shrink-0"
|
|
||||||
onClick={() => setMessage(prev => prev + '#')}
|
|
||||||
title="Add hashtag"
|
|
||||||
>
|
|
||||||
<Hash className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Character count and Send button */}
|
{/* Right side - Character count and Send button */}
|
||||||
@ -1601,8 +1699,8 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
lg:relative lg:translate-x-0 lg:shadow-none
|
lg:relative lg:translate-x-0 lg:shadow-none
|
||||||
${showSidebar ? 'fixed right-0 top-0 bottom-0 z-50 shadow-xl' : 'hidden lg:flex'}
|
${showSidebar ? 'fixed right-0 top-0 bottom-0 z-50 shadow-xl' : 'hidden lg:flex'}
|
||||||
`}>
|
`}>
|
||||||
<div className="p-4 sm:p-6 border-b border-gray-200">
|
<div className="p-4 sm:p-6 border-b border-gray-200 flex-1 flex flex-col min-h-0">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4 flex-shrink-0">
|
||||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900">Participants</h3>
|
<h3 className="text-base sm:text-lg font-semibold text-gray-900">Participants</h3>
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@ -1613,7 +1711,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
<X className="h-4 w-4" />
|
<X className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3 sm:space-y-4">
|
<div className="space-y-3 sm:space-y-4 overflow-y-auto flex-1 pr-2">
|
||||||
{participants.map((participant, index) => {
|
{participants.map((participant, index) => {
|
||||||
const isCurrentUser = (participant as any).userId === currentUserId;
|
const isCurrentUser = (participant as any).userId === currentUserId;
|
||||||
return (
|
return (
|
||||||
@ -1642,16 +1740,13 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
<p className="text-xs text-gray-400">{participant.lastSeen}</p>
|
<p className="text-xs text-gray-400">{participant.lastSeen}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
|
||||||
<MoreHorizontal className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4 sm:p-6">
|
<div className="p-4 sm:p-6 flex-shrink-0">
|
||||||
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
|
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{/* Only initiator can add approvers */}
|
{/* Only initiator can add approvers */}
|
||||||
@ -1724,6 +1819,48 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
|||||||
currentLevels={currentLevels}
|
currentLevels={currentLevels}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Document Validation Error Modal */}
|
||||||
|
<Dialog open={documentErrorModal.open} onOpenChange={(open) => setDocumentErrorModal(prev => ({ ...prev, open }))}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
Document Upload Policy Violation
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-gray-700">
|
||||||
|
The following file(s) could not be uploaded due to policy violations:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
{documentErrorModal.errors.map((error, index) => (
|
||||||
|
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
|
<p className="font-medium text-red-900 text-sm">{error.fileName}</p>
|
||||||
|
<p className="text-xs text-red-700 mt-1">{error.reason}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-blue-800 font-semibold mb-1">Document Policy:</p>
|
||||||
|
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
|
||||||
|
<li>Maximum file size: {documentPolicy.maxFileSizeMB}MB</li>
|
||||||
|
<li>Allowed file types: {documentPolicy.allowedFileTypes.join(', ')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setDocumentErrorModal({ open: false, errors: [] })}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -2,7 +2,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||||
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle } from 'lucide-react';
|
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon } from 'lucide-react';
|
||||||
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
||||||
|
|
||||||
export interface ApprovalStep {
|
export interface ApprovalStep {
|
||||||
@ -132,7 +132,10 @@ export function ApprovalStepCard({
|
|||||||
</button>
|
</button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="top" className="max-w-xs bg-orange-50 border-orange-200">
|
<TooltipContent side="top" className="max-w-xs bg-orange-50 border-orange-200">
|
||||||
<p className="text-xs font-semibold text-orange-900 mb-1">⏭️ Skip Reason:</p>
|
<p className="text-xs font-semibold text-orange-900 mb-1 flex items-center gap-1">
|
||||||
|
<FastForward className="w-3 h-3" />
|
||||||
|
Skip Reason:
|
||||||
|
</p>
|
||||||
<p className="text-xs text-gray-700">{step.skipReason}</p>
|
<p className="text-xs text-gray-700">{step.skipReason}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@ -197,7 +200,10 @@ export function ApprovalStepCard({
|
|||||||
{/* Conclusion Remark */}
|
{/* Conclusion Remark */}
|
||||||
{step.comment && (
|
{step.comment && (
|
||||||
<div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
|
<div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
|
||||||
<p className="text-xs font-semibold text-gray-700 mb-2">💬 Conclusion Remark:</p>
|
<p className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
|
||||||
|
<MessageSquare className="w-3.5 h-3.5 text-blue-600" />
|
||||||
|
Conclusion Remark:
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-700 italic leading-relaxed">{step.comment}</p>
|
<p className="text-sm text-gray-700 italic leading-relaxed">{step.comment}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -260,13 +266,15 @@ export function ApprovalStepCard({
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{approval.sla.status === 'breached' && (
|
{approval.sla.status === 'breached' && (
|
||||||
<p className="text-xs font-semibold text-center text-red-600">
|
<p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
|
||||||
🔴 Deadline Breached
|
<AlertOctagon className="w-4 h-4" />
|
||||||
|
Deadline Breached
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{approval.sla.status === 'critical' && (
|
{approval.sla.status === 'critical' && (
|
||||||
<p className="text-xs font-semibold text-center text-orange-600">
|
<p className="text-xs font-semibold text-center text-orange-600 flex items-center justify-center gap-1.5">
|
||||||
⚠️ Approaching Deadline
|
<AlertTriangle className="w-4 h-4" />
|
||||||
|
Approaching Deadline
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -278,7 +286,10 @@ export function ApprovalStepCard({
|
|||||||
{isWaiting && (
|
{isWaiting && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="bg-gray-100 border border-gray-300 rounded-lg p-3">
|
<div className="bg-gray-100 border border-gray-300 rounded-lg p-3">
|
||||||
<p className="text-xs text-gray-600 mb-1">⏸️ Awaiting Previous Approval</p>
|
<p className="text-xs text-gray-600 mb-1 flex items-center gap-1.5">
|
||||||
|
<PauseCircle className="w-3.5 h-3.5 text-gray-500" />
|
||||||
|
Awaiting Previous Approval
|
||||||
|
</p>
|
||||||
<p className="text-sm font-medium text-gray-700">Will be assigned after previous step</p>
|
<p className="text-sm font-medium text-gray-700">Will be assigned after previous step</p>
|
||||||
<p className="text-xs text-gray-500 mt-2">Allocated {tatHours} hours for approval</p>
|
<p className="text-xs text-gray-500 mt-2">Allocated {tatHours} hours for approval</p>
|
||||||
</div>
|
</div>
|
||||||
@ -288,7 +299,10 @@ export function ApprovalStepCard({
|
|||||||
{/* Rejected Status */}
|
{/* Rejected Status */}
|
||||||
{isRejected && step.comment && (
|
{isRejected && step.comment && (
|
||||||
<div className="mt-3 p-3 sm:p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
|
<div className="mt-3 p-3 sm:p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
|
||||||
<p className="text-xs font-semibold text-red-700 mb-2">❌ Rejection Reason:</p>
|
<p className="text-xs font-semibold text-red-700 mb-2 flex items-center gap-1.5">
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
Rejection Reason:
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-700 leading-relaxed">{step.comment}</p>
|
<p className="text-sm text-gray-700 leading-relaxed">{step.comment}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -296,7 +310,10 @@ export function ApprovalStepCard({
|
|||||||
{/* Skipped Status */}
|
{/* Skipped Status */}
|
||||||
{step.isSkipped && step.skipReason && (
|
{step.isSkipped && step.skipReason && (
|
||||||
<div className="mt-3 p-3 sm:p-4 bg-orange-50 border-l-4 border-orange-500 rounded-r-lg">
|
<div className="mt-3 p-3 sm:p-4 bg-orange-50 border-l-4 border-orange-500 rounded-r-lg">
|
||||||
<p className="text-xs font-semibold text-orange-700 mb-2">⏭️ Skip Reason:</p>
|
<p className="text-xs font-semibold text-orange-700 mb-2 flex items-center gap-1.5">
|
||||||
|
<FastForward className="w-3.5 h-3.5" />
|
||||||
|
Skip Reason:
|
||||||
|
</p>
|
||||||
<p className="text-sm text-gray-700 leading-relaxed">{step.skipReason}</p>
|
<p className="text-sm text-gray-700 leading-relaxed">{step.skipReason}</p>
|
||||||
{step.timestamp && (
|
{step.timestamp && (
|
||||||
<p className="text-xs text-gray-500 mt-2">Skipped on {formatDateTime(step.timestamp)}</p>
|
<p className="text-xs text-gray-500 mt-2">Skipped on {formatDateTime(step.timestamp)}</p>
|
||||||
@ -320,10 +337,16 @@ export function ApprovalStepCard({
|
|||||||
data-testid={`${testId}-tat-alert-${alertIndex}`}
|
data-testid={`${testId}-tat-alert-${alertIndex}`}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<div className="text-base sm:text-lg flex-shrink-0">
|
<div className="flex-shrink-0 mt-0.5">
|
||||||
{(alert.thresholdPercentage || 0) === 50 && '⏳'}
|
{(alert.thresholdPercentage || 0) === 50 && (
|
||||||
{(alert.thresholdPercentage || 0) === 75 && '⚠️'}
|
<Hourglass className="w-5 h-5 text-yellow-600" />
|
||||||
{(alert.thresholdPercentage || 0) === 100 && '⏰'}
|
)}
|
||||||
|
{(alert.thresholdPercentage || 0) === 75 && (
|
||||||
|
<AlertTriangle className="w-5 h-5 text-orange-600" />
|
||||||
|
)}
|
||||||
|
{(alert.thresholdPercentage || 0) === 100 && (
|
||||||
|
<AlertOctagon className="w-5 h-5 text-red-600" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 mb-1">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 mb-1">
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { uploadDocument } from '@/services/documentApi';
|
import { uploadDocument } from '@/services/documentApi';
|
||||||
|
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Custom Hook: useDocumentUpload
|
* Custom Hook: useDocumentUpload
|
||||||
@ -33,6 +35,83 @@ export function useDocumentUpload(
|
|||||||
fileSize?: number;
|
fileSize?: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
|
||||||
|
// Document policy state
|
||||||
|
const [documentPolicy, setDocumentPolicy] = useState<{
|
||||||
|
maxFileSizeMB: number;
|
||||||
|
allowedFileTypes: string[];
|
||||||
|
}>({
|
||||||
|
maxFileSizeMB: 10,
|
||||||
|
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document validation error state
|
||||||
|
const [documentError, setDocumentError] = useState<{
|
||||||
|
show: boolean;
|
||||||
|
errors: Array<{ fileName: string; reason: string }>;
|
||||||
|
}>({
|
||||||
|
show: false,
|
||||||
|
errors: []
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch document policy on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDocumentPolicy = async () => {
|
||||||
|
try {
|
||||||
|
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10');
|
||||||
|
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
|
||||||
|
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
|
||||||
|
|
||||||
|
setDocumentPolicy({
|
||||||
|
maxFileSizeMB,
|
||||||
|
allowedFileTypes
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load document policy:', error);
|
||||||
|
// Use defaults if loading fails
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDocumentPolicy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Function: validateFile
|
||||||
|
*
|
||||||
|
* Purpose: Validate file against document policy
|
||||||
|
*
|
||||||
|
* @param file - File to validate
|
||||||
|
* @returns Validation result with reason if invalid
|
||||||
|
*/
|
||||||
|
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||||
|
// Check file size
|
||||||
|
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
|
||||||
|
if (file.size > maxSizeBytes) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extension
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
|
||||||
|
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Function: handleDocumentUpload
|
* Function: handleDocumentUpload
|
||||||
*
|
*
|
||||||
@ -40,11 +119,12 @@ export function useDocumentUpload(
|
|||||||
*
|
*
|
||||||
* Process:
|
* Process:
|
||||||
* 1. Validate file selection
|
* 1. Validate file selection
|
||||||
* 2. Get request UUID (required for backend API)
|
* 2. Validate against document policy
|
||||||
* 3. Upload file to backend
|
* 3. Get request UUID (required for backend API)
|
||||||
* 4. Refresh request details to show new document
|
* 4. Upload file to backend
|
||||||
* 5. Clear file input for next upload
|
* 5. Refresh request details to show new document
|
||||||
* 6. Show success/error messages
|
* 6. Clear file input for next upload
|
||||||
|
* 7. Show success/error messages
|
||||||
*
|
*
|
||||||
* @param event - File input change event
|
* @param event - File input change event
|
||||||
*/
|
*/
|
||||||
@ -54,22 +134,51 @@ export function useDocumentUpload(
|
|||||||
// Validate: Check if file is selected
|
// Validate: Check if file is selected
|
||||||
if (!files || files.length === 0) return;
|
if (!files || files.length === 0) return;
|
||||||
|
|
||||||
|
const fileArray = Array.from(files);
|
||||||
|
|
||||||
|
// Validate all files against document policy
|
||||||
|
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
|
||||||
|
fileArray.forEach(file => {
|
||||||
|
const validation = validateFile(file);
|
||||||
|
if (!validation.valid) {
|
||||||
|
validationErrors.push({
|
||||||
|
fileName: file.name,
|
||||||
|
reason: validation.reason || 'Unknown validation error'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there are validation errors, show modal
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
setDocumentError({
|
||||||
|
show: true,
|
||||||
|
errors: validationErrors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no valid files, stop here
|
||||||
|
if (validFiles.length === 0) {
|
||||||
|
if (event.target) {
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setUploadingDocument(true);
|
setUploadingDocument(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const file = files[0];
|
// Upload only the first valid file (backend currently supports single file)
|
||||||
|
const file = validFiles[0];
|
||||||
// Validate: Ensure file exists
|
|
||||||
if (!file) {
|
|
||||||
alert('No file selected');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate: Ensure request ID is available
|
// Validate: Ensure request ID is available
|
||||||
// Note: Backend requires UUID, not request number
|
// Note: Backend requires UUID, not request number
|
||||||
const requestId = apiRequest?.requestId;
|
const requestId = apiRequest?.requestId;
|
||||||
if (!requestId) {
|
if (!requestId) {
|
||||||
alert('Request ID not found');
|
toast.error('Request ID not found');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -82,12 +191,16 @@ export function useDocumentUpload(
|
|||||||
await refreshDetails();
|
await refreshDetails();
|
||||||
|
|
||||||
// Success feedback
|
// Success feedback
|
||||||
alert('Document uploaded successfully');
|
if (validFiles.length < fileArray.length) {
|
||||||
|
toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`);
|
||||||
|
} else {
|
||||||
|
toast.success('Document uploaded successfully');
|
||||||
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[useDocumentUpload] Upload error:', error);
|
console.error('[useDocumentUpload] Upload error:', error);
|
||||||
|
|
||||||
// Error feedback with backend error message if available
|
// Error feedback with backend error message if available
|
||||||
alert(error?.response?.data?.error || 'Failed to upload document');
|
toast.error(error?.response?.data?.error || 'Failed to upload document');
|
||||||
} finally {
|
} finally {
|
||||||
setUploadingDocument(false);
|
setUploadingDocument(false);
|
||||||
|
|
||||||
@ -105,18 +218,14 @@ export function useDocumentUpload(
|
|||||||
*
|
*
|
||||||
* Process:
|
* Process:
|
||||||
* 1. Create temporary file input element
|
* 1. Create temporary file input element
|
||||||
* 2. Configure accepted file types
|
* 2. Configure accepted file types based on document policy
|
||||||
* 3. Attach upload handler
|
* 3. Attach upload handler
|
||||||
* 4. Trigger click to open file picker
|
* 4. Trigger click to open file picker
|
||||||
*
|
|
||||||
* Accepted formats:
|
|
||||||
* - Documents: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT
|
|
||||||
* - Images: JPG, JPEG, PNG, GIF
|
|
||||||
*/
|
*/
|
||||||
const triggerFileInput = () => {
|
const triggerFileInput = () => {
|
||||||
const input = document.createElement('input');
|
const input = document.createElement('input');
|
||||||
input.type = 'file';
|
input.type = 'file';
|
||||||
input.accept = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.gif';
|
input.accept = documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',');
|
||||||
input.onchange = handleDocumentUpload as any;
|
input.onchange = handleDocumentUpload as any;
|
||||||
input.click();
|
input.click();
|
||||||
};
|
};
|
||||||
@ -126,7 +235,10 @@ export function useDocumentUpload(
|
|||||||
handleDocumentUpload,
|
handleDocumentUpload,
|
||||||
triggerFileInput,
|
triggerFileInput,
|
||||||
previewDocument,
|
previewDocument,
|
||||||
setPreviewDocument
|
setPreviewDocument,
|
||||||
|
documentPolicy,
|
||||||
|
documentError,
|
||||||
|
setDocumentError
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
531
src/pages/Admin/Admin.tsx
Normal file
531
src/pages/Admin/Admin.tsx
Normal file
@ -0,0 +1,531 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
|
import { Separator } from '@/components/ui/separator';
|
||||||
|
import {
|
||||||
|
Shield,
|
||||||
|
ChartColumn,
|
||||||
|
TrendingUp,
|
||||||
|
Users,
|
||||||
|
Clock,
|
||||||
|
Bell,
|
||||||
|
FileText,
|
||||||
|
LayoutDashboard,
|
||||||
|
Brain,
|
||||||
|
Share2,
|
||||||
|
Activity,
|
||||||
|
Search,
|
||||||
|
ArrowUp,
|
||||||
|
ArrowDown,
|
||||||
|
CircleAlert
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { AnalyticsConfig } from '@/components/admin/AnalyticsConfig';
|
||||||
|
import { UserManagement } from '@/components/admin/UserManagement';
|
||||||
|
import { TATConfig } from '@/components/admin/TATConfig';
|
||||||
|
import { NotificationConfig } from '@/components/admin/NotificationConfig';
|
||||||
|
import { DocumentConfig } from '@/components/admin/DocumentConfig';
|
||||||
|
import { DashboardConfig } from '@/components/admin/DashboardConfig';
|
||||||
|
import { AIConfig } from '@/components/admin/AIConfig';
|
||||||
|
import { SharingConfig } from '@/components/admin/SharingConfig';
|
||||||
|
|
||||||
|
interface KPICard {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
value: string | number;
|
||||||
|
unit?: string;
|
||||||
|
change?: number;
|
||||||
|
changeType?: 'up' | 'down';
|
||||||
|
period: string;
|
||||||
|
visibleTo: string[];
|
||||||
|
category: string;
|
||||||
|
bgColor: string;
|
||||||
|
borderColor: string;
|
||||||
|
thresholdBreached?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KPICategory {
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
kpis: KPICard[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Admin() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [activeTab, setActiveTab] = useState('sharing');
|
||||||
|
|
||||||
|
// Sample KPI data organized by category
|
||||||
|
const kpiCategories: KPICategory[] = [
|
||||||
|
{
|
||||||
|
name: 'Request Volume & Status',
|
||||||
|
count: 4,
|
||||||
|
kpis: [
|
||||||
|
{
|
||||||
|
id: 'total-requests',
|
||||||
|
title: 'Total Requests Created',
|
||||||
|
description: 'Count of all workflow requests created in a selected period.',
|
||||||
|
value: 5,
|
||||||
|
change: 12,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'Request Volume & Status',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'open-requests',
|
||||||
|
title: 'Open Requests',
|
||||||
|
description: 'Number of workflows currently in progress with age.',
|
||||||
|
value: 3,
|
||||||
|
change: -5,
|
||||||
|
changeType: 'down',
|
||||||
|
period: 'Today',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'Request Volume & Status',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approved-requests',
|
||||||
|
title: 'Approved Requests',
|
||||||
|
description: 'Requests fully approved and closed.',
|
||||||
|
value: 1,
|
||||||
|
change: 8,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'Request Volume & Status',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'rejected-requests',
|
||||||
|
title: 'Rejected Requests',
|
||||||
|
description: 'Requests rejected at any approval stage.',
|
||||||
|
value: 1,
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'Request Volume & Status',
|
||||||
|
bgColor: 'bg-blue-50',
|
||||||
|
borderColor: 'border-blue-200'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'TAT Efficiency',
|
||||||
|
count: 3,
|
||||||
|
kpis: [
|
||||||
|
{
|
||||||
|
id: 'avg-tat-compliance',
|
||||||
|
title: 'Average TAT Compliance %',
|
||||||
|
description: '% of workflows completed within defined TAT vs breached ones at every level.',
|
||||||
|
value: 100,
|
||||||
|
unit: '%',
|
||||||
|
change: 3,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'TAT Efficiency',
|
||||||
|
bgColor: 'bg-green-50',
|
||||||
|
borderColor: 'border-green-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'avg-approval-cycle',
|
||||||
|
title: 'Avg Approval Cycle Time (Days)',
|
||||||
|
description: 'Average total time from creation to closure.',
|
||||||
|
value: 5.2,
|
||||||
|
unit: ' days',
|
||||||
|
change: -15,
|
||||||
|
changeType: 'down',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'TAT Efficiency',
|
||||||
|
bgColor: 'bg-green-50',
|
||||||
|
borderColor: 'border-green-200',
|
||||||
|
thresholdBreached: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delayed-workflows',
|
||||||
|
title: 'Delayed Workflows',
|
||||||
|
description: 'Requests currently breaching their TAT.',
|
||||||
|
value: 0,
|
||||||
|
change: -2,
|
||||||
|
changeType: 'down',
|
||||||
|
period: 'Today',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'TAT Efficiency',
|
||||||
|
bgColor: 'bg-green-50',
|
||||||
|
borderColor: 'border-green-200'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Approver Load',
|
||||||
|
count: 2,
|
||||||
|
kpis: [
|
||||||
|
{
|
||||||
|
id: 'pending-actions',
|
||||||
|
title: 'Pending Actions (My Queue)',
|
||||||
|
description: 'Requests currently awaiting user\'s approval with age.',
|
||||||
|
value: 3,
|
||||||
|
period: 'Today',
|
||||||
|
visibleTo: ['Management', 'Admin'],
|
||||||
|
category: 'Approver Load',
|
||||||
|
bgColor: 'bg-orange-50',
|
||||||
|
borderColor: 'border-orange-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'approvals-completed',
|
||||||
|
title: 'Approvals Completed (Today/Week)',
|
||||||
|
description: 'Count of actions taken within a time frame.',
|
||||||
|
value: 0,
|
||||||
|
change: 22,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Week',
|
||||||
|
visibleTo: ['Management', 'Admin'],
|
||||||
|
category: 'Approver Load',
|
||||||
|
bgColor: 'bg-orange-50',
|
||||||
|
borderColor: 'border-orange-200'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Engagement & Quality',
|
||||||
|
count: 2,
|
||||||
|
kpis: [
|
||||||
|
{
|
||||||
|
id: 'comments-worknotes',
|
||||||
|
title: 'Comments / Work Notes Added',
|
||||||
|
description: 'Measures collaboration activity.',
|
||||||
|
value: 8,
|
||||||
|
change: 18,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Week',
|
||||||
|
visibleTo: ['Management', 'Admin'],
|
||||||
|
category: 'Engagement & Quality',
|
||||||
|
bgColor: 'bg-purple-50',
|
||||||
|
borderColor: 'border-purple-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'attachments-uploaded',
|
||||||
|
title: 'Attachments Uploaded',
|
||||||
|
description: 'Number of documents added to workflows.',
|
||||||
|
value: 16,
|
||||||
|
change: 10,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Initiator', 'Management', 'Admin'],
|
||||||
|
category: 'Engagement & Quality',
|
||||||
|
bgColor: 'bg-purple-50',
|
||||||
|
borderColor: 'border-purple-200'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'AI & Closure Insights',
|
||||||
|
count: 2,
|
||||||
|
kpis: [
|
||||||
|
{
|
||||||
|
id: 'avg-conclusion-length',
|
||||||
|
title: 'Avg Conclusion Remark Length',
|
||||||
|
description: 'Indicates depth of closure remarks (optional).',
|
||||||
|
value: 85,
|
||||||
|
unit: ' chars',
|
||||||
|
change: 5,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Management', 'Admin'],
|
||||||
|
category: 'AI & Closure Insights',
|
||||||
|
bgColor: 'bg-pink-50',
|
||||||
|
borderColor: 'border-pink-200'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ai-summary-adoption',
|
||||||
|
title: 'AI Summary Adoption %',
|
||||||
|
description: 'How many closures used AI-generated text vs manual edits.',
|
||||||
|
value: 0,
|
||||||
|
unit: '%',
|
||||||
|
change: 25,
|
||||||
|
changeType: 'up',
|
||||||
|
period: 'Month',
|
||||||
|
visibleTo: ['Management', 'Admin'],
|
||||||
|
category: 'AI & Closure Insights',
|
||||||
|
bgColor: 'bg-pink-50',
|
||||||
|
borderColor: 'border-pink-200'
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
// Filter KPIs based on search
|
||||||
|
const filteredCategories = kpiCategories.map(category => ({
|
||||||
|
...category,
|
||||||
|
kpis: category.kpis.filter(kpi =>
|
||||||
|
kpi.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
kpi.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||||
|
kpi.category.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
)
|
||||||
|
})).filter(category => category.kpis.length > 0);
|
||||||
|
|
||||||
|
const totalActiveKPIs = kpiCategories.reduce((sum, cat) => sum + cat.count, 0);
|
||||||
|
|
||||||
|
const getRoleBadgeColor = (role: string) => {
|
||||||
|
switch (role) {
|
||||||
|
case 'Initiator':
|
||||||
|
return 'bg-blue-100 text-blue-800 border-blue-200';
|
||||||
|
case 'Management':
|
||||||
|
return 'bg-orange-100 text-orange-800 border-orange-200';
|
||||||
|
case 'Admin':
|
||||||
|
return 'bg-purple-100 text-purple-800 border-purple-200';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-re-black flex items-center gap-2">
|
||||||
|
<Shield className="w-8 h-8 text-re-green" />
|
||||||
|
Admin Control Panel
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Manage users, configure system settings, and control portal behavior
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-re-green/10 text-re-green border-re-green/20 px-4 py-2">
|
||||||
|
<Shield className="w-4 h-4 mr-2" />
|
||||||
|
Administrator: {user?.displayName || 'Admin User'}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="flex flex-col gap-2 space-y-4">
|
||||||
|
<TabsList className="text-muted-foreground items-center justify-center rounded-xl p-[3px] bg-muted grid w-full grid-cols-9 h-auto">
|
||||||
|
<TabsTrigger value="kpi" className="flex items-center gap-2">
|
||||||
|
<ChartColumn className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">KPI Config</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="analytics" className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Analytics</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="users" className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Users</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="tat" className="flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">TAT</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="notifications" className="flex items-center gap-2">
|
||||||
|
<Bell className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Notify</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="documents" className="flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Docs</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="dashboard" className="flex items-center gap-2">
|
||||||
|
<LayoutDashboard className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Dashboard</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="ai" className="flex items-center gap-2">
|
||||||
|
<Brain className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">AI</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="sharing" className="flex items-center gap-2">
|
||||||
|
<Share2 className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">Sharing</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* KPI Config Tab */}
|
||||||
|
<TabsContent value="kpi" className="flex-1 outline-none space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<CardTitle>KPI Configuration</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Configure which KPIs are enabled, visible to specific roles, and set alert thresholds
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-blue-50 text-blue-700">
|
||||||
|
{totalActiveKPIs} / {totalActiveKPIs} KPIs Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{/* Search */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder="Search KPIs by name, description, or category..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="pl-10"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category Counts */}
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
|
||||||
|
{kpiCategories.map((category) => (
|
||||||
|
<div key={category.name} className="p-4 bg-muted/50 rounded-lg">
|
||||||
|
<p className="text-xs text-muted-foreground">{category.name}</p>
|
||||||
|
<p className="text-2xl font-semibold">{category.count}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* KPI Cards by Category */}
|
||||||
|
{filteredCategories.map((category) => (
|
||||||
|
<div key={category.name} className="space-y-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5 text-re-green" />
|
||||||
|
<h3 className="font-semibold">{category.name}</h3>
|
||||||
|
<Badge variant="outline" className="ml-auto">
|
||||||
|
{category.kpis.length} Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||||||
|
{category.kpis.map((kpi) => (
|
||||||
|
<div
|
||||||
|
key={kpi.id}
|
||||||
|
className={`p-4 border rounded-lg ${kpi.bgColor} ${kpi.borderColor} transition-all hover:shadow-md ${
|
||||||
|
kpi.thresholdBreached ? 'ring-2 ring-orange-500' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div className="flex-1 space-y-3">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<h4 className="font-medium text-sm mb-1">{kpi.title}</h4>
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-1">
|
||||||
|
{kpi.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge className="bg-green-100 text-green-800 text-xs">
|
||||||
|
Active
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-end gap-3 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-baseline gap-1">
|
||||||
|
<span className="text-4xl font-bold text-re-black">
|
||||||
|
{kpi.value}
|
||||||
|
</span>
|
||||||
|
{kpi.unit && (
|
||||||
|
<span className="text-lg text-muted-foreground">
|
||||||
|
{kpi.unit}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{kpi.change !== undefined && (
|
||||||
|
<div
|
||||||
|
className={`flex items-center gap-1 text-xs px-2 py-1 rounded ${
|
||||||
|
kpi.changeType === 'up'
|
||||||
|
? 'bg-green-100 text-green-700'
|
||||||
|
: 'bg-red-100 text-red-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{kpi.changeType === 'up' ? (
|
||||||
|
<ArrowUp className="w-3 h-3" />
|
||||||
|
) : (
|
||||||
|
<ArrowDown className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
<span className="font-semibold">{Math.abs(kpi.change)}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 pt-2 border-t">
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5">
|
||||||
|
<span className="text-xs text-muted-foreground">Visible:</span>
|
||||||
|
{kpi.visibleTo.map((role) => (
|
||||||
|
<Badge
|
||||||
|
key={role}
|
||||||
|
className={`${getRoleBadgeColor(role)} text-xs px-1.5 py-0`}
|
||||||
|
>
|
||||||
|
{role}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
Period: {kpi.period}
|
||||||
|
</span>
|
||||||
|
{kpi.thresholdBreached && (
|
||||||
|
<div className="flex items-center gap-1 text-orange-600">
|
||||||
|
<CircleAlert className="w-3 h-3" />
|
||||||
|
<span className="font-medium">Threshold Breached!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Analytics Tab */}
|
||||||
|
<TabsContent value="analytics" className="flex-1 outline-none space-y-4">
|
||||||
|
<AnalyticsConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Users Tab */}
|
||||||
|
<TabsContent value="users" className="flex-1 outline-none space-y-4">
|
||||||
|
<UserManagement />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* TAT Tab */}
|
||||||
|
<TabsContent value="tat" className="flex-1 outline-none space-y-4">
|
||||||
|
<TATConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Notifications Tab */}
|
||||||
|
<TabsContent value="notifications" className="flex-1 outline-none space-y-4">
|
||||||
|
<NotificationConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Documents Tab */}
|
||||||
|
<TabsContent value="documents" className="flex-1 outline-none space-y-4">
|
||||||
|
<DocumentConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Dashboard Tab */}
|
||||||
|
<TabsContent value="dashboard" className="flex-1 outline-none space-y-4">
|
||||||
|
<DashboardConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* AI Tab */}
|
||||||
|
<TabsContent value="ai" className="flex-1 outline-none space-y-4">
|
||||||
|
<AIConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* Sharing Tab */}
|
||||||
|
<TabsContent value="sharing" className="flex-1 outline-none space-y-4">
|
||||||
|
<SharingConfig />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
2
src/pages/Admin/index.ts
Normal file
2
src/pages/Admin/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Admin } from './Admin';
|
||||||
|
|
||||||
@ -1,11 +1,11 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
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';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Calendar, Filter, Search, FileText, AlertCircle, CheckCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, Settings2, X, XCircle } from 'lucide-react';
|
import { Calendar, Filter, Search, FileText, AlertCircle, CheckCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, X, XCircle } from 'lucide-react';
|
||||||
import workflowApi from '@/services/workflowApi';
|
import workflowApi from '@/services/workflowApi';
|
||||||
import { formatDateTime } from '@/utils/dateFormatter';
|
import { formatDateTime } from '@/utils/dateFormatter';
|
||||||
|
|
||||||
@ -97,7 +97,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority'>('due');
|
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority'>('due');
|
||||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
|
||||||
const [items, setItems] = useState<Request[]>([]);
|
const [items, setItems] = useState<Request[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [refreshing, setRefreshing] = useState(false);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
@ -108,14 +107,24 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
const [totalRecords, setTotalRecords] = useState(0);
|
const [totalRecords, setTotalRecords] = useState(0);
|
||||||
const [itemsPerPage] = useState(10);
|
const [itemsPerPage] = useState(10);
|
||||||
|
|
||||||
const fetchRequests = async (page: number = 1) => {
|
const fetchRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
||||||
try {
|
try {
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setItems([]);
|
setItems([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await workflowApi.listClosedByMe({ page, limit: itemsPerPage });
|
console.log('[ClosedRequests] Fetching with filters:', { page, filters }); // Debug log
|
||||||
|
|
||||||
|
const result = await workflowApi.listClosedByMe({
|
||||||
|
page,
|
||||||
|
limit: itemsPerPage,
|
||||||
|
search: filters?.search,
|
||||||
|
status: filters?.status,
|
||||||
|
priority: filters?.priority,
|
||||||
|
sortBy: filters?.sortBy,
|
||||||
|
sortOrder: filters?.sortOrder
|
||||||
|
});
|
||||||
console.log('[ClosedRequests] API Response:', result); // Debug log
|
console.log('[ClosedRequests] API Response:', result); // Debug log
|
||||||
|
|
||||||
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
||||||
@ -133,24 +142,22 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
setTotalRecords(pagination.total || 0);
|
setTotalRecords(pagination.total || 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapped: Request[] = data
|
const mapped: Request[] = data.map((r: any) => ({
|
||||||
.filter((r: any) => ['APPROVED', 'REJECTED', 'CLOSED'].includes((r.status || '').toString()))
|
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
|
||||||
.map((r: any) => ({
|
requestId: r.requestId, // Keep requestId for reference
|
||||||
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
|
displayId: r.requestNumber || r.request_number || r.requestId,
|
||||||
requestId: r.requestId, // Keep requestId for reference
|
title: r.title,
|
||||||
displayId: r.requestNumber || r.request_number || r.requestId,
|
description: r.description,
|
||||||
title: r.title,
|
status: (r.status || '').toString().toLowerCase(),
|
||||||
description: r.description,
|
priority: (r.priority || '').toString().toLowerCase(),
|
||||||
status: (r.status || '').toString().toLowerCase(),
|
initiator: { name: r.initiator?.displayName || r.initiator?.email || '—', avatar: (r.initiator?.displayName || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase() },
|
||||||
priority: (r.priority || '').toString().toLowerCase(),
|
createdAt: r.submittedAt || r.createdAt || r.created_at || '—',
|
||||||
initiator: { name: r.initiator?.displayName || r.initiator?.email || '—', avatar: (r.initiator?.displayName || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase() },
|
dueDate: r.closureDate || r.closure_date || r.closedAt || undefined,
|
||||||
createdAt: r.submittedAt || r.createdAt || '—',
|
reason: r.conclusionRemark || r.conclusion_remark,
|
||||||
dueDate: r.closureDate || r.closure_date || undefined,
|
department: r.department,
|
||||||
reason: r.conclusionRemark || r.conclusion_remark,
|
totalLevels: r.totalLevels || 0,
|
||||||
department: r.department,
|
completedLevels: r.summary?.approvedLevels || 0,
|
||||||
totalLevels: r.totalLevels || 0,
|
}));
|
||||||
completedLevels: r.summary?.approvedLevels || 0,
|
|
||||||
}));
|
|
||||||
setItems(mapped);
|
setItems(mapped);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[ClosedRequests] Error fetching requests:', error);
|
console.error('[ClosedRequests] Error fetching requests:', error);
|
||||||
@ -159,17 +166,29 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
};
|
}, [itemsPerPage]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
fetchRequests(currentPage);
|
fetchRequests(currentPage, {
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||||
|
sortBy,
|
||||||
|
sortOrder
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
if (newPage >= 1 && newPage <= totalPages) {
|
if (newPage >= 1 && newPage <= totalPages) {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
fetchRequests(newPage);
|
fetchRequests(newPage, {
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||||
|
sortBy,
|
||||||
|
sortOrder
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -190,54 +209,34 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
return pages;
|
return pages;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initial fetch on mount and when filters/sorting change (with debouncing for search)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRequests(1);
|
// Debounce search: wait 500ms after user stops typing
|
||||||
}, []);
|
const timeoutId = setTimeout(() => {
|
||||||
|
console.log('[ClosedRequests] Filter changed, fetching...', {
|
||||||
|
searchTerm,
|
||||||
|
statusFilter,
|
||||||
|
priorityFilter,
|
||||||
|
sortBy,
|
||||||
|
sortOrder
|
||||||
|
}); // Debug log
|
||||||
|
|
||||||
const filteredAndSortedRequests = useMemo(() => {
|
setCurrentPage(1); // Reset to page 1 when filters change
|
||||||
let filtered = items.filter(request => {
|
fetchRequests(1, {
|
||||||
const matchesSearch =
|
search: searchTerm || undefined,
|
||||||
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
request.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||||
request.initiator.name.toLowerCase().includes(searchTerm.toLowerCase());
|
sortBy,
|
||||||
|
sortOrder
|
||||||
|
});
|
||||||
|
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
|
||||||
|
|
||||||
const matchesPriority = priorityFilter === 'all' || request.priority === priorityFilter;
|
return () => clearTimeout(timeoutId);
|
||||||
const matchesStatus = statusFilter === 'all' || request.status === statusFilter;
|
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, fetchRequests]);
|
||||||
|
|
||||||
return matchesSearch && matchesPriority && matchesStatus;
|
// Backend handles both filtering and sorting - use items directly
|
||||||
});
|
// No client-side filtering/sorting needed anymore
|
||||||
|
const filteredAndSortedRequests = items;
|
||||||
// Sort requests
|
|
||||||
filtered.sort((a, b) => {
|
|
||||||
let aValue: any, bValue: any;
|
|
||||||
|
|
||||||
switch (sortBy) {
|
|
||||||
case 'created':
|
|
||||||
aValue = new Date(a.createdAt);
|
|
||||||
bValue = new Date(b.createdAt);
|
|
||||||
break;
|
|
||||||
case 'due':
|
|
||||||
aValue = a.dueDate ? new Date(a.dueDate) : new Date(0);
|
|
||||||
bValue = b.dueDate ? new Date(b.dueDate) : new Date(0);
|
|
||||||
break;
|
|
||||||
case 'priority':
|
|
||||||
const priorityOrder = { express: 2, standard: 1 };
|
|
||||||
aValue = priorityOrder[a.priority as keyof typeof priorityOrder];
|
|
||||||
bValue = priorityOrder[b.priority as keyof typeof priorityOrder];
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sortOrder === 'asc') {
|
|
||||||
return aValue > bValue ? 1 : -1;
|
|
||||||
} else {
|
|
||||||
return aValue < bValue ? 1 : -1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return filtered;
|
|
||||||
}, [items, searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
|
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
@ -304,28 +303,17 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 sm:gap-2">
|
{activeFiltersCount > 0 && (
|
||||||
{activeFiltersCount > 0 && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={clearFilters}
|
|
||||||
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
|
||||||
<span className="text-xs sm:text-sm">Clear</span>
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
onClick={clearFilters}
|
||||||
className="gap-1 sm:gap-2 h-8 sm:h-9 px-2 sm:px-3"
|
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
|
||||||
>
|
>
|
||||||
<Settings2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||||
<span className="text-xs sm:text-sm hidden md:inline">{showAdvancedFilters ? 'Basic' : 'Advanced'}</span>
|
<span className="text-xs sm:text-sm">Clear</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
||||||
|
|||||||
@ -40,10 +40,13 @@ import {
|
|||||||
Minus,
|
Minus,
|
||||||
Eye,
|
Eye,
|
||||||
Lightbulb,
|
Lightbulb,
|
||||||
Settings
|
Settings,
|
||||||
|
Loader2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { useAuth } from '@/contexts/AuthContext';
|
import { useAuth } from '@/contexts/AuthContext';
|
||||||
|
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
interface CreateRequestProps {
|
interface CreateRequestProps {
|
||||||
onBack?: () => void;
|
onBack?: () => void;
|
||||||
@ -221,10 +224,30 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
|
|
||||||
const totalSteps = STEP_NAMES.length;
|
const totalSteps = STEP_NAMES.length;
|
||||||
const [loadingDraft, setLoadingDraft] = useState(isEditing);
|
const [loadingDraft, setLoadingDraft] = useState(isEditing);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [savingDraft, setSavingDraft] = useState(false);
|
||||||
const [existingDocuments, setExistingDocuments] = useState<any[]>([]); // Track documents from backend
|
const [existingDocuments, setExistingDocuments] = useState<any[]>([]); // Track documents from backend
|
||||||
const [documentsToDelete, setDocumentsToDelete] = useState<string[]>([]); // Track document IDs to delete
|
const [documentsToDelete, setDocumentsToDelete] = useState<string[]>([]); // Track document IDs to delete
|
||||||
const [previewDocument, setPreviewDocument] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; file?: File; documentId?: string } | null>(null);
|
const [previewDocument, setPreviewDocument] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; file?: File; documentId?: string } | null>(null);
|
||||||
|
|
||||||
|
// Document policy state
|
||||||
|
const [documentPolicy, setDocumentPolicy] = useState<{
|
||||||
|
maxFileSizeMB: number;
|
||||||
|
allowedFileTypes: string[];
|
||||||
|
}>({
|
||||||
|
maxFileSizeMB: 10,
|
||||||
|
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif']
|
||||||
|
});
|
||||||
|
|
||||||
|
// Document validation error modal
|
||||||
|
const [documentErrorModal, setDocumentErrorModal] = useState<{
|
||||||
|
open: boolean;
|
||||||
|
errors: Array<{ fileName: string; reason: string }>;
|
||||||
|
}>({
|
||||||
|
open: false,
|
||||||
|
errors: []
|
||||||
|
});
|
||||||
|
|
||||||
// Validation modal states
|
// Validation modal states
|
||||||
const [validationModal, setValidationModal] = useState<{
|
const [validationModal, setValidationModal] = useState<{
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -238,6 +261,33 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
message: ''
|
message: ''
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch document policy on mount
|
||||||
|
useEffect(() => {
|
||||||
|
const loadDocumentPolicy = async () => {
|
||||||
|
try {
|
||||||
|
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||||
|
const configMap: Record<string, string> = {};
|
||||||
|
configs.forEach((c: AdminConfiguration) => {
|
||||||
|
configMap[c.configKey] = c.configValue;
|
||||||
|
});
|
||||||
|
|
||||||
|
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10');
|
||||||
|
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
|
||||||
|
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
|
||||||
|
|
||||||
|
setDocumentPolicy({
|
||||||
|
maxFileSizeMB,
|
||||||
|
allowedFileTypes
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load document policy:', error);
|
||||||
|
// Use defaults if loading fails
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadDocumentPolicy();
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Fetch draft data when in edit mode
|
// Fetch draft data when in edit mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isEditing || !editRequestId) return;
|
if (!isEditing || !editRequestId) return;
|
||||||
@ -775,13 +825,79 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||||
const files = Array.from(event.target.files || []);
|
// Check file size
|
||||||
updateFormData('documents', [...formData.documents, ...files]);
|
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
|
||||||
|
if (file.size > maxSizeBytes) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file extension
|
||||||
|
const fileName = file.name.toLowerCase();
|
||||||
|
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||||
|
|
||||||
|
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = () => {
|
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (!isStepValid()) return;
|
const files = Array.from(event.target.files || []);
|
||||||
|
|
||||||
|
if (files.length === 0) return;
|
||||||
|
|
||||||
|
// Validate all files
|
||||||
|
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
||||||
|
const validFiles: File[] = [];
|
||||||
|
|
||||||
|
files.forEach(file => {
|
||||||
|
const validation = validateFile(file);
|
||||||
|
if (!validation.valid) {
|
||||||
|
validationErrors.push({
|
||||||
|
fileName: file.name,
|
||||||
|
reason: validation.reason || 'Unknown validation error'
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
validFiles.push(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// If there are validation errors, show modal
|
||||||
|
if (validationErrors.length > 0) {
|
||||||
|
setDocumentErrorModal({
|
||||||
|
open: true,
|
||||||
|
errors: validationErrors
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add only valid files
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
updateFormData('documents', [...formData.documents, ...validFiles]);
|
||||||
|
if (validFiles.length < files.length) {
|
||||||
|
toast.warning(`${validFiles.length} of ${files.length} file(s) were added. ${validationErrors.length} file(s) were rejected.`);
|
||||||
|
} else {
|
||||||
|
toast.success(`${validFiles.length} file(s) added successfully`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset file input
|
||||||
|
if (event.target) {
|
||||||
|
event.target.value = '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async () => {
|
||||||
|
if (!isStepValid() || submitting || savingDraft) return;
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
|
||||||
// Participants mapping
|
// Participants mapping
|
||||||
const initiatorId = user?.userId || '';
|
const initiatorId = user?.userId || '';
|
||||||
@ -935,59 +1051,68 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
|
|
||||||
if (hasNewFiles || hasDeletions) {
|
if (hasNewFiles || hasDeletions) {
|
||||||
// Use multipart update
|
// Use multipart update
|
||||||
updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete)
|
try {
|
||||||
.then(async () => {
|
await updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete);
|
||||||
// Submit the updated workflow
|
// Submit the updated workflow
|
||||||
try {
|
try {
|
||||||
await submitWorkflow(editRequestId);
|
await submitWorkflow(editRequestId);
|
||||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to submit workflow:', err);
|
console.error('Failed to submit workflow:', err);
|
||||||
}
|
setSubmitting(false);
|
||||||
})
|
}
|
||||||
.catch((err) => {
|
} catch (err) {
|
||||||
console.error('Failed to update workflow:', err);
|
console.error('Failed to update workflow:', err);
|
||||||
});
|
setSubmitting(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use regular update
|
// Use regular update
|
||||||
updateWorkflow(editRequestId, updatePayload)
|
try {
|
||||||
.then(async () => {
|
await updateWorkflow(editRequestId, updatePayload);
|
||||||
// Submit the updated workflow
|
// Submit the updated workflow
|
||||||
try {
|
try {
|
||||||
await submitWorkflow(editRequestId);
|
await submitWorkflow(editRequestId);
|
||||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to submit workflow:', err);
|
console.error('Failed to submit workflow:', err);
|
||||||
}
|
setSubmitting(false);
|
||||||
})
|
}
|
||||||
.catch((err) => {
|
} catch (err) {
|
||||||
console.error('Failed to update workflow:', err);
|
console.error('Failed to update workflow:', err);
|
||||||
});
|
setSubmitting(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create new workflow
|
// Create new workflow
|
||||||
createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING')
|
try {
|
||||||
.then(async (res) => {
|
const res = await createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING');
|
||||||
const id = (res as any).id;
|
const id = (res as any).id;
|
||||||
try {
|
try {
|
||||||
await submitWorkflow(id);
|
await submitWorkflow(id);
|
||||||
} catch {}
|
|
||||||
onSubmit?.({ ...formData, backendId: id, template: selectedTemplate });
|
onSubmit?.({ ...formData, backendId: id, template: selectedTemplate });
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
console.error('Failed to submit workflow:', err);
|
||||||
console.error('Failed to create workflow:', err);
|
setSubmitting(false);
|
||||||
});
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to create workflow:', err);
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSaveDraft = () => {
|
const handleSaveDraft = async () => {
|
||||||
// Same payload as submit, but do NOT call submit endpoint
|
// Same payload as submit, but do NOT call submit endpoint
|
||||||
if (!selectedTemplate || !formData.title.trim() || !formData.description.trim() || !formData.priority) {
|
if (!selectedTemplate || !formData.title.trim() || !formData.description.trim() || !formData.priority) {
|
||||||
// allow minimal validation for draft: require title/description/priority/template
|
// allow minimal validation for draft: require title/description/priority/template
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (submitting || savingDraft) return;
|
||||||
|
|
||||||
|
setSavingDraft(true);
|
||||||
|
|
||||||
// Handle edit mode - update existing draft with full structure
|
// Handle edit mode - update existing draft with full structure
|
||||||
if (isEditing && editRequestId) {
|
if (isEditing && editRequestId) {
|
||||||
// Build approval levels
|
// Build approval levels
|
||||||
@ -1055,18 +1180,22 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
|
|
||||||
if (hasNewFiles || hasDeletions) {
|
if (hasNewFiles || hasDeletions) {
|
||||||
// Use multipart update
|
// Use multipart update
|
||||||
updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete)
|
try {
|
||||||
.then(() => {
|
await updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete);
|
||||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => console.error('Failed to update draft:', err));
|
console.error('Failed to update draft:', err);
|
||||||
|
setSavingDraft(false);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// Use regular update
|
// Use regular update
|
||||||
updateWorkflow(editRequestId, updatePayload)
|
try {
|
||||||
.then(() => {
|
await updateWorkflow(editRequestId, updatePayload);
|
||||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => console.error('Failed to update draft:', err));
|
console.error('Failed to update draft:', err);
|
||||||
|
setSavingDraft(false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@ -1147,11 +1276,13 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
})),
|
})),
|
||||||
participants: participants, // Include participants array for draft
|
participants: participants, // Include participants array for draft
|
||||||
};
|
};
|
||||||
createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING')
|
try {
|
||||||
.then((res) => {
|
const res = await createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING');
|
||||||
onSubmit?.({ ...formData, backendId: (res as any).id, template: selectedTemplate });
|
onSubmit?.({ ...formData, backendId: (res as any).id, template: selectedTemplate });
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => console.error('Failed to save draft:', err));
|
console.error('Failed to save draft:', err);
|
||||||
|
setSavingDraft(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Show loading state while fetching draft data
|
// Show loading state while fetching draft data
|
||||||
@ -2267,9 +2398,9 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
<FileText className="w-5 h-5" />
|
<FileText className="w-5 h-5" />
|
||||||
File Upload
|
File Upload
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Attach supporting documents (PDF, Word, Excel, Images). Max 10MB per file.
|
Attach supporting documents. Max {documentPolicy.maxFileSizeMB}MB per file. Allowed types: {documentPolicy.allowedFileTypes.join(', ')}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
|
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
|
||||||
@ -2281,7 +2412,7 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
multiple
|
multiple
|
||||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.ppt,.pptx"
|
accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
|
||||||
onChange={handleFileUpload}
|
onChange={handleFileUpload}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
id="file-upload"
|
id="file-upload"
|
||||||
@ -2292,7 +2423,7 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
Browse Files
|
Browse Files
|
||||||
</Button>
|
</Button>
|
||||||
<p className="text-xs text-gray-500 mt-2">
|
<p className="text-xs text-gray-500 mt-2">
|
||||||
Supported formats: PDF, DOC, DOCX, XLS, XLSX, JPG, PNG, PPT, PPTX
|
Supported formats: {documentPolicy.allowedFileTypes.map(ext => ext.toUpperCase()).join(', ')} (Max {documentPolicy.maxFileSizeMB}MB per file)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -2986,19 +3117,35 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
onClick={handleSaveDraft}
|
onClick={handleSaveDraft}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="sm:size-lg flex-1 sm:flex-none text-xs sm:text-sm"
|
className="sm:size-lg flex-1 sm:flex-none text-xs sm:text-sm"
|
||||||
disabled={loadingDraft}
|
disabled={loadingDraft || submitting || savingDraft}
|
||||||
>
|
>
|
||||||
{isEditing ? 'Update Draft' : 'Save Draft'}
|
{savingDraft ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2 animate-spin" />
|
||||||
|
{isEditing ? 'Updating...' : 'Saving...'}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
isEditing ? 'Update Draft' : 'Save Draft'
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
{currentStep === totalSteps ? (
|
{currentStep === totalSteps ? (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSubmit}
|
onClick={handleSubmit}
|
||||||
disabled={!isStepValid() || loadingDraft}
|
disabled={!isStepValid() || loadingDraft || submitting || savingDraft}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="sm:size-lg bg-green-600 hover:bg-green-700 flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
|
className="sm:size-lg bg-green-600 hover:bg-green-700 flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
|
||||||
>
|
>
|
||||||
<Rocket className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
{submitting ? (
|
||||||
Submit
|
<>
|
||||||
|
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2 animate-spin" />
|
||||||
|
Submitting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Rocket className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
||||||
|
Submit
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
@ -3062,6 +3209,48 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Document Validation Error Modal */}
|
||||||
|
<Dialog open={documentErrorModal.open} onOpenChange={(open) => setDocumentErrorModal(prev => ({ ...prev, open }))}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
Document Upload Policy Violation
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-gray-700">
|
||||||
|
The following file(s) could not be uploaded due to policy violations:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
{documentErrorModal.errors.map((error, index) => (
|
||||||
|
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
|
<p className="font-medium text-red-900 text-sm">{error.fileName}</p>
|
||||||
|
<p className="text-xs text-red-700 mt-1">{error.reason}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-blue-800 font-semibold mb-1">Document Policy:</p>
|
||||||
|
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
|
||||||
|
<li>Maximum file size: {documentPolicy.maxFileSizeMB}MB</li>
|
||||||
|
<li>Allowed file types: {documentPolicy.allowedFileTypes.join(', ')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setDocumentErrorModal({ open: false, errors: [] })}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
{/* Validation Error Modal */}
|
{/* Validation Error Modal */}
|
||||||
<Dialog open={validationModal.open} onOpenChange={(open) => setValidationModal(prev => ({ ...prev, open }))}>
|
<Dialog open={validationModal.open} onOpenChange={(open) => setValidationModal(prev => ({ ...prev, open }))}>
|
||||||
<DialogContent className="sm:max-w-md">
|
<DialogContent className="sm:max-w-md">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
1037
src/pages/DetailedReports/DetailedReports.tsx
Normal file
1037
src/pages/DetailedReports/DetailedReports.tsx
Normal file
File diff suppressed because it is too large
Load Diff
2
src/pages/DetailedReports/index.ts
Normal file
2
src/pages/DetailedReports/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { DetailedReports } from './DetailedReports';
|
||||||
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@ -15,7 +15,6 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Flame,
|
Flame,
|
||||||
Target,
|
Target,
|
||||||
Eye,
|
|
||||||
AlertCircle
|
AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
@ -72,11 +71,11 @@ const getStatusConfig = (status: string) => {
|
|||||||
icon: Clock,
|
icon: Clock,
|
||||||
iconColor: 'text-yellow-600'
|
iconColor: 'text-yellow-600'
|
||||||
};
|
};
|
||||||
case 'in-review':
|
case 'closed':
|
||||||
return {
|
return {
|
||||||
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||||
icon: Eye,
|
icon: CheckCircle,
|
||||||
iconColor: 'text-blue-600'
|
iconColor: 'text-gray-600'
|
||||||
};
|
};
|
||||||
case 'draft':
|
case 'draft':
|
||||||
return {
|
return {
|
||||||
@ -97,6 +96,7 @@ const getStatusConfig = (status: string) => {
|
|||||||
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
|
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||||
const [apiRequests, setApiRequests] = useState<any[]>([]);
|
const [apiRequests, setApiRequests] = useState<any[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [hasFetchedFromApi, setHasFetchedFromApi] = useState(false);
|
const [hasFetchedFromApi, setHasFetchedFromApi] = useState(false);
|
||||||
@ -107,14 +107,20 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
const [totalRecords, setTotalRecords] = useState(0);
|
const [totalRecords, setTotalRecords] = useState(0);
|
||||||
const [itemsPerPage] = useState(10);
|
const [itemsPerPage] = useState(10);
|
||||||
|
|
||||||
const fetchMyRequests = async (page: number = 1) => {
|
const fetchMyRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string }) => {
|
||||||
try {
|
try {
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setApiRequests([]);
|
setApiRequests([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await workflowApi.listMyWorkflows({ page, limit: itemsPerPage });
|
const result = await workflowApi.listMyWorkflows({
|
||||||
|
page,
|
||||||
|
limit: itemsPerPage,
|
||||||
|
search: filters?.search,
|
||||||
|
status: filters?.status,
|
||||||
|
priority: filters?.priority
|
||||||
|
});
|
||||||
console.log('[MyRequests] API Response:', result); // Debug log
|
console.log('[MyRequests] API Response:', result); // Debug log
|
||||||
|
|
||||||
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
||||||
@ -141,18 +147,44 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [itemsPerPage]);
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
if (newPage >= 1 && newPage <= totalPages) {
|
if (newPage >= 1 && newPage <= totalPages) {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
fetchMyRequests(newPage);
|
fetchMyRequests(newPage, {
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initial fetch on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchMyRequests(1);
|
fetchMyRequests(1, {
|
||||||
}, []);
|
search: searchTerm || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined
|
||||||
|
});
|
||||||
|
}, [fetchMyRequests]);
|
||||||
|
|
||||||
|
// Fetch when filters change (with debouncing for search)
|
||||||
|
useEffect(() => {
|
||||||
|
// Debounce search: wait 500ms after user stops typing
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
if (hasFetchedFromApi) { // Only refetch if we've already loaded data once
|
||||||
|
setCurrentPage(1); // Reset to page 1 when filters change
|
||||||
|
fetchMyRequests(1, {
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [searchTerm, statusFilter, priorityFilter, hasFetchedFromApi, fetchMyRequests]);
|
||||||
|
|
||||||
// Convert API/dynamic requests to the format expected by this component
|
// Convert API/dynamic requests to the format expected by this component
|
||||||
// Once API has fetched (even if empty), always use API data, never fall back to props
|
// Once API has fetched (even if empty), always use API data, never fall back to props
|
||||||
@ -167,7 +199,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
displayId: req.requestNumber || req.request_number || req.id,
|
displayId: req.requestNumber || req.request_number || req.id,
|
||||||
title: req.title,
|
title: req.title,
|
||||||
description: req.description,
|
description: req.description,
|
||||||
status: (req.status || '').toString().toLowerCase().replace('in_progress','in-review'),
|
status: (req.status || '').toString().toLowerCase().replace('_','-'),
|
||||||
priority: priority,
|
priority: priority,
|
||||||
department: req.department,
|
department: req.department,
|
||||||
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
|
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
|
||||||
@ -179,31 +211,20 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
};
|
};
|
||||||
}) : [];
|
}) : [];
|
||||||
|
|
||||||
// Use only API/dynamic requests
|
// Use only API/dynamic requests - backend already filtered
|
||||||
const allRequests = convertedDynamicRequests;
|
const allRequests = convertedDynamicRequests;
|
||||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
|
||||||
|
|
||||||
// Filter requests based on search and filters
|
// No frontend filtering - backend handles all filtering
|
||||||
const filteredRequests = allRequests.filter(request => {
|
const filteredRequests = allRequests;
|
||||||
const matchesSearch =
|
|
||||||
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
request.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
request.id.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
const matchesStatus = statusFilter === 'all' || request.status === statusFilter;
|
|
||||||
const matchesPriority = priorityFilter === 'all' || request.priority === priorityFilter;
|
|
||||||
|
|
||||||
return matchesSearch && matchesStatus && matchesPriority;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Stats calculation - using total from pagination for total count
|
// Stats calculation - using total from pagination for total count
|
||||||
const stats = {
|
const stats = {
|
||||||
total: totalRecords || allRequests.length,
|
total: totalRecords || allRequests.length,
|
||||||
pending: allRequests.filter(r => r.status === 'pending').length,
|
pending: allRequests.filter(r => r.status === 'pending').length,
|
||||||
approved: allRequests.filter(r => r.status === 'approved').length,
|
approved: allRequests.filter(r => r.status === 'approved').length,
|
||||||
inReview: allRequests.filter(r => r.status === 'in-review').length,
|
|
||||||
rejected: allRequests.filter(r => r.status === 'rejected').length,
|
rejected: allRequests.filter(r => r.status === 'rejected').length,
|
||||||
draft: allRequests.filter(r => r.status === 'draft').length
|
draft: allRequests.filter(r => r.status === 'draft').length,
|
||||||
|
closed: allRequests.filter(r => r.status === 'closed').length
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -235,14 +256,14 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<StatsCard
|
<StatsCard
|
||||||
label="In Progress"
|
label="Pending"
|
||||||
value={stats.pending + stats.inReview}
|
value={stats.pending}
|
||||||
icon={Clock}
|
icon={Clock}
|
||||||
iconColor="text-orange-600"
|
iconColor="text-orange-600"
|
||||||
gradient="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200"
|
gradient="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200"
|
||||||
textColor="text-orange-700"
|
textColor="text-orange-700"
|
||||||
valueColor="text-orange-900"
|
valueColor="text-orange-900"
|
||||||
testId="stat-in-progress"
|
testId="stat-pending"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<StatsCard
|
<StatsCard
|
||||||
@ -289,7 +310,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
placeholder="Search requests by title, description, or ID..."
|
placeholder="Search requests by title, description, or ID..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-9 text-sm sm:text-base bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10"
|
className="pl-9 text-sm sm:text-base bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
||||||
data-testid="search-input"
|
data-testid="search-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -297,7 +318,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
<div className="flex gap-2 sm:gap-3 w-full md:w-auto">
|
<div className="flex gap-2 sm:gap-3 w-full md:w-auto">
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10"
|
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
||||||
data-testid="status-filter"
|
data-testid="status-filter"
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Status" />
|
<SelectValue placeholder="Status" />
|
||||||
@ -306,15 +327,15 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
|||||||
<SelectItem value="all">All Status</SelectItem>
|
<SelectItem value="all">All Status</SelectItem>
|
||||||
<SelectItem value="draft">Draft</SelectItem>
|
<SelectItem value="draft">Draft</SelectItem>
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
<SelectItem value="pending">Pending</SelectItem>
|
||||||
<SelectItem value="in-review">In Review</SelectItem>
|
|
||||||
<SelectItem value="approved">Approved</SelectItem>
|
<SelectItem value="approved">Approved</SelectItem>
|
||||||
<SelectItem value="rejected">Rejected</SelectItem>
|
<SelectItem value="rejected">Rejected</SelectItem>
|
||||||
|
<SelectItem value="closed">Closed</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10"
|
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
|
||||||
data-testid="priority-filter"
|
data-testid="priority-filter"
|
||||||
>
|
>
|
||||||
<SelectValue placeholder="Priority" />
|
<SelectValue placeholder="Priority" />
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useState, useCallback } from 'react';
|
||||||
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';
|
||||||
@ -6,14 +6,14 @@ import { Input } from '@/components/ui/input';
|
|||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, Eye, RefreshCw, Settings2, X } from 'lucide-react';
|
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, Settings2, X, CheckCircle, XCircle } from 'lucide-react';
|
||||||
import workflowApi from '@/services/workflowApi';
|
import workflowApi from '@/services/workflowApi';
|
||||||
import { formatDateShort } from '@/utils/dateFormatter';
|
import { formatDateShort } from '@/utils/dateFormatter';
|
||||||
interface Request {
|
interface Request {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
status: 'pending' | 'in-review' | 'approved';
|
status: 'pending' | 'approved' | 'rejected' | 'closed';
|
||||||
priority: 'express' | 'standard';
|
priority: 'express' | 'standard';
|
||||||
initiator: { name: string; avatar: string };
|
initiator: { name: string; avatar: string };
|
||||||
currentApprover?: {
|
currentApprover?: {
|
||||||
@ -61,13 +61,8 @@ const getStatusConfig = (status: string) => {
|
|||||||
return {
|
return {
|
||||||
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||||
icon: Clock,
|
icon: Clock,
|
||||||
iconColor: 'text-yellow-600'
|
iconColor: 'text-yellow-600',
|
||||||
};
|
label: 'Pending'
|
||||||
case 'in-review':
|
|
||||||
return {
|
|
||||||
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
|
||||||
icon: Eye,
|
|
||||||
iconColor: 'text-blue-600'
|
|
||||||
};
|
};
|
||||||
case 'approved':
|
case 'approved':
|
||||||
return {
|
return {
|
||||||
@ -76,11 +71,26 @@ const getStatusConfig = (status: string) => {
|
|||||||
iconColor: 'text-green-600',
|
iconColor: 'text-green-600',
|
||||||
label: 'Needs Closure'
|
label: 'Needs Closure'
|
||||||
};
|
};
|
||||||
|
case 'rejected':
|
||||||
|
return {
|
||||||
|
color: 'bg-red-100 text-red-800 border-red-200',
|
||||||
|
icon: XCircle,
|
||||||
|
iconColor: 'text-red-600',
|
||||||
|
label: 'Rejected'
|
||||||
|
};
|
||||||
|
case 'closed':
|
||||||
|
return {
|
||||||
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||||
|
icon: CheckCircle,
|
||||||
|
iconColor: 'text-gray-600',
|
||||||
|
label: 'Closed'
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||||
icon: AlertCircle,
|
icon: AlertCircle,
|
||||||
iconColor: 'text-gray-600'
|
iconColor: 'text-gray-600',
|
||||||
|
label: status
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -104,14 +114,22 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
const [totalRecords, setTotalRecords] = useState(0);
|
const [totalRecords, setTotalRecords] = useState(0);
|
||||||
const [itemsPerPage] = useState(10);
|
const [itemsPerPage] = useState(10);
|
||||||
|
|
||||||
const fetchRequests = async (page: number = 1) => {
|
const fetchRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
|
||||||
try {
|
try {
|
||||||
if (page === 1) {
|
if (page === 1) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setItems([]);
|
setItems([]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await workflowApi.listOpenForMe({ page, limit: itemsPerPage });
|
const result = await workflowApi.listOpenForMe({
|
||||||
|
page,
|
||||||
|
limit: itemsPerPage,
|
||||||
|
search: filters?.search,
|
||||||
|
status: filters?.status,
|
||||||
|
priority: filters?.priority,
|
||||||
|
sortBy: filters?.sortBy,
|
||||||
|
sortOrder: filters?.sortOrder
|
||||||
|
});
|
||||||
console.log('[OpenRequests] API Response:', result); // Debug log
|
console.log('[OpenRequests] API Response:', result); // Debug log
|
||||||
|
|
||||||
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
||||||
@ -138,7 +156,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
displayId: r.requestNumber || r.request_number || r.requestId,
|
displayId: r.requestNumber || r.request_number || r.requestId,
|
||||||
title: r.title,
|
title: r.title,
|
||||||
description: r.description,
|
description: r.description,
|
||||||
status: ((r.status || '').toString().toUpperCase() === 'IN_PROGRESS') ? 'in-review' : 'pending',
|
status: (r.status || '').toString().toLowerCase().replace('_', '-'),
|
||||||
priority: (r.priority || '').toString().toLowerCase(),
|
priority: (r.priority || '').toString().toLowerCase(),
|
||||||
initiator: {
|
initiator: {
|
||||||
name: (r.initiator?.displayName || r.initiator?.email || '—'),
|
name: (r.initiator?.displayName || r.initiator?.email || '—'),
|
||||||
@ -160,17 +178,29 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
setLoading(false);
|
setLoading(false);
|
||||||
setRefreshing(false);
|
setRefreshing(false);
|
||||||
}
|
}
|
||||||
};
|
}, [itemsPerPage]);
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
setRefreshing(true);
|
setRefreshing(true);
|
||||||
fetchRequests(currentPage);
|
fetchRequests(currentPage, {
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||||
|
sortBy,
|
||||||
|
sortOrder
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePageChange = (newPage: number) => {
|
const handlePageChange = (newPage: number) => {
|
||||||
if (newPage >= 1 && newPage <= totalPages) {
|
if (newPage >= 1 && newPage <= totalPages) {
|
||||||
setCurrentPage(newPage);
|
setCurrentPage(newPage);
|
||||||
fetchRequests(newPage);
|
fetchRequests(newPage, {
|
||||||
|
search: searchTerm || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||||
|
sortBy,
|
||||||
|
sortOrder
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -191,59 +221,39 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
return pages;
|
return pages;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initial fetch on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchRequests(1);
|
fetchRequests(1, {
|
||||||
}, []);
|
search: searchTerm || undefined,
|
||||||
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
const filteredAndSortedRequests = useMemo(() => {
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||||
let filtered = items.filter(request => {
|
sortBy,
|
||||||
const matchesSearch =
|
sortOrder
|
||||||
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
request.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
||||||
request.initiator.name.toLowerCase().includes(searchTerm.toLowerCase());
|
|
||||||
|
|
||||||
const matchesPriority = priorityFilter === 'all' || request.priority === priorityFilter;
|
|
||||||
const matchesStatus = statusFilter === 'all' || request.status === statusFilter;
|
|
||||||
|
|
||||||
return matchesSearch && matchesPriority && matchesStatus;
|
|
||||||
});
|
});
|
||||||
|
}, [fetchRequests]);
|
||||||
|
|
||||||
// Sort requests
|
// Fetch when filters or sorting change (with debouncing for search)
|
||||||
filtered.sort((a, b) => {
|
useEffect(() => {
|
||||||
let aValue: any, bValue: any;
|
// Debounce search: wait 500ms after user stops typing
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
switch (sortBy) {
|
if (items.length > 0 || loading) { // Only refetch if we've already loaded data once
|
||||||
case 'created':
|
setCurrentPage(1); // Reset to page 1 when filters change
|
||||||
aValue = new Date(a.createdAt);
|
fetchRequests(1, {
|
||||||
bValue = new Date(b.createdAt);
|
search: searchTerm || undefined,
|
||||||
break;
|
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||||
case 'due':
|
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||||
aValue = a.currentLevelSLA?.deadline ? new Date(a.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER;
|
sortBy,
|
||||||
bValue = b.currentLevelSLA?.deadline ? new Date(b.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER;
|
sortOrder
|
||||||
break;
|
});
|
||||||
case 'priority':
|
|
||||||
const priorityOrder = { express: 2, standard: 1 };
|
|
||||||
aValue = priorityOrder[a.priority as keyof typeof priorityOrder];
|
|
||||||
bValue = priorityOrder[b.priority as keyof typeof priorityOrder];
|
|
||||||
break;
|
|
||||||
case 'sla':
|
|
||||||
// Sort by SLA percentage (most urgent first)
|
|
||||||
aValue = a.currentLevelSLA?.percentageUsed || 0;
|
|
||||||
bValue = b.currentLevelSLA?.percentageUsed || 0;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
return 0;
|
|
||||||
}
|
}
|
||||||
|
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
|
||||||
|
|
||||||
if (sortOrder === 'asc') {
|
return () => clearTimeout(timeoutId);
|
||||||
return aValue > bValue ? 1 : -1;
|
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, fetchRequests]);
|
||||||
} else {
|
|
||||||
return aValue < bValue ? 1 : -1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return filtered;
|
// Backend handles both filtering and sorting - use items directly
|
||||||
}, [items, searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
|
// No client-side sorting needed anymore
|
||||||
|
const filteredAndSortedRequests = items;
|
||||||
|
|
||||||
const clearFilters = () => {
|
const clearFilters = () => {
|
||||||
setSearchTerm('');
|
setSearchTerm('');
|
||||||
@ -343,12 +353,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
placeholder="Search requests, IDs..."
|
placeholder="Search requests, IDs..."
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => setSearchTerm(e.target.value)}
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white transition-colors"
|
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
||||||
<SelectValue placeholder="All Priorities" />
|
<SelectValue placeholder="All Priorities" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
@ -369,19 +379,19 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
|||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
||||||
<SelectValue placeholder="All Statuses" />
|
<SelectValue placeholder="All Statuses" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="all">All Statuses</SelectItem>
|
<SelectItem value="all">All Statuses</SelectItem>
|
||||||
<SelectItem value="pending">Pending</SelectItem>
|
<SelectItem value="pending">Pending (In Approval)</SelectItem>
|
||||||
<SelectItem value="in-review">In Review</SelectItem>
|
<SelectItem value="approved">Approved (Needs Closure)</SelectItem>
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
|
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
|
||||||
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white">
|
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
|
||||||
<SelectValue placeholder="Sort by" />
|
<SelectValue placeholder="Sort by" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
|
|||||||
@ -85,13 +85,13 @@ export function Profile() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
{/* <Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="bg-white text-slate-900 hover:bg-gray-100"
|
className="bg-white text-slate-900 hover:bg-gray-100"
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4 mr-2" />
|
<Edit className="w-4 h-4 mr-2" />
|
||||||
Edit Profile
|
Edit Profile
|
||||||
</Button>
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import { Button } from '@/components/ui/button';
|
|||||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
import { Textarea } from '@/components/ui/textarea';
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||||
|
|
||||||
// Utility imports
|
// Utility imports
|
||||||
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
||||||
@ -77,7 +78,8 @@ import {
|
|||||||
UserPlus,
|
UserPlus,
|
||||||
ClipboardList,
|
ClipboardList,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Loader2
|
Loader2,
|
||||||
|
AlertCircle
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -235,7 +237,10 @@ function RequestDetailInner({
|
|||||||
uploadingDocument,
|
uploadingDocument,
|
||||||
triggerFileInput,
|
triggerFileInput,
|
||||||
previewDocument,
|
previewDocument,
|
||||||
setPreviewDocument
|
setPreviewDocument,
|
||||||
|
documentPolicy,
|
||||||
|
documentError,
|
||||||
|
setDocumentError
|
||||||
} = useDocumentUpload(apiRequest, refreshDetails);
|
} = useDocumentUpload(apiRequest, refreshDetails);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -892,17 +897,22 @@ function RequestDetailInner({
|
|||||||
<CardDescription className="text-xs sm:text-sm mt-1">Documents attached while creating the request</CardDescription>
|
<CardDescription className="text-xs sm:text-sm mt-1">Documents attached while creating the request</CardDescription>
|
||||||
</div>
|
</div>
|
||||||
{/* Upload Document Button */}
|
{/* Upload Document Button */}
|
||||||
<Button
|
<div className="flex flex-col items-end gap-1">
|
||||||
size="sm"
|
<Button
|
||||||
onClick={triggerFileInput}
|
size="sm"
|
||||||
disabled={uploadingDocument || request.status === 'closed'}
|
onClick={triggerFileInput}
|
||||||
className="gap-1 sm:gap-2 h-8 sm:h-9 text-xs sm:text-sm shrink-0"
|
disabled={uploadingDocument || request.status === 'closed'}
|
||||||
data-testid="upload-document-btn"
|
className="gap-1 sm:gap-2 h-8 sm:h-9 text-xs sm:text-sm shrink-0"
|
||||||
>
|
data-testid="upload-document-btn"
|
||||||
<Upload className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
>
|
||||||
{uploadingDocument ? 'Uploading...' : request.status === 'closed' ? 'Closed' : 'Upload'}
|
<Upload className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
|
||||||
<span className="hidden sm:inline">{request.status === 'closed' ? '' : 'Document'}</span>
|
{uploadingDocument ? 'Uploading...' : request.status === 'closed' ? 'Closed' : 'Upload'}
|
||||||
</Button>
|
<span className="hidden sm:inline">{request.status === 'closed' ? '' : 'Document'}</span>
|
||||||
|
</Button>
|
||||||
|
<p className="text-xs text-gray-500 whitespace-nowrap">
|
||||||
|
Max {documentPolicy.maxFileSizeMB}MB
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
@ -1226,6 +1236,48 @@ function RequestDetailInner({
|
|||||||
message={actionStatus.message}
|
message={actionStatus.message}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Document Validation Error Modal */}
|
||||||
|
<Dialog open={documentError.show} onOpenChange={(open) => setDocumentError(prev => ({ ...prev, show: open }))}>
|
||||||
|
<DialogContent className="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-5 h-5 text-red-600" />
|
||||||
|
Document Upload Policy Violation
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription asChild>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-gray-700">
|
||||||
|
The following file(s) could not be uploaded due to policy violations:
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||||
|
{documentError.errors.map((error, index) => (
|
||||||
|
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
|
<p className="font-medium text-red-900 text-sm">{error.fileName}</p>
|
||||||
|
<p className="text-xs text-red-700 mt-1">{error.reason}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||||
|
<p className="text-sm text-blue-800 font-semibold mb-1">Document Policy:</p>
|
||||||
|
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
|
||||||
|
<li>Maximum file size: {documentPolicy.maxFileSizeMB}MB</li>
|
||||||
|
<li>Allowed file types: {documentPolicy.allowedFileTypes.join(', ')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
onClick={() => setDocumentError({ show: false, errors: [] })}
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
OK
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,17 +25,34 @@ export function Settings() {
|
|||||||
const [showNotificationModal, setShowNotificationModal] = useState(false);
|
const [showNotificationModal, setShowNotificationModal] = useState(false);
|
||||||
const [notificationSuccess, setNotificationSuccess] = useState(false);
|
const [notificationSuccess, setNotificationSuccess] = useState(false);
|
||||||
const [notificationMessage, setNotificationMessage] = useState<string>();
|
const [notificationMessage, setNotificationMessage] = useState<string>();
|
||||||
|
const [isEnablingNotifications, setIsEnablingNotifications] = useState(false);
|
||||||
|
|
||||||
const handleEnableNotifications = async () => {
|
const handleEnableNotifications = async () => {
|
||||||
|
setIsEnablingNotifications(true);
|
||||||
|
setShowNotificationModal(false);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await setupPushNotifications();
|
await setupPushNotifications();
|
||||||
setNotificationSuccess(true);
|
setNotificationSuccess(true);
|
||||||
setNotificationMessage('You will now receive push notifications for workflow updates, approvals, and TAT alerts.');
|
setNotificationMessage('Push notifications have been successfully enabled! You will now receive notifications for workflow updates, approvals, and TAT alerts.');
|
||||||
setShowNotificationModal(true);
|
setShowNotificationModal(true);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
console.error('[Settings] Error enabling notifications:', error);
|
||||||
setNotificationSuccess(false);
|
setNotificationSuccess(false);
|
||||||
setNotificationMessage(error?.message || 'Unable to enable push notifications. Please check your browser settings.');
|
// Provide more specific error messages
|
||||||
|
const errorMessage = error?.message || 'Unknown error occurred';
|
||||||
|
setNotificationMessage(
|
||||||
|
errorMessage.includes('permission')
|
||||||
|
? 'Notification permission was denied. Please enable notifications in your browser settings and try again.'
|
||||||
|
: errorMessage.includes('Service worker')
|
||||||
|
? 'Service worker registration failed. Please refresh the page and try again.'
|
||||||
|
: errorMessage.includes('token')
|
||||||
|
? 'Authentication required. Please log in again and try enabling notifications.'
|
||||||
|
: `Unable to enable push notifications: ${errorMessage}`
|
||||||
|
);
|
||||||
setShowNotificationModal(true);
|
setShowNotificationModal(true);
|
||||||
|
} finally {
|
||||||
|
setIsEnablingNotifications(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -120,10 +137,11 @@ export function Settings() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleEnableNotifications}
|
onClick={handleEnableNotifications}
|
||||||
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transition-all"
|
disabled={isEnablingNotifications}
|
||||||
|
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Bell className="w-4 h-4 mr-2" />
|
<Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
|
||||||
Enable Push Notifications
|
{isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
@ -234,10 +252,11 @@ export function Settings() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleEnableNotifications}
|
onClick={handleEnableNotifications}
|
||||||
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transition-all"
|
disabled={isEnablingNotifications}
|
||||||
|
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Bell className="w-4 h-4 mr-2" />
|
<Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
|
||||||
Enable Push Notifications
|
{isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
|
|||||||
@ -154,17 +154,20 @@ export interface PriorityDistribution {
|
|||||||
complianceRate: number;
|
complianceRate: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DateRange = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'last30days';
|
export type DateRange = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'last30days' | 'custom';
|
||||||
|
|
||||||
class DashboardService {
|
class DashboardService {
|
||||||
/**
|
/**
|
||||||
* Get all KPI metrics
|
* Get all KPI metrics
|
||||||
*/
|
*/
|
||||||
async getKPIs(dateRange?: DateRange): Promise<DashboardKPIs> {
|
async getKPIs(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<DashboardKPIs> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/dashboard/kpis', {
|
const params: any = { dateRange };
|
||||||
params: { dateRange }
|
if (dateRange === 'custom' && startDate && endDate) {
|
||||||
});
|
params.startDate = startDate.toISOString();
|
||||||
|
params.endDate = endDate.toISOString();
|
||||||
|
}
|
||||||
|
const response = await apiClient.get('/dashboard/kpis', { params });
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch KPIs:', error);
|
console.error('Failed to fetch KPIs:', error);
|
||||||
@ -328,11 +331,14 @@ class DashboardService {
|
|||||||
/**
|
/**
|
||||||
* Get department-wise statistics
|
* Get department-wise statistics
|
||||||
*/
|
*/
|
||||||
async getDepartmentStats(dateRange?: DateRange): Promise<DepartmentStats[]> {
|
async getDepartmentStats(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<DepartmentStats[]> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/dashboard/stats/by-department', {
|
const params: any = { dateRange };
|
||||||
params: { dateRange }
|
if (dateRange === 'custom' && startDate && endDate) {
|
||||||
});
|
params.startDate = startDate.toISOString();
|
||||||
|
params.endDate = endDate.toISOString();
|
||||||
|
}
|
||||||
|
const response = await apiClient.get('/dashboard/stats/by-department', { params });
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch department stats:', error);
|
console.error('Failed to fetch department stats:', error);
|
||||||
@ -343,11 +349,14 @@ class DashboardService {
|
|||||||
/**
|
/**
|
||||||
* Get priority distribution
|
* Get priority distribution
|
||||||
*/
|
*/
|
||||||
async getPriorityDistribution(dateRange?: DateRange): Promise<PriorityDistribution[]> {
|
async getPriorityDistribution(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<PriorityDistribution[]> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/dashboard/stats/priority-distribution', {
|
const params: any = { dateRange };
|
||||||
params: { dateRange }
|
if (dateRange === 'custom' && startDate && endDate) {
|
||||||
});
|
params.startDate = startDate.toISOString();
|
||||||
|
params.endDate = endDate.toISOString();
|
||||||
|
}
|
||||||
|
const response = await apiClient.get('/dashboard/stats/priority-distribution', { params });
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch priority distribution:', error);
|
console.error('Failed to fetch priority distribution:', error);
|
||||||
@ -358,11 +367,14 @@ class DashboardService {
|
|||||||
/**
|
/**
|
||||||
* Get AI Remark Utilization with monthly trends
|
* Get AI Remark Utilization with monthly trends
|
||||||
*/
|
*/
|
||||||
async getAIRemarkUtilization(dateRange?: DateRange): Promise<AIRemarkUtilization> {
|
async getAIRemarkUtilization(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<AIRemarkUtilization> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/dashboard/stats/ai-remark-utilization', {
|
const params: any = { dateRange };
|
||||||
params: { dateRange }
|
if (dateRange === 'custom' && startDate && endDate) {
|
||||||
});
|
params.startDate = startDate.toISOString();
|
||||||
|
params.endDate = endDate.toISOString();
|
||||||
|
}
|
||||||
|
const response = await apiClient.get('/dashboard/stats/ai-remark-utilization', { params });
|
||||||
return response.data.data;
|
return response.data.data;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch AI remark utilization:', error);
|
console.error('Failed to fetch AI remark utilization:', error);
|
||||||
@ -373,7 +385,7 @@ class DashboardService {
|
|||||||
/**
|
/**
|
||||||
* Get Approver Performance metrics with pagination
|
* Get Approver Performance metrics with pagination
|
||||||
*/
|
*/
|
||||||
async getApproverPerformance(dateRange?: DateRange, page: number = 1, limit: number = 10): Promise<{
|
async getApproverPerformance(dateRange?: DateRange, page: number = 1, limit: number = 10, startDate?: Date, endDate?: Date): Promise<{
|
||||||
performance: ApproverPerformance[],
|
performance: ApproverPerformance[],
|
||||||
pagination: {
|
pagination: {
|
||||||
currentPage: number,
|
currentPage: number,
|
||||||
@ -383,9 +395,12 @@ class DashboardService {
|
|||||||
}
|
}
|
||||||
}> {
|
}> {
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get('/dashboard/stats/approver-performance', {
|
const params: any = { dateRange, page, limit };
|
||||||
params: { dateRange, page, limit }
|
if (dateRange === 'custom' && startDate && endDate) {
|
||||||
});
|
params.startDate = startDate.toISOString();
|
||||||
|
params.endDate = endDate.toISOString();
|
||||||
|
}
|
||||||
|
const response = await apiClient.get('/dashboard/stats/approver-performance', { params });
|
||||||
return {
|
return {
|
||||||
performance: response.data.data,
|
performance: response.data.data,
|
||||||
pagination: response.data.pagination
|
pagination: response.data.pagination
|
||||||
@ -395,6 +410,97 @@ class DashboardService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Request Lifecycle Report
|
||||||
|
*/
|
||||||
|
async getLifecycleReport(page: number = 1, limit: number = 50): Promise<{
|
||||||
|
lifecycleData: any[],
|
||||||
|
pagination: {
|
||||||
|
currentPage: number,
|
||||||
|
totalPages: number,
|
||||||
|
totalRecords: number,
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/dashboard/reports/lifecycle', {
|
||||||
|
params: { page, limit }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
lifecycleData: response.data.data,
|
||||||
|
pagination: response.data.pagination
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch lifecycle report:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get enhanced User Activity Log Report
|
||||||
|
*/
|
||||||
|
async getActivityLogReport(
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 50,
|
||||||
|
dateRange?: DateRange,
|
||||||
|
filterUserId?: string,
|
||||||
|
filterType?: string,
|
||||||
|
filterCategory?: string,
|
||||||
|
filterSeverity?: string
|
||||||
|
): Promise<{
|
||||||
|
activities: any[],
|
||||||
|
pagination: {
|
||||||
|
currentPage: number,
|
||||||
|
totalPages: number,
|
||||||
|
totalRecords: number,
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/dashboard/reports/activity-log', {
|
||||||
|
params: { page, limit, dateRange, filterUserId, filterType, filterCategory, filterSeverity }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
activities: response.data.data,
|
||||||
|
pagination: response.data.pagination
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch activity log report:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Workflow Aging Report
|
||||||
|
*/
|
||||||
|
async getWorkflowAgingReport(
|
||||||
|
threshold: number = 7,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 50,
|
||||||
|
dateRange?: DateRange
|
||||||
|
): Promise<{
|
||||||
|
agingData: any[],
|
||||||
|
pagination: {
|
||||||
|
currentPage: number,
|
||||||
|
totalPages: number,
|
||||||
|
totalRecords: number,
|
||||||
|
limit: number
|
||||||
|
}
|
||||||
|
}> {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.get('/dashboard/reports/workflow-aging', {
|
||||||
|
params: { threshold, page, limit, dateRange }
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
agingData: response.data.data,
|
||||||
|
pagination: response.data.pagination
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch workflow aging report:', error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const dashboardService = new DashboardService();
|
export const dashboardService = new DashboardService();
|
||||||
|
|||||||
@ -159,9 +159,9 @@ export async function listWorkflows(params: { page?: number; limit?: number } =
|
|||||||
return res.data?.data || res.data;
|
return res.data?.data || res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listMyWorkflows(params: { page?: number; limit?: number } = {}) {
|
export async function listMyWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string } = {}) {
|
||||||
const { page = 1, limit = 20 } = params;
|
const { page = 1, limit = 20, search, status, priority } = params;
|
||||||
const res = await apiClient.get('/workflows/my', { params: { page, limit } });
|
const res = await apiClient.get('/workflows/my', { params: { page, limit, search, status, priority } });
|
||||||
// Response structure: { success, data: { data: [...], pagination: {...} } }
|
// Response structure: { success, data: { data: [...], pagination: {...} } }
|
||||||
return {
|
return {
|
||||||
data: res.data?.data?.data || res.data?.data || [],
|
data: res.data?.data?.data || res.data?.data || [],
|
||||||
@ -169,9 +169,9 @@ export async function listMyWorkflows(params: { page?: number; limit?: number }
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listOpenForMe(params: { page?: number; limit?: number } = {}) {
|
export async function listOpenForMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string } = {}) {
|
||||||
const { page = 1, limit = 20 } = params;
|
const { page = 1, limit = 20, search, status, priority, sortBy, sortOrder } = params;
|
||||||
const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit } });
|
const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit, search, status, priority, sortBy, sortOrder } });
|
||||||
// Response structure: { success, data: { data: [...], pagination: {...} } }
|
// Response structure: { success, data: { data: [...], pagination: {...} } }
|
||||||
return {
|
return {
|
||||||
data: res.data?.data?.data || res.data?.data || [],
|
data: res.data?.data?.data || res.data?.data || [],
|
||||||
@ -179,9 +179,9 @@ export async function listOpenForMe(params: { page?: number; limit?: number } =
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listClosedByMe(params: { page?: number; limit?: number } = {}) {
|
export async function listClosedByMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string } = {}) {
|
||||||
const { page = 1, limit = 20 } = params;
|
const { page = 1, limit = 20, search, status, priority, sortBy, sortOrder } = params;
|
||||||
const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit } });
|
const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit, search, status, priority, sortBy, sortOrder } });
|
||||||
// Response structure: { success, data: { data: [...], pagination: {...} } }
|
// Response structure: { success, data: { data: [...], pagination: {...} } }
|
||||||
return {
|
return {
|
||||||
data: res.data?.data?.data || res.data?.data || [],
|
data: res.data?.data?.data || res.data?.data || [],
|
||||||
|
|||||||
@ -235,6 +235,16 @@
|
|||||||
border-color: #d1d5db !important;
|
border-color: #d1d5db !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.border-gray-400 {
|
||||||
|
border-color: #9ca3af !important;
|
||||||
|
border-width: 1px !important;
|
||||||
|
border-style: solid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.border-gray-500 {
|
||||||
|
border-color: #6b7280 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.border-gray-200 {
|
.border-gray-200 {
|
||||||
border-color: #e5e7eb !important;
|
border-color: #e5e7eb !important;
|
||||||
}
|
}
|
||||||
@ -243,7 +253,53 @@
|
|||||||
border-color: #9ca3af !important;
|
border-color: #9ca3af !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Focus states for inputs */
|
.hover\:border-gray-500:hover {
|
||||||
|
border-color: #6b7280 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure all inputs have visible borders by default */
|
||||||
|
input[data-slot="input"],
|
||||||
|
textarea[data-slot="textarea"],
|
||||||
|
[data-slot="select-trigger"] {
|
||||||
|
border-width: 1px !important;
|
||||||
|
border-style: solid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Focus states for inputs - Override all blue focus colors with light green */
|
||||||
|
input:focus-visible,
|
||||||
|
input[type="text"]:focus-visible,
|
||||||
|
input[type="number"]:focus-visible,
|
||||||
|
input[type="email"]:focus-visible,
|
||||||
|
input[type="search"]:focus-visible,
|
||||||
|
input[type="password"]:focus-visible,
|
||||||
|
textarea:focus-visible,
|
||||||
|
select:focus-visible,
|
||||||
|
[data-slot="input"]:focus-visible,
|
||||||
|
[data-slot="textarea"]:focus-visible,
|
||||||
|
[data-slot="select-trigger"]:focus-visible,
|
||||||
|
input:focus,
|
||||||
|
input[type="text"]:focus,
|
||||||
|
input[type="number"]:focus,
|
||||||
|
input[type="email"]:focus,
|
||||||
|
input[type="search"]:focus,
|
||||||
|
textarea:focus,
|
||||||
|
select:focus {
|
||||||
|
border-color: var(--re-light-green) !important;
|
||||||
|
outline: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
--tw-ring-width: 0 !important;
|
||||||
|
--tw-ring-offset-width: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Override any blue focus classes */
|
||||||
|
.focus\:border-blue-400:focus,
|
||||||
|
.focus\:border-blue-500:focus,
|
||||||
|
.focus\:ring-blue-200:focus,
|
||||||
|
.focus\:ring-blue-500:focus {
|
||||||
|
border-color: var(--re-light-green) !important;
|
||||||
|
--tw-ring-color: var(--re-light-green) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.focus\:border-re-green:focus {
|
.focus\:border-re-green:focus {
|
||||||
border-color: var(--re-green) !important;
|
border-color: var(--re-green) !important;
|
||||||
}
|
}
|
||||||
@ -252,6 +308,15 @@
|
|||||||
--tw-ring-color: var(--re-green) !important;
|
--tw-ring-color: var(--re-green) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.focus-visible\:border-re-light-green:focus-visible {
|
||||||
|
border-color: var(--re-light-green) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.focus-visible\:ring-re-light-green\/30:focus-visible {
|
||||||
|
--tw-ring-width: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.focus\:ring-1:focus {
|
.focus\:ring-1:focus {
|
||||||
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
|
||||||
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
|
||||||
|
|||||||
@ -13,35 +13,130 @@ function urlBase64ToUint8Array(base64String: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function registerServiceWorker() {
|
export async function registerServiceWorker() {
|
||||||
if (!('serviceWorker' in navigator)) throw new Error('Service workers not supported');
|
if (!('serviceWorker' in navigator)) {
|
||||||
const register = await navigator.serviceWorker.register('/service-worker.js');
|
throw new Error('Service workers are not supported in this browser');
|
||||||
return register;
|
}
|
||||||
|
|
||||||
|
let registration: ServiceWorkerRegistration;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if service worker is already registered
|
||||||
|
const existingRegistration = await navigator.serviceWorker.getRegistration('/service-worker.js');
|
||||||
|
|
||||||
|
if (existingRegistration) {
|
||||||
|
// Service worker already registered, wait for it to be ready
|
||||||
|
registration = await navigator.serviceWorker.ready;
|
||||||
|
} else {
|
||||||
|
// Register new service worker
|
||||||
|
registration = await navigator.serviceWorker.register('/service-worker.js');
|
||||||
|
|
||||||
|
// Wait for the service worker to be ready (may take a moment)
|
||||||
|
registration = await navigator.serviceWorker.ready;
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to register service worker: ${error?.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return registration;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function subscribeUserToPush(register: ServiceWorkerRegistration) {
|
export async function subscribeUserToPush(register: ServiceWorkerRegistration) {
|
||||||
if (!VAPID_PUBLIC_KEY) throw new Error('Missing VAPID public key');
|
if (!VAPID_PUBLIC_KEY) {
|
||||||
const subscription = await register.pushManager.subscribe({
|
throw new Error('Missing VAPID public key configuration');
|
||||||
userVisibleOnly: true,
|
}
|
||||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
|
||||||
});
|
// Check if already subscribed
|
||||||
|
let subscription: PushSubscription;
|
||||||
|
try {
|
||||||
|
const existingSubscription = await register.pushManager.getSubscription();
|
||||||
|
|
||||||
|
if (existingSubscription) {
|
||||||
|
// Already subscribed, check if it's still valid
|
||||||
|
subscription = existingSubscription;
|
||||||
|
} else {
|
||||||
|
// Subscribe to push
|
||||||
|
subscription = await register.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Failed to subscribe to push notifications: ${error?.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert subscription to JSON format for backend
|
||||||
|
const subscriptionJson = subscription.toJSON();
|
||||||
|
|
||||||
// Attach auth token if available
|
// Attach auth token if available
|
||||||
const token = (window as any)?.localStorage?.getItem?.('accessToken') || (document?.cookie || '').match(/accessToken=([^;]+)/)?.[1] || '';
|
const token = (window as any)?.localStorage?.getItem?.('accessToken') ||
|
||||||
await fetch(`${VITE_BASE_URL}/api/v1/workflows/notifications/subscribe`, {
|
(document?.cookie || '').match(/accessToken=([^;]+)/)?.[1] || '';
|
||||||
method: 'POST',
|
|
||||||
headers: {
|
if (!token) {
|
||||||
'Content-Type': 'application/json',
|
throw new Error('Authentication token not found. Please log in again.');
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
}
|
||||||
},
|
|
||||||
body: JSON.stringify(subscription)
|
// Send subscription to backend
|
||||||
});
|
try {
|
||||||
|
const response = await fetch(`${VITE_BASE_URL}/api/v1/workflows/notifications/subscribe`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(subscriptionJson)
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
|
||||||
|
throw new Error(errorData?.error || errorData?.message || `Server error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (!result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to save subscription');
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
// If it's already our error, rethrow it
|
||||||
|
if (error instanceof Error && error.message.includes('Failed')) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
throw new Error(`Failed to save subscription to server: ${error?.message || 'Network error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
return subscription;
|
return subscription;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function setupPushNotifications() {
|
export async function setupPushNotifications() {
|
||||||
const permission = await Notification.requestPermission();
|
// Check if notifications are supported
|
||||||
if (permission !== 'granted') return;
|
if (!('Notification' in window)) {
|
||||||
const reg = await registerServiceWorker();
|
throw new Error('Notifications are not supported in this browser');
|
||||||
await subscribeUserToPush(reg);
|
}
|
||||||
|
|
||||||
|
// Request permission
|
||||||
|
let permission = Notification.permission;
|
||||||
|
|
||||||
|
if (permission === 'default') {
|
||||||
|
permission = await Notification.requestPermission();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (permission !== 'granted') {
|
||||||
|
throw new Error('Notification permission was denied. Please enable notifications in your browser settings.');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register service worker (or get existing)
|
||||||
|
let reg: ServiceWorkerRegistration;
|
||||||
|
try {
|
||||||
|
reg = await registerServiceWorker();
|
||||||
|
} catch (error: any) {
|
||||||
|
throw new Error(`Service worker registration failed: ${error?.message || 'Unknown error'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subscribe to push
|
||||||
|
try {
|
||||||
|
await subscribeUserToPush(reg);
|
||||||
|
} catch (error: any) {
|
||||||
|
throw error; // Re-throw with detailed message
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user