bug fixes and admin screen added

This commit is contained in:
laxmanhalaki 2025-11-15 22:15:58 +05:30
parent 891096a184
commit 9281e3deb3
64 changed files with 6840 additions and 756 deletions

258
DetailedReports_Analysis.md Normal file
View 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

View File

@ -12,6 +12,8 @@ import { MyRequests } from '@/pages/MyRequests';
import { Profile } from '@/pages/Profile'; import { Profile } from '@/pages/Profile';
import { Settings } from '@/pages/Settings'; import { Settings } from '@/pages/Settings';
import { Notifications } from '@/pages/Notifications'; import { Notifications } from '@/pages/Notifications';
import { DetailedReports } from '@/pages/DetailedReports';
import { Admin } from '@/pages/Admin';
import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal'; import { ApprovalActionModal } from '@/components/modals/ApprovalActionModal';
import { Toaster } from '@/components/ui/sonner'; import { Toaster } from '@/components/ui/sonner';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -624,6 +626,26 @@ function AppRoutes({ onLogout }: AppProps) {
</PageLayout> </PageLayout>
} }
/> />
{/* Detailed Reports */}
<Route
path="/detailed-reports"
element={
<PageLayout currentPage="detailed-reports" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<DetailedReports />
</PageLayout>
}
/>
{/* Admin Control Panel */}
<Route
path="/admin"
element={
<PageLayout currentPage="admin" onNavigate={handleNavigate} onNewRequest={handleNewRequest} onLogout={onLogout}>
<Admin />
</PageLayout>
}
/>
</Routes> </Routes>
<Toaster <Toaster

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

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

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

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

View File

@ -0,0 +1,5 @@
export { AIConfig } from './AIConfig';
export { AIProviderSettings } from './AIProviderSettings';
export { AIFeatures } from './AIFeatures';
export { AIParameters } from './AIParameters';

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

View File

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

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

View File

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

View File

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

View 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';

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

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

View File

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

View File

@ -0,0 +1,5 @@
export { DashboardConfig } from './DashboardConfig';
export type { Role, KPICard } from './DashboardConfig';
export { DashboardNote } from './DashboardNote';
export { RoleDashboardSection } from './RoleDashboardSection';

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

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

View File

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

View File

@ -0,0 +1,4 @@
export { DocumentConfig } from './DocumentConfig';
export { DocumentUploadSettings } from './DocumentUploadSettings';
export { AllowedFileTypes } from './AllowedFileTypes';

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,5 @@
export { NotificationConfig } from './NotificationConfig';
export { NotificationChannels } from './NotificationChannels';
export { NotificationSettings } from './NotificationSettings';
export { EmailTemplateSection } from './EmailTemplateSection';

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

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

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

View File

@ -0,0 +1,4 @@
export { SharingConfig } from './SharingConfig';
export { SharingPermissions } from './SharingPermissions';
export { SharingOptions } from './SharingOptions';

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

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

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

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

View File

@ -0,0 +1,5 @@
export { TATConfig } from './TATConfig';
export { PriorityTATSettings } from './PriorityTATSettings';
export { EscalationSettings } from './EscalationSettings';
export { WorkingHoursSettings } from './WorkingHoursSettings';

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

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

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

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

View 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';

View File

@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose } from 'lucide-react'; import { Bell, Settings, User, Plus, Search, Home, FileText, CheckCircle, LogOut, PanelLeft, PanelLeftClose, Activity, Shield } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -62,6 +62,7 @@ export function PageLayout({ children, currentPage = 'dashboard', onNavigate, on
{ id: 'my-requests', label: 'My Requests', icon: User }, { id: 'my-requests', label: 'My Requests', icon: User },
{ id: 'open-requests', label: 'Open Requests', icon: FileText }, { id: 'open-requests', label: 'Open Requests', icon: FileText },
{ id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle }, { id: 'closed-requests', label: 'Closed Requests', icon: CheckCircle },
{ id: 'admin', label: 'Admin', icon: Shield },
]; ];
const toggleSidebar = () => { const toggleSidebar = () => {

View File

@ -1,6 +1,6 @@
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Clock } from 'lucide-react'; import { Clock, Lock, CheckCircle, XCircle, AlertTriangle, AlertOctagon } from 'lucide-react';
export interface SLAData { export interface SLAData {
status: 'normal' | 'approaching' | 'critical' | 'breached'; status: 'normal' | 'approaching' | 'critical' | 'breached';
@ -27,11 +27,14 @@ export function SLAProgressBar({
if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') { if (!sla || requestStatus === 'approved' || requestStatus === 'rejected' || requestStatus === 'closed') {
return ( return (
<div className="flex items-center gap-2" data-testid={`${testId}-status-only`}> <div className="flex items-center gap-2" data-testid={`${testId}-status-only`}>
<Clock className="h-4 w-4 text-gray-500" /> {requestStatus === 'closed' ? <Lock className="h-4 w-4 text-gray-600" /> :
requestStatus === 'approved' ? <CheckCircle className="h-4 w-4 text-green-600" /> :
requestStatus === 'rejected' ? <XCircle className="h-4 w-4 text-red-600" /> :
<Clock className="h-4 w-4 text-gray-500" />}
<span className="text-sm font-medium text-gray-700"> <span className="text-sm font-medium text-gray-700">
{requestStatus === 'closed' ? '🔒 Request Closed' : {requestStatus === 'closed' ? 'Request Closed' :
requestStatus === 'approved' ? '✅ Request Approved' : requestStatus === 'approved' ? 'Request Approved' :
requestStatus === 'rejected' ? 'Request Rejected' : 'SLA Not Available'} requestStatus === 'rejected' ? 'Request Rejected' : 'SLA Not Available'}
</span> </span>
</div> </div>
); );
@ -91,13 +94,15 @@ export function SLAProgressBar({
)} )}
{sla.status === 'critical' && ( {sla.status === 'critical' && (
<p className="text-xs text-orange-600 font-semibold mt-1" data-testid={`${testId}-warning-critical`}> <p className="text-xs text-orange-600 font-semibold mt-1 flex items-center gap-1.5" data-testid={`${testId}-warning-critical`}>
Approaching Deadline <AlertTriangle className="h-3.5 w-3.5" />
Approaching Deadline
</p> </p>
)} )}
{sla.status === 'breached' && ( {sla.status === 'breached' && (
<p className="text-xs text-red-600 font-semibold mt-1" data-testid={`${testId}-warning-breached`}> <p className="text-xs text-red-600 font-semibold mt-1 flex items-center gap-1.5" data-testid={`${testId}-warning-breached`}>
🔴 URGENT - Deadline Passed <AlertOctagon className="h-3.5 w-3.5" />
URGENT - Deadline Passed
</p> </p>
)} )}
</div> </div>

View File

@ -8,9 +8,10 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
type={type} type={type}
data-slot="input" data-slot="input"
className={cn( className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 flex h-9 w-full min-w-0 rounded-md border px-3 py-1 text-base transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground flex h-9 w-full min-w-0 rounded-md border border-gray-400 bg-white px-3 py-1 text-base text-gray-900 transition-all outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:ring-[3px]", "focus-visible:border-re-light-green focus-visible:ring-0 focus-visible:outline-none",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", "hover:border-gray-500",
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
className, className,
)} )}
{...props} {...props}

View File

@ -41,7 +41,10 @@ function SelectTrigger({
data-slot="select-trigger" data-slot="select-trigger"
data-size={size} data-size={size}
className={cn( className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-full items-center justify-between gap-2 rounded-md border bg-input-background px-3 py-2 text-sm whitespace-nowrap transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", "border-gray-400 data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground bg-white text-gray-900 flex w-full items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"hover:border-gray-500",
"focus-visible:border-re-light-green focus-visible:ring-0 focus-visible:outline-none",
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
className, className,
)} )}
{...props} {...props}

View File

@ -7,7 +7,10 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
<textarea <textarea
data-slot="textarea" data-slot="textarea"
className={cn( className={cn(
"resize-none border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-input-background px-3 py-2 text-base transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm", "resize-none border-gray-400 placeholder:text-muted-foreground bg-white text-gray-900 flex field-sizing-content min-h-16 w-full rounded-md border px-3 py-2 text-base transition-all outline-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"hover:border-gray-500",
"focus-visible:border-re-light-green focus-visible:ring-0 focus-visible:outline-none",
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive",
className, className,
)} )}
{...props} {...props}

View File

@ -1,5 +1,8 @@
import { useState, useRef, useEffect, useMemo } from 'react'; import { useState, useRef, useEffect, useMemo } from 'react';
import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi'; import { getWorkNotes, createWorkNoteMultipart, getWorkflowDetails, downloadWorkNoteAttachment, getWorkNoteAttachmentPreviewUrl, addSpectator, addApproverAtLevel } from '@/services/workflowApi';
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
import { toast } from 'sonner';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket'; import { getSocket, joinRequestRoom, leaveRequestRoom } from '@/utils/socket';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -19,21 +22,18 @@ import {
FileText, FileText,
Download, Download,
Eye, Eye,
MoreHorizontal,
MessageSquare, MessageSquare,
Clock, Clock,
Search, Search,
Hash,
AtSign, AtSign,
Archive,
Plus, Plus,
Activity, Activity,
Bell,
Flag, Flag,
X, X,
FileSpreadsheet, FileSpreadsheet,
Image, Image,
UserPlus UserPlus,
AlertCircle
} from 'lucide-react'; } from 'lucide-react';
interface Message { interface Message {
@ -148,6 +148,24 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
const socketRef = useRef<any>(null); const socketRef = useRef<any>(null);
const participantsLoadedRef = useRef(false); const participantsLoadedRef = useRef(false);
// Document policy state
const [documentPolicy, setDocumentPolicy] = useState<{
maxFileSizeMB: number;
allowedFileTypes: string[];
}>({
maxFileSizeMB: 10,
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif']
});
// Document validation error modal
const [documentErrorModal, setDocumentErrorModal] = useState<{
open: boolean;
errors: Array<{ fileName: string; reason: string }>;
}>({
open: false,
errors: []
});
console.log('[WorkNoteChat] Component render, participants loaded:', participantsLoadedRef.current); console.log('[WorkNoteChat] Component render, participants loaded:', participantsLoadedRef.current);
// Get request info (from props, all data comes from backend now) // Get request info (from props, all data comes from backend now)
@ -384,6 +402,33 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
} }
}, []); }, []);
// Fetch document policy on mount
useEffect(() => {
const loadDocumentPolicy = async () => {
try {
const configs = await getAllConfigurations('DOCUMENT_POLICY');
const configMap: Record<string, string> = {};
configs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10');
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
setDocumentPolicy({
maxFileSizeMB,
allowedFileTypes
});
} catch (error) {
console.error('Failed to load document policy:', error);
// Use defaults if loading fails
}
};
loadDocumentPolicy();
}, []);
// Realtime updates via Socket.IO (standalone usage OR when embedded in RequestDetail) // Realtime updates via Socket.IO (standalone usage OR when embedded in RequestDetail)
useEffect(() => { useEffect(() => {
if (!currentUserId) return; // Wait for currentUserId to be loaded if (!currentUserId) return; // Wait for currentUserId to be loaded
@ -894,10 +939,72 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
} }
}, [externalMessages, effectiveRequestId, participants]); }, [externalMessages, effectiveRequestId, participants]);
const validateFile = (file: File): { valid: boolean; reason?: string } => {
// Check file size
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
if (file.size > maxSizeBytes) {
return {
valid: false,
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
};
}
// Check file extension
const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
return {
valid: false,
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
};
}
return { valid: true };
};
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) { if (!e.target.files || e.target.files.length === 0) return;
const filesArray = Array.from(e.target.files);
setSelectedFiles(prev => [...prev, ...filesArray]); const filesArray = Array.from(e.target.files);
// Validate all files
const validationErrors: Array<{ fileName: string; reason: string }> = [];
const validFiles: File[] = [];
filesArray.forEach(file => {
const validation = validateFile(file);
if (!validation.valid) {
validationErrors.push({
fileName: file.name,
reason: validation.reason || 'Unknown validation error'
});
} else {
validFiles.push(file);
}
});
// If there are validation errors, show modal
if (validationErrors.length > 0) {
setDocumentErrorModal({
open: true,
errors: validationErrors
});
}
// Add only valid files
if (validFiles.length > 0) {
setSelectedFiles(prev => [...prev, ...validFiles]);
if (validFiles.length < filesArray.length) {
toast.warning(`${validFiles.length} of ${filesArray.length} file(s) were added. ${validationErrors.length} file(s) were rejected.`);
} else {
toast.success(`${validFiles.length} file(s) added successfully`);
}
}
// Reset file input
if (e.target) {
e.target.value = '';
} }
}; };
@ -1371,7 +1478,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
onChange={handleFileSelect} onChange={handleFileSelect}
className="hidden" className="hidden"
multiple multiple
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt" accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
/> />
{/* Selected Files Preview - Scrollable if many files */} {/* Selected Files Preview - Scrollable if many files */}
@ -1556,15 +1663,6 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
> >
<AtSign className="h-4 w-4" /> <AtSign className="h-4 w-4" />
</Button> </Button>
<Button
variant="ghost"
size="sm"
className="text-gray-500 h-8 w-8 p-0 hidden sm:flex hover:bg-blue-50 hover:text-blue-600 flex-shrink-0"
onClick={() => setMessage(prev => prev + '#')}
title="Add hashtag"
>
<Hash className="h-4 w-4" />
</Button>
</div> </div>
{/* Right side - Character count and Send button */} {/* Right side - Character count and Send button */}
@ -1601,8 +1699,8 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
lg:relative lg:translate-x-0 lg:shadow-none lg:relative lg:translate-x-0 lg:shadow-none
${showSidebar ? 'fixed right-0 top-0 bottom-0 z-50 shadow-xl' : 'hidden lg:flex'} ${showSidebar ? 'fixed right-0 top-0 bottom-0 z-50 shadow-xl' : 'hidden lg:flex'}
`}> `}>
<div className="p-4 sm:p-6 border-b border-gray-200"> <div className="p-4 sm:p-6 border-b border-gray-200 flex-1 flex flex-col min-h-0">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4 flex-shrink-0">
<h3 className="text-base sm:text-lg font-semibold text-gray-900">Participants</h3> <h3 className="text-base sm:text-lg font-semibold text-gray-900">Participants</h3>
<Button <Button
variant="ghost" variant="ghost"
@ -1613,7 +1711,7 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="space-y-3 sm:space-y-4"> <div className="space-y-3 sm:space-y-4 overflow-y-auto flex-1 pr-2">
{participants.map((participant, index) => { {participants.map((participant, index) => {
const isCurrentUser = (participant as any).userId === currentUserId; const isCurrentUser = (participant as any).userId === currentUserId;
return ( return (
@ -1642,16 +1740,13 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
<p className="text-xs text-gray-400">{participant.lastSeen}</p> <p className="text-xs text-gray-400">{participant.lastSeen}</p>
)} )}
</div> </div>
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
<div className="p-4 sm:p-6"> <div className="p-4 sm:p-6 flex-shrink-0">
<h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4> <h4 className="font-semibold text-gray-900 mb-3 text-sm sm:text-base">Quick Actions</h4>
<div className="space-y-2"> <div className="space-y-2">
{/* Only initiator can add approvers */} {/* Only initiator can add approvers */}
@ -1724,6 +1819,48 @@ export function WorkNoteChat({ requestId, onBack, messages: externalMessages, on
currentLevels={currentLevels} currentLevels={currentLevels}
/> />
)} )}
{/* Document Validation Error Modal */}
<Dialog open={documentErrorModal.open} onOpenChange={(open) => setDocumentErrorModal(prev => ({ ...prev, open }))}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
Document Upload Policy Violation
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<p className="text-gray-700">
The following file(s) could not be uploaded due to policy violations:
</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{documentErrorModal.errors.map((error, index) => (
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="font-medium text-red-900 text-sm">{error.fileName}</p>
<p className="text-xs text-red-700 mt-1">{error.reason}</p>
</div>
))}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-800 font-semibold mb-1">Document Policy:</p>
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
<li>Maximum file size: {documentPolicy.maxFileSizeMB}MB</li>
<li>Allowed file types: {documentPolicy.allowedFileTypes.join(', ')}</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={() => setDocumentErrorModal({ open: false, errors: [] })}
className="w-full sm:w-auto"
>
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
); );
} }

