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 { Settings } from '@/pages/Settings';
|
||||
import { Notifications } from '@/pages/Notifications';
|
||||
import { DetailedReports } from '@/pages/DetailedReports';
|
||||
import { Admin } from '@/pages/Admin';
|
||||
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
|
||||
import { Toaster } from '@/components/ui/sonner';
|
||||
import { toast } from 'sonner';
|
||||
@ -624,6 +626,26 @@ function AppRoutes({ onLogout }: AppProps) {
|
||||
</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>
|
||||
|
||||
<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 { 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 { Input } from '@/components/ui/input';
|
||||
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: 'open-requests', label: 'Open Requests', icon: FileText },
|
||||
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
|
||||
{ id: 'admin', label: 'Admin', icon: Shield },
|
||||
];
|
||||
|
||||
const toggleSidebar = () => {
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Clock } from 'lucide-react';
|
||||
import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react';
|
||||
|
||||
export interface SLAData {
|
||||
status: 'normal' | 'approaching' | 'critical' | 'breached';
|
||||
@ -27,11 +27,14 @@ export function SLAProgressBar({
|
||||
if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
|
||||
return (
|
||||
<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">
|
||||
{requestStatus === 'closed' ? '🔒 Request Closed' :
|
||||
requestStatus === 'approved' ? '✅ Request Approved' :
|
||||
requestStatus === 'rejected' ? '❌ Request Rejected' : 'SLA Not Available'}
|
||||
{requestStatus === 'closed' ? 'Request Closed' :
|
||||
requestStatus === 'approved' ? 'Request Approved' :
|
||||
requestStatus === 'rejected' ? 'Request Rejected' : 'SLA Not Available'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
@ -91,13 +94,15 @@ export function SLAProgressBar({
|
||||
)}
|
||||
|
||||
{sla.status === 'critical' && (
|
||||
<p className="text-xs text-orange-600 font-semibold mt-1" data-testid={`${testId}-warning-critical`}>
|
||||
⚠️ Approaching Deadline
|
||||
<p className="text-xs text-orange-600 font-semibold mt-1 flex items-center gap-1.5" data-testid={`${testId}-warning-critical`}>
|
||||
<AlertTriangle className="h-3.5 w-3.5" />
|
||||
Approaching Deadline
|
||||
</p>
|
||||
)}
|
||||
{sla.status === 'breached' && (
|
||||
<p className="text-xs text-red-600 font-semibold mt-1" data-testid={`${testId}-warning-breached`}>
|
||||
🔴 URGENT - Deadline Passed
|
||||
<p className="text-xs text-red-600 font-semibold mt-1 flex items-center gap-1.5" data-testid={`${testId}-warning-breached`}>
|
||||
<AlertOctagon className="h-3.5 w-3.5" />
|
||||
URGENT - Deadline Passed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -8,9 +8,10 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
|
||||
type={type}
|
||||
data-slot="input"
|
||||
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",
|
||||
"focus-visible:ring-[3px]",
|
||||
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
"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:border-re-light-green focus-visible:ring-0 focus-visible:outline-none",
|
||||
"hover:border-gray-500",
|
||||
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -41,7 +41,10 @@ function SelectTrigger({
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -7,7 +7,10 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
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,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
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 { useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -19,21 +22,18 @@ import {
|
||||
FileText,
|
||||
Download,
|
||||
Eye,
|
||||
MoreHorizontal,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
Search,
|
||||
Hash,
|
||||
AtSign,
|
||||
Archive,
|
||||
Plus,
|
||||
Activity,
|
||||
Bell,
|
||||
Flag,
|
||||
X,
|
||||
FileSpreadsheet,
|
||||
Image,
|
||||
UserPlus
|
||||
UserPlus,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Message {
|
||||
@ -148,6 +148,24 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
||||
const socketRef = useRef<any>(null);
|
||||
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);
|
||||
|
||||
// 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)
|
||||
useEffect(() => {
|
||||
if (!currentUserId) return; // Wait for currentUserId to be loaded
|
||||
@ -894,10 +939,72 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
||||
}
|
||||
}, [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>) => {
|
||||
if (e.target.files) {
|
||||
const filesArray = Array.from(e.target.files);
|
||||
setSelectedFiles(prev => [...prev, ...filesArray]);
|
||||
if (!e.target.files || e.target.files.length === 0) return;
|
||||
|
||||
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}
|
||||
className="hidden"
|
||||
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 */}
|
||||
@ -1556,15 +1663,6 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
||||
>
|
||||
<AtSign className="h-4 w-4" />
|
||||
</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>
|
||||
|
||||
{/* 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
|
||||
${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="flex items-center justify-between mb-4">
|
||||
<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 flex-shrink-0">
|
||||
<h3 className="text-base sm:text-lg font-semibold text-gray-900">Participants</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@ -1613,7 +1711,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</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) => {
|
||||
const isCurrentUser = (participant as any).userId === currentUserId;
|
||||
return (
|
||||
@ -1642,16 +1740,13 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
||||
<p className="text-xs text-gray-400">{participant.lastSeen}</p>
|
||||
)}
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</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>
|
||||
<div className="space-y-2">
|
||||
{/* Only initiator can add approvers */}
|
||||
@ -1724,6 +1819,48 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
|
||||
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>
|
||||
);
|
||||
}
|
||||
@ -2,7 +2,7 @@ import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
||||
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle } from 'lucide-react';
|
||||
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon } from 'lucide-react';
|
||||
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
||||
|
||||
export interface ApprovalStep {
|
||||
@ -132,7 +132,10 @@ export function ApprovalStepCard({
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs bg-orange-50 border-orange-200">
|
||||
<p className="text-xs font-semibold text-orange-900 mb-1">⏭️ 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>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@ -197,7 +200,10 @@ export function ApprovalStepCard({
|
||||
{/* Conclusion Remark */}
|
||||
{step.comment && (
|
||||
<div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
|
||||
<p className="text-xs font-semibold text-gray-700 mb-2">💬 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>
|
||||
</div>
|
||||
)}
|
||||
@ -260,13 +266,15 @@ export function ApprovalStepCard({
|
||||
</span>
|
||||
</div>
|
||||
{approval.sla.status === 'breached' && (
|
||||
<p className="text-xs font-semibold text-center text-red-600">
|
||||
🔴 Deadline Breached
|
||||
<p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
|
||||
<AlertOctagon className="w-4 h-4" />
|
||||
Deadline Breached
|
||||
</p>
|
||||
)}
|
||||
{approval.sla.status === 'critical' && (
|
||||
<p className="text-xs font-semibold text-center text-orange-600">
|
||||
⚠️ Approaching Deadline
|
||||
<p className="text-xs font-semibold text-center text-orange-600 flex items-center justify-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4" />
|
||||
Approaching Deadline
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@ -278,7 +286,10 @@ export function ApprovalStepCard({
|
||||
{isWaiting && (
|
||||
<div className="space-y-2">
|
||||
<div className="bg-gray-100 border border-gray-300 rounded-lg p-3">
|
||||
<p className="text-xs text-gray-600 mb-1">⏸️ 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-xs text-gray-500 mt-2">Allocated {tatHours} hours for approval</p>
|
||||
</div>
|
||||
@ -288,7 +299,10 @@ export function ApprovalStepCard({
|
||||
{/* Rejected Status */}
|
||||
{isRejected && step.comment && (
|
||||
<div className="mt-3 p-3 sm:p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
|
||||
<p className="text-xs font-semibold text-red-700 mb-2">❌ 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>
|
||||
</div>
|
||||
)}
|
||||
@ -296,7 +310,10 @@ export function ApprovalStepCard({
|
||||
{/* Skipped Status */}
|
||||
{step.isSkipped && step.skipReason && (
|
||||
<div className="mt-3 p-3 sm:p-4 bg-orange-50 border-l-4 border-orange-500 rounded-r-lg">
|
||||
<p className="text-xs font-semibold text-orange-700 mb-2">⏭️ 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>
|
||||
{step.timestamp && (
|
||||
<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}`}
|
||||
>
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="text-base sm:text-lg flex-shrink-0">
|
||||
{(alert.thresholdPercentage || 0) === 50 && '⏳'}
|
||||
{(alert.thresholdPercentage || 0) === 75 && '⚠️'}
|
||||
{(alert.thresholdPercentage || 0) === 100 && '⏰'}
|
||||
<div className="flex-shrink-0 mt-0.5">
|
||||
{(alert.thresholdPercentage || 0) === 50 && (
|
||||
<Hourglass className="w-5 h-5 text-yellow-600" />
|
||||
)}
|
||||
{(alert.thresholdPercentage || 0) === 75 && (
|
||||
<AlertTriangle className="w-5 h-5 text-orange-600" />
|
||||
)}
|
||||
{(alert.thresholdPercentage || 0) === 100 && (
|
||||
<AlertOctagon className="w-5 h-5 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 mb-1">
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { uploadDocument } from '@/services/documentApi';
|
||||
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
/**
|
||||
* Custom Hook: useDocumentUpload
|
||||
@ -33,6 +35,83 @@ export function useDocumentUpload(
|
||||
fileSize?: number;
|
||||
} | null>(null);
|
||||
|
||||
// Document policy state
|
||||
const [documentPolicy, setDocumentPolicy] = useState<{
|
||||
maxFileSizeMB: number;
|
||||
allowedFileTypes: string[];
|
||||
}>({
|
||||
maxFileSizeMB: 10,
|
||||
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif']
|
||||
});
|
||||
|
||||
// Document validation error state
|
||||
const [documentError, setDocumentError] = useState<{
|
||||
show: boolean;
|
||||
errors: Array<{ fileName: string; reason: string }>;
|
||||
}>({
|
||||
show: false,
|
||||
errors: []
|
||||
});
|
||||
|
||||
// Fetch document policy on mount
|
||||
useEffect(() => {
|
||||
const loadDocumentPolicy = async () => {
|
||||
try {
|
||||
const configs = await getAllConfigurations('DOCUMENT_POLICY');
|
||||
const configMap: Record<string, string> = {};
|
||||
configs.forEach((c: AdminConfiguration) => {
|
||||
configMap[c.configKey] = c.configValue;
|
||||
});
|
||||
|
||||
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10');
|
||||
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
|
||||
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
|
||||
|
||||
setDocumentPolicy({
|
||||
maxFileSizeMB,
|
||||
allowedFileTypes
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load document policy:', error);
|
||||
// Use defaults if loading fails
|
||||
}
|
||||
};
|
||||
|
||||
loadDocumentPolicy();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Function: validateFile
|
||||
*
|
||||
* Purpose: Validate file against document policy
|
||||
*
|
||||
* @param file - File to validate
|
||||
* @returns Validation result with reason if invalid
|
||||
*/
|
||||
const validateFile = (file: File): { valid: boolean; reason?: string } => {
|
||||
// Check file size
|
||||
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
|
||||
if (file.size > maxSizeBytes) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
|
||||
};
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
const fileName = file.name.toLowerCase();
|
||||
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
|
||||
|
||||
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
|
||||
return {
|
||||
valid: false,
|
||||
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
|
||||
};
|
||||
}
|
||||
|
||||
return { valid: true };
|
||||
};
|
||||
|
||||
/**
|
||||
* Function: handleDocumentUpload
|
||||
*
|
||||
@ -40,11 +119,12 @@ export function useDocumentUpload(
|
||||
*
|
||||
* Process:
|
||||
* 1. Validate file selection
|
||||
* 2. Get request UUID (required for backend API)
|
||||
* 3. Upload file to backend
|
||||
* 4. Refresh request details to show new document
|
||||
* 5. Clear file input for next upload
|
||||
* 6. Show success/error messages
|
||||
* 2. Validate against document policy
|
||||
* 3. Get request UUID (required for backend API)
|
||||
* 4. Upload file to backend
|
||||
* 5. Refresh request details to show new document
|
||||
* 6. Clear file input for next upload
|
||||
* 7. Show success/error messages
|
||||
*
|
||||
* @param event - File input change event
|
||||
*/
|
||||
@ -54,22 +134,51 @@ export function useDocumentUpload(
|
||||
// Validate: Check if file is selected
|
||||
if (!files || files.length === 0) return;
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
|
||||
// Validate all files against document policy
|
||||
const validationErrors: Array<{ fileName: string; reason: string }> = [];
|
||||
const validFiles: File[] = [];
|
||||
|
||||
fileArray.forEach(file => {
|
||||
const validation = validateFile(file);
|
||||
if (!validation.valid) {
|
||||
validationErrors.push({
|
||||
fileName: file.name,
|
||||
reason: validation.reason || 'Unknown validation error'
|
||||
});
|
||||
} else {
|
||||
validFiles.push(file);
|
||||
}
|
||||
});
|
||||
|
||||
// If there are validation errors, show modal
|
||||
if (validationErrors.length > 0) {
|
||||
setDocumentError({
|
||||
show: true,
|
||||
errors: validationErrors
|
||||
});
|
||||
}
|
||||
|
||||
// If no valid files, stop here
|
||||
if (validFiles.length === 0) {
|
||||
if (event.target) {
|
||||
event.target.value = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadingDocument(true);
|
||||
|
||||
try {
|
||||
const file = files[0];
|
||||
|
||||
// Validate: Ensure file exists
|
||||
if (!file) {
|
||||
alert('No file selected');
|
||||
return;
|
||||
}
|
||||
// Upload only the first valid file (backend currently supports single file)
|
||||
const file = validFiles[0];
|
||||
|
||||
// Validate: Ensure request ID is available
|
||||
// Note: Backend requires UUID, not request number
|
||||
const requestId = apiRequest?.requestId;
|
||||
if (!requestId) {
|
||||
alert('Request ID not found');
|
||||
toast.error('Request ID not found');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -82,12 +191,16 @@ export function useDocumentUpload(
|
||||
await refreshDetails();
|
||||
|
||||
// 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) {
|
||||
console.error('[useDocumentUpload] Upload error:', error);
|
||||
|
||||
// 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 {
|
||||
setUploadingDocument(false);
|
||||
|
||||
@ -105,18 +218,14 @@ export function useDocumentUpload(
|
||||
*
|
||||
* Process:
|
||||
* 1. Create temporary file input element
|
||||
* 2. Configure accepted file types
|
||||
* 2. Configure accepted file types based on document policy
|
||||
* 3. Attach upload handler
|
||||
* 4. Trigger click to open file picker
|
||||
*
|
||||
* Accepted formats:
|
||||
* - Documents: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT
|
||||
* - Images: JPG, JPEG, PNG, GIF
|
||||
*/
|
||||
const triggerFileInput = () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.gif';
|
||||
input.accept = documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',');
|
||||
input.onchange = handleDocumentUpload as any;
|
||||
input.click();
|
||||
};
|
||||
@ -126,7 +235,10 @@ export function useDocumentUpload(
|
||||
handleDocumentUpload,
|
||||
triggerFileInput,
|
||||
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 { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
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 { formatDateTime } from '@/utils/dateFormatter';
|
||||
|
||||
@ -97,7 +97,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority'>('due');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
const [items, setItems] = useState<Request[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
@ -108,14 +107,24 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||
const [totalRecords, setTotalRecords] = useState(0);
|
||||
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 {
|
||||
if (page === 1) {
|
||||
setLoading(true);
|
||||
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
|
||||
|
||||
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
||||
@ -133,24 +142,22 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||
setTotalRecords(pagination.total || 0);
|
||||
}
|
||||
|
||||
const mapped: Request[] = data
|
||||
.filter((r: any) => ['APPROVED', 'REJECTED', 'CLOSED'].includes((r.status || '').toString()))
|
||||
.map((r: any) => ({
|
||||
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
|
||||
requestId: r.requestId, // Keep requestId for reference
|
||||
displayId: r.requestNumber || r.request_number || r.requestId,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
status: (r.status || '').toString().toLowerCase(),
|
||||
priority: (r.priority || '').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() },
|
||||
createdAt: r.submittedAt || r.createdAt || '—',
|
||||
dueDate: r.closureDate || r.closure_date || undefined,
|
||||
reason: r.conclusionRemark || r.conclusion_remark,
|
||||
department: r.department,
|
||||
totalLevels: r.totalLevels || 0,
|
||||
completedLevels: r.summary?.approvedLevels || 0,
|
||||
}));
|
||||
const mapped: Request[] = data.map((r: any) => ({
|
||||
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
|
||||
requestId: r.requestId, // Keep requestId for reference
|
||||
displayId: r.requestNumber || r.request_number || r.requestId,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
status: (r.status || '').toString().toLowerCase(),
|
||||
priority: (r.priority || '').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() },
|
||||
createdAt: r.submittedAt || r.createdAt || r.created_at || '—',
|
||||
dueDate: r.closureDate || r.closure_date || r.closedAt || undefined,
|
||||
reason: r.conclusionRemark || r.conclusion_remark,
|
||||
department: r.department,
|
||||
totalLevels: r.totalLevels || 0,
|
||||
completedLevels: r.summary?.approvedLevels || 0,
|
||||
}));
|
||||
setItems(mapped);
|
||||
} catch (error) {
|
||||
console.error('[ClosedRequests] Error fetching requests:', error);
|
||||
@ -159,17 +166,29 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
}, [itemsPerPage]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
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) => {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
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;
|
||||
};
|
||||
|
||||
// Initial fetch on mount and when filters/sorting change (with debouncing for search)
|
||||
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
|
||||
|
||||
setCurrentPage(1); // Reset to page 1 when filters change
|
||||
fetchRequests(1, {
|
||||
search: searchTerm || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
|
||||
|
||||
const filteredAndSortedRequests = useMemo(() => {
|
||||
let filtered = items.filter(request => {
|
||||
const matchesSearch =
|
||||
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;
|
||||
});
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, fetchRequests]);
|
||||
|
||||
// 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]);
|
||||
// Backend handles both filtering and sorting - use items directly
|
||||
// No client-side filtering/sorting needed anymore
|
||||
const filteredAndSortedRequests = items;
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
@ -304,28 +303,17 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
{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>
|
||||
)}
|
||||
{activeFiltersCount > 0 && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
className="gap-1 sm:gap-2 h-8 sm:h-9 px-2 sm:px-3"
|
||||
onClick={clearFilters}
|
||||
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" />
|
||||
<span className="text-xs sm:text-sm hidden md:inline">{showAdvancedFilters ? 'Basic' : 'Advanced'}</span>
|
||||
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
|
||||
<span className="text-xs sm:text-sm">Clear</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">
|
||||
|
||||
@ -40,10 +40,13 @@ import {
|
||||
Minus,
|
||||
Eye,
|
||||
Lightbulb,
|
||||
Settings
|
||||
Settings,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface CreateRequestProps {
|
||||
onBack?: () => void;
|
||||
@ -221,10 +224,30 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
|
||||
const totalSteps = STEP_NAMES.length;
|
||||
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 [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);
|
||||
|
||||
// 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
|
||||
const [validationModal, setValidationModal] = useState<{
|
||||
open: boolean;
|
||||
@ -238,6 +261,33 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
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
|
||||
useEffect(() => {
|
||||
if (!isEditing || !editRequestId) return;
|
||||
@ -775,13 +825,79 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
updateFormData('documents', [...formData.documents, ...files]);
|
||||
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 handleSubmit = () => {
|
||||
if (!isStepValid()) return;
|
||||
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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
|
||||
const initiatorId = user?.userId || '';
|
||||
@ -935,59 +1051,68 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
|
||||
if (hasNewFiles || hasDeletions) {
|
||||
// Use multipart update
|
||||
updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete)
|
||||
.then(async () => {
|
||||
// Submit the updated workflow
|
||||
try {
|
||||
await submitWorkflow(editRequestId);
|
||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||
} catch (err) {
|
||||
console.error('Failed to submit workflow:', err);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to update workflow:', err);
|
||||
});
|
||||
try {
|
||||
await updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete);
|
||||
// Submit the updated workflow
|
||||
try {
|
||||
await submitWorkflow(editRequestId);
|
||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||
} catch (err) {
|
||||
console.error('Failed to submit workflow:', err);
|
||||
setSubmitting(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update workflow:', err);
|
||||
setSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
// Use regular update
|
||||
updateWorkflow(editRequestId, updatePayload)
|
||||
.then(async () => {
|
||||
// Submit the updated workflow
|
||||
try {
|
||||
await submitWorkflow(editRequestId);
|
||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||
} catch (err) {
|
||||
console.error('Failed to submit workflow:', err);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to update workflow:', err);
|
||||
});
|
||||
try {
|
||||
await updateWorkflow(editRequestId, updatePayload);
|
||||
// Submit the updated workflow
|
||||
try {
|
||||
await submitWorkflow(editRequestId);
|
||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||
} catch (err) {
|
||||
console.error('Failed to submit workflow:', err);
|
||||
setSubmitting(false);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update workflow:', err);
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Create new workflow
|
||||
createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING')
|
||||
.then(async (res) => {
|
||||
const id = (res as any).id;
|
||||
try {
|
||||
await submitWorkflow(id);
|
||||
} catch {}
|
||||
try {
|
||||
const res = await createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING');
|
||||
const id = (res as any).id;
|
||||
try {
|
||||
await submitWorkflow(id);
|
||||
onSubmit?.({ ...formData, backendId: id, template: selectedTemplate });
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('Failed to create workflow:', err);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to submit 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
|
||||
if (!selectedTemplate || !formData.title.trim() || !formData.description.trim() || !formData.priority) {
|
||||
// allow minimal validation for draft: require title/description/priority/template
|
||||
return;
|
||||
}
|
||||
|
||||
if (submitting || savingDraft) return;
|
||||
|
||||
setSavingDraft(true);
|
||||
|
||||
// Handle edit mode - update existing draft with full structure
|
||||
if (isEditing && editRequestId) {
|
||||
// Build approval levels
|
||||
@ -1055,18 +1180,22 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
|
||||
if (hasNewFiles || hasDeletions) {
|
||||
// Use multipart update
|
||||
updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete)
|
||||
.then(() => {
|
||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||
})
|
||||
.catch((err) => console.error('Failed to update draft:', err));
|
||||
try {
|
||||
await updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete);
|
||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||
} catch (err) {
|
||||
console.error('Failed to update draft:', err);
|
||||
setSavingDraft(false);
|
||||
}
|
||||
} else {
|
||||
// Use regular update
|
||||
updateWorkflow(editRequestId, updatePayload)
|
||||
.then(() => {
|
||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||
})
|
||||
.catch((err) => console.error('Failed to update draft:', err));
|
||||
try {
|
||||
await updateWorkflow(editRequestId, updatePayload);
|
||||
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
|
||||
} catch (err) {
|
||||
console.error('Failed to update draft:', err);
|
||||
setSavingDraft(false);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
@ -1147,11 +1276,13 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
})),
|
||||
participants: participants, // Include participants array for draft
|
||||
};
|
||||
createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING')
|
||||
.then((res) => {
|
||||
onSubmit?.({ ...formData, backendId: (res as any).id, template: selectedTemplate });
|
||||
})
|
||||
.catch((err) => console.error('Failed to save draft:', err));
|
||||
try {
|
||||
const res = await createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING');
|
||||
onSubmit?.({ ...formData, backendId: (res as any).id, template: selectedTemplate });
|
||||
} catch (err) {
|
||||
console.error('Failed to save draft:', err);
|
||||
setSavingDraft(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 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" />
|
||||
File Upload
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Attach supporting documents (PDF, Word, Excel, Images). Max 10MB per file.
|
||||
</CardDescription>
|
||||
<CardDescription>
|
||||
Attach supporting documents. Max {documentPolicy.maxFileSizeMB}MB per file. Allowed types: {documentPolicy.allowedFileTypes.join(', ')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<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
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.ppt,.pptx"
|
||||
accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
id="file-upload"
|
||||
@ -2292,7 +2423,7 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
Browse Files
|
||||
</Button>
|
||||
<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>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -2986,19 +3117,35 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
|
||||
onClick={handleSaveDraft}
|
||||
size="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>
|
||||
{currentStep === totalSteps ? (
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!isStepValid() || loadingDraft}
|
||||
disabled={!isStepValid() || loadingDraft || submitting || savingDraft}
|
||||
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"
|
||||
>
|
||||
<Rocket className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
|
||||
Submit
|
||||
{submitting ? (
|
||||
<>
|
||||
<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
|
||||
@ -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 */}
|
||||
<Dialog open={validationModal.open} onOpenChange={(open) => setValidationModal(prev => ({ ...prev, open }))}>
|
||||
<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 { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@ -15,7 +15,6 @@ import {
|
||||
Edit,
|
||||
Flame,
|
||||
Target,
|
||||
Eye,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
@ -72,11 +71,11 @@ const getStatusConfig = (status: string) => {
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600'
|
||||
};
|
||||
case 'in-review':
|
||||
case 'closed':
|
||||
return {
|
||||
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
icon: Eye,
|
||||
iconColor: 'text-blue-600'
|
||||
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-gray-600'
|
||||
};
|
||||
case 'draft':
|
||||
return {
|
||||
@ -97,6 +96,7 @@ const getStatusConfig = (status: string) => {
|
||||
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [priorityFilter, setPriorityFilter] = useState('all');
|
||||
const [apiRequests, setApiRequests] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hasFetchedFromApi, setHasFetchedFromApi] = useState(false);
|
||||
@ -107,14 +107,20 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
const [totalRecords, setTotalRecords] = useState(0);
|
||||
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 {
|
||||
if (page === 1) {
|
||||
setLoading(true);
|
||||
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
|
||||
|
||||
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
||||
@ -141,18 +147,44 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, [itemsPerPage]);
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
setCurrentPage(newPage);
|
||||
fetchMyRequests(newPage);
|
||||
fetchMyRequests(newPage, {
|
||||
search: searchTerm || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Initial fetch on mount
|
||||
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
|
||||
// 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,
|
||||
title: req.title,
|
||||
description: req.description,
|
||||
status: (req.status || '').toString().toLowerCase().replace('in_progress','in-review'),
|
||||
status: (req.status || '').toString().toLowerCase().replace('_','-'),
|
||||
priority: priority,
|
||||
department: req.department,
|
||||
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 [priorityFilter, setPriorityFilter] = useState('all');
|
||||
|
||||
// Filter requests based on search and filters
|
||||
const filteredRequests = allRequests.filter(request => {
|
||||
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;
|
||||
});
|
||||
// No frontend filtering - backend handles all filtering
|
||||
const filteredRequests = allRequests;
|
||||
|
||||
// Stats calculation - using total from pagination for total count
|
||||
const stats = {
|
||||
total: totalRecords || allRequests.length,
|
||||
pending: allRequests.filter(r => r.status === 'pending').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,
|
||||
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 (
|
||||
@ -235,14 +256,14 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
label="In Progress"
|
||||
value={stats.pending + stats.inReview}
|
||||
label="Pending"
|
||||
value={stats.pending}
|
||||
icon={Clock}
|
||||
iconColor="text-orange-600"
|
||||
gradient="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200"
|
||||
textColor="text-orange-700"
|
||||
valueColor="text-orange-900"
|
||||
testId="stat-in-progress"
|
||||
testId="stat-pending"
|
||||
/>
|
||||
|
||||
<StatsCard
|
||||
@ -289,7 +310,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
placeholder="Search requests by title, description, or ID..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="pl-9 text-sm sm:text-base bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@ -297,7 +318,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
<div className="flex gap-2 sm:gap-3 w-full md:w-auto">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<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"
|
||||
>
|
||||
<SelectValue placeholder="Status" />
|
||||
@ -306,15 +327,15 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="draft">Draft</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="in-review">In Review</SelectItem>
|
||||
<SelectItem value="approved">Approved</SelectItem>
|
||||
<SelectItem value="rejected">Rejected</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={priorityFilter} onValueChange={setPriorityFilter}>
|
||||
<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"
|
||||
>
|
||||
<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 { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@ -6,14 +6,14 @@ import { Input } from '@/components/ui/input';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
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 { formatDateShort } from '@/utils/dateFormatter';
|
||||
interface Request {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
status: 'pending' | 'in-review' | 'approved';
|
||||
status: 'pending' | 'approved' | 'rejected' | 'closed';
|
||||
priority: 'express' | 'standard';
|
||||
initiator: { name: string; avatar: string };
|
||||
currentApprover?: {
|
||||
@ -61,13 +61,8 @@ const getStatusConfig = (status: string) => {
|
||||
return {
|
||||
color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
|
||||
icon: Clock,
|
||||
iconColor: 'text-yellow-600'
|
||||
};
|
||||
case 'in-review':
|
||||
return {
|
||||
color: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
icon: Eye,
|
||||
iconColor: 'text-blue-600'
|
||||
iconColor: 'text-yellow-600',
|
||||
label: 'Pending'
|
||||
};
|
||||
case 'approved':
|
||||
return {
|
||||
@ -76,11 +71,26 @@ const getStatusConfig = (status: string) => {
|
||||
iconColor: 'text-green-600',
|
||||
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:
|
||||
return {
|
||||
color: 'bg-gray-100 text-gray-800 border-gray-200',
|
||||
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 [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 {
|
||||
if (page === 1) {
|
||||
setLoading(true);
|
||||
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
|
||||
|
||||
// Extract data - workflowApi now returns { data: [], pagination: {} }
|
||||
@ -138,7 +156,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
displayId: r.requestNumber || r.request_number || r.requestId,
|
||||
title: r.title,
|
||||
description: r.description,
|
||||
status: ((r.status || '').toString().toUpperCase() === 'IN_PROGRESS') ? 'in-review' : 'pending',
|
||||
status: (r.status || '').toString().toLowerCase().replace('_', '-'),
|
||||
priority: (r.priority || '').toString().toLowerCase(),
|
||||
initiator: {
|
||||
name: (r.initiator?.displayName || r.initiator?.email || '—'),
|
||||
@ -160,17 +178,29 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
}, [itemsPerPage]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
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) => {
|
||||
if (newPage >= 1 && newPage <= totalPages) {
|
||||
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;
|
||||
};
|
||||
|
||||
// Initial fetch on mount
|
||||
useEffect(() => {
|
||||
fetchRequests(1);
|
||||
}, []);
|
||||
|
||||
const filteredAndSortedRequests = useMemo(() => {
|
||||
let filtered = items.filter(request => {
|
||||
const matchesSearch =
|
||||
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(1, {
|
||||
search: searchTerm || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
|
||||
// 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.currentLevelSLA?.deadline ? new Date(a.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
bValue = b.currentLevelSLA?.deadline ? new Date(b.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER;
|
||||
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;
|
||||
}, [fetchRequests]);
|
||||
|
||||
// Fetch when filters or sorting change (with debouncing for search)
|
||||
useEffect(() => {
|
||||
// Debounce search: wait 500ms after user stops typing
|
||||
const timeoutId = setTimeout(() => {
|
||||
if (items.length > 0 || loading) { // Only refetch if we've already loaded data once
|
||||
setCurrentPage(1); // Reset to page 1 when filters change
|
||||
fetchRequests(1, {
|
||||
search: searchTerm || undefined,
|
||||
status: statusFilter !== 'all' ? statusFilter : undefined,
|
||||
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
}
|
||||
|
||||
if (sortOrder === 'asc') {
|
||||
return aValue > bValue ? 1 : -1;
|
||||
} else {
|
||||
return aValue < bValue ? 1 : -1;
|
||||
}
|
||||
});
|
||||
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
|
||||
|
||||
return filtered;
|
||||
}, [items, searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, fetchRequests]);
|
||||
|
||||
// Backend handles both filtering and sorting - use items directly
|
||||
// No client-side sorting needed anymore
|
||||
const filteredAndSortedRequests = items;
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearchTerm('');
|
||||
@ -343,12 +353,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
placeholder="Search requests, IDs..."
|
||||
value={searchTerm}
|
||||
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>
|
||||
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@ -369,19 +379,19 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
|
||||
</Select>
|
||||
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Statuses</SelectItem>
|
||||
<SelectItem value="pending">Pending</SelectItem>
|
||||
<SelectItem value="in-review">In Review</SelectItem>
|
||||
<SelectItem value="pending">Pending (In Approval)</SelectItem>
|
||||
<SelectItem value="approved">Approved (Needs Closure)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<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" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
|
||||
@ -85,13 +85,13 @@ export function Profile() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
{/* <Button
|
||||
variant="secondary"
|
||||
className="bg-white text-slate-900 hover:bg-gray-100"
|
||||
>
|
||||
<Edit className="w-4 h-4 mr-2" />
|
||||
Edit Profile
|
||||
</Button>
|
||||
</Button> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -31,6 +31,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
|
||||
// Utility imports
|
||||
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
|
||||
@ -77,7 +78,8 @@ import {
|
||||
UserPlus,
|
||||
ClipboardList,
|
||||
AlertTriangle,
|
||||
Loader2
|
||||
Loader2,
|
||||
AlertCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
/**
|
||||
@ -235,7 +237,10 @@ function RequestDetailInner({
|
||||
uploadingDocument,
|
||||
triggerFileInput,
|
||||
previewDocument,
|
||||
setPreviewDocument
|
||||
setPreviewDocument,
|
||||
documentPolicy,
|
||||
documentError,
|
||||
setDocumentError
|
||||
} = 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>
|
||||
</div>
|
||||
{/* Upload Document Button */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={triggerFileInput}
|
||||
disabled={uploadingDocument || request.status === 'closed'}
|
||||
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'}
|
||||
<span className="hidden sm:inline">{request.status === 'closed' ? '' : 'Document'}</span>
|
||||
</Button>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={triggerFileInput}
|
||||
disabled={uploadingDocument || request.status === 'closed'}
|
||||
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'}
|
||||
<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>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@ -1226,6 +1236,48 @@ function RequestDetailInner({
|
||||
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 [notificationSuccess, setNotificationSuccess] = useState(false);
|
||||
const [notificationMessage, setNotificationMessage] = useState<string>();
|
||||
const [isEnablingNotifications, setIsEnablingNotifications] = useState(false);
|
||||
|
||||
const handleEnableNotifications = async () => {
|
||||
setIsEnablingNotifications(true);
|
||||
setShowNotificationModal(false);
|
||||
|
||||
try {
|
||||
await setupPushNotifications();
|
||||
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);
|
||||
} catch (error: any) {
|
||||
console.error('[Settings] Error enabling notifications:', error);
|
||||
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);
|
||||
} finally {
|
||||
setIsEnablingNotifications(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -120,10 +137,11 @@ export function Settings() {
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
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" />
|
||||
Enable Push Notifications
|
||||
<Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
|
||||
{isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
@ -234,10 +252,11 @@ export function Settings() {
|
||||
<div className="space-y-4">
|
||||
<Button
|
||||
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" />
|
||||
Enable Push Notifications
|
||||
<Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
|
||||
{isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@ -154,17 +154,20 @@ export interface PriorityDistribution {
|
||||
complianceRate: number;
|
||||
}
|
||||
|
||||
export type DateRange = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'last30days';
|
||||
export type DateRange = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'last30days' | 'custom';
|
||||
|
||||
class DashboardService {
|
||||
/**
|
||||
* Get all KPI metrics
|
||||
*/
|
||||
async getKPIs(dateRange?: DateRange): Promise<DashboardKPIs> {
|
||||
async getKPIs(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<DashboardKPIs> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/kpis', {
|
||||
params: { dateRange }
|
||||
});
|
||||
const params: any = { 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;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch KPIs:', error);
|
||||
@ -328,11 +331,14 @@ class DashboardService {
|
||||
/**
|
||||
* Get department-wise statistics
|
||||
*/
|
||||
async getDepartmentStats(dateRange?: DateRange): Promise<DepartmentStats[]> {
|
||||
async getDepartmentStats(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<DepartmentStats[]> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/by-department', {
|
||||
params: { dateRange }
|
||||
});
|
||||
const params: any = { 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;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch department stats:', error);
|
||||
@ -343,11 +349,14 @@ class DashboardService {
|
||||
/**
|
||||
* Get priority distribution
|
||||
*/
|
||||
async getPriorityDistribution(dateRange?: DateRange): Promise<PriorityDistribution[]> {
|
||||
async getPriorityDistribution(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<PriorityDistribution[]> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/priority-distribution', {
|
||||
params: { dateRange }
|
||||
});
|
||||
const params: any = { 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;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch priority distribution:', error);
|
||||
@ -358,11 +367,14 @@ class DashboardService {
|
||||
/**
|
||||
* Get AI Remark Utilization with monthly trends
|
||||
*/
|
||||
async getAIRemarkUtilization(dateRange?: DateRange): Promise<AIRemarkUtilization> {
|
||||
async getAIRemarkUtilization(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<AIRemarkUtilization> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/ai-remark-utilization', {
|
||||
params: { dateRange }
|
||||
});
|
||||
const params: any = { 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;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch AI remark utilization:', error);
|
||||
@ -373,7 +385,7 @@ class DashboardService {
|
||||
/**
|
||||
* 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[],
|
||||
pagination: {
|
||||
currentPage: number,
|
||||
@ -383,9 +395,12 @@ class DashboardService {
|
||||
}
|
||||
}> {
|
||||
try {
|
||||
const response = await apiClient.get('/dashboard/stats/approver-performance', {
|
||||
params: { dateRange, page, limit }
|
||||
});
|
||||
const params: any = { 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 {
|
||||
performance: response.data.data,
|
||||
pagination: response.data.pagination
|
||||
@ -395,6 +410,97 @@ class DashboardService {
|
||||
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();
|
||||
|
||||
@ -159,9 +159,9 @@ export async function listWorkflows(params: { page?: number; limit?: number } =
|
||||
return res.data?.data || res.data;
|
||||
}
|
||||
|
||||
export async function listMyWorkflows(params: { page?: number; limit?: number } = {}) {
|
||||
const { page = 1, limit = 20 } = params;
|
||||
const res = await apiClient.get('/workflows/my', { params: { page, limit } });
|
||||
export async function listMyWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string } = {}) {
|
||||
const { page = 1, limit = 20, search, status, priority } = params;
|
||||
const res = await apiClient.get('/workflows/my', { params: { page, limit, search, status, priority } });
|
||||
// Response structure: { success, data: { data: [...], pagination: {...} } }
|
||||
return {
|
||||
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 } = {}) {
|
||||
const { page = 1, limit = 20 } = params;
|
||||
const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit } });
|
||||
export async function listOpenForMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string } = {}) {
|
||||
const { page = 1, limit = 20, search, status, priority, sortBy, sortOrder } = params;
|
||||
const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit, search, status, priority, sortBy, sortOrder } });
|
||||
// Response structure: { success, data: { data: [...], pagination: {...} } }
|
||||
return {
|
||||
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 } = {}) {
|
||||
const { page = 1, limit = 20 } = params;
|
||||
const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit } });
|
||||
export async function listClosedByMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string } = {}) {
|
||||
const { page = 1, limit = 20, search, status, priority, sortBy, sortOrder } = params;
|
||||
const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit, search, status, priority, sortBy, sortOrder } });
|
||||
// Response structure: { success, data: { data: [...], pagination: {...} } }
|
||||
return {
|
||||
data: res.data?.data?.data || res.data?.data || [],
|
||||
|
||||
@ -235,6 +235,16 @@
|
||||
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-color: #e5e7eb !important;
|
||||
}
|
||||
@ -243,7 +253,53 @@
|
||||
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 {
|
||||
border-color: var(--re-green) !important;
|
||||
}
|
||||
@ -252,6 +308,15 @@
|
||||
--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 {
|
||||
--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);
|
||||
|
||||
@ -13,35 +13,130 @@ function urlBase64ToUint8Array(base64String: string) {
|
||||
}
|
||||
|
||||
export async function registerServiceWorker() {
|
||||
if (!('serviceWorker' in navigator)) throw new Error('Service workers not supported');
|
||||
const register = await navigator.serviceWorker.register('/service-worker.js');
|
||||
return register;
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
throw new Error('Service workers are not supported in this browser');
|
||||
}
|
||||
|
||||
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) {
|
||||
if (!VAPID_PUBLIC_KEY) throw new Error('Missing VAPID public key');
|
||||
const subscription = await register.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
|
||||
});
|
||||
if (!VAPID_PUBLIC_KEY) {
|
||||
throw new Error('Missing VAPID public key configuration');
|
||||
}
|
||||
|
||||
// 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
|
||||
const token = (window as any)?.localStorage?.getItem?.('accessToken') || (document?.cookie || '').match(/accessToken=([^;]+)/)?.[1] || '';
|
||||
await fetch(`${VITE_BASE_URL}/api/v1/workflows/notifications/subscribe`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(subscription)
|
||||
});
|
||||
const token = (window as any)?.localStorage?.getItem?.('accessToken') ||
|
||||
(document?.cookie || '').match(/accessToken=([^;]+)/)?.[1] || '';
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in again.');
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
export async function setupPushNotifications() {
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== 'granted') return;
|
||||
const reg = await registerServiceWorker();
|
||||
await subscribeUserToPush(reg);
|
||||
// Check if notifications are supported
|
||||
if (!('Notification' in window)) {
|
||||
throw new Error('Notifications are not supported in this browser');
|
||||
}
|
||||
|
||||
// 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