View File

@ -2,7 +2,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle } from 'lucide-react'; import { CheckCircle, XCircle, Clock, AlertCircle, AlertTriangle, FastForward, MessageSquare, PauseCircle, Hourglass, AlertOctagon } from 'lucide-react';
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter'; import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
export interface ApprovalStep { export interface ApprovalStep {
@ -132,7 +132,10 @@ export function ApprovalStepCard({
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top" className="max-w-xs bg-orange-50 border-orange-200"> <TooltipContent side="top" className="max-w-xs bg-orange-50 border-orange-200">
<p className="text-xs font-semibold text-orange-900 mb-1"> Skip Reason:</p> <p className="text-xs font-semibold text-orange-900 mb-1 flex items-center gap-1">
<FastForward className="w-3 h-3" />
Skip Reason:
</p>
<p className="text-xs text-gray-700">{step.skipReason}</p> <p className="text-xs text-gray-700">{step.skipReason}</p>
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
@ -197,7 +200,10 @@ export function ApprovalStepCard({
{/* Conclusion Remark */} {/* Conclusion Remark */}
{step.comment && ( {step.comment && (
<div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg"> <div className="mt-4 p-3 sm:p-4 bg-blue-50 border-l-4 border-blue-500 rounded-r-lg">
<p className="text-xs font-semibold text-gray-700 mb-2">💬 Conclusion Remark:</p> <p className="text-xs font-semibold text-gray-700 mb-2 flex items-center gap-1.5">
<MessageSquare className="w-3.5 h-3.5 text-blue-600" />
Conclusion Remark:
</p>
<p className="text-sm text-gray-700 italic leading-relaxed">{step.comment}</p> <p className="text-sm text-gray-700 italic leading-relaxed">{step.comment}</p>
</div> </div>
)} )}
@ -260,13 +266,15 @@ export function ApprovalStepCard({
</span> </span>
</div> </div>
{approval.sla.status === 'breached' && ( {approval.sla.status === 'breached' && (
<p className="text-xs font-semibold text-center text-red-600"> <p className="text-xs font-semibold text-center text-red-600 flex items-center justify-center gap-1.5">
🔴 Deadline Breached <AlertOctagon className="w-4 h-4" />
Deadline Breached
</p> </p>
)} )}
{approval.sla.status === 'critical' && ( {approval.sla.status === 'critical' && (
<p className="text-xs font-semibold text-center text-orange-600"> <p className="text-xs font-semibold text-center text-orange-600 flex items-center justify-center gap-1.5">
Approaching Deadline <AlertTriangle className="w-4 h-4" />
Approaching Deadline
</p> </p>
)} )}
</div> </div>
@ -278,7 +286,10 @@ export function ApprovalStepCard({
{isWaiting && ( {isWaiting && (
<div className="space-y-2"> <div className="space-y-2">
<div className="bg-gray-100 border border-gray-300 rounded-lg p-3"> <div className="bg-gray-100 border border-gray-300 rounded-lg p-3">
<p className="text-xs text-gray-600 mb-1"> Awaiting Previous Approval</p> <p className="text-xs text-gray-600 mb-1 flex items-center gap-1.5">
<PauseCircle className="w-3.5 h-3.5 text-gray-500" />
Awaiting Previous Approval
</p>
<p className="text-sm font-medium text-gray-700">Will be assigned after previous step</p> <p className="text-sm font-medium text-gray-700">Will be assigned after previous step</p>
<p className="text-xs text-gray-500 mt-2">Allocated {tatHours} hours for approval</p> <p className="text-xs text-gray-500 mt-2">Allocated {tatHours} hours for approval</p>
</div> </div>
@ -288,7 +299,10 @@ export function ApprovalStepCard({
{/* Rejected Status */} {/* Rejected Status */}
{isRejected && step.comment && ( {isRejected && step.comment && (
<div className="mt-3 p-3 sm:p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg"> <div className="mt-3 p-3 sm:p-4 bg-red-50 border-l-4 border-red-500 rounded-r-lg">
<p className="text-xs font-semibold text-red-700 mb-2"> Rejection Reason:</p> <p className="text-xs font-semibold text-red-700 mb-2 flex items-center gap-1.5">
<XCircle className="w-3.5 h-3.5" />
Rejection Reason:
</p>
<p className="text-sm text-gray-700 leading-relaxed">{step.comment}</p> <p className="text-sm text-gray-700 leading-relaxed">{step.comment}</p>
</div> </div>
)} )}
@ -296,7 +310,10 @@ export function ApprovalStepCard({
{/* Skipped Status */} {/* Skipped Status */}
{step.isSkipped && step.skipReason && ( {step.isSkipped && step.skipReason && (
<div className="mt-3 p-3 sm:p-4 bg-orange-50 border-l-4 border-orange-500 rounded-r-lg"> <div className="mt-3 p-3 sm:p-4 bg-orange-50 border-l-4 border-orange-500 rounded-r-lg">
<p className="text-xs font-semibold text-orange-700 mb-2"> Skip Reason:</p> <p className="text-xs font-semibold text-orange-700 mb-2 flex items-center gap-1.5">
<FastForward className="w-3.5 h-3.5" />
Skip Reason:
</p>
<p className="text-sm text-gray-700 leading-relaxed">{step.skipReason}</p> <p className="text-sm text-gray-700 leading-relaxed">{step.skipReason}</p>
{step.timestamp && ( {step.timestamp && (
<p className="text-xs text-gray-500 mt-2">Skipped on {formatDateTime(step.timestamp)}</p> <p className="text-xs text-gray-500 mt-2">Skipped on {formatDateTime(step.timestamp)}</p>
@ -320,10 +337,16 @@ export function ApprovalStepCard({
data-testid={`${testId}-tat-alert-${alertIndex}`} data-testid={`${testId}-tat-alert-${alertIndex}`}
> >
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
<div className="text-base sm:text-lg flex-shrink-0"> <div className="flex-shrink-0 mt-0.5">
{(alert.thresholdPercentage || 0) === 50 && '⏳'} {(alert.thresholdPercentage || 0) === 50 && (
{(alert.thresholdPercentage || 0) === 75 && '⚠️'} <Hourglass className="w-5 h-5 text-yellow-600" />
{(alert.thresholdPercentage || 0) === 100 && '⏰'} )}
{(alert.thresholdPercentage || 0) === 75 && (
<AlertTriangle className="w-5 h-5 text-orange-600" />
)}
{(alert.thresholdPercentage || 0) === 100 && (
<AlertOctagon className="w-5 h-5 text-red-600" />
)}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 mb-1"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-1 sm:gap-2 mb-1">

View File

@ -1,5 +1,7 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { uploadDocument } from '@/services/documentApi'; import { uploadDocument } from '@/services/documentApi';
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
import { toast } from 'sonner';
/** /**
* Custom Hook: useDocumentUpload * Custom Hook: useDocumentUpload
@ -33,6 +35,83 @@ export function useDocumentUpload(
fileSize?: number; fileSize?: number;
} | null>(null); } | null>(null);
// Document policy state
const [documentPolicy, setDocumentPolicy] = useState<{
maxFileSizeMB: number;
allowedFileTypes: string[];
}>({
maxFileSizeMB: 10,
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif']
});
// Document validation error state
const [documentError, setDocumentError] = useState<{
show: boolean;
errors: Array<{ fileName: string; reason: string }>;
}>({
show: false,
errors: []
});
// Fetch document policy on mount
useEffect(() => {
const loadDocumentPolicy = async () => {
try {
const configs = await getAllConfigurations('DOCUMENT_POLICY');
const configMap: Record<string, string> = {};
configs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10');
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
setDocumentPolicy({
maxFileSizeMB,
allowedFileTypes
});
} catch (error) {
console.error('Failed to load document policy:', error);
// Use defaults if loading fails
}
};
loadDocumentPolicy();
}, []);
/**
* Function: validateFile
*
* Purpose: Validate file against document policy
*
* @param file - File to validate
* @returns Validation result with reason if invalid
*/
const validateFile = (file: File): { valid: boolean; reason?: string } => {
// Check file size
const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
if (file.size > maxSizeBytes) {
return {
valid: false,
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
};
}
// Check file extension
const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
return {
valid: false,
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
};
}
return { valid: true };
};
/** /**
* Function: handleDocumentUpload * Function: handleDocumentUpload
* *
@ -40,11 +119,12 @@ export function useDocumentUpload(
* *
* Process: * Process:
* 1. Validate file selection * 1. Validate file selection
* 2. Get request UUID (required for backend API) * 2. Validate against document policy
* 3. Upload file to backend * 3. Get request UUID (required for backend API)
* 4. Refresh request details to show new document * 4. Upload file to backend
* 5. Clear file input for next upload * 5. Refresh request details to show new document
* 6. Show success/error messages * 6. Clear file input for next upload
* 7. Show success/error messages
* *
* @param event - File input change event * @param event - File input change event
*/ */
@ -54,22 +134,51 @@ export function useDocumentUpload(
// Validate: Check if file is selected // Validate: Check if file is selected
if (!files || files.length === 0) return; if (!files || files.length === 0) return;
const fileArray = Array.from(files);
// Validate all files against document policy
const validationErrors: Array<{ fileName: string; reason: string }> = [];
const validFiles: File[] = [];
fileArray.forEach(file => {
const validation = validateFile(file);
if (!validation.valid) {
validationErrors.push({
fileName: file.name,
reason: validation.reason || 'Unknown validation error'
});
} else {
validFiles.push(file);
}
});
// If there are validation errors, show modal
if (validationErrors.length > 0) {
setDocumentError({
show: true,
errors: validationErrors
});
}
// If no valid files, stop here
if (validFiles.length === 0) {
if (event.target) {
event.target.value = '';
}
return;
}
setUploadingDocument(true); setUploadingDocument(true);
try { try {
const file = files[0]; // Upload only the first valid file (backend currently supports single file)
const file = validFiles[0];
// Validate: Ensure file exists
if (!file) {
alert('No file selected');
return;
}
// Validate: Ensure request ID is available // Validate: Ensure request ID is available
// Note: Backend requires UUID, not request number // Note: Backend requires UUID, not request number
const requestId = apiRequest?.requestId; const requestId = apiRequest?.requestId;
if (!requestId) { if (!requestId) {
alert('Request ID not found'); toast.error('Request ID not found');
return; return;
} }
@ -82,12 +191,16 @@ export function useDocumentUpload(
await refreshDetails(); await refreshDetails();
// Success feedback // Success feedback
alert('Document uploaded successfully'); if (validFiles.length < fileArray.length) {
toast.warning(`${validFiles.length} of ${fileArray.length} file(s) were uploaded. ${validationErrors.length} file(s) were rejected.`);
} else {
toast.success('Document uploaded successfully');
}
} catch (error: any) { } catch (error: any) {
console.error('[useDocumentUpload] Upload error:', error); console.error('[useDocumentUpload] Upload error:', error);
// Error feedback with backend error message if available // Error feedback with backend error message if available
alert(error?.response?.data?.error || 'Failed to upload document'); toast.error(error?.response?.data?.error || 'Failed to upload document');
} finally { } finally {
setUploadingDocument(false); setUploadingDocument(false);
@ -105,18 +218,14 @@ export function useDocumentUpload(
* *
* Process: * Process:
* 1. Create temporary file input element * 1. Create temporary file input element
* 2. Configure accepted file types * 2. Configure accepted file types based on document policy
* 3. Attach upload handler * 3. Attach upload handler
* 4. Trigger click to open file picker * 4. Trigger click to open file picker
*
* Accepted formats:
* - Documents: PDF, DOC, DOCX, XLS, XLSX, PPT, PPTX, TXT
* - Images: JPG, JPEG, PNG, GIF
*/ */
const triggerFileInput = () => { const triggerFileInput = () => {
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.accept = '.pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.gif'; input.accept = documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',');
input.onchange = handleDocumentUpload as any; input.onchange = handleDocumentUpload as any;
input.click(); input.click();
}; };
@ -126,7 +235,10 @@ export function useDocumentUpload(
handleDocumentUpload, handleDocumentUpload,
triggerFileInput, triggerFileInput,
previewDocument, previewDocument,
setPreviewDocument setPreviewDocument,
documentPolicy,
documentError,
setDocumentError
}; };
} }

531
src/pages/Admin/Admin.tsx Normal file
View 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
View File

@ -0,0 +1,2 @@
export { Admin } from './Admin';

View File

@ -1,11 +1,11 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Calendar, Filter, Search, FileText, AlertCircle, CheckCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, Settings2, X, XCircle } from 'lucide-react'; import { Calendar, Filter, Search, FileText, AlertCircle, CheckCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, X, XCircle } from 'lucide-react';
import workflowApi from '@/services/workflowApi'; import workflowApi from '@/services/workflowApi';
import { formatDateTime } from '@/utils/dateFormatter'; import { formatDateTime } from '@/utils/dateFormatter';
@ -97,7 +97,6 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority'>('due'); const [sortBy, setSortBy] = useState<'created' | 'due' | 'priority'>('due');
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [items, setItems] = useState<Request[]>([]); const [items, setItems] = useState<Request[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
@ -108,14 +107,24 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
const [totalRecords, setTotalRecords] = useState(0); const [totalRecords, setTotalRecords] = useState(0);
const [itemsPerPage] = useState(10); const [itemsPerPage] = useState(10);
const fetchRequests = async (page: number = 1) => { const fetchRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
try { try {
if (page === 1) { if (page === 1) {
setLoading(true); setLoading(true);
setItems([]); setItems([]);
} }
const result = await workflowApi.listClosedByMe({ page, limit: itemsPerPage }); console.log('[ClosedRequests] Fetching with filters:', { page, filters }); // Debug log
const result = await workflowApi.listClosedByMe({
page,
limit: itemsPerPage,
search: filters?.search,
status: filters?.status,
priority: filters?.priority,
sortBy: filters?.sortBy,
sortOrder: filters?.sortOrder
});
console.log('[ClosedRequests] API Response:', result); // Debug log console.log('[ClosedRequests] API Response:', result); // Debug log
// Extract data - workflowApi now returns { data: [], pagination: {} } // Extract data - workflowApi now returns { data: [], pagination: {} }
@ -133,24 +142,22 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
setTotalRecords(pagination.total || 0); setTotalRecords(pagination.total || 0);
} }
const mapped: Request[] = data const mapped: Request[] = data.map((r: any) => ({
.filter((r: any) => ['APPROVED', 'REJECTED', 'CLOSED'].includes((r.status || '').toString())) id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier
.map((r: any) => ({ requestId: r.requestId, // Keep requestId for reference
id: r.requestNumber || r.request_number || r.requestId, // Use requestNumber as primary identifier displayId: r.requestNumber || r.request_number || r.requestId,
requestId: r.requestId, // Keep requestId for reference title: r.title,
displayId: r.requestNumber || r.request_number || r.requestId, description: r.description,
title: r.title, status: (r.status || '').toString().toLowerCase(),
description: r.description, priority: (r.priority || '').toString().toLowerCase(),
status: (r.status || '').toString().toLowerCase(), initiator: { name: r.initiator?.displayName || r.initiator?.email || '—', avatar: (r.initiator?.displayName || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase() },
priority: (r.priority || '').toString().toLowerCase(), createdAt: r.submittedAt || r.createdAt || r.created_at || '—',
initiator: { name: r.initiator?.displayName || r.initiator?.email || '—', avatar: (r.initiator?.displayName || 'NA').split(' ').map((s: string) => s[0]).join('').slice(0,2).toUpperCase() }, dueDate: r.closureDate || r.closure_date || r.closedAt || undefined,
createdAt: r.submittedAt || r.createdAt || '—', reason: r.conclusionRemark || r.conclusion_remark,
dueDate: r.closureDate || r.closure_date || undefined, department: r.department,
reason: r.conclusionRemark || r.conclusion_remark, totalLevels: r.totalLevels || 0,
department: r.department, completedLevels: r.summary?.approvedLevels || 0,
totalLevels: r.totalLevels || 0, }));
completedLevels: r.summary?.approvedLevels || 0,
}));
setItems(mapped); setItems(mapped);
} catch (error) { } catch (error) {
console.error('[ClosedRequests] Error fetching requests:', error); console.error('[ClosedRequests] Error fetching requests:', error);
@ -159,17 +166,29 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
} }
}; }, [itemsPerPage]);
const handleRefresh = () => { const handleRefresh = () => {
setRefreshing(true); setRefreshing(true);
fetchRequests(currentPage); fetchRequests(currentPage, {
search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
sortBy,
sortOrder
});
}; };
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) { if (newPage >= 1 && newPage <= totalPages) {
setCurrentPage(newPage); setCurrentPage(newPage);
fetchRequests(newPage); fetchRequests(newPage, {
search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
sortBy,
sortOrder
});
} }
}; };
@ -190,54 +209,34 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
return pages; return pages;
}; };
// Initial fetch on mount and when filters/sorting change (with debouncing for search)
useEffect(() => { useEffect(() => {
fetchRequests(1); // Debounce search: wait 500ms after user stops typing
}, []); const timeoutId = setTimeout(() => {
console.log('[ClosedRequests] Filter changed, fetching...', {
searchTerm,
statusFilter,
priorityFilter,
sortBy,
sortOrder
}); // Debug log
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(() => { return () => clearTimeout(timeoutId);
let filtered = items.filter(request => { }, [searchTerm, statusFilter, priorityFilter, sortBy, sortOrder, fetchRequests]);
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;
});
// Sort requests // Backend handles both filtering and sorting - use items directly
filtered.sort((a, b) => { // No client-side filtering/sorting needed anymore
let aValue: any, bValue: any; const filteredAndSortedRequests = items;
switch (sortBy) {
case 'created':
aValue = new Date(a.createdAt);
bValue = new Date(b.createdAt);
break;
case 'due':
aValue = a.dueDate ? new Date(a.dueDate) : new Date(0);
bValue = b.dueDate ? new Date(b.dueDate) : new Date(0);
break;
case 'priority':
const priorityOrder = { express: 2, standard: 1 };
aValue = priorityOrder[a.priority as keyof typeof priorityOrder];
bValue = priorityOrder[b.priority as keyof typeof priorityOrder];
break;
default:
return 0;
}
if (sortOrder === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
return filtered;
}, [items, searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]);
const clearFilters = () => { const clearFilters = () => {
setSearchTerm(''); setSearchTerm('');
@ -304,28 +303,17 @@ export function ClosedRequests({ onViewRequest }: ClosedRequestsProps) {
</CardDescription> </CardDescription>
</div> </div>
</div> </div>
<div className="flex items-center gap-1 sm:gap-2"> {activeFiltersCount > 0 && (
{activeFiltersCount > 0 && (
<Button
variant="ghost"
size="sm"
onClick={clearFilters}
className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
>
<X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm">Clear</span>
</Button>
)}
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)} onClick={clearFilters}
className="gap-1 sm:gap-2 h-8 sm:h-9 px-2 sm:px-3" className="text-red-600 hover:bg-red-50 gap-1 h-8 sm:h-9 px-2 sm:px-3"
> >
<Settings2 className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> <X className="w-3 h-3 sm:w-3.5 sm:h-3.5" />
<span className="text-xs sm:text-sm hidden md:inline">{showAdvancedFilters ? 'Basic' : 'Advanced'}</span> <span className="text-xs sm:text-sm">Clear</span>
</Button> </Button>
</div> )}
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6"> <CardContent className="space-y-3 sm:space-y-4 px-3 sm:px-6">

View File

@ -40,10 +40,13 @@ import {
Minus, Minus,
Eye, Eye,
Lightbulb, Lightbulb,
Settings Settings,
Loader2
} from 'lucide-react'; } from 'lucide-react';
import { format } from 'date-fns'; import { format } from 'date-fns';
import { useAuth } from '@/contexts/AuthContext'; import { useAuth } from '@/contexts/AuthContext';
import { getAllConfigurations, AdminConfiguration } from '@/services/adminApi';
import { toast } from 'sonner';
interface CreateRequestProps { interface CreateRequestProps {
onBack?: () => void; onBack?: () => void;
@ -221,10 +224,30 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
const totalSteps = STEP_NAMES.length; const totalSteps = STEP_NAMES.length;
const [loadingDraft, setLoadingDraft] = useState(isEditing); const [loadingDraft, setLoadingDraft] = useState(isEditing);
const [submitting, setSubmitting] = useState(false);
const [savingDraft, setSavingDraft] = useState(false);
const [existingDocuments, setExistingDocuments] = useState<any[]>([]); // Track documents from backend const [existingDocuments, setExistingDocuments] = useState<any[]>([]); // Track documents from backend
const [documentsToDelete, setDocumentsToDelete] = useState<string[]>([]); // Track document IDs to delete const [documentsToDelete, setDocumentsToDelete] = useState<string[]>([]); // Track document IDs to delete
const [previewDocument, setPreviewDocument] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; file?: File; documentId?: string } | null>(null); const [previewDocument, setPreviewDocument] = useState<{ fileName: string; fileType: string; fileUrl: string; fileSize?: number; file?: File; documentId?: string } | null>(null);
// Document policy state
const [documentPolicy, setDocumentPolicy] = useState<{
maxFileSizeMB: number;
allowedFileTypes: string[];
}>({
maxFileSizeMB: 10,
allowedFileTypes: ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'jpg', 'jpeg', 'png', 'gif']
});
// Document validation error modal
const [documentErrorModal, setDocumentErrorModal] = useState<{
open: boolean;
errors: Array<{ fileName: string; reason: string }>;
}>({
open: false,
errors: []
});
// Validation modal states // Validation modal states
const [validationModal, setValidationModal] = useState<{ const [validationModal, setValidationModal] = useState<{
open: boolean; open: boolean;
@ -238,6 +261,33 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
message: '' message: ''
}); });
// Fetch document policy on mount
useEffect(() => {
const loadDocumentPolicy = async () => {
try {
const configs = await getAllConfigurations('DOCUMENT_POLICY');
const configMap: Record<string, string> = {};
configs.forEach((c: AdminConfiguration) => {
configMap[c.configKey] = c.configValue;
});
const maxFileSizeMB = parseInt(configMap['MAX_FILE_SIZE_MB'] || '10');
const allowedFileTypesStr = configMap['ALLOWED_FILE_TYPES'] || 'pdf,doc,docx,xls,xlsx,ppt,pptx,jpg,jpeg,png,gif';
const allowedFileTypes = allowedFileTypesStr.split(',').map(ext => ext.trim().toLowerCase());
setDocumentPolicy({
maxFileSizeMB,
allowedFileTypes
});
} catch (error) {
console.error('Failed to load document policy:', error);
// Use defaults if loading fails
}
};
loadDocumentPolicy();
}, []);
// Fetch draft data when in edit mode // Fetch draft data when in edit mode
useEffect(() => { useEffect(() => {
if (!isEditing || !editRequestId) return; if (!isEditing || !editRequestId) return;
@ -775,13 +825,79 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
} }
}; };
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { const validateFile = (file: File): { valid: boolean; reason?: string } => {
const files = Array.from(event.target.files || []); // Check file size
updateFormData('documents', [...formData.documents, ...files]); const maxSizeBytes = documentPolicy.maxFileSizeMB * 1024 * 1024;
if (file.size > maxSizeBytes) {
return {
valid: false,
reason: `File size exceeds the maximum allowed size of ${documentPolicy.maxFileSizeMB}MB. Current size: ${(file.size / (1024 * 1024)).toFixed(2)}MB`
};
}
// Check file extension
const fileName = file.name.toLowerCase();
const fileExtension = fileName.substring(fileName.lastIndexOf('.') + 1);
if (!documentPolicy.allowedFileTypes.includes(fileExtension)) {
return {
valid: false,
reason: `File type "${fileExtension}" is not allowed. Allowed types: ${documentPolicy.allowedFileTypes.join(', ')}`
};
}
return { valid: true };
}; };
const handleSubmit = () => { const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
if (!isStepValid()) return; const files = Array.from(event.target.files || []);
if (files.length === 0) return;
// Validate all files
const validationErrors: Array<{ fileName: string; reason: string }> = [];
const validFiles: File[] = [];
files.forEach(file => {
const validation = validateFile(file);
if (!validation.valid) {
validationErrors.push({
fileName: file.name,
reason: validation.reason || 'Unknown validation error'
});
} else {
validFiles.push(file);
}
});
// If there are validation errors, show modal
if (validationErrors.length > 0) {
setDocumentErrorModal({
open: true,
errors: validationErrors
});
}
// Add only valid files
if (validFiles.length > 0) {
updateFormData('documents', [...formData.documents, ...validFiles]);
if (validFiles.length < files.length) {
toast.warning(`${validFiles.length} of ${files.length} file(s) were added. ${validationErrors.length} file(s) were rejected.`);
} else {
toast.success(`${validFiles.length} file(s) added successfully`);
}
}
// Reset file input
if (event.target) {
event.target.value = '';
}
};
const handleSubmit = async () => {
if (!isStepValid() || submitting || savingDraft) return;
setSubmitting(true);
// Participants mapping // Participants mapping
const initiatorId = user?.userId || ''; const initiatorId = user?.userId || '';
@ -935,59 +1051,68 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
if (hasNewFiles || hasDeletions) { if (hasNewFiles || hasDeletions) {
// Use multipart update // Use multipart update
updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete) try {
.then(async () => { await updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete);
// Submit the updated workflow // Submit the updated workflow
try { try {
await submitWorkflow(editRequestId); await submitWorkflow(editRequestId);
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate }); onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
} catch (err) { } catch (err) {
console.error('Failed to submit workflow:', err); console.error('Failed to submit workflow:', err);
} setSubmitting(false);
}) }
.catch((err) => { } catch (err) {
console.error('Failed to update workflow:', err); console.error('Failed to update workflow:', err);
}); setSubmitting(false);
}
} else { } else {
// Use regular update // Use regular update
updateWorkflow(editRequestId, updatePayload) try {
.then(async () => { await updateWorkflow(editRequestId, updatePayload);
// Submit the updated workflow // Submit the updated workflow
try { try {
await submitWorkflow(editRequestId); await submitWorkflow(editRequestId);
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate }); onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
} catch (err) { } catch (err) {
console.error('Failed to submit workflow:', err); console.error('Failed to submit workflow:', err);
} setSubmitting(false);
}) }
.catch((err) => { } catch (err) {
console.error('Failed to update workflow:', err); console.error('Failed to update workflow:', err);
}); setSubmitting(false);
}
} }
return; return;
} }
// Create new workflow // Create new workflow
createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING') try {
.then(async (res) => { const res = await createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING');
const id = (res as any).id; const id = (res as any).id;
try { try {
await submitWorkflow(id); await submitWorkflow(id);
} catch {}
onSubmit?.({ ...formData, backendId: id, template: selectedTemplate }); onSubmit?.({ ...formData, backendId: id, template: selectedTemplate });
}) } catch (err) {
.catch((err) => { console.error('Failed to submit workflow:', err);
console.error('Failed to create workflow:', err); setSubmitting(false);
}); }
} catch (err) {
console.error('Failed to create workflow:', err);
setSubmitting(false);
}
}; };
const handleSaveDraft = () => { const handleSaveDraft = async () => {
// Same payload as submit, but do NOT call submit endpoint // Same payload as submit, but do NOT call submit endpoint
if (!selectedTemplate || !formData.title.trim() || !formData.description.trim() || !formData.priority) { if (!selectedTemplate || !formData.title.trim() || !formData.description.trim() || !formData.priority) {
// allow minimal validation for draft: require title/description/priority/template // allow minimal validation for draft: require title/description/priority/template
return; return;
} }
if (submitting || savingDraft) return;
setSavingDraft(true);
// Handle edit mode - update existing draft with full structure // Handle edit mode - update existing draft with full structure
if (isEditing && editRequestId) { if (isEditing && editRequestId) {
// Build approval levels // Build approval levels
@ -1055,18 +1180,22 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
if (hasNewFiles || hasDeletions) { if (hasNewFiles || hasDeletions) {
// Use multipart update // Use multipart update
updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete) try {
.then(() => { await updateWorkflowMultipart(editRequestId, updatePayload, formData.documents || [], documentsToDelete);
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate }); onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
}) } catch (err) {
.catch((err) => console.error('Failed to update draft:', err)); console.error('Failed to update draft:', err);
setSavingDraft(false);
}
} else { } else {
// Use regular update // Use regular update
updateWorkflow(editRequestId, updatePayload) try {
.then(() => { await updateWorkflow(editRequestId, updatePayload);
onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate }); onSubmit?.({ ...formData, backendId: editRequestId, template: selectedTemplate });
}) } catch (err) {
.catch((err) => console.error('Failed to update draft:', err)); console.error('Failed to update draft:', err);
setSavingDraft(false);
}
} }
return; return;
} }
@ -1147,11 +1276,13 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
})), })),
participants: participants, // Include participants array for draft participants: participants, // Include participants array for draft
}; };
createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING') try {
.then((res) => { const res = await createWorkflowMultipart(payload as any, formData.documents || [], 'SUPPORTING');
onSubmit?.({ ...formData, backendId: (res as any).id, template: selectedTemplate }); onSubmit?.({ ...formData, backendId: (res as any).id, template: selectedTemplate });
}) } catch (err) {
.catch((err) => console.error('Failed to save draft:', err)); console.error('Failed to save draft:', err);
setSavingDraft(false);
}
}; };
// Show loading state while fetching draft data // Show loading state while fetching draft data
@ -2267,9 +2398,9 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
<FileText className="w-5 h-5" /> <FileText className="w-5 h-5" />
File Upload File Upload
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
Attach supporting documents (PDF, Word, Excel, Images). Max 10MB per file. Attach supporting documents. Max {documentPolicy.maxFileSizeMB}MB per file. Allowed types: {documentPolicy.allowedFileTypes.join(', ')}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors"> <div className="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-gray-400 transition-colors">
@ -2281,7 +2412,7 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
<input <input
type="file" type="file"
multiple multiple
accept=".pdf,.doc,.docx,.xls,.xlsx,.jpg,.jpeg,.png,.ppt,.pptx" accept={documentPolicy.allowedFileTypes.map(ext => `.${ext}`).join(',')}
onChange={handleFileUpload} onChange={handleFileUpload}
className="hidden" className="hidden"
id="file-upload" id="file-upload"
@ -2292,7 +2423,7 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
Browse Files Browse Files
</Button> </Button>
<p className="text-xs text-gray-500 mt-2"> <p className="text-xs text-gray-500 mt-2">
Supported formats: PDF, DOC, DOCX, XLS, XLSX, JPG, PNG, PPT, PPTX Supported formats: {documentPolicy.allowedFileTypes.map(ext => ext.toUpperCase()).join(', ')} (Max {documentPolicy.maxFileSizeMB}MB per file)
</p> </p>
</div> </div>
</CardContent> </CardContent>
@ -2986,19 +3117,35 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
onClick={handleSaveDraft} onClick={handleSaveDraft}
size="sm" size="sm"
className="sm:size-lg flex-1 sm:flex-none text-xs sm:text-sm" className="sm:size-lg flex-1 sm:flex-none text-xs sm:text-sm"
disabled={loadingDraft} disabled={loadingDraft || submitting || savingDraft}
> >
{isEditing ? 'Update Draft' : 'Save Draft'} {savingDraft ? (
<>
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2 animate-spin" />
{isEditing ? 'Updating...' : 'Saving...'}
</>
) : (
isEditing ? 'Update Draft' : 'Save Draft'
)}
</Button> </Button>
{currentStep === totalSteps ? ( {currentStep === totalSteps ? (
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!isStepValid() || loadingDraft} disabled={!isStepValid() || loadingDraft || submitting || savingDraft}
size="sm" size="sm"
className="sm:size-lg bg-green-600 hover:bg-green-700 flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm" className="sm:size-lg bg-green-600 hover:bg-green-700 flex-1 sm:flex-none sm:px-8 text-xs sm:text-sm"
> >
<Rocket className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" /> {submitting ? (
Submit <>
<Loader2 className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2 animate-spin" />
Submitting...
</>
) : (
<>
<Rocket className="h-3 w-3 sm:h-4 sm:w-4 mr-1 sm:mr-2" />
Submit
</>
)}
</Button> </Button>
) : ( ) : (
<Button <Button
@ -3062,6 +3209,48 @@ export function CreateRequest({ onBack, onSubmit, requestId: propRequestId, isEd
/> />
)} )}
{/* Document Validation Error Modal */}
<Dialog open={documentErrorModal.open} onOpenChange={(open) => setDocumentErrorModal(prev => ({ ...prev, open }))}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
Document Upload Policy Violation
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<p className="text-gray-700">
The following file(s) could not be uploaded due to policy violations:
</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{documentErrorModal.errors.map((error, index) => (
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="font-medium text-red-900 text-sm">{error.fileName}</p>
<p className="text-xs text-red-700 mt-1">{error.reason}</p>
</div>
))}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-800 font-semibold mb-1">Document Policy:</p>
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
<li>Maximum file size: {documentPolicy.maxFileSizeMB}MB</li>
<li>Allowed file types: {documentPolicy.allowedFileTypes.join(', ')}</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={() => setDocumentErrorModal({ open: false, errors: [] })}
className="w-full sm:w-auto"
>
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Validation Error Modal */} {/* Validation Error Modal */}
<Dialog open={validationModal.open} onOpenChange={(open) => setValidationModal(prev => ({ ...prev, open }))}> <Dialog open={validationModal.open} onOpenChange={(open) => setValidationModal(prev => ({ ...prev, open }))}>
<DialogContent className="sm:max-w-md"> <DialogContent className="sm:max-w-md">

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,2 @@
export { DetailedReports } from './DetailedReports';

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Card, CardContent } from '@/components/ui/card'; import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@ -15,7 +15,6 @@ import {
Edit, Edit,
Flame, Flame,
Target, Target,
Eye,
AlertCircle AlertCircle
} from 'lucide-react'; } from 'lucide-react';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
@ -72,11 +71,11 @@ const getStatusConfig = (status: string) => {
icon: Clock, icon: Clock,
iconColor: 'text-yellow-600' iconColor: 'text-yellow-600'
}; };
case 'in-review': case 'closed':
return { return {
color: 'bg-blue-100 text-blue-800 border-blue-200', color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: Eye, icon: CheckCircle,
iconColor: 'text-blue-600' iconColor: 'text-gray-600'
}; };
case 'draft': case 'draft':
return { return {
@ -97,6 +96,7 @@ const getStatusConfig = (status: string) => {
export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) { export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsProps) {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const [priorityFilter, setPriorityFilter] = useState('all');
const [apiRequests, setApiRequests] = useState<any[]>([]); const [apiRequests, setApiRequests] = useState<any[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [hasFetchedFromApi, setHasFetchedFromApi] = useState(false); const [hasFetchedFromApi, setHasFetchedFromApi] = useState(false);
@ -107,14 +107,20 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
const [totalRecords, setTotalRecords] = useState(0); const [totalRecords, setTotalRecords] = useState(0);
const [itemsPerPage] = useState(10); const [itemsPerPage] = useState(10);
const fetchMyRequests = async (page: number = 1) => { const fetchMyRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string }) => {
try { try {
if (page === 1) { if (page === 1) {
setLoading(true); setLoading(true);
setApiRequests([]); setApiRequests([]);
} }
const result = await workflowApi.listMyWorkflows({ page, limit: itemsPerPage }); const result = await workflowApi.listMyWorkflows({
page,
limit: itemsPerPage,
search: filters?.search,
status: filters?.status,
priority: filters?.priority
});
console.log('[MyRequests] API Response:', result); // Debug log console.log('[MyRequests] API Response:', result); // Debug log
// Extract data - workflowApi now returns { data: [], pagination: {} } // Extract data - workflowApi now returns { data: [], pagination: {} }
@ -141,18 +147,44 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [itemsPerPage]);
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) { if (newPage >= 1 && newPage <= totalPages) {
setCurrentPage(newPage); setCurrentPage(newPage);
fetchMyRequests(newPage); fetchMyRequests(newPage, {
search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
priority: priorityFilter !== 'all' ? priorityFilter : undefined
});
} }
}; };
// Initial fetch on mount
useEffect(() => { useEffect(() => {
fetchMyRequests(1); fetchMyRequests(1, {
}, []); search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
priority: priorityFilter !== 'all' ? priorityFilter : undefined
});
}, [fetchMyRequests]);
// Fetch when filters change (with debouncing for search)
useEffect(() => {
// Debounce search: wait 500ms after user stops typing
const timeoutId = setTimeout(() => {
if (hasFetchedFromApi) { // Only refetch if we've already loaded data once
setCurrentPage(1); // Reset to page 1 when filters change
fetchMyRequests(1, {
search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
priority: priorityFilter !== 'all' ? priorityFilter : undefined
});
}
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
return () => clearTimeout(timeoutId);
}, [searchTerm, statusFilter, priorityFilter, hasFetchedFromApi, fetchMyRequests]);
// Convert API/dynamic requests to the format expected by this component // Convert API/dynamic requests to the format expected by this component
// Once API has fetched (even if empty), always use API data, never fall back to props // Once API has fetched (even if empty), always use API data, never fall back to props
@ -167,7 +199,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
displayId: req.requestNumber || req.request_number || req.id, displayId: req.requestNumber || req.request_number || req.id,
title: req.title, title: req.title,
description: req.description, description: req.description,
status: (req.status || '').toString().toLowerCase().replace('in_progress','in-review'), status: (req.status || '').toString().toLowerCase().replace('_','-'),
priority: priority, priority: priority,
department: req.department, department: req.department,
submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined), submittedDate: req.submittedAt || (req.createdAt ? new Date(req.createdAt).toISOString().split('T')[0] : undefined),
@ -179,31 +211,20 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
}; };
}) : []; }) : [];
// Use only API/dynamic requests // Use only API/dynamic requests - backend already filtered
const allRequests = convertedDynamicRequests; const allRequests = convertedDynamicRequests;
const [priorityFilter, setPriorityFilter] = useState('all');
// Filter requests based on search and filters // No frontend filtering - backend handles all filtering
const filteredRequests = allRequests.filter(request => { const filteredRequests = allRequests;
const matchesSearch =
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.id.toLowerCase().includes(searchTerm.toLowerCase());
const matchesStatus = statusFilter === 'all' || request.status === statusFilter;
const matchesPriority = priorityFilter === 'all' || request.priority === priorityFilter;
return matchesSearch && matchesStatus && matchesPriority;
});
// Stats calculation - using total from pagination for total count // Stats calculation - using total from pagination for total count
const stats = { const stats = {
total: totalRecords || allRequests.length, total: totalRecords || allRequests.length,
pending: allRequests.filter(r => r.status === 'pending').length, pending: allRequests.filter(r => r.status === 'pending').length,
approved: allRequests.filter(r => r.status === 'approved').length, approved: allRequests.filter(r => r.status === 'approved').length,
inReview: allRequests.filter(r => r.status === 'in-review').length,
rejected: allRequests.filter(r => r.status === 'rejected').length, rejected: allRequests.filter(r => r.status === 'rejected').length,
draft: allRequests.filter(r => r.status === 'draft').length draft: allRequests.filter(r => r.status === 'draft').length,
closed: allRequests.filter(r => r.status === 'closed').length
}; };
return ( return (
@ -235,14 +256,14 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
/> />
<StatsCard <StatsCard
label="In Progress" label="Pending"
value={stats.pending + stats.inReview} value={stats.pending}
icon={Clock} icon={Clock}
iconColor="text-orange-600" iconColor="text-orange-600"
gradient="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200" gradient="bg-gradient-to-br from-orange-50 to-orange-100 border-orange-200"
textColor="text-orange-700" textColor="text-orange-700"
valueColor="text-orange-900" valueColor="text-orange-900"
testId="stat-in-progress" testId="stat-pending"
/> />
<StatsCard <StatsCard
@ -289,7 +310,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
placeholder="Search requests by title, description, or ID..." placeholder="Search requests by title, description, or ID..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 text-sm sm:text-base bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10" className="pl-9 text-sm sm:text-base bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
data-testid="search-input" data-testid="search-input"
/> />
</div> </div>
@ -297,7 +318,7 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
<div className="flex gap-2 sm:gap-3 w-full md:w-auto"> <div className="flex gap-2 sm:gap-3 w-full md:w-auto">
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger <SelectTrigger
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10" className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
data-testid="status-filter" data-testid="status-filter"
> >
<SelectValue placeholder="Status" /> <SelectValue placeholder="Status" />
@ -306,15 +327,15 @@ export function MyRequests({ onViewRequest, dynamicRequests = [] }: MyRequestsPr
<SelectItem value="all">All Status</SelectItem> <SelectItem value="all">All Status</SelectItem>
<SelectItem value="draft">Draft</SelectItem> <SelectItem value="draft">Draft</SelectItem>
<SelectItem value="pending">Pending</SelectItem> <SelectItem value="pending">Pending</SelectItem>
<SelectItem value="in-review">In Review</SelectItem>
<SelectItem value="approved">Approved</SelectItem> <SelectItem value="approved">Approved</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem> <SelectItem value="rejected">Rejected</SelectItem>
<SelectItem value="closed">Closed</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<Select value={priorityFilter} onValueChange={setPriorityFilter}> <Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger <SelectTrigger
className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-re-green focus:ring-1 focus:ring-re-green h-9 sm:h-10" className="flex-1 md:w-28 lg:w-32 text-xs sm:text-sm bg-white border-gray-300 hover:border-gray-400 focus:border-blue-400 focus:ring-1 focus:ring-blue-200 h-9 sm:h-10"
data-testid="priority-filter" data-testid="priority-filter"
> >
<SelectValue placeholder="Priority" /> <SelectValue placeholder="Priority" />

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
@ -6,14 +6,14 @@ import { Input } from '@/components/ui/input';
import { Progress } from '@/components/ui/progress'; import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, Eye, RefreshCw, Settings2, X } from 'lucide-react'; import { Calendar, Clock, Filter, Search, FileText, AlertCircle, ArrowRight, SortAsc, SortDesc, Flame, Target, RefreshCw, Settings2, X, CheckCircle, XCircle } from 'lucide-react';
import workflowApi from '@/services/workflowApi'; import workflowApi from '@/services/workflowApi';
import { formatDateShort } from '@/utils/dateFormatter'; import { formatDateShort } from '@/utils/dateFormatter';
interface Request { interface Request {
id: string; id: string;
title: string; title: string;
description: string; description: string;
status: 'pending' | 'in-review' | 'approved'; status: 'pending' | 'approved' | 'rejected' | 'closed';
priority: 'express' | 'standard'; priority: 'express' | 'standard';
initiator: { name: string; avatar: string }; initiator: { name: string; avatar: string };
currentApprover?: { currentApprover?: {
@ -61,13 +61,8 @@ const getStatusConfig = (status: string) => {
return { return {
color: 'bg-yellow-100 text-yellow-800 border-yellow-200', color: 'bg-yellow-100 text-yellow-800 border-yellow-200',
icon: Clock, icon: Clock,
iconColor: 'text-yellow-600' iconColor: 'text-yellow-600',
}; label: 'Pending'
case 'in-review':
return {
color: 'bg-blue-100 text-blue-800 border-blue-200',
icon: Eye,
iconColor: 'text-blue-600'
}; };
case 'approved': case 'approved':
return { return {
@ -76,11 +71,26 @@ const getStatusConfig = (status: string) => {
iconColor: 'text-green-600', iconColor: 'text-green-600',
label: 'Needs Closure' label: 'Needs Closure'
}; };
case 'rejected':
return {
color: 'bg-red-100 text-red-800 border-red-200',
icon: XCircle,
iconColor: 'text-red-600',
label: 'Rejected'
};
case 'closed':
return {
color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: CheckCircle,
iconColor: 'text-gray-600',
label: 'Closed'
};
default: default:
return { return {
color: 'bg-gray-100 text-gray-800 border-gray-200', color: 'bg-gray-100 text-gray-800 border-gray-200',
icon: AlertCircle, icon: AlertCircle,
iconColor: 'text-gray-600' iconColor: 'text-gray-600',
label: status
}; };
} }
}; };
@ -104,14 +114,22 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
const [totalRecords, setTotalRecords] = useState(0); const [totalRecords, setTotalRecords] = useState(0);
const [itemsPerPage] = useState(10); const [itemsPerPage] = useState(10);
const fetchRequests = async (page: number = 1) => { const fetchRequests = useCallback(async (page: number = 1, filters?: { search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string }) => {
try { try {
if (page === 1) { if (page === 1) {
setLoading(true); setLoading(true);
setItems([]); setItems([]);
} }
const result = await workflowApi.listOpenForMe({ page, limit: itemsPerPage }); const result = await workflowApi.listOpenForMe({
page,
limit: itemsPerPage,
search: filters?.search,
status: filters?.status,
priority: filters?.priority,
sortBy: filters?.sortBy,
sortOrder: filters?.sortOrder
});
console.log('[OpenRequests] API Response:', result); // Debug log console.log('[OpenRequests] API Response:', result); // Debug log
// Extract data - workflowApi now returns { data: [], pagination: {} } // Extract data - workflowApi now returns { data: [], pagination: {} }
@ -138,7 +156,7 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
displayId: r.requestNumber || r.request_number || r.requestId, displayId: r.requestNumber || r.request_number || r.requestId,
title: r.title, title: r.title,
description: r.description, description: r.description,
status: ((r.status || '').toString().toUpperCase() === 'IN_PROGRESS') ? 'in-review' : 'pending', status: (r.status || '').toString().toLowerCase().replace('_', '-'),
priority: (r.priority || '').toString().toLowerCase(), priority: (r.priority || '').toString().toLowerCase(),
initiator: { initiator: {
name: (r.initiator?.displayName || r.initiator?.email || '—'), name: (r.initiator?.displayName || r.initiator?.email || '—'),
@ -160,17 +178,29 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
setLoading(false); setLoading(false);
setRefreshing(false); setRefreshing(false);
} }
}; }, [itemsPerPage]);
const handleRefresh = () => { const handleRefresh = () => {
setRefreshing(true); setRefreshing(true);
fetchRequests(currentPage); fetchRequests(currentPage, {
search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
sortBy,
sortOrder
});
}; };
const handlePageChange = (newPage: number) => { const handlePageChange = (newPage: number) => {
if (newPage >= 1 && newPage <= totalPages) { if (newPage >= 1 && newPage <= totalPages) {
setCurrentPage(newPage); setCurrentPage(newPage);
fetchRequests(newPage); fetchRequests(newPage, {
search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
priority: priorityFilter !== 'all' ? priorityFilter : undefined,
sortBy,
sortOrder
});
} }
}; };
@ -191,59 +221,39 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
return pages; return pages;
}; };
// Initial fetch on mount
useEffect(() => { useEffect(() => {
fetchRequests(1); fetchRequests(1, {
}, []); search: searchTerm || undefined,
status: statusFilter !== 'all' ? statusFilter : undefined,
const filteredAndSortedRequests = useMemo(() => { priority: priorityFilter !== 'all' ? priorityFilter : undefined,
let filtered = items.filter(request => { sortBy,
const matchesSearch = sortOrder
request.title.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.id.toLowerCase().includes(searchTerm.toLowerCase()) ||
request.initiator.name.toLowerCase().includes(searchTerm.toLowerCase());
const matchesPriority = priorityFilter === 'all' || request.priority === priorityFilter;
const matchesStatus = statusFilter === 'all' || request.status === statusFilter;
return matchesSearch && matchesPriority && matchesStatus;
}); });
}, [fetchRequests]);
// Sort requests
filtered.sort((a, b) => { // Fetch when filters or sorting change (with debouncing for search)
let aValue: any, bValue: any; useEffect(() => {
// Debounce search: wait 500ms after user stops typing
switch (sortBy) { const timeoutId = setTimeout(() => {
case 'created': if (items.length > 0 || loading) { // Only refetch if we've already loaded data once
aValue = new Date(a.createdAt); setCurrentPage(1); // Reset to page 1 when filters change
bValue = new Date(b.createdAt); fetchRequests(1, {
break; search: searchTerm || undefined,
case 'due': status: statusFilter !== 'all' ? statusFilter : undefined,
aValue = a.currentLevelSLA?.deadline ? new Date(a.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER; priority: priorityFilter !== 'all' ? priorityFilter : undefined,
bValue = b.currentLevelSLA?.deadline ? new Date(b.currentLevelSLA.deadline).getTime() : Number.MAX_SAFE_INTEGER; sortBy,
break; sortOrder
case 'priority': });
const priorityOrder = { express: 2, standard: 1 };
aValue = priorityOrder[a.priority as keyof typeof priorityOrder];
bValue = priorityOrder[b.priority as keyof typeof priorityOrder];
break;
case 'sla':
// Sort by SLA percentage (most urgent first)
aValue = a.currentLevelSLA?.percentageUsed || 0;
bValue = b.currentLevelSLA?.percentageUsed || 0;
break;
default:
return 0;
} }
}, searchTerm ? 500 : 0); // Debounce only for search, instant for dropdowns
if (sortOrder === 'asc') {
return aValue > bValue ? 1 : -1;
} else {
return aValue < bValue ? 1 : -1;
}
});
return filtered; return () => clearTimeout(timeoutId);
}, [items, searchTerm, priorityFilter, statusFilter, sortBy, sortOrder]); }, [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 = () => { const clearFilters = () => {
setSearchTerm(''); setSearchTerm('');
@ -343,12 +353,12 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
placeholder="Search requests, IDs..." placeholder="Search requests, IDs..."
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white transition-colors" className="pl-9 sm:pl-10 h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200 transition-colors"
/> />
</div> </div>
<Select value={priorityFilter} onValueChange={setPriorityFilter}> <Select value={priorityFilter} onValueChange={setPriorityFilter}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="All Priorities" /> <SelectValue placeholder="All Priorities" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
@ -369,19 +379,19 @@ export function OpenRequests({ onViewRequest }: OpenRequestsProps) {
</Select> </Select>
<Select value={statusFilter} onValueChange={setStatusFilter}> <Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="All Statuses" /> <SelectValue placeholder="All Statuses" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="all">All Statuses</SelectItem> <SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending">Pending</SelectItem> <SelectItem value="pending">Pending (In Approval)</SelectItem>
<SelectItem value="in-review">In Review</SelectItem> <SelectItem value="approved">Approved (Needs Closure)</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
<div className="flex gap-2"> <div className="flex gap-2">
<Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}> <Select value={sortBy} onValueChange={(value: any) => setSortBy(value)}>
<SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white"> <SelectTrigger className="h-9 sm:h-10 md:h-11 text-sm sm:text-base bg-gray-50 border-gray-200 focus:bg-white focus:border-blue-400 focus:ring-1 focus:ring-blue-200">
<SelectValue placeholder="Sort by" /> <SelectValue placeholder="Sort by" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>

View File

@ -85,13 +85,13 @@ export function Profile() {
)} )}
</div> </div>
</div> </div>
<Button {/* <Button
variant="secondary" variant="secondary"
className="bg-white text-slate-900 hover:bg-gray-100" className="bg-white text-slate-900 hover:bg-gray-100"
> >
<Edit className="w-4 h-4 mr-2" /> <Edit className="w-4 h-4 mr-2" />
Edit Profile Edit Profile
</Button> </Button> */}
</div> </div>
</div> </div>
</div> </div>

View File

@ -31,6 +31,7 @@ import { Button } from '@/components/ui/button';
import { Avatar, AvatarFallback } from '@/components/ui/avatar'; import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog';
// Utility imports // Utility imports
import { formatDateTime, formatDateShort } from '@/utils/dateFormatter'; import { formatDateTime, formatDateShort } from '@/utils/dateFormatter';
@ -77,7 +78,8 @@ import {
UserPlus, UserPlus,
ClipboardList, ClipboardList,
AlertTriangle, AlertTriangle,
Loader2 Loader2,
AlertCircle
} from 'lucide-react'; } from 'lucide-react';
/** /**
@ -235,7 +237,10 @@ function RequestDetailInner({
uploadingDocument, uploadingDocument,
triggerFileInput, triggerFileInput,
previewDocument, previewDocument,
setPreviewDocument setPreviewDocument,
documentPolicy,
documentError,
setDocumentError
} = useDocumentUpload(apiRequest, refreshDetails); } = useDocumentUpload(apiRequest, refreshDetails);
/** /**
@ -892,17 +897,22 @@ function RequestDetailInner({
<CardDescription className="text-xs sm:text-sm mt-1">Documents attached while creating the request</CardDescription> <CardDescription className="text-xs sm:text-sm mt-1">Documents attached while creating the request</CardDescription>
</div> </div>
{/* Upload Document Button */} {/* Upload Document Button */}
<Button <div className="flex flex-col items-end gap-1">
size="sm" <Button
onClick={triggerFileInput} size="sm"
disabled={uploadingDocument || request.status === 'closed'} onClick={triggerFileInput}
className="gap-1 sm:gap-2 h-8 sm:h-9 text-xs sm:text-sm shrink-0" disabled={uploadingDocument || request.status === 'closed'}
data-testid="upload-document-btn" className="gap-1 sm:gap-2 h-8 sm:h-9 text-xs sm:text-sm shrink-0"
> data-testid="upload-document-btn"
<Upload className="w-3.5 h-3.5 sm:w-4 sm:h-4" /> >
{uploadingDocument ? 'Uploading...' : request.status === 'closed' ? 'Closed' : 'Upload'} <Upload className="w-3.5 h-3.5 sm:w-4 sm:h-4" />
<span className="hidden sm:inline">{request.status === 'closed' ? '' : 'Document'}</span> {uploadingDocument ? 'Uploading...' : request.status === 'closed' ? 'Closed' : 'Upload'}
</Button> <span className="hidden sm:inline">{request.status === 'closed' ? '' : 'Document'}</span>
</Button>
<p className="text-xs text-gray-500 whitespace-nowrap">
Max {documentPolicy.maxFileSizeMB}MB
</p>
</div>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -1226,6 +1236,48 @@ function RequestDetailInner({
message={actionStatus.message} message={actionStatus.message}
/> />
)} )}
{/* Document Validation Error Modal */}
<Dialog open={documentError.show} onOpenChange={(open) => setDocumentError(prev => ({ ...prev, show: open }))}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertCircle className="w-5 h-5 text-red-600" />
Document Upload Policy Violation
</DialogTitle>
<DialogDescription asChild>
<div className="space-y-3">
<p className="text-gray-700">
The following file(s) could not be uploaded due to policy violations:
</p>
<div className="space-y-2 max-h-60 overflow-y-auto">
{documentError.errors.map((error, index) => (
<div key={index} className="bg-red-50 border border-red-200 rounded-lg p-3">
<p className="font-medium text-red-900 text-sm">{error.fileName}</p>
<p className="text-xs text-red-700 mt-1">{error.reason}</p>
</div>
))}
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<p className="text-sm text-blue-800 font-semibold mb-1">Document Policy:</p>
<ul className="text-xs text-blue-700 space-y-1 list-disc list-inside">
<li>Maximum file size: {documentPolicy.maxFileSizeMB}MB</li>
<li>Allowed file types: {documentPolicy.allowedFileTypes.join(', ')}</li>
</ul>
</div>
</div>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
onClick={() => setDocumentError({ show: false, errors: [] })}
className="w-full sm:w-auto"
>
OK
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</> </>
); );
} }

View File

@ -25,17 +25,34 @@ export function Settings() {
const [showNotificationModal, setShowNotificationModal] = useState(false); const [showNotificationModal, setShowNotificationModal] = useState(false);
const [notificationSuccess, setNotificationSuccess] = useState(false); const [notificationSuccess, setNotificationSuccess] = useState(false);
const [notificationMessage, setNotificationMessage] = useState<string>(); const [notificationMessage, setNotificationMessage] = useState<string>();
const [isEnablingNotifications, setIsEnablingNotifications] = useState(false);
const handleEnableNotifications = async () => { const handleEnableNotifications = async () => {
setIsEnablingNotifications(true);
setShowNotificationModal(false);
try { try {
await setupPushNotifications(); await setupPushNotifications();
setNotificationSuccess(true); setNotificationSuccess(true);
setNotificationMessage('You will now receive push notifications for workflow updates, approvals, and TAT alerts.'); setNotificationMessage('Push notifications have been successfully enabled! You will now receive notifications for workflow updates, approvals, and TAT alerts.');
setShowNotificationModal(true); setShowNotificationModal(true);
} catch (error: any) { } catch (error: any) {
console.error('[Settings] Error enabling notifications:', error);
setNotificationSuccess(false); setNotificationSuccess(false);
setNotificationMessage(error?.message || 'Unable to enable push notifications. Please check your browser settings.'); // Provide more specific error messages
const errorMessage = error?.message || 'Unknown error occurred';
setNotificationMessage(
errorMessage.includes('permission')
? 'Notification permission was denied. Please enable notifications in your browser settings and try again.'
: errorMessage.includes('Service worker')
? 'Service worker registration failed. Please refresh the page and try again.'
: errorMessage.includes('token')
? 'Authentication required. Please log in again and try enabling notifications.'
: `Unable to enable push notifications: ${errorMessage}`
);
setShowNotificationModal(true); setShowNotificationModal(true);
} finally {
setIsEnablingNotifications(false);
} }
}; };
@ -120,10 +137,11 @@ export function Settings() {
<div className="space-y-4"> <div className="space-y-4">
<Button <Button
onClick={handleEnableNotifications} onClick={handleEnableNotifications}
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transition-all" disabled={isEnablingNotifications}
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Bell className="w-4 h-4 mr-2" /> <Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
Enable Push Notifications {isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
@ -234,10 +252,11 @@ export function Settings() {
<div className="space-y-4"> <div className="space-y-4">
<Button <Button
onClick={handleEnableNotifications} onClick={handleEnableNotifications}
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transition-all" disabled={isEnablingNotifications}
className="w-full bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white shadow-md hover:shadow-lg transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
<Bell className="w-4 h-4 mr-2" /> <Bell className={`w-4 h-4 mr-2 ${isEnablingNotifications ? 'animate-pulse' : ''}`} />
Enable Push Notifications {isEnablingNotifications ? 'Enabling...' : 'Enable Push Notifications'}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>

View File

@ -154,17 +154,20 @@ export interface PriorityDistribution {
complianceRate: number; complianceRate: number;
} }
export type DateRange = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'last30days'; export type DateRange = 'today' | 'week' | 'month' | 'quarter' | 'year' | 'last30days' | 'custom';
class DashboardService { class DashboardService {
/** /**
* Get all KPI metrics * Get all KPI metrics
*/ */
async getKPIs(dateRange?: DateRange): Promise<DashboardKPIs> { async getKPIs(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<DashboardKPIs> {
try { try {
const response = await apiClient.get('/dashboard/kpis', { const params: any = { dateRange };
params: { dateRange } if (dateRange === 'custom' && startDate && endDate) {
}); params.startDate = startDate.toISOString();
params.endDate = endDate.toISOString();
}
const response = await apiClient.get('/dashboard/kpis', { params });
return response.data.data; return response.data.data;
} catch (error) { } catch (error) {
console.error('Failed to fetch KPIs:', error); console.error('Failed to fetch KPIs:', error);
@ -328,11 +331,14 @@ class DashboardService {
/** /**
* Get department-wise statistics * Get department-wise statistics
*/ */
async getDepartmentStats(dateRange?: DateRange): Promise<DepartmentStats[]> { async getDepartmentStats(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<DepartmentStats[]> {
try { try {
const response = await apiClient.get('/dashboard/stats/by-department', { const params: any = { dateRange };
params: { dateRange } if (dateRange === 'custom' && startDate && endDate) {
}); params.startDate = startDate.toISOString();
params.endDate = endDate.toISOString();
}
const response = await apiClient.get('/dashboard/stats/by-department', { params });
return response.data.data; return response.data.data;
} catch (error) { } catch (error) {
console.error('Failed to fetch department stats:', error); console.error('Failed to fetch department stats:', error);
@ -343,11 +349,14 @@ class DashboardService {
/** /**
* Get priority distribution * Get priority distribution
*/ */
async getPriorityDistribution(dateRange?: DateRange): Promise<PriorityDistribution[]> { async getPriorityDistribution(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<PriorityDistribution[]> {
try { try {
const response = await apiClient.get('/dashboard/stats/priority-distribution', { const params: any = { dateRange };
params: { dateRange } if (dateRange === 'custom' && startDate && endDate) {
}); params.startDate = startDate.toISOString();
params.endDate = endDate.toISOString();
}
const response = await apiClient.get('/dashboard/stats/priority-distribution', { params });
return response.data.data; return response.data.data;
} catch (error) { } catch (error) {
console.error('Failed to fetch priority distribution:', error); console.error('Failed to fetch priority distribution:', error);
@ -358,11 +367,14 @@ class DashboardService {
/** /**
* Get AI Remark Utilization with monthly trends * Get AI Remark Utilization with monthly trends
*/ */
async getAIRemarkUtilization(dateRange?: DateRange): Promise<AIRemarkUtilization> { async getAIRemarkUtilization(dateRange?: DateRange, startDate?: Date, endDate?: Date): Promise<AIRemarkUtilization> {
try { try {
const response = await apiClient.get('/dashboard/stats/ai-remark-utilization', { const params: any = { dateRange };
params: { dateRange } if (dateRange === 'custom' && startDate && endDate) {
}); params.startDate = startDate.toISOString();
params.endDate = endDate.toISOString();
}
const response = await apiClient.get('/dashboard/stats/ai-remark-utilization', { params });
return response.data.data; return response.data.data;
} catch (error) { } catch (error) {
console.error('Failed to fetch AI remark utilization:', error); console.error('Failed to fetch AI remark utilization:', error);
@ -373,7 +385,7 @@ class DashboardService {
/** /**
* Get Approver Performance metrics with pagination * Get Approver Performance metrics with pagination
*/ */
async getApproverPerformance(dateRange?: DateRange, page: number = 1, limit: number = 10): Promise<{ async getApproverPerformance(dateRange?: DateRange, page: number = 1, limit: number = 10, startDate?: Date, endDate?: Date): Promise<{
performance: ApproverPerformance[], performance: ApproverPerformance[],
pagination: { pagination: {
currentPage: number, currentPage: number,
@ -383,9 +395,12 @@ class DashboardService {
} }
}> { }> {
try { try {
const response = await apiClient.get('/dashboard/stats/approver-performance', { const params: any = { dateRange, page, limit };
params: { dateRange, page, limit } if (dateRange === 'custom' && startDate && endDate) {
}); params.startDate = startDate.toISOString();
params.endDate = endDate.toISOString();
}
const response = await apiClient.get('/dashboard/stats/approver-performance', { params });
return { return {
performance: response.data.data, performance: response.data.data,
pagination: response.data.pagination pagination: response.data.pagination
@ -395,6 +410,97 @@ class DashboardService {
throw error; throw error;
} }
} }
/**
* Get Request Lifecycle Report
*/
async getLifecycleReport(page: number = 1, limit: number = 50): Promise<{
lifecycleData: any[],
pagination: {
currentPage: number,
totalPages: number,
totalRecords: number,
limit: number
}
}> {
try {
const response = await apiClient.get('/dashboard/reports/lifecycle', {
params: { page, limit }
});
return {
lifecycleData: response.data.data,
pagination: response.data.pagination
};
} catch (error) {
console.error('Failed to fetch lifecycle report:', error);
throw error;
}
}
/**
* Get enhanced User Activity Log Report
*/
async getActivityLogReport(
page: number = 1,
limit: number = 50,
dateRange?: DateRange,
filterUserId?: string,
filterType?: string,
filterCategory?: string,
filterSeverity?: string
): Promise<{
activities: any[],
pagination: {
currentPage: number,
totalPages: number,
totalRecords: number,
limit: number
}
}> {
try {
const response = await apiClient.get('/dashboard/reports/activity-log', {
params: { page, limit, dateRange, filterUserId, filterType, filterCategory, filterSeverity }
});
return {
activities: response.data.data,
pagination: response.data.pagination
};
} catch (error) {
console.error('Failed to fetch activity log report:', error);
throw error;
}
}
/**
* Get Workflow Aging Report
*/
async getWorkflowAgingReport(
threshold: number = 7,
page: number = 1,
limit: number = 50,
dateRange?: DateRange
): Promise<{
agingData: any[],
pagination: {
currentPage: number,
totalPages: number,
totalRecords: number,
limit: number
}
}> {
try {
const response = await apiClient.get('/dashboard/reports/workflow-aging', {
params: { threshold, page, limit, dateRange }
});
return {
agingData: response.data.data,
pagination: response.data.pagination
};
} catch (error) {
console.error('Failed to fetch workflow aging report:', error);
throw error;
}
}
} }
export const dashboardService = new DashboardService(); export const dashboardService = new DashboardService();

View File

@ -159,9 +159,9 @@ export async function listWorkflows(params: { page?: number; limit?: number } =
return res.data?.data || res.data; return res.data?.data || res.data;
} }
export async function listMyWorkflows(params: { page?: number; limit?: number } = {}) { export async function listMyWorkflows(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string } = {}) {
const { page = 1, limit = 20 } = params; const { page = 1, limit = 20, search, status, priority } = params;
const res = await apiClient.get('/workflows/my', { params: { page, limit } }); const res = await apiClient.get('/workflows/my', { params: { page, limit, search, status, priority } });
// Response structure: { success, data: { data: [...], pagination: {...} } } // Response structure: { success, data: { data: [...], pagination: {...} } }
return { return {
data: res.data?.data?.data || res.data?.data || [], data: res.data?.data?.data || res.data?.data || [],
@ -169,9 +169,9 @@ export async function listMyWorkflows(params: { page?: number; limit?: number }
}; };
} }
export async function listOpenForMe(params: { page?: number; limit?: number } = {}) { export async function listOpenForMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string } = {}) {
const { page = 1, limit = 20 } = params; const { page = 1, limit = 20, search, status, priority, sortBy, sortOrder } = params;
const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit } }); const res = await apiClient.get('/workflows/open-for-me', { params: { page, limit, search, status, priority, sortBy, sortOrder } });
// Response structure: { success, data: { data: [...], pagination: {...} } } // Response structure: { success, data: { data: [...], pagination: {...} } }
return { return {
data: res.data?.data?.data || res.data?.data || [], data: res.data?.data?.data || res.data?.data || [],
@ -179,9 +179,9 @@ export async function listOpenForMe(params: { page?: number; limit?: number } =
}; };
} }
export async function listClosedByMe(params: { page?: number; limit?: number } = {}) { export async function listClosedByMe(params: { page?: number; limit?: number; search?: string; status?: string; priority?: string; sortBy?: string; sortOrder?: string } = {}) {
const { page = 1, limit = 20 } = params; const { page = 1, limit = 20, search, status, priority, sortBy, sortOrder } = params;
const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit } }); const res = await apiClient.get('/workflows/closed-by-me', { params: { page, limit, search, status, priority, sortBy, sortOrder } });
// Response structure: { success, data: { data: [...], pagination: {...} } } // Response structure: { success, data: { data: [...], pagination: {...} } }
return { return {
data: res.data?.data?.data || res.data?.data || [], data: res.data?.data?.data || res.data?.data || [],

View File

@ -235,6 +235,16 @@
border-color: #d1d5db !important; border-color: #d1d5db !important;
} }
.border-gray-400 {
border-color: #9ca3af !important;
border-width: 1px !important;
border-style: solid !important;
}
.border-gray-500 {
border-color: #6b7280 !important;
}
.border-gray-200 { .border-gray-200 {
border-color: #e5e7eb !important; border-color: #e5e7eb !important;
} }
@ -243,7 +253,53 @@
border-color: #9ca3af !important; border-color: #9ca3af !important;
} }
/* Focus states for inputs */ .hover\:border-gray-500:hover {
border-color: #6b7280 !important;
}
/* Ensure all inputs have visible borders by default */
input[data-slot="input"],
textarea[data-slot="textarea"],
[data-slot="select-trigger"] {
border-width: 1px !important;
border-style: solid !important;
}
/* Focus states for inputs - Override all blue focus colors with light green */
input:focus-visible,
input[type="text"]:focus-visible,
input[type="number"]:focus-visible,
input[type="email"]:focus-visible,
input[type="search"]:focus-visible,
input[type="password"]:focus-visible,
textarea:focus-visible,
select:focus-visible,
[data-slot="input"]:focus-visible,
[data-slot="textarea"]:focus-visible,
[data-slot="select-trigger"]:focus-visible,
input:focus,
input[type="text"]:focus,
input[type="number"]:focus,
input[type="email"]:focus,
input[type="search"]:focus,
textarea:focus,
select:focus {
border-color: var(--re-light-green) !important;
outline: none !important;
box-shadow: none !important;
--tw-ring-width: 0 !important;
--tw-ring-offset-width: 0 !important;
}
/* Override any blue focus classes */
.focus\:border-blue-400:focus,
.focus\:border-blue-500:focus,
.focus\:ring-blue-200:focus,
.focus\:ring-blue-500:focus {
border-color: var(--re-light-green) !important;
--tw-ring-color: var(--re-light-green) !important;
}
.focus\:border-re-green:focus { .focus\:border-re-green:focus {
border-color: var(--re-green) !important; border-color: var(--re-green) !important;
} }
@ -252,6 +308,15 @@
--tw-ring-color: var(--re-green) !important; --tw-ring-color: var(--re-green) !important;
} }
.focus-visible\:border-re-light-green:focus-visible {
border-color: var(--re-light-green) !important;
}
.focus-visible\:ring-re-light-green\/30:focus-visible {
--tw-ring-width: 0 !important;
box-shadow: none !important;
}
.focus\:ring-1:focus { .focus\:ring-1:focus {
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);

View File

@ -13,35 +13,130 @@ function urlBase64ToUint8Array(base64String: string) {
} }
export async function registerServiceWorker() { export async function registerServiceWorker() {
if (!('serviceWorker' in navigator)) throw new Error('Service workers not supported'); if (!('serviceWorker' in navigator)) {
const register = await navigator.serviceWorker.register('/service-worker.js'); throw new Error('Service workers are not supported in this browser');
return register; }
let registration: ServiceWorkerRegistration;
try {
// Check if service worker is already registered
const existingRegistration = await navigator.serviceWorker.getRegistration('/service-worker.js');
if (existingRegistration) {
// Service worker already registered, wait for it to be ready
registration = await navigator.serviceWorker.ready;
} else {
// Register new service worker
registration = await navigator.serviceWorker.register('/service-worker.js');
// Wait for the service worker to be ready (may take a moment)
registration = await navigator.serviceWorker.ready;
}
} catch (error: any) {
throw new Error(`Failed to register service worker: ${error?.message || 'Unknown error'}`);
}
return registration;
} }
export async function subscribeUserToPush(register: ServiceWorkerRegistration) { export async function subscribeUserToPush(register: ServiceWorkerRegistration) {
if (!VAPID_PUBLIC_KEY) throw new Error('Missing VAPID public key'); if (!VAPID_PUBLIC_KEY) {
const subscription = await register.pushManager.subscribe({ throw new Error('Missing VAPID public key configuration');
userVisibleOnly: true, }
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
}); // Check if already subscribed
let subscription: PushSubscription;
try {
const existingSubscription = await register.pushManager.getSubscription();
if (existingSubscription) {
// Already subscribed, check if it's still valid
subscription = existingSubscription;
} else {
// Subscribe to push
subscription = await register.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(VAPID_PUBLIC_KEY)
});
}
} catch (error: any) {
throw new Error(`Failed to subscribe to push notifications: ${error?.message || 'Unknown error'}`);
}
// Convert subscription to JSON format for backend
const subscriptionJson = subscription.toJSON();
// Attach auth token if available // Attach auth token if available
const token = (window as any)?.localStorage?.getItem?.('accessToken') || (document?.cookie || '').match(/accessToken=([^;]+)/)?.[1] || ''; const token = (window as any)?.localStorage?.getItem?.('accessToken') ||
await fetch(`${VITE_BASE_URL}/api/v1/workflows/notifications/subscribe`, { (document?.cookie || '').match(/accessToken=([^;]+)/)?.[1] || '';
method: 'POST',
headers: { if (!token) {
'Content-Type': 'application/json', throw new Error('Authentication token not found. Please log in again.');
...(token ? { Authorization: `Bearer ${token}` } : {}), }
},
body: JSON.stringify(subscription) // Send subscription to backend
}); try {
const response = await fetch(`${VITE_BASE_URL}/api/v1/workflows/notifications/subscribe`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
body: JSON.stringify(subscriptionJson)
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(errorData?.error || errorData?.message || `Server error: ${response.status}`);
}
const result = await response.json();
if (!result.success) {
throw new Error(result.error || 'Failed to save subscription');
}
} catch (error: any) {
// If it's already our error, rethrow it
if (error instanceof Error && error.message.includes('Failed')) {
throw error;
}
throw new Error(`Failed to save subscription to server: ${error?.message || 'Network error'}`);
}
return subscription; return subscription;
} }
export async function setupPushNotifications() { export async function setupPushNotifications() {
const permission = await Notification.requestPermission(); // Check if notifications are supported
if (permission !== 'granted') return; if (!('Notification' in window)) {
const reg = await registerServiceWorker(); throw new Error('Notifications are not supported in this browser');
await subscribeUserToPush(reg); }
// Request permission
let permission = Notification.permission;
if (permission === 'default') {
permission = await Notification.requestPermission();
}
if (permission !== 'granted') {
throw new Error('Notification permission was denied. Please enable notifications in your browser settings.');
}
// Register service worker (or get existing)
let reg: ServiceWorkerRegistration;
try {
reg = await registerServiceWorker();
} catch (error: any) {
throw new Error(`Service worker registration failed: ${error?.message || 'Unknown error'}`);
}
// Subscribe to push
try {
await subscribeUserToPush(reg);
} catch (error: any) {
throw error; // Re-throw with detailed message
}
} }